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

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 │
╰──────────────────────────────────────────────────╯ ╰───────────────────────────────────────────────────────────────────────────╯
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.
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.
prompt.compose hook in hooks/register.tsx to keep the box without it.Without a task list the box shows elapsed time and tool calls, never a made-up estimate.
claude --plugin-dir . # run a session with this folder loaded
claude plugin validate .
claude plugin test .hooks/register.tsx 335 lines1import { 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}
335hooks/work.ts 165 lines1import 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}
165types/index.d.ts 32 lines1export 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