SLOPSHOPPER

image-view

在终端里显示图片:Read 读到的图片可点击展开,展开后可放大缩小显示区域,回复里的 ![](路径) 直接显示(需要支持 kitty 图片协议的终端)

rowscommandpromptprocess
v0.2.0MITupdated 2026-10-18yu1745/claude-code-image-view
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · image-view
› fix the failing auth test and add an audit log call ⏺ 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 › /image-view ⎿ image-view: 图片默认展开方式:auto(回复里的图片展开,Read 的图片折叠) ⎿ image-view: 图片来源:auto ⎿ image-view: 用法:/image-view on|off|auto  /image-view source auto|file|inline ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

image-view

Claude Code 的一个 mod:在终端里直接显示图片。

  • Read 读到的图片会多出一个 [ 展开图片 ] 按钮,点开就在对话里显示,可 放大 / 缩小 / 复位。
  • 模型回复里的 alt 会被直接渲染成图片(默认展开)。
  • 自动告诉模型"交付图片时用 Markdown 图片语法引用本地绝对路径",你不用手动提示。
  • 非 PNG(jpg、gif、webp、svg、heic…)通过 ImageMagick 转成 PNG。
  • 本机终端默认只把文件路径交给终端读取,保持原图画质,不受引擎 2 MiB 渲染树上限影响;ssh 会话里自动退回内联 base64(缩小到预算内,且只渲染视口内的图)。
  • /image-view on|off|auto 控制默认展开还是折叠。
  • 默认显示高度跟随终端行数(最多占终端高度的 1/3,6~30 行之间),不会一张图占满一屏;需要看大图时点"放大"。

要求

依赖说明
Claude Code≥ 2.1.287(该版本起支持 Claude Mods,见 changelog);已在 2.1.296 上验证,更早的版本未测试
支持 kitty 图片协议的终端kitty、Ghostty 等;其他终端只显示 alt 文字
ImageMagick需要 identify 和 convert 命令;纯 PNG 且 ≤2 MiB 的图也要用 identify 读尺寸

Debian / Ubuntu:sudo apt install imagemagick;macOS:brew install imagemagick。

安装

在 Claude Code 的终端会话里输入:

/plugin install image-view --marketplace yu1745/claude-code-image-view

提示 Add marketplace? 时输入 y,再选择安装范围(user 即对所有会话生效),看到 Installed image-view. Plugin is now active. 即完成,无需重启。

让 AI 帮你装:把 INSTALL_FOR_AI.md 丢给它。

本地开发 / 试用

git clone https://github.com/yu1745/claude-code-image-view.git
claude --plugin-dir ./claude-code-image-view

使用

  1. 让 Claude 读一张图片(Read /path/to/pic.png),结果下面会出现 [ 展开图片 ],点击展开。
  2. 展开后的按钮:
  3. 放大 / 缩小:每次乘或除 1.5 倍,状态按图片块单独保存。
  4. 复位:回到默认大小。
  5. 右边的 N×M 格 显示当前占的列数 × 行数。
  6. 让 Claude 把生成的图(图表、截图)用 说明 发给你,直接显示在回复下面。

命令

命令作用
/image-view显示当前设置
/image-view on所有图片默认展开
/image-view off所有图片默认折叠
/image-view auto回复里的图展开,Read 的图折叠(默认)
`/image-view source auto\file\inline`图片交给终端的方式:auto 本机传路径、ssh 内联;file 总是传路径;inline 总是内联 base64

设置存在 mod 的 $.store 里,跨会话保留;切换展开方式会重置已手动展开/收起的块。

为什么有 file 和 inline 两种来源

引擎限制一棵渲染树里所有内联图片的 source 合计不超过 2 MiB(超了整棵树被丢弃,日志里是 more than 2097152 bytes of Image source in one tree)。file 来源只传路径,由终端自己读取和解码, 所以没有这个上限,也不需要缩小。终端读不到这台机器的文件时(ssh 远程)才需要 inline:此时每张图 缩小到 ≤700 KiB,一次渲染的总量按 2 MiB 预算,超出的图显示一行提示;全屏布局下滚出视口的图不进渲染树。

默认尺寸规则

一个字符格按宽:高 = 1:2 估算:

columns = min(80, 终端宽 - 4, ceil(图宽 / 8))
rows    = round(columns × 图高 / 图宽 / 2)
rows > maxRows 时按 maxRows 反推 columns
maxRows = clamp(终端行数 / 3, 6, 30)   // 终端行数未知时取 30

