SLOPSHOPPER

img-viewer

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

newpanebandrowscommandtoast
★ 1v0.1.0no licenseupdated 2026-10-07joseph-kang777/claude-mod-img-viewer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · img-viewer
│ ┃ img-viewer ✕ › fix the failing auth test and add an audit log call │ ┃ No image. Run /img path/to/picture.png or │ ┃ /img path/to/folder ⏺ Read(src/auth.ts) │ ┃ [ Prev ] [ Next ] [ Window ] [ Pixels ] [ Cl ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /img │ ⎿ img-viewer: No such file or folder: . │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · img-viewer
No image. Run /img path/to/picture.png or /img path/to/folder [ Prev ] [ Next ] [ Window ] [ Pixels ] [ Close ]
README

claude-mod-img-viewer

A Claude Code mod for viewing images from the terminal.

  • When you paste an image, an Images: Image #n label appears above the prompt. Click it to open the image at full quality in its own window.
  • Past messages that carried images get a 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).

Install

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.

Requirements

  • The fullscreen renderer (/tui fullscreen, or "tui": "fullscreen" in settings.json) for clickable labels.
  • A Claude Code build with mods (2.1.292 or later).
  • Windows: PowerShell (built in) for decoding and the viewer window.
  • macOS / Linux: ImageMagick (magick) for decoding; the system image viewer opens the window.
  • Pixel mode needs a terminal with kitty graphics Unicode placeholders (kitty, Ghostty, WezTerm). Elsewhere use block mode (m) or the window.
Source 2 files
hooks/register.tsx 598 lines
1import { 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}
598
types/index.d.ts 26 lines
1/** `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