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

hooks/register.tsx 83 lines1import 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