SLOPSHOPPER

mods-image-preview

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

newbandpromptprocesstimer
★ 3v0.5.0MITupdated 2026-10-07hahmjuntae/claude-mods-image-preview
A shopper browsing a rack in a slop shop
README

claude-mods-image-preview

English | 한국어

A Claude Code mod that shows thumbnails of pasted images right above the prompt, in any terminal.

Thumbnails of two pasted images above the Claude Code prompt

Terminals without image support get the same layout in colored quadrant blocks:

╭────────────╮ ╭────────────────────────╮
│▀▀▀▀▀▀▀▀▀▀▀▀│ │▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│
│▀▀▀▀▀▀▀▀▀▀▀▀│ │▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀│
│▀▀▀▀▀▀▀▀▀▀▀▀│ │           #2           │
│     #1     │ ╰────────────────────────╯
╰────────────╯
❯ [Image #1] [Image #2] compare these two screens

Install

Type this at the prompt of a Claude Code terminal session:

/plugin install mods-image-preview --marketplace hahmjuntae/claude-mods-image-preview
  1. Answer y to Add marketplace?.
  2. Pick a scope. The user scope is the default.
  3. When 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.

How it works

StepWhat happens
DetectReads the prompt draft every 300 ms and looks for [Image #N] tokens
SourceReads images/N.*, which Claude Code writes to the session temp folder the moment you paste
DrawRenders a thumbnail with the best renderer your terminal supports (see below)
FitShrinks 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
OpenOpens the original in the connected IDE's editor without taking the focus (see Original quality)
ClearThumbnails disappear when you delete the token or submit the prompt

Renderers

TerminalThumbnail
kitty, Ghostty (outside tmux, screen and SSH)Real pixels through the kitty graphics protocol
A terminal that advertises the overlay (see Terminal integration), tmux includedReal pixels drawn by the terminal over marker cells
Everything else: iTerm2, Terminal.app, WezTerm, Alacritty, Windows Terminal, VS Code, tmux, screen, SSHColored quadrant block characters (▀ ▌ ▚ ▗ ...), two pixels across and two down per cell
Any terminal or IDE on Windows, with renderer set to windowThe 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.

tmux

Thumbnails work inside tmux with no extra setup. Two settings decide whether their colors are exact:

SettingWhereWhy
export CLAUDE_CODE_TMUX_TRUECOLOR=1Shell 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.

Windows

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.

Original quality

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.
  • With 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.
  • Pressing a thumbnail's number (#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.

Settings

Change them in /config.

NameValuesDefault
rendererauto, blocks, pixels, overlay, windowauto
sizesmall 16x4, medium 24x6, large 40x10 (terminal cells)medium
openoff, ide, vieweroff

auto picks the overlay when the terminal advertises it, pixels on kitty and Ghostty, and quadrant blocks everywhere else.

Supported formats

FormatHandling
PNGBuilt-in decoder: 1/2/4/8/16-bit, palette, transparency, interlaced
JPEG, GIF, WebP, HEIC and othersConverted to a small PNG with the first converter found: macOS sips, ImageMagick (magick, convert), Windows PowerShell
PNG over 4 MiBDownscaled with the same converters

Without any of these converters, formats other than PNG show unsupported.

Terminal integration

A terminal can show real pixels even inside tmux by drawing an image over marker cells.

  1. Advertise the overlay by setting 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.
  2. The mod draws marker cells in place of the thumbnail and links the source image to ~/.claude/claude-mods-image-preview/<id as five hex digits>.
  3. The terminal scans its screen for marker cells, groups them by id and draws the linked image over them.
FieldEncoding
Glyph`U+2800 + (row << 4 \rows - 1)`, row counted from 0, at most 16 rows
ForegroundNibbles 0xA, id[19:16], id[15:12], each written as channel value nibble × 17
BackgroundNibbles id[11:8], id[7:4], id[3:0], each written as channel value nibble × 17
idLow 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.

Development

claude plugin validate .
claude plugin test .

License

MIT

Source 7 files
hooks/register.tsx 567 lines
1import { 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}
567
hooks/base64.ts 11 lines
1type 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}
11
hooks/layout.ts 31 lines
1import { 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}
31
hooks/open.ts 32 lines
1// 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}
32
hooks/png.ts 407 lines
1export 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}
407
hooks/thumb.ts 215 lines
1import { 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}
215
types/index.d.ts 23 lines
1export 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