Click a file path in a reply and Finder opens with it selected

Click a file path in a reply and Finder opens with it selected.
Claude Code prints file paths as plain text. With this mod, a path in a reply that exists on disk becomes a link: pressing it shows a file selected in its folder in Finder, or opens a folder as itself, and a toast says which.
open and open -Rfile:// address after it, so a path shows twice, and pressing it does not always open it/plugin marketplace add alex2481kobe/claude-mods
/plugin install link@claude-mods
ui.render hook on assistant messages finds path-like text, resolves it against the session's folder and home, and keeps only paths that exist.file:// links in the reply's Markdown. A folder's URL ends in a slash, which is how a press tells it from a file.open -R <file> or open <folder> and toasts the result, or the error if it failed.hooks/register.tsx 51 lines1import type { Register } from 'claude-code'
2
3import { absolute, candidates, hrefOf, linkify, targetOf } from './paths'
4
5// The most a Markdown element draws; a longer reply keeps the engine's drawing.
6const MARKDOWN_LIMIT = 10000
7// The mark the engine draws before a reply on macOS, the one platform `open -R` has.
8const BULLET = '\u23fa '
9
10export const register: Register = on => {
11 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
12 if (e.surface !== 'terminal') return next(e)
13 const found = candidates(e.props.text)
14 if (found.length === 0) return next(e)
15
16 const cwd = await $.session.cwd()
17 const home = (await $.env.get('HOME')) ?? ''
18 const hrefs = new Map<string, string>()
19 for (const path of found) {
20 if (path.startsWith('~') && home === '') continue
21 const full = absolute(path, cwd, home)
22 const stat = await $.fs.stat(full).catch(() => undefined)
23 if (stat) hrefs.set(path, hrefOf(full, stat.kind === 'dir'))
24 }
25 const text = linkify(e.props.text, hrefs)
26 if (hrefs.size === 0 || text.length > MARKDOWN_LIMIT) return next(e)
27
28 // The engine's own frame of a reply block: a blank row above, the bullet
29 // opening a reply or its width in spaces, then the markdown.
30 const { Box, Markdown, Text } = $.ui.resolve(e)
31 return (
32 <Box flexDirection="row" marginTop={1}>
33 <Text color="text">{e.props.isFirstOfReply ? BULLET : ' '}</Text>
34 <Box flexGrow={1}>
35 <Markdown
36 key="link"
37 text={text}
38 pressableLinks={[...hrefs.values()]}
39 onLinkPress={async link => {
40 const target = targetOf(link.href)
41 if (target === undefined) return
42 const { exitCode, stderr } = await $.process.run(target.argv)
43 $.ui.toast(exitCode === 0 ? target.done : `${target.failed}: ${stderr.trim() || `exit ${exitCode}`}`)
44 }}
45 />
46 </Box>
47 </Box>
48 )
49 })
50}
51hooks/paths.ts 143 lines1// Finds the file paths a reply's markdown writes and turns them into file:
2// links. Pure: the caller decides which paths exist and passes their hrefs in.
3
4// One name in a path: letters, digits and `_ . @ + -`, at most one leading dot
5// (`.claude`, `.env`) and never ending in one, so the full stop after a path
6// is not part of it. A folder step may also be `.` or `..`.
7const SEG = String.raw`\.?[\w@+-](?:[\w.@+-]*[\w@+-])?`
8const DIR = String.raw`(?:${SEG}|\.{1,2})`
9// Rooted (`/a`, `~/a`, `./a`, `../a`), relative with a slash (`src/a.js`,
10// `src/`), a bare file name with an extension (`app.js`), or a dotfile (`.env`).
11const PATH = [
12 String.raw`(?:~|\.{1,2})?\/(?:${DIR}\/)*${SEG}\/?`,
13 String.raw`${SEG}(?:\/${DIR})*\/(?:${SEG})?`,
14 String.raw`[\w@+-]+(?:\.[\w@+-]+)*\.[A-Za-z][A-Za-z0-9]{0,9}`,
15 String.raw`\.[A-Za-z][\w@+-]*`,
16].join('|')
17const LINE = String.raw`(?::\d+(?::\d+)?)?`
18
19// Bounded so a path inside a longer word, URL or path is not taken.
20const IN_TEXT = new RegExp(String.raw`(?<![\w/.~@+:-])(${PATH})${LINE}(?![\w/@+-])`, 'g')
21const WHOLE = new RegExp(String.raw`^(${PATH})${LINE}$`)
22
23// Spans never rewritten inside: code fences, links, autolinks, bare URLs, and
24// inline code, which is linked whole when it is exactly one path.
25const SPANS = /(```|~~~)[\s\S]*?(?:\1|$)|!?\[[^\]\n]*\]\([^)\n]*\)|<[a-z]+:[^>\n]*>|[a-z][a-z0-9+.-]*:\/\/[^\s)]+|`[^`\n]+`/g
26
27type Part = { text: string; isFree: boolean }
28
29const split = (text: string): Part[] => {
30 const parts: Part[] = []
31 let at = 0
32 for (const m of text.matchAll(SPANS)) {
33 if (m.index > at) parts.push({ text: text.slice(at, m.index), isFree: true })
34 parts.push({ text: m[0], isFree: false })
35 at = m.index + m[0].length
36 }
37 if (at < text.length) parts.push({ text: text.slice(at), isFree: true })
38 return parts
39}
40
41const codePath = (span: string): string | undefined =>
42 span.startsWith('`') && !span.startsWith('``') ? WHOLE.exec(span.slice(1, -1))?.[1] : undefined
43
44const LINK = /^\[([^\]\n]*)\]\(([^)\n]*)\)$/
45const SCHEME = /^[a-z][a-z0-9+.-]*:/i
46
47const decoded = (text: string): string | undefined => {
48 try {
49 return decodeURIComponent(text)
50 } catch {
51 return undefined
52 }
53}
54
55// What a link the reply wrote itself points at, when that is a path: a
56// `file:` URL, or a target with no scheme (`/a/b.md`, `src/a.ts#L3`).
57const linkPath = (span: string): string | undefined => {
58 const target = (LINK.exec(span)?.[2] ?? '').trim().replace(/[#?].*$/, '')
59 if (target.startsWith('file:///')) return decoded(target.slice('file://'.length))
60 if (target === '' || SCHEME.test(target)) return undefined
61 return decoded(target.replace(/:\d+(?::\d+)?$/, ''))
62}
63
64// Each distinct path the text writes, as written less its `:line`, in order:
65// in its prose, as inline code, or as the target of a link it wrote.
66export const candidates = (text: string): string[] => {
67 const found = new Set<string>()
68 for (const part of split(text)) {
69 if (part.isFree) {
70 for (const m of part.text.matchAll(IN_TEXT)) found.add(m[1] ?? '')
71 } else {
72 found.add(codePath(part.text) ?? linkPath(part.text) ?? '')
73 }
74 }
75 found.delete('')
76 return [...found]
77}
78
79// Past this many characters a label shows only its last two names.
80const LONG_LABEL = 32
81
82// What a link to `path` (as written, `line` after it) shows: a file as inline
83// code, a folder as plain text ending in a slash, a long one cut to `.../a/b`.
84const labelOf = (path: string, line: string, isFolder: boolean): string => {
85 const bare = path.replace(/\/$/, '')
86 const names = bare.split('/')
87 const isLong = bare.length + line.length > LONG_LABEL && names.length > 2
88 const shown = isLong ? `\u2026/${names.slice(-2).join('/')}` : bare
89 return isFolder ? `${shown.replace(/[\\_~*]/g, '\\$&')}/` : `\`${shown}${line}\``
90}
91
92const linkOf = (path: string, line: string, href: string) => `[${labelOf(path, line, href.endsWith('/'))}](${href})`
93
94// The text with each path `hrefs` holds made a link to its href; a link the
95// reply wrote keeps its label and gets the href.
96export const linkify = (text: string, hrefs: ReadonlyMap<string, string>): string =>
97 split(text)
98 .map(part => {
99 if (part.isFree) {
100 return part.text.replace(IN_TEXT, (all, path: string) => {
101 const href = hrefs.get(path)
102 return href === undefined ? all : linkOf(path, all.slice(path.length), href)
103 })
104 }
105 const code = codePath(part.text)
106 const href = hrefs.get(code ?? linkPath(part.text) ?? '')
107 if (href === undefined) return part.text
108 if (code !== undefined) return linkOf(code, part.text.slice(1 + code.length, -1), href)
109 return `[${LINK.exec(part.text)?.[1] ?? ''}](${href})`
110 })
111 .join('')
112
113// `~` as `home`, a relative path under `cwd`, `.` and `..` folded away.
114export const absolute = (path: string, cwd: string, home: string): string => {
115 const full = path === '~' || path.startsWith('~/') ? home + path.slice(1) : path.startsWith('/') ? path : `${cwd}/${path}`
116 const out: string[] = []
117 for (const seg of full.split('/')) {
118 if (seg === '..') out.pop()
119 else if (seg !== '' && seg !== '.') out.push(seg)
120 }
121 return `/${out.join('/')}`
122}
123
124// Every name escaped, parentheses too, so the href cannot end the markdown link.
125const escape = (seg: string) => encodeURIComponent(seg).replace(/[()!*']/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
126
127// A folder's URL ends in a slash, as file URLs write one, which is how a press
128// tells a folder from a file.
129export const hrefOf = (path: string, isFolder: boolean): string =>
130 `file://${path.split('/').map(escape).join('/')}${isFolder && path !== '/' ? '/' : ''}`
131
132export type Target = { argv: string[]; done: string; failed: string }
133
134// What a press on `href` runs, Finder showing a file selected in its folder or
135// a folder opened as itself, and what to say after; nothing for another link.
136export const targetOf = (href: string): Target | undefined => {
137 if (!href.startsWith('file:///')) return undefined
138 const path = decodeURIComponent(href.slice('file://'.length))
139 return href.endsWith('/')
140 ? { argv: ['open', path], done: `Opened ${path} in Finder`, failed: `Could not open ${path}` }
141 : { argv: ['open', '-R', path], done: `Revealed ${path} in Finder`, failed: `Could not reveal ${path}` }
142}
143