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.

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.
language: en (default) or zh-TW, from /config or settings:
"pluginConfigs": { "link-nav@claude-code-mods": { "options": { "language": "zh-TW" } } }hooks/register.tsx 202 lines1import { 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}
202hooks/links.ts 198 lines1import 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}
198hooks/strings.ts 53 lines1export 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}
53types/index.d.ts 23 lines1/** 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