Turns file mentions in Claude's replies into clickable links to where the file really is / Превращает упоминания файлов в ответах Claude в кликабельные ссылки…

Turns file mentions in Claude's replies into clickable links to where the file really is.
Claude often names a file as src/app.ts or C:\Users\me\notes.md, and the mention is not clickable, or opens nothing because the path is relative to another folder. This mod finds path-shaped mentions (in inline code or bare absolute Windows paths), looks the file up from the session's folder and project root, and turns each one it finds into a file:/// link. A :12 line suffix is kept. Code blocks, existing links and files that do not exist are left as written.
Checks whether mentioned paths exist (exists / stat) and changes only how a reply is drawn; what the model reads is not changed. Reads USERPROFILE to resolve ~.
/config)No settings.
/plugin install file-links --marketplace ArtyaEXE/claude-mods
Part of claude-mods. MIT license.
Превращает упоминания файлов в ответах Claude в кликабельные ссылки на то место, где файл реально лежит.
Claude часто называет файл как src/app.ts или C:\Users\me\notes.md, и упоминание либо не кликается, либо открывает пустоту, потому что путь относительный. Мод находит упоминания, похожие на пути (в inline code или голые абсолютные Windows-пути), ищет файл от папки сессии и корня проекта и превращает каждое найденное в ссылку file:///. Суффикс строки :12 сохраняется. Блоки кода, готовые ссылки и несуществующие файлы не трогает.
Проверяет, существуют ли упомянутые пути (exists / stat), и меняет только отрисовку ответа; то, что читает модель, не меняется. Читает USERPROFILE, чтобы раскрыть ~.
/config)Настроек нет.
/plugin install file-links --marketplace ArtyaEXE/claude-mods
Часть claude-mods. Лицензия MIT.
hooks/register.ts 123 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// A mention is either `inline code` that looks like a path, or a bare
4// absolute Windows path. A `:12` or `:12:3` line suffix is kept outside the link.
5const CODE_MENTION = /`([^`\n]{2,260})`/g
6const BARE_ABSOLUTE = /(?<![\w/\\`(\[])([A-Za-z]:[\\/][^\s`<>"'()\[\]*|?]+)/g
7const LINE_SUFFIX = /:(\d+)(?::\d+)?$/
8
9// Path-shaped: has a separator or a file extension, and nothing a command has.
10const looksLikePath = (s: string) =>
11 !/\s{2}|[<>|"*?]|^\w+\(|=/.test(s) && (/[\\/]/.test(s) || /\.[A-Za-z0-9]{1,8}$/.test(s)) && !/^https?:/i.test(s)
12
13const isAbsolute = (s: string) => /^[A-Za-z]:[\\/]/.test(s) || s.startsWith('/') || s.startsWith('~')
14
15const fileUrl = (abs: string) =>
16 'file:///' +
17 abs
18 .replace(/\\/g, '/')
19 .split('/')
20 .map((part, i) => (i === 0 && /^[A-Za-z]:$/.test(part) ? part : encodeURIComponent(part)))
21 .join('/')
22
23const MAX_CACHE = 2000
24const found = new Map<string, string | null>()
25
26const resolve = async ($: EngineInterface, mention: string, home: string, bases: string[]): Promise<string | null> => {
27 const key = `${bases.join('|')}::${mention}`
28 if (found.has(key)) return found.get(key) ?? null
29
30 const raw = mention.replace(/^~(?=[\\/])/, home)
31 const candidates = isAbsolute(raw) && !raw.startsWith('~') ? [raw] : bases.map(b => `${b}/${raw.replace(/^\.[\\/]/, '')}`)
32 let hit: string | null = null
33 for (const candidate of candidates) {
34 try {
35 if (await $.fs.exists(candidate)) {
36 const stat = await $.fs.stat(candidate, { resolve: true })
37 hit = (stat.realPath ?? candidate).replace(/\//g, '\\')
38 break
39 }
40 } catch {
41 // Unreadable spot: try the next base.
42 }
43 }
44
45 if (found.size >= MAX_CACHE) found.clear()
46 found.set(key, hit)
47
48 return hit
49}
50
51// Fenced code blocks and existing markdown links stay as written.
52const SKIP = /```[\s\S]*?(?:```|$)|\[[^\]\n]*\]\([^)\n]*\)/g
53
54const linkify = async ($: EngineInterface, text: string): Promise<string> => {
55 if (!/`|[A-Za-z]:[\\/]/.test(text)) return text
56
57 const cwd = (await $.session.cwd()).replace(/\\/g, '/')
58 const root = (await $.session.root()).replace(/\\/g, '/')
59 const home = String((await $.env.get('USERPROFILE')) ?? '').replace(/\\/g, '/')
60 const bases = [...new Set([cwd, root])]
61
62 const linkOne = async (shown: string, mention: string) => {
63 const bare = mention.replace(LINE_SUFFIX, '')
64 if (!looksLikePath(bare)) return null
65 const abs = await resolve($, bare, home, bases)
66
67 return abs === null ? null : `[${shown}](${fileUrl(abs)})`
68 }
69
70 const pieces: string[] = []
71 let at = 0
72 for (const skip of text.matchAll(SKIP)) {
73 pieces.push(await linkPlain(text.slice(at, skip.index), linkOne))
74 pieces.push(skip[0])
75 at = skip.index + skip[0].length
76 }
77 pieces.push(await linkPlain(text.slice(at), linkOne))
78
79 return pieces.join('')
80}
81
82const linkPlain = async (
83 part: string,
84 linkOne: (shown: string, mention: string) => Promise<string | null>,
85): Promise<string> => {
86 let out = ''
87 let at = 0
88 // Inline code first; bare paths only in the text between code spans.
89 for (const m of part.matchAll(CODE_MENTION)) {
90 out += await linkBare(part.slice(at, m.index), linkOne)
91 out += (await linkOne(m[0], m[1] ?? "")) ?? m[0]
92 at = m.index + m[0].length
93 }
94
95 return out + (await linkBare(part.slice(at), linkOne))
96}
97
98const linkBare = async (
99 part: string,
100 linkOne: (shown: string, mention: string) => Promise<string | null>,
101): Promise<string> => {
102 let out = ''
103 let at = 0
104 for (const m of part.matchAll(BARE_ABSOLUTE)) {
105 // A sentence's closing punctuation is not part of the path.
106 const mention = (m[1] ?? "").replace(/[.,;:!]+$/, '')
107 out += part.slice(at, m.index)
108 out += (await linkOne(mention, mention)) ?? mention
109 at = m.index + mention.length
110 }
111
112 return out + part.slice(at)
113}
114
115export const register: Register = on => {
116 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
117 const text = await linkify($, e.props.text)
118 if (text === e.props.text) return next(e)
119
120 return next({ ...e, props: { ...e.props, text } })
121 })
122}
123