Preview images pasted into the prompt box, in any terminal

Claude Code mod:在輸入框貼上圖片(Ctrl+V)時,於輸入框上方顯示縮圖預覽。
▀ 畫縮圖[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 .hooks/register.tsx 218 lines1import { 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}
218types/index.d.ts 21 lines1export 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