Ostrakon v2 design mockups (phase D, part 2): static renders of every pane state

<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 786 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, Register } from 'claude-code'
3
4// Design mockups (phase D, part 2): static renders of every pane state in three directions, from
5// fixed sample data. Nothing here is interactive; the real pane is built in P2.
6// /mock <quiet|cards|ledger|mix> <view> views: see VIEWS
7// /mockband <off|a|b> <pocket0|pocket3|pocket9|harvest>
8
9type E = Elements['terminal']
10type Direction = 'quiet' | 'cards' | 'ledger' | 'mix'
11
12const direction = atom({ plugin: 'spmock', key: 'direction' } as const, 'quiet')
13const view = atom({ plugin: 'spmock', key: 'view' } as const, 'search-empty')
14const band = atom({ plugin: 'spmock', key: 'band' } as const, 'off')
15
16const PANE = 'ostrakon'
17const VIEWS = [
18 'search-empty',
19 'search-typing',
20 'search-nomatch',
21 'list-grouped',
22 'list-folded',
23 'list-history',
24 'picked',
25 'bundle',
26 'capture',
27 'edit',
28 'empty',
29]
30
31// ---- sample data ----
32
33type Note = {
34 id: string
35 text: string
36 project: string
37 age: string
38 by?: 'claude' | 'phone'
39 status?: 'sent' | 'archived'
40 branch?: string
41}
42
43const CURRENT = 'cc'
44const NOTES: Note[] = [
45 { id: 'a', project: 'cc', age: '12m', branch: 'main', text: 'check why the cache test is flaky on CI. started after the redis bump, only on the arm runners' },
46 { id: 'b', project: 'cc', age: '1h', branch: 'main', text: 'rename the store helpers to match the spec (createStore, change, scan)' },
47 { id: 'c', project: 'cc', age: '3h', by: 'claude', branch: 'main', text: 'login returns 401 right after a token refresh; retry ignores Retry-After' },
48 { id: 'd', project: 'cc', age: '1d', branch: 'p1', text: 'try bun:test for the merge property tests' },
49 { id: 'e', project: 'cc', age: '2d', branch: 'main', text: 'add a /jot section to the README' },
50 { id: 'f', project: 'infra', age: '1h', text: 'add a cache invalidation metric to the hub dashboard' },
51 { id: 'g', project: 'infra', age: '2d', text: 'rotate the staging api keys before friday' },
52 { id: 'h', project: 'infra', age: '5d', text: 'tailscale serve on the server for the ostrakon hub' },
53 { id: 'i', project: 'api', age: '2d', text: 'document the cache-key format in the api readme' },
54 { id: 'j', project: 'api', age: '1w', text: 'pagination cursor is off by one on the last page' },
55 { id: 'k', project: 'unfiled', age: '4h', by: 'phone', text: 'book the dentist' },
56 { id: 'l', project: 'unfiled', age: '3d', by: 'phone', text: 'idea: weekly digest of what got sent' },
57]
58const HISTORY: Note[] = [
59 { id: 'x', project: 'cc', age: '2d', status: 'sent', text: 'bump redis to 7.2 in the test compose file' },
60 { id: 'y', project: 'cc', age: '5d', status: 'archived', text: 'remove the old v1 notes file' },
61]
62const PATHS: Record<string, string> = { cc: '~/Work/cc', infra: '~/code/infra', api: '~/Work/api', unfiled: '' }
63
64// Project colours: theme keys only (they follow light and dark); the current project gets the accent.
65const PALETTE = ['suggestion', 'success', 'merged', 'planMode', 'warning', 'autoAccept']
66const colourOf = (project: string) => {
67 if (project === CURRENT) return 'claude'
68 if (project === 'unfiled') return 'inactive'
69 let hash = 0
70 for (const char of project) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
71 return PALETTE[hash % PALETTE.length] ?? 'suggestion'
72}
73
74const fit = (text: string, width: number) => {
75 const flat = text.replaceAll('\n', ' ')
76 return flat.length <= width ? flat.padEnd(width) : `${flat.slice(0, Math.max(0, width - 1))}…`
77}
78
79// Matched characters of a query: a contiguous hit if there is one, else a greedy subsequence.
80const segments = (text: string, query: string): [string, boolean][] => {
81 if (query === '') return [[text, false]]
82 const at = text.toLowerCase().indexOf(query.toLowerCase())
83 if (at >= 0) return [[text.slice(0, at), false], [text.slice(at, at + query.length), true], [text.slice(at + query.length), false]]
84 return [[text, false]]
85}
86
87const matches = (query: string) => NOTES.filter(note => note.text.toLowerCase().includes(query.toLowerCase()))
88
89// ---- shared pieces ----
90
91type RowSpec = {
92 note: Note
93 width: number
94 number?: number
95 isSelected?: boolean
96 isPicked?: boolean
97 showPicks?: boolean
98 query?: string
99 isHistory?: boolean
100 // Grouped list rows leave the project out: the group header names it.
101 isGrouped?: boolean
102}
103
104const Highlighted = ({ el, text, query, width, isDim }: { el: E; text: string; query: string; width: number; isDim?: boolean }) => {
105 const { Text } = el
106 const cut = fit(text, width)
107 return (
108 <Text dimColor={isDim}>
109 {segments(cut, query).map(([part, isHit]) =>
110 isHit ? (
111 <Text bold color="claude" underline>
112 {part}
113 </Text>
114 ) : (
115 part
116 ),
117 )}
118 </Text>
119 )
120}
121
122const marker = (spec: RowSpec) => {
123 if (spec.isHistory) return spec.note.status === 'sent' ? '↗' : '·'
124 if (spec.note.by === 'claude') return '◆'
125 if (spec.note.by === 'phone') return '▪'
126 return ' '
127}
128
129// ---- direction: quiet ----
130// Almost no chrome. Selected row: a background band with an accent bar. Projects as coloured
131// words at the right. One footer line of keys.
132
133const QuietRow = ({ el, spec }: { el: E; spec: RowSpec }) => {
134 const { Box, Text } = el
135 const { note, width } = spec
136 const pick = spec.showPicks ? (spec.isPicked ? '● ' : '○ ') : ''
137 const right = spec.isGrouped ? ` ${note.age.padStart(4)}` : `${fit(note.project, 8).trimEnd().padStart(8)} ${note.age.padStart(4)}`
138 const textWidth = width - 3 - pick.length - right.length - 1
139 return (
140 <Box width={width} backgroundColor={spec.isSelected ? 'subtle' : undefined}>
141 <Text color="claude">{spec.isSelected ? '▌' : ' '}</Text>
142 <Text color={marker(spec) === '◆' ? 'claude' : 'inactive'}>{marker(spec)} </Text>
143 {pick !== '' && <Text color={spec.isPicked ? 'claude' : 'inactive'}>{pick}</Text>}
144 <Highlighted el={el} text={note.text} query={spec.query ?? ''} width={textWidth} isDim={spec.isHistory} />
145 <Text> </Text>
146 {!spec.isGrouped && <Text color={spec.isHistory ? 'inactive' : colourOf(note.project)}>{fit(note.project, 8).trimEnd().padStart(8)}</Text>}
147 <Text dimColor> {note.age.padStart(4)}</Text>
148 </Box>
149 )
150}
151
152// ---- direction: cards ----
153// Framed search, project pills on the left, preview in a card, primary actions as buttons.
154
155const Pill = ({ el, project, isDim }: { el: E; project: string; isDim?: boolean }) => {
156 const { Text } = el
157 return (
158 <Text backgroundColor={isDim ? 'inactive' : colourOf(project)} color="inverseText">
159 {` ${project.slice(0, 7).padEnd(7)} `}
160 </Text>
161 )
162}
163
164const CardsRow = ({ el, spec }: { el: E; spec: RowSpec }) => {
165 const { Box, Text } = el
166 const { note, width } = spec
167 const pick = spec.showPicks ? (spec.isPicked ? '● ' : '○ ') : ''
168 const textWidth = width - 2 - (spec.isGrouped ? 0 : 10) - 1 - pick.length - 2 - 5
169 return (
170 <Box width={width} backgroundColor={spec.isSelected ? 'subtle' : undefined}>
171 <Text color="claude">{spec.isSelected ? '▌ ' : ' '}</Text>
172 {!spec.isGrouped && <Pill el={el} project={note.project} isDim={spec.isHistory} />}
173 <Text> </Text>
174 {pick !== '' && <Text color={spec.isPicked ? 'claude' : 'inactive'}>{pick}</Text>}
175 <Text color={marker(spec) === '◆' ? 'claude' : 'inactive'}>{marker(spec)} </Text>
176 <Highlighted el={el} text={note.text} query={spec.query ?? ''} width={textWidth} isDim={spec.isHistory} />
177 <Text dimColor>{note.age.padStart(5)}</Text>
178 </Box>
179 )
180}
181
182// ---- direction: ledger ----
183// A table: numbered rows (1-9 jump), a column header, coloured dots for projects, selection
184// drawn inverse like the native focus ring.
185
186const LedgerRow = ({ el, spec }: { el: E; spec: RowSpec }) => {
187 const { Box, Text } = el
188 const { note, width } = spec
189 const num = spec.number !== undefined && spec.number <= 9 ? String(spec.number) : ' '
190 const pick = spec.showPicks ? (spec.isPicked ? '●' : '○') : marker(spec)
191 const textWidth = width - 4 - (spec.isGrouped ? 0 : 12) - 5
192 return (
193 <Box width={width}>
194 <Text color="claude" bold>
195 {num}
196 </Text>
197 <Text color={spec.isPicked || pick === '◆' ? 'claude' : 'inactive'}> {pick} </Text>
198 <Text inverse={spec.isSelected}>
199 <Highlighted el={el} text={note.text} query={spec.query ?? ''} width={textWidth} isDim={spec.isHistory} />
200 </Text>
201 {!spec.isGrouped && <Text color={spec.isHistory ? 'inactive' : colourOf(note.project)}> ● </Text>}
202 {!spec.isGrouped && <Text dimColor>{fit(note.project, 9)}</Text>}
203 <Text dimColor>{note.age.padStart(5)}</Text>
204 </Box>
205 )
206}
207
208// ---- direction: mix (Quiet + Ledger, the picked one) ----
209// Ledger's numbered rows and bold footer keys; Quiet's band-and-bar selection and coloured project
210// word. No column header. Two rules only: heavy under the search, light above preview and footer.
211
212const MixRow = ({ el, spec }: { el: E; spec: RowSpec }) => {
213 const { Box, Text } = el
214 const { note, width } = spec
215 const num = spec.number !== undefined && spec.number <= 9 ? String(spec.number) : ' '
216 const pick = spec.showPicks ? (spec.isPicked ? '●' : '○') : marker(spec)
217 const right = spec.isGrouped ? ` ${note.age.padStart(4)}` : `${fit(note.project, 8).trimEnd().padStart(8)} ${note.age.padStart(4)}`
218 const textWidth = width - 5 - right.length - 1
219 return (
220 <Box width={width} backgroundColor={spec.isSelected ? 'subtle' : undefined}>
221 <Text color="claude">{spec.isSelected ? '▌' : ' '}</Text>
222 <Text color={spec.isSelected ? undefined : 'claude'} bold>
223 {num}
224 </Text>
225 <Text color={spec.isPicked || pick === '◆' ? 'claude' : 'inactive'}> {pick} </Text>
226 <Highlighted el={el} text={note.text} query={spec.query ?? ''} width={textWidth} isDim={spec.isHistory} />
227 <Text> </Text>
228 {!spec.isGrouped && <Text color={spec.isHistory ? 'inactive' : colourOf(note.project)}>{fit(note.project, 8).trimEnd().padStart(8)}</Text>}
229 <Text dimColor> {note.age.padStart(4)}</Text>
230 </Box>
231 )
232}
233
234const Row = ({ el, dir, spec }: { el: E; dir: Direction; spec: RowSpec }) =>
235 dir === 'quiet' ? (
236 <QuietRow el={el} spec={spec} />
237 ) : dir === 'cards' ? (
238 <CardsRow el={el} spec={spec} />
239 ) : dir === 'mix' ? (
240 <MixRow el={el} spec={spec} />
241 ) : (
242 <LedgerRow el={el} spec={spec} />
243 )
244
245// ---- header (search / capture) ----
246
247type Header = { mode: 'search' | 'capture' | 'edit'; query: string; right: string; isFocused: boolean }
248
249const HeaderLine = ({ el, dir, width, header }: { el: E; dir: Direction; width: number; header: Header }) => {
250 const { Box, Text } = el
251 const isCapture = header.mode === 'capture'
252 const glyph = isCapture ? '✎' : '❯'
253 const label = isCapture ? 'new note' : ''
254 const caret = header.isFocused ? '▏' : ''
255 const placeholder = isCapture ? 'write a note, ⏎ to save' : 'search notes'
256 const content = (
257 <Box width={dir === 'cards' ? width - 4 : width}>
258 <Text color={isCapture ? 'claude' : header.isFocused ? 'claude' : 'inactive'} bold>
259 {glyph}{' '}
260 </Text>
261 {label !== '' && (
262 <Text color="claude" bold>
263 {label}{' '}
264 </Text>
265 )}
266 {header.query === '' ? (
267 <Text dimColor>
268 {caret}
269 {placeholder}
270 </Text>
271 ) : (
272 <Text bold={isCapture}>
273 {header.query}
274 {caret}
275 </Text>
276 )}
277 {dir === 'mix' && header.mode === 'search' && header.isFocused && <Text dimColor> ⏎ to list</Text>}
278 <Box flexGrow={1} />
279 <Text dimColor>{header.right}</Text>
280 </Box>
281 )
282 if (dir === 'cards') {
283 return (
284 <Box borderStyle="round" borderColor={isCapture ? 'claude' : header.isFocused ? 'promptBorder' : 'inactive'} paddingX={1} width={width}>
285 {content}
286 </Box>
287 )
288 }
289 if (dir === 'ledger' || dir === 'mix') {
290 return (
291 <Box flexDirection="column" width={width}>
292 {content}
293 <Text dimColor>{'━'.repeat(width)}</Text>
294 </Box>
295 )
296 }
297 return (
298 <Box flexDirection="column" width={width}>
299 {content}
300 <Text dimColor>{'─'.repeat(width)}</Text>
301 </Box>
302 )
303}
304
305const LedgerColumns = ({ el, width }: { el: E; width: number }) => {
306 const { Text } = el
307 return <Text dimColor>{`# ${'note'.padEnd(width - 4 - 12 - 5)} project age`}</Text>
308}
309
310// ---- preview ----
311
312const Preview = ({ el, dir, width, note }: { el: E; dir: Direction; width: number; note: Note }) => {
313 const { Box, Text } = el
314 const who = note.by === 'claude' ? '◆ claude' : note.by === 'phone' ? 'phone' : 'you'
315 const meta = [who, PATHS[note.project] || 'unfiled', note.branch, `${note.age} ago`].filter(Boolean).join(' · ')
316 const body = (
317 <Box flexDirection="column">
318 <Text wrap="wrap">{note.text}</Text>
319 <Text dimColor>{meta}</Text>
320 </Box>
321 )
322 if (dir === 'cards') {
323 return (
324 <Box borderStyle="round" borderColor="inactive" paddingX={1} width={width} flexDirection="column">
325 {body}
326 </Box>
327 )
328 }
329 return (
330 <Box flexDirection="column" width={width}>
331 <Text dimColor>{(dir === 'ledger' ? '┄' : '─').repeat(width)}</Text>
332 {body}
333 </Box>
334 )
335}
336
337// ---- footer ----
338
339type Key = [string, string]
340const SEARCH_KEYS: Key[] = [['ctrl+l', 'list view'], ['ctrl+s', 'scope'], ['ctrl+n', 'new'], ['ctrl+q', 'close']]
341const ROW_KEYS: Key[] = [['v', 'pick'], ['t', 'to prompt'], ['s', 'send'], ['e', 'edit'], ['d', 'archive'], ['b', 'bundle'], ['l', 'list'], ['f', 'search'], ['i', 'keys']]
342
343const Footer = ({ el, dir, width, keys, status, legend }: { el: E; dir: Direction; width: number; keys: Key[]; status?: string; legend?: string }) => {
344 const { Box, Text } = el
345 if (dir === 'cards') {
346 const [first, second, ...rest] = keys
347 return (
348 <Box flexDirection="column" width={width}>
349 <Box gap={1}>
350 {[first, second].filter((key): key is Key => key !== undefined).map(([k, label]) => (
351 <Text bold color="claude">
352 [ {k} {label} ]
353 </Text>
354 ))}
355 <Box flexGrow={1} />
356 {status !== undefined && <Text color="claude">{status}</Text>}
357 </Box>
358 <Box flexWrap="wrap" columnGap={2}>
359 {rest.map(([k, label]) => (
360 <Text dimColor>
361 {k} {label}
362 </Text>
363 ))}
364 {legend !== undefined && <Text dimColor>{legend}</Text>}
365 </Box>
366 </Box>
367 )
368 }
369 return (
370 <Box flexDirection="column" width={width}>
371 {dir === 'ledger' && <Text dimColor>{'━'.repeat(width)}</Text>}
372 {dir === 'mix' && <Text dimColor>{'─'.repeat(width)}</Text>}
373 <Box flexWrap="wrap" columnGap={2} width={width}>
374 {status !== undefined && (
375 <Text color="claude" bold>
376 {status}
377 </Text>
378 )}
379 {keys.map(([k, label]) => (
380 <Text>
381 <Text color={dir === 'quiet' ? 'claude' : undefined} bold={dir === 'ledger' || dir === 'mix'}>
382 {k}
383 </Text>
384 <Text dimColor> {label}</Text>
385 </Text>
386 ))}
387 {legend !== undefined && <Text dimColor>{legend}</Text>}
388 </Box>
389 </Box>
390 )
391}
392
393// ---- group header (list view) ----
394
395const GroupHeader = ({ el, dir, width, project, count, isFolded }: { el: E; dir: Direction; width: number; project: string; count: number; isFolded?: boolean }) => {
396 const { Box, Text } = el
397 const fold = isFolded ? '▸' : '▾'
398 if (dir === 'cards') {
399 return (
400 <Box width={width}>
401 <Text dimColor>{fold} </Text>
402 <Pill el={el} project={project} />
403 <Text dimColor> {count}</Text>
404 </Box>
405 )
406 }
407 if (dir === 'ledger') {
408 const label = ` ${project} · ${count} `
409 return (
410 <Text>
411 <Text dimColor>{fold}─</Text>
412 <Text color={colourOf(project)}>{label}</Text>
413 <Text dimColor>{'─'.repeat(Math.max(0, width - label.length - 2))}</Text>
414 </Text>
415 )
416 }
417 return (
418 <Text>
419 <Text dimColor>{fold} </Text>
420 <Text color={colourOf(project)} bold>
421 {project}
422 </Text>
423 {dir !== 'mix' && <Text dimColor> · {count}</Text>}
424 </Text>
425 )
426}
427
428// ---- views ----
429
430type Ctx = { el: E; dir: Direction; width: number }
431
432const rowsOf = (
433 ctx: Ctx,
434 notes: Note[],
435 options: { selected?: string; picked?: string[]; query?: string; isHistory?: boolean; isGrouped?: boolean; firstNumber?: number } = {},
436) =>
437 notes.map((note, i) => (
438 <Row
439 el={ctx.el}
440 dir={ctx.dir}
441 spec={{
442 note,
443 width: ctx.width,
444 number: (options.firstNumber ?? 1) + i,
445 isSelected: note.id === options.selected,
446 isPicked: options.picked?.includes(note.id),
447 showPicks: (options.picked?.length ?? 0) > 0,
448 query: options.query,
449 isHistory: options.isHistory,
450 isGrouped: options.isGrouped,
451 }}
452 />
453 ))
454
455const legendFor = (notes: Note[]) => (notes.some(note => note.by === 'claude') ? '◆ from Claude' : undefined)
456
457const SearchView = (ctx: Ctx, query: string) => {
458 const { Box, Text } = ctx.el
459 const here = NOTES.filter(note => note.project === CURRENT)
460 const found = query === '' ? here : matches(query)
461 const scope = query === '' ? 'this project' : 'all'
462 const total = query === '' ? here.length : NOTES.length
463 const right = query === '' ? `${scope} · ${here.length} open` : `${scope} · ${found.length} of ${total}`
464 return (
465 <Box flexDirection="column" width={ctx.width}>
466 <HeaderLine el={ctx.el} dir={ctx.dir} width={ctx.width} header={{ mode: 'search', query, right, isFocused: true }} />
467 {ctx.dir === 'ledger' && <LedgerColumns el={ctx.el} width={ctx.width} />}
468 {found.length === 0 ? (
469 <Box flexDirection="column" paddingY={1}>
470 <Text dimColor>No notes match. ⏎ saves it to cc.</Text>
471 </Box>
472 ) : (
473 <Box flexDirection="column">{rowsOf(ctx, found, { query })}</Box>
474 )}
475 {found[0] !== undefined && <Preview el={ctx.el} dir={ctx.dir} width={ctx.width} note={found[0]} />}
476 <Box height={1} />
477 <Footer
478 el={ctx.el}
479 dir={ctx.dir}
480 width={ctx.width}
481 keys={found.length === 0 ? [['⏎', 'save as note'], ['ctrl+s', 'scope'], ['ctrl+q', 'close']] : SEARCH_KEYS}
482 legend={legendFor(found)}
483 />
484 </Box>
485 )
486}
487
488const GROUPS = ['cc', 'infra', 'api', 'unfiled']
489
490const ListView = (ctx: Ctx, options: { folded?: string[]; history?: boolean; picked?: string[]; selected: string }) => {
491 const { Box } = ctx.el
492 let shown = 0
493 const groups = GROUPS.map(project => {
494 const notes = NOTES.filter(note => note.project === project)
495 const history = options.history ? HISTORY.filter(note => note.project === project) : []
496 const isFolded = options.folded?.includes(project)
497 const firstNumber = shown + 1
498 if (!isFolded) shown += notes.length + history.length
499 return (
500 <Box flexDirection="column">
501 <GroupHeader el={ctx.el} dir={ctx.dir} width={ctx.width} project={project} count={notes.length} isFolded={isFolded} />
502 {!isFolded && rowsOf(ctx, notes, { selected: options.selected, picked: options.picked, isGrouped: true, firstNumber })}
503 {!isFolded && history.length > 0 && rowsOf(ctx, history, { selected: options.selected, isHistory: true, isGrouped: true, firstNumber: firstNumber + notes.length })}
504 </Box>
505 )
506 })
507 const selected = [...NOTES, ...HISTORY].find(note => note.id === options.selected)
508 const picked = options.picked?.length ?? 0
509 return (
510 <Box flexDirection="column" width={ctx.width}>
511 <HeaderLine
512 el={ctx.el}
513 dir={ctx.dir}
514 width={ctx.width}
515 header={{ mode: 'search', query: '', right: `list · all · ${NOTES.length} open${options.history ? ' + history' : ''}`, isFocused: false }}
516 />
517 <Box flexDirection="column" gap={ctx.dir === 'cards' ? 1 : 0}>
518 {groups}
519 </Box>
520 {selected !== undefined && <Preview el={ctx.el} dir={ctx.dir} width={ctx.width} note={selected} />}
521 <Box height={1} />
522 <Footer
523 el={ctx.el}
524 dir={ctx.dir}
525 width={ctx.width}
526 keys={
527 options.history
528 ? [['u', 'restore'], ['h', 'hide history'], ['z', 'fold'], ['f', 'search']]
529 : picked > 0
530 ? [['b', 'bundle'], ['v', 'pick'], ['x', 'clear picks'], ['t', 'to prompt'], ['i', 'keys']]
531 : [...ROW_KEYS.slice(0, 5), ['z', 'fold'], ['h', 'history'], ['i', 'keys']]
532 }
533 status={picked > 0 ? `${picked} picked` : undefined}
534 legend={picked > 0 ? undefined : legendFor(NOTES)}
535 />
536 </Box>
537 )
538}
539
540const BundleView = (ctx: Ctx) => {
541 const { Box, Text } = ctx.el
542 const picked = ['a', 'c', 'f'].map(id => NOTES.find(note => note.id === id)).filter((note): note is Note => note !== undefined)
543 const prompt = [
544 'Please look at these notes from my ostrakon:',
545 '',
546 ...picked.map(note => `- ${note.project === CURRENT ? '' : `[${note.project}] `}${note.text}`),
547 ]
548 return (
549 <Box flexDirection="column" width={ctx.width}>
550 <Box width={ctx.width}>
551 <Text color="claude" bold>
552 ⇢ bundle{' '}
553 </Text>
554 <Text>3 notes into one prompt</Text>
555 <Box flexGrow={1} />
556 <Text dimColor>1 from infra</Text>
557 </Box>
558 <Text dimColor>{'─'.repeat(ctx.width)}</Text>
559 <Box borderStyle={ctx.dir === 'cards' ? 'round' : 'quote'} borderColor="inactive" paddingX={ctx.dir === 'cards' ? 1 : 0} flexDirection="column" width={ctx.width}>
560 {prompt.map(line => (
561 <Text wrap="wrap">{line === '' ? ' ' : line}</Text>
562 ))}
563 </Box>
564 <Box height={1} />
565 <Text dimColor>The picked notes are marked sent once the prompt goes in. Nothing is removed before.</Text>
566 <Box height={1} />
567 <Footer el={ctx.el} dir={ctx.dir} width={ctx.width} keys={[['⏎', 'send'], ['t', 'to prompt (edit first)'], ['x', 'back'], ['esc', 'close']]} />
568 </Box>
569 )
570}
571
572const CaptureView = (ctx: Ctx) => {
573 const { Box, Text } = ctx.el
574 const saved = ['check whether the hub needs a nightly backup cron', 'ask about arm runner minutes']
575 return (
576 <Box flexDirection="column" width={ctx.width}>
577 <HeaderLine
578 el={ctx.el}
579 dir={ctx.dir}
580 width={ctx.width}
581 header={{ mode: 'capture', query: 'the inline pane only has 11 rows at 100 columns', right: 'cc', isFocused: true }}
582 />
583 <Box flexDirection="column" paddingY={1}>
584 {saved.map(text => (
585 <Text>
586 <Text color="success">✓ </Text>
587 <Text>{text}</Text>
588 <Text dimColor> · just now</Text>
589 </Text>
590 ))}
591 </Box>
592 <Text dimColor>Notes save to cc. Keep typing for the next one.</Text>
593 <Box height={1} />
594 <Footer el={ctx.el} dir={ctx.dir} width={ctx.width} keys={[['⏎', 'save'], ['empty ⏎', 'back to search'], ['ctrl+s', 'project']]} />
595 </Box>
596 )
597}
598
599const EditView = (ctx: Ctx) => {
600 const { Box, Text } = ctx.el
601 const here = NOTES.filter(note => note.project === CURRENT)
602 return (
603 <Box flexDirection="column" width={ctx.width}>
604 <HeaderLine el={ctx.el} dir={ctx.dir} width={ctx.width} header={{ mode: 'search', query: '', right: `this project · ${here.length} open`, isFocused: false }} />
605 {rowsOf(ctx, here.slice(0, 1))}
606 <Box width={ctx.width} borderStyle="round" borderColor="claude" flexDirection="column" paddingX={1}>
607 <Text>
608 <Text color="claude" bold>
609 edit{' '}
610 </Text>
611 rename the store helpers to match the spec (createStore, change, scan) and the tests▏ ⏎ save
612 </Text>
613 <Box gap={2}>
614 <Text>[ esc cancel ]</Text>
615 <Text color="claude" bold>
616 [ ⏎ save ]
617 </Text>
618 <Box flexGrow={1} />
619 <Text dimColor>draft kept if the pane closes</Text>
620 </Box>
621 </Box>
622 {rowsOf(ctx, here.slice(2), { firstNumber: 3 })}
623 <Box height={1} />
624 <Footer el={ctx.el} dir={ctx.dir} width={ctx.width} keys={[['⏎', 'save'], ['esc', 'close, draft kept']]} />
625 </Box>
626 )
627}
628
629const EmptyView = (ctx: Ctx) => {
630 const { Box, Text } = ctx.el
631 const ways: [string, string][] = [
632 ['//', 'in the prompt, even mid-turn: // check the flaky test ⏎ (space first for a literal //)'],
633 ['ctrl+x s', 'opens this pane, and keeps your prompt draft'],
634 ['phone', 'my-server.ts.net: notes land here within seconds'],
635 ['◆', 'Claude can leave notes for you'],
636 ]
637 return (
638 <Box flexDirection="column" width={ctx.width}>
639 <HeaderLine el={ctx.el} dir={ctx.dir} width={ctx.width} header={{ mode: 'search', query: '', right: 'this project · 0 open', isFocused: true }} />
640 <Box flexDirection="column" paddingY={1} paddingX={2} borderStyle={ctx.dir === 'cards' ? 'round' : undefined} borderColor="inactive">
641 <Text bold>Nothing pending in cc.</Text>
642 <Box height={1} />
643 {ways.map(([key, text]) => (
644 <Text>
645 <Text color="claude" bold>
646 {key.padEnd(10)}
647 </Text>
648 <Text dimColor>{text}</Text>
649 </Text>
650 ))}
651 </Box>
652 <Footer el={ctx.el} dir={ctx.dir} width={ctx.width} keys={[['ctrl+n', 'new'], ['ctrl+s', 'scope'], ['ctrl+q', 'close']]} />
653 </Box>
654 )
655}
656
657const render = (ctx: Ctx, name: string) => {
658 switch (name) {
659 case 'search-empty':
660 return SearchView(ctx, '')
661 case 'search-typing':
662 return SearchView(ctx, 'cache')
663 case 'search-nomatch':
664 return SearchView(ctx, 'kubernetes')
665 case 'list-grouped':
666 return ListView(ctx, { selected: 'b' })
667 case 'list-folded':
668 return ListView(ctx, { selected: 'b', folded: ['infra', 'api'] })
669 case 'list-history':
670 return ListView(ctx, { selected: 'x', history: true, folded: ['infra', 'api', 'unfiled'] })
671 case 'picked':
672 return ListView(ctx, { selected: 'f', picked: ['a', 'c', 'f'] })
673 case 'bundle':
674 return BundleView(ctx)
675 case 'capture':
676 return CaptureView(ctx)
677 case 'edit':
678 return EditView(ctx)
679 default:
680 return EmptyView(ctx)
681 }
682}
683
684// ---- the band ----
685
686const POCKET = NOTES.filter(note => note.project === CURRENT)
687const POCKET_MANY: Note[] = [
688 ...POCKET,
689 ...['bump bun to 1.3 in the hub image', 'screenshot the inline pane at 100 cols', 'ask about arm runner minutes', 'tidy the fixture generator'].map(
690 (text, i): Note => ({ id: `p${i}`, project: CURRENT, age: `${i + 3}d`, text }),
691 ),
692]
693const chipLabel = (note: Note) => `${note.by === 'claude' ? '◆ ' : ''}${fit(note.text, 18).trimEnd()}`
694
695export const register: Register = on => {
696 on('session.start', async ($, e, next) => {
697 await $.command.register({ name: 'mock', description: 'mockups: /mock <quiet|cards|ledger|mix> <view>', immediate: true })
698 await $.command.register({ name: 'mockband', description: 'mockups: /mockband <off|a|b> <pocket0|pocket3|pocket9|harvest>', immediate: true })
699 return next(e)
700 })
701
702 on('command.run', { command: 'mock' }, async ($, e) => {
703 const [dir = 'quiet', name = 'search-empty'] = String(e.args).trim().split(/\s+/)
704 await update($, direction, () => dir)
705 await update($, view, () => (VIEWS.includes(name) ? name : 'search-empty'))
706 const opened = await $.ui.open({ id: PANE, title: 'ostrakon', closeOnEscape: true })
707 return { text: `mock ${dir} ${name} ${opened.isPlaced ? '' : opened.reason}` }
708 })
709
710 on('command.run', { command: 'mockband' }, async ($, e) => {
711 const [style = 'off', which = 'pocket3'] = String(e.args).trim().split(/\s+/)
712 await update($, band, () => (style === 'off' ? 'off' : `${style}:${which}`))
713 return { text: `band ${style} ${which}` }
714 })
715
716 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
717 const el = $.ui.resolve(e) as E
718 const dir = (await read($, direction)) as Direction
719 const name = await read($, view)
720 return render({ el, dir, width: e.props.bodyColumns - 1 }, name)
721 })
722
723 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
724 const setting = await read($, band)
725 if (setting === 'off' || e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
726 const { Box, Text, Button } = $.ui.resolve(e)
727 const [style, which] = setting.split(':')
728 const below = await next(e)
729 if (which === 'harvest') {
730 const proposals = [
731 'retry ignores Retry-After on 429',
732 'the arm runner image is 3 weeks old',
733 'merge.ts has no test for tombstones',
734 ]
735 return (
736 <Box flexDirection="column">
737 {below}
738 <Box gap={2}>
739 <Text color="claude" bold>
740 ◆ 3 notes from this turn
741 </Text>
742 {proposals.map((text, i) => (
743 <Button key={`h${i}`} plain hotkey={String(i + 1)} label={fit(text, 22).trimEnd()} onPress={() => undefined} />
744 ))}
745 <Button key="h0" plain hotkey="0" label="keep all" onPress={() => undefined} />
746 <Button key="hopen" plain label="✎" onPress={() => undefined} />
747 </Box>
748 <Text dimColor>1–3 keep one · 0 keep all · the rest wait in the pane under “proposed”</Text>
749 </Box>
750 )
751 }
752 const notes = which === 'pocket0' ? [] : which === 'pocket3' ? POCKET.slice(0, 3) : POCKET_MANY
753 const shown = notes.slice(0, 5)
754 const more = notes.length - shown.length
755 if (style === 'b') {
756 return (
757 <Box flexDirection="column">
758 {below}
759 <Box gap={1}>
760 <Text color={notes.length > 0 ? 'claude' : 'inactive'}>✎ {CURRENT}</Text>
761 {notes.length > 0 && <Text dimColor>│</Text>}
762 {shown.map((note, i) => (
763 <Button key={`p${i}`} plain hotkey={String(i + 1)} label={chipLabel(note)} onPress={() => undefined} />
764 ))}
765 {more > 0 && <Text dimColor>+{more} more</Text>}
766 <Box flexGrow={1} />
767 <Text dimColor>{notes.length > 0 ? 'digit ⏎ to use' : 'ctrl+x s · // to jot'}</Text>
768 </Box>
769 </Box>
770 )
771 }
772 return (
773 <Box flexDirection="column">
774 {below}
775 <Box gap={2}>
776 {shown.map((note, i) => (
777 <Button key={`p${i}`} plain hotkey={String(i + 1)} label={chipLabel(note)} onPress={() => undefined} />
778 ))}
779 {more > 0 && <Button key="more" plain dimColor label={`+${more}`} onPress={() => undefined} />}
780 <Button key="open" plain label="✎" onPress={() => undefined} />
781 </Box>
782 </Box>
783 )
784 })
785}
786types/index.d.ts 12 lines1export type SpmockPlaceholder = never
2
3declare module 'claude-code' {
4 interface PluginState {
5 spmock: {
6 direction: string
7 view: string
8 band: string
9 }
10 }
11}
12