SLOPSHOPPER

context-band

Keeps the context window, your plan's usage limits and the work in progress visible above the prompt

newbandguardpromptmodeltimer
v0.1.0no licenseupdated 2026-10-07johanandre/context-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-band
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Context ███████████████░░░░░░░░░░░░░░░ 97k / 200k 49% │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Context ███████████████░░░░░░░░░░░░░░░ 97k / 200k 49% │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

context-band

A Claude Code mod that keeps three things visible above the prompt:

╭────────────────────────────────────────────────────────────────────────╮
│ Task  Add a work summary box                                           │
│ Now   Writing the logic                                                │
│ Doing Editing register.tsx                                             │
│ Done  ███████░░░░░░░░░░░░░ 1 of 3 steps · 1m 0s · ~2m 0s left          │
╰────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────╮ ╭───────────────────────────────────────────────────────────────────────────╮
│ Context ████████░░░░░░░░░░░░░░░░░ 54k / 200k 27% │ │ 5h ██░░░░░░░░ 23.5% resets in 2h 10m   week ████░░░░░░ 41% resets in 3d 4h │
╰──────────────────────────────────────────────────╯ ╰───────────────────────────────────────────────────────────────────────────╯
  • Context: how full the context window is, refreshed after every model request.
  • Plan: the five-hour and weekly usage windows of your subscription and when they reset (hidden on an API key, which has none).
  • Task (while Claude works): a short title for your request, the step in progress, the latest tool call, and how many steps are done with an estimate of the time left.

Bars and borders turn yellow from 60% and red from 85%. The boxes sit side by side on a wide terminal and stack on a narrow one.

Install

At the prompt of a Claude Code terminal session:

/plugin install context-band --marketplace johanandre/context-band

Answer y to add the marketplace, then pick the user scope to have it in every session.

What it changes

  • System prompt: it adds one section asking Claude to plan requests of more than two steps with the task list and keep the tasks current, since the estimate comes from that list. Delete the prompt.compose hook in hooks/register.tsx to keep the box without it.
  • Model calls: a prompt longer than 60 characters gets a title from one short Haiku call, which counts toward your usage.

Without a task list the box shows elapsed time and tool calls, never a made-up estimate.

Developing

