SLOPSHOPPER

paste-preview

Preview images pasted into the prompt box, in any terminal

newbandcommandtoastpromptprocess
v0.1.0no licenseupdated 2026-10-06craneyu/claude-paste-preview
A shopper browsing a rack in a slop shop
README

paste-preview

Claude Code mod:在輸入框貼上圖片(Ctrl+V)時,於輸入框上方顯示縮圖預覽。

  • 任何 truecolor 終端機:以半格字元 ▀ 畫縮圖
  • Ghostty、kitty:自動改用終端機圖像協定顯示原圖
  • 「開原圖」按鈕:用 macOS 預設看圖程式開啟
  • 送出訊息或刪掉 [Image #N] 後自動收起

限 macOS(剪貼簿經 osascript 讀取)。

安裝

/plugin install paste-preview --marketplace craneyu/claude-paste-preview

出現 Add marketplace? 時按 y,再選 user scope。

指令

/paste-preview [now|open|clear|hd|text|auto|off]

參數作用
now(預設)手動擷取剪貼簿圖片
open開啟預覽中的原圖
clear收起預覽
hd / text / auto強制圖像協定 / 強制半格字元 / 依終端機自動判斷
off停用自動預覽

開發

claude plugin validate .
claude plugin test .
Source 2 files
hooks/register.tsx 218 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Mode, Preview } from '../types'
5
6const previews = atom({ plugin: 'paste-preview', key: 'previews' } as const, [] as Preview[])
7const mode = atom({ plugin: 'paste-preview', key: 'mode' } as const, 'auto' as Mode)
8const isHd = atom({ plugin: 'paste-preview', key: 'isHd' } as const, false)
9
10/** 一張縮圖的上限(欄 x 列);一次最多並排幾張 */
11const MAX_COLS = 32
12const MAX_ROWS = 10
13const MAX_SHOWN = 3
14
15/** 支援終端機圖像協定(kitty graphics + Unicode placeholder)的 TERM_PROGRAM */
16const HD_TERMINALS = ['ghostty', 'kitty']
17
18const IMAGE_TOKEN = /\[Image #\d+\]/g
19
20const imageTokens = (text: string) => text.match(IMAGE_TOKEN) ?? []
21
22/** 輪詢輸入框的間隔 */
23const POLL_MS = 400
24
25/** 32bpp BMP(NSBitmapImageRep 產出,BI_BITFIELDS)轉成 Raster 的半格字元 cells */
26export const bmpToCells = (bmp: Uint8Array): { cells: string; columns: number; rows: number } => {
27  const view = new DataView(bmp.buffer, bmp.byteOffset, bmp.byteLength)
28  const offset = view.getUint32(10, true)
29  const width = view.getInt32(18, true)
30  const rawHeight = view.getInt32(22, true)
31  const bpp = view.getUint16(28, true)
32  const height = Math.abs(rawHeight)
33  const isTopDown = rawHeight < 0
34  const masks =
35    view.getUint32(30, true) === 3
36      ? [54, 58, 62, 66].map(at => view.getUint32(at, true))
37      : [0x00ff0000, 0x0000ff00, 0x000000ff, bpp === 32 ? 0xff000000 : 0]
38  const shifts = masks.map(m => (m === 0 ? 0 : 31 - Math.clz32(m & -m)))
39  const stride = Math.ceil((width * bpp) / 32) * 4
40  const bytes = bpp / 8
41
42  const pixel = (x: number, y: number): number => {
43    const row = isTopDown ? y : height - 1 - y
44    const at = offset + row * stride + x * bytes
45    let word = 0
46    for (let k = bytes - 1; k >= 0; k--) word = (word * 256 + view.getUint8(at + k)) >>> 0
47    const channel = (i: number) => {
48      const m = masks[i] ?? 0
49      return m === 0 ? 255 : ((word & m) >>> 0) >>> (shifts[i] ?? 0)
50    }
51    // 透明像素交給終端機預設底色
52    return channel(3) < 128 ? 0x01000000 : (channel(0) << 16) | (channel(1) << 8) | channel(2)
53  }
54
55  const rows = Math.ceil(height / 2)
56  const words = new Uint32Array(width * rows * 3)
57  for (let y = 0; y < rows; y++) {
58    for (let x = 0; x < width; x++) {
59      const top = pixel(x, y * 2)
60      const bottom = y * 2 + 1 < height ? pixel(x, y * 2 + 1) : 0x01000000
61      const i = (y * width + x) * 3
62      // ▀:前景畫上半像素、背景畫下半像素
63      words[i] = 0x2580
64      words[i + 1] = top
65      words[i + 2] = bottom
66    }
67  }
68
69  return { cells: new Uint8Array(words.buffer).toBase64(), columns: width, rows }
70}
71
72const capture = async ($: EngineInterface) => {
73  const list = await read($, previews)
74  const path = `${$.plugin.root}/.cache/paste-${Date.now()}.png`
75  const { exitCode, stdout, stderr } = await $.process.run(
76    ['osascript', '-l', 'JavaScript', `${$.plugin.root}/bin/clip.js`, path, String(MAX_COLS), String(MAX_ROWS)],
77    { timeoutMs: 10_000 },
78  )
79  if (exitCode !== 0) {
80    $.ui.toast(`paste-preview: 讀不到剪貼簿(${stderr.trim().slice(0, 80)})`)
81    return
82  }
83  const out = JSON.parse(stdout)
84  if (out.error) {
85    $.ui.toast('paste-preview: 剪貼簿裡沒有圖片')
86    return
87  }
88  const raster = bmpToCells(Uint8Array.fromBase64(out.bmp))
89  const preview: Preview = { path, width: Number(out.width), height: Number(out.height), ...raster }
90  await update($, previews, () => [...list, preview].slice(-MAX_SHOWN))
91}
92
93export const register: Register = on => {
94  on('session.start', async ($, e, next) => {
95    await $.command.register({
96      name: 'paste-preview',
97      description: 'Preview pasted images: now | open | clear | hd | text | auto | off',
98      argumentHint: '[now|open|clear|hd|text|auto|off]',
99    })
100    const { stdout } = await $.process.run(['/usr/bin/printenv', 'TERM_PROGRAM'])
101    const { exitCode: kitty } = await $.process.run(['/usr/bin/printenv', 'KITTY_WINDOW_ID'])
102    const term = stdout.trim().toLowerCase()
103    await update($, isHd, () => kitty === 0 || HD_TERMINALS.some(name => term.includes(name)))
104    // 清掉一天前的暫存原圖
105    await $.process
106      .run(['/usr/bin/find', `${$.plugin.root}/.cache`, '-name', 'paste-*.png', '-mtime', '+1', '-delete'])
107      .catch(() => undefined)
108
109    // 貼圖時引擎直接把 [Image #N] 放進輸入框、不觸發 prompt.edit,所以輪詢輸入框
110    let seen = new Set(imageTokens((await $.prompt.read()).text))
111    let isBusy = false
112    $.clock.every(POLL_MS, () => {
113      if (isBusy) return
114      isBusy = true
115      void (async () => {
116        const tokens = imageTokens((await $.prompt.read()).text)
117        const isNew = tokens.some(t => !seen.has(t))
118        seen = new Set(tokens)
119        if (isNew && (await read($, mode)) !== 'off') {
120          await capture($)
121        } else if (tokens.length === 0 && (await read($, previews)).length > 0) {
122          // 圖片標記都被刪掉了,收起預覽
123          await update($, previews, () => [])
124        }
125      })()
126        .catch(error => $.ui.toast(`paste-preview: ${String(error).slice(0, 100)}`))
127        .finally(() => {
128          isBusy = false
129        })
130    })
131
132    return next(e)
133  })
134
135  on('prompt.submit', async ($, e, next) => {
136    // 清除失敗不能擋住送出
137    void update($, previews, () => []).catch(() => undefined)
138
139    return next(e)
140  })
141
142  on('command.run', { command: 'paste-preview' }, async ($, e) => {
143    const arg = e.args.trim().toLowerCase()
144    const list = await read($, previews)
145    if (arg === '' || arg === 'now') {
146      await capture($)
147      return { text: '已擷取剪貼簿圖片。' }
148    }
149    if (arg === 'open') {
150      if (list.length === 0) return { text: '目前沒有預覽中的圖片。' }
151      await $.process.run(['open', ...list.map(p => p.path)])
152      return { text: `已用預設看圖程式開啟 ${list.length} 張。` }
153    }
154    if (arg === 'clear') {
155      await update($, previews, () => [])
156      return { text: '預覽已清除。' }
157    }
158    if (arg === 'hd' || arg === 'text' || arg === 'auto' || arg === 'off') {
159      await update($, mode, () => arg)
160      return { text: `顯示模式:${arg}` }
161    }
162    return { text: '用法:/paste-preview [now|open|clear|hd|text|auto|off]' }
163  })
164
165  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
166    const list = await read($, previews)
167    const current = await read($, mode)
168    if (e.surface !== 'terminal' || e.props.hasSurvey || list.length === 0 || current === 'off') {
169      return next(e)
170    }
171    const useHd = current === 'hd' || (current === 'auto' && (await read($, isHd)))
172    const { Box, Button, Image, Raster, Text } = $.ui.resolve(e)
173    // 由最新一張往回塞,塞到區塊寬度為止
174    const shown: Preview[] = []
175    let width = 0
176    for (const p of [...list].reverse()) {
177      width += p.columns + (shown.length > 0 ? 2 : 0)
178      if (width > e.props.bodyColumns && shown.length > 0) break
179      shown.unshift(p)
180    }
181    const hidden = list.length - shown.length
182
183    return (
184      <Box flexDirection="column">
185        <Box flexDirection="row" gap={2}>
186          {shown.map((p, i) => (
187            <Box key={p.path} flexDirection="column">
188              {useHd ? (
189                <Image
190                  key={`img-${i}`}
191                  source={{ file: p.path, format: 'png' }}
192                  columns={p.columns}
193                  rows={p.rows}
194                  alt={`[圖片 ${p.width}x${p.height}]`}
195                />
196              ) : (
197                <Raster key={`img-${i}`} cells={p.cells} columns={p.columns} rows={p.rows} />
198              )}
199              <Text dimColor>
200                {p.width}x{p.height}
201              </Text>
202            </Box>
203          ))}
204        </Box>
205        <Box flexDirection="row" gap={1}>
206          <Button
207            key="open"
208            label="開原圖"
209            onPress={() => $.process.run(['open', ...list.map(p => p.path)])}
210          />
211          <Button key="hide" label="收起" onPress={() => update($, previews, () => [])} />
212          {hidden > 0 && <Text dimColor>另有 {hidden} 張放不下</Text>}
213        </Box>
214      </Box>
215    )
216  })
217}
218
types/index.d.ts 21 lines
1export type Preview = {
2  /** 原圖 PNG 的絕對路徑 */
3  path: string
4  /** 原圖像素尺寸 */
5  width: number
6  height: number
7  /** 半格字元縮圖:Raster 的 cells、欄數、列數 */
8  cells: string
9  columns: number
10  rows: number
11}
12
13/** auto:依 TERM_PROGRAM 判斷;hd:終端機圖像協定;text:半格字元;off:關閉 */
14export type Mode = 'auto' | 'hd' | 'text' | 'off'
15
16declare module 'claude-code' {
17  interface PluginState {
18    'paste-preview': { previews: Preview[]; mode: Mode; isHd: boolean }
19  }
20}
21