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…

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" />
Select text with the mouse anywhere in the transcript. A moment after you let go, a margin bar appears under the selected line:
| Button | What happens |
|---|---|
❝ Quote | Puts 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. |
♥ Remember | Asks Claude to remember the passage. |
+ Task | Creates 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 action | What happens |
|---|---|
? Explain | Asks Claude to explain the passage more simply. |
↳ Deeper | Asks Claude to go deeper, with one concrete example. |
✎ Redo | Asks Claude to rewrite the passage another way. |
▤ Anki | Asks Claude to turn the passage into an Anki card, the way the project makes cards. |
≡ Define | Defines the selected words (see MARGINALIA_DEFINE_CMD below). |
✗ Wrong | Tells Claude the passage looks wrong or unclear and asks it to check and fix it. |
⧉ Copy clean | Copies 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.
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.
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
Both are environment variables. Set them in your shell, or per project in .claude/settings.json under "env".
| Variable | Effect |
|---|---|
MARGINALIA_ACTIONS | Buttons 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_CMD | A 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" } }
/q quotes the current selection into the prompt./remember-this sends the selection to Remember.Terminal selections are tied to screen rows, so anything that moves text while you select changes what is selected. Marginalia avoids moving text:
✕, when you type, press a button or start a new selection, or by itself 15 seconds after it appears.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.
hooks/register.tsx 674 lines1import { 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}
674hooks/text.ts 122 lines1import 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)
122hooks/blocks.ts 108 lines1export 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 }, [])
108hooks/nav.ts 54 lines1export 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}
54hooks/themes.ts 11 lines1import 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}
11types/index.d.ts 47 lines1export 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