image-view (jarrodwatts) fixed for newer Claude Code: thumbnails of pasted images above the prompt, read from $TMPDIR/clipboard-*.png.

A Claude Code mod that shows the images you paste, so you see thumbnails above your prompt instead of bare [Image #1] tags.

Inside Claude Code, run:
/plugin marketplace add jarrodwatts/claude-image-view
/plugin install image-view
/reload-plugins
That's it. Paste an image into the prompt and its thumbnail appears above the input.
claude plugin marketplace add jarrodwatts/claude-image-view
claude plugin install image-view@claude-image-view
Then run /reload-plugins inside a session, or start a new one.
Paste one or more images and a row of thumbnails sits above the prompt, each labelled with the number of its tag:
╭────────────────────────╮ ╭────────────╮
│ │ │ │
│ (screenshot) │ │ (photo) │
│ │ │ │
│ #1 │ │ #2 │
╰────────────────────────╯ ╰────────────╯
❯ why is the header misaligned here [Image #1] vs [Image #2]
Claude Code saves every pasted image to a cache folder for the session, as <tmp>/<project>/<session>/images/<n>.png, and puts an [Image #n] tag in the prompt. Claude Image View is a mod:
[Image #n] tags. It checks on a timer because pasting an image doesn't raise an edit event.Image element. The terminal reads the file itself, so the image data never passes through the mod.Claude Image View is local-only. It makes no network requests and writes no files. It reads the prompt box, lists Claude Code's temp folder to find the current session's image cache, and reads the first bytes of each pasted image. If CLAUDE_CODE_TMPDIR isn't set, it runs id -u once to find the default temp folder.
Run claude plugin validate on the repo to see every event it hooks and every call it makes.
Other terminals show [Image #n] in each tile instead of the picture. The Claude Desktop app already previews pasted images, so the mod draws nothing there.
Nothing appears when I paste. Run /plugin and check the dim line under the tabs lists image-view as an active mod. If it isn't listed, run /reload-plugins.
The tile says "no preview". The mod couldn't find the cached file. Claude Code may have moved where it stores pasted images. Please open an issue with your Claude Code version.
The tile shows [Image #1] text instead of the picture. Your terminal doesn't support the kitty graphics protocol. See Requirements.
The tile shows [Image #1] text in agent view or a background session, even in Ghostty or kitty. Claude Code turns terminal images off for background sessions. If you attach from a terminal with the kitty graphics protocol, turn them back on in the env block of ~/.claude/settings.json, then start a new session:
"env": { "CLAUDE_CODE_FORCE_TERMINAL_IMAGES": "1" }
git clone https://github.com/jarrodwatts/claude-image-view
cd claude-image-view
# Load it for one session without installing
claude --plugin-dir .
# Check it and run the tests
claude plugin validate .
claude plugin test .
Claude Code writes the API types into .claude-plugin/types/ the first time it loads the mod, and tsc -p . type-checks it from then on.
MIT. See LICENSE.
hooks/register.tsx 159 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PastedImage } from '../types'
5import { fitRow, imageNumbers, pngSize } from './layout'
6import type { Size } from './layout'
7
8// Pasting an image raises no prompt.edit (the tag only shows up on the next keystroke),
9// so the draft is polled instead.
10const POLL_MS = 200
11
12const images = atom({ plugin: 'image-peek', key: 'images' } as const, [] as PastedImage[])
13
14let tmpRoot: string | undefined
15let found: { sessionId: string; dir: string } | undefined
16// The image numbers last drawn, so an unchanged draft doesn't rewrite state; undefined
17// while a drawn image's file is still missing, so the next poll looks again.
18let shownKey: string | undefined
19let isChecking = false
20const sizes = new Map<string, Size | null>()
21
22// Claude Code caches each paste as <tmp>/<project>/<session>/images/<n>.png. The project
23// folder is named after a working directory that may since have moved, so find it by the
24// session id instead of rebuilding it.
25async function imagesDir($: EngineInterface): Promise<string | undefined> {
26 const sessionId = await $.session.id()
27 if (found?.sessionId === sessionId) return found.dir
28 if (tmpRoot === undefined) {
29 const fromEnv = await $.env.get('CLAUDE_CODE_TMPDIR')
30 tmpRoot = fromEnv ?? `/tmp/claude-${(await $.process.run(['id', '-u'])).stdout.trim()}`
31 }
32 const entries = await $.fs.list(tmpRoot).catch(() => [])
33 for (const entry of entries) {
34 const dir = `${tmpRoot}/${entry.name}/${sessionId}/images`
35 if (entry.kind === 'dir' && (await $.fs.exists(dir))) {
36 found = { sessionId, dir }
37 return dir
38 }
39 }
40 return undefined
41}
42
43async function describe($: EngineInterface, dir: string | undefined, n: number): Promise<PastedImage> {
44 const path = `${dir}/${n}.png`
45 if (dir === undefined || !(await $.fs.exists(path))) return { n, path: null, size: null }
46 if (!sizes.has(path)) {
47 const head = await $.fs.read(path, { as: 'bytes' }).then(
48 ({ base64 }) => pngSize(base64),
49 () => undefined, // too big to read: still drawable, just without its aspect ratio
50 )
51 if (head === null) return { n, path: null, size: null }
52 sizes.set(path, head ?? null)
53 }
54 return { n, path, size: sizes.get(path) ?? null }
55}
56
57// 新版 Claude Code 不再把貼圖寫進 <tmp>/<project>/<session>/images,改存成系統暫存資料夾
58// (macOS 的 $TMPDIR)裡的 clipboard-<時間>.png。草稿裡有 k 張圖,就拿最新的 k 個檔,
59// 依時間順序對到由小到大的 [Image #N]。
60async function clipboardFiles($: EngineInterface): Promise<string[]> {
61 const dir = ((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/+$/, '')
62 const entries = await $.fs.list(dir).catch(() => [])
63 return entries
64 .filter(entry => entry.kind !== 'dir' && /^clipboard-.*\.png$/i.test(entry.name))
65 .map(entry => entry.name)
66 .sort()
67 .map(name => `${dir}/${name}`)
68}
69
70async function sized($: EngineInterface, n: number, path: string): Promise<PastedImage> {
71 if (!sizes.has(path)) {
72 const head = await $.fs.read(path, { as: 'bytes' }).then(
73 ({ base64 }) => pngSize(base64),
74 () => undefined,
75 )
76 if (head === null) return { n, path: null, size: null }
77 sizes.set(path, head ?? null)
78 }
79 return { n, path, size: sizes.get(path) ?? null }
80}
81
82async function show($: EngineInterface, draft: string) {
83 const numbers = imageNumbers(draft).slice().sort((a, b) => a - b)
84 const key = numbers.join(',')
85 if (key === shownKey) return
86 const dir = numbers.length > 0 ? await imagesDir($) : undefined
87 let list: PastedImage[] = []
88 for (const n of numbers) list.push(await describe($, dir, n))
89 // 舊位置找不到時,改用暫存資料夾裡最新的貼圖
90 if (list.some(image => image.path === null)) {
91 const files = await clipboardFiles($)
92 const recent = files.slice(Math.max(0, files.length - numbers.length))
93 const offset = numbers.length - recent.length
94 list = []
95 for (const [i, n] of numbers.entries()) {
96 const path = i >= offset ? recent[i - offset] : undefined
97 list.push(path ? await sized($, n, path) : { n, path: null, size: null })
98 }
99 }
100 shownKey = list.every(image => image.path !== null) ? key : undefined
101 await update($, images, () => list)
102}
103
104async function check($: EngineInterface) {
105 if (isChecking) return
106 isChecking = true
107 try {
108 await show($, (await $.prompt.read()).text)
109 } finally {
110 isChecking = false
111 }
112}
113
114export const register: Register = on => {
115 on('session.start', async ($, e, next) => {
116 $.clock.every(POLL_MS, () => check($))
117 return next(e)
118 })
119
120 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
121 if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
122 const list = await read($, images)
123 if (list.length === 0) return next(e)
124
125 const { Box, Image, Text } = $.ui.resolve(e)
126 const cells = fitRow(list.map(image => image.size), e.props.maxRows, e.props.bodyColumns)
127 const below = await next(e)
128
129 return (
130 <Box flexDirection="column">
131 <Box flexDirection="row" columnGap={1}>
132 {list.map((image, i) => {
133 const { columns, rows } = cells[i] ?? { columns: 4, rows: 1 }
134 return (
135 <Box flexDirection="column" alignItems="center" borderStyle="round" borderDimColor>
136 {image.path === null ? (
137 <Box width={columns} height={rows} alignItems="center" justifyContent="center">
138 <Text dimColor wrap="truncate">no preview</Text>
139 </Box>
140 ) : (
141 <Image
142 key={`image-${image.n}`}
143 source={{ file: image.path, format: 'png' }}
144 columns={columns}
145 rows={rows}
146 alt={`[Image #${image.n}]`}
147 />
148 )}
149 <Text dimColor>#{image.n}</Text>
150 </Box>
151 )
152 })}
153 </Box>
154 {below}
155 </Box>
156 )
157 })
158}
159hooks/layout.ts 60 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/**
48 * Picture boxes for one row of tiles that fits the band whole, so it never scrolls:
49 * the tallest tiles whose chrome fits in `maxRows` and whose total width fits in `bodyColumns`.
50 */
51export function fitRow(sizes: readonly (Size | null)[], maxRows: number, bodyColumns: number): Cells[] {
52 const tallest = Math.max(1, Math.min(TILE_ROWS, maxRows - TILE_CHROME_ROWS))
53 for (let tileRows = tallest; tileRows > 1; tileRows--) {
54 const cells = sizes.map(size => fitCells(size, tileRows))
55 const width = cells.reduce((sum, c) => sum + c.columns + TILE_CHROME_COLUMNS, 0) + GAP * (cells.length - 1)
56 if (width <= bodyColumns) return cells
57 }
58 return sizes.map(size => fitCells(size, 1))
59}
60types/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 'image-peek': { images: PastedImage[] }
12 }
13}
14