Throwaway gallery: draws everything this Claude Code build can draw

<h1> <img src="brand/lockup-light.svg" alt="ostrakon" width="300"> </h1>
A scratchpad for Claude Code. Halfway through a prompt, something else comes to mind: open the pane, note it, close the pane, and your prompt is back as you left it. Later, pull the note into the prompt when you get to it. Claude can jot notes for you too.
In the demo: note something mid-prompt, then add a saved note to the prompt.
<img src="docs/images/demo.gif" alt="Half a prompt typed in Claude Code; the ostrakon pane opens and the prompt empties, a note is typed and saved, and when the pane closes the prompt is back as it was. Then the pane opens again, a search finds a note Claude jotted, and t adds it to the prompt under the draft">
Notes are kept per project, and the pane shows all of them. Syncing between machines and a phone app are optional.
ctrl+x s opens the pane and ctrl+n starts a note; close the pane and your draft is back. In an empty prompt, /jot text or // saves one without opening anything.jot tool: Claude jots notes it thinks are important or wants to pass on to you. You can also ask it to add one: "jot that down". Claude can also list your notes and archive one once it's done.t: it goes in under what you've typed.Notes are plain Markdown files with some YAML. One per note, in ~/.local/share/ostrakon/ by default, so they are yours to read, grep and back up.

