SLOPSHOPPER

neighbour

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

newpanebandrowscommandtoast
★ 2v0.0.0-testApache-2.0updated 2026-10-21synaptik-app/synaptik/testbench/neighbour-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · neighbour
│ ┃ Neighbour ✕ › fix the failing auth test and add an audit log call │ ┃ [ one ][ two ][ suggest ] │ ┃ neighbour line 1 ⏺ Read(src/auth.ts) │ ┃ neighbour line 2 ⎿ Read 6 lines │ ┃ neighbour line 3 ⏺ Update(src/auth.ts) │ ┃ neighbour line 4 ⎿ Added 2 lines, removed 1 line │ ┃ neighbour line 5 ⏺ Bash(bun test) │ ┃ neighbour line 6 ⎿ 3 pass, 1 fail │ ┃ neighbour line 7 │ ┃ neighbour line 8 ⟨Claude Code's own drawing⟩ │ ┃ neighbour line 9 — neighbour saw this reply │ ┃ neighbour line 10 │ ┃ neighbour line 11 ✻ Worked for 42s · done 4:20 PM │ ┃ neighbour line 12 │ ┃ neighbour line 13 › /neighbour │ ┃ neighbour line 14 ⎿ neighbour: neighbour pane: {"isPlaced":true} │ ┃ neighbour line 15 │ ┃ neighbour line 16 │ ┃ neighbour line 17 │ ┃ neighbour line 18 │ ┃ neighbour line 19 │ ┃ neighbour line 20 │ neighbour · band n: ping ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
neighbour · band n: ping ⟨Claude Code's own drawing⟩
Pane · Neighbour
[ one ][ two ][ suggest ] neighbour line 1 neighbour line 2 neighbour line 3 neighbour line 4 neighbour line 5 neighbour line 6 neighbour line 7 neighbour line 8 neighbour line 9 neighbour line 10 neighbour line 11 neighbour line 12 neighbour line 13 neighbour line 14 neighbour line 15 neighbour line 16 neighbour line 17 neighbour line 18 neighbour line 19 neighbour line 20
Claude's reply
⟨Claude Code's own drawing⟩ — neighbour saw this reply
README

<img alt="Synaptik — accept, reject or comment on any part of the agent's reply" src=".github/assets/readme-banner-light.png">

Synaptik

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.

Synaptik: marking up a reply, then sending the marks as one prompt

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.

Status

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.

Install

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.

From the marketplace

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=1 first. claude plugin update synaptik@synaptik-app gets new versions.

Offline or air-gapped

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.

Once it's in

/synaptik opens the navigator, /synaptik off and /synaptik on turn it off and on for the session, and /synaptik help lists the rest.

Keys

After a reply, the band above the prompt says how to start. Inside the navigator:

DefaultWith the bindings below
Start reviewingctrl+x tabctrl+s
Move between blocks↑ / ↓same
First / last blockHome / End (Fn+← / Fn+→ on a MacBook)same
Choose an actionTab / ⇧Tab← / →
Apply it⏎same
Apply directlya accept · x reject · c commentsame
DoneEscsame

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" } }
  ]
}

Known limits

  • Fullscreen reviews the reply in place, with a block map docked beside it. Classic cannot redraw what has scrolled off, so the navigator shows the selected block itself, above the prompt.
  • The navigator may not appear on narrow terminals: Claude Code holds back a pane opened unasked below about 144 columns. The band then offers to open it.
  • Only the last reply can be marked, and marks last for the session — they are not saved, and nothing is written into the conversation.
  • One mod redraws a reply. Another mod that changes how replies are drawn may be bypassed while you review; outside review Synaptik leaves replies alone unless they carry marks.
  • Code blocks and tables are one block each, marked as a whole for now; stepping into them is planned (#105).
  • No key clears a mark yet. Pressing the same verb again unmarks ✓ and ✗; a ✎ goes when its text is deleted or another verb replaces it (#100).
  • A Claude Code update can break Synaptik: it is built on the young mods API. An issue with your claude --version helps.

Contributing

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.

License

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.

Source 1 files
hooks/register.tsx 117 lines
1// 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