SLOPSHOPPER

lenadweb-image-preview

Previews the image in the clipboard and the images pasted into the prompt, drawn above the prompt in any terminal

newbandpromptprocesstimer
A shopper browsing a rack in a slop shop
README

lenadweb-image-preview

See the image you are about to paste into Claude Code before you paste it. When the clipboard holds an image, lenadweb-image-preview draws a preview above the prompt with its size and format. After you paste, every [Image #N] in your draft gets a thumbnail until you send the prompt, so you always know which image is which.

A preview of the clipboard image above the Claude Code prompt, with its size, format and Open and Hide buttons

Features

  • A preview of the clipboard image, sized to the terminal window.
  • Thumbnails of the images pasted into the draft, numbered like the [Image #N] tags.
  • ↗ Open opens the full image in the default viewer, such as Preview.
  • ✕ Hide hides the preview until the clipboard changes.
  • With the band above the prompt focused (click it, or press ctrl+x then tab): o opens the clipboard image, 1 to 9 open a pasted image, and h hides the preview.

By default the image is drawn with block characters, choosing for each cell the shape and two colours that match it best, which works in every terminal, Terminal.app included. In Ghostty or kitty, turn on the Real pixels setting to draw the actual image instead.

Requirements

  • macOS, because the plugin reads the clipboard through the system pasteboard. On Windows and Linux the plugin turns itself off when the session starts: it draws nothing and runs nothing else.
  • Claude Code 2.1.287 or later, in a terminal. The plugin is a mod: it only draws inside the Claude Code terminal and does nothing in chat, Cowork or the desktop app, which has image previews of its own.

Settings

SettingDefaultWhat it does
Real pixels (Ghostty, kitty) (pixel_images)offDraws images with real pixels through the kitty graphics protocol. Turn it on only in Ghostty or kitty.

Change it in the /config panel of Claude Code.

What it sends, and where

Nothing. The plugin makes no network requests and sends no data to any server, Anthropic included. Clipboard images and the prompt draft never leave your Mac. The only things it hands data to are the local macOS programs listed below, and only to save, scale, show or delete images in its own temporary folder.

Programs it runs

ProgramExact commandWhenWhy
mktempmktemp -d -t claude-image-previewonce, when the session startscreates a private temporary folder for the images
osascriptosascript -l JavaScript -e <script> checkevery 1.5 secondsasks the macOS pasteboard whether the clipboard changed and holds an image; the script is in hooks/pasteboard.ts
osascriptosascript -l JavaScript -e <script> save <file>when a new image is copiedsaves that image into the temporary folder
sipssips -s format png <file> --out <file>.pngwhen a new image is copiedconverts the image to PNG
sipssips -s format bmp -z <height> <width> <file> --out <file>.bmpwhen a preview or thumbnail is drawnscales the image down to the size of the preview
openopen <file>.pngonly when you press ↗ Openshows the image in your default viewer
rmrm -rf <temporary folder>when the session endsdeletes the temporary folder and every image in it

mktemp, osascript, sips, open and rm are built into macOS. Every <file> is inside the temporary folder.

What it reads

  • The clipboard, through osascript, but only images; text in the clipboard is never read.
  • The prompt draft, only to find [Image #N] tags. The text is not stored or sent.
  • The terminal size Claude Code reports for the area above the prompt, to size the preview.

Images are kept only in the temporary folder, which is deleted when the Claude Code session ends.

Privacy

See the privacy policy: the plugin reads clipboard images and the prompt draft locally, keeps images only until the session ends and shares nothing.

Hooks it adds

EventWhat the hook does
session.startCreates the temporary folder and starts checking the clipboard every 1.5 seconds.
prompt.editLets every edit through unchanged; when the draft mentions [Image #N], checks for a new thumbnail at once.
prompt.submitLets the prompt through unchanged, then clears the thumbnails, because the images were sent with it.
session.endDeletes the temporary folder.
ui.render for the band above the promptDraws the clipboard preview or the thumbnails.

No hook changes, blocks or rewrites a prompt, a tool call or anything Claude sees.

License

MIT

Source 11 files
hooks/register.tsx 251 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Picture } from '../types'
5import { base64ToBytes } from './base64'
6import { decodeBmp } from './bmp'
7import { renderClipboardCard, renderThumbnails } from './cards'
8import { imageNumbersIn, mentionsImage } from './draft'
9import { SAMPLES_ACROSS, SAMPLES_DOWN } from './glyphs'
10import { parsePasteboardState, parseSavedImage, PASTEBOARD_SCRIPT } from './pasteboard'
11import { pixelsToCells } from './raster'
12import { DEFAULT_BAND, fitImage, isSameSize, previewArea, thumbnailArea } from './sizing'
13import type { CellBox } from './sizing'
14
15const clipboardAtom = atom({ plugin: 'lenadweb-image-preview', key: 'clipboard' } as const, null)
16const hiddenChangeCountAtom = atom({ plugin: 'lenadweb-image-preview', key: 'hiddenChangeCount' } as const, -1)
17const thumbnailsAtom = atom({ plugin: 'lenadweb-image-preview', key: 'thumbnails' } as const, {})
18
19const POLL_INTERVAL_MS = 1500
20const TEMP_DIR_NAME = 'claude-image-preview'
21
22const session = {
23  workDir: '',
24  showsPixels: false,
25  band: DEFAULT_BAND,
26  lastChangeCount: -1,
27  clipboardImage: null as Picture | null,
28  isPolling: false,
29  isResizing: false,
30}
31
32async function drawPicture($: EngineInterface, image: Picture, area: CellBox): Promise<Picture | null> {
33  const size = fitImage(image.width, image.height, area)
34  const sized: Picture = { ...image, ...size, cells: '' }
35  if (session.showsPixels) {
36    return sized
37  }
38
39  const bmpFile = `${session.workDir}/clip-${image.changeCount}-${size.columns}x${size.rows}.bmp`
40  const pixelHeight = String(size.rows * SAMPLES_DOWN)
41  const pixelWidth = String(size.columns * SAMPLES_ACROSS)
42  const scaled = await $.process.run(['sips', '-s', 'format', 'bmp', '-z', pixelHeight, pixelWidth, image.file, '--out', bmpFile])
43  if (scaled.exitCode !== 0) {
44    return null
45  }
46
47  const { base64 } = await $.fs.read(bmpFile, { as: 'bytes' })
48  const raster = pixelsToCells(decodeBmp(base64ToBytes(base64)))
49  return { ...sized, cells: raster.cells }
50}
51
52async function isPasteboardAvailable($: EngineInterface): Promise<boolean> {
53  try {
54    const check = await $.process.run(['osascript', '-l', 'JavaScript', '-e', PASTEBOARD_SCRIPT, 'check'])
55    return check.exitCode === 0
56  } catch {
57    return false
58  }
59}
60
61async function readClipboard($: EngineInterface): Promise<void> {
62  const check = await $.process.run(['osascript', '-l', 'JavaScript', '-e', PASTEBOARD_SCRIPT, 'check'])
63  const pasteboard = parsePasteboardState(check.stdout)
64  if (!pasteboard || pasteboard.changeCount === session.lastChangeCount) {
65    return
66  }
67  session.lastChangeCount = pasteboard.changeCount
68
69  if (!pasteboard.hasImage) {
70    session.clipboardImage = null
71    await update($, clipboardAtom, () => null)
72    return
73  }
74
75  const rawFile = `${session.workDir}/clip-${pasteboard.changeCount}.raw`
76  const pngFile = `${session.workDir}/clip-${pasteboard.changeCount}.png`
77  const save = await $.process.run(['osascript', '-l', 'JavaScript', '-e', PASTEBOARD_SCRIPT, 'save', rawFile])
78  const saved = parseSavedImage(save.stdout)
79  const converted = await $.process.run(['sips', '-s', 'format', 'png', rawFile, '--out', pngFile])
80  if (!saved || converted.exitCode !== 0) {
81    return
82  }
83
84  session.clipboardImage = { ...saved, changeCount: pasteboard.changeCount, file: pngFile, columns: 0, rows: 0, cells: '' }
85  const preview = await drawPicture($, session.clipboardImage, previewArea(session.band))
86  await update($, clipboardAtom, () => preview)
87}
88
89async function syncThumbnails($: EngineInterface): Promise<void> {
90  const { text } = await $.prompt.read()
91  const numbers = imageNumbersIn(text)
92  const current = await read($, thumbnailsAtom)
93  const area = thumbnailArea(session.band, numbers.length)
94
95  const next: Record<string, Picture> = {}
96  for (const number of numbers) {
97    const existing = current[number]
98    if (existing) {
99      next[number] = existing
100    } else if (session.clipboardImage) {
101      const thumbnail = await drawPicture($, session.clipboardImage, area)
102      if (thumbnail) {
103        next[number] = thumbnail
104      }
105    }
106  }
107
108  const isUnchanged =
109    Object.keys(next).length === Object.keys(current).length &&
110    Object.keys(next).every(number => next[number] === current[number])
111  if (!isUnchanged) {
112    await update($, thumbnailsAtom, () => next)
113  }
114}
115
116async function poll($: EngineInterface): Promise<void> {
117  if (session.isPolling || !session.workDir) {
118    return
119  }
120  session.isPolling = true
121  try {
122    await readClipboard($)
123    await syncThumbnails($)
124  } finally {
125    session.isPolling = false
126  }
127}
128
129async function resizeToBand($: EngineInterface): Promise<void> {
130  if (session.isResizing || session.showsPixels) {
131    return
132  }
133  session.isResizing = true
134  try {
135    const clipboard = await read($, clipboardAtom)
136    const preview = previewArea(session.band)
137    if (clipboard && !isSameSize(clipboard, fitImage(clipboard.width, clipboard.height, preview))) {
138      const redrawn = await drawPicture($, clipboard, preview)
139      await update($, clipboardAtom, () => redrawn)
140    }
141
142    const thumbnails = await read($, thumbnailsAtom)
143    const area = thumbnailArea(session.band, Object.keys(thumbnails).length)
144    const resized: Record<string, Picture> = {}
145    let hasChanged = false
146    for (const [number, thumbnail] of Object.entries(thumbnails)) {
147      if (isSameSize(thumbnail, fitImage(thumbnail.width, thumbnail.height, area))) {
148        resized[number] = thumbnail
149      } else {
150        resized[number] = (await drawPicture($, thumbnail, area)) ?? thumbnail
151        hasChanged = true
152      }
153    }
154    if (hasChanged) {
155      await update($, thumbnailsAtom, () => resized)
156    }
157  } finally {
158    session.isResizing = false
159  }
160}
161
162async function openInViewer($: EngineInterface, picture: Picture): Promise<void> {
163  await $.process.run(['open', picture.file])
164}
165
166async function removeTempFiles($: EngineInterface): Promise<void> {
167  if (!session.workDir.includes(TEMP_DIR_NAME)) {
168    return
169  }
170  await $.process.run(['rm', '-rf', session.workDir])
171  session.workDir = ''
172}
173
174export const register: Register = (on, options) => {
175  session.showsPixels = options.pixel_images === true
176
177  on('session.start', async ($, e, next) => {
178    const result = await next(e)
179
180    if (!(await isPasteboardAvailable($))) {
181      return result
182    }
183
184    const tempDir = await $.process.run(['mktemp', '-d', '-t', 'claude-image-preview'])
185    session.workDir = tempDir.stdout.trim()
186    await poll($)
187
188    $.clock.every(POLL_INTERVAL_MS, () => {
189      void poll($)
190    })
191
192    return result
193  })
194
195  on('prompt.edit', async ($, e, next) => {
196    const result = await next(e)
197    if (mentionsImage(e.text) || mentionsImage(result.text)) {
198      void poll($)
199    }
200    return result
201  })
202
203  on('session.end', async ($, e, next) => {
204    await removeTempFiles($)
205    return next(e)
206  })
207
208  on('prompt.submit', async ($, e, next) => {
209    const result = await next(e)
210    await update($, thumbnailsAtom, () => ({}))
211    return result
212  })
213
214  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
215    if (e.surface !== 'terminal' || e.props.hasSurvey) {
216      return next(e)
217    }
218
219    const band = { columns: e.props.bodyColumns, rows: e.props.maxRows }
220    if (!isSameSize(band, session.band)) {
221      session.band = band
222      $.clock.after(0, () => {
223        void resizeToBand($)
224      })
225    }
226
227    const ui = $.ui.resolve(e)
228    const thumbnails = Object.entries(await read($, thumbnailsAtom)).sort(([a], [b]) => Number(a) - Number(b))
229    if (thumbnails.length > 0) {
230      return renderThumbnails(ui, thumbnails, session.showsPixels, picture => {
231        void openInViewer($, picture)
232      })
233    }
234
235    const clipboard = await read($, clipboardAtom)
236    const hiddenChangeCount = await read($, hiddenChangeCountAtom)
237    if (!clipboard || clipboard.changeCount === hiddenChangeCount) {
238      return next(e)
239    }
240
241    return renderClipboardCard(ui, clipboard, session.showsPixels, {
242      onOpen: () => {
243        void openInViewer($, clipboard)
244      },
245      onHide: () => {
246        void update($, hiddenChangeCountAtom, () => clipboard.changeCount)
247      },
248    })
249  })
250}
251
hooks/base64.ts 19 lines
1const CHUNK_SIZE = 0x8000
2
3export function base64ToBytes(base64: string): Uint8Array {
4  const binary = atob(base64)
5  const bytes = new Uint8Array(binary.length)
6  for (let i = 0; i < binary.length; i++) {
7    bytes[i] = binary.charCodeAt(i)
8  }
9  return bytes
10}
11
12export function bytesToBase64(bytes: Uint8Array): string {
13  let binary = ''
14  for (let start = 0; start < bytes.length; start += CHUNK_SIZE) {
15    binary += String.fromCharCode(...bytes.subarray(start, start + CHUNK_SIZE))
16  }
17  return btoa(binary)
18}
19
hooks/bmp.ts 32 lines
1export type Pixels = {
2  width: number
3  height: number
4  rgb: Float32Array
5}
6
7export function decodeBmp(bytes: Uint8Array): Pixels {
8  const header = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
9  const dataOffset = header.getUint32(10, true)
10  const width = header.getInt32(18, true)
11  const signedHeight = header.getInt32(22, true)
12  const bytesPerPixel = header.getUint16(28, true) / 8
13
14  const height = Math.abs(signedHeight)
15  const isTopDown = signedHeight < 0
16  const rowSize = Math.ceil((width * bytesPerPixel) / 4) * 4
17  const rgb = new Float32Array(width * height * 3)
18
19  for (let y = 0; y < height; y++) {
20    const fileRow = isTopDown ? y : height - 1 - y
21    for (let x = 0; x < width; x++) {
22      const source = dataOffset + fileRow * rowSize + x * bytesPerPixel
23      const target = (y * width + x) * 3
24      rgb[target] = bytes[source + 2] ?? 0
25      rgb[target + 1] = bytes[source + 1] ?? 0
26      rgb[target + 2] = bytes[source] ?? 0
27    }
28  }
29
30  return { width, height, rgb }
31}
32
hooks/cards.tsx 73 lines
1import type { Elements } from 'claude-code'
2
3import type { Picture } from '../types'
4
5type CardElements = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Raster' | 'Image'>
6
7type ClipboardActions = {
8  onOpen: () => void
9  onHide: () => void
10}
11
12export function renderClipboardCard(ui: CardElements, picture: Picture, showsPixels: boolean, actions: ClipboardActions) {
13  const { Box, Text, Button } = ui
14  return (
15    <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1} alignSelf="flex-start">
16      {renderPicture(ui, 'clipboard', picture, showsPixels)}
17      <Box flexDirection="row" gap={1}>
18        <Text bold>Clipboard</Text>
19        <Text dimColor>{`${picture.width}×${picture.height} · ${picture.format}`}</Text>
20        <Text dimColor>·</Text>
21        <Text color="suggestion">ctrl+v</Text>
22        <Text dimColor>to paste</Text>
23        <Button key="open" label="↗ Open" plain dimColor hotkey="o" onPress={actions.onOpen} />
24        <Button key="hide" label="✕ Hide" plain dimColor hotkey="h" onPress={actions.onHide} />
25      </Box>
26    </Box>
27  )
28}
29
30export function renderThumbnails(
31  ui: CardElements,
32  thumbnails: [string, Picture][],
33  showsPixels: boolean,
34  onOpen: (picture: Picture) => void,
35) {
36  const { Box } = ui
37  return (
38    <Box flexDirection="row" gap={1} flexWrap="wrap">
39      {thumbnails.map(([number, picture]) => renderThumbnail(ui, number, picture, showsPixels, onOpen))}
40    </Box>
41  )
42}
43
44function renderThumbnail(
45  ui: CardElements,
46  number: string,
47  picture: Picture,
48  showsPixels: boolean,
49  onOpen: (picture: Picture) => void,
50) {
51  const { Box, Text, Button } = ui
52  const hotkey = Number(number) < 10 ? number : undefined
53  return (
54    <Box key={`thumbnail-${number}`} flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
55      {renderPicture(ui, `thumbnail-${number}`, picture, showsPixels)}
56      <Box flexDirection="row" gap={1}>
57        <Text color="suggestion" bold>{`#${number}`}</Text>
58        <Text dimColor wrap="truncate-end">{`${picture.width}×${picture.height}`}</Text>
59        <Button key={`open-${number}`} label="↗ Open" plain dimColor hotkey={hotkey} onPress={() => onOpen(picture)} />
60      </Box>
61    </Box>
62  )
63}
64
65function renderPicture(ui: CardElements, key: string, picture: Picture, showsPixels: boolean) {
66  const { Raster, Image } = ui
67  if (showsPixels || !picture.cells) {
68    const source = { file: picture.file, format: 'png' as const }
69    return <Image key={key} source={source} columns={picture.columns} rows={picture.rows} alt={key} />
70  }
71  return <Raster key={key} columns={picture.columns} rows={picture.rows} cells={picture.cells} />
72}
73
hooks/draft.ts 10 lines
1const IMAGE_TAG = /\[Image #(\d+)\]/g
2
3export function imageNumbersIn(draft: string): string[] {
4  return [...draft.matchAll(IMAGE_TAG)].map(match => match[1] ?? '').filter(Boolean)
5}
6
7export function mentionsImage(draft: string): boolean {
8  return draft.includes('[Image #')
9}
10
hooks/glyphs.ts 108 lines
1import type { Rgb } from './color'
2
3export const SAMPLES_ACROSS = 4
4export const SAMPLES_DOWN = 8
5export const SAMPLES_PER_CELL = SAMPLES_ACROSS * SAMPLES_DOWN
6const FULL_BLOCK = 0x2588
7
8export type CellMatch = {
9  glyph: number
10  foreground: Rgb
11  background: Rgb
12}
13
14type Glyph = {
15  code: number
16  covers: Uint8Array
17}
18
19const FLAT_CELL_ERROR = 9600
20
21const GLYPHS: Glyph[] = [
22  glyph(0x2580, (_, y) => y < 4),
23  glyph(0x2581, (_, y) => y >= 7),
24  glyph(0x2582, (_, y) => y >= 6),
25  glyph(0x2583, (_, y) => y >= 5),
26  glyph(0x2585, (_, y) => y >= 3),
27  glyph(0x2586, (_, y) => y >= 2),
28  glyph(0x2587, (_, y) => y >= 1),
29  glyph(0x258e, x => x < 1),
30  glyph(0x258c, x => x < 2),
31  glyph(0x258a, x => x < 3),
32  glyph(0x2598, (x, y) => x < 2 && y < 4),
33  glyph(0x259d, (x, y) => x >= 2 && y < 4),
34  glyph(0x2596, (x, y) => x < 2 && y >= 4),
35  glyph(0x2597, (x, y) => x >= 2 && y >= 4),
36  glyph(0x259a, (x, y) => (x < 2) === (y < 4)),
37]
38
39export function matchCell(samples: Float32Array): CellMatch {
40  const total = sumColors(samples, () => true)
41  const average = divide(total, SAMPLES_PER_CELL)
42  const flatError = errorAgainst(samples, () => average)
43
44  let best = { glyph: FULL_BLOCK, foreground: average, background: average, error: flatError }
45  if (flatError <= FLAT_CELL_ERROR) {
46    return best
47  }
48
49  for (const { code, covers } of GLYPHS) {
50    const coveredCount = covers.reduce((count, isCovered) => count + isCovered, 0)
51    const coveredSum = sumColors(samples, sample => covers[sample] === 1)
52    const foreground = divide(coveredSum, coveredCount)
53    const background = divide(subtract(total, coveredSum), SAMPLES_PER_CELL - coveredCount)
54    const error = errorAgainst(samples, sample => (covers[sample] === 1 ? foreground : background))
55    if (error < best.error) {
56      best = { glyph: code, foreground, background, error }
57    }
58  }
59
60  return best
61}
62
63function glyph(code: number, isCovered: (x: number, y: number) => boolean): Glyph {
64  const covers = new Uint8Array(SAMPLES_PER_CELL)
65  for (let y = 0; y < SAMPLES_DOWN; y++) {
66    for (let x = 0; x < SAMPLES_ACROSS; x++) {
67      covers[y * SAMPLES_ACROSS + x] = isCovered(x, y) ? 1 : 0
68    }
69  }
70  return { code, covers }
71}
72
73function sampleColor(samples: Float32Array, sample: number): Rgb {
74  return [samples[sample * 3] ?? 0, samples[sample * 3 + 1] ?? 0, samples[sample * 3 + 2] ?? 0]
75}
76
77function sumColors(samples: Float32Array, isIncluded: (sample: number) => boolean): Rgb {
78  const sum: Rgb = [0, 0, 0]
79  for (let sample = 0; sample < SAMPLES_PER_CELL; sample++) {
80    if (!isIncluded(sample)) {
81      continue
82    }
83    const [red, green, blue] = sampleColor(samples, sample)
84    sum[0] += red
85    sum[1] += green
86    sum[2] += blue
87  }
88  return sum
89}
90
91function errorAgainst(samples: Float32Array, expectedColor: (sample: number) => Rgb): number {
92  let error = 0
93  for (let sample = 0; sample < SAMPLES_PER_CELL; sample++) {
94    const [red, green, blue] = sampleColor(samples, sample)
95    const [expectedRed, expectedGreen, expectedBlue] = expectedColor(sample)
96    error += (red - expectedRed) ** 2 + (green - expectedGreen) ** 2 + (blue - expectedBlue) ** 2
97  }
98  return error
99}
100
101function divide([red, green, blue]: Rgb, count: number): Rgb {
102  return [red / count, green / count, blue / count]
103}
104
105function subtract(a: Rgb, b: Rgb): Rgb {
106  return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
107}
108
hooks/pasteboard.ts 51 lines
1export const PASTEBOARD_SCRIPT = `ObjC.import('AppKit')
2function run(argv) {
3  const pasteboard = $.NSPasteboard.generalPasteboard
4  const types = ObjC.deepUnwrap(pasteboard.types) || []
5  const imageType = ['public.png', 'public.tiff', 'public.jpeg', 'public.heic'].find(type => types.includes(type))
6  if (argv[0] !== 'save' || !imageType) return pasteboard.changeCount + ' ' + (imageType || '-')
7  pasteboard.dataForType(imageType).writeToFileAtomically(argv[1], true)
8  const representations = $.NSImage.alloc.initWithContentsOfFile(argv[1]).representations
9  let width = 0, height = 0
10  for (let i = 0; i < representations.count; i++) {
11    const representation = representations.objectAtIndex(i)
12    if (representation.pixelsWide > width) { width = representation.pixelsWide; height = representation.pixelsHigh }
13  }
14  return [pasteboard.changeCount, imageType, width, height].join(' ')
15}`
16
17const NO_IMAGE = '-'
18
19export type PasteboardState = {
20  changeCount: number
21  hasImage: boolean
22}
23
24export type SavedImage = {
25  format: string
26  width: number
27  height: number
28}
29
30export function parsePasteboardState(output: string): PasteboardState | null {
31  const [changeCount, imageType] = output.trim().split(' ')
32  const count = Number(changeCount)
33  if (!Number.isFinite(count)) {
34    return null
35  }
36  return { changeCount: count, hasImage: Boolean(imageType) && imageType !== NO_IMAGE }
37}
38
39export function parseSavedImage(output: string): SavedImage | null {
40  const [, imageType, width, height] = output.trim().split(' ')
41  const image = { format: toFormatName(imageType ?? ''), width: Number(width), height: Number(height) }
42  if (!imageType || !(image.width > 0) || !(image.height > 0)) {
43    return null
44  }
45  return image
46}
47
48function toFormatName(imageType: string): string {
49  return imageType.replace('public.', '').toUpperCase()
50}
51
hooks/raster.ts 49 lines
1import { bytesToBase64 } from './base64'
2import type { Pixels } from './bmp'
3import { packColor } from './color'
4import { matchCell, SAMPLES_ACROSS, SAMPLES_DOWN, SAMPLES_PER_CELL } from './glyphs'
5
6export type RasterCells = {
7  columns: number
8  rows: number
9  cells: string
10}
11
12export function pixelsToCells(pixels: Pixels): RasterCells {
13  const columns = Math.floor(pixels.width / SAMPLES_ACROSS)
14  const rows = Math.floor(pixels.height / SAMPLES_DOWN)
15  const words = new Uint32Array(columns * rows * 3)
16
17  for (let row = 0; row < rows; row++) {
18    for (let column = 0; column < columns; column++) {
19      const match = matchCell(cellSamples(pixels, column, row))
20      const cell = (row * columns + column) * 3
21      words[cell] = match.glyph
22      words[cell + 1] = packColor(match.foreground)
23      words[cell + 2] = packColor(match.background)
24    }
25  }
26
27  return { columns, rows, cells: encodeCells(words) }
28}
29
30function encodeCells(words: Uint32Array): string {
31  return bytesToBase64(new Uint8Array(words.buffer, words.byteOffset, words.byteLength))
32}
33
34function cellSamples(pixels: Pixels, column: number, row: number): Float32Array {
35  const samples = new Float32Array(SAMPLES_PER_CELL * 3)
36  for (let y = 0; y < SAMPLES_DOWN; y++) {
37    for (let x = 0; x < SAMPLES_ACROSS; x++) {
38      const pixelX = column * SAMPLES_ACROSS + x
39      const pixelY = row * SAMPLES_DOWN + y
40      const source = (pixelY * pixels.width + pixelX) * 3
41      const target = (y * SAMPLES_ACROSS + x) * 3
42      samples[target] = pixels.rgb[source] ?? 0
43      samples[target + 1] = pixels.rgb[source + 1] ?? 0
44      samples[target + 2] = pixels.rgb[source + 2] ?? 0
45    }
46  }
47  return samples
48}
49
hooks/sizing.ts 44 lines
1export type CellBox = {
2  columns: number
3  rows: number
4}
5
6export const DEFAULT_BAND: CellBox = { columns: 80, rows: 24 }
7
8const CELL_HEIGHT_TO_WIDTH = 2
9const CARD_FRAME_COLUMNS = 4
10const CARD_FRAME_ROWS = 3
11const PREVIEW_SHARE_OF_HEIGHT = 0.6
12const MIN_PREVIEW: CellBox = { columns: 16, rows: 6 }
13const MAX_PREVIEW_ROWS = 30
14const MAX_THUMBNAIL: CellBox = { columns: 48, rows: 12 }
15const MIN_THUMBNAIL_COLUMNS = 12
16
17export function fitImage(width: number, height: number, area: CellBox): CellBox {
18  const columnsForFullHeight = Math.round((area.rows * CELL_HEIGHT_TO_WIDTH * width) / height)
19  const columns = Math.max(1, Math.min(area.columns, width, columnsForFullHeight))
20  const rows = Math.round((columns * height) / (CELL_HEIGHT_TO_WIDTH * width))
21  return { columns, rows: Math.max(1, Math.min(area.rows, rows)) }
22}
23
24export function previewArea(band: CellBox): CellBox {
25  const rows = Math.floor(band.rows * PREVIEW_SHARE_OF_HEIGHT) - CARD_FRAME_ROWS
26  return {
27    columns: Math.max(MIN_PREVIEW.columns, band.columns - CARD_FRAME_COLUMNS),
28    rows: Math.max(MIN_PREVIEW.rows, Math.min(MAX_PREVIEW_ROWS, rows)),
29  }
30}
31
32export function thumbnailArea(band: CellBox, count: number): CellBox {
33  const gaps = Math.max(0, count - 1)
34  const columnsEach = Math.floor((band.columns - gaps) / Math.max(1, count)) - CARD_FRAME_COLUMNS
35  return {
36    columns: Math.max(MIN_THUMBNAIL_COLUMNS, Math.min(MAX_THUMBNAIL.columns, columnsEach)),
37    rows: MAX_THUMBNAIL.rows,
38  }
39}
40
41export function isSameSize(a: CellBox, b: CellBox): boolean {
42  return a.columns === b.columns && a.rows === b.rows
43}
44
hooks/color.ts 10 lines
1export type Rgb = [number, number, number]
2
3export function packColor([red, green, blue]: Rgb): number {
4  return (toByte(red) << 16) | (toByte(green) << 8) | toByte(blue)
5}
6
7function toByte(value: number): number {
8  return Math.max(0, Math.min(255, Math.round(value)))
9}
10
types/index.d.ts 21 lines
1export type Picture = {
2  changeCount: number
3  width: number
4  height: number
5  format: string
6  file: string
7  columns: number
8  rows: number
9  cells: string
10}
11
12declare module 'claude-code' {
13  interface PluginState {
14    'lenadweb-image-preview': {
15      clipboard: Picture | null
16      hiddenChangeCount: number
17      thumbnails: Record<string, Picture>
18    }
19  }
20}
21