A task band above the prompt that opens by itself: Claude plans multi-step work, ticks each task as it goes, and the band closes when everything is done. Three…

A task band above the Claude Code prompt that opens by itself. When a request has several steps, Claude plans it as a list of tasks, ticks each one as it goes, and the band closes a few seconds after the last task is done. You never have to ask for it.
Tasks 2/4 █████░░░░░
✔ Copy the files
✔ Remove the old code
◐ Write the README
○ Run the validation
────────────────────────────
f One at a time s Shrink
tasks tool with plan (one title per task), then update (in_progress, done) as it works. The band shows the progress bar and the list, and hides itself 5 seconds after everything is done.f one at a time, s shrink);done/total (e to expand);p previous, n next, e back to the list)./tasks shows or hides the band by hand.claude plugin marketplace add yanmwisa/claude-code-plugins
claude plugin install tallyrail@yanmwisa
Then start a new session.
This plugin was called panneau-taches before version 0.3.0, then task-band until version 0.4.0. If you installed it under either name, the marketplace moves you to tallyrail on the next update.
It is a function-hooks plugin (hooks/register.tsx):
| Hook | Role |
|---|---|
session.start | registers the tasks tool and the /tasks command |
tool.call on mcp__tallyrail__tasks | validates plan / update and stores the tasks |
prompt.submit | adds the planning reminder to longer prompts |
command.run on tasks | shows or hides the band |
ui.render on AbovePrompt | draws the band |
The tasks tool belongs to the plugin: session.start registers it, and the tool.call hook answers every call to it directly instead of passing it on, because no other implementation of tasks exists behind it. The prompt.submit hook only adds the reminder to the context Claude reads; it does not change the text you typed.
The tasks live in the session's state ($.state) only. Pure decisions (parsing tool input, computing the view) are separated from effects and covered by tallyrail.test.ts.
No network access, no files written, no process started. Task titles stay in the session.
claude plugin validate ./tallyrail
claude plugin test ./tallyrail
To try a local copy: claude --plugin-dir ./tallyrail.
MIT, see LICENSE.
hooks/register.tsx 375 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, Timer } from 'claude-code'
3
4import type { BandMode, Task, TaskStatus } from '../types'
5
6const TOOL = 'mcp__tallyrail__tasks'
7const STATUSES: readonly TaskStatus[] = ['pending', 'in_progress', 'done']
8const AUTO_HIDE_MS = 5000
9const BAR_WIDTH = 10
10const MIN_PROMPT_CHARS_FOR_REMINDER = 25
11const NARROW_BAND_COLUMNS = 44
12const MIN_RULE_WIDTH = 8
13const MAX_RULE_WIDTH = 48
14
15// Read by Claude next to each prompt, never shown to the user.
16const PLANNING_REMINDER =
17 `Reminder from the tallyrail plugin: if the request has two steps or more, first call the ${TOOL} tool ` +
18 'with action "plan" (one entry per task), then action "update" for each task: status "done" as soon as it ' +
19 'is finished. For a simple question, do not plan.'
20
21const tasksState = atom({ plugin: 'tallyrail', key: 'tasks' } as const, [])
22const modeState = atom({ plugin: 'tallyrail', key: 'mode' } as const, 'list')
23const cursorState = atom({ plugin: 'tallyrail', key: 'cursor' } as const, 0)
24const isHiddenState = atom({ plugin: 'tallyrail', key: 'isHidden' } as const, false)
25
26let hideTimer: Timer | undefined
27
28// --- pure:start (decisions without side effects; tested outside the engine)
29
30type TaskAction =
31 | { kind: 'plan'; titles: string[] }
32 | { kind: 'update'; id: number; status: TaskStatus }
33
34type Parsed = { action: TaskAction } | { error: string }
35
36type Progress = { done: number; total: number }
37
38type BandView =
39 | { kind: 'list'; progress: Progress; tasks: Task[]; isFinished: boolean }
40 | { kind: 'line'; progress: Progress; current: Task }
41 | { kind: 'focus'; progress: Progress; shown: Task; following: Task | null; canPrev: boolean; canNext: boolean }
42
43const isStatus = (value: unknown): value is TaskStatus => STATUSES.includes(value as TaskStatus)
44
45// Boundary: the tool input comes from the model, so it is not trusted.
46function parseTaskAction(input: Record<string, unknown>): Parsed {
47 if (input.action === 'plan') {
48 const titles = Array.isArray(input.titles)
49 ? input.titles.filter((title): title is string => typeof title === 'string' && title.trim() !== '')
50 : []
51 if (titles.length === 0) {
52 return { error: 'tallyrail: "plan" needs a non-empty "titles" list.' }
53 }
54 return { action: { kind: 'plan', titles } }
55 }
56
57 if (input.action === 'update') {
58 if (typeof input.id !== 'number' || !isStatus(input.status)) {
59 return { error: 'tallyrail: "update" needs "id" (a number) and "status" (pending, in_progress, done).' }
60 }
61 return { action: { kind: 'update', id: input.id, status: input.status } }
62 }
63
64 return { error: 'tallyrail: "action" must be "plan" or "update".' }
65}
66
67// While tasks remain there is always one in progress: the first one to do is promoted.
68function withCurrentTask(tasks: Task[]): Task[] {
69 if (tasks.some(task => task.status === 'in_progress')) {
70 return tasks
71 }
72 const firstPending = tasks.findIndex(task => task.status === 'pending')
73 if (firstPending === -1) {
74 return tasks
75 }
76 return tasks.map((task, index) => (index === firstPending ? { ...task, status: 'in_progress' } : task))
77}
78
79function applyTaskAction(current: Task[], request: TaskAction): { tasks: Task[] } | { error: string } {
80 if (request.kind === 'plan') {
81 const planned = request.titles.map((title, index): Task => ({ id: index + 1, title, status: 'pending' }))
82 return { tasks: withCurrentTask(planned) }
83 }
84
85 // Empty list: never planned, or lost when the session resumed (the state is not kept).
86 if (current.length === 0) {
87 return { error: 'tallyrail: the list was lost, plan again.' }
88 }
89 if (!current.some(task => task.id === request.id)) {
90 return { error: `tallyrail: no task number ${request.id}.` }
91 }
92 const updated = current.map(task => (task.id === request.id ? { ...task, status: request.status } : task))
93 return { tasks: withCurrentTask(updated) }
94}
95
96const allDone = (tasks: Task[]): boolean => tasks.length > 0 && tasks.every(task => task.status === 'done')
97
98function currentIndex(tasks: Task[]): number {
99 const index = tasks.findIndex(task => task.status !== 'done')
100 return index === -1 ? Math.max(0, tasks.length - 1) : index
101}
102
103const clampCursor = (cursor: number, total: number): number => Math.min(Math.max(cursor, 0), Math.max(total - 1, 0))
104
105const describeProgress = (tasks: Task[]): Progress => ({
106 done: tasks.filter(task => task.status === 'done').length,
107 total: tasks.length,
108})
109
110function describeOutcome(tasks: Task[]): string {
111 const { done, total } = describeProgress(tasks)
112 const current = tasks.find(task => task.status === 'in_progress')
113 return `${done}/${total} tasks done. ${current ? `In progress: ${current.title}.` : 'All done.'}`
114}
115
116function describeBand(mode: BandMode, tasks: Task[], cursor: number): BandView {
117 const progress = describeProgress(tasks)
118 if (allDone(tasks)) {
119 return { kind: 'list', progress, tasks, isFinished: true }
120 }
121 if (mode === 'line') {
122 return { kind: 'line', progress, current: tasks[currentIndex(tasks)] as Task }
123 }
124 if (mode === 'focus') {
125 const index = clampCursor(cursor, tasks.length)
126 return {
127 kind: 'focus',
128 progress,
129 shown: tasks[index] as Task,
130 following: tasks[index + 1] ?? null,
131 canPrev: index > 0,
132 canNext: index < tasks.length - 1,
133 }
134 }
135 return { kind: 'list', progress, tasks, isFinished: false }
136}
137
138const progressBar = (progress: Progress): { filled: number; rest: number } => {
139 const filled = progress.total === 0 ? 0 : Math.round((progress.done / progress.total) * BAR_WIDTH)
140 return { filled, rest: BAR_WIDTH - filled }
141}
142
143const markOf = (status: TaskStatus): string => (status === 'done' ? '✔' : status === 'in_progress' ? '◐' : '○')
144
145const markColorOf = (status: TaskStatus): string | undefined =>
146 status === 'done' ? 'green' : status === 'in_progress' ? 'yellow' : undefined
147
148const needsPlanningReminder = (promptText: string): boolean => promptText.trim().length >= MIN_PROMPT_CHARS_FOR_REMINDER
149
150type ButtonKey = 'focus' | 'shrink' | 'grow' | 'previous' | 'next'
151
152// Always words: a button says what it does. Shortened only when the band is very narrow.
153const BUTTON_LABELS: Record<ButtonKey, { wide: string; narrow: string }> = {
154 focus: { wide: 'One at a time', narrow: 'One by one' },
155 shrink: { wide: 'Shrink', narrow: 'Shrink' },
156 grow: { wide: 'Expand', narrow: 'Expand' },
157 previous: { wide: 'Previous', narrow: 'Prev' },
158 next: { wide: 'Next', narrow: 'Next' },
159}
160
161const isNarrowBand = (columns: number | undefined): boolean => (columns ?? 80) < NARROW_BAND_COLUMNS
162
163const labelOf = (key: ButtonKey, isNarrow: boolean): string => BUTTON_LABELS[key][isNarrow ? 'narrow' : 'wide']
164
165// Width of the rule between the list and its buttons: the band minus its margin, within bounds.
166const ruleWidthOf = (columns: number | undefined): number =>
167 Math.min(MAX_RULE_WIDTH, Math.max(MIN_RULE_WIDTH, (columns ?? 80) - 2))
168
169// --- pure:end
170
171function syncAutoHide($: EngineInterface, tasks: Task[]): void {
172 hideTimer?.cancel()
173 hideTimer = undefined
174 if (!allDone(tasks)) {
175 return
176 }
177 hideTimer = $.clock.after(AUTO_HIDE_MS, () => {
178 void update($, isHiddenState, () => true)
179 })
180}
181
182async function showFocus($: EngineInterface): Promise<void> {
183 const tasks = await read($, tasksState)
184 await update($, modeState, () => 'focus')
185 await update($, cursorState, () => currentIndex(tasks))
186}
187
188export const register: Register = on => {
189 on('session.start', async ($, e, next) => {
190 await $.tool.register({
191 name: 'tasks',
192 description:
193 "Task tracking shown automatically above the user's prompt. As soon as a request has several steps, " +
194 'first call action "plan" with the "titles" list (one entry per task). Then mark each task with ' +
195 'action "update" (id, status): "in_progress" when you start it, "done" as soon as it is finished. ' +
196 'The band opens and closes by itself: do not ask the user anything.',
197 inputSchema: {
198 type: 'object',
199 properties: {
200 action: { type: 'string', enum: ['plan', 'update'] },
201 titles: { type: 'array', items: { type: 'string' } },
202 id: { type: 'number' },
203 status: { type: 'string', enum: [...STATUSES] },
204 },
205 required: ['action'],
206 },
207 })
208 await $.command.register({ name: 'tasks', description: 'Show or hide the task band' })
209
210 return next(e)
211 })
212
213 on('prompt.submit', ($, e, next) => {
214 if (!needsPlanningReminder(e.text)) {
215 return next(e)
216 }
217 return next({ ...e, context: [...(e.context ?? []), PLANNING_REMINDER] })
218 })
219
220 on('command.run', { command: 'tasks' }, async $ => {
221 const tasks = await read($, tasksState)
222 if (tasks.length === 0) {
223 return { text: 'No tasks in progress.' }
224 }
225 const wasHidden = await read($, isHiddenState)
226 await update($, isHiddenState, () => !wasHidden)
227
228 return { text: wasHidden ? 'Task band shown.' : 'Task band hidden.' }
229 })
230
231 on('tool.call', { tool: TOOL }, async ($, e) => {
232 const parsed = parseTaskAction(e as unknown as Record<string, unknown>)
233 if ('error' in parsed) {
234 return { deny: parsed.error }
235 }
236
237 const outcome = applyTaskAction(await read($, tasksState), parsed.action)
238 if ('error' in outcome) {
239 return { deny: outcome.error }
240 }
241
242 await update($, tasksState, () => outcome.tasks)
243 await update($, cursorState, () => currentIndex(outcome.tasks))
244 if (parsed.action.kind === 'plan') {
245 await update($, isHiddenState, () => false)
246 }
247 syncAutoHide($, outcome.tasks)
248
249 return { result: describeOutcome(outcome.tasks) }
250 })
251
252 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next): Promise<RenderElement> => {
253 const below = await next(e)
254 const tasks = await read($, tasksState)
255 const isHidden = await read($, isHiddenState)
256 if (e.props.hasSurvey || isHidden || tasks.length === 0) {
257 return below
258 }
259
260 const mode = await read($, modeState)
261 const cursor = await read($, cursorState)
262 const view = describeBand(mode, tasks, cursor)
263 const { Box, Text, Button } = $.ui.resolve(e)
264
265 const isFinished = view.kind === 'list' && view.isFinished
266 const { filled, rest } = progressBar(view.progress)
267
268 const renderTask = (task: Task) => (
269 <Box key={`task${task.id}`}>
270 <Text color={markColorOf(task.status)} dimColor={task.status === 'pending'} bold={task.status === 'in_progress'}>
271 {markOf(task.status)}{' '}
272 </Text>
273 <Text strikethrough={task.status === 'done'} dimColor={task.status === 'done'} bold={task.status === 'in_progress'}>
274 {task.title}
275 </Text>
276 </Box>
277 )
278
279 const renderButton = (key: string, hotkey: string, label: string, onPress: () => void) => (
280 <Box key={key} marginRight={2}>
281 <Button hotkey={hotkey} plain label={label} onPress={onPress} />
282 </Box>
283 )
284
285 const toList = () => void update($, modeState, () => 'list')
286 const toLine = () => void update($, modeState, () => 'line')
287 const toFocus = () => void showFocus($)
288 const toPrevious = () => void update($, cursorState, previous => previous - 1)
289 const toNext = () => void update($, cursorState, previous => previous + 1)
290
291 const isNarrow = isNarrowBand(e.props.bodyColumns)
292
293 // Shrunk view: a single line, only the task in progress, and a small button to expand.
294 if (view.kind === 'line') {
295 return (
296 <Box flexDirection="column">
297 {below}
298 <Box marginTop={1} marginLeft={1}>
299 <Text color={markColorOf('in_progress')} bold>
300 {markOf('in_progress')}{' '}
301 </Text>
302 <Box flexShrink={1}>
303 <Text bold wrap="truncate-end">
304 {view.current.title}
305 </Text>
306 </Box>
307 <Box marginLeft={1}>
308 <Text dimColor>
309 {view.progress.done}/{view.progress.total}
310 </Text>
311 </Box>
312 <Box flexGrow={1} />
313 {renderButton('grow', 'e', labelOf('grow', isNarrow), toList)}
314 </Box>
315 </Box>
316 )
317 }
318
319 const buttons =
320 view.kind === 'list'
321 ? isFinished
322 ? []
323 : [
324 renderButton('focus', 'f', labelOf('focus', isNarrow), toFocus),
325 renderButton('line', 's', labelOf('shrink', isNarrow), toLine),
326 ]
327 : [
328 ...(view.canPrev ? [renderButton('prev', 'p', labelOf('previous', isNarrow), toPrevious)] : []),
329 ...(view.canNext ? [renderButton('next', 'n', labelOf('next', isNarrow), toNext)] : []),
330 renderButton('list', 'e', labelOf('grow', isNarrow), toList),
331 ]
332
333 const rows =
334 view.kind === 'list'
335 ? view.tasks.map(renderTask)
336 : [
337 renderTask(view.shown),
338 view.following === null ? null : (
339 <Text key="following" dimColor wrap="truncate-end">
340 next: {view.following.title}
341 </Text>
342 ),
343 ]
344
345 return (
346 <Box flexDirection="column">
347 {below}
348 <Box marginTop={1} marginLeft={1} flexDirection="column">
349 <Box>
350 <Text bold color={isFinished ? 'green' : undefined}>
351 Tasks
352 </Text>
353 <Box marginLeft={1}>
354 <Text color={isFinished ? 'green' : undefined} dimColor={!isFinished}>
355 {isFinished ? `${view.progress.total}/${view.progress.total} done` : `${view.progress.done}/${view.progress.total}`}
356 </Text>
357 </Box>
358 <Box marginLeft={1}>
359 <Text color={isFinished ? 'green' : 'yellow'}>{'█'.repeat(filled)}</Text>
360 <Text dimColor>{'█'.repeat(rest)}</Text>
361 </Box>
362 </Box>
363 {rows}
364 {buttons.length === 0 ? null : (
365 <Box flexDirection="column">
366 <Text dimColor>{'─'.repeat(ruleWidthOf(e.props.bodyColumns))}</Text>
367 <Box>{buttons}</Box>
368 </Box>
369 )}
370 </Box>
371 </Box>
372 )
373 })
374}
375types/index.d.ts 15 lines1export type TaskStatus = 'pending' | 'in_progress' | 'done'
2export type Task = { id: number; title: string; status: TaskStatus }
3export type BandMode = 'list' | 'line' | 'focus'
4
5declare module 'claude-code' {
6 interface PluginState {
7 'tallyrail': {
8 tasks: Task[]
9 mode: BandMode
10 cursor: number
11 isHidden: boolean
12 }
13 }
14}
15