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

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.
/plugin install image --marketplace paulund/claude-image
| Command | What it does |
|---|---|
/image | Open the latest screenshot (all session screenshots are loaded) |
/image N | Start on the Nth screenshot |
/image list | Print 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.
/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.
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.
hooks/register.tsx 61 lines1import { 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}
61types/index.d.ts 8 lines1export type ImageState = { paths: string[]; index: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 image: { state: ImageState }
6 }
7}
8