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 510 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 // 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}
510hooks/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