SLOPSHOPPER

shot-view

收集 Claude 截下或查看的 PNG,ctrl+x s 在侧边面板里看图;n/p 翻页,按住 o 在终端里放大、松开收起,轻按 o 用快速查看打开;看过的按 r 标为已读就不再出现(u 撤销)

newpanebandguardcommandprocess
v0.4.3MITupdated 2026-10-06Bearisbug/cc-mods/shot-view
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · shot-view
│ ┃ 截图 ✕ › fix the failing auth test and add an audit log call │ ┃ 还没有截图。Claude 截图或查看 PNG │ ┃ 后会出现在这里。 ⏺ 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 │ │ › /shots │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 截图
还没有截图。Claude 截图或查看 PNG 后会出现在这里。
README

cc-mods

三个 Claude Code mod:截图面板 shot-view、长任务进度 task-eta、按目录精简 MCP 说明 ctx-slim。三个 mod 各在输入框上方画一行,颜色分别是蓝、紫、绿,不再用 Claude Code 自带的橙色 ⚠ 状态行。

三个 mod 同时出现在输入框上方

77 秒介绍视频(点图下载 mp4,13.5 MB):

cc-mods 介绍视频

mod 是什么

mod 是 Claude Code v2.1.287 引入的一种插件:插件里放一个 JavaScript / TypeScript 模块,注册一组事件处理函数,在 Claude Code 进程里运行。它能在输入框上方的条带和侧边面板里画界面、注册斜杠命令、在工具调用前后插一手;其中画界面只有 mod 能做,settings 里的 hook、skill 和 MCP 都不行。v2.1.287 起默认开启。官方文档:<https://code.claude.com/docs/en/plugins/mods/overview>

安装

需要 Claude Code 2.1.287 或更新版本。

claude plugin marketplace add Bearisbug/cc-mods
claude plugin install shot-view@cc-mods
claude plugin install task-eta@cc-mods
claude plugin install ctx-slim@cc-mods

已经开着的会话要运行 /reload-plugins 或新开会话才会加载。

两个面板也可以用快捷键打开。在 ~/.claude/keybindings.json 里加上(command:<名字> 等于输入对应的斜杠命令,只能放在 Chat 上下文):

{
  "bindings": [
    {
      "context": "Chat",
      "bindings": { "ctrl+x s": "command:shots", "ctrl+x p": "command:steps" }
    }
  ]
}

不配快捷键时,输入 /shots、/steps 效果相同。

shot-view · 截图面板

shot-view 面板

  • Claude 用 Read 看了一张 PNG,或者它执行的命令、命令输出里出现了 10 分钟内写出的 PNG(比如 xcrun simctl io booted screenshot、Playwright 截图),这张图就进入列表,最多保留最近 60 张。
  • 有新截图时,输入框上方出现蓝色的「▍截图 新增 xxx.png 未读 N 张 · ctrl+x s 查看」;截图都标为已读后这一行不再出现。窗口窄或右侧停着面板时,这一行仍只占一行:先省掉快捷键提示,再省掉张数,文件名从中间截短,保留结尾的时间戳和 .png。
  • /shots(ctrl+x s)打开右侧面板,Claude 正在工作时也能立刻打开。面板只显示未读的截图,打开时停在最新一张,顶行写着当前是第几张未读、已读几张。n 下一张(更早的),p 上一张(更新的),r 标为已读(看过、不用再看时按,只是翻过不算),u 撤销最近一次已读并跳回那一张,按住 o 在面板里放大当前这张、松开收起,轻按 o 用 macOS 快速查看打开,f 在 Finder 里显示,y 复制路径;Esc 或再按一次 ctrl+x s 关闭,面板开着时 Esc 只关面板,不会中断 Claude。
  • 标为已读的截图不再出现,全部读完时面板显示「✓ 截图都看完了」。Claude 再读一遍没改过的同一个文件,它仍算已读;同一路径被新截图覆盖(修改时间变了),就重新算未读。
  • 截图文件被删除或移走后,约 2 秒内会自动从提示行和面板里去掉,面板注明另有几张已不在;文件被原地覆盖后,面板显示的是新内容。
  • 按住 o 是靠终端在按住时连续补发的重复按键来识别的,mod 收不到「松开」事件。有的终端按住字母时会弹出 macOS 的重音字符菜单,不补发重复按键,这时按住会被当成轻按,打开快速查看;对这个终端运行 defaults write <终端的 bundle id> ApplePressAndHoldEnabled -bool false 再重开,就会改回按住连发。面板内放大受面板宽度限制:横屏截图放大后几乎一样大,竖长的手机截图会明显变大;想看大图就轻按 o。
  • 面板里直接显示图片,要求终端支持 kitty 图形协议。kitty 和 Ghostty 默认开启;其他支持该协议的终端,在 ~/.claude/settings.json 的 env 里加 "CLAUDE_CODE_FORCE_TERMINAL_IMAGES": "1"。终端不支持时面板只显示文件名,轻按 o 用快速查看看。
  • 只能在 macOS 上用:读图片尺寸靠 sips,快速查看靠 qlmanage,在 Finder 里显示靠 open。