例:40 行终端里,960×540 的截图默认 46×13 格;小于 640 像素宽的图按 宽/8 列显示,会比较小,可点"放大"。 放大后只受终端宽度和单个 Image 255×255 格的上限限制,超过屏幕高度的图随对话一起滚动。

可调常量在 hooks/register.tsx 顶部:MAX_COLUMNS、MAX_ROWS、MIN_ROWS、ROWS_DIVISOR、SCALE_STEP、MIN_SCALE。

已知限制

  • viewport.rows 只在宽度或 props 变化时更新;只改窗口高度不会让已显示的图重新计算。
  • 展开状态和放大倍数保存在 mod 的内存里,热重载或重启会话后恢复默认。
  • 只处理本地文件,http(s):// 图片链接会被忽略。
  • auto 用 SSH_CONNECTION / SSH_TTY 判断是否 ssh;判断不对时用 /image-view source file|inline 手动指定。
  • “只渲染视口内”依赖引擎给出的 onScreen,只在全屏布局下有值,且只对 inline 来源有意义。
  • 同一路径的文件被覆盖后,当前会话内仍显示旧图(缓存按路径)。

目录结构

.claude-plugin/plugin.json        mod 清单
.claude-plugin/marketplace.json   让本仓库可作为 marketplace 安装
hooks/hooks.json                  声明 hooks 模块
hooks/register.tsx                全部逻辑

许可证

MIT,见 LICENSE。

