SLOPSHOPPER

scratch

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

newpanecommandtoastprocess
v0.1.0MITupdated 2026-10-13Lutifya/claude-code-mods/scratch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · scratch
│ ┃ Scratch ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./pad.tsx │ ┃ [ → prompt ] drag draw · right-drag erase · ⏺ 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 │ │ › /scratch │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Scratch
▣ client module ./pad.tsx [ → prompt ] drag draw · right-drag erase · 1-8 color · e er
README

claude-code-mods

Two mods for Claude Code, written with its function-hooks plugin API.

ModWhat it does
next-stepsA band above the prompt: three suggested next prompts and a usage line (cost, context, 5h/7d limit bars).
scratchA scratch pad you draw on with the mouse, opened with /scratch and already holding the keyboard; hands Claude the drawing as a PNG.

Install

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-steps

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)
  • Suggestions. When a turn of the main conversation ends, the mod asks the session's model for three next prompts, over the session's own transcript ($.model.fork), so the prefix comes from the prompt cache.
  • Picking one. Press 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.
  • Usage line. Session cost, context fill, and the 7-day and 5-hour rate-limit windows with their reset countdowns (green under 60%, amber to 90%, red above). The bars show only on a subscription that reports them.
  • (last prompt). What changed since your last prompt went in: context tokens added, 5-hour points used, dollars spent.

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.

scratch

[█] █  █  █  █  █  █  █  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.
  • Mouse. Left-drag draws, right-drag erases. Each cell is a braille character of 2 × 4 dots, so a line is one dot thin. The top row is a toolbar: eight colors, erase, nib size - n +, undo, clear.
  • Keys. 1-8 color, e eraser, +/- size, u undo, c clear, Esc back to the prompt.
  • → prompt saves the drawing as a PNG (white page, smooth strokes, cropped to what you drew) under ~/.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.

Develop

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

License

MIT

Source 4 files
hooks/register.tsx 160 lines
1import { 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
160
hooks/png.ts 208 lines
1// 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}
208
hooks/pad.tsx 280 lines
1import 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}
280
types/index.d.ts 17 lines
1// 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