SLOPSHOPPER

charts

Draws line and bar charts in a terminal pane from data Claude passes to its draw tool

newpanerowsguardcommandtool
v0.1.0MITupdated 2026-10-07JorgeRomero123/claude-charts
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · charts
│ ┃ Charts ✕ › fix the failing auth test and add an audit log call │ ┃ No charts yet. Ask for some numbers. │ ⏺ 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 │ │ › /charts │ ⎿ charts: Charts pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Charts
No charts yet. Ask for some numbers.
README

charts

A Claude Code mod that lets Claude draw charts in your terminal.

When you ask for numbers (analytics, a SQL result, a CSV), Claude fetches the data as usual and passes the rows to the mod's draw tool. The chart appears in a Charts pane beside the conversation, newest on top.

  • Three kinds: line (trends), vertical bars (a few buckets), horizontal bars (named categories such as countries, events or funnel steps).
  • Up to 6 series per chart; the pane keeps the last 4 charts.
  • In terminals with the kitty graphics protocol (Ghostty, kitty) charts are drawn as real pixels. Everywhere else, and in sessions that cannot draw images, they are drawn with braille and block characters.
  • On a terminal too narrow for the pane to open by itself, the chart is drawn inline in the transcript instead.

Install

At the Claude Code prompt in a terminal:

/plugin install charts --marketplace JorgeRomero123/claude-charts

Answer y to add the marketplace, then pick a scope (user scope loads it in every session).

Use

Ask Claude for data you want to see, for example "chart signups per day for the last 30 days". Type /charts to reopen the pane; it also retries pixel images. The pane's Clear button (hotkey c while the pane has focus) empties it.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .

The mod API is early access and may change between Claude Code releases.

License

MIT

