SLOPSHOPPER

image-preview

Shows images the agent reads or mentions inline, with a button to open them

newrowsprocess
v0.1.0no licenseupdated 2026-10-22pixeption/dotfiles/claude/mods/image-preview
A shopper browsing a rack in a slop shop
Source 1 files
hooks/register.tsx 83 lines
1import type { EngineInterface, Register, RenderElement } from 'claude-code'
2
3const IMAGE_PATH = /(?:file:\/\/)?[^\s`'"()<>[\]]+\.(?:png|jpe?g|gif|webp|bmp|tiff?|heic)\b/gi
4const CACHE_DIR = '/tmp/claude-image-preview'
5const MAX_COLUMNS = 48
6const MAX_ROWS = 16
7const MAX_PER_MESSAGE = 4
8
9type Preview = { path: string; png: string; columns: number; rows: number }
10type TerminalRender = Extract<Parameters<EngineInterface['ui']['resolve']>[0], { surface: 'terminal' }>
11
12const previews = new Map<string, Promise<Preview | null>>()
13
14const homeDir = async ($: EngineInterface) => (await $.process.run(['printenv', 'HOME'])).stdout.trim()
15
16const pixelSize = async ($: EngineInterface, png: string) => {
17  const { stdout } = await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', png])
18  const [width, height] = [/pixelWidth: (\d+)/, /pixelHeight: (\d+)/].map(re => Number(stdout.match(re)?.[1]))
19  return { width, height }
20}
21
22const toPng = async ($: EngineInterface, path: string) => {
23  if (/\.png$/i.test(path)) return path
24  const png = `${CACHE_DIR}/${path.replace(/[^\w.-]/g, '_')}.png`
25  await $.process.run(['mkdir', '-p', CACHE_DIR])
26  const { exitCode } = await $.process.run(['sips', '-s', 'format', 'png', path, '--out', png])
27  return exitCode === 0 ? png : null
28}
29
30const loadPreview = async ($: EngineInterface, raw: string): Promise<Preview | null> => {
31  const spelled = raw.replace(/^file:\/\//, '').replace(/^~(?=\/)/, await homeDir($))
32  const stat = await $.fs.stat(spelled, { resolve: true }).catch(() => undefined)
33  const path = stat?.kind === 'file' ? stat.realPath : undefined
34  const png = path && (await toPng($, path))
35  if (!path || !png) return null
36  const { width, height } = await pixelSize($, png)
37  if (!width || !height) return null
38  const columns = Math.min(MAX_COLUMNS, Math.ceil(width / 10))
39  const rows = Math.max(1, Math.min(MAX_ROWS, Math.round((columns * height) / width / 2)))
40  return { path, png, columns, rows }
41}
42
43const preview = ($: EngineInterface, raw: string) => {
44  const cached = previews.get(raw) ?? loadPreview($, raw).catch(() => null)
45  previews.set(raw, cached)
46  void cached.then(p => p ?? previews.delete(raw))
47  return cached
48}
49
50const withPreviews = async ($: EngineInterface, e: TerminalRender, own: RenderElement, paths: string[]) => {
51  const found = (await Promise.all(paths.map(p => preview($, p)))).filter(p => p !== null)
52  const unique = [...new Map(found.map(p => [p.path, p])).values()].slice(0, MAX_PER_MESSAGE)
53  if (unique.length === 0) return own
54  const { Box, Button, Image } = $.ui.resolve(e)
55  return (
56    <Box flexDirection="column">
57      {own}
58      {unique.map(p => (
59        <Box key={p.path} flexDirection="column" marginLeft={2}>
60          <Image source={{ file: p.png, format: 'png' }} columns={p.columns} rows={p.rows} alt={p.path} />
61          <Button key={`open:${p.path}`} label={`open ${p.path.split('/').pop()} ↗`} onPress={() => $.process.run(['open', p.path])} />
62        </Box>
63      ))}
64    </Box>
65  )
66}
67
68export const register: Register = on => {
69  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
70    const own = await next(e)
71    const { tool, input, isRunning, isErrored } = e.props
72    const filePath = (input as { file_path?: unknown } | undefined)?.file_path
73    if (e.surface !== 'terminal' || tool !== 'Read' || isRunning || isErrored || typeof filePath !== 'string') return own
74    return withPreviews($, e, own, filePath.match(IMAGE_PATH) ?? [])
75  })
76
77  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
78    const own = await next(e)
79    if (e.surface !== 'terminal') return own
80    return withPreviews($, e, own, e.props.text.match(IMAGE_PATH) ?? [])
81  })
82}
83