claude --plugin-dir .          # run a session with this folder loaded
claude plugin validate .
claude plugin test .
Source 3 files
hooks/register.tsx 335 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { UsageReading } from '../types'
5import {
6  applyTaskCall,
7  describe,
8  duration,
9  fallbackTitle,
10  isTaskTool,
11  PLANNING_SECTION_ID,
12  planningInstruction,
13  progress,
14  startWork,
15  summaryPrompt
16} from './work'
17
18const usage = atom({ plugin: 'context-band', key: 'usage' } as const, null)
19const work = atom({ plugin: 'context-band', key: 'work' } as const, null)
20const now = atom({ plugin: 'context-band', key: 'now' } as const, 0)
21
22const WARN_AT = 60
23const DANGER_AT = 85
24
25const LIMIT_LABELS: Record<string, string> = {
26  five_hour: '5h',
27  seven_day: 'week',
28  spend_limit: 'spend'
29}
30
31const kilo = (n: number) =>
32  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}k`
33
34const tone = (percent: number | null) =>
35  percent === null
36    ? 'inactive'
37    : percent >= DANGER_AT
38      ? 'error'
39      : percent >= WARN_AT
40        ? 'warning'
41        : 'success'
42
43// "2h 10m", "3d 4h", "12m": how long until the window resets, from the moment it was read.
44const until = (resetsAt: string | null, readAt: number) => {
45  if (resetsAt === null) return null
46  const minutes = Math.max(0, Math.round((Date.parse(resetsAt) - readAt) / 60_000))
47  if (Number.isNaN(minutes)) return null
48  const days = Math.floor(minutes / 1440)
49  const hours = Math.floor((minutes % 1440) / 60)
50  if (days > 0) return `${days}d ${hours}h`
51  if (hours > 0) return `${hours}h ${minutes % 60}m`
52  return `${minutes}m`
53}
54
55// Never lets a failed reading break the event it rides on.
56const refresh = async ($: EngineInterface) => {
57  const reading = await $.session.usage().catch(() => null)
58  if (reading === null) return
59
60  const { context, rateLimits } = reading
61  const next: UsageReading = {
62    context: {
63      tokens: context.tokens ?? null,
64      window: context.window,
65      percent: context.percent ?? null
66    },
67    limits: rateLimits.map(({ kind, percentUsed, resetsAt }) => ({
68      kind,
69      percentUsed,
70      resetsAt: resetsAt ?? null
71    })),
72    readAt: await $.clock.now()
73  }
74  await update($, usage, () => next)
75}
76
77// Names the request with a short title; until then the box shows the prompt's first line.
78const summarise = async ($: EngineInterface, startedAt: number, prompt: string) => {
79  const reply = await $.model
80    .complete({ model: 'haiku', prompt: summaryPrompt(prompt), maxTokens: 40 })
81    .catch(() => null)
82  const title = reply?.isAnswered ? reply.text.trim().replace(/^["']|["'.]$/g, '') : ''
83  await update($, work, w =>
84    w?.startedAt === startedAt ? { ...w, title: title || fallbackTitle(prompt) } : w
85  )
86}
87
88// Bookkeeping that fails must never hold up the prompt or tool call it rides on.
89const quietly = (work: Promise<unknown>) => work.catch(() => undefined)
90
91// The request whose title is being asked for, so each is asked for once.
92let summarisingFor: number | null = null
93
94// Runs for the whole session: keeps the elapsed time moving and asks for titles,
95// neither of which may hold up the prompt or the tool call that needs them.
96const tick = async ($: EngineInterface) => {
97  const current = await read($, work)
98  if (current === null || !current.isActive) return
99  const at = await $.clock.now()
100  await update($, now, () => at)
101  if (current.title === null && summarisingFor !== current.startedAt) {
102    summarisingFor = current.startedAt
103    await summarise($, current.startedAt, current.prompt)
104  }
105}
106
107export const register: Register = on => {
108  on('session.start', async ($, e, next) => {
109    const result = await next(e)
110    await refresh($)
111    $.clock.every(1000, () => void tick($).catch(() => undefined))
112    return result
113  })
114
115  on('prompt.compose', async ($, e, next) => {
116    const composed = await next(e)
117    const text = planningInstruction(e.tools)
118    if (text === null || composed.sections.some(s => s.id === PLANNING_SECTION_ID)) return composed
119    return {
120      sections: [...composed.sections, { id: PLANNING_SECTION_ID, text, scope: 'session' as const }]
121    }
122  })
123
124  on('prompt.submit', async ($, e, next) => {
125    // A prompt typed into a running turn adds to its request rather than starting one.
126    if (e.turnId === undefined) {
127      const at = await quietly($.clock.now())
128      if (typeof at === 'number') {
129        await quietly(update($, work, w => startWork(w, e.text, at)))
130        await quietly(update($, now, () => at))
131      }
132    }
133    return next(e)
134  }).catch(($, e, next) => next(e))
135
136  on('tool.call', async ($, e, next) => {
137    await quietly(
138      update($, work, w =>
139        w?.isActive
140          ? {
141              ...w,
142              doing: e.agentId === undefined ? describe(e) : `Subagent: ${describe(e)}`,
143              toolCalls: w.toolCalls + 1
144            }
145          : w
146      )
147    )
148    const ran = await next(e)
149    if (e.agentId === undefined && isTaskTool(e.tool) && ran.deny === undefined && !ran.isError) {
150      await quietly(
151        update($, work, w =>
152          w === null ? w : { ...w, steps: applyTaskCall(w.steps, e, ran.result) }
153        )
154      )
155    }
156    return ran
157  }).catch(($, e, next) => next(e))
158
159  on('turn.step', async function* ($, e, next) {
160    const result = yield* next(e)
161    await refresh($)
162    return result
163  })
164
165  on('turn.complete', async ($, e, next) => {
166    const result = await next(e)
167    await refresh($)
168    if (e.agentId === undefined) {
169      await update($, work, w => (w === null ? w : { ...w, isActive: false }))
170    }
171    return result
172  })
173
174  on('session.compact', async ($, e, next) => {
175    const result = await next(e)
176    await refresh($)
177    return result
178  }).catch(($, e, next) => next(e))
179
180  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
181    const reading = await read($, usage)
182    const current = await read($, work)
183    const isWorking = current?.isActive === true
184
185    if (e.props.hasSurvey || (reading === null && !isWorking)) {
186      return next(e)
187    }
188
189    const { Box, Text } = $.ui.resolve(e)
190    const columns = e.props.bodyColumns
191
192    const bar = (percent: number | null, cells: number, color = tone(percent)) => {
193      const filled = percent === null ? 0 : Math.round((Math.min(percent, 100) / 100) * cells)
194      return [
195        <Text color={color}>{'█'.repeat(filled)}</Text>,
196        <Text color="subtle">{'░'.repeat(cells - filled)}</Text>
197      ]
198    }
199
200    // A box's border stays quiet until what it holds crosses a threshold.
201    const frame = (percent: number | null) =>
202      percent !== null && percent >= WARN_AT
203        ? { borderStyle: 'round', borderColor: tone(percent) }
204        : { borderStyle: 'round', borderColor: 'subtle' }
205
206    let workTree = null
207    if (current !== null && isWorking) {
208      const clock = Math.max(await read($, now), current.startedAt)
209      const elapsed = duration(clock - current.startedAt)
210      const { done, total, current: step, remaining } = progress(current, clock)
211      const row = (label: string, value: string, dim = false) => (
212        <Box>
213          <Text dimColor>{label.padEnd(6)}</Text>
214          <Text dimColor={dim} wrap="truncate-end">
215            {value}
216          </Text>
217        </Box>
218      )
219
220      workTree = (
221        <Box borderStyle="round" borderColor="claude" flexDirection="column" paddingX={1}>
222          <Box>
223            <Text dimColor>{'Task'.padEnd(6)}</Text>
224            <Text bold wrap="truncate-end">
225              {current.title ?? fallbackTitle(current.prompt)}
226            </Text>
227          </Box>
228          {step !== null && row('Now', step.activeForm ?? step.subject)}
229          {current.doing !== null &&
230            row(step === null ? 'Now' : 'Doing', current.doing, step !== null)}
231          {total > 0 ? (
232            <Box>
233              <Text dimColor>{'Done'.padEnd(6)}</Text>
234              {bar((done / total) * 100, Math.max(10, Math.min(20, columns - 50)), 'claude')}
235              <Text>
236                {' '}
237                {done} of {total} steps · {elapsed}
238              </Text>
239              <Text dimColor>
240                {done === total
241                  ? ' · wrapping up'
242                  : remaining === null
243                    ? ' · estimating…'
244                    : ` · ~${duration(remaining)} left`}
245              </Text>
246            </Box>
247          ) : (
248            row(
249              'Time',
250              `${elapsed} · ${current.toolCalls} tool calls · no plan to estimate from`,
251              true
252            )
253          )}
254        </Box>
255      )
256    }
257
258    if (reading === null) {
259      return workTree ?? next(e)
260    }
261
262    const { context, limits, readAt } = reading
263    const windows = limits.map(limit => ({
264      ...limit,
265      label: LIMIT_LABELS[limit.kind] ?? limit.kind,
266      resets: until(limit.resetsAt, readAt)
267    }))
268    const LIMIT_BAR = 10
269    const GAP = 3
270    // Border and padding take four cells of each box.
271    const planWidth =
272      windows.reduce(
273        (sum, w) =>
274          sum +
275          `${w.label} `.length +
276          LIMIT_BAR +
277          ` ${w.percentUsed}%`.length +
278          (w.resets === null ? 0 : ` resets in ${w.resets}`.length),
279        0
280      ) +
281      GAP * Math.max(0, windows.length - 1) +
282      4
283    const contextChrome = 'Context '.length + ' 999k / 999k 100%'.length + 4
284    const isSideBySide = windows.length > 0 && columns >= planWidth + 1 + contextChrome + 10
285    const contextBar = Math.max(
286      10,
287      Math.min(30, columns - contextChrome - (isSideBySide ? planWidth + 1 : 0))
288    )
289    const worstLimit = windows.reduce<number | null>(
290      (worst, w) => (worst === null || w.percentUsed > worst ? w.percentUsed : worst),
291      null
292    )
293
294    const usageTree = (
295      <Box flexDirection={isSideBySide ? 'row' : 'column'} columnGap={1}>
296        <Box {...frame(context.percent)} paddingX={1} flexShrink={0}>
297          <Text dimColor>Context </Text>
298          {bar(context.percent, contextBar)}
299          <Text>
300            {' '}
301            {context.tokens === null ? '—' : kilo(context.tokens)} / {kilo(context.window)}{' '}
302          </Text>
303          <Text color={tone(context.percent)} bold>
304            {context.percent === null ? '' : `${context.percent}%`}
305          </Text>
306        </Box>
307        {windows.length > 0 && (
308          <Box {...frame(worstLimit)} paddingX={1} columnGap={GAP} flexShrink={0}>
309            {windows.map(w => (
310              <Box>
311                <Text dimColor>{w.label} </Text>
312                {bar(w.percentUsed, LIMIT_BAR)}
313                <Text color={tone(w.percentUsed)} bold>
314                  {' '}
315                  {w.percentUsed}%
316                </Text>
317                {w.resets !== null && <Text dimColor> resets in {w.resets}</Text>}
318              </Box>
319            ))}
320          </Box>
321        )}
322      </Box>
323    )
324
325    return workTree === null ? (
326      usageTree
327    ) : (
328      <Box flexDirection="column">
329        {workTree}
330        {usageTree}
331      </Box>
332    )
333  })
334}
335
hooks/work.ts 165 lines
1import type { ToolCallInput } from 'claude-code'
2
3import type { Step, Work } from '../types'
4
5const basename = (path: string) => path.split('/').pop() ?? path
6
7const clip = (text: string, length: number) =>
8  text.length > length ? `${text.slice(0, length - 1)}…` : text
9
10/** What a tool call is doing, as a person would say it. */
11export const describe = (e: ToolCallInput): string => {
12  switch (e.tool) {
13    case 'Bash':
14      return e.description ? clip(e.description, 80) : `Running ${clip(e.command, 60)}`
15    case 'Read':
16      return `Reading ${basename(e.file_path)}`
17    case 'Edit':
18      return `Editing ${basename(e.file_path)}`
19    case 'Write':
20      return `Writing ${basename(e.file_path)}`
21    case 'NotebookEdit':
22      return `Editing ${basename(e.notebook_path)}`
23    case 'Agent':
24      return `Delegating: ${clip(e.description, 60)}`
25    case 'Skill':
26      return `Loading the ${e.skill} skill`
27    case 'WebSearch':
28      return `Searching the web for ${clip(e.query, 50)}`
29    case 'WebFetch':
30      return `Fetching ${clip(e.url, 60)}`
31    case 'ToolSearch':
32      return 'Looking up tools'
33    case 'AskUserQuestion':
34      return 'Waiting for your answer'
35    case 'TaskCreate':
36    case 'TaskUpdate':
37    case 'TodoWrite':
38      return 'Planning'
39    default:
40      return e.tool.startsWith('mcp__') ? `Using ${e.tool.split('__').slice(1).join(' ')}` : e.tool
41  }
42}
43
44/** A prompt short enough to be its own title needs no summary. */
45export const fallbackTitle = (prompt: string) => {
46  const firstLine = prompt.trim().split('\n')[0] ?? ''
47  return clip(firstLine, 80)
48}
49
50export const needsSummary = (prompt: string) => prompt.trim().length > 60 || prompt.includes('\n')
51
52export const summaryPrompt = (prompt: string) =>
53  [
54    'Write a title of at most 8 words for this request to a coding assistant.',
55    "Write it in the request's own language. Reply with the title only: no quotes, no full stop.",
56    '',
57    '<request>',
58    prompt.slice(0, 4000),
59    '</request>'
60  ].join('\n')
61
62export const startWork = (previous: Work | null, prompt: string, now: number): Work => {
63  // A task list left unfinished carries over: the new request most likely continues it.
64  const steps = previous?.steps.some(s => s.status !== 'completed') ? previous.steps : []
65  return {
66    prompt,
67    title: needsSummary(prompt) ? null : fallbackTitle(prompt),
68    startedAt: now,
69    isActive: true,
70    steps,
71    doneAtStart: steps.filter(s => s.status === 'completed').length,
72    doing: null,
73    toolCalls: 0
74  }
75}
76
77type TaskResult = { task?: { id?: string } }
78
79/** Folds a finished task-list tool call into the steps. */
80export const applyTaskCall = (steps: Step[], e: ToolCallInput, result: unknown): Step[] => {
81  switch (e.tool) {
82    case 'TaskCreate': {
83      const id = (result as TaskResult | undefined)?.task?.id
84      if (id === undefined) return steps
85      return [
86        ...steps,
87        { id, subject: e.subject, activeForm: e.activeForm ?? null, status: 'pending' }
88      ]
89    }
90    case 'TaskUpdate': {
91      if (e.status === 'deleted') return steps.filter(s => s.id !== e.taskId)
92      const status = e.status
93      return steps.map(s =>
94        s.id === e.taskId
95          ? {
96              ...s,
97              subject: e.subject ?? s.subject,
98              activeForm: e.activeForm ?? s.activeForm,
99              status: status ?? s.status
100            }
101          : s
102      )
103    }
104    case 'TodoWrite':
105      return e.todos.map((todo, i) => ({
106        id: `todo-${i}`,
107        subject: todo.content,
108        activeForm: todo.activeForm,
109        status: todo.status
110      }))
111    default:
112      return steps
113  }
114}
115
116export const isTaskTool = (tool: string) =>
117  tool === 'TaskCreate' || tool === 'TaskUpdate' || tool === 'TodoWrite'
118
119/** "45s", "2m 10s", "1h 5m". */
120export const duration = (ms: number) => {
121  const seconds = Math.max(0, Math.round(ms / 1000))
122  const h = Math.floor(seconds / 3600)
123  const m = Math.floor((seconds % 3600) / 60)
124  if (h > 0) return `${h}h ${m}m`
125  if (m > 0) return `${m}m ${seconds % 60}s`
126  return `${seconds}s`
127}
128
129export type Progress = {
130  done: number
131  total: number
132  current: Step | null
133  /** Milliseconds left at the pace so far; null until a step of this request is done. */
134  remaining: number | null
135}
136
137export const progress = (work: Work, now: number): Progress => {
138  const total = work.steps.length
139  const done = work.steps.filter(s => s.status === 'completed').length
140  const current = work.steps.find(s => s.status === 'in_progress') ?? null
141  const doneHere = done - work.doneAtStart
142  const remaining =
143    doneHere > 0 && total > done ? ((now - work.startedAt) / doneHere) * (total - done) : null
144  return { done, total, current, remaining }
145}
146
147export const PLANNING_SECTION_ID = 'context-band:plan-with-tasks'
148
149/** The system prompt's instruction to plan with the task list, for whichever task tool the session has. */
150export const planningInstruction = (tools: readonly string[]) => {
151  const tool = tools.includes('TaskCreate')
152    ? 'TaskCreate and TaskUpdate'
153    : tools.includes('TodoWrite')
154      ? 'TodoWrite'
155      : null
156  if (tool === null) return null
157  return [
158    '# Planning with the task list',
159    `When a request takes more than two steps, break it into tasks with ${tool} before starting the work, each with a short activeForm.`,
160    'Mark each task in_progress as you start it and completed as soon as it is done, one at a time.',
161    'The person watches these tasks to see what is being worked on and how long is left, so keep them current.',
162    'Skip the task list for questions and for requests that take one or two steps.'
163  ].join('\n')
164}
165
types/index.d.ts 32 lines
1export type ContextFill = { tokens: number | null; window: number; percent: number | null }
2
3export type LimitWindow = { kind: string; percentUsed: number; resetsAt: string | null }
4
5export type UsageReading = { context: ContextFill; limits: LimitWindow[]; readAt: number }
6
7export type StepStatus = 'pending' | 'in_progress' | 'completed'
8
9/** One entry of the model's task list (TaskCreate/TaskUpdate or TodoWrite). */
10export type Step = { id: string; subject: string; activeForm: string | null; status: StepStatus }
11
12/** The request being worked on, from the prompt that started it until the turn ends. */
13export type Work = {
14  prompt: string
15  /** A short title for the request; null until the summary has come back. */
16  title: string | null
17  startedAt: number
18  isActive: boolean
19  steps: Step[]
20  /** Steps already completed when this request started, so the estimate counts only its own. */
21  doneAtStart: number
22  /** The latest tool call, described for a person. */
23  doing: string | null
24  toolCalls: number
25}
26
27declare module 'claude-code' {
28  interface PluginState {
29    'context-band': { usage: UsageReading | null; work: Work | null; now: number }
30  }
31}
32