SLOPSHOPPER

Synaptik

Accept, reject or comment on any part of the agent's reply — Synaptik turns your marks into one prompt.

newpanebandspinnerrowscommand
★ 2v0.1.0Apache-2.0updated 2026-10-24synaptik-app/synaptik/hosts/claude-code
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · synaptik
│ ┃ Synaptik ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ▲ Nothing marked yet ⏺ Read(src/auth.ts) │ ┃ █ ⎿ Read 6 lines │ ┃ █ ⏺ Update(src/auth.ts) │ ┃ █ ⎿ Added 2 lines, removed 1 line │ ┃ █ ⏺ Bash(bun test) │ ┃ █ ⎿ 3 pass, 1 fail │ ┃ █ │ ┃ █ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ █ │ ┃ █ ✻ Worked for 42s · done 4:20 PM │ ┃ █ │ ┃ █ › /synaptik │ ┃ █ │ ┃ █ │ ┃ █ │ ┃ █ │ ┃ █ │ ┃ █ │ ┃ █ │ ┃ █ │ ┃ █ │ ┃ █ │ ┃ █ │ ┃ █ ⟨Claude Code's own drawing⟩ Synaptik · respond to the reply in place ⎿ ctrl+x tab to respond in place ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ Synaptik · respond to the reply in place ⎿ ctrl+x tab to respond in place
Pane · Synaptik
▲ Nothing marked yet █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ▼ Block 1 of 1
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 17 files
hooks/register.tsx 990 lines
1// Synaptik's hooks: the only file that touches `$` (CLAUDE.md). The logic is in
2// src/; this draws it and wires it to Claude Code. What it draws (SPEC):
3//   reply      the selected block on the selection background, its mark in
4//              the margin, the actions under it (§6)
5//   navigator  a pane: a map of the blocks docked in fullscreen, the selected
6//              block itself in classic; ↑/↓ blocks, Tab or ←/→ actions (§10)
7//   band       above the prompt: what is staged, and the keys (§7)
8//   /synaptik  open the navigator; help, on/off, config, log (§10, D39)
9
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, Register } from 'claude-code'
12
13import type { Mark } from '../types'
14import { quote, segment } from '../src/blocks'
15import { lastTurnText } from '../src/turn'
16import { compile as compileTray, GLYPH } from '../src/compile'
17import { draftComment, dropComment, EMPTY, finishComment as finishTrayComment, toggle as toggleMark } from '../src/tray'
18import type { Tray } from '../src/tray'
19import { DEFAULT_KEYS, keyLabel, keysFrom, parseKeyFile } from '../src/keys'
20import type { KeyFile, Keys } from '../src/keys'
21import { applySetup, changes, costs, currentSetup, DEFAULT_ENTRY, DEFAULT_VERBS } from '../src/keysetup'
22import type { Setup } from '../src/keysetup'
23import { keybindingsPath, shownPath } from '../src/configpath'
24import { blockCounter } from '../src/counter'
25import { classicRows, clip, drawnRows, minimap } from '../src/layout'
26import { commentCount, glyphCounts, summary } from '../src/tally'
27import {
28  ACCENT,
29  BULLET,
30  COMMENT_PREFIX,
31  commentKey,
32  FIELD_CHROME,
33  MARGIN,
34  MAX_DRAWN,
35  PANE,
36  PANE_ANCHOR,
37  PANE_COLUMNS,
38  ROW_CHROME,
39  SELECTED_BACKGROUND,
40  VERBS,
41} from './ui'
42import { KeyMenuView } from './views/keymenu'
43import { ClassicView, EmptyView, MapView } from './views/navigator'
44import type { NavigatorActions } from './views/navigator'
45import { ReplyView } from './views/reply'
46import { BandView } from './views/band'
47import type { KeyMenuModel } from './views/keymenu'
48import type { ConfigEnv } from '../src/configpath'
49
50const isOn = atom({ plugin: 'synaptik', key: 'isOn' } as const, true)
51const isWorking = atom({ plugin: 'synaptik', key: 'isWorking' } as const, false)
52const lastText = atom({ plugin: 'synaptik', key: 'lastText' } as const, '')
53const selected = atom({ plugin: 'synaptik', key: 'selected' } as const, 0)
54const marks = atom({ plugin: 'synaptik', key: 'marks' } as const, {})
55const comments = atom({ plugin: 'synaptik', key: 'comments' } as const, {})
56const isCommenting = atom({ plugin: 'synaptik', key: 'isCommenting' } as const, false)
57const focusedVerb = atom({ plugin: 'synaptik', key: 'focusedVerb' } as const, '')
58const isPaneShown = atom({ plugin: 'synaptik', key: 'isPaneShown' } as const, false)
59// What the comment field is drawn with when it opens. The field is never redrawn
60// from the live comment: a redraw replaces what the person typed (InputProps).
61const commentSeed = atom({ plugin: 'synaptik', key: 'commentSeed' } as const, '')
62const commentSeq = atom({ plugin: 'synaptik', key: 'commentSeq' } as const, 0)
63
64// Synaptik draws only in the terminal renderer (D40): every render hook passes
65// any other surface (the Desktop app, the mobile app, VS Code) to next(e).
66
67// What happened, for `/synaptik log` and bug reports: events and failures —
68// adopting, suggesting, scrolling and focus moves, comments, the key setup —
69// not every redraw or keystroke, which would push them out of its 60 lines.
70// Module variable, not $.state: a render hook may not write state. Lost on reload.
71const observed: string[] = []
72function observe(line: string) {
73  observed.push(line)
74  observed.splice(0, Math.max(0, observed.length - 60))
75}
76
77// The transcript row of the reply being marked, for revealing it when a block
78// key cannot be scrolled to. Set while drawing; lost on reload.
79let replyRequestId = ''
80
81// The turn-duration row Claude Code draws after a turn (`✻ Cogitated for 19s`),
82// the newest one seen. Scrolling it to the window's bottom brings the end of the
83// reply above it into view — the reply itself, taller than the window, can only
84// be scrolled to its top. Lost on reload.
85let turnRowId = ''
86
87// Whether the navigator holds the keyboard. Esc raises no ui.focus event, so
88// the only reliable source is the navigator's own drawing (Pane isFocused);
89// a drawing may not write state, so it lands here and asks for a redraw.
90let isNavigatorFocused = false
91
92// The classic navigator (#31): the terminal's size, as last seen by a drawing;
93// how far the selected block is scrolled inside it (PgUp/PgDn); and how many
94// rows its text area had at the last draw.
95let screen = { columns: 120, rows: 40 }
96let blockOffset = 0
97let blockTextRows = 1
98
99// Whether the compiled prompt is the prompt box's dim suggestion right now
100// (Tab takes it). Set from prompt.suggest's answer; the band words itself by it.
101let isSuggested = false
102const PLACEHOLDER = 'Give me a sec to read through this.'
103
104// `/synaptik help` (D39). Claude Code leads a command's output with its name
105// (`synaptik:`), so no line here repeats it.
106const HELP = [
107  "turns the agent's reply into something you mark up in place — accept, reject,",
108  'comment — and sends your marks back as one prompt.',
109  '',
110  '  /synaptik          open the navigator',
111  '  /synaptik on       turn Synaptik on for this session',
112  '  /synaptik off      turn it off for this session (/plugin disables it for good)',
113  '  /synaptik config   customize Synaptik',
114  '  /synaptik log      what Synaptik observed, for bug reports',
115  '  /synaptik help     this list',
116].join('\n')
117
118// The compiled prompt is proposed in the empty prompt box after every change,
119// as Claude Code proposes its own: → or Tab takes it, the box is where it is
120// edited and sent, ctrl+c clears it (SPEC §7, *Sending*).
121async function suggestTray($: EngineInterface) {
122  try {
123    const text = await compile($)
124    if (!text) {
125      // Nothing staged. There is no call to withdraw a suggestion, a blank one
126      // cannot show, and emptying the box does not dismiss it — so replace ours
127      // with something harmless to send, which guesses nothing about intent.
128      if (isSuggested) {
129        const shown = await $.prompt.suggest({ text: ` ${PLACEHOLDER}` })
130        observe(`suggest placeholder: ${JSON.stringify(shown)}`)
131      }
132      isSuggested = false
133      $.ui.invalidate('ui.render')
134      return
135    }
136    // The box draws its cursor over a suggestion's first cell, hiding it; a
137    // leading space takes the cursor so the first mark glyph stays visible.
138    const shown = await $.prompt.suggest({ text: ` ${text}` })
139    isSuggested = shown.isShown
140    observe(`suggest ${text.split('\n').length} lines: ${JSON.stringify(shown)}`)
141    $.ui.invalidate('ui.render')
142  } catch (err) {
143    observe(`suggest failed: ${String(err)}`)
144  }
145}
146
147
148// Reads the last assistant reply from the transcript. A different reply than
149// the one marked so far starts a fresh tray; the same one (a reload) keeps it.
150async function adoptLastReply($: EngineInterface) {
151  // The whole last turn, not its last message: text before a tool call is a
152  // separate message and was left unmarkable.
153  const messages = await $.session.messages()
154  const text = lastTurnText(messages)
155  observe(`adopt ${messages.length} messages, picked ${text.length} chars`)
156  if (text !== (await read($, lastText))) {
157    await update($, lastText, () => text)
158    // Start on the last block: a long reply leaves the terminal at its end, so
159    // that is the block already on screen. Review runs upwards from there.
160    const count = segment(text).filter(s => s.isBlock).length
161    await update($, selected, () => Math.max(0, count - 1))
162    await writeTray($, () => EMPTY)
163    await update($, isCommenting, () => false)
164  }
165  if (text) await openPane($)
166}
167
168// Opened unasked (a new reply, a reload) the pane waits undrawn below 144
169// columns; opened from the person's press it is placed at any width. When it
170// waits or is closed, the band offers that press.
171async function openPane($: EngineInterface, focus = false) {
172  const rows = classicRows(await read($, lastText), screen)
173  const opened = await $.ui.open({
174    id: PANE,
175    title: 'Synaptik',
176    columns: PANE_COLUMNS,
177    rows,
178    ...(focus ? { focus: true as const } : {}),
179  })
180  await update($, isPaneShown, () => opened.isPlaced)
181  // Pinning can fail (the pane not drawn yet); it must not abort the caller —
182  // session.start adopts the reply around this call.
183  const pinned = await $.ui
184    .scroll({ in: PANE, to: { key: PANE_ANCHOR }, block: 'start' })
185    .catch((err: unknown) => ({ deny: String(err) }))
186  observe(
187    `pane open ${JSON.stringify(opened)} · asked rows=${rows} (screen ${screen.columns}×${screen.rows}) · pin ${JSON.stringify(pinned)}`,
188  )
189}
190
191// Moves the selection and brings the block into view in the transcript. The
192// transcript only scrolls while answering the person's own input.
193async function moveSelection($: EngineInterface, by: number) {
194  // While a turn runs the reply on screen is not the one adopted (SPEC §2).
195  if (await read($, isWorking)) return
196  await finishComment($)
197  const count = segment(await read($, lastText)).filter(s => s.isBlock).length
198  const before = await read($, selected)
199  blockOffset = 0
200  await update($, selected, s => Math.max(0, Math.min(count - 1, s + by)))
201  // The transcript can be scrolled to a whole row only, never to a block in a
202  // reply, and a reply taller than the window always lands on its top. So:
203  // entering the first third of the blocks brings the reply's top into view;
204  // entering the last third brings the row after it (the turn's duration) to the
205  // window's bottom, which shows the reply's end. The middle is the person's to
206  // scroll (the wheel, PgUp/PgDn).
207  const now = await read($, selected)
208  const topThird = Math.ceil(count / 3)
209  const bottomThird = count - Math.ceil(count / 3)
210  if (replyRequestId && now < topThird && before >= topThird) {
211    const shown = await $.ui.scroll({ to: { requestId: replyRequestId }, block: 'start' })
212    observe(`reveal reply top: ${JSON.stringify(shown)}`)
213  }
214  if (now >= bottomThird && before < bottomThird) {
215    if (!turnRowId) return observe('reveal reply end: no turn row seen')
216    const shown = await $.ui.scroll({ to: { requestId: turnRowId }, block: 'end' })
217    observe(`reveal reply end via ${turnRowId.slice(0, 12)}: ${JSON.stringify(shown)}`)
218  }
219}
220
221// The tray (src/tray.ts) as $.state holds it: two atoms, marks and comments.
222async function readTray($: EngineInterface): Promise<Tray> {
223  return { marks: await read($, marks), comments: (await read($, comments)) as Record<number, string> }
224}
225
226async function writeTray($: EngineInterface, change: (tray: Tray) => Tray) {
227  const next = change(await readTray($))
228  await update($, marks, () => ({ ...next.marks }))
229  await update($, comments, () => ({ ...next.comments }))
230}
231
232async function toggle($: EngineInterface, i: number, mark: Mark) {
233  await writeTray($, tray => toggleMark(tray, i, mark))
234  await suggestTray($)
235}
236
237async function runVerb($: EngineInterface, verb: string) {
238  if (await read($, isWorking)) return
239  const i = await read($, selected)
240  if (verb === 'verb-accept') return toggle($, i, 'accept')
241  if (verb === 'verb-reject') return toggle($, i, 'reject')
242  if (verb === 'verb-discuss') return $.ui.toast('discuss: phase 2')
243  const existing = (await read($, comments))[i] ?? ''
244  await update($, commentSeed, () => existing)
245  await update($, commentSeq, n => n + 1)
246  await update($, isCommenting, () => true)
247  // A focus move that fails costs only the focus, never the comment around it.
248  const moved = await $.ui
249    .focus({ requestId: PANE, key: commentKey(await read($, commentSeq)) })
250    .catch((err: unknown) => ({ deny: String(err) }))
251  observe(`focus comment-input: ${JSON.stringify(moved)}`)
252}
253
254// Drops the block's comment and its mark and returns to the menu.
255async function cancelComment($: EngineInterface, i: number, refocus = true) {
256  await writeTray($, tray => dropComment(tray, i))
257  if (refocus) return leaveComment($)
258  await update($, isCommenting, () => false)
259  await suggestTray($)
260}
261
262// Hands focus from the comment field back to ✎ in the menu before the field
263// goes, so the ring keeps a stop and ←→ keep working.
264async function leaveComment($: EngineInterface) {
265  const moved = await $.ui
266    .focus({ requestId: PANE, key: 'verb-comment' })
267    .catch((err: unknown) => ({ deny: String(err) }))
268  observe(`leave comment → verb-comment: ${JSON.stringify(moved)}`)
269  await finishComment($)
270}
271
272// Leaving an open comment line finishes it: empty, it is dropped; with text,
273// it is staged as if ⏎ had been pressed.
274async function finishComment($: EngineInterface) {
275  if (!(await read($, isCommenting))) return
276  const i = await read($, selected)
277  const text = (await read($, comments))[i] ?? ''
278  observe(`finish comment on ${i}: ${JSON.stringify(text)}`)
279  await writeTray($, tray => finishTrayComment(tray, i))
280  await update($, isCommenting, () => false)
281  await suggestTray($)
282}
283
284// The compiled prompt: src/compile.ts, over the adopted reply's blocks.
285async function compile($: EngineInterface): Promise<string> {
286  const blocks = segment(await read($, lastText)).filter(s => s.isBlock)
287  const tray = await readTray($)
288  return compileTray(blocks, tray.marks, tray.comments)
289}
290
291// The keys the band names (src/keys.ts), from the person's own keybindings.json
292// (written only by `/synaptik config`, on Save: D38). Claude Code applies an
293// edited file at once, so the band reads it again whenever its mtime changes.
294// Module variable, not $.state: the band's render hook reads it.
295let keyFile: { mtimeMs: number; keys: Keys } | undefined
296
297async function currentKeys($: EngineInterface): Promise<Keys> {
298  try {
299    const path = await keyFilePath($)
300    if (!path) throw new Error('no config folder')
301    const { mtimeMs } = await $.fs.stat(path)
302    if (keyFile?.mtimeMs !== mtimeMs) keyFile = { mtimeMs, keys: keysFrom(parseKeyFile(await $.fs.read(path))) }
303    return keyFile.keys
304  } catch {
305    // No file: Claude Code's defaults.
306    keyFile = undefined
307    return DEFAULT_KEYS
308  }
309}
310
311// The variables that place Claude Code's config folder (src/configpath.ts).
312// Names written out: `claude plugin validate` lists what the mod reads.
313async function configEnv($: EngineInterface): Promise<ConfigEnv> {
314  return {
315    CLAUDE_CONFIG_DIR: await $.env.get('CLAUDE_CONFIG_DIR'),
316    HOME: await $.env.get('HOME'),
317    USERPROFILE: await $.env.get('USERPROFILE'),
318  }
319}
320
321// Where keybindings.json is, or undefined when no variable names a folder.
322async function keyFilePath($: EngineInterface): Promise<string | undefined> {
323  return keybindingsPath(await configEnv($))
324}
325
326// The person's keybindings.json, whole: `{}` when there is none yet,
327// `undefined` when it is there but not JSON — the key setup then writes nothing.
328async function readKeyFile($: EngineInterface, path: string): Promise<KeyFile | undefined> {
329  let text: string
330  try {
331    text = String(await $.fs.read(path))
332  } catch {
333    return {} // no file yet: the setup creates it
334  }
335  try {
336    const file = JSON.parse(text) as unknown
337    return file !== null && typeof file === 'object' && !Array.isArray(file) ? (file as KeyFile) : undefined
338  } catch {
339    return undefined
340  }
341}
342
343// The key setup (D38, `/synaptik config`): while it is open the navigator pane
344// draws it instead of the map. A module variable, as the pane's own drawing
345// ends it (Esc) and a render hook may not write state. Lost on reload.
346type KeyMenu = {
347  // The file as read when the menu opened, and the choice so far.
348  file: KeyFile
349  choice: Setup
350  // A line for the person: why the save failed.
351  note?: string
352  // The file's path, and as the menu shows it (`~/.claude/keybindings.json`).
353  path: string
354  shown: string
355  // Seen holding the keyboard: losing it after that is Esc.
356  hasFocus: boolean
357  // Where the person is: the list of rows, or one setting's options; the row
358  // and option under the cursor; the setting whose options are open.
359  view: 'rows' | 'options'
360  row: number
361  option: number
362  editing?: 'entry' | 'verbs'
363}
364let keyMenu: KeyMenu | undefined
365
366type MenuRow = 'entry' | 'verbs' | 'save' | 'cancel'
367const MENU_ROWS: readonly MenuRow[] = ['entry', 'verbs', 'save', 'cancel']
368const MENU_LABEL: Record<MenuRow, string> = {
369  entry: 'Open Synaptik',
370  verbs: 'Move between actions',
371  save: 'Save',
372  cancel: 'Cancel',
373}
374
375// One setting's choice as an option's value: the entry key, or the two
376// action keys as `previous next`.
377function currentValue(menu: KeyMenu, setting: 'entry' | 'verbs') {
378  if (setting === 'entry') return menu.choice.entry ?? DEFAULT_ENTRY
379  const verbs = menu.choice.verbs ?? DEFAULT_VERBS
380  return `${verbs.previous} ${verbs.next}`
381}
382
383// One setting's choice as the row shows it.
384function menuValue(menu: KeyMenu, setting: 'entry' | 'verbs') {
385  if (setting === 'entry') return keyLabel(currentValue(menu, 'entry'))
386  const verbs = menu.choice.verbs
387  // The default as its option names it; otherwise previous / next.
388  return verbs ? `${keyLabel(verbs.previous)} / ${keyLabel(verbs.next)}` : 'tab / ⇧tab'
389}
390
391// The options of the setting being edited: back to the rows (Esc cannot be
392// that: it gives the keyboard to the prompt, and a mod cannot take it back),
393// the presets, and the person's own key when the file binds one that is none
394// of them, so a hand-made binding is never lost by opening the menu. Custom
395// keys are a feature request: the presets only, for now (#80).
396function menuOptions(menu: KeyMenu): { value: string; label: string }[] {
397  const setting = menu.editing ?? 'entry'
398  const now = currentValue(menu, setting)
399  const presets =
400    setting === 'entry'
401      ? [
402          { value: DEFAULT_ENTRY, label: `${DEFAULT_ENTRY} (default)` },
403          { value: 'ctrl+s', label: 'ctrl+s' },
404        ]
405      : [
406          { value: `${DEFAULT_VERBS.previous} ${DEFAULT_VERBS.next}`, label: 'tab / ⇧tab (default)' },
407          { value: 'left right', label: '← / →' },
408        ]
409  const own = presets.some(p => p.value === now) ? [] : [{ value: now, label: menuValue(menu, setting) }]
410  return [
411    { value: 'back', label: '‹ back' },
412    ...presets,
413    ...own,
414  ]
415}
416
417// ↑/↓ in the menu: the cursor, over the rows or the open setting's options.
418function moveMenu(by: number) {
419  const menu = keyMenu
420  if (!menu) return
421  const count = menu.view === 'rows' ? MENU_ROWS.length : menuOptions(menu).length
422  if (menu.view === 'rows') menu.row = Math.max(0, Math.min(count - 1, menu.row + by))
423  else menu.option = Math.max(0, Math.min(count - 1, menu.option + by))
424}
425
426// ⏎ in the menu: open a setting's options, take an option, save or cancel.
427async function enterMenu($: EngineInterface) {
428  const menu = keyMenu
429  if (!menu) return
430  if (menu.view === 'rows') {
431    const row = MENU_ROWS[menu.row]!
432    if (row === 'save') return saveKeyMenu($)
433    if (row === 'cancel') return closeKeyMenu($)
434    menu.editing = row
435    menu.view = 'options'
436    menu.option = Math.max(0, menuOptions(menu).findIndex(o => o.value === currentValue(menu, row)))
437    observe(`key menu: options of ${row}`)
438    return $.ui.invalidate('ui.render')
439  }
440  const setting = menu.editing!
441  const picked = menuOptions(menu)[menu.option]!
442  observe(`key menu: ${setting} picked ${picked.value}`)
443  menu.view = 'rows'
444  menu.note = undefined
445  if (picked.value === 'back') {
446    // Nothing chosen: the setting stays as it was.
447  } else if (setting === 'entry') {
448    menu.choice = { ...menu.choice, entry: picked.value === DEFAULT_ENTRY ? undefined : picked.value }
449  } else {
450    const [previous, nextKey] = picked.value.split(' ') as [string, string]
451    const isDefault = previous === DEFAULT_VERBS.previous && nextKey === DEFAULT_VERBS.next
452    menu.choice = { ...menu.choice, verbs: isDefault ? undefined : { previous, next: nextKey } }
453  }
454  $.ui.invalidate('ui.render')
455}
456
457async function openKeyMenu($: EngineInterface): Promise<string | undefined> {
458  const env = await configEnv($)
459  const path = keybindingsPath(env)
460  if (!path) return "can't find Claude Code's config folder: CLAUDE_CONFIG_DIR, HOME and USERPROFILE are all unset. Nothing to edit."
461  const file = await readKeyFile($, path)
462  if (!file) return `can't read ${path}: it isn't JSON. Fix it by hand, then try again.`
463  const shown = shownPath(path, env)
464  keyMenu = { file, path, shown, choice: currentSetup(file), hasFocus: false, view: 'rows', row: 0, option: 0 }
465  await openPane($, true)
466  $.ui.invalidate('ui.render')
467  return undefined
468}
469
470// Ends the key setup. With no reply to review (a new session: `/synaptik
471// config` opens the pane all the same), the pane closes too, which hands the
472// keyboard back to the prompt — an empty navigator is nothing to hold it (#73).
473async function closeKeyMenu($: EngineInterface) {
474  keyMenu = undefined
475  if (!(await read($, lastText))) {
476    await $.ui.close({ id: PANE }).catch((err: unknown) => observe(`close empty pane: ${String(err)}`))
477  }
478  $.ui.invalidate('ui.render')
479}
480
481// Writes the choice into the file as it is now, changing only the setup's own
482// entries. Saving is the person's confirmation: the menu showed the changes.
483async function saveKeyMenu($: EngineInterface) {
484  const menu = keyMenu
485  if (!menu) return
486  const file = await readKeyFile($, menu.path)
487  if (!file) {
488    menu.note = "the file changed and isn't JSON any more: nothing written"
489    return $.ui.invalidate('ui.render')
490  }
491  const after = applySetup(file, menu.choice)
492  const changed = changes(file, after)
493  if (changed.length) {
494    try {
495      await $.fs.write(menu.path, `${JSON.stringify(after, null, 2)}\n`)
496    } catch (err) {
497      // The menu stays, with the choice, and says why: a failed save is never silent.
498      menu.note = `couldn't write ${menu.shown}: ${String(err)}`
499      observe(`key setup failed to write ${menu.path}: ${String(err)}`)
500      return $.ui.invalidate('ui.render')
501    }
502    observe(`key setup wrote ${menu.path}: ${changed.join(' | ')}`)
503  }
504  await $.ui.toast(changed.length ? 'Synaptik: keys saved' : 'Synaptik: no changes')
505  await closeKeyMenu($)
506}
507
508// What the key setup's view draws (views/keymenu.tsx), from its state.
509function keyMenuModel(menu: KeyMenu, bodyRows: number): KeyMenuModel {
510  const after = applySetup(menu.file, menu.choice)
511  return {
512    view: menu.view,
513    rows: MENU_ROWS.map((row, i) => ({
514      text: `${MENU_LABEL[row]}${row === 'entry' || row === 'verbs' ? ` · ${menuValue(menu, row)}` : ''}`,
515      isAt: i === menu.row,
516      hasGapAfter: row === 'verbs',
517    })),
518    editing: menu.editing ? MENU_LABEL[menu.editing] : '',
519    options: menuOptions(menu).map((option, i) => ({
520      text: option.label,
521      isAt: i === menu.option,
522      isNow: option.value === currentValue(menu, menu.editing ?? 'entry'),
523    })),
524    note: menu.note,
525    changed: changes(menu.file, after),
526    costs: costs(menu.choice),
527    shown: menu.shown,
528    bodyRows,
529  }
530}
531
532export const register: Register = on => {
533  on('session.start', async ($, e, next) => {
534    await $.command.register({
535      name: 'synaptik',
536      description: "Mark up the agent's reply in place · help for more",
537      argumentHint: '[help|on|off|config|log]',
538      // So `off` works while a turn runs, when a drawing gone wrong hurts most.
539      immediate: true,
540    })
541    // A resumed session (and every reload of this mod) already has a reply.
542    await adoptLastReply($)
543    return next(e)
544  }).catch(($, e, next) => next(e))
545
546  // `/synaptik` and its subcommands (D39, SPEC §10).
547  on('command.run', { command: 'synaptik' }, async ($, e, next) => {
548    const args = (e.args ?? '').trim()
549    if (args === '') {
550      if (!(await read($, isOn))) return { text: 'off for this session · /synaptik on turns it back on' }
551      if (!(await read($, lastText))) return { text: 'nothing to respond to yet — the navigator opens on the next reply' }
552      await openPane($, true)
553      return {}
554    }
555    if (args === 'help') return { text: HELP }
556    if (args === 'on' || args === 'off') {
557      const wanted = args === 'on'
558      const was = await read($, isOn)
559      await update($, isOn, () => wanted)
560      // Redraw at once: the reply, band and navigator read it.
561      $.ui.invalidate('ui.render')
562      const now = wanted ? 'on' : 'off for this session'
563      if (was === wanted) return { text: `already ${now}` }
564      return { text: wanted ? 'on' : 'off for this session · /synaptik on turns it back on, /plugin disables it for good' }
565    }
566    if (args === 'log') return { text: observed.join('\n') || '(nothing observed yet)' }
567    if (args === 'config') {
568      if (!(await read($, isOn))) return { text: 'off for this session · /synaptik on turns it back on' }
569      const refused = await openKeyMenu($)
570      return refused ? { text: refused } : {}
571    }
572    return { text: `unknown: /synaptik ${args}\n\n${HELP}` }
573  }).catch(($, e, next) => next(e))
574
575  // While a turn runs, a new reply is on its way and will replace the tray
576  // (SPEC §5), so marking is off: the reply and the band draw as vanilla.
577  // Since Claude Code 2.1.300 these also hear subagents' and teammates' turns,
578  // told apart by `e.agentId`: only the main loop's turn brings a new reply, so
579  // only it switches marking off and on.
580  on('turn.start', async ($, e, next) => {
581    if (e.agentId) return next(e)
582    isSuggested = false
583    await update($, isWorking, () => true)
584    return next(e)
585  }).catch(($, e, next) => next(e))
586
587  on('turn.complete', async ($, e, next) => {
588    if (e.agentId) return next(e)
589    await adoptLastReply($)
590    await update($, isWorking, () => false)
591    return next(e)
592  }).catch(($, e, next) => next(e))
593
594  // The row after a turn, remembered so the reply's end can be scrolled into
595  // view. Drawn by Claude Code unchanged.
596  on('ui.render', { component: 'TurnDuration' }, ($, e, next) => {
597    if (e.requestId !== turnRowId) {
598      turnRowId = e.requestId
599      observe(`turn row ${e.requestId.slice(0, 12)}`)
600    }
601    return next(e)
602  }).catch(($, e, next) => next(e))
603
604  // --- The reply: the selected block's marker and in-place menu -------------
605
606  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
607    if (!(await read($, isOn)) || (await read($, isWorking))) return next(e)
608    if (e.surface !== 'terminal') return next(e)
609
610    const text = e.props.text
611    const target = await read($, lastText)
612    if (text.trim() === '' || !target.includes(text.trim())) return next(e)
613    if (text.length > MAX_DRAWN) return next(e)
614
615    replyRequestId = e.requestId
616    if (e.viewport) screen = { columns: e.viewport.columns, rows: e.viewport.rows }
617    const { Box, Text, Button, Markdown } = $.ui.resolve(e)
618    const segments = segment(text)
619    // Where this piece's blocks start in the whole reply, so numbering runs
620    // across the pieces of a reply split around tool calls.
621    const offset = segment(target.slice(0, target.indexOf(text.trim()))).filter(s => s.isBlock).length
622    const sel = await read($, selected)
623    const marked = await read($, marks)
624    const commented = (await read($, comments)) as Record<number, string>
625    const isTyping = await read($, isCommenting)
626    const cursor = isNavigatorFocused ? await read($, focusedVerb) : ''
627
628    // Draw only when there is something to show: while reviewing, or when a
629    // block in this piece is marked or being commented. Otherwise pass the reply
630    // through, so Claude Code and the mods beneath draw it as usual. Redrawing
631    // takes the reply from every mod beneath (one mod redraws a reply), and
632    // draws the transcript's text — bypassing any mod beneath that changes it
633    // (D33).
634    const pieceBlocks = segments.filter(s => s.isBlock).length
635    const isMarkedHere = Object.keys(marked).some(k => Number(k) >= offset && Number(k) < offset + pieceBlocks)
636    const isTypingHere = isTyping && sel >= offset && sel < offset + pieceBlocks
637    if (!isNavigatorFocused && !isMarkedHere && !isTypingHere) return next(e)
638
639    // The mod draws the reply itself (views/reply.tsx); each block's action
640    // buttons run here.
641    let index = offset - 1
642    return ReplyView(
643      { Box, Text, Button, Markdown },
644      {
645        isFirstOfReply: e.props.isFirstOfReply,
646        segments: segments.map(seg => {
647          if (!seg.isBlock) return { text: seg.text, isBlock: false, indent: seg.indent, hasGapBefore: seg.hasGapBefore }
648          index += 1
649          const i = index
650          const isSelected = i === sel
651          const mark = marked[i]
652          return {
653            text: seg.text,
654            isBlock: true,
655            indent: seg.indent,
656            hasGapBefore: seg.hasGapBefore,
657            glyph: mark ? GLYPH[mark] : undefined,
658            // Background and menu only while the navigator holds the keyboard:
659            // after Esc the reply reads as a plain reply again.
660            isSelected: isSelected && isNavigatorFocused,
661            // The comment line: live while it is typed, else once staged.
662            comment: (isSelected && isTyping) || (commented[i] && mark === 'comment') ? (commented[i] ?? '') : undefined,
663          }
664        }),
665        cursor,
666      },
667      key => runVerb($, key),
668    )
669  }).catch(($, e, next) => next(e))
670
671  // --- The pane: minimap, ↑↓ via scroll, ←→ via the focus ring ---------------
672
673  on('ui.close', { id: PANE }, async ($, e, next) => {
674    observe(`pane closed by ${e.origin.kind}`)
675    await finishComment($)
676    await update($, isPaneShown, () => false)
677    isNavigatorFocused = false
678    return next(e)
679  }).catch(($, e, next) => next(e))
680
681  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
682    // The key setup: ↑/↓ move its cursor, as they move the navigator's
683    // selection; the wheel and the other scroll keys move nothing.
684    if (keyMenu) {
685      if (e.origin.kind === 'person' && !e.pointer && Math.abs(e.by) === 1) {
686        moveMenu(e.by)
687        $.ui.invalidate('ui.render')
688      }
689      return next(e.origin.kind === 'person' ? { ...e, offset: 1 } : e)
690    }
691    observe(
692      `scroll by=${e.by} offset=${e.offset} rows=${e.contentRows}/${e.bodyRows} ${e.origin.kind}${e.pointer ? ' wheel' : ''}`,
693    )
694    if (e.origin.kind !== 'person' || e.by === 0) return next(e)
695    // The wheel or a trackpad over the pane (only they carry a pointer) moves
696    // nothing: blocks are the keys' to move, and a wheel is reached for to
697    // scroll the reply (#62). The window stays pinned, so ↑/↓ keep working.
698    if (e.pointer) return next({ ...e, offset: 1 })
699    // Home and End scroll by the whole tree (contentRows), a page key by the
700    // window (bodyRows), an arrow by a row: Home/End jump to the first or last
701    // block (#63). On a MacBook keyboard, Fn+← and Fn+→ send them.
702    if (Math.abs(e.by) >= e.contentRows) {
703      await moveSelection($, e.by < 0 ? -Infinity : Infinity)
704      return next({ ...e, offset: 1 })
705    }
706    // A page key scrolls by more than a row: read further into the selected
707    // block instead of moving to another (classic, #31).
708    if (Math.abs(e.by) > 1) {
709      blockOffset = Math.max(0, blockOffset + Math.sign(e.by) * Math.max(1, blockTextRows - 1))
710      $.ui.invalidate('ui.render')
711      return next({ ...e, offset: 1 })
712    }
713    await moveSelection($, e.by < 0 ? -1 : 1)
714    return next({ ...e, offset: 1 })
715  }).catch(($, e, next) => next(e))
716
717  // The ring stops on the verbs and the comment field only, so ←→ walk them.
718  // Inside the comment field ↑/↓ walk the ring too (AbovePromptInput binds
719  // them), so a move off the open field onto anything that is not a verb is
720  // sent to ✎ instead of refused: refusing left the ring stranded off the verbs.
721  // Another element is redirected in the move itself. Claude Code's own stop on
722  // the pane (no element) cannot be: the engine refuses a redirect of a move
723  // that named none (#60). That move goes on as it is; the comment is finished
724  // and focus sent to ✎ after it.
725  on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
726    observe(`pane focus → ${e.element ?? '(none)'} by ${e.origin.kind}`)
727    // The key setup's lists and buttons are all stops.
728    if (keyMenu) return next(e)
729    const isField = e.element?.startsWith(COMMENT_PREFIX) ?? false
730    const isVerb = e.element?.startsWith('verb-') ?? false
731    const wasField = (await read($, isCommenting)) && !isField
732    const isEngineStop = e.element === undefined
733    const target = !isField && !isVerb && wasField && !isEngineStop ? 'verb-comment' : e.element
734    const isStop = target === undefined || target.startsWith('verb-') || isField
735    if (!isStop) return { deny: 'synaptik: not a stop' }
736    const result = await next(target === e.element ? e : { ...e, element: target })
737    // Finish the comment only once the ring has left the field: removing the
738    // field while it still holds the ring loses the move, and ←→ with it.
739    if (!result.deny && !isField) {
740      // Not awaited: a focus move from inside this hook would wait on the move
741      // it is part of.
742      if (wasField && isEngineStop) void leaveComment($)
743      else await finishComment($)
744    }
745    if (!result.deny) {
746      await update($, focusedVerb, () => target ?? '')
747    }
748    // The navigator taking the keyboard — by the entry key, its tab or a click —
749    // raises this with origin 'plugin' (its autoFocus). Re-propose then: the
750    // prompt box keeps the last suggestion proposed, and another mod may have
751    // replaced ours meanwhile (issue #12).
752    if (!result.deny && e.origin.kind === 'plugin' && target === VERBS[0].key) {
753      isNavigatorFocused = true
754      void suggestTray($)
755    }
756    return result
757  }).catch(($, e, next) => next(e))
758
759  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
760    if (e.surface !== 'terminal') return next(e)
761    const { Box, Text, Button, Input, Markdown } = $.ui.resolve(e)
762
763    // The key setup, drawn instead of the map while it is open. Esc gives the
764    // keyboard back, and closes it unsaved.
765    if (keyMenu) {
766      const menu = keyMenu
767      if (e.props.isFocused) menu.hasFocus = true
768      else if (menu.hasFocus) {
769        observe('key menu closed: the pane lost the keyboard (Esc?)')
770        // A drawing can't wait on the close; the empty navigator below draws
771        // meanwhile.
772        void closeKeyMenu($)
773      }
774    }
775    if (keyMenu) {
776      isNavigatorFocused = false // the reply is not under review meanwhile
777      return KeyMenuView({ Box, Text, Button }, keyMenuModel(keyMenu, e.props.scroll.bodyRows), () => enterMenu($))
778    }
779    const blocks = segment(await read($, lastText)).filter(s => s.isBlock)
780    const sel = await read($, selected)
781    const marked = await read($, marks)
782    const seed = await read($, commentSeed)
783    const seq = await read($, commentSeq)
784    const commented = (await read($, comments)) as Record<number, string>
785    const isTyping = await read($, isCommenting)
786    const working = await read($, isWorking)
787
788    // No reply yet (a new session; the first reply not finished): one line, no
789    // counter and no keys — never "Block 1 of 0" (#73). A turn under way still
790    // draws "Waiting for the reply" below.
791    if (blocks.length === 0 && !working) {
792      isNavigatorFocused = false
793      return EmptyView({ Box, Text })
794    }
795
796    if (e.props.isFocused !== isNavigatorFocused) {
797      isNavigatorFocused = e.props.isFocused
798      // Re-propose on leaving. Entering is handled by ui.focus: a pane hidden
799      // behind another mod's tab is not redrawn, so this flag can miss a
800      // round trip through another pane.
801      if (!isNavigatorFocused) void suggestTray($)
802      $.ui.invalidate('ui.render')
803    }
804    const rows = Math.max(6, e.props.scroll.bodyRows)
805    const words = Math.max(4, e.props.bodyColumns - ROW_CHROME)
806    const counts = glyphCounts(marked)
807    const heading = working
808      ? 'Waiting for the reply...'
809      : counts
810        ? `Your notes · ${counts}`
811        : 'Nothing marked yet'
812    // The navigator's actions, for the views (views/navigator.tsx).
813    const actions: NavigatorActions = {
814      up: () => moveSelection($, -1),
815      verb: key => runVerb($, key),
816    }
817    // The comment field — visible: only the field knows where its cursor is (its
818    // events carry the text, never the cursor, and ←/→ raise none).
819    const field = (
820      <Input
821        key={commentKey(seq)}
822        placeholder="comment"
823        submitLabel="ok"
824        value={seed}
825        onInput={async value => {
826          const before = (await read($, comments))[sel] ?? ''
827          // Backspace on an already empty line, if the surface reports it.
828          if (value === '' && before === '') return cancelComment($, sel)
829          await writeTray($, tray => draftComment(tray, sel, value))
830        }}
831        onSubmit={async value => {
832          await writeTray($, tray => draftComment(tray, sel, value))
833          // Moving focus off the field finishes the comment (ui.focus):
834          // staged with text, dropped when empty.
835          await leaveComment($)
836        }}
837      />
838    )
839
840    // Classic (#31): the classic renderer places the pane inline, above the
841    // prompt — the one surface that stays current there. Show the selected
842    // block itself, in full, with its neighbours, menu and comment.
843    if (e.props.placement === 'inline' && !working) {
844      const all = segment(await read($, lastText))
845      let crumb = ''
846      let k = -1
847      for (const seg of all) {
848        if (!seg.isBlock) crumb = seg.text.replace(/^#+\s*/, '')
849        else if (++k === sel) break
850      }
851      const width = Math.max(10, e.props.bodyColumns - 2)
852      const textRows = Math.max(1, rows - 6)
853      blockTextRows = textRows
854      const current = blocks[sel]
855      const estimated = drawnRows(current?.text ?? '', width)
856      blockOffset = Math.min(blockOffset, Math.max(0, estimated - textRows))
857      const prev = blocks[sel - 1]
858      const nextBlock = blocks[sel + 1]
859      const mark = marked[sel]
860      return ClassicView(
861        { Box, Text, Button, Markdown },
862        {
863          rows,
864          heading,
865          crumb,
866          barHeights: minimap(blocks.map(b => b.text.length), Math.max(blocks.length, rows - 2)),
867          // One truncated line each; clipped, since a Text over 10,000
868          // characters is refused before Claude Code 2.1.304.
869          previous: prev ? clip(quote(prev.text, 10_000), 500) : undefined,
870          next: nextBlock ? clip(quote(nextBlock.text, 10_000), 500) : undefined,
871          text: current?.text ?? '',
872          glyph: mark ? GLYPH[mark] : undefined,
873          // The block takes the rows it needs; spare rows are split between
874          // after the header and before the footer, so the menu sits right
875          // under it.
876          blockRows: Math.max(1, Math.min(estimated, textRows)),
877          blockOffset,
878          comment: commented[sel] && mark === 'comment' ? commented[sel] : undefined,
879          counter: blockCounter(sel, blocks.length),
880          canReadOn: estimated > textRows + blockOffset || blockOffset > 0,
881        },
882        actions,
883        isTyping ? field : undefined,
884      )
885    }
886
887    // Everything but the heading and position rows, less one row for each
888    // comment, which may need a row of its own under a one-row block.
889    const commentRows = commentCount(marked, commented)
890    // Rows for the comment field while it is open: as many as its text wraps
891    // to, so the whole comment — and the cursor, wherever it is — stays in view.
892    // The field reports no cursor position, so a window around the cursor is not
893    // possible; showing all of it is. Capped at half the pane.
894    // The field shares each line with its submit hint (`⏎ ok`, drawn beside
895    // it while focused) and wraps at word boundaries, so a line holds well under
896    // the pane's width: estimate generously, plus a row of margin.
897    const fieldText = commented[sel] ?? seed
898    const fieldWidth = Math.max(4, e.props.bodyColumns - FIELD_CHROME)
899    const fieldRows = isTyping
900      ? Math.min(Math.max(1, Math.floor(rows / 2)), Math.ceil((fieldText.length * 1.25) / fieldWidth) + 1)
901      : 0
902    const heights = minimap(
903      blocks.map(b => b.text.length),
904      Math.max(blocks.length, rows - 2 - commentRows - fieldRows),
905    )
906    return MapView(
907      { Box, Text, Button },
908      {
909        rows,
910        heading: clip(heading, words + 2),
911        isWorking: working,
912        bars: blocks.map((block, i) => {
913          const mark = marked[i]
914          return {
915            isSelected: i === sel,
916            glyph: mark ? GLYPH[mark] : undefined,
917            // One row of words per staged block. Mirroring the reply line for
918            // line was tried: the mod cannot see how the reply wraps, so the
919            // lines never quite aligned.
920            opening: mark ? clip(quote(block.text, 10_000), words) : '',
921            comment: mark === 'comment' ? clip(commented[i]?.trim() ?? '', words - 2) : '',
922            span: heights[i] ?? 1,
923          }
924        }),
925        counter: working ? '' : clip(blockCounter(sel, blocks.length), words + 2),
926      },
927      actions,
928      isTyping ? field : undefined,
929    )
930  }).catch(($, e, next) => next(e))
931
932  // --- The band: read-only. Says what you can do now, never names a component.
933  //
934  // The band is shared by every mod: a hook that answers without next(e) hides
935  // every mod beneath it. Outside review, Synaptik draws whatever the chain
936  // beneath returns and adds its lines below it, last, just above the prompt.
937  // While the navigator holds the keyboard the band is the key legend for that
938  // mode, and Synaptik's stands alone (issue #12).
939
940  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
941    if (!(await read($, isOn)) || e.props.hasSurvey || e.props.isWorking) return next(e)
942    if (e.surface !== 'terminal') return next(e)
943    const target = await read($, lastText)
944    if (!target) return next(e)
945
946    const { Box, Text, Button } = $.ui.resolve(e)
947    const blocks = segment(target).filter(s => s.isBlock)
948    const sel = await read($, selected)
949    const isShown = await read($, isPaneShown)
950    const reviewing = isNavigatorFocused
951    const keys = await currentKeys($)
952    const key = keys.entry
953    const staged = summary(await read($, marks), (await read($, comments)) as Record<number, string>)
954    const compiled = await compile($)
955    const box = await $.prompt.read()
956
957    // Under a "Synaptik" header, each line led by ⎿ as Claude Code nests its own
958    // output, so with several mods in the band it is clear which lines are ours.
959    // The last line, just above the prompt: what the keys do right now.
960    const action = keyMenu
961      ? 'customizing Synaptik'
962      : reviewing
963      ? `${blockCounter(sel, blocks.length)} · ↑↓ move · ${keys.choose} choose · ⏎ apply · esc done`
964      : staged && isSuggested && box.text.trim() === ''
965        ? `tab to take it into the prompt · ${key} to continue responding`
966        : `${key} to ${staged ? 'continue responding' : 'respond in place'}`
967    const boxText = box.text.trim()
968    // Taken: the box holds exactly the suggestion. Edited: it holds something
969    // else (what to say then is an open decision; for now, the warning).
970    const isTaken = boxText !== '' && boxText === compiled.trim()
971    const isTyping = boxText !== '' && !isTaken
972    const beneath = reviewing ? null : await next(e)
973    return BandView(
974      { Box, Text, Button },
975      {
976        beneath,
977        columns: e.props.bodyColumns,
978        staged,
979        stagedAs: isTyping ? 'dropped' : isTaken ? 'taken' : 'plain',
980        action,
981        // The navigator not on screen (narrow terminal, or closed): the band
982        // offers the press that opens it instead of the action line.
983        isNavigatorShown: isShown || reviewing,
984        entryKey: key,
985      },
986      () => openPane($),
987    )
988  }).catch(($, e, next) => next(e))
989}
990
src/blocks.ts 224 lines
1// A reply split into blocks (SPEC §3): paragraphs, list items flat with their
2// parent, fenced code, tables, quotes; headings and rules kept as segments that
3// are not blocks.
4
5export type Segment = {
6  text: string
7  isBlock: boolean
8  // Nesting level: 0 at the top, 1 under a list item, and so on.
9  depth: number
10  // The enclosing list item's index among the blocks, or -1.
11  parent: number
12  // Columns the block is drawn in from the reply's edge, as Claude Code draws a
13  // nested item: the parent's indent plus its marker's width and a space — 3
14  // under "1.", 2 under "-".
15  indent: number
16  hasGapBefore: boolean
17}
18
19const LIST_ITEM = /^(\s*)([-*+]|\d{1,9}[.)])(\s+)/
20const FENCE = /^(\s*)(`{3,}|~{3,})/
21const HEADING = /^\s{0,3}#{1,6}(\s|$)/
22// A horizontal rule: drawn, but no block — nothing to mark (SPEC §3).
23const RULE = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/
24// The underline of a setext heading, under a paragraph line.
25const SETEXT = /^\s{0,3}(=+|-+)\s*$/
26const QUOTE = /^\s*>/
27
28type Kind = 'paragraph' | 'item' | 'quote' | 'code' | 'table'
29
30// A list item still open: later lines indented past its marker belong to it.
31type Item = { segment: Segment; block: number; markerColumn: number; childIndent: number }
32
33export function segment(text: string): Segment[] {
34  const out: Segment[] = []
35  let blockCount = 0
36  // The block being written, which a following line without a blank before it
37  // may continue.
38  let current: { segment: Segment; kind: Kind } | null = null
39  let hasGap = false
40  // An open fence: its character run, and how many columns its lines drop.
41  let fence: { run: string; drop: number } | null = null
42  const items: Item[] = []
43
44  const open = (text: string, kind: Kind, parent: Item | undefined) => {
45    const segment: Segment = {
46      text,
47      isBlock: true,
48      depth: parent ? items.indexOf(parent) + 1 : 0,
49      parent: parent ? parent.block : -1,
50      indent: parent ? parent.childIndent : 0,
51      hasGapBefore: hasGap && out.length > 0,
52    }
53    out.push(segment)
54    blockCount += 1
55    current = { segment, kind }
56    return segment
57  }
58  const notBlock = (text: string) => {
59    out.push({ text, isBlock: false, depth: 0, parent: -1, indent: 0, hasGapBefore: hasGap && out.length > 0 })
60    current = null
61  }
62  const lines = text.split('\n').map(raw => raw.replace(/^\t+/, tabs => '    '.repeat(tabs.length)))
63
64  for (const [n, line] of lines.entries()) {
65    const column = line.length - line.trimStart().length
66
67    if (fence) {
68      const open: { run: string; drop: number } = fence
69      current!.segment.text += '\n' + line.slice(Math.min(open.drop, column))
70      const close = line.trim().match(/^(`{3,}|~{3,})$/)
71      if (close && close[1]![0] === open.run[0] && close[1]!.length >= open.run.length) {
72        fence = null
73        current = null
74      }
75      continue
76    }
77
78    if (line.trim() === '') {
79      current = null
80      hasGap = out.length > 0
81      continue
82    }
83
84    const kind = current?.kind
85    const isFence = FENCE.test(line)
86    const isItem = !RULE.test(line) && LIST_ITEM.test(line)
87    // A table starts at a row with a delimiter row under it.
88    const isTable = line.includes('|') && TABLE_DELIMITER.test(lines[n + 1] ?? '')
89
90    // A setext heading: the paragraph above it was a heading all along.
91    if (kind === 'paragraph' && !hasGap && SETEXT.test(line)) {
92      const heading = current!.segment
93      heading.isBlock = false
94      heading.text = heading.text.trim() + '\n' + line.trim()
95      heading.depth = 0
96      heading.parent = -1
97      heading.indent = 0
98      blockCount -= 1
99      current = null
100      continue
101    }
102
103    if (HEADING.test(line) || RULE.test(line)) {
104      notBlock(line.trim())
105      items.length = 0
106      hasGap = false
107      continue
108    }
109
110    // A line that continues the block above it, with no blank line between.
111    if (current && !hasGap && !isFence && !isItem && !(isTable && kind !== 'table')) {
112      const isQuote = QUOTE.test(line)
113      if (kind === 'quote' || (!isQuote && kind !== 'code')) {
114        current.segment.text += '\n' + (kind === 'quote' ? line.trimStart() : line.trim())
115        continue
116      }
117    }
118
119    // Close the list items this line is not indented past.
120    while (items.length > 0 && column <= items[items.length - 1]!.markerColumn) items.pop()
121    const parent = items[items.length - 1]
122
123    const item = line.match(LIST_ITEM)
124    if (item) {
125      const segment = open(line.trimStart(), 'item', parent)
126      items.push({
127        segment,
128        block: blockCount - 1,
129        markerColumn: column,
130        childIndent: segment.indent + item[2]!.length + 1,
131      })
132      hasGap = false
133      continue
134    }
135
136    const fenceRun = line.match(FENCE)?.[2]
137    const lineKind: Kind = fenceRun ? 'code' : isTable ? 'table' : QUOTE.test(line) ? 'quote' : 'paragraph'
138
139    // A paragraph indented under an open list item, with nothing between, is
140    // part of it (SPEC §3: a list item spanning paragraphs is one block).
141    if (parent && lineKind === 'paragraph' && out[out.length - 1] === parent.segment) {
142      // Keep the indent relative to the item (whose own line is trimmed), so
143      // the paragraph stays inside the list item when drawn as Markdown.
144      parent.segment.text += (hasGap ? '\n\n' : '\n') + line.slice(parent.markerColumn)
145      current = { segment: parent.segment, kind: 'item' }
146      hasGap = false
147      continue
148    }
149
150    // Anything else is a block of its own: under the open list item, if any.
151    // Code, a table or a quote is one wherever it sits (SPEC §3).
152    open(line.trimStart(), lineKind, parent)
153    if (fenceRun) fence = { run: fenceRun, drop: column }
154    hasGap = false
155  }
156
157  return out
158}
159
160// The opening words of a block, for the compiled prompt and the navigator. A
161// numbered item keeps its number ("3. Top level"), so items that open alike
162// stay apart; a bullet's marker is dropped.
163export function quote(text: string, words = 7): string {
164  const lines = text.trim().split('\n')
165  // A table quotes its header cells: "table: Option · Latency · Cost".
166  if (lines.length > 1 && TABLE_DELIMITER.test(lines[1]!)) {
167    const cells = lines[0]!.split('|').map(c => c.trim()).filter(Boolean)
168    return `table: ${cells.join(' · ')}`
169  }
170  const marker = text.match(LIST_ITEM)?.[2] ?? ''
171  const number = /\d/.test(marker) ? `${marker} ` : ''
172  // A code block quotes its code, not its fence and language tag.
173  const unfenced = text.replace(/^\s*(```|~~~)[^\n]*\n?/, '').replace(/\n?\s*(```|~~~)\s*$/, '')
174  const plain = unfenced
175    .replace(LIST_ITEM, '')
176    .replace(/^\s*(>\s?)+/gm, '')
177    .replace(/^#{1,6}\s+/gm, '')
178    .replace(/`+/g, '')
179    .replace(/(\*\*|__)(.+?)\1/g, '$2')
180    // Emphasis, but not an underscore inside a word (SESSION_CACHE).
181    .replace(/(^|[^\w*])[*_](?=\S)(.+?)(?<=\S)[*_](?![\w*])/g, '$1$2')
182    .replace(/\s+/g, ' ')
183    .trim()
184  const parts = plain.split(' ')
185  return number + (parts.length <= words ? plain : parts.slice(0, words).join(' ') + '...')
186}
187
188// A table's second row: dashes between pipes, the outer pipes optional.
189const TABLE_DELIMITER = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$|^\s*\|\s*:?-+:?\s*\|\s*$/
190
191// The pieces a block is drawn in: Claude Code before 2.1.304 refuses a
192// Markdown or Text element over 10,000 characters (the whole render then falls
193// back to its own drawing). Split at line boundaries; a fenced code block's
194// pieces are each fenced again, so they still draw as code.
195export function chunk(text: string, limit = 10_000): string[] {
196  if (text.length <= limit) return [text]
197  const lines = text.split('\n')
198  const fenceRun = lines[0]!.match(FENCE)?.[2]
199  const isFenced = fenceRun !== undefined && lines.length > 1 && lines[lines.length - 1]!.trim().startsWith(fenceRun)
200  const open = isFenced ? lines[0]! + '\n' : ''
201  const close = isFenced ? '\n' + lines[lines.length - 1]! : ''
202  const body = isFenced ? lines.slice(1, -1) : lines
203  const room = limit - open.length - close.length
204  const pieces: string[] = []
205  let piece: string[] = []
206  let size = 0
207  const flush = () => {
208    if (piece.length > 0) pieces.push(open + piece.join('\n') + close)
209    piece = []
210    size = 0
211  }
212  for (const line of body) {
213    // A single line longer than a piece is cut where it must be.
214    for (let at = 0; at < Math.max(1, line.length); at += room) {
215      const part = line.slice(at, at + room)
216      if (size + part.length + 1 > room) flush()
217      piece.push(part)
218      size += part.length + 1
219    }
220  }
221  flush()
222  return pieces
223}
224
src/turn.ts 40 lines
1// Which transcript rows make up the last reply (SPEC §2): the whole last turn,
2// tool calls and engine-written rows skipped.
3
4export type Row = {
5  role: 'user' | 'assistant'
6  text: string
7  toolResults?: readonly unknown[]
8}
9
10// Assistant rows the engine writes itself, never the model: a resumed session
11// ends with one. Rows carry no flag for them, so they are matched by text.
12const SYNTHETIC = new Set(['No response requested.'])
13
14// A user row the person wrote, as opposed to a tool's result, which is stored
15// as a user row too.
16const isPrompt = (row: Row) =>
17  row.role === 'user' && row.text.trim() !== '' && !row.toolResults?.length
18
19const isReplyText = (row: Row) =>
20  row.role === 'assistant' && row.text.trim() !== '' && !SYNTHETIC.has(row.text.trim())
21
22// The text of the last turn: every assistant row with text since the last
23// prompt, joined in order. A reply that runs a tool mid-way is stored as
24// several assistant rows; taking only the last one left the text before the
25// tool call unmarkable. Rows after the turn that are not assistant text (a
26// slash command's record, a synthetic row) are skipped.
27export function lastTurnText(rows: readonly Row[]): string {
28  let end = rows.length - 1
29  while (end >= 0 && !isReplyText(rows[end]!)) end -= 1
30  if (end < 0) return ''
31
32  const pieces: string[] = []
33  for (let i = end; i >= 0; i -= 1) {
34    const row = rows[i]!
35    if (isPrompt(row)) break
36    if (isReplyText(row)) pieces.unshift(row.text.trim())
37  }
38  return pieces.join('\n\n')
39}
40
src/compile.ts 45 lines
1// The tray compiled into the prompt (SPEC §9): each marked block quoted, nested
2// under its parent, in reading order.
3
4import { quote } from './blocks'
5
6export type Mark = 'accept' | 'reject' | 'comment'
7
8export const GLYPH: Record<Mark, string> = { accept: '✓', reject: '✗', comment: '✎' }
9
10// A block as the segmenter gives it: its parent is its enclosing list item's
11// index, or -1.
12export type Block = { text: string; depth: number; parent: number }
13
14// One line per staged block, in document order, nested under its parent. An unmarked
15// block whose descendant is staged is sent too, as a plain quote with no glyph,
16// so a marked child arrives under the parent it belongs to. A comment with no
17// text is never sent.
18export function compile(
19  blocks: readonly Block[],
20  marks: Readonly<Record<number, Mark>>,
21  comments: Readonly<Record<number, string>>,
22): string {
23  const isStaged = (i: number) => {
24    const mark = marks[i]
25    return mark !== undefined && (mark !== 'comment' || (comments[i]?.trim() ?? '') !== '')
26  }
27  const context = new Set<number>()
28  blocks.forEach((_, i) => {
29    if (!isStaged(i)) return
30    for (let p = blocks[i]!.parent; p >= 0; p = blocks[p]!.parent) context.add(p)
31  })
32
33  return blocks
34    .flatMap((block, i) => {
35      const indent = '    '.repeat(block.depth)
36      if (isStaged(i)) {
37        const mark = marks[i]!
38        const tail = mark === 'comment' ? ` → ${comments[i]!.trim()}` : ''
39        return [`${indent}${GLYPH[mark]} "${quote(block.text)}"${tail}`]
40      }
41      return context.has(i) ? [`${indent}"${quote(block.text)}"`] : []
42    })
43    .join('\n')
44}
45
src/tray.ts 52 lines
1// The tray (SPEC §5): what the person staged on the adopted reply, a mark and a
2// comment's text per block index. Every change goes through these functions;
3// the hooks keep the result in $.state.
4
5import type { Mark } from './compile'
6
7export type Tray = {
8  marks: Readonly<Record<number, Mark>>
9  // A comment's text: kept when its mark is cleared or overwritten, so opening
10  // the comment again starts from it.
11  comments: Readonly<Record<number, string>>
12}
13
14export const EMPTY: Tray = { marks: {}, comments: {} }
15
16function withMark(tray: Tray, i: number, mark: Mark | undefined): Tray {
17  const marks = { ...tray.marks }
18  if (mark) marks[i] = mark
19  else delete marks[i]
20  return { ...tray, marks }
21}
22
23// A new mark overwrites the block's mark; the same one again unmarks it.
24export function toggle(tray: Tray, i: number, mark: Mark): Tray {
25  return withMark(tray, i, tray.marks[i] === mark ? undefined : mark)
26}
27
28// The comment as it is typed: its text only, staged by finishComment.
29export function draftComment(tray: Tray, i: number, text: string): Tray {
30  return { ...tray, comments: { ...tray.comments, [i]: text } }
31}
32
33// Leaving the comment field: with text, the comment is staged; empty, it is
34// dropped, along with a ✎ the block had.
35export function finishComment(tray: Tray, i: number): Tray {
36  if ((tray.comments[i] ?? '').trim() === '') return dropComment(tray, i)
37  return withMark(tray, i, 'comment')
38}
39
40// Cancelling the comment: its text goes, and its ✎ if the block had one.
41export function dropComment(tray: Tray, i: number): Tray {
42  const comments = { ...tray.comments }
43  delete comments[i]
44  return withMark({ ...tray, comments }, i, tray.marks[i] === 'comment' ? undefined : tray.marks[i])
45}
46
47// Clearing a block unstages it, never destroys: the mark goes, a comment's text
48// stays for when it is commented again.
49export function clear(tray: Tray, i: number): Tray {
50  return withMark(tray, i, undefined)
51}
52
src/keys.ts 75 lines
1// The keys the band names, from the person's ~/.claude/keybindings.json (D32):
2// the one that enters the navigator, and the ones that walk its verbs. Plain
3// data in, labels out; the hooks read the file.
4
5export type KeyFile = {
6  bindings?: readonly { context?: string; bindings?: Readonly<Record<string, string | null>> }[]
7}
8
9export type Keys = {
10  // Focuses the band and panes: `abovePrompt:focus` in the Chat context.
11  entry: string
12  // Walks the verbs in the navigator: abovePrompt:previous / :next in Pane.
13  choose: string
14}
15
16// Claude Code's own defaults, when the file binds nothing: ctrl+x tab enters,
17// Tab / Shift+Tab walk a pane's focus ring.
18export const DEFAULT_KEYS: Keys = { entry: 'ctrl+x tab', choose: 'tab/⇧tab' }
19
20// The key bound to `action` in `context`. Blocks apply in order, so a later
21// binding of a key replaces an earlier one, and `null` unbinds it; of several
22// keys still bound to the action, the last one counts.
23export function boundKey(file: KeyFile, context: string, action: string): string | undefined {
24  const keys = new Map<string, string | null>()
25  for (const block of file.bindings ?? []) {
26    if (block.context !== context) continue
27    for (const [key, bound] of Object.entries(block.bindings ?? {})) {
28      keys.delete(key)
29      keys.set(key, bound)
30    }
31  }
32  let found: string | undefined
33  for (const [key, bound] of keys) if (bound === action) found = key
34  return found
35}
36
37const GLYPHS: Record<string, string> = { left: '←', right: '→', up: '↑', down: '↓' }
38
39// A binding as the band shows it: `left` → `←`, `shift+tab` → `⇧tab`. Other
40// names and chords (`ctrl+x tab`) stay as written.
41export function keyLabel(binding: string): string {
42  return binding
43    .split(' ')
44    .map(step => {
45      const parts = step.split('+')
46      const key = parts.pop() ?? ''
47      const mods = parts.map(m => (m === 'shift' ? '⇧' : `${m}+`)).join('')
48      return mods + (GLYPHS[key] ?? key)
49    })
50    .join(' ')
51}
52
53export function keysFrom(file: KeyFile): Keys {
54  const entry = boundKey(file, 'Chat', 'abovePrompt:focus')
55  const previous = boundKey(file, 'Pane', 'abovePrompt:previous')
56  const next = boundKey(file, 'Pane', 'abovePrompt:next')
57  if (!previous && !next) return { entry: entry ?? DEFAULT_KEYS.entry, choose: DEFAULT_KEYS.choose }
58  const back = previous ? keyLabel(previous) : '⇧tab'
59  const forward = next ? keyLabel(next) : 'tab'
60  const choose = back === '←' && forward === '→' ? '←→' : `${back}/${forward}`
61  return { entry: entry ?? DEFAULT_KEYS.entry, choose }
62}
63
64// The file's text as Claude Code would take it: not JSON, or no list of
65// bindings, binds nothing.
66export function parseKeyFile(text: string): KeyFile {
67  try {
68    const file = JSON.parse(text) as unknown
69    const isFile = file !== null && typeof file === 'object' && Array.isArray((file as KeyFile).bindings)
70    return isFile ? (file as KeyFile) : {}
71  } catch {
72    return {}
73  }
74}
75
src/keysetup.ts 120 lines
1// The key setup (D38): what the person has bound, the file a choice makes, the
2// lines that change, and what a choice gives up. Plain data in and out; the
3// hooks read and write ~/.claude/keybindings.json.
4
5import { boundKey, keyLabel } from './keys'
6import type { KeyFile } from './keys'
7
8// The two settings. `undefined` is Claude Code's own default: nothing bound.
9export type Setup = {
10  // Enters the navigator: `abovePrompt:focus` in Chat. Default `ctrl+x tab`.
11  entry?: string
12  // Walk the verbs: `abovePrompt:previous` / `:next` in Pane. Default Tab / ⇧Tab.
13  verbs?: { previous: string; next: string }
14}
15
16const ENTRY = { context: 'Chat', action: 'abovePrompt:focus' } as const
17const PREVIOUS = { context: 'Pane', action: 'abovePrompt:previous' } as const
18const NEXT = { context: 'Pane', action: 'abovePrompt:next' } as const
19
20// Claude Code's own keys for these actions, bound when the file binds none.
21export const DEFAULT_ENTRY = 'ctrl+x tab'
22export const DEFAULT_VERBS = { previous: 'shift+tab', next: 'tab' } as const
23
24// The presets the menu offers (custom keys: #80).
25export const ENTRY_PRESETS = [DEFAULT_ENTRY, 'ctrl+s'] as const
26export const VERB_PRESETS = [DEFAULT_VERBS, { previous: 'left', next: 'right' }] as const
27
28// What the file binds now, as a Setup.
29export function currentSetup(file: KeyFile): Setup {
30  const entry = boundKey(file, ENTRY.context, ENTRY.action)
31  const previous = boundKey(file, PREVIOUS.context, PREVIOUS.action)
32  const next = boundKey(file, NEXT.context, NEXT.action)
33  return {
34    entry: entry === DEFAULT_ENTRY ? undefined : entry,
35    verbs: previous || next ? { previous: previous ?? DEFAULT_VERBS.previous, next: next ?? DEFAULT_VERBS.next } : undefined,
36  }
37}
38
39type Bindings = Record<string, string | null>
40
41// The file with `setup` applied, and nothing else changed: the setup's actions
42// lose every key bound to them, a default's own key loses an unbinding (null),
43// and the chosen keys are added to the last block of their context (a new one
44// at the end when there is none). Blocks left empty by a removal are dropped.
45export function applySetup(file: KeyFile, setup: Setup): KeyFile {
46  const blocks = (file.bindings ?? []).map(b => ({ ...b, bindings: { ...(b.bindings ?? {}) } as Bindings }))
47  const touched = new Set<number>()
48  const unbind = (context: string, action: string, defaultKey: string) =>
49    blocks.forEach((b, i) => {
50      if (b.context !== context) return
51      for (const [key, bound] of Object.entries(b.bindings)) {
52        if (bound === action || (key === defaultKey && bound === null)) {
53          delete b.bindings[key]
54          touched.add(i)
55        }
56      }
57    })
58  const bind = (context: string, key: string, action: string) => {
59    let i = blocks.map(b => b.context).lastIndexOf(context)
60    if (i < 0) i = blocks.push({ context, bindings: {} }) - 1
61    blocks[i]!.bindings[key] = action
62  }
63
64  unbind(ENTRY.context, ENTRY.action, DEFAULT_ENTRY)
65  unbind(PREVIOUS.context, PREVIOUS.action, DEFAULT_VERBS.previous)
66  unbind(NEXT.context, NEXT.action, DEFAULT_VERBS.next)
67  if (setup.entry && setup.entry !== DEFAULT_ENTRY) bind(ENTRY.context, setup.entry, ENTRY.action)
68  if (setup.verbs && !isDefaultVerbs(setup.verbs)) {
69    bind(PREVIOUS.context, setup.verbs.previous, PREVIOUS.action)
70    bind(NEXT.context, setup.verbs.next, NEXT.action)
71  }
72  const kept = blocks.filter((b, i) => !touched.has(i) || Object.keys(b.bindings).length > 0)
73  return { ...file, bindings: kept }
74}
75
76const isDefaultVerbs = (v: { previous: string; next: string }) =>
77  v.previous === DEFAULT_VERBS.previous && v.next === DEFAULT_VERBS.next
78
79// The entries that differ between two files, as the menu shows them before
80// writing: `+ Chat  ctrl+s → abovePrompt:focus`, `− Pane  left → …`.
81export function changes(before: KeyFile, after: KeyFile): string[] {
82  const entries = (file: KeyFile) => {
83    const all = new Map<string, string>()
84    for (const b of file.bindings ?? []) {
85      for (const [key, bound] of Object.entries(b.bindings ?? {})) {
86        all.set(`${b.context ?? '?'}  ${key}`, bound === null ? '(unbound)' : bound)
87      }
88    }
89    return all
90  }
91  const old = entries(before)
92  const now = entries(after)
93  const lines: string[] = []
94  for (const [where, bound] of old) if (now.get(where) !== bound) lines.push(`− ${where} → ${bound}`)
95  for (const [where, bound] of now) if (old.get(where) !== bound) lines.push(`+ ${where} → ${bound}`)
96  return lines
97}
98
99// Claude Code's own Chat keys a chosen entry key would take over.
100const CHAT_KEYS: Record<string, string> = {
101  'ctrl+s': 'stash your prompt',
102  'ctrl+r': 'search your prompt history',
103  'ctrl+o': 'open transcript mode',
104  'ctrl+l': 'redraw the screen',
105}
106
107// What a setup gives up, one short line each, for the menu to say before
108// writing. `tip` lines are advice, not a cost.
109export function costs(setup: Setup): { line: string; isTip?: true }[] {
110  const notes: { line: string; isTip?: true }[] = []
111  const given = setup.entry ? CHAT_KEYS[setup.entry] : undefined
112  if (setup.entry && given) notes.push({ line: `${keyLabel(setup.entry)} will no longer ${given}` })
113  const verbs = setup.verbs
114  if (verbs && [verbs.previous, verbs.next].some(k => k === 'left' || k === 'right')) {
115    notes.push({ line: "← → will also move between buttons in other mods' panes" })
116    notes.push({ line: 'Tip: ← on an empty prompt opens agent view (/config → leftArrowOpensAgents)', isTip: true })
117  }
118  return notes
119}
120
src/configpath.ts 32 lines
1// Where Claude Code keeps the person's keybindings.json (#74): its config folder
2// is CLAUDE_CONFIG_DIR when set, else `.claude` in the home folder — HOME, or on
3// Windows USERPROFILE (PowerShell and cmd set no HOME). Plain strings in, a path
4// or nothing out; the hooks read the variables.
5
6export type ConfigEnv = { CLAUDE_CONFIG_DIR?: string; HOME?: string; USERPROFILE?: string }
7
8// The path, joined with the folder's own separator, or undefined when no
9// variable names a folder — never a path built from "undefined".
10export function keybindingsPath(env: ConfigEnv): string | undefined {
11  const set = (value?: string) => (value && value.trim() ? value.trim() : undefined)
12  const configDir = set(env.CLAUDE_CONFIG_DIR)
13  if (configDir) return join(configDir, 'keybindings.json')
14  const home = set(env.HOME) ?? set(env.USERPROFILE)
15  return home ? join(home, '.claude', 'keybindings.json') : undefined
16}
17
18// The path as the menu shows it: the home folder written `~`.
19export function shownPath(path: string, env: ConfigEnv): string {
20  const home = env.HOME?.trim() || env.USERPROFILE?.trim()
21  if (!home) return path
22  const base = home.replace(/[\\/]+$/, '')
23  return path.startsWith(base) && /^[\\/]/.test(path.slice(base.length)) ? `~${path.slice(base.length)}` : path
24}
25
26// Joins with backslashes for a Windows folder (one that has them and no forward
27// slash), with forward slashes otherwise.
28function join(dir: string, ...parts: string[]): string {
29  const separator = dir.includes('\\') && !dir.includes('/') ? '\\' : '/'
30  return [dir.replace(/[\\/]+$/, ''), ...parts].join(separator)
31}
32
src/counter.ts 7 lines
1// "Block 3 of 12" as the navigator and band say it — nothing when there are no
2// blocks, never "of 0" (#73). Plain numbers in, text out.
3
4export function blockCounter(selected: number, count: number): string {
5  return count > 0 ? `Block ${Math.min(selected, count - 1) + 1} of ${count}` : ''
6}
7
src/layout.ts 62 lines
1// How much room things take, estimated: the mod can't measure how Claude Code
2// wraps Markdown, so rows are counted from text. Plain numbers and strings.
3
4import { segment } from './blocks'
5
6// `text` cut to `n` characters, `...` (three plain dots) when cut.
7export const clip = (text: string, n: number) => (text.length > n ? `${text.slice(0, n)}...` : text)
8
9// Rows a block takes when Claude Code draws it `width` wide: a table as drawn (a
10// border line around and between its rows), a code block without its fence
11// lines, prose wrapped by length.
12export function drawnRows(text: string, width: number): number {
13  const lines = text.split('\n')
14  if (lines.every(l => l.trim().startsWith('|'))) {
15    const rows = lines.filter(l => !/^\s*\|[\s:|-]+\|\s*$/.test(l)).length
16    return 2 * rows + 1
17  }
18  const body = /^\s*(```|~~~)/.test(lines[0] ?? '') ? lines.slice(1, -1) : lines
19  return body.reduce((n, l) => n + Math.max(1, Math.ceil(l.length / width)), 0)
20}
21
22// The classic navigator's smallest height.
23export const CLASSIC_MIN_ROWS = 7
24
25// Rows the classic (inline) navigator asks for: the tallest block's estimated
26// lines plus its six fixed rows (header, previous, menu, comment, next,
27// footer), between a floor and a third of the screen. Decided once per reply,
28// so the pane does not change height while navigating (each change would shift
29// the screen, and the classic renderer redraws by erasing what it printed).
30// The dock ignores it.
31export function classicRows(text: string, screen: { columns: number; rows: number }): number {
32  const width = Math.max(20, screen.columns - 8)
33  const tallest = Math.max(
34    1,
35    ...segment(text)
36      .filter(b => b.isBlock)
37      .map(b => drawnRows(b.text, width)),
38  )
39  return Math.max(CLASSIC_MIN_ROWS, Math.min(tallest + 6, Math.floor(screen.rows / 3)))
40}
41
42// Rows per block in the minimap, proportional to each block's length and at
43// least one, scaled into `rows`. Happy path: assumes blocks <= rows.
44export function minimap(lengths: readonly number[], rows: number): number[] {
45  const total = lengths.reduce((a, b) => a + b, 0) || 1
46  const spare = Math.max(0, rows - lengths.length)
47  const shares = lengths.map(len => (len / total) * spare)
48  const heights = shares.map(share => 1 + Math.floor(share))
49  // Hand the rows lost to rounding down to the largest remainders, so the
50  // column fills `rows` exactly instead of leaving space at the bottom.
51  let left = rows - heights.reduce((a, b) => a + b, 0)
52  const byRemainder = shares
53    .map((share, i) => [share - Math.floor(share), i] as const)
54    .sort((a, b) => b[0] - a[0])
55  for (const [, i] of byRemainder) {
56    if (left <= 0) break
57    heights[i]! += 1
58    left -= 1
59  }
60  return heights
61}
62
src/tally.ts 38 lines
1// What is staged, counted: the band's summary and the navigator's heading.
2// The tray's marks and comments in, text out.
3
4import { GLYPH } from './compile'
5import type { Mark } from './compile'
6
7type Marks = Readonly<Record<number, Mark>>
8type Comments = Readonly<Record<number, string>>
9
10// Comments that count: a block marked to comment, with text.
11export function commentCount(marks: Marks, comments: Comments): number {
12  return Object.entries(marks).filter(([i, m]) => m === 'comment' && comments[Number(i)]?.trim()).length
13}
14
15// The band: "2 accepted · 1 rejected · 1 comment", zero counts left out.
16export function summary(marks: Marks, comments: Comments): string {
17  const marked = Object.values(marks)
18  const count = (m: Mark) => marked.filter(x => x === m).length
19  const notes = commentCount(marks, comments)
20  return [
21    count('accept') && `${count('accept')} accepted`,
22    count('reject') && `${count('reject')} rejected`,
23    notes && `${notes} comment${notes === 1 ? '' : 's'}`,
24  ]
25    .filter(Boolean)
26    .join(' · ')
27}
28
29// The navigator's heading: "✓2 ✗1 ✎1", zero counts left out.
30export function glyphCounts(marks: Marks): string {
31  const marked = Object.values(marks)
32  return (['accept', 'reject', 'comment'] as const)
33    .map(m => [m, marked.filter(x => x === m).length] as const)
34    .filter(([, n]) => n > 0)
35    .map(([m, n]) => `${GLYPH[m]}${n}`)
36    .join(' ')
37}
38
hooks/ui.ts 49 lines
1// What the hooks and the views share, with no `$` in it: the actions, the pane's
2// keys, and the sizes and colours the drawings use. Claude Code's mod loader
3// follows `$` only into functions in the same file, so everything that touches
4// it stays in register.tsx; views (hooks/views/) take plain data and callbacks.
5
6export const VERBS = [
7  { key: 'verb-accept', glyph: '✓', label: '✓ accept', hotkey: 'a' },
8  { key: 'verb-reject', glyph: '✗', label: '✗ reject', hotkey: 'x' },
9  { key: 'verb-comment', glyph: '✎', label: '✎ comment', hotkey: 'c' },
10  { key: 'verb-discuss', glyph: '💬', label: '💬 discuss', hotkey: 'd' },
11] as const
12// Each opening of the comment field gets a fresh key: a field drawn again
13// under the same key and the same value keeps what was typed last time.
14export const COMMENT_PREFIX = 'comment-'
15export const commentKey = (seq: number) => `${COMMENT_PREFIX}${seq}`
16
17// The pane's tree is always bodyRows + 2 rows with its window pinned at offset
18// 1, so ↑ and ↓ always have a row to scroll to and each raises ui.scroll with a
19// direction. The band's focus ring cannot tell ↑ from ← (SPEC §10, *Why a pane*).
20export const PANE = 'synaptik-map'
21export const PANE_ANCHOR = 'pane-anchor'
22export const PANE_COLUMNS = 4
23
24// A navigator row is bar, space, glyph, space (4 cells), then the block's
25// words and "..." (3 cells), sized to the pane's current width less a 2-cell
26// margin, which keeps clear of the terminal's scrollbar.
27export const ROW_CHROME = 9
28
29// Cells of each comment-field line taken by its submit hint (`⏎ ok`) and gaps.
30export const FIELD_CHROME = 7
31export const ACCENT = 'cyan'
32
33// The selected block's background: Claude Code's own text-selection colour, a
34// theme key, so it follows /config's light or dark theme. Painted on the Box
35// around the block's Markdown, which takes no background itself. The key is
36// found in the binary, not the documented API; an unknown one would make the
37// reply fall back to Claude Code's own drawing.
38export const SELECTED_BACKGROUND = 'selectionBg'
39
40// Claude Code's reply frame, redrawn by the mod: a 2-cell left margin and the
41// bullet on the reply's first line.
42export const MARGIN = 2
43export const BULLET = '⏺'
44
45// The longest reply piece Synaptik draws itself. One drawing holds 100,000
46// characters in all, its markup included; a longer piece is left to Claude
47// Code, which draws any length, and is reviewed from the navigator alone.
48export const MAX_DRAWN = 90_000
49