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

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.
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).
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.
claude plugin validate .
claude plugin test .
claude --plugin-dir .
The mod API is early access and may change between Claude Code releases.
MIT
hooks/register.tsx 246 lines1import { 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}
246hooks/chart.ts 281 lines1import 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}
281hooks/picture.ts 261 lines1import { 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}
261types/index.d.ts 22 lines1export 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