Shows thumbnails of pasted images above the prompt, in any terminal

English | 한국어
A Claude Code mod that shows thumbnails of pasted images right above the prompt, in any terminal.

Terminals without image support get the same layout in colored quadrant blocks:
╭────────────╮ ╭────────────────────────╮
│▀▀▀▀▀▀▀▀▀▀▀▀│ │▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│
│▀▀▀▀▀▀▀▀▀▀▀▀│ │▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│
│▀▀▀▀▀▀▀▀▀▀▀▀│ │ #2 │
│ #1 │ ╰────────────────────────╯
╰────────────╯
❯ [Image #1] [Image #2] compare these two screens
Type this at the prompt of a Claude Code terminal session:
/plugin install mods-image-preview --marketplace hahmjuntae/claude-mods-image-preview
y to Add marketplace?.Installed mods-image-preview. Plugin is now active. appears, the mod is running in the current session.Requires a Claude Code build with function-hook mods. Tested on Claude Code 2.1.292.
| Step | What happens |
|---|---|
| Detect | Reads the prompt draft every 300 ms and looks for [Image #N] tokens |
| Source | Reads images/N.*, which Claude Code writes to the session temp folder the moment you paste |
| Draw | Renders a thumbnail with the best renderer your terminal supports (see below) |
| Fit | Shrinks thumbnails to the rows and columns free above the prompt. When the frame would make the image smaller than its size setting, the frame is dropped and the number moves beside the image |
| Open | Opens the original in the connected IDE's editor without taking the focus (see Original quality) |
| Clear | Thumbnails disappear when you delete the token or submit the prompt |
| Terminal | Thumbnail |
|---|---|
| kitty, Ghostty (outside tmux, screen and SSH) | Real pixels through the kitty graphics protocol |
| A terminal that advertises the overlay (see Terminal integration), tmux included | Real pixels drawn by the terminal over marker cells |
| Everything else: iTerm2, Terminal.app, WezTerm, Alacritty, Windows Terminal, VS Code, tmux, screen, SSH | Colored quadrant block characters (▀ ▌ ▚ ▗ ...), two pixels across and two down per cell |
Any terminal or IDE on Windows, with renderer set to window | The original at full quality, drawn by a helper window over the thumbnail (see Original quality) |
If a terminal looks like kitty but does not answer the graphics query, the mod switches to quadrant blocks on its own.
Each cell picks the two colors and the quadrant glyph that best fit its 2x2 pixels, so a cell holds twice the horizontal detail of a half-block (▀) while a split between the pixel rows still draws as ▀.
Inside tmux, Claude Code does not send kitty graphics, and it rejects the kitty Unicode placeholder character (U+10EEEE) that tmux image passthrough relies on. So inside tmux the thumbnail is drawn with quadrant blocks unless the outer terminal supports the overlay.
Thumbnails work inside tmux with no extra setup. Two settings decide whether their colors are exact:
| Setting | Where | Why |
|---|---|---|
export CLAUDE_CODE_TMUX_TRUECOLOR=1 | Shell profile (~/.zshrc, ~/.bashrc) | Claude Code lowers its colors to 256 inside tmux unless this is set. On Claude Code 2.1.292 a thumbnail drew with 256-color codes without it and 24-bit RGB with it |
set -sa terminal-features ',*:RGB' | ~/.tmux.conf (tmux 3.2+) | Lets tmux pass 24-bit color to the outer terminal. On older tmux use set -ga terminal-overrides ',*:Tc' |
The overlay renderer needs both inside tmux. Without CLAUDE_CODE_TMUX_TRUECOLOR=1 the mod falls back to quadrant blocks on its own.
Works in Windows Terminal, PowerShell and Git Bash with quadrant blocks. PNG is decoded by the built-in decoder, and other formats are converted with PowerShell (System.Drawing) when it is available.
A terminal draws a thumbnail out of text cells unless it speaks the kitty graphics protocol, so in the JetBrains terminal, Windows Terminal, VS Code and the rest a screenshot's text cannot be read in the thumbnail. The mod therefore offers two ways to see the original:
renderer set to window (Windows): the thumbnail is painted in one marker color, and a helper (hooks/overlay.ps1, PowerShell with C#) finds that rectangle on screen about ten times a second and draws the original over it in a borderless window. The window takes neither the focus nor clicks, follows the band as it moves, and disappears with the token, the submitted prompt, or the terminal leaving the screen. It is excluded from screen capture, so it does not show in your own screenshots. The helper only reads the screen to find the marker, keeps nothing, and exits within 15 seconds after the session ends; if it cannot start, the session falls back to blocks.open set to ide, inside an IDE terminal with the Claude Code extension (JetBrains, VS Code), each pasted image opens once in an editor tab, the IDE's own image viewer at full quality. The tab opens without taking the focus, so you keep typing in the prompt, and the thumbnail above the prompt shrinks to a one-line #1 in IDE. When Claude Code does not lend the IDE connection to mods, the mod reaches the same IDE server itself through PowerShell on Windows.#1) opens it again, in the IDE or, outside an IDE, in the system image viewer.open set to viewer also opens the system image viewer on paste when no IDE is connected.This only changes what you see. Claude always receives the original file.
Change them in /config.
| Name | Values | Default |
|---|---|---|
renderer | auto, blocks, pixels, overlay, window | auto |
size | small 16x4, medium 24x6, large 40x10 (terminal cells) | medium |
open | off, ide, viewer | off |
auto picks the overlay when the terminal advertises it, pixels on kitty and Ghostty, and quadrant blocks everywhere else.
| Format | Handling |
|---|---|
| PNG | Built-in decoder: 1/2/4/8/16-bit, palette, transparency, interlaced |
| JPEG, GIF, WebP, HEIC and others | Converted to a small PNG with the first converter found: macOS sips, ImageMagick (magick, convert), Windows PowerShell |
| PNG over 4 MiB | Downscaled with the same converters |
Without any of these converters, formats other than PNG show unsupported.
A terminal can show real pixels even inside tmux by drawing an image over marker cells.
CLAUDE_MODS_IMAGE_OVERLAY=1 in the environment of the shells it starts. Inside tmux, the mod also accepts the same variable in the tmux global environment (tmux set-environment -g CLAUDE_MODS_IMAGE_OVERLAY 1), so panes that were already open pick it up.~/.claude/claude-mods-image-preview/<id as five hex digits>.| Field | Encoding | |
|---|---|---|
| Glyph | `U+2800 + (row << 4 \ | rows - 1)`, row counted from 0, at most 16 rows |
| Foreground | Nibbles 0xA, id[19:16], id[15:12], each written as channel value nibble × 17 | |
| Background | Nibbles id[11:8], id[7:4], id[3:0], each written as channel value nibble × 17 | |
| id | Low 20 bits of the FNV-1a 32-bit hash of the source path |
The Claude Code Raster palette keeps channel values that are multiples of 17, so the markers pass through tmux unchanged.
claude plugin validate .
claude plugin test .
MIT
hooks/register.tsx 567 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Mode, Preview } from '../types'
5import { fromBase64, toBase64 } from './base64'
6import { planBand } from './layout'
7import { ideCommand, viewerCommands } from './open'
8import { decodePng, isPng } from './png'
9import { downsample, fitBox, markCells, markHex, markId, quadrantCells, windowCells, windowKey, type Fit } from './thumb'
10
11const previews = atom({ plugin: 'mods-image-preview', key: 'previews' } as const, [] as Preview[])
12const mode = atom({ plugin: 'mods-image-preview', key: 'mode' } as const, 'blocks' as Mode)
13
14const POLL_MS = 300
15const PENDING_TICKS = 10
16const MAX_READ_BYTES = 4 * 1024 * 1024
17const TOKEN = /\[Image #(\d+)\]/g
18const IMAGE_FILE = /^(\d+)\.(png|jpe?g|gif|webp|bmp|tiff?|heic)$/i
19const WEB_IMAGE = /\.(png|jpe?g|gif|webp)$/i
20const CLAUDE_TEMP = /^claude(-\d+)?$/
21const ALT_DENY = /\balt\b/i
22const SIZES: Record<string, [number, number]> = { small: [16, 4], medium: [24, 6], large: [40, 10] }
23// 렌더 시점 축소 여유분으로 사분면 격자의 2배 해상도 원본 보관
24const SOURCE_SCALE = 2
25const IDE_TIMEOUT_MS = 10_000
26const VIEWER_TIMEOUT_MS = 5_000
27const PORT = /^\d{1,5}$/
28// 오버레이 보조 프로세스가 spec 갱신이 15초 끊기면 스스로 끝나므로 그보다 짧은 생존 신호 간격
29const HEARTBEAT_MS = 5_000
30
31type Picture = { bytes: Uint8Array; path: string }
32
33let located: { sessionId: string; dir: string } | undefined
34let bandRequestId: string | undefined
35let isPolling = false
36const attempts = new Map<number, number>()
37const probed = new Set<number>()
38const opened = new Set<string>()
39const overlay = { isRunning: false, writtenAt: 0 }
40
41export const register: Register = (on, options) => {
42 const box: [number, number] = SIZES[String(options.size)] ?? [24, 6]
43 const renderer = String(options.renderer)
44 const openMode = String(options.open)
45
46 on('session.start', async ($, e, next) => {
47 const chosen = await chooseMode($, renderer)
48 await update($, mode, () => chosen)
49 await update($, previews, () => [])
50 if (chosen === 'overlay') await pruneLinks($)
51 // 이미지 붙여넣기는 prompt.edit 를 거치지 않아 초안 폴링으로 우회
52 $.clock.every(POLL_MS, () => {
53 void poll($, box, openMode)
54 })
55
56 return next(e)
57 })
58
59 on('prompt.submit', async ($, e, next) => {
60 await update($, previews, () => [])
61
62 if ((await read($, mode)) === 'window') await syncOverlay($, [])
63
64 return next(e)
65 })
66
67 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
68 if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
69
70 const list = await read($, previews)
71
72 if (list.length === 0) return next(e)
73
74 const current = await read($, mode)
75 const { Box, Button, Image, Raster, Text } = $.ui.resolve(e)
76 // IDE 탭이 원본을 보여 주는 이미지는 흐린 썸네일 대신 한 줄 표시라 폭 배분에서 제외
77 const plan = planBand(
78 list.filter(preview => !preview.isInIde),
79 e.props.maxRows,
80 e.props.bodyColumns,
81 box,
82 )
83 const fits = new Map(plan.tiles.map(tile => [tile.n, tile.fit]))
84 bandRequestId = e.requestId
85
86 const picture = (preview: Preview, fit: Fit | undefined) => {
87 if (!fit) return <Text dimColor>{preview.note ?? '...'}</Text>
88
89 if (current === 'pixels' && preview.file) {
90 return (
91 <Image
92 key={`image-${preview.n}`}
93 source={{ file: preview.file, format: 'png' }}
94 columns={fit.columns}
95 rows={fit.rows}
96 alt={`Image #${preview.n}`}
97 />
98 )
99 }
100
101 // 보조 프로세스가 화면에서 이 단색 칸을 찾아 원본 이미지 창으로 덮는 마커
102 if (current === 'window' && preview.source) {
103 const key = overlayKey(preview.n)
104
105 return <Raster key={`raster-${preview.n}`} columns={fit.columns} rows={fit.rows} cells={windowCells(key, fit.columns, fit.rows)} />
106 }
107
108 const cells =
109 current === 'overlay' && preview.id !== undefined
110 ? markCells(preview.id, fit.columns, fit.rows)
111 : blockCells(preview, fit)
112
113 return <Raster key={`raster-${preview.n}`} columns={fit.columns} rows={fit.rows} cells={cells} />
114 }
115
116 // 번호를 누르면 IDE 탭이나 OS 뷰어로 원본 화질 확인
117 const label = (preview: Preview) => {
118 const { source } = preview
119
120 if (!source) return <Text dimColor>#{preview.n}</Text>
121
122 return <Button key={`open-${preview.n}`} label={`#${preview.n}`} plain dimColor onPress={() => void openOriginal($, source, true)} />
123 }
124
125 return (
126 <Box flexDirection="row" alignItems="flex-end" columnGap={1}>
127 {list.map(preview =>
128 preview.isInIde ? (
129 <Box key={`tile-${preview.n}`} flexDirection="row" columnGap={1}>
130 {label(preview)}
131 <Text dimColor>in IDE</Text>
132 </Box>
133 ) : plan.isCompact ? (
134 <Box key={`tile-${preview.n}`} flexDirection="row" columnGap={1}>
135 {picture(preview, fits.get(preview.n))}
136 {label(preview)}
137 </Box>
138 ) : (
139 <Box key={`tile-${preview.n}`} flexDirection="column" alignItems="center" borderStyle="round" borderDimColor>
140 {picture(preview, fits.get(preview.n))}
141 {label(preview)}
142 </Box>
143 ),
144 )}
145 </Box>
146 )
147 })
148}
149
150function blockCells(preview: Preview, fit: Fit): string {
151 if (!preview.thumb || !preview.thumbWidth || !preview.thumbHeight) return ''
152
153 const thumb = { width: preview.thumbWidth, height: preview.thumbHeight, data: fromBase64(preview.thumb) }
154
155 return quadrantCells(downsample(thumb, fit.pxWidth * 2, fit.pxHeight))
156}
157
158async function poll($: EngineInterface, box: [number, number], openMode: string): Promise<void> {
159 if (isPolling) return
160
161 isPolling = true
162
163 try {
164 const { text } = await $.prompt.read()
165 const wanted = [...new Set([...text.matchAll(TOKEN)].map(match => Number(match[1])))]
166 const current = await read($, previews)
167 const chosen = await read($, mode)
168 const shown = current.map(preview => preview.n)
169 const isSettled =
170 current.every(preview => !preview.isPending) &&
171 wanted.length === shown.length &&
172 wanted.every((n, i) => n === shown[i])
173
174 if (isSettled) {
175 if (chosen === 'pixels') await probePixels($, current)
176 if (chosen === 'window' && current.length > 0 && (await $.clock.now()) - overlay.writtenAt >= HEARTBEAT_MS) {
177 await syncOverlay($, current)
178 }
179
180 return
181 }
182
183 for (const n of attempts.keys()) if (!wanted.includes(n)) attempts.delete(n)
184
185 const nextPreviews: Preview[] = []
186
187 for (const n of wanted) {
188 const known = current.find(preview => preview.n === n)
189 nextPreviews.push(known && !known.isPending ? known : await loadPreview($, n, box, chosen))
190 }
191
192 await update($, previews, () => nextPreviews)
193
194 if (chosen === 'window') await syncOverlay($, nextPreviews)
195
196 for (const { source } of nextPreviews) {
197 if (!source || opened.has(source)) continue
198
199 opened.add(source)
200
201 if (openMode === 'off') continue
202
203 void openOriginal($, source, openMode === 'viewer').then(where => {
204 if (where !== 'ide') return
205
206 return update($, previews, list => list.map(preview => (preview.source === source ? { ...preview, isInIde: true } : preview)))
207 })
208 }
209 } finally {
210 isPolling = false
211 }
212}
213
214async function loadPreview($: EngineInterface, n: number, box: [number, number], chosen: Mode): Promise<Preview> {
215 const [maxColumns, maxRows] = box
216 const file = await findImage($, n)
217
218 if (!file) {
219 const tries = (attempts.get(n) ?? 0) + 1
220 attempts.set(n, tries)
221
222 return tries < PENDING_TICKS ? { n, isPending: true } : { n, note: 'not found' }
223 }
224
225 try {
226 const picture = await readPicture($, file, maxColumns * 4)
227 const image = decodePng(picture.bytes)
228 const fit = fitBox(image.width, image.height, maxColumns, maxRows)
229 const thumb = downsample(image, fit.pxWidth * 2 * SOURCE_SCALE, fit.pxHeight * SOURCE_SCALE)
230 const preview: Preview = {
231 n,
232 source: file,
233 width: image.width,
234 height: image.height,
235 thumb: toBase64(thumb.data),
236 thumbWidth: thumb.width,
237 thumbHeight: thumb.height,
238 }
239
240 if (chosen === 'pixels') return { ...preview, file: picture.path }
241
242 if (chosen === 'overlay') {
243 const source = WEB_IMAGE.test(file) ? file : picture.path
244 const id = markId(source)
245
246 if (await linkForOverlay($, source, id)) return { ...preview, id }
247 }
248
249 return preview
250 } catch {
251 return { n, source: file, note: 'unsupported' }
252 }
253}
254
255// 엔진이 kitty 그래픽 질의에 응답이 없으면 alt 를 그리므로 블록 폴백 근거
256async function probePixels($: EngineInterface, list: Preview[]): Promise<void> {
257 const target = list.find(preview => preview.file && !probed.has(preview.n))
258
259 if (!target?.file || !bandRequestId) return
260
261 const { deny } = await $.ui.blit({
262 requestId: bandRequestId,
263 key: `image-${target.n}`,
264 source: { file: target.file, format: 'png' },
265 })
266
267 if (deny === undefined) {
268 probed.add(target.n)
269 } else if (ALT_DENY.test(deny)) {
270 probed.add(target.n)
271 await update($, mode, () => 'blocks')
272 }
273}
274
275async function linkForOverlay($: EngineInterface, source: string, id: number): Promise<boolean> {
276 const dir = await overlayDir($)
277
278 if (!dir) return false
279
280 const made = await $.process.run(['mkdir', '-p', dir])
281
282 if (made.exitCode !== 0) return false
283
284 const { exitCode } = await $.process.run(['ln', '-sfn', source, `${dir}/${markHex(id)}`])
285
286 return exitCode === 0
287}
288
289async function pruneLinks($: EngineInterface): Promise<void> {
290 const dir = await overlayDir($)
291
292 if (dir && (await $.fs.exists(dir))) await $.process.run(['find', dir, '-type', 'l', '-mmin', '+1440', '-delete'])
293}
294
295async function overlayDir($: EngineInterface): Promise<string | undefined> {
296 const home = await $.env.get('HOME')
297
298 return home ? `${home}/.claude/claude-mods-image-preview` : undefined
299}
300
301async function readPicture($: EngineInterface, file: string, edge: number): Promise<Picture> {
302 const { size } = await $.fs.stat(file)
303
304 if (size <= MAX_READ_BYTES) {
305 const bytes = fromBase64((await $.fs.read(file, { as: 'bytes' })).base64)
306
307 if (isPng(bytes)) return { bytes, path: file }
308 }
309
310 // PNG 가 아니거나 읽기 상한 초과 시 OS 별 변환기로 만든 축소 PNG 폴백
311 const out = `${file.replace(/[\\/]images[\\/][^\\/]+$/, '')}/claude-mods-image-preview/${file.replace(/^.*[\\/]/, '')}.png`
312 await $.fs.write(out, '')
313
314 for (const argv of converters(file, out, edge)) {
315 try {
316 const { exitCode } = await $.process.run(argv)
317
318 if (exitCode !== 0) continue
319
320 const bytes = fromBase64((await $.fs.read(out, { as: 'bytes' })).base64)
321
322 if (isPng(bytes)) return { bytes, path: out }
323 } catch {
324 continue
325 }
326 }
327
328 throw new Error('no converter')
329}
330
331function converters(file: string, out: string, edge: number): string[][] {
332 const quote = (value: string) => `'${value.replace(/'/g, "''")}'`
333 const resize = `${edge}x${edge}>`
334 const script =
335 '& { param($in, $out, $edge) Add-Type -AssemblyName System.Drawing; ' +
336 '$s = [System.Drawing.Image]::FromFile($in); ' +
337 '$k = [Math]::Min(1, $edge / [Math]::Max($s.Width, $s.Height)); ' +
338 '$b = New-Object System.Drawing.Bitmap ([int][Math]::Max(1, $s.Width * $k)), ([int][Math]::Max(1, $s.Height * $k)); ' +
339 '$g = [System.Drawing.Graphics]::FromImage($b); $g.DrawImage($s, 0, 0, $b.Width, $b.Height); ' +
340 '$b.Save($out, [System.Drawing.Imaging.ImageFormat]::Png) }'
341
342 return [
343 ['sips', '-s', 'format', 'png', '-Z', String(edge), file, '--out', out],
344 ['magick', file, '-resize', resize, out],
345 ['convert', file, '-resize', resize, out],
346 ['powershell', '-NoProfile', '-NonInteractive', '-Command', `${script} ${quote(file)} ${quote(out)} ${edge}`],
347 ]
348}
349
350async function findImage($: EngineInterface, n: number): Promise<string | undefined> {
351 const dir = await imagesDir($)
352
353 if (!dir) return undefined
354
355 const entry = (await $.fs.list(dir)).find(item => item.kind === 'file' && Number(IMAGE_FILE.exec(item.name)?.[1]) === n)
356
357 return entry ? `${dir}/${entry.name}` : undefined
358}
359
360// 엔진이 붙여넣기 즉시 이미지를 쓰는 <temp>/claude-<uid>/<project>/<session>/images 기준
361async function imagesDir($: EngineInterface): Promise<string | undefined> {
362 const sessionId = await $.session.id()
363
364 if (located?.sessionId === sessionId) return located.dir
365
366 for (const root of await tempRoots($)) {
367 for (const project of await childDirs($, root)) {
368 const dir = `${root}/${project}/${sessionId}/images`
369
370 if (await $.fs.exists(dir)) {
371 located = { sessionId, dir }
372
373 return dir
374 }
375 }
376 }
377
378 return undefined
379}
380
381async function tempRoots($: EngineInterface): Promise<string[]> {
382 const override = await $.env.get('CLAUDE_CODE_TMPDIR')
383 const parents = [override, '/tmp', '/private/tmp', await $.env.get('TMPDIR'), await $.env.get('TEMP')]
384 const roots = override ? [override] : []
385
386 for (const parent of parents) {
387 if (!parent) continue
388
389 const base = parent.replace(/[\\/]+$/, '')
390
391 for (const name of await childDirs($, base)) if (CLAUDE_TEMP.test(name)) roots.push(`${base}/${name}`)
392 }
393
394 return [...new Set(roots)]
395}
396
397async function childDirs($: EngineInterface, path: string): Promise<string[]> {
398 try {
399 return (await $.fs.list(path)).filter(item => item.kind === 'dir').map(item => item.name)
400 } catch {
401 return []
402 }
403}
404
405// IDE 편집기 탭은 원본 화질 그대로이고 makeFrontmost 를 끄면 입력 포커스가 프롬프트에 남는 기준
406async function openOriginal($: EngineInterface, file: string, useViewer: boolean): Promise<'ide' | 'viewer' | undefined> {
407 if (await openInIde($, file)) return 'ide'
408 if (useViewer && (await openInViewer($, file))) return 'viewer'
409
410 return undefined
411}
412
413async function openInIde($: EngineInterface, file: string): Promise<boolean> {
414 try {
415 const { isError } = await $.mcp.call('ide', 'openFile', { filePath: file, preview: true, makeFrontmost: false })
416
417 if (!isError) return true
418 } catch {
419 // 엔진이 IDE 내부 도구를 내주지 않으면 IDE 터미널이 알려 준 서버로 직접 연결하는 폴백
420 }
421
422 try {
423 const port = await $.env.get('CLAUDE_CODE_SSE_PORT')
424 const dir = await claudeDir($)
425
426 if (!port || !PORT.test(port) || !dir || (await $.env.get('OS')) !== 'Windows_NT') return false
427
428 const lock = `${dir}/ide/${port}.lock`
429
430 if (!(await $.fs.exists(lock))) return false
431
432 const { exitCode } = await $.process.run(ideCommand(port, lock, file), { timeoutMs: IDE_TIMEOUT_MS })
433
434 return exitCode === 0
435 } catch {
436 return false
437 }
438}
439
440async function openInViewer($: EngineInterface, file: string): Promise<boolean> {
441 try {
442 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
443
444 for (const argv of viewerCommands(file, isWindows)) {
445 try {
446 const { exitCode } = await $.process.run(argv, { timeoutMs: VIEWER_TIMEOUT_MS })
447
448 // explorer 는 열기에 성공해도 종료 코드 1 을 돌려주는 기준
449 if (exitCode === 0 || isWindows) return true
450 } catch {
451 continue
452 }
453 }
454 } catch {
455 return false
456 }
457
458 return false
459}
460
461async function claudeDir($: EngineInterface): Promise<string | undefined> {
462 const configured = await $.env.get('CLAUDE_CONFIG_DIR')
463
464 if (configured) return configured
465
466 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
467
468 return home ? `${home}/.claude` : undefined
469}
470
471// 세션마다 마커 색이 달라 한 화면에 뜬 여러 세션의 썸네일을 구분하는 기준
472function overlayKey(n: number): number {
473 return windowKey(n, located ? markId(located.sessionId) : 0)
474}
475
476async function syncOverlay($: EngineInterface, list: Preview[]): Promise<void> {
477 if (!located) return
478
479 const spec = `${located.dir.replace(/[\\/]images$/, '')}/claude-mods-image-preview/overlay.txt`
480 const lines = list
481 .filter(preview => preview.source && !preview.isInIde && preview.width)
482 .map(preview => `${overlayKey(preview.n).toString(16).padStart(6, '0')}\t${preview.source}`)
483
484 await $.fs.write(spec, lines.length > 0 ? `${lines.join('\n')}\n` : '')
485 overlay.writtenAt = await $.clock.now()
486
487 if (lines.length > 0 && !overlay.isRunning) await startOverlay($, spec)
488}
489
490// 모듈이 내려가면 엔진이 자식을 끝내고, 비정상 종료면 이 세션은 블록 렌더러로 되돌리는 기준
491async function startOverlay($: EngineInterface, spec: string): Promise<void> {
492 const root = $.plugin.root
493 const candidates = [`${root}/hooks/overlay.ps1`, `${root}/../hooks/overlay.ps1`]
494 let script: string | undefined
495
496 for (const path of candidates) {
497 if (await $.fs.exists(path)) {
498 script = path
499 break
500 }
501 }
502
503 if (!script) {
504 await update($, mode, () => 'blocks')
505 return
506 }
507
508 overlay.isRunning = true
509
510 const argv = ['powershell', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script, '-Spec', spec]
511
512 void (async () => {
513 let code: number | null = -1
514
515 try {
516 const child = $.process.spawn({ argv })
517
518 for await (const piece of child) {
519 if (piece.stream === 'stderr') $.ui.log(piece.text, { to: 'debug' })
520 }
521
522 code = (await child.result).code
523 } catch {
524 code = -1
525 }
526
527 overlay.isRunning = false
528
529 if (code !== 0) await update($, mode, () => 'blocks')
530 })()
531}
532
533async function chooseMode($: EngineInterface, renderer: string): Promise<Mode> {
534 if (renderer === 'window') return (await $.env.get('OS')) === 'Windows_NT' ? 'window' : 'blocks'
535 if (renderer === 'blocks' || renderer === 'pixels' || renderer === 'overlay') return renderer
536 if (await advertisesOverlay($)) return 'overlay'
537
538 return (await drawsPixels($)) ? 'pixels' : 'blocks'
539}
540
541// 이미 떠 있는 tmux 창은 새 환경 변수를 못 받아 tmux 전역 환경을 함께 확인하는 기준
542async function advertisesOverlay($: EngineInterface): Promise<boolean> {
543 const isTmux = Boolean(await $.env.get('TMUX'))
544
545 // tmux 안에서 truecolor 가 꺼지면 마커 색이 256색으로 깨지는 문제 방지
546 if (isTmux && !(await $.env.get('CLAUDE_CODE_TMUX_TRUECOLOR'))) return false
547 if ((await $.env.get('CLAUDE_MODS_IMAGE_OVERLAY')) === '1') return true
548 if (!isTmux) return false
549
550 try {
551 const { exitCode, stdout } = await $.process.run(['tmux', 'show-environment', '-g', 'CLAUDE_MODS_IMAGE_OVERLAY'])
552
553 return exitCode === 0 && stdout.trim() === 'CLAUDE_MODS_IMAGE_OVERLAY=1'
554 } catch {
555 return false
556 }
557}
558
559async function drawsPixels($: EngineInterface): Promise<boolean> {
560 if ((await $.env.get('TMUX')) || (await $.env.get('STY')) || (await $.env.get('SSH_CONNECTION'))) return false
561
562 const term = await $.env.get('TERM')
563 const program = await $.env.get('TERM_PROGRAM')
564
565 return term === 'xterm-kitty' || term === 'xterm-ghostty' || program === 'ghostty' || Boolean(await $.env.get('KITTY_WINDOW_ID'))
566}
567hooks/base64.ts 11 lines1type Base64Constructor = Uint8ArrayConstructor & { fromBase64: (text: string) => Uint8Array }
2type Base64Bytes = Uint8Array & { toBase64: () => string }
3
4export function fromBase64(text: string): Uint8Array {
5 return (Uint8Array as Base64Constructor).fromBase64(text)
6}
7
8export function toBase64(bytes: Uint8Array): string {
9 return (bytes as Base64Bytes).toBase64()
10}
11hooks/layout.ts 31 lines1import { fitBox, type Fit } from './thumb'
2
3export type TileInput = { n: number; width?: number; height?: number }
4
5export type TilePlan = { n: number; fit?: Fit }
6
7export type BandPlan = { isCompact: boolean; tiles: TilePlan[] }
8
9const BORDER_ROWS = 2
10const LABEL_ROWS = 1
11const GAP = 1
12
13// 테두리를 두면 설정 크기보다 작아지는 좁은 밴드는 번호를 옆에 두는 압축 배치 기준
14export function planBand(list: TileInput[], maxRows: number, bodyColumns: number, box: [number, number]): BandPlan {
15 const [boxColumns, boxRows] = box
16 const isCompact = maxRows - BORDER_ROWS - LABEL_ROWS < boxRows
17 const rowBudget = isCompact ? Math.max(1, Math.min(boxRows, maxRows)) : boxRows
18 const count = Math.max(1, list.length)
19 const chrome = isCompact ? 1 + Math.max(...list.map(item => `#${item.n}`.length), 2) : 2
20 const perTile = Math.floor((bodyColumns - GAP * (count - 1)) / count) - chrome
21 const columnBudget = Math.max(2, Math.min(boxColumns, perTile))
22
23 return {
24 isCompact,
25 tiles: list.map(item => ({
26 n: item.n,
27 fit: item.width && item.height ? fitBox(item.width, item.height, columnBudget, rowBudget) : undefined,
28 })),
29 }
30}
31hooks/open.ts 32 lines1// IDE MCP 서버에 openFile 한 번 보내는 웹소켓 클라이언트, 명령줄 따옴표 깨짐 방지로 큰따옴표 없음
2const IDE_SCRIPT = [
3 '& { param($port, $lock, $file)',
4 "$ErrorActionPreference = 'Stop';",
5 '$token = (Get-Content -Raw -LiteralPath $lock | ConvertFrom-Json).authToken;',
6 '$ws = New-Object System.Net.WebSockets.ClientWebSocket;',
7 "$ws.Options.AddSubProtocol('mcp');",
8 "$ws.Options.SetRequestHeader('x-claude-code-ide-authorization', $token);",
9 '$none = [Threading.CancellationToken]::None;',
10 "if (-not $ws.ConnectAsync([Uri]('ws://127.0.0.1:' + $port), $none).Wait(3000)) { exit 1 };",
11 '$buffer = New-Object byte[] 65536;',
12 'function Send($message) { $bytes = [Text.Encoding]::UTF8.GetBytes(($message | ConvertTo-Json -Compress -Depth 6)); [void]$ws.SendAsync([ArraySegment[byte]]$bytes, 0, $true, $none).Wait(3000) };',
13 "function Receive { $text = ''; do { $task = $ws.ReceiveAsync([ArraySegment[byte]]$buffer, $none); if (-not $task.Wait(5000)) { exit 1 }; $text += [Text.Encoding]::UTF8.GetString($buffer, 0, $task.Result.Count) } until ($task.Result.EndOfMessage); $text | ConvertFrom-Json };",
14 "Send @{ jsonrpc = '2.0'; id = 1; method = 'initialize'; params = @{ protocolVersion = '2025-06-18'; capabilities = @{}; clientInfo = @{ name = 'mods-image-preview'; version = '1' } } };",
15 'do { $reply = Receive } until ($reply.id -eq 1);',
16 "Send @{ jsonrpc = '2.0'; method = 'notifications/initialized' };",
17 "Send @{ jsonrpc = '2.0'; id = 2; method = 'tools/call'; params = @{ name = 'openFile'; arguments = @{ filePath = $file; preview = $true; makeFrontmost = $false } } };",
18 'do { $reply = Receive } until ($reply.id -eq 2);',
19 "[void]$ws.CloseAsync(1000, '', $none).Wait(1000);",
20 'if ($reply.error -or $reply.result.isError) { exit 1 } }',
21].join(' ')
22
23export function ideCommand(port: string, lock: string, file: string): string[] {
24 const quote = (value: string) => `'${value.replace(/'/g, "''")}'`
25
26 return ['powershell', '-NoProfile', '-NonInteractive', '-Command', `${IDE_SCRIPT} ${quote(port)} ${quote(lock)} ${quote(file)}`]
27}
28
29export function viewerCommands(file: string, isWindows: boolean): string[][] {
30 return isWindows ? [['explorer', file.replace(/\//g, '\\')]] : [['xdg-open', file], ['open', file]]
31}
32hooks/png.ts 407 lines1export type Rgba = { width: number; height: number; data: Uint8Array }
2
3const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10]
4const MAX_PIXELS = 16_000_000
5
6const LEN_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]
7const LEN_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]
8const DIST_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]
9const DIST_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]
10const CODE_LENGTH_ORDER = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]
11
12const CHANNELS: Record<number, number> = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }
13
14const ADAM7 = [
15 [0, 0, 8, 8],
16 [4, 0, 8, 8],
17 [0, 4, 4, 8],
18 [2, 0, 4, 4],
19 [0, 2, 2, 4],
20 [1, 0, 2, 2],
21 [0, 1, 1, 2],
22]
23
24type Huffman = { table: Int32Array; bits: number }
25
26type Header = {
27 width: number
28 height: number
29 depth: number
30 colorType: number
31 interlace: number
32}
33
34export function isPng(bytes: Uint8Array): boolean {
35 return bytes.length >= 8 && SIGNATURE.every((b, i) => bytes[i] === b)
36}
37
38export function pngSize(bytes: Uint8Array): { width: number; height: number } {
39 if (!isPng(bytes) || bytes.length < 24) throw new Error('not a PNG')
40
41 return { width: u32(bytes, 16), height: u32(bytes, 20) }
42}
43
44export function decodePng(bytes: Uint8Array): Rgba {
45 if (!isPng(bytes)) throw new Error('not a PNG')
46
47 let header: Header | undefined
48 let palette: Uint8Array | undefined
49 let transparency: Uint8Array | undefined
50 const idat: Uint8Array[] = []
51 let pos = 8
52
53 while (pos + 8 <= bytes.length) {
54 const length = u32(bytes, pos)
55 const type = String.fromCharCode(bytes[pos + 4], bytes[pos + 5], bytes[pos + 6], bytes[pos + 7])
56 const body = bytes.subarray(pos + 8, pos + 8 + length)
57 pos += 12 + length
58
59 if (type === 'IHDR') {
60 header = {
61 width: u32(body, 0),
62 height: u32(body, 4),
63 depth: body[8],
64 colorType: body[9],
65 interlace: body[12],
66 }
67 } else if (type === 'PLTE') {
68 palette = body
69 } else if (type === 'tRNS') {
70 transparency = body
71 } else if (type === 'IDAT') {
72 idat.push(body)
73 } else if (type === 'IEND') {
74 break
75 }
76 }
77
78 if (!header) throw new Error('PNG without IHDR')
79
80 const { width, height, depth, colorType, interlace } = header
81 const channels = CHANNELS[colorType]
82
83 if (!channels) throw new Error(`PNG color type ${colorType}`)
84 if (width === 0 || height === 0 || width * height > MAX_PIXELS) throw new Error('PNG too large')
85 if (colorType === 3 && !palette) throw new Error('PNG without PLTE')
86
87 const bitsPerPixel = channels * depth
88 const stride = Math.max(1, bitsPerPixel >> 3)
89 const passes = interlace === 1 ? ADAM7 : [[0, 0, 1, 1]]
90 const sizes = passes.map(([x0, y0, dx, dy]) => [passLength(width, x0, dx), passLength(height, y0, dy)])
91 const rawLength = sizes.reduce((sum, [w, h]) => sum + (w && h ? h * (1 + Math.ceil((w * bitsPerPixel) / 8)) : 0), 0)
92 const raw = inflate(concat(idat).subarray(2), rawLength)
93 const out = new Uint8Array(width * height * 4)
94 const sample = makeSampler(colorType, depth, palette, transparency)
95 let offset = 0
96
97 passes.forEach(([x0, y0, dx, dy], index) => {
98 const [pw, ph] = sizes[index]
99
100 if (!pw || !ph) return
101
102 const rowBytes = Math.ceil((pw * bitsPerPixel) / 8)
103 let previous: Uint8Array = new Uint8Array(rowBytes)
104
105 for (let y = 0; y < ph; y++) {
106 const filter = raw[offset]
107 const row = raw.subarray(offset + 1, offset + 1 + rowBytes)
108 offset += 1 + rowBytes
109 unfilter(filter, row, previous, stride)
110
111 for (let x = 0; x < pw; x++) {
112 sample(row, x, out, ((y0 + y * dy) * width + x0 + x * dx) * 4)
113 }
114
115 previous = row
116 }
117 })
118
119 return { width, height, data: out }
120}
121
122function passLength(size: number, start: number, step: number): number {
123 return size > start ? Math.ceil((size - start) / step) : 0
124}
125
126function u32(bytes: Uint8Array, at: number): number {
127 return ((bytes[at] << 24) | (bytes[at + 1] << 16) | (bytes[at + 2] << 8) | bytes[at + 3]) >>> 0
128}
129
130function concat(parts: Uint8Array[]): Uint8Array {
131 const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0))
132 let at = 0
133
134 for (const part of parts) {
135 out.set(part, at)
136 at += part.length
137 }
138
139 return out
140}
141
142function unfilter(filter: number, row: Uint8Array, previous: Uint8Array, stride: number): void {
143 const length = row.length
144
145 if (filter === 1) {
146 for (let i = stride; i < length; i++) row[i] = (row[i] + row[i - stride]) & 255
147 } else if (filter === 2) {
148 for (let i = 0; i < length; i++) row[i] = (row[i] + previous[i]) & 255
149 } else if (filter === 3) {
150 for (let i = 0; i < length; i++) {
151 const left = i >= stride ? row[i - stride] : 0
152 row[i] = (row[i] + ((left + previous[i]) >> 1)) & 255
153 }
154 } else if (filter === 4) {
155 for (let i = 0; i < length; i++) {
156 const a = i >= stride ? row[i - stride] : 0
157 const b = previous[i]
158 const c = i >= stride ? previous[i - stride] : 0
159 const p = a + b - c
160 const pa = Math.abs(p - a)
161 const pb = Math.abs(p - b)
162 const pc = Math.abs(p - c)
163 row[i] = (row[i] + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c)) & 255
164 }
165 } else if (filter !== 0) {
166 throw new Error(`PNG filter ${filter}`)
167 }
168}
169
170type Sampler = (row: Uint8Array, x: number, out: Uint8Array, at: number) => void
171
172function makeSampler(colorType: number, depth: number, palette?: Uint8Array, transparency?: Uint8Array): Sampler {
173 const max = (1 << depth) - 1
174 const read = depth === 16
175 ? (row: Uint8Array, i: number) => (row[i * 2] << 8) | row[i * 2 + 1]
176 : depth === 8
177 ? (row: Uint8Array, i: number) => row[i]
178 : (row: Uint8Array, i: number) => (row[(i * depth) >> 3] >> (8 - depth - ((i * depth) & 7))) & max
179 const scale = (v: number) => (depth === 16 ? v >> 8 : depth === 8 ? v : Math.round((v * 255) / max))
180 const keyGray = transparency && transparency.length >= 2 ? (transparency[0] << 8) | transparency[1] : -1
181 const keyRgb = transparency && transparency.length >= 6
182 ? [(transparency[0] << 8) | transparency[1], (transparency[2] << 8) | transparency[3], (transparency[4] << 8) | transparency[5]]
183 : undefined
184
185 if (colorType === 0) {
186 return (row, x, out, at) => {
187 const v = read(row, x)
188 out[at] = out[at + 1] = out[at + 2] = scale(v)
189 out[at + 3] = v === keyGray ? 0 : 255
190 }
191 }
192
193 if (colorType === 2) {
194 return (row, x, out, at) => {
195 const r = read(row, x * 3)
196 const g = read(row, x * 3 + 1)
197 const b = read(row, x * 3 + 2)
198 out[at] = scale(r)
199 out[at + 1] = scale(g)
200 out[at + 2] = scale(b)
201 out[at + 3] = keyRgb && r === keyRgb[0] && g === keyRgb[1] && b === keyRgb[2] ? 0 : 255
202 }
203 }
204
205 if (colorType === 3) {
206 const colors = palette as Uint8Array
207
208 return (row, x, out, at) => {
209 const index = read(row, x)
210 out[at] = colors[index * 3] ?? 0
211 out[at + 1] = colors[index * 3 + 1] ?? 0
212 out[at + 2] = colors[index * 3 + 2] ?? 0
213 out[at + 3] = transparency && index < transparency.length ? transparency[index] : 255
214 }
215 }
216
217 if (colorType === 4) {
218 return (row, x, out, at) => {
219 out[at] = out[at + 1] = out[at + 2] = scale(read(row, x * 2))
220 out[at + 3] = scale(read(row, x * 2 + 1))
221 }
222 }
223
224 return (row, x, out, at) => {
225 out[at] = scale(read(row, x * 4))
226 out[at + 1] = scale(read(row, x * 4 + 1))
227 out[at + 2] = scale(read(row, x * 4 + 2))
228 out[at + 3] = scale(read(row, x * 4 + 3))
229 }
230}
231
232export function inflate(data: Uint8Array, expected: number): Uint8Array {
233 let out = new Uint8Array(Math.max(expected, 1024))
234 let length = 0
235 let pos = 0
236 let bitBuffer = 0
237 let bitCount = 0
238
239 const need = (n: number) => {
240 while (bitCount < n) {
241 bitBuffer |= (pos < data.length ? data[pos] : 0) << bitCount
242 pos++
243 bitCount += 8
244 }
245 }
246 const bits = (n: number) => {
247 need(n)
248 const value = bitBuffer & ((1 << n) - 1)
249 bitBuffer >>>= n
250 bitCount -= n
251
252 return value
253 }
254 const symbol = (code: Huffman) => {
255 need(code.bits)
256 const entry = code.table[bitBuffer & ((1 << code.bits) - 1)]
257
258 if (entry < 0) throw new Error('bad Huffman code')
259
260 bitBuffer >>>= entry & 15
261 bitCount -= entry & 15
262
263 return entry >> 4
264 }
265 const reserve = (n: number) => {
266 if (length + n <= out.length) return
267
268 const grown = new Uint8Array(Math.max(out.length * 2, length + n))
269 grown.set(out.subarray(0, length))
270 out = grown
271 }
272
273 let isLast = false
274
275 while (!isLast) {
276 if (pos > data.length + 4) throw new Error('truncated deflate stream')
277
278 isLast = bits(1) === 1
279 const type = bits(2)
280
281 if (type === 0) {
282 pos -= bitCount >> 3
283 bitBuffer = 0
284 bitCount = 0
285 const size = data[pos] | (data[pos + 1] << 8)
286 pos += 4
287 reserve(size)
288 out.set(data.subarray(pos, pos + size), length)
289 length += size
290 pos += size
291 continue
292 }
293
294 if (type === 3) throw new Error('bad deflate block')
295
296 const [literals, distances] = type === 1 ? fixedTables() : readDynamicTables(bits, symbol)
297
298 for (;;) {
299 const value = symbol(literals)
300
301 if (value < 256) {
302 reserve(1)
303 out[length++] = value
304 continue
305 }
306
307 if (value === 256) break
308
309 const lengthIndex = value - 257
310 const size = LEN_BASE[lengthIndex] + bits(LEN_EXTRA[lengthIndex])
311 const distanceIndex = symbol(distances)
312 const distance = DIST_BASE[distanceIndex] + bits(DIST_EXTRA[distanceIndex])
313
314 if (distance > length) throw new Error('bad deflate distance')
315
316 reserve(size)
317
318 for (let i = 0; i < size; i++) {
319 out[length] = out[length - distance]
320 length++
321 }
322 }
323 }
324
325 return out.subarray(0, length)
326}
327
328let fixed: [Huffman, Huffman] | undefined
329
330function fixedTables(): [Huffman, Huffman] {
331 if (fixed) return fixed
332
333 const literal = new Uint8Array(288)
334 literal.fill(8, 0, 144)
335 literal.fill(9, 144, 256)
336 literal.fill(7, 256, 280)
337 literal.fill(8, 280, 288)
338 fixed = [buildHuffman(literal), buildHuffman(new Uint8Array(30).fill(5))]
339
340 return fixed
341}
342
343function readDynamicTables(bits: (n: number) => number, symbol: (code: Huffman) => number): [Huffman, Huffman] {
344 const literalCount = bits(5) + 257
345 const distanceCount = bits(5) + 1
346 const codeLengthCount = bits(4) + 4
347 const codeLengths = new Uint8Array(19)
348
349 for (let i = 0; i < codeLengthCount; i++) codeLengths[CODE_LENGTH_ORDER[i]] = bits(3)
350
351 const codeLengthTable = buildHuffman(codeLengths)
352 const lengths = new Uint8Array(literalCount + distanceCount)
353 let i = 0
354
355 while (i < lengths.length) {
356 const value = symbol(codeLengthTable)
357
358 if (value < 16) {
359 lengths[i++] = value
360 } else if (value === 16) {
361 if (i === 0) throw new Error('bad code length repeat')
362
363 const repeat = 3 + bits(2)
364 lengths.fill(lengths[i - 1], i, i + repeat)
365 i += repeat
366 } else {
367 const repeat = value === 17 ? 3 + bits(3) : 11 + bits(7)
368 i += repeat
369 }
370 }
371
372 return [buildHuffman(lengths.subarray(0, literalCount)), buildHuffman(lengths.subarray(literalCount))]
373}
374
375function buildHuffman(lengths: Uint8Array): Huffman {
376 let maxBits = 1
377
378 for (const length of lengths) if (length > maxBits) maxBits = length
379
380 const counts = new Int32Array(16)
381
382 for (const length of lengths) counts[length]++
383
384 counts[0] = 0
385 const nextCode = new Int32Array(16)
386 let code = 0
387
388 for (let size = 1; size < 16; size++) {
389 code = (code + counts[size - 1]) << 1
390 nextCode[size] = code
391 }
392
393 const table = new Int32Array(1 << maxBits).fill(-1)
394
395 lengths.forEach((size, value) => {
396 if (!size) return
397
398 const assigned = nextCode[size]++
399 let reversed = 0
400
401 for (let k = 0; k < size; k++) reversed = (reversed << 1) | ((assigned >> k) & 1)
402 for (let at = reversed; at < table.length; at += 1 << size) table[at] = (value << 4) | size
403 })
404
405 return { table, bits: maxBits }
406}
407hooks/thumb.ts 215 lines1import { toBase64 } from './base64'
2import type { Rgba } from './png'
3
4export type Fit = { columns: number; rows: number; pxWidth: number; pxHeight: number }
5
6const DEFAULT_COLOR = 0x01000000
7const OPAQUE = 128
8// 사분면 마스크별 전경 글리프, 비트는 왼위 1 오른위 2 왼아래 4 오른아래 8
9const QUADRANT = [
10 0x20, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b,
11 0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f, 0x2588,
12]
13// 동률이면 앞선 분할을 고르므로 반블록 ▀ 우선
14const SPLITS = [0b0011, 0b0101, 0b1001, 0b0001, 0b0010, 0b0100, 0b1000]
15
16export function fitBox(width: number, height: number, maxColumns: number, maxRows: number): Fit {
17 const scale = Math.min(maxColumns / width, (maxRows * 2) / height)
18 const pxWidth = Math.max(1, Math.min(maxColumns, Math.round(width * scale)))
19 const pxHeight = Math.max(1, Math.min(maxRows * 2, Math.round(height * scale)))
20
21 return { columns: pxWidth, rows: Math.ceil(pxHeight / 2), pxWidth, pxHeight }
22}
23
24export function downsample(image: Rgba, width: number, height: number): Rgba {
25 const { width: sourceWidth, height: sourceHeight, data } = image
26 const out = new Uint8Array(width * height * 4)
27
28 for (let ty = 0; ty < height; ty++) {
29 const y0 = Math.floor((ty * sourceHeight) / height)
30 const y1 = Math.max(y0 + 1, Math.floor(((ty + 1) * sourceHeight) / height))
31
32 for (let tx = 0; tx < width; tx++) {
33 const x0 = Math.floor((tx * sourceWidth) / width)
34 const x1 = Math.max(x0 + 1, Math.floor(((tx + 1) * sourceWidth) / width))
35 let r = 0
36 let g = 0
37 let b = 0
38 let a = 0
39 let count = 0
40
41 for (let y = y0; y < y1; y++) {
42 for (let x = x0; x < x1; x++) {
43 const i = (y * sourceWidth + x) * 4
44 const alpha = data[i + 3]
45 r += data[i] * alpha
46 g += data[i + 1] * alpha
47 b += data[i + 2] * alpha
48 a += alpha
49 count++
50 }
51 }
52
53 const o = (ty * width + tx) * 4
54
55 if (a > 0) {
56 out[o] = Math.round(r / a)
57 out[o + 1] = Math.round(g / a)
58 out[o + 2] = Math.round(b / a)
59 }
60
61 out[o + 3] = Math.round(a / count)
62 }
63 }
64
65 return { width, height, data: out }
66}
67
68// 셀마다 2x2 픽셀을 오차 제곱합이 가장 작은 두 색 분할로 근사해 반블록보다 가로 해상도 2배
69export function quadrantCells(image: Rgba): string {
70 const { width, height, data } = image
71 const columns = Math.ceil(width / 2)
72 const rows = Math.ceil(height / 2)
73 const words = new Uint32Array(columns * rows * 3)
74 const quad: (number | undefined)[] = [0, 0, 0, 0]
75
76 const pixel = (x: number, y: number) => {
77 if (x >= width || y >= height) return undefined
78
79 const i = (y * width + x) * 4
80
81 return data[i + 3] < OPAQUE ? undefined : i
82 }
83
84 const mean = (mask: number) => {
85 let r = 0
86 let g = 0
87 let b = 0
88 let count = 0
89
90 for (let bit = 0; bit < 4; bit++) {
91 const i = quad[bit]
92
93 if (!(mask & (1 << bit)) || i === undefined) continue
94
95 r += data[i]
96 g += data[i + 1]
97 b += data[i + 2]
98 count++
99 }
100
101 return [r / count, g / count, b / count]
102 }
103
104 const pack = ([r, g, b]: number[]) => (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(b)
105
106 const error = (mask: number, fg: number[], bg: number[]) => {
107 let sum = 0
108
109 for (let bit = 0; bit < 4; bit++) {
110 const i = quad[bit] as number
111 const c = mask & (1 << bit) ? fg : bg
112 sum += (data[i] - c[0]) ** 2 + (data[i + 1] - c[1]) ** 2 + (data[i + 2] - c[2]) ** 2
113 }
114
115 return sum
116 }
117
118 for (let cy = 0; cy < rows; cy++) {
119 for (let cx = 0; cx < columns; cx++) {
120 quad[0] = pixel(cx * 2, cy * 2)
121 quad[1] = pixel(cx * 2 + 1, cy * 2)
122 quad[2] = pixel(cx * 2, cy * 2 + 1)
123 quad[3] = pixel(cx * 2 + 1, cy * 2 + 1)
124
125 const opaque = quad.reduce<number>((mask, i, bit) => (i === undefined ? mask : mask | (1 << bit)), 0)
126 const w = (cy * columns + cx) * 3
127
128 if (opaque === 0) {
129 words[w] = QUADRANT[0]
130 words[w + 1] = DEFAULT_COLOR
131 words[w + 2] = DEFAULT_COLOR
132 continue
133 }
134
135 // 투명 픽셀은 터미널 배경에 맡기므로 불투명 픽셀만 전경 한 색
136 if (opaque !== 0b1111) {
137 words[w] = QUADRANT[opaque]
138 words[w + 1] = pack(mean(opaque))
139 words[w + 2] = DEFAULT_COLOR
140 continue
141 }
142
143 let best = { mask: 0, fg: [0, 0, 0], bg: [0, 0, 0], error: Infinity }
144
145 for (const mask of SPLITS) {
146 const fg = mean(mask)
147 const bg = mean(~mask & 0b1111)
148 const sum = error(mask, fg, bg)
149
150 if (sum < best.error) best = { mask, fg, bg, error: sum }
151 }
152
153 words[w] = QUADRANT[best.mask]
154 words[w + 1] = pack(best.fg)
155 words[w + 2] = pack(best.bg)
156 }
157 }
158
159 return toBase64(new Uint8Array(words.buffer))
160}
161
162// 화면 캡처로 찾을 단색 마커, 터미널이 채널당 4비트로 줄여 그려도 그대로 남는 0x11 배수 색만 사용
163export function windowKey(n: number, salt: number): number {
164 return 0x220000 | ((0x11 * (salt & 3)) << 8) | (0x11 * (4 + (n % 12)))
165}
166
167export function windowCells(key: number, columns: number, rows: number): string {
168 const words = new Uint32Array(columns * rows * 3)
169
170 for (let i = 0; i < columns * rows; i++) {
171 words[i * 3] = 0x20
172 words[i * 3 + 1] = DEFAULT_COLOR
173 words[i * 3 + 2] = key
174 }
175
176 return toBase64(new Uint8Array(words.buffer))
177}
178
179const MARK_FIRST = 0x2800
180const MARK_MAGIC = 0xa
181const MARK_STEP = 17
182const MARK_MAX_ROWS = 16
183
184export function markId(path: string): number {
185 let hash = 0x811c9dc5
186
187 for (let i = 0; i < path.length; i++) hash = Math.imul(hash ^ path.charCodeAt(i), 0x01000193) >>> 0
188
189 return hash & 0xfffff
190}
191
192export function markHex(id: number): string {
193 return id.toString(16).padStart(5, '0')
194}
195
196export function markCells(id: number, columns: number, rows: number): string {
197 if (rows > MARK_MAX_ROWS) throw new Error(`marker rows over ${MARK_MAX_ROWS}`)
198
199 const n = [MARK_MAGIC, (id >> 16) & 15, (id >> 12) & 15, (id >> 8) & 15, (id >> 4) & 15, id & 15].map(v => v * MARK_STEP)
200 const fg = (n[0] << 16) | (n[1] << 8) | n[2]
201 const bg = (n[3] << 16) | (n[4] << 8) | n[5]
202 const words = new Uint32Array(columns * rows * 3)
203
204 for (let row = 0; row < rows; row++) {
205 for (let x = 0; x < columns; x++) {
206 const w = (row * columns + x) * 3
207 words[w] = MARK_FIRST + ((row << 4) | (rows - 1))
208 words[w + 1] = fg
209 words[w + 2] = bg
210 }
211 }
212
213 return toBase64(new Uint8Array(words.buffer))
214}
215types/index.d.ts 23 lines1export type Mode = 'blocks' | 'pixels' | 'overlay' | 'window'
2
3export type Preview = {
4 n: number
5 source?: string
6 width?: number
7 height?: number
8 thumb?: string
9 thumbWidth?: number
10 thumbHeight?: number
11 file?: string
12 id?: number
13 note?: string
14 isPending?: boolean
15 isInIde?: boolean
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'mods-image-preview': { previews: Preview[]; mode: Mode }
21 }
22}
23