SLOPSHOPPER

mod-monitor

Subscription limit meters above the prompt, plus a live 'what's happening' feed and a short AI summary of each turn.

panebandguardcommandpromptmodel
v0.1.0no licenseupdated 2026-10-19Andrew-MM-Net/claude-code-mod-monitor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-monitor
│ ┃ Monitor ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ TASK │ ┃ fix the failing auth test and add an audit ⏺ Read(src/auth.ts) │ ┃ log call ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ✦ SUMMARY ⎿ Added 2 lines, removed 1 line │ ┃ Appears after the first turn finishes. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ▶ ACTIVITY · 9 steps │ ┃ ✓ Reading auth.ts ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ✓ Searching code for "refresh\(" │ ┃ ✓ Editing auth.ts ✻ Worked for 42s · done 4:20 PM │ ┃ ✓ Writing audit.ts │ ┃ ✓ Writing cache.ts › /monitor │ ┃ ✗ Running: Run the test suite ⎿ mod-monitor: Monitor pane opened. │ ┃ ✓ Running: Show working tree status │ ┃ ✓ Running: Clean build output and push │ ┃ ✓ Running: Show environment file │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ╭─ monitor ──────────────────────────────────────────────────────────────────────────── opus-5-5 ──╮ │ 5h ███▊████████ 31% ● NaNm │ ctx ███▉████ 49% 97k/200k │ $0.42 ≈$0.84/h │ ◷ 31m │ ✓8 ✗1 │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭─ monitor ──────────────────────────────────────────────────────────────────────────── opus-5-5 ──╮ │ 5h ███▊████████ 31% ● NaNm │ ctx ███▉████ 49% 97k/200k │ $0.42 ≈$0.84/h │ ◷ 31m │ ✓8 ✗1 │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Pane · Monitor
◆ TASK fix the failing auth test and add an audit log call ✦ SUMMARY Appears after the first turn finishes. ▶ ACTIVITY · 9 steps ✓ Reading auth.ts ✓ Searching code for "refresh\(" ✓ Editing auth.ts ✓ Writing audit.ts ✓ Writing cache.ts ✗ Running: Run the test suite ✓ Running: Show working tree status ✓ Running: Clean build output and push ✓ Running: Show environment file
README

mod-monitor

A Claude Code mod that keeps an eye on your session: subscription limit meters, context use and cost in a card above the prompt, plus a Monitor pane with the current task, a short AI summary of each turn and a live log of every tool call.

What it shows

The card above the prompt

