SLOPSHOPPER

image-pane

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

newpaneguardcommandtoastprocess
★ 2v0.1.3MITupdated 2026-10-24alexcz-a11y/claude-mods/image-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · image-pane
│ ┃ Images ✕ › fix the failing auth test and add an audit log call │ ┃ 还没有图片。你发的图和 agent │ ┃ 读到、截到的图会出现在这里。 ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /images │ ⎿ image-pane: 图片栏已打开,共 0 张。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Images
还没有图片。你发的图和 agent 读到、截到的图会出现在这里。
README

image-pane

在终端右侧的停靠栏里显示本会话里的图片:

  • 你发给 agent 的图:每轮开始时,从你那条消息里取出。
  • agent 拿到的图:任何工具结果里带的图,包括 Read 读的图片文件、截图工具(computer-use、浏览器、MCP 截图)返回的图,以及其他工具返回的图。子 agent 的图也收,并标出是哪个子 agent。

每张图会标出序号、来源、工具名和时间。h / l 翻上一张和下一张,f 回到"跟随最新"。按 o 或"打开原图"按钮(图片本身点不了:Image 元素不接受点击)用系统默认程序打开原图:Read 读的文件、你贴图时 Claude Code 存的原文件,找不到这些时打开 mod 保存的未缩小原件。/images 打开这个栏。

只在终端里工作

  • 图片用终端图形协议画出:kitty、Ghostty、iTerm2 3.5 以上(全屏渲染)能显示真实像素(以 PNG 字节送出,超过 2 MiB 先缩小,因为 iTerm2 不认文件路径),其他终端和 tmux 只显示一行文字说明。
  • 终端为全屏布局、宽度不少于 110 列时,栏停靠在对话右侧;否则出现在输入框上方。
  • Claude Desktop 不会做任何事:mod 的安装分不出 CLI 和 Desktop(两边读同一份用户级插件),所以改为运行时判断:会话开始时没有终端(Desktop、-p、SDK)就什么都不注册、不收图;Desktop 的界面里也不画这个栏。

实现说明

  • 显示用的 PNG 优先从原文件做(Read 读的文件、你贴图时的原文件):引擎放进对话的那份已缩小,贴图还会重编码成 JPEG,透明背景会丢。原文件读不到、不是图片,或一次收到多张对不上来源时,回退到引擎那份。
  • 工具结果的图在 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,其余只留文字条目。
  • 图按原比例显示要知道终端一格的高宽比,默认 2.125(iTerm2 的 Hack 14pt 实测 8×17 point)。换了字体或行距就在 /config 里改 cellAspect:在终端里运行 printf '\e[16t'; read -rs -t1 -d t r; printf '%q\n' "$r",回报 6;高;宽,高除以宽即是。窗口大小、全屏与否和 herdr 都不影响它。
  • 每会话最多保留 200 张,重复的图(同一张截图再读一次)只收一次。

运行

claude --plugin-dir ./image-pane
claude plugin install image-pane@alex-mods --scope user

测试时的 Claude Code 版本:2.1.309(Desktop 自带的 2.1.308 上 validate 通过)。

Source 3 files
hooks/image-pane.tsx 251 lines
1import { 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}
251
hooks/core.ts 104 lines
1// 纯函数:从任意工具结果或 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}
104
types/index.d.ts 35 lines
1export 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