在终端右侧停靠栏里显示本会话的图片:你发给 agent 的图,以及 agent(含子 agent)读到、截到、工具返回的图。只在终端里工作,Claude Desktop 里整个 mod 不做任何事。

在终端右侧的停靠栏里显示本会话里的图片:
每张图会标出序号、来源、工具名和时间。h / l 翻上一张和下一张,f 回到"跟随最新"。按 o 或"打开原图"按钮(图片本身点不了:Image 元素不接受点击)用系统默认程序打开原图:Read 读的文件、你贴图时 Claude Code 存的原文件,找不到这些时打开 mod 保存的未缩小原件。/images 打开这个栏。
-p、SDK)就什么都不注册、不收图;Desktop 的界面里也不画这个栏。tool.call 里收。先加载的 mod 在 tool.call 里直接答掉、不往下传的调用(例如 dispatch-pilot 的 visual_check)image-pane 看不到,这些在下一步发请求前(turn.step)从对话里的 tool_result 收,按 tool_use_id 不重复收。$.state:解码后写到 /tmp/image-pane/<sessionId>/<n>.png,界面让终端自己读这个文件。非 PNG(JPEG、WebP、GIF)用 macOS 的 sips 转成 PNG;别的系统上只能显示 PNG,其余只留文字条目。/config 里改 cellAspect:在终端里运行 printf '\e[16t'; read -rs -t1 -d t r; printf '%q\n' "$r",回报 6;高;宽,高除以宽即是。窗口大小、全屏与否和 herdr 都不影响它。claude --plugin-dir ./image-pane
claude plugin install image-pane@alex-mods --scope user
测试时的 Claude Code 版本:2.1.309(Desktop 自带的 2.1.308 上 validate 通过)。
hooks/image-pane.tsx 251 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ImageEntry } from '../types'
5import { CELL_ASPECT, classify, findImages, imageSources, fingerprint, fitCells, ORIGIN_LABEL, pngSize } from './core'
6import type { FoundImage, Origin } from './core'
7
8const PANE = 'image-pane'
9const TITLE = 'Images'
10const KEEP = 200
11
12const images = atom({ plugin: 'image-pane', key: 'images' } as const, [])
13const selected = atom({ plugin: 'image-pane', key: 'selected' } as const, null)
14const seen = atom({ plugin: 'image-pane', key: 'seen' } as const, [])
15const calls = atom({ plugin: 'image-pane', key: 'calls' } as const, [])
16
17// 解码 stdin 的 base64,留作 <n>.orig.<ext>,「打开原图」找不到真实来源时打开它。
18// 显示用的 PNG 先从真实来源 $4(Read 的文件、贴图的原文件)做:引擎给的那份已缩小并可能重编码成 JPEG,
19// 透明背景已经丢了。来源读不到或不是图片,再回退到 stdin 那份。非 PNG 用 sips 转成 PNG(macOS 才有 sips,别处只收 PNG)。
20// 报宽高,再把超过 2 MiB 的缩小:iTerm2 只画 `{ png }` 字节,字节最多 2 MiB。
21const SAVE = `dir=$1; name=$2; mt=$3; src=$4
22mkdir -p "$dir" || exit 1
23ext=\${mt#image/}; orig="$dir/$name.orig.$ext"; png="$dir/$name.png"
24base64 -d > "$orig" || exit 1
25done=
26if [ -n "$src" ] && [ -f "$src" ]; then
27 case "$(sips -g format "$src" 2>/dev/null | awk '/format:/{print $2}')" in
28 png) cp "$src" "$png" && done=1 ;;
29 jpeg|gif|tiff|bmp|heic|webp) sips -s format png "$src" --out "$png" >/dev/null 2>&1 && done=1 ;;
30 esac
31 # 认不出的文件 sips 会报 <nil>,写出的 PNG 也读不出宽度:都不算,回退。
32 sips -g pixelWidth "$png" 2>/dev/null | grep -q 'pixelWidth: [0-9]' || done=
33fi
34if [ -z "$done" ]; then
35 if [ "$mt" = image/png ]; then cp "$orig" "$png"; else
36 sips -s format png "$orig" --out "$png" >/dev/null 2>&1 || exit 2
37 fi
38fi
39sips -g pixelWidth -g pixelHeight "$png" 2>/dev/null | awk '/pixelWidth/{w=$2}/pixelHeight/{h=$2}END{print w" "h}'
40# 长边超过 1600 先缩到 1600:栏宽画不出更多像素,而 herdr 把图解成 RGBA 再转给外层终端,
41# 3248×2122 解开约 27 MiB,base64 后超过它 32 MiB 的图形帧上限,整张被丢掉,栏里只剩空白。
42sips -g pixelWidth -g pixelHeight "$png" 2>/dev/null | awk '/pixel/{if($2>1600)big=1}END{exit !big}' && sips -Z 1600 "$png" >/dev/null 2>&1
43for side in 1600 1200 800 500; do
44 [ "$(wc -c < "$png")" -le 2000000 ] && break
45 sips -Z $side "$png" >/dev/null 2>&1 || break
46done`
47
48// 画图用的 base64,按序号缓存,免得每次重绘都读 2 MiB 文件。
49// 热重载后清空,代价只是下一次重绘再读一次文件。
50const bytes = new Map<number, string>()
51
52async function pngOf($: EngineInterface, one: ImageEntry): Promise<string | null> {
53 if (!one.file) return null
54 const hit = bytes.get(one.n)
55 if (hit) return hit
56 try {
57 const { base64 } = await $.fs.read(one.file, { as: 'bytes' })
58 bytes.set(one.n, base64)
59 return base64
60 } catch {
61 return null
62 }
63}
64
65// 只在终端里工作:session.start 时没有终端(Claude Desktop、-p、SDK)就整个 mod 不动。
66// 热重载会重跑 session.start,所以放模块变量即可,丢了会在下一次 session.start 补回。
67let isTerminal = false
68
69export const register: Register = (on, options) => {
70 // 换了字体或行距时在 /config 里改;终端里 `printf '\e[16t'` 回报的 `6;高;宽` 相除就是它。
71 const aspect = typeof options.cellAspect === 'number' && options.cellAspect > 0 ? options.cellAspect : CELL_ASPECT
72
73 on('session.start', async ($, e, next) => {
74 const surfaces = await $.session.surfaces()
75 isTerminal = e.surface === 'terminal' || surfaces.includes('terminal')
76 if (!isTerminal) return next(e)
77
78 await $.command.register({
79 name: 'images',
80 description: '打开本会话的图片栏(你发的图和 agent 读到、截到的图)',
81 })
82 if ((await read($, images)).length > 0) void $.ui.open({ id: PANE, title: TITLE })
83
84 return next(e)
85 })
86
87 on('command.run', { command: 'images' }, async $ => {
88 if (!isTerminal) return { text: 'image-pane 只在终端里显示图片。' }
89 const opened = await $.ui.open({ id: PANE, title: TITLE })
90 const count = (await read($, images)).length
91
92 return { text: opened.isPlaced ? `图片栏已打开,共 ${count} 张。` : '图片栏已打开,终端放不下时会等到有空间再显示。' }
93 })
94
95 // 用户发的图:这一轮开始时,最后一条用户消息里直接带的 image 块。
96 on('turn.start', async ($, e, next) => {
97 const r = await next(e)
98 if (!isTerminal || e.agentId !== undefined) return r
99 const messages = await $.session.messages({ as: 'api' })
100 const last = [...messages].reverse().find(m => m.role === 'user')
101 const blocks = last?.content ?? []
102 const found = blocks.filter(b => b.type === 'image').flatMap(b => findImages(b))
103 // 引擎把贴的图缩小后放进消息,原文件路径在紧跟的 `[Image: source: …]` 文本里;只有一张时才对得上。
104 const sources = blocks.flatMap(b => (b.type === 'text' && typeof b.text === 'string' ? imageSources(b.text) : []))
105 const sourcePath = found.length === 1 && sources.length === 1 ? sources[0] : undefined
106 await collect($, found, { origin: 'user', sourcePath }).catch(() => {})
107
108 return r
109 })
110
111 // agent 的图:任何工具结果里带的图片,主循环和子 agent 都算。
112 on('tool.call', async ($, e, next) => {
113 const r = await next(e)
114 if (!isTerminal || r.deny !== undefined || r.isError) return r
115 const id = (e as unknown as { tool_use_id?: unknown }).tool_use_id
116 if (typeof id === 'string') await update($, calls, list => [...list, id].slice(-KEEP))
117 const found = findImages(r.result)
118 if (found.length === 0) return r
119 const input = e as unknown as { file_path?: unknown }
120 await collect($, found, {
121 origin: classify(e.tool),
122 tool: e.tool,
123 agentId: e.agentId,
124 sourcePath: e.tool === 'Read' && typeof input.file_path === 'string' ? input.file_path : undefined,
125 }).catch(() => {})
126
127 return r
128 })
129
130 // 上面那个 tool.call 看不到的工具结果:先加载的 mod 在 tool.call 里直接答掉、不往下传的调用
131 // (dispatch-pilot 的 visual_check),classic.PostToolUse 也不为它们触发。它们的结果仍会进对话,
132 // 所以每一步发请求前,看最后一条用户消息里的 tool_result,收 tool.call 没经手的那些。
133 on('turn.step', async function* ($, e, next) {
134 if (isTerminal) await collectUnseen($, e.agentId).catch(() => {})
135 return yield* next(e)
136 })
137
138 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
139 if (e.surface !== 'terminal') return next(e)
140 const { Box, Text, Button, Image } = $.ui.resolve(e)
141 const list = await read($, images)
142 const pick = await read($, selected)
143 const { bodyColumns, scroll } = e.props
144
145 if (list.length === 0) return <Text dimColor>还没有图片。你发的图和 agent 读到、截到的图会出现在这里。</Text>
146
147 const at = pick === null ? list.length - 1 : Math.max(0, list.findIndex(one => one.n === pick))
148 const one = list[at]
149 if (!one) return next(e)
150 const go = (to: number) => update($, selected, () => (to >= list.length - 1 ? null : list[Math.max(0, to)]?.n ?? null))
151 const size = fitCells(one.width, one.height, bodyColumns, scroll.bodyRows - 4, aspect)
152 const alt = `${ORIGIN_LABEL[one.origin]} ${one.sourcePath ?? one.file ?? ''}`.trim()
153 const time = new Date(one.at).toTimeString().slice(0, 5)
154 const png = await pngOf($, one)
155 // Image 不接受点击,叠按钮又会盖掉像素,所以只能用按钮或热键打开。
156 const target = one.sourcePath ?? one.original
157 const open = () => void openOriginal($, one)
158
159 return (
160 <Box flexDirection="column">
161 <Text>
162 {at + 1}/{list.length} · {ORIGIN_LABEL[one.origin]}
163 {one.tool ? ` · ${one.tool}` : ''}
164 {one.agentId ? ` · 子 agent ${one.agentId.slice(0, 8)}` : ''}
165 <Text dimColor> {time}{one.width ? ` · ${one.width}×${one.height}` : ''}</Text>
166 </Text>
167 {png ? (
168 <Image key="shown" source={{ png }} columns={size.columns} rows={size.rows} alt={alt} />
169 ) : (
170 <Text dimColor>(这张图没能转成 PNG,无法显示:{one.mediaType})</Text>
171 )}
172 {one.sourcePath && <Text dimColor>{one.sourcePath}</Text>}
173 <Box flexDirection="row" gap={1}>
174 <Button key="prev" label="上一张" hotkey="h" onPress={() => go(at - 1)} />
175 <Button key="next" label="下一张" hotkey="l" onPress={() => go(at + 1)} />
176 {target && <Button key="open" label="打开原图" hotkey="o" onPress={open} />}
177 <Button key="latest" label={pick === null ? '跟随最新 ✓' : '跟随最新'} hotkey="f" onPress={() => go(list.length - 1)} />
178 </Box>
179 </Box>
180 )
181 })
182}
183
184// 「打开原图」:先开真实来源(Read 的文件、贴图时引擎存的原文件),开不了再开保存时留下的原件。
185async function openOriginal($: EngineInterface, one: ImageEntry) {
186 for (const path of [one.sourcePath, one.original]) {
187 if (!path) continue
188 const run = await $.process.run(['open', path]).catch(() => null)
189 if (run?.exitCode === 0) return
190 }
191 await $.ui.toast('打不开这张图的原图:文件可能已被移走。')
192}
193
194async function collectUnseen($: EngineInterface, agentId: string | undefined) {
195 const messages = agentId === undefined ? await $.session.messages({ as: 'api' }) : await $.session.messages({ as: 'api', agentId })
196 if (!Array.isArray(messages)) return
197 const lastUser = messages.findLastIndex(m => m.role === 'user')
198 const results = (messages[lastUser]?.content ?? []).filter(b => b.type === 'tool_result')
199 if (results.length === 0) return
200 const known = new Set(await read($, calls))
201 const uses = (messages[lastUser - 1]?.content ?? []).filter(b => b.type === 'tool_use')
202 for (const result of results) {
203 const id = String(result.tool_use_id)
204 if (known.has(id)) continue
205 await update($, calls, list => [...list, id].slice(-KEEP))
206 const found = findImages(result.content)
207 if (found.length === 0) continue
208 const tool = String(uses.find(u => u.id === id)?.name ?? '')
209 await collect($, found, { origin: classify(tool), tool: tool || undefined, agentId })
210 }
211}
212
213type Meta = { origin: Origin; tool?: string; agentId?: string; sourcePath?: string }
214
215async function collect($: EngineInterface, found: FoundImage[], meta: Meta) {
216 if (found.length === 0) return
217 const known = new Set(await read($, seen))
218 const fresh = found.filter(f => !known.has(fingerprint(f.base64)))
219 if (fresh.length === 0) return
220
221 const dir = `/tmp/image-pane/${await $.session.id()}`
222 let n = ((await read($, images)).at(-1)?.n ?? 0) + 1
223 const added: ImageEntry[] = []
224 for (const f of fresh) {
225 const entry: ImageEntry = { n, ...meta, mediaType: f.mediaType, at: Date.now() }
226 try {
227 // 一次收到几张图时对不上哪张是来源,只在一张时用它。
228 const source = fresh.length === 1 ? (meta.sourcePath ?? '') : ''
229 const run = await $.process.run(['/bin/sh', '-c', SAVE, 'sh', dir, String(n), f.mediaType, source], {
230 stdin: f.base64,
231 timeoutMs: 8000,
232 })
233 if (run.exitCode === 0) {
234 entry.file = `${dir}/${n}.png`
235 entry.original = `${dir}/${n}.orig.${f.mediaType.slice('image/'.length)}`
236 const [w, h] = run.stdout.trim().split(' ').map(Number)
237 const dims = w && h ? { width: w, height: h } : f.mediaType === 'image/png' ? pngSize(f.base64) : null
238 if (dims) Object.assign(entry, dims)
239 }
240 } catch {
241 // 写不出文件就只留文字条目
242 }
243 added.push(entry)
244 n++
245 }
246
247 await update($, seen, list => [...list, ...fresh.map(f => fingerprint(f.base64))].slice(-KEEP))
248 await update($, images, list => [...list, ...added].slice(-KEEP))
249 void $.ui.open({ id: PANE, title: TITLE })
250}
251hooks/core.ts 104 lines1// 纯函数:从任意工具结果或 API 消息里找出图片,判断来源,算显示尺寸。
2// 不碰 `$`,测试直接调用。
3
4export type FoundImage = { mediaType: string; base64: string }
5
6const MEDIA = /^image\/(png|jpeg|jpg|gif|webp)$/
7
8// 认得的几种形状:
9// Read 的 `{ type: 'image', file: { base64, type } }`;
10// MCP 的 `{ type: 'image', data, mimeType }`;
11// Messages API 的 `{ type: 'image', source: { type: 'base64', media_type, data } }`;
12// 其他工具的 `{ media_type, data }`。
13export function findImages(value: unknown, depth = 0, out: FoundImage[] = []): FoundImage[] {
14 if (depth > 8 || value === null || typeof value !== 'object') return out
15 if (Array.isArray(value)) {
16 for (const one of value) findImages(one, depth + 1, out)
17 return out
18 }
19 const o = value as Record<string, unknown>
20 const mediaType = [o.mimeType, o.media_type, o.mediaType, o.type].find(
21 (t): t is string => typeof t === 'string' && MEDIA.test(t),
22 )
23 const base64 = [o.base64, o.data].find((d): d is string => typeof d === 'string' && d.length > 32)
24 if (mediaType && base64) {
25 out.push({ mediaType, base64 })
26 return out
27 }
28 for (const key of Object.keys(o)) findImages(o[key], depth + 1, out)
29 return out
30}
31
32export type Origin = 'user' | 'read' | 'screenshot' | 'tool'
33
34export function classify(tool: string): Origin {
35 if (tool === 'Read') return 'read'
36 if (/screenshot|zoom|snapshot|capture|computer/i.test(tool)) return 'screenshot'
37 return 'tool'
38}
39
40export const ORIGIN_LABEL: Record<Origin, string> = {
41 user: '用户发的',
42 read: '读取',
43 screenshot: '截图',
44 tool: '工具结果',
45}
46
47// 去重用的指纹:长度加首尾片段,不必为了它整段做哈希。
48export function fingerprint(base64: string): string {
49 return `${base64.length}:${base64.slice(0, 48)}:${base64.slice(-48)}`
50}
51
52// PNG 的宽高在 IHDR 里(第 16 到 23 字节),解开前 32 个 base64 字符就够。
53export function pngSize(base64: string): { width: number; height: number } | null {
54 try {
55 const head = decode(base64.slice(0, 32))
56 if (head[1] !== 0x50 || head[2] !== 0x4e || head[3] !== 0x47) return null
57 const b = (i: number) => head[i] ?? 0
58 const at = (i: number) => ((b(i) << 24) | (b(i + 1) << 16) | (b(i + 2) << 8) | b(i + 3)) >>> 0
59 return { width: at(16), height: at(20) }
60 } catch {
61 return null
62 }
63}
64
65// 格子的高宽比:iTerm2 的 Hack 14pt 实测一格 8×17 point(herdr 里 16×34 像素),17/8。
66// 它只跟字体、字号和行距有关,跟窗口大小、全屏与否、herdr 都无关;按 2 算图会被纵向拉长约 6%。
67export const CELL_ASPECT = 17 / 8
68
69// 行数是列数乘图的高宽比再除以格子的高宽比。Image 会把图拉伸到填满整个框,所以框的比例必须和图一致。
70export function fitCells(
71 width: number | undefined,
72 height: number | undefined,
73 columns: number,
74 rows: number,
75 aspect = CELL_ASPECT,
76): { columns: number; rows: number } {
77 const cols = Math.max(1, Math.min(255, columns))
78 const maxRows = Math.max(1, Math.min(255, rows))
79 if (!width || !height) return { columns: cols, rows: Math.min(maxRows, Math.round(cols / 3)) || 1 }
80 const want = Math.round((cols * height) / width / aspect)
81 if (want <= maxRows) return { columns: cols, rows: Math.max(1, want) }
82 return { columns: Math.max(1, Math.min(cols, Math.round((maxRows * aspect * width) / height))), rows: maxRows }
83}
84
85const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
86
87// 只为解开 PNG 文件头的几十个字符,沙箱里没有 atob。
88function decode(text: string): number[] {
89 const out: number[] = []
90 for (let i = 0; i + 3 < text.length; i += 4) {
91 const v = [0, 1, 2, 3].map(k => ALPHABET.indexOf(text[i + k] ?? 'A'))
92 if (v.some(x => x < 0)) throw new Error('not base64')
93 const [a = 0, b = 0, c = 0, d = 0] = v
94 out.push((a << 2) | (b >> 4), ((b & 15) << 4) | (c >> 2), ((c & 3) << 6) | d)
95 }
96 return out
97}
98
99// 消息里 `[Image: source: /path/1.png …]` 的路径,贴图时引擎存下的原文件。
100export function imageSources(text: string): string[] {
101 // 路径里可能有空格(macOS 截屏的文件名),所以一直取到 `]`。
102 return [...text.matchAll(/\[Image: source: ([^\]]+?)\s*\]/g)].map(m => m[1] ?? '').filter(Boolean)
103}
104types/index.d.ts 35 lines1export type ImageEntry = {
2 /** 这次会话里的序号,从 1 起 */
3 n: number
4 origin: 'user' | 'read' | 'screenshot' | 'tool'
5 /** 产生它的工具名;用户发的图没有 */
6 tool?: string
7 /** 子 agent 的 id;主循环没有 */
8 agentId?: string
9 /** 写好的 PNG 绝对路径;转换失败时没有,只显示文字 */
10 file?: string
11 /** 保存时留下的未缩小原件 */
12 original?: string
13 /** 真实来源:Read 读的文件,或贴图时引擎存的原文件 */
14 sourcePath?: string
15 mediaType: string
16 width?: number
17 height?: number
18 /** Date.now() */
19 at: number
20}
21
22declare module 'claude-code' {
23 interface PluginState {
24 'image-pane': {
25 images: ImageEntry[]
26 /** 正在看的序号;null 表示一直跟着最新一张 */
27 selected: number | null
28 /** 已收过的图的指纹,去重用 */
29 seen: string[]
30 /** 已经手的工具调用的 tool_use_id:tool.call 看过的,或 turn.step 从对话里收过的 */
31 calls: string[]
32 }
33 }
34}
35