SLOPSHOPPER

read-preview

Shows a thumbnail above the prompt of each image Claude reads with the Read tool

rowsguardprocess
v0.1.0MITupdated 2026-10-17wtckk/claude-code-read-preview
A shopper browsing a rack in a slop shop
README

read-preview

A Claude Code mod that shows a thumbnail of every image Claude reads with the Read tool, in the transcript, right under the read. Scroll up and each picture is still where it was read.

Install

In Claude Code:

/plugin marketplace add wtckk/claude-code-read-preview
/plugin install read-preview@read-preview

Then restart Claude Code.

Requirements

  • Claude Code 2.1.291 or newer
  • A terminal that supports the kitty graphics protocol (kitty, Ghostty). Elsewhere you see the file name instead of the picture.
  • ImageMagick (magick on PATH), used to shrink the picture for the preview.

How it works

Reading png, jpg, gif, webp, bmp or tiff files converts them to an 800 px PNG under /tmp/read-preview/. The mod draws that PNG under the read in the transcript. Runs of folded reads that hold a picture are unfolded so the picture shows. Nothing is sent over the network.

License

MIT

Source 2 files
hooks/register.tsx 132 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Shot } from '../types'
5
6const shots = atom({ plugin: 'read-preview', key: 'shots' } as const, {} as Record<string, Shot>)
7
8const IMAGE = /\.(png|jpe?g|gif|webp|bmp|tiff?)$/i
9const DIR = '/tmp/read-preview'
10const MAX_COLUMNS = 60
11const MAX_ROWS = 14
12
13// A terminal cell is about twice as tall as wide, so a picture of w by h pixels
14// fills 2 * rows * w / h columns.
15const fit = (w: number, h: number) => {
16  let rows = MAX_ROWS
17  let columns = Math.round((2 * rows * w) / h)
18
19  if (columns > MAX_COLUMNS) {
20    columns = MAX_COLUMNS
21    rows = Math.round((columns * h) / (2 * w))
22  }
23
24  return { columns: Math.max(1, columns), rows: Math.max(1, rows) }
25}
26
27// The picture and its name; the elements come from the surface the hook drew for.
28type Parts = { Box: any; Image: any; Text: any }
29
30function picture(id: string, shot: Shot, { Box, Image, Text }: Parts) {
31  return (
32    <Box flexDirection="column" paddingLeft={2}>
33      <Text dimColor>{shot.name}</Text>
34      <Image key={`read-${id}`} source={{ file: shot.file, format: 'png' }} columns={shot.columns} rows={shot.rows} alt={shot.name} />
35    </Box>
36  )
37}
38
39export const register: Register = on => {
40  let count = 0
41
42  // Each read of a picture is converted once and remembered under its tool_use_id,
43  // so its thumbnail stays in the transcript where the read happened.
44  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
45    const ran = await next(e)
46
47    if (ran.deny !== undefined || !IMAGE.test(e.file_path)) {
48      return ran
49    }
50
51    try {
52      count += 1
53      const out = `${DIR}/${await $.clock.now()}-${count}.png`
54      await $.process.run(['mkdir', '-p', DIR])
55      const made = await $.process.run(['magick', `${e.file_path}[0]`, '-auto-orient', '-resize', '800x800>', `png:${out}`], { timeoutMs: 20_000 })
56
57      if (made.exitCode !== 0) {
58        return ran
59      }
60
61      const size = await $.process.run(['magick', 'identify', '-format', '%w %h', out])
62      const [w, h] = size.stdout.trim().split(' ').map(Number)
63
64      if (!w || !h) {
65        return ran
66      }
67
68      const shot: Shot = { file: out, name: e.file_path.split('/').pop() ?? e.file_path, ...fit(w, h) }
69      await update($, shots, all => ({ ...all, [e.tool_use_id]: shot }))
70    } catch {
71      // A preview that cannot be made is not worth failing the read.
72    }
73
74    return ran
75  }).catch(($, e, next) => next(e))
76
77  // The transcript folds reads into one line ("Read 1 file"); a group holding a
78  // read picture is unfolded so each picture shows where it was read.
79  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
80    if (e.surface !== 'terminal' || e.props.isExpanded) {
81      return next(e)
82    }
83
84    const all = await read($, shots)
85    const hasPicture = e.props.calls.some(call => call.tool === 'Read' && call.tool_use_id !== undefined && all[call.tool_use_id] !== undefined)
86
87    return next(hasPicture ? { ...e, props: { ...e.props, isExpanded: true } } : e)
88  })
89
90  // A read row with a picture: the row's name, then the picture under it.
91  on('ui.render', { component: 'ToolUse', props: { tool: 'Read' } }, async ($, e, next) => {
92    if (e.surface !== 'terminal') {
93      return next(e)
94    }
95
96    const shot = (await read($, shots))[e.props.tool_use_id]
97
98    if (shot === undefined) {
99      return next(e)
100    }
101
102    const { Box, Image, Text } = $.ui.resolve(e)
103
104    return (
105      <Box flexDirection="column">
106        <Text>
107          <Text bold>Read</Text>
108          <Text dimColor>({shot.name})</Text>
109        </Text>
110        {picture(e.props.tool_use_id, shot, { Box, Image, Text })}
111      </Box>
112    )
113  })
114
115  // A standalone read: the picture under the row the engine draws.
116  on('ui.render', { component: 'ToolResult', props: { tool: 'Read' } }, async ($, e, next) => {
117    if (e.surface !== 'terminal') {
118      return next(e)
119    }
120
121    const shot = (await read($, shots))[e.props.tool_use_id]
122
123    if (shot === undefined) {
124      return next(e)
125    }
126
127    const { Box, Image, Text } = $.ui.resolve(e)
128
129    return picture(e.props.tool_use_id, shot, { Box, Image, Text })
130  })
131}
132
types/index.d.ts 8 lines
1export type Shot = { file: string; name: string; columns: number; rows: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'read-preview': { shots: Record<string, Shot> }
6  }
7}
8