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

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

77 秒介绍视频(点图下载 mp4,13.5 MB):
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 效果相同。

xcrun simctl io booted screenshot、Playwright 截图),这张图就进入列表,最多保留最近 60 张。.png。/shots(ctrl+x s)打开右侧面板,Claude 正在工作时也能立刻打开。面板只显示未读的截图,打开时停在最新一张,顶行写着当前是第几张未读、已读几张。n 下一张(更早的),p 上一张(更新的),r 标为已读(看过、不用再看时按,只是翻过不算),u 撤销最近一次已读并跳回那一张,按住 o 在面板里放大当前这张、松开收起,轻按 o 用 macOS 快速查看打开,f 在 Finder 里显示,y 复制路径;Esc 或再按一次 ctrl+x s 关闭,面板开着时 Esc 只关面板,不会中断 Claude。o 是靠终端在按住时连续补发的重复按键来识别的,mod 收不到「松开」事件。有的终端按住字母时会弹出 macOS 的重音字符菜单,不补发重复按键,这时按住会被当成轻按,打开快速查看;对这个终端运行 defaults write <终端的 bundle id> ApplePressAndHoldEnabled -bool false 再重开,就会改回按住连发。面板内放大受面板宽度限制:横屏截图放大后几乎一样大,竖长的手机截图会明显变大;想看大图就轻按 o。~/.claude/settings.json 的 env 里加 "CLAUDE_CODE_FORCE_TERMINAL_IMAGES": "1"。终端不支持时面板只显示文件名,轻按 o 用快速查看看。sips,快速查看靠 qlmanage,在 Finder 里显示靠 open。
/steps(ctrl+x p)展开「任务步骤」面板:每一步用 ✓ / ▶ / ○ 标状态,「–」表示 Claude 核对后认为这一步后来不需要了;每一步写着实际用时、预计用时和调用次数,当前步骤下面显示 Claude 正在执行的命令。面板里按 c 让 Claude 重新核对;一轮已经结束、清单还没完成时,按 d 把整份清单标为完成。/clear 会清掉清单。$.model.fork 发请求,每轮最多 6 次;一轮结束时的核对不受这个次数限制,失败后隔 5 秒、10 秒各重试一次。请求复用会话的 prompt cache,但仍然计入你的用量。
prompt.attachment 上拦下 MCP 说明,按你配的规则去掉当前目录用不上的服务器那一节;工具本身照常可用。规则可以在 /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-view | tool.call、command.run、ui.render | $.process.run(只运行 sips、qlmanage、open)、$.fs.stat、$.clock.*、$.ui.*、$.state.* |
| task-eta | turn.start、tool.call、turn.complete、command.run、session.end、ui.render | $.model.fork、$.clock.*、$.ui.* |
| ctx-slim | prompt.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 许可之内。
hooks/register.tsx 436 lines1import { 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}
436types/index.d.ts 24 lines1export 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