SLOPSHOPPER

session-links

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…

newpanebandcommandtoastprompt
v0.3.0MITupdated 2026-10-09samaphp/session-links
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-links
│ ┃ Links ✕ › fix the failing auth test and add an audit log call │ ┃ Links 0 pinned · 0 floating · 0 dismissed │ ┃ add : type or paste an address ⏎ pin it ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Every URL you or Claude mention lands here ⏺ Update(src/auth.ts) │ ┃ and floats above the prompt. ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /links │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Links
Links 0 pinned · 0 floating · 0 dismissed add : type or paste an address ⏎ pin it Every URL you or Claude mention lands here and floats above the prompt.
README

session-links

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  ×    ≡

Why

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.

Install

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.

The band

ControlWhat 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 addressOpens 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 allShown 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 moreOpens 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.

/links

The 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).

What is collected

  • Addresses in what you type, in slash-command rows, and in Claude's replies.
  • Left out: tool output (one search result or lockfile would bury the links the conversation is about), and addresses a writer shortened with ….
  • Query strings are never drawn on the band: that is where tokens and signatures ride.

Terminal notes

  • In Claude Code's default layout the mouse does not reach the band; the keyboard does. The fullscreen layout (/tui fullscreen) makes every control clickable.
  • On a terminal Claude Code does not recognise as hyperlink-capable, set 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.
  • Everything is laid out for terminals as narrow as 87 columns.

How it works

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.

Develop

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/

License

MIT. See LICENSE.

Source 3 files
hooks/register.tsx 830 lines
1import { 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}
830
hooks/links.ts 246 lines
1import 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}
246
types/index.d.ts 40 lines
1/** 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