SLOPSHOPPER

agent-dashboard

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

newbandguardprompt
v0.1.0no licenseupdated 2026-10-09Fred-Edwin/V3-RMS/.claude/mods/agent-dashboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-dashboard
› 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 █████░░░░░ 49% (97k/200k) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Context █████░░░░░ 49% (97k/200k)
Source 2 files
hooks/register.tsx 194 lines
1import { 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}
194
types/index.d.ts 15 lines
1export 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