SLOPSHOPPER

link-nav

Lists the files sent as cards and the links in replies this session, with fuzzy search; open one or jump to where it was sent.

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · link-nav
│ ┃ Links & files ✕ › fix the failing auth test and add an audit log call │ ┃ fuzzy search ⏎ open first │ ┃ No files or links yet ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ 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 │ │ › /link-nav │ ⎿ link-nav: Link navigator on │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Links & files
fuzzy search ⏎ open first No files or links yet
README

link-nav

A pane that lists every file sent to you as a card (SendUserFile) and every link in Claude's replies this session, newest first. Type in the search field to filter: fuzzy, so rprt finds report, and every word you type must match. Press a row to open it with macOS open on the machine the session runs on: the browser for a URL, the default app for a file (elsewhere a press shows the error). A file that would run when opened (an app, a script, an installer) is shown in Finder instead. Press › to jump the transcript to where it was sent. Enter in the search field opens the first match; Esc closes the pane.

Each row reads number time name · where: its number in order of arrival (kept while a search reorders the rows) and the time it was handed over, HH:MM, or MM-DD HH:MM when not today. Rows read back after a resume have no time.

A line above the prompt shows the count and the newest one while the pane is closed; press it to open the pane with the search field focused. /link-nav shows or hides the pane, and hidden stays hidden in new sessions until you run it again.

Links inside code (backticks or fences) are left out: a URL in a command is not one being handed over. A file linked in a reply and the same file sent as a card share one row.

Jumping needs the fullscreen layout. Rows read back from earlier in a resumed session open but only cards there can jump; links found live can. Files and links a subagent sends are not listed.

Options

language: en (default) or zh-TW, from /config or settings:

