SLOPSHOPPER

marginalia

Select text in the conversation, then quote it into your prompt (it stays outlined), ask about it, turn it into a task or keep it. Theme mods can draw its bar…

newbandspinnerrowscommandtoast
v0.1.0MITupdated 2026-10-15miiraheart/marginalia
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · marginalia
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ marginalia │ ⏺ Read(src/auth.ts) │ Select some text in the conversation first │ ⎿ Read 6 lines ╰────────────────────────────────────────────╯ ⏺ Update(src/auth.ts) ╭────────────────────────────────────────────╮ ⎿ Added 2 lines, removed 1 line │ marginalia │ ⏺ Bash(bun test) │ Select some text in the conversation first │ ⎿ 3 pass, 1 fail ╰────────────────────────────────────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /q ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts me [ ▲ ] [ ▼ ] reply [ ▲ ] [ ▼ ] ⟨Claude Code's own drawing⟩
README

Marginalia

Select text in a Claude Code conversation and act on it right there: quote it into your prompt, ask about it, turn it into a task, or keep it for later.

<img width="1161" height="120" alt="image" src="https://github.com/user-attachments/assets/0ffd14ac-314f-437a-bc9a-68fe86fff42f" />

What it does

Select text with the mouse anywhere in the transcript. A moment after you let go, a margin bar appears under the selected line:

ButtonWhat happens
❝ QuotePuts a chip like [❝1 Cats sleep 12 to…] at your cursor. When you send the prompt, the full passage goes along as context, labeled with where it came from.
♥ RememberAsks Claude to remember the passage.
+ TaskCreates a task from it with beads (bd create), when the project uses beads.
⋯More: Explain, Deeper, Redo, Anki, Define, Wrong, Copy clean.
✕Closes the bar.

<img width="1221" height="115" alt="image" src="https://github.com/user-attachments/assets/747afb7a-0c9a-4983-a77f-cf3bf6b3e7c4" />

<img width="1305" height="244" alt="image" src="https://github.com/user-attachments/assets/dc2544cf-e922-469e-bcfc-f4be47e087fa" />

Click ⋯ and the other actions open on a second line:

<img width="1074" height="183" alt="image" src="https://github.com/user-attachments/assets/ff6b71a3-3f57-4d85-aeea-d20193a87d2c" />

More actionWhat happens
? ExplainAsks Claude to explain the passage more simply.
↳ DeeperAsks Claude to go deeper, with one concrete example.
✎ RedoAsks Claude to rewrite the passage another way.
▤ AnkiAsks Claude to turn the passage into an Anki card, the way the project makes cards.
≡ DefineDefines the selected words (see MARGINALIA_DEFINE_CMD below).
✗ WrongTells Claude the passage looks wrong or unclear and asks it to check and fix it.
⧉ Copy cleanCopies the passage without list markers or button text.

Quoted text stays outlined in gray in the conversation, with ❝1 quoted [✕ unquote] in its border. Every quote also shows in a quote tray above the prompt, with ↥ go (jump back to the passage), ▸ show and ✕ unquote. Removing the chip from the prompt unquotes it too.

Jump between messages

The footer next to Progress holds me ▲ ▼ and reply ▲ ▼. me steps through the messages you sent, including ones sent while Claude was working. reply steps through Claude's replies, landing on the start of each one. ▲ goes to older ones, ▼ to newer ones, and each jump puts the message at the top of the screen. Sending a new message starts over from the bottom.

Drawing the bar, outlines and tray costs no tokens. Only sending a quote or one of the ask actions does.

Install

Requires a Claude Code version with mods. Tested on 2.1.296.

/plugin marketplace add miiraheart/marginalia
/plugin install marginalia@marginalia

Selection needs Claude Code's fullscreen mode: the transcript view with "Jump to bottom" and mouse selection. That is a Claude Code setting, not the size of your terminal window.

To update:

claude plugin marketplace update marginalia && claude plugin update marginalia@marginalia

To uninstall:

claude plugin uninstall marginalia@marginalia

Settings

Both are environment variables. Set them in your shell, or per project in .claude/settings.json under "env".

VariableEffect
MARGINALIA_ACTIONSButtons on the bar itself, comma separated, in order. Default quote,remember,task. The rest go under ⋯. Names: quote, remember, task, explain, deeper, redo, anki, define, wrong, copy.
MARGINALIA_DEFINE_CMDA command run with the selected words for Define (for example a dictionary lookup). Its output shows as a toast. Without it, or when the command fails or takes more than 20 seconds, Define asks Claude.

Example: to have ≡ Define and ▤ Anki on the bar instead of ♥ Remember (handy when you learn a language), put this in that project's .claude/settings.json. The bar then shows [ ❝ Quote ] [ ≡ Define ] [ ▤ Anki ] [ + Task ], and Remember moves under ⋯.

{ "env": { "MARGINALIA_ACTIONS": "quote,define,anki,task" } }

Commands

  • /q quotes the current selection into the prompt.
  • /remember-this sends the selection to Remember.

How the bar stays out of your way

Terminal selections are tied to screen rows, so anything that moves text while you select changes what is selected. Marginalia avoids moving text:

  • The bar sits beside the selected line when it fits, or on a blank line under it.
  • Between tight list items, where there is no blank line, the bar takes its own row under the selected item. It is added only after your selection settles.
  • When you select again, the old bar row is reused if it sits right above your new selection: the bar moves into it, just above the selected line. Otherwise the old row turns blank instead of disappearing, so nothing above your new selection shifts. Blank rows below the selection are removed at once.
  • The terminal does not report a plain click outside the selection, so the bar closes with ✕, when you type, press a button or start a new selection, or by itself 15 seconds after it appears.

For theme mods

Marginalia works on its own. A theme mod that draws assistant messages itself can draw the bar and quote outlines in its own style through $.marginalia:

  • view({ requestId, role }) returns what to draw for a message: the selection, its quotes (pins in the types), the buttons, and blank rows to keep.
  • press(args) runs a button.
  • version() changes whenever something should be redrawn.
  • placed({ inFlow, rows }) tells Marginalia whether the bar was drawn as its own row.

The types are in types/index.d.ts.

Known limits

  • Claude Code's fullscreen mode only: in the classic mode Claude Code reports no selection, so the bar never appears.
  • The jump buttons follow their own last jump: after scrolling by hand, the next click continues from the message it last jumped to. The last few messages of a conversation stop as high as they can, since nothing below them can scroll up.
  • When Marginalia runs alone (no theme mod drawing the messages), it draws a message itself while that message has a bar or a quote. Its spacing can then differ by a line from Claude Code's own drawing.

License

MIT

