Show images in a terminal pane: /img and a show_image tool for kitty and Ghostty, with a session history

An opinionated macOS web dev environment, powered by a custom Claude Code config.
Covers how the agent investigates, edits, and asks.
/tmp once and grep locally.sed -i require a clean git state and a dry-run first.AskUserQuestion at any hint of ambiguity, not free-form chat questions.\uXXXX escapes.rm and rmdir are turned back with a pointer to trash, so deletes stay recoverable.Every project picks up Chrome DevTools for Agents and SEED Design Figma/Docs integration.
Decisions, state changes, and findings move out of the private chat onto surfaces the team actually checks.
/comment posts decisions back to the issue thread./notion promotes durable findings to a default Notion page.packages/codex/ supplies Codex copies of the harness-specific skills in packages/claude/ and harness-specific instructions. Stow it into the home directory alongside agents. Its global AGENTS.md links to packages/codex/.codex/AGENTS.md, which includes the full text of the shared rules and all Codex-specific instructions. Codex loads this body at startup without asking the model to read another instruction file. It does not discover the Markdown rules directory or filter it by paths itself.
After editing the rule or Codex instruction sources, run python3 scripts/sync-codex-instructions.py to regenerate AGENTS.md; --check detects a stale snapshot. No developer_instructions loader is needed. Existing Codex model and app preferences are preserved.
claude-recall and claude-recap live under .codex/skills/ for Codex-only discovery: the former searches local Claude sessions and returns resume commands, while the latter reconstructs a known Claude session's dialogue into the current Codex conversation. The Claude originals remain separate. After an app restart, Slack self-DM approval and prompting for other destinations were verified. GitHub human-approval routing remains unregistered. The adapter uses direnv per command when project environment is needed and the existing notify CLI for explicit notification requests.
Anything that holds regardless of which agent is running — skills and standing instructions alike — lives once in packages/agents/ and is written harness-neutral: it says "ask the user", not the name of one harness's prompt tool. The package projects that single copy into each agent's expected path with symlinks, so both read the same file and an edit lands everywhere at once:
packages/agents/
├─ .agents/skills/<name>/ # the actual files
├─ .agents/rules/<name>.md # ″
├─ .claude/skills/<name> # → ../../.agents/skills/<name>
├─ .claude/rules/<name>.md # → ../../.agents/rules/<name>.md
├─ .gemini/config/skills/<name> # → ../../../.agents/skills/<name>
└─ .gemini/config/rules/<name>.md
One frontmatter serves both, because each ignores the other's keys. Claude Code scopes a rule with paths: and loads it unconditionally when that key is absent; agy needs an explicit trigger: and scopes with glob::
---
description: JavaScript, TypeScript, and React code style conventions.
paths: ["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,vue,svelte,astro,mdx}"]
trigger: glob
glob: "**/*.ts, **/*.tsx, **/*.mts, **/*.cts, **/*.js, **/*.jsx, **/*.mjs, **/*.cjs, **/*.vue, **/*.svelte, **/*.astro, **/*.mdx"
---
Write a description that says what the file governs, not what it says, so it survives edits to the body. For trigger: model_decision the description is the only thing the model sees up front, so there it has to carry the "when does this apply" on its own.
Both agents also honor disable-model-invocation, so a /-only skill stays /-only in both.
context: fork is worth setting on a neutral skill even though only Claude Code reads it. A read-heavy, report-shaped one — catchup, review-comments — spends its whole budget on raw material the caller never needs (a branch diff, an unfiltered dump of every PR comment), and forking keeps all of it out of the calling conversation. agy ignores the key and runs the skill inline as it always did, so the body has to hold up either way: it says the skill may run in a fork, and a step that wanted to ask the user makes the call itself and reports which way it went, since a fork has nobody to ask.
Harness-specific originals stay in packages/claude/, with Codex equivalents in packages/codex/. The Claude session skills have Codex-only copies named claude-recall and claude-recap. Both harnesses have adapters for asking-the-user, notifications, local-references, notion, and model-name. These translate shared intent into the tools and settings each host provides. Claude imports its five adapters from CLAUDE.md; Codex includes their complete bodies and its shell/workflow instructions in the generated global AGENTS.md. The neutral rules must not also be imported from CLAUDE.md, or they load twice there.
Gotchas worth remembering, all found the hard way:
agy needs an absolute path. Its skills.json documents ~/-relative entries, but the CLI rejects them (path is not absolute). Linking into ~/.gemini/config/, its global discovery directory, sidesteps the config file entirely and ranks higher in its precedence order.agy parses frontmatter as strict YAML. An unquoted description containing : is invalid YAML; Claude Code accepts it anyway, so a broken file can sit there for months looking fine. Use a >- block for anything long.glob is singular, and its value is one comma-separated string. globs: ["*.ts"] fails to unmarshal and silently discards the entire rule, trigger and all. Brace expansion doesn't survive the comma split either — write **/*.ts, **/*.tsx, not **/*.{ts,tsx}. Quote the value, since a bare * opens a YAML alias.trigger accepts exactly always_on, model_decision, manual, glob. Anything else, including a missing frontmatter block, drops the rule without a word.agy truncates a rule body at 24,000 characters — not the 12,000 its public docs claim.Every file here that instructs an agent — a SKILL.md, a rule, CLAUDE.md itself — holds both properties at once, and the obvious fix for either one breaks the other.
Self-contained means whoever acts on a section can act from that section. The tell that it isn't is a cross-reference by section name: "run Whose issue is it first", "per When the flow runs late above". Each one is a jump taken mid-task, with the work already in hand.
MECE means every rule written down in exactly one place, and every case the file claims to cover reachable from somewhere. Duplication is the more expensive half — two copies drift, nothing marks which one is current, and whoever finds the stale copy has no way to tell. A gap at least announces itself when somebody hits it.
The trap is that inlining a rule to remove a cross-reference duplicates it, and adding a pointer to remove a duplicate breaks self-containedness. Neither is the fix. A rule referenced from several places is a rule living in the wrong place — move it to where it is used and the references go away with it. linear-workflow kept its late-arrival rule in the issue-identification section and pointed at it three times from the lifecycle section; moving the rule into the lifecycle retired all three pointers and left one statement. Where no single home exists, keep the one statement and point at it by what it does rather than by its heading — "the sub-issue check below" survives a rename, and tells the reader whether they need to go at all.
Both properties break through ordinary editing rather than through bad writing: a rule appended under the nearest heading instead of its own, a section that quietly grew a second copy of something two headings up. So read the file back for both after editing it.
npx skillsnpx skills owns ~/.agents/skills/ for skills pulled from other people's repos, and materializes them there as real directories. Stow puts its symlinks in the same directory, which is fine — the two only collide if the same skill name is managed by both. Don't let that happen.
Don't hand your own skills to npx skills either: it copies from the source instead of linking, and skills update ignores sourceType: local, so every edit would need a re-install to take effect.
Its lockfile is stowed out of packages/agents/.agents/.skill-lock.json, so this repo carries the manifest — a Brewfile for skills, listing every source repo and which skills came from it. npx skills add -g writes through the symlink, so it stays current on its own. There's no matching bundle install, though: the CLI's experimental_install only replays a project's skills-lock.json into ./.agents/skills/ and never reads the global one, hence the loop in step 5 of the bootstrap.
Fish abbreviations expand inline to the real xcrun / adb command, so the URL stays editable. Swap it for any deep link like myapp://route.
simsaf <NAME> opens a portless route in iOS Simulator Safari.andshell <NAME> opens one in WebView Shell, on whichever device adb is attached to. and, not em, because the URL names no host — emulator and physical device take the same command. Only WebView Shell itself is emulator-flavored: system images ship it, retail devices generally don't.*u variants take a literal URL instead, for a server portless isn't fronting.showurl on the way in, so the address that actually got assembled is on screen — a launcher opens the app without a word otherwise, and a deep link built wrong looks identical to one built right. showurl prints on stderr precisely because the launcher is reading it off stdout.Opening a dev server inside an app — its web view, its native renderer — means percent-encoding the whole dev URL into a single query parameter of a private scheme. Three things about that resist being written inline, and each one fails silently: a partly encoded URL still parses, so the app opens a blank screen rather than complaining; the scheme is internal and shouldn't reach a public commit; and simctl openurl booted refuses outright once two simulators are booted, as adb does with two devices.
deeplink holds all three, and both callers go through it — the simw/andw/qrw abbreviations in a gitignored abbreviations.local.fish, and the open-deeplink skill the agent loads. The schemes live in ~/.config/deeplink/deeplinks.local.json, gitignored beside a committed deeplinks.json.example, so adding a view is a config edit rather than a code change. It looks no address up — --url is required, and resolving a portless route stays with the caller: plurl in the abbreviations, and for the agent whatever local-dev-server.md says. The script is therefore free of any assumption about how dev servers are addressed here, while the scheme, the encoding, and the several-devices-attached case still exist exactly once.
--qr hands the link to showqr, which picks its rendering off the caller — half-block glyphs on a terminal, a PNG opened in a viewer everywhere else. An agent's shell strips the ESC byte out of every colour code, which unpicks the light and dark modules a text QR is made of, and deeplink runs the same check to keep the bold link plain there rather than leaving [1m behind. showqr encodes any string, so it stands on its own outside a deep link, and it replaced the fish function of the same name so that one rendering serves both callers.
portless fronts every dev server with a named host under a fixed .test domain, so the address survives restarts and reaches phones and emulators unchanged. PORTLESS_TLD picks the domain, in a gitignored portless.local.fish.
<app>.<name>.test per project, <branch>.<app>.<name>.test per git worktree.plurl, so no host or port is ever typed — not localhost, not Android's 10.0.2.2, not a LAN or tailnet IP for a physical device.portless list instead of guessing localhost:3000.plurl with no name resolves the route serving the current directory. portless get needs a name and has no cwd fallback, and its inferProjectName (portless.json → package.json → git root → directory name) is internal, so the obvious move is to reimplement that chain — which drifts from portless the moment it changes, and still can't separate two worktrees of one package, since the hostname prefix comes off the branch rather than the directory. Instead plurl reads the pid portless list already prints for every live route and matches $PWD against that process's cwd, deepest enclosing directory first. That is measurement, not inference: it cannot answer wrongly, only fail to answer, and then it falls back to listing the routes for you to name. Alias routes carry no pid and never match, which is correct — a static route has no project directory.
Like showqr, it replaced the fish function of the same name and lives in bin as POSIX sh, so the abbreviations and the agent's tool shell reach one implementation by name. A fish function is invisible to every other shell, and documenting a fish -c plurl workaround only spreads the shell's name into instructions that have nothing to do with it.
portless binds loopback only. Its --lan flag opens 0.0.0.0 but hard-forces the TLD to .local, discarding the issued domain — so portless-lan-forward installs a root daemon that relays 0.0.0.0:80 to 127.0.0.1:80 instead, leaving the TLD alone. It knows nothing about the machine's IP, so it ports to a new machine as-is. docs/portless-drop-lan-forwarder.md retires it if portless ever decouples the two.
portless-proxy-service installs the other half, the root daemon that holds port 80. It replaces portless service install, which pins the plist to the versioned Cellar path of the node it ran under — one brew upgrade node later the daemon cannot exec, an unprivileged proxy takes over on a high loopback port, and every device URL breaks while the machine keeps working. This one execs the portless launcher instead, so no version appears in the plist. portless-proxy-service status names that failure when it happens.
Install with Homebrew and GNU Stow.
# 1. Install Homebrew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# 2. Clone
git clone <this-repo> ~/Projects/dotfiles
cd ~/Projects/dotfiles
# 3. Install everything from Brewfile
brew bundle install
# 4. Stow every package into $HOME. Run stow from packages/, where .stowrc
# sets the target to ~ and ignores .DS_Store.
cd packages && stow */
# Or selectively
stow fish git starship
# 5. Reinstall third-party skills from the committed lockfile
jq -r '.skills | to_entries | group_by(.value.source)[] | "\(.[0].value.source) \(map(.key) | join(" "))"' \
~/.agents/.skill-lock.json | while read -r src skills; do npx skills add "$src" -g -y --skill $skills; done
# 6. Install the portless root daemons — proxy on port 80, plus the LAN relay in front
# of it. Needs portless.local.fish in place first, for $PORTLESS_TLD.
sudo portless-proxy-service install
sudo portless-lan-forward install
# 7. (Optional) Enable brew autoupdate
brew autoupdate start --upgrade --immediate --cleanup --sudo
This repo is public. Nothing committed may reveal the employer, the people there, or what the work is: no company or product names, no colleagues' names, no internal hostnames, Slack channels or their IDs, no issue keys, and no decisions, specs, or numbers lifted from real work. Paths go through ~ or $HOME, never a literal username.
A value the setup genuinely needs, like the GitHub orgs that count as work repos, goes in an environment variable set from a gitignored per-machine file, and the committed file refers to the variable. Anything that only illustrates a point uses made-up values instead — Acme-Corp, ABC-1234, example.invalid.
The usual leak is a skill or rule written from a real session: the case that motivated it comes along as its example, with the real channel, the real colleague, and the real numbers still in it. Swap those for invented ones before committing, and when a diff adds prose, read it for this as well as for correctness.
Anything matching *.local.* or *.secret.* is gitignored, so machine-specific values live next to their checked-in counterpart.
packages/fish/.config/fish/conf.d/
├─ linear.fish.example # checked in template
└─ linear.local.fish # ignored, real values
.local naming doesn't fit, fold / unfoldWhen the filename itself is meaningful (e.g. Claude Code's commands/X.md, where the filename is the slash command), the per-machine file has to live outside the repo, directly under ~/.... But if stow has folded that directory into a single symlink back to the repo, new files inside it will land in the repo instead. Unfold once first.
rm ~/<path> && mkdir ~/<path>
cd ~/Projects/dotfiles/packages && stow -R <package>
cd ~/Projects/dotfiles/packages
stow -D <package> # one package
stow -D */ # everything
~/.config/karabiner must be symlinked as a whole directory (Docs).~/.config/openlogi must be symlinked as a whole directory too. The OpenLogi GUI saves config.toml by renaming a temp file over it, which would replace a file-level symlink with a plain file.hooks/register.tsx 296 lines1import { atom, read, update } from 'claude-code';
2import type { Register } from 'claude-code';
3import type { ImageEntry } from '../types';
4import {
5 INLINE_BUDGET_CHARS,
6 MAX_IMAGES,
7 describeFailure,
8 forgetDropped,
9 loadImage,
10 sourceOf,
11 withEntry,
12} from './image-loading';
13import type { ImageIo, LoadedImage } from './image-loading';
14import { fitCells } from './png-size';
15import { displayOf, resolveImagePath } from './terminal-images';
16
17const IMG_PANE = 'img';
18const IMG_TITLE = 'Images';
19/** Per image in the history: fit to the pane's width, at most this many rows. */
20const IMAGE_ROWS = 16;
21const SHOW_IMAGE = 'show_image';
22const SHOW_IMAGE_TOOL = 'mcp__images__show_image';
23
24const history = atom({ plugin: 'images', key: 'history' } as const, []);
25
26let cwd = '';
27
28const NO_DISPLAY = "Images can't be drawn in this terminal.";
29
30/** Why a path can't be handed to `showimg`, or undefined when it names a file. */
31async function fileProblem(path: string, fs: { exists: (path: string) => Promise<boolean>; isFile: (path: string) => Promise<boolean> }) {
32 if (!(await fs.exists(path))) return 'no such file';
33
34 return (await fs.isFile(path)) ? undefined : 'not a file';
35}
36
37/** `showimg` splits an Orca pane and returns at once; its own conversion covers every format. */
38async function showInOrca(paths: readonly string[], run: (argv: readonly string[]) => Promise<{ exitCode: number; stderr: string }>) {
39 try {
40 const shown = await run(['showimg', ...paths]);
41 if (shown.exitCode === 0) return undefined;
42
43 return shown.stderr.trim().split('\n')[0]?.trim() || `showimg exited ${shown.exitCode}`;
44 } catch (error) {
45 return String(error).split('\n')[0] ?? 'showimg failed';
46 }
47}
48
49const clock = (at: number) => {
50 const time = new Date(at);
51 return [time.getHours(), time.getMinutes(), time.getSeconds()].map((part) => String(part).padStart(2, '0')).join(':');
52};
53
54/** Loads every path, one at a time (they may share sips and the cache folder). */
55async function loadAll(paths: readonly string[], io: ImageIo) {
56 const loaded: { path: string; loaded: LoadedImage }[] = [];
57 for (const path of paths) loaded.push({ path, loaded: await loadImage(path, io) });
58 return loaded;
59}
60
61/** Puts a showing first in the history and drops the converted copies of what fell off the end. */
62async function record(
63 entry: ImageEntry,
64 store: (change: (list: ImageEntry[]) => ImageEntry[]) => Promise<unknown>,
65 io: ImageIo,
66) {
67 let trimmed = withEntry([], entry);
68 await store((list) => {
69 trimmed = withEntry(list, entry);
70 return trimmed.kept;
71 });
72 await forgetDropped(trimmed.dropped, trimmed.kept, io).catch(() => undefined);
73}
74
75export const register: Register = (on) => {
76 on('session.start', async ($, e, next) => {
77 cwd = e.cwd;
78 try {
79 await $.command.register({ name: 'img', description: 'Show an image, or the session\'s images, in a pane', argumentHint: '[path]', immediate: true });
80 await $.tool.register({
81 name: SHOW_IMAGE,
82 description: 'Show image files to the user.',
83 inputSchema: {
84 type: 'object',
85 properties: {
86 paths: {
87 type: 'array',
88 items: { type: 'string' },
89 minItems: 1,
90 maxItems: MAX_IMAGES,
91 description: 'Image paths',
92 },
93 caption: { type: 'string', description: 'Short caption' },
94 },
95 required: ['paths'],
96 additionalProperties: false,
97 },
98 isDeferred: false,
99 });
100 } catch (error) {
101 $.ui.log(`images: setup failed: ${String(error)}`, { to: 'debug' });
102 }
103 return next(e);
104 });
105
106 on('command.run', { command: 'img' }, async ($, e) => {
107 const display = displayOf({
108 termProgram: await $.env.get('TERM_PROGRAM'),
109 term: await $.env.get('TERM'),
110 tmux: await $.env.get('TMUX'),
111 sty: await $.env.get('STY'),
112 orcaPaneKey: await $.env.get('ORCA_PANE_KEY'),
113 });
114 if (display === 'none') return { text: NO_DISPLAY };
115 if (display === 'orca') {
116 const io: ImageIo = {
117 exists: (target) => $.fs.exists(target),
118 stat: (target) => $.fs.stat(target),
119 bytes: (target) => $.fs.read(target, { as: 'bytes' }),
120 list: (target) => $.fs.list(target),
121 run: (argv) => $.process.run(argv),
122 tmpDir: async () => (await $.env.get('TMPDIR')) ?? '/tmp',
123 sessionId: () => $.session.id(),
124 };
125 if (e.args.trim() === '') {
126 const [newest] = await read($, history);
127 if (newest === undefined) return { text: 'No images yet.' };
128
129 const failure = await showInOrca(newest.paths, (argv) => $.process.run(argv));
130 return failure === undefined ? {} : { text: `img: ${failure}` };
131 }
132
133 const path = await resolveImagePath(e.args, {
134 home: async () => (await $.env.get('HOME')) ?? '',
135 cwd: async () => (cwd !== '' ? cwd : $.session.cwd()),
136 });
137 const problem = await fileProblem(path, {
138 exists: (target) => $.fs.exists(target),
139 isFile: async (target) => (await $.fs.stat(target)).kind === 'file',
140 });
141 if (problem !== undefined) return { text: `img: ${path}: ${problem}` };
142
143 const failure = await showInOrca([path], (argv) => $.process.run(argv));
144 if (failure !== undefined) return { text: `img: ${failure}` };
145
146 await record({ paths: [path], at: Date.now() }, (change) => update($, history, change), io);
147 return {};
148 }
149
150 if (e.args.trim() !== '') {
151 const io: ImageIo = {
152 exists: (target) => $.fs.exists(target),
153 stat: (target) => $.fs.stat(target),
154 bytes: (target) => $.fs.read(target, { as: 'bytes' }),
155 list: (target) => $.fs.list(target),
156 run: (argv) => $.process.run(argv),
157 tmpDir: async () => (await $.env.get('TMPDIR')) ?? '/tmp',
158 sessionId: () => $.session.id(),
159 };
160 const path = await resolveImagePath(e.args, {
161 home: async () => (await $.env.get('HOME')) ?? '',
162 cwd: async () => (cwd !== '' ? cwd : $.session.cwd()),
163 });
164 const loaded = await loadImage(path, io);
165 if (loaded.error !== undefined) return { text: `img: ${describeFailure(path, loaded)}` };
166
167 await record({ paths: [path], at: Date.now() }, (change) => update($, history, change), io);
168 }
169
170 const opened = await $.ui.open({ id: IMG_PANE, title: IMG_TITLE, focus: true, closeOnEscape: true, rows: 24 });
171 if (!opened.isPlaced) return { text: `img pane is waiting: ${opened.reason}` };
172
173 await $.ui.scroll({ in: IMG_PANE, to: 'start' }).catch(() => undefined);
174 return {};
175 }).catch(() => ({ text: 'img: could not open the pane (see claude --debug)' }));
176 on('tool.call', { tool: SHOW_IMAGE_TOOL }, async ($, e) => {
177 const rawPaths = Array.isArray(e.paths) ? e.paths : typeof e.path === 'string' ? [e.path] : [];
178 const given = rawPaths.filter((one): one is string => typeof one === 'string' && one.trim() !== '');
179 if (given.length === 0) return { result: 'error: no paths' };
180 if (given.length > MAX_IMAGES) return { result: `error: at most ${MAX_IMAGES} paths` };
181
182 const where = {
183 home: async () => (await $.env.get('HOME')) ?? '',
184 cwd: async () => (cwd !== '' ? cwd : $.session.cwd()),
185 };
186 const io: ImageIo = {
187 exists: (target) => $.fs.exists(target),
188 stat: (target) => $.fs.stat(target),
189 bytes: (target) => $.fs.read(target, { as: 'bytes' }),
190 list: (target) => $.fs.list(target),
191 run: (argv) => $.process.run(argv),
192 tmpDir: async () => (await $.env.get('TMPDIR')) ?? '/tmp',
193 sessionId: () => $.session.id(),
194 };
195 const display = displayOf({
196 termProgram: await $.env.get('TERM_PROGRAM'),
197 term: await $.env.get('TERM'),
198 tmux: await $.env.get('TMUX'),
199 sty: await $.env.get('STY'),
200 orcaPaneKey: await $.env.get('ORCA_PANE_KEY'),
201 });
202 if (display === 'none') return { result: 'error: no image display in this terminal' };
203
204 const paths = await Promise.all(given.map((one) => resolveImagePath(one, where)));
205 const caption = typeof e.caption === 'string' && e.caption.trim() !== '' ? e.caption.trim().slice(0, 200) : undefined;
206 if (display === 'orca') {
207 const problems: string[] = [];
208 for (const path of paths) {
209 const problem = await fileProblem(path, {
210 exists: (target) => $.fs.exists(target),
211 isFile: async (target) => (await $.fs.stat(target)).kind === 'file',
212 });
213 if (problem !== undefined) problems.push(`error: ${path}: ${problem}`);
214 }
215 if (problems.length > 0) return { result: problems.join('\n') };
216
217 const failure = await showInOrca(paths, (argv) => $.process.run(argv));
218 if (failure !== undefined) return { result: `error: ${failure}` };
219
220 await record({ paths, at: Date.now(), ...(caption !== undefined && { caption }) }, (change) => update($, history, change), io);
221 return { result: 'ok' };
222 }
223
224 const loaded = await loadAll(paths, io);
225 const failed = loaded.flatMap(({ path, loaded: one }) => (one.error === undefined ? [] : [`error: ${path}: ${one.error}`]));
226 if (failed.length > 0) return { result: failed.join('\n') };
227
228 const entry = { paths, at: Date.now(), ...(caption !== undefined && { caption }) };
229 await record(entry, (change) => update($, history, change), io);
230 const opened = await $.ui.open({ id: IMG_PANE, title: IMG_TITLE, closeOnEscape: true, rows: 24 });
231 if (opened.isPlaced) await $.ui.scroll({ in: IMG_PANE, to: 'start' }).catch(() => undefined);
232 // The engine draws nothing for a waiting pane, so the person hears it here.
233 else $.ui.toast('Image ready · /img');
234 return { result: 'ok' };
235 }).catch(() => ({ result: 'error: show_image failed' }));
236 on('ui.render', { component: 'Pane', requestId: IMG_PANE }, async ($, e) => {
237 const { Box, Text } = $.ui.resolve(e);
238 try {
239 const entries = await read($, history);
240 if (!Array.isArray(entries) || entries.length === 0) return <Text dimColor>No images yet.</Text>;
241
242 const io: ImageIo = {
243 exists: (target) => $.fs.exists(target),
244 stat: (target) => $.fs.stat(target),
245 bytes: (target) => $.fs.read(target, { as: 'bytes' }),
246 list: (target) => $.fs.list(target),
247 run: (argv) => $.process.run(argv),
248 tmpDir: async () => (await $.env.get('TMPDIR')) ?? '/tmp',
249 sessionId: () => $.session.id(),
250 };
251 const shown = [];
252 for (const entry of entries) shown.push({ entry, images: await loadAll(entry.paths, io) });
253
254 const budget = { chars: INLINE_BUDGET_CHARS };
255 const box = { columns: e.props.bodyColumns, rows: Math.max(1, Math.min(IMAGE_ROWS, e.props.scroll.bodyRows - 3)) };
256 const terminal = e.surface === 'terminal' ? $.ui.resolve(e) : undefined;
257
258 return (
259 <Box flexDirection="column">
260 {shown.map(({ entry, images }, entryIndex) => (
261 <Box flexDirection="column" {...(entryIndex > 0 && { marginTop: 1 })}>
262 {entry.caption !== undefined && <Text bold wrap="truncate-end">{entry.caption}</Text>}
263 <Text dimColor>{clock(entry.at)}</Text>
264 {images.map(({ path, loaded }, imageIndex) => {
265 if (loaded.error !== undefined) return <Text color="error">{describeFailure(path, loaded)}</Text>;
266
267 const cells = fitCells(loaded, box);
268 const alt = `Image ${path} (${loaded.width}x${loaded.height}): this terminal shows text in place of pictures; kitty and Ghostty draw it`;
269 if (terminal === undefined) return <Text dimColor>{alt}</Text>;
270
271 const { Image } = terminal;
272 return (
273 <Box flexDirection="column">
274 <Image
275 key={`img-${entryIndex}-${imageIndex}`}
276 source={sourceOf(loaded, budget)}
277 columns={cells.columns}
278 rows={cells.rows}
279 alt={alt}
280 />
281 <Text dimColor wrap="truncate-middle">
282 {`${path} · ${loaded.width}x${loaded.height}px`}
283 </Text>
284 </Box>
285 );
286 })}
287 </Box>
288 ))}
289 </Box>
290 );
291 } catch (error) {
292 return <Text color="error">{`img: ${String(error)}`}</Text>;
293 }
294 });
295};
296hooks/image-loading.ts 207 lines1import type { FsBytes, FsEntry, FsStat, ImageSource, ProcessRunResult } from 'claude-code';
2import type { ImageEntry } from '../types';
3import { pngSize } from './png-size';
4
5export const MAX_IMAGES = 6;
6export const HISTORY_LIMIT = 30;
7const MAX_INLINE_PNG = 2 * 1024 * 1024;
8const MAX_READ = 4 * 1024 * 1024;
9/** Long edges tried in turn until the PNG fits inline. */
10const EDGES = [1600, 1100, 800];
11const CACHE_ROOT = 'claude-images';
12const CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
13/** Base64 characters held in memory across redraws; past it the oldest are read from disk again. */
14const MEMORY_BUDGET = 48 * 1024 * 1024;
15
16/**
17 * A drawable PNG: `file` always names one on disk (the original, or the
18 * session's converted copy), `base64` its bytes when they fit inline.
19 */
20export type LoadedImage =
21 | { width: number; height: number; file: string; base64?: string; error?: never }
22 | { error: string; detail?: string };
23
24/** What loading needs from `$`, handed in as closures since a module never passes `$` itself. */
25export type ImageIo = {
26 exists: (path: string) => Promise<boolean>;
27 stat: (path: string) => Promise<FsStat>;
28 bytes: (path: string) => Promise<string | FsBytes>;
29 list: (path: string) => Promise<FsEntry[]>;
30 run: (argv: readonly string[]) => Promise<ProcessRunResult>;
31 tmpDir: () => Promise<string>;
32 sessionId: () => Promise<string>;
33};
34
35/** Loaded images by source path, size and mtime: a changed file is a new key. */
36const memory = new Map<string, LoadedImage>();
37let memoryChars = 0;
38let cacheDirMade: string | undefined;
39
40export const describeFailure = (path: string, failed: { error: string; detail?: string }) =>
41 `${path}: ${failed.error}${failed.detail === undefined ? '' : ` (${failed.detail})`}`;
42
43/** FNV-1a, so a source's converted copy keeps one name across reloads. */
44function hashKey(key: string) {
45 let hash = 0x811c9dc5;
46 for (let index = 0; index < key.length; index += 1) {
47 hash ^= key.charCodeAt(index);
48 hash = Math.imul(hash, 0x01000193) >>> 0;
49 }
50 return hash.toString(16).padStart(8, '0');
51}
52
53const sourceKey = (path: string, stat: FsStat) => `${path}|${stat.size}|${Math.floor(stat.mtimeMs)}`;
54
55function remember(key: string, loaded: LoadedImage) {
56 memory.set(key, loaded);
57 memoryChars += loaded.error === undefined ? (loaded.base64?.length ?? 0) : 0;
58 for (const [oldKey, old] of memory) {
59 if (memoryChars <= MEMORY_BUDGET) break;
60 memory.delete(oldKey);
61 memoryChars -= old.error === undefined ? (old.base64?.length ?? 0) : 0;
62 }
63 return loaded;
64}
65
66/** This session's folder of converted copies; the first call also removes other sessions' folders a day old. */
67async function cacheDir(io: ImageIo) {
68 if (cacheDirMade !== undefined) return cacheDirMade;
69
70 const root = `${(await io.tmpDir()).replace(/\/$/, '')}/${CACHE_ROOT}`;
71 const session = (await io.sessionId()).replace(/[^A-Za-z0-9_-]/g, '_');
72 const dir = `${root}/${session}`;
73 const made = await io.run(['mkdir', '-p', dir]);
74 if (made.exitCode !== 0) throw new Error(`mkdir ${dir}: ${made.stderr.trim()}`);
75
76 const stale = (await io.list(root))
77 .filter((entry) => entry.name !== session && Date.now() - entry.mtimeMs > CACHE_MAX_AGE_MS)
78 .map((entry) => `${root}/${entry.name}`);
79 if (stale.length > 0) await io.run(['rm', '-rf', '--', ...stale]);
80 cacheDirMade = dir;
81 return dir;
82}
83
84const convertedPath = async (key: string, io: ImageIo) => `${await cacheDir(io)}/img-${hashKey(key)}.png`;
85
86/** `pixelWidth: 4032` and `pixelHeight: 3024` from `sips -g`; undefined when sips cannot read the file. */
87async function probe(path: string, io: ImageIo) {
88 const probed = await io.run(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', path]);
89 const width = Number(/pixelWidth:\s*(\d+)/.exec(probed.stdout)?.[1]);
90 const height = Number(/pixelHeight:\s*(\d+)/.exec(probed.stdout)?.[1]);
91 if (probed.exitCode !== 0 || !(width > 0) || !(height > 0)) return undefined;
92
93 return { width, height };
94}
95
96async function readPng(file: string, io: ImageIo): Promise<Extract<LoadedImage, { file: string }> | undefined> {
97 const stat = await io.stat(file);
98 const bytes = await io.bytes(file);
99 const png = typeof bytes === 'string' ? undefined : pngSize(bytes.base64);
100 if (typeof bytes === 'string' || png === undefined) return undefined;
101
102 return { ...png, file, ...(stat.size <= MAX_INLINE_PNG && { base64: bytes.base64 }) };
103}
104
105/** Converts any image sips reads into a PNG small enough to send inline, under a name the source's key fixes. */
106async function convert(path: string, out: string, io: ImageIo): Promise<LoadedImage> {
107 const size = await probe(path, io);
108 if (size === undefined) return { error: 'sips cannot read it as an image' };
109
110 const longEdge = Math.max(size.width, size.height);
111 for (const edge of EDGES) {
112 const scale = longEdge > edge ? ['-Z', String(edge)] : [];
113 const converted = await io.run(['sips', '-s', 'format', 'png', ...scale, path, '--out', out]);
114 if (converted.exitCode !== 0) return { error: `sips failed: ${converted.stderr.trim() || `exit ${converted.exitCode}`}` };
115 if ((await io.stat(out)).size > MAX_INLINE_PNG && longEdge > edge) continue;
116
117 return (await readPng(out, io)) ?? { error: 'sips wrote no PNG' };
118 }
119 return (await readPng(out, io)) ?? { error: 'sips wrote no PNG' };
120}
121
122/**
123 * One image ready to draw. A small PNG is read as is; anything else is
124 * converted once into the session's folder, and later loads (a redraw, a
125 * reload) read that copy back instead of running sips again.
126 */
127export async function loadImage(path: string, io: ImageIo): Promise<LoadedImage> {
128 let stat: FsStat;
129 try {
130 stat = await io.stat(path);
131 } catch (error) {
132 return (await io.exists(path).catch(() => false)) ? { error: 'unreadable', detail: String(error) } : { error: 'no such file' };
133 }
134 if (stat.kind !== 'file') return { error: 'not a file' };
135
136 const key = sourceKey(path, stat);
137 const known = memory.get(key);
138 if (known !== undefined) return known;
139
140 try {
141 const original = stat.size <= MAX_INLINE_PNG ? await readPng(path, io) : undefined;
142 if (original !== undefined && Math.max(original.width, original.height) <= (EDGES[0] ?? Infinity)) {
143 return remember(key, original);
144 }
145
146 const out = await convertedPath(key, io).catch(() => undefined);
147 if (out !== undefined && (await io.exists(out))) {
148 const cached = await readPng(out, io);
149 if (cached !== undefined) return remember(key, cached);
150 }
151
152 const converted =
153 out === undefined ? { error: 'no folder for converted copies' } : await convert(path, out, io).catch((error: unknown) => ({ error: String(error) }));
154 if (converted.error === undefined) return remember(key, converted);
155
156 // A PNG that would not shrink still draws by path where the terminal reads this machine's files.
157 const png = stat.size <= MAX_READ ? (original ?? (await readPng(path, io))) : undefined;
158 if (png !== undefined) return remember(key, { width: png.width, height: png.height, file: path });
159
160 return {
161 error: /\.png$/i.test(path) ? 'too large, could not shrink' : 'unsupported format',
162 detail: converted.detail ?? converted.error,
163 };
164 } catch (error) {
165 return { error: 'unreadable', detail: String(error) };
166 }
167}
168
169/** Removes the converted copies of sources no entry still names. */
170export async function forgetDropped(dropped: readonly ImageEntry[], kept: readonly ImageEntry[], io: ImageIo) {
171 const stillShown = new Set(kept.flatMap((entry) => entry.paths));
172 const gone = [...new Set(dropped.flatMap((entry) => entry.paths))].filter((path) => !stillShown.has(path));
173 if (gone.length === 0) return;
174
175 const dir = await cacheDir(io);
176 const files: string[] = [];
177 for (const path of gone) {
178 const stat = await io.stat(path).catch(() => undefined);
179 if (stat === undefined) continue;
180
181 const key = sourceKey(path, stat);
182 const known = memory.get(key);
183 if (known !== undefined) {
184 memory.delete(key);
185 memoryChars -= known.error === undefined ? (known.base64?.length ?? 0) : 0;
186 }
187 files.push(`${dir}/img-${hashKey(key)}.png`);
188 }
189 if (files.length > 0) await io.run(['rm', '-f', '--', ...files]);
190}
191
192/** The history with `entry` first and at most `HISTORY_LIMIT` kept, and what fell off the end. */
193export function withEntry(history: readonly ImageEntry[], entry: ImageEntry) {
194 const next = [entry, ...history];
195 return { kept: next.slice(0, HISTORY_LIMIT), dropped: next.slice(HISTORY_LIMIT) };
196}
197
198/** The source to draw: inline bytes while the drawing's budget lasts, the file's path past it. */
199export function sourceOf(image: { file: string; base64?: string }, budget: { chars: number }): ImageSource {
200 if (image.base64 === undefined || image.base64.length > budget.chars) return { file: image.file, format: 'png' };
201
202 budget.chars -= image.base64.length;
203 return { png: image.base64 };
204}
205
206export const INLINE_BUDGET_CHARS = 24 * 1024 * 1024;
207hooks/png-size.ts 55 lines1const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
2
3const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
4
5/** Decodes the first `count` bytes of a base64 string (the environment has no atob). */
6export function decodeBase64Head(base64: string, count: number) {
7 const bytes: number[] = [];
8 let buffer = 0;
9 let bits = 0;
10
11 for (const char of base64) {
12 if (bytes.length >= count) break;
13 const value = BASE64.indexOf(char);
14 if (value < 0) continue;
15
16 buffer = (buffer << 6) | value;
17 bits += 6;
18 if (bits >= 8) {
19 bits -= 8;
20 bytes.push((buffer >> bits) & 0xff);
21 }
22 }
23
24 return bytes;
25}
26
27/** Width and height from a PNG's IHDR chunk, or undefined when the bytes are no PNG. */
28export function pngSize(base64: string): { width: number; height: number } | undefined {
29 const head = decodeBase64Head(base64, 24);
30 if (head.length < 24 || PNG_SIGNATURE.some((byte, index) => head[index] !== byte)) return undefined;
31
32 const word = (at: number) =>
33 ((head[at] ?? 0) * 0x1000000) + (((head[at + 1] ?? 0) << 16) | ((head[at + 2] ?? 0) << 8) | (head[at + 3] ?? 0));
34 return { width: word(16), height: word(20) };
35}
36
37/**
38 * Cells for a picture: no wider than its own pixels at ~8 px a cell, inside the
39 * box, terminal cells being about twice as tall as wide.
40 */
41export function fitCells(
42 size: { width: number; height: number },
43 box: { columns: number; rows: number },
44): { columns: number; rows: number } {
45 const maxColumns = Math.max(1, Math.min(255, box.columns));
46 const maxRows = Math.max(1, Math.min(255, box.rows));
47 const aspect = size.height / Math.max(1, size.width);
48
49 const columns = Math.max(1, Math.min(maxColumns, Math.ceil(size.width / 8)));
50 const rows = Math.max(1, Math.round((columns * aspect) / 2));
51 if (rows <= maxRows) return { columns, rows };
52
53 return { columns: Math.max(1, Math.min(maxColumns, Math.round((maxRows * 2) / aspect))), rows: maxRows };
54}
55hooks/terminal-images.ts 34 lines1/**
2 * Whether this terminal draws an `Image` (kitty graphics with Unicode
3 * placeholders): Ghostty or kitty, not under tmux or screen. The engine's own
4 * detection result is not exposed to plugins, so this reads the environment.
5 */
6export function canDrawImages(env: { termProgram?: string; term?: string; tmux?: string; sty?: string }) {
7 if ((env.tmux ?? '') !== '' || (env.sty ?? '') !== '') return false;
8
9 return (env.termProgram ?? '').toLowerCase() === 'ghostty' || env.term === 'xterm-kitty' || env.term === 'xterm-ghostty';
10}
11
12/**
13 * How images reach the person here: `pane`, an `Image` in a pane of this
14 * terminal; `orca`, a split pane Orca opens through its `showimg` command;
15 * `none`, nowhere.
16 */
17export function displayOf(env: Parameters<typeof canDrawImages>[0] & { orcaPaneKey?: string }) {
18 if (canDrawImages(env)) return 'pane';
19 if ((env.orcaPaneKey ?? '') !== '') return 'orca';
20
21 return 'none';
22}
23
24/** An absolute path for what was typed or passed: absolute, `~/…`, or relative to the session's folder. */
25export async function resolveImagePath(raw: string, where: { home: () => Promise<string>; cwd: () => Promise<string> }) {
26 const path = raw.trim().replace(/^(['"])(.*)\1$/, '$2');
27 if (path.startsWith('/')) return path;
28 if (path === '~' || path.startsWith('~/')) return `${await where.home()}${path.slice(1)}`;
29
30 return `${(await where.cwd()).replace(/\/$/, '')}/${path.replace(/^\.\//, '')}`;
31}
32
33export const basename = (path: string) => path.slice(path.lastIndexOf('/') + 1);
34types/index.d.ts 16 lines1/** One showing of images: an `/img <path>` or a `show_image` call. */
2export type ImageEntry = {
3 paths: string[];
4 caption?: string;
5 at: number;
6};
7
8declare module 'claude-code' {
9 interface PluginState {
10 images: {
11 /** Newest first, capped. */
12 history: ImageEntry[];
13 };
14 }
15}
16