task-eta · 长任务进度

task-eta 步骤面板

  • 一轮任务跑满 3 分钟,task-eta 向当前会话的模型发一个旁路问题,估出剩余步骤;不满 3 分钟时按一下 ctrl+x p 会当场估算。
  • 输入框上方出现紫色进度行,例如「▍进度 2/5 跑单元测试 ▰▰▱▱ 已 4 分 · 约剩 6 分 · 下一步 修复失败用例 · ctrl+x p 看步骤」。剩余时间按已完成步骤的实际快慢校准。窗口窄或右侧停着面板时,进度行仍只占一行:放不下的部分按「下一步 → 已用时 → 快捷键提示 → 进度条」的顺序省掉,步骤标题最后才截短。
  • /steps(ctrl+x p)展开「任务步骤」面板:每一步用 ✓ / ▶ / ○ 标状态,「–」表示 Claude 核对后认为这一步后来不需要了;每一步写着实际用时、预计用时和调用次数,当前步骤下面显示 Claude 正在执行的命令。面板里按 c 让 Claude 重新核对;一轮已经结束、清单还没完成时,按 d 把整份清单标为完成。
  • 只有下一步真正开始、或 Claude 核对确认后,才给一步打勾。一轮结束时 Claude 核对一次,进度行显示结果:任务完成、等你回复、未完成、已中断、出错停下;核对试了 3 次都没成功时显示「未能核对」。被中断或没做完时,发下一条消息会接着原来那份清单,不重新估算;/clear 会清掉清单。
  • 估算和过程中的核对通过 $.model.fork 发请求,每轮最多 6 次;一轮结束时的核对不受这个次数限制,失败后隔 5 秒、10 秒各重试一次。请求复用会话的 prompt cache,但仍然计入你的用量。

ctx-slim · 按目录精简 MCP 说明

ctx-slim 提示行

  • 每接一个 MCP 服务器,它的使用说明都会跟着每一轮请求发给模型。ctx-slim 在 prompt.attachment 上拦下 MCP 说明,按你配的规则去掉当前目录用不上的服务器那一节;工具本身照常可用。
  • 每个会话第一次精简时,输入框上方出现一次绿色提示「▍精简 本目录省略了 … 的 MCP 说明 −N 字符」,30 秒后消失。
  • 默认什么都不做,要先配规则。

配置 ctx-slim

规则可以在 /config 里的「MCP 说明保留规则」填一行,或者运行 /plugin configure ctx-slim@cc-mods,也可以直接写进 ~/.claude/settings.json:

{
  "pluginConfigs": {
    "ctx-slim@cc-mods": {
      "options": {
        "rules": "kando: path~kando; synco: text=<!-- synco:project-context:start | path~synco; shadcn-io: file=package.json"
      }
    }
  }
}

规则格式是 服务器: 条件 | 条件; 服务器: 条件,多条规则用分号或换行隔开:

写法含义
服务器名MCP 说明里 ## 后面的名字,不区分大小写
path~子串当前目录路径里包含这个子串,不区分大小写
file=文件名从当前目录往上能找到这个文件;当前目录在 HOME 下时只找到 HOME 为止
text=文字当前目录及上级目录的 CLAUDE.md 或 AGENTS.md 里含有这段文字

列出的服务器只要有一个条件成立就保留它的说明,否则省略;没列出的服务器一律保留。条件里不能出现 ; 和 |,写错的部分会被跳过,不会因此误删说明。