Source 6 files
hooks/register.tsx 674 lines
1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, PromptOrigin, Register, RenderElement, RenderSurface } from 'claude-code'
3
4import type { MarginaliaAction, MarginaliaPin, MarginaliaPress, MarginaliaView } from '../types'
5import { ACTION_LABELS, askPrompt, chipOf, chipsIn, cleanSelection, contextBlock, defineWords, moreActions, nextNumber, normalized, preview, rememberPrompt, shortActions, sourceOf, taskTitle, withoutChip, withoutOwnRows, wordCount } from './text'
6import { mergeSpans, spanOf, splitBlocks } from './blocks'
7import { candidates, navMap, renderIdsOf, type NavMap, type NavRole } from './nav'
8import './themes'
9
10const pending = atom({ plugin: 'marginalia', key: 'pending' } as const, [])
11const present = atom({ plugin: 'marginalia', key: 'present' } as const, [])
12const selected = atom({ plugin: 'marginalia', key: 'selected' } as const, null)
13const selectedRow = atom({ plugin: 'marginalia', key: 'row' } as const, null)
14
15const POLL_MS = 400
16const SETTLE_MS = 1200
17const ROW_WAIT_MS = 1500
18const SHIFT_MS = 1000
19const NAV_KEYS: Record<string, { role: NavRole; dir: 1 | -1 }> = {
20  'me-up': { role: 'user', dir: -1 },
21  'me-down': { role: 'user', dir: 1 },
22  'reply-up': { role: 'assistant', dir: -1 },
23  'reply-down': { role: 'assistant', dir: 1 },
24}
25const CLEAR_MS = 15000
26const DARK = '#2b2e3b'
27
28async function cleanSelectionOf($: EngineInterface) {
29  const sel = await $.ui.selection()
30  if (!sel) return undefined
31  return { ...sel, text: withoutOwnRows(sel.text) }
32}
33
34const COLORS: Record<MarginaliaAction, string> = {
35  quote: 'claude',
36  remember: 'remember',
37  task: 'success',
38  explain: 'suggestion',
39  deeper: 'suggestion',
40  redo: 'suggestion',
41  anki: 'merged',
42  define: 'ide',
43  wrong: 'error',
44  copy: 'subtle',
45}
46
47const roles = new Map<string, 'user' | 'assistant'>()
48const onScreen = new Map<string, boolean>()
49const nav = { path: '', at: null as number | null, key: '', map: null as NavMap | null }
50const SHRINK = String.raw`LC_ALL=C sed -E 's/"(([^"\\]|\\.){40})([^"\\]|\\.){200,}"/"\1"/g' "$1" | tail -c 3900000`
51const seen = { dismissed: null as string | null, last: null as string | null, at: 0, steady: 0, skipUntil: 0, shifted: null as string | null }
52const ui = { root: '', rowDrawnFor: null as string | null, pinsDrawn: new Set<string>(), claimed: new Set<string>(), flash: null as number | null, more: false, open: new Set<number>(), version: 0, shown: 0, actions: '', inFlow: false, inFlowRows: 1, inFlowSlot: '', ghosts: [] as { row: string; slot: string; rows: number }[] }
53
54function changed($: EngineInterface) {
55  ui.version += 1
56  $.ui.invalidate('ui.render')
57}
58
59async function hideBar($: EngineInterface, text: string | null) {
60  seen.dismissed = text
61  ui.more = false
62  ui.inFlow = false
63  ui.ghosts = []
64  await update($, selected, () => null)
65  await update($, selectedRow, () => null)
66  changed($)
67  await expectShift($)
68}
69
70async function setPending($: EngineInterface, fn: (all: MarginaliaPin[]) => MarginaliaPin[]) {
71  await update($, pending, fn)
72  changed($)
73}
74
75async function visiblePins($: EngineInterface): Promise<MarginaliaPin[]> {
76  const inBox = new Set(await read($, present))
77  return (await read($, pending)).filter(p => inBox.has(p.n))
78}
79
80async function removeChip($: EngineInterface, n: number) {
81  const box = await $.prompt.read()
82  const text = withoutChip(box.text, n)
83  if (text !== box.text) await $.prompt.fill({ text, mode: 'replace' })
84}
85
86async function pollSelection($: EngineInterface) {
87  const sel = await $.ui.selection()
88  const raw = sel?.text.trim() || null
89  const text = raw ? withoutOwnRows(raw) || null : null
90  const now = await $.clock.now()
91  if (text !== seen.last && now < seen.skipUntil) {
92    Object.assign(seen, { last: text, at: now, steady: 0, skipUntil: 0, shifted: text })
93    return
94  }
95  if (text !== seen.last) {
96    const isSame = normalized(text) === normalized(seen.dismissed)
97    Object.assign(seen, { last: text, at: now, steady: 0, dismissed: isSame ? seen.dismissed : null, shifted: null })
98    if (!ui.inFlow && ((await read($, selected)) !== null || (await read($, selectedRow)) !== null)) {
99      ui.more = false
100      await update($, selected, () => null)
101      await update($, selectedRow, () => null)
102      changed($)
103    }
104    return
105  }
106  seen.steady += 1
107  if (now - seen.at < SETTLE_MS || seen.steady < 1 || text === seen.shifted) return
108  const shown = text && normalized(text) !== normalized(seen.dismissed) ? text : null
109  const old = await read($, selected)
110  if (shown === old) return
111  const row = shown && sel?.requestId && onScreen.get(sel.requestId) !== false ? sel.requestId : null
112  const oldRow = await read($, selectedRow)
113  if (!shown) ui.ghosts = []
114  else if (old && oldRow && ui.inFlow && !ui.ghosts.some(g => g.row === oldRow && g.slot === ui.inFlowSlot)) ui.ghosts.push({ row: oldRow, slot: ui.inFlowSlot, rows: ui.inFlowRows })
115  await update($, selected, () => shown)
116  await update($, selectedRow, () => row)
117  ui.more = false
118  if (!shown) ui.inFlow = false
119  ui.rowDrawnFor = null
120  changed($)
121  await expectShift($)
122  if (row && !onScreen.has(row)) $.clock.after(ROW_WAIT_MS, () => void fallBackToBar($, row))
123  const turn = ++ui.shown
124  if (shown) $.clock.after(CLEAR_MS, () => void clearIfIdle($, turn))
125}
126
127async function clearIfIdle($: EngineInterface, turn: number) {
128  const showing = await read($, selected)
129  if (turn !== ui.shown || !showing || (await isSelecting($))) return
130  await hideBar($, showing)
131}
132
133async function isSelecting($: EngineInterface): Promise<boolean> {
134  return seen.last !== (await read($, selected)) || (await $.clock.now()) - seen.at < SETTLE_MS
135}
136
137async function fallBackToBar($: EngineInterface, row: string, isForced = false) {
138  if ((await read($, selectedRow)) !== row) return
139  if (!isForced && ((onScreen.get(row) ?? ui.rowDrawnFor === row) || (await isSelecting($)))) return
140  await update($, selectedRow, () => null)
141  changed($)
142}
143
144function noteScreen($: EngineInterface, requestId: string, at: { first: number } | null | undefined) {
145  if (at === undefined) return
146  const was = onScreen.get(requestId)
147  onScreen.set(requestId, at !== null)
148  if (at === null && was !== false) void fallBackToBar($, requestId)
149}
150
151async function quote($: EngineInterface) {
152  const sel = await cleanSelectionOf($)
153  if (!sel?.text.trim()) {
154    $.ui.toast('Select some text in the conversation first')
155    return
156  }
157  const role = sel.requestId ? roles.get(sel.requestId) : undefined
158  const inBox = chipsIn((await $.prompt.read()).text).map(c => c.n)
159  await update($, present, () => inBox)
160  await setPending($, all => all.filter(p => inBox.includes(p.n)))
161  const pin: MarginaliaPin = { n: nextNumber(inBox.map(n => ({ n, text: '' }))), text: sel.text, ...(sel.requestId ? { requestId: sel.requestId } : {}), ...(role ? { role } : {}) }
162  const chip = chipOf(pin)
163  const filled = await $.prompt.fill({ text: `${chip} `, mode: 'insert', decorations: [{ start: 0, end: chip.length, color: 'suggestion', bold: true }] })
164  if (!filled.isFilled) {
165    $.ui.toast('The prompt box is busy, close the dialog and try again')
166    return
167  }
168  await expectShift($)
169  await update($, present, all => [...new Set([...all, pin.n])])
170  await setPending($, all => [...all, pin])
171  await hideBar($, sel.text.trim())
172}
173
174async function expectShift($: EngineInterface) {
175  seen.skipUntil = (await $.clock.now()) + SHIFT_MS
176}
177
178async function unpin($: EngineInterface, n: number) {
179  await expectShift($)
180  ui.open.delete(n)
181  await update($, present, all => all.filter(m => m !== n))
182  await setPending($, all => all.filter(p => p.n !== n))
183  await removeChip($, n)
184}
185
186async function takeQuotes($: EngineInterface, text: string, origin: PromptOrigin): Promise<string[]> {
187  const all = await read($, pending)
188  if (!all.length || origin.kind === 'plugin') return []
189  const quoted = chipsIn(text).flatMap(c => {
190    const pin = all.find(p => p.n === c.n)
191    return pin ? [contextBlock(pin)] : []
192  })
193  ui.open.clear()
194  await update($, present, () => [])
195  await setPending($, () => [])
196  return quoted
197}
198
199async function runAction($: EngineInterface, action: MarginaliaAction) {
200  if (action === 'quote') return quote($)
201  const sel = await cleanSelectionOf($)
202  if (!sel?.text.trim()) {
203    $.ui.toast('Select some text in the conversation first')
204    return
205  }
206  await hideBar($, sel.text.trim())
207  if (action === 'remember') {
208    await $.prompt.submit({ text: rememberPrompt(sel.text), asUser: true })
209    $.ui.toast('♥ Sent to remember')
210    return
211  }
212  if (action === 'copy') {
213    const copied = await $.ui.copy({ text: cleanSelection(sel.text) })
214    $.ui.toast(copied.isCopied ? '⧉ Copied' : `Copy failed: ${copied.reason}`)
215    return
216  }
217  if (action === 'task') {
218    const made = await $.process.run(['bd', 'create', taskTitle(sel.text), '--description', cleanSelection(sel.text), '-t', 'task', '-p', '2', '--json'], { cwd: ui.root || undefined }).catch(() => undefined)
219    const id = made?.exitCode === 0 ? /"id":\s*"([^"]+)"/.exec(made.stdout)?.[1] : undefined
220    $.ui.toast(id ? `+ Task ${id} created` : 'Could not create the task: is bd set up in this folder?')
221    return
222  }
223  if (action === 'define') {
224    const command = await $.env.get('MARGINALIA_DEFINE_CMD')
225    if (command) {
226      $.ui.toast(`≡ Looking up ${defineWords(sel.text).join(' ')}…`)
227      const found = await $.process.run([command, ...defineWords(sel.text)], { timeoutMs: 20000 }).catch(() => undefined)
228      if (found?.exitCode === 0 && found.stdout.trim()) {
229        $.ui.toast(found.stdout.trim().split('\n').slice(0, 12).join('\n'), { timeoutMs: 15000 })
230        return
231      }
232    }
233  }
234  const prompt = askPrompt(action, sel.text)
235  if (prompt) await $.prompt.submit({ text: prompt, asUser: true })
236}
237
238async function press($: EngineInterface, args: MarginaliaPress): Promise<boolean> {
239  ui.ghosts = []
240  if ('action' in args) await runAction($, args.action)
241  else if ('unpin' in args) await unpin($, args.unpin)
242  else if ('close' in args) {
243    const showing = await read($, selected)
244    if (showing) await hideBar($, showing)
245  } else if ('toggle' in args) {
246    if (ui.open.has(args.toggle)) ui.open.delete(args.toggle)
247    else ui.open.add(args.toggle)
248    changed($)
249  } else {
250    ui.more = !ui.more
251    changed($)
252  }
253  return true
254}
255
256async function viewOf($: EngineInterface, requestId: string, role?: 'user' | 'assistant', isExternal = false): Promise<MarginaliaView | undefined> {
257  if (role) roles.set(requestId, role)
258  const mine = (await visiblePins($)).filter(p => p.requestId === requestId)
259  const selection = (await read($, selectedRow)) === requestId ? await read($, selected) : null
260  const ghosts = ui.ghosts.filter(g => g.row === requestId).map(g => ({ slot: g.slot, rows: g.rows }))
261  if (!mine.length && !selection && !ghosts.length) return undefined
262  if (isExternal) ui.claimed.add(requestId)
263  if (selection) ui.rowDrawnFor = requestId
264  if (mine.length) ui.pinsDrawn.add(requestId)
265  const button = (action: MarginaliaAction) => ({ action, label: ACTION_LABELS[action] })
266  return {
267    selection,
268    pins: mine,
269    short: shortActions(ui.actions).map(button),
270    more: moreActions(ui.actions).map(button),
271    isMoreOpen: ui.more,
272    open: [...ui.open],
273    flash: ui.flash,
274    ghosts,
275  }
276}
277
278function actionRow($: EngineInterface, el: ElementTable, view: MarginaliaView): RenderElement {
279  const { Box, Button, Text } = el
280  const button = (b: MarginaliaView['short'][number]) => (
281    <Button key={b.action} label={b.label} variant="primary" onPress={() => void press($, { action: b.action })}>
282      <Text color={COLORS[b.action]} bold>{b.label}</Text>
283    </Button>
284  )
285  return (
286    <Box key="actions" flexDirection="column">
287      <Box flexDirection="row" columnGap={1}>
288        {view.short.map(button)}
289        <Button key="more" label={view.isMoreOpen ? '⋯ less' : '⋯'} onPress={() => void press($, { more: true })} />
290        <Button key="close" label="✕" onPress={() => void press($, { close: true })} />
291        <Text dimColor>{wordCount(view.selection ?? '')}</Text>
292      </Box>
293      {view.isMoreOpen ? <Box flexDirection="row" columnGap={1}>{view.more.map(button)}</Box> : null}
294    </Box>
295  )
296}
297
298async function transcriptOf($: EngineInterface): Promise<string> {
299  if (nav.path) return nav.path
300  const id = await $.session.id().catch(() => '')
301  const home = await $.env.get('HOME').catch(() => undefined)
302  const config = (await $.env.get('CLAUDE_CONFIG_DIR').catch(() => undefined)) ?? `${home}/.claude`
303  ui.root ||= await $.session.root().catch(() => '')
304  return id && home && ui.root ? `${config}/projects/${ui.root.replace(/[^A-Za-z0-9]/g, '-')}/${id}.jsonl` : ''
305}
306
307async function navMapOf($: EngineInterface, path: string): Promise<NavMap | null> {
308  const stat = await $.fs.stat(path).catch(() => undefined)
309  if (!stat) return null
310  const key = `${path}:${stat.size}:${stat.mtimeMs}`
311  if (nav.key === key && nav.map) return nav.map
312  const ran = await $.process.run(['sh', '-c', SHRINK, 'sh', path], { timeoutMs: 20000 }).catch(() => undefined)
313  const read = ran?.exitCode === 0 && ran.stdout ? ran.stdout : await $.fs.read(path).catch(() => undefined)
314  if (typeof read !== 'string') return null
315  Object.assign(nav, { key, map: navMap(read) })
316  return nav.map
317}
318
319async function warmNav($: EngineInterface) {
320  const path = await transcriptOf($)
321  if (path) await navMapOf($, path)
322}
323
324async function jump($: EngineInterface, role: NavRole, dir: 1 | -1) {
325  const map = nav.map ?? (await warmNav($), nav.map)
326  if (!map) {
327    $.ui.toast(`Can't read this conversation's history${nav.path ? ` (${nav.path})` : ''}`)
328    return
329  }
330  let deny = ''
331  for (const target of candidates(map, role, dir, nav.at).slice(0, 30)) {
332    for (const requestId of renderIdsOf(target.id)) {
333      const moved = await $.ui.scroll({ to: { requestId }, block: 'start' })
334      if (moved.deny) {
335        deny ||= moved.deny
336        continue
337      }
338      await $.ui.scroll({ to: { requestId }, block: 'start' })
339      nav.at = target.at
340      void warmNav($)
341      return
342    }
343  }
344  void warmNav($)
345  const none = `No ${dir === 1 ? 'later' : 'earlier'} ${role === 'user' ? 'message of yours' : 'reply'}`
346  $.ui.toast(deny ? `${none} (${deny})` : none)
347}
348
349async function goTo($: EngineInterface, requestId: string, n: number) {
350  const moved = await $.ui.scroll({ to: { requestId }, block: 'center' })
351  if (moved.deny) {
352    $.ui.toast(`Can't jump there: ${moved.deny}`)
353    return
354  }
355  ui.flash = n
356  changed($)
357  $.clock.after(3000, () => {
358    if (ui.flash === n) ui.flash = null
359    changed($)
360  })
361}
362
363function pinRow($: EngineInterface, el: ElementTable, p: MarginaliaPin, isOpen: boolean): RenderElement {
364  const { Box, Button, Text } = el
365  return (
366    <Box key={`bar${p.n}`} flexDirection="column">
367      <Box flexDirection="row" columnGap={1}>
368        <Text color="suggestion" bold>{`❝${p.n}`}</Text>
369        <Text dimColor>{`"${preview(p.text)}" from ${sourceOf(p)}`}</Text>
370        {p.requestId ? <Button key={`go${p.n}`} label="↥ go" onPress={() => void goTo($, p.requestId!, p.n)} /> : null}
371        <Button key={`show${p.n}`} label={isOpen ? '▾ hide' : '▸ show'} onPress={() => void press($, { toggle: p.n })} />
372        <Button key={`unpin${p.n}`} label="✕ unquote" onPress={() => void press($, { unpin: p.n })} />
373      </Box>
374      {isOpen ? (
375        <Box backgroundColor={DARK} paddingX={1}>
376          <Text>{p.text}</Text>
377        </Box>
378      ) : null}
379    </Box>
380  )
381}
382
383function barOverlay($: EngineInterface, el: ElementTable, view: MarginaliaView, isBelow: boolean, isBeside = false, besideAt = 0): RenderElement {
384  const height = view.isMoreOpen ? 2 : 1
385  if (isBeside) {
386    return (
387      <el.Box key="bar" position="absolute" left={besideAt} bottom={0}>
388        {actionRow($, el, view)}
389      </el.Box>
390    )
391  }
392  return (
393    <el.Box key="bar" position="absolute" left={0} right={0} height={height} top={isBelow ? undefined : -height} bottom={isBelow ? -height : undefined}>
394      {actionRow($, el, view)}
395    </el.Box>
396  )
397}
398
399function pinLegend($: EngineInterface, el: ElementTable, pins: MarginaliaPin[], flash: number | null): RenderElement[] {
400  const { Button, Text } = el
401  return pins.flatMap(p => [
402    <Text key={`pin${p.n}`} color={p.n === flash ? 'warning' : 'suggestion'} bold>{` ❝${p.n} quoted `}</Text>,
403    <Button key={`unpin${p.n}`} label="✕ unquote" onPress={() => void press($, { unpin: p.n })} />,
404    <Text key={`end${p.n}`}> </Text>,
405  ])
406}
407
408const RULE = Array.from({ length: 200 }, () => '│').join('\n')
409
410function pinOverlay($: EngineInterface, el: ElementTable, pins: MarginaliaPin[], flash: number | null, isTight: boolean): RenderElement[] {
411  const { Box, Text } = el
412  const color = pins.some(p => p.n === flash) ? 'warning' : 'inactive'
413  if (isTight) {
414    return [
415      <Box key="rule" position="absolute" top={0} bottom={0} left={-2} width={1} overflow="hidden" flexDirection="column">
416        <Text color={color}>{RULE}</Text>
417      </Box>,
418      <Box key="legend" position="absolute" top={0} right={0} flexDirection="row">
419        {pinLegend($, el, pins, flash)}
420      </Box>,
421    ]
422  }
423  return [
424    <Box key="outline" position="absolute" top={-1} bottom={-1} left={-2} right={0} borderStyle="round" borderColor={color} />,
425    <Box key="legend" position="absolute" top={-1} left={1} flexDirection="row">
426      {pinLegend($, el, pins, flash)}
427    </Box>,
428  ]
429}
430
431function noRoomForBar($: EngineInterface, requestId: string) {
432  ui.rowDrawnFor = null
433  void fallBackToBar($, requestId, true)
434}
435
436async function themed($: EngineInterface, surface: RenderSurface, text: string, columns: number): Promise<RenderElement | undefined> {
437  try {
438    const drawn = await $.prismantis.markdown({ surface, text, columns })
439    if (drawn) return drawn
440  } catch {}
441  try {
442    return await $.skins.markdown({ surface, text, columns })
443  } catch {
444    return undefined
445  }
446}
447
448async function messageWithRows($: EngineInterface, el: ElementTable, surface: RenderSurface, text: string, columns: number, view: MarginaliaView, mark: RenderElement | null, isFullscreen: boolean): Promise<{ tree: RenderElement; hasBar: boolean; isInFlow: boolean; slot: string }> {
449  const chunks = splitBlocks(text)
450  const raws = chunks.map(c => c.raw)
451  const end = chunks.length - 1
452  const whole = { first: end, last: end }
453  const groups = mergeSpans(view.pins.map(p => ({ ...(spanOf(raws, p.text) ?? whole), items: [p] })))
454  const span = view.selection ? (spanOf(raws, view.selection, true) ?? whole) : null
455  const width = [...view.short, ...(view.isMoreOpen ? view.more : [])].reduce((n, b) => n + b.label.length + 5, 30)
456  const placeOf = (i: number) => {
457    if (!chunks[i + 1]?.isTight) return { at: i, isBelow: true, isBeside: false, isInFlow: false }
458    const raw = chunks[i]!.raw
459    const isBeside = !raw.includes('\n') && raw.length + 4 + width <= columns
460    return { at: i, isBelow: true, isBeside, isInFlow: !isBeside }
461  }
462  const ghostRows = new Map(view.ghosts.map(g => [g.slot, g.rows]))
463  const reuse = span && span.first > 0 && chunks[span.first]?.isTight && ghostRows.has(String(span.first - 1)) ? span.first - 1 : null
464  const bar = !span ? null : reuse !== null ? { at: reuse, isBelow: true, isBeside: false, isInFlow: true } : span.last < end || isFullscreen ? placeOf(span.last) : span.first > 0 ? { at: span.first, isBelow: false, isBeside: false, isInFlow: false } : null
465  const { Box, Markdown } = el
466  const drawn = await Promise.all(chunks.map(c => themed($, surface, c.raw, columns)))
467  const gap = (i: number) => (i === 0 || chunks[i]!.isTight ? 0 : 1)
468  const edgeOf = (g: { first: number; last: number }) => (bar?.isBelow ? g.last : g.first)
469  const onGroup = (g: { first: number; last: number }) => !!bar && bar.at === edgeOf(g)
470  const ghostAt = new Map([...ghostRows].flatMap(([slot, rows]) => {
471    const at = Number(slot)
472    const isGone = !/^\d+$/.test(slot) || !chunks[at + 1]?.isTight || (bar?.isInFlow && bar.at === at) || (span && at >= span.last)
473    return isGone ? [] : [[at, rows] as const]
474  }))
475  const ghostBox = (i: number) => (ghostAt.has(i) ? <Box key={`ghost${i}`} height={ghostAt.get(i)} /> : null)
476  const barAt = () => (bar?.isInFlow ? <Box key="bar">{actionRow($, el, view)}</Box> : bar ? barOverlay($, el, view, bar.isBelow, bar.isBeside, bar.isBeside ? chunks[bar.at]!.raw.length + 2 : 0) : null)
477  const chunkBox = (i: number, top: number, hasBar: boolean) => (
478    <Box key={`c${i}`} flexDirection="column" marginTop={top}>
479      {drawn[i] ?? <Markdown text={chunks[i]!.raw} />}
480      {hasBar ? barAt() : null}
481      {ghostBox(i)}
482    </Box>
483  )
484  const body: RenderElement[] = []
485  for (let i = 0; i < chunks.length; i++) {
486    const group = groups.find(g => g.first === i)
487    if (!group) {
488      body.push(chunkBox(i, gap(i), bar?.at === i))
489      continue
490    }
491    const inside = Array.from({ length: group.last - group.first + 1 }, (_, k) => group.first + k)
492    const isTight = chunks[i]!.isTight || !!chunks[group.last + 1]?.isTight
493    body.push(
494      <Box key={`g${i}`} flexDirection="column" marginTop={gap(i)}>
495        {inside.map(j => chunkBox(j, j === i ? 0 : gap(j), bar?.at === j && !onGroup(group)))}
496        {pinOverlay($, el, group.items, view.flash, isTight)}
497        {onGroup(group) ? barAt() : null}
498      </Box>,
499    )
500    i = group.last
501  }
502  return {
503    hasBar: !!bar,
504    isInFlow: !!bar?.isInFlow,
505    slot: bar?.isInFlow ? String(bar.at) : '',
506    tree: (
507      <Box flexDirection="row" marginTop={1}>
508        {mark}
509        <Box flexDirection="column" flexGrow={1}>
510          {body}
511        </Box>
512      </Box>
513    ),
514  }
515}
516
517async function underPrompt($: EngineInterface, el: ElementTable, requestId: string, inner: RenderElement, isFullscreen: boolean): Promise<RenderElement> {
518  const view = await viewOf($, requestId, 'user')
519  if (!view) return inner
520  if (view.selection) ui.inFlow = false
521  if (view.selection && !isFullscreen) noRoomForBar($, requestId)
522  const { Box } = el
523  return (
524    <Box flexDirection="column">
525      {inner}
526      {view.pins.length ? (
527        <Box position="absolute" top={0} right={0} flexDirection="row">
528          {pinLegend($, el, view.pins, view.flash)}
529        </Box>
530      ) : null}
531      {view.selection && isFullscreen ? barOverlay($, el, view, true) : null}
532    </Box>
533  )
534}
535
536export const register: Register = on => {
537  on('engine.create', async (_$, e, next) => ({
538    ...(await next(e)),
539    marginalia: { view: async () => undefined, press: async () => false, version: async () => 0, placed: async () => false },
540  }))
541
542  on('marginalia.view', async ($, e) => ({ value: await viewOf($, e.requestId, e.role, true) }))
543  on('marginalia.press', async ($, e) => ({ value: await press($, e) }))
544  on('marginalia.version', () => ({ value: ui.version }))
545  on('marginalia.placed', (_$, e) => {
546    ui.inFlow = e.inFlow
547    ui.inFlowRows = e.rows ?? 1
548    ui.inFlowSlot = e.slot ?? ''
549    return { value: true }
550  })
551
552  on('session.start', async ($, e, next) => {
553    const started = await next(e)
554    ui.root = await $.session.root().catch(() => '')
555    ui.actions = (await $.env.get('MARGINALIA_ACTIONS').catch(() => undefined)) ?? ''
556    await $.command.register({ name: 'q', description: 'Quote the selected text into the prompt' }).catch(() => undefined)
557    await $.command.register({ name: 'remember-this', description: 'Ask Claude to remember the selected text' }).catch(() => undefined)
558    $.clock.every(POLL_MS, () => void pollSelection($))
559    return started
560  })
561
562  on('classic.SessionStart', async ($, e, next) => {
563    nav.path = e.transcript_path || nav.path
564    void warmNav($)
565    return next(e)
566  })
567
568  on('classic.UserPromptSubmit', async (_$, e, next) => {
569    nav.path = e.transcript_path || nav.path
570    nav.at = null
571    return next(e)
572  })
573
574  on('classic.Stop', async ($, e, next) => {
575    nav.path = e.transcript_path || nav.path
576    void warmNav($)
577    return next(e)
578  })
579
580  on('ui.press', { plugin: 'marginalia' }, async ($, e, next) => {
581    const to = NAV_KEYS[e.element]
582    if (!to) return next(e)
583    await jump($, to.role, to.dir)
584    return { element: e.element }
585  })
586
587  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
588    if (e.surface === 'terminal' && e.viewport?.isFullscreen !== true) return next(e)
589    const { Box, Button, Text } = $.ui.resolve(e)
590    const below = await next(e)
591    return (
592      <Box flexDirection="row" alignItems="center" gap={1}>
593        <Text dimColor>me</Text>
594        <Button key="me-up" label="▲" onPress={() => undefined} />
595        <Button key="me-down" label="▼" onPress={() => undefined} />
596        <Text dimColor>reply</Text>
597        <Button key="reply-up" label="▲" onPress={() => undefined} />
598        <Button key="reply-down" label="▼" onPress={() => undefined} />
599        {below}
600      </Box>
601    )
602  })
603
604  on('command.run', { command: 'q' }, async $ => {
605    await quote($)
606    return {}
607  })
608
609  on('command.run', { command: 'remember-this' }, async $ => {
610    await runAction($, 'remember')
611    return {}
612  })
613
614  on('prompt.edit', async ($, e, next) => {
615    const r = await next(e)
616    const showing = await read($, selected)
617    if (showing) await hideBar($, showing)
618    const chips = chipsIn(r.text)
619    const now = chips.map(c => c.n)
620    const before = await read($, present)
621    if (now.join() !== before.join()) {
622      await update($, present, () => now)
623      changed($)
624    }
625    if (!chips.length) return r
626    const paint = chips.map(c => ({ start: c.start, end: c.end, color: 'suggestion' as const, bold: true }))
627    return { ...r, decorations: [...(r.decorations ?? []), ...paint] }
628  })
629
630  on('prompt.submit', async ($, e, next) => {
631    const quoted = await takeQuotes($, e.text, e.origin)
632    return next(quoted.length ? { ...e, context: [...(e.context ?? []), ...quoted] } : e)
633  })
634
635  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
636    noteScreen($, e.requestId, e.props.onScreen)
637    const view = await viewOf($, e.requestId, 'assistant')
638    if (!view || !e.props.text.trim()) return next(e)
639    ui.claimed.delete(e.requestId)
640    const below = await next(e)
641    if (ui.claimed.has(e.requestId)) return below
642    const el = $.ui.resolve(e)
643    const mark = <el.Box width={2} flexShrink={0}><el.Text color="claude">{e.props.isFirstOfReply ? '●' : ' '}</el.Text></el.Box>
644    const drawn = await messageWithRows($, el, e.surface, e.props.text, Math.max(20, (e.viewport?.columns ?? 100) - 4), view, mark, !!e.viewport?.isFullscreen)
645    if (view.selection) Object.assign(ui, { inFlow: drawn.isInFlow, inFlowRows: view.isMoreOpen ? 2 : 1, inFlowSlot: drawn.slot })
646    if (view.selection && !drawn.hasBar) noRoomForBar($, e.requestId)
647    return drawn.tree
648  })
649
650  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
651    noteScreen($, e.requestId, e.props.onScreen)
652    ui.claimed.delete(e.requestId)
653    const below = await next(e)
654    return ui.claimed.has(e.requestId) ? below : underPrompt($, $.ui.resolve(e), e.requestId, below, !!e.viewport?.isFullscreen)
655  })
656
657  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
658    const selection = (await read($, selectedRow)) ? null : await read($, selected)
659    const loose = await visiblePins($)
660    if (e.props.hasSurvey || (!selection && !loose.length)) return next(e)
661    const el = $.ui.resolve(e)
662    const view: MarginaliaView = { selection, pins: loose, short: shortActions(ui.actions).map(a => ({ action: a, label: ACTION_LABELS[a] })), more: moreActions(ui.actions).map(a => ({ action: a, label: ACTION_LABELS[a] })), isMoreOpen: ui.more, open: [...ui.open], flash: ui.flash, ghosts: [] }
663    if (selection) ui.inFlow = false
664    const others = await next(e)
665    return (
666      <el.Box flexDirection="column">
667        {others}
668        {selection ? actionRow($, el, view) : null}
669        {loose.map(p => pinRow($, el, p, ui.open.has(p.n)))}
670      </el.Box>
671    )
672  })
673}
674
hooks/text.ts 122 lines
1import type { MarginaliaAction, MarginaliaPin as Pin } from '../types'
2
3const PREVIEW = 24
4const CHIP = /\[❝(\d+)[^\]\n]*\]/g
5
6export const preview = (text: string): string => {
7  const flat = text.replace(/\s+/g, ' ').replace(/[[\]]/g, '').trim()
8  return flat.length > PREVIEW ? `${flat.slice(0, PREVIEW).trimEnd()}…` : flat
9}
10
11export const chipOf = (pin: Pin): string => `[❝${pin.n} ${preview(pin.text)}]`
12
13export const chipsIn = (text: string): { n: number; start: number; end: number }[] =>
14  [...text.matchAll(CHIP)].map(m => ({ n: Number(m[1]), start: m.index, end: m.index + m[0].length }))
15
16export const withoutChip = (text: string, n: number): string =>
17  text.replace(CHIP, (chip, id) => (Number(id) === n ? '' : chip)).replace(/ {2,}/g, ' ')
18
19export const nextNumber = (pins: Pin[]): number => pins.reduce((max, p) => Math.max(max, p.n), 0) + 1
20
21const SOURCE = { assistant: 'assistant', user: 'user' } as const
22
23export const contextBlock = (pin: Pin): string =>
24  `The user quoted this passage and refers to it as ❝${pin.n} in their message.\n` +
25  `<quoted_text n="${pin.n}" source="${pin.role ? SOURCE[pin.role] : 'conversation'}">\n${pin.text}\n</quoted_text>`
26
27export const rememberPrompt = (text: string): string =>
28  `I like this passage and want you to remember it. Save it the way this project stores memories, then confirm in one line.\n\n${text
29    .split('\n')
30    .map(line => `> ${line}`)
31    .join('\n')}`
32
33export const wordCount = (text: string): string => {
34  const n = withoutOwnRows(text).split(/\s+/).filter(Boolean).length
35  return `${n} word${n === 1 ? '' : 's'} selected`
36}
37
38export const sourceOf = (pin: Pin): string =>
39  pin.role === 'user' ? 'your message' : pin.role === 'assistant' ? 'my reply' : 'the conversation'
40
41type QuoteAction = MarginaliaAction
42
43export const ACTION_LABELS: Record<QuoteAction, string> = {
44  quote: '❝ Quote',
45  remember: '♥ Remember',
46  task: '+ Task',
47  explain: '? Explain',
48  deeper: '↳ Deeper',
49  redo: '✎ Redo',
50  anki: '▤ Anki',
51  define: '≡ Define',
52  wrong: '✗ Wrong',
53  copy: '⧉ Copy clean',
54}
55
56const ALL_ACTIONS = Object.keys(ACTION_LABELS) as QuoteAction[]
57
58const DEFAULT_ACTIONS: QuoteAction[] = ['quote', 'remember', 'task']
59
60const isAction = (name: string): name is QuoteAction => name in ACTION_LABELS
61
62export const shortActions = (setting = ''): QuoteAction[] => {
63  const picked = [...new Set(setting.split(',').map(name => name.trim().toLowerCase()).filter(isAction))]
64  return picked.length ? picked : DEFAULT_ACTIONS
65}
66
67export const moreActions = (setting = ''): QuoteAction[] => {
68  const short = new Set(shortActions(setting))
69  return ALL_ACTIONS.filter(a => !short.has(a))
70}
71
72const asQuote = (text: string) => text.split('\n').map(line => `> ${line}`).join('\n')
73
74const ASK: Partial<Record<QuoteAction, string>> = {
75  explain: 'Explain this passage more simply, in a few lines:',
76  deeper: 'Go deeper on this passage, with one concrete example:',
77  redo: "This passage didn't click. Rewrite it another way:",
78  define: 'Define this in a few lines with one example sentence. If its language gives words a gender, include it:',
79  anki: 'Turn this passage into an Anki card the way this project makes cards, then confirm in one line:',
80  wrong: 'This passage looks wrong or unclear. Check it and fix it:',
81}
82
83export const askPrompt = (action: QuoteAction, text: string): string | undefined => {
84  const lead = ASK[action]
85  return lead ? `${lead}\n\n${asQuote(text)}` : undefined
86}
87
88export const cleanSelection = (text: string): string =>
89  text
90    .replace(/\[\s*⧉[^\]]*\]/g, '')
91    .replace(/Jump to bottom \(click\) ↓/g, '')
92    .split('\n')
93    .map(line => line.replace(/\s+$/, ''))
94    .join('\n')
95    .replace(/\n{3,}/g, '\n\n')
96    .trim()
97
98export const taskTitle = (text: string): string => {
99  const flat = cleanSelection(text).replace(/\s+/g, ' ')
100  return flat.length > 70 ? `${flat.slice(0, 70).trimEnd()}…` : flat
101}
102
103export const defineWords = (text: string): string[] =>
104  cleanSelection(text).replace(/[«»"“”.,;:!?()]/g, ' ').split(/\s+/).filter(Boolean).slice(0, 4)
105
106const BOX_ONLY = /^[\s─│╭╮╰╯┌┐└┘├┤┬┴┼]+$/
107const OWN_LINE = /^\[ ❝ Quote \]|\bwords? selected$|^❝\d+( pinned| quoted|\s+")|\[ ✕ un(pin|quote) \]$/
108const BAR_TAIL = /\s*\[ [❝≡♥+].*$/
109const BAR_ONLY = /^(?:\[ [^\]]*\]|⋯|\d+ words? selected|\s)+$/
110const LIST_MARKER = /^(?:[•◦*+-]|\d+\.)\s+/
111
112export const withoutOwnRows = (raw: string): string =>
113  raw
114    .split('\n')
115    .map(line => line.replace(BAR_TAIL, '').replace(/^[\s│╭╮╰╯─]+|[\s│╭╮╰╯─]+$/g, ''))
116    .filter(line => line && !OWN_LINE.test(line) && !BOX_ONLY.test(line) && !BAR_ONLY.test(line))
117    .map(line => line.replace(LIST_MARKER, ''))
118    .join('\n')
119    .trim()
120
121export const normalized = (text: string | null): string | null => (text ? withoutOwnRows(text).replace(/\s+/g, ' ').trim() || null : null)
122
hooks/blocks.ts 108 lines
1export type Chunk = { raw: string; isItem: boolean; isTight: boolean }
2
3const FENCE = /^\s*(`{3,}|~{3,})/
4const ITEM = /^([-*+]|\d+[.)])\s+/
5const HEADING = /^#{1,6}\s/
6const TABLE = /^\s*\|/
7
8const startsBlock = (line: string) => FENCE.test(line) || ITEM.test(line) || HEADING.test(line) || TABLE.test(line)
9
10export const splitBlocks = (text: string): Chunk[] => {
11  const lines = text.split('\n')
12  const chunks: Chunk[] = []
13  let i = 0
14  let afterItem = false
15  const push = (raw: string[], isItem: boolean) => {
16    chunks.push({ raw: raw.join('\n'), isItem, isTight: isItem && afterItem })
17    afterItem = isItem
18  }
19  while (i < lines.length) {
20    const line = lines[i]!
21    if (!line.trim()) {
22      i++
23      if (lines[i] !== undefined && !ITEM.test(lines[i]!)) afterItem = false
24      continue
25    }
26    const fence = FENCE.exec(line)
27    if (fence) {
28      const body = [line]
29      i++
30      while (i < lines.length && !lines[i]!.trim().startsWith(fence[1]!)) body.push(lines[i++]!)
31      if (i < lines.length) body.push(lines[i++]!)
32      push(body, false)
33      continue
34    }
35    if (TABLE.test(line)) {
36      const body: string[] = []
37      while (i < lines.length && TABLE.test(lines[i]!)) body.push(lines[i++]!)
38      push(body, false)
39      continue
40    }
41    if (HEADING.test(line)) {
42      push([line], false)
43      i++
44      continue
45    }
46    if (ITEM.test(line)) {
47      const body = [line]
48      i++
49      while (i < lines.length && lines[i]!.trim() && !ITEM.test(lines[i]!) && !(startsBlock(lines[i]!) && !/^\s/.test(lines[i]!))) body.push(lines[i++]!)
50      while (i < lines.length && /^\s+\S/.test(lines[i]!) ) body.push(lines[i++]!)
51      push(body, true)
52      continue
53    }
54    const body = [line]
55    i++
56    while (i < lines.length && lines[i]!.trim() && !startsBlock(lines[i]!)) body.push(lines[i++]!)
57    push(body, false)
58  }
59  return chunks
60}
61
62const forMatch = (text: string): string =>
63  text
64    .replace(/\[\s*⧉[^\]]*\]/g, ' ')
65    .replace(/[^\p{L}\p{N}]+/gu, ' ')
66    .trim()
67    .toLowerCase()
68
69const ends = (selection: string): (string | undefined)[] => {
70  const lines = selection.split('\n').map(line => forMatch(line.replace(/^\s*(\d+[.)]|[-*+•◦])\s+/, ''))).filter(line => line.length >= 3)
71  return [lines[0], lines.at(-1)].map(p => p?.slice(0, 40))
72}
73
74const probes = (selection: string): string[] => ends(selection).filter((p): p is string => !!p)
75
76export const chunkIndexOf = (raws: readonly string[], selection: string): number => {
77  const flat = raws.map(forMatch)
78  for (const probe of probes(selection)) {
79    const at = flat.findIndex(raw => raw.includes(probe))
80    if (at !== -1) return at
81  }
82  return -1
83}
84
85export type Span = { first: number; last: number }
86
87export const spanOf = (raws: readonly string[], selection: string, isLatest = false): Span | null => {
88  const flat = raws.map(forMatch)
89  const [head, tail] = ends(selection)
90  const find = (probe: string | undefined, from: number) => (probe ? flat.findIndex((raw, i) => i >= from && raw.includes(probe)) : -1)
91  const first = find(head, 0)
92  const after = first === -1 ? -1 : isLatest && tail ? flat.findLastIndex((raw, i) => i >= first && raw.includes(tail)) : find(tail, first)
93  const last = after === -1 ? find(tail, 0) : after
94  if (first === -1 && last === -1) return null
95  if (first === -1) return { first: last, last }
96  if (last === -1) return { first, last: first }
97  return { first: Math.min(first, last), last: Math.max(first, last) }
98}
99
100export const mergeSpans = <T>(spans: (Span & { items: T[] })[]): (Span & { items: T[] })[] =>
101  [...spans]
102    .sort((a, b) => a.first - b.first)
103    .reduce<(Span & { items: T[] })[]>((all, s) => {
104      const prev = all.at(-1)
105      if (!prev || s.first > prev.last) return [...all, s]
106      return [...all.slice(0, -1), { first: prev.first, last: Math.max(prev.last, s.last), items: [...prev.items, ...s.items] }]
107    }, [])
108
hooks/nav.ts 54 lines
1export type NavRole = 'user' | 'assistant'
2export type NavTarget = { id: string; role: NavRole; at: number }
3export type NavMap = { order: Map<string, number>; targets: NavTarget[] }
4
5export const stemOf = (id: string): string => id.slice(0, 23)
6
7export const renderIdsOf = (id: string): string[] => (/^[0-9a-f-]{36}$/.test(id) ? [id, `${stemOf(id)}-000000000000`] : [id])
8
9type Block = { type?: string; text?: string }
10type Content = string | Block[] | undefined
11type Row = { uuid?: string; type?: string; isMeta?: boolean; isSidechain?: boolean; message?: { content?: Content }; attachment?: { type?: string } }
12
13const isTyped = (content: Content): boolean => {
14  if (typeof content === 'string') return !!content.trim() && !content.trimStart().startsWith('<')
15  if (!Array.isArray(content) || content.some(b => b.type === 'tool_result')) return false
16  return content.some(b => b.type === 'text' && !!b.text?.trim() && !b.text.trimStart().startsWith('<'))
17}
18
19const hasText = (content: Content): boolean =>
20  Array.isArray(content) ? content.some(b => b.type === 'text' && !!b.text?.trim()) : typeof content === 'string' && !!content.trim()
21
22export const navMap = (jsonl: string): NavMap => {
23  const order = new Map<string, number>()
24  const targets: NavTarget[] = []
25  let isInReply = false
26  for (const line of jsonl.split('\n')) {
27    if (!line.trim()) continue
28    let row: Row
29    try {
30      row = JSON.parse(line) as Row
31    } catch {
32      continue
33    }
34    if (!row.uuid || row.isSidechain || order.has(stemOf(row.uuid))) continue
35    const at = order.size
36    order.set(stemOf(row.uuid), at)
37    const isQueued = row.type === 'attachment' && row.attachment?.type === 'queued_command'
38    if (isQueued || (row.type === 'user' && !row.isMeta && isTyped(row.message?.content))) {
39      targets.push({ id: row.uuid, role: 'user', at })
40      isInReply = false
41    } else if (row.type === 'assistant' && !isInReply && hasText(row.message?.content)) {
42      targets.push({ id: row.uuid, role: 'assistant', at })
43      isInReply = true
44    }
45  }
46  return { order, targets }
47}
48
49export const candidates = (map: NavMap, role: NavRole, dir: 1 | -1, at: number | null): NavTarget[] => {
50  const mine = map.targets.filter(t => t.role === role)
51  if (at === null) return dir === 1 ? [] : [...mine].reverse()
52  return dir === 1 ? mine.filter(t => t.at > at) : mine.filter(t => t.at < at).reverse()
53}
54
hooks/themes.ts 11 lines
1import type { RenderElement, RenderSurface } from 'claude-code'
2
3export type ThemeMarkdownArgs = { surface: RenderSurface; text: string; columns: number }
4
5declare module 'claude-code' {
6  interface EngineInterface {
7    prismantis: { markdown: (args: ThemeMarkdownArgs) => Promise<RenderElement | undefined> }
8    skins: { markdown: (args: ThemeMarkdownArgs) => Promise<RenderElement | undefined> }
9  }
10}
11
types/index.d.ts 47 lines
1export type MarginaliaAction = 'quote' | 'remember' | 'task' | 'explain' | 'deeper' | 'redo' | 'anki' | 'define' | 'wrong' | 'copy'
2
3export type MarginaliaPin = { n: number; text: string; requestId?: string; role?: 'user' | 'assistant' }
4
5export type MarginaliaButton = { action: MarginaliaAction; label: string }
6
7export type MarginaliaGhost = { slot: string; rows: number }
8
9export type MarginaliaView = {
10  selection: string | null
11  pins: MarginaliaPin[]
12  short: MarginaliaButton[]
13  more: MarginaliaButton[]
14  isMoreOpen: boolean
15  open: number[]
16  flash: number | null
17  ghosts: MarginaliaGhost[]
18}
19
20export type MarginaliaPress =
21  | { action: MarginaliaAction }
22  | { unpin: number }
23  | { toggle: number }
24  | { more: true }
25  | { close: true }
26
27export type Marginalia = {
28  view: (args: { requestId: string; role?: 'user' | 'assistant' }) => Promise<MarginaliaView | undefined>
29  press: (args: MarginaliaPress) => Promise<boolean>
30  version: () => Promise<number>
31  placed: (args: { inFlow: boolean; rows?: number; slot?: string }) => Promise<boolean>
32}
33
34declare module 'claude-code' {
35  interface EngineInterface {
36    marginalia: Marginalia
37  }
38  interface PluginState {
39    marginalia: {
40      pending: MarginaliaPin[]
41      present: number[]
42      selected: string | null
43      row: string | null
44    }
45  }
46}
47