SLOPSHOPPER

images

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

newpaneguardcommandtoasttool
v0.1.0no licenseupdated 2026-10-08te6-in/dotfiles/packages/claude/.claude/mods/images
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · images
│ ┃ Images ✕ › fix the failing auth test and add an audit log call │ ┃ No images yet. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /img │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Images
No images yet.
README

te6-in/dotfiles

An opinionated macOS web dev environment, powered by a custom Claude Code config.

Claude Code config

CLAUDE.md with habits Claude doesn't form on its own

Covers how the agent investigates, edits, and asks.

  • Pipe expensive command output to /tmp once and grep locally.
  • Programmatic shell edits like sed -i require a clean git state and a dry-run first.
  • Lean on AskUserQuestion at any hint of ambiguity, not free-form chat questions.
  • Korean in a tool-call parameter goes in as literal UTF-8, never as \uXXXX escapes.
  • ... and more

Hooks that catch agent footguns

  • rm and rmdir are turned back with a pointer to trash, so deletes stay recoverable.
  • Lockfile edits are blocked.

MCP servers, ready out of the box

Every project picks up Chrome DevTools for Agents and SEED Design Figma/Docs integration.

One workflow across Linear, Slack, and Notion

Decisions, state changes, and findings move out of the private chat onto surfaces the team actually checks.

  • Sessions anchor to a Linear issue and drive its status (Todo → In Progress → In Review → Done). No GitHub integration does this — the agent does, at the first code edit and then off PR events, and it asks first on any issue with sub-issues, where the status is a signal other people read.
  • /comment posts decisions back to the issue thread.
  • /notion promotes durable findings to a default Notion page.
  • Slack self-DMs are pre-approved for quick notes.

Codex config

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.

One set of instructions, two agents

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.

Self-contained, and MECE

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.

Third-party skills stay on npx skills

npx 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.

Quick access to iOS/Android targets

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.
  • The *u variants take a literal URL instead, for a server portless isn't fronting.
  • Each one pipes the URL through 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.

Deep links into a host app, from the shell and the agent alike

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.

One URL per app, no port numbers

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.
  • The simulator abbreviations resolve that URL through 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.
  • Claude Code is told to read the URL off 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.

Bootstrap

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

Nothing that identifies the work

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.

Per-machine overrides

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

When .local naming doesn't fit, fold / unfold

When 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>

Uninstall

cd ~/Projects/dotfiles/packages
stow -D <package>   # one package
stow -D */          # everything

Notes

  • ~/.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.
Source 5 files
hooks/register.tsx 296 lines
1import { 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};
296
hooks/image-loading.ts 207 lines
1import 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;
207
hooks/png-size.ts 55 lines
1const 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}
55
hooks/terminal-images.ts 34 lines
1/**
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);
34
types/index.d.ts 16 lines
1/** 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