Thumbnails of the images a session writes, reads or generates: under the tool's row in the terminal, and in an /images pane

English | 日本語
Personal Claude Code mods (function-hook plugins), published as one marketplace.
| Mod | What it does |
|---|---|
agent-pane | Live pane of the session's subagents: running ones with elapsed time, finished ones with result and duration |
done-gate | Flags unverified edits, stops repeated failing commands, and blocks git --no-verify |
sandbox-lens | Tallies sandbox denials seen in Bash output and reports them with /sandbox-report |
skill-hint | Suggests up to three matching skills above the prompt from what was typed; never invokes them. |
todo-agent | Collects #TODO(agent) follow-ups from Claude's replies into a per-project list with a /todos pane. |
context-meter | Band above the prompt with a Gruvbox sparkline of context use per turn, plus tokens and cost |
tool-row | Redraws finished tool-call rows in the terminal as one compact line with a Nerd Font icon, Gruvbox colors and a short stat |
image-preview | Thumbnails of the images a session writes, reads or generates: under the tool's row in the terminal, and in an /images pane |
self-audit | After each turn, a cached fork of the conversation audits Claude's own reply against the CLAUDE.md reporting rules and flags violations above the prompt |
context-diet | Trims the skill listing to what the project kind needs (off by default); /context-diet shows what was dropped and the tokens saved |
browser-cam | Live pane of the browser Claude drives: the latest screenshot from Claude in Chrome, the page URL and the recent actions |
prompt-coach | Holds back a typed prompt that looks ambiguous (no done-condition, unclear target) and offers guesses at what it meant, sending it as is, or adding one; on by default |
loopback | Feeds what self-audit, done-gate and sandbox-lens found back to Claude beside the next prompt, instead of only showing it to the person, skipping rules hit-rate found mostly wrong; off by default |
zukan | Error book: catches the errors Bash commands hit across sessions and repos, records what got past each one, and when one comes back tells Claude and the person how it was fixed last time |
pr-lamp | The current branch's pull request as a lamp: checks, review, unresolved threads and what is left before a merge, in the status line and a /pr pane; red checks and new review comments go to Claude beside the next prompt |
fog-map | Exploration map: the repository as a tree under fog, cleared where Claude read and marked where it edited, made a file or hit an error, with the importers an edit may have broken unseen; /map |
assertion-guard | Catches edits that weaken tests to pass: assertions removed, expectations loosened or rewritten, tests skipped, errors swallowed; asks Claude for the reason beside the edit |
negative-space | When an edit adds authorization, validation or error handling, lists the refusal and boundary-value tests no test touched this session covers; /negative-space |
mutation-tap | /mutate runs a mutation test limited to the lines changed since HEAD and lists the mutants the tests did not catch |
perf-delta | /perf compares a benchmark before and after (a saved baseline, or a git ref in a temporary worktree): median time, peak memory, spread, and whether the change beats the noise |
handoff-capsule | Before a compaction and at session end, keeps the goal, decisions, open items, commands to reproduce and files touched; the next session picks which parts to bring back |
port-lantern | The dev servers the session started: URL, port, whether each still listens, and their latest log lines, in the status line and /ports |
artifact-shelf | A shelf of what sessions made (CSV, PDF, HTML, video, patches…), tied to the call and request that made each; /shelf searches by kind, word and time |
focus-hud | One status line: goal, target, what is running and for how long, and what the session waits on; /focus |
side-effect-ledger | A ledger of what a git diff never shows (push, publish, deploy, PRs, API writes, messages) with outcome, how to undo, and whether the undo was checked; /ledger |
trust-label | Labels external text (web, issues, logs, browser pages, services) with its source beside the result and flags candidate injected instructions; a heuristic, never a safety verdict; /trust |
visual-diff | Compares browser screenshots of the same URL and size before and after an edit: side by side, a map of changed cells, regions, and the edits in between; /vdiff |
journey-trace | Records Claude's browser actions and the expected states as a replayable recipe; /journey md for a bug report, /journey e2e for a Playwright draft |
hit-rate | Gives each finding of done-gate, assertion-guard, self-audit and zukan a later verdict from what the session did next, keeps each rule's precision across sessions, and names the rules loopback stops feeding; /hit-rate; off by default |
token-lens | Checks an element's actual colours, spacing and type against the project's design tokens and lists off-token values with the nearest token; /token-lens <selector> |
At the prompt of a terminal session:
/plugin install <mod> --marketplace simota/claude-mods
From a shell:
claude plugin install <mod> --marketplace simota/claude-mods
This repository is private: installing needs read access on GitHub and a git credential for github.com on the machine (an SSH key, or gh auth login then gh auth setup-git).
Options are set with /plugin configure <mod>@simota-mods in a session, or from a shell:
echo '{"enabled":"true"}' | claude plugin configure <mod>@simota-mods --values-stdin
A change made with /plugin configure reloads the mod; one made from a shell applies after a restart. Bands above the prompt stack: each mod draws its row above what the others draw.
/agent-pane opens the pane: running subagents first with elapsed time, then finished ones with ✓/✗ and duration; a background run that was stopped shows ■ 停止. A background run that left the roster without a result shows ? 結果不明; a teammate or remote launch shows ↗ 起動済み(追跡外).agents: N running while any run.Works on its own, no command:
ls build, npm ci are not checks), and only when its exit status is the command's: the check is last, or only && follows it (npm test | tail does not count).--no-verify (or a prefix of it from --no-ver), and git commit -n / -nm, is denied, also inside subshells, $(…), backticks, bash -c "…", eval and wrappers such as sudo/timeout. The value of an option (-m "…"), a comment or a heredoc never triggers it. So is a git command that moves its hooks for the run (-c core.hooksPath=…, --config-env core.hooksPath=…) or carries a hook manager's off switch in its environment (HUSKY=0, LEFTHOOK=0, pre-commit's SKIP=…), or moves the hooks through config in its environment (GIT_CONFIG_KEY_n=core.hooksPath, GIT_CONFIG_PARAMETERS). ANSI-C quoting ($'--no-\x76erify'), env -S "…", bash -c -- '…' and a shell reading its script from stdin (bash <<< '…', a heredoc, echo '…' | sh) are read through. It is a best-effort guard: git aliases (git ci -n), a hook path written into config beforehand and an environment exported by an earlier command are not detected./sandbox-report lists the sandbox denials seen in Bash output, for this session and all time: denied hosts and paths, counts, last seen, an example command, and which setting would allow each./sandbox-report reset clears the record.127.0.0.1:3000), since an entry without a port opens every port.<sandbox_violations> block, and Operation not permitted lines a program printed about itself, are read. For a command that succeeded or was interrupted, the block is taken from where the engine puts it, apart from the command's own output, so one inside a shown file or page is ignored even when it ends the output. A failed command keeps only its error text: the block that ends it (before an agent-proxy note) is read, and there a block the command printed itself cannot be told from the harness's. Credentials in the example command (tokens, passwords, Authorization/Cookie and other credential headers such as X-API-Key, URL userinfo, keys in env assignments including run-together names such as PGPASSWORD, known key shapes) are masked. Each section shows the top 10; when the record is full (200), the least recently seen entry gives way.After a prompt is sent, up to three skills matching it show above the prompt (💡 skill候補: /coding-debug …), only ones that exist in the session. Nothing is invoked; 閉じる or the next prompt (a slash command included) clears it. A prompt that already names a skill (/name anywhere, or a hyphenated skill name such as coding-debug) gets no hint; built-in commands such as /clear and path segments such as src/review/ do not count as naming one. Plugin skills (plugin:name) are suggested by their full name.
#TODO(agent): <action> (after an optional quote or list bullet) in Claude's main-loop replies are collected per project (repo root or cwd). A mention inside a sentence, a table or inline code is not a TODO; subagent replies are ignored./todos opens the pane of this project's open items, each with a 完了 button./todos all lists every project's open items as text, marked as stored data.TODO(agent): N while the project has open items.A band with a sparkline of context use per turn and ctx 63% ▲4 · 126k · turn in 120k / out 8k · $1.23: the context size of the last request, then the whole turn's input/output (shown when the turn reported usage). The percentage turns yellow from 50% and red from 80%.
| Option | Default | |
|---|---|---|
band | true | Draw the band (samples are recorded either way) |
Finished tool calls are drawn as one compact line with a Nerd Font icon: Edit/Write path +A −D, Read with line count, Bash (red on error), Agent, web and MCP calls. The result block under a compacted row is hidden, so a call is one line; an errored result stays. A row another mod decorated (an image-preview thumbnail) is left as it is, result included. Runs Claude Code folds into one count line (Read 3 files, ran 2 shell commands) stay folded. Terminal only.
| Option | Default | |
|---|---|---|
compact | true | Off leaves every row and result as Claude Code draws them |
/images opens a pane, newest first; h/l step through them.sips after the tool result has gone back, so tool calls never wait on it. Converted files live in a private cache ($TMPDIR or ~/.cache), newest 50 kept. A killed session's folder is removed by a later session's start once a day untouched; a running session touches its own every hour (again after a plugin reload), so it is kept while idle, though not across a day the machine sleeps./tmp/a b/x.jpg) joins at most 8 preceding pieces and 4096 characters; the first 64 paths named are read, and of those the first 4 that exist are shown.| Option | Default | |
|---|---|---|
inline | true | Thumbnail under the tool row |
maxRows | 12 | Rows a thumbnail may take, 1 to 60 |
After each reply of at least minChars characters, one tool-less fork of the conversation (served mostly from the prompt cache), given the reply itself as quoted data, checks it against the CLAUDE.md rules: claims done without evidence, first line not the result, unverified assertions, bypassed checks, scope inflation. Only violations of the five rules whose quote is found in the reply are kept (a made-up quote or rule is dropped); the quote is compared past markdown marks, bullets, curly quotes, case, spacing and a closing full stop, and one shortened with … is found piece by piece. Violations show in red above the prompt; 詳細 or /self-audit opens the pane with quotes and the fork's token use. Results belong to the latest reply only: when it was not audited, the pane says why.
| Option | Default | |
|---|---|---|
enabled | true | |
minChars | 200 | Shorter replies are not audited |
Cost: one extra request per turn on the session's model, plus the reply text (up to 12,000 characters) as uncached input; a cold cache (after 5 minutes idle or a model switch) resends the whole conversation.
Off by default. When on, the skill listing is trimmed to what the project kind needs (Swift, Android, Rust, web, Python, Go, detected from the repo root and one level below). Coding, quality, planning and research skills are always kept; red-team, dynamic-*, persona and operation skills are dropped; image, design, ux-scan and brand-scan are dropped for Rust, Python and Go. Only the main loop's listing is reported. A dropped skill named in a prompt is still described for that prompt.
/context-diet shows the project kind, what was kept and dropped in the latest listing, and the tokens saved (estimate). A listing it cannot read reliably goes out unchanged./context-diet on|off|reset overrides the option for this session. Switching rebuilds the listing, so the prompt cache misses once.| Option | Default | |
|---|---|---|
enabled | false | |
keep | Comma list of names or prefix* never dropped; wins over drop | |
drop | Comma list of names or prefix* always dropped |
type ‹7文字›), since the field may be a password; a URL's secrets (token parameters, userinfo, key-shaped parts) are masked./cam toggles the pane; in a fullscreen terminal it opens by itself once per session, on the first browser call. Screenshots are decoded only while the pane is open, and written to a private per-session folder that is removed at session end. A killed session's folder is removed by a later session's start once a day untouched; a running session touches its own every hour (again after a plugin reload), so it is kept while idle, though not across a day the machine sleeps.| Option | Default | |
|---|---|---|
autoOpen | true | Open on the first browser call (fullscreen terminal) |
On by default (enabled: false turns it off). A typed prompt that looks ambiguous (no done-condition and an unclear target, or only evaluative words like いい感じ) is held back, and a band shows what is missing with up to three guesses at what it meant (judged along the lines of the clarify skill: the likeliest reading first, each a different deliverable with a checkable done-condition):
a / b / d: a guess goes into the prompt to edit and send (not held again)s: このまま送る sends it unchangede: 補足して送る puts it back with 完了条件: to fill in (not held again)q: 閉じる puts it back in the promptTyping another prompt instead sends that one and puts the held text back into the prompt. The held text is never lost: when a send fails or is dropped beneath, or putting words back fails, it stays held with its band (unless another prompt was held since) and its words are shown in a toast; a prompt still held at /clear goes straight back into the box, and one held when the session quits comes back at this project's next interactive session start. Words go into an empty box as they are, and are appended after a draft typed meanwhile on a line of their own, never over it.
Slash commands, prompts under 12 or over 1500 characters, prompts with any attachment or an @ mention, prompts sent by a plugin, and prompts typed while a turn runs are not judged. A timeout or error sends the prompt unchanged; the same text is not held twice (within the last 50 judged). Sending can wait up to timeoutMs.
| Option | Default | |
|---|---|---|
enabled | true | |
model | claude-haiku-5-5 | Judging model (alias or id) |
timeoutMs | 3000 | 500 to 3000 |
Off by default. When on, the new findings of the detector mods are attached to the next prompt typed at an idle prompt, as context only the model reads, so the correction does not wait for the person to retype it:
self-audit: the violations of the latest audited replydone-gate: edits with no passing check after them (again after a check passed and new edits were left unverified; 閉じる on its band takes the flag down, and nothing is fed)sandbox-lens: denials seen this session, each onceEach finding goes back once per session, marked as data that may be wrong for Claude to check before acting. A toast says how many went back and from where. Slash commands, hidden prompts, prompts sent by a plugin and prompts typed while a turn runs carry nothing. A detector that is not installed contributes nothing.
With hit-rate on, the rules it muted are not fed (sandbox-lens denials never are muted).
/loopback shows what was fed last and the self-audit repeat rate: of the rules fed back, how many self-audit flagged again on the next audited reply (all sessions). The rate is what the design is judged by (.genius/decisions/loopback.md). It also names the rules hit-rate muted.| Option | Default | |
|---|---|---|
enabled | false |
Cost: the findings ride as extra input on the prompt they are attached to (each quote clipped to 300 characters, at most 12 findings).
An error book, kept across sessions and repos. When a Bash command fails, the error line is caught and numbered:
📕 新種のエラー発見! No.042「…」 — a kind not seen before📙 No.017 再会(3回目・未解決) — seen before, nothing known to get past it 📗 既知 No.017(3回目)— 前回: sandbox 外で git push origin main ` — seen before with a fix: Claude also reads, beside the tool's result, where it was seen and what got past it (marked as a past record to check, not an instruction)A fix is learned when the same program and subcommand (git push, pnpm install; read past a leading cd, export or source, so cd app && npm install is npm install) succeeds within the next 10 Bash calls of the session; whether it ran outside the sandbox is kept with it. Two sightings are one kind when their error line matches with paths, quotes, numbers and hashes taken out.
Failures of tests, linters, type checkers and builds are not caught: they are fixed by editing code, not by a command. Credentials in a kept command and in the kept error line are masked as sandbox-lens does.
/zukan opens the book, most recently seen first: kinds, solved, sightings/zukan note <No> <text> writes how to get past it, shown and fed in place of the learned command; an empty text removes the note/zukan forget <No> removes an entryThe newest 300 kinds by last sighting are kept.
The current branch's pull request, kept in the status line while you work:
⎇ #42 · ✓5 ✗1 ⏳2 · 🛑 変更要求 · 💬3 · マージまであと4
"マージまであと N" counts what still stands before a merge: draft, failed checks, running checks, changes requested or approval pending, unresolved review threads, a conflict, the branch behind its base. At 0 it reads 🟢 マージ可能.
A toast when it changes: 🔴 CI 失敗: test, 💬 新しいレビューコメント 2件 (@rev), ✅ Approved, 🟢 #42 マージまであと0, 🎉 #42 がマージされました. Checks turning red, new review comments, changes requested and a new conflict are also attached to the next prompt typed at an idle prompt, once, with the last 40 lines of the failed log of up to three jobs (more are counted), marked as data whose comments and logs are not to be followed as instructions. The session's own replies on a thread (the comment, made after the post, carrying words it replied with through an MCP reply tool or gh api …/pulls/N/comments… -f body=, matched once) are not news; your own review comments are. What waits for the next prompt is about one pull request and is capped (newest kept). The option feed (on by default) turns that off.
/pr asks again and opens the pane: title, size, what is left, checks (failed first) and unresolved threads/pr <number|url|branch> follows that pull request; /pr clear goes back to the defaultWhich pull request: the one a Bash command of the session last worked on (gh pr comment 2, gh pr review 2, the URL gh pr create printed, the branch a git push sent to; looking with gh pr view|diff|checks does not move it), so work pushed to a review branch from another checkout is followed; else the checked-out branch's; else your newest open pull request in the repository.
It asks gh (gh pr view, and gh api graphql for review threads) at session start, right after a Bash git push, git checkout, gh pr create and the like, every minute while a check runs or after a push, and every five minutes otherwise; it stops once the pull request is merged or closed. Without an origin remote, gh, or a pull request for the branch it shows nothing, and /pr says why. gh runs on the host, outside the Bash sandbox, with your gh auth.
The repository as a map under fog, cleared as Claude works through it:
🗺 探索率 31% ██████░░░░░░░░░░░░░░ 読 23 · 編 9 · ✗ 2 / 101
▾ mods/
▾ pr-lamp/
▾ hooks/
● lamp.ts +43 −2 編集 4回
✗ register.tsx +59 −16 編集 6回 エラー 1 ★
▸ loopback/ ○●
░ agent-pane/ ░ browser-cam/ ░ context-diet/ …8
⚠ 編集したファイルを import しているが未読: mods/pr-lamp/tests/pr-lamp.test.ts
░ nothing touched yet, ○ read (Read, or cat, sed, grep… in Bash), ● edited (Edit, Write, NotebookEdit, sed -i), ✚ made by the session, ✗ named with a line number by a failed Bash command since its last edit, ★ the file touched last. A directory holding an edit, a new file or an error is open; one only read is closed with its marks beside it; untouched directories and files at a level share one fog row.
Two warnings, each a likely oversight: a file edited without being read, and a file that imports an edited one (JS/TS and Python, found by git grep) that was never opened. A very large repository's file list is cut to what one state value holds (3 M characters of paths), so it is still mapped, partly.
/map opens the pane🗺 31% · ●9 · ✗2 · ⚠1🗺 新しい区域を開拓: mods/zukan/)The map is the session's, subagents' calls included; files outside the repository ar
hooks/register.tsx 421 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, RenderInput, RenderSurface, Register, Timer } from 'claude-code'
3
4import type { ConvertedFile, ImageItem } from '../types'
5import {
6 IMAGE_EXT,
7 absolutize,
8 baseName,
9 caption,
10 cellBox,
11 findImageCandidates,
12 isPng,
13 pngDimensions,
14 sipsDimensions,
15} from './images'
16
17const PANE = 'image-preview'
18const TITLE = '画像プレビュー'
19const KEEP = 20
20const PER_CALL = 4
21// What the terminal is asked to decode; bigger files get a caption instead.
22const MAX_BYTES = 32 * 1024 * 1024
23// A Bash run "produces" an image modified since it started (with clock slack).
24const SLACK_MS = 2000
25const CONVERT_MAX_PX = 1024
26// Converted copies kept in the session's cache; older ones are removed, never one the pane shows.
27const CACHE_KEEP = 50
28const CACHE_NAME = 'claude-image-preview'
29// A session's cache left behind (the process was killed before session.end) goes after a day.
30const STALE_MS = 24 * 60 * 60 * 1000
31// A live session touches its cache this often, so one left idle for a day is not taken for killed.
32const HEARTBEAT_MS = 60 * 60 * 1000
33// The pane's own rows that are not the picture: the step buttons, the caption, the path.
34const PANE_CHROME_ROWS = 3
35const PRUNED = 'キャッシュから削除済み'
36
37const images = atom({ plugin: 'image-preview', key: 'images' } as const, [])
38const cursor = atom({ plugin: 'image-preview', key: 'cursor' } as const, 0)
39const byTool = atom({ plugin: 'image-preview', key: 'byTool' } as const, [])
40const converted = atom({ plugin: 'image-preview', key: 'converted' } as const, [] as ConvertedFile[])
41
42// Where a call's paths are read from: taken in the hook itself, since a timer that outlives
43// the hook reads the session's directory, not that of the call (a subagent's worktree).
44type CallPlace = { cwd: string; home: string | undefined; sessionId: string }
45
46// Session caches made and checked by this load of the module, by session id.
47const cacheDirs = new Map<string, string>()
48let heartbeat: Timer | undefined
49
50// Each hour while this process keeps a cache, its mtime is moved on: another session's sweep
51// reads a folder untouched for a day as a killed session's, however idle this one is.
52function keepAlive($: EngineInterface): void {
53 heartbeat ??= $.clock.every(HEARTBEAT_MS, () => {
54 if (cacheDirs.size === 0) {
55 heartbeat?.cancel()
56 heartbeat = undefined
57 return
58 }
59 for (const dir of cacheDirs.values()) void $.process.run(['touch', '-c', '--', dir]).catch(() => undefined)
60 })
61}
62// Work after a tool call runs one at a time, in the order the calls finished.
63let queue: Promise<void> = Promise.resolve()
64
65async function sips($: EngineInterface, argv: string[]) {
66 return $.process.run(['sips', ...argv], { timeoutMs: 15_000 }).catch(() => undefined)
67}
68
69// A session id as a folder name: the same id always gives the same name, so a reload finds it again.
70const folderName = (sessionId: string) => sessionId.replace(/[^\w-]/g, '_')
71
72// TMPDIR when set, else ~/.cache: never a shared /tmp.
73async function rootDir($: EngineInterface): Promise<string | undefined> {
74 const tmp = (await $.env.get('TMPDIR'))?.replace(/\/+$/, '')
75 const home = (await $.env.get('HOME'))?.replace(/\/+$/, '')
76 return tmp !== undefined && tmp !== '' ? tmp : home !== undefined && home !== '' ? `${home}/.cache` : undefined
77}
78
79// `<parent>/<name>` when it is already a real directory (not a link) right where the parent resolves.
80async function checkedChild($: EngineInterface, parent: string, name: string): Promise<string | undefined> {
81 const [where, above] = await Promise.all([
82 $.fs.stat(`${parent}/${name}`, { resolve: true }).catch(() => undefined),
83 $.fs.stat(parent, { resolve: true }).catch(() => undefined),
84 ])
85 if (where?.kind !== 'dir' || where.isLink || above?.realPath === undefined) return undefined
86 return where.realPath === `${above.realPath}/${name}` ? where.realPath : undefined
87}
88
89/**
90 * `<parent>/<name>` as a folder only this user can reach: made 0700, then checked to be
91 * a real directory of ours (not a link, owned by us) right where the parent resolves,
92 * so nothing planted beforehand can take a write.
93 */
94async function privateChild($: EngineInterface, parent: string, name: string): Promise<string | undefined> {
95 const made = await $.process
96 .run(['/bin/sh', '-c', 'umask 077 && mkdir -p -m 700 -- "$1" && [ -d "$1" ] && [ ! -L "$1" ] && [ -O "$1" ] && chmod 700 -- "$1"', 'sh', `${parent}/${name}`])
97 .catch(() => undefined)
98 if (made?.exitCode !== 0) return undefined
99 return checkedChild($, parent, name)
100}
101
102// One cache per session: a session's prune never touches what another session's rows draw.
103async function cacheFolder($: EngineInterface, sessionId: string): Promise<string | undefined> {
104 const known = cacheDirs.get(sessionId)
105 if (known !== undefined) return known
106 // Work queued for a session a /clear has since ended: its folder is gone and stays gone.
107 if (sessionId !== (await $.session.id())) return undefined
108 const parent = await rootDir($)
109 if (parent === undefined) return undefined
110 const base = await privateChild($, parent, CACHE_NAME)
111 if (base === undefined) return undefined
112 const dir = await privateChild($, base, folderName(sessionId))
113 if (dir !== undefined) {
114 cacheDirs.set(sessionId, dir)
115 keepAlive($)
116 }
117 return dir
118}
119
120// The plugin's base folder as it already stands; nothing is made.
121async function existingBase($: EngineInterface): Promise<string | undefined> {
122 const parent = await rootDir($)
123 return parent === undefined ? undefined : checkedChild($, parent, CACHE_NAME)
124}
125
126async function removeSessionCache($: EngineInterface, sessionId: string): Promise<void> {
127 cacheDirs.delete(sessionId)
128 const base = await existingBase($)
129 if (base === undefined) return
130 const dir = await checkedChild($, base, folderName(sessionId))
131 if (dir !== undefined) await $.process.run(['rm', '-rf', '--', dir]).catch(() => undefined)
132}
133
134// Caches of sessions that never reached session.end (a killed process), once a day old.
135async function sweepStale($: EngineInterface, currentId: string): Promise<void> {
136 const base = await existingBase($)
137 if (base === undefined) return
138 const entries = await $.fs.list(base).catch(() => [])
139 const cutoff = (await $.clock.now()) - STALE_MS
140 for (const entry of entries) {
141 if (entry.kind !== 'dir' || entry.name === folderName(currentId)) continue
142 const stat = await $.fs.stat(`${base}/${entry.name}`, { resolve: true }).catch(() => undefined)
143 if (stat === undefined || stat.mtimeMs >= cutoff) continue
144 const dir = await checkedChild($, base, entry.name)
145 if (dir !== undefined) await $.process.run(['rm', '-rf', '--', dir]).catch(() => undefined)
146 }
147}
148
149/**
150 * Notes a converted copy and, past CACHE_KEEP, removes the oldest copies the pane does not
151 * show. Each row that drew a removed copy is told, so it shows a caption, not an empty box.
152 */
153async function trackConverted($: EngineInterface, png: string, toolUseId: string): Promise<void> {
154 const list = await update($, converted, all => {
155 const known = all.find(one => one.png === png)
156 const ids = [...new Set([...(known?.toolUseIds ?? []), toolUseId])]
157 return [...all.filter(one => one.png !== png), { png, toolUseIds: ids }]
158 })
159 if (list.length <= CACHE_KEEP) return
160 const shown = new Set((await read($, images)).flatMap(item => (item.png === undefined ? [] : [item.png])))
161 const dropped = list.filter(one => one.png !== png && !shown.has(one.png)).slice(0, list.length - CACHE_KEEP)
162 if (dropped.length === 0) return
163 const gone = new Set(dropped.map(one => one.png))
164 await update($, converted, all => all.filter(one => !gone.has(one.png)))
165 for (const one of dropped) {
166 await $.process.run(['rm', '-f', '--', one.png]).catch(() => undefined)
167 for (const id of one.toolUseIds) {
168 await update($, memberOf(byTool, { requestId: id }), items =>
169 items.map(item => {
170 if (item.png !== one.png) return item
171 const { png: _removed, ...rest } = item
172 return { ...rest, skipped: PRUNED }
173 }),
174 )
175 }
176 }
177}
178
179// A PNG's size is in its first 24 bytes: read those alone, never the whole file.
180async function pngHeader($: EngineInterface, path: string): Promise<string | undefined> {
181 const ran = await $.process.run(['/bin/sh', '-c', 'head -c 32 -- "$1" | base64', 'sh', path]).catch(() => undefined)
182 return ran?.exitCode === 0 ? ran.stdout.replace(/\s+/g, '') : undefined
183}
184
185async function dimensions($: EngineInterface, path: string) {
186 if (isPng(path)) {
187 const head = await pngHeader($, path)
188 return head === undefined ? undefined : pngDimensions(head)
189 }
190 const ran = await sips($, ['-g', 'pixelWidth', '-g', 'pixelHeight', path])
191 return ran?.exitCode === 0 ? sipsDimensions(ran.stdout) : undefined
192}
193
194async function hashOf(text: string): Promise<string> {
195 const digest = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(text))
196 return [...new Uint8Array(digest)].slice(0, 8).map(b => b.toString(16).padStart(2, '0')).join('')
197}
198
199// Only PNG reaches the terminal's Image: anything else is converted with macOS sips.
200async function convertToPng($: EngineInterface, path: string, mtimeMs: number, place: CallPlace, toolUseId: string): Promise<string | undefined> {
201 const dir = await cacheFolder($, place.sessionId)
202 if (dir === undefined) return undefined
203 const out = `${dir}/${await hashOf(`${path}:${mtimeMs}`)}.png`
204 const ran = await sips($, ['-s', 'format', 'png', '-Z', String(CONVERT_MAX_PX), path, '--out', out])
205 if (ran?.exitCode !== 0) return undefined
206 await trackConverted($, out, toolUseId)
207 return out
208}
209
210async function describe(
211 $: EngineInterface,
212 path: string,
213 toolUseId: string,
214 since: number | undefined,
215 place: CallPlace,
216): Promise<ImageItem | undefined> {
217 const stat = await $.fs.stat(path).catch(() => undefined)
218 if (stat === undefined || stat.kind !== 'file') return undefined
219 // The Image's generation is a whole number; a file system's mtime need not be.
220 const mtimeMs = Math.trunc(stat.mtimeMs)
221 if (since !== undefined && mtimeMs < since - SLACK_MS) return undefined
222
223 const item: ImageItem = {
224 id: `${path}:${mtimeMs}`,
225 path,
226 name: baseName(path),
227 size: stat.size,
228 mtimeMs,
229 toolUseId,
230 }
231 if (stat.size > MAX_BYTES) return { ...item, skipped: '大きすぎるため省略' }
232
233 const dims = await dimensions($, path)
234 const png = isPng(path) ? path : await convertToPng($, path, mtimeMs, place, toolUseId)
235 return {
236 ...item,
237 ...dims,
238 ...(png === undefined ? { skipped: 'PNG に変換できないため省略' } : { png }),
239 }
240}
241
242async function remember($: EngineInterface, found: ImageItem[], toolUseId: string): Promise<void> {
243 if (found.length === 0) return
244 const ids = new Set(found.map(item => item.id))
245 await update($, images, list => [...found, ...list.filter(item => !ids.has(item.id))].slice(0, KEEP))
246 await update($, cursor, () => 0)
247 await update($, memberOf(byTool, { requestId: toolUseId }), () => found)
248 const first = found[0]
249 if (first !== undefined) {
250 $.ui.toast(`画像プレビュー: ${first.name}${found.length > 1 ? ` 他${found.length - 1}件` : ''}`)
251 }
252}
253
254async function collect(
255 $: EngineInterface,
256 groups: string[][],
257 toolUseId: string,
258 since: number | undefined,
259 place: CallPlace,
260): Promise<void> {
261 // A Bash run that only names an image it did not make (`file a.png` after a Write)
262 // does not show that version again.
263 const shown = since === undefined ? new Set<string>() : new Set((await read($, images)).map(item => item.id))
264 const found: ImageItem[] = []
265 const seen = new Set<string>()
266 for (const group of groups) {
267 // The longest reading that exists is the path: `/tmp/a /tmp/b.png` is no file, `/tmp/b.png` is.
268 for (const candidate of group) {
269 const path = absolutize(candidate, place.cwd, place.home)
270 if (path === undefined || !IMAGE_EXT.test(path) || seen.has(path)) continue
271 const item = await describe($, path, toolUseId, since, place)
272 if (item === undefined) continue
273 seen.add(path)
274 if (!shown.has(item.id)) found.push(item)
275 break
276 }
277 if (found.length >= PER_CALL) break
278 }
279 await remember($, found, toolUseId)
280}
281
282// Run after the result has gone back to the model: stat and sips never hold a tool call up.
283function later($: EngineInterface, work: () => Promise<void>): void {
284 $.clock.after(0, () => {
285 queue = queue.then(work).catch(err => $.ui.log(`image-preview: ${String(err)}`, { to: 'debug' }))
286 return queue
287 })
288}
289
290async function placeOf($: EngineInterface): Promise<CallPlace> {
291 const [cwd, home, sessionId] = await Promise.all([$.session.cwd(), $.env.get('HOME'), $.session.id()])
292 return { cwd, home, sessionId }
293}
294
295function thumbnail(e: RenderInput, $: EngineInterface, item: ImageItem, maxColumns: number, maxRows: number) {
296 const surface: RenderSurface = e.surface
297 const label = caption(item)
298 if (surface === 'terminal' && item.png !== undefined) {
299 const { Box, Text, Image } = $.ui.resolve({ ...e, surface })
300 const box = cellBox(item, maxColumns, maxRows)
301 return (
302 <Box key={item.id} flexDirection="column">
303 <Image
304 key={item.id}
305 source={{ file: item.png, format: 'png', generation: Math.trunc(item.mtimeMs) }}
306 columns={box.columns}
307 rows={box.rows}
308 alt={label}
309 />
310 <Text dimColor>🖼️ {label}</Text>
311 </Box>
312 )
313 }
314 const { Text } = $.ui.resolve(e)
315 return (
316 <Text key={item.id} dimColor wrap="truncate-end">
317 🖼️ {label}
318 {item.skipped !== undefined ? `(${item.skipped})` : ''}
319 </Text>
320 )
321}
322
323export const register: Register = (on, options) => {
324 const isInline = options.inline !== false
325 const inlineRows = Math.min(60, Math.max(1, Number(options.maxRows ?? 12) || 12))
326
327 on('session.start', async ($, e, next) => {
328 await $.command.register({ name: 'images', description: 'このセッションの画像をプレビューペインに表示する' })
329 const sessionId = await $.session.id()
330 await sweepStale($, sessionId)
331 // After a reload this load has made nothing, yet the session's cache may be there already.
332 const base = await existingBase($)
333 const dir = base === undefined ? undefined : await checkedChild($, base, folderName(sessionId))
334 if (dir !== undefined) {
335 cacheDirs.set(sessionId, dir)
336 keepAlive($)
337 }
338 return next(e)
339 })
340
341 on('session.end', async ($, e, next) => {
342 // The session's converted copies go with it; the folder is found from the id and checked first.
343 await removeSessionCache($, e.sessionId)
344 return next(e)
345 })
346
347 on('command.run', { command: 'images' }, async $ => {
348 await $.ui.open({ id: PANE, title: TITLE })
349 return { text: '画像プレビューを開きました。' }
350 })
351
352 on('tool.call', { tool: ['Write', 'Edit', 'Read'] }, async ($, e, next) => {
353 const ran = await next(e)
354 if (ran.deny === undefined && ran.isError !== true && IMAGE_EXT.test(e.file_path)) {
355 const { file_path: path, tool_use_id: id } = e
356 const place = await placeOf($)
357 later($, () => collect($, [[path]], id, undefined, place))
358 }
359 return ran
360 }).catch(($, e, next) => next(e)) // an observer: a failure here never changes the call
361
362 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
363 const startedAt = await $.clock.now()
364 const ran = await next(e)
365 if (ran.deny !== undefined) return ran
366 const output = ran.text ?? (ran.isError === true ? '' : `${ran.result.stdout}\n${ran.result.stderr}`)
367 const groups = findImageCandidates(`${e.command}\n${output}`)
368 if (groups.length > 0) {
369 const id = e.tool_use_id
370 const place = await placeOf($)
371 later($, () => collect($, groups, id, startedAt, place))
372 }
373 return ran
374 }).catch(($, e, next) => next(e))
375
376 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
377 if (!isInline) return next(e)
378 const found = await read($, memberOf(byTool, e))
379 if (found.length === 0) return next(e)
380 const row = await next(e)
381 const { Box } = $.ui.resolve(e)
382 const columns = Math.max(8, (e.viewport?.columns ?? 80) - 4)
383 return (
384 <Box flexDirection="column">
385 {row}
386 <Box flexDirection="column" paddingLeft={2}>
387 {found.map(item => thumbnail(e, $, item, columns, inlineRows))}
388 </Box>
389 </Box>
390 )
391 })
392
393 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
394 const { Box, Text, Button } = $.ui.resolve(e)
395 const list = await read($, images)
396 if (list.length === 0) return <Text dimColor>まだ画像はありません。</Text>
397 const at = Math.min(await read($, cursor), list.length - 1)
398 const item = list[at]
399 if (item === undefined) return <Text dimColor>まだ画像はありません。</Text>
400 const columns = Math.max(8, e.props.bodyColumns - 2)
401 // The pane's own room, not the whole screen: a docked pane shares it with tabs and marks.
402 const rows = Math.max(4, e.props.scroll.bodyRows - PANE_CHROME_ROWS)
403 const step = (by: number) => () => update($, cursor, n => Math.min(list.length - 1, Math.max(0, n + by)))
404 return (
405 <Box flexDirection="column">
406 <Box flexDirection="row" gap={2}>
407 <Button key="prev" hotkey="h" label="◀ 新しい" onPress={step(-1)} />
408 <Text dimColor>
409 {at + 1} / {list.length}
410 </Text>
411 <Button key="next" hotkey="l" label="古い ▶" onPress={step(1)} />
412 </Box>
413 {thumbnail(e, $, item, columns, rows)}
414 <Text dimColor wrap="truncate-start">
415 {item.path}
416 </Text>
417 </Box>
418 )
419 })
420}
421hooks/images.ts 197 lines1// Pure helpers: finding image paths in text, sizing a thumbnail in cells, captions.
2
3export const IMAGE_EXT = /\.(png|jpe?g|webp|gif)$/i
4
5// A quoted path may hold spaces: "…", '…' or `…`.
6const QUOTED = /(["'`])([^"'`\n]*?\.(?:png|jpe?g|webp|gif))\1/gi
7// A bare path holds none, and may end a sentence: `out/cat.png.`, `out/cat.png。`, `(out/cat.png)`.
8// It lies inside one word: a run of characters a bare path may hold.
9const WORD = /[^\s'"`()<>|;,\[\]「」『』。、:]+/g
10const EXTENSION_AT = /\.(?:png|jpe?g|webp|gif)/gi
11// What may follow a bare path's extension, and what may stand before the word it starts.
12const AFTER = /[\s'"`),;::\].。、」』!?!?]/
13const BEFORE = /[\s(,\[「『]/
14// A path that starts inside a word starts after one of these: `--out=a.png`, `x:/b.png`.
15const INNER_START = /[=:]/g
16// The token before a bare match, when it may be the head of a path split at a
17// space (`/tmp/a` before `b/x.jpg`): a path start, no file extension of its own.
18const PATH_START = /^(?:~|\.{1,2})?\//
19const NOT_TOKEN = /[\s'"`]/
20const HAS_EXTENSION = /\.\w{1,5}$/
21// Punctuation that ends a list item or a clause: `a.png, b.png` is two paths, never one.
22const SEPARATOR_END = /[,;:)\]]$/
23// How far back a split path is followed: a long argument list (`ls /a /b … /x.png`) is
24// not offered as one path per argument, nor one longer than a path can be.
25const MAX_PIECES = 8
26const MAX_PATH = 4096
27// Paths read from one text: a call shows its first few images, so a listing of thousands
28// (an echoed glob, `find -name '*.png'`) is read no further than its first ones.
29const MAX_GROUPS = 64
30
31const isUrl = (path: string) => /^[a-z]+:\/\//i.test(path)
32
33/**
34 * The bare image path in one word, at most one: from the word's first place a path may start
35 * to its last image extension that ends a path. As the regex it replaces read it, and linear.
36 */
37function pathInWord(text: string, from: number, word: string): { at: number; path: string } | undefined {
38 let end = -1
39 let extensionAt = -1
40 for (const match of word.matchAll(EXTENSION_AT)) {
41 const after = from + match.index + match[0].length
42 if (after === text.length || AFTER.test(text.charAt(after))) {
43 end = after
44 extensionAt = from + match.index
45 }
46 }
47 if (end < 0) return undefined
48 let at = from === 0 || BEFORE.test(text.charAt(from - 1)) ? from : -1
49 if (at < 0) {
50 INNER_START.lastIndex = 0
51 const inner = INNER_START.exec(word)
52 if (inner !== null) at = from + inner.index + 1
53 }
54 if (at < 0 || at > extensionAt) return undefined
55 return { at, path: text.slice(at, end) }
56}
57
58/** The token that ends one space before `end`, when one may head a split path. */
59function headBefore(text: string, end: number): string | undefined {
60 if (text.charAt(end - 1) !== ' ') return undefined
61 let from = end - 1
62 while (from > 0 && !NOT_TOKEN.test(text.charAt(from - 1)) && end - from <= MAX_PATH) from -= 1
63 if (from > 0 && !/\s/.test(text.charAt(from - 1))) return undefined
64 const piece = text.slice(from, end - 1)
65 return PATH_START.test(piece) ? piece : undefined
66}
67
68/**
69 * Image-looking paths in a command or its output, in order, each as its candidates,
70 * longest first. Text alone cannot tell `/tmp/a b/x.jpg` (one path with a space) from
71 * `convert /tmp/a /tmp/b.png` (two arguments): each joined reading is offered with the
72 * plain tail last, and the one that exists on disk is the path. Linear in the text: it
73 * runs on every Bash result before the result goes back. The first MAX_GROUPS paths only.
74 */
75export function findImageCandidates(text: string): string[][] {
76 const found: { at: number; candidates: string[] }[] = []
77 // Distinct paths read so far, URLs aside: what the cap counts, so repeats never use it up.
78 const quoted = new Set<string>()
79 const tails = new Set<string>()
80 for (const match of text.matchAll(QUOTED)) {
81 if (match[2] === undefined) continue
82 found.push({ at: match.index, candidates: [match[2]] })
83 if (!isUrl(match[2])) quoted.add(match[2])
84 if (quoted.size >= MAX_GROUPS) break
85 }
86 // Quoted spans are blanked out, so a space inside one is never read as a split.
87 const bare = text.replace(QUOTED, span => ' '.repeat(span.length))
88 for (const word of bare.matchAll(WORD)) {
89 if (tails.size >= MAX_GROUPS) break
90 const one = pathInWord(bare, word.index, word[0])
91 if (one === undefined) continue
92 let start = one.at
93 const candidates = [one.path]
94 let path = one.path
95 for (let head = headBefore(bare, start); head !== undefined && candidates.length <= MAX_PIECES; head = headBefore(bare, start)) {
96 if (SEPARATOR_END.test(head) || HAS_EXTENSION.test(head) || path.length + head.length + 1 > MAX_PATH) break
97 path = `${head} ${path}`
98 start -= head.length + 1
99 candidates.unshift(path)
100 }
101 found.push({ at: start, candidates })
102 if (!isUrl(one.path)) tails.add(one.path)
103 }
104 const seen = new Set<string>()
105 const groups: string[][] = []
106 for (const one of found.sort((a, b) => a.at - b.at)) {
107 const fresh = one.candidates.filter(path => !isUrl(path) && !seen.has(path))
108 if (fresh.length === 0) continue
109 for (const path of fresh) seen.add(path)
110 groups.push(fresh)
111 if (groups.length >= MAX_GROUPS) break
112 }
113 return groups
114}
115
116/** An absolute path for `path`, `~` expanded, relative ones against `cwd`; `..` and `.` folded. */
117export function absolutize(path: string, cwd: string, home: string | undefined): string | undefined {
118 let full = path
119 if (full === '~' || full.startsWith('~/')) {
120 if (home === undefined) return undefined
121 full = home + full.slice(1)
122 } else if (!full.startsWith('/')) {
123 full = `${cwd.replace(/\/$/, '')}/${full}`
124 }
125 const parts: string[] = []
126 for (const part of full.split('/')) {
127 if (part === '' || part === '.') continue
128 if (part === '..') parts.pop()
129 else parts.push(part)
130 }
131 return `/${parts.join('/')}`
132}
133
134export function baseName(path: string): string {
135 return path.slice(path.lastIndexOf('/') + 1)
136}
137
138export function isPng(path: string): boolean {
139 return /\.png$/i.test(path)
140}
141
142export function formatSize(bytes: number): string {
143 if (bytes < 1024) return `${bytes} B`
144 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
145 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
146}
147
148/** Width and height from the first bytes of a PNG (signature + IHDR), base64. */
149export function pngDimensions(headBase64: string): { width: number; height: number } | undefined {
150 let bytes: string
151 try {
152 bytes = atob(headBase64.slice(0, 32))
153 } catch {
154 return undefined
155 }
156 if (bytes.length < 24 || bytes.slice(1, 4) !== 'PNG' || bytes.slice(12, 16) !== 'IHDR') return undefined
157 const u32 = (at: number) =>
158 ((bytes.charCodeAt(at) << 24) | (bytes.charCodeAt(at + 1) << 16) | (bytes.charCodeAt(at + 2) << 8) | bytes.charCodeAt(at + 3)) >>> 0
159 const width = u32(16)
160 const height = u32(20)
161 return width > 0 && height > 0 ? { width, height } : undefined
162}
163
164/** `pixelWidth: 640` / `pixelHeight: 480` lines of `sips -g`. */
165export function sipsDimensions(stdout: string): { width: number; height: number } | undefined {
166 const width = Number(/pixelWidth:\s*(\d+)/.exec(stdout)?.[1])
167 const height = Number(/pixelHeight:\s*(\d+)/.exec(stdout)?.[1])
168 return width > 0 && height > 0 ? { width, height } : undefined
169}
170
171const clamp = (n: number, low: number, high: number) => Math.min(high, Math.max(low, Math.round(n)))
172
173/**
174 * The cell box a picture keeps its shape in: a terminal cell is about twice as
175 * tall as it is wide, and the Image element stretches to fill its box.
176 */
177export function cellBox(
178 size: { width?: number; height?: number },
179 maxColumns: number,
180 maxRows: number,
181): { columns: number; rows: number } {
182 const columnsCap = clamp(maxColumns, 1, 255)
183 const rowsCap = clamp(maxRows, 1, 255)
184 if (size.width === undefined || size.height === undefined) {
185 return { columns: Math.min(columnsCap, rowsCap * 2), rows: rowsCap }
186 }
187 const ratio = size.width / size.height
188 const columns = rowsCap * 2 * ratio
189 if (columns <= columnsCap) return { columns: clamp(columns, 1, 255), rows: rowsCap }
190 return { columns: columnsCap, rows: clamp(columnsCap / (2 * ratio), 1, 255) }
191}
192
193export function caption(item: { name: string; size: number; width?: number; height?: number }): string {
194 const dims = item.width !== undefined && item.height !== undefined ? ` · ${item.width}×${item.height}` : ''
195 return `${item.name} · ${formatSize(item.size)}${dims}`
196}
197types/index.d.ts 36 lines1export type ImageItem = {
2 /** `path:mtimeMs`: one per version of a file. */
3 id: string
4 /** Absolute path of the image as the session named it. */
5 path: string
6 name: string
7 size: number
8 mtimeMs: number
9 width?: number
10 height?: number
11 /** Absolute path of a PNG the terminal can draw: the file itself, or a converted copy. */
12 png?: string
13 /** Why no thumbnail is drawn, when none is. */
14 skipped?: string
15 /** The tool call that wrote, read or produced it. */
16 toolUseId: string
17}
18
19/** A PNG this plugin converted into the session's cache, and the rows that draw it. */
20export type ConvertedFile = { png: string; toolUseIds: string[] }
21
22declare module 'claude-code' {
23 interface PluginState {
24 'image-preview': {
25 /** Newest first. */
26 images: ImageItem[]
27 /** Index into `images` the pane shows. */
28 cursor: number
29 /** The images each ToolUse row draws, keyed by tool_use_id. */
30 byTool: StateFamily<ImageItem[]>
31 /** Converted copies in the session's cache, oldest first: what a prune may remove, and whose rows to tell. */
32 converted: ConvertedFile[]
33 }
34 }
35}
36