SLOPSHOPPER

open-in-pycharm

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

newrowsprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · open-in-pycharm
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⟨Claude Code's own drawing⟩ [ Open in PyCharm ] ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Tool row
⟨Claude Code's own drawing⟩ [ Open in PyCharm ]
README

sourav-claude-mods

Claude Code mods by Sourav.

Mods

limits-band

A bar above the prompt that shows what is left of your limits:

limits-band showing Hourly Left, Weekly Left and Context Left above the prompt

  • Percent turns red below 20%.
  • The bracket shows time until that limit resets.
  • Hourly and weekly limits are shared between sessions, so every chat shows the freshest reading any session has seen, even before its own first reply.
  • First time after installing: send one message in any chat. Hourly and weekly limits only arrive with a reply, so until then only context shows. After that, every chat shows them.
  • When you switch to a chat that has no limits showing yet, they can take a few seconds to appear.
  • Needs a Claude subscription plan. On API billing, only context shows.

open-in-pycharm

Opens files in PyCharm from the chat:

  • A 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.
  • An "Open in PyCharm" button under each Read, Edit and Write row. An Edit opens at the changed line.
  • PyCharm starts through Explorer, so a running PyCharm takes the file instead of failing with "Start Failed".
  • Windows only. The PyCharm path is set in hooks/register.tsx.

Install

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).

Source 2 files
hooks/register.tsx 81 lines
1import 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}
81
hooks/links.ts 69 lines
1export 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