SLOPSHOPPER

todo-bar

The task list's progress in a band above the prompt, read from the todo and task tools' own calls: no tool, no prompt, no tokens

newbandguardcommandprompttimer
★ 5v0.3.1MITupdated 2026-10-09hoobnn/hoobnn-agent-mods/claude-code/todo-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · todo-bar
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ 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 › /todos ⎿ todo-bar: No tasks yet: the band appears when Claude writes a task list ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

todo-bar:Claude Code 任务进度条

简体中文 · English

Claude 列出任务清单后,输入框上方会出现一条进度条:现在做到哪一项、这一项做了多久、总共完成了多少,一眼就能看到。它只读 Claude 本来就会发出的工具调用,不额外消耗 token。

todo-bar:正在进行的任务、已用时间、进度条和接下来的任务

功能

  • 进度一目了然:第一行是正在做的任务、进度条、完成数和百分比,第二行列出接下来的一两项。
  • 任务计时:一项任务做满 1 分钟后,后面会显示已用时间(如 3m 12s);超过 slowMinutes(默认 10 分钟)变成黄色,卡住的步骤一眼就能看出来。
  • 子代理在做什么:Claude 派出子代理时,正在进行的任务下面每个子代理占一行:类型、任务描述、正在用的工具,跑满 1 分钟后显示已用时间。子代理结束就收起,最多列 3 行,再多的合成一行「还有 N 个子代理」。showAgents 可以关掉。
  • 完成提示:全部完成后进度条变绿,显示整份清单的总用时,8 秒后自动收起;Claude 再列新清单时会重新出现。
  • 零 token、零干扰:只在 TodoWrite、TaskCreate、TaskUpdate 执行完之后读取结果,不注册工具,不往系统提示词里加内容,也不拦截任何调用。被拒绝或失败的调用不计入,子代理自己的清单也不显示。
  • 恢复会话不丢进度:每个会话的清单都会保存,恢复会话后进度条还在。
  • 输入框弹出 / 或 @ 选择器时,进度条会暂时让开。

todo-bar:全部任务完成

安装

claude plugin marketplace add hoobnn/hoobnn-agent-mods
claude plugin install todo-bar@hoobnn-agent-mods

命令

  • /todos:列出全部任务和状态(✓ 已完成、● 进行中、○ 等待中),以及每项的用时、期间的工具调用次数和派出的子代理数,例如 ✓ 编写测试 5m 20s · 工具调用 14 次 · 子代理 2 个。
  • /todos off、/todos on:隐藏或显示进度条。这个设置会写回 /config,以后的会话也会沿用。

选项

在 /config 里修改,或写在 ~/.claude/settings.json 的 pluginConfigs 里:

选项作用默认
visible显示进度条(/todos off / on 改的就是它)开
showNext第二行显示接下来的一两项任务开
showAgents正在进行的任务下面列出还在运行的子代理开
slowMinutes当前任务做了多少分钟后时间变黄;0 表示不变色10
language界面语言:auto、en、zh-Hans、zh-Hant、ja、ko、es、fr、de、pt-BR、ruauto

language 为 auto 时,依次跟随 Claude Code 的 language 设置和系统语言环境,都没有时用英语。

同系列 mod

hoobnn-agent-mods 里还有状态栏 HUD(hud)、回合回执(receipt)、运行动画和宠物(spinner)、Tailscale 节点状态(ts-band)和一言(hitokoto),可以搭配使用。

开发

  • hooks/register.tsx:钩子(会话开始时的语言、/todos 和已保存的清单,读取工具调用,命令和横条)。
  • hooks/board.ts:根据每次调用的内容生成进度条要画的清单。
  • hooks/config.ts:选项,一次性读成类型化的 Config。
  • hooks/i18n.ts:各语言文案。
  • hooks/kit/:claude-code/kit 的副本;改源文件后运行 scripts/sync-kit.sh。
