Every URL mentioned in the session, floating in a compact band above the prompt: pin it, dismiss it, open it in the browser. Decisions survive exit and resume…

Every link your session mentions, in one row above the prompt.
Paste a docs page, let Claude point you at a pull request, start a dev server: each address becomes a chip above the prompt the moment it is mentioned. Pin the ones you keep coming back to, dismiss the noise, open any of them in your browser. What you pin and dismiss is saved with the session, so it is exactly as you left it when you resume.
▎ ★ docs.claude.com ▎ ☆ github.com/pull/12 × ▎ ☆ localhost:5173 × ≡
A long session scatters addresses through the transcript: the page you were reading, the issue Claude found, the server it started. Scrolling back to find them again is the tax this mod removes. The band stays out of your way (nothing is drawn while there is no link), the links that matter stay pinned, and the model never sees any of it: the mod costs zero tokens. It never fetches anything either: no address is requested until you open it, and then it is your browser that does.
Needs Claude Code 2.1.291 or later.
To try it, run Claude Code with the folder:
claude --plugin-dir path/to/session-links
To have it in every session:
claude plugin marketplace add samaphp/session-links
claude plugin install session-links@session-links
Start claude, mention a link, and the band appears. To remove it: claude plugin uninstall session-links.
| Control | What it does |
|---|---|
☆ / ★ | Pin or unpin. A pinned link takes the first seat and stays for the whole session; it shows no ×, so losing it takes an unpin first. |
| the address | Opens the link in your browser. |
× | Dismisses the link. The first press asks (dismiss?), the second confirms; left alone, it lapses. A dismissed link stays hidden even when mentioned again, and can be restored from /links. |
dismiss all | Shown from five floating links. The first press asks (dismiss 12?): press it again to dismiss every floating link, or press dismiss 12 & open the list beside it to do the same and open /links, where any of them can be restored. Pinned links stay. |
≡ / +N more | Opens the full list. |
The coloured bar on each chip says what it is: orange for pinned, blue for a link that arrived since your last prompt, grey for older ones. Pinned links come first, then the most repeated. The band grows to three rows before it counts the rest, and two links on one site get a short hint of their path (github.com/pull/12, github.com/issues/7).
Keyboard: ctrl+x then Tab moves into the band; Tab, Shift+Tab and the arrows walk the controls; Enter presses; Esc returns to the prompt.
/linksThe pane lists every link of the session in three groups, pinned, floating and dismissed, each with open, pin, dismiss and copy, and restore for the dismissed ones. A text box at the top takes an address you type or paste; press Enter and it is added pinned (the https:// can be left off).
…./tui fullscreen) makes every control clickable.FORCE_HYPERLINK=1 (for example in the env block of your Claude Code settings) and the addresses become real hyperlinks your terminal can open on a click.A Claude Code mod is a plugin whose hooks are TypeScript functions running inside Claude Code.
hooks/register.tsx watches every row the conversation keeps, draws the band and the pane, and registers /links. Decisions are written through to the plugin's store on every change, keyed by session id, so a resume restores them exactly.hooks/links.ts finds addresses in text, keeps the list within its cap, orders it, and writes the labels.types/index.d.ts is the state contract the engine checks the hooks against.tests/ presses the real band and pane through Claude Code's own test kit.git clone https://github.com/samaphp/session-links
cd session-links
claude --plugin-dir . # loads from disk, hot-reloads on save
claude plugin validate .
claude plugin test .
npx -y -p typescript tsc -p . # once a load has written .claude-plugin/types/
MIT. See LICENSE.
hooks/register.tsx 830 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderSurface } from 'claude-code'
3
4import type { Link, LinkSource, LinkStatus, SessionRecord } from '../types'
5
6import { bandOrder, byPriority, chipLabels, clip, displayOf, extractUrls, labelOf, mergeMentions } from './links'
7
8const PANE = 'links'
9// `$.store` holds 4 MiB across every session; past this many the oldest records leave.
10const MAX_SESSIONS = 300
11// Making room reads every record to find the oldest. Trimming to the cap
12// exactly would repeat that read at every start once the store is full, so
13// it trims this far below and the read comes once in that many new sessions.
14const SESSION_SLACK = 50
15// One chip is `▎ ☆ domain × `. Each control carries a cell of air on each
16// side inside its own label: the highlight under the pointer or the focus is
17// the label's width, and one bare glyph made a target too tight to see or to
18// hit. A pinned chip is `▎ ★ domain `: a lock has no dismiss beside it, so
19// losing a link the person kept takes an unpin first. Both widths count the
20// gap to the next chip.
21const FLOATING_CELLS = 10
22const PINNED_CELLS = 7
23// A dismissal takes two presses. The first turns the cross into this question
24// on the chip itself, so what is about to go is named where the person is
25// looking; the second, on the same control, dismisses. Left alone, the
26// question goes back to a cross.
27const ARMED_LABEL = ' dismiss? '
28const DISMISS_LABEL = ' × '
29const DISARM_MS = 4000
30// Below this many floating links, one `×` at a time is quick; from it on the
31// band also offers to dismiss them all at once. Pinned links are never touched.
32const BULK_MIN = 5
33// The `armed` value that puts the question over every floating link rather
34// than one of them. No normalized address can look like it.
35const ALL = '*'
36// Room kept at a row's end for the control that closes the band, air included:
37// ` +12 more ` on the last row the band may take, ` ≡ ` on an earlier one.
38const BAND_TAIL = 11
39const ROW_TAIL = 3
40// The band grows downward before it hides a link: three rows show a busy
41// session whole, and the person tidies by dismissing what they no longer need.
42const MAX_ROWS = 3
43// xdg-open stays alive for as long as the browser it started does, and
44// `$.process.run` waits on a child's output: so it is detached, its output
45// dropped, and the address rides as an argument, never as script text. An
46// opener with nothing to hand the address to gives up within a moment, so
47// the script watches for that moment: its exit then says whether the
48// address was taken, which is all "sent to your browser" may claim.
49const DETACHED_XDG_OPEN = [
50 'command -v xdg-open >/dev/null || exit 127',
51 'if command -v setsid >/dev/null; then setsid xdg-open "$1" >/dev/null 2>&1 &',
52 'else xdg-open "$1" >/dev/null 2>&1 &',
53 'fi',
54 'opener=$!',
55 'for tick in 1 2 3 4 5 6 7 8 9 10; do',
56 ' kill -0 "$opener" 2>/dev/null || break',
57 ' sleep 0.1',
58 'done',
59 'kill -0 "$opener" 2>/dev/null && exit 0',
60 'wait "$opener"',
61].join('\n')
62
63const sessionId = atom({ plugin: 'session-links', key: 'sessionId' } as const, '')
64const links = atom({ plugin: 'session-links', key: 'links' } as const, [])
65const freshSince = atom({ plugin: 'session-links', key: 'freshSince' } as const, 0)
66const armed = atom({ plugin: 'session-links', key: 'armed' } as const, '')
67
68type Engine = EngineInterface
69// `id` is the link's address: it names the link's controls wherever the chip
70// sits, so a key stays with its link when the row reorders or the cap trims.
71type Chip = { link: Link; id: string; label: string; width: number }
72type Kit = Pick<Elements[RenderSurface], 'Box' | 'Button' | 'Link' | 'Text'> & {
73 // The mobile app draws no text field yet: there the list goes without its add box.
74 Input?: Elements['terminal']['Input']
75}
76type Row = {
77 agentId?: string
78 message: { content: readonly { type: string; [field: string]: unknown }[] }
79}
80
81const reasonOf = (error: unknown): string => (error instanceof Error ? error.message : String(error))
82
83/** A failure of this mod must never cost the conversation a row or a prompt: it goes to the debug log. */
84async function quietly($: Engine, what: string, work: () => Promise<unknown>): Promise<void> {
85 try {
86 await work()
87 } catch (error) {
88 // The engine tags each log line with the plugin's name: the line carries only what happened.
89 $.ui.log(`could not ${what}: ${reasonOf(error)}`, { to: 'debug' })
90 }
91}
92
93const keyOf = (id: string): string => `session:${id}`
94
95const isRecord = (value: unknown): value is SessionRecord =>
96 typeof value === 'object' && value !== null && Array.isArray((value as SessionRecord).links)
97
98/** Once more than `limit` sessions are saved, drops the ones saved longest ago, down to `keep`. */
99async function forget($: Engine, limit: number, keep: number): Promise<void> {
100 const keys = (await $.store.keys()).filter(key => key.startsWith('session:'))
101
102 if (keys.length <= limit) {
103 return
104 }
105
106 const aged: { key: string; savedAt: number }[] = []
107
108 for (const key of keys) {
109 const record = await $.store.get(key)
110 aged.push({ key, savedAt: isRecord(record) ? record.savedAt : 0 })
111 }
112
113 aged.sort((a, b) => a.savedAt - b.savedAt)
114
115 for (const { key } of aged.slice(0, aged.length - keep)) {
116 await $.store.delete(key)
117 }
118}
119
120/**
121 * The store is what a resume reads, so every change of the list is written
122 * through at once: there is no "save on exit" to miss when the terminal dies.
123 */
124async function save($: Engine): Promise<void> {
125 const id = await read($, sessionId)
126
127 if (id === '') {
128 return
129 }
130
131 const record: SessionRecord = { links: await read($, links), savedAt: await $.clock.now() }
132
133 try {
134 await $.store.set(keyOf(id), record)
135 } catch (error) {
136 $.ui.log(`the store refused a save, making room: ${reasonOf(error)}`, { to: 'debug' })
137 await forget($, 0, Math.floor(MAX_SESSIONS / 2))
138
139 try {
140 await $.store.set(keyOf(id), record)
141 } catch (again) {
142 $.ui.toast(`Links were not saved, so a resume will not restore them: ${reasonOf(again)}`)
143 }
144 }
145}
146
147/** A session the mod never saw (it was installed mid-conversation) starts from what its transcript mentions. */
148function fromTranscript(messages: readonly { role: 'user' | 'assistant'; text: string }[]): Link[] {
149 return messages.reduce<Link[]>(
150 (list, message, index) =>
151 mergeMentions(list, extractUrls(message.text), message.role === 'user' ? 'you' : 'claude', index + 1),
152 [],
153 )
154}
155
156async function load($: Engine): Promise<void> {
157 const id = await $.session.id()
158
159 // Also true after a hot reload of this file: the host kept the state.
160 if ((await read($, sessionId)) === id) {
161 return
162 }
163
164 const saved = await $.store.get(keyOf(id))
165 const restored = isRecord(saved) ? saved.links : fromTranscript(await $.session.messages())
166 const now = await $.clock.now()
167
168 await update($, links, () => restored)
169 await update($, armed, () => '')
170 // Nothing restored is news: only what arrives from here on is drawn fresh.
171 await update($, freshSince, () => now)
172 await update($, sessionId, () => id)
173
174 if (!isRecord(saved) && restored.length > 0) {
175 await save($)
176 }
177
178 await forget($, MAX_SESSIONS, MAX_SESSIONS - SESSION_SLACK)
179}
180
181let loading: Promise<void> | undefined
182
183/**
184 * The state always belongs to the session on screen. A /clear and an
185 * in-process /resume go on under another id with no `session.start`, so every
186 * entry point and every drawing passes through here, and a changed id reloads
187 * that session's links.
188 */
189function ensure($: Engine): Promise<void> {
190 loading ??= load($).finally(() => {
191 loading = undefined
192 })
193
194 return loading
195}
196
197/**
198 * A drawing never shows another session's links. An in-process /resume is
199 * announced (`classic.SessionStart`) while the process is still under the
200 * session it leaves, so the announcement alone reloads the wrong one: the
201 * first drawing after the switch is what notices. A render cannot write
202 * state, so the reload runs as a dispatch of its own and redraws when done.
203 */
204async function isCurrent($: Engine): Promise<boolean> {
205 if ((await $.session.id()) === (await read($, sessionId))) {
206 return true
207 }
208
209 $.clock.after(0, () => void quietly($, 'load the links', () => ensure($)))
210
211 return false
212}
213
214async function capture($: Engine, row: Row, source: LinkSource): Promise<void> {
215 // A subagent's conversation is its own; what matters of it reaches the main one.
216 if (row.agentId !== undefined) {
217 return
218 }
219
220 const text = row.message.content
221 .map(block => (block.type === 'text' && typeof block.text === 'string' ? block.text : ''))
222 .join('\n')
223 const urls = extractUrls(text)
224
225 if (urls.length === 0) {
226 return
227 }
228
229 // Before the merge, so a first load never reads this row back from the transcript as well.
230 await ensure($)
231
232 const now = await $.clock.now()
233 await update($, links, list => mergeMentions(list, urls, source, now))
234 await save($)
235}
236
237/** Also seats an address the conversation never mentioned, when the person adds it by hand. */
238async function setStatus($: Engine, url: string, status: LinkStatus): Promise<void> {
239 const now = await $.clock.now()
240
241 await update($, links, list => {
242 const known = list.some(link => link.url === url) ? list : mergeMentions(list, [url], 'you', now)
243
244 return known.map(link => (link.url === url ? { ...link, status } : link))
245 })
246 await save($)
247}
248
249/**
250 * A pin moves its link to the front of the row, while the engine's focus ring
251 * keeps its place in the row: the ring is sent after the link it was on.
252 */
253async function togglePin($: Engine, link: Link, key: string, requestId: string): Promise<void> {
254 await setStatus($, link.url, link.status === 'pinned' ? 'floating' : 'pinned')
255
256 // Answers `{ deny }` when the site does not hold the keyboard (a pointer
257 // press on a surface with no ring): there is no highlight to move then.
258 const moved = await $.ui.focus({ requestId, key })
259
260 if (moved.deny !== undefined) {
261 $.ui.log(`the highlight stayed where it was: ${moved.deny}`, { to: 'debug' })
262 }
263}
264
265async function dismiss($: Engine, url: string): Promise<void> {
266 await setStatus($, url, 'dismissed')
267 // The chip is gone from the band, so the way back is said where they acted.
268 $.ui.toast(`Dismissed ${labelOf(url, 40)} · /links brings it back`)
269}
270
271/**
272 * What the person typed or pasted into the list's field. A link added by hand
273 * is one they went out of their way to keep, so it starts locked in. Nothing
274 * of this reaches the model: the field is the mod's, the prompt box is not touched.
275 */
276async function addByHand($: Engine, text: string): Promise<void> {
277 const typed = text.trim()
278 // An address is usually copied without its scheme. A machine on the desk
279 // serves plain http; everything else is assumed to be the secure web.
280 const isLocal = /^(localhost|\d{1,3}(\.\d{1,3}){3})([:/]|$)/i.test(typed)
281 const urls = extractUrls(/https?:\/\//i.test(typed) ? typed : `${isLocal ? 'http' : 'https'}://${typed}`)
282
283 if (urls.length === 0) {
284 // The engine empties the field on Enter, so the reason quotes what was typed.
285 $.ui.toast(typed === '' ? 'Type or paste a web address, then press Enter' : `"${clip(typed, 40)}" is not a web address`)
286
287 return
288 }
289
290 for (const url of urls) {
291 await setStatus($, url, 'pinned')
292 }
293
294 $.ui.toast(urls.length === 1 ? `Pinned ${labelOf(urls[0] ?? '', 44)}` : `Pinned ${urls.length} links`)
295}
296
297/** Puts the question on `what` (one address, or ALL). One question stands at a time: asking moves it. */
298async function ask($: Engine, what: string): Promise<void> {
299 await update($, armed, () => what)
300 // A question nobody answers must not wait there to catch a stray press later.
301 $.clock.after(DISARM_MS, () => void quietly($, 'withdraw the question', () => update($, armed, now => (now === what ? '' : now))))
302}
303
304/** The first press on a link's dismiss asks; the second, while the question stands, answers yes. */
305async function pressDismiss($: Engine, url: string): Promise<void> {
306 if ((await read($, armed)) === url) {
307 await update($, armed, () => '')
308 await dismiss($, url)
309
310 return
311 }
312
313 await ask($, url)
314}
315
316/**
317 * The question over every floating link. Arming re-seats the band (the
318 * question is wider than the offer), so the focus ring is sent back to it:
319 * two presses on the same spot dismiss them all, as on a chip.
320 */
321async function askAll($: Engine, key: string, requestId: string): Promise<void> {
322 await ask($, ALL)
323
324 const moved = await $.ui.focus({ requestId, key })
325
326 if (moved.deny !== undefined) {
327 $.ui.log(`the highlight stayed where it was: ${moved.deny}`, { to: 'debug' })
328 }
329}
330
331/**
332 * Every floating link at once, while the question still stands: a press that
333 * lands after it lapsed, on a frame not yet redrawn, must not clear the band.
334 * Pinned links are the person's own and stay; the toast counts both.
335 */
336async function dismissAll($: Engine): Promise<void> {
337 if ((await read($, armed)) !== ALL) {
338 return
339 }
340
341 let gone = 0
342 let kept = 0
343
344 await update($, armed, () => '')
345 await update($, links, list => {
346 gone = list.filter(link => link.status === 'floating').length
347 kept = list.filter(link => link.status === 'pinned').length
348
349 return list.map(link => (link.status === 'floating' ? { ...link, status: 'dismissed' } : link))
350 })
351 await save($)
352 $.ui.toast(`Dismissed ${gone === 1 ? 'one link' : `${gone} links`}${kept === 0 ? '' : `, kept ${kept} pinned`} · /links brings them back`)
353}
354
355async function copyLink($: Engine, url: string, surface: RenderSurface): Promise<void> {
356 const copied = await $.ui.copy({ text: url, surface })
357
358 $.ui.toast(copied.isCopied ? `Copied ${labelOf(url, 44)}` : `This surface has no clipboard: ${url}`)
359}
360
361async function openInBrowser($: Engine, url: string, surface: RenderSurface): Promise<void> {
362 const openers = [
363 ['sh', '-c', DETACHED_XDG_OPEN, 'sh', url],
364 ['open', url],
365 ['rundll32', 'url.dll,FileProtocolHandler', url],
366 ]
367 const refusals: string[] = []
368
369 for (const argv of openers) {
370 try {
371 const ran = await $.process.run(argv, { timeoutMs: 10_000 })
372
373 if (ran.exitCode === 0) {
374 // The opener took the address; whether a window then appeared is the desktop's to show.
375 $.ui.toast(`Sent ${labelOf(url, 44)} to your browser`)
376
377 return
378 }
379
380 // 127 is the script's own word for "this machine has no xdg-open": nothing gave up, nothing was there.
381 if (ran.exitCode !== 127) {
382 refusals.push(`${argv[0] === 'sh' ? 'xdg-open' : argv[0]} gave up with exit ${ran.exitCode}`)
383 }
384 } catch (error) {
385 // Each opener belongs to one platform; the next one is tried.
386 $.ui.log(`${argv[0]} is not an opener here: ${reasonOf(error)}`, { to: 'debug' })
387 }
388 }
389
390 const copied = await $.ui.copy({ text: url, surface })
391 const why = refusals.length === 0 ? 'No browser opener answered on this machine' : `No browser took the link (${refusals.join(', ')})`
392
393 $.ui.toast(copied.isCopied ? `${why}, so the link is on your clipboard` : `${why}: ${url}`)
394}
395
396/**
397 * The engine seats a pane at any width only when the open answers the
398 * person's own press or command. One that arrives after that press has been
399 * answered counts as the mod's own idea and waits for a 110-column terminal
400 * ("waiting for room"). So every press handler hands its promise back
401 * (`() => openPane($)`, never `() => void openPane($)`), and the press stays
402 * open until the pane is asked for.
403 */
404async function openPane($: Engine): Promise<void> {
405 const opened = await $.ui.open({ id: PANE, title: 'Links', focus: true })
406
407 if (!opened.isPlaced) {
408 $.ui.toast(`The links pane is waiting for room: ${opened.reason}`)
409 }
410}
411
412/**
413 * Whether a Link will be drawn as a real hyperlink. On a terminal the engine
414 * draws one only when it takes the terminal to speak hyperlinks; otherwise it
415 * prints the Link's text followed by the whole URL in dim. It does not say
416 * which it will do, so the mod goes by what the person has declared the
417 * standard way (FORCE_HYPERLINK), and draws no Link on a terminal without it.
418 */
419async function drawsHyperlinks($: Engine, surface: RenderSurface): Promise<boolean> {
420 if (surface !== 'terminal') {
421 return true
422 }
423
424 const declared = await $.env.get('FORCE_HYPERLINK')
425
426 return declared !== undefined && !(declared.length > 0 && parseInt(declared, 10) === 0)
427}
428
429const barOf = (link: Link, since: number): string =>
430 link.status === 'pinned' ? 'claude' : link.lastAt >= since ? 'suggestion' : 'subtle'
431
432/**
433 * What the band offers over every floating link at once, by their count and
434 * whether the question stands: nothing below BULK_MIN, else the offer, else
435 * the question (press it to answer yes) beside the answer that also opens the
436 * list, for the person who clears the band and then restores a few.
437 */
438function bulkLabels(floating: number, isAsked: boolean): string[] {
439 if (floating < BULK_MIN) {
440 return []
441 }
442
443 return isAsked ? [` dismiss ${floating}? `, ` dismiss ${floating} & open the list `] : [' dismiss all ']
444}
445
446/**
447 * Chips fill a row, then the next, up to `maxRows`; what the last row cannot
448 * seat whole is counted, never squeezed. Every row keeps room for the control
449 * that closes the band, since any row may turn out to be its last; the last
450 * one also keeps `tail` cells for the bulk control drawn beside it.
451 */
452function seat(
453 all: readonly Link[],
454 columns: number,
455 maxRows: number,
456 asked: string,
457 tail: number,
458): { rows: Chip[][]; hidden: number } {
459 const ordered = bandOrder(all)
460 const labels = chipLabels(ordered)
461 const rows: Chip[][] = [[]]
462 let used = 0
463 let seated = 0
464
465 for (const link of ordered) {
466 const label = labels.get(link.url) ?? ''
467 const width =
468 label.length +
469 (link.status === 'pinned' ? PINNED_CELLS : FLOATING_CELLS) +
470 (link.url === asked ? ARMED_LABEL.length - DISMISS_LABEL.length : 0)
471 const isLastRow = rows.length >= maxRows
472 const isFull = used > 0 && used + width > columns - (isLastRow ? BAND_TAIL + tail : ROW_TAIL)
473
474 if (isFull && isLastRow) {
475 break
476 }
477
478 if (isFull) {
479 rows.push([])
480 used = 0
481 }
482
483 rows.at(-1)?.push({ link, id: link.url, label, width })
484 used += width
485 seated += 1
486 }
487
488 // The row that turns out last carries the tail too, and it kept room only
489 // for a row's end if the links ran out before the cap. Its closing chip
490 // moves down to a row of its own while there is one; at the cap it gives
491 // way, since a clipped control cannot be pressed and a hidden chip is counted.
492 for (;;) {
493 const last = rows.at(-1) ?? []
494 const lastUsed = last.reduce((cells, chip) => cells + chip.width, 0)
495 // Below the cap nothing is hidden, so the row ends in ` ≡ `, not a count.
496 const end = rows.length >= maxRows ? BAND_TAIL : ROW_TAIL
497
498 if (lastUsed <= columns - (end + tail)) {
499 break
500 }
501
502 const moved = last.pop()
503
504 if (moved === undefined) {
505 break
506 }
507
508 if (rows.length >= maxRows) {
509 seated -= 1
510 } else if (last.length > 0) {
511 rows.push([moved])
512 } else {
513 // A lone chip too wide to share its row with the tail keeps the row; the tail takes the next.
514 last.push(moved)
515 rows.push([])
516 break
517 }
518 }
519
520 return { rows, hidden: ordered.length - seated }
521}
522
523function listView(
524 $: Engine,
525 kit: Kit,
526 all: readonly Link[],
527 columns: number,
528 since: number,
529 isLinked: boolean,
530 asked: string,
531) {
532 const { Box, Button, Link, Text } = kit
533 const Field = kit.Input
534 const run = (what: string, work: () => Promise<unknown>) => () => quietly($, what, work)
535 // A row's keys carry its link's address: unique across the sections, and
536 // unmoved when a link changes section or the cap trims the list.
537 const pinned = all.filter(link => link.status === 'pinned').sort(byPriority)
538 const floating = all.filter(link => link.status === 'floating').sort(byPriority)
539 const dismissed = all.filter(link => link.status === 'dismissed')
540 const width = Math.max(8, columns - 4)
541
542 const row = (link: Link) => (
543 <Box key={`row:${link.url}`} flexDirection="column" marginBottom={1}>
544 <Box flexDirection="row">
545 <Text color={link.url === asked ? 'error' : barOf(link, since)}>▎</Text>
546 {isLinked ? (
547 <Link href={link.url} label={` ${displayOf(link.url, width)} `} />
548 ) : (
549 // Without hyperlinks the whole address is written once, as plain
550 // text: that is what such a terminal can open on a click.
551 <Text>{` ${link.url}`}</Text>
552 )}
553 </Box>
554 <Box flexDirection="row" paddingLeft={1}>
555 <Button
556 key={`open:${link.url}`}
557 plain
558 dimColor
559 label=" open "
560 onPress={press => quietly($, 'open the browser', () => openInBrowser($, link.url, press.surface))}
561 />
562 <Button
563 key={`pin:${link.url}`}
564 plain
565 dimColor
566 label={link.status === 'pinned' ? ' unpin ' : ' pin '}
567 onPress={press => quietly($, 'pin', () => togglePin($, link, `pin:${link.url}`, press.requestId))}
568 />
569 {link.status !== 'pinned' && (
570 <Button
571 key={`drop:${link.url}`}
572 plain
573 dimColor={link.url !== asked}
574 label={link.url === asked ? ' dismiss? ' : ' dismiss '}
575 onPress={run('dismiss', () => pressDismiss($, link.url))}
576 />
577 )}
578 <Button
579 key={`copy:${link.url}`}
580 plain
581 dimColor
582 label=" copy "
583 onPress={press => quietly($, 'copy', () => copyLink($, link.url, press.surface))}
584 />
585 {columns >= 60 && <Text dimColor>{` ${link.source === 'you' ? 'you' : 'Claude'} · ${link.mentions}×`}</Text>}
586 </Box>
587 </Box>
588 )
589
590 return (
591 <Box flexDirection="column">
592 <Box flexDirection="row">
593 <Text bold>Links</Text>
594 <Text dimColor>{` ${pinned.length} pinned · ${floating.length} floating · ${dismissed.length} dismissed`}</Text>
595 </Box>
596 {Field !== undefined && (
597 <Box marginBottom={1}>
598 <Field
599 key="add"
600 label="add "
601 placeholder="type or paste an address"
602 submitLabel="pin it"
603 autoFocus
604 onSubmit={value => quietly($, 'add the link', () => addByHand($, value))}
605 />
606 </Box>
607 )}
608 {all.length === 0 && <Text dimColor>Every URL you or Claude mention lands here and floats above the prompt.</Text>}
609 {pinned.length > 0 && (
610 <Text bold color="claude">
611 ★ PINNED
612 </Text>
613 )}
614 {pinned.map(row)}
615 {floating.length > 0 && (
616 <Text bold color="suggestion">
617 ☆ FLOATING
618 </Text>
619 )}
620 {floating.map(row)}
621 {dismissed.length > 0 && (
622 <Text bold dimColor>
623 × DISMISSED
624 </Text>
625 )}
626 {dismissed.map(link => (
627 <Box key={`row:${link.url}`} flexDirection="row">
628 <Text dimColor>{' '}</Text>
629 <Text dimColor strikethrough>
630 {displayOf(link.url, Math.max(8, width - 12))}
631 </Text>
632 <Button key={`restore:${link.url}`} plain label=" restore " onPress={run('restore', () => setStatus($, link.url, 'floating'))} />
633 </Box>
634 ))}
635 {all.length > 0 && (
636 <Box marginTop={1}>
637 <Text dimColor>★ locks a link in for the whole session · × hides it · both survive exit and resume</Text>
638 </Box>
639 )}
640 </Box>
641 )
642}
643
644export const register: Register = on => {
645 on('session.start', async ($, e, next) => {
646 const started = await next(e)
647
648 await $.command.register({
649 name: 'links',
650 description: 'Every link of this session in a pane: pinned, floating and dismissed',
651 })
652 await quietly($, 'load the links', () => ensure($))
653
654 return started
655 })
656
657 // Fires where `session.start` does not: a /clear lands here already under its new id.
658 on('classic.SessionStart', async ($, e, next) => {
659 const started = await next(e)
660
661 await quietly($, 'load the links', () => ensure($))
662
663 return started
664 })
665
666 on('prompt.submit', async ($, e, next) => {
667 await quietly($, 'mark the turn', async () => {
668 await ensure($)
669
670 const now = await $.clock.now()
671 await update($, freshSince, () => now)
672 })
673
674 return next(e)
675 })
676
677 // What the person typed, what a command they ran said, and what Claude
678 // answered, each read as it arrives. Tool output stays out: one search
679 // result or lockfile would bury the links the conversation is about.
680 on('session.append', { door: 'prompt' }, async ($, e, next) => {
681 await quietly($, 'collect links', () => capture($, e, 'you'))
682
683 return next(e)
684 })
685
686 on('session.append', { door: 'command' }, async ($, e, next) => {
687 await quietly($, 'collect links', () => capture($, e, 'you'))
688
689 return next(e)
690 })
691
692 on('session.append', { door: 'response' }, async ($, e, next) => {
693 await quietly($, 'collect links', () => capture($, e, 'claude'))
694
695 return next(e)
696 })
697
698 on('command.run', { command: 'links' }, async $ => {
699 await quietly($, 'load the links', () => ensure($))
700 await openPane($)
701
702 return {}
703 })
704
705 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
706 // Asked first, and of every drawing: an empty band is exactly what a session just left looks like.
707 const isMine = await isCurrent($)
708 const all = await read($, links)
709 const shown = all.filter(link => link.status !== 'dismissed').length
710
711 if (e.props.hasSurvey || shown === 0 || !isMine) {
712 return next(e)
713 }
714
715 const since = await read($, freshSince)
716 const { Box, Button, Link, Text } = $.ui.resolve(e)
717 const asked = await read($, armed)
718 const floating = all.filter(link => link.status === 'floating').length
719 const bulk = bulkLabels(floating, asked === ALL)
720 const tail = bulk.reduce((cells, label) => cells + label.length, 0)
721 const { rows, hidden } = seat(all, e.props.bodyColumns, Math.max(1, Math.min(MAX_ROWS, e.props.maxRows)), asked, tail)
722 // A click reaches a Button only where the surface reports clicks: the
723 // fullscreen terminal. On the main screen only the terminal's own
724 // hyperlink answers a click, so the label is a Link there when one will
725 // be drawn as a hyperlink, and a Button the keyboard presses otherwise.
726 const hasClicks = e.surface === 'terminal' && e.viewport?.isFullscreen === true
727 const isPressed = hasClicks || !(await drawsHyperlinks($, e.surface))
728 const run = (what: string, work: () => Promise<unknown>) => () => quietly($, what, work)
729
730 const chip = ({ link, id, label }: Chip) => {
731 const isPinned = link.status === 'pinned'
732 const isAsked = link.url === asked
733 // The bar names what the standing question would take: this link, or every floating one.
734 const isMarked = isAsked || (asked === ALL && !isPinned)
735
736 return (
737 <Box key={`chip:${id}`} flexDirection="row" flexShrink={0} marginRight={1}>
738 <Text color={isMarked ? 'error' : barOf(link, since)}>▎</Text>
739 <Button
740 key={`pin:${id}`}
741 plain
742 dimColor={!isPinned}
743 label={isPinned ? ' ★ ' : ' ☆ '}
744 hover={{ color: 'warning' }}
745 onPress={press => quietly($, 'pin', () => togglePin($, link, `pin:${id}`, press.requestId))}
746 />
747 {isPressed ? (
748 <Button
749 key={`open:${id}`}
750 plain
751 label={` ${label} `}
752 hover={{ underline: true }}
753 onPress={press => quietly($, 'open the browser', () => openInBrowser($, link.url, press.surface))}
754 />
755 ) : (
756 <Link href={link.url} label={` ${label} `} />
757 )}
758 {!isPinned && (
759 <Button
760 key={`drop:${id}`}
761 plain
762 dimColor={!isAsked}
763 label={isAsked ? ARMED_LABEL : DISMISS_LABEL}
764 hover={{ color: 'error' }}
765 onPress={run('dismiss', () => pressDismiss($, link.url))}
766 />
767 )}
768 </Box>
769 )
770 }
771
772 return (
773 <Box flexDirection="column">
774 {rows.map((row, at) => (
775 <Box key={`row:${at}`} flexDirection="row">
776 {row.map(chip)}
777 {at === rows.length - 1 && bulk.length === 1 && (
778 <Button
779 key="bulk"
780 plain
781 dimColor
782 label={bulk[0]}
783 hover={{ color: 'error' }}
784 onPress={press => quietly($, 'ask about every link', () => askAll($, 'bulk', press.requestId))}
785 />
786 )}
787 {at === rows.length - 1 && bulk.length === 2 && (
788 <Button key="bulk" plain label={bulk[0]} hover={{ color: 'error' }} onPress={run('dismiss every link', () => dismissAll($))} />
789 )}
790 {at === rows.length - 1 && bulk.length === 2 && (
791 <Button
792 key="bulk:list"
793 plain
794 label={bulk[1]}
795 hover={{ color: 'error' }}
796 onPress={run('dismiss every link and open the list', async () => {
797 await dismissAll($)
798 await openPane($)
799 })}
800 />
801 )}
802 {at === rows.length - 1 && (
803 <Button
804 key="all"
805 plain
806 dimColor
807 label={hidden > 0 ? ` +${hidden} more ` : ' ≡ '}
808 onPress={run('open the list', () => openPane($))}
809 />
810 )}
811 </Box>
812 ))}
813 </Box>
814 )
815 })
816
817 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
818 const all = (await isCurrent($)) ? await read($, links) : []
819 const since = await read($, freshSince)
820 const kit = $.ui.resolve(e)
821 const columns = Math.max(24, e.props.bodyColumns)
822
823 const isLinked = await drawsHyperlinks($, e.surface)
824
825 const asked = await read($, armed)
826
827 return listView($, kit, all, columns, since, isLinked, asked)
828 })
829}
830hooks/links.ts 246 lines1import type { Link, LinkSource } from '../types'
2
3/**
4 * Floating links per session. Past it the oldest floating link leaves. A pinned
5 * or dismissed one never does: those are the person's decisions, and the
6 * list promises that a dismissed link stays dismissed when mentioned again.
7 */
8export const MAX_LINKS = 300
9
10const CANDIDATE = /https?:\/\/[^\s<>"'`\\^{}|]+/gi
11// Prose punctuation that ends a sentence, Arabic and CJK marks included.
12const TRAILING = '.,;:!?*_~،؛؟。,'
13const CLOSERS: Record<string, string> = { ')': '(', ']': '[' }
14const NAMED_HOST =
15 /^(localhost|(\d{1,3}\.){3}\d{1,3}|\[[0-9a-f:.]+\]|([a-z0-9-]+\.)+([a-z]{2,}|xn--[a-z0-9-]+))$/i
16// `http://web:3000` between containers is a real address; a bare `http://word` is prose.
17const SINGLE_LABEL = /^[a-z0-9-]+$/i
18
19const count = (text: string, mark: string): number => text.split(mark).length - 1
20
21/** `text` cut to `max` cells, the cut marked. The one place a label is shortened. */
22export const clip = (text: string, max: number): string =>
23 text.length <= max ? text : `${text.slice(0, Math.max(1, max - 1))}…`
24
25/**
26 * Injected context (instruction files, reminders) is not the conversation:
27 * what sits between its tags is left out. One walk through the text, so a row
28 * full of opening tags with no end costs one search, not one per tag.
29 */
30function withoutReminders(text: string): string {
31 const open = '<system-reminder>'
32 const close = '</system-reminder>'
33 let kept = ''
34 let at = 0
35
36 for (;;) {
37 const start = text.indexOf(open, at)
38 const end = start === -1 ? -1 : text.indexOf(close, start)
39
40 if (end === -1) {
41 return kept + text.slice(at)
42 }
43
44 kept += `${text.slice(at, start)} `
45 at = end + close.length
46 }
47}
48
49function trimTail(raw: string): string {
50 let url = raw
51
52 while (url !== '') {
53 const last = url.at(-1) ?? ''
54 const opener = CLOSERS[last]
55 // `(see https://host/a)` closes the sentence; `/wiki/Foo_(bar)` closes its own.
56 const isLoose = opener !== undefined && count(url, last) > count(url, opener)
57
58 if (!TRAILING.includes(last) && !isLoose) {
59 break
60 }
61
62 url = url.slice(0, -1)
63 }
64
65 return url
66}
67
68function normalize(raw: string): string | null {
69 if (!URL.canParse(raw)) {
70 return null
71 }
72
73 const url = new URL(raw)
74 const isAddress =
75 NAMED_HOST.test(url.hostname) || (SINGLE_LABEL.test(url.hostname) && url.port !== '')
76
77 return isAddress ? url.href : null
78}
79
80/** The http(s) links a text mentions: normalized, each once, in reading order. */
81export function extractUrls(text: string): string[] {
82 const found = new Set<string>()
83
84 for (const match of withoutReminders(text).matchAll(CANDIDATE)) {
85 // An address written with an ellipsis (`https://host/blog/…`) was shortened
86 // by its writer: what is left opens nothing, so it is no link to collect.
87 if (match[0].includes('…')) {
88 continue
89 }
90
91 const url = normalize(trimTail(match[0]))
92
93 if (url !== null) {
94 found.add(url)
95 }
96 }
97
98 return [...found]
99}
100
101function trim(list: Link[]): Link[] {
102 const floating = list.filter(link => link.status === 'floating').sort((a, b) => a.lastAt - b.lastAt)
103 const gone = new Set(floating.slice(0, Math.max(0, floating.length - MAX_LINKS)))
104
105 return gone.size === 0 ? list : list.filter(link => !gone.has(link))
106}
107
108/**
109 * A mention never changes a decision: a dismissed link mentioned again stays
110 * dismissed, a pinned one stays pinned.
111 */
112export function mergeMentions(
113 list: readonly Link[],
114 urls: readonly string[],
115 source: LinkSource,
116 at: number,
117): Link[] {
118 const merged = [...list]
119
120 for (const url of urls) {
121 const index = merged.findIndex(link => link.url === url)
122 const seen = merged[index]
123
124 if (seen === undefined) {
125 merged.push({ url, source, status: 'floating', mentions: 1, firstAt: at, lastAt: at })
126 } else {
127 merged[index] = { ...seen, mentions: seen.mentions + 1, lastAt: at }
128 }
129 }
130
131 return trim(merged)
132}
133
134/** The link the conversation keeps coming back to leads; between equals, the one mentioned last. */
135export const byPriority = (a: Link, b: Link): number => b.mentions - a.mentions || b.lastAt - a.lastAt
136
137/** What the band seats, in order: the pinned links, then the floating ones, each group by priority. */
138export function bandOrder(list: readonly Link[]): Link[] {
139 const pinned = list.filter(link => link.status === 'pinned').sort(byPriority)
140 const floating = list.filter(link => link.status === 'floating').sort(byPriority)
141
142 return [...pinned, ...floating]
143}
144
145const MAX_DOMAIN = 28
146const MAX_HINT = 16
147
148const siteOf = (url: string): string => new URL(url).host.replace(/^www\./, '')
149
150const closing = (segments: readonly string[], count: number): string => segments.slice(-count).join('/')
151
152/**
153 * What tells apart the links that share a site: the closing run of each
154 * one's path, as short as still differs from the others'. A last segment that
155 * is only a number or a few letters brings the one before it (`pull/12`).
156 */
157function hintsFor(paths: readonly (readonly string[])[]): string[] {
158 return paths.map((segments, index) => {
159 const start = /^(\d+|.{1,3})$/.test(segments.at(-1) ?? '') ? 2 : 1
160
161 for (let count = start; count <= segments.length; count += 1) {
162 const tail = closing(segments, count)
163
164 if (paths.every((other, at) => at === index || closing(other, count) !== tail)) {
165 return tail
166 }
167 }
168
169 return segments.join('/')
170 })
171}
172
173/**
174 * What each chip writes, by link: the site alone, so that one row seats many
175 * links, and beside it a short hint of the path only for links that share
176 * their site with another one on the band.
177 */
178export function chipLabels(links: readonly Link[]): Map<string, string> {
179 const bySite = new Map<string, Link[]>()
180
181 for (const link of links) {
182 const site = siteOf(link.url)
183 bySite.set(site, [...(bySite.get(site) ?? []), link])
184 }
185
186 const labels = new Map<string, string>()
187
188 for (const [site, group] of bySite) {
189 const hints =
190 group.length === 1
191 ? ['']
192 : hintsFor(group.map(link => new URL(link.url).pathname.split('/').filter(Boolean)))
193
194 group.forEach((link, index) => {
195 const hint = hints[index] ?? ''
196
197 labels.set(link.url, clip(site, MAX_DOMAIN) + (hint === '' ? '' : `/${clip(hint, MAX_HINT)}`))
198 })
199 }
200
201 return labels
202}
203
204/**
205 * Host and path, sized to `max` cells: how a toast names the one link it is
206 * about. The query string is never drawn: that is where tokens and signatures ride.
207 */
208export function labelOf(url: string, max: number): string {
209 const { host, pathname } = new URL(url)
210 const site = host.replace(/^www\./, '')
211 const segments = pathname.split('/').filter(Boolean)
212 const full = [site, ...segments].join('/')
213
214 if (full.length <= max || segments.length < 2) {
215 return clip(full, max)
216 }
217
218 // The end of a path names the page, the middle is the way there: the
219 // middle folds first, and as many closing segments stay as the room holds.
220 let tail = ''
221
222 for (const segment of [...segments].reverse()) {
223 const longer = `/${segment}${tail}`
224
225 if (site.length + 2 + longer.length > max) {
226 break
227 }
228
229 tail = longer
230 }
231
232 return clip(`${site}/…${tail === '' ? `/${segments.at(-1)}` : tail}`, max)
233}
234
235/**
236 * The address as the pane labels a hyperlink: the query folded to `?…`, the
237 * fragment kept. Where the terminal has no hyperlinks the pane writes the
238 * address whole instead, since only a complete one can be opened on a click.
239 */
240export function displayOf(url: string, max: number): string {
241 const { host, pathname, search, hash } = new URL(url)
242 const full = host + pathname.replace(/\/$/, '') + (search === '' ? '' : '?…') + hash
243
244 return clip(full, max)
245}
246types/index.d.ts 40 lines1/** Who brought the link into the conversation first. */
2export type LinkSource = 'you' | 'claude'
3
4/**
5 * `floating`: shown while it is recent, pushed off the band by newer links.
6 * `pinned`: the person locked it; it keeps its seat for the whole session.
7 * `dismissed`: the person hid it; it stays hidden even when mentioned again.
8 */
9export type LinkStatus = 'floating' | 'pinned' | 'dismissed'
10
11export type Link = {
12 /** Normalized href; the link's identity. */
13 url: string
14 source: LinkSource
15 status: LinkStatus
16 mentions: number
17 /**
18 * Milliseconds since the epoch. A link recovered from a transcript that
19 * predates the mod carries its message's position instead (a small number),
20 * which keeps the order and claims no time.
21 */
22 firstAt: number
23 lastAt: number
24}
25
26/** What `$.store` keeps under `session:<id>`, so a resume restores the band exactly. */
27export type SessionRecord = { links: Link[]; savedAt: number }
28
29declare module 'claude-code' {
30 interface PluginState {
31 'session-links': {
32 sessionId: string
33 links: Link[]
34 freshSince: number
35 /** The url whose dismiss was pressed once and waits for the second press, `*` when the question stands over every floating link; '' when none does. */
36 armed: string
37 }
38 }
39}
40