SLOPSHOPPER

image-preview

Shows a small preview card when the pointer is over [Image #n] in a sent prompt

newrowsprocess
v0.1.0MITupdated 2026-10-04vrnrn/agent-harness/mods/image-preview
A shopper browsing a rack in a slop shop
README

Image preview

Hover over an [Image #n] reference in a sent prompt to see the attached image without leaving the conversation.

An image hover preview in Claude Code

Install

claude plugin marketplace add vrnrn/agent-harness
claude plugin install image-preview@vrnrn

Requires the Claude Code mods API, an image-capable terminal, and macOS sips. Image metadata must be available in the session.

See the mod guide for compatibility, local loading, validation, and tests.

Source 1 files
hooks/register.tsx 254 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3type Thumb = { base64: string; columns: number; rows: number }
4
5const TOKEN = /\[Image #(\d+)\]/g
6const HAS_TOKEN = /\[Image #\d+\]/
7const SOURCE = /\[Image: source: ([^\]\n]+)\]/g
8const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
9
10// The card is at most this many cells. A cell is about twice as tall as it is wide.
11const MAX_COLUMNS = 40
12const MAX_ROWS = 14
13const BACKGROUND = '#373737'
14
15// Image number to the file the engine pasted it from.
16const paths = new Map<string, string>()
17const thumbs = new Map<string, Thumb | null>()
18const running = new Map<string, Promise<Thumb | null>>()
19const gaveUp = new Set<string>()
20let pending: string[] = []
21let hydrating: Promise<void> | null = null
22
23// The row that names a file is stored after the prompt row, so a first draw can come too early.
24const ATTEMPTS = 8
25const RETRY_MS = 150
26
27function hash(text: string): string {
28  let h = 0x811c9dc5
29
30  for (let n = 0; n < text.length; n++) {
31    h = Math.imul(h ^ text.charCodeAt(n), 0x01000193)
32  }
33
34  return (h >>> 0).toString(16) + text.length.toString(16)
35}
36
37// A prompt row names the numbers; the row after it names the files, in the same order.
38function note(text: string, into: Map<string, string>, waiting: string[]): string[] {
39  const numbers = [...new Set([...text.matchAll(TOKEN)].map(match => match[1]))]
40  const sources = [...text.matchAll(SOURCE)].map(match => match[1])
41  const names = numbers.length > 0 ? numbers : waiting
42
43  if (sources.length === 0) {
44    return names
45  }
46
47  sources.forEach((file, n) => {
48    if (names[n] !== undefined && !into.has(names[n])) {
49      into.set(names[n], file)
50    }
51  })
52
53  return []
54}
55
56async function hydrate($: EngineInterface) {
57  const found = new Map<string, string>()
58  let waiting: string[] = []
59
60  for (const message of await $.session.messages()) {
61    if (message.role === 'user') {
62      waiting = note(message.text, found, waiting)
63    }
64  }
65
66  for (const [number, file] of found) {
67    if (!paths.has(number)) {
68      paths.set(number, file)
69    }
70  }
71}
72
73// Width and height sit at bytes 16 to 23 of a PNG: the first 32 base64 characters hold them.
74function pngSize(base64: string): { width: number; height: number } | null {
75  const bytes: number[] = []
76  const head = base64.slice(0, 32)
77
78  for (let n = 0; n < head.length; n += 4) {
79    const a = ALPHABET.indexOf(head[n])
80    const b = ALPHABET.indexOf(head[n + 1])
81    const c = ALPHABET.indexOf(head[n + 2])
82    const d = ALPHABET.indexOf(head[n + 3])
83    bytes.push((a << 2) | (b >> 4), ((b & 15) << 4) | (c >> 2), ((c & 3) << 6) | d)
84  }
85
86  const read = (at: number) => ((bytes[at] << 24) | (bytes[at + 1] << 16) | (bytes[at + 2] << 8) | bytes[at + 3]) >>> 0
87  const width = read(16)
88  const height = read(20)
89
90  return width > 0 && height > 0 ? { width, height } : null
91}
92
93async function makeThumb($: EngineInterface, file: string): Promise<Thumb | null> {
94  const output = `/tmp/image-preview-${hash(file)}.png`
95  const run = await $.process.run(['sips', '-s', 'format', 'png', '-Z', '640', file, '--out', output], {
96    timeoutMs: 15_000,
97  })
98
99  if (run.exitCode !== 0) {
100    return null
101  }
102
103  const { base64 } = await $.fs.read(output, { as: 'bytes' })
104  const size = pngSize(base64)
105
106  if (!size) {
107    return null
108  }
109
110  const aspect = size.height / size.width
111  let columns = MAX_COLUMNS
112  let rows = Math.round((columns * aspect) / 2)
113
114  if (rows > MAX_ROWS) {
115    rows = MAX_ROWS
116    columns = Math.round((rows * 2) / aspect)
117  }
118
119  return { base64, columns: Math.max(1, columns), rows: Math.max(1, rows) }
120}
121
122async function thumbFor($: EngineInterface, number: string): Promise<Thumb | null> {
123  const file = paths.get(number)
124
125  if (!file) {
126    return null
127  }
128
129  if (thumbs.has(file)) {
130    return thumbs.get(file) ?? null
131  }
132
133  let job = running.get(file)
134
135  if (!job) {
136    job = makeThumb($, file).catch(() => null)
137    running.set(file, job)
138  }
139
140  const thumb = await job
141  thumbs.set(file, thumb)
142  running.delete(file)
143
144  return thumb
145}
146
147export const register: Register = on => {
148  // Drawing is pure, so the files are noted when a row is stored.
149  on('session.append', async ($, e, next) => {
150    const result = await next(e)
151
152    if (e.message.type === 'user') {
153      for (const block of e.message.content) {
154        if (block.type === 'text' && typeof block.text === 'string') {
155          pending = note(block.text, paths, pending)
156        }
157      }
158    }
159
160    return result
161  })
162
163  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
164    if (e.surface !== 'terminal' || e.props.origin.kind !== 'composer' || !HAS_TOKEN.test(e.props.text)) {
165      return next(e)
166    }
167
168    const numbers = [...new Set([...e.props.text.matchAll(TOKEN)].map(match => match[1]))]
169
170    for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
171      const missing = numbers.filter(number => !paths.has(number) && !gaveUp.has(number))
172
173      if (missing.length === 0) {
174        break
175      }
176
177      hydrating ??= hydrate($)
178        .catch(() => undefined)
179        .finally(() => {
180          hydrating = null
181        })
182      await hydrating
183
184      if (missing.every(number => paths.has(number))) {
185        break
186      }
187
188      if (attempt === ATTEMPTS - 1) {
189        missing.forEach(number => gaveUp.add(number))
190      } else {
191        await $.clock.sleep(RETRY_MS)
192      }
193    }
194
195    const drawn = new Map<string, Thumb | null>()
196    await Promise.all(numbers.map(async number => drawn.set(number, await thumbFor($, number))))
197
198    if ([...drawn.values()].every(thumb => thumb === null)) {
199      return next(e)
200    }
201
202    const { Box, Image, Text } = $.ui.resolve(e)
203
204    return (
205      <Box backgroundColor={BACKGROUND} flexDirection="row">
206        <Text dimColor>{'❯ '}</Text>
207        <Box flexDirection="column" flexGrow={1}>
208          {e.props.text.split('\n').map((line, i) => (
209            <Box key={`l${i}`} flexDirection="row" flexWrap="wrap">
210              {line.split(/(\[Image #\d+\])/).map((part, j) => {
211                if (part === '') {
212                  return null
213                }
214
215                const number = /^\[Image #(\d+)\]$/.exec(part)?.[1]
216                const thumb = number === undefined ? null : drawn.get(number)
217
218                if (!thumb) {
219                  return <Text key={`t${i}-${j}`}>{part}</Text>
220                }
221
222                return (
223                  <Box key={`k${i}-${j}`}>
224                    <Text color="blue" hover={{ underline: true }}>
225                      {part}
226                    </Text>
227                    <Box
228                      position="absolute"
229                      top={-(thumb.rows + 2)}
230                      left={0}
231                      display="none"
232                      hover={{ display: 'flex' }}
233                      borderStyle="round"
234                      borderColor="gray"
235                    >
236                      <Image
237                        key={`p${i}-${j}`}
238                        source={{ png: thumb.base64 }}
239                        columns={thumb.columns}
240                        rows={thumb.rows}
241                        alt={part}
242                      />
243                    </Box>
244                  </Box>
245                )
246              })}
247            </Box>
248          ))}
249        </Box>
250      </Box>
251    )
252  })
253}
254