上面那段示例是作者自己的规则:kando 的说明只在路径含 kando 时保留,synco 的说明在 CLAUDE.md 里有 Synco 的 project-context 标记、或路径含 synco 时保留,shadcn-io 的说明只在能找到 package.json 的前端项目里保留。

安全

mod 以你的用户权限在 Claude Code 进程里运行,不在沙箱里。装之前可以对插件目录运行 claude plugin validate <目录>,它不执行代码,只列出 mod 处理哪些事件、调用哪些接口:

mod处理的事件调用的接口
shot-viewtool.call、command.run、ui.render$.process.run(只运行 sips、qlmanage、open)、$.fs.stat、$.clock.*、$.ui.*、$.state.*
task-etaturn.start、tool.call、turn.complete、command.run、session.end、ui.render$.model.fork、$.clock.*、$.ui.*
ctx-slimprompt.attachment、ui.render(没配规则时不注册任何钩子)$.fs.exists、$.fs.ancestors、$.env.get、$.ui.*、$.state.*

开发

claude plugin validate shot-view      # 静态检查
claude plugin test shot-view          # 跑 tests/ 里的测试
claude --plugin-dir ./shot-view       # 只在这一个会话里加载,存盘自动重载

brand/promo/intro/ 是介绍视频的源码(HyperFrames 0.8.96 + GSAP),分镜在 storyboard.md,录屏数据在 assets/term.js。配乐文件不在仓库里,先用 bash tools/cut_music.sh <原曲> 从原曲剪出 assets/audio.wav,再在该目录运行 npm ci、npx hyperframes render . -o renders/cc-mods-intro.mp4 --strict 重新出片。

README 和视频里的终端画面来自真实会话的录屏数据;shot-view 面板里的图片区域是后期合成的,因为录屏用的终端模拟器不支持 kitty 图形协议。视频里的计数器 mod 是官方文档的示例,拦截 rm -rf 的是官方示例 blast-radius。配乐:《栖谷来信》(Letter from an old friend),制造木屋/陈越龙,版权归原作者,不在本仓库的 MIT 许可之内。

许可

MIT