Source 4 files
hooks/register.tsx 246 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, Register } from 'claude-code'
3
4import { colorOf, draw, hex, normalize } from './chart'
5import type { ChartSpec } from './chart'
6import { TREE_BUDGET, picture } from './picture'
7import type { Picture } from './picture'
8
9const TOOL = 'mcp__charts__draw'
10const PANE = 'charts'
11const KEPT = 4
12const shown = atom({ plugin: 'charts', key: 'shown' } as const, [])
13const isAltOnly = atom({ plugin: 'charts', key: 'isAltOnly' } as const, false)
14
15const DESCRIPTION = [
16  'Draws a chart in the Charts pane of the terminal, for the person to look at; the pane keeps the last few, newest on top.',
17  'Use it whenever numbers are worth seeing rather than reading: a metric over time, a breakdown, a funnel, a before/after.',
18  'Fetch and aggregate the data first (PostHog, SQL, a file), then pass the rows here; this tool fetches nothing.',
19  'kind "line": a trend over ordered labels (dates), up to 6 series.',
20  'kind "bars": vertical bars for a few ordered buckets.',
21  'kind "hbars": horizontal bars for categories with names (countries, devices, events, funnel steps), sorted before passing.',
22  'After it draws, say in one or two sentences what the chart shows; do not repeat the numbers as a table.',
23].join(' ')
24
25const SCHEMA = {
26  type: 'object',
27  properties: {
28    title: { type: 'string', description: 'What is measured and over what period, e.g. "Image uploads per day, last 30 days".' },
29    kind: { type: 'string', enum: ['line', 'bars', 'hbars'] },
30    labels: { type: 'array', items: { type: 'string' }, description: 'The x values or category names, in order; short (dates as "Oct 3").' },
31    series: {
32      type: 'array',
33      items: {
34        type: 'object',
35        properties: {
36          name: { type: 'string' },
37          values: { type: 'array', items: { type: ['number', 'null'] }, description: 'One per label; null for a missing point.' },
38        },
39        required: ['name', 'values'],
40      },
41    },
42    unit: { type: 'string', description: 'A suffix for the values, e.g. "%".' },
43    note: { type: 'string', description: 'One short line under the chart: the source, a filter, a caveat.' },
44  },
45  required: ['title', 'kind', 'labels', 'series'],
46}
47
48/** Terminals that draw pictures (the kitty graphics protocol); elsewhere an Image is only its `alt`. */
49const isDrawingPictures = (program: string | undefined, term: string | undefined, kitty: string | undefined) =>
50  kitty !== undefined || program === 'ghostty' || /kitty|ghostty/.test(term ?? '')
51
52/** Rasterized pictures by spec and width: a transcript row is drawn again on every scroll and resize. */
53const pictures = new Map<string, Picture>()
54
55const pictureOf = (spec: ChartSpec, columns: number, budget: number) => {
56  const key = `${columns}:${budget}:${JSON.stringify(spec)}`
57  let made = pictures.get(key)
58  if (made === undefined) {
59    if (pictures.size >= 24) pictures.clear()
60    made = picture(spec, columns, budget)
61    pictures.set(key, made)
62  }
63
64  return made
65}
66
67const parsed = (output: unknown): unknown => {
68  if (typeof output !== 'string') return output
69  try {
70    return JSON.parse(output)
71  } catch {
72    return undefined
73  }
74}
75
76const whereOf = (output: unknown) => (parsed(output) as { where?: unknown } | null | undefined)?.where
77
78/**
79 * The site and source each Image was last drawn at. Some sessions cannot draw
80 * pictures though the terminal can, and nothing says so while drawing: a blit
81 * of the same picture is refused there, naming the reason.
82 */
83const drawn = new Map<string, { requestId: string; plot: Picture['plot'] }>()
84
85const chartOf = (output: unknown): ChartSpec | undefined => {
86  let value = output
87  if (typeof value === 'string') {
88    try {
89      value = JSON.parse(value)
90    } catch {
91      return undefined
92    }
93  }
94  const chart = (value as { chart?: unknown } | null)?.chart
95  if (chart === null || typeof chart !== 'object') return undefined
96  const spec = normalize(chart as Record<string, unknown>)
97
98  return typeof spec === 'string' ? undefined : spec
99}
100
101/**
102 * One chart as a terminal tree: its title, the plot (pixels where the terminal
103 * draws pictures, cells elsewhere), the legend and the note. `id` keeps the
104 * keys of several charts in one tree apart.
105 */
106const chartTree = (
107  els: Elements['terminal'],
108  spec: ChartSpec,
109  columns: number,
110  isPictures: boolean,
111  id: string,
112  budget = TREE_BUDGET,
113  requestId = PANE,
114) => {
115  const { Box, Text, Raster, Image } = els
116  const made = isPictures ? pictureOf(spec, columns, budget) : undefined
117  if (made !== undefined) drawn.set(`plot-${id}`, { requestId, plot: made.plot })
118  const drawing = made === undefined ? draw(spec, columns) : undefined
119
120  return (
121    <Box flexDirection="column">
122      <Text bold>{spec.title}</Text>
123      {drawing !== undefined && (
124        <Raster key={`plot-${id}`} columns={drawing.columns} rows={drawing.rows} cells={drawing.cells} />
125      )}
126      {made !== undefined && (
127        <Box flexDirection="row">
128          <Raster key={`side-${id}`} columns={made.side.columns} rows={made.side.rows} cells={made.side.cells} />
129          <Image
130            key={`plot-${id}`}
131            source={{ rgba: made.plot.rgba, width: made.plot.width, height: made.plot.height }}
132            columns={made.plot.columns}
133            rows={made.plot.rows}
134            alt={spec.title}
135          />
136        </Box>
137      )}
138      {made?.under !== undefined && (
139        <Raster key={`under-${id}`} columns={made.under.columns} rows={made.under.rows} cells={made.under.cells} />
140      )}
141      {spec.series.length > 1 && (
142        <Box flexDirection="row">
143          {spec.series.map((s, i) => (
144            <Text color={hex(colorOf(i))}>{`\u25a0 ${s.name}   `}</Text>
145          ))}
146        </Box>
147      )}
148      {spec.note !== undefined && <Text dimColor>{spec.note}</Text>}
149    </Box>
150  )
151}
152
153export const register: Register = on => {
154  on('session.start', async ($, e, next) => {
155    await $.tool.register({ name: 'draw', description: DESCRIPTION, inputSchema: SCHEMA })
156    await $.command.register({ name: 'charts', description: 'Show the Charts pane' })
157    // A resumed or reloaded session may draw pictures where the last one could not: try them anew.
158    await update($, isAltOnly, () => false)
159
160    return next(e)
161  })
162
163  on('command.run', { command: 'charts' }, async $ => {
164    // The session may draw pictures again (another terminal, another way of running): try them anew.
165    await update($, isAltOnly, () => false)
166    await $.ui.open({ id: PANE, title: 'Charts' })
167
168    return { text: 'Charts pane opened.' }
169  })
170
171  on('tool.call', { tool: TOOL }, async ($, e) => {
172    const spec = normalize(e as unknown as Record<string, unknown>)
173    if (typeof spec === 'string') return { deny: spec }
174    await update($, shown, list => [spec, ...list].slice(0, KEPT))
175    // Opened unasked, the pane seats only on a wide terminal; where it waits, the chart is drawn in the transcript.
176    const opened = await $.ui.open({ id: PANE, title: 'Charts' })
177
178    // A plugin tool answers the model in text: the spec as JSON, which the ToolResult hook parses back.
179    // Once the picture has had time to be drawn, ask whether it was; the answer redraws every chart in cells.
180    $.clock.after(700, async () => {
181      for (const [key, { requestId, plot }] of drawn) {
182        const { deny } = await $.ui.blit({ requestId, key, source: { rgba: plot.rgba, width: plot.width, height: plot.height } })
183        if (deny?.includes('alt')) {
184          drawn.clear()
185          await update($, isAltOnly, () => true)
186          break
187        }
188      }
189    })
190
191    // A plugin tool answers the model in text: the spec as JSON, which the ToolResult hook parses back.
192    return { result: JSON.stringify({ where: opened.isPlaced ? 'pane' : 'transcript', chart: spec }) }
193  })
194
195  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
196    const list = await read($, shown)
197    if (e.surface !== 'terminal') {
198      const { Text } = $.ui.resolve(e)
199
200      return <Text>{`${list.length} chart${list.length === 1 ? '' : 's'}; they draw in the terminal only.`}</Text>
201    }
202    const els = $.ui.resolve(e)
203    const { Box, Text, Button } = els
204    if (list.length === 0) return <Text dimColor>No charts yet. Ask for some numbers.</Text>
205    const isPictures = !(await read($, isAltOnly)) &&
206      isDrawingPictures(
207        await $.env.get('TERM_PROGRAM'),
208        await $.env.get('TERM'),
209        await $.env.get('KITTY_WINDOW_ID'),
210      )
211    const columns = e.props.bodyColumns - 1
212
213    return (
214      <Box flexDirection="column">
215        {list.map((spec, i) => (
216          <Box flexDirection="column" marginBottom={1}>
217            {chartTree(els, spec, columns, isPictures, String(i), Math.floor(TREE_BUDGET / list.length))}
218          </Box>
219        ))}
220        <Button key="clear" label="Clear" hotkey="c" onPress={() => void update($, shown, () => [])} />
221      </Box>
222    )
223  })
224
225  on('ui.render', { component: 'ToolResult', props: { tool: TOOL } }, async ($, e, next) => {
226    if (e.surface !== 'terminal' || e.props.isErrored) return next(e)
227    const spec = chartOf(e.props.output)
228    if (spec === undefined) return next(e)
229    const els = $.ui.resolve(e)
230    if (whereOf(e.props.output) === 'pane') {
231      const { Text } = els
232
233      return <Text dimColor>{`${spec.title}: in the Charts pane (/charts shows it)`}</Text>
234    }
235
236    const isPictures = !(await read($, isAltOnly)) &&
237      isDrawingPictures(
238        await $.env.get('TERM_PROGRAM'),
239        await $.env.get('TERM'),
240        await $.env.get('KITTY_WINDOW_ID'),
241      )
242
243    return chartTree(els, spec, (e.viewport?.columns ?? 80) - 8, isPictures, `row-${e.requestId}`, TREE_BUDGET, e.requestId)
244  })
245}
246
hooks/chart.ts 281 lines
1import type { ChartKind, ChartSeries, ChartSpec } from '../types'
2
3export type { ChartSpec } from '../types'
4type Series = ChartSeries
5export type Drawing = { columns: number; rows: number; cells: string }
6
7export const PALETTE = [0x4e9af1, 0xf28e2b, 0x59c17a, 0xe15759, 0xb07aa1, 0x76b7b2]
8export const hex = (color: number) => `#${color.toString(16).padStart(6, '0')}`
9export const colorOf = (i: number) => PALETTE[i % PALETTE.length] ?? 0x4e9af1
10
11export const DEFAULT = 0x01000000
12export const DIM = 0x808080
13const MAX_POINTS = 400
14const MAX_SERIES = 6
15export const PLOT_ROWS = 11
16
17/** The tool's arguments as a spec, or the reason they are not one. */
18export const normalize = (input: Record<string, unknown>): ChartSpec | string => {
19  const kind = input.kind ?? 'line'
20  if (kind !== 'line' && kind !== 'bars' && kind !== 'hbars')
21    return 'kind must be "line", "bars" or "hbars".'
22  if (!Array.isArray(input.labels) || input.labels.length === 0)
23    return 'labels must be a non-empty array.'
24  if (input.labels.length > MAX_POINTS) return `labels holds more than ${MAX_POINTS} points; aggregate first.`
25  const labels = input.labels.map(String)
26  if (!Array.isArray(input.series) || input.series.length === 0)
27    return 'series must be a non-empty array of { name, values }.'
28  if (input.series.length > MAX_SERIES) return `series holds more than ${MAX_SERIES}; keep the top ones.`
29  const series: Series[] = []
30  for (const raw of input.series as unknown[]) {
31    const one = (raw ?? {}) as Record<string, unknown>
32    if (!Array.isArray(one.values) || one.values.length !== labels.length)
33      return `series "${String(one.name ?? '?')}" needs one value per label (${labels.length}).`
34    const values = one.values.map(v => (typeof v === 'number' && Number.isFinite(v) ? v : null))
35    if (values.every(v => v === null)) return `series "${String(one.name ?? '?')}" has no numeric value.`
36    series.push({ name: String(one.name ?? `series ${series.length + 1}`), values })
37  }
38  return {
39    title: String(input.title ?? 'Chart'),
40    kind,
41    labels,
42    series,
43    unit: typeof input.unit === 'string' ? input.unit : '',
44    ...(typeof input.note === 'string' && input.note !== '' ? { note: input.note } : {}),
45  }
46}
47
48const trim = (n: number) => n.toFixed(1).replace(/\.0$/, '')
49export const fmt = (v: number, unit = ''): string => {
50  const a = Math.abs(v)
51  const s =
52    a >= 1e6 ? `${trim(v / 1e6)}M`
53    : a >= 1e4 ? `${trim(v / 1e3)}k`
54    : Number.isInteger(v) ? String(v)
55    : a >= 100 ? v.toFixed(0)
56    : trim(v)
57  return s + unit
58}
59
60export const niceStep = (x: number) => {
61  if (!(x > 0)) return 1
62  const p = 10 ** Math.floor(Math.log10(x))
63  const m = x / p
64  return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10) * p
65}
66
67export type Grid = { columns: number; rows: number; words: Uint32Array }
68
69export const makeGrid = (columns: number, rows: number): Grid => {
70  const words = new Uint32Array(columns * rows * 3)
71  for (let i = 0; i < columns * rows; i++) {
72    words[i * 3] = 0x20
73    words[i * 3 + 1] = DEFAULT
74    words[i * 3 + 2] = DEFAULT
75  }
76  return { columns, rows, words }
77}
78
79const put = (g: Grid, x: number, y: number, codePoint: number, color: number) => {
80  if (x < 0 || y < 0 || x >= g.columns || y >= g.rows) return
81  const i = (y * g.columns + x) * 3
82  g.words[i] = codePoint
83  g.words[i + 1] = color
84}
85
86/** A Raster cell takes one printable width-1 character; anything else is drawn as `?`. */
87const safe = (c: number) =>
88  (c >= 0x20 && c <= 0x7e) || (c >= 0xa1 && c <= 0x24f && c !== 0xad) || (c >= 0x370 && c <= 0x4ff) || c === 0x2026
89    ? c
90    : 0x3f
91
92export const write = (g: Grid, x: number, y: number, s: string, color: number) => {
93  for (let i = 0; i < s.length; i++) put(g, x + i, y, safe(s.charCodeAt(i)), color)
94}
95
96export const clip = (s: string, width: number) =>
97  s.length <= width ? s : width <= 1 ? s.slice(0, width) : `${s.slice(0, width - 1)}…`
98
99const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
100export const base64 = (bytes: Uint8Array) => {
101  let out = ''
102  for (let i = 0; i < bytes.length; i += 3) {
103    const a = bytes[i] ?? 0
104    const b = bytes[i + 1] ?? 0
105    const c = bytes[i + 2] ?? 0
106    out += B64[a >> 2]! + B64[((a & 3) << 4) | (b >> 4)]!
107    out += i + 1 < bytes.length ? B64[((b & 15) << 2) | (c >> 6)]! : '='
108    out += i + 2 < bytes.length ? B64[c & 63]! : '='
109  }
110  return out
111}
112
113export const pack = (g: Grid): Drawing => {
114  const view = new DataView(new ArrayBuffer(g.words.length * 4))
115  g.words.forEach((word, i) => view.setUint32(i * 4, word, true))
116  return { columns: g.columns, rows: g.rows, cells: base64(new Uint8Array(view.buffer)) }
117}
118
119export const numbers = (spec: ChartSpec) =>
120  spec.series.flatMap(s => s.values.filter((v): v is number => v !== null))
121
122/** The left gutter with its three tick labels and the two axis lines; answers where the plot starts. */
123const axes = (g: Grid, rows: number, lo: number, hi: number, unit: string, isLowOnAxis: boolean) => {
124  const ticks = [fmt(hi, unit), fmt((hi + lo) / 2, unit), fmt(lo, unit)]
125  const gutter = Math.max(...ticks.map(t => t.length))
126  const middle = (rows - 1) / 2
127  const at = [0, middle, isLowOnAxis ? rows : rows - 1]
128  for (let y = 0; y < rows; y++) put(g, gutter, y, at.includes(y) ? 0x2524 : 0x2502, DIM)
129  put(g, gutter, rows, isLowOnAxis ? 0x253c : 0x2514, DIM)
130  for (let x = gutter + 1; x < g.columns; x++) put(g, x, rows, 0x2500, DIM)
131  ticks.forEach((t, i) => write(g, gutter - t.length, at[i] ?? 0, t, DIM))
132  return gutter + 1
133}
134
135const line = (spec: ChartSpec, columns: number): Drawing => {
136  const all = numbers(spec)
137  const min = Math.min(...all)
138  const max = Math.max(...all)
139  const step = niceStep((max - min || Math.abs(max) || 1) / 8)
140  let lo = Math.floor(min / step) * step
141  let hi = Math.ceil(max / step) * step
142  if (min >= 0 && min <= max - min) lo = 0
143  if (hi === lo) hi = lo + step
144  const rows = PLOT_ROWS
145  const g = makeGrid(columns, rows + 2)
146  const x0 = axes(g, rows, lo, hi, spec.unit, false)
147  const w = columns - x0
148  const dotsW = w * 2
149  const dotsH = rows * 4
150  const n = spec.labels.length
151  const bits = new Uint8Array(w * rows)
152  const tint = new Uint32Array(w * rows)
153  const xOf = (i: number) => (n === 1 ? Math.floor(dotsW / 2) : Math.round((i * (dotsW - 1)) / (n - 1)))
154  const yOf = (v: number) => Math.round(((hi - v) / (hi - lo)) * (dotsH - 1))
155  const dot = (x: number, y: number, color: number) => {
156    if (x < 0 || y < 0 || x >= dotsW || y >= dotsH) return
157    const cell = (y >> 2) * w + (x >> 1)
158    const row = y & 3
159    bits[cell] = (bits[cell] ?? 0) | (x & 1 ? [0x08, 0x10, 0x20, 0x80][row]! : [0x01, 0x02, 0x04, 0x40][row]!)
160    tint[cell] = color
161  }
162  const segment = (ax: number, ay: number, bx: number, by: number, color: number) => {
163    const dx = Math.abs(bx - ax)
164    const dy = -Math.abs(by - ay)
165    const sx = ax < bx ? 1 : -1
166    const sy = ay < by ? 1 : -1
167    let err = dx + dy
168    let x = ax
169    let y = ay
170    for (;;) {
171      dot(x, y, color)
172      if (x === bx && y === by) return
173      const twice = 2 * err
174      if (twice >= dy) { err += dy; x += sx }
175      if (twice <= dx) { err += dx; y += sy }
176    }
177  }
178  // Drawn last to first, so the first series is on top where two cross.
179  for (let s = spec.series.length - 1; s >= 0; s--) {
180    const color = colorOf(s)
181    let before: [number, number] | undefined
182    spec.series[s]?.values.forEach((v, i) => {
183      if (v === null) { before = undefined; return }
184      const here: [number, number] = [xOf(i), yOf(v)]
185      if (before) segment(before[0], before[1], here[0], here[1], color)
186      else dot(here[0], here[1], color)
187      before = here
188    })
189  }
190  for (let i = 0; i < bits.length; i++)
191    if (bits[i]) put(g, x0 + (i % w), Math.floor(i / w), 0x2800 + (bits[i] ?? 0), tint[i] ?? DEFAULT)
192
193  const widest = Math.max(...spec.labels.map(l => l.length))
194  const count = Math.min(n, Math.max(2, Math.floor(w / (widest + 2))))
195  const picks = n === 1 ? [0] : Array.from({ length: count }, (_, j) => Math.round((j * (n - 1)) / (count - 1)))
196  const placed: [number, number][] = []
197  for (const i of [picks[picks.length - 1] ?? 0, ...picks.slice(0, -1)]) {
198    const label = clip(spec.labels[i] ?? '', w)
199    const centre = x0 + (xOf(i) >> 1)
200    const start = Math.min(columns - label.length, Math.max(x0, centre - (label.length >> 1)))
201    const end = start + label.length
202    if (placed.some(([a, b]) => start <= b && end >= a)) continue
203    placed.push([start, end])
204    write(g, start, rows + 1, label, DIM)
205  }
206  return pack(g)
207}
208
209const bars = (spec: ChartSpec, columns: number): Drawing => {
210  const max = Math.max(0, ...numbers(spec))
211  const step = niceStep((max || 1) / 4)
212  const hi = Math.ceil((max || 1) / step) * step
213  const rows = PLOT_ROWS
214  const g = makeGrid(columns, rows + 2)
215  const x0 = axes(g, rows, 0, hi, spec.unit, true)
216  const w = columns - x0
217  const n = spec.labels.length
218  const m = spec.series.length
219  const slot = Math.floor(w / n)
220  const width = Math.min(6, Math.max(1, Math.floor((slot - 1) / m)))
221  const inset = Math.max(0, Math.floor((slot - width * m) / 2))
222  spec.series.forEach((s, j) => {
223    s.values.forEach((v, i) => {
224      if (v === null || v <= 0) return
225      const eighths = Math.max(1, Math.round((v / hi) * rows * 8))
226      for (let k = 0; k < width; k++) {
227        const x = x0 + i * slot + inset + j * width + k
228        for (let r = 0; r * 8 < eighths; r++) {
229          const part = Math.min(8, eighths - r * 8)
230          put(g, x, rows - 1 - r, 0x2580 + part, colorOf(j))
231        }
232      }
233    })
234  })
235  const widest = Math.max(...spec.labels.map(l => l.length))
236  const every = slot > widest ? 1 : Math.ceil((widest + 1) / slot)
237  for (let i = 0; i < n; i += every) {
238    const room = every === 1 ? Math.max(1, slot - 1) : widest
239    const label = clip(spec.labels[i] ?? '', room)
240    const centre = x0 + i * slot + (slot >> 1)
241    write(g, Math.min(columns - label.length, Math.max(x0, centre - (label.length >> 1))), rows + 1, label, DIM)
242  }
243  return pack(g)
244}
245
246const hbars = (spec: ChartSpec, columns: number): Drawing => {
247  const m = spec.series.length
248  const per = m > 1 ? m + 1 : 1
249  const n = Math.min(spec.labels.length, Math.floor(60 / per))
250  const max = Math.max(0, ...numbers(spec)) || 1
251  const gutter = Math.min(Math.max(...spec.labels.slice(0, n).map(l => l.length)), Math.floor(columns * 0.35))
252  const texts = spec.series.map(s => s.values.map(v => (v === null ? '' : fmt(v, spec.unit))))
253  const widest = Math.max(...texts.flat().map(t => t.length))
254  const room = Math.max(4, columns - gutter - 1 - widest - 1)
255  const g = makeGrid(columns, n * per - (m > 1 ? 1 : 0))
256  for (let i = 0; i < n; i++) {
257    write(g, 0, i * per, clip(spec.labels[i] ?? '', gutter).padStart(gutter), DEFAULT)
258    spec.series.forEach((s, j) => {
259      const v = s.values[i]
260      if (v === null || v === undefined) return
261      const y = i * per + j
262      const eighths = v <= 0 ? 0 : Math.max(1, Math.round((v / max) * room * 8))
263      let x = gutter + 1
264      for (let left = eighths; left > 0; left -= 8) put(g, x++, y, left >= 8 ? 0x2588 : 0x2590 - left, colorOf(j))
265      write(g, x + 1, y, texts[j]?.[i] ?? '', DIM)
266    })
267  }
268  return pack(g)
269}
270
271/** Whether the vertical bars of a spec fit a plot `columns` wide; a spec that does not is drawn as lines. */
272const isBarsFitting = (spec: ChartSpec, columns: number) =>
273  Math.floor((columns - 10) / spec.labels.length) >= spec.series.length
274
275export const draw = (spec: ChartSpec, available: number): Drawing => {
276  const columns = Math.min(110, Math.max(36, Math.floor(available)))
277  if (spec.kind === 'hbars') return hbars(spec, columns)
278  if (spec.kind === 'bars' && isBarsFitting(spec, columns)) return bars(spec, columns)
279  return line(spec, columns)
280}
281
hooks/picture.ts 261 lines
1import { DEFAULT, DIM, PLOT_ROWS, base64, clip, colorOf, fmt, makeGrid, niceStep, numbers, pack, write } from './chart'
2import type { ChartSpec, Drawing } from './chart'
3
4/**
5 * A chart as pixels: the plot is one RGBA picture over a box of cells, and the
6 * words around it (tick labels, category names, values) stay terminal text,
7 * packed as Rasters so they line up with the picture row for row.
8 */
9export type Picture = {
10  /** The cells left of the plot: tick labels, or category names and values. */
11  side: Drawing
12  plot: { rgba: string; width: number; height: number; columns: number; rows: number }
13  /** The row of x labels under the plot, already indented past `side`; absent for hbars. */
14  under?: Drawing
15}
16
17const AXIS = 0x8a8a8a
18/**
19 * One tree may hold 2 MiB of Image source in all, so the charts of a tree share
20 * a budget of pixel bytes; a cell is drawn at the largest of these sizes that fits its share.
21 */
22const CELL_SIZES: [number, number][] = [[14, 28], [12, 24], [10, 20], [8, 16], [6, 12], [4, 8]]
23export const TREE_BUDGET = 1_450_000
24
25type Canvas = { width: number; height: number; pixels: Uint8Array; cellW: number; cellH: number }
26
27const canvas = (columns: number, rows: number, budget: number): Canvas => {
28  const [cellW, cellH] = CELL_SIZES.find(([w, h]) => columns * w * rows * h * 4 <= budget) ?? [4, 8]
29  const width = Math.min(2048, columns * cellW)
30  const height = Math.min(2048, rows * cellH)
31
32  return { width, height, pixels: new Uint8Array(width * height * 4), cellW, cellH }
33}
34
35/** Source over destination, straight alpha, for one pixel. */
36const blend = (c: Canvas, x: number, y: number, color: number, alpha: number) => {
37  if (alpha <= 0 || x < 0 || y < 0 || x >= c.width || y >= c.height) return
38  const i = (y * c.width + x) * 4
39  const a = Math.min(1, alpha)
40  const under = (c.pixels[i + 3] ?? 0) / 255
41  const out = a + under * (1 - a)
42  const mix = (top: number, bottom: number) => Math.round((top * a + bottom * under * (1 - a)) / out)
43  c.pixels[i] = mix((color >> 16) & 255, c.pixels[i] ?? 0)
44  c.pixels[i + 1] = mix((color >> 8) & 255, c.pixels[i + 1] ?? 0)
45  c.pixels[i + 2] = mix(color & 255, c.pixels[i + 2] ?? 0)
46  c.pixels[i + 3] = Math.round(out * 255)
47}
48
49/** A rectangle with fractional edges: the partly covered rows and columns take partial alpha. */
50const rect = (c: Canvas, x0: number, y0: number, x1: number, y1: number, color: number, alpha = 1) => {
51  for (let y = Math.floor(y0); y < Math.ceil(y1); y++) {
52    const coverY = Math.min(y + 1, y1) - Math.max(y, y0)
53    for (let x = Math.floor(x0); x < Math.ceil(x1); x++) {
54      const coverX = Math.min(x + 1, x1) - Math.max(x, x0)
55      blend(c, x, y, color, alpha * coverX * coverY)
56    }
57  }
58}
59
60/** Grows a coverage mask by a round-capped stroke from a to b, `radius` pixels either side. */
61const stroke = (mask: Float32Array, c: Canvas, ax: number, ay: number, bx: number, by: number, radius: number) => {
62  const reach = radius + 1
63  const dx = bx - ax
64  const dy = by - ay
65  const length2 = dx * dx + dy * dy
66  for (let y = Math.max(0, Math.floor(Math.min(ay, by) - reach)); y <= Math.min(c.height - 1, Math.ceil(Math.max(ay, by) + reach)); y++) {
67    for (let x = Math.max(0, Math.floor(Math.min(ax, bx) - reach)); x <= Math.min(c.width - 1, Math.ceil(Math.max(ax, bx) + reach)); x++) {
68      const px = x + 0.5 - ax
69      const py = y + 0.5 - ay
70      const t = length2 === 0 ? 0 : Math.max(0, Math.min(1, (px * dx + py * dy) / length2))
71      const cover = radius + 0.5 - Math.hypot(px - t * dx, py - t * dy)
72      const i = y * c.width + x
73      if (cover > (mask[i] ?? 0)) mask[i] = Math.min(1, cover)
74    }
75  }
76}
77
78const paint = (c: Canvas, mask: Float32Array, color: number) => {
79  for (let i = 0; i < mask.length; i++) {
80    const cover = mask[i] ?? 0
81    if (cover > 0) blend(c, i % c.width, Math.floor(i / c.width), color, cover)
82  }
83}
84
85const finish = (c: Canvas, columns: number, rows: number): Picture['plot'] => {
86  const native = (c.pixels as { toBase64?: () => string }).toBase64
87
88  return {
89    rgba: typeof native === 'function' ? native.call(c.pixels) : base64(c.pixels),
90    width: c.width,
91    height: c.height,
92    columns,
93    rows,
94  }
95}
96
97/** The tick labels of a plot `rows` tall, each centred on the row its gridline crosses. */
98const ticksOf = (rows: number, lo: number, hi: number, unit: string) => {
99  const texts = [fmt(hi, unit), fmt((hi + lo) / 2, unit), fmt(lo, unit)]
100  const width = Math.max(...texts.map(t => t.length)) + 1
101  const g = makeGrid(width, rows)
102  ;[0, (rows - 1) / 2, rows - 1].forEach((y, i) => write(g, width - 1 - (texts[i]?.length ?? 0), y, texts[i] ?? '', DIM))
103
104  return pack(g)
105}
106
107/** The three gridlines the tick labels name, the bottom one drawn as the axis. */
108const gridlines = (c: Canvas) => {
109  const thick = Math.max(1, c.cellW / 8)
110  const top = c.cellH / 2
111  const bottom = c.height - c.cellH / 2
112  rect(c, 0, top - thick / 2, c.width, top + thick / 2, AXIS, 0.22)
113  rect(c, 0, (top + bottom - thick) / 2, c.width, (top + bottom + thick) / 2, AXIS, 0.22)
114  rect(c, 0, bottom - thick / 2, c.width, bottom + thick / 2, AXIS, 0.75)
115}
116
117/** One row of x labels: `at` gives each label's centre cell; a label that would touch a placed one is left out. */
118const under = (indent: number, columns: number, labels: string[], picks: number[], at: (i: number) => number) => {
119  const g = makeGrid(indent + columns, 1)
120  const placed: [number, number][] = []
121  for (const i of picks) {
122    const label = clip(labels[i] ?? '', columns)
123    const start = Math.min(columns - label.length, Math.max(0, Math.round(at(i) - label.length / 2)))
124    const end = start + label.length
125    if (placed.some(([a, b]) => start <= b && end >= a)) continue
126    placed.push([start, end])
127    write(g, indent + start, 0, label, DIM)
128  }
129
130  return pack(g)
131}
132
133const line = (spec: ChartSpec, available: number, budget: number): Picture => {
134  const all = numbers(spec)
135  const min = Math.min(...all)
136  const max = Math.max(...all)
137  const step = niceStep((max - min || Math.abs(max) || 1) / 8)
138  let lo = Math.floor(min / step) * step
139  let hi = Math.ceil(max / step) * step
140  if (min >= 0 && min <= max - min) lo = 0
141  if (hi === lo) hi = lo + step
142  const rows = PLOT_ROWS
143  const side = ticksOf(rows, lo, hi, spec.unit)
144  const columns = Math.min(255, available - side.columns)
145  const c = canvas(columns, rows, budget)
146  gridlines(c)
147  const n = spec.labels.length
148  const top = c.cellH / 2
149  const bottom = c.height - c.cellH / 2
150  const xOf = (i: number) => (n === 1 ? c.width / 2 : c.cellW + (i * (c.width - 2 * c.cellW)) / (n - 1))
151  const yOf = (v: number) => top + ((hi - v) / (hi - lo)) * (bottom - top)
152  const radius = c.cellW / 7
153  // Drawn last to first, so the first series is on top where two cross.
154  for (let s = spec.series.length - 1; s >= 0; s--) {
155    const values = spec.series[s]?.values ?? []
156    const color = colorOf(s)
157    const mask = new Float32Array(c.width * c.height)
158    let before: [number, number] | undefined
159    values.forEach((v, i) => {
160      if (v === null) { before = undefined; return }
161      const here: [number, number] = [xOf(i), yOf(v)]
162      if (before) {
163        if (spec.series.length === 1) {
164          // The area under a lone series, fading toward the axis.
165          for (let x = Math.ceil(before[0]); x < here[0]; x++) {
166            const y = before[1] + ((x - before[0]) / (here[0] - before[0])) * (here[1] - before[1])
167            for (let row = Math.ceil(y); row < bottom; row++) blend(c, x, row, color, 0.28 * (1 - (row - y) / (bottom - y + 1)) + 0.04)
168          }
169        }
170        stroke(mask, c, before[0], before[1], here[0], here[1], radius)
171      }
172      if (n <= 40 || !before) stroke(mask, c, here[0], here[1], here[0], here[1], radius * (n <= 40 ? 2.1 : 1))
173      before = here
174    })
175    paint(c, mask, color)
176  }
177  const widest = Math.max(...spec.labels.map(l => l.length))
178  const count = Math.min(n, Math.max(2, Math.floor(columns / (widest + 2))))
179  const picks = n === 1 ? [0] : Array.from({ length: count }, (_, j) => Math.round((j * (n - 1)) / (count - 1)))
180
181  return {
182    side,
183    plot: finish(c, columns, rows),
184    under: under(side.columns, columns, spec.labels, [picks[picks.length - 1] ?? 0, ...picks.slice(0, -1)], i => xOf(i) / c.cellW),
185  }
186}
187
188const bars = (spec: ChartSpec, available: number, budget: number): Picture => {
189  const max = Math.max(0, ...numbers(spec))
190  const step = niceStep((max || 1) / 4)
191  const hi = Math.ceil((max || 1) / step) * step
192  const rows = PLOT_ROWS
193  const side = ticksOf(rows, 0, hi, spec.unit)
194  const columns = Math.min(255, available - side.columns)
195  const c = canvas(columns, rows, budget)
196  gridlines(c)
197  const n = spec.labels.length
198  const m = spec.series.length
199  const top = c.cellH / 2
200  const bottom = c.height - c.cellH / 2
201  const slot = c.width / n
202  const group = Math.min(slot * 0.74, m * 7 * c.cellW)
203  const gap = m > 1 ? Math.min(2, group / m / 6) : 0
204  spec.series.forEach((s, j) => {
205    s.values.forEach((v, i) => {
206      if (v === null || v <= 0) return
207      const left = i * slot + (slot - group) / 2 + (j * group) / m
208      const height = Math.max(1.5, (v / hi) * (bottom - top))
209      rect(c, left + gap / 2, bottom - height, left + group / m - gap / 2, bottom, colorOf(j))
210    })
211  })
212  const slotCells = columns / n
213  const widest = Math.max(...spec.labels.map(l => l.length))
214  const every = slotCells > widest ? 1 : Math.ceil((widest + 1) / slotCells)
215  const picks = Array.from({ length: Math.ceil(n / every) }, (_, k) => k * every)
216
217  return {
218    side,
219    plot: finish(c, columns, rows),
220    under: under(side.columns, columns, spec.labels, picks, i => (i + 0.5) * slotCells),
221  }
222}
223
224const hbars = (spec: ChartSpec, available: number, budget: number): Picture => {
225  const m = spec.series.length
226  const per = m > 1 ? m + 1 : 1
227  const n = Math.min(spec.labels.length, Math.floor(60 / per))
228  const rows = n * per - (m > 1 ? 1 : 0)
229  const max = Math.max(0, ...numbers(spec)) || 1
230  const names = Math.min(Math.max(...spec.labels.slice(0, n).map(l => l.length)), Math.floor(available * 0.35))
231  const texts = spec.series.map(s => s.values.map(v => (v === null ? '' : fmt(v, spec.unit))))
232  const widest = Math.max(...texts.flat().map(t => t.length))
233  const g = makeGrid(names + 1 + widest + 1, rows)
234  const columns = Math.max(4, Math.min(255, available - g.columns))
235  const c = canvas(columns, rows, budget)
236  for (let i = 0; i < n; i++) {
237    write(g, 0, i * per, clip(spec.labels[i] ?? '', names).padStart(names), DEFAULT)
238    spec.series.forEach((s, j) => {
239      const v = s.values[i]
240      if (v === null || v === undefined) return
241      const y = (i * per + j) * c.cellH
242      write(g, names + 1, i * per + j, (texts[j]?.[i] ?? '').padStart(widest), DIM)
243      if (v > 0) rect(c, 0, y + c.cellH * 0.16, Math.max(2, (v / max) * c.width), y + c.cellH * 0.84, colorOf(j))
244    })
245  }
246
247  return { side: pack(g), plot: finish(c, columns, rows) }
248}
249
250/** Whether the vertical bars of a spec have room: at least two cells a bar; a spec without it is drawn as lines. */
251const isBarsFitting = (spec: ChartSpec, columns: number) =>
252  (columns - 8) / (spec.labels.length * spec.series.length) >= 1.5
253
254export const picture = (spec: ChartSpec, available: number, budget = TREE_BUDGET): Picture => {
255  const columns = Math.min(120, Math.max(36, Math.floor(available)))
256  if (spec.kind === 'hbars') return hbars(spec, columns, budget)
257  if (spec.kind === 'bars' && isBarsFitting(spec, columns)) return bars(spec, columns, budget)
258
259  return line(spec, columns, budget)
260}
261
types/index.d.ts 22 lines
1export type ChartKind = 'line' | 'bars' | 'hbars'
2export type ChartSeries = { name: string; values: (number | null)[] }
3export type ChartSpec = {
4  title: string
5  kind: ChartKind
6  labels: string[]
7  series: ChartSeries[]
8  unit: string
9  note?: string
10}
11
12declare module 'claude-code' {
13  interface PluginState {
14    /** The charts the pane shows, newest first. */
15    charts: {
16      shown: ChartSpec[]
17      /** True once the surface said it draws an Image as its alt here: charts are then drawn in cells. */
18      isAltOnly: boolean
19    }
20  }
21}
22