The spop side panel: a list of plot images; Enter copies one to the clipboard and opens it in the default viewer.

A Claude Code mod that adds a side panel named spop with a list of plot images.
/spop <folder> lists every PNG and JPG in the folder. /spop alone shows the latest plots in the working directory and its figures, plots, output, out and results folders./plots is an alias for /spop.Windows only (PowerShell, System.Windows.Forms clipboard). Install by copying this folder into a Claude Code dev-mods directory and running /reload-plugins.
hooks/register.tsx 164 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PlotFile } from '../types'
5
6const PANE = 'plot-deck'
7const LATEST = 12
8const IMAGE = /\.(?:png|jpe?g)$/i
9const MENTION = /(?:[A-Za-z]:)?[\\/]?[^\s"'<>|*?`,;()[\]{}]+\.(?:png|jpe?g)/gi
10const SUBDIRS = ['figures', 'plots', 'output', 'out', 'results']
11
12const page = atom({ plugin: 'plot-deck', key: 'page' } as const, 'plot' as 'plot' | 'pick')
13const selected = atom({ plugin: 'plot-deck', key: 'selected' } as const, null as string | null)
14const tracked = atom({ plugin: 'plot-deck', key: 'tracked' } as const, [] as string[])
15const folder = atom({ plugin: 'plot-deck', key: 'folder' } as const, null as string | null)
16
17const isAbsolute = (path: string): boolean => /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('/') || path.startsWith('\\\\')
18const join = (dir: string, name: string): string => `${dir.replace(/[\\/]$/, '')}/${name}`
19const mentioned = (text: string, root: string): string[] =>
20 [...text.replace(/\\\\/g, '\\').matchAll(MENTION)].map(m => (isAbsolute(m[0]) ? m[0] : join(root, m[0].replace(/^\.[\\/]/, ''))))
21
22const stat = async ($: EngineInterface, path: string): Promise<PlotFile | undefined> => {
23 const s = await $.fs.stat(path).catch(() => undefined)
24 return s && s.kind === 'file' ? { path, name: path.split(/[\\/]/).pop() ?? path, mtimeMs: s.mtimeMs } : undefined
25}
26
27const gather = async ($: EngineInterface, dirsOption: string, extra: string[], pinned: string | null): Promise<PlotFile[]> => {
28 const root = await $.session.root()
29 if (pinned) {
30 const entries = await $.fs.list(pinned).catch(() => [])
31 const fromFolder = entries
32 .filter(entry => entry.kind === 'file' && IMAGE.test(entry.name))
33 .map(entry => ({ path: join(pinned, entry.name), name: entry.name, mtimeMs: entry.mtimeMs }))
34 .sort((a, b) => a.name.localeCompare(b.name))
35 const norm = (p: string): string => p.replace(/\\/g, '/').toLowerCase()
36 const seen = new Set(fromFolder.map(f => norm(f.path)))
37 const fresh: PlotFile[] = []
38 for (const path of extra) {
39 if (seen.has(norm(path))) continue
40 const file = await stat($, path)
41 if (file) {
42 seen.add(norm(path))
43 fresh.push(file)
44 }
45 }
46 return [...fresh, ...fromFolder]
47 }
48 const dirs = dirsOption.trim()
49 ? dirsOption.split(',').map(d => d.trim()).filter(Boolean)
50 : [root, ...SUBDIRS.map(d => join(root, d))]
51 const found = new Map<string, PlotFile>()
52 for (const dir of dirs) {
53 const entries = await $.fs.list(dir).catch(() => [])
54 for (const entry of entries) {
55 if (entry.kind === 'file' && IMAGE.test(entry.name)) {
56 const path = join(dir, entry.name)
57 found.set(path, { path, name: entry.name, mtimeMs: entry.mtimeMs })
58 }
59 }
60 }
61 for (const path of extra) {
62 const file = found.has(path) ? undefined : await stat($, path)
63 if (file) found.set(path, file)
64 }
65 return [...found.values()].sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, LATEST)
66}
67
68// Copy the plot to a temp folder, put the image on the clipboard, and open the copy in the default viewer.
69const viewCopy = async ($: EngineInterface, path: string): Promise<void> => {
70 const temp = ((await $.env.get('TEMP')) ?? 'C:/Windows/Temp').replace(/\\/g, '/')
71 const dir = `${temp}/spop`
72 const name = path.split(/[\\/]/).pop() ?? 'plot.png'
73 const q = (p: string): string => `'${p.replace(/\//g, '\\').replace(/'/g, "''")}'`
74 const script = `New-Item -ItemType Directory -Force ${q(dir)} | Out-Null; Copy-Item -LiteralPath ${q(path)} -Destination ${q(`${dir}/${name}`)} -Force; Add-Type -AssemblyName System.Windows.Forms, System.Drawing; $img = [System.Drawing.Image]::FromFile(${q(`${dir}/${name}`)}); [System.Windows.Forms.Clipboard]::SetImage($img); $img.Dispose(); Start-Process ${q(`${dir}/${name}`)}`
75 await $.process.run(['powershell', '-NoProfile', '-STA', '-Command', script])
76}
77
78export const register: Register = (on, options) => {
79 on('session.start', async ($, e, next) => {
80 await $.command.register({
81 name: 'plots',
82 description: 'Open the plot pane: the latest Python plot, or `pick` to choose among the latest',
83 argumentHint: '[pick | <image path> | <folder>]',
84 })
85 await $.command.register({
86 name: 'spop',
87 description: 'Same as /plots: open the spop side panel, or pass a folder to list all its plots',
88 argumentHint: '[pick | <image path> | <folder>]',
89 })
90 void $.ui.open({ id: PANE, title: 'spop' })
91 return next(e)
92 })
93
94 on('command.run', { command: /^(?:plots|spop)$/ }, async ($, e) => {
95 const arg = String((e as { args?: string }).args ?? '').trim()
96 if (arg && arg !== 'pick') {
97 const root = await $.session.root()
98 const path = isAbsolute(arg) ? arg : join(root, arg)
99 const info = await $.fs.stat(path).catch(() => undefined)
100 if (info?.kind === 'dir') {
101 const count = (await $.fs.list(path).catch(() => [])).filter(f => f.kind === 'file' && IMAGE.test(f.name)).length
102 if (!count) return { text: `No images in ${path}.` }
103 await update($, folder, () => path)
104 await update($, selected, () => null)
105 await $.ui.open({ id: PANE, title: 'spop', focus: true })
106 return { text: `Listed ${count} plots from ${path} in the Plots pane.` }
107 }
108 if (!(await stat($, path))) return { text: `No image at ${path}.` }
109 await update($, tracked, list => [path, ...list.filter(p => p !== path)].slice(0, 50))
110 await update($, selected, () => path)
111 } else {
112 await update($, selected, () => null)
113 if (!arg) await update($, folder, () => null)
114 }
115 await update($, page, () => (arg === 'pick' ? 'pick' : 'plot'))
116 await $.ui.open({ id: PANE, title: 'spop', focus: true })
117 return { text: arg === 'pick' ? 'Plot picker opened.' : 'Plot pane opened.' }
118 })
119
120 on('tool.call', async ($, e, next) => {
121 const ran = await next(e)
122 if (e.tool !== 'Bash' && e.tool !== 'PowerShell') return ran
123 const root = await $.session.root()
124 const command = String((e as { command?: string }).command ?? '')
125 const output = 'result' in ran && ran.result ? JSON.stringify(ran.result) : ''
126 const paths = [...new Set([...mentioned(command, root), ...mentioned(output, root)])]
127 const real: string[] = []
128 for (const path of paths) if (await stat($, path)) real.push(path)
129 if (real.length) {
130 await update($, tracked, list => [...real, ...list.filter(p => !real.includes(p))].slice(0, 50))
131 void $.ui.invalidate('ui.render')
132 $.ui.status(`plot: ${real[0]?.split(/[\\/]/).pop()} (/plots)`)
133 }
134 return ran
135 })
136
137 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
138 const { Box, Text, Button } = $.ui.resolve(e) as any
139 const files = await gather($, String(options.plotDirs ?? ''), await read($, tracked), await read($, folder))
140 if (!files.length) {
141 return (
142 <Box flexDirection="column">
143 <Text bold>No plots yet</Text>
144 <Text dimColor>Save one from Python (plt.savefig("plot.png")) or run /spop <folder>.</Text>
145 </Box>
146 )
147 }
148
149 return (
150 <Box flexDirection="column">
151 {files.map(f => (
152 <Button
153 key={`plot-${f.path}`}
154 label={f.name}
155 onPress={() => {
156 void viewCopy($, f.path).catch(() => $.ui.toast('Could not open the image'))
157 }}
158 />
159 ))}
160 </Box>
161 )
162 })
163}
164types/index.d.ts 8 lines1export type PlotFile = { path: string; name: string; mtimeMs: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'plot-deck': { page: 'plot' | 'pick'; selected: string | null; tracked: string[]; folder: string | null }
6 }
7}
8