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

English | 繁體中文
A Claude Code mod that lets you see the images you paste, in two places:
[Image #n] tags, a row of numbered thumbnails shows above it. With no tags, nothing is drawn.[ 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.
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.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.
$.fs.read(path, { as: 'bytes' }) for its size; over the engine's 4 MiB cap only the aspect ratio is lost.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.
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.
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.
On Herdr (macOS) only; no promise for other terminals or later Claude Code versions.
--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.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.
claude plugin validate .
claude plugin test .
tsc -p .hooks/register.tsx 487 lines1import { 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}
487hooks/layout.ts 91 lines1export 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}
91hooks/i18n.ts 51 lines1export 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}
51hooks/sent.ts 68 lines1// `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}
68types/index.d.ts 14 lines1export 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