A thin band above the prompt that shows TASKS.md progress and the next unchecked item.

A Claude Code mod that draws one thin band above the prompt showing how far through your TASKS.md checklist you are, and what is next.
TASKS 4/9 ▓▓▓▓░░░░░ next: Install blast-radius from the playground clone
done/total and a short bar (one cell per item, up to 10 cells; a partly done list never shows as fully empty or full).… to fit the band's width.It counts lines that look like a markdown checkbox, nested or not: - [ ], - [x], - [X], or the same with *, +, or a numbered marker (1. or 1)). Everything else in the file is ignored.
The band draws nothing when the file is missing or holds no checkbox items, and it yields when the engine is showing a survey.
| Hook | What it does |
|---|---|
session.start | Reads the file, so the band shows before the first turn. |
turn.complete | Reads it again after each main-loop turn (subagent turns are skipped). |
tool.call on Edit, Write | When the target path ends in the task file's name, lets the edit run untouched, then reads the file again. |
ui.render on AbovePrompt | Draws the band. |
The counts live in $.state (tasks-band.tasks), so a write redraws the band without a manual invalidate.
Requirements: a Claude Code build with mods (2.1.295 was used), in a terminal or the desktop app. No accounts or environment variables.
Install:
/plugin install tasks-band --marketplace jsnkle/ai-native-sdlc
Answer y to add the marketplace, choose a scope, and set the option below if you want a different file name.
Option: file (string, default TASKS.md) is the checklist to read, relative to the session's working directory.
Try it from a clone, for one session:
claude plugin validate ./mods/tasks-band
claude --plugin-dir ./mods/tasks-band
Test it:
claude plugin test ./mods/tasks-band
The tests run on both the terminal and desktop surfaces.
Edit/Write of the file. A change you make in an editor outside the session shows after the next turn.** and ~~ are stripped, control characters become spaces). A checkbox with no text counts toward the total but is never chosen as "next".AbovePrompt competes for the same slot.| Name | Version | License (SPDX) | Source |
|---|---|---|---|
| None |
None.
hooks/register.tsx 126 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Tasks } from '../types'
5
6const tasks = atom({ plugin: 'tasks-band', key: 'tasks' } as const, null)
7
8const ITEM = /^\s*(?:[-*+]|\d+[.)])\s+\[([ xX])\](?:\s+(.*))?$/
9const BAR_MAX = 10
10
11export const register: Register = (on, options) => {
12 const file = String(options.file || 'TASKS.md')
13
14 on('session.start', async ($, e, next) => {
15 await refresh($, file)
16
17 return next(e)
18 })
19
20 on('turn.complete', async ($, e, next) => {
21 const result = await next(e)
22
23 if (!e.agentId) {
24 await refresh($, file)
25 }
26
27 return result
28 })
29
30 on('tool.call', { tool: /^(Edit|Write)$/ }, async ($, e, next) => {
31 const result = await next(e)
32 const target = 'file_path' in e ? e.file_path : ''
33 const path = String(target).replace(/\\/g, '/')
34
35 if (result.deny === undefined && (path === file || path.endsWith('/' + file))) {
36 await refresh($, file)
37 }
38
39 return result
40 })
41
42 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
43 const counts = await read($, tasks)
44
45 if (e.props.hasSurvey || counts === null) {
46 return next(e)
47 }
48
49 const { Box, Text } = $.ui.resolve(e)
50 const { done, total } = counts
51 const width = Math.min(total, BAR_MAX)
52 let filled = Math.round((done / total) * width)
53 // Never draw 1/100 as empty or 99/100 as full.
54 if (done > 0) filled = Math.max(1, filled)
55 if (done < total) filled = Math.min(width - 1, filled)
56 const head = `TASKS ${done}/${total} `
57 const lead = ' next: '
58 // One column of padding each side, so the budget is two less than the band.
59 const room = e.props.bodyColumns - 2 - head.length - width - lead.length
60 const shown = counts.next === null || room < 2 ? '' : truncate(counts.next, room)
61
62 return (
63 <Box paddingX={1}>
64 <Text bold>{head}</Text>
65 <Text color="success">{'▓'.repeat(filled)}</Text>
66 <Text dimColor>{'░'.repeat(width - filled)}</Text>
67 {shown !== '' && (
68 <Text dimColor wrap="truncate-end">
69 {lead + shown}
70 </Text>
71 )}
72 </Box>
73 )
74 })
75}
76
77async function refresh($: EngineInterface, file: string) {
78 let text = ''
79
80 try {
81 text = await $.fs.read(file)
82 } catch {
83 // No file: the band draws nothing.
84 }
85
86 let done = 0
87 let total = 0
88 let next: string | null = null
89
90 for (const line of text.split(/\r?\n/)) {
91 const item = ITEM.exec(line)
92
93 if (item === null) continue
94
95 total += 1
96
97 if (item[1] === ' ') {
98 const label = clean(item[2] ?? '')
99 if (label !== '') next ??= label
100 } else {
101 done += 1
102 }
103 }
104
105 const value: Tasks | null = total === 0 ? null : { done, total, next }
106 await update($, tasks, () => value)
107}
108
109// Plain, single-line text: markdown marks and control characters would
110// otherwise be drawn as written (or refused by the engine).
111function clean(text: string) {
112 return text
113 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
114 .replace(/`|\*\*|~~/g, '')
115 .replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ')
116 .replace(/\s+/g, ' ')
117 .trim()
118}
119
120// Cut by code point, so a surrogate pair is never split.
121function truncate(text: string, room: number) {
122 const chars = [...text]
123
124 return chars.length > room ? chars.slice(0, room - 1).join('') + '…' : text
125}
126types/index.d.ts 8 lines1export type Tasks = { done: number; total: number; next: string | null }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'tasks-band': { tasks: Tasks | null }
6 }
7}
8