Bookmark transcript messages or selected text and jump back to them from a side pane

Bookmark any part of a Claude Code conversation and jump straight back to it.
In a long session (lots of prompts, replies and code edits), finding something said an hour ago means scrolling up through the transcript for a long time. This mod gives you bookmarks, as in a browser or an e-book reader:
You can keep as many bookmarks as you like. Each one has its own Delete button, and they're saved with the session, so they come back when you resume it.
┌─ conversation ──────────────────────────────────┐ ┌─ Bookmarks ───────────────────────┐
│ │ │ [+ Bookmark selection] [Clear all]│
│ > how should we cache the API responses? │ │ │
│ │ │ claude · 14:32 │
│ ⏺ Two options: an in-memory LRU, or… ★ │ │ Two options: an in-memory LRU, or │
│ │ │ a Redis layer in front of… │
│ ⏺ Edit(src/api/cache.ts) 📌 │ │ [ Go to ] [ Delete ] │
│ └ Added 24 lines │ │ │
│ │ │ auth idea · selection · 15:07 │
│ │ │ refresh the token 60s before it │
│ │ │ expires, not after the 401 │
│ │ │ [ Go to ] [ Delete ] │
└─────────────────────────────────────────────────┘ └───────────────────────────────────┘
📌 appears when you hover a message · ★ marks the ones already bookmarked
| Bookmark a whole message | Hover any message (your prompt, Claude's reply, or a tool/edit row like Edit(src/x.ts)) and click the 📌 that shows up at its top right. |
| Bookmark just some text | Select a few lines with the mouse, then click + Bookmark selection in the pane, or type /bookmark. Only the text you selected is kept. |
| Give it a name | /bookmark auth idea saves the selection under the label auth idea. |
| Jump back | Click Go to on a bookmark and the conversation scrolls so that message sits at the top. |
| See what's bookmarked | Bookmarked messages keep a small ★ at their top right. |
| Delete | Each bookmark has its own Delete button. Clear all removes every bookmark in the session. |
| Keep them | Bookmarks are saved with the session and come back on claude --resume / claude --continue. |
"tui": "fullscreen" to ~/.claude/settings.json, orCLAUDE_CODE_NO_FLICKER=1 claude.Inside tmux, Claude Code uses the main screen by default, so you need one of the two settings above there too.
This mod lives in arcadeJHS/claude-code-mods. That repository is a plugin marketplace: it has a .claude-plugin/marketplace.json at its root that lists transcript-bookmarks. In a Claude Code session, type:
/plugin install transcript-bookmarks --marketplace arcadeJHS/claude-code-mods
Then:
y when asked to add the marketplace,You should see ✓ Installed transcript-bookmarks. Plugin is now active. and can start bookmarking right away.
Clone the repository, then add it as a marketplace and install from it:
git clone https://github.com/arcadeJHS/claude-code-mods.git
claude plugin marketplace add ./claude-code-mods # the folder holding .claude-plugin/marketplace.json
claude plugin install transcript-bookmarks@matteo-mods
A plugin installed this way runs from that folder, not from a copy. After you edit its files, run /reload-plugins in your session to load the changes.
claude --plugin-dir ./claude-code-mods/transcript-bookmarks
This loads it for that session only, and reloads it whenever you save a file in the folder.
Move the mouse over any message in the conversation. A 📌 appears at the right end of its first line. Click it.
Clicking ★ on a message that's already bookmarked doesn't add it twice. It just brings the pane up.
Sometimes one sentence matters more than the whole reply.
/bookmark, or /bookmark some label to name it.The bookmark shows the selected text and is labelled … · selection. You can do this even while Claude is still answering.
If your selection spans several messages, the bookmark is still saved (you keep the text), but it has no single message to jump to, so Go to just tells you so.
Click Go to on any bookmark. The conversation scrolls until that message is at the top of the screen.
/bookmarks opens the pane at any time.1. Find a decision again. Early in a session you ask "how should we cache the API responses?" and Claude compares an in-memory LRU with a Redis layer. You pick one and keep working. Before moving on, hover Claude's reply and click 📌. Two hours and fifty messages later you want to re-read the trade-offs: click Go to on the claude bookmark, and the transcript scrolls straight back to that reply.
2. Keep a command you'll need later. Claude gives you the exact command to run the database migration, but you won't run it until the end of the day. Select just the command with the mouse and type /bookmark migration command. The pane now shows migration command · selection with the command underneath, ready to copy, and Go to takes you to the reply it came from.
3. Turn a long review into a to-do list. You ask Claude to review a pull request and get back a dozen points. Select each point you want to act on and click + Bookmark selection. Each one becomes an entry in the pane. Work through them, using Go to for the full context, and Delete each one once it's fixed. When the pane is empty, you're done.
4. Get back to a specific edit. Claude edited several files in one go, and later you want to look at the change to src/api/cache.ts again. Hover the Edit(src/api/cache.ts) row and click 📌. The bookmark is titled Edit(api/cache.ts), and Go to brings that edit's diff back on screen.
| Command | What it does |
|---|---|
/bookmarks | Opens the Bookmarks pane. |
/bookmark [label] | Bookmarks the text currently selected with the mouse, optionally under label. |
Both commands work immediately, even while Claude is in the middle of a turn.
claude --resume / --continue brings back that session's bookmarks./clear starts fresh. It begins a new conversation, so the list empties. The old conversation keeps its bookmarks for when you resume it.Nothing is sent anywhere, and the model never reads your bookmarks. They only change what you see on screen. Privacy has the details.
Transcript Bookmarks works entirely on your computer.
What it reads
/bookmark).you, claude, or a tool with its file or command, such as Edit(api/cache.ts). It keeps only these short labels, for the last 500 messages drawn, in memory. They're never saved and are gone when you quit Claude Code.It doesn't read your files, your project, your other conversations, or any message you haven't bookmarked.
What it stores
What it sends
Every hook and call it makes
| Hook | What it does |
|---|---|
session.start | Adds the /bookmarks and /bookmark commands, loads this session's saved bookmarks, and opens the pane if there are any. |
session.end | After /clear or /resume, empties the list on screen. The old session's bookmarks stay saved. |
prompt.submit | Loads the current session's saved bookmarks if the session changed (after /clear or /resume). It doesn't read the prompt, and passes it on unchanged. |
command.run | Answers only its own two commands, /bookmarks and /bookmark. |
ui.render | Draws the Bookmarks pane, and adds the 📌 / ★ button to each message row while keeping Claude Code's own drawing of the row. |
| Call | What it does |
|---|---|
$.ui.selection() | Reads the text you last selected with the mouse, and which message it's in. Called only when you bookmark a selection. |
$.session.id() | Gets the current session's id, used only as the key your bookmarks are filed under in local storage. |
$.store.get / set / delete | Reads and writes the plugin's local storage on your machine. |
$.state.get / set | Keeps the list in memory for the session, so the pane redraws when it changes. |
$.ui.open, toast, resolve, scroll, copy | Opens the pane, shows short notes, draws, scrolls the transcript, and copies a bookmark's text to your clipboard. |
$.clock.now() | Reads the time shown on each bookmark. |
$.command.register | Adds the /bookmarks and /bookmark commands. |
None of these sends anything off your machine.
The source is all in hooks/register.tsx, unminified, if you want to check.
I don't see the 📌 when I hover a message. You're probably on the main screen rather than fullscreen. See Requirements. Inside tmux, fullscreen is off by default.
/bookmark says "Nothing is selected". Select the text with the mouse first, then run the command. The selection is remembered until you select something else or run your next prompt.
"Can't scroll to that message (…). Its text is on the clipboard." The message is no longer part of the drawn conversation, for example after /compact. The mod copies the bookmarked text to your clipboard so you can still use it.
The pane opens above the prompt instead of on the side. The window is narrower than about 110 columns. Widen it and the pane docks beside the conversation.
Nothing happens at all. Check that the mod is loaded with claude plugin list, or start with claude --debug. If a hook fails, Claude Code writes a line naming the transcript-bookmarks plugin and the reason.
/compact, older messages are summarised, so their bookmarks can no longer jump (you get the text on the clipboard instead).transcript-bookmarks/
├── .claude-plugin/
│ └── plugin.json # name, version, description, type contract
├── hooks/
│ ├── hooks.json # points Claude Code at register.tsx
│ └── register.tsx # the whole mod: hover marks, pane, commands, storage
├── types/
│ └── index.d.ts # the shape of the state the mod keeps
├── tests/
│ └── transcript-bookmarks.test.tsx # runs against Claude Code's own engine
├── tsconfig.json # extends the types Claude Code writes on load
└── README.md
How it works, briefly
AssistantMessage, UserMessage, ToolUse). It keeps Claude Code's own drawing of the row and adds a small hover-revealed button on top, which doesn't shift the layout.$.ui.selection() gives the selected text and the message it belongs to.$.ui.scroll({ to: { requestId } }) scrolls the conversation to a message.Check, test, run
claude plugin validate ./transcript-bookmarks # manifest + hooks, the way Claude Code reads them
claude plugin test ./transcript-bookmarks # the tests in tests/
claude --plugin-dir ./transcript-bookmarks # a live session that reloads on save
tsc -p ./transcript-bookmarks # type-check (after Claude Code has loaded the mod once)
.claude-plugin/types/ is written by Claude Code each time it loads the mod (it holds the API's type declarations for your editor). It is git-ignored.
claude --version), whether you run fullscreen mode, and the steps that show the problem.WTFPL: Do What The Fuck You Want To Public License, version 2. Copy it, change it, ship it, do whatever you want with it.
hooks/register.tsx 388 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type {
3 ElementTable,
4 EngineInterface,
5 Register,
6 RenderElement,
7 RenderSurface,
8 RenderViewport,
9} from 'claude-code'
10
11import type { Bookmark, BookmarkKind } from '../types'
12
13const PANE = 'bookmarks'
14const TITLE = 'Bookmarks'
15const STORE_PREFIX = 'bookmarks:'
16const STORE_INDEX = 'bookmarks-index'
17const MAX_BOOKMARKS = 200
18const MAX_SESSIONS = 50
19const MAX_TEXT = 2000
20const MAX_TITLES = 500
21const PREVIEW_LINES = 3
22
23const list = atom({ plugin: 'transcript-bookmarks', key: 'list' } as const, [])
24const loadedFor = atom({ plugin: 'transcript-bookmarks', key: 'loadedFor' } as const, '')
25const marked = atom({ plugin: 'transcript-bookmarks', key: 'marked' } as const, false)
26
27type MessageKind = Exclude<BookmarkKind, 'selection'>
28type RowTitle = { kind: MessageKind; title: string }
29type Row = RowTitle & { text: string }
30type NewBookmark = Omit<Bookmark, 'id' | 'createdAt'>
31
32// Who wrote each drawn transcript row, so a selection (which only names its
33// row) can be titled. Titles only: a row's text is kept just when its 📌 is
34// pressed. A cache: a reload redraws the rows and refills it.
35const titles = new Map<string, RowTitle>()
36
37function remember(requestId: string, { kind, title }: RowTitle) {
38 titles.delete(requestId)
39 titles.set(requestId, { kind, title })
40 if (titles.size > MAX_TITLES) {
41 const oldest = titles.keys().next().value
42 if (oldest !== undefined) titles.delete(oldest)
43 }
44}
45
46const clip = (text: string, max: number) =>
47 text.length > max ? `${text.slice(0, max - 1)}…` : text
48
49const isRecord = (value: unknown): value is Record<string, unknown> =>
50 typeof value === 'object' && value !== null
51
52function asBookmarks(value: unknown): Bookmark[] {
53 if (!Array.isArray(value)) return []
54 return value.filter(
55 (b): b is Bookmark =>
56 isRecord(b) &&
57 typeof b.id === 'string' &&
58 typeof b.title === 'string' &&
59 typeof b.excerpt === 'string' &&
60 typeof b.createdAt === 'number',
61 )
62}
63
64const asStrings = (value: unknown): string[] =>
65 Array.isArray(value) ? value.filter((s): s is string => typeof s === 'string') : []
66
67function toolTitle(tool: string, input: unknown): { title: string; text: string } {
68 const args = isRecord(input) ? input : {}
69 const arg = [args.file_path, args.notebook_path, args.command, args.pattern, args.url, args.description]
70 .find((v): v is string => typeof v === 'string' && v.length > 0)
71 if (arg === undefined) return { title: tool, text: tool }
72 const short = arg === args.file_path || arg === args.notebook_path
73 ? arg.split('/').slice(-2).join('/')
74 : arg.split('\n')[0] ?? arg
75 return { title: `${tool}(${clip(short, 40)})`, text: arg }
76}
77
78function clockTime(ms: number) {
79 const d = new Date(ms)
80 const pad = (n: number) => String(n).padStart(2, '0')
81 return `${pad(d.getHours())}:${pad(d.getMinutes())}`
82}
83
84function previewLines(excerpt: string) {
85 const lines = excerpt.split('\n').map(line => line.trimEnd()).filter(line => line.trim() !== '')
86 const shown = lines.slice(0, PREVIEW_LINES)
87 if (lines.length > PREVIEW_LINES) shown.push('…')
88 return shown.length > 0 ? shown : ['(no text)']
89}
90
91// Hover marks need a pointer: the fullscreen terminal, or an app surface.
92function showsMarks(surface: RenderSurface, viewport: RenderViewport | undefined) {
93 if (surface === 'terminal') return viewport?.isFullscreen === true
94 return surface !== 'mobile'
95}
96
97async function persist($: EngineInterface) {
98 const id = await $.session.id()
99 const items = await read($, list)
100 const index = asStrings(await $.store.get(STORE_INDEX)).filter(s => s !== id)
101 if (items.length === 0) {
102 await $.store.delete(STORE_PREFIX + id)
103 } else {
104 await $.store.set(STORE_PREFIX + id, items)
105 index.push(id)
106 }
107 for (const old of index.splice(0, Math.max(0, index.length - MAX_SESSIONS))) {
108 await $.store.delete(STORE_PREFIX + old)
109 }
110 await $.store.set(STORE_INDEX, index)
111}
112
113async function syncMark($: EngineInterface, requestId: string | undefined) {
114 if (requestId === undefined) return
115 const items = await read($, list)
116 const isMarked = items.some(b => b.requestId === requestId)
117 await update($, memberOf(marked, { requestId }), () => isMarked)
118}
119
120async function replaceList($: EngineInterface, bookmarks: Bookmark[]) {
121 const previous = await read($, list)
122 await update($, list, () => bookmarks)
123 const rows = new Set([...previous, ...bookmarks].map(b => b.requestId))
124 for (const requestId of rows) await syncMark($, requestId)
125}
126
127// Loads this session's saved bookmarks once per session id: a hot reload
128// keeps the list, a resumed session gets its own back.
129async function ensureLoaded($: EngineInterface) {
130 const id = await $.session.id()
131 if ((await read($, loadedFor)) === id) return
132 await replaceList($, asBookmarks(await $.store.get(STORE_PREFIX + id)))
133 await update($, loadedFor, () => id)
134}
135
136function openPane($: EngineInterface) {
137 return $.ui.open({ id: PANE, title: TITLE })
138}
139
140async function addBookmark($: EngineInterface, bookmark: NewBookmark) {
141 await ensureLoaded($)
142 if ((await read($, list)).length >= MAX_BOOKMARKS) {
143 $.ui.toast(`You have ${MAX_BOOKMARKS} bookmarks already: delete some first`)
144 return
145 }
146 const createdAt = await $.clock.now()
147 const added: Bookmark = {
148 ...bookmark,
149 excerpt: clip(bookmark.excerpt, MAX_TEXT),
150 id: `${createdAt.toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
151 createdAt,
152 }
153 await update($, list, items => [added, ...items])
154 await syncMark($, added.requestId)
155 await persist($)
156 await openPane($)
157 $.ui.toast(`Bookmarked: ${added.title}`)
158}
159
160async function removeBookmark($: EngineInterface, id: string) {
161 const gone = (await read($, list)).find(b => b.id === id)
162 await update($, list, items => items.filter(b => b.id !== id))
163 await syncMark($, gone?.requestId)
164 await persist($)
165}
166
167async function clearAll($: EngineInterface) {
168 await replaceList($, [])
169 await persist($)
170}
171
172async function markMessage($: EngineInterface, requestId: string, row: Row) {
173 await ensureLoaded($)
174 const already = (await read($, list)).find(b => b.requestId === requestId && b.kind !== 'selection')
175 if (already !== undefined) {
176 await openPane($)
177 $.ui.toast('That message is already bookmarked')
178 return
179 }
180 await addBookmark($, { requestId, kind: row.kind, title: row.title, excerpt: row.text })
181}
182
183// Answers with why nothing was bookmarked, or undefined once it was.
184async function bookmarkSelection($: EngineInterface, label: string): Promise<string | undefined> {
185 const selection = await $.ui.selection()
186 if (selection === undefined || selection.text.trim() === '') {
187 return 'Nothing is selected: select text in the transcript with the mouse first (fullscreen mode).'
188 }
189 const info = selection.requestId === undefined ? undefined : titles.get(selection.requestId)
190 await addBookmark($, {
191 requestId: selection.requestId,
192 kind: 'selection',
193 title: label !== '' ? label : (info?.title ?? 'selection'),
194 excerpt: selection.text,
195 })
196 if (selection.requestId === undefined) {
197 $.ui.toast('Saved, but the selection spans several messages, so it has no single place to jump to')
198 }
199 return undefined
200}
201
202async function jump($: EngineInterface, bookmark: Bookmark, surface: RenderSurface) {
203 if (bookmark.requestId === undefined) {
204 $.ui.toast('This bookmark spans several messages, so there is no single place to jump to')
205 return
206 }
207 const why = await $.ui.scroll({ to: { requestId: bookmark.requestId }, block: 'start' }).then(
208 scrolled => scrolled.deny,
209 (error: unknown) => String(error instanceof Error ? error.message : error),
210 )
211 if (why === undefined) return
212 const copied = await $.ui.copy({ text: bookmark.excerpt, surface })
213 $.ui.toast(
214 `Can't scroll to that message (${why}).` +
215 (copied.isCopied ? ' Its text is on the clipboard.' : ''),
216 )
217}
218
219// The engine's own drawing of a row, with a 📌 at its top right that shows on
220// hover and stays as ★ once the row is bookmarked. The 📌 carries the row's
221// text, so nothing else needs to remember it.
222function markable(
223 $: EngineInterface,
224 ui: Pick<ElementTable, 'Box' | 'Button'>,
225 requestId: string,
226 drawing: RenderElement,
227 row: Row,
228 isMarked: boolean,
229) {
230 const { Box, Button } = ui
231 return (
232 <Box key={`bm:${requestId}`} flexDirection="column">
233 {drawing}
234 <Box
235 position="absolute"
236 top={0}
237 right={1}
238 {...(isMarked ? {} : { display: 'none', hover: { display: 'flex' } })}
239 >
240 <Button
241 plain
242 dimColor
243 key={`mark:${requestId}`}
244 label={isMarked ? '★' : '📌'}
245 onPress={() => markMessage($, requestId, row)}
246 />
247 </Box>
248 </Box>
249 )
250}
251
252export const register: Register = on => {
253 on('session.start', async ($, e, next) => {
254 await $.command.register({
255 name: 'bookmarks',
256 description: 'Open the bookmarks pane',
257 immediate: true,
258 })
259 await $.command.register({
260 name: 'bookmark',
261 description: 'Bookmark the text you selected with the mouse',
262 argumentHint: '[label]',
263 immediate: true,
264 })
265 await ensureLoaded($)
266 if ((await read($, list)).length > 0) void openPane($)
267
268 return next(e)
269 })
270
271 // A /clear or /resume moves the process to another session id: its rows are
272 // gone, so the list is too (the old session's stay saved for its resume).
273 on('session.end', async ($, e, next) => {
274 if (e.reason === 'clear' || e.reason === 'resume') {
275 await replaceList($, [])
276 await update($, loadedFor, () => '')
277 }
278
279 return next(e)
280 })
281
282 on('prompt.submit', async ($, e, next) => {
283 await ensureLoaded($)
284
285 return next(e)
286 })
287
288 on('command.run', { command: 'bookmarks' }, async $ => {
289 await ensureLoaded($)
290 await openPane($)
291
292 return {}
293 })
294
295 on('command.run', { command: 'bookmark' }, async ($, e) => {
296 if (!e.presentation.isFullscreen) {
297 return { text: 'Bookmarking a selection needs the fullscreen terminal (CLAUDE_CODE_NO_FLICKER=1, outside tmux).' }
298 }
299 const refusal = await bookmarkSelection($, e.args.trim())
300
301 return refusal === undefined ? {} : { text: refusal }
302 })
303
304 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
305 const drawing = await next(e)
306 const row: Row = { kind: 'assistant', title: 'claude', text: e.props.text }
307 remember(e.requestId, row)
308 if (!showsMarks(e.surface, e.viewport)) return drawing
309
310 return markable($, $.ui.resolve(e), e.requestId, drawing, row, await read($, memberOf(marked, e)))
311 })
312
313 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
314 const drawing = await next(e)
315 const title = e.props.from?.name ?? (e.props.origin.kind === 'task-notification' ? 'task' : 'you')
316 const row: Row = { kind: 'user', title, text: e.props.text }
317 remember(e.requestId, row)
318 if (!showsMarks(e.surface, e.viewport)) return drawing
319
320 return markable($, $.ui.resolve(e), e.requestId, drawing, row, await read($, memberOf(marked, e)))
321 })
322
323 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
324 const drawing = await next(e)
325 const row: Row = { kind: 'tool', ...toolTitle(e.props.tool, e.props.input) }
326 remember(e.requestId, row)
327 if (!showsMarks(e.surface, e.viewport)) return drawing
328
329 return markable($, $.ui.resolve(e), e.requestId, drawing, row, await read($, memberOf(marked, e)))
330 })
331
332 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
333 const { Box, Button, Text } = $.ui.resolve(e)
334 const items = await read($, list)
335
336 return (
337 <Box flexDirection="column" width={Math.max(10, e.props.bodyColumns)}>
338 <Box flexDirection="row" gap={1}>
339 <Button
340 key="add-selection"
341 variant="primary"
342 label="+ Bookmark selection"
343 onPress={async () => {
344 const refusal = await bookmarkSelection($, '')
345 if (refusal !== undefined) $.ui.toast(refusal)
346 }}
347 />
348 {items.length > 0 && (
349 <Button key="clear-all" label="Clear all" onPress={() => clearAll($)} />
350 )}
351 </Box>
352 {items.length === 0 && (
353 <Box marginTop={1}>
354 <Text dimColor>
355 No bookmarks yet. Hover a message and click 📌, or select text with the mouse and
356 press "+ Bookmark selection" (or type /bookmark).
357 </Text>
358 </Box>
359 )}
360 {items.map(bookmark => (
361 <Box key={`b:${bookmark.id}`} flexDirection="column" marginTop={1}>
362 <Text dimColor wrap="truncate-end">
363 {bookmark.title}
364 {bookmark.kind === 'selection' ? ' · selection' : ''} · {clockTime(bookmark.createdAt)}
365 </Text>
366 {previewLines(bookmark.excerpt).map(line => (
367 <Text wrap="truncate-end">{line}</Text>
368 ))}
369 <Box flexDirection="row" gap={1}>
370 <Button
371 key={`jump:${bookmark.id}`}
372 label="Go to"
373 dimColor={bookmark.requestId === undefined}
374 onPress={press => jump($, bookmark, press.surface)}
375 />
376 <Button
377 key={`del:${bookmark.id}`}
378 label="Delete"
379 onPress={() => removeBookmark($, bookmark.id)}
380 />
381 </Box>
382 </Box>
383 ))}
384 </Box>
385 )
386 })
387}
388types/index.d.ts 24 lines1export type BookmarkKind = 'user' | 'assistant' | 'tool' | 'selection'
2
3export type Bookmark = {
4 id: string
5 /** The transcript row to scroll back to; absent when a selection spanned several rows. */
6 requestId?: string
7 kind: BookmarkKind
8 title: string
9 excerpt: string
10 createdAt: number
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'transcript-bookmarks': {
16 list: Bookmark[]
17 /** The session id the list was loaded for, so a hot reload keeps it and a /clear drops it. */
18 loadedFor: string
19 /** Per transcript row: whether a bookmark points at it, so each row redraws alone. */
20 marked: StateFamily<boolean>
21 }
22 }
23}
24