SLOPSHOPPER

paste-view

See what you paste into Claude Code: image thumbnails and long-text previews above the prompt instead of bare [Image #1] and [Pasted text #2] tags

newpanebandtoastprocesstimer
★ 18v0.1.0MITupdated 2026-10-09Amorfx/claude-paste-view
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · paste-view
│ ┃ paste-view ✕ › fix the failing auth test and add an audit log call │ ┃ This paste is no longer in the prompt. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · paste-view
This paste is no longer in the prompt.
README

Claude Paste View

A Claude Code mod that shows what you paste, so you see image thumbnails and a preview of long text above your prompt instead of bare [Image #1] and [Pasted text #2 +41 lines] tags.

A pasted screenshot shown as a thumbnail and a pasted text as a one-line preview, above the prompt

Install

Inside Claude Code, run:

/plugin marketplace add Amorfx/claude-paste-view
/plugin install paste-view
/reload-plugins
claude plugin marketplace add Amorfx/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

╭────────────────────────╮
│      (screenshot)      │
│           #1           │
╰────────────────────────╯
1: #2 · 42 lines · 3.1k chars — Traceback (most recent call last):
click a paste, or ctrl+x tab then its number, to read it whole
❯ why does this fail [Image #1] [Pasted text #2 +41 lines]
  • Images show as thumbnails that keep their shape and shrink to fit the space above the prompt. In a terminal that can't draw pictures (Terminal.app, …), or that Claude Code doesn't recognise as one (iTerm2, see Limitations), each image is a line instead, #1 · image 1630×632 — open, that opens it in your system's image viewer. A pasted JPEG, WebP or other non-PNG image is always a line, #1 · image · jpg — open, since the terminal draws only PNG files.
  • Long text shows as one line: its line count, its size and its first line.
  • See a paste whole by clicking its line, or with ctrl+x tab then its number. Text opens in a pane (↑↓ to scroll, Esc to close); an image opens in your system's viewer. Your draft is left as it is.
  • Clears on send. Once the prompt is sent, or a tag is deleted, its preview goes away.

A pasted text opened whole in a pane, with its line and character counts

How It Works

  1. Every 200ms the mod reads the prompt box and looks for [Image #n] and [Pasted text #n …] tags. It polls because pasting raises no edit event.
  2. Images: Claude Code caches each pasted image as <tmp>/<project>/<session>/images/<n>.png, or <n>.jpg, <n>.webp, … when it was pasted in another format. The mod finds that file and draws a PNG with Claude Code's Image element.
  3. Text: Claude Code keeps a collapsed paste to itself until the prompt is sent, and writes no file for it before then. So when a new text tag appears, the mod reads the clipboard once (pbpaste on macOS, wl-paste or xclip on Linux). It keeps that text only if its line count matches the count in the tag, and holds it in memory until the tag leaves the prompt.

Limitations

  • Text previews come from the clipboard. If the clipboard changes within ~200ms of pasting, or the paste didn't come from the clipboard (a tmux buffer, a remote session over SSH), the line reads no preview rather than showing the wrong text.
  • A draft restored from history with several text tags shows no text preview, since one clipboard can't stand for several pastes.
  • No thumbnails inside tmux or screen, or in a background session (agent view), even in Ghostty or kitty: Claude Code turns terminal pictures off there, and the mod lists images as lines instead. If your terminal speaks the kitty graphics protocol through them, add "env": { "CLAUDE_CODE_FORCE_TERMINAL_IMAGES": "1" } to ~/.claude/settings.json and start a new session.
  • iTerm2 isn't recognised as a terminal that draws pictures, so images are listed as lines, although iTerm2 answers Claude Code's graphics probe. With CLAUDE_CODE_FORCE_TERMINAL_IMAGES set, thumbnails draw in iTerm2 (seen with 3.7.3). Set it for iTerm2 only, since a terminal that can't draw pictures would then show neither a thumbnail nor the open line: add [ "$TERM_PROGRAM" = "iTerm.app" ] && export CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 to your shell profile (~/.zshrc, …), then start a new session.
  • The tag formats and the image cache path are Claude Code internals. A future version may change them; please open an issue if previews stop appearing.

Security

Everything stays on your machine: the mod never touches the network and never writes to disk. It reads the prompt box, lists Claude Code's temp folder to find the session's image cache and the pasted images in it, reads the first bytes of each pasted image, and reads the clipboard once per text paste. It reads TERM, TERM_PROGRAM, KITTY_WINDOW_ID, TMUX, STY, CLAUDE_CODE_SESSION_KIND and CLAUDE_CODE_FORCE_TERMINAL_IMAGES to know whether the terminal draws pictures. It runs id -u once if CLAUDE_CODE_TMPDIR isn't set, the clipboard tool for each text paste, and uname then open or xdg-open when you ask to open an image. Pasted text is kept in memory only, and dropped once its tag leaves the prompt.

Run claude plugin validate .claude-plugin/plugin.json 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 with wl-clipboard or xclip for text previews
  • For image thumbnails, a terminal with the kitty graphics protocol, such as Ghostty or kitty, or iTerm2 with CLAUDE_CODE_FORCE_TERMINAL_IMAGES set (see Limitations). The mod recognises them from TERM, TERM_PROGRAM and KITTY_WINDOW_ID, or trusts CLAUDE_CODE_FORCE_TERMINAL_IMAGES when it is set; in any other terminal, or inside tmux, it lists images as lines that open them with open (macOS) or xdg-open (Linux).

Development

git clone https://github.com/Amorfx/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/plugin.json
claude plugin test .

Once Claude Code has loaded the mod, it generates the API typings in .claude-plugin/types/ along with a tsconfig.json; after that, tsc -p . type-checks the code.

License

MIT. See LICENSE.

Source 4 files
hooks/register.tsx 296 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PastedImage, PastedText } from '../types'
5import { charCount, draftTags, firstLine, lineBreaks, matchesTag } from './tags'
6import type { TextTag } from './tags'
7import { drawsImages, pngDimensions, thumbnailBoxes } from './thumbnails'
8import type { Dimensions } from './thumbnails'
9
10const PANE = 'paste-view'
11// A paste raises no prompt.edit, and a collapsed text paste reaches no hook before it is
12// sent: the draft is watched on a timer, and a new text tag is matched against the
13// clipboard the moment it appears.
14const POLL_MS = 200
15
16const images = atom({ plugin: 'paste-view', key: 'images' } as const, [] as PastedImage[])
17const texts = atom({ plugin: 'paste-view', key: 'texts' } as const, [] as PastedText[])
18const viewing = atom({ plugin: 'paste-view', key: 'viewing' } as const, null as number | null)
19
20// Tried in order on first use; the first that runs is kept.
21const CLIPBOARD_COMMANDS: readonly (readonly string[])[] = [
22  ['pbpaste'],
23  ['wl-paste', '--no-newline'],
24  ['xclip', '-selection', 'clipboard', '-o'],
25]
26
27let cwd = ''
28let hasGraphics = false
29let clipboardCommand: readonly string[] | null | undefined
30let viewerCommand: string | undefined
31let imagesDir: { session: string; path: string } | undefined
32const dimensions = new Map<string, Dimensions | null>()
33// Pasted texts by tag number, each read once, when its tag first shows up.
34const pastes = new Map<number, string | null>()
35// What the draft held at the last refresh; undefined forces the next one to redo the work.
36let lastSignature: string | undefined
37// What was last written to state, so a refresh that finds the same pastes doesn't redraw.
38let written: string | undefined
39let isRefreshing = false
40
41async function tmpRoot($: EngineInterface): Promise<string> {
42  const configured = await $.env.get('CLAUDE_CODE_TMPDIR')
43  if (configured !== undefined) return configured
44  const { stdout } = await $.process.run(['id', '-u'])
45  return `/tmp/claude-${stdout.trim()}`
46}
47
48// Claude Code keeps a session's pasted images in <tmp>/<project>/<session>/images/<n>.<ext>,
49// <project> being the working directory with every character but letters and digits
50// turned into '-'. That folder is tried first; the session id alone finds it otherwise.
51async function findImagesDir($: EngineInterface): Promise<string | undefined> {
52  const session = await $.session.id()
53  if (imagesDir?.session === session) return imagesDir.path
54  const root = await tmpRoot($)
55  const projects = await $.fs.list(root).catch(() => [])
56  const candidates = [
57    cwd.replace(/[^a-zA-Z0-9]/g, '-'),
58    ...projects.filter(entry => entry.kind === 'dir').map(entry => entry.name),
59  ]
60  for (const project of candidates) {
61    const path = `${root}/${project}/${session}/images`
62    if (await $.fs.exists(path)) {
63      imagesDir = { session, path }
64      return path
65    }
66  }
67  return undefined
68}
69
70// An image is cached in the format it was pasted in: <n>.png for a screenshot, but
71// <n>.jpg or <n>.webp for others.
72async function findImage($: EngineInterface, dir: string, n: number): Promise<string | undefined> {
73  const png = `${dir}/${n}.png`
74  if (await $.fs.exists(png)) return png
75  const entries = await $.fs.list(dir).catch(() => [])
76  const other = entries.find(entry => entry.kind === 'file' && entry.name.startsWith(`${n}.`))
77  return other === undefined ? undefined : `${dir}/${other.name}`
78}
79
80const isPng = (path: string) => path.endsWith('.png')
81
82async function describeImage($: EngineInterface, dir: string | undefined, n: number): Promise<PastedImage> {
83  const path = dir === undefined ? undefined : await findImage($, dir, n)
84  if (path === undefined) return { n, path: null, size: null }
85  if (!isPng(path)) return { n, path, size: null }
86  if (!dimensions.has(path)) {
87    // A file past the read cap is still drawn, in a default shape.
88    const head = await $.fs.read(path, { as: 'bytes' }).catch(() => undefined)
89    dimensions.set(path, head === undefined ? null : pngDimensions(head.base64))
90  }
91  return { n, path, size: dimensions.get(path) ?? null }
92}
93
94async function readClipboard($: EngineInterface): Promise<string | null> {
95  if (clipboardCommand === null) return null
96  for (const argv of clipboardCommand === undefined ? CLIPBOARD_COMMANDS : [clipboardCommand]) {
97    // pbpaste decodes by the locale, which a bare child process may lack.
98    const ran = await $.process.run(argv, { env: { LANG: 'en_US.UTF-8' }, timeoutMs: 1000 }).catch(() => undefined)
99    if (ran?.exitCode === 0 && !ran.isStdoutTruncated) {
100      clipboardCommand = argv
101      return ran.stdout
102    }
103  }
104  clipboardCommand ??= null
105  return null
106}
107
108async function capturePastes($: EngineInterface, tags: readonly TextTag[]) {
109  for (const n of pastes.keys()) if (!tags.some(tag => tag.n === n)) pastes.delete(n)
110  const fresh = tags.filter(tag => !pastes.has(tag.n))
111  if (fresh.length === 0) return
112  // One clipboard can only stand for one paste: tags arriving together (a draft brought
113  // back from history) are left without a preview rather than guessed.
114  const clipboard = fresh.length === 1 ? await readClipboard($) : null
115  for (const tag of fresh) pastes.set(tag.n, clipboard !== null && matchesTag(clipboard, tag.lines) ? clipboard : null)
116}
117
118async function refresh($: EngineInterface, draft: string) {
119  const tags = draftTags(draft)
120  await capturePastes($, tags.texts)
121
122  const signature = JSON.stringify([tags.images, tags.texts.map(tag => tag.n)])
123  if (signature === lastSignature) return
124
125  const dir = tags.images.length > 0 ? await findImagesDir($) : undefined
126  const imageList = await Promise.all(tags.images.map(n => describeImage($, dir, n)))
127  // An image whose file hasn't landed yet is looked for again on the next poll.
128  lastSignature = imageList.some(image => image.path === null) ? undefined : signature
129
130  const textList = tags.texts.map(tag => ({ ...tag, text: pastes.get(tag.n) ?? null }))
131  // Each write redraws the band: an image still missing must not redraw it on every poll.
132  const json = JSON.stringify([imageList, textList])
133  if (json !== written) {
134    await update($, images, () => imageList)
135    await update($, texts, () => textList)
136    written = json
137  }
138
139  const shown = await read($, viewing)
140  if (shown !== null && !tags.texts.some(tag => tag.n === shown)) await closePane($)
141}
142
143async function poll($: EngineInterface) {
144  if (isRefreshing) return
145  isRefreshing = true
146  try {
147    await refresh($, (await $.prompt.read()).text)
148  } finally {
149    isRefreshing = false
150  }
151}
152
153// Without pictures in the terminal, an image opens in the system's own viewer.
154async function openImage($: EngineInterface, path: string) {
155  viewerCommand ??= (await $.process.run(['uname'])).stdout.trim() === 'Darwin' ? 'open' : 'xdg-open'
156  const ran = await $.process.run([viewerCommand, path], { timeoutMs: 5000 }).catch(() => undefined)
157  if (ran?.exitCode !== 0) $.ui.toast(`paste-view: couldn't open the image with ${viewerCommand}`)
158}
159
160async function openPane($: EngineInterface, paste: PastedText) {
161  await update($, viewing, () => paste.n)
162  await $.ui.open({ id: PANE, title: `Pasted text #${paste.n}`, focus: true, closeOnEscape: true })
163}
164
165async function closePane($: EngineInterface) {
166  await update($, viewing, () => null)
167  if ((await $.ui.panes()).some(pane => pane.id === PANE)) await $.ui.close({ id: PANE })
168}
169
170/** Lines a paste spans on screen, a trailing newline not counted. */
171const shownLines = (paste: PastedText) =>
172  paste.text === null ? (paste.lines ?? 0) + 1 : lineBreaks(paste.text.trimEnd()) + 1
173
174const imageLabel = (image: PastedImage, path: string) => {
175  if (image.size !== null) return `#${image.n} · image ${image.size.width}×${image.size.height}`
176  return isPng(path) ? `#${image.n} · image` : `#${image.n} · image · ${path.slice(path.lastIndexOf('.') + 1)}`
177}
178
179export const register: Register = on => {
180  on('session.start', async ($, e, next) => {
181    cwd = e.cwd
182    hasGraphics = drawsImages({
183      term: await $.env.get('TERM'),
184      termProgram: await $.env.get('TERM_PROGRAM'),
185      kittyWindowId: await $.env.get('KITTY_WINDOW_ID'),
186      forceImages: await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES'),
187      sessionKind: await $.env.get('CLAUDE_CODE_SESSION_KIND'),
188      multiplexer: (await $.env.get('TMUX')) ?? (await $.env.get('STY')),
189    })
190    // A reload keeps the state: the texts already read stay matched to their tags.
191    for (const paste of await read($, texts)) pastes.set(paste.n, paste.text)
192    $.clock.every(POLL_MS, () => poll($))
193    return next(e)
194  })
195
196  on('ui.close', { id: PANE }, async ($, e, next) => {
197    await update($, viewing, () => null)
198    return next(e)
199  })
200
201  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
202    if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
203    const imageList = await read($, images)
204    const textList = await read($, texts)
205    if (imageList.length === 0 && textList.length === 0) return next(e)
206
207    const { Box, Button, Image, Text } = $.ui.resolve(e)
208    const width = e.props.bodyColumns
209    // The Image element draws a PNG file only: any other format is a line that opens it.
210    const pictures = hasGraphics ? imageList.filter(image => image.path !== null && isPng(image.path)) : []
211    const imageLines = imageList.filter(image => !pictures.includes(image))
212    const openable = imageLines.filter(image => image.path !== null)
213    // One row per line below the thumbnails, plus the hint; the thumbnails get the rest.
214    const lineRows = imageLines.length + textList.length + 1
215    const boxes = thumbnailBoxes(pictures.map(image => image.size), e.props.maxRows - lineRows, width)
216    // Number keys run down the lines that open something, images first.
217    const hotkey = (i: number) => (i >= 0 && i < 9 ? { hotkey: String(i + 1) } : {})
218    const below = await next(e)
219
220    return (
221      <Box flexDirection="column">
222        {pictures.length > 0 && (
223          <Box flexDirection="row" columnGap={1} alignItems="flex-end">
224            {pictures.map((image, i) => (
225              <Box flexDirection="column" alignItems="center">
226                <Box borderStyle="round" borderDimColor>
227                  <Image
228                    key={`picture-${image.n}`}
229                    source={{ file: image.path ?? '', format: 'png' }}
230                    columns={boxes[i]?.columns ?? 4}
231                    rows={boxes[i]?.rows ?? 1}
232                    alt={`[Image #${image.n}]`}
233                  />
234                </Box>
235                <Text dimColor>#{image.n}</Text>
236              </Box>
237            ))}
238          </Box>
239        )}
240        {imageLines.map(image => {
241          const path = image.path
242          if (path === null) {
243            return <Text dimColor wrap="truncate">{`#${image.n} · image · no preview`}</Text>
244          }
245          return (
246            <Button
247              key={`image-${image.n}`}
248              plain
249              {...hotkey(openable.indexOf(image))}
250              label={`${imageLabel(image, path)} — open`}
251              onPress={() => openImage($, path)}
252            />
253          )
254        })}
255        {textList.map((paste, i) => {
256          const head = `#${paste.n} · ${shownLines(paste)} lines`
257          if (paste.text === null) {
258            return <Text dimColor wrap="truncate">{`${head} · no preview (clipboard changed)`}</Text>
259          }
260          const label = `${head} · ${charCount(paste.text.length)} — `
261          // The hotkey prefix (`1: `) takes three cells.
262          return (
263            <Button
264              key={`text-${paste.n}`}
265              plain
266              {...hotkey(openable.length + i)}
267              label={`${label}${firstLine(paste.text, width - label.length - 3)}`}
268              onPress={() => openPane($, paste)}
269            />
270          )
271        })}
272        {(openable.length > 0 || textList.some(paste => paste.text !== null)) && (
273          <Text dimColor wrap="truncate">click a line, or ctrl+x tab then its number, to see it whole</Text>
274        )}
275        {below}
276      </Box>
277    )
278  })
279
280  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
281    const { Box, Text } = $.ui.resolve(e)
282    const shown = await read($, viewing)
283    const paste = (await read($, texts)).find(one => one.n === shown)
284    if (paste?.text == null) return <Text dimColor>This paste is no longer in the prompt.</Text>
285
286    return (
287      <Box flexDirection="column">
288        <Text dimColor wrap="truncate">
289          {`${shownLines(paste)} lines · ${charCount(paste.text.length)} · ↑↓ scroll · esc close`}
290        </Text>
291        <Text>{paste.text}</Text>
292      </Box>
293    )
294  })
295}
296
hooks/tags.ts 48 lines
1export type TextTag = { n: number; lines: number | null }
2export type DraftTags = { images: number[]; texts: TextTag[] }
3
4// `[Image #3]`, `[Pasted text #2 +9 lines]`, or `[Pasted text #2]` for one long line.
5const TAG = /\[(?:Image #(\d+)|Pasted text #(\d+)(?: \+(\d+) lines?)?)\]/g
6
7/** The image and pasted-text tags a draft holds, each once, in the order they first appear. */
8export function draftTags(draft: string): DraftTags {
9  const images: number[] = []
10  const texts: TextTag[] = []
11  for (const [, image, text, lines] of draft.matchAll(TAG)) {
12    if (image !== undefined && !images.includes(Number(image))) images.push(Number(image))
13    if (text !== undefined && !texts.some(tag => tag.n === Number(text))) {
14      texts.push({ n: Number(text), lines: lines === undefined ? null : Number(lines) })
15    }
16  }
17  return { images, texts }
18}
19
20/** Line breaks in a text, counted the way Claude Code counts them for a paste's tag. */
21export function lineBreaks(text: string): number {
22  return text.match(/\r\n|\r|\n/g)?.length ?? 0
23}
24
25/**
26 * Whether a clipboard text is the paste a tag stands for: same line count as the tag
27 * announces, allowing for a trailing newline the terminal may have dropped.
28 */
29export function matchesTag(text: string, lines: number | null): boolean {
30  if (text.trim() === '') return false
31  const expected = lines ?? 0
32  return lineBreaks(text) === expected || lineBreaks(text.trimEnd()) === expected
33}
34
35/** The first non-blank line, whitespace collapsed, cut to `width` with an ellipsis. */
36export function firstLine(text: string, width: number): string {
37  const line = text.split(/\r\n|\r|\n/).find(l => l.trim() !== '')?.replace(/\s+/g, ' ').trim() ?? ''
38  if (width < 1) return ''
39  return line.length <= width ? line : `${line.slice(0, Math.max(0, width - 1))}…`
40}
41
42/** A short character count: `840 chars`, `2.3k chars`, `1.2M chars`. */
43export function charCount(length: number): string {
44  if (length < 1000) return `${length} chars`
45  if (length < 1_000_000) return `${(length / 1000).toFixed(1).replace(/\.0$/, '')}k chars`
46  return `${(length / 1_000_000).toFixed(1).replace(/\.0$/, '')}M chars`
47}
48
hooks/thumbnails.ts 93 lines
1export type Dimensions = { width: number; height: number }
2export type Box = { columns: number; rows: number }
3
4// A thumbnail is at most this many rows tall and columns wide, and never narrower than MIN.
5const MAX_ROWS = 6
6const MAX_COLUMNS = 32
7const MIN_COLUMNS = 4
8// Terminal cells are roughly twice as tall as they are wide.
9const CELL_RATIO = 2
10// Each thumbnail's frame takes a cell on each side, a border row above and below, plus
11// its label row; neighbours sit one column apart.
12const FRAME_COLUMNS = 2
13const FRAME_ROWS = 3
14const GAP = 1
15// The shape assumed when an image's size is unknown.
16const UNKNOWN: Dimensions = { width: 3, height: 2 }
17
18const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47]
19const IHDR = [0x49, 0x48, 0x44, 0x52]
20
21/**
22 * Width and height from the start of a PNG file (base64), read from its IHDR chunk;
23 * null when the bytes aren't a PNG.
24 */
25export function pngDimensions(base64: string): Dimensions | null {
26  // Bytes 0-7 are the signature, 12-15 the IHDR tag, 16-23 width and height (big-endian).
27  const raw = atob(base64.slice(0, 32))
28  if (raw.length < 24) return null
29  const byte = (i: number) => raw.charCodeAt(i)
30  if (PNG_MAGIC.some((b, i) => byte(i) !== b) || IHDR.some((b, i) => byte(12 + i) !== b)) return null
31  const uint32 = (at: number) => ((byte(at) << 24) | (byte(at + 1) << 16) | (byte(at + 2) << 8) | byte(at + 3)) >>> 0
32  const width = uint32(16)
33  const height = uint32(20)
34  return width > 0 && height > 0 ? { width, height } : null
35}
36
37function boxAt(rows: number, size: Dimensions | null): Box {
38  const { width, height } = size ?? UNKNOWN
39  const columns = Math.round((rows * CELL_RATIO * width) / height)
40  if (columns <= MAX_COLUMNS) return { columns: Math.max(MIN_COLUMNS, columns), rows }
41  // Too wide: keep the column cap and give back the rows the picture no longer needs.
42  return { columns: MAX_COLUMNS, rows: Math.max(1, Math.round((MAX_COLUMNS * height) / (CELL_RATIO * width))) }
43}
44
45const rowWidth = (boxes: readonly Box[]) =>
46  boxes.reduce((sum, box) => sum + box.columns + FRAME_COLUMNS, 0) + GAP * Math.max(0, boxes.length - 1)
47
48/**
49 * Picture boxes for a single row of thumbnails, each keeping its image's proportions,
50 * as tall as `maxRows` allows and scaled down until the row fits in `columns`.
51 */
52export function thumbnailBoxes(sizes: readonly (Dimensions | null)[], maxRows: number, columns: number): Box[] {
53  let rows = Math.min(MAX_ROWS, Math.max(1, maxRows - FRAME_ROWS))
54  let boxes = sizes.map(size => boxAt(rows, size))
55  while (rows > 1 && rowWidth(boxes) > columns) {
56    // Jump straight to the height the overflow suggests, then settle by single rows.
57    const scaled = Math.floor((rows * columns) / rowWidth(boxes))
58    rows = Math.max(1, Math.min(rows - 1, scaled))
59    boxes = sizes.map(size => boxAt(rows, size))
60  }
61  return boxes
62}
63
64export type TerminalEnv = {
65  term?: string
66  termProgram?: string
67  kittyWindowId?: string
68  /** CLAUDE_CODE_FORCE_TERMINAL_IMAGES */
69  forceImages?: string
70  /** CLAUDE_CODE_SESSION_KIND */
71  sessionKind?: string
72  /** TMUX or STY, set inside tmux or screen */
73  multiplexer?: string
74}
75
76/**
77 * Whether the terminal draws Claude Code's `Image` element, which needs the kitty
78 * graphics protocol (kitty, Ghostty); elsewhere it only draws the `alt` text. Claude Code
79 * itself turns pictures off in background sessions and inside tmux or screen, unless
80 * CLAUDE_CODE_FORCE_TERMINAL_IMAGES is set.
81 */
82export function drawsImages(env: TerminalEnv): boolean {
83  if (env.forceImages) return true
84  if (env.sessionKind === 'bg' || env.multiplexer) return false
85  const term = env.term?.toLowerCase() ?? ''
86  return (
87    env.kittyWindowId !== undefined ||
88    env.termProgram?.toLowerCase() === 'ghostty' ||
89    term.includes('kitty') ||
90    term.includes('ghostty')
91  )
92}
93
types/index.d.ts 27 lines
1export type PastedImage = {
2  n: number
3  /** Where Claude Code cached the image; null until its file is found. */
4  path: string | null
5  /** Its size in pixels, from the PNG header; null when that couldn't be read. */
6  size: { width: number; height: number } | null
7}
8
9export type PastedText = {
10  n: number
11  /** The line count the tag announces (`+9 lines`); null when the tag gives none. */
12  lines: number | null
13  /** The pasted text, read from the clipboard; null when it couldn't be confirmed. */
14  text: string | null
15}
16
17declare module 'claude-code' {
18  interface PluginState {
19    'paste-view': {
20      images: PastedImage[]
21      texts: PastedText[]
22      /** The pasted text shown in the pane, by tag number; null while it is closed. */
23      viewing: number | null
24    }
25  }
26}
27