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

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

3m 12s);超过 slowMinutes(默认 10 分钟)变成黄色,卡住的步骤一眼就能看出来。showAgents 可以关掉。TodoWrite、TaskCreate、TaskUpdate 执行完之后读取结果,不注册工具,不往系统提示词里加内容,也不拦截任何调用。被拒绝或失败的调用不计入,子代理自己的清单也不显示。/ 或 @ 选择器时,进度条会暂时让开。
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、ru | auto |
language 为 auto 时,依次跟随 Claude Code 的 language 设置和系统语言环境,都没有时用英语。
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。hooks/register.tsx 318 lines1// 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}
318hooks/board.ts 127 lines1// 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}
127hooks/config.ts 26 lines1// 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}
26hooks/i18n.ts 143 lines1// 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)
143hooks/kit/band.tsx 31 lines1// 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}
31hooks/kit/lang.ts 68 lines1// 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}
68hooks/kit/prefs.ts 60 lines1// 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}
60hooks/kit/options.ts 31 lines1// 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}
31types/index.d.ts 31 lines1export 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