Source 9 files
hooks/register.tsx 318 lines
1// The task list's progress above the prompt, read from the calls the model
2// already makes (TodoWrite, TaskCreate, TaskUpdate) once they have run: no tool
3// of its own, nothing in the prompt, no call refused, no tokens.
4import { atom, derive, read, update } from 'claude-code'
5import type { Elements, EngineInterface, Register, RenderElement } from 'claude-code'
6
7import type { Agent, Board } from '../types'
8import { addTask, bar, countAgent, countCall, elapsed, formatDuration, fromTodos, progress, updateTask } from './board'
9import { readConfig } from './config'
10import type { Config } from './config'
11import { m, setLang } from './i18n'
12import { isPickerOpen, stackAbove } from './kit/band'
13import { resolveLanguage } from './kit/lang'
14import { persist, switchArg } from './kit/prefs'
15import type { Prefs } from './kit/prefs'
16
17const board = atom({ plugin: 'todo-bar', key: 'board' } as const, null as Board | null)
18// Set this session by `/todos` or session.start; null in a session resumed or
19// cleared, which gets no session.start: `isHidden` is the `visible` row's then.
20const hiddenSet = atom({ plugin: 'todo-bar', key: 'isHidden' } as const, null as boolean | null)
21// The `visible` row's, set in register.
22let isRowHidden = false
23const isHidden = derive([hiddenSet], set => set ?? isRowHidden)
24// True while a picker is open above the band (see kit/band).
25const isPicking = atom({ plugin: 'todo-bar', key: 'isPicking' } as const, false)
26// The main thread's subagents still at work, oldest first.
27const agents = atom({ plugin: 'todo-bar', key: 'agents' } as const, [] as Agent[])
28// The clock as the band last read it: moved on while a task runs, so its time keeps up.
29const tick = atom({ plugin: 'todo-bar', key: 'tick' } as const, 0)
30
31/** How long a finished list shows its time before the band folds away. */
32export const FINISH_MS = 8000
33// Each session's board is kept in the store, so a resumed session finds it; the newest few sessions.
34const SAVED = 'board:'
35const KEEP_SESSIONS = 20
36const TOOLS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate'])
37const MARK = { pending: '○', in_progress: '●', completed: '✓' } as const
38/** How often the running task's time is redrawn. */
39const TICK_MS = 30_000
40/** Where an agent's loop no longer runs. */
41const DONE = new Set(['completed', 'failed', 'killed'])
42/** Subagent rows under the running task; the rest are counted on one more. */
43const AGENT_ROWS = 3
44
45/** The kit's hold on this mod's store and `/config` rows. */
46function prefsOf($: EngineInterface): Prefs {
47  return {
48    kept: key => $.store.get(key),
49    forget: key => $.store.delete(key),
50    write: (field, value) => $.config.set({ key: `todo-bar.${field}`, value }),
51  }
52}
53
54/** Sets the board, keeps it for a resume, and folds a finished one after FINISH_MS. */
55async function setBoard($: EngineInterface, next: Board | null): Promise<void> {
56  const was = await read($, board)
57  await update($, board, () => next)
58  const key = SAVED + (await $.session.id())
59  if (next === null) await $.store.delete(key)
60  else {
61    await $.store.set(key, next)
62    const keys = (await $.store.keys()).filter(k => k.startsWith(SAVED))
63    for (const old of keys.slice(0, Math.max(0, keys.length - KEEP_SESSIONS))) await $.store.delete(old)
64  }
65  const doneAt = next?.doneAt ?? null
66  if (doneAt !== null && was?.doneAt !== doneAt) {
67    $.clock.after(FINISH_MS, async () => {
68      const now = await read($, board)
69      if (now?.doneAt === doneAt && !now.isFolded) await update($, board, () => ({ ...now, isFolded: true }))
70    })
71  }
72}
73
74/** `/todos` alone: every task with its mark, how long it ran and the tool calls it took. */
75async function listing($: EngineInterface): Promise<string> {
76  const now = await read($, board)
77  if (!now) return m('cmd.none')
78  const { done, total } = progress(now)
79  const at = await $.clock.now()
80  const line = (i: Board['items'][number]) => {
81    const ran = elapsed(i, at)
82    const facts = [
83      ran === null ? '' : formatDuration(ran),
84      i.calls ? m('cmd.calls', { n: i.calls }) : '',
85      i.agents ? m('cmd.agents', { n: i.agents }) : '',
86    ].filter(Boolean)
87    return `${MARK[i.status]} ${i.title}${facts.length > 0 ? `  ${facts.join(' · ')}` : ''}`
88  }
89  return [m('cmd.header', { done, total }), ...now.items.map(line)].join('\n')
90}
91
92export const register: Register = (on, options) => {
93  const config = readConfig(options)
94  isRowHidden = !config.isVisible
95
96  on('session.start', async ($, e, next) => {
97    const settings = (await $.settings.read().catch(() => ({}))) as { language?: unknown }
98    const locale = await Promise.all([
99      $.env.get('LC_ALL').catch(() => undefined),
100      $.env.get('LC_MESSAGES').catch(() => undefined),
101      $.env.get('LANG').catch(() => undefined),
102    ])
103    setLang(resolveLanguage(config.language, settings.language, locale))
104    await $.command.register({ name: 'todos', description: m('cmd.description'), argumentHint: '[off|on]' })
105    await update($, hiddenSet, () => !config.isVisible)
106    // A reload keeps the session's state; a resumed session finds its board in the store.
107    if ((await read($, board)) === null) {
108      const kept = (await $.store.get(SAVED + (await $.session.id()))) as Board | undefined
109      if (kept && Array.isArray(kept.items)) await update($, board, () => (kept.doneAt !== null ? { ...kept, isFolded: true } : kept))
110    }
111    await update($, tick, () => 0)
112    $.clock.every(TICK_MS, async () => {
113      const now = await read($, board)
114      const isBusy = now && now.doneAt === null && now.items.some(i => i.status === 'in_progress')
115      if (isBusy || (await read($, agents)).length > 0) {
116        const at = await $.clock.now()
117        await update($, tick, () => at)
118      }
119    })
120    return next(e)
121  })
122
123  // A subagent the main thread starts: a row under the running task, which counts it.
124  on('agent.spawn', async ($, e, next) => {
125    const started = await next(e)
126    if (!started.agentId || e.parentAgentId !== undefined || (e as { workflow?: unknown }).workflow !== undefined) return started
127    const id = started.agentId
128    const now = await $.clock.now()
129    const agent = { id, title: oneLine(e.description) || e.subagentType, type: e.subagentType, startedAt: now, tool: null, calls: 0 }
130    await update($, agents, list => [...list.filter(a => a.id !== id), agent])
131    const was = await read($, board)
132    if (was && was.doneAt === null) {
133      const counted = countAgent(was)
134      if (counted !== was) await setBoard($, counted)
135    }
136    return started
137  })
138
139  // A subagent's row ends with its turn. At the main turn's end, a row whose agent
140  // the engine no longer runs (killed, interrupted, ended across a reload) goes too.
141  on('turn.complete', async ($, e, next) => {
142    if (e.agentId !== undefined) {
143      const id = e.agentId
144      if ((await read($, agents)).some(a => a.id === id)) await update($, agents, list => list.filter(a => a.id !== id))
145    } else if ((await read($, agents)).length > 0) {
146      const listed = await $.agent.list().catch(() => null)
147      if (listed) {
148        const live = new Set(listed.filter(a => !DONE.has(a.status)).map(a => a.id))
149        await update($, agents, list => list.filter(a => live.has(a.id)))
150      }
151    }
152    return next(e)
153  })
154
155  // The main thread's list, once the call has run; a refused or failed call changes nothing.
156  on('tool.call', async ($, e, next) => {
157    // A subagent's call names the tool on its row, as it starts.
158    if (e.agentId !== undefined) {
159      const id = e.agentId
160      if ((await read($, agents)).some(a => a.id === id)) {
161        await update($, agents, list => list.map(a => (a.id === id ? { ...a, tool: e.tool, calls: a.calls + 1 } : a)))
162      }
163      return next(e)
164    }
165    const ran = await next(e)
166    if (ran.deny !== undefined) return ran
167    // Another tool's call counts toward the running task; the count is saved with the board's next change.
168    if (!TOOLS.has(e.tool)) {
169      const was = await read($, board)
170      if (was && was.doneAt === null) {
171        const counted = countCall(was)
172        if (counted !== was) await update($, board, () => counted)
173      }
174      return ran
175    }
176    if (ran.isError) return ran
177    const input = e as unknown as Record<string, unknown>
178    const result = (ran.result ?? {}) as Record<string, unknown>
179    const was = await read($, board)
180    const now = await $.clock.now()
181    if (e.tool === 'TodoWrite') {
182      await setBoard($, fromTodos(was, Array.isArray(result.newTodos) ? result.newTodos : input.todos, now))
183    } else if (e.tool === 'TaskCreate') {
184      const task = result.task as { id?: unknown } | undefined
185      if (typeof task?.id !== 'string') return ran
186      const made = { id: task.id, subject: String(input.subject ?? ''), activeForm: typeof input.activeForm === 'string' ? input.activeForm : undefined }
187      await setBoard($, addTask(was, made, now))
188    } else if (result.success !== false && typeof input.taskId === 'string') {
189      const change = {
190        taskId: input.taskId,
191        status: typeof input.status === 'string' ? input.status : undefined,
192        subject: typeof input.subject === 'string' ? input.subject : undefined,
193        activeForm: typeof input.activeForm === 'string' ? input.activeForm : undefined,
194      }
195      const changed = updateTask(was, change, now)
196      if (changed !== was) await setBoard($, changed)
197    }
198    return ran
199  })
200
201  on('command.run', { command: 'todos' }, async ($, e) => {
202    const arg = e.args.trim().toLowerCase()
203    if (arg !== 'off' && arg !== 'on') return { text: await listing($) }
204    const was = await read($, isHidden)
205    const hidden = await update($, hiddenSet, () => switchArg(arg, was))
206    if (hidden !== was) await persist(prefsOf($), 'visible', !hidden)
207    return { text: m(hidden ? 'cmd.hidden' : 'cmd.shown') }
208  })
209
210  // A picker (`/` commands, `@` files) opens above the band: the band steps aside meanwhile.
211  on('prompt.edit', async ($, e, next) => {
212    const box = await next(e)
213    const isOpen = isPickerOpen(box.text, box.cursor)
214    if ((await read($, isPicking)) !== isOpen) await update($, isPicking, () => isOpen)
215    return box
216  })
217  on('prompt.submit', async ($, e, next) => {
218    if (await read($, isPicking)) await update($, isPicking, () => false)
219    return next(e)
220  })
221
222  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
223    const now = await read($, board)
224    if (e.props.hasSurvey || now === null || now.isFolded || (await read($, isHidden)) || (await read($, isPicking))) return next(e)
225    const ui = $.ui.resolve(e)
226    // Two cells in, as kit/band indents the band.
227    await read($, tick)
228    const running = config.hasAgents ? await read($, agents) : []
229    return stackAbove(ui, drawBoard(ui, now, running, e.props.bodyColumns - 2, config, await $.clock.now()), await next(e))
230  })
231}
232
233const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim()
234
235/** The band: the running task, a bar, the count; its subagents at work; then what comes next. Finished: a check and the time it took. */
236function drawBoard(ui: Pick<Elements['terminal'], 'Box' | 'Text'>, now: Board, running: Agent[], columns: number, config: Config, at: number): RenderElement {
237  const { Box, Text } = ui
238  const { done, total, current, isRunning, next } = progress(now)
239  const pct = total === 0 ? 0 : Math.round((done / total) * 100)
240  const count = `${done}/${total} ${String(pct).padStart(3)}%`
241  const width = Math.max(8, Math.min(30, Math.floor(columns * 0.3)))
242  const { filled, empty } = bar(done, total, width)
243
244  if (now.doneAt !== null) {
245    return (
246      <Box flexDirection="row" columnGap={1}>
247        <Text color="green" bold>✓</Text>
248        <Text color="green">{m('band.done')}</Text>
249        <Text color="green">{filled}</Text>
250        <Text>{`${done}/${total}`}</Text>
251        <Text dimColor>{m('band.took', { d: formatDuration(now.doneAt - now.startedAt) })}</Text>
252      </Box>
253    )
254  }
255  const label = current ? (isRunning ? current.active : current.title) : ''
256  // The running task's time from its first minute; yellow once it passes slowMinutes.
257  const ran = isRunning && current ? elapsed(current, at) : null
258  const isSlow = ran !== null && config.slowMinutes > 0 && ran >= config.slowMinutes * 60_000
259  return (
260    <Box flexDirection="column">
261      <Box flexDirection="row" columnGap={1}>
262        <Text color={isRunning ? 'cyan' : undefined} dimColor={!isRunning}>{isRunning ? MARK.in_progress : MARK.pending}</Text>
263        <Box flexGrow={1} flexShrink={1}>
264          <Text wrap="truncate-end" dimColor={!isRunning}>{label}</Text>
265        </Box>
266        {ran !== null && ran >= 60_000 ? (
267          <Box flexShrink={0}>
268            <Text color={isSlow ? 'yellow' : undefined} dimColor={!isSlow}>{formatDuration(ran)}</Text>
269          </Box>
270        ) : null}
271        <Box flexShrink={0}>
272          <Text>
273            <Text color="cyan">{filled}</Text>
274            <Text dimColor>{empty}</Text>
275          </Text>
276        </Box>
277        <Box flexShrink={0}>
278          <Text>{count}</Text>
279        </Box>
280      </Box>
281      {running.slice(0, running.length > AGENT_ROWS ? AGENT_ROWS - 1 : AGENT_ROWS).map(a => {
282        const ran = at - a.startedAt
283        return (
284          <Box key={a.id} flexDirection="row" columnGap={1} paddingLeft={2}>
285            <Text color="magenta">↳</Text>
286            <Box flexShrink={0}>
287              <Text color="magenta">{a.type}</Text>
288            </Box>
289            <Box flexGrow={1} flexShrink={1}>
290              <Text wrap="truncate-end">{a.title}</Text>
291            </Box>
292            {a.tool ? (
293              <Box flexShrink={0}>
294                <Text dimColor>{a.tool}</Text>
295              </Box>
296            ) : null}
297            {ran >= 60_000 ? (
298              <Box flexShrink={0}>
299                <Text dimColor>{formatDuration(ran)}</Text>
300              </Box>
301            ) : null}
302          </Box>
303        )
304      })}
305      {running.length > AGENT_ROWS ? (
306        <Box paddingLeft={2}>
307          <Text dimColor>{`↳ ${m('band.moreAgents', { n: running.length - (AGENT_ROWS - 1) })}`}</Text>
308        </Box>
309      ) : null}
310      {config.hasNext && next.length > 0 ? (
311        <Box paddingLeft={2}>
312          <Text dimColor wrap="truncate-end">{m('band.next') + next.slice(0, 2).map(i => i.title).join(' · ')}</Text>
313        </Box>
314      ) : null}
315    </Box>
316  )
317}
318
hooks/board.ts 127 lines
1// The task list as the band draws it, built from the calls the model already
2// makes: TodoWrite sends the whole list each time, TaskCreate and TaskUpdate
3// one task a call. Pure: register.tsx hands in what the call carried and when.
4import type { Board, Item, ItemStatus } from '../types'
5
6type Todo = { content?: unknown; status?: unknown; activeForm?: unknown }
7
8const STATUSES: readonly ItemStatus[] = ['pending', 'in_progress', 'completed']
9
10const str = (v: unknown) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim() : '')
11const statusOf = (v: unknown): ItemStatus => STATUSES.find(s => s === v) ?? 'pending'
12
13/**
14 * Each item's times carried over from the board before (a task by its id, a
15 * todo by its title, as TodoWrite sends the whole list again): it started when
16 * first seen running, was done when first seen completed.
17 */
18function stamp(prev: Board | null, source: Board['source'], items: Item[], now: number): Item[] {
19  const before = prev && prev.source === source ? prev.items : []
20  return items.map(item => {
21    const was = before.find(b => (source === 'task' ? b.id === item.id : b.title === item.title))
22    const startedAt = was?.startedAt ?? (item.status === 'in_progress' ? now : undefined)
23    const doneAt = item.status !== 'completed' ? undefined : (was?.status === 'completed' ? was.doneAt : undefined) ?? now
24    const stamped: Item = { ...item }
25    if (startedAt !== undefined) stamped.startedAt = startedAt
26    if (doneAt !== undefined) stamped.doneAt = doneAt
27    if (was?.calls) stamped.calls = was.calls
28    if (was?.agents) stamped.agents = was.agents
29    return stamped
30  })
31}
32
33/** A board whose list just changed: finished when every item is, started over when it comes back from finished. */
34function settle(prev: Board | null, source: Board['source'], listed: Item[], now: number): Board | null {
35  if (listed.length === 0) return null
36  const isDone = listed.every(i => i.status === 'completed')
37  // A finished list sent again (the model closing it twice) stays as it finished.
38  if (isDone && prev !== null && prev.source === source && prev.doneAt !== null) return { ...prev, items: stamp(prev, source, listed, now) }
39  const isFresh = prev === null || prev.source !== source || prev.doneAt !== null
40  // A fresh list owes the finished one nothing, not even a task of the same name.
41  const items = stamp(isFresh ? null : prev, source, listed, now)
42  return { source, items, startedAt: isFresh ? now : prev.startedAt, doneAt: isDone ? now : null, isFolded: false }
43}
44
45/** TodoWrite's list (its `newTodos`, else the input's `todos`) as the board. */
46export function fromTodos(prev: Board | null, todos: unknown, now: number): Board | null {
47  const list = (Array.isArray(todos) ? todos : []) as Todo[]
48  const items = list
49    .map((t, i): Item => {
50      const title = str(t?.content)
51      return { id: String(i), title, active: str(t?.activeForm) || title, status: statusOf(t?.status) }
52    })
53    .filter(i => i.title)
54  return settle(prev, 'todo', items, now)
55}
56
57/** A task TaskCreate made, added to the task board (a finished or todo board makes way). */
58export function addTask(prev: Board | null, task: { id: string; subject: string; activeForm?: string }, now: number): Board | null {
59  const title = str(task.subject)
60  if (!title) return prev
61  const kept = prev && prev.source === 'task' && prev.doneAt === null ? prev.items : []
62  const items = [...kept.filter(i => i.id !== task.id), { id: task.id, title, active: str(task.activeForm) || title, status: 'pending' as const }]
63  return settle(prev && prev.source === 'task' && prev.doneAt === null ? prev : null, 'task', items, now)
64}
65
66/** What TaskUpdate changed on a task the board holds; `deleted` drops it. */
67export function updateTask(
68  prev: Board | null,
69  change: { taskId: string; status?: string; subject?: string; activeForm?: string },
70  now: number,
71): Board | null {
72  if (!prev || prev.source !== 'task' || !prev.items.some(i => i.id === change.taskId)) return prev
73  const items =
74    change.status === 'deleted'
75      ? prev.items.filter(i => i.id !== change.taskId)
76      : prev.items.map(i => {
77          if (i.id !== change.taskId) return i
78          const title = str(change.subject) || i.title
79          const status = change.status === undefined ? i.status : statusOf(change.status)
80          return { ...i, title, active: str(change.activeForm) || (str(change.subject) ? title : i.active), status }
81        })
82  return settle(prev, 'task', items, now)
83}
84
85/** The board with one more tool call counted on the running item; the same board when none runs. */
86export function countCall(board: Board): Board {
87  const running = board.items.find(i => i.status === 'in_progress')
88  if (!running) return board
89  return { ...board, items: board.items.map(i => (i === running ? { ...i, calls: (i.calls ?? 0) + 1 } : i)) }
90}
91
92/** The board with one more subagent counted on the running item; the same board when none runs. */
93export function countAgent(board: Board): Board {
94  const running = board.items.find(i => i.status === 'in_progress')
95  if (!running) return board
96  return { ...board, items: board.items.map(i => (i === running ? { ...i, agents: (i.agents ?? 0) + 1 } : i)) }
97}
98
99/** How long an item ran: until it was done, else until now; null when it was never seen running. */
100export function elapsed(item: Item, now: number): number | null {
101  if (item.startedAt === undefined) return null
102  return Math.max(0, (item.doneAt ?? now) - item.startedAt)
103}
104
105/** Where the board stands: done and total, the running item (else the first open one), and the next open ones. */
106export function progress(board: Board): { done: number; total: number; current: Item | null; isRunning: boolean; next: Item[] } {
107  const done = board.items.filter(i => i.status === 'completed').length
108  const running = board.items.find(i => i.status === 'in_progress') ?? null
109  const open = board.items.filter(i => i.status === 'pending')
110  const current = running ?? open[0] ?? null
111  return { done, total: board.items.length, current, isRunning: running !== null, next: open.filter(i => i !== current) }
112}
113
114/** A bar of `width` cells, `━` for the done share and `─` for the rest. */
115export function bar(done: number, total: number, width: number): { filled: string; empty: string } {
116  const n = total === 0 ? 0 : Math.round((done / total) * width)
117  return { filled: '━'.repeat(n), empty: '─'.repeat(Math.max(0, width - n)) }
118}
119
120/** 42s, 3m 12s, 1h 05m. */
121export function formatDuration(ms: number): string {
122  const sec = Math.max(0, Math.round(ms / 1000))
123  if (sec < 60) return `${sec}s`
124  if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`
125  return `${Math.floor(sec / 3600)}h ${String(Math.floor((sec % 3600) / 60)).padStart(2, '0')}m`
126}
127
hooks/config.ts 26 lines
1// todo-bar's options (plugin.json `userConfig`), read once into a typed config.
2import type { PluginOptions } from 'claude-code'
3
4import { count, flag, text } from './kit/options'
5
6export type Config = {
7  isVisible: boolean
8  hasNext: boolean
9  /** Rows under the running task for the subagents still at work. */
10  hasAgents: boolean
11  /** Minutes after which the running task's time turns yellow; 0 never. */
12  slowMinutes: number
13  /** `auto` or a language; the kit resolves it (kit/lang.ts). */
14  language: string
15}
16
17export function readConfig(options: PluginOptions): Config {
18  return {
19    isVisible: flag(options.visible, true),
20    hasNext: flag(options.showNext, true),
21    hasAgents: flag(options.showAgents, true),
22    slowMinutes: count(options.slowMinutes, 10, { min: 0 }),
23    language: text(options.language, 'auto'),
24  }
25}
26
hooks/i18n.ts 143 lines
1// todo-bar's messages; the language is resolved by the kit (kit/lang.ts).
2import { createMessages } from './kit/lang'
3import type { Lang } from './kit/lang'
4
5export { parseLanguage, resolveLanguage } from './kit/lang'
6
7export type Key = 'cmd.description' | 'cmd.hidden' | 'cmd.shown' | 'cmd.none' | 'cmd.header' | 'band.next' | 'band.done' | 'band.took' | 'cmd.calls' | 'cmd.agents' | 'band.moreAgents'
8
9export const MESSAGES: Record<Lang, Record<Key, string>> = {
10  en: {
11    'cmd.description': 'List the tasks and their progress; off / on hides or shows the band (kept across sessions)',
12    'cmd.hidden': 'Task band hidden',
13    'cmd.shown': 'Task band shown',
14    'cmd.none': 'No tasks yet: the band appears when Claude writes a task list',
15    'cmd.header': 'Tasks {done}/{total}',
16    'band.next': 'Next: ',
17    'band.done': 'All done',
18    'band.took': 'took {d}',
19    'cmd.calls': 'tool calls: {n}',
20    'cmd.agents': 'subagents: {n}',
21    'band.moreAgents': '+{n} more subagents',
22  },
23  'zh-Hans': {
24    'cmd.description': '列出任务与进度;off / on 隐藏或显示进度条(跨会话保持)',
25    'cmd.hidden': '任务进度条已隐藏',
26    'cmd.shown': '任务进度条已显示',
27    'cmd.none': '还没有任务:Claude 写下任务清单时进度条会出现',
28    'cmd.header': '任务 {done}/{total}',
29    'band.next': '接下来:',
30    'band.done': '全部完成',
31    'band.took': '用时 {d}',
32    'cmd.calls': '工具调用 {n} 次',
33    'cmd.agents': '子代理 {n} 个',
34    'band.moreAgents': '还有 {n} 个子代理',
35  },
36  'zh-Hant': {
37    'cmd.description': '列出任務與進度;off / on 隱藏或顯示進度條(跨工作階段保留)',
38    'cmd.hidden': '任務進度條已隱藏',
39    'cmd.shown': '任務進度條已顯示',
40    'cmd.none': '還沒有任務:Claude 寫下任務清單時進度條會出現',
41    'cmd.header': '任務 {done}/{total}',
42    'band.next': '接下來:',
43    'band.done': '全部完成',
44    'band.took': '用時 {d}',
45    'cmd.calls': '工具呼叫 {n} 次',
46    'cmd.agents': '子代理 {n} 個',
47    'band.moreAgents': '還有 {n} 個子代理',
48  },
49  ja: {
50    'cmd.description': 'タスクと進捗を一覧表示。off / on でバーを非表示・表示(セッションをまたいで保持)',
51    'cmd.hidden': 'タスクバーを非表示にしました',
52    'cmd.shown': 'タスクバーを表示しました',
53    'cmd.none': 'タスクはまだありません。Claude がタスクリストを書くとバーが表示されます',
54    'cmd.header': 'タスク {done}/{total}',
55    'band.next': '次: ',
56    'band.done': 'すべて完了',
57    'band.took': '所要 {d}',
58    'cmd.calls': 'ツール呼び出し {n} 回',
59    'cmd.agents': 'サブエージェント {n} 個',
60    'band.moreAgents': 'ほかサブエージェント {n} 個',
61  },
62  ko: {
63    'cmd.description': '작업과 진행 상황 보기. off / on으로 막대 숨기기·표시(세션 간 유지)',
64    'cmd.hidden': '작업 막대를 숨겼습니다',
65    'cmd.shown': '작업 막대를 표시했습니다',
66    'cmd.none': '아직 작업이 없습니다. Claude가 작업 목록을 쓰면 막대가 나타납니다',
67    'cmd.header': '작업 {done}/{total}',
68    'band.next': '다음: ',
69    'band.done': '모두 완료',
70    'band.took': '소요 {d}',
71    'cmd.calls': '도구 호출 {n}회',
72    'cmd.agents': '하위 에이전트 {n}개',
73    'band.moreAgents': '하위 에이전트 {n}개 더',
74  },
75  es: {
76    'cmd.description': 'Lista las tareas y su avance; off / on oculta o muestra la barra (se mantiene entre sesiones)',
77    'cmd.hidden': 'Barra de tareas oculta',
78    'cmd.shown': 'Barra de tareas visible',
79    'cmd.none': 'Aún no hay tareas: la barra aparece cuando Claude escribe una lista de tareas',
80    'cmd.header': 'Tareas {done}/{total}',
81    'band.next': 'Después: ',
82    'band.done': 'Todo listo',
83    'band.took': 'en {d}',
84    'cmd.calls': 'llamadas a herramientas: {n}',
85    'cmd.agents': 'subagentes: {n}',
86    'band.moreAgents': '+{n} subagentes más',
87  },
88  fr: {
89    'cmd.description': 'Liste les tâches et leur avancement ; off / on masque ou affiche la barre (conservé entre les sessions)',
90    'cmd.hidden': 'Barre des tâches masquée',
91    'cmd.shown': 'Barre des tâches affichée',
92    'cmd.none': 'Pas encore de tâches : la barre apparaît quand Claude écrit une liste de tâches',
93    'cmd.header': 'Tâches {done}/{total}',
94    'band.next': 'Ensuite : ',
95    'band.done': 'Tout est fait',
96    'band.took': 'en {d}',
97    'cmd.calls': "appels d'outils : {n}",
98    'cmd.agents': 'sous-agents : {n}',
99    'band.moreAgents': '+{n} autres sous-agents',
100  },
101  de: {
102    'cmd.description': 'Aufgaben und Fortschritt auflisten; off / on blendet die Leiste aus oder ein (bleibt über Sitzungen erhalten)',
103    'cmd.hidden': 'Aufgabenleiste ausgeblendet',
104    'cmd.shown': 'Aufgabenleiste eingeblendet',
105    'cmd.none': 'Noch keine Aufgaben: Die Leiste erscheint, sobald Claude eine Aufgabenliste schreibt',
106    'cmd.header': 'Aufgaben {done}/{total}',
107    'band.next': 'Als Nächstes: ',
108    'band.done': 'Alles erledigt',
109    'band.took': 'in {d}',
110    'cmd.calls': 'Werkzeugaufrufe: {n}',
111    'cmd.agents': 'Subagenten: {n}',
112    'band.moreAgents': '+{n} weitere Subagenten',
113  },
114  'pt-BR': {
115    'cmd.description': 'Lista as tarefas e o progresso; off / on oculta ou mostra a barra (mantido entre sessões)',
116    'cmd.hidden': 'Barra de tarefas oculta',
117    'cmd.shown': 'Barra de tarefas visível',
118    'cmd.none': 'Ainda não há tarefas: a barra aparece quando o Claude escreve uma lista de tarefas',
119    'cmd.header': 'Tarefas {done}/{total}',
120    'band.next': 'Depois: ',
121    'band.done': 'Tudo pronto',
122    'band.took': 'em {d}',
123    'cmd.calls': 'chamadas de ferramentas: {n}',
124    'cmd.agents': 'subagentes: {n}',
125    'band.moreAgents': '+{n} subagentes',
126  },
127  ru: {
128    'cmd.description': 'Список задач и прогресс; off / on скрывает или показывает панель (сохраняется между сессиями)',
129    'cmd.hidden': 'Панель задач скрыта',
130    'cmd.shown': 'Панель задач показана',
131    'cmd.none': 'Задач пока нет: панель появится, когда Claude составит список задач',
132    'cmd.header': 'Задачи {done}/{total}',
133    'band.next': 'Далее: ',
134    'band.done': 'Всё готово',
135    'band.took': 'за {d}',
136    'cmd.calls': 'вызовов инструментов: {n}',
137    'cmd.agents': 'субагентов: {n}',
138    'band.moreAgents': 'ещё субагентов: {n}',
139  },
140}
141
142export const { m, setLang } = createMessages(MESSAGES)
143
hooks/kit/band.tsx 31 lines
1// Generated from claude-code/kit/band.tsx by scripts/sync-kit.sh: edit the source, then re-run it.
2// A mod's rows in the band above the prompt. Several mods draw there, so each
3// stacks its rows over what the rest of the chain drew (`await next(e)`)
4// instead of replacing it, two cells in, as the engine indents the lines under
5// the prompt. The hook stays the mod's own (`on('ui.render', ...)` is spelled
6// in the hooks module): it yields to a survey, draws, and hands both here.
7import type { Elements, RenderElement } from 'claude-code'
8
9/**
10 * Whether the prompt's draft has a picker open: a slash command being named
11 * (`/sp`, before any space) or a file being mentioned (`@src/a`, the word at the
12 * cursor). The engine draws the picker above the band, so a band steps aside
13 * while one is open and the picker sits right on the prompt.
14 */
15export function isPickerOpen(text: string, cursor = text.length): boolean {
16  const before = text.slice(0, cursor)
17  return /^\/\S*$/.test(before) || /(^|\s)@\S*$/.test(before)
18}
19
20export function stackAbove(ui: Pick<Elements['terminal'], 'Box'>, mine: RenderElement, below: RenderElement): RenderElement {
21  const { Box } = ui
22  return (
23    <Box flexDirection="column">
24      <Box flexDirection="column" paddingLeft={2}>
25        {mine}
26      </Box>
27      {below}
28    </Box>
29  )
30}
31
hooks/kit/lang.ts 68 lines
1// Generated from claude-code/kit/lang.ts by scripts/sync-kit.sh: edit the source, then re-run it.
2// A mod's language: the `language` option, or with `auto` Claude Code's own
3// `language` setting (free text: "简体中文", "Japanese", "pt-BR"), then the locale
4// (LC_ALL, LC_MESSAGES, LANG), then English. The messages stay the mod's own:
5// `createMessages(MESSAGES)` gives it `m` over them.
6
7export type Lang = 'en' | 'zh-Hans' | 'zh-Hant' | 'ja' | 'ko' | 'es' | 'fr' | 'de' | 'pt-BR' | 'ru'
8
9export const LANGS: readonly Lang[] = ['en', 'zh-Hans', 'zh-Hant', 'ja', 'ko', 'es', 'fr', 'de', 'pt-BR', 'ru']
10
11/** The `language` option's values, as a manifest's `options` lists them. */
12export const LANGUAGE_OPTIONS = ['auto', ...LANGS] as const
13
14// Names a person may write, folded (lowercase, no spaces, `-`, `_` or `.`).
15const NAMES: Record<string, Lang> = {
16  en: 'en', english: 'en', 英语: 'en', 英文: 'en', 英語: 'en',
17  zh: 'zh-Hans', zhcn: 'zh-Hans', zhsg: 'zh-Hans', zhhans: 'zh-Hans', chinese: 'zh-Hans',
18  simplifiedchinese: 'zh-Hans', 中文: 'zh-Hans', 简体中文: 'zh-Hans', 简体: 'zh-Hans', 汉语: 'zh-Hans', 普通话: 'zh-Hans',
19  zhtw: 'zh-Hant', zhhk: 'zh-Hant', zhmo: 'zh-Hant', zhhant: 'zh-Hant', traditionalchinese: 'zh-Hant',
20  繁體中文: 'zh-Hant', 繁体中文: 'zh-Hant', 正體中文: 'zh-Hant', 繁體: 'zh-Hant', 繁体: 'zh-Hant',
21  ja: 'ja', japanese: 'ja', 日本語: 'ja', 日语: 'ja',
22  ko: 'ko', korean: 'ko', 한국어: 'ko', 韩语: 'ko', 韓語: 'ko',
23  es: 'es', spanish: 'es', español: 'es', espanol: 'es', castellano: 'es', 西班牙语: 'es',
24  fr: 'fr', french: 'fr', français: 'fr', francais: 'fr', 法语: 'fr',
25  de: 'de', german: 'de', deutsch: 'de', 德语: 'de',
26  pt: 'pt-BR', ptbr: 'pt-BR', portuguese: 'pt-BR', brazilianportuguese: 'pt-BR', português: 'pt-BR', portugues: 'pt-BR', 葡萄牙语: 'pt-BR',
27  ru: 'ru', russian: 'ru', русский: 'ru', 俄语: 'ru',
28}
29
30const fold = (s: string) => s.trim().toLowerCase().replace(/[\s_.-]/g, '')
31
32/** "简体中文", "zh_TW.UTF-8", "es-MX", "Deutsch" → a language the mods have, else null. */
33export function parseLanguage(text: unknown): Lang | null {
34  if (typeof text !== 'string' || !text.trim()) return null
35  const bare = text.split('.')[0]!.split('@')[0]!
36  const named = NAMES[fold(bare)]
37  if (named) return named
38  // A tag with a region or script this table does not list: its first subtag.
39  const [primary = '', ...rest] = bare.trim().toLowerCase().split(/[-_\s]/)
40  if (primary === 'zh') return rest.some(s => ['tw', 'hk', 'mo', 'hant'].includes(s)) ? 'zh-Hant' : 'zh-Hans'
41  return NAMES[primary] ?? null
42}
43
44/** The language to draw in: the option unless `auto`, then the setting, then the locale. */
45export function resolveLanguage(option: unknown, setting: unknown, locale: readonly (string | undefined)[]): Lang {
46  if (typeof option === 'string' && option !== 'auto' && (LANGS as readonly string[]).includes(option)) return option as Lang
47  return parseLanguage(setting) ?? locale.map(parseLanguage).find(Boolean) ?? 'en'
48}
49
50
51export type Params = Record<string, string | number>
52
53/**
54 * `m(key, params?, lang?)` over a mod's table: the message in the current
55 * language (English when missing), its `{placeholders}` filled.
56 */
57export function createMessages<K extends string>(table: Record<Lang, Record<K, string>>) {
58  let current: Lang = 'en'
59  return {
60    m: (key: K, params: Params = {}, lang: Lang = current): string =>
61      (table[lang][key] ?? table.en[key]).replace(/\{(\w+)\}/g, (_, k: string) => String(params[k] ?? '')),
62    setLang: (lang: Lang): void => {
63      current = lang
64    },
65    lang: (): Lang => current,
66  }
67}
68
hooks/kit/prefs.ts 60 lines
1// Generated from claude-code/kit/prefs.ts by scripts/sync-kit.sh: edit the source, then re-run it.
2// `/config` is where a mod's settings live: a slash command that changes one
3// (`/ts off`, `/spinner neon`) writes that row as the person would in the menu,
4// so the menu shows it, settings.json keeps it, and the engine reloads the
5// module with it. The command also sets the mod's session state, so the change
6// shows at once, and stays for the session where no row can be written
7// (`claude -p` has no plugin rows).
8//
9// The engine follows `$` only within the hooks module's own file, so the mod
10// hands the kit closures over `$` (a `Prefs`) rather than `$` itself.
11import type { ConfigValue } from 'claude-code'
12
13/** The mod's store and its own `/config` rows, as closures the hooks module builds over `$`. */
14export type Prefs = {
15  kept: (key: string) => Promise<unknown>
16  forget: (key: string) => Promise<void>
17  /** `$.config.set` on `<plugin>.<field>`. */
18  write: (field: string, value: ConfigValue) => Promise<{ deny?: string }>
19}
20
21/** Writes one of the mod's rows; false when no row took it. */
22export async function persist(prefs: Prefs, field: string, value: ConfigValue): Promise<boolean> {
23  const result = await prefs.write(field, value).catch(() => null)
24  return result !== null && result.deny === undefined
25}
26
27/** What a value kept in the store under an older version becomes: a row and its value, or nothing. */
28export type Move = (kept: unknown) => readonly [field: string, value: ConfigValue] | null
29
30/** The rows values older versions kept in `$.store` stand for, which the session applies at once. */
31export async function keptRows(prefs: Prefs, moves: Readonly<Record<string, Move>>): Promise<Record<string, ConfigValue>> {
32  const rows: Record<string, ConfigValue> = {}
33  for (const [key, move] of Object.entries(moves)) {
34    const kept = await prefs.kept(key)
35    const target = kept === undefined ? null : move(kept)
36    if (target) rows[target[0]] = target[1]
37  }
38  return rows
39}
40
41/**
42 * Moves those values to their `/config` rows, once: a key is dropped only
43 * after its row took the value (or it has none to give). Run it after
44 * `session.start`'s `next(e)`: the plugin's rows join `/config` as the
45 * session comes up, and the write brings a reload that applies them.
46 */
47export async function migrateStore(prefs: Prefs, moves: Readonly<Record<string, Move>>): Promise<void> {
48  for (const [key, move] of Object.entries(moves)) {
49    const kept = await prefs.kept(key)
50    if (kept === undefined) continue
51    const target = move(kept)
52    if (!target || (await persist(prefs, target[0], target[1]))) await prefs.forget(key)
53  }
54}
55
56/** `off` → true, `on` → false, anything else flips `isOff`. */
57export function switchArg(arg: string, isOff: boolean): boolean {
58  return arg === 'off' ? true : arg === 'on' ? false : !isOff
59}
60
hooks/kit/options.ts 31 lines
1// Generated from claude-code/kit/options.ts by scripts/sync-kit.sh: edit the source, then re-run it.
2// Readers for `register`'s options: each takes the raw value and the field's
3// default, so a mod's `readConfig` is one typed line per `userConfig` field.
4// The engine has validated the type already; these hold the ranges.
5
6export function text(value: unknown, fallback = ''): string {
7  return typeof value === 'string' ? value.trim() : fallback
8}
9
10export function flag(value: unknown, fallback: boolean): boolean {
11  return typeof value === 'boolean' ? value : fallback
12}
13
14/** A finite number, clamped to `min` / `max` and floored with `isInteger`. */
15export function count(
16  value: unknown,
17  fallback: number,
18  range: { min?: number; max?: number; isInteger?: boolean } = {},
19): number {
20  let n = typeof value === 'number' && Number.isFinite(value) ? value : fallback
21  if (range.isInteger) n = Math.floor(n)
22  if (range.min !== undefined) n = Math.max(range.min, n)
23  if (range.max !== undefined) n = Math.min(range.max, n)
24  return n
25}
26
27/** One of `values`, else the fallback. */
28export function oneOf<const T extends string>(value: unknown, values: readonly T[], fallback: T): T {
29  return values.find(v => v === value) ?? fallback
30}
31
types/index.d.ts 31 lines
1export type ItemStatus = 'pending' | 'in_progress' | 'completed'
2
3/**
4 * One task: its title, the words it shows while it runs, and where it stands;
5 * when it started running and was done, the tool calls made and the subagents
6 * started while it ran. The last four are optional so a board kept by an older version resumes.
7 */
8export type Item = { id: string; title: string; active: string; status: ItemStatus; startedAt?: number; doneAt?: number; calls?: number; agents?: number }
9
10/** A subagent the main thread started and that still runs: its task, its type, the tool it is on, its calls so far. */
11export type Agent = { id: string; title: string; type: string; startedAt: number; tool: string | null; calls: number }
12
13/**
14 * The list the band draws: from TodoWrite (the whole list each call) or from
15 * TaskCreate / TaskUpdate (one task a call). `doneAt` is set once every item
16 * is completed; `isFolded` once the finished band has shown its time.
17 */
18export type Board = {
19  source: 'todo' | 'task'
20  items: Item[]
21  startedAt: number
22  doneAt: number | null
23  isFolded: boolean
24}
25
26declare module 'claude-code' {
27  interface PluginState {
28    'todo-bar': { board: Board | null; isHidden: boolean | null; isPicking: boolean; tick: number; agents: Agent[] }
29  }
30}
31