Shows the session's tasks above the prompt as a list with a colored progress bar and a percentage

A Claude Code mod that shows the session's tasks above the prompt as progress bars.
Each task is one bar holding its name, its state and its percentage; the bars sit in two columns where the terminal is wide enough.
| Color | State |
|---|---|
| Green | running (the bar grows) or done (100%) |
| Orange | awaiting |
| Red | blocked by an unfinished task, or stopped by an interrupted turn |
The colors are the theme's success, warning and error.
At the prompt of a Claude Code terminal session:
/plugin install task-bars --marketplace mrtsvr-techloop/claude-taskbars
Answer y to add the marketplace, then pick a scope.
To run it from a clone instead: claude --plugin-dir <path to the clone>.
Each bar starts with a pin: ○ loose, 📌 pinned. Click it to pin or unpin the task. The pin is the one spot of a bar a click reaches.
A pinned task is locked: it stays when the model sends a list without it, when the task list deletes it, and when the finished tasks are cleared. The model still updates its state and progress. Only the person's click unpins it.
The model pins a task too, by declaring it with persistent: true in set_tasks.
Tasks that are not pinned are cleared at the next prompt once all of them are done. Pins last for the session.
/task-bars hides or shows the bars./task-bars-demo adds or removes sample tasks at different percentages.The bars appear by themselves; nobody asks for them. Four sources feed them:
mcp__task-bars__set_tasks, which the model calls with the whole list of tasks of its current work; it is what feeds the bars where the session offers no task list;TaskCreate, TaskUpdate, TaskList);TodoWrite;Agent: running from the launch, done when they stop.Background shells and monitors are not tracked.
The mod adds a section to the system prompt asking the model to keep the bars current and to give each running task its progress (0-100). A running task with no reported progress sits at 50%: a subagent always does, and a todo is always at 0, 50 or 100.
While it draws, the mod takes the band above the prompt in place of other mods drawing there.
The mod follows Mod Signals, a standard for mods to hear each other without knowing each other. It accepts open, close and toggle, and emits opened, closed and status-changed. Another mod, such as a dock of buttons, can open and close it with nothing added here.
The standard travels in hooks/mod-signals, a git subtree of its repository: the files are committed here and are not edited here. A newer version is pulled with
git subtree pull --prefix hooks/mod-signals git@github.com:mrtsvr-techloop/mod-signals.git main --squash
claude plugin validate .
claude plugin test .
Architecture: hooks/register.tsx is the adapter (the hooks on Claude Code's events and the drawing), hooks/model.ts the pure task model it calls, types/index.d.ts the state contract.
hooks/register.tsx 376 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { TaskRow } from '../types'
5
6import { hasDemo, withDemo, withoutDemo } from './demo'
7import {
8 agentAnswered,
9 agentCalled,
10 agentStopped,
11 cellOf,
12 changesOf,
13 cleared,
14 created,
15 declared,
16 fitting,
17 interrupted,
18 listed,
19 todos,
20 toggled,
21 toneOf,
22 updated,
23} from './model'
24import { ROLL_CALL_MS, readSignal, signalOf } from './mod-signals/kit/signals'
25import type { Announce, SignalKind } from './mod-signals/kit/signals'
26
27const COMMAND = 'task-bars'
28const DEMO_COMMAND = 'task-bars-demo'
29const MAX_ROWS = 10
30/** Two columns of bars from the width that gives each this much. */
31const MIN_CELL_COLUMNS = 30
32const MAX_CELL_COLUMNS = 60
33const GAP_COLUMNS = 2
34/** The pin at the head of each bar, the one spot of it a click reaches. */
35const PIN_COLUMNS = 3
36const PINNED = '📌'
37const LOOSE = '○'
38
39const PROGRESS_SECTION = {
40 id: 'task-bars:progress',
41 text:
42 'The person watches one progress bar per task above the prompt. Keep the bars current without ' +
43 'being asked and without announcing it: for any work of two or more steps, call ' +
44 'mcp__task-bars__set_tasks with the whole list as you start, and again whenever a step starts, ' +
45 'reaches a real milestone, ends or gets blocked, giving each in_progress task its progress ' +
46 '(0-100). Where you keep a task list with TaskCreate and TaskUpdate, that list is shown already: ' +
47 'report progress there with TaskUpdate metadata: { "progress": <0-100> } and leave set_tasks out.',
48 scope: 'session',
49} as const
50
51const TASK_STATUSES = ['pending', 'in_progress', 'completed', 'blocked']
52
53const SET_TASKS = {
54 name: 'set_tasks',
55 description:
56 'Shows the person the tasks of the current work as progress bars above the prompt. Takes the ' +
57 'whole list every time: it replaces the one shown before; an empty list clears it. A task ' +
58 'marked persistent is pinned: it stays, even left out of a later list, until the person unpins it.',
59 inputSchema: {
60 type: 'object',
61 properties: {
62 tasks: {
63 type: 'array',
64 items: {
65 type: 'object',
66 properties: {
67 name: { type: 'string', description: 'A short title for the task' },
68 status: { type: 'string', enum: TASK_STATUSES },
69 progress: { type: 'number', minimum: 0, maximum: 100, description: 'How far an in_progress task is' },
70 persistent: { type: 'boolean', description: 'Pins the task so no clean-up removes it' },
71 },
72 required: ['name', 'status'],
73 },
74 },
75 },
76 required: ['tasks'],
77 },
78}
79
80const tasks = atom({ plugin: 'task-bars', key: 'tasks' } as const, [])
81const isHidden = atom({ plugin: 'task-bars', key: 'isHidden' } as const, false)
82
83/** The mod's name as the engine gives it: the `to` of the commands it obeys. */
84const MOD = 'task-bars'
85const SELF: Announce = { title: 'Tasks', accepts: ['open', 'close', 'toggle'], emits: ['opened', 'closed', 'status-changed'] }
86
87const signal = atom({ plugin: 'task-bars', key: 'signal' } as const, null)
88const sent = { count: 0, answeredAt: 0 }
89
90/** One signal to whoever listens; a mod above that refuses the write stops nothing here. */
91const emit = async (
92 $: EngineInterface,
93 kind: SignalKind,
94 name: string,
95 data?: Record<string, unknown>,
96 tags?: string[],
97): Promise<void> => {
98 sent.count += 1
99
100 try {
101 await update($, signal, () => signalOf(kind, name, sent.count, Date.now(), { ...(data === undefined ? {} : { data }), ...(tags === undefined ? {} : { tags }) }))
102 } catch {
103 // The mod goes on without the signal.
104 }
105}
106
107/**
108 * Every write of the tasks: each change of status it makes is told to the mods
109 * hooked on `taskBars.statusChanged`, one that fails stopping nothing, and to
110 * whoever listens to signals, as the event `status-changed`.
111 */
112const change = async ($: EngineInterface, apply: (list: TaskRow[]) => TaskRow[]): Promise<void> => {
113 const before = await read($, tasks)
114 await update($, tasks, apply)
115
116 for (const one of changesOf(before, await read($, tasks))) {
117 try {
118 await $.taskBars.statusChanged(one)
119 } catch {
120 // The tasks go on whatever a listener met.
121 }
122
123 // A task that is done is the one change tagged for whoever tells the person.
124 await emit($, 'event', 'status-changed', { ...one, text: `${one.name} is done` }, one.to === 'completed' ? ['info', 'completion'] : undefined)
125 }
126}
127
128/** Shows or hides the bars, and says so where that changed. */
129const setHidden = async ($: EngineInterface, hidden: boolean): Promise<boolean> => {
130 const wasHidden = await read($, isHidden)
131 await update($, isHidden, () => hidden)
132
133 if (wasHidden !== hidden) {
134 await emit($, 'event', hidden ? 'closed' : 'opened')
135 }
136
137 return !hidden
138}
139
140/** A command another mod sent: what the person could do with `/task-bars`, and no more. */
141const obey = async ($: EngineInterface, name: string): Promise<void> => {
142 if (name === 'open') {
143 await $.taskBars.open()
144 } else if (name === 'close') {
145 await $.taskBars.close()
146 } else if (name === 'toggle') {
147 await $.taskBars.toggle()
148 }
149}
150
151export const register: Register = on => {
152 // Mod Signals: every signal, whoever writes it. The write goes on first and
153 // untouched; the mod answers a roll-call and obeys the commands sent to it.
154 on('state.set', { key: 'signal' }, async ($, e, next) => {
155 const done = await next(e)
156 const heard = done.value?.isSet === true ? readSignal(e.value) : null
157
158 if (heard?.kind === 'event' && heard.name === 'roll-call' && Date.now() - sent.answeredAt >= ROLL_CALL_MS) {
159 sent.answeredAt = Date.now()
160 await emit($, 'announce', 'announce', SELF)
161 }
162
163 if (heard?.kind === 'command' && heard.to === MOD) {
164 await obey($, heard.name)
165 }
166
167 return done
168 }).catch((_, e, next) => next(e))
169
170 // The mod's API: `$.taskBars` for any other mod. Each method is answered by its
171 // hook below, but `statusChanged`, which is the other mods' to hook.
172 on('engine.create', async ($, e, next) => ({
173 ...(await next(e)),
174 taskBars: {
175 open: async () => false,
176 close: async () => false,
177 toggle: async () => false,
178 isOpen: async () => false,
179 list: async () => [],
180 getStatus: async () => null,
181 statusChanged: async () => undefined,
182 },
183 }))
184
185 on('taskBars.isOpen', async $ => ({ value: !(await read($, isHidden)) }))
186
187 on('taskBars.open', async $ => ({ value: await setHidden($, false) }))
188
189 on('taskBars.close', async $ => ({ value: await setHidden($, true) }))
190
191 on('taskBars.toggle', async $ => ({ value: await setHidden($, !(await read($, isHidden))) }))
192
193 on('taskBars.list', async $ => ({
194 value: (await read($, tasks)).map(({ id, name, status, progress }) => ({ id, name, status, progress })),
195 }))
196
197 on('taskBars.getStatus', async ($, e) => {
198 const list = await read($, tasks)
199
200 return { value: (list.find(one => one.id === e.task) ?? list.find(one => one.name === e.task))?.status ?? null }
201 })
202
203 on('session.start', async ($, e, next) => {
204 await $.command.register({
205 name: COMMAND,
206 description: 'Show or hide the task progress bars above the prompt',
207 })
208
209 await $.command.register({
210 name: DEMO_COMMAND,
211 description: 'Add or remove sample tasks at different percentages in the task bars',
212 })
213
214 await $.tool.register(SET_TASKS)
215 await emit($, 'announce', 'announce', SELF)
216
217 return next(e)
218 })
219
220 on('tool.call', { tool: 'mcp__task-bars__set_tasks' }, async ($, e) => {
221 const input = { ...e }
222
223 if (declared([], input) === undefined) {
224 return { deny: `tasks must be a list of { name, status, progress? }, status one of ${TASK_STATUSES.join(', ')}.` }
225 }
226
227 await change($, list => declared(list, input) ?? list)
228
229 return { result: 'The task bars are up to date.' }
230 })
231
232 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
233 await change($, list => agentCalled(list, e.tool_use_id, { ...e }))
234 const ran = await next(e)
235 await change($, list =>
236 agentAnswered(list, e.tool_use_id, ran.result, ran.deny !== undefined || ran.isError === true),
237 )
238
239 return ran
240 })
241
242 on('classic.SubagentStop', async ($, e, next) => {
243 await change($, list => agentStopped(list, e.agent_id))
244
245 return next(e)
246 })
247
248 on('command.run', { command: DEMO_COMMAND }, async $ => {
249 const isShown = hasDemo(await read($, tasks))
250 await change($, isShown ? withoutDemo : withDemo)
251
252 if (!isShown) {
253 await $.taskBars.open()
254 }
255
256 return { text: isShown ? 'Sample tasks removed.' : 'Sample tasks added.' }
257 })
258
259 on('command.run', { command: COMMAND }, async $ => {
260 // The command is one caller of the mod's API among others.
261 return { text: (await $.taskBars.toggle()) ? 'Task bars shown.' : 'Task bars hidden.' }
262 })
263
264 on('prompt.compose', async ($, e, next) => {
265 const composed = await next(e)
266
267 return { sections: [...composed.sections, PROGRESS_SECTION] }
268 })
269
270 on('prompt.submit', async ($, e, next) => {
271 await change($, cleared)
272
273 return next(e)
274 })
275
276 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
277 const ran = await next(e)
278 await change($, list => created(list, ran.result))
279
280 return ran
281 })
282
283 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
284 const ran = await next(e)
285 await change($, list => updated(list, { ...e }, ran.result))
286
287 return ran
288 })
289
290 on('tool.call', { tool: 'TaskList' }, async ($, e, next) => {
291 const ran = await next(e)
292 await change($, list => listed(list, ran.result))
293
294 return ran
295 })
296
297 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
298 const ran = await next(e)
299
300 if (ran.deny === undefined && ran.isError !== true) {
301 await change($, list => todos(list, { ...e }))
302 }
303
304 return ran
305 })
306
307 on('turn.complete', async ($, e, next) => {
308 if (e.reason === 'aborted') {
309 await change($, interrupted)
310 }
311
312 return next(e)
313 })
314
315 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
316 const list = await read($, tasks)
317
318 if (e.props.hasSurvey || list.length === 0 || (await read($, isHidden))) {
319 return next(e)
320 }
321
322 const { Box, Button, Text } = $.ui.resolve(e)
323 const { bodyColumns } = e.props
324 const columns = bodyColumns >= MIN_CELL_COLUMNS * 2 + GAP_COLUMNS ? 2 : 1
325 const cellColumns = Math.max(
326 1,
327 Math.min(
328 MAX_CELL_COLUMNS,
329 Math.floor((bodyColumns - GAP_COLUMNS * (columns - 1)) / columns) - PIN_COLUMNS,
330 ),
331 )
332 const room = Math.max(1, Math.min(MAX_ROWS, e.props.maxRows - 1)) * columns
333 const rows = fitting(list, room)
334 const lines = Math.ceil(rows.length / columns)
335
336 return (
337 <Box flexDirection="column">
338 {Array.from({ length: lines }, (_, line) => (
339 <Box>
340 {/* Column by column: the list reads down the first, then the second. */}
341 {Array.from({ length: columns }, (_, column) => rows[column * lines + line]).flatMap(row => {
342 if (row === undefined) {
343 return []
344 }
345
346 const tone = toneOf(row, list)
347 const cell = cellOf(row, list, cellColumns)
348
349 return [
350 <Box marginRight={GAP_COLUMNS}>
351 <Box width={PIN_COLUMNS}>
352 <Button
353 key={`pin-${row.id}`}
354 plain
355 dimColor={row.isPinned !== true}
356 label={row.isPinned === true ? PINNED : LOOSE}
357 onPress={() => change($, rows => toggled(rows, row.id))}
358 />
359 </Box>
360 {cell.filled.length > 0 && (
361 <Text backgroundColor={tone} color="inverseText">
362 {cell.filled}
363 </Text>
364 )}
365 {cell.rest.length > 0 && <Text color={tone}>{cell.rest}</Text>}
366 </Box>,
367 ]
368 })}
369 </Box>
370 ))}
371 {rows.length < list.length && <Text dimColor>+{list.length - rows.length} altre</Text>}
372 </Box>
373 )
374 })
375}
376hooks/demo.ts 25 lines1import type { TaskRow } from '../types'
2
3const DEMO_PREFIX = 'demo-'
4
5const SAMPLES: readonly Omit<TaskRow, 'id'>[] = [
6 { name: 'Migrazione schema database', status: 'completed', blockedBy: [], progress: null },
7 { name: 'Refactor client API', status: 'in_progress', blockedBy: [], progress: 82 },
8 { name: 'Test componenti UI', status: 'in_progress', blockedBy: [], progress: 47 },
9 { name: 'Audit permessi di accesso', status: 'in_progress', blockedBy: [], progress: 15 },
10 { name: 'Aggiornare documentazione', status: 'pending', blockedBy: [], progress: null },
11 { name: 'Deploy su staging', status: 'pending', blockedBy: [`${DEMO_PREFIX}1`], progress: null },
12 { name: 'Build di release', status: 'stopped', blockedBy: [], progress: 63 },
13]
14
15export const hasDemo = (list: TaskRow[]): boolean => list.some(row => row.id.startsWith(DEMO_PREFIX))
16
17export const withoutDemo = (list: TaskRow[]): TaskRow[] =>
18 list.filter(row => !row.id.startsWith(DEMO_PREFIX))
19
20/** The sample rows, one per state the band can draw, after the real ones. */
21export const withDemo = (list: TaskRow[]): TaskRow[] => [
22 ...withoutDemo(list),
23 ...SAMPLES.map((sample, index) => ({ ...sample, id: `${DEMO_PREFIX}${index}` })),
24]
25hooks/model.ts 325 lines1import type { TaskBarsChange, TaskRow, TaskStatus } from '../types'
2
3type Bag = Record<string, unknown>
4
5export type Tone = 'success' | 'warning' | 'error'
6
7const TODO_PREFIX = 'todo-'
8const PLAN_PREFIX = 'plan-'
9const CALL_PREFIX = 'call-'
10const AGENT_PREFIX = 'agent-'
11/** Rows the task list does not hold: todos, declared tasks, subagents, samples. */
12const OWN_PREFIXES = [TODO_PREFIX, PLAN_PREFIX, CALL_PREFIX, AGENT_PREFIX, 'demo-']
13const isOwn = (row: TaskRow): boolean => OWN_PREFIXES.some(prefix => row.id.startsWith(prefix))
14/** A started task whose progress the model never reported sits at the middle. */
15const UNREPORTED_PERCENT = 50
16
17const isBag = (value: unknown): value is Bag =>
18 typeof value === 'object' && value !== null && !Array.isArray(value)
19
20const asText = (value: unknown): string | undefined =>
21 typeof value === 'string' && value.length > 0 ? value : undefined
22
23const asIds = (value: unknown): string[] =>
24 Array.isArray(value) ? value.filter((id): id is string => typeof id === 'string') : []
25
26const asStatus = (value: unknown): TaskStatus | undefined =>
27 value === 'pending' || value === 'in_progress' || value === 'completed' ? value : undefined
28
29const asProgress = (metadata: unknown): number | undefined => {
30 const value = isBag(metadata) ? metadata.progress : undefined
31
32 return typeof value === 'number' && Number.isFinite(value)
33 ? Math.min(100, Math.max(0, Math.round(value)))
34 : undefined
35}
36
37/**
38 * One source's rows replaced by its new whole list. A pinned row is locked:
39 * it keeps its pin when the list names it again, and stays when it does not.
40 */
41const replaced = (list: TaskRow[], isSource: (row: TaskRow) => boolean, rows: TaskRow[]): TaskRow[] => {
42 const fresh = new Map(rows.map(row => [row.id, row]))
43 const merged = [...fresh.values()].map(row =>
44 list.some(one => one.id === row.id && one.isPinned === true) ? { ...row, isPinned: true } : row,
45 )
46
47 return [...list.filter(row => !isSource(row) || (row.isPinned === true && !fresh.has(row.id))), ...merged]
48}
49
50/** The person's click on a task's pin: pinned becomes loose, loose pinned. */
51export const toggled = (list: TaskRow[], id: string): TaskRow[] =>
52 list.map(row => (row.id === id ? { ...row, isPinned: row.isPinned !== true } : row))
53
54/** TaskCreate's result adds one awaiting row. */
55export const created = (list: TaskRow[], result: unknown): TaskRow[] => {
56 const task = isBag(result) ? result.task : undefined
57 const id = isBag(task) ? asText(task.id) : undefined
58
59 if (!isBag(task) || id === undefined || list.some(row => row.id === id)) {
60 return list
61 }
62
63 return [
64 ...list,
65 { id, name: asText(task.subject) ?? id, status: 'pending', blockedBy: [], progress: null },
66 ]
67}
68
69/** TaskUpdate's input, once its result says it took, changes or removes one row. */
70export const updated = (list: TaskRow[], input: Bag, result: unknown): TaskRow[] => {
71 const id = asText(input.taskId)
72
73 if (id === undefined || !isBag(result) || result.success !== true) {
74 return list
75 }
76
77 if (input.status === 'deleted') {
78 return list.filter(row => row.id !== id || row.isPinned === true)
79 }
80
81 const known = list.find(row => row.id === id)
82 const row: TaskRow = known ?? { id, name: id, status: 'pending', blockedBy: [], progress: null }
83 const next: TaskRow = {
84 ...row,
85 name: asText(input.subject) ?? row.name,
86 status: asStatus(input.status) ?? row.status,
87 blockedBy: [...new Set([...row.blockedBy, ...asIds(input.addBlockedBy)])],
88 progress: asProgress(input.metadata) ?? row.progress,
89 }
90
91 return known ? list.map(one => (one.id === id ? next : one)) : [...list, next]
92}
93
94/** TaskList's result is the whole task list: it replaces every row of that list. */
95export const listed = (list: TaskRow[], result: unknown): TaskRow[] => {
96 const tasks = isBag(result) && Array.isArray(result.tasks) ? result.tasks : undefined
97
98 if (tasks === undefined) {
99 return list
100 }
101
102 const rows = tasks.flatMap((task): TaskRow[] => {
103 const id = isBag(task) ? asText(task.id) : undefined
104
105 if (!isBag(task) || id === undefined) {
106 return []
107 }
108
109 const known = list.find(row => row.id === id)
110 const status = asStatus(task.status) ?? 'pending'
111
112 return [
113 {
114 id,
115 name: asText(task.subject) ?? id,
116 // TaskList does not know a turn was interrupted: a stopped row stays stopped.
117 status: known?.status === 'stopped' && status === 'in_progress' ? 'stopped' : status,
118 blockedBy: asIds(task.blockedBy),
119 progress: known?.progress ?? null,
120 },
121 ]
122 })
123
124 return replaced(list, row => !isOwn(row), rows)
125}
126
127/** TodoWrite's input is the whole todo list: it replaces every todo row. */
128export const todos = (list: TaskRow[], input: Bag): TaskRow[] => {
129 if (!Array.isArray(input.todos)) {
130 return list
131 }
132
133 const rows = input.todos.flatMap((todo): TaskRow[] => {
134 const name = isBag(todo) ? asText(todo.content) : undefined
135
136 return isBag(todo) && name !== undefined
137 ? [
138 {
139 id: `${TODO_PREFIX}${name}`,
140 name,
141 status: asStatus(todo.status) ?? 'pending',
142 blockedBy: [],
143 progress: null,
144 },
145 ]
146 : []
147 })
148
149 return replaced(list, row => row.id.startsWith(TODO_PREFIX), rows)
150}
151
152/**
153 * The whole list the model declared through the mod's own tool: it replaces
154 * every declared row, a task it marks persistent pinned. Undefined when the
155 * input is no list of named tasks.
156 */
157export const declared = (list: TaskRow[], input: Bag): TaskRow[] | undefined => {
158 if (!Array.isArray(input.tasks)) {
159 return undefined
160 }
161
162 const rows: TaskRow[] = []
163
164 for (const task of input.tasks) {
165 const name = isBag(task) ? asText(task.name) : undefined
166 const status = isBag(task) ? (task.status === 'blocked' ? 'blocked' : asStatus(task.status)) : undefined
167
168 if (!isBag(task) || name === undefined || status === undefined) {
169 return undefined
170 }
171
172 rows.push({
173 id: `${PLAN_PREFIX}${name}`,
174 name,
175 status,
176 blockedBy: [],
177 progress: asProgress({ progress: task.progress }) ?? null,
178 ...(task.persistent === true ? { isPinned: true } : {}),
179 })
180 }
181
182 return replaced(list, row => row.id.startsWith(PLAN_PREFIX), rows)
183}
184
185/** A subagent the model launched is a running row, named as the call describes it. */
186export const agentCalled = (list: TaskRow[], callId: string, input: Bag): TaskRow[] => [
187 ...list,
188 {
189 id: `${CALL_PREFIX}${callId}`,
190 name: asText(input.description) ?? asText(input.subagent_type) ?? 'subagent',
191 status: 'in_progress',
192 blockedBy: [],
193 progress: null,
194 },
195]
196
197/**
198 * The launch's answer settles the row: done when the subagent ran to its end,
199 * stopped when the call failed, still running under the agent's id when it
200 * went to the background.
201 */
202export const agentAnswered = (list: TaskRow[], callId: string, result: unknown, isFailed: boolean): TaskRow[] =>
203 list.map((row): TaskRow => {
204 if (row.id !== `${CALL_PREFIX}${callId}`) {
205 return row
206 }
207
208 if (isFailed || !isBag(result)) {
209 return { ...row, status: 'stopped' }
210 }
211
212 const agentId = asText(result.agentId)
213
214 return result.status === 'completed' || agentId === undefined
215 ? { ...row, status: 'completed' }
216 : { ...row, id: `${AGENT_PREFIX}${agentId}` }
217 })
218
219/** A background subagent that stopped is done. */
220export const agentStopped = (list: TaskRow[], agentId: string): TaskRow[] =>
221 list.map(row => (row.id === `${AGENT_PREFIX}${agentId}` ? { ...row, status: 'completed' } : row))
222
223/** An interrupted turn leaves what was running stopped. */
224export const interrupted = (list: TaskRow[]): TaskRow[] =>
225 list.map(row => (row.status === 'in_progress' ? { ...row, status: 'stopped' } : row))
226
227/** Once every loose task is done they all go; the pinned ones stay. */
228export const cleared = (list: TaskRow[]): TaskRow[] => {
229 const loose = list.filter(row => row.isPinned !== true)
230
231 return loose.length > 0 && loose.every(row => row.status === 'completed')
232 ? list.filter(row => row.isPinned === true)
233 : list
234}
235
236export const isBlocked = (row: TaskRow, list: TaskRow[]): boolean =>
237 row.status === 'blocked' ||
238 (row.status !== 'completed' &&
239 row.blockedBy.some(id => list.some(one => one.id === id && one.status !== 'completed')))
240
241export const percentOf = (row: TaskRow): number => {
242 if (row.status === 'completed') {
243 return 100
244 }
245
246 if (row.status === 'pending' || row.status === 'blocked') {
247 return row.progress ?? 0
248 }
249
250 return row.progress ?? UNREPORTED_PERCENT
251}
252
253export const labelOf = (row: TaskRow, list: TaskRow[]): string => {
254 if (row.status === 'completed') {
255 return 'fine'
256 }
257
258 if (row.status === 'stopped') {
259 return 'stopped'
260 }
261
262 if (isBlocked(row, list)) {
263 return 'blocked'
264 }
265
266 return row.status === 'pending' ? 'awaiting' : 'in corso'
267}
268
269/** Green while it runs and when done, orange while it waits, red blocked or stopped. */
270export const toneOf = (row: TaskRow, list: TaskRow[]): Tone => {
271 if (row.status === 'stopped' || isBlocked(row, list)) {
272 return 'error'
273 }
274
275 return row.status === 'pending' ? 'warning' : 'success'
276}
277
278/** Below this width a cell drops the state's label and keeps the percentage. */
279const LABEL_MIN_COLUMNS = 34
280
281/**
282 * One task as a bar `width` cells wide with its text inside: the name at the
283 * left, the state and the percentage at the right, cut where the fill ends.
284 */
285export const cellOf = (row: TaskRow, list: TaskRow[], width: number): { filled: string; rest: string } => {
286 const percent = percentOf(row)
287 const tail = width >= LABEL_MIN_COLUMNS ? `${labelOf(row, list)} ${percent}%` : `${percent}%`
288 const room = Math.max(0, width - tail.length - 3)
289 const name = row.name.length > room ? `${row.name.slice(0, Math.max(0, room - 1))}…` : row.name
290 const text = ` ${name.padEnd(room)} ${tail} `.slice(0, width).padEnd(width)
291 const cells = Math.min(width, Math.max(0, Math.round((percent / 100) * width)))
292
293 return { filled: text.slice(0, cells), rest: text.slice(cells) }
294}
295
296/** The rows that fit: when there are too many, finished loose ones give way first. */
297export const fitting = (list: TaskRow[], room: number): TaskRow[] => {
298 if (list.length <= room) {
299 return list
300 }
301
302 const open = list.filter(row => row.status !== 'completed' || row.isPinned === true)
303 const kept = new Set(
304 (open.length >= room ? open.slice(0, room) : [...open, ...list.filter(row => !open.includes(row))].slice(0, room)).map(
305 row => row.id,
306 ),
307 )
308
309 return list.filter(row => kept.has(row.id))
310}
311
312/** The changes of status between two states of the list, tasks that appeared or left included. */
313export const changesOf = (before: TaskRow[], after: TaskRow[]): TaskBarsChange[] => {
314 const moved = after.flatMap(row => {
315 const from = before.find(one => one.id === row.id)?.status ?? null
316
317 return from === row.status ? [] : [{ id: row.id, name: row.name, from, to: row.status }]
318 })
319 const gone = before
320 .filter(row => !after.some(one => one.id === row.id))
321 .map(row => ({ id: row.id, name: row.name, from: row.status, to: null }))
322
323 return [...moved, ...gone]
324}
325hooks/mod-signals/kit/signals.ts 110 lines1/**
2 * The Mod Signals kit, for version 0.1 of the standard. A mod imports this
3 * file as it is: it is the same in every mod, and is not to be edited there.
4 *
5 * Mod Signals 0.1, the part every mod carries: what a signal is, how one is
6 * made and how one is read. A mod emits by writing a signal under its own state
7 * key `signal`, and listens by hooking `state.set` on that key, whoever writes.
8 */
9export type SignalKind = 'event' | 'command' | 'announce'
10
11export type Signal = {
12 v: 1
13 kind: SignalKind
14 name: string
15 to?: string
16 data?: Record<string, unknown>
17 /** What kind of signal this is, for whoever picks signals by kind: `['info', 'completion']`. */
18 tags?: string[]
19 id: string
20}
21
22/** What a mod says of itself in an `announce`. */
23export type Announce = {
24 title: string
25 accepts: string[]
26 emits: string[]
27}
28
29/** The most characters of a `name`, an `id` or a `to`. */
30const NAME_LENGTH = 64
31
32/** The most tags of a signal. */
33const TAGS_LENGTH = 8
34
35/**
36 * The tags every mod shares; any other is its mod's own, written with a
37 * namespace: `citroen.low-fuel`.
38 */
39export const TAGS = ['info', 'warning', 'error', 'success', 'completion', 'important', 'progress'] as const
40
41/** A mod answers the roll-calls of others no more often than this. */
42export const ROLL_CALL_MS = 1000
43
44const KINDS: readonly SignalKind[] = ['event', 'command', 'announce']
45
46/** Lowercase words joined by `-`, `.` between namespaces. */
47const NAME = /^[a-z0-9]+(?:[-.][a-z0-9]+)*$/
48
49const isRecord = (value: unknown): value is Record<string, unknown> =>
50 typeof value === 'object' && value !== null && !Array.isArray(value)
51
52const isShort = (value: unknown): value is string =>
53 typeof value === 'string' && value.length > 0 && value.length <= NAME_LENGTH
54
55const isName = (value: unknown): value is string => isShort(value) && NAME.test(value)
56
57/** A signal of this mod's, the `count`-th it emits: its id is unique in the session. */
58export const signalOf = (
59 kind: SignalKind,
60 name: string,
61 count: number,
62 now: number,
63 rest: { to?: string; data?: Record<string, unknown>; tags?: string[] } = {},
64): Signal => ({
65 v: 1,
66 kind,
67 name,
68 id: `${now.toString(36)}-${count}`,
69 ...(rest.to === undefined ? {} : { to: rest.to }),
70 ...(rest.data === undefined ? {} : { data: rest.data }),
71 ...(rest.tags === undefined || rest.tags.length === 0 ? {} : { tags: rest.tags }),
72})
73
74/**
75 * The signal a written value holds, or null: an unknown version or kind, a name
76 * off the grammar, a command with no receiver, a receiver on anything else.
77 */
78export const readSignal = (value: unknown): Signal | null => {
79 if (!isRecord(value) || value.v !== 1 || !isName(value.name) || !isShort(value.id)) {
80 return null
81 }
82
83 const kind = KINDS.find(one => one === value.kind)
84 const isAddressed = value.to !== undefined
85
86 if (kind === undefined || (isAddressed && !isShort(value.to)) || isAddressed !== (kind === 'command')) {
87 return null
88 }
89
90 if ((kind === 'announce') !== (value.name === 'announce') || (value.data !== undefined && !isRecord(value.data))) {
91 return null
92 }
93
94 if (value.tags !== undefined && !(Array.isArray(value.tags) && value.tags.length <= TAGS_LENGTH && value.tags.every(isName))) {
95 return null
96 }
97
98 return value as Signal
99}
100
101/** Whether the signal carries the tag. */
102export const hasTag = (signal: Signal, tag: string): boolean => (signal.tags ?? []).includes(tag)
103
104/** What an `announce` says its sender accepts, or null when it does not hold. */
105export const acceptsOf = (signal: Signal): string[] | null => {
106 const accepts = signal.kind === 'announce' ? signal.data?.accepts : undefined
107
108 return Array.isArray(accepts) && accepts.every(isShort) ? accepts : null
109}
110types/index.d.ts 77 lines1export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'blocked' | 'stopped'
2
3export type TaskRow = {
4 id: string
5 name: string
6 status: TaskStatus
7 /** Ids of the tasks this one waits for. */
8 blockedBy: string[]
9 /** Pinned by the person's click or by the model: no clean-up removes it. */
10 isPinned?: boolean
11 /** 0-100 as the model reported it in `metadata.progress`; null when it never did. */
12 progress: number | null
13}
14
15/** One task as the model declares it through the mod's own tool. */
16export type DeclaredTask = {
17 name: string
18 status: 'pending' | 'in_progress' | 'completed' | 'blocked'
19 progress?: number
20 /** Pins the task: it stays until the person unpins it. */
21 persistent?: boolean
22}
23
24/** A signal of Mod Signals 0.1, as the mod writes it under `signal` for every other mod to hear. */
25export type TaskBarsSignal = {
26 v: 1
27 kind: 'event' | 'command' | 'announce'
28 name: string
29 to?: string
30 data?: Record<string, unknown>
31 /** What kind of signal this is, for whoever picks signals by kind: `['info', 'completion']`. */
32 tags?: string[]
33 id: string
34}
35
36/** A task as another mod reads it. */
37export type TaskBarsTask = { id: string; name: string; status: TaskStatus; progress: number | null }
38
39/** One task's change of status: `from` null when it appears, `to` null when it leaves the list. */
40export type TaskBarsChange = { id: string; name: string; from: TaskStatus | null; to: TaskStatus | null }
41
42/**
43 * What any other mod calls on `$.taskBars`, and the event it hooks to follow
44 * the tasks: `on('taskBars.statusChanged', hook)`.
45 */
46export type TaskBars = {
47 /** Shows the bars; answers whether they are shown once the call is done, as the next three do. */
48 open: () => Promise<boolean>
49 close: () => Promise<boolean>
50 toggle: () => Promise<boolean>
51 isOpen: () => Promise<boolean>
52 list: () => Promise<TaskBarsTask[]>
53 /** The status of the task of that id, else of that name; null when there is none. */
54 getStatus: (ref: { task: string }) => Promise<TaskStatus | null>
55 /** Called by the mod itself at every change of status: hook it, do not call it. */
56 statusChanged: (change: TaskBarsChange) => Promise<void>
57}
58
59declare module 'claude-code' {
60 interface EngineInterface {
61 taskBars: TaskBars
62 }
63
64 interface McpToolInputs {
65 'mcp__task-bars__set_tasks': { tasks: DeclaredTask[] }
66 }
67
68 interface PluginState {
69 'task-bars': {
70 tasks: TaskRow[]
71 isHidden: boolean
72 /** The mod's signals: `announce`, `opened`, `closed`, `status-changed`. */
73 signal: TaskBarsSignal | null
74 }
75 }
76}
77