Click URLs and file paths in Claude's replies to open them in the browser and editor you choose

Claude prints a URL or a file path, and you copy it, switch windows, and paste it somewhere. This mod makes them links: one click opens the URL in the browser you chose and the file in your editor, at the line Claude named.
https://… opens in the system browser, or the one you pick.docs/a.md:3, ~/notes.md, C:\work\a.md open in VS Code at line 3. Paths are linked only when the file exists, relative ones against the session's directory.It needs Claude Code's fullscreen layout ("tui": "fullscreen"), where a plain click reaches the mod. Ctrl-click still opens a link the terminal's way.
At the prompt of a Claude Code session:
/plugin install reply-linker --marketplace navishachiku/reply-linker
Or from a shell:
claude plugin install reply-linker --marketplace navishachiku/reply-linker
Set them with /plugin configure reply-linker@reply-linker in Claude Code, or --config KEY=VALUE on claude plugin install:
| Setting | Default | Values |
|---|---|---|
| Browser | system | system, chrome, safari, edge, firefox |
| Editor command | code | any command; code, cursor, windsurf, codium also jump to the line |
If the chosen app fails to start, the link falls back to the system opener, and a toast says so when nothing could open it.
| Platform | URLs | Files |
|---|---|---|
| macOS | open -a <browser> | editor, then open |
| WSL | Windows browser via powershell.exe | editor, then Windows Explorer |
| Linux | <browser> or xdg-open | editor, then xdg-open |
| Windows | Start-Process | editor via cmd, then Explorer |
Tested on macOS. On a Windows machine running Claude inside Herdr, both native Windows and WSL draw the links and react to hover. On native Windows, if the environment variable CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN is set, Claude does not receive the mouse and a single click does nothing; unset it to restore clicking. WSL is not affected.
On macOS inside Herdr, Claude draws links as plain text, so they look like the words around them. Add "FORCE_HYPERLINK": "1" to env in Claude's settings.json and links are drawn in the link color.
<!-- Remove the paragraph above once links have their style inside Herdr on macOS without it, after a Claude Code or Herdr update. -->
claude --plugin-dir ./reply-linker
claude plugin test ./reply-linker
hooks/linkify.ts finds and links paths, hooks/opener.ts builds the open commands per platform, and hooks/register.tsx draws replies and handles clicks. Each has its tests beside it.
hooks/register.tsx 131 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { absolute, candidates, fileHref, linkify, parseFileHref } from './linkify.ts'
4import { detect, fileCommands, urlCommands, type Command, type Options, type Platform } from './opener.ts'
5
6type Found = { isDir: boolean }
7
8// Paths that exist are remembered; missing ones are asked again next draw,
9// since the reply may name a file the turn is about to write.
10const found = new Map<string, Found>()
11let platform: Platform | undefined
12
13// A failed `uname` is not an answer: the guess for this call is Linux, but it
14// is not remembered, so the next call asks again.
15async function detectPlatform($: EngineInterface): Promise<{ platform: Platform; settled: boolean }> {
16 const [wslDistro, os] = await Promise.all([$.env.get('WSL_DISTRO_NAME'), $.env.get('OS')])
17 if (wslDistro || os === 'Windows_NT') return { platform: detect({ wslDistro, os }), settled: true }
18 const uname = await $.process.run(['uname', '-s']).then(
19 r => (r.exitCode === 0 ? r.stdout : ''),
20 () => '',
21 )
22 return { platform: detect({ uname }), settled: uname.trim() !== '' }
23}
24
25async function platformOf($: EngineInterface): Promise<Platform> {
26 if (platform) return platform
27 const det = await detectPlatform($)
28 if (det.settled) platform = det.platform
29 return det.platform
30}
31
32async function lookup($: EngineInterface, abs: string): Promise<Found | undefined> {
33 const hit = found.get(abs)
34 if (hit) return hit
35 const stat = await $.fs.stat(abs).catch(() => undefined)
36 if (!stat || stat.kind === 'other') return undefined
37 const entry = { isDir: stat.kind === 'dir' }
38 found.set(abs, entry)
39 return entry
40}
41
42// Why a command failed: the exit code with the start of its stderr, or the
43// error that kept it from starting.
44const REASON_CHARS = 120
45
46function describe(r: { exitCode: number; stderr: string } | Error): string {
47 if (r instanceof Error) return r.message
48 const err = r.stderr.trim().replace(/\s+/g, ' ').slice(0, REASON_CHARS)
49 return err ? `exit ${r.exitCode}: ${err}` : `exit ${r.exitCode}`
50}
51
52// Returns an empty list when a command opened the target, else one line per
53// command that was tried.
54async function run($: EngineInterface, commands: Command[]): Promise<string[]> {
55 const failures: string[] = []
56 for (const c of commands) {
57 try {
58 const r = await $.process.run(c.argv, { timeoutMs: 15000 })
59 if (c.anyExit || r.exitCode === 0) return []
60 failures.push(`${c.argv[0]}: ${describe(r)}`)
61 } catch (e) {
62 failures.push(`${c.argv[0]}: ${describe(e instanceof Error ? e : new Error(String(e)))}`)
63 }
64 }
65 return failures
66}
67
68async function winPathOf($: EngineInterface, path: string): Promise<string | undefined> {
69 const r = await $.process.run(['wslpath', '-w', path]).catch(() => undefined)
70 return r?.exitCode === 0 ? r.stdout.trim() : undefined
71}
72
73async function open($: EngineInterface, href: string, opts: Options): Promise<void> {
74 const p = await platformOf($)
75 const file = parseFileHref(href)
76 let failures: string[]
77 if (!file) {
78 failures = await run($, urlCommands(p, href, opts))
79 } else {
80 const isDir = (await lookup($, file.path))?.isDir ?? false
81 const winPath = p === 'wsl' ? await winPathOf($, file.path) : undefined
82 failures = await run($, fileCommands(p, { ...file, isDir, winPath }, opts))
83 }
84 if (failures.length > 0) {
85 $.ui.toast(`reply-linker: could not open ${href}\n${failures.join('\n')}`, { timeoutMs: 15000 })
86 }
87}
88
89// Hrefs for the paths in `text` that exist, keyed by path and line as written.
90async function resolvePaths($: EngineInterface, text: string): Promise<Map<string, string>> {
91 const [cwd, home, userProfile] = await Promise.all([$.session.cwd(), $.env.get('HOME'), $.env.get('USERPROFILE')])
92 const hrefs = new Map<string, string>()
93 for (const c of candidates(text)) {
94 if (c.kind !== 'path') continue
95 const abs = absolute(c.path, cwd, home ?? userProfile)
96 if (abs && (await lookup($, abs))) hrefs.set(`${c.path}:${c.line ?? ''}`, fileHref(abs, c.line))
97 }
98 return hrefs
99}
100
101export const register: Register = (on, options) => {
102 const opts: Options = {
103 browser: typeof options.browser === 'string' ? options.browser : 'system',
104 editor: typeof options.editor === 'string' ? options.editor : 'code',
105 }
106
107 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
108 if (e.props.isSummary) return next(e)
109 const cands = candidates(e.props.text)
110 if (cands.length === 0) return next(e)
111
112 const hrefs = await resolvePaths($, e.props.text)
113 if (hrefs.size === 0 && !cands.some(c => c.kind === 'url')) return next(e)
114
115 const text = linkify(e.props.text, (path, line) => hrefs.get(`${path}:${line ?? ''}`))
116 const bullet = (await platformOf($)) === 'macos' ? '⏺' : '●'
117 const { Box, Text, Markdown } = $.ui.resolve(e)
118
119 return (
120 <Box flexDirection="row">
121 <Box minWidth={2}>
122 <Text>{e.props.isFirstOfReply ? bullet : ' '}</Text>
123 </Box>
124 <Box flexDirection="column" flexGrow={1}>
125 <Markdown key={`reply-${e.requestId}`} text={text} onLinkPress={link => open($, link.href, opts)} />
126 </Box>
127 </Box>
128 )
129 })
130}
131hooks/linkify.ts 139 lines1// Finds URLs and file paths in a reply's markdown and turns them into links.
2// Pure text work: whether a path exists is decided by the caller.
3
4export type Candidate = { kind: 'url'; url: string } | { kind: 'path'; path: string; line?: number }
5
6type Segment = { text: string; kind: 'skip' | 'code' | 'plain' }
7
8export type Resolve = (path: string, line?: number) => string | undefined
9
10// Full-width brackets (\uFF08 \uFF09 \u300C \u300D) end a URL in CJK prose.
11const URL_RE = /https?:\/\/[^\s<>`"'()\uFF08\uFF09\u300C\u300D\[\]]+/g
12const NAME = String.raw`[\w@+-][\w.@+-]*\.\w+`
13const DIRS = String.raw`(?:[\w.@+-]+[\\/])*`
14// POSIX (`/a/b.md`, `~/b.md`, `./b.md`, `src/b.md`) or Windows (`C:\a\b.md`).
15const PATH_BODY = String.raw`(?:[A-Za-z]:[\\/]${DIRS}${NAME}|(?:~|\.{1,2})?\/?${DIRS}${NAME})`
16const LINE = String.raw`(?::(\d+)(?:[-:]\d+)?)?`
17// `file://` before a path is part of the link text (`file:///D:/a.md` keeps the third slash with the drive).
18const SCHEME = String.raw`(?:file:\/\/(?:\/(?=[A-Za-z]:[\\/]))?)?`
19const PATH_RE = new RegExp(String.raw`(?<![\w/\\.:~-])${SCHEME}(${PATH_BODY})${LINE}(?![\w/\\])`, 'g')
20const WHOLE_PATH_RE = new RegExp(String.raw`^${SCHEME}(${PATH_BODY})${LINE}$`)
21// Sentence punctuation, ASCII and full-width (\u3002 \uFF0C \uFF1B \uFF1A \uFF01 \uFF1F).
22const TRAILING = /[.,;:!?\u3002\uFF0C\uFF1B\uFF1A\uFF01\uFF1F]+$/
23
24// Fenced blocks and existing links stay as written; inline code is linked
25// only when the whole span is one path or URL.
26export function split(text: string): Segment[] {
27 const out: Segment[] = []
28 const re = /```[\s\S]*?(?:```|$)|\[[^\]\n]*\]\([^)\n]*\)|`[^`\n]+`/g
29 let at = 0
30 for (const m of text.matchAll(re)) {
31 if (m.index > at) out.push({ text: text.slice(at, m.index), kind: 'plain' })
32 const isCode = m[0].startsWith('`') && !m[0].startsWith('```')
33 out.push({ text: m[0], kind: isCode ? 'code' : 'skip' })
34 at = m.index + m[0].length
35 }
36 if (at < text.length) out.push({ text: text.slice(at), kind: 'plain' })
37 return out
38}
39
40// Plain text needs a directory part, so `v1.2` or `e.g.` stays text; inline
41// code may hold a bare file name.
42const hasDir = (path: string) => /[\\/]/.test(path)
43
44function codeTarget(span: string): Candidate | undefined {
45 const inner = span.slice(1, -1).trim()
46 if (/^https?:\/\/\S+$/.test(inner)) return { kind: 'url', url: inner }
47 const m = WHOLE_PATH_RE.exec(inner)
48 return m ? { kind: 'path', path: m[1]!, line: m[2] ? Number(m[2]) : undefined } : undefined
49}
50
51export function candidates(text: string): Candidate[] {
52 const out: Candidate[] = []
53 for (const seg of split(text)) {
54 if (seg.kind === 'skip') continue
55 if (seg.kind === 'code') {
56 const c = codeTarget(seg.text)
57 if (c) out.push(c)
58 continue
59 }
60 for (const m of seg.text.matchAll(URL_RE)) out.push({ kind: 'url', url: m[0].replace(TRAILING, '') })
61 for (const m of seg.text.replace(URL_RE, ' ').matchAll(PATH_RE)) {
62 if (hasDir(m[1]!)) out.push({ kind: 'path', path: m[1]!, line: m[2] ? Number(m[2]) : undefined })
63 }
64 }
65 return out
66}
67
68// `resolve` maps a path as written to the href it links to, or undefined to
69// leave it as text.
70export function linkify(text: string, resolve: Resolve): string {
71 return split(text)
72 .map(seg => {
73 if (seg.kind === 'skip') return seg.text
74 if (seg.kind === 'plain') return linkPlain(seg.text, resolve)
75 const c = codeTarget(seg.text)
76 const href = c?.kind === 'url' ? c.url : c ? resolve(c.path, c.line) : undefined
77 return href ? `[${seg.text}](${href})` : seg.text
78 })
79 .join('')
80}
81
82function linkPlain(text: string, resolve: Resolve): string {
83 const parts: string[] = []
84 let at = 0
85 for (const m of text.matchAll(URL_RE)) {
86 parts.push(linkPaths(text.slice(at, m.index), resolve))
87 const url = m[0].replace(TRAILING, '')
88 parts.push(`[${url}](${url})`, m[0].slice(url.length))
89 at = m.index + m[0].length
90 }
91 parts.push(linkPaths(text.slice(at), resolve))
92 return parts.join('')
93}
94
95function linkPaths(text: string, resolve: Resolve): string {
96 return text.replace(PATH_RE, (whole, path: string, line?: string) => {
97 if (!hasDir(path)) return whole
98 const href = resolve(path, line ? Number(line) : undefined)
99 return href ? `[${whole}](${href})` : whole
100 })
101}
102
103const DRIVE = /^[A-Za-z]:[\\/]/
104
105// Absolute path from a path as written, against the session's directory,
106// with `/` separators (`C:/a/b.md` on Windows).
107export function absolute(path: string, cwd: string, home: string | undefined): string | undefined {
108 let p = path.replace(/\\/g, '/')
109 const base = cwd.replace(/\\/g, '/').replace(/\/+$/, '')
110 if (p === '~' || p.startsWith('~/')) {
111 if (!home) return undefined
112 p = home.replace(/\\/g, '/') + p.slice(1)
113 } else if (!p.startsWith('/') && !DRIVE.test(p)) {
114 p = `${base}/${p}`
115 }
116 const drive = DRIVE.test(p) ? p.slice(0, 2) : ''
117 const out: string[] = []
118 for (const part of p.slice(drive.length).split('/')) {
119 if (part === '' || part === '.') continue
120 if (part === '..') out.pop()
121 else out.push(part)
122 }
123 return `${drive}/${out.join('/')}`
124}
125
126export function fileHref(abs: string, line?: number): string {
127 const p = DRIVE.test(abs) ? `/${abs}` : abs
128 return `file://${encodeURI(p)}${line ? `#L${line}` : ''}`
129}
130
131export function parseFileHref(href: string): { path: string; line?: number } | undefined {
132 if (!href.startsWith('file://')) return undefined
133 const [rest, frag] = href.slice('file://'.length).split('#')
134 const line = frag && /^L\d+$/.test(frag) ? Number(frag.slice(1)) : undefined
135 let path = decodeURI(rest!)
136 if (/^\/[A-Za-z]:\//.test(path)) path = path.slice(1)
137 return { path, line }
138}
139hooks/opener.ts 75 lines1// Which commands open a link on each platform. Each target lists commands
2// tried in order until one starts and exits cleanly.
3
4export type Platform = 'macos' | 'windows' | 'wsl' | 'linux'
5
6export type Options = { browser: string; editor: string }
7
8// `explorer.exe` exits 1 even when it opened the target.
9export type Command = { argv: string[]; anyExit?: boolean }
10
11const MAC_APPS: Record<string, string> = {
12 chrome: 'Google Chrome',
13 safari: 'Safari',
14 edge: 'Microsoft Edge',
15 firefox: 'Firefox',
16}
17const WIN_EXES: Record<string, string> = { chrome: 'chrome', edge: 'msedge', firefox: 'firefox' }
18const LINUX_BINS: Record<string, string> = { chrome: 'google-chrome', edge: 'microsoft-edge', firefox: 'firefox' }
19
20// Editors that take `-g file:line` to jump to a line.
21const GOTO_EDITORS = new Set(['code', 'code-insiders', 'cursor', 'windsurf', 'codium'])
22
23const psQuote = (s: string) => `'${s.replace(/'/g, "''")}'`
24
25function powershell(script: string, exe: string): Command {
26 return { argv: [exe, '-NoProfile', '-NonInteractive', '-Command', script] }
27}
28
29export function urlCommands(platform: Platform, url: string, opts: Options): Command[] {
30 const b = opts.browser
31 switch (platform) {
32 case 'macos':
33 return MAC_APPS[b] ? [{ argv: ['open', '-a', MAC_APPS[b]!, url] }, { argv: ['open', url] }] : [{ argv: ['open', url] }]
34 case 'windows':
35 case 'wsl': {
36 const exe = platform === 'wsl' ? 'powershell.exe' : 'powershell'
37 const system = powershell(`Start-Process ${psQuote(url)}`, exe)
38 return WIN_EXES[b] ? [powershell(`Start-Process ${WIN_EXES[b]} -ArgumentList ${psQuote(url)}`, exe), system] : [system]
39 }
40 case 'linux':
41 return LINUX_BINS[b] ? [{ argv: [LINUX_BINS[b]!, url] }, { argv: ['xdg-open', url] }] : [{ argv: ['xdg-open', url] }]
42 }
43}
44
45function editorArgv(platform: Platform, editor: string, path: string, line?: number): string[] {
46 const words = editor.trim().split(/\s+/)
47 const name = words[0]!.replace(/\.(cmd|exe)$/i, '').split(/[\\/]/).pop()!
48 const args = GOTO_EDITORS.has(name) ? ['-g', line ? `${path}:${line}` : path] : [path]
49 // `code` is a .cmd script on Windows, which only cmd can start.
50 return platform === 'windows' ? ['cmd', '/c', ...words, ...args] : [...words, ...args]
51}
52
53// `isDir` folders go to the file manager; files to the editor first. On WSL
54// the file manager is Windows', so the path is converted with `winPath`.
55export function fileCommands(
56 platform: Platform,
57 target: { path: string; line?: number; isDir: boolean; winPath?: string },
58 opts: Options,
59): Command[] {
60 const system: Command[] = {
61 macos: [{ argv: ['open', target.path] }],
62 windows: [{ argv: ['explorer.exe', target.path.replace(/\//g, '\\')], anyExit: true }],
63 wsl: target.winPath ? [{ argv: ['explorer.exe', target.winPath], anyExit: true }] : [{ argv: ['xdg-open', target.path] }],
64 linux: [{ argv: ['xdg-open', target.path] }],
65 }[platform]
66 if (target.isDir || !opts.editor.trim()) return system
67 return [{ argv: editorArgv(platform, opts.editor, target.path, target.line) }, ...system]
68}
69
70export function detect(env: { wslDistro?: string; os?: string; uname?: string }): Platform {
71 if (env.wslDistro) return 'wsl'
72 if (env.os === 'Windows_NT') return 'windows'
73 return env.uname?.trim() === 'Darwin' ? 'macos' : 'linux'
74}
75