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

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.

[Image #N] tags.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.
| Setting | Default | What it does |
|---|---|---|
Real pixels (Ghostty, kitty) (pixel_images) | off | Draws 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.
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.
| Program | Exact command | When | Why |
|---|---|---|---|
mktemp | mktemp -d -t claude-image-preview | once, when the session starts | creates a private temporary folder for the images |
osascript | osascript -l JavaScript -e <script> check | every 1.5 seconds | asks the macOS pasteboard whether the clipboard changed and holds an image; the script is in hooks/pasteboard.ts |
osascript | osascript -l JavaScript -e <script> save <file> | when a new image is copied | saves that image into the temporary folder |
sips | sips -s format png <file> --out <file>.png | when a new image is copied | converts the image to PNG |
sips | sips -s format bmp -z <height> <width> <file> --out <file>.bmp | when a preview or thumbnail is drawn | scales the image down to the size of the preview |
open | open <file>.png | only when you press ↗ Open | shows the image in your default viewer |
rm | rm -rf <temporary folder> | when the session ends | deletes 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.
osascript, but only images; text in the clipboard is never read.[Image #N] tags. The text is not stored or sent.Images are kept only in the temporary folder, which is deleted when the Claude Code session ends.
See the privacy policy: the plugin reads clipboard images and the prompt draft locally, keeps images only until the session ends and shares nothing.
| Event | What the hook does |
|---|---|
session.start | Creates the temporary folder and starts checking the clipboard every 1.5 seconds. |
prompt.edit | Lets every edit through unchanged; when the draft mentions [Image #N], checks for a new thumbnail at once. |
prompt.submit | Lets the prompt through unchanged, then clears the thumbnails, because the images were sent with it. |
session.end | Deletes the temporary folder. |
ui.render for the band above the prompt | Draws the clipboard preview or the thumbnails. |
No hook changes, blocks or rewrites a prompt, a tool call or anything Claude sees.
MIT
hooks/register.tsx 251 lines1import { 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}
251hooks/base64.ts 19 lines1const 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}
19hooks/bmp.ts 32 lines1export 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}
32hooks/cards.tsx 73 lines1import 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}
73hooks/draft.ts 10 lines1const 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}
10hooks/glyphs.ts 108 lines1import 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}
108hooks/pasteboard.ts 51 lines1export 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}
51hooks/raster.ts 49 lines1import { 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}
49hooks/sizing.ts 44 lines1export 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}
44hooks/color.ts 10 lines1export 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}
10types/index.d.ts 21 lines1export 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