SLOPSHOPPER

image

View, zoom and pan the screenshots an agent takes, in a herdr/Ghostty split: /image [n|list]

guardcommandtoastprocess
v0.1.0no licenseupdated 2026-10-18paulund/claude-image
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · image
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 › /image ⎿ image: No screenshots seen yet. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-image

A Claude Code mod for looking at the screenshots your agent takes without leaving the terminal.

It watches tool calls for absolute .png paths (for example a verification run that saves screenshots) and adds a /image command that opens them in a zoomable, pannable viewer in a new herdr split.

Install

/plugin install image --marketplace paulund/claude-image

Use

CommandWhat it does
/imageOpen the latest screenshot (all session screenshots are loaded)
/image NStart on the Nth screenshot
/image listPrint every screenshot path seen this session

Viewer keys (click the new pane first): + / - zoom, arrows or h j k l pan, 0 fit, [ / ] previous / next, q quit.

Requirements

  • A terminal with the kitty graphics protocol (Ghostty, kitty, WezTerm).
  • herdr, which passes graphics through and provides the split. Outside herdr, /image prints the command to run bin/viewpng.py yourself.
  • python3.

The viewer (bin/viewpng.py) is standalone: python3 bin/viewpng.py a.png b.png.

How zoom works

It crops the source image and lets the terminal scale the crop (kitty graphics x,y,w,h source rectangle), so zoomed text stays sharp.

Source 2 files
hooks/register.tsx 61 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4const PNG = /\/[^\s"'`\\<>()]*\.png\b/g
5const state = atom({ plugin: 'image', key: 'state' } as const, { paths: [] as string[], index: -1 })
6
7export const register: Register = on => {
8  on('session.start', async ($, e, next) => {
9    await $.command.register({
10      name: 'image',
11      description: 'View screenshots from this session in a zoomable split (/image N, /image list)',
12      argumentHint: '[n|list]',
13    })
14    return next(e)
15  })
16
17  // Collect absolute .png paths from any tool call's input and output.
18  on('tool.call', async ($, e, next) => {
19    const ran = await next(e)
20    const found = JSON.stringify([e, ran.deny === undefined ? ran.result : ''])
21      .replace(/\\\//g, '/')
22      .match(PNG)
23    if (found) {
24      const fresh = []
25      for (const p of new Set(found)) {
26        const ok = await $.fs.stat(p).then(s => s.kind === 'file', () => false)
27        if (ok) fresh.push(p)
28      }
29      if (fresh.length) {
30        await update($, state, s => {
31          const merged = [...s.paths.filter(p => !fresh.includes(p)), ...fresh].slice(-50)
32          return { paths: merged, index: merged.length - 1 }
33        })
34        $.ui.toast(`screenshot: ${fresh[fresh.length - 1]}  (/image to view)`)
35      }
36    }
37    return ran
38  }).catch(($, e, next) => next(e))
39
40  on('command.run', { command: 'image' }, async ($, e) => {
41    const s = await read($, state)
42    const arg = (e.args ?? '').trim()
43    if (arg === 'list') {
44      return { text: s.paths.length ? s.paths.map((p, i) => `${i + 1}. ${p}`).join('\n') : 'No screenshots seen yet.' }
45    }
46    if (!s.paths.length) return { text: 'No screenshots seen yet.' }
47    const n = arg ? Number(arg) : s.paths.length
48    if (!Number.isInteger(n) || n < 1 || n > s.paths.length) return { text: `Pick 1-${s.paths.length}.` }
49
50    // Open a herdr split beside this pane and run the viewer there.
51    const split = await $.process.run(['herdr', 'pane', 'split', '--current', '--direction', 'right'])
52    if (split.exitCode !== 0) {
53      return { text: `Could not split a herdr pane (are you running inside herdr?). Run: python3 ${$.plugin.root}/bin/viewpng.py ${s.paths.join(' ')}` }
54    }
55    const paneId = JSON.parse(split.stdout)?.result?.pane?.pane_id
56    if (!paneId) return { text: 'herdr split gave no pane id.' }
57    await $.process.run(['herdr', 'pane', 'run', paneId, 'python3', `${$.plugin.root}/bin/viewpng.py`, `--start=${n - 1}`, ...s.paths])
58    return { text: `Viewing ${n}/${s.paths.length} in pane ${paneId}. Click it, then + - arrows 0 [ ] q.` }
59  })
60}
61
types/index.d.ts 8 lines
1export type ImageState = { paths: string[]; index: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    image: { state: ImageState }
6  }
7}
8