SLOPSHOPPER

tasks-band

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

newbandguard
★ 1v0.1.0MITupdated 2026-10-09jsnkle/ai-native-sdlc/mods/tasks-band
A shopper browsing a rack in a slop shop
README

tasks-band

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.

What this shows

 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).
  • The text of the first unchecked item, cut with … to fit the band's width.
  • When every item is checked, the count and a full bar, with no "next".

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.

HookWhat it does
session.startReads the file, so the band shows before the first turn.
turn.completeReads it again after each main-loop turn (subagent turns are skipped).
tool.call on Edit, WriteWhen the target path ends in the task file's name, lets the edit run untouched, then reads the file again.
ui.render on AbovePromptDraws the band.

The counts live in $.state (tasks-band.tasks), so a write redraws the band without a manual invalidate.

Run it

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.

Notes / limitations

  • The file is read from the session's working directory only, not searched for upward.
  • The band refreshes on session start, after each main-loop turn, and after an Edit/Write of the file. A change you make in an editor outside the session shows after the next turn.
  • Only the first line of an item counts as its text, shown as plain text (links, backticks, ** and ~~ are stripped, control characters become spaces). A checkbox with no text counts toward the total but is never chosen as "next".
  • One band per session: another plugin that draws AbovePrompt competes for the same slot.
  • The band is a single line of text; it has no buttons.

Dependencies

NameVersionLicense (SPDX)Source
None

Third-party notices

None.

Source 2 files
hooks/register.tsx 126 lines
1import { 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}
126
types/index.d.ts 8 lines
1export 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