See and edit what you paste into Claude Code: image thumbnails and text notes above the prompt, an editor for pasted text, and a paste that is never folded

A Claude Code mod for what you paste: thumbnails of pasted images above the prompt instead of bare [Image #1] tags, a note tile for each folded [Pasted text #1 +4 lines] with an editor behind it, and a shortcut that pastes text without Claude Code folding it.
Started as a fork of jarrodwatts/claude-image-view (MIT), which drew the thumbnails; the controls, the pasted-text tiles, the editor and the raw paste were added here.

Inside Claude Code, run:
/plugin marketplace add Snouzy/claude-paste-view
/plugin install paste-view@claude-paste-view
/reload-plugins
That's it. Paste an image into the prompt and its thumbnail appears above the input.
claude plugin marketplace add Snouzy/claude-paste-view
claude plugin install paste-view@claude-paste-view
Then run /reload-plugins inside a session, or start a new one.
Paste one or more images and a row of thumbnails sits above the prompt, each labelled with the number of its tag:
╭────────────────────────╮ ╭────────────╮
│ │ │ │
│ (screenshot) │ │ (photo) │
│ │ │ │
│ #1 │ │ #2 │
╰────────────────────────╯ ╰────────────╯
❯ why is the header misaligned here [Image #1] vs [Image #2]
+N for the rest.✎ puts the caret right after the image's tag; ✕ takes the tag out of the prompt. Clicks need fullscreen rendering; otherwise ctrl+x tab focuses the band, then the image's number presses its ✕, and Tab + Enter the pencil.Claude Code folds a multi-line paste into [Pasted text #n +N lines] and keeps the text to itself. The mod watches the paste go in, so each folded text gets a note tile next to the images: its first line, its line count, ✎ and ✕. Click the tile (or ✎) and an editor opens above the prompt with the text in a field:
[Note #n (N lines)] mark in the prompt instead, and puts the text back when the prompt is sent. Its text is kept in the session's state, so a reload of the mod doesn't lose it.code command line tool must be installed). Each save in VS Code lands in the field; then Save or Fold as usual.A text pasted before the mod loaded was never seen going in: its tile says so, and only ✕ works for it.
The mod can't stop Claude Code from folding a paste, but it can paste for you. If a file named <tmp>/claude-<uid>/paste-view/raw-paste-<session id>.txt appears, its text goes into the prompt at the caret exactly as it is, and the file is emptied. Bind a terminal shortcut to write the clipboard there. With Herdr, whose shortcuts know the Claude session of the focused pane:
[[keys.command]]
key = "cmd+shift+v"
type = "shell"
command = "~/.config/herdr/raw-paste.sh"
#!/bin/sh
sid=$("$HERDR_BIN_PATH" pane get "$HERDR_ACTIVE_PANE_ID" | jq -r '.result.pane.agent_session.value // empty')
[ -n "$sid" ] || exit 0
dir="${CLAUDE_CODE_TMPDIR:-/tmp}/claude-$(id -u)/paste-view"
mkdir -p "$dir"
pbpaste > "$dir/raw-paste-$sid.txt.tmp" && mv "$dir/raw-paste-$sid.txt.tmp" "$dir/raw-paste-$sid.txt"
Claude Code saves every pasted image to a cache folder for the session, as <tmp>/<project>/<session>/images/<n>.<ext> in the format it was pasted (.png, .webp, .jpg, ...), and puts an [Image #n] tag in the prompt. Claude Paste View is a mod:
[Image #n] tags. It checks on a timer because pasting an image doesn't raise an edit event.sips to <session>/paste-view-<n>.png. It reads the size from the PNG header.Image element, and the terminal reads the file itself, so the image data never passes through the mod.▀) with Claude Code's Raster element. It's lower resolution than a real picture, but it's the picture.Claude Code, not the mod, decides whether a terminal gets real pictures. It draws them in kitty (0.28+) and Ghostty, and turns them off inside tmux and screen and in background sessions. Every other terminal gets half-block thumbnails.
| Terminal | Thumbnail |
|---|---|
| Ghostty, kitty | Real picture |
| iTerm2, Terminal.app, WezTerm, VS Code, Windows Terminal, Orca, most others | Half blocks |
| Inside tmux or screen, even in Ghostty or kitty | Half blocks |
| Agent view and background sessions | Half blocks, or real pictures with CLAUDE_CODE_FORCE_TERMINAL_IMAGES (below) |
Some terminals speak the kitty graphics protocol but still get half blocks, because Claude Code trusts terminals by name (anthropics/claude-code#99566), or because it doesn't wrap images for tmux passthrough yet (anthropics/claude-code#100146). Those are Claude Code limits, so a fix there brings real pictures to the mod with no change here.
CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 makes Claude Code draw pictures in any terminal. Only set it if your terminal supports the kitty protocol's Unicode placeholders. VS Code's terminal, for one, doesn't yet, and shows a grid of colored squares instead (xtermjs/xterm.js#5711).
Claude Paste View is local-only. It makes no network requests. It reads the prompt box, lists Claude Code's temp folder to find the current session's image cache, and reads each pasted image (up to Claude Code's 4 MiB read limit). For a non-PNG paste it runs sips and writes one PNG thumbnail into that session's temp folder. On macOS and Linux it runs id -u once to find that temp folder. It also reads a few terminal variables (TERM, TERM_PROGRAM, TMUX and similar) to tell which kind of thumbnail Claude Code can draw.
Run claude plugin validate on the repo to see every event it hooks and every call it makes.
The Claude Desktop app already previews pasted images, so the mod draws nothing there.
Nothing appears when I paste. Run /plugin and check the dim line under the tabs lists paste-view as an active mod. If it isn't listed, run /reload-plugins. If it still isn't, look for an paste-view: hooks module did not load line above the first prompt of a new session: that means your Claude Code is too old for the mod, so update it.
The tile says "no preview". The mod couldn't find the cached file, or the paste isn't a PNG and you're not on macOS, where sips converts it. Claude Code may have moved where it stores pasted images. Please open an issue with your Claude Code version.
The tile is blocky. Claude Code doesn't draw pictures in your terminal, so the mod draws the picture in colored half blocks. See Terminal Support.
The tile shows [Image #1] text instead of the picture. The mod expected Claude Code to draw a picture, but it didn't. That happens in terminals that look like Ghostty or kitty but aren't, such as Herdr (anthropics/claude-code#101306), or in kitty older than 0.28. Set CLAUDE_PASTE_VIEW_RENDERER=blocks in the environment you start Claude Code from. CLAUDE_PASTE_VIEW_RENDERER=image forces real pictures the other way.
The tile is a grid of colored squares. CLAUDE_CODE_FORCE_TERMINAL_IMAGES is set in a terminal that can't draw Unicode placeholders, such as VS Code's. Unset it to get half-block thumbnails.
I want real pictures in agent view or a background session. Claude Code turns terminal images off there. If you attach from Ghostty or kitty, turn them back on in the env block of ~/.claude/settings.json, then start a new session:
"env": { "CLAUDE_CODE_FORCE_TERMINAL_IMAGES": "1" }
git clone https://github.com/Snouzy/claude-paste-view
cd claude-paste-view
# Load it for one session without installing
claude --plugin-dir .
# Check it and run the tests
claude plugin validate .
claude plugin test .
Claude Code writes the API types into .claude-plugin/types/ the first time it loads the mod, and tsc -p . type-checks it from then on.
MIT. See LICENSE.
hooks/register.tsx 475 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PastedImage, PastedText } from '../types'
5import { afterImage, blockCells, drawsPictures, fitRow, fitTextTiles, imageNumbers, pngSize, removeImage, TEXT_TILE_COLUMNS } from './layout'
6import type { Size } from './layout'
7import { agrees, editorRows, expandNotes, matchPastes, noteTag, preview, removeTag, replaceTag, textTags } from './pastes'
8import type { TextKind } from './pastes'
9import { decodeThumb } from './png'
10import type { Thumb } from './png'
11
12// Pasting an image raises no prompt.edit (the tag only shows up on the next keystroke),
13// so the draft is polled instead.
14const POLL_MS = 200
15// The pane the pasted-text editor draws in.
16const EDITOR = 'paste-view-text'
17// Pastes seen at prompt.edit and not yet paired with a `[Pasted text #n]` tag: a few at most,
18// so a paste the editor never folded doesn't pile up.
19const PENDING_MAX = 8
20
21const images = atom({ plugin: 'paste-view', key: 'images' } as const, [] as PastedImage[])
22const texts = atom({ plugin: 'paste-view', key: 'texts' } as const, [] as PastedText[])
23// The texts behind the mod's own `[Note #n (N lines)]` marks: session state, so a reload of the
24// mod doesn't lose what a sent prompt must carry.
25const NOTES = { plugin: 'paste-view', key: 'notes' } as const
26const notes = atom(NOTES, {} as Record<string, string>)
27// Bumped when a save in VS Code changes the editor's text, so the pane draws the new text.
28const editorTick = atom({ plugin: 'paste-view', key: 'editor' } as const, 0)
29
30let tmpRoot: string | undefined
31let found: { sessionId: string; dir: string } | undefined
32// The image numbers last drawn, so an unchanged draft doesn't rewrite state; undefined
33// while a drawn image's file is still missing, so the next poll looks again.
34let shownKey: string | undefined
35// The list last written, so a poll that finds the same tiles doesn't redraw the band.
36let written: string | undefined
37let isChecking = false
38const sizes = new Map<string, Size | null>()
39// Files whose first bytes aren't a PNG's: they never will be, so they aren't read again.
40const notPng = new Set<string>()
41// Source files sips couldn't convert, so a broken paste isn't retried on every poll.
42const unconvertible = new Set<string>()
43// Small decoded copies for terminals that can't draw pictures, which get colored blocks instead.
44const thumbs = new Map<string, Thumb | null>()
45let pictures: boolean | undefined
46// What Claude Code folded into each `[Pasted text #n]`, as prompt.edit showed it going in.
47const pastes = new Map<number, string>()
48const pending: string[] = []
49// The text tags last drawn, so an unchanged draft doesn't rewrite state; the list last written,
50// so a draft with no text tag doesn't write the empty list the state starts with.
51let textsKey: string | undefined
52let textsWritten: string | undefined = '[]'
53// The text open in the editor pane, as edited so far.
54let editing:
55 | {
56 kind: TextKind
57 n: number
58 tag: string
59 text: string
60 isUnknown: boolean
61 // The file VS Code edits, once opened there, with the modification time last read and when.
62 file?: string
63 fileMtimeMs?: number
64 savedAt?: string
65 }
66 | undefined
67// The raw-paste file last consumed, by its modification time, so it isn't inserted twice.
68let rawPasteFile: string | undefined
69
70// Claude Code doesn't tell mods whether its Image draws a picture, so go by the environment
71// it decides from.
72async function canDrawPictures($: EngineInterface): Promise<boolean> {
73 if (pictures === undefined) {
74 pictures = drawsPictures({
75 TERM: await $.env.get('TERM'),
76 TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
77 TMUX: await $.env.get('TMUX'),
78 STY: await $.env.get('STY'),
79 KITTY_WINDOW_ID: await $.env.get('KITTY_WINDOW_ID'),
80 GHOSTTY_RESOURCES_DIR: await $.env.get('GHOSTTY_RESOURCES_DIR'),
81 CLAUDE_CODE_FORCE_TERMINAL_IMAGES: await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES'),
82 CLAUDE_CODE_SESSION_KIND: await $.env.get('CLAUDE_CODE_SESSION_KIND'),
83 CLAUDE_PASTE_VIEW_RENDERER: await $.env.get('CLAUDE_PASTE_VIEW_RENDERER'),
84 })
85 }
86 return pictures
87}
88
89// Claude Code keeps its temp files in <base>/claude-<uid>, or <base>\claude on Windows. The base
90// is CLAUDE_CODE_TMPDIR when set, else /tmp (%TEMP% on Windows): the variable names the folder
91// Claude Code nests its own in, not that folder itself.
92async function tempRoot($: EngineInterface): Promise<string | undefined> {
93 const fromEnv = await $.env.get('CLAUDE_CODE_TMPDIR')
94 if ((await $.env.get('OS')) === 'Windows_NT') {
95 const base = fromEnv ?? (await $.env.get('TEMP'))
96 return base === undefined ? undefined : `${base.replace(/[\\/]+$/, '')}/claude`
97 }
98 const uid = (await $.process.run(['id', '-u'])).stdout.trim()
99 return `${(fromEnv ?? '/tmp').replace(/\/+$/, '')}/claude-${uid}`
100}
101
102// Claude Code caches each paste as <tmp>/<project>/<session>/images/<n>.png. The project
103// folder is named after a working directory that may since have moved, so find it by the
104// session id instead of rebuilding it.
105async function imagesDir($: EngineInterface): Promise<string | undefined> {
106 const sessionId = await $.session.id()
107 if (found?.sessionId === sessionId) return found.dir
108 tmpRoot ??= await tempRoot($)
109 if (tmpRoot === undefined) return undefined
110 const entries = await $.fs.list(tmpRoot).catch(() => [])
111 for (const entry of entries) {
112 const dir = `${tmpRoot}/${entry.name}/${sessionId}/images`
113 if (entry.kind === 'dir' && (await $.fs.exists(dir))) {
114 found = { sessionId, dir }
115 return dir
116 }
117 }
118 return undefined
119}
120
121// Pastes are cached as <n>.png or <n>.webp (or another format), but the terminal only
122// decodes PNG, so anything else is converted once to <session>/paste-view-<n>.png.
123// sips ships with macOS only, so on Linux a non-PNG paste still shows "no preview".
124async function pngFor($: EngineInterface, dir: string, n: number): Promise<string | undefined> {
125 const png = `${dir}/${n}.png`
126 if (await $.fs.exists(png)) return png
127 const out = `${dir.replace(/\/images$/, '')}/paste-view-${n}.png`
128 if (await $.fs.exists(out)) return out
129 const entries = await $.fs.list(dir).catch(() => [])
130 const source = entries.find(entry => entry.kind === 'file' && entry.name.startsWith(`${n}.`))
131 if (source === undefined) return undefined
132 const sourcePath = `${dir}/${source.name}`
133 if (unconvertible.has(sourcePath)) return undefined
134 const { exitCode } = await $.process
135 .run(['sips', '-s', 'format', 'png', '-Z', '800', sourcePath, '--out', out])
136 .catch(() => ({ exitCode: 1 }))
137 if (exitCode === 0) return out
138 unconvertible.add(sourcePath)
139 return undefined
140}
141
142async function describe($: EngineInterface, dir: string | undefined, n: number): Promise<PastedImage> {
143 const path = dir === undefined ? undefined : await pngFor($, dir, n)
144 if (path === undefined || notPng.has(path)) return { n, path: null, size: null }
145 if (!sizes.has(path)) {
146 const base64 = await $.fs.read(path, { as: 'bytes' }).then(
147 bytes => bytes.base64,
148 () => undefined, // too big to read: still drawable, just without its aspect ratio
149 )
150 const head = base64 === undefined ? undefined : pngSize(base64)
151 if (head === null) {
152 // A file no longer than the 24-byte header may still be being written; a longer one is settled.
153 if (base64!.length > 32) notPng.add(path)
154 return { n, path: null, size: null }
155 }
156 sizes.set(path, head ?? null)
157 if (base64 !== undefined && !(await canDrawPictures($))) thumbs.set(path, decodeThumb(Uint8Array.fromBase64(base64)))
158 }
159 return { n, path, size: sizes.get(path) ?? null }
160}
161
162async function showImages($: EngineInterface, draft: string) {
163 const numbers = imageNumbers(draft)
164 const key = numbers.join(',')
165 if (key === shownKey) return
166 const dir = numbers.length > 0 ? await imagesDir($) : undefined
167 const list: PastedImage[] = []
168 for (const n of numbers) list.push(await describe($, dir, n))
169 shownKey = list.every(image => image.path !== null) ? key : undefined
170 const json = JSON.stringify(list)
171 if (json === written) return
172 await update($, images, () => list)
173 written = json
174}
175
176// The text behind a tile: Claude Code's paste as prompt.edit showed it, or the mod's own note.
177async function textOf($: EngineInterface, kind: TextKind, n: number): Promise<string | undefined> {
178 return kind === 'paste' ? pastes.get(n) : (await read($, notes))[n]
179}
180
181// The clipboard, for a paste prompt.edit didn't show going in (Claude Code folds a long paste on
182// its own path): on macOS `pbpaste` still holds what was just pasted.
183async function clipboard($: EngineInterface): Promise<string | undefined> {
184 const result = await $.process.run(['pbpaste']).catch(() => undefined)
185 return result?.exitCode === 0 && result.stdout !== '' ? result.stdout : undefined
186}
187
188async function showTexts($: EngineInterface, draft: string) {
189 const tags = textTags(draft)
190 const key = tags.map(tag => tag.tag).join('|')
191 if (key === textsKey) return
192 const before = new Set((textsKey ?? '').split('|'))
193 textsKey = key
194 // A tag that just appeared was pasted within the last poll: the clipboard still holds that text,
195 // so it comes first, when its line count agrees with the tag's. Claude Code doesn't raise
196 // prompt.edit for a paste it folds, so what prompt.edit did show (pending) is only a fallback.
197 for (const tag of tags) {
198 if (tag.kind !== 'paste' || pastes.has(tag.n) || before.has(tag.tag)) continue
199 const text = await clipboard($)
200 if (text !== undefined && agrees(text, tag.lines)) pastes.set(tag.n, text)
201 }
202 for (const [n, text] of matchPastes(tags, new Set(pastes.keys()), pending)) pastes.set(n, text)
203 const list: PastedText[] = []
204 for (const tag of tags) {
205 const text = await textOf($, tag.kind, tag.n)
206 list.push({ n: tag.n, kind: tag.kind, lines: tag.lines, tag: tag.tag, preview: text === undefined ? null : preview(text, TEXT_TILE_COLUMNS) })
207 }
208 const json = JSON.stringify(list)
209 if (json === textsWritten) return
210 await update($, texts, () => list)
211 textsWritten = json
212}
213
214// A paste the person made with the terminal's raw-paste shortcut lands in a file named after the
215// session (see the README): it goes into the draft as it is, so Claude Code never folds it.
216async function rawPastePath($: EngineInterface): Promise<string | undefined> {
217 tmpRoot ??= await tempRoot($)
218 return tmpRoot === undefined ? undefined : `${tmpRoot}/paste-view/raw-paste-${await $.session.id()}.txt`
219}
220
221async function insertRawPaste($: EngineInterface) {
222 const path = await rawPastePath($)
223 if (path === undefined || !(await $.fs.exists(path))) return
224 const stamp = `${path}@${(await $.fs.stat(path).catch(() => ({ mtimeMs: 0 }))).mtimeMs}`
225 if (stamp === rawPasteFile) return
226 rawPasteFile = stamp
227 const text = await $.fs.read(path).catch(() => '')
228 if (typeof text !== 'string' || text === '') return
229 await $.fs.write(path, '')
230 await $.prompt.fill({ text, mode: 'insert' })
231}
232
233// The editor's text is in a file VS Code has open: a save there (a new modification time) becomes
234// the field's text, and the pane redraws to show it.
235async function pullEditorFile($: EngineInterface) {
236 const item = editing
237 if (item?.file === undefined) return
238 const stat = await $.fs.stat(item.file).catch(() => undefined)
239 if (stat === undefined || stat.mtimeMs === item.fileMtimeMs) return
240 item.fileMtimeMs = stat.mtimeMs
241 const text = await $.fs.read(item.file).catch(() => undefined)
242 if (typeof text !== 'string' || text === item.text) return
243 item.text = text
244 item.savedAt = new Date(stat.mtimeMs).toTimeString().slice(0, 8)
245 await update($, editorTick, tick => tick + 1)
246}
247
248async function check($: EngineInterface) {
249 if (isChecking) return
250 isChecking = true
251 try {
252 // A raw paste that can't be read or written is left where it is: the tiles still draw.
253 await insertRawPaste($).catch(() => undefined)
254 await pullEditorFile($).catch(() => undefined)
255 const draft = (await $.prompt.read()).text
256 await showImages($, draft)
257 await showTexts($, draft)
258 } finally {
259 isChecking = false
260 }
261}
262
263export const register: Register = on => {
264 on('session.start', async ($, e, next) => {
265 pictures = undefined
266 $.clock.every(POLL_MS, () => check($))
267 return next(e)
268 })
269
270 // A paste is an edit with no key: what goes in is what Claude Code is about to fold, so keep it
271 // for the tile and the editor. The draft is left to the editor as it stands.
272 on('prompt.edit', async ($, e, next) => {
273 if (e.key === undefined && e.inputText.length > 0) {
274 pending.push(e.inputText)
275 if (pending.length > PENDING_MAX) pending.shift()
276 }
277 return next(e)
278 })
279
280 // The mod's own folds go back into the prompt before it is sent; a slash command is left alone.
281 on('prompt.submit', async ($, e, next) => {
282 if (e.command) return next(e)
283 const known = await read($, notes)
284 const text = expandNotes(e.text, known)
285 return text === e.text ? next(e) : next({ ...e, text })
286 })
287
288 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
289 if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
290 const list = await read($, images)
291 const textList = await read($, texts)
292 if (list.length === 0 && textList.length === 0) return next(e)
293
294 const { Box, Button, Image, Raster, Text } = $.ui.resolve(e)
295
296 // The cross takes every `[Image #n]` tag out of the draft; the pencil puts the caret just past
297 // the first one. Both rewrite the draft as it stands, so what the person typed around stays.
298 const remove = async (n: number) => {
299 const box = await $.prompt.read()
300 const draft = removeImage(box.text, box.cursor, n)
301 await $.prompt.fill({ text: draft.text, cursor: draft.cursor })
302 }
303 const edit = async (n: number) => {
304 const box = await $.prompt.read()
305 const at = afterImage(box.text, n)
306 if (at !== null) await $.prompt.fill({ text: box.text, cursor: at })
307 }
308 const removeText = async (tag: string) => {
309 const box = await $.prompt.read()
310 const draft = removeTag(box.text, box.cursor, tag)
311 await $.prompt.fill({ text: draft.text, cursor: draft.cursor })
312 }
313 // The editor opens on the text as it is; one the mod never saw opens empty, and what is saved
314 // replaces the tag all the same.
315 const openEditor = async (item: PastedText, tag: string) => {
316 const text = await textOf($, item.kind, item.n)
317 editing = { kind: item.kind, n: item.n, tag, text: text ?? '', isUnknown: text === undefined }
318 await $.ui.open({ id: EDITOR, title: `Text #${item.n}`, focus: true, closeOnEscape: true, rows: 30 })
319 }
320 const cells = fitRow(list.map(image => image.size), e.props.maxRows, e.props.bodyColumns)
321 const shownTexts = fitTextTiles(textList.length, cells, e.props.maxRows, e.props.bodyColumns)
322 if (cells.length === 0 && shownTexts === 0) return next(e)
323 const hidden = list.length - cells.length + (textList.length - shownTexts)
324 const below = await next(e)
325
326 return (
327 <Box flexDirection="column">
328 <Box flexDirection="row" columnGap={1}>
329 {list.slice(0, cells.length).map((image, i) => {
330 const { columns, rows } = cells[i] ?? { columns: 4, rows: 1 }
331 return (
332 <Box key={`tile-${image.n}`} flexDirection="column" alignItems="center" borderStyle="round" borderDimColor>
333 <Text dimColor>#{image.n}</Text>
334 {image.path === null ? (
335 <Box width={columns} height={rows} alignItems="center" justifyContent="center">
336 <Text dimColor wrap="truncate">no preview</Text>
337 </Box>
338 ) : thumbs.get(image.path) ? (
339 <Raster
340 key={`blocks-${image.n}`}
341 columns={columns}
342 rows={rows}
343 cells={blockCells(thumbs.get(image.path)!, columns, rows)}
344 />
345 ) : (
346 <Image
347 key={`image-${image.n}`}
348 source={{ file: image.path, format: 'png' }}
349 columns={columns}
350 rows={rows}
351 alt={`[Image #${image.n}]`}
352 />
353 )}
354 <Box flexDirection="row" columnGap={1}>
355 <Button key={`edit-${image.n}`} dimColor label="✎" onPress={() => void edit(image.n)} />
356 {/* On the main screen the terminal keeps the mouse, so the band is reached with
357 ctrl+x tab; the image's own number then removes it (the pencil is a Tab away). */}
358 <Button
359 key={`remove-${image.n}`}
360 dimColor
361 label="✕"
362 hotkey={image.n <= 9 ? String(image.n) : undefined}
363 onPress={() => void remove(image.n)}
364 />
365 </Box>
366 </Box>
367 )
368 })}
369 {textList.slice(0, shownTexts).map(item => {
370 const tag = item.tag
371 return (
372 <Box key={`text-${item.kind}-${item.n}`} flexDirection="column" alignItems="center" borderStyle="round" borderDimColor width={TEXT_TILE_COLUMNS + 2}>
373 <Text dimColor>{item.kind === 'note' ? '📝 note' : '📝 text'} #{item.n}</Text>
374 <Button
375 key={`open-text-${item.n}`}
376 plain
377 label={item.preview === null ? '(text not captured)' : item.preview === '' ? '(blank)' : item.preview}
378 onPress={() => void openEditor(item, tag)}
379 />
380 <Text dimColor>{item.lines} line{item.lines === 1 ? '' : 's'}</Text>
381 <Box flexDirection="row" columnGap={1}>
382 <Button key={`edit-text-${item.n}`} dimColor label="✎" onPress={() => void openEditor(item, tag)} />
383 <Button key={`remove-text-${item.n}`} dimColor label="✕" onPress={() => void removeText(tag)} />
384 </Box>
385 </Box>
386 )
387 })}
388 {hidden > 0 && <Text dimColor>+{hidden}</Text>}
389 </Box>
390 {below}
391 </Box>
392 )
393 })
394
395 on('ui.render', { component: 'Pane', requestId: EDITOR }, async ($, e, next) => {
396 const item = editing
397 // The mobile app draws no text field yet.
398 if (item === undefined || e.surface === 'mobile') return next(e)
399 await read($, editorTick) // a save in VS Code redraws this pane
400 const { Box, Button, Input, Text } = $.ui.resolve(e)
401
402 // The text goes to a file VS Code opens; each save there lands in the field (pullEditorFile).
403 const openInCode = async () => {
404 tmpRoot ??= await tempRoot($)
405 if (tmpRoot === undefined) return
406 const file = `${tmpRoot}/paste-view/edit-${await $.session.id()}-${item.n}.txt`
407 await $.fs.write(file, item.text)
408 const stat = await $.fs.stat(file).catch(() => undefined)
409 item.file = file
410 item.fileMtimeMs = stat?.mtimeMs
411 const ran = await $.process.run(['code', '--reuse-window', file]).catch(() => undefined)
412 if (ran === undefined || ran.exitCode !== 0) {
413 item.file = undefined
414 $.ui.toast('Could not run `code`: install the VS Code command line tool (Shell Command: Install \'code\' command in PATH).')
415 }
416 }
417
418 const close = async () => {
419 editing = undefined
420 await $.ui.close({ id: EDITOR })
421 }
422 // Saving puts the text in the draft in place of the tag, in the open; folding leaves the mod's
423 // own mark there instead and keeps the text to put back when the prompt is sent.
424 const write = async (replacement: string, keep: string | undefined) => {
425 const box = await $.prompt.read()
426 const draft = replaceTag(box.text, box.cursor, item.tag, replacement)
427 const known = { ...(await read($, notes)) }
428 if (keep === undefined) delete known[item.n]
429 else known[item.n] = keep
430 await $.state.set(NOTES, known)
431 await $.prompt.fill({ text: draft.text, cursor: draft.cursor })
432 await close()
433 }
434 const save = (text: string) => write(text, undefined)
435 const fold = () => write(noteTag(item.n, item.text), item.text)
436
437 return (
438 <Box flexDirection="column" rowGap={1}>
439 <Input
440 key="text"
441 rows={editorRows(item.text, e.props.bodyColumns, e.props.scroll.bodyRows)}
442 value={item.text}
443 autoFocus
444 submitLabel="save"
445 onInput={value => {
446 item.text = value
447 }}
448 onSubmit={value => {
449 item.text = value
450 void save(value)
451 }}
452 />
453 <Box flexDirection="row" columnGap={2}>
454 <Button key="save" label="Save" hotkey="s" onPress={() => void save(item.text)} />
455 <Button key="fold" label="Fold" hotkey="f" onPress={() => void fold()} />
456 <Button key="cancel" label="Cancel" hotkey="c" onPress={() => void close()} />
457 <Button key="code" label="VS Code" hotkey="v" onPress={() => void openInCode()} />
458 </Box>
459 {item.isUnknown && <Text color="warning">The pasted text wasn't captured, so the field starts empty: what you save replaces the tag.</Text>}
460 {item.file !== undefined && (
461 <Text dimColor>
462 Open in VS Code: each save there lands here{item.savedAt === undefined ? '' : ` (last ${item.savedAt})`}; then Save or Fold.
463 </Text>
464 )}
465 <Text dimColor>Save puts the text in the prompt as is; Fold keeps it behind a [Note] mark until you send.</Text>
466 </Box>
467 )
468 })
469
470 on('ui.close', { id: EDITOR }, async ($, e, next) => {
471 editing = undefined
472 return next(e)
473 })
474}
475hooks/layout.ts 168 lines1import { removeTag } from './pastes'
2
3export type Size = { width: number; height: number }
4export type Cells = { columns: number; rows: number }
5
6const TILE_ROWS = 6
7const MAX_COLUMNS = 32
8// Wide enough for the controls row: `[ ✎ ] [ ✕ ]`.
9const MIN_COLUMNS = 11
10// A terminal cell is about twice as tall as it is wide.
11const CELL_ASPECT = 2
12// Used when the size is unknown (file over $.fs.read's 4 MiB cap, or no file).
13const FALLBACK: Size = { width: 16, height: 10 }
14// Each tile adds a border on every side, a number row over the picture and a controls row under it.
15const TILE_CHROME_ROWS = 4
16const TILE_CHROME_COLUMNS = 2
17const GAP = 1
18
19/** The distinct image numbers a draft references, in the order they first appear. */
20export function imageNumbers(draft: string): number[] {
21 const seen = new Set<number>()
22 for (const match of draft.matchAll(/\[Image #(\d+)\]/g)) seen.add(Number(match[1]))
23 return [...seen]
24}
25
26const tagOf = (n: number) => `[Image #${n}]`
27
28/** The draft with every `[Image #n]` tag taken out (see `removeTag`), and where the caret lands. */
29export function removeImage(draft: string, cursor: number, n: number): { text: string; cursor: number } {
30 return removeTag(draft, cursor, tagOf(n))
31}
32
33/** The offset just past the first `[Image #n]` tag of the draft, or null when it has none. */
34export function afterImage(draft: string, n: number): number | null {
35 const start = draft.indexOf(tagOf(n))
36 return start < 0 ? null : start + tagOf(n).length
37}
38
39/** Width and height from a PNG's IHDR chunk, or null when the bytes aren't a PNG. */
40export function pngSize(base64: string): Size | null {
41 // 24 bytes cover the signature and IHDR's width and height; 32 base64 chars decode to exactly 24.
42 const head = Uint8Array.from(atob(base64.slice(0, 32)), char => char.charCodeAt(0))
43 const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
44 if (head.length < 24 || signature.some((byte, i) => head[i] !== byte)) return null
45 const view = new DataView(head.buffer, head.byteOffset, head.byteLength)
46 const width = view.getUint32(16)
47 const height = view.getUint32(20)
48 return width > 0 && height > 0 ? { width, height } : null
49}
50
51/** A picture box `rows` tall that keeps the picture's aspect ratio. */
52export function fitCells(size: Size | null, tileRows = TILE_ROWS): Cells {
53 const { width, height } = size ?? FALLBACK
54 let rows = tileRows
55 let columns = Math.round((rows * CELL_ASPECT * width) / height)
56 if (columns > MAX_COLUMNS) {
57 columns = MAX_COLUMNS
58 rows = Math.max(1, Math.round((MAX_COLUMNS * height) / (CELL_ASPECT * width)))
59 }
60 return { columns: Math.max(MIN_COLUMNS, columns), rows: Math.min(rows, tileRows) }
61}
62
63/**
64 * Picture boxes for one row of tiles that fits the band whole, so it never scrolls:
65 * the tallest tiles whose chrome fits in `maxRows` and whose total width fits in `bodyColumns`.
66 * When even one-row tiles are too wide, only the first ones that fit beside a `+N` label for
67 * the rest; none when the band is too small for one tile.
68 */
69export function fitRow(sizes: readonly (Size | null)[], maxRows: number, bodyColumns: number): Cells[] {
70 if (maxRows < TILE_CHROME_ROWS + 1) return []
71 const tallest = Math.min(TILE_ROWS, maxRows - TILE_CHROME_ROWS)
72 for (let tileRows = tallest; tileRows > 1; tileRows--) {
73 const cells = sizes.map(size => fitCells(size, tileRows))
74 if (rowWidth(cells, 0) <= bodyColumns) return cells
75 }
76 const cells = sizes.map(size => fitCells(size, 1))
77 let count = cells.length
78 while (count > 0 && rowWidth(cells.slice(0, count), cells.length - count) > bodyColumns) count--
79 return cells.slice(0, count)
80}
81
82// A note tile: a border, a number row, two rows of preview, a line count and the controls row.
83export const TEXT_TILE_COLUMNS = 24
84export const TEXT_TILE_ROWS = 7
85
86/**
87 * How many note tiles fit on the row after the image tiles (`cells`), each `TEXT_TILE_COLUMNS` wide
88 * inside its border, leaving room for a `+N` label when some are left out; none when the band is
89 * shorter than a note tile.
90 */
91export function fitTextTiles(count: number, cells: readonly Cells[], maxRows: number, bodyColumns: number): number {
92 if (count === 0 || maxRows < TEXT_TILE_ROWS) return 0
93 const images = rowWidth(cells, 0)
94 for (let shown = count; shown > 0; shown--) {
95 const tiles = shown * (TEXT_TILE_COLUMNS + TILE_CHROME_COLUMNS) + GAP * shown
96 const label = shown < count ? GAP + `+${count - shown}`.length : 0
97 if (images + tiles + label <= bodyColumns) return shown
98 }
99 return 0
100}
101
102function rowWidth(cells: readonly Cells[], hidden: number): number {
103 const tiles = cells.reduce((sum, c) => sum + c.columns + TILE_CHROME_COLUMNS, 0) + GAP * Math.max(0, cells.length - 1)
104 return hidden > 0 ? tiles + GAP + `+${hidden}`.length : tiles
105}
106
107type Env = Readonly<Record<string, string | undefined>>
108
109/**
110 * Whether Claude Code will draw `Image` as a real picture. It decides that itself and doesn't tell
111 * mods, so this mirrors its rule as far as the environment shows it: always when
112 * CLAUDE_CODE_FORCE_TERMINAL_IMAGES is set; otherwise never inside tmux or screen or in a
113 * background session, and only when the terminal is kitty or Ghostty (it goes by the terminal's
114 * name, so other kitty-protocol terminals such as WezTerm still get the `alt` text).
115 * `CLAUDE_PASTE_VIEW_RENDERER` overrides the guess: `image` for the picture, `blocks` for colored half blocks.
116 */
117export function drawsPictures(env: Env): boolean {
118 const forced = env.CLAUDE_PASTE_VIEW_RENDERER
119 if (forced === 'image' || forced === 'blocks') return forced === 'image'
120 if (env.CLAUDE_CODE_FORCE_TERMINAL_IMAGES) return true
121 if (env.TMUX || env.STY || env.CLAUDE_CODE_SESSION_KIND === 'bg') return false
122 return (
123 Boolean(env.KITTY_WINDOW_ID || env.GHOSTTY_RESOURCES_DIR) ||
124 /kitty|ghostty/i.test(env.TERM ?? '') ||
125 /^ghostty$/i.test(env.TERM_PROGRAM ?? '')
126 )
127}
128
129/**
130 * The cells of a `Raster` that draws `rgb` (a `width` x `height` thumbnail) in `columns` x `rows`
131 * cells, base64. Each cell is an upper half block, so it holds two pixels: the top as its
132 * foreground and the bottom as its background.
133 */
134export function blockCells(thumb: { width: number; height: number; rgb: Uint8Array }, columns: number, rows: number): string {
135 const pixelRows = rows * 2
136 const average = (px: number, py: number): number => {
137 // The box of source pixels under output pixel (px, py), at least one pixel wide and tall.
138 const x0 = Math.floor((px * thumb.width) / columns)
139 const x1 = Math.max(x0 + 1, Math.floor(((px + 1) * thumb.width) / columns))
140 const y0 = Math.floor((py * thumb.height) / pixelRows)
141 const y1 = Math.max(y0 + 1, Math.floor(((py + 1) * thumb.height) / pixelRows))
142 let r = 0
143 let g = 0
144 let b = 0
145 let n = 0
146 for (let y = y0; y < y1 && y < thumb.height; y++) {
147 for (let x = x0; x < x1 && x < thumb.width; x++, n++) {
148 const at = (y * thumb.width + x) * 3
149 r += thumb.rgb[at]!
150 g += thumb.rgb[at + 1]!
151 b += thumb.rgb[at + 2]!
152 }
153 }
154 n = n || 1
155 return (Math.round(r / n) << 16) | (Math.round(g / n) << 8) | Math.round(b / n)
156 }
157 const words = new Uint32Array(columns * rows * 3)
158 for (let row = 0; row < rows; row++) {
159 for (let column = 0; column < columns; column++) {
160 const at = (row * columns + column) * 3
161 words[at] = 0x2580
162 words[at + 1] = average(column, row * 2)
163 words[at + 2] = average(column, row * 2 + 1)
164 }
165 }
166 return (new Uint8Array(words.buffer) as Uint8Array & { toBase64(): string }).toBase64()
167}
168hooks/pastes.ts 130 lines1// Pasted texts: Claude Code folds a multi-line paste into `[Pasted text #n +N lines]` and keeps
2// the text to itself, so the mod remembers what went in at `prompt.edit` and draws a note tile.
3// A text the person folds from the editor becomes the mod's own `[Note #n (N lines)]`, whose
4// text the mod keeps and puts back when the prompt is sent.
5
6export type TextKind = 'paste' | 'note'
7
8export type TextTag = { kind: TextKind; n: number; lines: number; tag: string }
9
10const PASTE_TAG = /\[Pasted text #(\d+)(?: \+(\d+) lines?)?\]/g
11const NOTE_TAG = /\[Note #(\d+) \((\d+) lines?\)\]/g
12
13/** How many lines a text spans: one more than its newlines. */
14export function lineCount(text: string): number {
15 return text.split('\n').length
16}
17
18/** The mod's own fold mark for a text numbered `n`. */
19export function noteTag(n: number, text: string): string {
20 const lines = lineCount(text)
21 return `[Note #${n} (${lines} line${lines === 1 ? '' : 's'})]`
22}
23
24/** The distinct pasted-text and note tags a draft holds, in the order they first appear. */
25export function textTags(draft: string): TextTag[] {
26 const found: TextTag[] = []
27 const seen = new Set<string>()
28 const add = (tag: TextTag) => {
29 const key = `${tag.kind}:${tag.n}`
30 if (!seen.has(key)) {
31 seen.add(key)
32 found.push(tag)
33 }
34 }
35 const all = [
36 ...[...draft.matchAll(PASTE_TAG)].map(m => ({ kind: 'paste' as const, n: Number(m[1]), lines: Number(m[2] ?? 0), tag: m[0], at: m.index ?? 0 })),
37 ...[...draft.matchAll(NOTE_TAG)].map(m => ({ kind: 'note' as const, n: Number(m[1]), lines: Number(m[2]), tag: m[0], at: m.index ?? 0 })),
38 ].sort((a, b) => a.at - b.at)
39 for (const { at: _at, ...tag } of all) add(tag)
40 return found
41}
42
43/** Whether a text can be what a `+N lines` tag folded: Claude Code counts its lines one short at times. */
44export function agrees(text: string, lines: number): boolean {
45 const count = lineCount(text)
46 return count === lines || count - 1 === lines
47}
48
49/**
50 * Pairs the pasted-text tags whose text isn't known yet with the pastes seen at `prompt.edit`, in
51 * order: the oldest paste whose line count agrees with the tag's `+N lines`, and none when no
52 * paste agrees (a short paste Claude Code never folds also goes through `prompt.edit`, so a tag
53 * must not fall back on whatever is oldest). Each paste is used once; the ones taken are removed
54 * from `pending`.
55 */
56export function matchPastes(tags: readonly TextTag[], known: ReadonlySet<number>, pending: string[]): Map<number, string> {
57 const matched = new Map<number, string>()
58 for (const tag of tags) {
59 if (tag.kind !== 'paste' || known.has(tag.n) || matched.has(tag.n)) continue
60 const index = pending.findIndex(text => agrees(text, tag.lines))
61 if (index < 0) continue
62 matched.set(tag.n, pending[index]!)
63 pending.splice(index, 1)
64 }
65 return matched
66}
67
68/** The first line of a text with something on it, trimmed and cut to `columns` cells. */
69export function preview(text: string, columns: number): string {
70 const line = text.split('\n').find(l => l.trim() !== '')?.trim() ?? ''
71 return line.length > columns ? `${line.slice(0, Math.max(0, columns - 1))}…` : line
72}
73
74/**
75 * The draft with every occurrence of `tag` replaced by `replacement`, and where the caret lands:
76 * where it was, moved over what changed before it, or at the end of the replacement if it stood
77 * inside the tag.
78 */
79export function replaceTag(draft: string, cursor: number, tag: string, replacement: string): { text: string; cursor: number } {
80 let text = draft
81 let at = cursor
82 for (let start = text.lastIndexOf(tag); start >= 0; start = start === 0 ? -1 : text.lastIndexOf(tag, start - 1)) {
83 const end = start + tag.length
84 text = text.slice(0, start) + replacement + text.slice(end)
85 if (at >= end) at += replacement.length - tag.length
86 else if (at > start) at = start + replacement.length
87 }
88 return { text, cursor: Math.max(0, Math.min(at, text.length)) }
89}
90
91/**
92 * The draft with every occurrence of `tag` taken out, each with the space after it (or, at the end
93 * of a word, the one before it) so the words around it don't run together; and where the caret
94 * lands: where it was, moved back over what went before it, or to the tag's start if it stood inside.
95 */
96export function removeTag(draft: string, cursor: number, tag: string): { text: string; cursor: number } {
97 let text = draft
98 let at = cursor
99 for (let start = text.lastIndexOf(tag); start >= 0; start = text.lastIndexOf(tag, start - 1)) {
100 let end = start + tag.length
101 if (text[end] === ' ') end++
102 else if (text[start - 1] === ' ') start--
103 text = text.slice(0, start) + text.slice(end)
104 if (at >= end) at -= end - start
105 else if (at > start) at = start
106 if (start === 0) break
107 }
108 return { text, cursor: Math.max(0, Math.min(at, text.length)) }
109}
110
111/** The prompt with every `[Note #n (N lines)]` whose text is known put back in its place. */
112export function expandNotes(text: string, notes: Readonly<Record<string, string>>): string {
113 return text.replace(NOTE_TAG, (tag, n: string) => notes[n] ?? tag)
114}
115
116/**
117 * How tall the editor's field is: enough rows to show the whole text as it wraps at `columns`,
118 * one spare, within what the pane has (`bodyRows` less the rows the buttons and hint take) and
119 * never under three.
120 */
121export function editorRows(text: string, columns: number, bodyRows: number): number {
122 const width = Math.max(1, columns)
123 const wrapped = text.split('\n').reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / width)), 0)
124 const room = bodyRows - EDITOR_CHROME_ROWS
125 return Math.max(3, Math.min(wrapped + 1, room))
126}
127
128// The rows under the field: a gap, the buttons, a gap and the hint (two rows when it wraps).
129const EDITOR_CHROME_ROWS = 5
130hooks/png.ts 289 lines1// A small PNG decoder for terminals that can't draw pictures. A mod has no zlib, so this
2// carries its own inflate. It box-averages the picture down to a thumbnail as it unfilters,
3// so a screenshot never has to sit in memory as RGBA.
4
5export type Thumb = { width: number; height: number; rgb: Uint8Array }
6
7// The longest side of the stored thumbnail, in pixels: more than any tile can show.
8const THUMB_SIDE = 96
9// What transparent pixels are composited over (the dark of most terminal themes).
10const BACKDROP = 0x1e
11// Refuse a picture whose raw scanlines would be bigger than this (an Adam7-free 8k x 8k RGBA).
12const MAX_RAW_BYTES = 256 * 1024 * 1024
13
14const LENGTH_BASE = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258]
15const LENGTH_EXTRA = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0]
16const DISTANCE_BASE = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577]
17const DISTANCE_EXTRA = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13]
18const CODE_LENGTH_ORDER = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]
19
20type Huffman = { table: Uint32Array; bits: number }
21
22/** A lookup table indexed by the next `bits` input bits: (code length << 16) | symbol. */
23function huffman(lengths: ArrayLike<number>): Huffman {
24 let bits = 0
25 const count = new Array<number>(16).fill(0)
26 for (let i = 0; i < lengths.length; i++) {
27 count[lengths[i]!]!++
28 if (lengths[i]! > bits) bits = lengths[i]!
29 }
30 count[0] = 0
31 const next = new Array<number>(16).fill(0)
32 for (let len = 1, code = 0; len < 16; len++) {
33 code = (code + count[len - 1]!) << 1
34 next[len] = code
35 }
36 const size = 1 << Math.max(bits, 1)
37 const table = new Uint32Array(size)
38 for (let symbol = 0; symbol < lengths.length; symbol++) {
39 const len = lengths[symbol]!
40 if (len === 0) continue
41 let code = next[len]!++
42 let reversed = 0
43 for (let i = 0; i < len; i++, code >>= 1) reversed = (reversed << 1) | (code & 1)
44 for (let at = reversed; at < size; at += 1 << len) table[at] = (len << 16) | symbol
45 }
46 return { table, bits: Math.max(bits, 1) }
47}
48
49let fixedLiterals: Huffman | undefined
50let fixedDistances: Huffman | undefined
51
52/** Inflate a zlib stream into exactly `size` bytes; null when it is malformed or short. */
53export function inflate(input: Uint8Array, size: number): Uint8Array | null {
54 if (input.length < 2 || (input[0]! & 0x0f) !== 8) return null
55 const out = new Uint8Array(size)
56 let pos = 2
57 let written = 0
58 let buffer = 0
59 let held = 0
60
61 const need = (n: number) => {
62 while (held < n) {
63 buffer |= (pos < input.length ? input[pos]! : 0) << held
64 pos++
65 held += 8
66 }
67 }
68 const take = (n: number) => {
69 if (n === 0) return 0
70 need(n)
71 const value = buffer & ((1 << n) - 1)
72 buffer >>>= n
73 held -= n
74 return value
75 }
76 const decode = ({ table, bits }: Huffman) => {
77 need(bits)
78 const entry = table[buffer & ((1 << bits) - 1)]!
79 const len = entry >>> 16
80 if (len === 0) return -1
81 buffer >>>= len
82 held -= len
83 return entry & 0xffff
84 }
85
86 let last = false
87 while (!last) {
88 if (pos > input.length + 4) return null
89 last = take(1) === 1
90 const type = take(2)
91 if (type === 0) {
92 take(held & 7)
93 const len = take(16)
94 take(16)
95 for (let i = 0; i < len && written < size; i++) out[written++] = take(8)
96 continue
97 }
98 let literals: Huffman
99 let distances: Huffman
100 if (type === 1) {
101 if (!fixedLiterals || !fixedDistances) {
102 const lengths = new Uint8Array(288)
103 lengths.fill(8, 0, 144).fill(9, 144, 256).fill(7, 256, 280).fill(8, 280, 288)
104 fixedLiterals = huffman(lengths)
105 fixedDistances = huffman(new Uint8Array(30).fill(5))
106 }
107 literals = fixedLiterals
108 distances = fixedDistances
109 } else if (type === 2) {
110 const nLiterals = take(5) + 257
111 const nDistances = take(5) + 1
112 const nCodes = take(4) + 4
113 const codeLengths = new Uint8Array(19)
114 for (let i = 0; i < nCodes; i++) codeLengths[CODE_LENGTH_ORDER[i]!] = take(3)
115 const codes = huffman(codeLengths)
116 const lengths = new Uint8Array(nLiterals + nDistances)
117 for (let i = 0; i < lengths.length; ) {
118 const symbol = decode(codes)
119 if (symbol < 0) return null
120 if (symbol < 16) {
121 lengths[i++] = symbol
122 continue
123 }
124 const repeat = symbol === 16 ? 3 + take(2) : symbol === 17 ? 3 + take(3) : 11 + take(7)
125 const value = symbol === 16 ? lengths[i - 1]! : 0
126 if (symbol === 16 && i === 0) return null
127 for (let r = 0; r < repeat && i < lengths.length; r++) lengths[i++] = value
128 }
129 literals = huffman(lengths.subarray(0, nLiterals))
130 distances = huffman(lengths.subarray(nLiterals))
131 } else {
132 return null
133 }
134
135 for (;;) {
136 const symbol = decode(literals)
137 if (symbol < 0) return null
138 if (symbol < 256) {
139 if (written >= size) return null
140 out[written++] = symbol
141 continue
142 }
143 if (symbol === 256) break
144 const lengthIndex = symbol - 257
145 if (lengthIndex >= 29) return null
146 const length = LENGTH_BASE[lengthIndex]! + take(LENGTH_EXTRA[lengthIndex]!)
147 const distanceSymbol = decode(distances)
148 if (distanceSymbol < 0 || distanceSymbol >= 30) return null
149 const distance = DISTANCE_BASE[distanceSymbol]! + take(DISTANCE_EXTRA[distanceSymbol]!)
150 if (distance > written || written + length > size) return null
151 for (let i = 0; i < length; i++, written++) out[written] = out[written - distance]!
152 }
153 }
154 return written === size ? out : null
155}
156
157const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
158// Samples per pixel by PNG color type: gray, -, RGB, palette, gray + alpha, -, RGBA.
159const CHANNELS = [1, 0, 3, 1, 2, 0, 4]
160
161/**
162 * Decode a PNG into a small RGB thumbnail (longest side at most THUMB_SIDE), or null when it
163 * isn't a PNG, is interlaced, or is malformed. Transparent pixels are composited over a dark backdrop.
164 */
165export function decodeThumb(png: Uint8Array): Thumb | null {
166 if (png.length < 33 || SIGNATURE.some((byte, i) => png[i] !== byte)) return null
167 const view = new DataView(png.buffer, png.byteOffset, png.byteLength)
168
169 let width = 0
170 let height = 0
171 let depth = 0
172 let colorType = 0
173 let palette = new Uint8Array(0)
174 let paletteAlpha = new Uint8Array(0)
175 const data: Uint8Array[] = []
176 for (let at = 8; at + 8 <= png.length; ) {
177 const length = view.getUint32(at)
178 const name = String.fromCharCode(png[at + 4]!, png[at + 5]!, png[at + 6]!, png[at + 7]!)
179 const body = png.subarray(at + 8, at + 8 + length)
180 if (body.length < length) return null
181 if (name === 'IHDR' && length >= 13) {
182 width = view.getUint32(at + 8)
183 height = view.getUint32(at + 12)
184 depth = body[8]!
185 colorType = body[9]!
186 if (body[10] !== 0 || body[11] !== 0 || body[12] !== 0) return null // not deflate, or interlaced
187 } else if (name === 'PLTE') palette = body
188 else if (name === 'tRNS') paletteAlpha = body
189 else if (name === 'IDAT') data.push(body)
190 else if (name === 'IEND') break
191 at += 12 + length
192 }
193 const channels = CHANNELS[colorType] ?? 0
194 if (width === 0 || height === 0 || channels === 0 || ![1, 2, 4, 8, 16].includes(depth)) return null
195 if (colorType === 3 && (palette.length === 0 || depth === 16)) return null
196
197 const bitsPerPixel = channels * depth
198 const stride = Math.ceil((width * bitsPerPixel) / 8)
199 const step = Math.max(1, bitsPerPixel >> 3)
200 if ((stride + 1) * height > MAX_RAW_BYTES) return null
201
202 const joined = new Uint8Array(data.reduce((sum, chunk) => sum + chunk.length, 0))
203 for (let i = 0, offset = 0; i < data.length; offset += data[i]!.length, i++) joined.set(data[i]!, offset)
204 const raw = inflate(joined, (stride + 1) * height)
205 if (!raw) return null
206
207 const scale = Math.min(1, THUMB_SIDE / Math.max(width, height))
208 const tw = Math.max(1, Math.round(width * scale))
209 const th = Math.max(1, Math.round(height * scale))
210 const sums = new Float64Array(tw * th * 3)
211 const counts = new Uint32Array(tw * th)
212 const columnOf = new Uint16Array(width)
213 for (let x = 0; x < width; x++) columnOf[x] = Math.min(tw - 1, Math.floor((x * tw) / width))
214
215 const max = (1 << Math.min(depth, 8)) - 1
216 const unit = depth === 16 ? 1 : 255 / max
217 let prior = new Uint8Array(stride)
218 let line = new Uint8Array(stride)
219 const sample = (x: number, channel: number) => {
220 if (depth === 8) return line[x * channels + channel]!
221 if (depth === 16) return line[(x * channels + channel) * 2]!
222 const bit = (x * channels + channel) * depth
223 return (line[bit >> 3]! >> (8 - depth - (bit & 7))) & max
224 }
225
226 for (let y = 0; y < height; y++) {
227 const filter = raw[y * (stride + 1)]!
228 const source = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1))
229 for (let i = 0; i < stride; i++) {
230 const left = i >= step ? line[i - step]! : 0
231 const up = prior[i]!
232 const upLeft = i >= step ? prior[i - step]! : 0
233 let predicted = 0
234 if (filter === 1) predicted = left
235 else if (filter === 2) predicted = up
236 else if (filter === 3) predicted = (left + up) >> 1
237 else if (filter === 4) {
238 const p = left + up - upLeft
239 const pa = Math.abs(p - left)
240 const pb = Math.abs(p - up)
241 const pc = Math.abs(p - upLeft)
242 predicted = pa <= pb && pa <= pc ? left : pb <= pc ? up : upLeft
243 } else if (filter !== 0) return null
244 line[i] = (source[i]! + predicted) & 0xff
245 }
246
247 const row = Math.min(th - 1, Math.floor((y * th) / height))
248 for (let x = 0; x < width; x++) {
249 let r: number
250 let g: number
251 let b: number
252 let a = 255
253 if (colorType === 2 || colorType === 6) {
254 r = sample(x, 0)
255 g = sample(x, 1)
256 b = sample(x, 2)
257 if (colorType === 6) a = sample(x, 3)
258 } else if (colorType === 3) {
259 const index = sample(x, 0)
260 r = palette[index * 3] ?? 0
261 g = palette[index * 3 + 1] ?? 0
262 b = palette[index * 3 + 2] ?? 0
263 a = paletteAlpha[index] ?? 255
264 } else {
265 r = g = b = Math.round(sample(x, 0) * unit)
266 if (colorType === 4) a = sample(x, 1)
267 }
268 if (a < 255) {
269 r = (r * a + BACKDROP * (255 - a)) / 255
270 g = (g * a + BACKDROP * (255 - a)) / 255
271 b = (b * a + BACKDROP * (255 - a)) / 255
272 }
273 const cell = row * tw + columnOf[x]!
274 sums[cell * 3] += r
275 sums[cell * 3 + 1] += g
276 sums[cell * 3 + 2] += b
277 counts[cell]!++
278 }
279 ;[prior, line] = [line, prior]
280 }
281
282 const rgb = new Uint8Array(tw * th * 3)
283 for (let cell = 0; cell < tw * th; cell++) {
284 const n = counts[cell] || 1
285 for (let c = 0; c < 3; c++) rgb[cell * 3 + c] = Math.round(sums[cell * 3 + c]! / n)
286 }
287 return { width: tw, height: th, rgb }
288}
289types/index.d.ts 32 lines1export type PastedImage = {
2 n: number
3 /** Absolute path of the PNG to draw (the cached paste or its converted copy); null when it can't be found. */
4 path: string | null
5 /** Pixel size; null when unknown, and the tile falls back to a default shape. */
6 size: { width: number; height: number } | null
7}
8
9export type PastedText = {
10 n: number
11 /** `paste`: Claude Code's own `[Pasted text #n +N lines]`; `note`: the mod's `[Note #n (N lines)]`. */
12 kind: 'paste' | 'note'
13 lines: number
14 /** The tag as the draft spells it, `[Pasted text #3 +2 lines]` or `[Note #3 (3 lines)]`. */
15 tag: string
16 /** The first line with something on it; null when the text wasn't seen (pasted before the mod loaded). */
17 preview: string | null
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 'paste-view': {
23 images: PastedImage[]
24 texts: PastedText[]
25 /** The text behind each `[Note #n (N lines)]` of the draft, by `n`; put back when the prompt is sent. */
26 notes: Record<string, string>
27 /** Bumped when the editor's text changes from outside (a save in VS Code), so the pane redraws. */
28 editor: number
29 }
30 }
31}
32