SLOPSHOPPER

cc-image-view

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

newpanebandrowspromptprocess
★ 20v0.3.1MITupdated 2026-10-06GGGODLIN/cc-mod-image-view
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 487 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    tmpRoot = fromEnv ?? `/tmp/claude-${(await $.process.run(['id', '-u'])).stdout.trim()}`
26  }
27  return tmpRoot
28}
29
30// Claude Code caches each paste as <tmp>/<project>/<session>/images/<n>.<ext>. The project
31// folder is named after a working directory that may since have moved, so find it by the
32// session id instead of rebuilding it.
33async function imagesDir($: EngineInterface): Promise<string | undefined> {
34  const sessionId = await $.session.id()
35  if (found?.sessionId === sessionId) return found.dir
36  const base = await root($)
37  for (const entry of await $.fs.list(base).catch(() => [])) {
38    const dir = `${base}/${entry.name}/${sessionId}/images`
39    if (entry.kind === 'dir' && (await $.fs.exists(dir))) {
40      found = { sessionId, dir }
41      return dir
42    }
43  }
44  return undefined
45}
46
47/** The cached paste for image `n`, whatever its extension (a JPEG paste is `<n>.jpg`). */
48async function cachedFile($: EngineInterface, dir: string | undefined, n: number): Promise<string | null> {
49  if (dir === undefined) return null
50  const entries = await $.fs.list(dir).catch(() => [])
51  const hit = entries.find(entry => entry.kind === 'file' && new RegExp(`^${n}\\.[A-Za-z0-9]+$`).test(entry.name))
52  return hit === undefined ? null : `${dir}/${hit.name}`
53}
54
55// Image draws only PNG (or raw pixels), so the formats Claude Code accepts as pastes are converted
56// once with whatever tool the machine has: sips ships with macOS, the others are common on Linux.
57// Only these extensions, only the first frame, and file input only: a converter is a trust boundary.
58const CONVERTIBLE = /\.(jpe?g|gif|webp)$/i
59const CONVERTERS = (src: string, out: string): string[][] => [
60  ['sips', '-s', 'format', 'png', src, '--out', out],
61  ['ffmpeg', '-loglevel', 'error', '-y', '-protocol_whitelist', 'file', '-i', src, '-frames:v', '1', out],
62  ['magick', '-limit', 'memory', '256MiB', '-limit', 'disk', '1GiB', `${src}[0]`, out],
63  ['convert', '-limit', 'memory', '256MiB', '-limit', 'disk', '1GiB', `${src}[0]`, out],
64]
65
66// The copies here are someone's pictures. A folder is only as private as the one holding it: if
67// another account can rename entries in the temp root, it can swap our folder for its own. So the
68// root must be ours, not a symlink, writable by no one else, and sit in a parent that is either
69// closed to others or sticky; then both folders are made ours and 700. Run on every write, which
70// also recreates a folder someone cleared.
71const PRIVATE_DIRS = [
72  'umask 077',
73  'r="$1"; p=$(dirname "$r")',
74  '[ -d "$r" ] && [ ! -L "$r" ] && [ -O "$r" ] || exit 1',
75  '[ -z "$(find "$r" -maxdepth 0 \\( -perm -0020 -o -perm -0002 \\))" ] || exit 1',
76  '[ -z "$(find "$p" -maxdepth 0 \\( -perm -0020 -o -perm -0002 \\) ! -perm -1000)" ] || exit 1',
77  'for d in "$2" "$3"; do mkdir -p "$d" && [ ! -L "$d" ] && [ -O "$d" ] && chmod 700 "$d" || exit 1; done',
78].join('; ')
79
80async function scratch($: EngineInterface, name: string): Promise<string | null> {
81  const top = await root($)
82  const base = `${top}/cc-image-view`
83  const dir = `${base}/${await $.session.id()}`
84  const run = await $.process.run(['sh', '-c', PRIVATE_DIRS, 'sh', top, base, dir], { timeoutMs: 5_000 }).catch(() => null)
85  return run?.exitCode === 0 ? `${dir}/${name}` : null
86}
87
88// One job per output, so two renders asking for the same picture never write it at once
89const jobs = new Map<string, Promise<string | null>>()
90
91function once(out: string, make: () => Promise<string | null>): Promise<string | null> {
92  const running = jobs.get(out)
93  if (running !== undefined) return running
94  const job = make().finally(() => jobs.delete(out))
95  jobs.set(out, job)
96  return job
97}
98
99// Written under a temporary name and renamed into place, so a reader never sees half a file
100async function publish($: EngineInterface, temp: string, out: string): Promise<string | null> {
101  const run = await $.process.run(['mv', '-f', temp, out], { timeoutMs: 5_000 }).catch(() => null)
102  return run?.exitCode === 0 ? out : null
103}
104
105/** A PNG path for `src`: itself when it already is one, else a converted copy; null when none could be made. */
106async function asPng($: EngineInterface, src: string, n: number): Promise<string | null> {
107  if (src.toLowerCase().endsWith('.png')) return src
108  // The draft is polled every 200 ms; without this a machine with no converter respawns four tools each time
109  if (!CONVERTIBLE.test(src) || unconvertible.has(src)) return null
110  const out = await scratch($, `${n}.png`)
111  if (out === null) return null
112  return once(out, async () => {
113    if (await $.fs.exists(out)) return out
114    const temp = `${out}.part.png`
115    for (const argv of CONVERTERS(src, temp)) {
116      const ok = await $.process.run(argv, { timeoutMs: 10_000 }).then(run => run.exitCode === 0, () => false)
117      if (ok && (await $.fs.exists(temp))) return publish($, temp, out)
118    }
119    unconvertible.add(src)
120    return null
121  })
122}
123
124const unconvertible = new Set<string>()
125
126/** Writes base64 bytes from the transcript to a scratch file, for a paste whose cache is gone. */
127async function writeBytes($: EngineInterface, base64: string, name: string): Promise<string | null> {
128  const out = await scratch($, name)
129  if (out === null) return null
130  return once(out, async () => {
131    if (await $.fs.exists(out)) return out
132    const temp = `${out}.part`
133    const run = await $.process
134      .run(['sh', '-c', 'umask 077; base64 -d > "$1"', 'sh', temp], { stdin: base64, timeoutMs: 10_000 })
135      .catch(() => null)
136    return run?.exitCode === 0 && (await $.fs.exists(temp)) ? publish($, temp, out) : null
137  })
138}
139
140const projectFolder = (dir: string) => dir.replace(/[^a-zA-Z0-9]/g, '-')
141
142/** This session's transcript file, found once per session id. */
143async function transcriptPath($: EngineInterface): Promise<string | undefined> {
144  const sessionId = await $.session.id()
145  if (transcript?.sessionId === sessionId) return transcript.path
146  const home = await $.env.get('HOME')
147  const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`
148  const projects = `${config}/projects`
149  const guesses = [await $.session.root(), await $.session.cwd()].map(dir => `${projects}/${projectFolder(dir)}/${sessionId}.jsonl`)
150  for (const path of guesses) {
151    if (await $.fs.exists(path)) {
152      transcript = { sessionId, path }
153      return path
154    }
155  }
156  for (const entry of await $.fs.list(projects).catch(() => [])) {
157    const path = `${projects}/${entry.name}/${sessionId}.jsonl`
158    if (entry.kind === 'dir' && (await $.fs.exists(path))) {
159      transcript = { sessionId, path }
160      return path
161    }
162  }
163  return undefined
164}
165
166// A transcript can run to hundreds of MB, past $.fs.read's 4 MiB, so grep scans it on disk and
167// passes on only the person's rows that mention an image tag, with every base64 blob removed.
168// A pipeline reports only its last command, so a failed read is turned into a line of its own;
169// grep's 1 means no match, which is fine. (awk would report the read itself but takes ~80x longer.)
170const READ_FAILED = '__CC_IMAGE_VIEW_READ_FAILED__'
171const TAGGED_ROWS =
172  `[ -r "$1" ] || exit 2; ` +
173  `{ grep -F '[Image #' "$1"; s=$?; [ "$s" -le 1 ] || echo '${READ_FAILED}'; } | ` +
174  `grep -F -e '"type":"user"' -e '${READ_FAILED}' | ` +
175  `sed -E 's/"data":"[^"]*"/"data":""/g'`
176
177/** Those rows, or null when the read failed or came back cut short: a partial index is not one. */
178async function taggedRows($: EngineInterface, path: string): Promise<string | null> {
179  const run = await $.process.run(['sh', '-c', TAGGED_ROWS, 'sh', path], { timeoutMs: 10_000 }).catch(() => null)
180  if (run === null || run.isStdoutTruncated || run.exitCode !== 0) return null
181  return run.stdout.split('\n').includes(READ_FAILED) ? null : run.stdout
182}
183
184/** The one full transcript line of a prompt, base64 included; empty when it is over the 4 MiB output cap. */
185async function fullRow($: EngineInterface, path: string, uuid: string): Promise<string> {
186  const run = await $.process
187    .run(['grep', '-F', '-m', '1', `"uuid":"${uuid}"`, path], { timeoutMs: 10_000 })
188    .catch(() => null)
189  return run === null || run.isStdoutTruncated ? '' : run.stdout
190}
191
192async function fileSize($: EngineInterface, path: string): Promise<number> {
193  return (await $.fs.stat(path).catch(() => null))?.size ?? -1
194}
195
196// The image numbers last drawn, so an unchanged draft doesn't rewrite state; undefined
197// while a drawn image's file is still missing, so the next poll looks again.
198let shownKey: string | undefined
199let isChecking = false
200const sizes = new Map<string, Size | null>()
201
202// undefined: the file is no PNG and must not be drawn; null: drawable, aspect ratio unknown
203async function sizeOf($: EngineInterface, path: string): Promise<Size | null | undefined> {
204  if (!sizes.has(path)) {
205    const head = await $.fs.read(path, { as: 'bytes' }).then(
206      ({ base64 }) => pngSize(base64),
207      () => undefined, // too big to read: still drawable, just without its aspect ratio
208    )
209    if (head === null) return undefined
210    sizes.set(path, head ?? null)
211  }
212  return sizes.get(path) ?? null
213}
214
215async function describe($: EngineInterface, dir: string | undefined, n: number): Promise<PastedImage> {
216  const cached = await cachedFile($, dir, n)
217  const path = cached === null ? null : await asPng($, cached, n)
218  const size = path === null ? undefined : await sizeOf($, path)
219  return path === null || size === undefined ? { n, path: null, size: null } : { n, path, size }
220}
221
222// Sent prompts: an image is shown only when the transcript ties it to the prompt (imagePasteIds),
223// so a typed "[Image #1]" never borrows an earlier paste.
224const PANE = 'cc-image-view'
225type Shown = { n: number; path: string; size: Size | null }
226let sent: { path: string; size: number; messages: SentMessage[] } | undefined
227let loading: Promise<void> | undefined
228// A picture found stays found; one that could not be had is retried only once the transcript grew
229const resolved = new Map<string, Shown | { missingAt: number }>()
230let zoomed: Shown | undefined
231
232async function reload($: EngineInterface, path: string) {
233  const size = await fileSize($, path)
234  // Unchanged since the last read: a row still missing is a typed tag, not a late write
235  if (sent?.path === path && sent.size === size) return
236  const rows = await taggedRows($, path)
237  // A failed read voids the old index too: the transcript grew, and the old copy can't say
238  // whether a later prompt made some words ambiguous
239  sent = rows === null ? undefined : { path, size, messages: sentMessages(rows) }
240}
241
242async function sentPrompt($: EngineInterface, text: string): Promise<SentMessage | undefined> {
243  const path = await transcriptPath($)
244  if (path === undefined) return undefined
245  // Re-read whenever the transcript grew, even for a prompt already found: a typed copy sent
246  // later makes the same words ambiguous, and the answer must change with it
247  loading ??= reload($, path).finally(() => {
248    loading = undefined
249  })
250  await loading
251  if (!settled(text)) return undefined
252  return sent === undefined ? undefined : messageFor(sent.messages, text)
253}
254
255// A prompt's row is drawn before its transcript line is written and is not drawn again on its own.
256// Until that line lands the row could only borrow an earlier prompt with the same words, so every
257// sent prompt with a tag waits as pending until the transcript holds as many prompts with those
258// words as were known before plus every send since. Running out of quick retries ends the fast
259// follow-up, never the wait: an unconfirmed prompt stays without buttons, and any later render
260// that finds the lines lands it.
261const FOLLOW_MS = 300
262const FOLLOW_TRIES = 20
263const pending = new Map<string, { before: number | undefined; sends: number }>()
264const countOf = (text: string) => sent?.messages.filter(message => message.text.trim() === text.trim()).length
265
266function settled(text: string): boolean {
267  const key = text.trim()
268  const wait = pending.get(key)
269  if (wait === undefined) return true
270  const now = countOf(text)
271  if (wait.before === undefined || now === undefined || now < wait.before + wait.sends) return false
272  pending.delete(key)
273  return true
274}
275
276// Marked at once: a send never waits on a grep of a large transcript. The baseline comes from the
277// index already in hand, which can't hold this send's line yet; with no index, from the first read.
278function watchSend($: EngineInterface, text: string) {
279  const key = text.trim()
280  const wait = pending.get(key)
281  if (wait !== undefined) wait.sends += 1
282  else pending.set(key, { before: countOf(text), sends: 1 })
283  void (async () => {
284    const entry = pending.get(key)
285    if (entry !== undefined && entry.before === undefined) {
286      const path = await transcriptPath($)
287      if (path !== undefined) await reload($, path)
288      entry.before = countOf(text) ?? 0
289    }
290    follow($, text, FOLLOW_TRIES)
291  })()
292}
293
294function follow($: EngineInterface, text: string, tries: number) {
295  $.clock.after(FOLLOW_MS, () =>
296    void (async () => {
297      const path = await transcriptPath($)
298      if (path !== undefined) await reload($, path)
299      if (!pending.has(text.trim())) return
300      if (settled(text)) $.ui.invalidate('ui.render')
301      else if (tries > 1) follow($, text, tries - 1)
302    })(),
303  )
304}
305
306const EXTENSIONS: Record<string, string> = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' }
307
308async function sentImage($: EngineInterface, message: SentMessage, index: number): Promise<Shown | null> {
309  const n = message.ids[index] ?? 0
310  const key = `${message.uuid}:${index}`
311  const known = resolved.get(key)
312  if (known !== undefined && 'path' in known && (await $.fs.exists(known.path))) return known
313  if (known !== undefined && 'missingAt' in known && known.missingAt === sent?.size) return null
314  const cached = await cachedFile($, await imagesDir($), n)
315  let path = cached === null ? null : await asPng($, cached, n)
316  if (path === null) {
317    // The paste cache lives in a temp folder a reboot clears; the transcript keeps the bytes
318    const transcript = await transcriptPath($)
319    const block = transcript === undefined ? null : imageBlock(await fullRow($, transcript, message.uuid), index)
320    const raw = block === null ? null : await writeBytes($, block.base64, `transcript-${n}.${EXTENSIONS[block.mediaType] ?? 'img'}`)
321    path = raw === null ? null : await asPng($, raw, n)
322  }
323  const size = path === null ? undefined : await sizeOf($, path)
324  if (path === null || size === undefined) {
325    resolved.set(key, { missingAt: sent?.size ?? -1 })
326    return null
327  }
328  const shown = { n, path, size }
329  resolved.set(key, shown)
330  return shown
331}
332
333async function show($: EngineInterface, draft: string) {
334  const numbers = imageNumbers(draft)
335  const key = numbers.join(',')
336  if (key === shownKey) return
337  const dir = numbers.length > 0 ? await imagesDir($) : undefined
338  const list: PastedImage[] = []
339  for (const n of numbers) list.push(await describe($, dir, n))
340  shownKey = list.every(image => image.path !== null) ? key : undefined
341  await update($, images, () => list)
342}
343
344async function check($: EngineInterface) {
345  if (isChecking) return
346  isChecking = true
347  try {
348    await show($, (await $.prompt.read()).text)
349  } finally {
350    isChecking = false
351  }
352}
353
354// One hover group per image: its button and its card light together, so the pointer can travel
355// from one to the other. Image numbers are unique within a session.
356const scopeOf = (n: number) => `cc-image-view-${n}`
357// English until session.start has read the language settings
358let ui: Strings = stringsFor('en')
359
360async function settingsLanguage($: EngineInterface): Promise<unknown> {
361  const home = await $.env.get('HOME')
362  const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`
363  try {
364    return (JSON.parse(await $.fs.read(`${config}/settings.json`)) as { language?: unknown }).language
365  } catch {
366    return undefined
367  }
368}
369
370export const register: Register = (on, options) => {
371  on('prompt.submit', async ($, e, next) => {
372    if (imageNumbers(e.text).length > 0) watchSend($, e.text)
373    return next(e)
374  })
375
376  on('session.start', async ($, e, next) => {
377    $.clock.every(POLL_MS, () => check($))
378    // An empty LC_ALL means unset to the C library, so it must not hide LANG
379    const lcAll = await $.env.get('LC_ALL')
380    const envLang = lcAll !== undefined && lcAll !== '' ? lcAll : await $.env.get('LANG')
381    ui = stringsFor(pickLocale({ option: options.language, claudeLanguage: await settingsLanguage($), envLang }))
382    $.ui.invalidate('ui.render')
383    return next(e)
384  })
385
386  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
387    if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
388    const list = await read($, images)
389    if (list.length === 0) return next(e)
390
391    const { Box, Image, Text } = $.ui.resolve(e)
392    const cells = fitRow(list.map(image => image.size), e.props.maxRows, e.props.bodyColumns)
393    const below = await next(e)
394
395    return (
396      <Box flexDirection="column">
397        <Box flexDirection="row" columnGap={1}>
398          {list.map((image, i) => {
399            const { columns, rows } = cells[i] ?? { columns: 4, rows: 1 }
400            return (
401              <Box flexDirection="column" alignItems="center" borderStyle="round" borderDimColor>
402                {image.path === null ? (
403                  <Box width={columns} height={rows} alignItems="center" justifyContent="center">
404                    <Text dimColor wrap="truncate">{ui.noPreview}</Text>
405                  </Box>
406                ) : (
407                  <Image
408                    key={`image-${image.n}`}
409                    source={{ file: image.path, format: 'png' }}
410                    columns={columns}
411                    rows={rows}
412                    alt={`[Image #${image.n}]`}
413                  />
414                )}
415                <Text dimColor>#{image.n}</Text>
416              </Box>
417            )
418          })}
419        </Box>
420        {below}
421      </Box>
422    )
423  })
424
425  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
426    if (e.surface !== 'terminal' || e.props.origin.kind !== 'composer' || imageNumbers(e.props.text).length === 0) return next(e)
427    const message = await sentPrompt($, e.props.text)
428    if (message === undefined) return next(e)
429    const list: Shown[] = []
430    for (const index of message.ids.keys()) {
431      const shown = await sentImage($, message, index)
432      if (shown !== null) list.push(shown)
433    }
434    if (list.length === 0) return next(e)
435
436    const { Box, Button, Image } = $.ui.resolve(e)
437    const zoom = (shown: Shown) => {
438      zoomed = shown
439      void $.ui.open({ id: PANE, title: ui.paneTitle(shown.n), focus: true, closeOnEscape: true })
440      $.ui.invalidate('ui.render')
441    }
442    const row = await next(e)
443    const offsets = buttonOffsets(list.map(shown => ui.sentButton(shown.n)))
444
445    return (
446      <Box flexDirection="column">
447        {row}
448        <Box flexDirection="row" columnGap={1}>
449          {list.map(shown => (
450            <Box hover={{ scope: scopeOf(shown.n) }}>
451              <Button key={`cc-image-view:open:${shown.n}`} label={ui.sentButton(shown.n)} dimColor onPress={() => zoom(shown)} />
452            </Box>
453          ))}
454        </Box>
455        {list.map((shown, i) => {
456          const cells = fitCells(shown.size)
457          // In the flow under the button row, shifted to sit under its own button: the rows below
458          // move down while it shows, the buttons never do. Absolute cards were painted over by
459          // the transcript rows after them.
460          return (
461            <Box display="none" hover={{ scope: scopeOf(shown.n), display: 'flex' }} marginLeft={offsets[i]} flexDirection="column" alignItems="flex-start">
462              <Box flexDirection="column" alignItems="center" borderStyle="round" borderDimColor>
463                <Image key={`sent-${shown.n}`} source={{ file: shown.path, format: 'png' }} columns={cells.columns} rows={cells.rows} alt={`[Image #${shown.n}]`} />
464                <Button key={`cc-image-view:zoom:${shown.n}`} label={ui.zoom} dimColor onPress={() => zoom(shown)} />
465              </Box>
466            </Box>
467          )
468        })}
469      </Box>
470    )
471  })
472
473  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
474    if (e.surface !== 'terminal') return next(e)
475    const { Box, Image, Text } = $.ui.resolve(e)
476    if (zoomed === undefined) return <Text dimColor>{ui.noSelection}</Text>
477    // One row for the caption under the picture
478    const cells = fitBox(zoomed.size, e.props.bodyColumns, e.props.scroll.bodyRows - 1)
479    return (
480      <Box flexDirection="column" alignItems="center">
481        <Image key="zoom" source={{ file: zoomed.path, format: 'png' }} columns={cells.columns} rows={cells.rows} alt={`[Image #${zoomed.n}]`} />
482        <Text dimColor>{ui.closeHint(zoomed.n)}</Text>
483      </Box>
484    )
485  })
486}
487
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