Clickable labels for pasted images that open them at full quality, plus /img to browse images in a terminal pane

A Claude Code mod for viewing images from the terminal.
Images: Image #n label appears above the prompt. Click it to open the image at full quality in its own window.view: Image #n label under them./img <file | folder> opens a pane to browse images (n/p browse, m block/pixel mode, w window, q close).At the prompt of a Claude Code terminal session:
/plugin install img-viewer --marketplace joseph-kang777/claude-mod-img-viewer
Answer y to add the marketplace, then pick a scope (user scope keeps it on in every session).
Then turn on the fullscreen renderer, which is what lets the terminal report clicks on the image labels:
/tui fullscreen
Without it the labels still show but clicking them does nothing. /tui default switches back.
/tui fullscreen, or "tui": "fullscreen" in settings.json) for clickable labels.magick) for decoding; the system image viewer opens the window.m) or the window.hooks/register.tsx 598 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Pasted, ViewMode } from '../types'
5
6const PANE = 'img-viewer'
7const files = atom({ plugin: 'img-viewer', key: 'files' } as const, [] as string[])
8const index = atom({ plugin: 'img-viewer', key: 'index' } as const, 0)
9const mode = atom({ plugin: 'img-viewer', key: 'mode' } as const, 'block' as ViewMode)
10const pasted = atom({ plugin: 'img-viewer', key: 'pasted' } as const, [] as Pasted[])
11const IMAGE_TAG = /\[Image #(\d+)\]/g
12
13export const EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tif', 'tiff', 'ico', 'webp']
14const DEFAULT_COLOR = 0x01000000
15
16/**
17 * Quadrant glyphs by which of a cell's four pixels take the foreground:
18 * bit 1 top-left, 2 top-right, 4 bottom-left, 8 bottom-right.
19 */
20const QUADRANT = [
21 0x20, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b,
22 0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f, 0x2588,
23]
24
25/** One decode: the picture fitted to a box of cells, packed as Raster cells. */
26type Frame =
27 | {
28 kind: 'ok'
29 /** Cells across and down. */
30 columns: number
31 rows: number
32 srcWidth: number
33 srcHeight: number
34 cells: string
35 png?: string
36 }
37 | { kind: 'error'; message: string }
38
39// Module-level cache: a reload empties it, which only costs a re-decode.
40const frames = new Map<string, Frame>()
41const pending = new Set<string>()
42
43export const isImage = (name: string) => {
44 const dot = name.lastIndexOf('.')
45 return dot > 0 && EXTENSIONS.includes(name.slice(dot + 1).toLowerCase())
46}
47const baseName = (path: string) =>
48 path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
49const dirName = (path: string) => {
50 const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
51 if (cut < 0) return '.'
52 // Keep a drive or filesystem root a root: "C:\" and "/".
53 return cut === 0 || /^[A-Za-z]:$/.test(path.slice(0, cut)) ? path.slice(0, cut + 1) : path.slice(0, cut)
54}
55const isWindowsPath = (path: string) => /^[A-Za-z]:[\\/]/.test(path)
56const join = (dir: string, name: string) => {
57 const sep = isWindowsPath(dir) ? '\\' : '/'
58 return /[\\/]$/.test(dir) ? `${dir}${name}` : `${dir}${sep}${name}`
59}
60const unquote = (text: string) => text.trim().replace(/^(["'])(.*)\1$/, '$2')
61const firstLine = (text: string) => text.trim().split(/\r?\n/)[0] ?? ''
62const hash = (text: string) => {
63 let h = 2166136261
64 for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619)
65 return (h >>> 0).toString(36)
66}
67
68/**
69 * Packs a `2 * columns` x `2 * rows` picture into Raster cells, four pixels a
70 * cell: each cell splits its pixels into the two color groups that fit them
71 * best and draws the quadrant glyph of that split.
72 */
73export const toCells = (
74 px: Uint8Array,
75 columns: number,
76 rows: number,
77 stride: number,
78 isBgra: boolean,
79) => {
80 const words = new Uint32Array(columns * rows * 3)
81 const r = [0, 0, 0, 0]
82 const g = [0, 0, 0, 0]
83 const b = [0, 0, 0, 0]
84 for (let cy = 0; cy < rows; cy++) {
85 for (let cx = 0; cx < columns; cx++) {
86 let opaque = 0
87 for (let q = 0; q < 4; q++) {
88 const at = (cy * 2 + (q >> 1)) * stride + (cx * 2 + (q & 1)) * 4
89 const a = px[at + 3] ?? 0
90 if (a >= 128) opaque++
91 // Transparent pixels count as black: pastes are almost always opaque.
92 const k = a >= 128 ? 1 : 0
93 r[q] = ((isBgra ? px[at + 2] : px[at]) ?? 0) * k
94 g[q] = (px[at + 1] ?? 0) * k
95 b[q] = ((isBgra ? px[at] : px[at + 2]) ?? 0) * k
96 }
97 const i = (cy * columns + cx) * 3
98 if (opaque === 0) {
99 words[i] = 0x20
100 words[i + 1] = DEFAULT_COLOR
101 words[i + 2] = DEFAULT_COLOR
102 continue
103 }
104 // Try each split (masks holding the top-left pixel cover them all once).
105 let best = 15
106 let bestErr = Infinity
107 let bestFg = 0
108 let bestBg = 0
109 for (let mask = 1; mask < 16; mask += 2) {
110 let fr = 0, fg = 0, fb = 0, fn = 0, br = 0, bg = 0, bb = 0, bn = 0
111 for (let q = 0; q < 4; q++) {
112 if (mask & (1 << q)) { fr += r[q]!; fg += g[q]!; fb += b[q]!; fn++ }
113 else { br += r[q]!; bg += g[q]!; bb += b[q]!; bn++ }
114 }
115 fr /= fn; fg /= fn; fb /= fn
116 if (bn) { br /= bn; bg /= bn; bb /= bn }
117 let err = 0
118 for (let q = 0; q < 4; q++) {
119 const isFg = mask & (1 << q)
120 const dr = r[q]! - (isFg ? fr : br)
121 const dg = g[q]! - (isFg ? fg : bg)
122 const db = b[q]! - (isFg ? fb : bb)
123 err += dr * dr * 0.3 + dg * dg * 0.59 + db * db * 0.11
124 }
125 if (err < bestErr) {
126 bestErr = err
127 best = mask
128 bestFg = (Math.round(fr) << 16) | (Math.round(fg) << 8) | Math.round(fb)
129 bestBg = bn ? (Math.round(br) << 16) | (Math.round(bg) << 8) | Math.round(bb) : bestFg
130 }
131 if (err === 0) break
132 }
133 words[i] = QUADRANT[best]!
134 words[i + 1] = bestFg >>> 0
135 words[i + 2] = bestBg >>> 0
136 }
137 }
138 return new Uint8Array(words.buffer).toBase64()
139}
140
141/** Decodes `path` for a box of `maxCols` x `maxRows` cells: PowerShell on Windows, ImageMagick elsewhere. */
142const decode = async (
143 $: EngineInterface,
144 path: string,
145 maxCols: number,
146 maxRows: number,
147 wantPng: boolean,
148): Promise<Frame> => {
149 const root = $.plugin.root
150 const isPng = path.toLowerCase().endsWith('.png')
151 const pngOut = wantPng && !isPng ? join(join(root, '.cache'), `${hash(path)}.png`) : ''
152 const png = wantPng ? pngOut || path : undefined
153
154 if (isWindowsPath(root)) {
155 const argv = [
156 'powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
157 '-File', join(join(root, 'hooks'), 'decode.ps1'),
158 '-Path', path, '-MaxCols', String(maxCols), '-MaxRows', String(maxRows),
159 ...(pngOut ? ['-OutPng', pngOut] : []),
160 ]
161 const run = await $.process.run(argv, { timeoutMs: 30_000 })
162 if (run.exitCode !== 0) {
163 return { kind: 'error', message: firstLine(run.stderr) || `decoder exited ${run.exitCode}` }
164 }
165 const [head = '', body = ''] = run.stdout.trim().split(/\r?\n/)
166 const [w = 0, h = 0, stride = 0, sw = 0, sh = 0] = head.trim().split(' ').map(Number)
167 if (!w || !h || !stride) return { kind: 'error', message: `unexpected decoder output: ${head}` }
168 const columns = w / 2
169 const rows = h / 2
170 const cells = toCells(Uint8Array.fromBase64(body.trim()), columns, rows, stride, true)
171 return { kind: 'ok', columns, rows, srcWidth: sw, srcHeight: sh, cells, png }
172 }
173
174 // POSIX: ImageMagick, raw RGBA through a temp file.
175 const info = await $.process.run(['magick', 'identify', '-format', '%w %h\n', `${path}[0]`])
176 if (info.exitCode !== 0) {
177 return { kind: 'error', message: firstLine(info.stderr) || 'ImageMagick (magick) is required' }
178 }
179 const [sw = 0, sh = 0] = firstLine(info.stdout).split(/\s+/).map(Number)
180 if (!sw || !sh) return { kind: 'error', message: `unexpected identify output: ${info.stdout}` }
181 // Cells are about twice as tall as wide: cols / rows = 2 * W / H keeps the shape.
182 const fit = Math.min(maxCols, (2 * maxRows * sw) / sh)
183 const columns = Math.max(1, Math.round(fit))
184 const rows = Math.max(1, Math.min(maxRows, Math.round((fit * sh) / (2 * sw))))
185 const raw = `/tmp/img-viewer-${hash(path)}-${columns}x${rows}.rgba`
186 const conv = await $.process.run([
187 'magick', `${path}[0]`, '-resize', `${columns * 2}x${rows * 2}!`, '-depth', '8', `rgba:${raw}`,
188 ])
189 if (conv.exitCode !== 0) return { kind: 'error', message: firstLine(conv.stderr) }
190 if (pngOut) {
191 await $.fs.write(join(join(root, '.cache'), '.keep'), '')
192 await $.process.run(['magick', `${path}[0]`, pngOut])
193 }
194 const { base64 } = await $.fs.read(raw, { as: 'bytes' })
195 const cells = toCells(Uint8Array.fromBase64(base64), columns, rows, columns * 8, false)
196 return { kind: 'ok', columns, rows, srcWidth: sw, srcHeight: sh, cells, png }
197}
198
199/** The cached frame, or undefined after starting its decode; the sites redraw when it lands. */
200const ensure = ($: EngineInterface, path: string, maxCols: number, maxRows: number, wantPng: boolean) => {
201 const key = `${path}|${maxCols}|${maxRows}|${wantPng}`
202 const cached = frames.get(key)
203 if (cached || pending.has(key)) return cached
204 pending.add(key)
205 void decode($, path, maxCols, maxRows, wantPng)
206 .catch((error: unknown): Frame => ({
207 kind: 'error',
208 message: error instanceof Error ? error.message : String(error),
209 }))
210 .then(frame => {
211 frames.set(key, frame)
212 if (frames.size > 40) {
213 const oldest = frames.keys().next().value
214 if (oldest !== undefined) frames.delete(oldest)
215 }
216 pending.delete(key)
217 $.ui.invalidate('ui.render')
218 })
219 return undefined
220}
221
222
223/** The image files beside `target` (a file) or inside it (a folder), and which to show. */
224const collect = async ($: EngineInterface, target: string) => {
225 const stat = await $.fs.stat(target, { resolve: true })
226 const real = stat.realPath ?? target
227 const isDir = stat.kind === 'dir'
228 const dir = isDir ? real : dirName(real)
229 const entries = await $.fs.list(dir)
230 const list = entries
231 .filter(one => one.kind === 'file' && isImage(one.name))
232 .map(one => one.name)
233 .sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }))
234 .map(name => join(dir, name))
235 if (!isDir && !list.includes(real)) list.unshift(real)
236 return { list, at: isDir ? 0 : Math.max(0, list.indexOf(real)) }
237}
238
239const step = async ($: EngineInterface, by: number) => {
240 const count = (await read($, files)).length
241 await update($, index, i => (count ? ((((i ?? 0) + by) % count) + count) % count : 0))
242}
243const toggleMode = ($: EngineInterface) =>
244 update($, mode, m => (m === 'pixel' ? 'block' : 'pixel'))
245
246/** Terminals that draw kitty graphics, as `TERM_PROGRAM` names them. */
247const PIXEL_TERMINALS = ['wezterm', 'kitty', 'ghostty']
248
249/** The host terminal's `TERM_PROGRAM`, or '' when it sets none. */
250const terminalProgram = async ($: EngineInterface) => {
251 const argv = isWindowsPath($.plugin.root)
252 ? ['cmd.exe', '/d', '/c', 'echo %TERM_PROGRAM%']
253 : ['sh', '-c', 'echo "$TERM_PROGRAM"']
254 const run = await $.process.run(argv).catch(() => undefined)
255 const said = firstLine(run?.stdout ?? '')
256 return said === '%TERM_PROGRAM%' ? '' : said
257}
258
259/** Opens the viewer as a dialog: it takes the keys and Esc closes it. */
260const openModal = ($: EngineInterface) =>
261 $.ui.open({ id: PANE, title: 'Image', focus: true, closeOnEscape: true, holdToasts: true })
262
263/**
264 * Shows `list[at]` at full quality in a window of its own: a borderless,
265 * always-on-top WinForms window on Windows, the system viewer elsewhere.
266 */
267const openWindow = async ($: EngineInterface, list: string[], at: number) => {
268 const root = $.plugin.root
269 const path = list[at]
270 if (!path) return
271 if (!isWindowsPath(root)) {
272 const isMac = (await $.process.run(['uname'])).stdout.trim() === 'Darwin'
273 await $.process.run([isMac ? 'open' : 'xdg-open', path])
274 return
275 }
276 const viewer = $.process.spawn({
277 argv: [
278 'powershell.exe', '-STA', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
279 '-WindowStyle', 'Hidden',
280 '-File', join(join(root, 'hooks'), 'viewer.ps1'),
281 '-List', list.join('|'), '-Index', String(at),
282 ],
283 })
284 // The loop is the window's life: it ends when the window closes.
285 void (async () => {
286 for await (const piece of viewer) {
287 if (piece.stream === 'stderr') $.ui.log(`img-viewer window: ${piece.text}`, { to: 'debug' })
288 }
289 })().catch(error =>
290 $.ui.log(`img-viewer window: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' }),
291 )
292}
293
294let tempDir: string | undefined
295
296/** Looks up the temp folder once; session.start warms it so typing never starts a process. */
297const loadTempDir = async ($: EngineInterface) => {
298 if (tempDir !== undefined) return
299 const isWindows = isWindowsPath($.plugin.root)
300 const run = await $.process
301 .run(isWindows ? ['cmd.exe', '/d', '/c', 'echo %TEMP%'] : ['sh', '-c', 'echo "${TMPDIR:-/tmp}"'])
302 .catch(() => undefined)
303 tempDir = firstLine(run?.stdout ?? '').replace(/[\\/]$/, '')
304}
305
306/** Where Claude Code keeps this session's `[Image #n]`: <temp>/claude/<project>/<session>/images/<n>.png. */
307const sessionImage = async ($: EngineInterface, tag: number) => {
308 await loadTempDir($)
309 if (!tempDir) return undefined
310 const project = (await $.session.cwd()).replace(/[:\\/]/g, '-')
311 const dir = [tempDir, 'claude', project, await $.session.id(), 'images'].reduce(join)
312 const path = join(dir, `${tag}.png`)
313 return (await $.fs.exists(path)) ? path : undefined
314}
315
316/** Opens the window on the images a past message carried, at `tag`. */
317const openTags = async ($: EngineInterface, tags: number[], tag: number) => {
318 const pastedNow = await read($, pasted)
319 const found: { tag: number; path: string }[] = []
320 for (const one of tags) {
321 const path =
322 (await sessionImage($, one)) ?? pastedNow.find(p => p.match === `[Image #${one}]`)?.path
323 if (path) found.push({ tag: one, path })
324 }
325 const at = found.findIndex(one => one.tag === tag)
326 if (at < 0) {
327 $.ui.toast(`Image #${tag} is no longer on disk`)
328 return
329 }
330 await openWindow($, found.map(one => one.path), at)
331}
332
333const showPasted = async ($: EngineInterface, at: number) => {
334 const list = (await read($, pasted)).map(one => one.path)
335 await update($, files, () => list)
336 await update($, index, () => at)
337 await openWindow($, list, at)
338}
339
340const grabbing = new Set<number>()
341/** Tags given up on: never on disk, so the draft does not look for them again. */
342const grabbed = new Set<number>()
343
344/** Keeps the thumbnails in step with the draft: new `[Image #n]` tags or pasted paths in, removed ones out. */
345const syncPasted = async ($: EngineInterface, inputText: string, draft: string) => {
346 const added: Pasted[] = []
347 const current = await read($, pasted)
348 for (const match of draft.matchAll(IMAGE_TAG)) {
349 const tag = Number(match[1])
350 if (current.some(one => one.match === match[0]) || grabbing.has(tag)) continue
351 if (grabbed.has(tag)) continue
352 grabbing.add(tag)
353 try {
354 // Claude Code's own copy first: a file check, no process. The tag shows in
355 // the draft before that file is written, so wait for it a few seconds
356 // rather than start a PowerShell right as the person goes back to typing.
357 let saved: string | undefined
358 for (let tries = 0; tries < 10 && !saved; tries++) {
359 if (tries > 0) await $.clock.sleep(300)
360 saved = await sessionImage($, tag)
361 }
362 if (saved) {
363 added.push({ match: match[0], path: saved, label: `Image #${tag}` })
364 continue
365 }
366 // No clipboard fallback: starting a PowerShell under the terminal while the
367 // person types resets the Korean/English IME, so a tag never found is skipped.
368 grabbed.add(tag)
369 $.ui.log(`img-viewer: Image #${tag} not on disk, skipped`, { to: 'debug' })
370 } finally {
371 grabbing.delete(tag)
372 }
373 }
374 const typed = unquote(inputText)
375 if (typed && !/[\r\n]/.test(typed) && isImage(typed) && !current.some(one => one.match === typed)) {
376 const stat = await $.fs.stat(typed, { resolve: true }).catch(() => undefined)
377 if (stat?.kind === 'file') added.push({ match: typed, path: stat.realPath ?? typed, label: baseName(typed) })
378 }
379 const stillThere = (one: Pasted) => draft.includes(one.match)
380 if (added.length === 0 && current.every(stillThere)) return
381 await update($, pasted, list => [
382 ...(list ?? []).filter(stillThere),
383 ...added.filter(one => !(list ?? []).some(old => old.match === one.match)),
384 ])
385}
386
387export const register: Register = on => {
388 on('session.start', async ($, e, next) => {
389 await $.command.register({
390 name: 'img',
391 description: 'View images in a pane (n/p browse, m block/pixel mode, q close)',
392 argumentHint: '[file | folder | next | prev | mode | close]',
393 })
394 // An image paste raises no prompt.edit: watch the draft so its label shows at once.
395 let lastDraft = ''
396 let isSyncing = false
397 $.clock.every(400, async () => {
398 if (isSyncing) return
399 const { text } = await $.prompt.read()
400 if (text === lastDraft) return
401 lastDraft = text
402 isSyncing = true
403 try {
404 await syncPasted($, '', text)
405 } finally {
406 isSyncing = false
407 }
408 })
409 await loadTempDir($)
410 // First load this session: pixels where the terminal draws them, blocks elsewhere.
411 const { value } = await $.state.get({ plugin: 'img-viewer', key: 'mode' })
412 if (value === undefined) {
413 const program = await terminalProgram($)
414 if (PIXEL_TERMINALS.some(name => program.toLowerCase().includes(name))) {
415 await update($, mode, () => 'pixel')
416 }
417 }
418 return next(e)
419 })
420
421 on('prompt.edit', async ($, e, next) => {
422 const box = await next(e)
423 // Off the editor's path: waiting for the image file takes a moment.
424 void syncPasted($, e.inputText, box.text).catch(error =>
425 $.ui.log(`img-viewer: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' }),
426 )
427 return box
428 })
429
430 on('prompt.submit', async ($, e, next) => {
431 const sent = await next(e)
432 await update($, pasted, () => [])
433 return sent
434 }).catch(() => undefined) // a failed clear never holds up the prompt
435
436 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
437 const list = await read($, pasted)
438 if (e.props.hasSurvey || list.length === 0 || e.surface !== 'terminal') return next(e)
439 const { Box, Text, Button } = $.ui.resolve(e)
440
441 // No preview: one clickable label per pasted image, opening the full-quality window.
442 return (
443 <Box flexDirection="row" gap={2}>
444 <Text dimColor>Images:</Text>
445 {list.map((one, i) => (
446 <Button
447 key={`open-${i}`}
448 plain
449 onPress={() => showPasted($, i)}
450 >
451 {one.label}
452 </Button>
453 ))}
454 </Box>
455 )
456 })
457
458 // Past prompts that carried images get a clickable label per image under them.
459 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
460 const drawn = await next(e)
461 if (e.surface !== 'terminal') return drawn
462 const tags = [...new Set([...e.props.text.matchAll(IMAGE_TAG)].map(m => Number(m[1])))]
463 if (tags.length === 0) return drawn
464 const { Box, Text, Button } = $.ui.resolve(e)
465 return (
466 <Box flexDirection="column">
467 {drawn}
468 <Box flexDirection="row" gap={2} paddingLeft={2}>
469 <Text dimColor>view:</Text>
470 {tags.map(tag => (
471 <Button key={`msg-image-${tag}`} plain dimColor onPress={() => openTags($, tags, tag)}>
472 {`Image #${tag}`}
473 </Button>
474 ))}
475 </Box>
476 </Box>
477 )
478 })
479
480 on('command.run', { command: 'img' }, async ($, e) => {
481 const arg = unquote(e.args)
482 if (arg === 'close') {
483 await $.ui.close({ id: PANE })
484 return { text: 'Image viewer closed.' }
485 }
486 if (arg === 'next' || arg === 'prev') {
487 await step($, arg === 'next' ? 1 : -1)
488 } else if (arg === 'mode') {
489 await toggleMode($)
490 } else {
491 const target = arg || '.'
492 if (!(await $.fs.exists(target))) return { text: `No such file or folder: ${target}` }
493 const { list, at } = await collect($, target)
494 if (list.length === 0) return { text: `No images in ${target} (${EXTENSIONS.join(', ')}).` }
495 await update($, files, () => list)
496 await update($, index, () => at)
497 }
498 await openModal($)
499 const list = await read($, files)
500 const i = await read($, index)
501 return {
502 text: list.length
503 ? `Showing ${baseName(list[i] ?? '')} (${i + 1}/${list.length}).`
504 : 'No image open.',
505 }
506 })
507
508 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
509 if (e.surface !== 'terminal') {
510 const { Text } = $.ui.resolve(e)
511 return <Text dimColor>The image viewer draws on the terminal only.</Text>
512 }
513 const { Box, Text, Button, Raster, Image } = $.ui.resolve(e)
514 const list = await read($, files)
515 const i = await read($, index)
516 const viewMode = await read($, mode)
517 const path = list[i]
518
519 const cols = Math.max(4, Math.min(512, e.props.bodyColumns))
520 // Two rows for the header and the buttons.
521 const rows = Math.max(2, Math.min(255, e.props.scroll.bodyRows - 2))
522 const wantPng = viewMode === 'pixel'
523
524 const controls = (
525 <Box flexDirection="row" gap={1}>
526 <Button key="prev" hotkey="p" onPress={() => step($, -1)}>Prev</Button>
527 <Button key="next" hotkey="n" onPress={() => step($, 1)}>Next</Button>
528 <Button key="window" hotkey="w" variant="primary" onPress={async () => openWindow($, await read($, files), await read($, index))}>
529 Window
530 </Button>
531 <Button key="mode" hotkey="m" onPress={() => toggleMode($)}>
532 {viewMode === 'pixel' ? 'Blocks' : 'Pixels'}
533 </Button>
534 <Button key="close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: PANE })}>Close</Button>
535 </Box>
536 )
537
538 if (!path) {
539 return (
540 <Box flexDirection="column">
541 <Text dimColor>No image. Run /img path/to/picture.png or /img path/to/folder</Text>
542 {controls}
543 </Box>
544 )
545 }
546
547 const frame = ensure($, path, cols, rows, wantPng)
548 // Warm the neighbours once this one is in, so n and p feel instant.
549 if (frame && list.length > 1) {
550 for (const near of [list[(i + 1) % list.length], list[(i - 1 + list.length) % list.length]]) {
551 if (near) ensure($, near, cols, rows, wantPng)
552 }
553 }
554
555 const size = frame?.kind === 'ok' ? ` ${frame.srcWidth}x${frame.srcHeight}` : ''
556 const header = (
557 <Text wrap="truncate-end">
558 <Text bold>{baseName(path)}</Text>
559 <Text dimColor>{` ${i + 1}/${list.length}${size} ${viewMode}`}</Text>
560 </Text>
561 )
562
563 let body
564 if (!frame) {
565 body = <Text dimColor>Decoding...</Text>
566 } else if (frame.kind === 'error') {
567 body = <Text color="red">Cannot show this image: {frame.message}</Text>
568 } else if (wantPng && frame.png) {
569 body = (
570 <Image
571 key="view"
572 source={{ file: frame.png, format: 'png' }}
573 columns={frame.columns}
574 rows={frame.rows}
575 alt={`${baseName(path)}: no kitty graphics in this terminal, press m for blocks`}
576 />
577 )
578 } else {
579 body = (
580 <Raster
581 key="view"
582 columns={frame.columns}
583 rows={frame.rows}
584 cells={frame.cells}
585 />
586 )
587 }
588
589 return (
590 <Box flexDirection="column">
591 {header}
592 {body}
593 {controls}
594 </Box>
595 )
596 })
597}
598types/index.d.ts 26 lines1/** `block`: half-block glyphs (any truecolor terminal); `pixel`: kitty graphics. */
2export type ViewMode = 'block' | 'pixel'
3
4/** An image in the prompt draft, shown as a thumbnail above the prompt. */
5export type Pasted = {
6 /** The text in the draft that stands for it: `[Image #2]`, or a pasted path. */
7 match: string
8 /** Absolute path of the image file. */
9 path: string
10 label: string
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'img-viewer': {
16 /** Absolute paths of the images being browsed. */
17 files: string[]
18 /** Which of `files` is shown. */
19 index: number
20 mode: ViewMode
21 /** Images in the current prompt draft. */
22 pasted: Pasted[]
23 }
24 }
25}
26