"pluginConfigs": { "link-nav@claude-code-mods": { "options": { "language": "zh-TW" } } }
Source 4 files
hooks/register.tsx 202 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { NavItem } from '../types'
4import { arrivalNumbers, denyMessage, fileItems, filterItems, fitLine, linkItems, mergeItems, openArgs, rowText } from './links'
5import type { Found } from './links'
6import { stringsFor } from './strings'
7import type { Strings } from './strings'
8
9const PANE = 'link-nav'
10// Change the tag when NavItem's fields change: it declines what an earlier version stored.
11const items = atom({ plugin: 'link-nav', key: 'items' } as const, [] as NavItem[], { shape: 'items-1' })
12const query = atom({ plugin: 'link-nav', key: 'query' } as const, '')
13// Bumped on every open and close so the line above the prompt re-reads whether the pane is drawn.
14const paneTick = atom({ plugin: 'link-nav', key: 'paneTick' } as const, 0)
15// ponytail: a long session lists every row; draw the best 200 of a search, the newest 200 without one
16const DRAWN = 200
17
18type Mode = 'open' | 'off'
19
20async function readMode($: EngineInterface): Promise<Mode> {
21  return (await $.store.get('mode')) === 'off' ? 'off' : 'open'
22}
23
24async function home($: EngineInterface): Promise<string | undefined> {
25  return (await $.env.get('HOME')) || undefined
26}
27
28async function add($: EngineInterface, found: Found[], at?: number): Promise<void> {
29  if (!found.length) return
30  const stamped = at === undefined ? found : found.map(item => ({ ...item, at }))
31  await update($, items, list => mergeItems(list, stamped))
32}
33
34async function now($: EngineInterface): Promise<number | undefined> {
35  return $.clock.now().catch(() => undefined)
36}
37
38// The transcript so far: a resumed session's earlier replies, and on a reload what was listed already.
39async function readTranscript($: EngineInterface): Promise<void> {
40  const messages = await $.session.messages()
41  if (!Array.isArray(messages)) return
42  const cwd = await $.session.cwd()
43  const h = await home($)
44  const found: Found[] = []
45  for (const message of messages) {
46    if (message.role !== 'assistant') continue
47    found.push(...linkItems(message.text, undefined, h))
48    for (const use of message.toolUses) {
49      if (use.tool === 'SendUserFile' && !use.isError) found.push(...fileItems(use.input.files, use.tool_use_id, cwd, h))
50    }
51  }
52  await add($, found)
53}
54
55async function openPane($: EngineInterface, s: Strings): Promise<void> {
56  // A pane opened for a look starts unfiltered; it takes the keys so typing searches at once.
57  await update($, query, () => '')
58  // Esc closes it while it holds the keys; the line above the prompt opens it again.
59  await $.ui.open({ id: PANE, title: s.title, rows: 10, columns: 40, focus: true, closeOnEscape: true })
60  await update($, paneTick, n => n + 1)
61}
62
63async function openItem($: EngineInterface, item: NavItem, s: Strings): Promise<void> {
64  // ponytail: macOS `open` on the machine the session runs on; elsewhere the press toasts the error
65  const { argv, revealed } = openArgs(item)
66  const run = await $.process.run(argv).catch((error: unknown) => ({ exitCode: -1, stderr: String(error) }))
67  if (run.exitCode !== 0) $.ui.toast(`${s.openFailed}${run.stderr.trim().slice(0, 200)}`)
68  else if (revealed) $.ui.toast(s.revealed)
69}
70
71async function jumpTo($: EngineInterface, item: NavItem, s: Strings): Promise<void> {
72  if (!item.jump) return
73  const result = await $.ui.scroll({ to: { requestId: item.jump }, block: 'start' })
74  if (result.deny) $.ui.toast(denyMessage(result.deny, s))
75}
76
77export const register: Register = (on, options) => {
78  const s = stringsFor(options?.language)
79
80  on('session.start', async ($, e, next) => {
81    await $.command.register({ name: 'link-nav', description: 'Show or hide the files and links of this session (remembered for new sessions)' })
82    const started = await next(e)
83    try {
84      await readTranscript($)
85    } catch {
86      // Reading back what came before must not hold up the session.
87    }
88    return started
89  })
90
91  // A card's files, with the call's id: the ToolUse row the card is drawn in answers to it.
92  on('tool.call', { tool: 'SendUserFile' }, async ($, e, next) => {
93    const result = await next(e)
94    try {
95      // ponytail: a subagent's cards are not listed
96      const files = (e as unknown as { files?: unknown }).files
97      if (!e.agentId && !('deny' in result) && !('isError' in result && result.isError)) {
98        await add($, fileItems(files, e.tool_use_id, await $.session.cwd(), await home($)), await now($))
99      }
100    } catch {
101      // Listing must not change what the tool answered.
102    }
103    return result
104  })
105
106  on('session.append', { door: 'response' }, async ($, e, next) => {
107    const stored = await next(e)
108    try {
109      if (!e.agentId) {
110        const id = 'uuid' in stored ? stored.uuid : undefined
111        const content: unknown = e.message.content
112        const text = typeof content === 'string' ? content : Array.isArray(content)
113          ? content.filter(block => block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string')
114            .map(block => block.text as string).join('\n')
115          : ''
116        await add($, linkItems(text, id, await home($)), await now($))
117      }
118    } catch {
119      // Listing must not interrupt the transcript append.
120    }
121    return stored
122  })
123
124  on('command.run', { command: 'link-nav' }, async $ => {
125    if ((await $.ui.panes()).some(p => p.id === PANE && p.isPlaced)) {
126      await $.ui.close({ id: PANE })
127      await $.store.set('mode', 'off')
128      await update($, paneTick, n => n + 1)
129      return { text: s.off }
130    }
131    await $.store.set('mode', 'open')
132    await openPane($, s)
133    return { text: s.on }
134  })
135
136  on('ui.close', async ($, e, next) => {
137    const result = await next(e)
138    if (e.id === PANE) await update($, paneTick, n => n + 1).catch(() => undefined)
139    return result
140  })
141
142  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
143    if (e.props.hasSurvey) return next(e)
144    await read($, paneTick)
145    const list = await read($, items)
146    if (list.length === 0 || (await readMode($)) === 'off') return next(e)
147    if ((await $.ui.panes()).some(p => p.id === PANE && p.isPlaced)) return next(e)
148    const { Box, Button } = $.ui.resolve(e)
149    const below = await next(e)
150    const newest = filterItems(list, '')[0]
151    return (
152      <Box flexDirection="column">
153        {/* No hotkey: a plain Button draws one as `n: ` ahead of the label. */}
154        <Button key="link-nav-open" plain dimColor label={fitLine(s.navLabel(list.length, newest?.label), Math.max(24, e.props.bodyColumns - 4))} onPress={() => openPane($, s)} />
155        {below}
156      </Box>
157    )
158  })
159
160  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
161    // Input is not in every surface's table (a phone has none): there the list shows unfiltered.
162    const table = $.ui.resolve(e)
163    const { Box, Button, Text } = table
164    const Input = 'Input' in table ? table.Input : undefined
165    const all = await read($, items)
166    const typed = Input ? await read($, query) : ''
167    const shown = filterItems(all, typed)
168    const numbers = arrivalNumbers(all)
169    const time = (await now($)) ?? Date.now()
170    const width = Math.max(12, e.props.bodyColumns - 1)
171    return (
172      <Box flexDirection="column">
173        {Input && (
174          <Input
175            key="search"
176            autoFocus
177            placeholder={s.placeholder}
178            value={typed}
179            submitLabel={s.submit}
180            onInput={value => { void update($, query, () => value) }}
181            onSubmit={async value => {
182              const first = filterItems(await read($, items), value)[0]
183              if (first) await openItem($, first, s)
184            }}
185          />
186        )}
187        {typed.trim() !== '' && <Text dimColor>{s.count(shown.length, all.length)}</Text>}
188        {all.length === 0 && <Text dimColor>{s.empty}</Text>}
189        {all.length > 0 && shown.length === 0 && <Text dimColor>{s.noMatch}</Text>}
190        {shown.slice(0, DRAWN).map((item, i) => (
191          <Box flexDirection="row">
192            <Button key={`open-${i}`} plain label={rowText(item, numbers.get(item.href) ?? 0, time, width - 2)} onPress={() => openItem($, item, s)} />
193            {item.jump
194              ? <Button key={`jump-${i}`} plain label=" ›" onPress={() => jumpTo($, item, s)} />
195              : <Text>{'  '}</Text>}
196          </Box>
197        ))}
198      </Box>
199    )
200  })
201}
202
hooks/links.ts 198 lines
1import type { NavItem } from '../types'
2
3export type Found = Omit<NavItem, 'seq'>
4
5// East Asian wide and fullwidth ranges plus emoji: a terminal cell pair each (turn-nav's table).
6const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩\u{1F300}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u
7
8/** Terminal cells a string takes: wide characters count two. */
9export function cellWidth(text: string): number {
10  let width = 0
11  for (const char of text) width += WIDE.test(char) ? 2 : 1
12  return width
13}
14
15/** Cut with … when needed, then pad to `width` cells, so the whole row is the tap target. */
16export function fitLine(text: string, width: number): string {
17  let body = text.replace(/\s+/g, ' ')
18  if (cellWidth(body) > width) {
19    let out = ''
20    for (const char of body) {
21      if (cellWidth(out + char) > width - 1) break
22      out += char
23    }
24    body = `${out}…`
25  }
26  return body + ' '.repeat(Math.max(0, width - cellWidth(body)))
27}
28
29// Code is not read for links: a URL inside a command or a log is not one the reply hands over.
30const CODE = /```[\s\S]*?```|`[^`\n]*`/g
31// An address may hold one level of parentheses, as Wikipedia's do: /wiki/Foo_(bar).
32const MD_LINK = /\[([^\]\n]*)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g
33const BARE = /\bhttps?:\/\/[^\s<>"'`\]]+/g
34const TRAILING = /[.,;:!?。,、;:!?)》」』]+$/
35
36const blank = (text: string) => ' '.repeat(text.length)
37const count = (text: string, char: string) => text.split(char).length - 1
38
39/** A bare URL less the punctuation after it and a `)` that closes the prose around it, not the URL. */
40function trimUrl(raw: string): string {
41  let url = raw.replace(TRAILING, '')
42  while (url.endsWith(')') && count(url, '(') < count(url, ')')) url = url.slice(0, -1).replace(TRAILING, '')
43  return url
44}
45
46function decode(text: string): string {
47  try {
48    return decodeURI(text)
49  } catch {
50    return text
51  }
52}
53
54function shortDir(dir: string, home: string | undefined): string {
55  return home && (dir === home || dir.startsWith(`${home}/`)) ? `~${dir.slice(home.length)}` : dir
56}
57
58/** A file the session sent; a relative path is read against `cwd`, as SendUserFile reads it. */
59export function fileItem(path: string, jump: string | undefined, cwd: string, home: string | undefined): Found {
60  const abs = path.startsWith('/') ? path
61    : path.startsWith('~/') && home ? `${home}${path.slice(1)}`
62    : `${cwd.replace(/\/+$/, '')}/${path.replace(/^\.\//, '')}`
63  const at = abs.replace(/\/+$/, '').lastIndexOf('/')
64  const dir = abs.slice(0, at) || '/'
65  return { kind: 'file', href: `file://${encodeURI(abs)}`, label: abs.slice(at + 1), where: shortDir(dir, home), ...(jump ? { jump } : {}) }
66}
67
68/** The files of one SendUserFile call; anything that is not a list of paths gives none. */
69export function fileItems(files: unknown, jump: string | undefined, cwd: string, home: string | undefined): Found[] {
70  return Array.isArray(files) ? files.filter((f): f is string => typeof f === 'string' && f.length > 0).map(f => fileItem(f, jump, cwd, home)) : []
71}
72
73/** One link; a file:// one becomes a file, so it meets the same file sent as a card under one address. */
74export function linkItem(href: string, text: string, jump: string | undefined, home: string | undefined): Found | undefined {
75  if (href.startsWith('file://')) return fileItem(decode(href.slice('file://'.length)), jump, '/', home)
76  const host = /^https?:\/\/([^/?#\s]+)/.exec(href)?.[1]
77  if (!host) return undefined
78  const own = text.trim()
79  return { kind: 'url', href, label: own && own !== href ? own : href.replace(/^https?:\/\//, ''), where: host, ...(jump ? { jump } : {}) }
80}
81
82/** The markdown links and bare http(s) URLs of a reply's text, code left out, in the order written. */
83export function linkItems(text: string, jump: string | undefined, home: string | undefined): Found[] {
84  // Blanked to the same length, so every find keeps its place in the text.
85  const hits: { at: number; item: Found }[] = []
86  const rest = text.replace(CODE, blank).replace(MD_LINK, (whole: string, label: string, href: string, at: number) => {
87    const item = linkItem(href, label, jump, home)
88    if (item) hits.push({ at, item })
89    return blank(whole)
90  })
91  for (const match of rest.matchAll(BARE)) {
92    const item = linkItem(trimUrl(match[0]), '', jump, home)
93    if (item) hits.push({ at: match.index ?? 0, item })
94  }
95  return hits.sort((a, b) => a.at - b.at).map(hit => hit.item)
96}
97
98const isBare = (item: Found) => item.kind === 'url' && item.label === item.href.replace(/^https?:\/\//, '')
99
100/**
101 * New finds merged into the list by address: a find already listed moves to newest and keeps the jump it had
102 * when the find brings none (a reload's re-read of the transcript knows no reply row ids). Keeps the newest `keep`.
103 */
104export function mergeItems(list: NavItem[], found: Found[], keep = 500): NavItem[] {
105  let seq = list.reduce((top, item) => Math.max(top, item.seq), 0)
106  const byHref = new Map(list.map(item => [item.href, item]))
107  for (const item of found) {
108    const old = byHref.get(item.href)
109    seq += 1
110    const jump = item.jump ?? old?.jump
111    const at = item.at ?? old?.at
112    const label = old && isBare(item) ? old.label : item.label
113    byHref.set(item.href, { ...item, label, seq, ...(jump ? { jump } : {}), ...(at === undefined ? {} : { at }) })
114  }
115  return [...byHref.values()].sort((a, b) => a.seq - b.seq).slice(-keep)
116}
117
118/**
119 * How well `query` matches `text`, or -1: each whitespace-separated token must appear in order (letters may
120 * have gaps); adjacent letters, letters opening a word and a token found whole score more.
121 * Twin of turn-nav's hooks/fuzzy.ts: change both.
122 */
123export function fuzzyScore(query: string, text: string): number {
124  const hay = text.toLowerCase()
125  let total = 0
126  for (const token of query.toLowerCase().split(/\s+/).filter(Boolean)) {
127    let at = -1
128    let prev = -2
129    for (const char of token) {
130      at = hay.indexOf(char, at + 1)
131      if (at < 0) return -1
132      total += 1 + (at === prev + char.length ? 3 : 0) + (at === 0 || /[\s/._\-·:#]/.test(hay[at - 1] ?? '') ? 2 : 0)
133      prev = at
134    }
135    if (hay.includes(token)) total += 2 * token.length
136  }
137  return total
138}
139
140// A file's encoded address would match latin letters out of %E4%B8 runs; its name and folder say it all.
141const haystack = (item: NavItem) => item.kind === 'file' ? `${item.label} ${item.where}` : `${item.label} ${item.href}`
142
143/** Newest first; with a query, the matches only, best first. */
144export function filterItems(list: NavItem[], query: string): NavItem[] {
145  const newest = [...list].sort((a, b) => b.seq - a.seq)
146  if (!query.trim()) return newest
147  return newest
148    .map(item => ({ item, score: fuzzyScore(query, haystack(item)) }))
149    .filter(hit => hit.score >= 0)
150    .sort((a, b) => b.score - a.score || b.item.seq - a.item.seq)
151    .map(hit => hit.item)
152}
153
154const pad = (v: number) => String(v).padStart(2, '0')
155
156/** `HH:MM` in local time; `MM-DD HH:MM` when `at` is not on the same day as `now`. Twin of turn-nav's stamp. */
157export function stamp(at: number, now: number): string {
158  const d = new Date(at)
159  const n = new Date(now)
160  const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`
161  const sameDay = d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth() && d.getDate() === n.getDate()
162  return sameDay ? time : `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}`
163}
164
165/** Each item's number in order of arrival, 1 for the first, kept while a search reorders the rows. */
166export function arrivalNumbers(list: NavItem[]): Map<string, number> {
167  return new Map([...list].sort((a, b) => a.seq - b.seq).map((item, i) => [item.href, i + 1]))
168}
169
170/** A row: its number, the time it came, kind, name, then where it lives, fitted to `width` cells. */
171export function rowText(item: NavItem, n: number, now: number, width: number): string {
172  const when = item.at === undefined ? '' : `${stamp(item.at, now)} `
173  return fitLine(`${n} ${when}${item.kind === 'file' ? '📄' : '🔗'} ${item.label} · ${item.where}`, width)
174}
175
176/** What `open` is handed: a file's own path, or the URL. */
177export function openTarget(item: NavItem): string {
178  return item.kind === 'file' ? decode(item.href.slice('file://'.length)) : item.href
179}
180
181// Files `open` would run or install rather than show. Any reply can link one (a prompt injection in a page the model
182// read is enough), so a press reveals these in Finder instead of opening them.
183const RUNS = /\.(app|command|tool|terminal|sh|zsh|bash|py|rb|pl|scpt|scptd|applescript|workflow|action|pkg|mpkg|dmg|jar|exe|bat|ps1|webloc|inetloc|fileloc)\/?$/i
184
185/** The argv for a press, and whether it only reveals the file. */
186export function openArgs(item: NavItem): { argv: string[]; revealed: boolean } {
187  const target = openTarget(item)
188  const revealed = item.kind === 'file' && RUNS.test(target)
189  return { argv: ['/usr/bin/open', ...(revealed ? ['-R'] : []), target], revealed }
190}
191
192// The engine moves transcript rows only in the fullscreen layout; elsewhere it answers this deny.
193const NOT_FULLSCREEN = 'transcript not scrollable here'
194
195export function denyMessage(deny: string, s: { denyFullscreen: string; denyPrefix: string }): string {
196  return deny === NOT_FULLSCREEN ? s.denyFullscreen : `${s.denyPrefix}${deny}`
197}
198
hooks/strings.ts 53 lines
1export type Strings = {
2  title: string
3  off: string
4  on: string
5  empty: string
6  noMatch: string
7  placeholder: string
8  submit: string
9  count: (shown: number, total: number) => string
10  navLabel: (count: number, newest: string | undefined) => string
11  denyFullscreen: string
12  denyPrefix: string
13  openFailed: string
14  revealed: string
15}
16
17export const STRINGS: Record<'en' | 'zh-TW', Strings> = {
18  en: {
19    title: 'Links & files',
20    off: 'Link navigator off (stays off in new sessions; /link-nav turns it back on)',
21    on: 'Link navigator on',
22    empty: 'No files or links yet',
23    noMatch: 'Nothing matches',
24    placeholder: 'fuzzy search',
25    submit: 'open first',
26    count: (shown, total) => `${shown} of ${total}`,
27    navLabel: (count, newest) => `🔗 Links · ${count}${newest ? ` · ${newest}` : ''}`,
28    denyFullscreen: "Can't jump: this session is not fullscreen",
29    denyPrefix: "Can't jump: ",
30    openFailed: "Couldn't open: ",
31    revealed: 'Shown in Finder, not opened: a file like this would run',
32  },
33  'zh-TW': {
34    title: '連結與檔案',
35    off: '連結導覽已關閉(新 session 也不再開啟,/link-nav 可再開)',
36    on: '連結導覽已開啟',
37    empty: '還沒有檔案或連結',
38    noMatch: '沒有符合的',
39    placeholder: '模糊搜尋',
40    submit: '開第一個',
41    count: (shown, total) => `${shown} / ${total}`,
42    navLabel: (count, newest) => `🔗 連結 · ${count} 個${newest ? ` · ${newest}` : ''}`,
43    denyFullscreen: '跳不過去:這個 session 不是全螢幕',
44    denyPrefix: '跳不過去:',
45    openFailed: '開不了:',
46    revealed: '已在 Finder 中標出,沒有直接開:這類檔案一開就會執行',
47  },
48}
49
50export function stringsFor(language: unknown): Strings {
51  return language === 'zh-TW' ? STRINGS['zh-TW'] : STRINGS.en
52}
53
types/index.d.ts 23 lines
1/** One file or link the session handed over. */
2export type NavItem = {
3  kind: 'file' | 'url'
4  /** What it opens: an http(s) URL, or a file:// URL of an absolute path. */
5  href: string
6  /** A file's name, or a link's own text. */
7  label: string
8  /** A file's folder, or a link's host. */
9  where: string
10  /** The transcript row it came in: a SendUserFile call's tool_use_id, or a reply row's uuid. */
11  jump?: string
12  /** Order of arrival, newest highest. */
13  seq: number
14  /** When it was last handed over (ms since the epoch); absent for what a resume read back. */
15  at?: number
16}
17
18declare module 'claude-code' {
19  interface PluginState {
20    'link-nav': { items: Shaped<NavItem[]>; query: string; paneTick: number }
21  }
22}
23