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

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.
In Claude Code:
/plugin marketplace add wtckk/claude-code-read-preview
/plugin install read-preview@read-preview
Then restart Claude Code.
magick on PATH), used to shrink the picture for the preview.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.
MIT
hooks/register.tsx 132 lines1import { 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}
132types/index.d.ts 8 lines1export 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