See and mark up the images you paste into Claude Code: thumbnails above the prompt and in the transcript, and an editor that opens on paste

<img src="docs/assets/icon.svg" width="112" height="112" alt="paste-preview icon"> <h1>paste-preview</h1>
At the prompt of a Claude Code session in a terminal:
/plugin install paste-preview --marketplace alan890104/claude-code-paste-preview
Answer y to add the marketplace, then pick a scope (user scope loads it in every session). It runs at once, with no restart.
https://github.com/user-attachments/assets/7098c51e-ce1e-43ad-9fe3-5189ef1fdb88
A Claude Code mod for the images you paste. In the terminal a pasted image is just [Image #1], and three pastes later you can't tell which is which. With this mod:
[Image #N] in the prompt box shows as a thumbnail above the prompt, and sent prompts show theirs in the transcript, each labelled Image #N.Image #N in place of the picture, with its Edit button.| The editor | The clipboard, only when Claude Code's own copy of a paste can't be found | |
|---|---|---|
| macOS | The Xcode Command Line Tools (xcode-select --install). The panel is compiled once, on first use, in a few seconds. Without them the editor opens as a Google Chrome app window instead, which needs Node.js and Chrome. | Optional: pngpaste (brew install pngpaste) |
| Linux | Node.js 18 or newer (node on PATH). The editor opens as an app window of Chrome, Chromium, Edge or Brave when one is installed, else in the default browser (xdg-open). | wl-clipboard (Wayland) or xclip (X11), the tools Claude Code itself pastes images with |
| Windows | Node.js 18 or newer (node.exe on PATH). The editor opens as a Microsoft Edge app window (Chrome where Edge is missing), else in the default browser. | Nothing: PowerShell, built in |
Where something is missing the mod says so in a toast: which tool, and the thumbnail still shows.
Paste an image as usual: Ctrl+V, or Alt+V on Windows and under WSL, the keys Claude Code itself binds to pasting an image (chat:imagePaste). The editor opens on its own.
| Key | |
|---|---|
P O B A T | pen, ellipse, box, arrow, text |
C | crop: drag a frame, then Enter |
R | rotate 90° |
1–7 | colour |
⌘Z (Ctrl+Z on Linux and Windows) | undo |
| Shift while drawing | a circle or square |
| Enter | done |
| Esc | cancel and keep the picture as pasted |
To edit a picture again, press Edit under its thumbnail above the prompt (or ctrl+x tab, then its number).
The editor and the thumbnail labels follow the system's language (macOS's first preferred language; on Linux LC_ALL, else LC_MESSAGES, else LANG; on Windows the UI culture) in five languages: English, Traditional Chinese (zh-Hant, and zh-TW, zh-HK, zh-MO), Simplified Chinese (zh-Hans, and any other zh: zh-CN, zh-SG, bare zh), Japanese (ja) and Korean (ko). Any other language gets English. This README is available in the same five languages, linked at the top.
In a plain browser tab (the default browser on Linux or Windows, where no Chromium browser is installed), the page can't close itself: after Done or Cancel it says the tab can be closed.
<tmp>/claude-<uid>/<project>/<session>/images/<N>.png (<tmp> is /tmp on macOS and $TMPDIR or /tmp on Linux), or %TEMP%\claude\<project>\<session>\images\<N>.png on Windows; CLAUDE_CODE_TMPDIR moves it on all three. The mod reads that copy, so the thumbnail and the editor show exactly what was pasted. That folder is Claude Code's internal layout, not an API; if it moves, the mod falls back to reading the clipboard./private/tmp/paste-preview/<session>/<N>.png on macOS, /tmp/paste-preview-<uid>/<session>/<N>.png on Linux (a folder only you can read) and %TEMP%\paste-preview\<session>\<N>.png on Windows. Folders older than a week are removed.session.append) and adds a note for Claude beside the prompt (prompt.submit context) naming the edited file, which Claude then reads. You'll see that read in the transcript. The prompt box and the transcript keep showing [Image #N].wslview or Windows' explorer.exe.[Image #N] as before.Image #N with its Edit button, as in other terminals. The editor works as usual. For thumbnails, run Claude Code straight in Ghostty or kitty, outside tmux.git clone https://github.com/alan890104/claude-code-paste-preview
cd claude-code-paste-preview
claude --plugin-dir . # run Claude Code with the mod loaded from this folder
claude plugin validate . # what the engine will load and refuse
claude plugin test . # hooks/*.test.tsx: the band, the send, and a paste on each system
node test/e2e.mjs [--clipboard] # the mod's own commands for real on this machine (Node 22.6+)
node test/live.mjs # Linux, Windows: a live session pasted into, offline
test/e2e.mjs puts a picture on the clipboard and reads it back with the commands the mod runs, starts the editor server as the mod does, draws on the page with a headless browser and checks the edited picture lands where the mod reads it. It needs playwright-core (PLAYWRIGHT_DIR names a folder whose node_modules holds it) and Chrome. test/live.mjs starts Claude Code itself with the mod in a pseudo-terminal (@lydell/node-pty, in the same folder), pastes with Claude Code's own key, draws in the editor the mod opens, presses Enter and reads the transcript; its key is made up and the API's address is a closed local port, so nothing is sent. CI (.github/workflows/test.yml) runs them with the plugin tests on macOS, Linux and Windows (the live session on Linux and Windows).
Loading the folder once writes the engine's type declarations to .claude-plugin/types/; after that, tsc -p . type-checks the mod.
| Path | |
|---|---|
hooks/register.tsx | the mod: finding pastes, thumbnails, the editor queue, what happens at Enter |
hooks/platform.ts | what differs on macOS, Linux and Windows: folders, clipboard commands, the editor's command |
editor/editor.html | the editor (canvas) |
editor/panel.swift | the floating panel that hosts the editor |
editor/server.mjs | the editor in a browser window: on Linux and Windows, and on a Mac without Swift |
hooks/register.tsx 524 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ImageSource, Register } from 'claude-code'
3
4import type { Shot } from '../types'
5import { clipboardSteps, editorArgv, editsRoot, engineRoot, fromBase64, headStep, isMissing, join, LANGUAGE_PS, langOf, localeOf, osOf, sizeOf, sweepStep, thumbOf } from './platform'
6import type { Env, Lang, Os, Step } from './platform'
7
8// paste-preview: a pasted image is only "[Image #1]" in the prompt box, which says
9// nothing about which picture it is. So, as in a chat app, a paste opens an editor at
10// once (pen, ellipse, box, arrow, text, crop, rotate); Done (Enter) puts the edited
11// picture in the paste's place, Cancel (Esc) keeps the paste, and every image in the box
12// shows as a thumbnail above the prompt. On macOS the editor is a floating panel
13// (editor/panel.swift) that sits over a full-screen terminal without switching Spaces,
14// kept warm for the session so a paste shows it at once;
15// where Swift is missing, and on Linux and Windows, it is a page in a browser window
16// that editor/server.mjs serves and opens, under Node. What differs per system is in
17// ./platform.
18//
19// How the picture travels: Claude Code writes each paste to
20// <tmp>/claude-<uid>/<project>/<session>/images/<N>.png (an internal layout, not an API:
21// the clipboard is the fallback) and sends the bytes it read at paste time. No plugin can
22// change or add an image in a message, only drop one, and an "@file" a hook writes into
23// the prompt at Enter is not read. So the box and the transcript keep "[Image #N]"; at
24// Enter an edited paste's original is dropped and Claude is told, beside the prompt,
25// where the edited picture is, which it then reads.
26//
27// Sent prompts show their pictures too, each named "Image #N", so the history says which
28// picture was which.
29
30const shots = atom({ plugin: 'paste-preview', key: 'shots' } as const, [])
31const inBox = atom({ plugin: 'paste-preview', key: 'inBox' } as const, [])
32
33const PLACEHOLDER = /\[Image #(\d+)\]/g
34// A terminal cell is about twice as tall as it is wide.
35const CELL = 2
36// A thumbnail's height in rows, above the prompt as in the transcript.
37const ROWS = 8
38const THUMB_PX = '480'
39// What $.fs.read hands over at most, and what an Image takes as bytes at most.
40const READABLE = 4 * 1024 * 1024
41const DRAWABLE = 2 * 1024 * 1024
42
43type Paths = { os: Os; edits: string; tmp: string; session: string; terminal: string; panel: string | undefined; lang: Lang; env: Env }
44
45// The words follow the system's language (./platform says how it is read): Traditional
46// or Simplified Chinese, Japanese, Korean, or English. The terms follow macOS Preview.
47const WORDS = {
48 'zh-Hant': {
49 edit: '編輯', edited: '已編輯', noEditor: '編輯器沒有打開', noTools: '編輯器沒有打開:找不到 Swift 或 node', noNode: '編輯器沒有打開:找不到 node',
50 noBrowser: '編輯器沒有打開:找不到瀏覽器', noClipboard: { linux: '讀不到剪貼簿:請安裝 wl-clipboard 或 xclip', windows: '讀不到剪貼簿:找不到 PowerShell' },
51 },
52 'zh-Hans': {
53 edit: '编辑', edited: '已编辑', noEditor: '编辑器没有打开', noTools: '编辑器没有打开:找不到 Swift 或 node', noNode: '编辑器没有打开:找不到 node',
54 noBrowser: '编辑器没有打开:找不到浏览器', noClipboard: { linux: '读不到剪贴板:请安装 wl-clipboard 或 xclip', windows: '读不到剪贴板:找不到 PowerShell' },
55 },
56 ja: {
57 edit: '編集', edited: '編集済み', noEditor: 'エディタを開けませんでした', noTools: 'エディタを開けませんでした:Swift も node も見つかりません', noNode: 'エディタを開けませんでした:node が見つかりません',
58 noBrowser: 'エディタを開けませんでした:ブラウザが見つかりません', noClipboard: { linux: 'クリップボードを読み取れませんでした:wl-clipboard か xclip をインストールしてください', windows: 'クリップボードを読み取れませんでした:PowerShell が見つかりません' },
59 },
60 ko: {
61 edit: '편집', edited: '편집됨', noEditor: '편집기를 열지 못했습니다', noTools: '편집기를 열지 못했습니다: Swift도 node도 찾을 수 없습니다', noNode: '편집기를 열지 못했습니다: node를 찾을 수 없습니다',
62 noBrowser: '편집기를 열지 못했습니다: 브라우저를 찾을 수 없습니다', noClipboard: { linux: '클립보드를 읽지 못했습니다: wl-clipboard 또는 xclip을 설치하세요', windows: '클립보드를 읽지 못했습니다: PowerShell을 찾을 수 없습니다' },
63 },
64 en: {
65 edit: 'Edit', edited: 'edited', noEditor: 'The editor did not open', noTools: 'The editor did not open: neither Swift nor node was found', noNode: 'The editor did not open: node was not found',
66 noBrowser: 'The editor did not open: no browser was found', noClipboard: { linux: 'Could not read the clipboard: install wl-clipboard or xclip', windows: 'Could not read the clipboard: PowerShell was not found' },
67 },
68}
69let lang: Lang = 'en'
70
71// Module variables start over on a hot reload; everything a drawing reads is in $.state.
72let paths: Promise<Paths> | undefined
73let images: string | undefined
74let queue: Promise<void> = Promise.resolve()
75let warm: Promise<Warm | undefined> | undefined
76let isTicking = false
77let isClipboardToldOff = false
78const capturing = new Set<number>()
79const thumbs = new Map<string, string>()
80
81// The images a text holds, in order.
82const numbersIn = (text: string) => [...new Set([...text.matchAll(PLACEHOLDER)].map(m => Number(m[1])))]
83
84
85const isSame = (a: readonly number[], b: readonly number[]) => a.length === b.length && a.every((n, i) => n === b[i])
86
87const run = async ($: EngineInterface, argv: string[]) => (await $.process.run(argv)).stdout.trim()
88const runStep = ($: EngineInterface, step: Step) => $.process.run(step.argv, step.env === undefined ? undefined : { env: step.env })
89
90// The panel is compiled once (a few seconds) for each version of its source, and named
91// by it: the mod and the panel talk to each other, so a binary another install of the
92// mod built, older or newer, never stands in for this one's. Built aside and moved in, so
93// a second session never starts a half-written one.
94const buildPanel = async ($: EngineInterface, root: string) => {
95 const source = `${$.plugin.root}/editor/panel.swift`
96 const text = await $.fs.read(source).catch(() => '')
97 const digest = text === '' ? '' : [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))].slice(0, 6).map(b => b.toString(16).padStart(2, '0')).join('')
98 const binary = digest === '' ? `${root}/bin/panel` : `${root}/bin/panel-${digest}`
99 const built = await $.fs.stat(binary).catch(() => undefined)
100 if (built !== undefined && (digest !== '' || built.mtimeMs >= (await $.fs.stat(source)).mtimeMs)) return binary
101 await $.process.run(['mkdir', '-p', `${root}/bin`])
102 const part = `${binary}.${crypto.randomUUID().slice(0, 8)}`
103 const made = await $.process.run(['xcrun', 'swiftc', '-O', source, '-o', part], { timeoutMs: 120_000 }).catch(() => undefined)
104 if (made?.exitCode !== 0) return undefined
105 return (await $.process.run(['mv', '-f', part, binary])).exitCode === 0 ? binary : undefined
106}
107
108// Each name spelled out: $.env.get takes literals only.
109const readEnv = async ($: EngineInterface): Promise<Env> => {
110 const [CLAUDE_CODE_TMPDIR, TMPDIR, TMP, TEMP, SystemRoot, WAYLAND_DISPLAY] = await Promise.all([
111 $.env.get('CLAUDE_CODE_TMPDIR'), $.env.get('TMPDIR'), $.env.get('TMP'), $.env.get('TEMP'), $.env.get('SystemRoot'), $.env.get('WAYLAND_DISPLAY'),
112 ])
113 return { CLAUDE_CODE_TMPDIR, TMPDIR, TMP, TEMP, SystemRoot, WAYLAND_DISPLAY }
114}
115
116const languageOf = async ($: EngineInterface, os: Os) => {
117 if (os === 'mac') return run($, ['defaults', 'read', '-g', 'AppleLanguages']).catch(() => '')
118 if (os === 'windows') return run($, ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', LANGUAGE_PS]).catch(() => '')
119 const [LC_ALL, LC_MESSAGES, LANG] = await Promise.all([$.env.get('LC_ALL'), $.env.get('LC_MESSAGES'), $.env.get('LANG')])
120 return localeOf({ LC_ALL, LC_MESSAGES, LANG })
121}
122
123const setUp = ($: EngineInterface) => {
124 paths ??= (async () => {
125 const session = await $.session.id()
126 const osVariable = await $.env.get('OS')
127 const os = osOf($.plugin.root, osVariable, osVariable === 'Windows_NT' ? '' : await run($, ['uname', '-s']).catch(() => ''))
128 const env = await readEnv($)
129 const uid = os === 'windows' ? '' : await run($, ['id', '-u'])
130 const root = editsRoot(os, env, uid)
131 const at = join(os, root, session.slice(0, 8))
132 // Windows has no mkdir to run; a file written there makes the folders on the way.
133 if (os === 'windows') await $.fs.write(join(os, at, '.keep'), '')
134 else await $.process.run(os === 'mac' ? ['mkdir', '-p', at] : ['mkdir', '-p', '-m', '700', root, at])
135 // Old sessions' edits are no use to anyone after a week.
136 void runStep($, sweepStep(os, root)).catch(() => undefined)
137 const terminal = (await $.env.get('TERM_PROGRAM')) ?? ''
138 // Claude Code's own temp folder, as it resolves it (./platform says how, per system).
139 const base = engineRoot(os, env, uid)
140 const tmp = (await $.fs.stat(base, { resolve: true }).catch(() => undefined))?.realPath ?? (os === 'mac' ? `/private/tmp/claude-${uid}` : base)
141 lang = langOf(os, await languageOf($, os))
142 return { os, edits: at, tmp, session, terminal, panel: os === 'mac' ? await buildPanel($, root) : undefined, lang, env }
143 })()
144 return paths
145}
146
147// Claude Code's own copy of paste N; it lands a moment after the placeholder does, and
148// the editor waits on it, so it is looked for often (a listing costs next to nothing).
149const engineCopy = async ($: EngineInterface, n: number) => {
150 const { os, tmp, session } = await setUp($)
151 for (let attempt = 0; attempt < 120; attempt++) {
152 images ??= await imagesOf($, os, tmp, session)
153 if (images !== undefined) {
154 const entry = (await $.fs.list(images).catch(() => [])).find(e => new RegExp(`^${n}\\.[a-z]+$`).test(e.name))
155 if (entry !== undefined) return join(os, images, entry.name)
156 }
157 await $.clock.sleep(25)
158 }
159 return undefined
160}
161
162// <tmp>/<project>/<session>/images, the project's folder named as Claude Code names it.
163const imagesOf = async ($: EngineInterface, os: Os, tmp: string, session: string) => {
164 for (const entry of await $.fs.list(tmp).catch(() => [])) {
165 if (entry.kind === 'file') continue
166 const at = join(os, tmp, entry.name, session, 'images')
167 if (await $.fs.exists(at).catch(() => false)) return at
168 }
169 return undefined
170}
171
172// Only where Claude Code kept no copy: the clipboard still holds what was just pasted.
173// Where no tool to read it is there at all, the person is told once what to install.
174const clipboardCopy = async ($: EngineInterface, n: number) => {
175 const { os, edits, env } = await setUp($)
176 const file = join(os, edits, `${n}.paste.png`)
177 const steps = clipboardSteps(os, file, env)
178 let missing = 0
179 for (const step of steps) {
180 const pasted = await runStep($, step).catch(() => undefined)
181 if (isMissing(pasted?.exitCode)) missing += 1
182 else if (pasted?.exitCode === 0 && ((await $.fs.stat(file).catch(() => undefined))?.size ?? 0) > 0) return file
183 }
184 if (os !== 'mac' && missing === steps.length && !isClipboardToldOff) {
185 isClipboardToldOff = true
186 $.ui.toast(WORDS[lang].noClipboard[os])
187 }
188 return undefined
189}
190
191// A picture's size and first bytes, without a tool where the file is small enough to read.
192const header = async ($: EngineInterface, os: Os, file: string) => {
193 const { size } = await $.fs.stat(file)
194 if (size <= READABLE) {
195 const { base64 } = await $.fs.read(file, { as: 'bytes' })
196 return { size, seen: sizeOf(fromBase64(base64.slice(0, 87_384))) }
197 }
198 const head = await runStep($, headStep(os, file))
199 return { size, seen: head.exitCode === 0 ? sizeOf(fromBase64(head.stdout)) : undefined }
200}
201
202type Look = { width: number; height: number; thumb: string; isLarge: boolean }
203
204// Size and a small PNG for the band: a screenshot can be 10 MB, the band needs 480 px.
205// sips makes it on macOS. No tool for it comes with every Linux or Windows, so there
206// the size is read from the file's header and the band draws the picture itself, the
207// terminal scaling it: as bytes up to 2 MiB, by its name past that. An edit made in the
208// browser comes with its own small copy, which stands in for it.
209const look = async ($: EngineInterface, n: number, file: string, small?: string): Promise<Look | undefined> => {
210 const { os, edits } = await setUp($)
211 if (os === 'mac') {
212 const info = await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', file])
213 const width = Number(/pixelWidth: (\d+)/.exec(info.stdout)?.[1] ?? 0)
214 const height = Number(/pixelHeight: (\d+)/.exec(info.stdout)?.[1] ?? 0)
215 const thumb = `${edits}/${n}.thumb.png`
216 const made = await $.process.run(['sips', '-s', 'format', 'png', '-Z', THUMB_PX, file, '--out', thumb])
217 return made.exitCode === 0 && width > 0 && height > 0 ? { width, height, thumb, isLarge: false } : undefined
218 }
219 if (small !== undefined) {
220 const { seen } = await header($, os, small).catch(() => ({ seen: undefined }))
221 if (seen?.isPng) return { width: seen.width, height: seen.height, thumb: small, isLarge: false }
222 }
223 const { size, seen } = await header($, os, file)
224 if (seen === undefined) return undefined
225 // A JPEG (Claude Code keeps a large paste as one) has no picture in the band, only its
226 // name; the terminal draws PNG alone.
227 return { width: seen.width, height: seen.height, thumb: seen.isPng ? file : '', isLarge: size > DRAWABLE }
228}
229
230const refresh = async ($: EngineInterface, n: number, file: string, isEdited: boolean, small?: string) => {
231 const seen = file === '' ? undefined : await look($, n, file, small).catch(() => undefined)
232 await update($, shots, list => {
233 const was = list.find(s => s.n === n)
234 const gen = (was?.gen ?? -1) + 1
235 const shot: Shot = seen === undefined
236 ? { n, file: '', thumb: '', gen, width: 0, height: 0, isEdited, ratio: 0, isLarge: false }
237 : { n, file, thumb: seen.thumb, gen, width: seen.width, height: seen.height, isEdited, ratio: isEdited && was !== undefined ? was.ratio : seen.width / seen.height, isLarge: seen.isLarge }
238 return [...list.filter(s => s.n !== n), shot]
239 })
240 return seen !== undefined
241}
242
243// The panel kept warm (macOS): started with the session, and again on the next keystroke
244// after it quit unused, so by the time a paste lands it only has to show. Each edit is a
245// file in its requests folder, answered by a line on its output: SAVED or CANCELLED and
246// the request's id. Gone (quit, or failed to start), the edit falls back to a panel of
247// its own.
248type Warm = { requests: string; waiting: Map<string, (word: string) => void>; isUsed: boolean }
249
250const keepWarm = ($: EngineInterface) => {
251 warm ??= (async () => {
252 const { os, edits, panel } = await setUp($)
253 if (os !== 'mac' || panel === undefined) return undefined
254 const requests = `${edits}/requests`
255 await $.process.run(['mkdir', '-p', requests])
256 const held: Warm = { requests, waiting: new Map(), isUsed: false }
257 const mine = warm
258 const born = Date.now()
259 void (async () => {
260 let said = ''
261 try {
262 for await (const piece of $.process.spawn({ argv: [panel, 'serve', `${$.plugin.root}/editor/editor.html`, requests, lang] })) {
263 if (!('text' in piece) || piece.stream !== 'stdout') continue
264 said += piece.text
265 for (let end = said.indexOf('\n'); end >= 0; end = said.indexOf('\n')) {
266 const [word = '', id = ''] = said.slice(0, end).trim().split(' ')
267 said = said.slice(end + 1)
268 held.waiting.get(id)?.(word)
269 held.waiting.delete(id)
270 }
271 }
272 } catch {}
273 // Quit unused: the next keystroke starts it again. Gone at once: it will not start
274 // here, and each edit has a panel of its own.
275 if (warm === mine) warm = Date.now() - born < 10_000 && held.waiting.size === 0 && !held.isUsed ? Promise.resolve(undefined) : undefined
276 for (const done of held.waiting.values()) done('GONE')
277 })()
278 return held
279 })().catch(() => undefined)
280 return warm
281}
282
283// One picture through the warm panel: SAVED, CANCELLED, or undefined where there is none.
284const editWarm = async ($: EngineInterface, file: string, out: string, label: string) => {
285 const held = await keepWarm($)
286 if (held === undefined) return undefined
287 const id = `${Date.now().toString(36)}-${label.replace(/\D/g, '')}`
288 held.isUsed = true
289 const answer = new Promise<string>(resolve => held.waiting.set(id, resolve))
290 await $.fs.write(`${held.requests}/${id}.json`, JSON.stringify({ picture: file, out, label }))
291 const word = await answer
292 return word === 'GONE' ? undefined : word
293}
294
295const edit = async ($: EngineInterface, n: number, file?: string) => {
296 const picture = file ?? (await read($, shots)).find(s => s.n === n)?.file
297 if (picture === undefined || picture === '') return
298 const { os, edits: at, terminal, panel } = await setUp($)
299 const words = WORDS[lang]
300 const out = join(os, at, `${n}.png`)
301 let said = (await editWarm($, picture, out, `Image #${n}`).catch(() => undefined)) ?? ''
302 if (said === '') {
303 const argv = editorArgv(os, { root: $.plugin.root, panel, file: picture, out, label: `Image #${n}`, terminal, lang })
304 try {
305 for await (const piece of $.process.spawn({ argv })) {
306 if ('text' in piece && piece.stream === 'stdout') said += piece.text
307 }
308 } catch {
309 $.ui.toast(panel !== undefined ? words.noEditor : os === 'mac' ? words.noTools : words.noNode)
310 return
311 }
312 }
313 // The browser editor says when no window could be opened for it.
314 if (said.includes('UNOPENED')) $.ui.toast(words.noBrowser)
315 if (said.includes('SAVED')) await refresh($, n, out, true, panel === undefined ? thumbOf(out) : undefined)
316}
317
318// One editor at a time: three pastes in a row open one after another.
319const enqueue = ($: EngineInterface, n: number, file?: string) => {
320 queue = queue.then(() => edit($, n, file)).catch(() => undefined)
321}
322
323// The editor opens on the file as soon as it is found; the band's thumbnail is made
324// beside it, not before it.
325const capture = async ($: EngineInterface, n: number) => {
326 const file = (await engineCopy($, n)) ?? (await clipboardCopy($, n)) ?? ''
327 if (file !== '') enqueue($, n, file)
328 await refresh($, n, file, false)
329}
330
331// Numbers run up through a session (#1, #3, #10), so a known N is the same picture coming
332// back (undo, a recalled prompt) and only an unknown one is a new paste.
333const sync = async ($: EngineInterface, text: string) => {
334 await setUp($)
335 const ns = numbersIn(text)
336 if (!isSame(ns, await read($, inBox))) await update($, inBox, () => ns)
337 const known = await read($, shots)
338 for (const n of ns) {
339 if (capturing.has(n) || known.some(s => s.n === n)) continue
340 capturing.add(n)
341 void capture($, n).finally(() => capturing.delete(n))
342 }
343}
344
345const tick = async ($: EngineInterface) => {
346 if (isTicking) return
347 isTicking = true
348 try {
349 await sync($, (await $.prompt.read()).text)
350 } finally {
351 isTicking = false
352 }
353}
354
355// What an Image draws: the thumbnail's bytes, read once per version, or for a picture
356// past what bytes may carry (2 MiB), its file's name, which the terminal reads itself.
357const picture = async ($: EngineInterface, shot: Shot): Promise<ImageSource> => {
358 if (shot.isLarge) return { file: shot.thumb, format: 'png', generation: shot.gen }
359 const at = `${shot.thumb}:${shot.gen}`
360 const held = thumbs.get(at)
361 if (held !== undefined) return { png: held }
362 const { base64 } = await $.fs.read(shot.thumb, { as: 'bytes' })
363 thumbs.set(at, base64)
364 return { png: base64 }
365}
366
367// Every thumbnail the same height, side by side: `tallest` rows, fewer only where they
368// would not fit across.
369const fit = (list: readonly Shot[], columns: number, tallest: number) => {
370 const widthAt = (s: Shot, rows: number) => Math.max(4, Math.min(48, Math.round((rows * CELL * s.width) / s.height)))
371 let rows = tallest
372 while (rows > 3 && list.reduce((sum, s) => sum + widthAt(s, rows) + 2, 0) > columns) rows -= 1
373 return list.map(s => {
374 const width = widthAt(s, rows)
375 return { columns: width, rows: Math.max(1, Math.min(rows, Math.round((width * s.height) / s.width / CELL))) }
376 })
377}
378
379// The band's thumbnails are sized by the screen, never by the rows the band has left: in
380// fullscreen those are what the prompt leaves, so a thumbnail sized by them shrank with
381// every line typed. The bottom slot is half the screen, the prompt's included, so on a
382// short terminal a fifth of the screen leaves the prompt its room. A prompt longer than
383// the rest scrolls the band, as any tall band does: the picture is cut, never squeezed.
384const bandRows = (screenRows: number | undefined) => (screenRows === undefined ? ROWS : Math.max(3, Math.min(ROWS, Math.floor(screenRows / 5))))
385
386// Width over height of an image block, from its header: enough to tell one paste from
387// another when Claude Code has scaled it down or turned it into a JPEG.
388export const ratioOf = (block: { type: string; [field: string]: unknown }) => {
389 const source = block.source as { type?: string; data?: string } | undefined
390 if (block.type !== 'image' || source?.type !== 'base64' || typeof source.data !== 'string') return undefined
391 const seen = sizeOf(fromBase64(source.data.slice(0, 87_384)))
392 return seen === undefined ? undefined : seen.width / seen.height
393}
394
395type Wanted = { n: number; rank: number; ratio: number }
396
397// Which originals the prompt being sent should lose: for each edited paste, its place
398// among the prompt's images (they go in by number) and its shape, to check the block.
399let toDrop: Wanted[] = []
400
401// A shape the header does not tell (a large JPEG's) does not rule a block out.
402const isNear = (a: number | undefined, b: number) => a === undefined || Math.abs(a - b) / b < 0.02
403
404export const dropOriginals = <B extends { type: string; [field: string]: unknown }>(content: readonly B[], wanted: readonly Wanted[]) => {
405 const images = content.flatMap((block, i) => (block.type === 'image' ? [{ i, ratio: ratioOf(block) }] : []))
406 const gone = new Set<number>()
407 for (const want of wanted) {
408 const ranked = images[want.rank]
409 // By place first; if Claude Code ordered them otherwise, the one picture of that shape.
410 const alike = images.filter(m => !gone.has(m.i) && m.ratio !== undefined && isNear(m.ratio, want.ratio))
411 const pick = ranked !== undefined && !gone.has(ranked.i) && isNear(ranked.ratio, want.ratio) ? ranked : alike.length === 1 ? alike[0] : undefined
412 if (pick !== undefined) gone.add(pick.i)
413 }
414 return content.filter((_, i) => !gone.has(i))
415}
416
417export const register: Register = on => {
418 on('session.start', async ($, e, next) => {
419 void setUp($).then(() => keepWarm($)).catch(() => undefined)
420 $.clock.every(500, () => void tick($).catch(() => undefined))
421 return next(e)
422 })
423
424 on('prompt.edit', async ($, e, next) => {
425 const box = await next(e)
426 void keepWarm($)
427 // Only the band's list is awaited; finding the picture runs on behind it.
428 await sync($, box.text).catch(() => undefined)
429 return box
430 })
431
432 // At Enter: an edited paste's original is left out and Claude is pointed at the edit.
433 on('prompt.submit', async ($, e, next) => {
434 toDrop = []
435 const ns = numbersIn(e.text)
436 const edited = (await read($, shots)).filter(s => s.isEdited && s.file !== '' && ns.includes(s.n))
437 if (edited.length === 0) return next(e)
438 const ranks = [...ns].sort((a, b) => a - b)
439 toDrop = edited.map(s => ({ n: s.n, rank: ranks.indexOf(s.n), ratio: s.ratio }))
440 const notes = edited.map(s => `The person edited [Image #${s.n}] before sending (drew on, cropped or rotated it). The original paste is not attached; the edited picture is ${s.file}. Read that file and work from it.`)
441 return next({ ...e, context: [...(e.context ?? []), ...notes] })
442 })
443
444 on('session.append', { door: 'prompt' }, async ($, e, next) => {
445 // Only the prompt the hook above saw: its text still names the edited pastes.
446 const isOurs = toDrop.length > 0 && e.message.role === 'user' && e.message.content.some(b => b.type === 'text' && typeof b.text === 'string' && toDrop.every(w => (b.text as string).includes(`[Image #${w.n}]`)))
447 if (!isOurs) return next(e)
448 const content = dropOriginals(e.message.content, toDrop)
449 toDrop = []
450 return next({ ...e, message: { ...e.message, content } })
451 })
452
453 // A sent prompt with pictures: the row as Claude Code draws it, then each picture named
454 // as the text names it. ctrl+o shows the engine's own row.
455 on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'composer' } } }, async ($, e, next) => {
456 if (e.surface !== 'terminal' || e.props.isExpanded) return next(e)
457 const ns = numbersIn(e.props.text)
458 if (ns.length === 0) return next(e)
459 const all = await read($, shots)
460 const drawn = ns.flatMap(n => all.filter(s => s.n === n && s.thumb !== ''))
461 if (drawn.length === 0) return next(e)
462 const { Box, Text, Image } = $.ui.resolve(e)
463 const columns = e.viewport?.columns ?? 80
464 const sizes = fit(drawn, columns - 4, ROWS)
465 const pictures = await Promise.all(drawn.map(s => picture($, s).catch(() => undefined)))
466 return (
467 <Box flexDirection="column">
468 <Box backgroundColor="userMessageBackground" paddingRight={1}>
469 <Text><Text color="inactive">❯</Text> <Text color="text">{e.props.text}</Text></Text>
470 </Box>
471 <Box flexDirection="row" flexWrap="wrap" columnGap={2} paddingLeft={2} marginTop={1}>
472 {drawn.map((shot, i) => {
473 const source = pictures[i]
474 const size = sizes[i]
475 return (
476 <Box key={`sent-${shot.n}`} flexDirection="column">
477 {source !== undefined && size !== undefined && (
478 <Image key={`sent-img-${shot.n}`} source={source} columns={size.columns} rows={size.rows} alt={`Image #${shot.n}`} />
479 )}
480 <Text key={`sent-name-${shot.n}`} dimColor>Image #{shot.n}{shot.isEdited ? ` ${WORDS[lang].edited}` : ''}</Text>
481 </Box>
482 )
483 })}
484 </Box>
485 </Box>
486 )
487 })
488
489 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
490 if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
491 const ns = await read($, inBox)
492 if (ns.length === 0) return next(e)
493 const all = await read($, shots)
494 const { Box, Text, Button, Image } = $.ui.resolve(e)
495 // A number whose copy is still being found shows as #N alone until it lands.
496 const drawn = ns.flatMap(n => all.filter(s => s.n === n && s.thumb !== ''))
497 const sizes = fit(drawn, e.props.bodyColumns, bandRows(e.viewport?.rows))
498 const pictures = await Promise.all(drawn.map(s => picture($, s).catch(() => undefined)))
499
500 return (
501 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
502 {ns.map((n, i) => {
503 const shot = all.find(s => s.n === n)
504 const at = shot === undefined ? -1 : drawn.indexOf(shot)
505 const source = pictures[at]
506 const size = sizes[at]
507 const hotkey = i < 9 ? { hotkey: String(i + 1) } : {}
508 return (
509 <Box key={`shot-${n}`} flexDirection="column">
510 {source !== undefined && size !== undefined && (
511 <Image key={`img-${n}`} source={source} columns={size.columns} rows={size.rows} alt={`#${n}`} />
512 )}
513 <Box flexDirection="row" columnGap={1}>
514 <Text key={`name-${n}`} dimColor>Image #{n}{shot?.isEdited ? ` ${WORDS[lang].edited}` : ''}</Text>
515 {shot !== undefined && shot.file !== '' && <Button key={`edit-${n}`} label={WORDS[lang].edit} plain {...hotkey} onPress={() => enqueue($, n)} />}
516 </Box>
517 </Box>
518 )
519 })}
520 </Box>
521 )
522 })
523}
524hooks/platform.ts 160 lines1// What differs between macOS, Linux and Windows, as plain data and pure functions: kept
2// apart from the hooks so a test reads it without an engine, and so the end-to-end
3// checks (test/e2e.mjs, under Node) run the very commands the mod runs. Each table
4// follows what Claude Code itself does on that system, read from its own builds: where
5// it keeps a paste, how it reads the clipboard, which key pastes (Ctrl+V, Alt+V on
6// Windows; the mod only ever sees the "[Image #N]" that lands).
7
8export type Os = 'mac' | 'linux' | 'windows'
9
10// The variables the mod reads; each is passed in, since only the hooks can ask for them.
11export type Env = {
12 CLAUDE_CODE_TMPDIR?: string
13 TMPDIR?: string
14 TMP?: string
15 TEMP?: string
16 SystemRoot?: string
17 WAYLAND_DISPLAY?: string
18}
19
20// One command, by its argument vector (no shell, as $.process.run runs it), and the
21// variables set over the session's own for it.
22export type Step = { argv: string[]; env?: Record<string, string> }
23
24// A Windows path starts with a drive or a share; so does the folder the mod runs from.
25const isWindowsPath = (path: string) => /^[A-Za-z]:[\\/]|^\\\\/.test(path)
26
27// Windows says so in OS, set for every process there; macOS and Linux by uname. Only
28// where neither answers does the plugin's own folder tell, by its drive letter.
29export const osOf = (pluginRoot: string, osVariable: string | undefined, uname: string): Os =>
30 osVariable === 'Windows_NT' ? 'windows' : /^Darwin/.test(uname.trim()) ? 'mac' : uname.trim() === '' && isWindowsPath(pluginRoot) ? 'windows' : 'linux'
31
32export const separator = (os: Os) => (os === 'windows' ? '\\' : '/')
33
34export const join = (os: Os, first: string, ...rest: string[]) => {
35 if (rest.length === 0) return first
36 // A folder that already ends in a separator (a drive's root, C:\) takes no other.
37 return (/[\\/]$/.test(first) ? first : first + separator(os)) + rest.join(separator(os))
38}
39
40// The system's temp folder as Node and Bun's os.tmpdir() read it.
41const posixTemp = (env: Env) => (env.TMPDIR || env.TMP || env.TEMP || '/tmp').replace(/(.)\/+$/, '$1')
42const windowsTemp = (env: Env) => (env.TEMP || env.TMP || `${env.SystemRoot || 'C:\\Windows'}\\temp`).replace(/([^:])\\+$/, '$1')
43
44// Where Claude Code keeps a session's pastes: <root>/<project>/<session>/images/<N>.png,
45// the root being CLAUDE_CODE_TMPDIR or the temp folder as each build resolves it: /tmp
46// on macOS (not the per-user $TMPDIR), os.tmpdir() on Linux and Windows; then
47// claude-<uid>, or plain "claude" on Windows, which has no uid. An internal layout, not
48// an API: where it is not found the clipboard is read instead.
49export const engineRoot = (os: Os, env: Env, uid: string) =>
50 os === 'windows'
51 ? join(os, env.CLAUDE_CODE_TMPDIR || windowsTemp(env), 'claude')
52 : join(os, env.CLAUDE_CODE_TMPDIR || (os === 'mac' ? '/tmp' : posixTemp(env)), `claude-${uid}`)
53
54// Where the mod writes edited pictures. On Linux a folder of the user's own, made 0700,
55// since /tmp is shared and an edit is often a screenshot; Windows' temp folder is
56// already the user's.
57export const editsRoot = (os: Os, env: Env, uid: string) =>
58 os === 'mac' ? '/private/tmp/paste-preview' : os === 'windows' ? join(os, windowsTemp(env), 'paste-preview') : join(os, posixTemp(env), `paste-preview-${uid}`)
59
60// What Claude Code runs for an image on the clipboard, to a file: pngpaste on macOS (the
61// mod's own choice there); on Linux wl-paste for Wayland and xclip for X11, the session's
62// own first; on Windows the .NET clipboard through PowerShell, in a single-threaded
63// apartment as the clipboard needs. No shell on Windows, so the file's name goes in a
64// variable, never into the script's text.
65export const CLIPBOARD_PS =
66 'Add-Type -AssemblyName System.Windows.Forms, System.Drawing; ' +
67 '$image = [System.Windows.Forms.Clipboard]::GetImage(); if ($null -eq $image) { exit 1 }; ' +
68 '$image.Save($env:PASTE_PREVIEW_OUT, [System.Drawing.Imaging.ImageFormat]::Png)'
69
70export const clipboardSteps = (os: Os, out: string, env: Env): Step[] => {
71 if (os === 'mac') return [{ argv: ['pngpaste', out] }]
72 if (os === 'windows') return [{ argv: ['powershell.exe', '-NoProfile', '-NonInteractive', '-Sta', '-Command', CLIPBOARD_PS], env: { PASTE_PREVIEW_OUT: out } }]
73 // sh only to point the tool's output at the file; a missing tool is its exit 127.
74 const wayland: Step = { argv: ['sh', '-c', 'exec wl-paste --no-newline --type image/png > "$1"', 'sh', out] }
75 const x11: Step = { argv: ['sh', '-c', 'exec xclip -selection clipboard -t image/png -o > "$1"', 'sh', out] }
76 return env.WAYLAND_DISPLAY ? [wayland, x11] : [x11, wayland]
77}
78
79// A step whose tool is not there: it could not start, or sh could not find it.
80export const isMissing = (exitCode: number | undefined) => exitCode === undefined || exitCode === 127
81
82// The words follow the system's language: the first of AppleLanguages on macOS, the
83// locale for messages on Linux (LC_ALL, else LC_MESSAGES, else LANG), the UI culture on
84// Windows. Chinese is Traditional where the tag says Hant or a region that writes it
85// (Taiwan, Hong Kong, Macau), Simplified for any other zh (Hans, CN, SG, bare zh);
86// Japanese and Korean by their own tags; everything else is English.
87export type Lang = 'en' | 'zh-Hant' | 'zh-Hans' | 'ja' | 'ko'
88export const LANGUAGE_PS = '(Get-UICulture).Name'
89export type Locale = { LC_ALL?: string; LC_MESSAGES?: string; LANG?: string }
90export const localeOf = (env: Locale) => env.LC_ALL || env.LC_MESSAGES || env.LANG || ''
91export const langOfTag = (tag: string): Lang => {
92 // zh_TW.UTF-8, ja_JP@euro, zh-Hant-TW, ko-KR: the encoding and modifier set aside.
93 const parts = tag.trim().replace(/[.@].*$/, '').toLowerCase().split(/[-_]/)
94 if (parts[0] === 'ja' || parts[0] === 'ko') return parts[0]
95 if (parts[0] !== 'zh') return 'en'
96 if (parts.includes('hant')) return 'zh-Hant'
97 if (parts.includes('hans')) return 'zh-Hans'
98 return parts.some(part => part === 'tw' || part === 'hk' || part === 'mo') ? 'zh-Hant' : 'zh-Hans'
99}
100// macOS answers a list, `(\n "zh-Hant-TW",\n en\n)`: its first entry is the language.
101export const langOf = (os: Os, said: string): Lang => langOfTag(os === 'mac' ? (/^[\s(]*"?([^",\s)]+)/.exec(said)?.[1] ?? '') : said)
102
103// Folders of old sessions' edits, a week on. find on macOS and Linux; on Windows
104// PowerShell, the folder again in a variable.
105export const SWEEP_PS =
106 "Get-ChildItem -LiteralPath $env:PASTE_PREVIEW_ROOT -Directory | Where-Object { $_.Name -ne 'bin' -and $_.LastWriteTime -lt (Get-Date).AddDays(-7) } | Remove-Item -Recurse -Force"
107export const sweepStep = (os: Os, root: string): Step =>
108 os === 'windows'
109 ? { argv: ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', SWEEP_PS], env: { PASTE_PREVIEW_ROOT: root } }
110 : { argv: ['find', root, '-mindepth', '1', '-maxdepth', '1', '-type', 'd', '-not', '-name', 'bin', '-mtime', '+7', '-exec', 'rm', '-rf', '{}', '+'] }
111
112// The editor: the floating panel where Swift built it (macOS), else the page in a
113// browser window that editor/server.mjs serves and opens, under Node. Node is node.exe
114// on Windows, named in full so no lookup of extensions is needed to start it.
115export type EditorArgs = { root: string; panel: string | undefined; file: string; out: string; label: string; terminal: string; lang: string }
116export const editorArgv = (os: Os, a: EditorArgs) =>
117 a.panel !== undefined && os === 'mac'
118 ? [a.panel, join(os, a.root, 'editor', 'editor.html'), a.file, a.out, a.label, a.lang]
119 : [os === 'windows' ? 'node.exe' : 'node', join(os, a.root, 'editor', 'server.mjs'), a.file, a.out, a.label, a.terminal, a.lang]
120
121// Beside an edit, the small copy the browser editor draws for the band: <N>.thumb.png.
122export const thumbOf = (out: string) => out.replace(/\.png$/i, '') + '.thumb.png'
123
124// A picture's size from its first bytes: PNG, GIF, JPEG and WebP, the kinds Claude Code
125// keeps a paste as.
126export const sizeOf = (bytes: Uint8Array): { width: number; height: number; isPng: boolean } | undefined => {
127 const at = (i: number) => bytes[i] ?? 0
128 const size = (width: number, height: number, isPng = false) => (width > 0 && height > 0 ? { width, height, isPng } : undefined)
129 if (at(0) === 0x89 && at(1) === 0x50) return size(((at(16) << 24) | (at(17) << 16) | (at(18) << 8) | at(19)) >>> 0, ((at(20) << 24) | (at(21) << 16) | (at(22) << 8) | at(23)) >>> 0, true)
130 if (at(0) === 0x47 && at(1) === 0x49) return size(at(6) | (at(7) << 8), at(8) | (at(9) << 8))
131 if (at(0) === 0xff && at(1) === 0xd8) {
132 for (let i = 2; i + 8 < bytes.length; ) {
133 if (at(i) !== 0xff) return undefined
134 const marker = at(i + 1)
135 if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) return size((at(i + 7) << 8) | at(i + 8), (at(i + 5) << 8) | at(i + 6))
136 i += 2 + ((at(i + 2) << 8) | at(i + 3))
137 }
138 return undefined
139 }
140 // RIFF....WEBP, then a lossy (VP8 ), lossless (VP8L) or extended (VP8X) header.
141 if (at(0) === 0x52 && at(1) === 0x49 && at(8) === 0x57 && at(9) === 0x45) {
142 const kind = String.fromCharCode(at(12), at(13), at(14), at(15))
143 if (kind === 'VP8 ') return size((at(26) | (at(27) << 8)) & 0x3fff, (at(28) | (at(29) << 8)) & 0x3fff)
144 if (kind === 'VP8L') return size(1 + (at(21) | ((at(22) & 0x3f) << 8)), 1 + ((at(22) >> 6) | (at(23) << 2) | ((at(24) & 0x0f) << 10)))
145 if (kind === 'VP8X') return size(1 + (at(24) | (at(25) << 8) | (at(26) << 16)), 1 + (at(27) | (at(28) << 8) | (at(29) << 16)))
146 }
147 return undefined
148}
149
150// The first bytes of a file too large for $.fs.read (4 MiB), as base64 text, since a
151// command's output reaches the mod as text.
152export const HEAD_PS =
153 '$f = [IO.File]::OpenRead($env:PASTE_PREVIEW_FILE); $b = New-Object byte[] 65536; $n = $f.Read($b, 0, 65536); $f.Close(); [Convert]::ToBase64String($b, 0, $n)'
154export const headStep = (os: Os, file: string): Step =>
155 os === 'windows'
156 ? { argv: ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', HEAD_PS], env: { PASTE_PREVIEW_FILE: file } }
157 : { argv: ['sh', '-c', 'head -c 65536 "$1" | base64', 'sh', file] }
158
159export const fromBase64 = (text: string) => Uint8Array.from(atob(text.replace(/\s+/g, '')), c => c.charCodeAt(0))
160types/index.d.ts 27 lines1// One pasted image: the N of its [Image #N] placeholder, the picture it stands for now
2// (Claude Code's copy of the paste, or the edited file once 完成 was pressed) and the
3// small PNG the band draws.
4export type Shot = {
5 n: number
6 // '' when no copy of the paste could be found: the band names #N alone.
7 file: string
8 // '' when there is nothing the terminal can draw (a JPEG, off macOS): #N alone again.
9 thumb: string
10 // Bumped each time the picture changes, so the thumbnail is read again.
11 gen: number
12 width: number
13 height: number
14 isEdited: boolean
15 // The paste's width over height as pasted, to find its block in the message at Enter.
16 ratio: number
17 // The thumbnail is the picture itself and past 2 MiB, more than an Image takes as
18 // bytes: the band names its file instead and the terminal reads it.
19 isLarge: boolean
20}
21
22declare module 'claude-code' {
23 interface PluginState {
24 'paste-preview': { shots: Shot[]; inBox: number[] }
25 }
26}
27