SLOPSHOPPER

reply-linker

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

rowstoastprocess
v0.1.0MITupdated 2026-10-26navishachiku/reply-linker
A shopper browsing a rack in a slop shop
README

reply-linker

License Claude Code 2.1.302+ Platforms

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.

  • URLs — https://… opens in the system browser, or the one you pick.
  • Files — 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.
  • Folders — open in Finder or Explorer.
  • Hands off code — fenced blocks and links Claude already wrote stay as they are. A reply with nothing to link is drawn by Claude as usual.

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.

Install

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

Configuration

Set them with /plugin configure reply-linker@reply-linker in Claude Code, or --config KEY=VALUE on claude plugin install:

SettingDefaultValues
Browsersystemsystem, chrome, safari, edge, firefox
Editor commandcodeany 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.

Platforms

PlatformURLsFiles
macOSopen -a <browser>editor, then open
WSLWindows browser via powershell.exeeditor, then Windows Explorer
Linux<browser> or xdg-openeditor, then xdg-open
WindowsStart-Processeditor 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. -->

Development

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.

License

MIT

Source 3 files
hooks/register.tsx 131 lines
1import 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}
131
hooks/linkify.ts 139 lines
1// 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}
139
hooks/opener.ts 75 lines
1// 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