SLOPSHOPPER

cw-chart

Opens a CloudWatch metric widget as a live chart pane with range, pan, refresh and click-to-ask

newpanecommandprocesstimer
v0.1.0MITupdated 2026-10-07voitta-ai/mods/cw-chart
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cw-chart
│ ┃ cw-chart ✕ › fix the failing auth test and add an audit log call │ ┃ No chart. Run /cw-chart <widget.json>. │ ⏺ 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 │ │ › /cw-chart │ ⎿ cw-chart: Usage: /cw-chart <path to a CloudWatch metric widget J │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · cw-chart
No chart. Run /cw-chart <widget.json>.
README

mods

Claude Code mods: plugins of function hooks that draw inside a terminal session.

ModWhat it does
cw-chart/cw-chart <widget.json> opens a CloudWatch metric widget in a pane. The pane has 1h/6h/24h ranges, pan, refresh, and a strip you click to put a timestamp in the prompt. A widget with a fixed start/end opens as that window. Without them, or after you pick a range, it is live and re-fetches every minute.
image-mirrorDraws each PNG that Claude reads inline in the transcript, under its Read row.

Install

At the prompt of a terminal session:

/plugin install cw-chart --marketplace voitta-ai/mods
/plugin install image-mirror --marketplace voitta-ai/mods

Answer y to add the marketplace, then choose a scope.

Requirements

  • Pictures need a terminal with the kitty graphics protocol (kitty, Ghostty). Other terminals show the picture's alt text.
  • cw-chart runs aws cloudwatch get-metric-widget-image with your default AWS credentials. They need cloudwatch:GetMetricWidgetImage.

Developing

Run a mod from its folder with claude --plugin-dir <mod folder>, or link it into the session's dev-mods folder for hot reload. Check a mod with claude plugin validate <mod folder>. CI runs the same check on every push and pull request.

Releasing

A mod's version in its .claude-plugin/plugin.json is the cache key that installs use. A PR that changes a mod must bump that mod's version, or CI fails the PR. When the PR merges, CI tags <mod>-v<version> and creates a GitHub release. The release notes are the titles of the merged PRs, so write each PR title as a release note.

License

MIT