Source 2 files
hooks/register.tsx 436 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Shot } from '../types'
5
6type Api = EngineInterface
7
8const PANE = 'shots'
9const RECENT_MS = 10 * 60_000
10const KEEP = 60
11const shots = atom({ plugin: 'shot-view', key: 'shots' } as const, [])
12// 在「未读」列表里的位置,0 是最新一张
13const index = atom({ plugin: 'shot-view', key: 'index' } as const, 0)
14
15// 状态条带里的提示:最新一张未读截图的路径,打开面板后清掉
16let hint: string | null = null
17
18// 截图文件在会话外被删、被移走或被覆盖时,Claude Code 不会因此重画。
19// 条带有提示或面板开着时每 WATCH_MS 查一次文件,和上次画出来的不一样就重画
20const WATCH_MS = 2000
21let watching: Timer | null = null
22let drawn = ''
23
24// 按住 o:在终端里放大看,松开就收起;轻按 o:打开 macOS 快速查看。
25// 插件收不到「松开」事件,只能靠按住时终端不断补发的重复按键来判断:
26// HOLD_MS 内来了第二下就算按住,之后 RELEASE_MS 没再来就算松开;HOLD_MS 内没有第二下就算轻按
27const HOLD_MS = 500
28const RELEASE_MS = 220
29type Press = { path: string; lastAt: number; isHeld: boolean; timer: Timer | null }
30let press: Press | null = null
31let isPeeking = false
32
33// 只有按 r 才算已读:翻过、看过都不算
34function unreadOf(list: Shot[]): Shot[] {
35  return list.filter(s => !s.readAt)
36}
37
38// 文件现在还在不在、修改时间是多少:被删掉、移走或还是空文件的不显示(不然终端只能画一块黑),
39// 被原地覆盖的把修改时间交给终端,让它重新读,不用缓存里的旧图
40async function present($: Api, list: Shot[]): Promise<Map<string, number>> {
41  const out = new Map<string, number>()
42  await Promise.all(
43    list.map(async s => {
44      const stat = await $.fs.stat(s.path).catch(() => undefined)
45      if (stat?.kind === 'file' && stat.size > 0) out.set(s.path, Math.floor(stat.mtimeMs))
46    }),
47  )
48  return out
49}
50
51function keyOf(live: Map<string, number>): string {
52  return [...live]
53    .map(([path, mtime]) => `${path}@${mtime}`)
54    .sort()
55    .join('\n')
56}
57
58function watchFiles($: Api) {
59  if (watching) return
60  watching = $.clock.every(WATCH_MS, () => void recheck($))
61}
62
63async function recheck($: Api) {
64  const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
65  if (!hint && !isOpen) {
66    watching?.cancel()
67    watching = null
68    return
69  }
70  if (keyOf(await present($, unreadOf(await read($, shots)))) !== drawn) $.ui.invalidate('ui.render')
71}
72
73export function findPngPaths(text: string): string[] {
74  return [...new Set(text.match(/[\w@%+=:,.\/~-]+\.png\b/gi) ?? [])]
75}
76
77export function resolvePath(cwd: string, p: string, home: string): string {
78  const abs = p.startsWith('/') ? p : p.startsWith('~/') ? home + p.slice(1) : `${cwd}/${p}`
79  const out: string[] = []
80  for (const part of abs.split('/')) {
81    if (part === '' || part === '.') continue
82    if (part === '..') out.pop()
83    else out.push(part)
84  }
85  return '/' + out.join('/')
86}
87
88function basename(path: string): string {
89  return path.slice(path.lastIndexOf('/') + 1)
90}
91
92// 终端里汉字、全角标点和 emoji 占两格
93export function cells(s: string): number {
94  let n = 0
95  for (const ch of s) {
96    const c = ch.codePointAt(0) ?? 0
97    const isWide =
98      (c >= 0x1100 && c <= 0x115f) ||
99      (c >= 0x2e80 && c <= 0xa4cf) ||
100      (c >= 0xac00 && c <= 0xd7a3) ||
101      (c >= 0xf900 && c <= 0xfaff) ||
102      (c >= 0xfe30 && c <= 0xfe4f) ||
103      (c >= 0xff00 && c <= 0xff60) ||
104      (c >= 0xffe0 && c <= 0xffe6) ||
105      c >= 0x1f300
106    n += isWide ? 2 : 1
107  }
108  return n
109}
110
111// 从中间截短:截图文件名的开头和结尾(时间戳、.png)都留着
112function clipMiddle(s: string, width: number): string {
113  if (cells(s) <= width) return s
114  const chars = [...s]
115  let head = ''
116  let tail = ''
117  let i = 0
118  let j = chars.length - 1
119  while (i < j && cells(head + chars[i]) <= Math.ceil((width - 1) / 2)) head += chars[i++]
120  while (j >= i && cells(head) + 1 + cells(chars[j] + tail) <= width) tail = chars[j--] + tail
121  return `${head}…${tail}`
122}
123
124const BAND_LABEL = '▍截图'
125const MIN_HEAD = 20
126
127// 条带固定一行。右端 4 格是 Claude Code 自己画的折叠按钮「 [-]」,bodyColumns 里含着它。
128// 放不下时先去掉快捷键提示,再去掉张数,最后从中间截短文件名
129export function bandLine(name: string, unread: number, bodyColumns: number): { head: string; tail: string } {
130  const room = bodyColumns - 4 - cells(BAND_LABEL) - 1
131  const head = `新增 ${name}`
132  const headMin = Math.min(cells(head), MIN_HEAD)
133  const tail = [`未读 ${unread} 张 · ctrl+x s 查看`, `未读 ${unread} 张`].find(t => headMin + 1 + cells(t) <= room) ?? ''
134  return { head: clipMiddle(head, Math.max(2, room - (tail ? cells(tail) + 1 : 0))), tail }
135}
136
137function ago(at: number): string {
138  const min = Math.floor((Date.now() - at) / 60_000)
139  return min < 1 ? '刚刚' : `${min} 分钟前`
140}
141
142async function pixelSize($: Api, path: string): Promise<{ width: number; height: number } | undefined> {
143  const r = await $.process.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', path]).catch(() => undefined)
144  const width = Number(/pixelWidth: (\d+)/.exec(r?.stdout ?? '')?.[1])
145  const height = Number(/pixelHeight: (\d+)/.exec(r?.stdout ?? '')?.[1])
146  return width > 0 && height > 0 ? { width, height } : undefined
147}
148
149async function record($: Api, candidates: string[], via: string, isRecentOnly: boolean) {
150  const cwd = await $.session.cwd()
151  const home = (await $.env.get('HOME')) ?? ''
152  const now = Date.now()
153  const found: Shot[] = []
154  for (const raw of candidates.slice(0, 20)) {
155    const path = resolvePath(cwd, raw, home)
156    const stat = await $.fs.stat(path).catch(() => undefined)
157    if (stat?.kind !== 'file' || (isRecentOnly && now - stat.mtimeMs > RECENT_MS)) continue
158    const size = await pixelSize($, path)
159    if (size) found.push({ path, at: now, via, mtimeMs: stat.mtimeMs, readAt: 0, ...size })
160  }
161  if (found.length === 0) return
162
163  // 同一个文件、内容没变(修改时间相同):保留原来的已读;文件被重新写过就是新截图,算未读
164  const before = await read($, shots)
165  const merged = found.map(f => {
166    const old = before.find(s => s.path === f.path)
167    return old && old.mtimeMs === f.mtimeMs ? { ...f, readAt: old.readAt } : f
168  })
169  const newest = unreadOf(merged).at(-1)
170  await update($, shots, cur => [...[...merged].reverse(), ...cur.filter(s => !merged.some(f => f.path === s.path))].slice(0, KEEP))
171  if (!newest) return
172  await update($, index, () => 0)
173  const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
174  if (!isOpen) {
175    hint = newest.path
176    $.ui.invalidate('ui.render')
177    watchFiles($)
178  }
179}
180
181// 放大只在当前面板里做(藏起标题、路径和按键,让图铺满)。不去改面板宽度:
182// 停靠面板的宽度由 Claude Code 管,重新 open 带 columns 时宽度不升反降,还可能被记住
183function setPeeking($: Api, on: boolean) {
184  isPeeking = on
185  $.ui.invalidate('ui.render')
186}
187
188async function watchRelease($: Api, p: Press) {
189  if (press !== p) return
190  if ((await $.clock.now()) - p.lastAt < RELEASE_MS) return
191  p.timer?.cancel()
192  press = null
193  setPeeking($, false)
194}
195
196export async function pressPeek($: Api, path: string) {
197  const now = await $.clock.now()
198  const p = press
199  if (!p || p.path !== path) {
200    p?.timer?.cancel()
201    const fresh: Press = { path, lastAt: now, isHeld: false, timer: null }
202    press = fresh
203    fresh.timer = $.clock.after(HOLD_MS, () => {
204      if (press !== fresh || fresh.isHeld) return
205      press = null
206      void $.process.run(['qlmanage', '-p', path], { timeoutMs: 600_000 }).catch(() => undefined)
207    })
208    return
209  }
210  p.lastAt = now
211  if (p.isHeld) return
212  p.isHeld = true
213  p.timer?.cancel()
214  p.timer = $.clock.every(60, () => void watchRelease($, p))
215  setPeeking($, true)
216}
217
218// 按面板能用的格子数缩图,保持宽高比(终端一格约是一宽两高)
219function fit(shot: Shot, maxColumns: number, maxRows: number): { columns: number; rows: number } {
220  let columns = maxColumns
221  let rows = Math.round((columns * shot.height) / shot.width / 2)
222  if (rows > maxRows) {
223    rows = maxRows
224    columns = Math.max(4, Math.round((rows * 2 * shot.width) / shot.height))
225  }
226  return { columns, rows: Math.min(255, rows) }
227}
228
229async function markRead($: Api, path: string) {
230  const now = Date.now()
231  await update($, shots, cur => cur.map(s => (s.path === path ? { ...s, readAt: now } : s)))
232  const left = unreadOf(await read($, shots)).length
233  await update($, index, n => Math.max(0, Math.min(n, left - 1)))
234}
235
236// 撤销最近一次「已读」,并跳到那一张
237async function undoRead($: Api) {
238  let restored = ''
239  await update($, shots, cur => {
240    const last = cur.filter(s => s.readAt).sort((a, b) => b.readAt - a.readAt)[0]
241    restored = last?.path ?? ''
242    return cur.map(s => (s.path === restored ? { ...s, readAt: 0 } : s))
243  })
244  if (restored === '') return
245  const unread = unreadOf(await read($, shots))
246  const live = await present($, unread)
247  const at = unread.filter(s => live.has(s.path)).findIndex(s => s.path === restored)
248  await update($, index, () => Math.max(0, at))
249}
250
251export const register: Register = on => {
252  on('session.start', async ($, e, next) => {
253    await $.command.register({ name: 'shots', description: '在侧边面板查看本会话的截图', immediate: true })
254    return next(e)
255  })
256
257  // 同一个快捷键再按一次就关面板;面板拿着键盘焦点,Esc 只关面板,不会中断 Claude
258  on('command.run', { command: 'shots' }, async $ => {
259    if ((await $.ui.panes()).some(p => p.id === PANE)) {
260      await $.ui.close({ id: PANE })
261      return {}
262    }
263    hint = null
264    $.ui.invalidate('ui.render')
265    await $.ui.open({ id: PANE, title: '截图', focus: true, closeOnEscape: true, rows: 30 })
266    watchFiles($)
267    return {}
268  })
269
270  on('tool.call', async ($, e, next) => {
271    const ran = await next(e)
272    if (ran.deny !== undefined || ran.isError) return ran
273    if (e.tool === 'Read') {
274      const path = String(e.file_path ?? '')
275      if (/\.png$/i.test(path)) await record($, [path], 'Read', false)
276    } else {
277      const candidates = findPngPaths(`${JSON.stringify(e)}\n${ran.text ?? ''}`)
278      if (candidates.length > 0) await record($, candidates, e.tool, true)
279    }
280    return ran
281  })
282
283  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
284    const below = await next(e)
285    if (!hint) return below
286    const unread = unreadOf(await read($, shots))
287    const live = await present($, unread)
288    drawn = keyOf(live)
289    // 提示的那张不在了就换成还在的最新一张;一张都不在了就不再提示
290    if (!live.has(hint)) hint = unread.find(s => live.has(s.path))?.path ?? null
291    if (!hint || e.props.hasSurvey) return below
292    const { Box, Text } = $.ui.resolve(e)
293    const line = bandLine(basename(hint), live.size, e.props.bodyColumns)
294    return (
295      <Box flexDirection="column">
296        <Box flexDirection="row" gap={1}>
297          <Box flexShrink={0}>
298            <Text color="ide" bold>
299              {BAND_LABEL}
300            </Text>
301          </Box>
302          <Text wrap="truncate-end">{line.head}</Text>
303          {line.tail !== '' && (
304            <Text dimColor wrap="truncate-end">
305              {line.tail}
306            </Text>
307          )}
308        </Box>
309        {below}
310      </Box>
311    )
312  })
313
314  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
315    const { Box, Text, Button } = $.ui.resolve(e)
316    const list = await read($, shots)
317    const unreadAll = unreadOf(list)
318    const live = await present($, unreadAll)
319    drawn = keyOf(live)
320    const unread = unreadAll.filter(s => live.has(s.path))
321    const gone = unreadAll.length - unread.length
322    const readCount = list.length - unreadAll.length
323    const at = Math.min(await read($, index), Math.max(0, unread.length - 1))
324    const shot = unread[at]
325    const undo = (
326      <Button key="undo" plain hotkey="u" onPress={() => void undoRead($)}>
327        撤销已读
328      </Button>
329    )
330    const closeHint = <Text dimColor>Esc 或 ctrl+x s 关闭(面板开着时 Esc 只关面板,不会中断 Claude)</Text>
331    const goneNote = gone > 0 && <Text dimColor>另有 {gone} 张截图的文件已不在(被删除或移走),不再显示</Text>
332    if (list.length === 0) {
333      return (
334        <Box paddingX={1}>
335          <Text dimColor>还没有截图。Claude 截图或查看 PNG 后会出现在这里。</Text>
336        </Box>
337      )
338    }
339    if (!shot) {
340      return (
341        <Box flexDirection="column" paddingX={1}>
342          <Box flexDirection="row" gap={1}>
343            <Text color="success">✓</Text>
344            <Text bold>{readCount === 0 ? '没有可看的截图' : '截图都看完了'}</Text>
345            {readCount > 0 && <Text dimColor>已读 {readCount} 张</Text>}
346          </Box>
347          {goneNote}
348          {readCount > 0 && undo}
349          {closeHint}
350        </Box>
351      )
352    }
353
354    const visibleRows = Math.min(e.props.scroll.bodyRows, e.viewport?.rows ?? e.props.scroll.bodyRows)
355    const alt = `${basename(shot.path)}(终端没有开启图片显示,轻按 o 用快速查看打开)`
356    const pictureOf = (size: { columns: number; rows: number }) =>
357      e.surface === 'terminal' ? (
358        (() => {
359          const { Image } = $.ui.resolve(e)
360          return (
361            <Image
362              key="shot"
363              source={{ file: shot.path, format: 'png', generation: live.get(shot.path) ?? 0 }}
364              columns={size.columns}
365              rows={size.rows}
366              alt={alt}
367            />
368          )
369        })()
370      ) : (
371        <Text dimColor>{alt}</Text>
372      )
373
374    // 按住 o 的放大视图:只留图和一行提示,图铺满面板。
375    // 提示行本身就是绑着 o 的按键:按住时终端补发的重复按键要有地方接,否则会被当成已经松开
376    if (isPeeking) {
377      return (
378        <Box flexDirection="column">
379          {pictureOf(fit(shot, Math.max(10, e.props.bodyColumns), Math.max(4, visibleRows - 1)))}
380          <Button key="peek" plain hotkey="o" dimColor onPress={() => void pressPeek($, shot.path)}>
381            {`放大查看 ${basename(shot.path)} · 松开 o 回到列表`}
382          </Button>
383        </Box>
384      )
385    }
386
387    // 普通视图按面板真正能显示的高度缩图:标题、路径、两排按键和两行说明约占 8 行,图片太高会把按键挤出去
388    const picture = pictureOf(fit(shot, Math.max(10, e.props.bodyColumns - 2), Math.max(4, visibleRows - 8 - (gone > 0 ? 1 : 0))))
389
390    return (
391      <Box flexDirection="column" paddingX={1}>
392        <Box flexDirection="row" gap={1}>
393          <Text bold>{basename(shot.path)}</Text>
394          <Text color="suggestion">
395            未读 {at + 1}/{unread.length}
396          </Text>
397          {readCount > 0 && <Text dimColor>已读 {readCount}</Text>}
398          <Text dimColor>
399            {ago(shot.at)} · {shot.via} · {shot.width}×{shot.height}
400          </Text>
401        </Box>
402        <Text dimColor wrap="wrap">
403          {shot.path}
404        </Text>
405        {goneNote}
406        {picture}
407        <Box flexDirection="row" gap={2}>
408          <Button key="next" plain hotkey="n" onPress={() => void update($, index, n => Math.min(n + 1, unread.length - 1))}>
409            下一张
410          </Button>
411          <Button key="prev" plain hotkey="p" onPress={() => void update($, index, n => Math.max(n - 1, 0))}>
412            上一张
413          </Button>
414          <Button key="read" plain hotkey="r" onPress={() => void markRead($, shot.path)}>
415            标为已读
416          </Button>
417          {readCount > 0 && undo}
418        </Box>
419        <Box flexDirection="row" gap={2}>
420          <Button key="peek" plain hotkey="o" onPress={() => void pressPeek($, shot.path)}>
421            按住放大 · 轻按快速查看
422          </Button>
423          <Button key="reveal" plain hotkey="f" onPress={() => void $.process.run(['open', '-R', shot.path])}>
424            Finder
425          </Button>
426          <Button key="copy" plain hotkey="y" onPress={press => void $.ui.copy({ text: shot.path, surface: press.surface })}>
427            复制路径
428          </Button>
429        </Box>
430        <Text dimColor>从最新一张开始,n 往后看更早的;看过、不需要再看了就按 r,它不会再出现,只是翻过不算已读</Text>
431        {closeHint}
432      </Box>
433    )
434  })
435}
436
types/index.d.ts 24 lines
1export type Shot = {
2  path: string
3  /** when the session first saw it, ms since the epoch */
4  at: number
5  /** the tool that produced or read it */
6  via: string
7  width: number
8  height: number
9  /** the file's modification time when recorded: a rewrite of the same path is a new screenshot */
10  mtimeMs: number
11  /** when the person marked it read (r); 0 while unread */
12  readAt: number
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'shot-view': {
18      shots: Shot[]
19      /** position in the unread list, 0 = newest */
20      index: number
21    }
22  }
23}
24