Open files in PyCharm from tool rows and file links in replies.

Claude Code mods by Sourav.
A bar above the prompt that shows what is left of your limits:

Opens files in PyCharm from the chat:
PyCharm: line under each of Claude's replies that links to files, one link per file. The reply itself is drawn by the app as usual; replies with no file links get nothing.hooks/register.tsx.claude plugin marketplace add sourav-das-enosis/sourav-claude-mods
claude plugin install limits-band@sourav-claude-mods
claude plugin install open-in-pycharm@sourav-claude-mods
In the desktop app, list the mod folder in CLAUDE_CODE_PLUGIN_DIRS under env in ~/.claude/settings.json instead (paths separated by ; on Windows).
hooks/register.tsx 81 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { pycharmLinksFor } from './links'
4
5const PYCHARM = 'C:\\Users\\govinda\\AppData\\Local\\Programs\\PyCharm\\bin\\pycharm64.exe'
6const LAUNCHER = 'C:\\Users\\govinda\\AppData\\Local\\Temp\\open-in-pycharm.cmd'
7const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'MultiEdit'])
8
9type FileToolInput = { file_path?: string; new_string?: string; offset?: number }
10
11const lineOf = (text: string, needle: string): number | undefined => {
12 const index = text.indexOf(needle)
13 return index < 0 ? undefined : text.slice(0, index).split('\n').length
14}
15
16const openInPyCharm = async ($: EngineInterface, filePath: string, line?: number): Promise<void> => {
17 const lineArgs = line ? `--line ${line} ` : ''
18 // cmd expands %VAR% even inside quotes, so a literal % is doubled.
19 const target = filePath.replaceAll('/', '\\').replaceAll('%', '%%')
20 await $.fs.write(LAUNCHER, `@start "" "${PYCHARM}" ${lineArgs}"${target}"\r\n`)
21 // Explorer runs the script, not this process: a PyCharm started from Claude's
22 // process tree cannot hand the file to the running PyCharm and shows "Start Failed".
23 await $.process.run(['explorer.exe', LAUNCHER])
24}
25
26export const register: Register = on => {
27 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
28 const drawn = await next(e)
29 const input = e.props.input as FileToolInput
30 const filePath = input?.file_path
31
32 if (!FILE_TOOLS.has(e.props.tool) || !filePath || e.props.isRunning || e.props.isErrored) {
33 return drawn
34 }
35
36 const { Box, Button } = $.ui.resolve(e)
37
38 const open = async () => {
39 let line = input.offset
40 if (input.new_string) {
41 line = lineOf(await $.fs.read(filePath), input.new_string) ?? line
42 }
43 await openInPyCharm($, filePath, line)
44 }
45
46 return (
47 <Box flexDirection="column">
48 {drawn}
49 <Button key={`pycharm-${e.requestId}`} label="Open in PyCharm" onPress={open} />
50 </Box>
51 )
52 })
53
54 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
55 if (e.props.isSummary) return next(e)
56
57 const links = pycharmLinksFor(e.props.text, await $.session.cwd())
58 if (!links) return next(e)
59
60 // The app draws the reply itself, so its styling stays; the links go on a line below.
61 const drawn = await next(e)
62 const { Box, Markdown } = $.ui.resolve(e)
63
64 return (
65 <Box flexDirection="column">
66 {drawn}
67 <Markdown
68 key={`pycharm-links-${e.requestId}`}
69 text={links.text}
70 dimColor
71 pressableLinks={[...links.targets.keys()]}
72 onLinkPress={async link => {
73 const file = links.targets.get(link.href)
74 if (file) await openInPyCharm($, file.path, file.line)
75 }}
76 />
77 </Box>
78 )
79 })
80}
81hooks/links.ts 69 lines1export type FileLink = { path: string; line?: number }
2
3export type PyCharmLinks = {
4 /** One markdown line of PyCharm links, drawn under the reply. */
5 text: string
6 /** Keyed by the href of each PyCharm link. */
7 targets: Map<string, FileLink>
8}
9
10const MARKDOWN_LINK = /\[([^\]\n]+)\]\(([^)\s]+)\)/g
11const CODE = /(```[\s\S]*?(?:```|$)|`[^`\n]*`)/
12const SCHEME = /^[a-z][a-z0-9+.-]*:/i
13const LINE_SUFFIX = /(?::(\d+)(?::\d+)?|#L(\d+))$/
14
15const toFileUrl = (absolute: string): string =>
16 'file:///' + encodeURI(absolute.replaceAll('\\', '/')).replaceAll('#', '%23').replaceAll('?', '%3F').replaceAll('(', '%28').replaceAll(')', '%29')
17
18const isAbsolute = (path: string): boolean => /^[a-z]:[\\/]/i.test(path) || path.startsWith('/')
19
20const joinPath = (cwd: string, relative: string): string =>
21 `${cwd.replace(/[\\/]+$/, '')}/${relative.replace(/^\.\//, '')}`
22
23/** Reads a link target as a file on disk, or undefined for web links, anchors and folders. */
24export const parseFileLink = (href: string, cwd: string): FileLink | undefined => {
25 let raw = href
26 if (/^file:/i.test(raw)) {
27 raw = decodeURI(raw.replace(/^file:\/*/i, ''))
28 } else if (SCHEME.test(raw) && !/^[a-z]:[\\/]/i.test(raw)) {
29 return undefined
30 }
31 if (raw.startsWith('#')) return undefined
32
33 const lineMatch = LINE_SUFFIX.exec(raw)
34 const line = lineMatch ? Number(lineMatch[1] ?? lineMatch[2]) : undefined
35 const bare = lineMatch ? raw.slice(0, lineMatch.index) : raw
36 const name = bare.split(/[\\/]/).pop() ?? ''
37 if (!name.includes('.') || name.startsWith('.') && name.lastIndexOf('.') === 0) return undefined
38
39 return { path: isAbsolute(bare) ? bare : joinPath(cwd, bare), line }
40}
41
42/**
43 * Builds a line of PyCharm links for the file links outside code, one per file and
44 * line, or returns undefined when the reply has none.
45 */
46export const pycharmLinksFor = (text: string, cwd: string): PyCharmLinks | undefined => {
47 const targets = new Map<string, FileLink>()
48 const labels: string[] = []
49 const seen = new Set<string>()
50
51 // split() with a capture group alternates prose and code, code at the odd indexes.
52 text.split(CODE).forEach((part, index) => {
53 if (index % 2 === 1) return
54 for (const [, label, href] of part.matchAll(MARKDOWN_LINK)) {
55 const file = href && parseFileLink(href, cwd)
56 if (!label || !file) continue
57 const identity = `${file.path.toLowerCase()}:${file.line ?? ''}`
58 if (seen.has(identity)) continue
59 seen.add(identity)
60
61 const pycharmHref = `${toFileUrl(file.path)}#pycharm-${targets.size}`
62 targets.set(pycharmHref, file)
63 labels.push(`[${label}](${pycharmHref})`)
64 }
65 })
66
67 return targets.size === 0 ? undefined : { text: `PyCharm: ${labels.join(' · ')}`, targets }
68}
69