SLOPSHOPPER

paste-preview

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

newbandrowstoastpromptprocess
★ 33v0.2.0MITupdated 2026-10-08alan890104/claude-code-paste-preview
A shopper browsing a rack in a slop shop
README

<img src="docs/assets/icon.svg" width="112" height="112" alt="paste-preview icon"> <h1>paste-preview</h1>

Install

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:

  • You see what you pasted. Every [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.
  • You can mark it up before sending. An editor opens the moment you paste: pen, ellipse, box, arrow, text, crop, rotate, seven colours, undo. Press Enter when done, or Esc to keep the picture as pasted.
  • Claude gets the marked-up version. At Enter the original paste is left out of the message and Claude is pointed at the edited picture.
  • Full screen works on macOS. There the editor is a native floating panel that sits over a full-screen terminal the way Spotlight does: no Space switch, and the keys return to the prompt when it closes. It waits ready in the background, so it appears the instant you paste (about 100 MB while it waits; after 20 minutes unused it quits, and comes back as you type). On Linux and Windows it opens as a browser app window.

Requirements

  • macOS, Linux or Windows
  • Claude Code 2.1.291 or newer (mods are an early-access feature)
  • For thumbnails, a terminal with the kitty graphics protocol, such as Ghostty or kitty. Other terminals (Windows Terminal among them) show Image #N in place of the picture, with its Edit button.
The editorThe clipboard, only when Claude Code's own copy of a paste can't be found
macOSThe 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)
LinuxNode.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
WindowsNode.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.

Use

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 Tpen, ellipse, box, arrow, text
Ccrop: drag a frame, then Enter
Rrotate 90°
1–7colour
⌘Z (Ctrl+Z on Linux and Windows)undo
Shift while drawinga circle or square
Enterdone
Esccancel 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.

How it works

  • Claude Code writes each paste to <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.
  • Edited pictures are written to /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.
  • On Linux and Windows no tool for making thumbnails comes with every system, so the band reads the picture's size from its header and the terminal scales the picture itself; an edit comes back from the editor with a 480 px copy for the band.
  • A plugin cannot change or add an image in a message, only drop one. So at Enter the mod drops the original paste's image block (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].
  • To find the right block, the mod checks the image's place in the message and its aspect ratio. If neither matches, it drops nothing: Claude then gets both the original and the edit, never the wrong one.

Limits

  • On Linux and Windows the editor is a browser window, not a panel: over a full-screen terminal it may switch to another desktop, and the keys go back to the terminal only when the window closes (or when you click it).
  • Under WSL the mod runs as on Linux; the editor opens in a Linux browser when one is installed, else through wslview or Windows' explorer.exe.
  • Thumbnails in the transcript cover the current session. After a restart, older messages show [Image #N] as before.
  • Inside tmux or GNU screen there are no thumbnails: Claude Code draws no pictures there, so above the prompt and in the transcript each paste shows as 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.
  • The editor's text tool uses the system input method. If typing Chinese or Japanese into the floating panel misbehaves, please open an issue.

Development

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.tsxthe mod: finding pastes, thumbnails, the editor queue, what happens at Enter
hooks/platform.tswhat differs on macOS, Linux and Windows: folders, clipboard commands, the editor's command
editor/editor.htmlthe editor (canvas)
editor/panel.swiftthe floating panel that hosts the editor
editor/server.mjsthe editor in a browser window: on Linux and Windows, and on a Mac without Swift

License

MIT

Source 3 files
hooks/register.tsx 524 lines
1import { 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}
524
hooks/platform.ts 160 lines
1// 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))
160
types/index.d.ts 27 lines
1// 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