Clicking a file reference in a desktop reply opens it in VS Code at the line (Remote-SSH or local).

A Claude Code mod for the desktop app. Click a file reference in a reply, and the file opens in VS Code at that line.
<!-- TODO: demo GIF -->
In the desktop app's Code tab, a reply cites files as links such as src/server.ts:42. A click opens the app's own file pane, and an SSH session offers no "Open in" entry for your editor. With this mod, the click goes to the VS Code window you already have open.
code command.You need the desktop app's Code tab with Claude Code v2.1.287 or later, VS Code 1.90 or later, and bash. Run these on the machine where the session runs. For an SSH session, that is the remote host.
git clone https://github.com/gggg5151/claude-desktop-links-to-vscode-mod.git ~/.claude/mods/links-to-vscode
~/.claude/settings.json: {
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/links-to-vscode"
}
}
bash ~/.claude/mods/links-to-vscode/scripts/install-vscode-extension.sh
A mod runs with your permissions. claude plugin validate ~/.claude/mods/links-to-vscode lists what this one hooks and calls, without running it.
.env included. Mind it while sharing your screen.+ may not be matched. The newest window is used.The full list is in How it works.
hooks/register.tsx 151 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Redraws a desktop reply that cites files so a click on a file reference
4// opens it in VS Code; scripts/open-in-vscode.sh decides which window.
5
6// The most text a Markdown element draws; a longer reply keeps its own drawing.
7const MAX_TEXT = 10000
8const MAX_LINKS = 256
9
10// Code spans and fences: their text is left as written.
11const CODE = /(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`)/
12// [label](target), not an image; the target without spaces, its parentheses
13// in pairs as in "app/(marketing)/page.tsx".
14const LINK = /(!?)\[([^\]\n]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g
15// https:, mailto:, ...; "CLAUDE.md:3" is a file and its line.
16const SCHEME = /^[a-z][a-z0-9+.-]*:(?!\d)/i
17// "a.ts", "a.ts:42", "a.ts:42:7", "a.ts:42-50", "a.ts#L42", "a.ts#L42C7-L50"
18const LOCATION = /^(.+?)(?::(\d+)(?::(\d+))?(?:-\d+)?|#L(\d+)(?:C(\d+))?(?:-L?\d+)?)?$/
19// VS Code opens these as a workspace rather than as a file, also with `-g`.
20const WORKSPACE_FILE = /\.code-workspace$/i
21
22type FileRef = { path: string; line: number; column: number }
23
24const decode = (text: string): string => {
25 try {
26 return decodeURIComponent(text)
27 } catch {
28 return text
29 }
30}
31
32const normalize = (path: string): string => {
33 const parts: string[] = []
34 for (const part of path.split('/')) {
35 if (part === '' || part === '.') continue
36 if (part === '..') parts.pop()
37 else parts.push(part)
38 }
39 return `/${parts.join('/')}`
40}
41
42const toFileRef = (target: string, cwd: string): FileRef | undefined => {
43 let spelled = target
44 if (spelled.startsWith('file://')) spelled = spelled.slice('file://'.length)
45 else if (SCHEME.test(spelled) || spelled.startsWith('#')) return undefined
46
47 const match = LOCATION.exec(decode(spelled))
48 const [, path, line, column, lineL, columnC] = match ?? []
49 if (!path) return undefined
50
51 return {
52 path: normalize(path.startsWith('/') ? path : `${cwd}/${path}`),
53 line: Number(line ?? lineL ?? 1),
54 column: Number(column ?? columnC ?? 1),
55 }
56}
57
58const toHref = ({ path, line, column }: FileRef): string => {
59 const encoded = encodeURI(path).replace(/[()#?]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
60 return `file://${encoded}#L${line}C${column}`
61}
62
63const fromHref = (href: string): FileRef | undefined => {
64 const url = new URL(href)
65 if (url.protocol !== 'file:') return undefined
66 const location = /^#L(\d+)C(\d+)$/.exec(url.hash)
67 return {
68 path: decode(url.pathname),
69 line: Number(location?.[1] ?? 1),
70 column: Number(location?.[2] ?? 1),
71 }
72}
73
74const openInVSCode = async ($: EngineInterface, href: string): Promise<void> => {
75 const ref = fromHref(href)
76 if (!ref) return
77
78 try {
79 const ran = await $.process.run(
80 ['bash', `${$.plugin.root}/scripts/open-in-vscode.sh`, ref.path, String(ref.line), String(ref.column)],
81 { timeoutMs: 20000 },
82 )
83 if (ran.exitCode !== 0) {
84 $.ui.toast(`Could not open in VS Code: ${ran.stderr.trim() || `exit ${ran.exitCode}`}`)
85 }
86 } catch (error) {
87 $.ui.toast(`Could not open in VS Code: ${String(error)}`)
88 }
89}
90
91// A regular file VS Code opens as a file: a folder or a workspace file would
92// open as a workspace in a new window, and a device or a pipe would hang it.
93const isOpenableFile = async ($: EngineInterface, path: string): Promise<boolean> => {
94 if (WORKSPACE_FILE.test(path)) return false
95 try {
96 return (await $.fs.stat(path)).kind === 'file'
97 } catch {
98 return false
99 }
100}
101
102export const register: Register = on => {
103 // Whether each absolute path is a file to open, so any other link keeps its own behaviour.
104 const openable = new Map<string, boolean>()
105
106 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
107 if (e.surface !== 'desktop' || !e.props.text.includes('](')) return next(e)
108
109 const cwd = await $.session.cwd()
110 const hrefs = new Set<string>()
111 const pieces: string[] = []
112
113 for (const [index, piece] of e.props.text.split(CODE).entries()) {
114 if (index % 2 === 1) {
115 pieces.push(piece)
116 continue
117 }
118
119 let rewritten = ''
120 let last = 0
121 for (const match of piece.matchAll(LINK)) {
122 const [whole, bang, label, target] = match
123 const ref = bang || !target ? undefined : toFileRef(target, cwd)
124 if (!ref) continue
125
126 if (!openable.has(ref.path)) openable.set(ref.path, await isOpenableFile($, ref.path))
127 if (!openable.get(ref.path)) continue
128
129 const href = toHref(ref)
130 hrefs.add(href)
131 rewritten += `${piece.slice(last, match.index)}[${label}](${href})`
132 last = match.index + whole.length
133 }
134 pieces.push(rewritten + piece.slice(last))
135 }
136
137 const text = pieces.join('')
138 if (hrefs.size === 0 || hrefs.size > MAX_LINKS || text.length > MAX_TEXT) return next(e)
139
140 const { Markdown } = $.ui.resolve(e)
141 return (
142 <Markdown
143 key="open-in-vscode"
144 text={text}
145 pressableLinks={[...hrefs]}
146 onLinkPress={link => openInVSCode($, link.href)}
147 />
148 )
149 })
150}
151