SLOPSHOPPER

cc-image-view

See the images you paste into Claude Code: thumbnails above the prompt instead of bare [Image #1] tags

newpanebandrowspromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-image-view
│ ┃ cc-image-view ✕ › fix the failing auth test and add an audit log call │ ┃ No image selected │ ⏺ Read(src/auth.ts) │ ⎿ 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 │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · cc-image-view
No image selected
README

cc-image-view

English | 繁體中文

A Claude Code mod that lets you see the images you paste, in two places:

  • Above the prompt: while the draft holds [Image #n] tags, a row of numbered thumbnails shows above it. With no tags, nothing is drawn.
  • Under sent prompts: a prompt you sent with images gets a row of [ img #n ] buttons. Hover one and its thumbnail appears right under it; ⤢ Zoom under the thumbnail, or the button itself, opens the picture in a side pane (Esc closes it). The picture itself can't be clicked: Claude Code's image element takes no presses.

https://github.com/user-attachments/assets/d673f3cf-24d4-43b7-85b0-539b95333d52

The demo is an HTML reconstruction, not a screen recording: the side pane is drawn wider than in a real terminal (about 44% of the window against about 29%), the reveal and slide timings were designed, and terminal glyphs such as ⎿ are redrawn.

Which tags count

  • Above the prompt only looks at the tag and the paste cache, so a typed tag whose number is still cached shows that old picture.
  • Under sent prompts shows only pictures the transcript ties to that prompt: Claude Code stores each prompt's paste numbers in imagePasteIds, one per image block. A typed [Image #1] has no such record and never shows a picture; in a prompt that mixes a typed old tag with a new paste, only the paste shows. When two prompts read exactly the same but carry different images (say the same line typed again later), the row can't tell them apart and neither shows buttons. A prompt just sent shows its buttons once its transcript line is written, usually within a second; until the transcript confirms it, it shows none, however long that takes. If the transcript can't be read in full (a read error, or more than 4 MiB of matching rows), no sent prompt shows buttons until a read succeeds. A prompt with a missing picture gets no button for it.

Formats and files

Claude Code's image element draws PNG only, so JPG, GIF and WebP are converted to PNG once, first frame only; other extensions are not converted. Converters are tried in order: sips (built into macOS), ffmpeg (file input only), magick, convert (256 MiB memory, 1 GiB disk), 10 seconds each; if none works the picture is not shown, and a failed source is not retried. These are programs on your machine and decode the file their own way, so they are part of what you trust when you install this mod. Only sips on macOS has been tested.

Converted PNGs and rescued pictures go to <temp dir>/cc-image-view/<session id>/. Before every write the temp dir itself must be yours, not a symlink, writable by no one else, and inside a folder others can't rename things in (closed to them, or sticky like /tmp); the two folders under it are then made yours and mode 700. If any of that fails nothing is written, so on a shared CLAUDE_CODE_TMPDIR JPG, GIF and WebP previews and rescues are simply off. Files are written under a temporary name and renamed into place. The mod does not delete them; the system's temp cleanup does.

When the paste cache is gone (a reboot cleared the temp dir), pictures of sent prompts are rescued from the transcript. Limit: the rescue reads that prompt's whole transcript line, base64 of every picture included, so a prompt over 4 MiB can't be rescued and its pictures don't show.

What it reads and runs

  • The whole draft text, every 200 ms (a poll interval, not a promise: a picture that needs converting holds that round until it is done).
  • Each cached PNG, read whole with $.fs.read(path, { as: 'bytes' }) for its size; over the engine's 4 MiB cap only the aspect ratio is lost.
  • This session's transcript: grep scans the whole file on disk and hands the mod only the person's rows that mention [Image #, with every base64 blob removed, so a transcript of hundreds of MB never enters the mod's memory, though each first lookup after it grows scans it once. A result over 4 MiB counts as a failed read. A full row is read only to rescue a picture.
  • ~/.claude/settings.json (or the one under CLAUDE_CONFIG_DIR), once at start, for the language.

It makes no network requests and calls no model. External commands: id -u (to build the default temp dir when CLAUDE_CODE_TMPDIR is unset), sh / mkdir / chmod (the private folder), grep / sed (the transcript), base64 (rescues), mv (renaming finished files), and the converters above. Paths and message ids are passed as arguments, never spliced into shell code.

It takes over three components: the band above the prompt (AbovePrompt), sent prompts (UserMessage, only to add buttons when pictures are bound, otherwise passed through), and its own zoom pane. Everything else, and every non-terminal surface, is left to the existing chain.

Language

The UI comes in English and Traditional Chinese. The default auto follows the language in Claude Code's settings.json, then LC_ALL / LANG, and falls back to English; any Chinese shows Traditional Chinese. Only the user-level ~/.claude/settings.json (or the one under CLAUDE_CONFIG_DIR) is read, not project settings or --settings. To pin a language, open /config, find this plugin's Language, and pick en or zh-TW.

Install

claude plugin marketplace add GGGODLIN/cc-mod-image-view
claude plugin install cc-image-view@cc-mod-image-view --scope user

Sessions started after the install load it. Needs Claude Code 2.1.287 or later (checked against the 2.1.290 types), macOS or Linux, and a terminal with kitty graphics (Ghostty, kitty); other terminals show the alt text instead of pictures. Under Herdr, Claude Code 2.1.288 reads the terminal name libghostty as no kitty graphics; set CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 for Herdr shells, for example in your interactive shell's startup file:

if [[ "${HERDR_ENV:-}" == "1" ]]; then
  export CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1
fi

Sessions already open don't pick up the new variable; start a new one.

Tested

On Herdr (macOS) only; no promise for other terminals or later Claude Code versions.

  • Above the prompt: Claude Code 2.1.288; JPG previews on 2.1.291.
  • Under sent prompts: 2.1.291 with simulated mouse events: three pictures mixed with text (one JPG), typed fake tags, a typed old tag next to a new paste, a typed copy of a real prompt, --resume, the button row staying put on hover, switching the zoom pane, and a rescue after the whole paste cache was hidden. The Chinese UI was checked live; the English UI by tests only.
  • Not tested: pasting from the clipboard with ctrl+v (only pasted file paths), keyboard-only use of the buttons, fullscreen mode, very long conversations, converters on Linux.

Credits

Adapted and maintained by gggodlin from jarrodwatts/claude-image-view at commit 12795b62f1c17f4b36c980e33672fbdff3a6731a. MIT, Copyright (c) 2026 Jarrod Watts; see LICENSE and NOTICE. The plugin is named cc-image-view so it doesn't clash with upstream's image-view.

Checks

claude plugin validate .
claude plugin test .
tsc -p .
Source 5 files
hooks/register.tsx 510 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PastedImage } from '../types'
5import { buttonOffsets, fitBox, fitCells, fitRow, imageNumbers, pngSize } from './layout'
6import type { Size } from './layout'
7import { pickLocale, stringsFor } from './i18n'
8import type { Strings } from './i18n'
9import { imageBlock, messageFor, sentMessages } from './sent'
10import type { SentMessage } from './sent'
11
12// Pasting an image raises no prompt.edit (the tag only shows up on the next keystroke),
13// so the draft is polled instead.
14const POLL_MS = 200
15
16const images = atom({ plugin: 'cc-image-view', key: 'images' } as const, [] as PastedImage[])
17
18let tmpRoot: string | undefined
19let found: { sessionId: string; dir: string } | undefined
20let transcript: { sessionId: string; path: string } | undefined
21
22async function root($: EngineInterface): Promise<string> {
23  if (tmpRoot === undefined) {
24    const fromEnv = await $.env.get('CLAUDE_CODE_TMPDIR')
25    // Windows caches pastes under %TEMP%\claude rather than /tmp/claude-<uid>
26    const winTemp = (await $.env.get('OS')) === 'Windows_NT' ? await $.env.get('TEMP') : undefined
27    tmpRoot = fromEnv
28      ?? (winTemp ? `${winTemp.replace(/\\/g, '/')}/claude` : `/tmp/claude-${(await $.process.run(['id', '-u'])).stdout.trim()}`)
29  }
30  return tmpRoot
31}
32
33// Windows has no sh, mv or grep on PATH; Git for Windows' bash (which Claude Code itself needs)
34// brings them, so there every command runs through it. Elsewhere commands run as given.
35const GIT_BASH = 'C:/Program Files/Git/bin/bash.exe'
36let windowsBash: string | null | undefined
37
38async function bashOnWindows($: EngineInterface): Promise<string | null> {
39  if (windowsBash === undefined) {
40    windowsBash = (await $.env.get('OS')) === 'Windows_NT' ? ((await $.env.get('CLAUDE_CODE_GIT_BASH_PATH'))?.replace(/\\/g, '/') ?? GIT_BASH) : null
41  }
42  return windowsBash
43}
44
45/** `argv` as this machine can run it: `sh -c` scripts and plain commands alike. */
46async function command($: EngineInterface, argv: string[]): Promise<string[]> {
47  const bash = await bashOnWindows($)
48  if (bash === null) return argv
49  return argv[0] === 'sh' ? [bash, ...argv.slice(1)] : [bash, '-c', 'exec "$@"', 'sh', ...argv]
50}
51
52const execute = async ($: EngineInterface, argv: string[], options: { stdin?: string; timeoutMs: number }) =>
53  $.process.run(await command($, argv), options)
54
55// Claude Code caches each paste as <tmp>/<project>/<session>/images/<n>.<ext>. The project
56// folder is named after a working directory that may since have moved, so find it by the
57// session id instead of rebuilding it.
58async function imagesDir($: EngineInterface): Promise<string | undefined> {
59  const sessionId = await $.session.id()
60  if (found?.sessionId === sessionId) return found.dir
61  const base = await root($)
62  for (const entry of await $.fs.list(base).catch(() => [])) {
63    const dir = `${base}/${entry.name}/${sessionId}/images`
64    if (entry.kind === 'dir' && (await $.fs.exists(dir))) {
65      found = { sessionId, dir }
66      return dir
67    }
68  }
69  return undefined
70}
71
72/** The cached paste for image `n`, whatever its extension (a JPEG paste is `<n>.jpg`). */
73async function cachedFile($: EngineInterface, dir: string | undefined, n: number): Promise<string | null> {
74  if (dir === undefined) return null
75  const entries = await $.fs.list(dir).catch(() => [])
76  const hit = entries.find(entry => entry.kind === 'file' && new RegExp(`^${n}\\.[A-Za-z0-9]+$`).test(entry.name))
77  return hit === undefined ? null : `${dir}/${hit.name}`
78}
79
80// Image draws only PNG (or raw pixels), so the formats Claude Code accepts as pastes are converted
81// once with whatever tool the machine has: sips ships with macOS, the others are common on Linux.
82// Only these extensions, only the first frame, and file input only: a converter is a trust boundary.
83const CONVERTIBLE = /\.(jpe?g|gif|webp)$/i
84const CONVERTERS = (src: string, out: string): string[][] => [
85  ['sips', '-s', 'format', 'png', src, '--out', out],
86  ['ffmpeg', '-loglevel', 'error', '-y', '-protocol_whitelist', 'file', '-i', src, '-frames:v', '1', out],
87  ['magick', '-limit', 'memory', '256MiB', '-limit', 'disk', '1GiB', `${src}[0]`, out],
88  ['convert', '-limit', 'memory', '256MiB', '-limit', 'disk', '1GiB', `${src}[0]`, out],
89]
90
91// The copies here are someone's pictures. A folder is only as private as the one holding it: if
92// another account can rename entries in the temp root, it can swap our folder for its own. So the
93// root must be ours, not a symlink, writable by no one else, and sit in a parent that is either
94// closed to others or sticky; then both folders are made ours and 700. Run on every write, which
95// also recreates a folder someone cleared.
96const PRIVATE_DIRS = [
97  'umask 077',
98  'r="$1"; p=$(dirname "$r")',
99  '[ -d "$r" ] && [ ! -L "$r" ] && [ -O "$r" ] || exit 1',
100  '[ -z "$(find "$r" -maxdepth 0 \\( -perm -0020 -o -perm -0002 \\))" ] || exit 1',
101  '[ -z "$(find "$p" -maxdepth 0 \\( -perm -0020 -o -perm -0002 \\) ! -perm -1000)" ] || exit 1',
102  'for d in "$2" "$3"; do mkdir -p "$d" && [ ! -L "$d" ] && [ -O "$d" ] && chmod 700 "$d" || exit 1; done',
103].join('; ')
104
105async function scratch($: EngineInterface, name: string): Promise<string | null> {
106  const top = await root($)
107  const base = `${top}/cc-image-view`
108  const dir = `${base}/${await $.session.id()}`
109  const run = await execute($, ['sh', '-c', PRIVATE_DIRS, 'sh', top, base, dir], { timeoutMs: 5_000 }).catch(() => null)
110  return run?.exitCode === 0 ? `${dir}/${name}` : null
111}
112
113// One job per output, so two renders asking for the same picture never write it at once
114const jobs = new Map<string, Promise<string | null>>()
115
116function once(out: string, make: () => Promise<string | null>): Promise<string | null> {
117  const running = jobs.get(out)
118  if (running !== undefined) return running
119  const job = make().finally(() => jobs.delete(out))
120  jobs.set(out, job)
121  return job
122}
123
124// Written under a temporary name and renamed into place, so a reader never sees half a file
125async function publish($: EngineInterface, temp: string, out: string): Promise<string | null> {
126  const run = await execute($, ['mv', '-f', temp, out], { timeoutMs: 5_000 }).catch(() => null)
127  return run?.exitCode === 0 ? out : null
128}
129
130/** A PNG path for `src`: itself when it already is one, else a converted copy; null when none could be made. */
131async function asPng($: EngineInterface, src: string, n: number): Promise<string | null> {
132  if (src.toLowerCase().endsWith('.png')) return src
133  // The draft is polled every 200 ms; without this a machine with no converter respawns four tools each time
134  if (!CONVERTIBLE.test(src) || unconvertible.has(src)) return null
135  const out = await scratch($, `${n}.png`)
136  if (out === null) return null
137  return once(out, async () => {
138    if (await $.fs.exists(out)) return out
139    const temp = `${out}.part.png`
140    for (const argv of CONVERTERS(src, temp)) {
141      const ok = await execute($, argv, { timeoutMs: 10_000 }).then(run => run.exitCode === 0, () => false)
142      if (ok && (await $.fs.exists(temp))) return publish($, temp, out)
143    }
144    unconvertible.add(src)
145    return null
146  })
147}
148
149const unconvertible = new Set<string>()
150
151/** Writes base64 bytes from the transcript to a scratch file, for a paste whose cache is gone. */
152async function writeBytes($: EngineInterface, base64: string, name: string): Promise<string | null> {
153  const out = await scratch($, name)
154  if (out === null) return null
155  return once(out, async () => {
156    if (await $.fs.exists(out)) return out
157    const temp = `${out}.part`
158    const run = await execute($, ['sh', '-c', 'umask 077; base64 -d > "$1"', 'sh', temp], { stdin: base64, timeoutMs: 10_000 }).catch(
159      () => null,
160    )
161    return run?.exitCode === 0 && (await $.fs.exists(temp)) ? publish($, temp, out) : null
162  })
163}
164
165const projectFolder = (dir: string) => dir.replace(/[^a-zA-Z0-9]/g, '-')
166
167/** This session's transcript file, found once per session id. */
168async function transcriptPath($: EngineInterface): Promise<string | undefined> {
169  const sessionId = await $.session.id()
170  if (transcript?.sessionId === sessionId) return transcript.path
171  const home = ((await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')))?.replace(/\\/g, '/')
172  const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`
173  const projects = `${config}/projects`
174  const guesses = [await $.session.root(), await $.session.cwd()].map(dir => `${projects}/${projectFolder(dir)}/${sessionId}.jsonl`)
175  for (const path of guesses) {
176    if (await $.fs.exists(path)) {
177      transcript = { sessionId, path }
178      return path
179    }
180  }
181  for (const entry of await $.fs.list(projects).catch(() => [])) {
182    const path = `${projects}/${entry.name}/${sessionId}.jsonl`
183    if (entry.kind === 'dir' && (await $.fs.exists(path))) {
184      transcript = { sessionId, path }
185      return path
186    }
187  }
188  return undefined
189}
190
191// A transcript can run to hundreds of MB, past $.fs.read's 4 MiB, so grep scans it on disk and
192// passes on only the person's rows that mention an image tag, with every base64 blob removed.
193// A pipeline reports only its last command, so a failed read is turned into a line of its own;
194// grep's 1 means no match, which is fine. (awk would report the read itself but takes ~80x longer.)
195const READ_FAILED = '__CC_IMAGE_VIEW_READ_FAILED__'
196const TAGGED_ROWS =
197  `[ -r "$1" ] || exit 2; ` +
198  `{ grep -F '[Image #' "$1"; s=$?; [ "$s" -le 1 ] || echo '${READ_FAILED}'; } | ` +
199  `grep -F -e '"type":"user"' -e '${READ_FAILED}' | ` +
200  `sed -E 's/"data":"[^"]*"/"data":""/g'`
201
202/** Those rows, or null when the read failed or came back cut short: a partial index is not one. */
203async function taggedRows($: EngineInterface, path: string): Promise<string | null> {
204  const run = await execute($, ['sh', '-c', TAGGED_ROWS, 'sh', path], { timeoutMs: 10_000 }).catch(() => null)
205  if (run === null || run.isStdoutTruncated || run.exitCode !== 0) return null
206  return run.stdout.split('\n').includes(READ_FAILED) ? null : run.stdout
207}
208
209/** The one full transcript line of a prompt, base64 included; empty when it is over the 4 MiB output cap. */
210async function fullRow($: EngineInterface, path: string, uuid: string): Promise<string> {
211  const run = await execute($, ['grep', '-F', '-m', '1', `"uuid":"${uuid}"`, path], { timeoutMs: 10_000 }).catch(() => null)
212  return run === null || run.isStdoutTruncated ? '' : run.stdout
213}
214
215async function fileSize($: EngineInterface, path: string): Promise<number> {
216  return (await $.fs.stat(path).catch(() => null))?.size ?? -1
217}
218
219// The image numbers last drawn, so an unchanged draft doesn't rewrite state; undefined
220// while a drawn image's file is still missing, so the next poll looks again.
221let shownKey: string | undefined
222let isChecking = false
223const sizes = new Map<string, Size | null>()
224
225// undefined: the file is no PNG and must not be drawn; null: drawable, aspect ratio unknown
226async function sizeOf($: EngineInterface, path: string): Promise<Size | null | undefined> {
227  if (!sizes.has(path)) {
228    const head = await $.fs.read(path, { as: 'bytes' }).then(
229      ({ base64 }) => pngSize(base64),
230      () => undefined, // too big to read: still drawable, just without its aspect ratio
231    )
232    if (head === null) return undefined
233    sizes.set(path, head ?? null)
234  }
235  return sizes.get(path) ?? null
236}
237
238async function describe($: EngineInterface, dir: string | undefined, n: number): Promise<PastedImage> {
239  const cached = await cachedFile($, dir, n)
240  const path = cached === null ? null : await asPng($, cached, n)
241  const size = path === null ? undefined : await sizeOf($, path)
242  return path === null || size === undefined ? { n, path: null, size: null } : { n, path, size }
243}
244
245// Sent prompts: an image is shown only when the transcript ties it to the prompt (imagePasteIds),
246// so a typed "[Image #1]" never borrows an earlier paste.
247const PANE = 'cc-image-view'
248type Shown = { n: number; path: string; size: Size | null }
249let sent: { path: string; size: number; messages: SentMessage[] } | undefined
250let loading: Promise<void> | undefined
251// A picture found stays found; one that could not be had is retried only once the transcript grew
252const resolved = new Map<string, Shown | { missingAt: number }>()
253let zoomed: Shown | undefined
254
255async function reload($: EngineInterface, path: string) {
256  const size = await fileSize($, path)
257  // Unchanged since the last read: a row still missing is a typed tag, not a late write
258  if (sent?.path === path && sent.size === size) return
259  const rows = await taggedRows($, path)
260  // A failed read voids the old index too: the transcript grew, and the old copy can't say
261  // whether a later prompt made some words ambiguous
262  sent = rows === null ? undefined : { path, size, messages: sentMessages(rows) }
263}
264
265async function sentPrompt($: EngineInterface, text: string): Promise<SentMessage | undefined> {
266  const path = await transcriptPath($)
267  if (path === undefined) return undefined
268  // Re-read whenever the transcript grew, even for a prompt already found: a typed copy sent
269  // later makes the same words ambiguous, and the answer must change with it
270  loading ??= reload($, path).finally(() => {
271    loading = undefined
272  })
273  await loading
274  if (!settled(text)) return undefined
275  return sent === undefined ? undefined : messageFor(sent.messages, text)
276}
277
278// A prompt's row is drawn before its transcript line is written and is not drawn again on its own.
279// Until that line lands the row could only borrow an earlier prompt with the same words, so every
280// sent prompt with a tag waits as pending until the transcript holds as many prompts with those
281// words as were known before plus every send since. Running out of quick retries ends the fast
282// follow-up, never the wait: an unconfirmed prompt stays without buttons, and any later render
283// that finds the lines lands it.
284const FOLLOW_MS = 300
285const FOLLOW_TRIES = 20
286const pending = new Map<string, { before: number | undefined; sends: number }>()
287const countOf = (text: string) => sent?.messages.filter(message => message.text.trim() === text.trim()).length
288
289function settled(text: string): boolean {
290  const key = text.trim()
291  const wait = pending.get(key)
292  if (wait === undefined) return true
293  const now = countOf(text)
294  if (wait.before === undefined || now === undefined || now < wait.before + wait.sends) return false
295  pending.delete(key)
296  return true
297}
298
299// Marked at once: a send never waits on a grep of a large transcript. The baseline comes from the
300// index already in hand, which can't hold this send's line yet; with no index, from the first read.
301function watchSend($: EngineInterface, text: string) {
302  const key = text.trim()
303  const wait = pending.get(key)
304  if (wait !== undefined) wait.sends += 1
305  else pending.set(key, { before: countOf(text), sends: 1 })
306  void (async () => {
307    const entry = pending.get(key)
308    if (entry !== undefined && entry.before === undefined) {
309      const path = await transcriptPath($)
310      if (path !== undefined) await reload($, path)
311      entry.before = countOf(text) ?? 0
312    }
313    follow($, text, FOLLOW_TRIES)
314  })()
315}
316
317function follow($: EngineInterface, text: string, tries: number) {
318  $.clock.after(FOLLOW_MS, () =>
319    void (async () => {
320      const path = await transcriptPath($)
321      if (path !== undefined) await reload($, path)
322      if (!pending.has(text.trim())) return
323      if (settled(text)) $.ui.invalidate('ui.render')
324      else if (tries > 1) follow($, text, tries - 1)
325    })(),
326  )
327}
328
329const EXTENSIONS: Record<string, string> = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' }
330
331async function sentImage($: EngineInterface, message: SentMessage, index: number): Promise<Shown | null> {
332  const n = message.ids[index] ?? 0
333  const key = `${message.uuid}:${index}`
334  const known = resolved.get(key)
335  if (known !== undefined && 'path' in known && (await $.fs.exists(known.path))) return known
336  if (known !== undefined && 'missingAt' in known && known.missingAt === sent?.size) return null
337  const cached = await cachedFile($, await imagesDir($), n)
338  let path = cached === null ? null : await asPng($, cached, n)
339  if (path === null) {
340    // The paste cache lives in a temp folder a reboot clears; the transcript keeps the bytes
341    const transcript = await transcriptPath($)
342    const block = transcript === undefined ? null : imageBlock(await fullRow($, transcript, message.uuid), index)
343    const raw = block === null ? null : await writeBytes($, block.base64, `transcript-${n}.${EXTENSIONS[block.mediaType] ?? 'img'}`)
344    path = raw === null ? null : await asPng($, raw, n)
345  }
346  const size = path === null ? undefined : await sizeOf($, path)
347  if (path === null || size === undefined) {
348    resolved.set(key, { missingAt: sent?.size ?? -1 })
349    return null
350  }
351  const shown = { n, path, size }
352  resolved.set(key, shown)
353  return shown
354}
355
356async function show($: EngineInterface, draft: string) {
357  const numbers = imageNumbers(draft)
358  const key = numbers.join(',')
359  if (key === shownKey) return
360  const dir = numbers.length > 0 ? await imagesDir($) : undefined
361  const list: PastedImage[] = []
362  for (const n of numbers) list.push(await describe($, dir, n))
363  shownKey = list.every(image => image.path !== null) ? key : undefined
364  await update($, images, () => list)
365}
366
367async function check($: EngineInterface) {
368  if (isChecking) return
369  isChecking = true
370  try {
371    await show($, (await $.prompt.read()).text)
372  } finally {
373    isChecking = false
374  }
375}
376
377// One hover group per image: its button and its card light together, so the pointer can travel
378// from one to the other. Image numbers are unique within a session.
379const scopeOf = (n: number) => `cc-image-view-${n}`
380// English until session.start has read the language settings
381let ui: Strings = stringsFor('en')
382
383async function settingsLanguage($: EngineInterface): Promise<unknown> {
384  const home = ((await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')))?.replace(/\\/g, '/')
385  const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`
386  try {
387    return (JSON.parse(await $.fs.read(`${config}/settings.json`)) as { language?: unknown }).language
388  } catch {
389    return undefined
390  }
391}
392
393export const register: Register = (on, options) => {
394  on('prompt.submit', async ($, e, next) => {
395    if (imageNumbers(e.text).length > 0) watchSend($, e.text)
396    return next(e)
397  })
398
399  on('session.start', async ($, e, next) => {
400    $.clock.every(POLL_MS, () => check($))
401    // An empty LC_ALL means unset to the C library, so it must not hide LANG
402    const lcAll = await $.env.get('LC_ALL')
403    const envLang = lcAll !== undefined && lcAll !== '' ? lcAll : await $.env.get('LANG')
404    ui = stringsFor(pickLocale({ option: options.language, claudeLanguage: await settingsLanguage($), envLang }))
405    $.ui.invalidate('ui.render')
406    return next(e)
407  })
408
409  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
410    if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
411    const list = await read($, images)
412    if (list.length === 0) return next(e)
413
414    const { Box, Image, Text } = $.ui.resolve(e)
415    const cells = fitRow(list.map(image => image.size), e.props.maxRows, e.props.bodyColumns)
416    const below = await next(e)
417
418    return (
419      <Box flexDirection="column">
420        <Box flexDirection="row" columnGap={1}>
421          {list.map((image, i) => {
422            const { columns, rows } = cells[i] ?? { columns: 4, rows: 1 }
423            return (
424              <Box flexDirection="column" alignItems="center" borderStyle="round" borderDimColor>
425                {image.path === null ? (
426                  <Box width={columns} height={rows} alignItems="center" justifyContent="center">
427                    <Text dimColor wrap="truncate">{ui.noPreview}</Text>
428                  </Box>
429                ) : (
430                  <Image
431                    key={`image-${image.n}`}
432                    source={{ file: image.path, format: 'png' }}
433                    columns={columns}
434                    rows={rows}
435                    alt={`[Image #${image.n}]`}
436                  />
437                )}
438                <Text dimColor>#{image.n}</Text>
439              </Box>
440            )
441          })}
442        </Box>
443        {below}
444      </Box>
445    )
446  })
447
448  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
449    if (e.surface !== 'terminal' || e.props.origin.kind !== 'composer' || imageNumbers(e.props.text).length === 0) return next(e)
450    const message = await sentPrompt($, e.props.text)
451    if (message === undefined) return next(e)
452    const list: Shown[] = []
453    for (const index of message.ids.keys()) {
454      const shown = await sentImage($, message, index)
455      if (shown !== null) list.push(shown)
456    }
457    if (list.length === 0) return next(e)
458
459    const { Box, Button, Image } = $.ui.resolve(e)
460    const zoom = (shown: Shown) => {
461      zoomed = shown
462      void $.ui.open({ id: PANE, title: ui.paneTitle(shown.n), focus: true, closeOnEscape: true })
463      $.ui.invalidate('ui.render')
464    }
465    const row = await next(e)
466    const offsets = buttonOffsets(list.map(shown => ui.sentButton(shown.n)))
467
468    return (
469      <Box flexDirection="column">
470        {row}
471        <Box flexDirection="row" columnGap={1}>
472          {list.map(shown => (
473            <Box hover={{ scope: scopeOf(shown.n) }}>
474              <Button key={`cc-image-view:open:${shown.n}`} label={ui.sentButton(shown.n)} dimColor onPress={() => zoom(shown)} />
475            </Box>
476          ))}
477        </Box>
478        {list.map((shown, i) => {
479          const cells = fitCells(shown.size)
480          // In the flow under the button row, shifted to sit under its own button: the rows below
481          // move down while it shows, the buttons never do. Absolute cards were painted over by
482          // the transcript rows after them.
483          return (
484            <Box display="none" hover={{ scope: scopeOf(shown.n), display: 'flex' }} marginLeft={offsets[i]} flexDirection="column" alignItems="flex-start">
485              <Box flexDirection="column" alignItems="center" borderStyle="round" borderDimColor>
486                <Image key={`sent-${shown.n}`} source={{ file: shown.path, format: 'png' }} columns={cells.columns} rows={cells.rows} alt={`[Image #${shown.n}]`} />
487                <Button key={`cc-image-view:zoom:${shown.n}`} label={ui.zoom} dimColor onPress={() => zoom(shown)} />
488              </Box>
489            </Box>
490          )
491        })}
492      </Box>
493    )
494  })
495
496  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
497    if (e.surface !== 'terminal') return next(e)
498    const { Box, Image, Text } = $.ui.resolve(e)
499    if (zoomed === undefined) return <Text dimColor>{ui.noSelection}</Text>
500    // One row for the caption under the picture
501    const cells = fitBox(zoomed.size, e.props.bodyColumns, e.props.scroll.bodyRows - 1)
502    return (
503      <Box flexDirection="column" alignItems="center">
504        <Image key="zoom" source={{ file: zoomed.path, format: 'png' }} columns={cells.columns} rows={cells.rows} alt={`[Image #${zoomed.n}]`} />
505        <Text dimColor>{ui.closeHint(zoomed.n)}</Text>
506      </Box>
507    )
508  })
509}
510
hooks/layout.ts 91 lines
1export type Size = { width: number; height: number }
2export type Cells = { columns: number; rows: number }
3
4const TILE_ROWS = 6
5const MAX_COLUMNS = 32
6const MIN_COLUMNS = 4
7// A terminal cell is about twice as tall as it is wide.
8const CELL_ASPECT = 2
9// Used when the size is unknown (file over $.fs.read's 4 MiB cap, or no file).
10const FALLBACK: Size = { width: 16, height: 10 }
11// Each tile adds a border on every side and a label row under the picture.
12const TILE_CHROME_ROWS = 3
13const TILE_CHROME_COLUMNS = 2
14const GAP = 1
15
16/** The distinct image numbers a draft references, in the order they first appear. */
17export function imageNumbers(draft: string): number[] {
18  const seen = new Set<number>()
19  for (const match of draft.matchAll(/\[Image #(\d+)\]/g)) seen.add(Number(match[1]))
20  return [...seen]
21}
22
23/** Width and height from a PNG's IHDR chunk, or null when the bytes aren't a PNG. */
24export function pngSize(base64: string): Size | null {
25  // 24 bytes cover the signature and IHDR's width and height; 32 base64 chars decode to exactly 24.
26  const head = Uint8Array.from(atob(base64.slice(0, 32)), char => char.charCodeAt(0))
27  const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
28  if (head.length < 24 || signature.some((byte, i) => head[i] !== byte)) return null
29  const view = new DataView(head.buffer, head.byteOffset, head.byteLength)
30  const width = view.getUint32(16)
31  const height = view.getUint32(20)
32  return width > 0 && height > 0 ? { width, height } : null
33}
34
35/** A picture box `rows` tall that keeps the picture's aspect ratio. */
36export function fitCells(size: Size | null, tileRows = TILE_ROWS): Cells {
37  const { width, height } = size ?? FALLBACK
38  let rows = tileRows
39  let columns = Math.round((rows * CELL_ASPECT * width) / height)
40  if (columns > MAX_COLUMNS) {
41    columns = MAX_COLUMNS
42    rows = Math.max(1, Math.round((MAX_COLUMNS * height) / (CELL_ASPECT * width)))
43  }
44  return { columns: Math.max(MIN_COLUMNS, columns), rows: Math.min(rows, tileRows) }
45}
46
47/** The largest box inside `maxColumns` × `maxRows` that keeps the picture's aspect ratio; Image caps both at 255. */
48export function fitBox(size: Size | null, maxColumns: number, maxRows: number): Cells {
49  const { width, height } = size ?? FALLBACK
50  const columnsCap = Math.max(1, Math.min(255, maxColumns))
51  const rowsCap = Math.max(1, Math.min(255, maxRows))
52  const columns = Math.round((rowsCap * CELL_ASPECT * width) / height)
53  if (columns <= columnsCap) return { columns: Math.max(1, columns), rows: rowsCap }
54  return { columns: columnsCap, rows: Math.max(1, Math.round((columnsCap * height) / (CELL_ASPECT * width))) }
55}
56
57/**
58 * Picture boxes for one row of tiles that fits the band whole, so it never scrolls:
59 * the tallest tiles whose chrome fits in `maxRows` and whose total width fits in `bodyColumns`.
60 */
61export function fitRow(sizes: readonly (Size | null)[], maxRows: number, bodyColumns: number): Cells[] {
62  const tallest = Math.max(1, Math.min(TILE_ROWS, maxRows - TILE_CHROME_ROWS))
63  for (let tileRows = tallest; tileRows > 1; tileRows--) {
64    const cells = sizes.map(size => fitCells(size, tileRows))
65    const width = cells.reduce((sum, c) => sum + c.columns + TILE_CHROME_COLUMNS, 0) + GAP * (cells.length - 1)
66    if (width <= bodyColumns) return cells
67  }
68  return sizes.map(size => fitCells(size, 1))
69}
70
71// East Asian wide and fullwidth ranges a label may use; everything else here is one cell.
72const WIDE = /[\u1100-\u115f\u2e80-\u303e\u3041-\u33ff\u3400-\u4dbf\u4e00-\u9fff\ua000-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/
73
74/** Terminal cells a string takes. */
75export function cellWidth(text: string): number {
76  let width = 0
77  for (const char of text) width += WIDE.test(char) ? 2 : 1
78  return width
79}
80
81/** Where each button of a row starts: the terminal draws `[ label ]`, one cell apart. */
82export function buttonOffsets(labels: readonly string[]): number[] {
83  const offsets: number[] = []
84  let at = 0
85  for (const label of labels) {
86    offsets.push(at)
87    at += cellWidth(label) + 4 + GAP
88  }
89  return offsets
90}
91
hooks/i18n.ts 51 lines
1export type Locale = 'en' | 'zh-TW'
2
3export type Strings = {
4  noPreview: string
5  sentButton: (n: number) => string
6  zoom: string
7  paneTitle: (n: number) => string
8  closeHint: (n: number) => string
9  noSelection: string
10}
11
12const STRINGS: Record<Locale, Strings> = {
13  en: {
14    noPreview: 'no preview',
15    sentButton: n => `img #${n}`,
16    zoom: '⤢ Zoom',
17    paneTitle: n => `Image #${n}`,
18    closeHint: n => `#${n} · Esc to close`,
19    noSelection: 'No image selected',
20  },
21  'zh-TW': {
22    noPreview: '無法預覽',
23    // CJK, not an emoji: a CJK glyph is two cells on every terminal, so the card offsets add up
24    sentButton: n => `圖 #${n}`,
25    zoom: '⤢ 放大',
26    paneTitle: n => `圖片 #${n}`,
27    closeHint: n => `#${n} · Esc 關閉`,
28    noSelection: '沒有選取的圖片',
29  },
30}
31
32export const stringsFor = (locale: Locale): Strings => STRINGS[locale]
33
34const CHINESE = /中文|漢語|汉语|華語|华语|國語|国语|chinese|mandarin|^zh(?:[-_.\s]|$)/i
35const ENGLISH = /英文|英語|english|^en(?:[-_.\s]|$)/i
36
37/**
38 * The UI language: the mod's own setting when it names one, then Claude Code's free-text
39 * `language` setting, then LC_ALL / LANG; English when none of them says. Any Chinese maps to
40 * Traditional Chinese, the only Chinese the mod ships.
41 */
42export function pickLocale(input: { option: unknown; claudeLanguage: unknown; envLang: string | undefined }): Locale {
43  if (input.option === 'en' || input.option === 'zh-TW') return input.option
44  if (typeof input.claudeLanguage === 'string') {
45    const language = input.claudeLanguage.trim()
46    if (CHINESE.test(language)) return 'zh-TW'
47    if (ENGLISH.test(language)) return 'en'
48  }
49  return /^zh/i.test(input.envLang ?? '') ? 'zh-TW' : 'en'
50}
51
hooks/sent.ts 68 lines
1// `ids` are the paste numbers Claude Code stores with the prompt (imagePasteIds), one per image
2// block in order, so `[Image #ids[i]]` is block i. A prompt with no images has both empty.
3export type SentMessage = { uuid: string; text: string; ids: number[]; kinds: string[] }
4
5type Block = { type?: unknown; text?: unknown; source?: { media_type?: unknown; data?: unknown } }
6type Row = { uuid: string; blocks: Block[]; pasteIds: unknown }
7
8function contentOf(line: string): Row | null {
9  let row: { type?: unknown; isMeta?: unknown; uuid?: unknown; imagePasteIds?: unknown; message?: { content?: unknown } }
10  try {
11    row = JSON.parse(line)
12  } catch {
13    return null
14  }
15  const content = row.message?.content
16  if (row.type !== 'user' || row.isMeta === true || typeof row.uuid !== 'string') return null
17  // A typed prompt is stored as one string; keep it, it is how a typed copy of a real prompt is told apart
18  const blocks: Block[] = typeof content === 'string' ? [{ type: 'text', text: content }] : Array.isArray(content) ? content : []
19  // A tool result can carry an image too (a screenshot tool); only a prompt the person sent counts
20  if (blocks.length === 0 || blocks.some(block => block?.type === 'tool_result')) return null
21  return { uuid: row.uuid, blocks, pasteIds: row.imagePasteIds }
22}
23
24const textOf = (blocks: Block[]) =>
25  blocks
26    .filter(block => block?.type === 'text' && typeof block.text === 'string')
27    .map(block => block.text as string)
28    .join('\n')
29
30/**
31 * The person's prompts from transcript lines (base64 may be stripped), with the paste number of
32 * each image. Without imagePasteIds matching the image blocks one to one, ids stay empty: a number
33 * is never guessed from the tags, since a typed tag reads the same as a real one.
34 */
35export function sentMessages(lines: string): SentMessage[] {
36  const out: SentMessage[] = []
37  for (const line of lines.split('\n')) {
38    const row = contentOf(line)
39    if (row === null) continue
40    const kinds = row.blocks.filter(block => block?.type === 'image').map(block => String(block.source?.media_type ?? ''))
41    const ids = Array.isArray(row.pasteIds) && row.pasteIds.every(id => Number.isInteger(id)) ? (row.pasteIds as number[]) : []
42    const paired = ids.length === kinds.length && kinds.length > 0
43    out.push({ uuid: row.uuid, text: textOf(row.blocks), ids: paired ? ids : [], kinds: paired ? kinds : [] })
44  }
45  return out
46}
47
48/**
49 * The prompt a transcript row shows, matched by its text. Two prompts reading the same with
50 * different images (a real one and a typed copy) can't be told apart from the row, so neither
51 * is returned: no preview beats the wrong one.
52 */
53export function messageFor(messages: readonly SentMessage[], text: string): SentMessage | undefined {
54  const wanted = text.trim()
55  const same = messages.filter(message => message.text.trim() === wanted)
56  const first = same[0]
57  if (first === undefined || same.some(message => message.ids.join(',') !== first.ids.join(','))) return undefined
58  return first.ids.length > 0 ? first : undefined
59}
60
61/** The bytes of a prompt's `index`-th image block, from its full transcript line, for when the paste cache is gone. */
62export function imageBlock(line: string, index: number): { mediaType: string; base64: string } | null {
63  const row = contentOf(line)
64  const source = row?.blocks.filter(block => block?.type === 'image')[index]?.source
65  if (typeof source?.data !== 'string' || source.data === '') return null
66  return { mediaType: String(source.media_type ?? ''), base64: source.data }
67}
68
types/index.d.ts 14 lines
1export type PastedImage = {
2  n: number
3  /** Absolute path of the cached PNG; null when it can't be found. */
4  path: string | null
5  /** Pixel size; null when unknown, and the tile falls back to a default shape. */
6  size: { width: number; height: number } | null
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    'cc-image-view': { images: PastedImage[] }
12  }
13}
14