SLOPSHOPPER

Transcript Bookmarks

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

newpanerowscommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · transcript-bookmarks
│ ┃ Bookmarks ✕ ⟨Claude Code's own drawing⟩ │ ┃ [ + Bookmark selection ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ No bookmarks yet. Hover a message and click ⎿ Read 6 lines │ ┃ select text with the mouse and press "+ Book ⟨Claude Code's own drawing⟩ │ ┃ selection" (or type /bookmark). ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ⟨Claude Code's own drawing⟩ │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /bookmarks │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Bookmarks
[ + Bookmark selection ] No bookmarks yet. Hover a message and click 📌, or select text with the mouse and press "+ Bookmark selection" (or type /bookmark).
Your message
⟨Claude Code's own drawing⟩
Claude's reply
⟨Claude Code's own drawing⟩
Tool row
⟨Claude Code's own drawing⟩
README

📌 Transcript Bookmarks for Claude Code

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:

  1. Mark a message, or just the few lines you care about.
  2. It's added to a Bookmarks pane that docks beside the conversation, like the diff panel does.
  3. Click Go to and the transcript scrolls back to that exact message, so you can pick up from there.

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

Contents


What you can do

Bookmark a whole messageHover 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 textSelect 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 backClick Go to on a bookmark and the conversation scrolls so that message sits at the top.
See what's bookmarkedBookmarked messages keep a small ★ at their top right.
DeleteEach bookmark has its own Delete button. Clear all removes every bookmark in the session.
Keep themBookmarks are saved with the session and come back on claude --resume / claude --continue.

Requirements

  • The Claude Code terminal. This mod is built and tested only for Claude Code running in a terminal. The directory can also offer it in the desktop app's Code tab and in the IDE extensions (VS Code, JetBrains), but it hasn't been tested there. Some parts may work there and others may not, such as selecting text or jumping to a message.
  • Claude Code with plugin function hooks. Tested on 2.1.291. The hooks API is in early access and may change between releases.
  • Fullscreen terminal mode. Hover marks, mouse selection and scroll-to-message all need it, because the classic "main screen" mode has no mouse tracking and leaves scrolling to your terminal. Turn it on in either of these ways:
  • add "tui": "fullscreen" to ~/.claude/settings.json, or
  • start Claude Code with CLAUDE_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.

  • A wide enough window for the pane to dock beside the conversation (110+ columns). In a narrower window the pane opens above the prompt instead, and everything still works.

Install

From GitHub (recommended)

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:

  1. answer y when asked to add the marketplace,
  2. press Enter to pick the user scope (the mod is then active in every session).

You should see ✓ Installed transcript-bookmarks. Plugin is now active. and can start bookmarking right away.

From a local folder

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.

Just try it, without installing

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.


How to use it

1. Bookmark a message

Move the mouse over any message in the conversation. A 📌 appears at the right end of its first line. Click it.

  • The Bookmarks pane opens, with the new bookmark at the top.
  • A short note confirms it: Bookmarked: claude.
  • From then on the message shows a ★, so you can tell it's bookmarked while you scroll.

Clicking ★ on a message that's already bookmarked doesn't add it twice. It just brings the pane up.

2. Bookmark a selection

Sometimes one sentence matters more than the whole reply.

  1. Select the text with the mouse, as if you were going to copy it.
  2. Then either:
  3. click + Bookmark selection at the top of the pane, or
  4. type /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.

3. Jump back

Click Go to on any bookmark. The conversation scrolls until that message is at the top of the screen.

4. Clean up

  • Delete removes one bookmark.
  • Clear all removes every bookmark in this session.

Opening and closing the pane

  • /bookmarks opens the pane at any time.
  • Close it with the pane's close mark, or ctrl+x x. Your bookmarks are kept.
  • When you resume a session that has bookmarks, the pane opens by itself if the window is wide enough.

Examples

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.


Commands

CommandWhat it does
/bookmarksOpens 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.


How bookmarks are kept

  • Per session. Each conversation has its own list.
  • Saved as you go. Every add or delete is saved straight away in the plugin's own storage (Claude Code's per-plugin store, not your project files).
  • Restored on resume. 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.
  • Limits (so storage stays small):
  • up to 200 bookmarks per session,
  • up to 2,000 characters of text per bookmark,
  • the 50 most recent sessions with bookmarks are kept; older ones are dropped.

Nothing is sent anywhere, and the model never reads your bookmarks. They only change what you see on screen. Privacy has the details.


Privacy

Transcript Bookmarks works entirely on your computer.

What it reads

  • A message's text, only when you click its 📌. That text becomes the bookmark.
  • The text you selected, only when you bookmark a selection (with + Bookmark selection or /bookmark).
  • Who wrote each message on screen, so a selection can be labelled: 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

  • For each bookmark: the text, its label, the time, and which message it points to.
  • Where: Claude Code's local storage for this plugin, on your machine, filed by session.
  • How long: until you click Delete or Clear all. Bookmarks are kept for the 50 most recent sessions that have any, and older ones are deleted automatically.

What it sends

  • Nothing. The plugin makes no network requests, runs no MCP servers, runs no shell commands and collects no telemetry. Nothing goes to Anthropic, to the author, or to anyone else.
  • The model never sees your bookmarks.
  • The only thing that leaves the plugin is a copy of a bookmark's text to your own clipboard, and only when Go to can't scroll to the message. A note on screen tells you when it happens.

Every hook and call it makes

HookWhat it does
session.startAdds the /bookmarks and /bookmark commands, loads this session's saved bookmarks, and opens the pane if there are any.
session.endAfter /clear or /resume, empties the list on screen. The old session's bookmarks stay saved.
prompt.submitLoads 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.runAnswers only its own two commands, /bookmarks and /bookmark.
ui.renderDraws the Bookmarks pane, and adds the 📌 / ★ button to each message row while keeping Claude Code's own drawing of the row.
CallWhat 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 / deleteReads and writes the plugin's local storage on your machine.
$.state.get / setKeeps the list in memory for the session, so the pane redraws when it changes.
$.ui.open, toast, resolve, scroll, copyOpens 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.registerAdds 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.


Troubleshooting

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.


Known limitations

  • Terminal only, in fullscreen mode. It's tested only in the Claude Code terminal; see Requirements for the desktop app and IDE extensions. Hover, selection and jumping need fullscreen mode. On the main screen the mod stays out of the way and draws nothing on messages.
  • Jumping needs the message to still be in the conversation view. After a /compact, older messages are summarised, so their bookmarks can no longer jump (you get the text on the clipboard instead).
  • Grouped tool rows. When several reads or searches are folded into one group row, the group has no 📌 of its own. Bookmark the reply next to it, or select the text.
  • Early-access API. Plugin function hooks are new in Claude Code and may change in future releases.

Development

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

  • It hooks the drawing of each transcript row (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.
  • The list lives in session state (so it survives a mod reload) and is copied to the plugin store under the session id (so it survives a restart).

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.


Support


License

WTFPL: Do What The Fuck You Want To Public License, version 2. Copy it, change it, ship it, do whatever you want with it.

Source 2 files
hooks/register.tsx 388 lines
1import { 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}
388
types/index.d.ts 24 lines
1export 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