SLOPSHOPPER

plot-deck

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

newpaneguardcommandtoaststatus
★ 3v0.2.1MITupdated 2026-10-11ahmadhammoud56/MOAA/plugins/plot-deck
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · plot-deck
│ ┃ spop ✕ › fix the failing auth test and add an audit log call │ ┃ No plots yet │ ┃ Save one from Python ⏺ Read(src/auth.ts) │ ┃ (plt.savefig("plot.png")) or run /spop ⎿ Read 6 lines │ ┃ <folder>. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ 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 │ │ › /plots │ ⎿ plot-deck: Plot pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · spop
No plots yet Save one from Python (plt.savefig("plot.png")) or run /spop <folder>.
README

plot-deck (spop)

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.
  • Enter on a name copies the image to the Windows clipboard and opens a copy in the default image viewer. The original file is never opened or locked.
  • Image paths that Bash or PowerShell calls in the chat mention or print are added to the top of the list.
  • /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.

Source 2 files
hooks/register.tsx 164 lines
1import { 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 &lt;folder&gt;.</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}
164
types/index.d.ts 8 lines
1export 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