A bookmark button on each reply; click a mark above the prompt to scroll back to it

A Claude Code mod for starring paragraphs in Claude's replies and keeping them in a side pane.
/bookmarks opens the pane.In the terminal, clicking a favorite scrolls the chat back to it. The desktop Code tab doesn't let mods scroll the chat, so there the card opens in place instead.
Point Claude Code at this folder, for every session, in ~/.claude/settings.json:
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "C:\\path\\to\\chat-bookmarks"
}
Or for one terminal session:
claude --plugin-dir /path/to/chat-bookmarks
claude plugin validate .
claude plugin test .hooks/register.tsx 403 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Mark } from '../types'
5import { KIND_LABEL, kindOf, paragraphsOf, segmentsOf } from './paragraphs'
6import type { Kind } from './paragraphs'
7
8const PANE = 'chat-bookmarks'
9const TITLE = 'Favorites'
10const marks = atom({ plugin: 'chat-bookmarks', key: 'marks' } as const, [] as Mark[])
11const openKeys = atom({ plugin: 'chat-bookmarks', key: 'openKeys' } as const, [] as string[])
12const query = atom({ plugin: 'chat-bookmarks', key: 'query' } as const, '')
13const kindFilter = atom({ plugin: 'chat-bookmarks', key: 'kind' } as const, 'all')
14const editing = atom({ plugin: 'chat-bookmarks', key: 'editing' } as const, null as string | null)
15
16// Tags get a steady color each, picked from the theme by the tag's name.
17const TAG_COLORS = ['suggestion', 'success', 'warning', 'merged', 'planMode', 'autoAccept', 'remember', 'ide']
18const tagColor = (tag: string) =>
19 TAG_COLORS[[...tag].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7) % TAG_COLORS.length] ?? 'suggestion'
20
21// "auth, Fix later #ui" -> ["auth", "fix-later", "ui"]
22const parseTags = (value: string) => [
23 ...new Set(
24 value
25 .split(/[,#]/)
26 .map(t => t.trim().toLowerCase().replace(/\s+/g, '-'))
27 .filter(Boolean),
28 ),
29]
30
31// Favorites are kept on disk per session, so reopening a session brings them back.
32const SESSIONS = 'sessions'
33const MAX_SESSIONS = 50
34const storeKey = (sid: string) => `marks:${sid}`
35
36const clip = (line: string) => (line.length > 40 ? `${line.slice(0, 40)}…` : line)
37const plain = (text: string) => text.replace(/[#*`>_~|]/g, '').replace(/\s+/g, ' ').trim().toLowerCase()
38
39// A code mark is labelled by its first line of code; prose by its words.
40const snippetOf = (text: string) => {
41 const parts = segmentsOf(text)
42 const code = parts.find(p => p.kind === 'code')
43 if (code && parts.every(p => p.kind === 'code' || !p.text.trim())) {
44 const first = code.source.split('\n').find(l => l.trim()) ?? ''
45 return clip(first.trim())
46 }
47 return clip(text.replace(/[#*`>_]/g, '').replace(/\s+/g, ' ').trim())
48}
49
50// Writes the list and saves it under this session; the oldest sessions' lists give way.
51const saveMarks = async ($: EngineInterface, fn: (list: Mark[]) => Mark[]) => {
52 await update($, marks, fn)
53 try {
54 const sid = await $.session.id()
55 const list = await read($, marks)
56 const known = ((await $.store.get(SESSIONS)) as string[] | undefined) ?? []
57 const sessions = [...known.filter(s => s !== sid), sid]
58 for (const old of sessions.splice(0, Math.max(0, sessions.length - MAX_SESSIONS))) {
59 await $.store.delete(storeKey(old))
60 }
61 await $.store.set(storeKey(sid), list)
62 await $.store.set(SESSIONS, sessions)
63 } catch {
64 // Saving is best effort; the session's own copy stands.
65 }
66}
67
68// Scrolls back to the paragraph where the surface allows it (the terminal);
69// where it doesn't (the desktop Code tab), the pane shows the text instead.
70const jump = async ($: EngineInterface, m: Mark) => {
71 try {
72 const exact = await $.ui.scroll({ to: { key: m.key }, block: 'start' })
73 if (!exact.deny) return true
74 const reply = await $.ui.scroll({ to: { requestId: m.id }, block: 'start' })
75 return !reply.deny
76 } catch {
77 return false
78 }
79}
80
81export const register: Register = on => {
82 on('session.start', async ($, e, next) => {
83 try {
84 await $.command.register({ name: 'bookmarks', description: 'Open the favorites pane' })
85 } catch (err) {
86 $.ui.toast(`favorites: /bookmarks not registered: ${String(err)}`)
87 }
88 try {
89 const saved = await $.store.get(storeKey(await $.session.id()))
90 if (Array.isArray(saved) && saved.length > 0 && (await read($, marks)).length === 0) {
91 await update($, marks, () => saved as Mark[])
92 }
93 } catch {
94 // Nothing saved for this session.
95 }
96 return next(e)
97 })
98
99 on('command.run', { command: 'bookmarks' }, async $ => {
100 await $.ui.open({ id: PANE, title: TITLE })
101 return { text: 'Favorites pane opened.' }
102 })
103
104 // Each reply, paragraph by paragraph; pointing at one reveals a ☆ at its right edge.
105 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
106 if (e.props.isSummary) {
107 return next(e)
108 }
109
110 const { Box, Button, Markdown } = $.ui.resolve(e)
111 const id = e.requestId
112 const marked = new Set((await read($, marks)).filter(m => m.id === id).map(m => m.key))
113
114 return (
115 <Box flexDirection="column">
116 {paragraphsOf(e.props.text).map((para, i) => {
117 const key = `p:${id}:${i}`
118 const isMarked = marked.has(key)
119 return (
120 // A keyed row: the hover scope its toggle is revealed under.
121 <Box key={key} position="relative" paddingRight={3}>
122 <Markdown text={para} />
123 <Box
124 position="absolute"
125 top={0}
126 right={0}
127 {...(isMarked ? {} : { display: 'none' as const, hover: { display: 'flex' as const } })}
128 >
129 <Button
130 key={`mark:${i}`}
131 plain
132 dimColor={!isMarked}
133 label={isMarked ? '★' : '☆'}
134 onPress={async () => {
135 await saveMarks($, list =>
136 list.some(m => m.key === key)
137 ? list.filter(m => m.key !== key)
138 : [...list, { id, key, snippet: snippetOf(para), text: para }],
139 )
140 if (!isMarked) await $.ui.open({ id: PANE, title: TITLE })
141 }}
142 />
143 </Box>
144 </Box>
145 )
146 })}
147 </Box>
148 )
149 })
150
151 // The side pane: toolbar, search and type filters, then one card per favorite.
152 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
153 const { Box, Button, Code, Markdown, Text } = $.ui.resolve(e)
154 const list = await read($, marks)
155 const editingKey = await read($, editing)
156 const shown = new Set(await read($, openKeys))
157 const rawQuery = await read($, query)
158 const q = plain(rawQuery)
159 const kind = await read($, kindFilter)
160
161 const kinds = [...new Set(list.map(m => kindOf(m.text)))]
162 const tags = [...new Set(list.flatMap(m => m.tags ?? []))].sort()
163 const visible = list
164 .map((m, i) => ({ m, n: i + 1, kind: kindOf(m.text) }))
165 .filter(v =>
166 kind === 'all' ? true : kind.startsWith('#') ? (v.m.tags ?? []).includes(kind.slice(1)) : v.kind === kind,
167 )
168 .filter(v => !q || plain(`${v.m.text} ${(v.m.tags ?? []).join(' ')}`).includes(q))
169 const isAllOpen = visible.length > 0 && visible.every(v => shown.has(v.m.key))
170
171 if (list.length === 0) {
172 return (
173 <Box flexDirection="column" alignItems="center" paddingY={1}>
174 <Text bold>No favorites yet</Text>
175 <Text dimColor>Point at a paragraph in a reply and</Text>
176 <Text dimColor>click the ☆ at its right edge.</Text>
177 </Box>
178 )
179 }
180
181 const codeBox = (key: string, language: string, source: string) =>
182 e.surface === 'terminal' ? (
183 <Box key={`code:${key}`} flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
184 <Box flexDirection="row" justifyContent="space-between">
185 <Text dimColor>{language || 'code'}</Text>
186 <Button
187 key={`copy:${key}`}
188 plain
189 dimColor
190 label="Copy"
191 onPress={async press => {
192 const r = await $.ui.copy({ text: source, surface: press.surface })
193 $.ui.toast(r.isCopied ? 'Code copied' : `Couldn't copy: ${r.reason}`)
194 }}
195 />
196 </Box>
197 <Code source={source} language={language || undefined} startLine={1} />
198 </Box>
199 ) : (
200 // Other surfaces draw their own code card, copy icon included.
201 <Box key={`code:${key}`} flexDirection="column">
202 <Code source={source} language={language || undefined} startLine={1} />
203 </Box>
204 )
205
206 // Comma- or #-separated tags, added to the card's own.
207 const addTags = async (m: Mark, value: string) => {
208 const added = parseTags(value)
209 if (added.length) {
210 await saveMarks($, l =>
211 l.map(x => (x.key === m.key ? { ...x, tags: [...new Set([...(x.tags ?? []), ...added])] } : x)),
212 )
213 }
214 await update($, editing, () => null)
215 }
216
217 const tagInput = (m: Mark) => {
218 if (e.surface === 'mobile') return null
219 const { Input } = $.ui.resolve(e)
220 return (
221 <Box key={`tag-edit:${m.key}`} flexGrow={1} flexShrink={1} minWidth={12}>
222 <Input
223 key={`tag-input:${m.key}`}
224 placeholder="add tags, comma separated"
225 submitLabel="Save"
226 autoFocus
227 onSubmit={value => addTags(m, value)}
228 />
229 </Box>
230 )
231 }
232
233 const search =
234 e.surface === 'mobile'
235 ? null
236 : (() => {
237 const { Input } = $.ui.resolve(e)
238 return (
239 <Input
240 key="search"
241 placeholder="Search favorites…"
242 value={rawQuery}
243 onInput={value => update($, query, () => value)}
244 onSubmit={value => update($, query, () => value)}
245 />
246 )
247 })()
248
249 return (
250 <Box flexDirection="column" gap={1}>
251 <Box flexDirection="row" justifyContent="space-between" alignItems="center">
252 <Text bold>
253 ★ {list.length} {list.length === 1 ? 'favorite' : 'favorites'}
254 </Text>
255 <Box flexDirection="row" gap={2}>
256 <Button
257 key="toggle-all"
258 plain
259 label={isAllOpen ? 'Collapse all' : 'Expand all'}
260 onPress={() =>
261 update($, openKeys, ks =>
262 isAllOpen
263 ? ks.filter(k => !visible.some(v => v.m.key === k))
264 : [...new Set([...ks, ...visible.map(v => v.m.key)])],
265 )
266 }
267 />
268 <Button
269 key="clear"
270 plain
271 label="Delete all"
272 onPress={async () => {
273 await saveMarks($, () => [])
274 await update($, openKeys, () => [])
275 }}
276 />
277 </Box>
278 </Box>
279
280 {search}
281
282 {(kinds.length > 1 || tags.length > 0) && (
283 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
284 {['all', ...(kinds.length > 1 ? kinds : []), ...tags.map(t => `#${t}`)].map(k => (
285 <Button
286 key={`kind:${k}`}
287 plain
288 dimColor={kind !== k}
289 label={`${kind === k ? '● ' : ''}${
290 k === 'all' ? 'All' : k.startsWith('#') ? k : KIND_LABEL[k as Kind]
291 }`}
292 onPress={() => update($, kindFilter, () => k)}
293 />
294 ))}
295 </Box>
296 )}
297
298 {visible.length === 0 && <Text dimColor>No favorites match.</Text>}
299
300 {visible.map(({ m, n, kind: k }) => {
301 const isOpen = shown.has(m.key)
302 const parts = segmentsOf(m.text)
303 const code = parts.find(p => p.kind === 'code')
304 const language = code && code.kind === 'code' ? code.language || (k === 'code' ? 'code' : '') : ''
305
306 return (
307 <Box
308 key={`item:${m.key}`}
309 flexDirection="column"
310 borderStyle="round"
311 borderDimColor={!isOpen}
312 hover={{ scope: `card:${m.key}`, borderDimColor: false }}
313 paddingX={1}
314 >
315 <Box flexDirection="row" justifyContent="space-between" gap={1}>
316 <Box flexShrink={1} flexGrow={1}>
317 <Button
318 key={`jump:${m.key}`}
319 plain
320 label={`${isOpen ? '▾' : '▸'} ${n}. ${m.snippet.replace(/^\[[^\]]*\] /, '')}`}
321 onPress={async () => {
322 if (!isOpen && (await jump($, m))) return
323 await update($, openKeys, ks =>
324 ks.includes(m.key) ? ks.filter(x => x !== m.key) : [...ks, m.key],
325 )
326 }}
327 />
328 </Box>
329 <Box flexDirection="row" gap={1} flexShrink={0}>
330 {language && <Text dimColor>{language}</Text>}
331 <Button
332 key={`del:${m.key}`}
333 plain
334 dimColor
335 label="✕"
336 onPress={async () => {
337 await saveMarks($, l => l.filter(x => x.key !== m.key))
338 await update($, openKeys, ks => ks.filter(x => x !== m.key))
339 }}
340 />
341 </Box>
342 </Box>
343
344 {isOpen && (
345 <Box key={`text:${m.key}`} flexDirection="column" marginTop={1}>
346 {parts.map((part, j) =>
347 part.kind === 'text' ? (
348 <Markdown key={`md:${m.key}:${j}`} text={part.text} />
349 ) : (
350 codeBox(`${m.key}:${j}`, part.language, part.source)
351 ),
352 )}
353 </Box>
354 )}
355
356 {isOpen && (m.tags ?? []).length > 0 && (
357 <Box flexDirection="row" flexWrap="wrap" columnGap={1} marginTop={1}>
358 {(m.tags ?? []).map(t => (
359 <Box key={`chip:${m.key}:${t}`} flexDirection="row" flexShrink={0}>
360 <Text color={tagColor(t)}>#{t}</Text>
361 <Button
362 key={`untag:${m.key}:${t}`}
363 plain
364 dimColor
365 label="×"
366 onPress={() =>
367 saveMarks($, l =>
368 l.map(x => (x.key === m.key ? { ...x, tags: (x.tags ?? []).filter(y => y !== t) } : x)),
369 )
370 }
371 />
372 </Box>
373 ))}
374 </Box>
375 )}
376 {isOpen && (
377 <Box
378 flexDirection="row"
379 alignItems="center"
380 columnGap={1}
381 marginTop={(m.tags ?? []).length === 0 ? 1 : 0}
382 >
383 {editingKey === m.key ? (
384 tagInput(m)
385 ) : (
386 <Button
387 key={`tags:${m.key}`}
388 plain
389 dimColor
390 label="Add tag"
391 onPress={() => update($, editing, () => m.key)}
392 />
393 )}
394 </Box>
395 )}
396 </Box>
397 )
398 })}
399 </Box>
400 )
401 })
402}
403hooks/paragraphs.ts 78 lines1// Splits a reply into paragraphs on blank lines, keeping code fences whole.
2export const paragraphsOf = (text: string): string[] => {
3 const out: string[] = []
4 let current: string[] = []
5 let inFence = false
6 for (const line of text.split('\n')) {
7 if (/^\s*(```|~~~)/.test(line)) inFence = !inFence
8 if (!inFence && line.trim() === '') {
9 if (current.length) out.push(current.join('\n'))
10 current = []
11 } else {
12 current.push(line)
13 }
14 }
15 if (current.length) out.push(current.join('\n'))
16 return out
17}
18
19export type Segment =
20 | { kind: 'text'; text: string }
21 | { kind: 'code'; language: string; source: string }
22
23// Splits a paragraph into its prose and its fenced code blocks, in order.
24export const segmentsOf = (text: string): Segment[] => {
25 const out: Segment[] = []
26 let prose: string[] = []
27 let code: string[] | null = null
28 let language = ''
29 for (const line of text.split('\n')) {
30 const fence = /^\s*(```|~~~)\s*([\w+#.-]*)/.exec(line)
31 if (fence && code === null) {
32 if (prose.length) out.push({ kind: 'text', text: prose.join('\n') })
33 prose = []
34 code = []
35 language = fence[2] ?? ''
36 } else if (fence && code !== null) {
37 out.push({ kind: 'code', language, source: code.join('\n') })
38 code = null
39 } else if (code !== null) {
40 code.push(line)
41 } else {
42 prose.push(line)
43 }
44 }
45 if (code !== null) out.push({ kind: 'code', language, source: code.join('\n') })
46 if (prose.length) out.push({ kind: 'text', text: prose.join('\n') })
47 return out
48}
49
50export type Kind = 'code' | 'list' | 'table' | 'quote' | 'heading' | 'text'
51
52export const KIND_LABEL: Record<Kind, string> = {
53 code: 'Code',
54 list: 'List',
55 table: 'Table',
56 quote: 'Quote',
57 heading: 'Heading',
58 text: 'Text',
59}
60
61// What a paragraph mostly is, read off its first non-empty line.
62export const kindOf = (text: string): Kind => {
63 const first = text.split('\n').find(l => l.trim())?.trim() ?? ''
64 if (/^(```|~~~)/.test(first)) return 'code'
65 if (/^([-*+]|\d+[.)])\s/.test(first)) return 'list'
66 if (/^\|/.test(first)) return 'table'
67 if (/^>/.test(first)) return 'quote'
68 if (/^#{1,6}\s/.test(first)) return 'heading'
69 return segmentsOf(text).some(p => p.kind === 'code') ? 'code' : 'text'
70}
71
72// The paragraph as a markdown quote, ready to drop into the prompt.
73export const quoteOf = (text: string) =>
74 `${text
75 .split('\n')
76 .map(l => (l ? `> ${l}` : '>'))
77 .join('\n')}\n\n`
78types/index.d.ts 8 lines1export type Mark = { id: string; key: string; snippet: string; text: string; tags?: string[] }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'chat-bookmarks': { marks: Mark[]; openKeys: string[]; query: string; kind: string; editing: string | null }
6 }
7}
8