SLOPSHOPPER

open-in-vscode

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

newrowstoastprocess
A shopper browsing a rack in a slop shop
README

claude-desktop-links-to-vscode-mod

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.

  • Remote-SSH: the file opens in the window whose folder contains it.
  • Local: on macOS and Linux, through the code command.
  • Markdown Preview: a previewed Markdown file scrolls to the line and stays a preview.
  • Web links, images, folders and paths that do not exist behave as before.

日本語 · How it works

Install

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.

  1. Get the files.
   git clone https://github.com/gggg5151/claude-desktop-links-to-vscode-mod.git ~/.claude/mods/links-to-vscode
  1. Load the mod. Add this to ~/.claude/settings.json:
   {
     "env": {
       "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/links-to-vscode"
     }
   }
  1. For the Markdown Preview jump, install the bundled VS Code extension while a VS Code window is open:
   bash ~/.claude/mods/links-to-vscode/scripts/install-vscode-extension.sh
  1. Start a new session and click a file reference.

A mod runs with your permissions. claude plugin validate ~/.claude/mods/links-to-vscode lists what this one hooks and calls, without running it.

Known limitations

  • Windows is not supported as the machine the session runs on.
  • Any readable file can be opened, .env included. Mind it while sharing your screen.
  • When VS Code cannot be reached, the click shows a notice and the app's file pane does not open.
  • With several Remote-SSH windows, a folder whose path has spaces, non-ASCII characters or + may not be matched. The newest window is used.
  • The Markdown Preview jump shows a text tab for a moment.
  • Choosing the window relies on VS Code internals, so a VS Code update can break it.

The full list is in How it works.

License

MIT

Source 1 files
hooks/register.tsx 151 lines
1import 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