Source 2 files
hooks/register.tsx 314 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { Chart } from '../types'
5
6type Widget = Record<string, unknown>
7
8const PANE = 'cw-chart'
9const chart = atom({ plugin: 'cw-chart', key: 'chart' } as const, null)
10
11// The widget size asked of CloudWatch when the spec names none.
12const DEFAULT_WIDTH = 1200
13const DEFAULT_HEIGHT = 420
14// A terminal cell is about twice as tall as it is wide.
15const CELL_ASPECT = 2
16const HOUR = 3_600_000
17const LIVE_REFRESH_MS = 60_000
18// ponytail: guessed plot area of a CloudWatch PNG, as fractions of its width,
19// so a click on the strip maps to an approximate time. Exact times need the
20// data itself (drawing the chart ourselves).
21const PLOT_LEFT = 0.05
22const PLOT_RIGHT = 0.95
23
24const iso = (ms: number) => {
25  const retval = new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z')
26  return retval
27}
28
29const hhmm = (ms: number) => {
30  const retval = `${iso(ms).slice(11, 16)}Z`
31  return retval
32}
33
34const numberOr = (value: unknown, fallback: number) => {
35  const retval = typeof value === 'number' ? value : fallback
36  return retval
37}
38
39const firstLine = (text: string) => {
40  const retval = text.trim().split('\n')[0] ?? ''
41  return retval
42}
43
44const messageOf = (thrown: unknown) => {
45  const retval = thrown instanceof Error ? thrown.message : String(thrown)
46  return retval
47}
48
49// Width and height from the PNG's IHDR chunk: bytes 16-23 of the file, the
50// first 32 base64 characters.
51const sizeOf = (base64: string) => {
52  const head = atob(base64.slice(0, 32))
53  const u32 = (at: number) => {
54    const value =
55      ((head.charCodeAt(at) << 24) |
56        (head.charCodeAt(at + 1) << 16) |
57        (head.charCodeAt(at + 2) << 8) |
58        head.charCodeAt(at + 3)) >>>
59      0
60    return value
61  }
62  const retval = { width: u32(16), height: u32(20) }
63  return retval
64}
65
66// A chart from a widget spec: an absolute start and end give a fixed window,
67// anything else the last three hours, live.
68const chartOf = (widget: Widget, fallbackTitle: string) => {
69  const start = typeof widget.start === 'string' ? Date.parse(widget.start) : Number.NaN
70  const end = typeof widget.end === 'string' ? Date.parse(widget.end) : Number.NaN
71  const isFixed = Number.isFinite(start) && Number.isFinite(end) && end > start
72  const sized = {
73    ...widget,
74    width: numberOr(widget.width, DEFAULT_WIDTH),
75    height: numberOr(widget.height, DEFAULT_HEIGHT),
76  }
77  const retval: Chart = {
78    title: typeof widget.title === 'string' ? widget.title : fallbackTitle,
79    widget: JSON.stringify(sized),
80    rangeMs: isFixed ? end - start : 3 * HOUR,
81    endMs: isFixed ? end : null,
82    png: null,
83    renderedAt: null,
84    error: null,
85    isRendering: false,
86  }
87  return retval
88}
89
90// Asks CloudWatch for the chart's window as a PNG. A PNG of another size than
91// asked is refused: a 200 with the wrong picture is the failure to catch.
92const fetchPng = async ($: EngineInterface, asked: Chart, now: number) => {
93  const end = asked.endMs ?? now
94  const widget: Widget = {
95    ...(JSON.parse(asked.widget) as Widget),
96    start: iso(end - asked.rangeMs),
97    end: iso(end),
98  }
99  const region = typeof widget.region === 'string' ? widget.region : 'us-east-1'
100  let retval: { png: string | null; error: string | null }
101  try {
102    const ran = await $.process.run(
103      [
104        'aws',
105        '--no-cli-pager',
106        'cloudwatch',
107        'get-metric-widget-image',
108        '--region',
109        region,
110        '--metric-widget',
111        JSON.stringify(widget),
112        '--output-format',
113        'png',
114        '--query',
115        'MetricWidgetImage',
116        '--output',
117        'text',
118      ],
119      { timeoutMs: 60_000 },
120    )
121    const png = ran.stdout.trim()
122    const size = ran.exitCode === 0 ? sizeOf(png) : null
123    const isExpected =
124      size !== null && size.width === widget.width && size.height === widget.height
125    if (isExpected) {
126      retval = { png, error: null }
127    } else if (size === null) {
128      retval = { png: null, error: firstLine(ran.stderr) || `aws exited ${ran.exitCode}` }
129    } else {
130      retval = {
131        png: null,
132        error: `CloudWatch returned ${size.width}x${size.height}, not ${String(widget.width)}x${String(widget.height)}`,
133      }
134    }
135  } catch (thrown) {
136    retval = { png: null, error: messageOf(thrown) }
137  }
138  return retval
139}
140
141// Renders the chart's current window and stores the picture, or why it
142// failed, keeping the last good one. A render whose window changed meanwhile
143// is dropped; the render the change started lands instead.
144const renderChart = async ($: EngineInterface) => {
145  const asked = await read($, chart)
146  if (asked === null) {
147    return
148  }
149  await update($, chart, c => {
150    const retval = c === null ? c : { ...c, isRendering: true }
151    return retval
152  })
153  const now = await $.clock.now()
154  const { png, error } = await fetchPng($, asked, now)
155  await update($, chart, c => {
156    const isStale =
157      c === null ||
158      c.widget !== asked.widget ||
159      c.rangeMs !== asked.rangeMs ||
160      c.endMs !== asked.endMs
161    const retval = isStale
162      ? c
163      : { ...c, isRendering: false, renderedAt: now, error, png: png ?? c.png }
164    return retval
165  })
166}
167
168const setWindow = async (
169  $: EngineInterface,
170  change: (c: Chart, now: number) => Pick<Chart, 'rangeMs' | 'endMs'>,
171) => {
172  const now = await $.clock.now()
173  await update($, chart, c => {
174    const retval = c === null ? c : { ...c, ...change(c, now) }
175    return retval
176  })
177  await renderChart($)
178}
179
180const live = (rangeMs: number) => () => {
181  const retval = { rangeMs, endMs: null }
182  return retval
183}
184
185// Moves the window by half its length; reaching now goes live again.
186const shift = (sign: number) => (c: Chart, now: number) => {
187  const end = (c.endMs ?? now) + (sign * c.rangeMs) / 2
188  const retval = { rangeMs: c.rangeMs, endMs: end >= now ? null : end }
189  return retval
190}
191
192export const register: Register = on => {
193  on('session.start', async ($, e, next) => {
194    await $.command.register({
195      name: 'cw-chart',
196      description: 'Open a CloudWatch metric widget JSON file as a live chart pane',
197      argumentHint: '<widget.json>',
198    })
199    $.clock.every(LIVE_REFRESH_MS, () => {
200      void (async () => {
201        const current = await read($, chart)
202        if (current !== null && current.endMs === null) {
203          await renderChart($)
204        }
205      })()
206    })
207    const retval = await next(e)
208    return retval
209  })
210
211  on('command.run', { command: 'cw-chart' }, async ($, e) => {
212    const path = e.args.trim()
213    let retval = { text: 'Usage: /cw-chart <path to a CloudWatch metric widget JSON file>' }
214    if (path !== '') {
215      try {
216        const widget = JSON.parse(await $.fs.read(path)) as Widget
217        const opened = chartOf(widget, path.split('/').pop() ?? path)
218        await update($, chart, () => opened)
219        await $.ui.open({ id: PANE, title: opened.title })
220        await renderChart($)
221        retval = { text: `Opened ${opened.title} in the chart pane.` }
222      } catch (thrown) {
223        retval = { text: `cw-chart: ${messageOf(thrown)}` }
224      }
225    }
226    return retval
227  })
228
229  // Closing the pane drops the chart, which stops its refresh.
230  on('ui.close', { id: PANE }, async ($, e, next) => {
231    await update($, chart, () => null)
232    const retval = await next(e)
233    return retval
234  })
235
236  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
237    const c = await read($, chart)
238    let retval: RenderElement
239    if (e.surface !== 'terminal' || c === null) {
240      const { Text } = $.ui.resolve(e)
241      retval = (
242        <Text dimColor>
243          {c === null ? 'No chart. Run /cw-chart <widget.json>.' : 'The chart draws in the terminal only.'}
244        </Text>
245      )
246    } else {
247      const { Box, Button, Image, Text } = $.ui.resolve(e)
248      const widget = JSON.parse(c.widget) as Widget
249      const width = numberOr(widget.width, DEFAULT_WIDTH)
250      const height = numberOr(widget.height, DEFAULT_HEIGHT)
251      const columns = Math.max(20, Math.min(255, e.props.bodyColumns))
252      const rows = Math.max(4, Math.min(255, Math.round((columns * height) / width / CELL_ASPECT)))
253      const end = c.endMs ?? c.renderedAt ?? (await $.clock.now())
254      const start = end - c.rangeMs
255      const segments = Math.floor(columns / 2)
256      const timeAt = (i: number) => {
257        const fraction = ((i + 0.5) / segments - PLOT_LEFT) / (PLOT_RIGHT - PLOT_LEFT)
258        const at = fraction < 0 || fraction > 1 ? null : start + fraction * c.rangeMs
259        return at
260      }
261      const status = [
262        c.endMs === null ? 'live, every 60s' : 'fixed window',
263        `${hhmm(start)}-${hhmm(end)}`,
264        c.renderedAt === null ? 'not rendered yet' : `rendered ${iso(c.renderedAt).slice(11)}`,
265        ...(c.isRendering ? ['rendering...'] : []),
266      ].join(' | ')
267      retval = (
268        <Box flexDirection="column">
269          {c.png === null ? (
270            <Text dimColor>{c.isRendering ? 'Rendering...' : 'No picture yet.'}</Text>
271          ) : (
272            <Image key="chart" source={{ png: c.png }} columns={columns} rows={rows} alt={c.title} />
273          )}
274          <Box>
275            {Array.from({ length: segments }, (_, i) => {
276              const at = timeAt(i)
277              const cell =
278                at === null ? (
279                  <Text>{'  '}</Text>
280                ) : (
281                  <Box key={`s${i}`}>
282                    <Button
283                      key={`t${i}`}
284                      label=".."
285                      plain
286                      onPress={async () => {
287                        await $.prompt.fill({ text: `[${c.title} @ ${iso(at)}] `, mode: 'insert' })
288                      }}
289                    />
290                    <Box position="absolute" top={1} left={0} display="none" hover={{ display: 'flex' }}>
291                      <Text inverse>{` ${hhmm(at)} `}</Text>
292                    </Box>
293                  </Box>
294                )
295              return cell
296            })}
297          </Box>
298          <Box gap={1}>
299            <Button key="r1" label="1h" hotkey="1" onPress={() => setWindow($, live(HOUR))} />
300            <Button key="r6" label="6h" hotkey="6" onPress={() => setWindow($, live(6 * HOUR))} />
301            <Button key="r24" label="24h" hotkey="d" onPress={() => setWindow($, live(24 * HOUR))} />
302            <Button key="back" label="<" hotkey="h" onPress={() => setWindow($, shift(-1))} />
303            <Button key="fwd" label=">" hotkey="l" onPress={() => setWindow($, shift(1))} />
304            <Button key="refresh" label="refresh" hotkey="r" onPress={() => renderChart($)} />
305          </Box>
306          <Text dimColor>{status}</Text>
307          {c.error !== null && <Text color="red">{c.error}</Text>}
308        </Box>
309      )
310    }
311    return retval
312  })
313}
314
types/index.d.ts 21 lines
1export type Chart = {
2  title: string
3  /** The CloudWatch metric widget as JSON text; each render sets its start and end. */
4  widget: string
5  rangeMs: number
6  /** The window's end, ms since the epoch; null follows now and refreshes every minute. */
7  endMs: number | null
8  /** The last good render, a base64 PNG. */
9  png: string | null
10  renderedAt: number | null
11  /** Why the last render failed. */
12  error: string | null
13  isRendering: boolean
14}
15
16declare module 'claude-code' {
17  interface PluginState {
18    'cw-chart': { chart: Chart | null }
19  }
20}
21