SLOPSHOPPER

spx

Ostrakon v2 platform experiments

newpanebandcommandtoastprompt
★ 2v0.0.1MITupdated 2026-10-10gaius-codius/ostrakon/tools/experiments/spx
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · spx
│ ┃ spx c ✕ › fix the failing auth test and add an audit log call │ ┃ search: type │ ┃ spxc · focused=false · dock · 56 cols ● spx: SPX c open {"isPlaced":true} panes=[{"id":"spxc","title":"spx │ ┃ j: row 0 ● spx: SPX spxi ran args="" at 1760000000020 │ ┃ k: row 1 ⏺ Read(src/auth.ts) │ ┃ borrow app:toggleReplTab ⎿ Read 6 lines │ ┃ borrow app:toggleTerminal ⏺ Update(src/auth.ts) │ ┃ borrow app:redraw ⎿ Added 2 lines, removed 1 line │ ┃ borrow pane:next ⏺ Bash(bun test) │ ┃ borrow pane:previous ⎿ 3 pass, 1 fail │ ┃ borrow pane:grow │ ┃ borrow pane:shrink ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /spx │ ● spx: SPX spxi open {"isPlaced":true} panes=[{"id":"spxc","title":"s │ ● spx: SPX pid "" second= │ │ ⟨Claude Code's own drawing⟩ 9: spx band open ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ 9: spx band open
Pane · spx c
search: type spxc · focused=false · dock · 56 cols j: row 0 k: row 1 borrow app:toggleReplTab borrow app:toggleTerminal borrow app:redraw borrow pane:next borrow pane:previous borrow pane:grow borrow pane:shrink
Pane · spx i
search: type spxi · focused=false · dock · 56 cols j: row 0 k: row 1 borrow app:toggleReplTab borrow app:toggleTerminal borrow app:redraw borrow pane:next borrow pane:previous borrow pane:grow borrow pane:shrink
README

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

What it does

  • Capture a note in under two seconds, even while Claude is working. Mid-prompt, 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.
  • Gives Claude a 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.
  • Triage open notes across all your projects in a searchable pane: edit, move, archive, bundle several into one prompt.
  • Dispatch a note into the prompt. This project's open notes sit in a row of numbered chips above the prompt (the "pocket"); press a note's number in an empty prompt to pull it in. Mid-prompt, find the note in the pane and press t: it goes in under what you've typed.
  • Harvest (opt-in): after each turn, an extra review looks for side issues that came up and offers them as notes, which you keep or dismiss. It doesn't change what Claude is doing.
  • Sync: two-way sync enabled by multiple methods. Through a small hub, a plain folder, Obsidian or a combination of methods (Sync).
  • Phone: capture and triage from your phone with the hub's web app, installed to the Home Screen, or send a note from the share sheet or Siri with an iOS Shortcut.

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.

The ostrakon pane docked beside a Claude Code session, listing open notes across projects, with this project's notes as numbered chips above the prompt

Sync

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:

  • The hub (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.
  • A folder (the 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.
  • Both at once: they work independently, so you can use the hub for your phone and a folder for Obsidian.

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.

Install

There are two ways to install it:

  • The install script gets you the mod and the ostrakon command: update, doctor, pair, and the hub for syncing and the phone app. Updates come through ostrakon update.
  • Claude Code's plugin marketplace gets you the mod only. There's no 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.

Requirements

  • Claude Code with mods support (see the mods docs). Tested with 2.1.296; mods are still changing, so a much older or newer version may not work.
  • git, and python3 for the install script (without it, the script tells you the one line to add yourself).
  • For the hub: Bun (tested with 1.4.3) to run the hub and to use 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.

With the install script

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:

  • switches to the newest release (leave out --release to run what's on main),
  • loads the mod in every Claude Code session, by setting CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json (only if it isn't set already, and it keeps a .bak copy),
  • and adds the 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" } }

From the plugin marketplace

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.

Updating

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.

If something isn't working

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.

Keybindings

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.

Configuration

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):

OptionDefaultWhat it does
notesDir~/.local/share/ostrakonWhere notes are stored. The OSTRAKON_DIR environment variable overrides it.
pocket / pocketMaxon / 5The numbered chips above the prompt, and how many show.
harvestoffReview each finished turn for side issues worth keeping.
harvestModeforkfork 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.
harvestMaxPerTurn3The most notes harvest offers after one turn.
hubUrl / hubTokenemptyThe hub to sync with, and its token. Empty: no hub.
mirrorDiremptyA 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 and the phone app (optional)

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.

Known limitations

These come from Claude Code's mods interface, which is still new, and should go away as it matures.

  • Long notes in the pane's text field. When a note is longer than the capture or edit field, the text doesn't scroll with you: it disappears past the end of the field, while the cursor keeps moving and can even drop to an empty "second" line. What you type is all there, though, and the whole note shows in full just below the field as you write. It needs a text field that wraps or scrolls, which mods don't have yet.
  • Claude Code updates can break things. Mods change between versions. If something stops working after an update, run ostrakon doctor and mention your Claude Code version when you report it.

More, including a couple of folder-sync edge cases, in ROADMAP.md.

Development

  • How it's designed, and notes on the platform it builds on: 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.
  • The hub's tests need only Bun: cd server && bun test.
  • How to contribute, and how releases are made: CONTRIBUTING.md.
  • tools/ also has the screenshot pipeline and some experiment scripts; they write to working-docs/, which git ignores.

License

MIT

Source 2 files
hooks/register.tsx 252 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4// Every result goes to the debug log as "SPX ..." so the experiment runner can grep it.
5const pendingFocus = atom({ plugin: 'spx', key: 'pendingFocus' } as const, '')
6const stash = atom({ plugin: 'spx', key: 'stash' } as const, '')
7const slashVariant = atom({ plugin: 'spx', key: 'slashVariant' } as const, '')
8const focusLog = atom({ plugin: 'spx', key: 'focusLog' } as const, [])
9
10const log = ($: EngineInterface, text: string) => $.ui.log(`SPX ${text}`, { to: 'debug' })
11
12// Borrowed-action candidates for ctrl+l / ctrl+n (experiment 3).
13const CANDIDATES = [
14  'app:toggleReplTab',
15  'app:toggleTerminal',
16  'app:redraw',
17  'pane:next',
18  'pane:previous',
19  'pane:grow',
20  'pane:shrink',
21] as const
22
23const DIR = '/tmp/spx-mv'
24
25const timeWrites = async ($: EngineInterface, n: number) => {
26  const t0 = await $.clock.now()
27  for (let i = 0; i < n; i++) await $.fs.write(`${DIR}/direct-${i}.md`, `note ${i}\n`)
28  const t1 = await $.clock.now()
29  for (let i = 0; i < n; i++) {
30    await $.fs.write(`${DIR}/atomic-${i}.md.tmp`, `note ${i}\n`)
31    await $.process.run(['mv', `${DIR}/atomic-${i}.md.tmp`, `${DIR}/atomic-${i}.md`])
32  }
33  const t2 = await $.clock.now()
34  const t3s = await $.clock.now()
35  const listed = await $.fs.list(DIR)
36  const t3 = await $.clock.now()
37  return `direct ${((t1 - t0) / n).toFixed(2)}ms/write, tmp+mv ${((t2 - t1) / n).toFixed(2)}ms/write, list(${listed.length}) ${(t3 - t3s).toFixed(1)}ms`
38}
39
40export const register: Register = on => {
41  on('session.start', async ($, e, next) => {
42    await $.command.register({ name: 'spx', description: 'experiments: open the test pane' })
43    await $.command.register({ name: 'spxi', description: 'experiments: immediate capture', immediate: true, argumentHint: '<note>' })
44    await $.command.register({ name: 'spxv', description: 'experiments: // variant', immediate: true })
45    await $.command.register({ name: 'spxpid', description: 'experiments: parent pid of process.run' })
46    await $.command.register({ name: 'spxmv', description: 'experiments: time tmp+mv writes' })
47    await $.command.register({ name: 'spxnotion', description: 'experiments: $.mcp.call Notion' })
48    return next(e)
49  })
50
51  // Experiments 1 and 2: open from a typed "//x" prompt.
52  on('prompt.submit', async ($, e, next) => {
53    const m = /^\/\/(a|b|c|d0|d1|e|g)\b/.exec(e.text)
54    if (!m || e.origin.kind !== 'composer') return next(e)
55    const box = await $.prompt.read()
56    log($, `submit ${m[1]} composer="${box.text}"`)
57    if (m[1] === 'a') {
58      const r = await $.ui.open({ id: 'spxa', title: 'spx a', focus: true })
59      log($, `a open ${JSON.stringify(r)} panes=${JSON.stringify(await $.ui.panes())}`)
60      return { drop: '✎ a' }
61    }
62    if (m[1] === 'b') {
63      await update($, pendingFocus, () => 'spxb')
64      const r = await $.ui.open({ id: 'spxb', title: 'spx b' })
65      log($, `b open ${JSON.stringify(r)}`)
66      return { drop: '✎ b' }
67    }
68    if (m[1] === 'c') {
69      $.command.run({ command: 'spx' }).then(
70        r => log($, `c command.run ok ${JSON.stringify(r).slice(0, 200)}`),
71        err => log($, `c command.run rejected ${String(err)}`),
72      )
73      return { drop: '✎ c' }
74    }
75    if (m[1] === 'e') {
76      $.clock.after(60, async () => {
77        const r = await $.ui.open({ id: 'spxe', title: 'spx e', focus: true })
78        log($, `e delayed open ${JSON.stringify(r)} panes=${JSON.stringify(await $.ui.panes())}`)
79      })
80      return { drop: '✎ e' }
81    }
82    if (m[1] === 'g') {
83      const r = await $.ui.open({ id: 'spxg', title: 'spx g' })
84      log($, `g open ${JSON.stringify(r)}`)
85      for (const ms of [60, 250, 600]) {
86        $.clock.after(ms, async () => {
87          const again = await $.ui.open({ id: 'spxg', title: 'spx g', focus: true })
88          log($, `g reopen+focus @${ms}ms ${JSON.stringify(again)} panes=${JSON.stringify(await $.ui.panes())}`)
89        })
90      }
91      return { drop: '✎ g' }
92    }
93    if (m[1] === 'd0') return { drop: '' }
94    return { drop: '✎ noted' }
95  })
96
97  on('command.run', { command: 'spx' }, async $ => {
98    const r = await $.ui.open({ id: 'spxc', title: 'spx c', focus: true, rows: 30 })
99    log($, `c open ${JSON.stringify(r)} panes=${JSON.stringify(await $.ui.panes())}`)
100    return { text: '' }
101  })
102
103  on('command.run', { command: 'spxi' }, async ($, e) => {
104    log($, `spxi ran args=${JSON.stringify(e.args)} at ${await $.clock.now()}`)
105    if (String(e.args).trim() !== '') {
106      $.ui.toast(`✎ noted: ${e.args}`)
107      return { text: '' }
108    }
109    const r = await $.ui.open({ id: 'spxi', title: 'spx i', focus: true })
110    log($, `spxi open ${JSON.stringify(r)} panes=${JSON.stringify(await $.ui.panes())}`)
111    return { text: '' }
112  })
113
114  // "//" as an instant trigger: the second slash typed into an otherwise empty prompt.
115  on('prompt.edit', async ($, e, next) => {
116    if (e.text.slice(0, e.start) + e.inputText + e.text.slice(e.end) !== '//') return next(e)
117    const variant = (await read($, slashVariant)) || 'before'
118    if (variant === 'rewrite') {
119      log($, 'slash rewrite → /spxi')
120      return { text: '/spxi ', cursor: 6 }
121    }
122    if (variant === 'before') {
123      const r = await $.ui.open({ id: 'spxs', title: 'spx slash', focus: true, closeOnEscape: true })
124      log($, `slash open-before ${JSON.stringify(r)} panes=${JSON.stringify(await $.ui.panes())}`)
125      return { text: '', cursor: 0 }
126    }
127    $.clock.after(0, async () => {
128      const r = await $.ui.open({ id: 'spxs', title: 'spx slash', focus: true, closeOnEscape: true })
129      log($, `slash open-after ${JSON.stringify(r)} panes=${JSON.stringify(await $.ui.panes())}`)
130    })
131    return { text: '', cursor: 0 }
132  })
133
134  on('command.run', { command: 'spxv' }, async ($, e) => {
135    await update($, slashVariant, () => String(e.args).trim())
136    return { text: `slash variant: ${String(e.args).trim()}` }
137  })
138
139  on('command.run', { command: 'spxpid' }, async $ => {
140    const a = await $.process.run(['sh', '-c', 'echo $PPID; ps -o pid=,comm=,args= -p $PPID'])
141    const b = await $.process.run(['sh', '-c', 'echo $PPID'])
142    log($, `pid ${JSON.stringify(a.stdout)} second=${b.stdout.trim()}`)
143    return { text: `spx pid: ${a.stdout.trim()} | second call ppid ${b.stdout.trim()}` }
144  })
145
146  // Experiment 5.
147  on('command.run', { command: 'spxmv' }, async $ => {
148    const out = [await timeWrites($, 100), await timeWrites($, 100)].join(' | ')
149    log($, `mv ${out}`)
150    return { text: `spx mv: ${out}` }
151  })
152
153  // Experiment 7.
154  on('command.run', { command: 'spxnotion' }, async $ => {
155    try {
156      const r = await $.mcp.call('claude.ai Notion', 'notion-get-self', {})
157      const text = JSON.stringify(r).slice(0, 300)
158      log($, `notion ok ${text}`)
159      return { text: `spx notion ok: ${text}` }
160    } catch (err) {
161      log($, `notion rejected ${String(err)}`)
162      return { text: `spx notion rejected: ${String(err)}` }
163    }
164  })
165
166  on('ui.close', async ($, e, next) => {
167    const r = await next(e)
168    const text = await read($, stash)
169    if (text !== '') {
170      await update($, stash, () => '')
171      const f = await $.prompt.fill({ text, mode: 'replace' })
172      log($, `restored draft "${text}" on close of ${e.id} fill=${JSON.stringify(f)}`)
173    }
174    return r
175  })
176
177  // Experiment 4: every focus move, with its origin.
178  on('ui.focus', async ($, e, next) => {
179    if (!e.requestId.startsWith('spx') && e.component !== 'AbovePrompt') return next(e)
180    const r = await next(e)
181    const line = `${e.requestId} → ${e.element ?? '(engine stop)'} by ${e.origin.kind}`
182    log($, `focus ${line} ${JSON.stringify(r)}`)
183    await update($, focusLog, list => [...list, line].slice(-6))
184    return r
185  })
186
187  // Experiment 1 (band): open from a band Button press.
188  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
189    const { Box, Button } = $.ui.resolve(e)
190    return (
191      <Box flexDirection="row" gap={2}>
192        {await next(e)}
193        <Button
194          key="spx-open"
195          plain
196          hotkey="9"
197          label="spx band open"
198          action="app:cycleDiffBase"
199          onPress={async () => {
200            // F23 workaround: stash a non-empty draft, empty the box, then open with focus.
201            const box = await $.prompt.read()
202            if (box.text !== '') {
203              await update($, stash, () => box.text)
204              const f = await $.prompt.fill({ text: '', mode: 'replace' })
205              log($, `stashed draft "${box.text}" fill=${JSON.stringify(f)}`)
206            }
207            const r = await $.ui.open({ id: 'spxband', title: 'spx band', focus: true, closeOnEscape: true })
208            log($, `band open ${JSON.stringify(r)} panes=${JSON.stringify(await $.ui.panes())}`)
209          }}
210        />
211      </Box>
212    )
213  })
214
215  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
216    if (!e.requestId.startsWith('spx')) return next(e)
217    const { Box, Text, Button, Input } = $.ui.resolve(e)
218    const want = await read($, pendingFocus)
219    if (want === e.requestId) {
220      // Experiment 1b: focus on first render. A render hook can't write state, so clear it from a timer.
221      $.clock.after(0, async () => {
222        await update($, pendingFocus, () => '')
223        const r = await $.ui.focus({ requestId: e.requestId, key: 'q' }).catch(err => ({ deny: String(err) }))
224        log($, `b focus-on-render ${JSON.stringify(r)}`)
225      })
226    }
227    const lines = await read($, focusLog)
228    return (
229      <Box flexDirection="column">
230        <Input key="q" autoFocus label="search" placeholder="type" onSubmit={() => log($, 'q submit')} />
231        <Text>
232          {e.requestId} · focused={String(e.props.isFocused)} · {e.props.placement} · {e.props.bodyColumns} cols
233        </Text>
234        <Button key="row0" plain hotkey="j" label="row 0" onPress={() => log($, 'press row0')} />
235        <Button key="row1" plain hotkey="k" label="row 1" onPress={() => log($, 'press row1')} />
236        {CANDIDATES.map((action, i) => (
237          <Button
238            key={`act${i}`}
239            plain
240            action={action}
241            label={`borrow ${action}`}
242            onPress={() => log($, `action ${action} pressed`)}
243          />
244        ))}
245        {lines.map(line => (
246          <Text dimColor>{line}</Text>
247        ))}
248      </Box>
249    )
250  })
251}
252
types/index.d.ts 13 lines
1export type SpxPlaceholder = never
2
3declare module 'claude-code' {
4  interface PluginState {
5    spx: {
6      pendingFocus: string
7      stash: string
8      slashVariant: string
9      focusLog: string[]
10    }
11  }
12}
13