Test bench companion: a well-behaved mod sharing the band, panes, the reply and the prompt box with Synaptik (issue #12).

<img alt="Synaptik — accept, reject or comment on any part of the agent's reply" src=".github/assets/readme-banner-light.png">
Synaptik (with a k) is a mod for Claude Code. Pick any part of the agent's reply — a paragraph, a step in its plan, an item in a list — and accept it, reject it, or comment on it. Synaptik turns your marks into a single prompt, written as if you'd typed it yourself.

Your marks appear in the prompt box as a dim suggestion: Tab takes it, ⏎ sends it. Nothing reaches the agent until you send, and what it receives is an ordinary prompt you saw first — Synaptik adds no markers or identifiers, and never interferes with the prompt/response flow.
v0.1.0, early. The core loop works and is used daily by its author: accept, reject and comment on blocks of the last reply, in the fullscreen and the classic renderer. What comes next is on the roadmap.
Claude Code v2.1.287+ in a terminal — any terminal, including the ones in VS Code, Cursor and JetBrains IDEs, and over SSH or tmux. Mods are enabled by default; claude --version reports yours.
This repository is its own plugin marketplace, synaptik-app:
claude plugin marketplace add synaptik-app/synaptik
claude plugin install synaptik@synaptik-app
[!NOTE] No GitHub SSH key? Set
CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1first.claude plugin update synaptik@synaptik-appgets new versions.
Download synaptik-<version>.zip from Releases on a connected machine and carry it over. Then either:
# one session only, straight from the zip
claude --plugin-dir ./synaptik-<version>.zip
# every session: unzip it into your skills folder
unzip synaptik-<version>.zip -d ~/.claude/skills/
On Windows the skills folder is %USERPROFILE%\.claude\skills\. To update, replace the synaptik folder there with the newer zip's. Use one install or the other: with both, the marketplace copy loads and the unzipped one doesn't.
/synaptik opens the navigator, /synaptik off and /synaptik on turn it off and on for the session, and /synaptik help lists the rest.
After a reply, the band above the prompt says how to start. Inside the navigator:
| Default | With the bindings below | |
|---|---|---|
| Start reviewing | ctrl+x tab | ctrl+s |
| Move between blocks | ↑ / ↓ | same |
| First / last block | Home / End (Fn+← / Fn+→ on a MacBook) | same |
| Choose an action | Tab / ⇧Tab | ← / → |
| Apply it | ⏎ | same |
| Apply directly | a accept · x reject · c comment | same |
| Done | Esc | same |
A mod cannot ship key bindings, so the better ones go in your own ~/.claude/keybindings.json. /synaptik config sets them for you: pick the key that opens Synaptik (ctrl+x tab or ctrl+s) and the keys that move between actions (Tab / ⇧Tab or ← / →), see exactly what it will add and what it gives up, and save. For other keys, edit the file by hand — /synaptik config shows them as they are:
{
"bindings": [
{ "context": "Chat", "bindings": { "ctrl+s": "abovePrompt:focus" } },
{ "context": "Pane", "bindings": { "left": "abovePrompt:previous", "right": "abovePrompt:next" } }
]
}
claude --version helps.Found a bug, or something that felt wrong or missing on a real reply? An issue is the most useful thing you can send. Ideas and design discussion are welcome; see CONTRIBUTING.md. docs/DECISIONS.md records what was decided and what was deliberately left out, so a proposal is easier to argue for against that than from scratch. Security issues: SECURITY.md.
Apache 2.0 — see LICENSE.
Synaptik is an independent open-source project, not affiliated with or endorsed by Anthropic. Claude and Claude Code are trademarks of Anthropic.
hooks/register.tsx 117 lines1// Test bench companion for issue #12: a deliberately well-behaved mod that
2// shares every surface Synaptik uses, so anything that breaks points at
3// Synaptik. It always calls next(e) and never takes what is not its own.
4//
5// band a line of its own plus a button (n), composed with next(e)
6// pane a small scrollable pane, "Neighbour"
7// reply the reply as drawn beneath it, plus a dim footer line
8// suggestion the pane's suggest button (or /neighbour suggest) proposes one
9//
10// /neighbour open the pane
11// /neighbour suggest propose a suggestion
12// /neighbour greedy toggle greedy mode: the band and the reply are drawn by
13// the neighbour alone, without next(e) — what Synaptik
14// looks like when a less polite mod sits above it
15
16import type { Register } from 'claude-code'
17
18
19// Everything lives inside register: the test kit loads this function as an
20// inline plugin on its own, without the rest of the module.
21export const register: Register = on => {
22 const PANE = 'neighbour-pane'
23
24 // Greedy mode, toggled by /neighbour greedy. A reload resets it to polite.
25 let isGreedy = false
26
27 on('session.start', async ($, e, next) => {
28 await $.command.register({
29 name: 'neighbour',
30 description: 'Test bench neighbour mod: open its pane, or `suggest`',
31 })
32 return next(e)
33 }).catch(($, e, next) => next(e))
34
35 on('command.run', { command: 'neighbour' }, async ($, e) => {
36 if (e.args.trim() === 'greedy') {
37 isGreedy = !isGreedy
38 $.ui.invalidate('ui.render')
39 return { text: `neighbour greedy: ${isGreedy ? 'on — no next(e) for band and reply' : 'off'}` }
40 }
41 if (e.args.trim() === 'suggest') {
42 const shown = await $.prompt.suggest({ text: 'neighbour: a suggestion of its own' })
43 return { text: `neighbour suggest: ${JSON.stringify(shown)}` }
44 }
45 const opened = await $.ui.open({ id: PANE, title: 'Neighbour', rows: 6, columns: 24 })
46 return { text: `neighbour pane: ${JSON.stringify(opened)}` }
47 })
48
49 // The band: its own line above whatever the chain beneath draws.
50 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
51 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
52 const { Box, Text, Button } = $.ui.resolve(e)
53 if (isGreedy) return <Text dimColor>neighbour · band (greedy: nothing beneath is drawn)</Text>
54 const beneath = await next(e)
55 return (
56 <Box flexDirection="column">
57 <Box flexDirection="row">
58 <Text dimColor>neighbour · band </Text>
59 <Button key="ping" hotkey="n" plain dimColor label="ping" onPress={() => $.ui.toast('neighbour: ping')} />
60 </Box>
61 {beneath}
62 </Box>
63 )
64 }).catch(($, e, next) => next(e))
65
66 // The reply, as drawn beneath this hook, with a footer: if the footer shows,
67 // the neighbour sits above Synaptik in the chain and saw its drawing.
68 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
69 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
70 const { Box, Text, Markdown } = $.ui.resolve(e)
71 if (isGreedy) {
72 // Draws the reply itself and never calls next(e): no mod beneath it runs.
73 return (
74 <Box flexDirection="column">
75 <Markdown text={e.props.text} />
76 <Text dimColor>— neighbour drew this reply alone (greedy)</Text>
77 </Box>
78 )
79 }
80 const beneath = await next(e)
81 return (
82 <Box flexDirection="column">
83 {beneath}
84 <Text dimColor>— neighbour saw this reply</Text>
85 </Box>
86 )
87 }).catch(($, e, next) => next(e))
88
89 // A pane with more lines than it shows, so ↑/↓ scroll it and the person's
90 // Pane bindings (←/→) can be seen to reach another mod's pane.
91 on('ui.render', { component: 'Pane', requestId: PANE }, ($, e, next) => {
92 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
93 const { Box, Text, Button } = $.ui.resolve(e)
94 return (
95 <Box flexDirection="column">
96 <Box flexDirection="row">
97 <Button key="one" label="one" onPress={() => $.ui.toast('neighbour: one')} />
98 <Button key="two" label="two" onPress={() => $.ui.toast('neighbour: two')} />
99 <Button
100 key="suggest"
101 label="suggest"
102 onPress={async () => {
103 // From a button, not a command: the prompt box is empty and no
104 // command runs, so a refusal here is a real conflict.
105 const shown = await $.prompt.suggest({ text: 'neighbour: a suggestion of its own' })
106 $.ui.toast(`neighbour suggest: ${JSON.stringify(shown)}`)
107 }}
108 />
109 </Box>
110 {Array.from({ length: 20 }, (_, i) => (
111 <Text dimColor>neighbour line {i + 1}</Text>
112 ))}
113 </Box>
114 )
115 }).catch(($, e, next) => next(e))
116}
117