A band above the prompt: task checklist, what the agent is editing and why, and a live context meter.

hooks/register.tsx 194 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DashContext, DashNow, DashTask } from '../types'
5
6const tasks = atom({ plugin: 'agent-dashboard', key: 'tasks' } as const, [] as DashTask[])
7const now = atom({ plugin: 'agent-dashboard', key: 'now' } as const, null as DashNow | null)
8const context = atom({ plugin: 'agent-dashboard', key: 'context' } as const, null as DashContext | null)
9
10const MAX_TASK_ROWS = 6
11
12const MAX_WHY = 160
13
14// The agent is asked (CLAUDE.md) to write a "Why: ..." line before each edit. Only such a
15// line counts as a reason, so a stray sentence about something else is never shown as one.
16const WHY_LINE = /^\s*Why:\s*(.+)$/gim
17
18const lastWhyLine = (text: string): string => {
19 const lines = [...text.matchAll(WHY_LINE)]
20 const last = lines[lines.length - 1]
21 const why = last?.[1]?.trim() ?? ''
22 return why.length > MAX_WHY ? why.slice(0, MAX_WHY - 3) + '...' : why
23}
24
25// The last reason shown: a repeat on a later edit is the previous edit's reason, not this one's.
26let lastShownWhy = ''
27
28const shortPath = (path: string): string => path.split('/').slice(-2).join('/')
29
30const tokensLabel = (n: number): string => (n >= 1000 ? Math.round(n / 1000) + 'k' : String(n))
31
32const bar = (percent: number): string => {
33 const filled = Math.max(0, Math.min(10, Math.round(percent / 10)))
34 return '█'.repeat(filled) + '░'.repeat(10 - filled)
35}
36
37// Context meter: refreshed whenever something happens that can change it.
38async function refreshContext($: EngineInterface): Promise<void> {
39 try {
40 const usage = await $.session.usage()
41 const { tokens, window, percent } = usage.context
42 if (tokens === undefined || percent === undefined) return
43 await update($, context, () => ({ percent, tokens, window }))
44 } catch {
45 // The meter is a convenience; never break a turn over it.
46 }
47}
48
49async function whyNow($: EngineInterface): Promise<string> {
50 try {
51 const messages = await $.session.messages()
52 // Only the newest assistant message: an edit with no "Why:" of its own shows the file alone.
53 for (let i = messages.length - 1; i >= 0; i--) {
54 const m = messages[i]
55 if (m.role === 'assistant') return lastWhyLine(m.text)
56 }
57 } catch {
58 // No reason available; the file name alone is still shown.
59 }
60 return ''
61}
62
63export const register: Register = on => {
64 on('session.start', async ($, e, next) => {
65 await refreshContext($)
66 return next(e)
67 })
68
69 on('prompt.submit', async ($, e, next) => {
70 // A new request: drop a finished checklist, keep one still in progress.
71 await update($, tasks, list => (list.every(t => t.status === 'completed') ? [] : list))
72 await update($, now, () => null)
73 return next(e)
74 })
75
76 // ---- Checklist -----------------------------------------------------------
77
78 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
79 const ran = await next(e)
80 if (ran.deny === undefined && ran.isError !== true) {
81 await update($, tasks, () =>
82 e.todos.map((t, i) => ({ id: String(i), label: t.content, status: t.status })),
83 )
84 }
85 return ran
86 })
87
88 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
89 const ran = await next(e)
90 if (ran.deny === undefined && ran.isError !== true) {
91 const id = ran.result?.task?.id ?? String(Date.now())
92 await update($, tasks, list => [...list, { id, label: e.subject, status: 'pending' as const }])
93 }
94 return ran
95 })
96
97 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
98 const ran = await next(e)
99 if (ran.deny === undefined && ran.isError !== true) {
100 await update($, tasks, list =>
101 e.status === 'deleted'
102 ? list.filter(t => t.id !== e.taskId)
103 : list.map(t =>
104 t.id === e.taskId
105 ? { ...t, label: e.subject ?? t.label, status: e.status ?? t.status }
106 : t,
107 ),
108 )
109 }
110 return ran
111 })
112
113 // ---- What is being edited, and why --------------------------------------
114
115 for (const tool of ['Edit', 'Write'] as const) {
116 on('tool.call', { tool }, async ($, e, next) => {
117 const found = await whyNow($)
118 const why = found === lastShownWhy ? '' : found
119 if (why !== '') lastShownWhy = why
120 const file = shortPath(e.file_path)
121 await update($, now, () => ({ file, why, failed: false }))
122 const ran = await next(e)
123 // A refused or failed edit changed nothing: say so instead of leaving it looking in progress.
124 if (ran.deny !== undefined || ran.isError === true) {
125 await update($, now, () => ({ file, why: '', failed: true }))
126 }
127 await refreshContext($)
128 return ran
129 })
130 }
131
132 on('turn.complete', async ($, e, next) => {
133 await refreshContext($)
134 await update($, now, () => null)
135 return next(e)
136 })
137
138 // ---- The band ------------------------------------------------------------
139
140 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
141 const list = await read($, tasks)
142 const current = await read($, now)
143 const ctx = await read($, context)
144
145 if (e.props.hasSurvey || (list.length === 0 && current === null && ctx === null)) {
146 return next(e)
147 }
148
149 const { Box, Text } = $.ui.resolve(e)
150
151 const done = list.filter(t => t.status === 'completed').length
152 const open = list.filter(t => t.status !== 'completed')
153 const recentDone = list.filter(t => t.status === 'completed').slice(-2)
154 const shown = [...recentDone, ...open].slice(0, MAX_TASK_ROWS)
155 const hidden = list.length - shown.length
156
157 const ctxColor = ctx === null ? undefined : ctx.percent >= 80 ? 'red' : ctx.percent >= 60 ? 'yellow' : 'green'
158
159 return (
160 <Box flexDirection="column">
161 {list.length > 0 && (
162 <Text bold>
163 Progress {done}/{list.length}
164 </Text>
165 )}
166 {shown.map(t => (
167 <Text
168 key={t.id}
169 dimColor={t.status !== 'in_progress'}
170 bold={t.status === 'in_progress'}
171 color={t.status === 'in_progress' ? 'cyan' : undefined}
172 >
173 {t.status === 'completed' ? ' ✓ ' : t.status === 'in_progress' ? ' ● ' : ' ○ '}
174 {t.label}
175 </Text>
176 ))}
177 {hidden > 0 && <Text dimColor>{' '}+{hidden} more</Text>}
178 {current !== null && (
179 <Text color={current.failed ? 'red' : undefined}>
180 {current.failed ? '✗ ' : '▸ '}
181 {current.file}
182 {current.failed ? ' — edit failed, the agent will retry' : current.why !== '' ? ' — ' + current.why : ''}
183 </Text>
184 )}
185 {ctx !== null && (
186 <Text color={ctxColor}>
187 Context {bar(ctx.percent)} {Math.round(ctx.percent)}% ({tokensLabel(ctx.tokens)}/{tokensLabel(ctx.window)})
188 </Text>
189 )}
190 </Box>
191 )
192 })
193}
194types/index.d.ts 15 lines1export type TaskStatus = 'pending' | 'in_progress' | 'completed'
2export type DashTask = { id: string; label: string; status: TaskStatus }
3export type DashContext = { percent: number; tokens: number; window: number }
4export type DashNow = { file: string; why: string; failed: boolean }
5
6declare module 'claude-code' {
7 interface PluginState {
8 'agent-dashboard': {
9 tasks: DashTask[]
10 now: DashNow | null
11 context: DashContext | null
12 }
13 }
14}
15