A scratch pad to draw on with the mouse: /scratch opens it with the keyboard and pointer already in it, no click to focus.

Two mods for Claude Code, written with its function-hooks plugin API.
| Mod | What it does |
|---|---|
next-steps | A band above the prompt: three suggested next prompts and a usage line (cost, context, 5h/7d limit bars). |
scratch | A scratch pad you draw on with the mouse, opened with /scratch and already holding the keyboard; hands Claude the drawing as a PNG. |
At the prompt of a Claude Code terminal session:
/plugin install next-steps --marketplace Lutifya/claude-code-mods
/plugin install scratch --marketplace Lutifya/claude-code-mods
Answer y to add the marketplace, then pick a scope (user scope loads the mod in every session).
The function-hooks API is early access and moves between Claude Code releases; these mods were built and tested on Claude Code 2.1.296.
next:
1: Run the traversal test checklist
2: Commit the pending work
3: Wire in the story slideshow
0: dismiss
$0.01 · ctx 82.1k/1000.0k (8%) · 7d ██████████ 91% (resets 21h30m) · 5h ░░░░░░░░░░ 0% (resets 4h30m) · +8.7k +0% +$0.12 (last prompt)
$.model.fork), so the prefix comes from the prompt cache.1, 2 or 3 with the prompt empty (or click a suggestion) and it lands in the prompt box for you to edit and send; 0 dismisses the list. With text already in the box it is appended.Cost. Each main-thread turn makes one extra request: a cache read of the transcript plus about fifty output tokens on the session's model. It counts toward the limits the band draws.
Heads-up. A bare digit typed into an empty prompt goes to the band. If Claude asks "1 or 2?" and you answer by typing 1, you get the suggestion in the box instead; delete it and type again.
[█] █ █ █ █ █ █ █ erase -1+ undo clear
⢀⡠⠔⠒⠒⠢⢄⡀
⡰⠁ ⠈⢆ ⣀⠤⠒⠉⠉⠒⠤⣀
⢣⡀ ⢀⡜ ⠉⠁ ⠈⠉
⠈⠢⢄⣀⣀⡠⠔⠁
/scratch opens the pad with the keyboard and the pointer already in it: no click to focus first. It works while a turn is running too.erase, nib size - n +, undo, clear.1-8 color, e eraser, +/- size, u undo, c clear, Esc back to the prompt.~/.claude/scratch/ and puts an @ mention of it in the prompt, so Claude can open the picture. Each save deletes the sketches there older than a week; /scratch prune does it right away./scratch clear wipes it. The drawing persists across sessions (one pad, shared by every project).Needs the fullscreen terminal layout (see /config): only there does Claude Code receive mouse events. Terminals that report sub-cell pointer positions (Ghostty, iTerm2, kitty, WezTerm) put the pen on the very dot; elsewhere, tmux included, it snaps to the middle of each cell and the line between moves is filled in dot by dot.
Each mod is a plugin folder: .claude-plugin/plugin.json, hooks/hooks.json naming hooks/register.tsx, a state contract in types/index.d.ts, and tests.
claude plugin validate ./scratch
claude plugin test ./scratch
claude --plugin-dir ./scratch # a session with the mod loaded from this folder
MIT
hooks/register.tsx 160 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Canvas } from '../types'
5import { canvasToPng, toBase64 } from './png'
6
7const PANE = 'scratch'
8// 'dots': the braille canvas (the first, half-block one was 'canvas').
9const STORE_KEY = 'dots'
10// Where "→ prompt" writes the sketch for Claude to read, under $HOME.
11const SKETCH_DIR = '.claude/scratch'
12// Each save deletes the sketches older than this, so the folder stays small.
13const KEEP_DAYS = 7
14// Deletes the sketches in "$dir" older than KEEP_DAYS, printing each one gone.
15const PRUNE = `find "$dir" -name 'sketch-*.png' -mmin +${KEEP_DAYS * 24 * 60} -delete -print`
16const rev = atom({ plugin: 'scratch', key: 'rev' } as const, 0)
17
18export const register: Register = on => {
19 on('session.start', async ($, e, next) => {
20 const started = await next(e)
21 await $.command.register({
22 name: 'scratch',
23 description: `Open the drawing pad, mouse and keys already in it (/scratch clear wipes it, /scratch prune deletes sketches older than ${KEEP_DAYS} days)`,
24 argumentHint: '[clear|prune]',
25 immediate: true,
26 })
27
28 return started
29 })
30
31 on('command.run', { command: 'scratch' }, async ($, e) => {
32 if (e.args.trim() === 'clear') {
33 await $.store.set(STORE_KEY, [])
34 await update($, rev, n => n + 1)
35
36 return { text: 'Scratch pad cleared.' }
37 }
38 if (e.args.trim() === 'prune') {
39 return { text: await pruneSketches($) }
40 }
41 // `focus` plus moving the ring onto the canvas is what spares the click:
42 // the pad takes the keys and the first drag draws.
43 await $.ui.open({ id: PANE, title: 'Scratch', focus: true, rows: 22 })
44 try {
45 await $.ui.focus({ requestId: PANE, key: 'canvas' })
46 } catch {
47 // The pane opened without the keys (text in the composer): a click still draws.
48 }
49
50 return {}
51 })
52
53 // The pad posts its canvas at the end of every stroke, undo and clear.
54 on('ui.message', { requestId: PANE, element: 'canvas' }, async ($, e, next) => {
55 const canvas = asCanvas(e.data)
56 if (canvas !== undefined) {
57 await $.store.set(STORE_KEY, canvas)
58 }
59
60 return next(e)
61 })
62
63 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
64 if (e.surface !== 'terminal' && e.surface !== 'desktop') {
65 const { Text } = $.ui.resolve(e)
66
67 return <Text dimColor>The scratch pad draws in the terminal and the desktop app.</Text>
68 }
69 const { Box, Button, Client, Text } = $.ui.resolve(e)
70 const canvas = await loadCanvas($)
71 const version = await read($, rev)
72 const height = Math.max(4, e.props.scroll.bodyRows - 2)
73
74 const toPrompt = async () => {
75 const path = await saveSketch($, await loadCanvas($))
76 if (path === undefined) {
77 $.ui.toast('Nothing drawn yet.')
78
79 return
80 }
81 // The @ mention hands Claude the picture itself; the path alone would
82 // still let it open the file.
83 const box = await $.prompt.read()
84 await $.prompt.fill(box.text.trim() === '' ? { text: `@${path} ` } : { text: ` @${path} `, mode: 'append' })
85 }
86
87 return (
88 <Box flexDirection="column">
89 <Client key="canvas" module="./pad.tsx" props={{ canvas, rev: version }} width={e.props.bodyColumns} height={height} />
90 <Box flexDirection="row" gap={1}>
91 <Button key="to-prompt" label="→ prompt" onPress={() => void toPrompt()} />
92 <Text dimColor wrap="truncate-end">
93 drag draw · right-drag erase · 1-8 color · e eraser · +/- size · u undo · c clear · esc prompt
94 </Text>
95 </Box>
96 </Box>
97 )
98 })
99}
100
101async function loadCanvas($: EngineInterface): Promise<Canvas> {
102 return asCanvas(await $.store.get(STORE_KEY)) ?? []
103}
104
105// Writes the drawing as a PNG under ~/.claude/scratch and answers its path:
106// $.fs writes text only, so the bytes go through `base64 -d` on stdin. The
107// sketches past KEEP_DAYS go first; a failed cleanup doesn't stop the save.
108async function saveSketch($: EngineInterface, canvas: Canvas): Promise<string | undefined> {
109 const png = canvasToPng(canvas)
110 const home = await $.env.get('HOME')
111 if (png === undefined || home === undefined) {
112 return undefined
113 }
114 const stamp = new Date(await $.clock.now()).toISOString().replace(/[:.]/g, '-')
115 const path = `${home}/${SKETCH_DIR}/sketch-${stamp}.png`
116 const script = [
117 'dir=$(dirname "$1")',
118 'mkdir -p "$dir" || exit 1',
119 `${PRUNE} >/dev/null 2>&1`,
120 'base64 -d > "$1"',
121 ].join('\n')
122 const written = await $.process.run(['/bin/sh', '-c', script, 'sh', path], { stdin: toBase64(png) })
123 if (written.exitCode !== 0) {
124 $.ui.toast(`Could not write the sketch: ${written.stderr.trim().slice(0, 200)}`)
125
126 return undefined
127 }
128
129 return path
130}
131
132// `/scratch prune`: the cleanup a save does, now, and what it came to.
133async function pruneSketches($: EngineInterface): Promise<string> {
134 const home = await $.env.get('HOME')
135 if (home === undefined) {
136 return 'HOME is not set: no sketch folder to prune.'
137 }
138 const script = ['dir="$1"', '[ -d "$dir" ] || exit 0', PRUNE].join('\n')
139 const pruned = await $.process.run(['/bin/sh', '-c', script, 'sh', `${home}/${SKETCH_DIR}`])
140 const count = pruned.stdout.split('\n').filter(line => line !== '').length
141 const deleted = `Deleted ${count} ${count === 1 ? 'sketch' : 'sketches'} older than ${KEEP_DAYS} days`
142 if (pruned.exitCode !== 0) {
143 return `${count > 0 ? `${deleted}; ` : ''}could not delete the rest: ${pruned.stderr.trim().slice(0, 200)}`
144 }
145
146 return count === 0 ? `No sketches older than ${KEEP_DAYS} days.` : `${deleted}.`
147}
148
149function asCanvas(value: unknown): Canvas | undefined {
150 if (typeof value === 'object' && value !== null && 'canvas' in value) {
151 return asCanvas(value.canvas)
152 }
153 if (!Array.isArray(value) || !value.every(row => typeof row === 'string')) {
154 return undefined
155 }
156
157 return value.slice(0, 1000).map(row => row.slice(0, 2000))
158}
159
160hooks/png.ts 208 lines1// A drawing as a PNG, with no library: the environment has no zlib, so the
2// image data goes in stored (uncompressed) deflate blocks. Palette-indexed,
3// one byte a pixel, so a sketch stays a few hundred kilobytes.
4
5import type { Canvas } from '../types'
6
7// The pad's palette as RGB for a white page: index 0 is the paper, 1-8 the
8// pad's colors in order ('text' becomes ink black).
9const RGB: readonly (readonly [number, number, number])[] = [
10 [255, 255, 255],
11 [24, 24, 27],
12 [220, 38, 38],
13 [202, 138, 4],
14 [22, 163, 74],
15 [8, 145, 178],
16 [37, 99, 235],
17 [192, 38, 211],
18 [113, 113, 122],
19]
20
21const MARGIN_DOTS = 6
22const MAX_SIDE_PX = 1200
23
24// Renders the dots of `canvas` (one string per dot row, '1'-'8' inked) as
25// round strokes on white, cropped to the drawing; undefined when it is empty.
26export function canvasToPng(canvas: Canvas): Uint8Array | undefined {
27 let left = Infinity
28 let right = -1
29 let top = Infinity
30 let bottom = -1
31 canvas.forEach((row, y) => {
32 for (let x = 0; x < row.length; x += 1) {
33 if (row[x] !== ' ') {
34 left = Math.min(left, x)
35 right = Math.max(right, x)
36 top = Math.min(top, y)
37 bottom = Math.max(bottom, y)
38 }
39 }
40 })
41 if (right < 0) {
42 return undefined
43 }
44 left -= MARGIN_DOTS
45 top -= MARGIN_DOTS
46 const dotsWide = right + MARGIN_DOTS - left + 1
47 const dotsTall = bottom + MARGIN_DOTS - top + 1
48 const scale = Math.max(2, Math.min(10, Math.floor(MAX_SIDE_PX / Math.max(dotsWide, dotsTall))))
49 const width = dotsWide * scale
50 const height = dotsTall * scale
51 const pixels = new Uint8Array(width * height)
52 const inkAt = (x: number, y: number) => Number(canvas[y]?.[x] ?? ' ')
53 // Each dot is a round nib, joined to every inked neighbour (across, down and
54 // both diagonals) by a stroke as wide: the dots read as one smooth line.
55 const radius = scale * 0.55
56 const stroke = (x0: number, y0: number, x1: number, y1: number, ink: number) => {
57 const [ax, ay, bx, by] = [(x0 - left + 0.5) * scale, (y0 - top + 0.5) * scale, (x1 - left + 0.5) * scale, (y1 - top + 0.5) * scale]
58 const length2 = (bx - ax) ** 2 + (by - ay) ** 2
59 for (let py = Math.max(0, Math.floor(Math.min(ay, by) - radius)); py < Math.min(height, Math.ceil(Math.max(ay, by) + radius)); py += 1) {
60 for (let px = Math.max(0, Math.floor(Math.min(ax, bx) - radius)); px < Math.min(width, Math.ceil(Math.max(ax, bx) + radius)); px += 1) {
61 const [cx, cy] = [px + 0.5, py + 0.5]
62 const t = length2 === 0 ? 0 : Math.max(0, Math.min(1, ((cx - ax) * (bx - ax) + (cy - ay) * (by - ay)) / length2))
63 if ((cx - ax - t * (bx - ax)) ** 2 + (cy - ay - t * (by - ay)) ** 2 <= radius * radius) {
64 pixels[py * width + px] = ink
65 }
66 }
67 }
68 }
69 canvas.forEach((row, y) => {
70 for (let x = 0; x < row.length; x += 1) {
71 const ink = inkAt(x, y)
72 if (!(ink >= 1 && ink <= 8)) {
73 continue
74 }
75 stroke(x, y, x, y, ink)
76 for (const [dx, dy] of [[1, 0], [0, 1], [1, 1], [-1, 1]] as const) {
77 // A diagonal already joined through a shared neighbour would only
78 // thicken the corner of a staircase.
79 const isJoined = dx !== 0 && dy !== 0 && (inkAt(x + dx, y) === ink || inkAt(x, y + dy) === ink)
80 if (inkAt(x + dx, y + dy) === ink && !isJoined) {
81 stroke(x, y, x + dx, y + dy, ink)
82 }
83 }
84 }
85 })
86
87 return encodeIndexed(width, height, pixels)
88}
89
90function encodeIndexed(width: number, height: number, pixels: Uint8Array): Uint8Array {
91 // Each scanline leads with filter type 0 (none).
92 const raw = new Uint8Array((width + 1) * height)
93 for (let y = 0; y < height; y += 1) {
94 raw.set(pixels.subarray(y * width, (y + 1) * width), y * (width + 1) + 1)
95 }
96 const header = new Uint8Array(13)
97 const view = new DataView(header.buffer)
98 view.setUint32(0, width)
99 view.setUint32(4, height)
100 header.set([8, 3, 0, 0, 0], 8) // 8-bit, indexed color, deflate, no filter, no interlace
101
102 return concat([
103 new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
104 chunk('IHDR', header),
105 chunk('PLTE', new Uint8Array(RGB.flat())),
106 chunk('IDAT', zlibStored(raw)),
107 chunk('IEND', new Uint8Array(0)),
108 ])
109}
110
111function zlibStored(data: Uint8Array): Uint8Array {
112 const blocks: Uint8Array[] = [new Uint8Array([0x78, 0x01])]
113 for (let at = 0; at < data.length || at === 0; at += 65_535) {
114 const part = data.subarray(at, Math.min(data.length, at + 65_535))
115 const isLast = at + 65_535 >= data.length
116 const head = new Uint8Array(5)
117 head[0] = isLast ? 1 : 0
118 head[1] = part.length & 0xff
119 head[2] = part.length >> 8
120 head[3] = ~part.length & 0xff
121 head[4] = (~part.length >> 8) & 0xff
122 blocks.push(head, part)
123 if (isLast) {
124 break
125 }
126 }
127 const sum = new Uint8Array(4)
128 new DataView(sum.buffer).setUint32(0, adler32(data))
129 blocks.push(sum)
130
131 return concat(blocks)
132}
133
134function chunk(type: string, body: Uint8Array): Uint8Array {
135 const out = new Uint8Array(12 + body.length)
136 const view = new DataView(out.buffer)
137 view.setUint32(0, body.length)
138 for (let i = 0; i < 4; i += 1) {
139 out[4 + i] = type.charCodeAt(i)
140 }
141 out.set(body, 8)
142 view.setUint32(8 + body.length, crc32(out.subarray(4, 8 + body.length)))
143
144 return out
145}
146
147const CRC_TABLE = (() => {
148 const table = new Uint32Array(256)
149 for (let n = 0; n < 256; n += 1) {
150 let c = n
151 for (let k = 0; k < 8; k += 1) {
152 c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
153 }
154 table[n] = c >>> 0
155 }
156
157 return table
158})()
159
160function crc32(bytes: Uint8Array): number {
161 let c = 0xffffffff
162 for (const byte of bytes) {
163 c = (CRC_TABLE[(c ^ byte) & 0xff] ?? 0) ^ (c >>> 8)
164 }
165
166 return (c ^ 0xffffffff) >>> 0
167}
168
169function adler32(bytes: Uint8Array): number {
170 let a = 1
171 let b = 0
172 for (const byte of bytes) {
173 a = (a + byte) % 65_521
174 b = (b + a) % 65_521
175 }
176
177 return ((b << 16) | a) >>> 0
178}
179
180function concat(parts: readonly Uint8Array[]): Uint8Array {
181 const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0))
182 let at = 0
183 for (const part of parts) {
184 out.set(part, at)
185 at += part.length
186 }
187
188 return out
189}
190
191const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
192
193export function toBase64(bytes: Uint8Array): string {
194 let out = ''
195 for (let i = 0; i < bytes.length; i += 3) {
196 const a = bytes[i] ?? 0
197 const b = bytes[i + 1] ?? 0
198 const c = bytes[i + 2] ?? 0
199 const n = (a << 16) | (b << 8) | c
200 out += ALPHABET[(n >> 18) & 63]
201 out += ALPHABET[(n >> 12) & 63]
202 out += i + 1 < bytes.length ? ALPHABET[(n >> 6) & 63] : '='
203 out += i + 2 < bytes.length ? ALPHABET[n & 63] : '='
204 }
205
206 return out
207}
208hooks/pad.tsx 280 lines1import type { ClientKeyEvent, ClientModule, ClientPointerEvent, ClientSurface, RenderChildren } from 'claude-code'
2
3import type { Canvas, PadProps } from '../types'
4
5// Palette index 1..8; `text` follows the theme, so ink shows on light and dark.
6const PALETTE = ['text', 'red', 'yellow', 'green', 'cyan', 'blue', 'magenta', 'gray']
7const MAX_SIZE = 5
8const UNDO_DEPTH = 30
9// Each cell is a braille character: 2 dots across, 4 down.
10const DOTS_X = 2
11const DOTS_Y = 4
12// The braille bit of the dot at (x, y) within its cell (U+2800 + bits).
13const BRAILLE_BITS = [
14 [0x01, 0x08],
15 [0x02, 0x10],
16 [0x04, 0x20],
17 [0x40, 0x80],
18]
19
20type Point = { x: number; y: number }
21
22type Pad = {
23 canvas: Canvas
24 rev: number
25 color: number
26 size: number
27 isErasing: boolean
28 undo: Canvas[]
29 // The stroke under way: its last point and whether it erases.
30 stroke?: { last: Point; erases: boolean }
31}
32
33type Surface = ClientSurface<Pad>
34
35// One toolbar entry on the pad's first row, hit-tested by column.
36type Tool = { text: string; color?: string; isOn?: boolean; act: (pad: Pad) => Pad }
37
38// Kept narrow (46 columns) so a docked pane still reaches "clear".
39const toolbar = (pad: Pad): Tool[] => [
40 ...PALETTE.map((color, i) => ({
41 text: pad.color === i + 1 && !pad.isErasing ? '[█]' : ' █ ',
42 color,
43 act: (one: Pad) => ({ ...one, color: i + 1, isErasing: false }),
44 })),
45 { text: ' erase ', isOn: pad.isErasing, act: one => ({ ...one, isErasing: !one.isErasing }) },
46 { text: ' -', act: one => ({ ...one, size: Math.max(1, one.size - 1) }) },
47 { text: String(pad.size), act: one => one },
48 { text: '+ ', act: one => ({ ...one, size: Math.min(MAX_SIZE, one.size + 1) }) },
49 { text: 'undo ', act: undo },
50 { text: 'clear', act: clear },
51]
52
53const Pad: ClientModule<PadProps, Pad> = (props, surface) => {
54 const { Box, Text } = surface.elements
55 if (surface.state === undefined || surface.state.rev !== props.rev) {
56 if (surface.state === undefined) {
57 surface.onPointer(event => pointer(surface, event))
58 surface.onKey(event => key(surface, event))
59 }
60 surface.setState({
61 canvas: props.canvas,
62 rev: props.rev,
63 color: surface.state?.color ?? 1,
64 size: surface.state?.size ?? 1,
65 isErasing: false,
66 undo: [],
67 })
68 }
69 const pad = surface.state ?? { canvas: props.canvas, rev: props.rev, color: 1, size: 1, isErasing: false, undo: [] }
70 const columns = surface.columns
71 const rows = Math.max(0, surface.rows - 1)
72
73 const tools = toolbar(pad).map((tool, i) => (
74 <Text key={`tool${i}`} color={tool.color} inverse={tool.isOn === true} dimColor={tool.color === undefined && !tool.isOn}>
75 {tool.text}
76 </Text>
77 ))
78
79 const lines: RenderChildren[] = []
80 for (let row = 0; row < rows; row += 1) {
81 lines.push(
82 <Text key={`row${row}`} wrap="truncate-end">
83 {runs(pad.canvas, row, columns).map((run, i) => (
84 <Text key={`run${i}`} color={run.color}>
85 {run.text}
86 </Text>
87 ))}
88 </Text>,
89 )
90 }
91
92 return (
93 <Box flexDirection="column">
94 <Text wrap="truncate-end">{tools}</Text>
95 {lines}
96 </Box>
97 )
98}
99
100export default Pad
101
102function pointer(surface: Surface, event: ClientPointerEvent) {
103 const pad = surface.state
104 if (pad === undefined) {
105 return
106 }
107 if (event.type === 'down' && event.y === 0) {
108 const tool = toolAt(pad, event.x)
109 if (tool !== undefined) {
110 const next = tool.act(pad)
111 surface.setState(next)
112 if (next.canvas !== pad.canvas) {
113 surface.post({ canvas: next.canvas })
114 }
115 }
116
117 return
118 }
119 if (event.type === 'down' && event.button !== undefined) {
120 const at = dot(surface, event)
121 const erases = event.button === 'right' || pad.isErasing
122 const started = { ...pad, undo: [...pad.undo, pad.canvas].slice(-UNDO_DEPTH) }
123 const stroke = { last: at, erases }
124 surface.setState({ ...started, canvas: paint(started, stroke, at), stroke })
125
126 return
127 }
128 if (event.type === 'move' && event.button !== undefined && pad.stroke !== undefined) {
129 const at = dot(surface, event)
130 surface.setState({ ...pad, canvas: line(pad, pad.stroke, pad.stroke.last, at), stroke: { ...pad.stroke, last: at } })
131
132 return
133 }
134 if (event.type === 'up' && pad.stroke !== undefined) {
135 surface.setState({ ...pad, stroke: undefined })
136 surface.post({ canvas: pad.canvas })
137 }
138}
139
140function key(surface: Surface, event: ClientKeyEvent) {
141 const pad = surface.state
142 if (pad === undefined || event.ctrl === true || event.meta === true) {
143 return
144 }
145 const digit = Number(event.key)
146 if (Number.isInteger(digit) && digit >= 1 && digit <= PALETTE.length) {
147 surface.setState({ ...pad, color: digit, isErasing: false })
148 } else if (event.key === 'e') {
149 surface.setState({ ...pad, isErasing: !pad.isErasing })
150 } else if (event.key === '+' || event.key === '=') {
151 surface.setState({ ...pad, size: Math.min(MAX_SIZE, pad.size + 1) })
152 } else if (event.key === '-') {
153 surface.setState({ ...pad, size: Math.max(1, pad.size - 1) })
154 } else if (event.key === 'u' || event.key === 'c') {
155 const next = event.key === 'u' ? undo(pad) : clear(pad)
156 surface.setState(next)
157 // The hooks module keeps the canvas as it now is.
158 surface.post({ canvas: next.canvas })
159 }
160}
161
162function undo(pad: Pad): Pad {
163 const previous = pad.undo[pad.undo.length - 1]
164
165 return previous === undefined ? pad : { ...pad, canvas: previous, undo: pad.undo.slice(0, -1) }
166}
167
168function clear(pad: Pad): Pad {
169 return pad.canvas.length === 0 ? pad : { ...pad, canvas: [], undo: [...pad.undo, pad.canvas].slice(-UNDO_DEPTH) }
170}
171
172function toolAt(pad: Pad, x: number): Tool | undefined {
173 let start = 0
174 for (const tool of toolbar(pad)) {
175 if (x >= start && x < start + tool.text.length) {
176 return tool
177 }
178 start += tool.text.length
179 }
180
181 return undefined
182}
183
184// The dot under the pointer, the toolbar's row above the canvas. Where the
185// terminal reports sub-cell positions (Ghostty, iTerm2, kitty, WezTerm) the
186// pen lands on the very dot; elsewhere on the cell's middle, and the line
187// between two moves still runs dot by dot. A drag held past the region's
188// edges keeps to them.
189function dot(surface: Surface, event: ClientPointerEvent): Point {
190 const x = event.fine === undefined ? event.x * DOTS_X : Math.floor(event.fine.x * DOTS_X)
191 const y = event.fine === undefined ? (event.y - 1) * DOTS_Y + 1 : Math.floor((event.fine.y - 1) * DOTS_Y)
192 const right = Math.max(0, surface.columns * DOTS_X - 1)
193 const bottom = Math.max(0, (surface.rows - 1) * DOTS_Y - 1)
194
195 return { x: Math.min(right, Math.max(0, x)), y: Math.min(bottom, Math.max(0, y)) }
196}
197
198function line(pad: Pad, stroke: NonNullable<Pad['stroke']>, from: Point, to: Point): Canvas {
199 let canvas = pad.canvas
200 const dx = Math.abs(to.x - from.x)
201 const dy = Math.abs(to.y - from.y)
202 const steps = Math.max(dx, dy, 1)
203 for (let i = 0; i <= steps; i += 1) {
204 const point = {
205 x: Math.round(from.x + ((to.x - from.x) * i) / steps),
206 y: Math.round(from.y + ((to.y - from.y) * i) / steps),
207 }
208 canvas = paint({ ...pad, canvas }, stroke, point)
209 }
210
211 return canvas
212}
213
214// A round nib: size 1 is one dot, each size up widens it by a dot a side.
215function paint(pad: Pad, stroke: NonNullable<Pad['stroke']>, at: Point): Canvas {
216 const ink = stroke.erases ? ' ' : String(pad.color)
217 const reach = (stroke.erases ? pad.size + 1 : pad.size) - 1
218 const canvas = [...pad.canvas]
219 for (let y = Math.max(0, at.y - reach); y <= at.y + reach; y += 1) {
220 while (canvas.length <= y) {
221 canvas.push('')
222 }
223 let row = canvas[y] ?? ''
224 for (let x = Math.max(0, at.x - reach); x <= at.x + reach; x += 1) {
225 if ((x - at.x) ** 2 + (y - at.y) ** 2 <= reach * reach + reach) {
226 row = row.padEnd(x + 1, ' ')
227 row = row.slice(0, x) + ink + row.slice(x + 1)
228 }
229 }
230 canvas[y] = row.trimEnd()
231 }
232
233 return canvas
234}
235
236type Run = { text: string; color?: string }
237
238// One cell row as braille characters, in runs of one color: a cell takes the
239// color most of its dots have.
240function runs(canvas: Canvas, row: number, columns: number): Run[] {
241 const out: Run[] = []
242 for (let column = 0; column < columns; column += 1) {
243 let bits = 0
244 const counts = new Map<string, number>()
245 for (let dy = 0; dy < DOTS_Y; dy += 1) {
246 const line = canvas[row * DOTS_Y + dy] ?? ''
247 for (let dx = 0; dx < DOTS_X; dx += 1) {
248 const ink = line[column * DOTS_X + dx]
249 if (ink !== undefined && ink !== ' ') {
250 bits |= BRAILLE_BITS[dy]?.[dx] ?? 0
251 counts.set(ink, (counts.get(ink) ?? 0) + 1)
252 }
253 }
254 }
255 let top: string | undefined
256 for (const [ink, count] of counts) {
257 if (top === undefined || count > (counts.get(top) ?? 0)) {
258 top = ink
259 }
260 }
261 const cell: Run = bits === 0 ? { text: ' ' } : { text: String.fromCharCode(0x2800 + bits), color: colorOf(top) }
262 const last = out[out.length - 1]
263 if (last !== undefined && last.color === cell.color) {
264 last.text += cell.text
265 } else {
266 out.push(cell)
267 }
268 }
269
270 return out
271}
272
273function colorOf(ink: string | undefined): string | undefined {
274 if (ink === undefined || ink === ' ') {
275 return undefined
276 }
277
278 return PALETTE[Number(ink) - 1]
279}
280types/index.d.ts 17 lines1// The drawing, one string per dot row: ' ' is empty, '1'..'8' a palette
2// color. A terminal cell holds 2 × 4 dots, drawn as one braille character.
3export type Canvas = string[]
4
5// What the hooks module hands the pad's surface module as its props.
6export type PadProps = { canvas: Canvas; rev: number }
7
8declare module 'claude-code' {
9 interface PluginState {
10 scratch: {
11 // Bumped when the drawing changes from outside the pad (/scratch clear),
12 // so a pad on screen drops what it holds and takes the new canvas.
13 rev: number
14 }
15 }
16}
17