SLOPSHOPPER

paste-view

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

newpanebandtoastpromptprocess
v0.3.1MITupdated 2026-10-26Snouzy/claude-paste-view
A shopper browsing a rack in a slop shop
README

Claude Paste View

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.

License

Claude Paste View in action

Install

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.

What You See

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]
  • Thumbnails appear as soon as you paste. You don't have to type another key first.
  • Thumbnails keep their shape. Wide screenshots stay wide and phone shots stay tall.
  • Always fits on screen. Tiles shrink to fit the space above the prompt, so the row never scrolls or gets cut off. If there are too many, the row ends with +N for the rest.
  • Clears on send. Once the prompt is sent (or the tags are deleted), the row goes away.
  • Each tile has controls. ✎ 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.

Pasted text

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:

  • Save puts the edited text into the prompt in the open, in place of the tag.
  • Fold leaves the mod's own [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.
  • Cancel (or Escape) leaves the prompt as it was.
  • VS Code writes the text to a temporary file and opens it there (the 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.

Paste as is

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"

How It Works

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:

  1. Every 200ms it reads the prompt box and looks for [Image #n] tags. It checks on a timer because pasting an image doesn't raise an edit event.
  2. For each tag it finds the cached file. Kitty graphics only draws PNG from a file, so on macOS anything else (WebP, JPEG, HEIC, GIF, TIFF) is converted once with sips to <session>/paste-view-<n>.png. It reads the size from the PNG header.
  3. It draws the thumbnails in the band above the prompt. Where Claude Code draws pictures (see Terminal Support) it uses Claude Code's Image element, and the terminal reads the file itself, so the image data never passes through the mod.
  4. Anywhere else it decodes the PNG itself, averages it down to a small thumbnail, and draws it in colored half blocks (▀) with Claude Code's Raster element. It's lower resolution than a real picture, but it's the picture.

Terminal Support

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.

TerminalThumbnail
Ghostty, kittyReal picture
iTerm2, Terminal.app, WezTerm, VS Code, Windows Terminal, Orca, most othersHalf blocks
Inside tmux or screen, even in Ghostty or kittyHalf blocks
Agent view and background sessionsHalf 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).

Security

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.

Requirements

  • Claude Code v2.1.287 or later (mods support)
  • macOS or Linux. Windows finds pastes too, but it's less tested and only previews PNG pastes.
  • For full-resolution thumbnails, Ghostty or kitty. Any other terminal gets half-block thumbnails (best with truecolor).

The Claude Desktop app already previews pasted images, so the mod draws nothing there.

Troubleshooting

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" }

Development

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.

License

MIT. See LICENSE.

Star History

Star History Chart

Source 5 files
hooks/register.tsx 475 lines
1import { 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}
475
hooks/layout.ts 168 lines
1import { 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}
168
hooks/pastes.ts 130 lines
1// 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
130
hooks/png.ts 289 lines
1// 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}
289
types/index.d.ts 32 lines
1export 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