Source 1 files
hooks/register.tsx 407 lines
1import type { EngineInterface, Register, RenderComponent, RenderInput } from 'claude-code'
2
3// 能显示的图片后缀;非 PNG 的由 ImageMagick 转成 PNG
4const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp|tiff?|svg|avif|heic|ico)$/i
5// ![alt](path) / ![alt](<path with spaces>),可带 "title"
6const MD_IMAGE = /!\[([^\]\n]*)\]\(\s*(?:<([^>\n]+)>|([^)\s]+))(?:\s+"[^"\n]*")?\s*\)/g
7const CACHE_DIR = '/tmp/cc-image-view'
8// 图片有两种交给终端的方式:
9//  file   只传文件路径,由终端自己读取解码:原图画质,不占引擎的字节预算(默认,本机终端)
10//  inline 把 PNG 的 base64 放进渲染树:终端读不到这台机器的文件时(ssh)用,受下面的预算限制
11// 引擎限制:一棵渲染树里所有 inline Image 的 source 加起来不超过 2 MiB,超了整棵树会被丢弃。
12// 按 base64 长度保守计,一次渲染(一条回复 / 一组 Read)共用这一份预算
13const TREE_IMAGE_BUDGET = 2 * 1024 * 1024
14// inline:单张 PNG 上限(解码后)。取小一点,一条回复里才放得下几张;超过就继续缩小
15const MAX_PNG_BYTES = 700 * 1024
16// inline:缩小的边长梯度
17const RESIZE_EDGES = [1200, 900, 700, 500, 350]
18// file:非 PNG 转换时的最长边,只为避免超大图拖慢终端解码
19const FILE_MAX_EDGE = 2400
20// file 来源的路径最长 3072 字节,超过就退回 inline
21const MAX_FILE_PATH_BYTES = 3072
22const MAX_COLUMNS = 80
23// 默认高度的绝对上限;实际上限随终端行数走:视口行数 / ROWS_DIVISOR,至少 MIN_ROWS
24const MAX_ROWS = 30
25const MIN_ROWS = 6
26const ROWS_DIVISOR = 3
27// 每按一次“放大/缩小”,占的行列乘或除以这个数
28const SCALE_STEP = 1.5
29const MIN_SCALE = 0.3
30// 一个 Image 最多 255 列 × 255 行
31const MAX_CELLS = 255
32
33type Prepared =
34  | { state: 'loading' }
35  | { state: 'ready'; source: PngSource; width: number; height: number }
36  | { state: 'error'; message: string }
37
38type Engine = EngineInterface
39type PngSource = { png: string } | { file: string; format: 'png'; generation: number }
40// 消息当前在不在视口里:null 表示在视口外(只在全屏布局下有值)
41type OnScreen = { first: number; last: number; of: number } | null | undefined
42// 一棵树里还能放多少 Image source(base64 长度);每次渲染新建一份,画图时扣减
43type Budget = { left: number }
44const newBudget = (): Budget => ({ left: TREE_IMAGE_BUDGET })
45
46const onScreenOf = (props: object): OnScreen => (props as { onScreen?: OnScreen }).onScreen
47
48const basename = (p: string) => p.slice(p.lastIndexOf('/') + 1)
49
50async function identify($: Engine, path: string) {
51  const { exitCode, stdout, stderr } = await $.process.run([
52    'identify', '-format', '%w %h %m\n', `${path}[0]`,
53  ])
54  if (exitCode !== 0) throw new Error(stderr.trim() || `identify 失败 (${exitCode})`)
55  const [w, h, format] = (stdout.split('\n')[0] ?? '').split(' ')
56  return { width: Number(w), height: Number(h), format: format ?? '' }
57}
58
59// file / inline / auto;auto 在 ssh 会话里用 inline(终端读不到这台机器的文件)
60type SourceMode = 'auto' | 'file' | 'inline'
61const SOURCE_MODES: readonly SourceMode[] = ['auto', 'file', 'inline']
62const SOURCE_MODE_KEY = 'imageSource'
63let sourceMode: SourceMode | undefined
64
65const describeSource = (mode: SourceMode) => ({
66  auto: '图片来源:auto(本机终端传文件路径,ssh 会话里内联 base64)',
67  file: '图片来源:file(传文件路径,原图画质;终端必须能读到这台机器的文件)',
68  inline: '图片来源:inline(内联 base64,会缩小到总量预算内)',
69})[mode]
70
71async function loadSourceMode($: Engine): Promise<SourceMode> {
72  if (sourceMode) return sourceMode
73  const stored = await $.store.get(SOURCE_MODE_KEY)
74  sourceMode = SOURCE_MODES.find(m => m === stored) ?? 'auto'
75  return sourceMode
76}
77
78async function useInline($: Engine): Promise<boolean> {
79  const mode = await loadSourceMode($)
80  if (mode !== 'auto') return mode === 'inline'
81  // webterm(浏览器里的 xterm.js)只支持直接传输,读不到本机文件,和 ssh 一样走 inline
82  const r = await $.process.run(['sh', '-c', 'printf %s "$SSH_CONNECTION$SSH_TTY$WEBTERM_SESSION"'])
83  return r.exitCode === 0 && r.stdout.trim() !== ''
84}
85
86const hex = async (text: string) => {
87  const digest = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(text))
88  return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('')
89}
90
91/** 准备好终端能显示的 PNG:file 来源是一个 PNG 文件路径,inline 来源是缩小后的 base64 */
92async function toPng($: Engine, path: string): Promise<Prepared> {
93  const info = await identify($, path)
94  const { size } = await $.fs.stat(path)
95  const inline = (await useInline($)) || new TextEncoder().encode(path).length > MAX_FILE_PATH_BYTES
96
97  if (!inline) {
98    if (info.format === 'PNG') {
99      return {
100        state: 'ready', width: info.width, height: info.height,
101        source: { file: path, format: 'png', generation: size },
102      }
103    }
104    const out = `${CACHE_DIR}/${await hex(`${path}:${size}:file`)}.png`
105    await $.process.run(['mkdir', '-p', CACHE_DIR])
106    const r = await $.process.run([
107      'convert', `${path}[0]`, '-resize', `${FILE_MAX_EDGE}x${FILE_MAX_EDGE}>`, '-strip', `png:${out}`,
108    ], { timeoutMs: 60_000 })
109    if (r.exitCode !== 0) throw new Error(r.stderr.trim() || `convert 失败 (${r.exitCode})`)
110    const converted = await identify($, out)
111    return {
112      state: 'ready', width: converted.width, height: converted.height,
113      source: { file: out, format: 'png', generation: (await $.fs.stat(out)).size },
114    }
115  }
116
117  if (info.format === 'PNG' && size <= MAX_PNG_BYTES) {
118    const { base64 } = await $.fs.read(path, { as: 'bytes' })
119    return { state: 'ready', source: { png: base64 }, width: info.width, height: info.height }
120  }
121
122  const out = `${CACHE_DIR}/${await hex(`${path}:${size}`)}.png`
123  await $.process.run(['mkdir', '-p', CACHE_DIR])
124  // 逐级缩小,直到不超过单张上限
125  for (const edge of RESIZE_EDGES) {
126    const r = await $.process.run([
127      'convert', `${path}[0]`, '-resize', `${edge}x${edge}>`, '-strip', `png:${out}`,
128    ], { timeoutMs: 60_000 })
129    if (r.exitCode !== 0) throw new Error(r.stderr.trim() || `convert 失败 (${r.exitCode})`)
130    if ((await $.fs.stat(out)).size <= MAX_PNG_BYTES) break
131  }
132  const small = await identify($, out)
133  const { base64 } = await $.fs.read(out, { as: 'bytes' })
134  return { state: 'ready', source: { png: base64 }, width: small.width, height: small.height }
135}
136
137/**
138 * 按图片比例算出占多少行列:一个字符格大约宽:高 = 1:2。
139 * scale 是 1 时不超过 MAX_COLUMNS × maxRows(maxRows 随终端行数变化,见 MAX_ROWS);
140 * 放大后只受终端宽度和 Image 的格数上限限制,比屏幕高的图跟着对话一起滚动。
141 */
142function cellsOf(
143  width: number, height: number,
144  viewportColumns: number | undefined, viewportRows: number | undefined, scale: number,
145) {
146  // 左边缩进 2 格,再留一点边
147  const widest = Math.min(MAX_CELLS, Math.max(8, (viewportColumns ?? 100) - 4))
148  const maxRows = viewportRows === undefined
149    ? MAX_ROWS
150    : Math.min(MAX_ROWS, Math.max(MIN_ROWS, Math.floor(viewportRows / ROWS_DIVISOR)))
151  const rowsFor = (columns: number) => Math.round((columns * height) / width / 2)
152  const columnsFor = (rows: number) => Math.round((rows * 2 * width) / height)
153
154  let columns = Math.min(MAX_COLUMNS, widest, Math.ceil(width / 8))
155  if (rowsFor(columns) > maxRows) columns = Math.max(1, columnsFor(maxRows))
156
157  const base = columns
158  columns = Math.round(base * scale)
159  let atLimit = false
160  if (columns >= widest) { columns = widest; atLimit = true }
161  if (rowsFor(columns) >= MAX_CELLS) { columns = columnsFor(MAX_CELLS); atLimit = true }
162  columns = Math.max(1, Math.min(MAX_CELLS, columns))
163  // applied:夹过之后实际生效的倍数,下一次放大缩小从它算起
164  return {
165    columns, rows: Math.max(1, Math.min(MAX_CELLS, rowsFor(columns))), atLimit,
166    applied: atLimit ? columns / base : scale,
167  }
168}
169
170// 默认展开方式(/image-view 设置,存在 $.store 里跨会话保留):
171// auto 回复里的图展开、Read 的图折叠;on 全部展开;off 全部折叠
172type OpenMode = 'auto' | 'on' | 'off'
173const OPEN_MODES: readonly OpenMode[] = ['auto', 'on', 'off']
174const OPEN_MODE_KEY = 'defaultOpen'
175let openMode: OpenMode | undefined
176
177async function loadOpenMode($: Engine): Promise<OpenMode> {
178  if (openMode) return openMode
179  const stored = await $.store.get(OPEN_MODE_KEY)
180  openMode = OPEN_MODES.find(m => m === stored) ?? 'auto'
181  return openMode
182}
183
184async function defaultOpen($: Engine, kind: 'read' | 'reply'): Promise<boolean> {
185  const mode = await loadOpenMode($)
186  return mode === 'auto' ? kind === 'reply' : mode === 'on'
187}
188
189const describeMode = (mode: OpenMode) => ({
190  auto: '图片默认展开方式:auto(回复里的图片展开,Read 的图片折叠)',
191  on: '图片默认展开方式:on(全部默认展开)',
192  off: '图片默认展开方式:off(全部默认折叠)',
193})[mode]
194
195const prepared = new Map<string, Prepared>()
196// 每个图片块的展开状态;没有记录时用该块的默认值
197const expanded = new Map<string, boolean>()
198// 每个图片块的显示倍数:占的行列相对默认大小放大或缩小了多少
199const scales = new Map<string, number>()
200
201function ensure($: Engine, path: string) {
202  if (prepared.has(path)) return
203  prepared.set(path, { state: 'loading' })
204  toPng($, path)
205    .catch((err: unknown): Prepared => ({
206      state: 'error',
207      message: err instanceof Error ? err.message : String(err),
208    }))
209    .then(result => {
210      prepared.set(path, result)
211      $.ui.invalidate('ui.render')
212    })
213}
214
215async function resolvePath($: Engine, raw: string) {
216  let p = raw.replace(/^file:\/\//, '')
217  try { p = decodeURI(p) } catch {}
218  if (p.startsWith('/')) return p
219  return `${(await $.session.cwd()).replace(/\/$/, '')}/${p.replace(/^\.\//, '')}`
220}
221
222/** 一个图片块:一行按钮 + 文件名,展开时下面是图片 */
223function imageBlock(
224  $: Engine,
225  e: RenderInput<RenderComponent, 'terminal'>,
226  id: string,
227  path: string,
228  alt: string,
229  openByDefault: boolean,
230  budget: Budget,
231  onScreen: OnScreen,
232) {
233  const { Box, Text, Button, Image } = $.ui.resolve(e)
234  const open = expanded.get(id) ?? openByDefault
235  if (open) ensure($, path)
236  const p = prepared.get(path)
237  const scale = scales.get(id) ?? 1
238  const setScale = (s: number) => () => { scales.set(id, s); $.ui.invalidate('ui.render') }
239
240  let body = null
241  let controls = null
242  // 只有 inline 来源的 base64 会进渲染树、占预算;file 来源只是一个路径
243  const cost = p?.state === 'ready' && 'png' in p.source ? p.source.png.length : 0
244  if (open && p?.state === 'ready' && cost > 0 && onScreen === null) {
245    // 滚出视口:inline 图不进渲染树(终端会按上次的行数占位),滚回来时本消息会重绘
246    body = <Text dimColor>图片在视口外,滚动到这里时显示</Text>
247  } else if (open && p?.state === 'ready' && cost > budget.left) {
248    // 引擎会丢掉超过预算的整棵树,所以这张不画,让已画的几张照常显示
249    body = <Text dimColor>本条消息里的图片总量已达显示上限({TREE_IMAGE_BUDGET >> 20} MiB),这张暂不显示;收起其他图片后再展开</Text>
250  } else if (open && p?.state === 'ready') {
251    budget.left -= cost
252    const { columns, rows, atLimit, applied } = cellsOf(
253      p.width, p.height, e.viewport?.columns, e.viewport?.rows, scale)
254    body = (
255      <Image key={`img:${id}`} source={p.source} columns={columns} rows={rows}
256        alt={`[图片 ${alt || basename(path)} ${p.width}×${p.height}]`} />
257    )
258    controls = (
259      <Box flexDirection="row" gap={1}>
260        {!atLimit && <Button key={`grow:${id}`} label="放大" dimColor
261          onPress={setScale(applied * SCALE_STEP)} />}
262        {applied / SCALE_STEP >= MIN_SCALE && columns > 1 && rows > 1 && <Button key={`shrink:${id}`}
263          label="缩小" dimColor onPress={setScale(applied / SCALE_STEP)} />}
264        {scale !== 1 && <Button key={`reset:${id}`} label="复位" dimColor onPress={setScale(1)} />}
265        <Text dimColor>{columns}×{rows} 格</Text>
266      </Box>
267    )
268  } else if (open && p?.state === 'error') {
269    body = <Text color="red">图片无法显示:{p.message}</Text>
270  } else if (open) {
271    body = <Text dimColor>正在加载图片…</Text>
272  }
273
274  return (
275    <Box key={`blk:${id}`} flexDirection="column" marginLeft={2}>
276      <Box flexDirection="row" gap={1}>
277        <Button key={`btn:${id}`} label={open ? '收起图片' : '展开图片'} dimColor
278          onPress={() => { expanded.set(id, !open); $.ui.invalidate('ui.render') }} />
279        <Text dimColor>{alt && alt !== basename(path) ? `${alt} · ` : ''}{path}</Text>
280      </Box>
281      {controls}
282      {body}
283    </Box>
284  )
285}
286
287const imageReadPath = (tool: string, input: unknown) => {
288    if (tool !== 'Read' || typeof input !== 'object' || input === null) return null
289    const p = (input as { file_path?: unknown }).file_path
290    return typeof p === 'string' && IMAGE_EXT.test(p) ? p : null
291  }
292
293export const register: Register = on => {
294  // 单独显示的 Read 行:结果下面加一个折叠的图片块
295  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
296    const drawn = await next(e)
297    if (e.surface !== 'terminal') return drawn
298    const { tool, input, isRunning, isErrored, tool_use_id } = e.props
299    const path = imageReadPath(tool, input)
300    if (!path || isRunning || isErrored) return drawn
301    const { Box } = $.ui.resolve(e)
302    const open = await defaultOpen($, 'read')
303    return (
304      <Box flexDirection="column">
305        {drawn}
306        {imageBlock($, e, tool_use_id, path, '', open, newBudget(), onScreenOf(e.props))}
307      </Box>
308    )
309  })
310
311  // 折叠成一行的 "Read N files":给其中每张图片各加一个折叠的图片块
312  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
313    const drawn = await next(e)
314    if (e.surface !== 'terminal' || e.props.isExpanded) return drawn
315    const budget = newBudget()
316    const open = await defaultOpen($, 'read')
317    const blocks = e.props.calls.flatMap((c, i) => {
318      const path = imageReadPath(c.tool, c.input)
319      if (!path || c.isRunning || c.isErrored) return []
320      return [imageBlock($, e, c.tool_use_id ?? `${e.requestId}:${i}`, path, '', open, budget, onScreenOf(e.props))]
321    })
322    if (blocks.length === 0) return drawn
323    const { Box } = $.ui.resolve(e)
324    return (
325      <Box flexDirection="column">
326        {drawn}
327        {blocks}
328      </Box>
329    )
330  })
331
332  // 回复里的 ![alt](path):默认展开显示
333  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
334    const drawn = await next(e)
335    if (e.surface !== 'terminal') return drawn
336    const found = [...e.props.text.matchAll(MD_IMAGE)]
337      .map(m => ({ alt: m[1] ?? '', raw: m[2] ?? m[3] ?? '' }))
338      .filter(m => IMAGE_EXT.test(m.raw) && !/^https?:/i.test(m.raw))
339    if (found.length === 0) return drawn
340    const budget = newBudget()
341    const open = await defaultOpen($, 'reply')
342    // 先解析完路径再依次画,预算按出现顺序扣
343    const paths = await Promise.all(found.map(m => resolvePath($, m.raw)))
344    const blocks = found.map((m, i) =>
345      imageBlock($, e, `${e.requestId}:${i}`, paths[i]!, m.alt, open, budget, onScreenOf(e.props)))
346    const { Box } = $.ui.resolve(e)
347    return (
348      <Box flexDirection="column">
349        {drawn}
350        {blocks}
351      </Box>
352    )
353  })
354
355  // /image-view [on|off|auto]:设置图片默认展开还是折叠;不带参数显示当前设置
356  on('session.start', async ($, e, next) => {
357    await $.command.register({
358      name: 'image-view',
359      description: '设置图片默认展开(on)、折叠(off)或自动(auto);不带参数显示当前设置',
360      argumentHint: '[on|off|auto]',
361    })
362    return next(e)
363  })
364
365  on('command.run', { command: 'image-view' }, async ($, e) => {
366    const [first = '', second = ''] = e.args.trim().toLowerCase().split(/\s+/)
367    const usage = '用法:/image-view on|off|auto  /image-view source auto|file|inline'
368    const current = await loadOpenMode($)
369    const currentSource = await loadSourceMode($)
370    if (first === '') return { text: `${describeMode(current)}\n图片来源:${currentSource}\n${usage}` }
371
372    if (first === 'source') {
373      const picked = SOURCE_MODES.find(m => m === second)
374      if (!picked) return { text: `未知参数 "${second}"。${usage}\n图片来源:${currentSource}` }
375      await $.store.set(SOURCE_MODE_KEY, picked)
376      sourceMode = picked
377      prepared.clear() // 重新按新来源准备
378      $.ui.invalidate('ui.render')
379      return { text: describeSource(picked) }
380    }
381
382    const mode = OPEN_MODES.find(m => m === first)
383    if (!mode) return { text: `未知参数 "${first}"。${usage}\n${describeMode(current)}` }
384    await $.store.set(OPEN_MODE_KEY, mode)
385    openMode = mode
386    // 已手动展开/收起的块也回到新的默认值
387    expanded.clear()
388    $.ui.invalidate('ui.render')
389    return { text: describeMode(mode) }
390  })
391
392  // 告诉模型:交付图片时用 Markdown 图片语法引用本地绝对路径,终端会直接显示
393  on('prompt.context', async ($, e, next) => {
394    const r = await next(e)
395    return {
396      ...r,
397      blocks: [
398        ...r.blocks,
399        {
400          name: 'imageView',
401          text: 'The terminal renders images inline. When you produce or want to show the user a local image file (a chart you plotted, a screenshot, a generated picture), reference it in your reply with Markdown image syntax and an absolute path, e.g. `![p95 latency](/abs/path/chart.png)`; it is drawn under your message. Images you Read get an expand button automatically, so do not re-embed those unless asked.',
402        },
403      ],
404    }
405  })
406}
407