╭─ monitor ───────────────────────────────────────────────────────────── opus-5-5 ──╮
│ 5h ██████┃▌░░░  52% ● 2h13m │ 7d ███┃░░░░░░  28% ▼ 3d4h │ ctx ██░░ 31% │ $1.84 │   │
├────────────────────────────────────────────────────────────────────────────────────┤
│ ● Running: Run tests  1m05s                                                        │
╰────────────────────────────────────────────────────────────────────────────────────╯
  • Limit meters for the 5-hour and 7-day windows (and a spend limit, if you have one), coloured green / amber / red at 70% and 90%, with a countdown to each reset.
  • Pace marker: the ┃ tick on each meter is where an even burn through the window would put you right now. The glyph after the percent says whether you're ahead of it (▲), on track (●) or behind it (▼).
  • Context: how full the context window is, as a percent and in tokens.
  • Cost: the session's cost so far and, after five minutes, an hourly rate.
  • Session time, and a ✓ / ✗ tally of the tool calls in the current turn.
  • 5h forecast: when the 5-hour limit is climbing, roughly when it will be full at the current pace (only shown if that's before it resets).
  • Status line: what Claude is doing right now while it works, and the AI summary of the last turn once it's done.
  • The current model in the top edge.

The card adapts to the terminal width, dropping the less important stats first.

The Monitor pane

  • TASK: the last thing you asked.
  • SUMMARY: a one- or two-sentence summary of what was just done, written by Haiku after each turn.
  • ACTIVITY: every tool call in plain language ("Reading register.tsx", "Searching the web: …", "Using Gmail: search threads"), marked running, done or failed. Calls made by helper agents are indented with ↳.

Commands

CommandWhat it does
/monitorOpens the Monitor pane.
/monitor offTurns the AI turn summary off.
/monitor onTurns it back on.

The pane opens on its own when a session starts.

A note on the turn summary

After each turn, the mod sends a short request to Haiku containing your prompt (cut to 400 characters), the list of tool-call descriptions from the turn, and the start of Claude's reply (cut to 1,500 characters). It uses your normal Claude Code account, so it counts toward your usage like any other request, though it's small (at most 120 output tokens). Use /monitor off if you'd rather not have it. The prompt it sends is in hooks/lib.ts.

Nothing else leaves your machine, and the mod doesn't write any files.

Install globally

The mod is loaded from a folder on disk through the CLAUDE_CODE_PLUGIN_DIRS setting, so it's available in every project.

  1. Clone the repo somewhere permanent. Your Claude config folder is a good place:

Windows (PowerShell)

   git clone https://github.com/Andrew-MM-Net/claude-code-mod-monitor.git "$HOME\.claude\mods\mod-monitor"

macOS / Linux

   git clone https://github.com/Andrew-MM-Net/claude-code-mod-monitor.git ~/.claude/mods/mod-monitor
  1. Open your user settings file, ~/.claude/settings.json (on Windows, C:\Users\<you>\.claude\settings.json), and add the folder to env. Use the full path; on Windows, double the backslashes:
   {
     "env": {
       "CLAUDE_CODE_PLUGIN_DIRS": "C:\\Users\\<you>\\.claude\\mods\\mod-monitor"
     }
   }

On macOS / Linux:

   {
     "env": {
       "CLAUDE_CODE_PLUGIN_DIRS": "/home/<you>/.claude/mods/mod-monitor"
     }
   }

If you already have an env block, add the line to it rather than creating a second one. If CLAUDE_CODE_PLUGIN_DIRS already lists another mod, add this folder to the list.

  1. Restart Claude Code. The card appears above the prompt and the Monitor pane opens.

Updating

cd ~/.claude/mods/mod-monitor
git pull

Then restart Claude Code.

Uninstalling

Remove the folder from CLAUDE_CODE_PLUGIN_DIRS in settings.json, restart Claude Code, and delete the folder.

Development

hooks/hooks.json     lists the hook module to load
hooks/register.tsx   the hooks: the /monitor command, the card, the pane and the turn summary
hooks/lib.ts         pure helpers (meters, countdowns, tool-call descriptions, the summary prompt)
types/index.d.ts     the mod's state types
tests/monitor.test.ts tests

From the repo folder:

claude plugin validate .
claude plugin test .

.claude-plugin/types/ holds generated Claude Code type definitions for editor support. It's ignored by git.

Source 3 files
hooks/register.tsx 366 lines
1import { atom, read, update } from 'claude-code'
2import type { Color, Register, SessionMeasureInput, SessionUsage } from 'claude-code'
3
4import type { Activity, Meter } from '../types'
5import {
6  bar,
7  clip,
8  countdown,
9  describe,
10  elapsed,
11  expectedPercent,
12  fill,
13  forecast,
14  levelColor,
15  limitLabel,
16  modelLabel,
17  paceOf,
18  rule,
19  SUMMARY_SYSTEM,
20  summaryPrompt,
21  tokens,
22} from './lib'
23import type { Pace, Sample } from './lib'
24
25const PANE = 'mod-monitor'
26// The card's edges, and the darker groove the meters fill along.
27const EDGE = '#4a5263'
28const TRACK = '#333a47'
29
30// One styled run of a card row; its length is its width in cells.
31type Seg = { text: string; color?: Color; dim?: boolean; bold?: boolean }
32
33// The glyph after a limit's percent: ahead of, on, or behind an even burn through its window.
34const PACE: Record<Pace, { glyph: string; color: Color }> = {
35  over: { glyph: '▲', color: 'error' },
36  on: { glyph: '●', color: 'success' },
37  under: { glyph: '▼', color: 'suggestion' },
38}
39
40// A meter's runs, with a tick at `mark` percent: where an even burn would be now.
41const meterSegs = (percent: number, width: number, color: Color, mark: number | null): Seg[] => {
42  const { fill: done, track } = bar(percent, width)
43  if (mark === null) return [{ text: done, color }, { text: track, color: TRACK }]
44  const cells = [...done, ...track].map(c => ({ text: c, color }) as Seg)
45  cells.forEach((c, i) => i >= done.length && (c.color = TRACK))
46  cells[Math.min(width - 1, Math.floor((mark / 100) * width))] = { text: '┃', color: 'text', bold: true }
47  return cells.reduce<Seg[]>((runs, c) => {
48    const last = runs[runs.length - 1]
49    if (last && last.color === c.color && last.bold === c.bold) last.text += c.text
50    else runs.push({ ...c })
51    return runs
52  }, [])
53}
54
55const meter = atom({ plugin: 'mod-monitor', key: 'meter' } as const, null)
56const now = atom({ plugin: 'mod-monitor', key: 'now' } as const, null)
57const feed = atom({ plugin: 'mod-monitor', key: 'feed' } as const, [])
58const goal = atom({ plugin: 'mod-monitor', key: 'goal' } as const, null)
59const summary = atom({ plugin: 'mod-monitor', key: 'summary' } as const, null)
60const isSummarizing = atom({ plugin: 'mod-monitor', key: 'isSummarizing' } as const, false)
61const isSummaryOn = atom({ plugin: 'mod-monitor', key: 'isSummaryOn' } as const, true)
62const tick = atom({ plugin: 'mod-monitor', key: 'tick' } as const, 0)
63
64const toMeter = (u: SessionUsage | SessionMeasureInput): Meter => ({
65  limits: u.rateLimits.map(l => ({ kind: l.kind, percent: l.percentUsed, resetsAt: l.resetsAt })),
66  contextPercent: u.context.percent,
67  contextTokens: u.context.tokens,
68  contextWindow: u.context.window,
69  costUsd: u.cost?.usd,
70})
71
72export const register: Register = on => {
73  let turnStart = 0
74  let startedAt = 0
75  // Recent 5h readings, for the "full in" forecast.
76  let fiveHour: Sample[] = []
77  const sample = (m: Meter, at: number) => {
78    const percent = m.limits.find(l => l.kind === 'five_hour')?.percent
79    if (percent === undefined) return
80    // A drop means the window reset: the old pace no longer applies.
81    const last = fiveHour[fiveHour.length - 1]
82    if (last && percent < last.percent) fiveHour = []
83    fiveHour = [...fiveHour.filter(s => at - s.at <= 60 * 60_000), { at, percent }]
84  }
85
86  on('session.start', async ($, e, next) => {
87    await $.command.register({
88      name: 'monitor',
89      description: 'Open the monitor pane. "/monitor off" or "/monitor on" turns the AI turn summary off or on.',
90    })
91    void $.session.usage().then(async u => {
92      startedAt = u.startedAt
93      sample(toMeter(u), await $.clock.now())
94      await update($, meter, () => toMeter(u))
95    })
96    // Redraw once a minute so the reset countdowns move.
97    $.clock.every(60_000, () => {
98      void update($, tick, n => n + 1)
99    })
100    void $.ui.open({ id: PANE, title: 'Monitor' })
101
102    return next(e)
103  })
104
105  on('command.run', { command: 'monitor' }, async ($, e) => {
106    const arg = e.args.trim().toLowerCase()
107    if (arg === 'off' || arg === 'on') {
108      await update($, isSummaryOn, () => arg === 'on')
109      return { text: `AI turn summaries ${arg === 'on' ? 'on' : 'off'}.` }
110    }
111    await $.ui.open({ id: PANE, title: 'Monitor' })
112    return { text: 'Monitor pane opened.' }
113  })
114
115  on('session.measure', async ($, e, next) => {
116    sample(toMeter(e), await $.clock.now())
117    await update($, meter, () => toMeter(e))
118    return next(e)
119  })
120
121  on('prompt.submit', async ($, e, next) => {
122    if (e.turnId === undefined) {
123      turnStart = await $.clock.now()
124      await update($, goal, () => clip(e.text, 200))
125    }
126    return next(e)
127  })
128
129  on('tool.call', async ($, e, next) => {
130    const args = e as unknown as Record<string, unknown>
131    const text = (e.agentId ? '↳ ' : '') + describe(String(e.tool), args)
132    const item: Activity = { id: e.tool_use_id, at: await $.clock.now(), text, state: 'running' }
133    if (!e.agentId) await update($, now, () => text)
134    await update($, feed, list => [...list, item].slice(-60))
135
136    const ran = await next(e)
137
138    const state: Activity['state'] = ran.deny !== undefined || ran.isError ? 'error' : 'done'
139    await update($, feed, list => list.map(a => (a.id === item.id ? { ...a, state } : a)))
140    return ran
141  })
142
143  on('turn.complete', async ($, e, next) => {
144    if (!e.agentId) {
145      await update($, now, () => null)
146      const answer = e.answer
147      // Summarize off the turn's own dispatch, so the turn ends right away.
148      $.clock.after(0, () => {
149        void (async () => {
150          if (!(await read($, isSummaryOn))) return
151          const steps = (await read($, feed)).filter(a => a.at >= turnStart).map(a => a.text).slice(-20)
152          await update($, isSummarizing, () => true)
153          try {
154            const reply = await $.model.complete({
155              model: 'haiku',
156              system: SUMMARY_SYSTEM,
157              prompt: summaryPrompt(await read($, goal), steps, answer),
158              maxTokens: 120,
159              effort: 'low',
160            })
161            if (reply.isAnswered && reply.text.trim()) {
162              await update($, summary, () => clip(reply.text, 300))
163            }
164          } finally {
165            // A failed call must not leave the pane saying "updating…" forever.
166            await update($, isSummarizing, () => false)
167          }
168        })()
169      })
170    }
171    return next(e)
172  })
173
174  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
175    if (e.props.hasSurvey) return next(e)
176    const { Box, Text } = $.ui.resolve(e)
177    await read($, tick)
178    const m = await read($, meter)
179    const doing = await read($, now)
180    const said = await read($, summary)
181    const model = modelLabel(await $.session.model())
182    const clock = await $.clock.now()
183
184    // A rounded card drawn by hand, so the title and model can sit in its top edge.
185    const cols = e.props.bodyColumns
186    const inner = cols - 2
187    const limits = m?.limits ?? []
188    const SEP: Seg = { text: ' │', color: EDGE }
189    const widthOf = (segs: Seg[]) => segs.reduce((n, s) => n + s.text.length, 0)
190
191    const limitSegs = (w: number): Seg[] =>
192      limits.length === 0
193        ? [{ text: ' limits: waiting for first reply', dim: true }, SEP]
194        : limits.flatMap(l => {
195            const resetsIn = l.resetsAt ? Date.parse(l.resetsAt) - clock : undefined
196            const resets = resetsIn !== undefined ? countdown(resetsIn) : ''
197            const expected = expectedPercent(l.kind, resetsIn)
198            const pace = expected === null ? null : PACE[paceOf(l.percent, expected)]
199            return [
200              { text: ` ${limitLabel(l.kind)} `, dim: true },
201              ...meterSegs(l.percent, w, levelColor(l.percent), expected),
202              { text: ` ${String(Math.round(l.percent)).padStart(3)}%`, color: levelColor(l.percent), bold: true },
203              ...(pace ? [{ text: ` ${pace.glyph}`, color: pace.color, bold: true }] : []),
204              { text: ` ${resets.padEnd(6)}`, dim: true },
205              SEP,
206            ]
207          })
208
209    const pct = m?.contextPercent
210    const ctxPlain: Seg[] =
211      pct === undefined ? [] : [{ text: ' ctx ', dim: true }, { text: `${pct}%`, color: levelColor(pct), bold: true }, SEP]
212    // On a wide band: a mini meter and the token count, lavender until it nears full.
213    const ctxColor: Color = pct !== undefined && pct >= 70 ? levelColor(pct) : 'permission'
214    const ctxRich: Seg[] =
215      pct === undefined || m?.contextTokens === undefined || !m.contextWindow
216        ? ctxPlain
217        : [
218            { text: ' ctx ', dim: true },
219            { text: bar(pct, 8).fill, color: ctxColor },
220            { text: bar(pct, 8).track, color: TRACK },
221            { text: ` ${pct}%`, color: ctxColor, bold: true },
222            { text: ` ${tokens(m.contextTokens)}/${tokens(m.contextWindow)}`, dim: true },
223            SEP,
224          ]
225
226    const usd = m?.costUsd
227    const ran = startedAt > 0 ? clock - startedAt : 0
228    const costPlain: Seg[] = usd === undefined ? [] : [{ text: ` $${usd.toFixed(2)}` }, SEP]
229    const costRich: Seg[] =
230      usd === undefined
231        ? []
232        : [
233            { text: ` $${usd.toFixed(2)}`, bold: true },
234            ...(ran >= 5 * 60_000 ? [{ text: ` ≈$${(usd / (ran / 3_600_000)).toFixed(2)}/h`, dim: true }] : []),
235            SEP,
236          ]
237    const session: Seg[] = ran > 0 ? [{ text: ' ◷ ', dim: true }, { text: countdown(ran) }, SEP] : []
238
239    const steps = turnStart > 0 ? (await read($, feed)).filter(a => a.at >= turnStart) : []
240    const failed = steps.filter(a => a.state === 'error').length
241    const tally: Seg[] = steps.length
242      ? [
243          { text: ' ✓', color: 'success' },
244          { text: `${steps.length - failed} `, color: 'success', bold: true },
245          { text: '✗', color: failed ? 'error' : undefined, dim: !failed },
246          { text: `${failed}`, color: failed ? 'error' : undefined, dim: !failed, bold: failed > 0 },
247          SEP,
248        ]
249      : []
250
251    const five = limits.find(l => l.kind === 'five_hour')
252    const eta = five ? forecast(fiveHour, clock, five.resetsAt ? Date.parse(five.resetsAt) - clock : undefined) : null
253    const pace: Seg[] =
254      eta === null
255        ? []
256        : [
257            { text: ' 5h full in ', dim: true },
258            { text: eta === 0 ? 'now' : `~${countdown(eta)}`, color: 'warning', bold: true },
259            { text: ' at this pace', dim: true },
260            SEP,
261          ]
262
263    // The richest row that fits; a narrow band falls back to the plain one with shrinking meters.
264    const wide = limitSegs(12)
265    const stats = [
266      [...wide, ...ctxRich, ...costRich, ...session, ...tally, ...pace],
267      [...wide, ...ctxRich, ...costRich, ...session, ...tally],
268      [...wide, ...ctxRich, ...costRich, ...session],
269      [...wide, ...ctxRich, ...costPlain],
270    ].find(segs => widthOf(segs) <= inner) ?? [
271      ...limitSegs(Math.max(4, Math.min(12, Math.floor((inner - 20 * limits.length - 20) / Math.max(1, limits.length))))),
272      ...ctxPlain,
273      ...costPlain,
274    ]
275
276    const tail = turnStart > 0 ? `  ${elapsed(clock - turnStart)}` : ''
277    const status: Seg[] | null =
278      e.props.isWorking && doing
279        ? [
280            { text: ' ● ', color: 'claude' },
281            { text: clip(doing, inner - 4 - tail.length) },
282            { text: tail, dim: true },
283          ]
284        : !e.props.isWorking && said
285          ? [
286              { text: ' ✓ ', color: 'success', bold: true },
287              { text: clip(said, inner - 4), dim: true },
288            ]
289          : null
290
291    const row = (segs: Seg[]) => (
292      <Text wrap="truncate-end">
293        <Text color={EDGE}>│</Text>
294        {segs.map(s => (
295          <Text color={s.color} dimColor={s.dim} bold={s.bold}>
296            {s.text}
297          </Text>
298        ))}
299        <Text>{fill(widthOf(segs), inner)}</Text>
300        <Text color={EDGE}>│</Text>
301      </Text>
302    )
303
304    return (
305      <Box flexDirection="column" width="100%">
306        <Text wrap="truncate-end">
307          <Text color={EDGE}>╭─</Text>
308          <Text color="claude" bold>
309            {' monitor '}
310          </Text>
311          <Text color={EDGE}>{rule('─', cols - 16 - model.length)}</Text>
312          <Text> </Text>
313          <Text color="success" bold>{model}</Text>
314          <Text> </Text>
315          <Text color={EDGE}>──╮</Text>
316        </Text>
317        {row(stats)}
318        {status && <Text color={EDGE}>├{rule('─', inner)}┤</Text>}
319        {status && row(status)}
320        <Text color={EDGE}>╰{rule('─', inner)}╯</Text>
321      </Box>
322    )
323  })
324
325  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
326    const { Box, Text } = $.ui.resolve(e)
327    const asked = await read($, goal)
328    const said = await read($, summary)
329    const busy = await read($, isSummarizing)
330    const isOn = await read($, isSummaryOn)
331    const list = await read($, feed)
332    const room = Math.max(3, e.props.scroll.bodyRows - 9)
333    const heading = (icon: string, label: string, color: Color, note?: string) => (
334      <Text wrap="truncate-end">
335        <Text color={color} bold>
336          {icon} {label}
337        </Text>
338        {note && <Text dimColor> · {note}</Text>}
339      </Text>
340    )
341
342    return (
343      <Box flexDirection="column" backgroundColor="black" width="100%" minHeight={e.props.scroll.bodyRows}>
344        {heading('◆', 'TASK', 'suggestion')}
345        <Text wrap="wrap">{asked ?? 'Nothing asked yet.'}</Text>
346        <Text> </Text>
347        {heading('✦', 'SUMMARY', 'permission', busy ? 'updating…' : isOn ? undefined : 'off')}
348        <Text wrap="wrap" dimColor={!said}>
349          {isOn ? (said ?? 'Appears after the first turn finishes.') : 'Off. Type /monitor on to turn it back on.'}
350        </Text>
351        <Text> </Text>
352        {heading('▶', 'ACTIVITY', 'claude', list.length ? `${list.length} steps` : undefined)}
353        {list.length === 0 && <Text dimColor>No tool calls yet.</Text>}
354        {list.slice(-room).map(a => (
355          <Text
356            wrap="truncate-end"
357            color={a.state === 'error' ? 'error' : a.state === 'running' ? 'claude' : 'success'}
358          >
359            {a.state === 'running' ? '●' : a.state === 'error' ? '✗' : '✓'} {a.text}
360          </Text>
361        ))}
362      </Box>
363    )
364  })
365}
366
hooks/lib.ts 147 lines
1// Pure helpers: no `$`, so the tests can call them directly.
2
3export const clip = (text: string, max: number): string => {
4  const flat = text.replace(/\s+/g, ' ').trim()
5  return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
6}
7
8const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
9
10// A solid meter: `fill` is drawn in the level color, `track` (solid blocks) dimmed behind it.
11export const bar = (percent: number, width: number): { fill: string; track: string } => {
12  const eighths = Math.max(0, Math.min(width * 8, Math.round((percent / 100) * width * 8)))
13  const full = Math.floor(eighths / 8)
14  const part = EIGHTHS[eighths % 8]!
15  return { fill: '█'.repeat(full) + part, track: '█'.repeat(width - full - (part ? 1 : 0)) }
16}
17
18export const levelColor = (percent: number): 'success' | 'warning' | 'error' =>
19  percent >= 90 ? 'error' : percent >= 70 ? 'warning' : 'success'
20
21export const limitLabel = (kind: string): string =>
22  kind === 'five_hour' ? '5h' : kind === 'seven_day' ? '7d' : kind === 'spend_limit' ? 'spend' : kind
23
24export const countdown = (ms: number): string => {
25  if (ms <= 0) return 'now'
26  const minutes = Math.ceil(ms / 60_000)
27  const days = Math.floor(minutes / 1440)
28  const hours = Math.floor((minutes % 1440) / 60)
29  const mins = minutes % 60
30  if (days > 0) return `${days}d${hours}h`
31  if (hours > 0) return `${hours}h${String(mins).padStart(2, '0')}m`
32  return `${mins}m`
33}
34
35const base = (path: unknown): string =>
36  typeof path === 'string' ? (path.split(/[\\/]/).pop() ?? path) : 'a file'
37
38const str = (value: unknown): string => (typeof value === 'string' ? value : '')
39
40// One plain-language line for a tool call, from its name and arguments.
41export const describe = (tool: string, args: Record<string, unknown>): string => {
42  switch (tool) {
43    case 'Bash':
44    case 'PowerShell':
45      return `Running: ${clip(str(args.description) || str(args.command), 60)}`
46    case 'Read':
47      return `Reading ${base(args.file_path)}`
48    case 'Edit':
49    case 'MultiEdit':
50      return `Editing ${base(args.file_path)}`
51    case 'Write':
52      return `Writing ${base(args.file_path)}`
53    case 'NotebookEdit':
54      return `Editing notebook ${base(args.notebook_path)}`
55    case 'Grep':
56      return `Searching code for "${clip(str(args.pattern), 40)}"`
57    case 'Glob':
58      return `Finding files ${clip(str(args.pattern), 40)}`
59    case 'WebSearch':
60      return `Searching the web: ${clip(str(args.query), 50)}`
61    case 'WebFetch': {
62      const url = str(args.url)
63      try {
64        return `Fetching ${new URL(url).host}`
65      } catch {
66        return 'Fetching a web page'
67      }
68    }
69    case 'Agent':
70    case 'Task':
71      return `Started a helper agent: ${clip(str(args.description), 50)}`
72    case 'Skill':
73      return `Using skill ${str(args.skill)}`
74    case 'ToolSearch':
75      return 'Loading tools'
76    default: {
77      const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
78      if (mcp) return `Using ${mcp[1]!.replace(/^claude_ai_/, '').replace(/_/g, ' ')}: ${mcp[2]!.replace(/_/g, ' ')}`
79      return `Using ${tool}`
80    }
81  }
82}
83
84export const summaryPrompt = (goal: string | null, steps: readonly string[], answer: string): string =>
85  [
86    `The user asked: ${goal ? clip(goal, 400) : '(unknown)'}`,
87    '',
88    'Steps the coding assistant took:',
89    ...(steps.length ? steps.map(s => `- ${s}`) : ['- (no tool calls)']),
90    '',
91    `Its final reply (may be cut): ${clip(answer, 1500)}`,
92  ].join('\n')
93
94export const SUMMARY_SYSTEM =
95  'You write a status line for someone watching a coding assistant work. ' +
96  'In one or two short, plain sentences, say what was just done and where things stand. ' +
97  'No preamble, no markdown, under 40 words.'
98
99// A short model label: "claude-opus-5-5[1m]" reads as "opus-5-5[1m]".
100export const modelLabel = (model: string): string => model.replace(/^claude-/, '').trim() || model
101
102// Repeats a box-drawing run, never negative (a narrow band just loses the run).
103export const rule = (char: string, count: number): string => char.repeat(Math.max(0, count))
104
105// Spaces that fill a card row out to `inner` cells, given what it already holds.
106export const fill = (used: number, inner: number): string => rule(' ', inner - used)
107
108// Time since the turn began: "12s", "3m05s".
109export const elapsed = (ms: number): string => {
110  const secs = Math.max(0, Math.floor(ms / 1000))
111  return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m${String(secs % 60).padStart(2, '0')}s`
112}
113
114// A token count as the band shows it: "62k", "1.2M".
115export const tokens = (n: number): string =>
116  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}k`
117
118export type Sample = { at: number; percent: number }
119
120// When a limit will fill at its recent pace, in ms from `now`, or null when it isn't
121// climbing, there's under 10 minutes of history, or it resets first.
122export const forecast = (samples: readonly Sample[], now: number, resetsIn?: number): number | null => {
123  const first = samples[0]
124  const last = samples[samples.length - 1]
125  if (!first || !last || last.at - first.at < 10 * 60_000 || last.percent <= first.percent) return null
126  const perMs = (last.percent - first.percent) / (last.at - first.at)
127  const eta = (100 - last.percent) / perMs - (now - last.at)
128  if (eta <= 0) return 0
129  return resetsIn !== undefined && eta >= resetsIn ? null : eta
130}
131
132const WINDOW_MS: Record<string, number> = { five_hour: 5 * 3_600_000, seven_day: 7 * 86_400_000 }
133
134// Where an even burn through the window would have the meter now, or null for a
135// limit with no known window length or reset time.
136export const expectedPercent = (kind: string, resetsIn?: number): number | null => {
137  const window = WINDOW_MS[kind]
138  if (window === undefined || resetsIn === undefined) return null
139  return Math.max(0, Math.min(100, (1 - resetsIn / window) * 100))
140}
141
142export type Pace = 'over' | 'on' | 'under'
143
144// Within 5 points of the even-burn line counts as on track.
145export const paceOf = (percent: number, expected: number): Pace =>
146  percent - expected > 5 ? 'over' : expected - percent > 5 ? 'under' : 'on'
147
types/index.d.ts 19 lines
1export type Limit = { kind: string; percent: number; resetsAt?: string }
2export type Meter = { limits: Limit[]; contextPercent?: number; contextTokens?: number; contextWindow?: number; costUsd?: number }
3export type Activity = { id: string; at: number; text: string; state: 'running' | 'done' | 'error' }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'mod-monitor': {
8      meter: Meter | null
9      now: string | null
10      feed: Activity[]
11      goal: string | null
12      summary: string | null
13      isSummarizing: boolean
14      isSummaryOn: boolean
15      tick: number
16    }
17  }
18}
19