Sync is optional and works both ways. Add, edit, move or archive a note on one machine or on your phone, and the change shows up everywhere else. If the same note changes in two places, the later change wins everywhere. There are a few ways to sync; use one or combine them:
server/): a small service you run on one of your machines. Your other machines and your phone reach it over Tailscale (the easy option) or your own HTTPS proxy, tunnel or VPN. Changes show up in other sessions within seconds, and the hub also serves the phone app. Setup: the hub and the phone app.mirrorDir option): ostrakon keeps a copy of your notes in a folder, and whatever syncs that folder (Dropbox, Syncthing, any synced drive) carries them between machines. Point it at an Obsidian vault and you can edit notes there too; your edits come back. Obsidian shows each note's metadata as Properties at the top; to hide them, set Settings → Editor → Properties in document to Hidden. No server needed.On your phone, the hub's web app installs to the Home Screen and opens straight to a note field. You can triage there too (edit, move, archive, mark sent), and anything you capture offline is kept until the hub is reachable again. To pair a phone, scan the QR code that ostrakon pair shows. An iOS Shortcut can send a note from the share sheet or Siri without opening the app. Details: docs/phone.md.
There are two ways to install it:
ostrakon command: update, doctor, pair, and the hub for syncing and the phone app. Updates come through ostrakon update.ostrakon command, so you can't run the hub or pair a phone from it; folder sync, and a hub set up on another machine, still work. Updates come through Claude Code.Use one or the other. The install script offers to add the keybindings; with the marketplace, add them yourself.
ostrakon pair, and qrencode for the pairing QR code. install.sh --hub sets the hub up as a systemd service, so that part is Linux-only for now; elsewhere, run ostrakon hub however you like to keep things running.Tested on Linux. macOS should work for the mod but hasn't been tried yet; reports are welcome.
One command clones ostrakon into ~/.local/share/ostrakon-src and installs the newest release:
curl -fsSL https://raw.githubusercontent.com/gaius-codius/ostrakon/main/get.sh | bash
Add -s -- --hub after bash to set up the hub too, or -s -- --main to follow the newest code on main instead of releases. Running it again updates what's there. To read the script before running it: curl -fsSL https://raw.githubusercontent.com/gaius-codius/ostrakon/main/get.sh -o get.sh, look it over, then bash get.sh.
Or clone the repo wherever you like and run the install script yourself:
git clone https://github.com/gaius-codius/ostrakon.git
cd ostrakon && ./install.sh --release
Either way, the install script:
--release to run what's on main),CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json (only if it isn't set already, and it keeps a .bak copy),ostrakon command to ~/.local/bin.It's safe to run again. ./install.sh --hub also sets up the hub (below).
To try it without installing anything, load it for a single session: claude --plugin-dir ostrakon/plugin. To install by hand, add the folder to ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/ostrakon/plugin" } }
claude plugin marketplace add gaius-codius/ostrakon
claude plugin install ostrakon@ostrakon
Update it with claude plugin update ostrakon@ostrakon, or turn on auto-update for the marketplace in /plugin. A new copy arrives with each release. If you also run the install script, it sees the plugin and doesn't load the mod a second time.
ostrakon update # or ./update.sh in the repo
ostrakon update --main # switch to the newest code on main instead of releases
ostrakon update --release # switch back to releases
ostrakon version
If you installed a release, update moves you to the newest one; if you're on a branch, it pulls that branch (and stops rather than touch commits of your own). It restarts the hub if it's running and lists what changed. Restart any open Claude Code sessions to pick up the new version. Each release's changes are in CHANGELOG.md.
ostrakon doctor
It checks that the mod loads, which Claude Code version last ran it and whether anything went wrong there, your notes folder, the hub and its token, and the tools ostrakon needs, and says what to fix. It changes nothing and never prints your token, so its output is safe to paste into a bug report.
A mod can't add keyboard shortcuts of its own, so ostrakon borrows some existing Claude Code actions (which is why the names below look unrelated). The install script asks whether to add these to ~/.claude/keybindings.json (--keys adds them without asking, --no-keys skips them). It leaves alone any key you've bound to something else, and any of these actions you've already given a key. To add them by hand:
{
"bindings": [
{ "context": "Chat", "bindings": { "ctrl+x s": "app:cycleDiffBase" } },
{
"context": "Pane",
"bindings": {
"ctrl+x s": "app:cycleDiffBase", "ctrl+q": "pane:previous", "ctrl+f": "app:toggleDiffNoiseFilter",
"ctrl+s": "app:toggleDiffPreSession", "ctrl+n": "pane:next"
}
},
{
"context": "PaneField",
"bindings": {
"ctrl+x s": "app:cycleDiffBase", "ctrl+q": "pane:previous", "ctrl+f": "app:toggleDiffNoiseFilter",
"ctrl+s": "app:toggleDiffPreSession", "ctrl+n": "pane:next"
}
}
]
}
ctrl+x s opens and closes the pane; the other keys work inside it. /jot and // need no setup. The full key list is in docs/SPEC.md §4.
Set these under the plugin's options (pluginConfigs.ostrakon.options in your settings, or pluginConfigs["ostrakon@ostrakon"].options if you installed it with claude plugin install):
| Option | Default | What it does |
|---|---|---|
notesDir | ~/.local/share/ostrakon | Where notes are stored. The OSTRAKON_DIR environment variable overrides it. |
pocket / pocketMax | on / 5 | The numbered chips above the prompt, and how many show. |
harvest | off | Review each finished turn for side issues worth keeping. |
harvestMode | fork | fork reviews the whole conversation, tool output included, so it sees more but costs more as the conversation grows. light has a small model read only your prompt and Claude's final answer: much cheaper, but it misses more. |
harvestMaxPerTurn | 3 | The most notes harvest offers after one turn. |
hubUrl / hubToken | empty | The hub to sync with, and its token. Empty: no hub. |
mirrorDir | empty | A folder to keep a synced copy of your notes in, such as a Dropbox or Syncthing folder or part of an Obsidian vault. |
The hub is a single Bun file (server/hub.ts) that stores notes in SQLite. It only listens on the machine it runs on and needs a token for every request, so you choose how other devices reach it. We use Tailscale, the simplest option: tailscale serve gives it an HTTPS address only your devices can reach, and install.sh and ostrakon pair set it up for you.
./install.sh --hub
This creates a token in ~/.config/ostrakon-hub/env (if there isn't one yet) and installs and starts the hub as a systemd user service (server/ostrakon-hub.service). It then prints the two steps left for you: loginctl enable-linger, so the hub keeps running when you log out, and tailscale serve, to put it on your tailnet.
To pair a phone, run ostrakon pair (it needs qrencode) and scan the QR code. The code in it works once and expires after 5 minutes, so the QR never contains the token itself; --token prints a link that does.
To run the hub in the foreground instead: HUB_TOKEN=$(openssl rand -hex 24) ostrakon hub. The ostrakon command reads HUB_TOKEN from the environment or from ~/.config/ostrakon-hub/env.
Not using Tailscale? Any HTTPS reverse proxy, tunnel or VPN works too; see Open it.
The service runs ~/.local/bin/ostrakon hub, so it doesn't matter where the repo lives. Phone setup, the Home Screen app and the iOS Shortcut are covered in docs/phone.md. Read SECURITY.md before making the hub reachable from other devices.
These come from Claude Code's mods interface, which is still new, and should go away as it matures.
ostrakon doctor and mention your Claude Code version when you report it.More, including a couple of folder-sync edge cases, in ROADMAP.md.
docs/SPEC.md. How it looks: docs/DESIGN.md. Ideas for what's next: ROADMAP.md.tools/check.sh runs every check: claude plugin validate, the design check (docs/DESIGN.md against the code), the type-check, claude plugin test and the hub's tests. The mod's tests and type-check need the claude CLI installed, because the mods test kit and types ship with it.cd server && bun test.CONTRIBUTING.md.tools/ also has the screenshot pipeline and some experiment scripts; they write to working-docs/, which git ignores.hooks/register.tsx 546 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register } from 'claude-code'
3
4// Throwaway gallery of everything the terminal surface can draw.
5// /gallery <boxes|text|wrap|controls|rich|graphics|client|band>
6const page = atom({ plugin: 'spgallery', key: 'page' } as const, 'boxes')
7const last = atom({ plugin: 'spgallery', key: 'last' } as const, '(nothing pressed yet)')
8const bandOn = atom({ plugin: 'spgallery', key: 'band' } as const, false)
9const hintMode = atom({ plugin: 'spgallery', key: 'hint' } as const, 'off')
10
11const PANE = 'gallery'
12const PAGES = ['boxes', 'overlay', 'colours', 'text', 'wrap', 'controls', 'rich', 'code', 'graphics', 'client', 'band']
13type E = Elements['terminal']
14
15const THEME_KEYS = [
16 'text', 'inverseText', 'inactive', 'subtle', 'suggestion', 'remember', 'success', 'error',
17 'warning', 'merged', 'claude', 'permission', 'planMode', 'autoAccept', 'promptBorder',
18 'bashBorder', 'ide', 'diffAdded', 'diffRemoved', 'diffAddedDimmed', 'diffRemovedDimmed',
19 'diffAddedWord', 'diffRemovedWord',
20]
21const BORDERS = ['single', 'double', 'round', 'bold', 'singleDouble', 'doubleSingle', 'classic', 'arrow', 'dashed', 'quote', 'sparkles']
22
23const rec = ($: EngineInterface, text: string) => update($, last, () => text)
24
25// ---- raster / image helpers (pure) ----
26const b64 = (bytes: Uint8Array): string => {
27 const any = bytes as unknown as { toBase64?: () => string }
28 if (typeof any.toBase64 === 'function') return any.toBase64()
29 let s = ''
30 for (const b of bytes) s += String.fromCharCode(b)
31 return btoa(s)
32}
33const DEFAULT = 0x01000000
34const packCells = (cells: Array<[number, number, number]>): string => {
35 const words = new Uint32Array(cells.length * 3)
36 cells.forEach(([cp, fg, bg], i) => {
37 words[i * 3] = cp
38 words[i * 3 + 1] = fg
39 words[i * 3 + 2] = bg
40 })
41 return b64(new Uint8Array(words.buffer))
42}
43const rgb = (r: number, g: number, b: number) => (r << 16) | (g << 8) | b
44const BLOCKS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
45const SERIES = [2, 3, 5, 4, 7, 9, 6, 3, 2, 1, 4, 8, 10, 12, 9, 7, 5, 6, 8, 11, 14, 12, 9, 6, 4, 3, 5, 7, 10, 13, 15, 12, 8, 5, 3, 2, 1, 2, 4, 6]
46const sparkline = (): string => {
47 const max = Math.max(...SERIES)
48 return packCells(
49 SERIES.map(v => {
50 const lvl = Math.min(7, Math.floor((v / max) * 8))
51 return [BLOCKS[lvl]!.codePointAt(0)!, rgb(97, 175, 239), DEFAULT] as [number, number, number]
52 }),
53 )
54}
55// two-row sparkline using background colour: top row inverse half blocks
56const heatStrip = (cols: number, rows: number): string => {
57 const cells: Array<[number, number, number]> = []
58 for (let y = 0; y < rows; y++) {
59 for (let x = 0; x < cols; x++) {
60 const t = (x / (cols - 1)) * 0.7 + (y / Math.max(1, rows - 1)) * 0.3
61 const r = Math.round(40 + 215 * t)
62 const g = Math.round(180 - 140 * Math.abs(t - 0.5) * 2)
63 const b = Math.round(220 - 200 * t)
64 cells.push([0x2588, rgb(r, g, b), DEFAULT])
65 }
66 }
67 return packCells(cells)
68}
69// shaded cells: char + fg + bg together (calendar-style heat grid)
70const heatGrid = (cols: number, rows: number): string => {
71 const cells: Array<[number, number, number]> = []
72 for (let y = 0; y < rows; y++) {
73 for (let x = 0; x < cols; x++) {
74 const v = (Math.sin(x * 0.6 + y) + 1) / 2
75 const lvl = Math.floor(v * 4)
76 const fg = [rgb(60, 60, 60), rgb(14, 100, 60), rgb(38, 166, 91), rgb(110, 231, 140)][lvl]!
77 cells.push([0x25a0, fg, DEFAULT]) // ■
78 }
79 }
80 return packCells(cells)
81}
82const gradientRgba = (w: number, h: number): string => {
83 const px = new Uint8Array(w * h * 4)
84 for (let y = 0; y < h; y++)
85 for (let x = 0; x < w; x++) {
86 const i = (y * w + x) * 4
87 px[i] = Math.round((x / (w - 1)) * 255)
88 px[i + 1] = Math.round((y / (h - 1)) * 255)
89 px[i + 2] = 160
90 px[i + 3] = 255
91 }
92 return b64(px)
93}
94// 8x8 RGB PNG written once by python (shipped as a constant: no encoder at runtime)
95const PNG8 =
96 'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAIAAABLbSncAAAAgklEQVR4nA3JoQFAUQQFUMMYxjCm+FmQZFm+WTaCYYT/Tj1ENEwtBKVySlAs2dFHxMPcwlAu5wTHsh2/kGFpEaiUS0JixU5e6LC2KFTLNaGxaqcvfNhbHOrlnvBYt/MXGEYLoChHArGww4sd3paFbvkmNnbt9sUNX8tBr/wSF3t29/2udWhBRWempAAAAABJRU5ErkJggg=='
97
98const ROWS: Array<[string, string, string]> = [
99 ['12', 'fix', 'src/state.ts'],
100 ['3', 'feat', 'hooks/register.tsx'],
101 ['140', 'chore', 'docs/SPEC.md'],
102 ['7', 'wip', 'a.ts'],
103]
104
105// ---- pages ----
106const Title = ({ el, text }: { el: E; text: string }) => <el.Text bold>{text}</el.Text>
107
108function boxesPage(el: E) {
109 const { Box, Text } = el
110 return (
111 <Box flexDirection="column" gap={1}>
112 <Text bold>boxes: borderStyle (last one is not a style)</Text>
113 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
114 {BORDERS.map(b => (
115 <Box borderStyle={b} width={13} paddingX={1}>
116 <Text>{b === 'singleDouble' ? 'sglDbl' : b === 'doubleSingle' ? 'dblSgl' : b}</Text>
117 </Box>
118 ))}
119 </Box>
120 <Text bold>border colours, background, padding, gap</Text>
121 <Box flexDirection="row" gap={1}>
122 <Box borderStyle="round" borderColor="success" paddingX={1}>
123 <Text>success</Text>
124 </Box>
125 <Box borderStyle="round" borderColor="permission" borderDimColor paddingX={1}>
126 <Text>dim border</Text>
127 </Box>
128 <Box backgroundColor="promptBorder" paddingX={1}>
129 <Text color="inverseText">promptBorder bg</Text>
130 </Box>
131 <Box backgroundColor="#304060" paddingX={1}>
132 <Text>#304060</Text>
133 </Box>
134 </Box>
135 <Box flexDirection="row" gap={2}>
136 <Box borderStyle="single" padding={1}>
137 <Text>padding 1</Text>
138 </Box>
139 <Box borderStyle="single" paddingX={3}>
140 <Text>paddingX 3</Text>
141 </Box>
142 <Box borderStyle="single" width={18} justifyContent="center">
143 <Text>width 18 ctr</Text>
144 </Box>
145 </Box>
146 <Text bold>nested</Text>
147 <Box borderStyle="double" paddingX={1} flexDirection="column">
148 <Text>outer double</Text>
149 <Box borderStyle="round" paddingX={1} flexDirection="column">
150 <Text>inner round</Text>
151 <Box borderStyle="dashed" paddingX={1}>
152 <Text dimColor>innermost dashed</Text>
153 </Box>
154 </Box>
155 </Box>
156 </Box>
157 )
158}
159
160function overlayPage(el: E) {
161 const { Box, Text } = el
162 return (
163 <Box flexDirection="column" gap={1}>
164 <Text bold>hover (move the pointer over a box; tmux has none)</Text>
165 <Box flexDirection="row" gap={1}>
166 <Box key="h1" borderStyle="single" paddingX={1} hover={{ borderColor: 'claude', backgroundColor: 'inactive' }}>
167 <Text hover={{ bold: true, color: 'claude' }}>hover me</Text>
168 </Box>
169 <Box key="h2" paddingX={1} hover={{ backgroundColor: 'suggestion' }}>
170 <Text>bg on hover</Text>
171 </Box>
172 </Box>
173 <Text bold>absolute overlay (card painted over its parent)</Text>
174 <Box position="relative" height={5} borderStyle="single">
175 <Text dimColor>row under the card: the quick brown fox jumps over the lazy dog</Text>
176 <Box position="absolute" top={1} left={12} borderStyle="round" borderColor="warning" backgroundColor="inactive" paddingX={1}>
177 <Text color="warning">overlay card</Text>
178 </Box>
179 </Box>
180 <Box key="rv" borderStyle="single" paddingX={1}>
181 <Text>hover reveals a card</Text>
182 <Box position="absolute" top={-3} left={4} display="none" hover={{ display: 'flex' }} borderStyle="round" paddingX={1}>
183 <Text>revealed</Text>
184 </Box>
185 </Box>
186 </Box>
187 )
188}
189
190function textPage(el: E) {
191 const { Box, Text } = el
192 return (
193 <Box flexDirection="column" gap={1}>
194 <Text bold>theme colours (color, then backgroundColor)</Text>
195 <Box flexDirection="row" flexWrap="wrap">
196 {THEME_KEYS.map(k => (
197 <Box width={21}>
198 <Text color={k}>■■ {k}</Text>
199 </Box>
200 ))}
201 </Box>
202 <Box flexDirection="row" flexWrap="wrap">
203 {THEME_KEYS.map(k => (
204 <Box width={21}>
205 <Text backgroundColor={k} color={k === 'inverseText' ? 'text' : 'inverseText'}>
206 {` ${k} `}
207 </Text>
208 </Box>
209 ))}
210 </Box>
211 </Box>
212 )
213}
214
215function stylesPage(el: E) {
216 const { Box, Text } = el
217 return (
218 <Box flexDirection="column" gap={1}>
219 <Text bold>raw colours</Text>
220 <Box flexDirection="row" gap={1}>
221 <Text color="red">red</Text>
222 <Text color="#ff8800">#ff8800</Text>
223 <Text color="rgb(80,200,255)">rgb()</Text>
224 <Text color="ansi256(201)">ansi256</Text>
225 <Text color="notacolour">bogus</Text>
226 </Box>
227 <Text bold>styles</Text>
228 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
229 <Text>plain</Text>
230 <Text dimColor>dimColor</Text>
231 <Text bold>bold</Text>
232 <Text italic>italic</Text>
233 <Text underline>underline</Text>
234 <Text strikethrough>strike</Text>
235 <Text inverse>inverse</Text>
236 <Text bold italic underline color="claude">
237 combined
238 </Text>
239 </Box>
240 <Text bold>nested Text inheritance</Text>
241 <Text color="success">
242 outer success, <Text bold>inner bold</Text>, <Text color="error">inner error</Text>, back
243 </Text>
244 <Text bold>tabular: fixed-width cells, right-aligned numbers</Text>
245 <Box flexDirection="column">
246 <Box flexDirection="row">
247 <Box width={6} justifyContent="flex-end">
248 <Text dimColor>n</Text>
249 </Box>
250 <Box width={8} paddingLeft={2}>
251 <Text dimColor>kind</Text>
252 </Box>
253 <Box flexGrow={1} paddingLeft={1}>
254 <Text dimColor>path</Text>
255 </Box>
256 </Box>
257 {ROWS.map(([n, k, p]) => (
258 <Box flexDirection="row">
259 <Box width={6} justifyContent="flex-end">
260 <Text color="success">{n}</Text>
261 </Box>
262 <Box width={8} paddingLeft={2}>
263 <Text color="warning">{k}</Text>
264 </Box>
265 <Box width={22} paddingLeft={1}>
266 <Text wrap="truncate-end">{p}</Text>
267 </Box>
268 </Box>
269 ))}
270 </Box>
271 <Text bold>flexGrow spacer: label left, value right</Text>
272 <Box width={40} borderStyle="round" paddingX={1}>
273 <Text>notes</Text>
274 <Box flexGrow={1} />
275 <Text dimColor>128</Text>
276 </Box>
277 <Box width={40} justifyContent="space-between">
278 <Text>left</Text>
279 <Text>middle</Text>
280 <Text>right</Text>
281 </Box>
282 </Box>
283 )
284}
285
286function wrapPage(el: E) {
287 const { Box, Text } = el
288 const long = 'The quick brown fox jumps over the lazy dog, then keeps running to the end of the path'
289 const modes = ['wrap', 'end', 'middle', 'truncate', 'truncate-start', 'truncate-middle', 'truncate-end'] as const
290 return (
291 <Box flexDirection="column" gap={1}>
292 <Text bold>wrap modes in a 34-wide box</Text>
293 {modes.map(m => (
294 <Box flexDirection="row" gap={1}>
295 <Box width={16}>
296 <Text dimColor>{m}</Text>
297 </Box>
298 <Box width={34} borderStyle="single" borderDimColor>
299 <Text wrap={m}>{long}</Text>
300 </Box>
301 </Box>
302 ))}
303 </Box>
304 )
305}
306
307function controlsPage($: EngineInterface, el: E, lastText: string) {
308 const { Box, Text, Button, Input, Select } = el
309 const press = (what: string) => () => void rec($, `pressed ${what}`)
310 return (
311 <Box flexDirection="column" gap={1}>
312 <Text bold>Button</Text>
313 <Box flexDirection="row" gap={1} flexWrap="wrap">
314 <Button key="b-default" label="default" onPress={press('default')} />
315 <Button key="b-primary" label="primary" variant="primary" onPress={press('primary')} />
316 <Button key="b-secondary" label="secondary" variant="secondary" onPress={press('secondary')} />
317 <Button key="b-dim" label="dim" dimColor onPress={press('dim')} />
318 <Button key="b-dismiss" label="dismiss" role="dismiss" onPress={press('dismiss')} />
319 </Box>
320 <Box flexDirection="row" gap={2} flexWrap="wrap">
321 <Button key="p1" plain hotkey="1" label="plain hotkey 1" onPress={press('plain 1')} />
322 <Button key="p-a" plain hotkey="a" label="hotkey a" onPress={press('plain a')} />
323 <Button key="p0" plain label="plain, no hotkey" onPress={press('plain nohotkey')} />
324 <Button key="hk" hotkey="h" label="hotkey on boxed" onPress={press('boxed h')} />
325 <Button key="glyph" plain label="★" onPress={press('glyph')} />
326 </Box>
327 <Text bold>Input (autoFocus on the first)</Text>
328 <Input key="in-1" label="name" placeholder="type here" submitLabel="save" autoFocus onSubmit={v => void rec($, `submit "${v}"`)} onInput={v => void rec($, `typing "${v}"`)} />
329 <Input key="in-2" label="with value" value="prefilled" onSubmit={v => void rec($, `submit2 "${v}"`)} />
330 <Input key="in-3" placeholder="no label, default submit" onSubmit={v => void rec($, `submit3 "${v}"`)} />
331 <Text bold>Select</Text>
332 <Select
333 key="sel"
334 label="scope"
335 value="project"
336 options={[
337 { value: 'all', label: 'All notes' },
338 { value: 'project', label: 'This project' },
339 { value: 'inbox', label: 'Inbox' },
340 ]}
341 onSelect={v => void rec($, `select ${v}`)}
342 />
343 <Box borderStyle="round" paddingX={1} borderDimColor>
344 <Text dimColor>last event: </Text>
345 <Text color="success">{lastText}</Text>
346 </Box>
347 </Box>
348 )
349}
350
351const MD = `# Heading one
352## Heading two
353Some **bold**, *italic*, \`inline code\` and a [link](https://example.com).
354
355- first item
356- second item
357 - nested item
3581. ordered one
3592. ordered two
360
361> a blockquote line
362
363| col | n |
364|-----|--:|
365| a | 1 |
366| bb | 22 |
367
368\`\`\`ts
369const x: number = 1
370\`\`\``
371
372const DIFF = `@@ -1,3 +1,4 @@
373 import a from 'a'
374-const old = 1
375+const fresh = 2
376+const extra = 3
377 export {}`
378
379function richPage(el: E) {
380 const { Box, Text, Markdown } = el
381 return (
382 <Box flexDirection="column" gap={1}>
383 <Text bold>Markdown</Text>
384 <Markdown text={MD} />
385 </Box>
386 )
387}
388
389function codePage(el: E) {
390 const { Box, Text, Code, Link } = el
391 return (
392 <Box flexDirection="column" gap={1}>
393 <Text bold>Code (language ts, startLine 10)</Text>
394 <Code language="ts" startLine={10} source={'function add(a: number, b: number) {\n return a + b // sum\n}'} />
395 <Text bold>Code format diff</Text>
396 <Code format="diff" path="x.ts" source={DIFF} />
397 <Text bold>Code wrap truncate-end / diff that fails to parse</Text>
398 <Code wrap="truncate-end" language="ts" source={'const veryLongLine = "this line is much wider than the pane and must be cut at the edge with an ellipsis"'} />
399 <Code format="diff" source="not a diff at all" />
400 <Text bold>Link</Text>
401 <Box flexDirection="row" gap={2}>
402 <Link href="https://example.com" label="with label" />
403 <Link href="https://example.com/long/path">children text</Link>
404 <Link href="https://example.org" />
405 </Box>
406 </Box>
407 )
408}
409
410function graphicsPage(el: E) {
411 const { Box, Text, Raster, Image } = el
412 return (
413 <Box flexDirection="column" gap={1}>
414 <Text bold>Raster: sparkline, 40x1</Text>
415 <Raster key="spark" columns={40} rows={1} cells={sparkline()} />
416 <Text bold>Raster: heat strip 40x2</Text>
417 <Raster key="heat" columns={40} rows={2} cells={heatStrip(40, 2)} />
418 <Text bold>Raster: ■ grid 28x4 (fg only)</Text>
419 <Raster key="grid" columns={28} rows={4} cells={heatGrid(28, 4)} />
420 <Text bold>Image: rgba gradient, 24x6 cells</Text>
421 <Image key="img-rgba" source={{ rgba: gradientRgba(48, 24), width: 48, height: 24 }} columns={24} rows={6} alt="[gradient image 48x24 rgba]" />
422 <Text bold>Image: shipped 8x8 png, 12x4 cells</Text>
423 <Image key="img-png" source={{ png: PNG8 }} columns={12} rows={4} alt="[png 8x8]" />
424 </Box>
425 )
426}
427
428function clientPage(el: E) {
429 const { Box, Text, Client, Input } = el
430 return (
431 <Box flexDirection="column" gap={1}>
432 <Text bold>Client: click it to focus, then type</Text>
433 <Client key="probe" module="./client.tsx" width={44} height={5} />
434 <Text dimColor>An Input for comparison (it swallows letters too):</Text>
435 <Input key="cmp" label="input" placeholder="type" onSubmit={() => {}} />
436 </Box>
437 )
438}
439
440function bandPage(el: E, band: boolean, hint: string, lastText: string) {
441 const { Box, Text } = el
442 return (
443 <Box flexDirection="column" gap={1}>
444 <Text bold>band + prompt page</Text>
445 <Text>band chips: {band ? 'ON (above the prompt)' : 'off'}</Text>
446 <Text>hint mode: {hint}</Text>
447 <Text>last event: <Text color="success">{lastText}</Text></Text>
448 <Text dimColor>/gallery band on|off, /gallery hint off|tail|tree</Text>
449 <Text>Type <Text color="warning" bold>//</Text> at the start of the prompt: the leading // is decorated by prompt.edit.</Text>
450 </Box>
451 )
452}
453
454export const register: Register = on => {
455 on('session.start', async ($, e, next) => {
456 await $.command.register({ name: 'gallery', description: `gallery: ${PAGES.join('|')}`, argumentHint: PAGES.join('|') })
457 return next(e)
458 })
459
460 on('command.run', { command: 'gallery' }, async ($, e) => {
461 const parts = String(e.args ?? '').trim().split(/\s+/)
462 const a = parts[0] || 'boxes'
463 if (a === 'hint') {
464 await update($, hintMode, () => parts[1] ?? 'off')
465 return { text: `hint ${parts[1] ?? 'off'}` }
466 }
467 if (a === 'band') {
468 await update($, bandOn, () => parts[1] !== 'off')
469 }
470 if (!PAGES.includes(a)) return { text: `unknown page ${a}; pages: ${PAGES.join(' ')}` }
471 await update($, page, () => a)
472 const r = await $.ui.open({ id: PANE, title: `gallery: ${a}`, focus: true, closeOnEscape: true, rows: 38 })
473 return { text: `gallery ${a} ${JSON.stringify(r)}` }
474 })
475
476 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
477 if (e.surface !== 'terminal') return next(e)
478 const el = $.ui.resolve(e)
479 const { Box, Text } = el
480 const p = await read($, page)
481 const lastText = await read($, last)
482 const band = await read($, bandOn)
483 const hint = await read($, hintMode)
484 const body =
485 p === 'boxes' ? boxesPage(el)
486 : p === 'overlay' ? overlayPage(el)
487 : p === 'colours' ? textPage(el)
488 : p === 'text' ? stylesPage(el)
489 : p === 'code' ? codePage(el)
490 : p === 'wrap' ? wrapPage(el)
491 : p === 'controls' ? controlsPage($, el, lastText)
492 : p === 'rich' ? richPage(el)
493 : p === 'graphics' ? graphicsPage(el)
494 : p === 'client' ? clientPage(el)
495 : bandPage(el, band, hint, lastText)
496 return (
497 <Box flexDirection="column">
498 <Text dimColor>
499 {p} | cols {e.props.bodyColumns} | rows {e.props.scroll.bodyRows} | {e.props.placement}
500 </Text>
501 {body}
502 </Box>
503 )
504 })
505
506 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
507 if (e.surface !== 'terminal' || e.props.hasSurvey || !(await read($, bandOn))) return next(e)
508 const { Box, Text, Button } = $.ui.resolve(e)
509 const inner = await next(e)
510 const chips = ['inbox', 'todo', 'idea']
511 return (
512 <Box flexDirection="column">
513 <Box flexDirection="row" gap={2}>
514 <Text dimColor>chips</Text>
515 {chips.map((c, i) => (
516 <Button key={`chip-${c}`} plain hotkey={String(i + 1)} label={c} onPress={() => void rec($, `chip ${c}`)} />
517 ))}
518 <Button key="chip-x" label="x" onPress={() => void update($, bandOn, () => false)} />
519 </Box>
520 {inner}
521 </Box>
522 )
523 })
524
525 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
526 const m = await read($, hintMode)
527 if (e.surface !== 'terminal' || m === 'off') return next(e)
528 if (m === 'tail') return next({ ...e, props: { ...e.props, tail: ' | gallery tail' } })
529 const { Box, Text } = $.ui.resolve(e)
530 return (
531 <Box>
532 <Text color="claude">gallery hint tree</Text>
533 <Text dimColor> draft={String(e.props.isDraft)} working={String(e.props.isWorking)}</Text>
534 </Box>
535 )
536 })
537
538 on('prompt.edit', async ($, e, next) => {
539 const r = await next(e)
540 const deco = [...(r.decorations ?? [])]
541 if (r.text.startsWith('//')) deco.push({ start: 0, end: 2, color: 'warning', bold: true })
542 if (r.text.startsWith('//')) deco.push({ start: 2, end: r.text.length, italic: true, underline: true })
543 return { ...r, decorations: deco }
544 })
545}
546hooks/client.tsx 29 lines1// Surface module for the gallery's Client element: records the keys it receives.
2type S = { keys: string[]; focusNote: string }
3
4const buf: string[] = []
5
6export default function KeyProbe(_props: unknown, surface: any) {
7 const { Box, Text } = surface.elements
8 if (surface.state === undefined) {
9 surface.onKey((ev: { key: string; ctrl?: true; shift?: true; meta?: true }) => {
10 const tag = `${ev.ctrl ? 'C-' : ''}${ev.meta ? 'M-' : ''}${ev.shift ? 'S-' : ''}${ev.key}`
11 buf.push(tag)
12 if (buf.length > 12) buf.shift()
13 surface.setState({ keys: [...buf], focusNote: 'got key' } as S)
14 })
15 surface.setState({ keys: [], focusNote: 'no key yet' } as S)
16 }
17 const st: S = surface.state ?? { keys: [], focusNote: '' }
18 return Box({
19 flexDirection: 'column',
20 borderStyle: 'round',
21 paddingX: 1,
22 children: [
23 Text({ bold: true, children: 'Client (raw keys)' }),
24 Text({ dimColor: true, children: `region ${surface.columns}x${surface.rows}; ${st.focusNote}` }),
25 Text({ color: 'success', children: `keys: ${st.keys.join(' ') || '(click me, then type)'}` }),
26 ],
27 })
28}
29types/index.d.ts 13 lines1export type GalleryPage = string
2
3declare module 'claude-code' {
4 interface PluginState {
5 spgallery: {
6 page: string
7 last: string
8 band: boolean
9 hint: string
10 }
11 }
12}
13