Preview images inside Claude Code: hover the chip under a reply that names an image file to pop its picture up, click it to open the picture in a pane, or use…

Preview images without leaving Claude Code, two ways:
▣ login.png). For a file in the working directory, rest the pointer on the chip and a card with the picture pops up over the rows above it; move away and it is gone. A click on a chip opens the picture in the pane. A file elsewhere on the machine gets a chip that says where it is (▣ ~/Desktop/shot.png) and no card: click it./img <path> opens a picture in a pane of the session: docked beside the transcript in the fullscreen layout from 110 columns, inline above the prompt otherwise.Inside a session:
/plugin install imgview --marketplace hieuvo/claude-imgview
Or from a shell:
claude plugin install imgview --marketplace hieuvo/claude-imgview
This repository is the plugin and a marketplace of that one plugin (.claude-plugin/marketplace.json, named claude-imgview), so the command adds the marketplace and installs imgview@claude-imgview from it.
The mod is a hooks module alone, so it is active in the session that installed it with no reload, and at user scope in every session started after.
/img shots/login.png # relative to the session's working directory
/img ~/Desktop/Screenshot.png # ~ expands
/img /tmp/my\ shot.png # a path dragged into the terminal works as dropped
/img --cells # the picture already shown, drawn the other way
Escape at an idle, empty prompt closes the pane (closeOnEscape in hooks/register.tsx); so does the pane's own close mark.
Docked, the pane asks for 45 in a hundred of the width it is opened from, between 40 and 100 columns, and keeps asking for that same width for as long as it is up (dockColumns): a docked pane narrows the conversation, so a share taken anew at every click would give every picture another width. A width you drag the dock to wins.
This plugin is a mod: a hooks module the engine loads (hooks/hooks.json names register.tsx), not a skill. It puts no skill and no tool in the model's context, and one short section at the end of the system prompt, quoted under "What it hooks and adds". The slash command is the person's; its one-line answer (login.png · 1280×720 · cells) is recorded in the transcript as any command's output is.
It sends nothing. The module makes no network call and no model call. claude plugin validate . prints every engine call it makes: $.clock, $.command.register, $.env.get, $.fs.stat, $.fs.exists, $.fs.read, $.process.run, $.state and $.ui. The one way out of the module is $.process.run, and all it starts are the six local commands below.
Every command goes through one function (run in session.start, hooks/register.tsx) as an argument list. No command line is assembled from a path, and the one shell script is fixed text that takes its two paths as positional arguments.
sips -g format -g pixelWidth -g pixelHeight <file>
sips -s format <bmp or png> [-z <height> <width>] <file> --out <temp>
magick identify -format "%w %h %m" <file>[0]
magick <file>[0] [-resize <width>x<height>!] <temp>
/bin/sh -c 'set -C; { cat -- "$1" || exit 4; } >"$2" || exit 3' sh <file> <temp>
rm -f <temp>...
sips measures a picture and converts it to the size on screen. It is the converter macOS ships.magick (ImageMagick) does the same where sips cannot read the file, for /img and a clicked chip only. It is not run for a hover card./bin/sh runs cat to copy a file a reply named, before anything decodes it (see "What a reply can make the mod open").rm -f removes the mod's own temporary files, by the exact paths it wrote down.<file> is the picture: the path you gave /img, or for a hover card a file inside the working directory that a reply named, and then the mod's copy of it. <temp> is a file of the mod's own, $TMPDIR/claude-imgview-<random uuid> with the suffix .snap, .bmp or .png. Where TMPDIR is unset, empty or not an absolute path the directory is /tmp (tempDir in hooks/picture.ts), so a <temp> begins with a slash and is no program's option.
No program is given an address. A web address in a reply is skipped (path.includes("://") in hooks/picture.ts), and /img opens only what the file system says is a regular file. What sips and ImageMagick do with a file's content is theirs: a hover card is made from a bitmap alone, by the format sips itself names, but /img on an SVG you name hands that SVG to them. To name the format, sips -g is run on the copy of every mentioned file that passes the checks before it, whatever the copy holds: of a PDF named scan.png it answers pdf and the page's size (seen with the sips of macOS 14.5), and the file is refused there, before any conversion.
e.props.text in the ui.render hook), to find the image paths in it (findImagePaths). The text is not kept, not written to the session's state and not passed to any program. The paths found are kept in memory, 128 at most.$.fs.stat), whether a converter wrote its output ($.fs.exists), and the bytes of the bitmap it just had converted ($.fs.read, on its own .bmp alone). The pictures themselves are read by the programs above.| Variable | Used for |
|---|---|
HOME | expanding ~ in a path, when it is an absolute path |
TMPDIR | where the temporary files go, when it is an absolute path |
TERM, TERM_PROGRAM, KITTY_WINDOW_ID | whether the terminal is kitty or Ghostty, which draw pixels |
TMUX | whether it is set: tmux passes no pictures through |
SSH_CONNECTION, SSH_CLIENT, SSH_TTY | whether one is set: across ssh the terminal cannot read this machine's files. Their values are not used. |
imgview.shown, the picture the pane shows (its path, name, size, modification time and mode), and imgview.temps, the paths of its temporary files (types/index.d.ts)./img, and no tool and no agent.command.run, for img alone ({ command: "img" }): it answers its own command. Other code that runs /img gets the same answer; no other command reaches the hook.ui.render for AssistantMessage: it returns the row the engine drew with a row of chips under it. It does not change the reply's text, and nothing it draws is read by the model.ui.render for its own pane (Pane, imgview): it draws the picture.prompt.compose: on a terminal it adds one section, imgview:paths, after the last of the system prompt, and changes no other. Without it a model that has just made a picture has no reason to say where the file is. The whole of its text (HINT):The imgview plugin is installed. The path of an image file written in your reply (png, jpg, jpeg, gif, webp, heic, bmp, tif, tiff) becomes a chip under the reply that the person hovers or clicks to preview the picture, and
/img <path>opens one too. So when you create, change or refer to an image file, write its full path in the reply, bare or in a code span. A path with a space in it is not recognised.
classic.CwdChanged: it reads the new working directory, to move the boundary of what a reply may preview, and passes the event on unchanged (next(e)).session.start and session.end: it sets itself up and removes its temporary files, and passes both events on unchanged.The chip is the hover target, not the path inside Claude's sentence: the reply's own text is drawn by the engine, and a mod can add rows around it but cannot attach a hover to a word in it (ui.render on AssistantMessage wraps what next(e) drew).
MAX_CHIPS): paths written bare, in a code span, in a markdown link or as file://, ending in .png, .jpg, .jpeg, .gif, .webp, .heic, .bmp, .tif or .tiff. A link is followed, and the file it leads to has to end in one of those too (hasImageSuffix(found.realPath)): shot.png linked to notes.pdf, or to a key, gets no chip. A code span, of one backtick or of two, is taken as written; in prose and in a fenced block the parentheses around a path are the sentence's. A web address gets none: nothing here fetches. A path with a space in it is not recognised.~, and of a path longer than 40 characters the first 13 and the last 26 around an ellipsis (shortenPath): the start says whose directory the file is in, the end which file it is. A directory name cut by the ellipsis is not whole. Nothing is run for the file until you click.could not show …, try again).CARD_COLUMNS, CARD_ROWS), fewer columns on a terminal narrower than 92. In cells mode that is a 160 by 48 pixel thumbnail.MAX_THUMBS). A card that a reply on screen draws is in use: a drawn one is not dropped for another, so with more than 32 pictures on screen the extra mentions get no chip until one of the others scrolls away. The mod then asks for the replies to be drawn again (freed), since the engine draws a reply that sits still no other way. A file no preview could draw is remembered apart and holds no card's place.e.props.onScreen), and not in between, so the mod keeps the replies now on screen with the cards each one draws (onScreen in hooks/register.tsx) and reads no clock: a reply that sits still keeps its cards however long a conversion takes. A reply stops holding its cards when it is drawn again off screen, with no image file named, or outside the fullscreen layout (leave). When two drawings of one reply overlap, the one begun last asks for the cards and decides which the reply holds; the other asks for none (isHolding)./clear is one), or until 64 other replies that asked for a card have been drawn on screen after it (MAX_ON_SCREEN). While 32 cards are held that way, new mentions get no chip. A reply asks for a card when it names a file inside the working directory that no preview has refused, whether or not there was room for the card; one whose files are all missing, refused or outside takes no place in the record (isAsking).pruneQueue and drain).onScreen absent), the chips are drawn and no card is made or drawn for them; a click still opens the pane."tui": "fullscreen"), on a terminal surface. A card that would rise past the top of the viewport is clipped there.A reply is the model's text, and a chip is drawn with no action of yours, so a hostile repository or an injected reply can name any path it likes. What that reaches with no click is held to less than what /img takes (locateMention, preview and sketch in hooks/register.tsx).
A mention that leads to a regular file outside the session's working directory gets a chip and nothing else: the mod has asked the file system what the path leads to and has run no program (isOutside in locateMention; such a file is never given a card, file.isOutside !== true in the ui.render hook). Inside the working directory the chip is drawn at once, over a card that says Rendering…, and the file is then held to this table. One that fails it loses its chip at the drawing that follows, and is remembered as refused by its path, modification time and card width (failed, the last 128). A reply drawn off screen, or on a terminal that does not say what is on screen, shows its chips with no card and with nothing checked: a click on one is the first look at the file, as for a file outside.
| A mention gets a hover card only when | Enforced by |
|---|---|
| the file it leads to, links followed, is named as a bitmap | hasImageSuffix(found.realPath) in locateMention, for a chip of any kind |
| it lands inside the session's working directory, links followed | isInside(found.realPath, host.root); host.root follows classic.CwdChanged |
| the file is at most 20 MiB | PREVIEW_MAX_BYTES, on the file before anything runs and again on the copy |
sips names the copy's real format a bitmap's | isBitmapFormat(size.format): no SVG, no PDF, whatever the name says |
| the picture is at most 40 megapixels | PREVIEW_MAX_PIXELS, checked before the conversion |
How the conversion itself is held:
sips never opens the path the reply named. preview copies the file to a name under $TMPDIR that nobody can guess (crypto.randomUUID()). The copy is made by /bin/sh with noclobber set (SNAPSHOT: set -C; { cat -- "$1" || exit 4; } >"$2" || exit 3), which opens a free name with O_EXCL and refuses a file that is there: no copy is made over a file, or through a link to a file or to nothing. Exit 3 says the shell did not open the name (it is taken, or $TMPDIR has no room or is gone), and then the mod reads nothing there and removes nothing. After any other exit it looks at what is at the name, and takes only a regular file as its copy (copied?.kind !== "file"): that is written down, checked, and removed when the preview is done. The mod then looks at the original again, and measures and converts the copy alone.drain takes one job from queue and returns at once while another call is running): 32 mentions mean 32 conversions in a row, never 32 decoders at once.sips run for a chip is killed after five seconds (PREVIEW_TIMEOUT_MS), and ImageMagick is never run for one. So where sips is missing (Linux) there are no hover cards, only /img.PREVIEW_DEADLINE_MS) is given up, so the cards behind it are drawn. Its commands cannot take that long at five seconds each; a file system call that never answers can. The preview given up starts no further command when that call does answer, and a command of its that is running at the 30 seconds ends, at its own timeout, before the next card is begun (previewInTime). The files it had written down are removed then; one it comes upon later is removed as it is found.MAX_ABANDONED), no new card is begun, and a mention that has no card yet gets no chip, until one of them answers: a mount that has stopped answering costs eight waiting previews, not one for every mention. When one answers the mod asks for the replies to be drawn again (settle in previewInTime), and the mentions left out get their chips.io.root is asked after the clock has answered) and hands the reply back with no chips (drawnUnder).This stops protecting at three edges:
sips still decodes, with no click, any bitmap in the working directory that passes the table. A hostile bitmap inside the repository you opened reaches ImageIO.The same process can act on the mod's temporary names too. They are not guessable, but they can be read from the process list while the shell and sips run:
sips writes its BMP or PNG to a fresh random name with no such guarantee, so a link put at that name in time is written through. And bash's noclobber opens a pipe or a device that sits at the copy's name instead of refusing it: the file's bytes are written into it, for the five seconds the shell is allowed at most. The mod then finds no regular file there, reads nothing from it, and leaves it: not removed, not written down.rm -f), so whatever that process has put at one of those names by then is what is removed.A process that can do any of this runs as you, and could write or remove those files itself.
/img <path> has none of these limits: a path you type is opened wherever it is, SVG and PDF included, with ImageMagick as the fallback. So is a chip you click: the pane then takes the file itself, measured again as it is at the click. For the chip of a file outside the working directory that click is the first look at the file: no check has said it is a picture, and the chip's label is all that tells you where it is.Claude Code owns the terminal while a session runs, and the Bash tool has no terminal at all (tty prints not a tty there), so imgcat, chafa and the like cannot draw from inside a session. The pane goes through the engine's own two picture elements instead:
| Mode | When | What you get |
|---|---|---|
pixels | kitty or Ghostty, as the environment names them | The engine's Image element: the terminal reads the PNG itself and draws real pixels (the kitty graphics protocol). |
cells | every other terminal (iTerm2, Terminal.app, VS Code) | The engine's Raster element: two by two pixels a cell, drawn as the block character (▀ ▌ ▞ ▙ and the like) whose quarters split the four pixels best into a foreground and a background color. |
Under tmux and across ssh the mode is cells even on kitty: tmux passes no pictures through, and the terminal reads the PNG from its own machine's disk, not the remote one. In pixels mode only a file its converter names a PNG is handed to the terminal as it is: sips, or ImageMagick's identify where sips reads no size. --cells and --pixels override the choice; a terminal that cannot draw an Image shows its caption and a hint to use --cells.
cells is a preview, not a viewer: a pane 80 columns wide shows a 160-pixel-wide picture. It is enough to tell which screenshot this is, whether a chart rendered, or whether a layout is broken; it is not enough to read small text.
One converter on PATH. For /img the mod tries them in this order; a hover card uses the first alone:
sips, which macOS ships. Checked on macOS 14.5: PNG, JPEG, HEIC, GIF, WebP, SVG and PDF (first page); sips --formats lists the rest.magick (ImageMagick 7), for whatever sips cannot read, and on Linux.Each /img runs the converter once to measure the picture and, in cells mode, once per pane size to resize it to exactly the cells on screen: one size at a time, and of the sizes a resize passes through meanwhile only the last (paint). The resized file goes to $TMPDIR/claude-imgview-*.bmp and is removed once read. In pixels mode a file its converter names a PNG is handed to the terminal by its own path; any other is first converted to $TMPDIR/claude-imgview-*.png, which is removed once the pane has taken the picture that replaces it (replace). Where the pane's state does not take the next picture, the PNG made for that one is removed instead and the one on show stays. A chip's copy (*.snap) is removed as soon as its card is drawn, and a card's PNG when the card is dropped.
Every temporary file the mod makes is written down by its path until it is removed (owned in hooks/register.tsx), and that list is copied into the session's state (imgview.temps) a moment later, from a timer: the engine refuses a state write while a reply is being drawn, which is when most of these files are made. A name is written down only once the mod has found a file there, so a name that t
hooks/register.tsx 1285 lines1import { atom, read, update } from "claude-code";
2import type { Register } from "claude-code";
3
4import type { Shown } from "../types";
5import {
6 baseName,
7 decodeBmp,
8 expandHome,
9 findImagePaths,
10 fit,
11 fromBase64,
12 hasImageSuffix,
13 isBitmapFormat,
14 isInside,
15 parseArgs,
16 parseIdentifySize,
17 parseSipsSize,
18 shortenPath,
19 tempDir,
20 toCells,
21} from "./picture";
22import type { Measured, Mode, Size } from "./picture";
23
24const PANE = "imgview";
25const KEY = "picture";
26// An Image is at most 255 cells a side; a Raster is held to the same here.
27const MAX_CELLS = 255;
28const USAGE = "Usage: /img [--cells|--pixels] <image path>";
29const TEMP_PREFIX = "claude-imgview-";
30// A reply draws a chip for its first few image files; each chip's hover card
31// holds a picture of at most this many cells.
32const MAX_CHIPS = 4;
33// The chip of a file outside the working directory says where the file is,
34// in this many characters at most.
35const OUTSIDE_LABEL_MAX = 40;
36// The one thing of the mod the model reads: that a path it writes out is one
37// the person can open. Without it a model that made a picture has no reason
38// to say where.
39const HINT = {
40 id: "imgview:paths",
41 text:
42 "The imgview plugin is installed. The path of an image file written in " +
43 "your reply (png, jpg, jpeg, gif, webp, heic, bmp, tif, tiff) becomes a " +
44 "chip under the reply that the person hovers or clicks to preview the " +
45 "picture, and `/img <path>` opens one too. So when you create, change or " +
46 "refer to an image file, write its full path in the reply, bare or in a " +
47 "code span. A path with a space in it is not recognised.",
48 scope: "session",
49} as const;
50const CARD_COLUMNS = 80;
51const CARD_ROWS = 24;
52// The pixels a card's PNG is given a cell, where the terminal draws pixels.
53const CARD_CELL_PIXELS = { width: 10, height: 20 };
54// A reply is drawn again for every piece that streams in: a path is looked up
55// once in this long, and this many lookups are remembered.
56const LOCATE_TTL_MS = 2000;
57const MAX_LOCATED = 128;
58// This many cards are kept, drawn or waiting to be. One a reply on screen
59// draws is in use: a drawn one is not dropped for another, and a waiting one
60// keeps its turn. Past the limit a new card waits with no chip. A picture
61// that could not be drawn is remembered apart.
62const MAX_THUMBS = 32;
63const MAX_FAILED = 128;
64// The replies remembered as on screen: those that asked for a card. The
65// engine says when one scrolls off it, and nothing of one taken out of the
66// transcript: such a reply holds its cards until this many others have been
67// drawn after it.
68const MAX_ON_SCREEN = 64;
69// What a reply names is converted with no action of the person's, so it is
70// held to less than what `/img` takes: a file this small, a picture of this
71// many pixels, `sips` alone, this long, and one conversion at a time.
72const PREVIEW_MAX_BYTES = 20 * 1024 * 1024;
73const PREVIEW_MAX_PIXELS = 40_000_000;
74const PREVIEW_TIMEOUT_MS = 5000;
75// More than a preview's commands take at their timeouts, one after another:
76// only a file system call that never answers lasts this long, and then the
77// card is given up so that the ones behind it are drawn.
78const PREVIEW_DEADLINE_MS = 30_000;
79// A preview given up may never answer at all. With this many unanswered no
80// new card is begun, so they and what they left behind cannot pile up.
81const MAX_ABANDONED = 8;
82// Copies $1 to $2 under the shell's noclobber, which refuses a file that is
83// there and opens with O_EXCL where none is: no copy is made over a file, or
84// through a link to one or to nothing. Exit 3 says the shell did not open the
85// name (it is taken, or there is no room or no directory for it), and then
86// nothing was written. A pipe or a device that sits at the name is opened as
87// it is, so what is there afterwards is looked at before it is called a copy.
88const SNAPSHOT = 'set -C; { cat -- "$1" || exit 4; } >"$2" || exit 3';
89const SNAPSHOT_NOT_OPENED = 3;
90
91const shown = atom({ plugin: "imgview", key: "shown" } as const, null);
92// Every temporary file the mod has made and not yet removed, by its path:
93// kept with the session, so a module loaded again knows what the last left.
94const temps = atom({ plugin: "imgview", key: "temps" } as const, []);
95/** The engine as a hook is handed it, which the session's state is read by. */
96type Engine = Parameters<typeof read>[0];
97// How far behind the files that record is written: a drawing may write no
98// state, so the record is never written from where a file is made.
99const RECORD_DELAY_MS = 100;
100
101type Cells = { columns: number; rows: number; cells: string };
102
103/** The cells last painted for one picture at one size, or why they were not. */
104type Frame = {
105 id: string;
106 columns: number;
107 rows: number;
108 cells?: string;
109 error?: string;
110};
111
112/**
113 * A file a path leads to: where it landed and when it last changed. One
114 * outside the working directory gets a chip to click and no card: nothing of
115 * it is converted before the person asks.
116 */
117type Located = { path: string; mtimeMs: number; isOutside?: true };
118
119/**
120 * A hover card's picture, measured and converted: cells, or on a terminal
121 * that draws pixels a small PNG of the mod's own for it to read.
122 */
123type Card = Measured & {
124 columns: number;
125 rows: number;
126 cells?: string;
127 png?: string;
128};
129
130/**
131 * A card's place among those kept: no `card` yet while it waits its turn.
132 * `askedAt` orders the cards by when a reply on screen last asked for each:
133 * a count, not a time, so no two are equal.
134 */
135type Slot = { card?: Card; askedAt: number };
136
137/**
138 * A reply on screen: the cards its last finished drawing asked for, and the
139 * ones the drawing of it begun last, if still at work, has asked for so far.
140 */
141type Reply = { cards: Set<string>; drawing?: Set<string> };
142
143/** What the pane's drawing asks to be converted: a picture at a size. */
144type Wanted = {
145 id: string;
146 picture: Shown;
147 box: { columns: number; rows: number };
148};
149
150/** A card waiting to be converted, under the working directory it was asked in. */
151type Job = {
152 id: string;
153 file: Located;
154 maxColumns: number;
155 slot: Slot;
156 boundary: number;
157};
158
159/** How a conversion is held back: `/img` passes none of these. */
160type Limits = {
161 /** No ImageMagick where `sips` cannot. */
162 isSipsOnly?: boolean;
163 timeoutMs?: number;
164};
165
166const PREVIEW: Limits = { isSipsOnly: true, timeoutMs: PREVIEW_TIMEOUT_MS };
167
168/**
169 * What the converters need of the engine, bound once at `session.start`: a
170 * conversion outlives the drawing that asked for it.
171 */
172type Host = {
173 /** Runs a host command; undefined when it is not installed or timed out. */
174 run: (
175 argv: readonly string[],
176 timeoutMs?: number,
177 ) => Promise<{ exitCode: number; stdout: string } | undefined>;
178 exists: (path: string) => Promise<boolean>;
179 /** What a path leads to, with where it lands; undefined when missing. */
180 stat: (path: string) => Promise<
181 | {
182 kind: string;
183 size: number;
184 mtimeMs: number;
185 realPath?: string;
186 }
187 | undefined
188 >;
189 /** A file's bytes, base64. */
190 readBytes: (path: string) => Promise<string>;
191 /** A fresh path under the temporary directory, its name not guessable. */
192 tempPath: (suffix: "bmp" | "png" | "snap") => string;
193 /** Writes a path down as a file the mod made, its own to remove. */
194 keep: (path: string) => void;
195 /** Removes a file the mod made, and strikes its path off. */
196 remove: (path: string) => void;
197 /**
198 * Removes every file written down but the PNG the pane shows: what a
199 * module loaded before, or a session that ended, left behind.
200 */
201 sweep: () => Promise<void>;
202 redraw: () => void;
203 /** Calls `fn` once after `ms` milliseconds on the engine's clock. */
204 after: (ms: number, fn: () => void) => { cancel: () => void };
205 home: string | undefined;
206 /**
207 * Where the session's working directory lands, kept up as it changes;
208 * undefined when unknown, and then no reply's mention is previewed.
209 */
210 root: string | undefined;
211 /**
212 * kitty and Ghostty draw an Image as pixels, from a file they read
213 * themselves: not through tmux, and not across ssh.
214 */
215 isPixelTerminal: boolean;
216};
217
218async function measure(
219 host: Host,
220 path: string,
221 limits: Limits = {},
222): Promise<Measured | undefined> {
223 const sips = await host.run(
224 ["sips", "-g", "format", "-g", "pixelWidth", "-g", "pixelHeight", path],
225 limits.timeoutMs,
226 );
227 const size = sips ? parseSipsSize(sips.stdout) : undefined;
228 if (size !== undefined || limits.isSipsOnly) return size;
229 const magick = await host.run(
230 ["magick", "identify", "-format", "%w %h %m", `${path}[0]`],
231 limits.timeoutMs,
232 );
233
234 return magick?.exitCode === 0 ? parseIdentifySize(magick.stdout) : undefined;
235}
236
237/**
238 * Writes the picture to `out` as its suffix says (BMP or PNG), stretched to
239 * `size` when given: `sips` first, ImageMagick where sips cannot.
240 */
241async function convert(
242 host: Host,
243 path: string,
244 out: string,
245 size?: Size,
246 limits: Limits = {},
247): Promise<boolean> {
248 const format = out.endsWith(".png") ? "png" : "bmp";
249 const resample = size ? ["-z", String(size.height), String(size.width)] : [];
250 // sips exits 0 on a file it cannot read: the written file is the proof.
251 await host.run(
252 ["sips", "-s", "format", format, ...resample, path, "--out", out],
253 limits.timeoutMs,
254 );
255 const isWritten = await host.exists(out);
256 if (isWritten || limits.isSipsOnly) return isWritten;
257 const resize = size ? ["-resize", `${size.width}x${size.height}!`] : [];
258 await host.run(["magick", `${path}[0]`, ...resize, out], limits.timeoutMs);
259
260 return host.exists(out);
261}
262
263/**
264 * The picture as `box` cells of quarter blocks, two pixels a cell each way;
265 * throws when nothing converts it.
266 */
267async function rasterize(
268 host: Host,
269 path: string,
270 box: { columns: number; rows: number },
271 limits: Limits = {},
272): Promise<Cells> {
273 const out = host.tempPath("bmp");
274 const size = { width: box.columns * 2, height: box.rows * 2 };
275 if (!(await convert(host, path, out, size, limits))) {
276 throw new Error(
277 limits.isSipsOnly
278 ? "sips could not convert it"
279 : "neither sips nor ImageMagick could convert it",
280 );
281 }
282 host.keep(out);
283 let base64: string;
284 try {
285 base64 = await host.readBytes(out);
286 } finally {
287 host.remove(out);
288 }
289 const bitmap = decodeBmp(fromBase64(base64));
290 if (bitmap.width !== size.width || bitmap.height !== size.height) {
291 throw new Error(
292 `the converter wrote ${bitmap.width}x${bitmap.height}, not ${size.width}x${size.height}`,
293 );
294 }
295
296 return toCells(bitmap);
297}
298
299/**
300 * The file a reply's mention leads to, a `~` expanded and every link
301 * followed: a regular file named as a bitmap is where it lands, as the
302 * mention was where it was written. One outside the session's working
303 * directory is marked so, whatever its size; one inside is no larger than a
304 * preview takes. Undefined for anything else.
305 */
306async function locateMention(
307 host: Host,
308 given: string,
309): Promise<Located | undefined> {
310 if (host.root === undefined) return undefined;
311 const found = await host.stat(expandHome(given, host.home));
312 if (found?.kind !== "file" || found.realPath === undefined) return undefined;
313 // A link named like a picture is taken by the name of what it leads to.
314 if (!hasImageSuffix(found.realPath)) return undefined;
315 if (!isInside(found.realPath, host.root))
316 return { path: found.realPath, mtimeMs: found.mtimeMs, isOutside: true };
317 if (found.size > PREVIEW_MAX_BYTES) return undefined;
318
319 return { path: found.realPath, mtimeMs: found.mtimeMs };
320}
321
322/**
323 * The card of a file a reply named, or undefined when it is not a picture a
324 * preview takes. Everything is decoded from a copy of the file made first:
325 * the copy is the mod's own, so what was checked is what `sips` reads.
326 */
327async function preview(
328 host: Host,
329 file: Located,
330 maxColumns: number,
331): Promise<Card | undefined> {
332 const copy = host.tempPath("snap");
333 let isCopyMine = false;
334 try {
335 const copying = await host.run(
336 ["/bin/sh", "-c", SNAPSHOT, "sh", file.path, copy],
337 PREVIEW_TIMEOUT_MS,
338 );
339 // The name was not opened: what is there is not the mod's to read or
340 // remove.
341 if (copying?.exitCode === SNAPSHOT_NOT_OPENED) return undefined;
342 // A regular file there is the one the shell made: whole, in part, or cut
343 // short at the timeout. Anything else sat there before, and is left.
344 const copied = await host.stat(copy);
345 if (copied?.kind !== "file") return undefined;
346 isCopyMine = true;
347 host.keep(copy);
348 if (copying?.exitCode !== 0 || copied.size > PREVIEW_MAX_BYTES)
349 return undefined;
350 // Swapped for another file, a link or a pipe while it was copied: not
351 // this one.
352 const still = await host.stat(file.path);
353 if (
354 still?.kind !== "file" ||
355 still.realPath !== file.path ||
356 still.mtimeMs !== file.mtimeMs
357 )
358 return undefined;
359
360 const size = await measure(host, copy, PREVIEW);
361 if (
362 size === undefined ||
363 !isBitmapFormat(size.format) ||
364 size.width * size.height > PREVIEW_MAX_PIXELS
365 )
366 return undefined;
367 const box = fit(size, maxColumns, CARD_ROWS);
368 if (host.isPixelTerminal) {
369 // A small PNG of the mod's own for the terminal to read, whatever the
370 // file's format: never the file the reply named.
371 const png = host.tempPath("png");
372 const pixels = {
373 width: box.columns * CARD_CELL_PIXELS.width,
374 height: box.rows * CARD_CELL_PIXELS.height,
375 };
376 if (await convert(host, copy, png, pixels, PREVIEW)) {
377 host.keep(png);
378
379 return { ...size, ...box, png };
380 }
381 }
382
383 return { ...size, ...(await rasterize(host, copy, box, PREVIEW)) };
384 } finally {
385 if (isCopyMine) host.remove(copy);
386 }
387}
388
389/**
390 * Whether the terminal can read this picture itself: a PNG by its content,
391 * as the converter named it, at an absolute path.
392 */
393function isReadablePng(path: string, format: string | undefined): boolean {
394 return path.startsWith("/") && format === "png";
395}
396
397/**
398 * The picture the pane is to show next, or undefined when `pixels` mode needs
399 * a PNG and nothing converts the file to one. Nothing is removed here: the
400 * PNG made for the picture on show goes once the pane has taken this one
401 * (`replace`).
402 */
403async function present(
404 host: Host,
405 file: Located,
406 size: Measured,
407 mode: Mode,
408): Promise<Shown | undefined> {
409 const picture: Shown = {
410 path: file.path,
411 name: baseName(file.path),
412 width: size.width,
413 height: size.height,
414 mtimeMs: file.mtimeMs,
415 mode,
416 };
417 if (mode === "pixels") {
418 // The terminal reads a PNG by its absolute path; anything else is converted.
419 if (isReadablePng(file.path, size.format)) {
420 picture.png = file.path;
421 } else {
422 const out = host.tempPath("png");
423 if (!(await convert(host, file.path, out))) return undefined;
424 host.keep(out);
425 picture.png = out;
426 picture.isPngTemporary = true;
427 }
428 }
429
430 return picture;
431}
432
433/**
434 * Puts a picture in the pane in place of the one there; undefined when it
435 * converts to no PNG. The PNG the mod made for the picture replaced goes once
436 * the pane has taken the next, and not before. Where the pane does not take
437 * it the call fails, the PNG made for the next goes instead, and the picture
438 * on show stays as it is. A PNG the pane was shown as it is is the person's,
439 * and never removed. One call at a time: each sees the picture it replaces.
440 */
441async function replace(
442 $: Engine,
443 host: Host,
444 file: Located,
445 size: Measured,
446 mode: Mode,
447): Promise<Shown | undefined> {
448 const previous = await read($, shown);
449 const picture = await present(host, file, size, mode);
450 if (picture === undefined) return undefined;
451 try {
452 await update($, shown, () => picture);
453 } catch (error) {
454 if (picture.isPngTemporary === true && picture.png !== undefined)
455 host.remove(picture.png);
456 throw error;
457 }
458 if (
459 previous?.png !== undefined &&
460 previous.isPngTemporary === true &&
461 previous.png !== picture.png
462 ) {
463 host.remove(previous.png);
464 }
465
466 return picture;
467}
468
469/**
470 * What the pane asks of the layout for a picture: inline, the rows the picture
471 * fills under the full width; docked, a share of the columns.
472 */
473function paneRequest(picture: Shown, columns: number) {
474 const inline = fit(
475 picture,
476 Math.min(Math.max(1, columns - 4), MAX_CELLS),
477 24,
478 );
479
480 return {
481 id: PANE,
482 title: picture.name,
483 closeOnEscape: true as const,
484 rows: inline.rows + 1,
485 columns: Math.max(40, Math.min(100, Math.round(columns * 0.45))),
486 };
487}
488
489export const register: Register = (on) => {
490 let host: Host | undefined;
491 let frame: Frame | undefined;
492 // Whether the pane has a conversion in flight, and what its last drawing
493 // asked for.
494 let isPainting = false;
495 let wanted: Wanted | undefined;
496 // The temporary files the mod has made and not removed: what the session's
497 // record is written from.
498 const owned = new Set<string>();
499 const thumbs = new Map<string, Slot>();
500 // Counts up each time a reply on screen asks for a card.
501 let asked = 0;
502 // The replies on screen by the engine's id for each, the one drawn longest
503 // ago first. The engine draws a reply again as it comes on screen and as it
504 // leaves, and not in between: a reply is on screen until it is said to go.
505 const onScreen = new Map<string, Reply>();
506 // The previews given up that have not answered since.
507 let abandoned = 0;
508 // Pictures no preview could draw, oldest first: they hold no card's place.
509 const failed = new Set<string>();
510 const located = new Map<string, { at: number; file: Located | undefined }>();
511 // Cards waiting to be converted: one at a time, never side by side.
512 const queue: Job[] = [];
513 let isPreviewing = false;
514 // Moves on when the working directory does: what was started before is void.
515 let boundary = 0;
516 // The pane's picture is replaced by one caller at a time, so each one sees
517 // the picture it really replaces.
518 let replacing: Promise<unknown> = Promise.resolve();
519 // The width the dock was asked for when the pane was opened, kept for as
520 // long as the pane is up. A docked pane narrows the conversation, so a share
521 // of the conversation's width taken anew at every open never comes to rest.
522 let dockColumns: number | undefined;
523 // Counts the sweeps: one that ran across the pane's conversion took its
524 // bitmap from under it.
525 let sweeps = 0;
526 // A mention on screen was left without a chip for want of room among the
527 // cards: once room is made, the replies are drawn again.
528 let isStarved = false;
529
530 /** What the pane asks of the layout for a picture, up already or not. */
531 function sized(picture: Shown, columns: number, isUp: boolean) {
532 const request = paneRequest(picture, columns);
533 if (!isUp || dockColumns === undefined) dockColumns = request.columns;
534
535 return { ...request, columns: dockColumns };
536 }
537
538 function oneAtATime<T>(work: () => Promise<T>): Promise<T> {
539 const done = replacing.then(work, work);
540 replacing = done.catch(() => undefined);
541
542 return done;
543 }
544
545 /**
546 * Converts what the pane wants to cells and asks for the redraw: one
547 * conversion at a time. Where the pane has moved on to another size or
548 * picture meanwhile, the one it wants by then is next, and none between.
549 */
550 async function paint(io: Host) {
551 if (isPainting) return;
552 isPainting = true;
553 try {
554 for (;;) {
555 const job = wanted;
556 if (job === undefined || frame?.id === job.id) return;
557 let painted: Frame = { id: job.id, ...job.box };
558 const swept = sweeps;
559 try {
560 painted = {
561 id: job.id,
562 ...(await rasterize(io, job.picture.path, job.box)),
563 };
564 } catch (error) {
565 // A sweep removed the bitmap before it was read: converted again.
566 if (swept !== sweeps) continue;
567 painted.error =
568 error instanceof Error ? error.message : String(error);
569 // The pane outlives the file it shows: removed since, that is the
570 // reason to give, not the converters.
571 if ((await io.stat(job.picture.path))?.kind !== "file")
572 painted.error = "the file is no longer there";
573 }
574 if (wanted?.id !== job.id) continue;
575 frame = painted;
576 io.redraw();
577
578 return;
579 }
580 } finally {
581 isPainting = false;
582 }
583 }
584
585 /** Forgets a card, and removes the PNG the mod made for it. */
586 function dropThumb(io: Host, id: string) {
587 const png = thumbs.get(id)?.card?.png;
588 thumbs.delete(id);
589 if (png !== undefined) io.remove(png);
590 }
591
592 /** Forgets every card and every reply, with no file removed. */
593 function forgetCards() {
594 thumbs.clear();
595 queue.length = 0;
596 onScreen.clear();
597 }
598
599 /**
600 * Room was made among the cards. Where a mention was left without a chip
601 * for want of it, the replies are drawn again: after the drawing this is
602 * called from, since the engine draws a reply that sits still no other way.
603 */
604 function freed() {
605 if (!isStarved || host === undefined) return;
606 isStarved = false;
607 const io = host;
608 io.after(0, () => io.redraw());
609 }
610
611 /** Notes a reply as on screen, the one drawn last of them. */
612 function enter(requestId: string): Reply {
613 const reply = onScreen.get(requestId) ?? { cards: new Set() };
614 onScreen.delete(requestId);
615 onScreen.set(requestId, reply);
616
617 return reply;
618 }
619
620 /**
621 * Holds the record to `MAX_ON_SCREEN` replies: past that the one drawn
622 * longest ago goes, and its cards are in use no more.
623 */
624 function trim() {
625 for (const [stale, reply] of onScreen) {
626 if (onScreen.size <= MAX_ON_SCREEN) break;
627 onScreen.delete(stale);
628 if (reply.cards.size > 0) freed();
629 }
630 }
631
632 /** Notes a reply as on screen no longer: its cards are in use no more. */
633 function leave(requestId: string) {
634 const reply = onScreen.get(requestId);
635 if (reply === undefined) return;
636 onScreen.delete(requestId);
637 pruneQueue();
638 if (reply.cards.size > 0 || (reply.drawing?.size ?? 0) > 0) freed();
639 }
640
641 /** Whether a reply on screen draws this card, or is being drawn with it. */
642 function isInUse(id: string): boolean {
643 for (const reply of onScreen.values()) {
644 if (reply.cards.has(id) || reply.drawing?.has(id) === true) return true;
645 }
646
647 return false;
648 }
649
650 /**
651 * Gives up the waiting cards no reply on screen draws: they scrolled away
652 * before their turn came.
653 */
654 function pruneQueue() {
655 for (let i = queue.length - 1; i >= 0; i--) {
656 const job = queue[i]!;
657 if (isInUse(job.id)) continue;
658 queue.splice(i, 1);
659 if (thumbs.get(job.id) === job.slot) thumbs.delete(job.id);
660 }
661 }
662
663 /**
664 * Makes room for one more card: true while fewer than `MAX_THUMBS` are
665 * kept, or once one that is drawn is on screen no longer, and then the one
666 * asked for longest ago goes. The card being converted is never the one.
667 * None is made while too many previews given up are unanswered.
668 */
669 function admitThumb(io: Host): boolean {
670 if (abandoned >= MAX_ABANDONED) return false;
671 pruneQueue();
672 if (thumbs.size < MAX_THUMBS) return true;
673 let oldest: string | undefined;
674 for (const [id, slot] of thumbs) {
675 if (slot.card === undefined || isInUse(id)) continue;
676 if (oldest === undefined || slot.askedAt < thumbs.get(oldest)!.askedAt)
677 oldest = id;
678 }
679 if (oldest === undefined) return false;
680 dropThumb(io, oldest);
681
682 return true;
683 }
684
685 /**
686 * A job's card, or undefined when it has none or was not drawn in time: a
687 * call that never answers must not hold up the cards behind it.
688 */
689 function previewInTime(io: Host, job: Job): Promise<Card | undefined> {
690 return new Promise((resolve) => {
691 let isLate = false;
692 // The command the preview began last, and the files it holds.
693 let running: Promise<unknown> = Promise.resolve();
694 const held = new Set<string>();
695 const bound: Host = {
696 ...io,
697 // Once given up, the preview starts no command.
698 run: (argv, timeoutMs) => {
699 if (isLate) return Promise.resolve(undefined);
700 const result = io.run(argv, timeoutMs);
701 running = result;
702
703 return result;
704 },
705 keep: (path) => {
706 io.keep(path);
707 // Too late to be of use: a file found only now goes at once.
708 if (isLate) io.remove(path);
709 else held.add(path);
710 },
711 remove: (path) => {
712 if (held.delete(path)) io.remove(path);
713 },
714 };
715 const giveUp = () => {
716 for (const path of held) io.remove(path);
717 held.clear();
718 resolve(undefined);
719 };
720 const deadline = io.after(PREVIEW_DEADLINE_MS, () => {
721 isLate = true;
722 abandoned++;
723 // A command begun before now ends at its own timeout, and the next
724 // card waits for that: no two conversions run side by side. Then the
725 // files this preview holds go, whether or not it ever answers.
726 void running.then(giveUp, giveUp);
727 });
728 const settle = (card: Card | undefined) => {
729 deadline.cancel();
730 if (isLate) {
731 abandoned--;
732 // Cards are begun again from here: the mentions left without a
733 // chip meanwhile get theirs with this drawing.
734 if (abandoned === MAX_ABANDONED - 1) io.redraw();
735 }
736 // Too late to be a card: its PNG went as it was kept.
737 resolve(isLate ? undefined : card);
738 };
739 // Rejected: not a picture sips converts, and the chip is dropped.
740 preview(bound, job.file, job.maxColumns).then(settle, () =>
741 settle(undefined),
742 );
743 });
744 }
745
746 /**
747 * Converts the waiting cards one after another, the one asked for last
748 * first, and asks for the redraw as each is drawn or given up.
749 */
750 async function drain(io: Host) {
751 if (isPreviewing) return;
752 isPreviewing = true;
753 try {
754 for (;;) {
755 pruneQueue();
756 // Too many previews given up are unanswered: no other is begun, and
757 // the cards that waited their turn are given up with them.
758 if (abandoned >= MAX_ABANDONED) {
759 for (const job of queue.splice(0)) {
760 if (thumbs.get(job.id) === job.slot) thumbs.delete(job.id);
761 }
762
763 return;
764 }
765 let next = -1;
766 for (let i = 0; i < queue.length; i++) {
767 if (next < 0 || queue[i]!.slot.askedAt > queue[next]!.slot.askedAt)
768 next = i;
769 }
770 if (next < 0) return;
771 const job = queue.splice(next, 1)[0]!;
772 const card = await previewInTime(io, job);
773 // The working directory moved on meanwhile, and took the place with it.
774 if (job.boundary !== boundary || thumbs.get(job.id) !== job.slot) {
775 if (card?.png !== undefined) io.remove(card.png);
776 } else if (card === undefined) {
777 thumbs.delete(job.id);
778 failed.add(job.id);
779 for (const stale of failed) {
780 if (failed.size <= MAX_FAILED) break;
781 failed.delete(stale);
782 }
783 } else {
784 job.slot.card = card;
785 }
786 io.redraw();
787 }
788 } finally {
789 isPreviewing = false;
790 }
791 }
792
793 /** Takes a card's place at once and puts its conversion in the queue. */
794 function sketch(io: Host, file: Located, id: string, maxColumns: number) {
795 const slot: Slot = { askedAt: ++asked };
796 thumbs.set(id, slot);
797 queue.push({ id, file, maxColumns, slot, boundary });
798 void drain(io);
799 }
800
801 on("session.start", async ($, e, next) => {
802 const dir = tempDir(await $.env.get("TMPDIR"));
803 let root: string | undefined;
804 try {
805 root = (await $.fs.stat(e.cwd, { resolve: true })).realPath;
806 } catch {
807 // no working directory to hold previews to: none is drawn
808 }
809 const run: Host["run"] = async (argv, timeoutMs) => {
810 try {
811 return await $.process.run(
812 argv,
813 timeoutMs === undefined ? undefined : { timeoutMs },
814 );
815 } catch {
816 return undefined;
817 }
818 };
819 // The record follows `owned` a moment behind, from a timer of its own.
820 let isRecordDue = false;
821 const writeRecord = async () => {
822 isRecordDue = false;
823 try {
824 await update($, temps, () => [...owned]);
825 } catch {
826 // Refused while a reply was being drawn: once more, a moment later.
827 record();
828 }
829 };
830 const record = () => {
831 if (isRecordDue) return;
832 isRecordDue = true;
833 $.clock.after(RECORD_DELAY_MS, () => void writeRecord());
834 };
835 host = {
836 run,
837 exists: (path) => $.fs.exists(path),
838 stat: async (path) => {
839 try {
840 return await $.fs.stat(path, { resolve: true });
841 } catch {
842 return undefined;
843 }
844 },
845 readBytes: async (path) =>
846 (await $.fs.read(path, { as: "bytes" })).base64,
847 tempPath: (suffix) =>
848 `${dir}/${TEMP_PREFIX}${crypto.randomUUID()}.${suffix}`,
849 keep: (path) => {
850 owned.add(path);
851 record();
852 },
853 // A path is struck off once `rm` has said it is gone, and not before:
854 // one that could not be removed stays written down for the next sweep.
855 remove: (path) => {
856 void run(["rm", "-f", path]).then((done) => {
857 if (done?.exitCode !== 0) return;
858 owned.delete(path);
859 record();
860 });
861 },
862 sweep: async () => {
863 sweeps++;
864 const pane = await read($, shown);
865 const kept = pane?.isPngTemporary === true ? pane.png : undefined;
866 const left = [...owned].filter((path) => path !== kept);
867 if (left.length > 0) {
868 const done = await run(["rm", "-f", ...left]);
869 if (done?.exitCode === 0) for (const path of left) owned.delete(path);
870 }
871 await writeRecord();
872 },
873 redraw: () => $.ui.invalidate("ui.render"),
874 after: (ms, fn) => $.clock.after(ms, fn),
875 home: await $.env.get("HOME"),
876 root,
877 isPixelTerminal:
878 (await $.env.get("TMUX")) === undefined &&
879 (await $.env.get("SSH_CONNECTION")) === undefined &&
880 (await $.env.get("SSH_CLIENT")) === undefined &&
881 (await $.env.get("SSH_TTY")) === undefined &&
882 ((await $.env.get("TERM")) === "xterm-kitty" ||
883 (await $.env.get("TERM")) === "xterm-ghostty" ||
884 (await $.env.get("TERM_PROGRAM"))?.toLowerCase() === "ghostty" ||
885 (await $.env.get("KITTY_WINDOW_ID")) !== undefined),
886 };
887 // A module loaded again starts with no card: the files the last one made
888 // for its own go, each by the path it was written down under.
889 owned.clear();
890 for (const path of await read($, temps)) owned.add(path);
891 forgetCards();
892 await host.sweep();
893 await $.command.register({
894 name: "img",
895 description: "Preview an image in a pane",
896 argumentHint: "[--cells|--pixels] <path>",
897 });
898
899 return next(e);
900 });
901
902 // The previews' boundary is the working directory as it is now. When it
903 // changes, everything made under the old one is dropped before anything is
904 // awaited, and no preview is made until the new one is resolved.
905 on("classic.CwdChanged", async (_$, e, next) => {
906 if (host !== undefined) {
907 const io = host;
908 const mine = ++boundary;
909 io.root = undefined;
910 for (const id of [...thumbs.keys()]) dropThumb(io, id);
911 queue.length = 0;
912 onScreen.clear();
913 failed.clear();
914 located.clear();
915 // The cards on screen are the old directory's: they go now, not when
916 // the new one is known.
917 io.redraw();
918 const root = (await io.stat(e.new_cwd))?.realPath;
919 // A later change owns the root now: this one's answer is stale.
920 if (mine === boundary) {
921 io.root = root;
922 io.redraw();
923 }
924 }
925
926 return next(e);
927 });
928
929 // The session's replies go with it, and their cards with them. The PNG the
930 // pane shows stays: after a `/clear` the pane is still there to show it.
931 on("session.end", async (_$, e, next) => {
932 if (host !== undefined) {
933 forgetCards();
934 await host.sweep();
935 }
936
937 return next(e);
938 });
939
940 // On a terminal, where chips are drawn and `/img` answers, the system
941 // prompt ends with the hint. No other section is touched.
942 on("prompt.compose", async (_$, e, next) => {
943 const composed = await next(e);
944 if (!e.surfaces.includes("terminal")) return composed;
945
946 return { sections: [...composed.sections, HINT] };
947 });
948
949 on("command.run", { command: "img" }, async ($, e) => {
950 if (host === undefined)
951 return { text: "img: not ready, the session has not started" };
952 const io = host;
953 const request = parseArgs(e.args);
954 // No path: the picture already shown, opened again or drawn the other way.
955 const target =
956 request.path === "" ? (await read($, shown))?.path : request.path;
957 if (target === undefined) return { text: USAGE };
958
959 // A path completed with `@` keeps its mention mark: try it without.
960 const candidates = target.startsWith("@")
961 ? [target, target.slice(1)]
962 : [target];
963 let found: Awaited<ReturnType<Host["stat"]>>;
964 let given = target;
965 for (given of candidates) {
966 found = await io.stat(expandHome(given, io.home));
967 if (found !== undefined) break;
968 }
969 if (found === undefined) return { text: `img: no such file: ${target}` };
970 if (found.kind !== "file") return { text: `img: not a file: ${target}` };
971
972 given = expandHome(given, io.home);
973 const file = {
974 path: found.realPath ?? (given.startsWith("/") ? given : `./${given}`),
975 mtimeMs: found.mtimeMs,
976 };
977 const size = await measure(io, file.path);
978 if (size === undefined)
979 return {
980 text: `img: not an image sips or ImageMagick can read: ${target}`,
981 };
982
983 const mode = request.mode ?? (io.isPixelTerminal ? "pixels" : "cells");
984 let picture: Shown | undefined;
985 try {
986 picture = await oneAtATime(() => replace($, io, file, size, mode));
987 } catch {
988 return { text: `img: could not show ${target}, try again` };
989 }
990 if (picture === undefined)
991 return { text: `img: could not convert to PNG: ${target}` };
992 const isUp = (await $.ui.panes()).some((pane) => pane.id === PANE);
993 await $.ui.open(sized(picture, e.presentation.columns, isUp));
994
995 return {
996 text: `${picture.name} · ${picture.width}×${picture.height} · ${mode}`,
997 };
998 });
999
1000 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
1001 if (e.surface !== "terminal") {
1002 const { Text } = $.ui.resolve(e);
1003
1004 return <Text dimColor>imgview draws pictures in the terminal only.</Text>;
1005 }
1006 const { Box, Image, Raster, Text } = $.ui.resolve(e);
1007 const picture = await read($, shown);
1008 if (picture === null) return <Text dimColor>{USAGE}</Text>;
1009
1010 const caption = `${picture.name} · ${picture.width}×${picture.height}`;
1011 const box = fit(
1012 picture,
1013 Math.min(e.props.bodyColumns, MAX_CELLS),
1014 Math.min(e.props.scroll.bodyRows - 1, MAX_CELLS),
1015 );
1016 if (picture.mode === "pixels" && picture.png !== undefined) {
1017 return (
1018 <Box flexDirection="column">
1019 <Image
1020 key={KEY}
1021 source={{
1022 file: picture.png,
1023 format: "png",
1024 generation: Math.floor(picture.mtimeMs),
1025 }}
1026 columns={box.columns}
1027 rows={box.rows}
1028 alt={`${caption}: no pixels on this terminal, try /img --cells`}
1029 />
1030 <Text dimColor>{caption}</Text>
1031 </Box>
1032 );
1033 }
1034
1035 const id = `${picture.path}|${picture.mtimeMs}|${box.columns}x${box.rows}`;
1036 wanted = { id, picture, box };
1037 if (host !== undefined && frame?.id !== id) void paint(host);
1038 if (frame?.id === id && frame.error !== undefined) {
1039 return <Text dimColor>{`${caption}: ${frame.error}`}</Text>;
1040 }
1041
1042 // While another size is converted, the last frame of this picture stays up.
1043 const isSamePicture =
1044 frame?.id.startsWith(`${picture.path}|${picture.mtimeMs}|`) === true;
1045 return (
1046 <Box flexDirection="column">
1047 {frame?.cells !== undefined && isSamePicture ? (
1048 <Raster
1049 key={KEY}
1050 columns={frame.columns}
1051 rows={frame.rows}
1052 cells={frame.cells}
1053 />
1054 ) : (
1055 <Text dimColor>Rendering…</Text>
1056 )}
1057 <Text dimColor>{caption}</Text>
1058 </Box>
1059 );
1060 });
1061
1062 // Under a reply that names image files: one chip a file. The pointer on a
1063 // chip reveals a card with the picture over the rows above; a click opens
1064 // the pane. The surface applies the hover itself, so the card is drawn
1065 // ahead of it, hidden, while the reply is on screen.
1066 on("ui.render", { component: "AssistantMessage" }, async ($, e, next) => {
1067 const row = await next(e);
1068 if (e.surface !== "terminal" || host === undefined) return row;
1069 // Only the fullscreen terminal reports the pointer. A reply that draws
1070 // no chip, there or for want of a file named, holds no card either.
1071 if (e.viewport?.isFullscreen !== true) {
1072 leave(e.requestId);
1073
1074 return row;
1075 }
1076 const mentions = findImagePaths(e.props.text, MAX_CHIPS);
1077 if (mentions.length === 0) {
1078 leave(e.requestId);
1079
1080 return row;
1081 }
1082
1083 const { Box, Button, Image, Raster, Text } = $.ui.resolve(e);
1084 const io = host;
1085 // Where the terminal does not say what is on screen, nothing is taken to
1086 // be: the chips are drawn, and no card is made or drawn for them.
1087 const visibility = e.props.onScreen;
1088 const reply =
1089 visibility === undefined || visibility === null
1090 ? undefined
1091 : enter(e.requestId);
1092 if (reply === undefined) leave(e.requestId);
1093 // The cards this drawing asks for. They are the reply's own once the
1094 // drawing is done, and no other drawing's set is touched meanwhile.
1095 const cards = new Set<string>();
1096 if (reply !== undefined) reply.drawing = cards;
1097 const columns = e.viewport.columns;
1098 const maxColumns = Math.max(16, Math.min(CARD_COLUMNS, columns - 12));
1099 const chips = [];
1100 let offset = 0;
1101 // Whether an inside file of this reply's asks for a card, got or not.
1102 let isAsking = false;
1103 const drawnUnder = boundary;
1104 for (const mention of mentions) {
1105 const now = await $.clock.now();
1106 // Between two working directories nothing is looked up, or remembered:
1107 // asked once the clock has answered, behind which a change may have come.
1108 if (io.root === undefined) break;
1109 let entry = located.get(mention);
1110 if (entry === undefined || now - entry.at > LOCATE_TTL_MS) {
1111 const asked = boundary;
1112 entry = { at: now, file: await locateMention(io, mention) };
1113 // Looked up under a working directory that has since changed.
1114 if (asked !== boundary) continue;
1115 located.delete(mention);
1116 if (located.size >= MAX_LOCATED) {
1117 for (const [key, old] of located) {
1118 if (now - old.at > LOCATE_TTL_MS) located.delete(key);
1119 }
1120 }
1121 // Still full of fresh lookups: the oldest of them goes.
1122 for (const key of located.keys()) {
1123 if (located.size < MAX_LOCATED) break;
1124 located.delete(key);
1125 }
1126 located.set(mention, entry);
1127 }
1128 const file = entry.file;
1129 if (file === undefined) continue;
1130
1131 const id = `${file.path}|${file.mtimeMs}|${maxColumns}`;
1132 if (failed.has(id)) continue;
1133 const slot = thumbs.get(id);
1134 // Only the drawing begun last of a reply still on screen asks for a
1135 // card, holds one in use, or draws one, and none does for a file
1136 // outside the working directory.
1137 const isHolding =
1138 file.isOutside !== true &&
1139 reply !== undefined &&
1140 reply.drawing === cards &&
1141 onScreen.get(e.requestId) === reply;
1142 if (file.isOutside !== true) isAsking = true;
1143 if (isHolding) {
1144 // No room among the cards on screen: this one waits, with no chip.
1145 if (slot === undefined && !admitThumb(io)) {
1146 isStarved = true;
1147 continue;
1148 }
1149 cards.add(id);
1150 if (slot !== undefined) slot.askedAt = ++asked;
1151 else sketch(io, file, id, maxColumns);
1152 }
1153 const card = slot?.card;
1154
1155 const index = chips.length;
1156 const name = baseName(file.path);
1157 const label =
1158 file.isOutside === true
1159 ? `▣ ${shortenPath(file.path, io.home, OUTSIDE_LABEL_MAX)}`
1160 : `▣ ${name}`;
1161 const width = card === undefined ? 16 : card.columns;
1162 const caption = (
1163 card === undefined
1164 ? "Rendering…"
1165 : `${name} · ${card.width}×${card.height}`
1166 )
1167 .slice(0, width)
1168 .padEnd(width);
1169 chips.push(
1170 <Box key={`imgview-chip-${index}`}>
1171 <Button
1172 key={`imgview-open-${index}`}
1173 plain
1174 dimColor
1175 label={label}
1176 onPress={async () => {
1177 // A click is the person's: the pane takes the file itself, as
1178 // it is now and not as it was when the card was drawn.
1179 const found = await io.stat(file.path);
1180 if (found?.kind !== "file") {
1181 $.ui.toast(`imgview: not a file any more: ${name}`);
1182
1183 return;
1184 }
1185 const fresh = {
1186 path: found.realPath ?? file.path,
1187 mtimeMs: found.mtimeMs,
1188 };
1189 const size = await measure(io, fresh.path);
1190 if (size === undefined) {
1191 $.ui.toast(
1192 `imgview: not an image sips or ImageMagick can read: ${name}`,
1193 );
1194
1195 return;
1196 }
1197 const mode = io.isPixelTerminal ? "pixels" : "cells";
1198 let picture: Shown | undefined;
1199 try {
1200 picture = await oneAtATime(() =>hooks/picture.ts 676 lines1// Pure helpers of the image pane: argument parsing, fitting a picture to a box
2// of cells, decoding the BMP a converter wrote, and packing it as Raster cells.
3
4export type Mode = "cells" | "pixels";
5
6export type Request = { path: string; mode?: Mode };
7
8export type Size = { width: number; height: number };
9
10export type Bitmap = Size & {
11 /** `width * height` pixels, row-major from the top, 4 bytes each: R, G, B, A. */
12 rgba: Uint8Array;
13};
14
15/** A Raster paints this many distinct foreground/background pairs at once. */
16export const MAX_PAIRS = 1024;
17
18const DEFAULT_COLOR = 0x01000000;
19// The block a cell draws, by which of its quarters are foreground: 1 the top
20// left, 2 the top right, 4 the bottom left, 8 the bottom right.
21const QUARTER_BLOCKS = [
22 0x20, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b, 0x2597, 0x259a,
23 0x2590, 0x259c, 0x2584, 0x2599, 0x259f, 0x2588,
24];
25// A cell whose quarters differ by less than this (summed squares over the
26// channels) is drawn in one color.
27const FLAT_SPREAD = 48;
28// The most colors a reduced palette is searched up to.
29const MAX_PALETTE = 2048;
30
31/** The bytes a base64 text holds. */
32export function fromBase64(text: string): Uint8Array {
33 const binary = atob(text);
34 const bytes = new Uint8Array(binary.length);
35 for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
36
37 return bytes;
38}
39
40/** Standard padded base64 of the bytes. */
41export function toBase64(bytes: Uint8Array): string {
42 let binary = "";
43 for (let i = 0; i < bytes.length; i += 0x8000) {
44 binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
45 }
46
47 return btoa(binary);
48}
49
50/**
51 * `/img` arguments: an optional `--cells` or `--pixels`, then one path as it
52 * was typed, quoted, or dragged into the terminal (spaces backslash-escaped).
53 */
54export function parseArgs(args: string): Request {
55 let rest = args.trim();
56 let mode: Mode | undefined;
57 const flag = /^--(cells|pixels)(?:\s+|$)/.exec(rest);
58 if (flag) {
59 mode = flag[1] as Mode;
60 rest = rest.slice(flag[0].length).trim();
61 }
62 const quoted = /^(['"])(.*)\1$/.exec(rest);
63 const path = quoted ? quoted[2]! : rest.replace(/\\(.)/g, "$1");
64
65 return mode ? { path, mode } : { path };
66}
67
68/**
69 * `~` and `~/x` under `home`; any other path as given, and every path where
70 * `home` is no absolute path: an empty one would turn `~/x` into `/x`.
71 */
72export function expandHome(path: string, home: string | undefined): string {
73 if (
74 home === undefined ||
75 !home.startsWith("/") ||
76 (path !== "~" && !path.startsWith("~/"))
77 )
78 return path;
79
80 // `/` is the one home that is nothing once its slashes are taken off.
81 return home.replace(/\/+$/, "") + path.slice(1) || "/";
82}
83
84/**
85 * Where the mod's temporary files go: `tmpdir` when it is an absolute path,
86 * `/tmp` when it is unset, empty or relative. No slash at the end, so a name
87 * under it is `${dir}/name`, begins with a slash, and is no program's option.
88 */
89export function tempDir(tmpdir: string | undefined): string {
90 const dir = tmpdir?.startsWith("/") === true ? tmpdir : "/tmp";
91
92 return dir.replace(/\/+$/, "");
93}
94
95/** The last path segment. */
96export function baseName(path: string): string {
97 return path.replace(/\/+$/, "").split("/").pop() || path;
98}
99
100/**
101 * A path short enough to label a chip with: the home directory as `~`, and
102 * of a path longer than `max` characters both ends around an ellipsis, `max`
103 * characters in all. The start says whose directory it is, the end which
104 * file: a third of the room goes to the start.
105 */
106export function shortenPath(
107 path: string,
108 home: string | undefined,
109 max: number,
110): string {
111 const homeDir = home?.replace(/\/+$/, "") ?? "";
112 const shown =
113 homeDir !== "" && path.startsWith(`${homeDir}/`)
114 ? `~${path.slice(homeDir.length)}`
115 : path;
116
117 if (shown.length <= max) return shown;
118 const head = Math.floor((max - 1) / 3);
119
120 return `${shown.slice(0, head)}…${shown.slice(shown.length - (max - 1 - head))}`;
121}
122
123// What a reply's mention is taken for a picture by: a bitmap's suffix, on the
124// path as written and on the file it leads to. The suffix is a first sieve
125// and no proof: `sips` is then asked what a copy of the file really is, and a
126// PDF under a bitmap's name is read that far before it is refused.
127const IMAGE_SUFFIX = /[^/]\.(?:png|jpe?g|gif|webp|heic|bmp|tiff?)$/i;
128const BITMAP_FORMATS = ["png", "jpeg", "gif", "tiff", "bmp", "heic", "webp"];
129
130/** Whether a path ends as a bitmap's file name does. */
131export function hasImageSuffix(path: string): boolean {
132 return IMAGE_SUFFIX.test(path);
133}
134
135/**
136 * Where the code span that a run of `length` backticks opens is closed: at
137 * the next run of as many, on the same line. -1 when there is none, and then
138 * the backticks are the prose's.
139 */
140function closeOfSpan(text: string, from: number, length: number): number {
141 const runs = /`+/g;
142 runs.lastIndex = from;
143 for (let run = runs.exec(text); run !== null; run = runs.exec(text)) {
144 if (text.slice(from, run.index).includes("\n")) return -1;
145 if (run[0].length === length) return run.index;
146 }
147
148 return -1;
149}
150
151/**
152 * A text as markdown reads it: the code spans, each opened by one or two
153 * backticks and closed by as many, and the prose around them. Three backticks
154 * or more fence a block, whose lines are read as prose.
155 */
156function segments(text: string): { text: string; isCode: boolean }[] {
157 const parts: { text: string; isCode: boolean }[] = [];
158 const ticks = /`+/g;
159 let prose = 0;
160 for (let open = ticks.exec(text); open !== null; open = ticks.exec(text)) {
161 const length = open[0].length;
162 const start = open.index + length;
163 const close = length > 2 ? -1 : closeOfSpan(text, start, length);
164 if (close < 0) continue;
165 parts.push({ text: text.slice(prose, open.index), isCode: false });
166 parts.push({ text: text.slice(start, close), isCode: true });
167 prose = close + length;
168 ticks.lastIndex = prose;
169 }
170 parts.push({ text: text.slice(prose), isCode: false });
171
172 return parts;
173}
174
175/**
176 * The image files a text mentions, in order, each once, `limit` at most: every
177 * run of path characters that ends in a bitmap's suffix, as markdown writes it
178 * (bare, in a code span, in a link) or as `file://`.
179 *
180 * A code span's runs are taken as written. In prose, the punctuation and the
181 * parentheses around a run are the sentence's. A web address is left out:
182 * nothing here fetches. Whether a mention is a file at all is the caller's to
183 * check.
184 */
185export function findImagePaths(text: string, limit: number): string[] {
186 const found: string[] = [];
187 const take = (run: string) => {
188 const path = run.startsWith("file://") ? run.slice("file://".length) : run;
189 if (path.includes("://") || !hasImageSuffix(path)) return;
190 if (found.length < limit && !found.includes(path)) found.push(path);
191 };
192 for (const part of segments(text)) {
193 if (part.isCode) {
194 for (const run of part.text.split(/\s+/)) take(run);
195 continue;
196 }
197 // A backtick left in prose closed no span: it is no part of a path.
198 for (const run of part.text.split(/[\s'"<>[\]{}|*`]+/)) {
199 take(unwrap(run.replace(/[.,;:!?]+$/, "")).replace(/[.,;:!?]+$/, ""));
200 }
201 }
202
203 return found;
204}
205
206/**
207 * A run without the parentheses that are the sentence's or the link's, not
208 * the path's own: `(a.png)` and `a.png)` are `a.png`, `a(1).png` stays.
209 */
210function unwrap(run: string): string {
211 let path = run;
212 for (;;) {
213 const opens = path.split("(").length - 1;
214 const closes = path.split(")").length - 1;
215 if (path.startsWith("(") && closeOf(path) === path.length - 1) {
216 path = path.slice(1, -1);
217 } else if (path.startsWith("(") && opens > closes) {
218 path = path.slice(1);
219 } else if (path.endsWith(")") && closes > opens) {
220 path = path.slice(0, -1);
221 } else {
222 return path;
223 }
224 }
225}
226
227/** Where the parenthesis a run opens with is closed; -1 when it never is. */
228function closeOf(run: string): number {
229 let depth = 0;
230 for (let i = 0; i < run.length; i++) {
231 if (run[i] === "(") depth++;
232 if (run[i] === ")" && --depth === 0) return i;
233 }
234
235 return -1;
236}
237
238/** Whether `path` is `root` or lies beneath it; both absolute and resolved. */
239export function isInside(path: string, root: string): boolean {
240 const base = root.replace(/\/+$/, "");
241
242 return path === base || path.startsWith(`${base}/`);
243}
244
245/** A picture's size and, where the converter said, the file's real format. */
246export type Measured = Size & {
247 /** As `sips` names it, lower case: `png`, `jpeg`, `pdf`, ... */
248 format?: string;
249};
250
251/** Whether a converter's format name is a bitmap's: no SVG, no PDF. */
252export function isBitmapFormat(format: string | undefined): boolean {
253 return format !== undefined && BITMAP_FORMATS.includes(format);
254}
255
256/**
257 * What `sips -g format -g pixelWidth -g pixelHeight` printed, if it printed a
258 * size. The format is the file's own, whatever its suffix says.
259 */
260export function parseSipsSize(stdout: string): Measured | undefined {
261 const width = /pixelWidth:\s*(\d+)/.exec(stdout);
262 const height = /pixelHeight:\s*(\d+)/.exec(stdout);
263 const format = /format:\s*(\w+)/.exec(stdout);
264 const size =
265 width && height ? toSize(Number(width[1]), Number(height[1])) : undefined;
266
267 return size && format ? { ...size, format: format[1]!.toLowerCase() } : size;
268}
269
270/** What `magick identify -format '%w %h %m'` printed, if it printed a size. */
271export function parseIdentifySize(stdout: string): Measured | undefined {
272 const found = /^\s*(\d+) (\d+)(?: (\w+))?/.exec(stdout);
273 const size = found ? toSize(Number(found[1]), Number(found[2])) : undefined;
274
275 return size && found?.[3]
276 ? { ...size, format: found[3].toLowerCase() }
277 : size;
278}
279
280function toSize(width: number, height: number): Size | undefined {
281 return width > 0 && height > 0 ? { width, height } : undefined;
282}
283
284/**
285 * The largest box of cells, at most `maxColumns` by `maxRows`, that keeps the
286 * picture's shape: a cell is taken as twice as tall as it is wide.
287 */
288export function fit(
289 size: Size,
290 maxColumns: number,
291 maxRows: number,
292): { columns: number; rows: number } {
293 const columns = clamp(
294 Math.round((maxRows * 2 * size.width) / size.height),
295 1,
296 maxColumns,
297 );
298 const rows = clamp(
299 Math.ceil((columns * size.height) / size.width / 2),
300 1,
301 maxRows,
302 );
303
304 return { columns, rows };
305}
306
307function clamp(value: number, low: number, high: number): number {
308 return Math.max(low, Math.min(Math.max(low, high), value));
309}
310
311// No conversion asks for more than 510 pixels a side: a header that claims
312// far more is not a converter's.
313const MAX_BMP_SIDE = 4096;
314
315/**
316 * Decodes an uncompressed 24- or 32-bit BMP, the two forms `sips` and
317 * ImageMagick write. Throws on any other file, naming what it found.
318 */
319export function decodeBmp(bytes: Uint8Array): Bitmap {
320 if (bytes.length < 54 || bytes[0] !== 0x42 || bytes[1] !== 0x4d)
321 throw new Error("not a BMP file");
322 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
323 const dataOffset = view.getUint32(10, true);
324 const headerSize = view.getUint32(14, true);
325 const width = view.getInt32(18, true);
326 const signedHeight = view.getInt32(22, true);
327 const height = Math.abs(signedHeight);
328 const planes = view.getUint16(26, true);
329 const bitsPerPixel = view.getUint16(28, true);
330 const compression = view.getUint32(30, true);
331 if (headerSize < 40 || planes !== 1)
332 throw new Error(`a BMP header of ${headerSize} bytes, ${planes} planes`);
333 if (bitsPerPixel !== 24 && bitsPerPixel !== 32)
334 throw new Error(`a ${bitsPerPixel}-bit BMP`);
335 if (compression !== 0 && compression !== 3)
336 throw new Error(`BMP compression ${compression}`);
337 if (
338 width <= 0 ||
339 height === 0 ||
340 width > MAX_BMP_SIDE ||
341 height > MAX_BMP_SIDE
342 )
343 throw new Error(`a ${width}x${signedHeight} BMP`);
344
345 // Three masks follow the first 40 header bytes, a fourth in the longer
346 // headers; the pixels start past the header and past the masks.
347 const hasMasks = compression === 3;
348 const hasAlphaMask = hasMasks && headerSize >= 56;
349 if (hasMasks && bitsPerPixel !== 32)
350 throw new Error("a 24-bit BMP with channel masks");
351 const firstPixel = Math.max(
352 14 + headerSize,
353 hasMasks ? (hasAlphaMask ? 70 : 66) : 54,
354 );
355 // The sides are small, so none of this leaves the safe integers.
356 const step = bitsPerPixel / 8;
357 const stride = Math.ceil((width * step) / 4) * 4;
358 if (
359 dataOffset < firstPixel ||
360 dataOffset + stride * (height - 1) + width * step > bytes.length
361 )
362 throw new Error("a truncated BMP");
363
364 // Which byte of a pixel holds each channel: B, G, R(, A) unless masks say otherwise.
365 let red = 2;
366 let green = 1;
367 let blue = 0;
368 let alpha = bitsPerPixel === 32 ? 3 : -1;
369 if (hasMasks) {
370 red = maskByte(view.getUint32(54, true));
371 green = maskByte(view.getUint32(58, true));
372 blue = maskByte(view.getUint32(62, true));
373 alpha = hasAlphaMask ? maskByte(view.getUint32(66, true), true) : -1;
374 }
375
376 const rgba = new Uint8Array(width * height * 4);
377 let isAlphaUsed = false;
378 for (let y = 0; y < height; y++) {
379 const row = dataOffset + stride * (signedHeight < 0 ? y : height - 1 - y);
380 for (let x = 0; x < width; x++) {
381 const from = row + x * step;
382 const to = (y * width + x) * 4;
383 rgba[to] = bytes[from + red]!;
384 rgba[to + 1] = bytes[from + green]!;
385 rgba[to + 2] = bytes[from + blue]!;
386 rgba[to + 3] = alpha < 0 ? 255 : bytes[from + alpha]!;
387 if (rgba[to + 3] !== 0) isAlphaUsed = true;
388 }
389 }
390 // Without masks a 32-bit pixel's fourth byte means nothing: zero throughout
391 // is an opaque picture. Under an alpha mask, zero is transparent.
392 if (!hasMasks && !isAlphaUsed)
393 for (let i = 3; i < rgba.length; i += 4) rgba[i] = 255;
394
395 return { width, height, rgba };
396}
397
398function maskByte(mask: number, isOptional = false): number {
399 const index = [0xff, 0xff00, 0xff0000, 0xff000000].indexOf(mask >>> 0);
400 if (index < 0 && !(isOptional && mask === 0))
401 throw new Error(`BMP channel mask ${mask.toString(16)}`);
402
403 return index;
404}
405
406/**
407 * Packs a bitmap as Raster cells, one cell per two by two pixels: the block
408 * whose quarters split the four pixels best into two colors, a foreground and
409 * a background.
410 *
411 * A transparent pixel shows the terminal's own background. A picture that
412 * needs more than `maxPairs` distinct color pairs is drawn from the largest
413 * palette that fits (`reducePairs`).
414 *
415 * @returns the grid's size and its cells as `RasterProps` packs them
416 */
417export function toCells(
418 bitmap: Bitmap,
419 maxPairs = MAX_PAIRS,
420): { columns: number; rows: number; cells: string } {
421 const columns = Math.ceil(bitmap.width / 2);
422 const rows = Math.ceil(bitmap.height / 2);
423 const words = new Uint32Array(columns * rows * 3);
424 const foreground = new Int32Array(columns * rows);
425 const background = new Int32Array(columns * rows);
426 // The four pixels of the cell in hand: red, green, blue each.
427 const quarters = new Float64Array(12);
428 for (let row = 0; row < rows; row++) {
429 for (let column = 0; column < columns; column++) {
430 let opaque = 0;
431 for (let quarter = 0; quarter < 4; quarter++) {
432 const x = column * 2 + (quarter & 1);
433 const y = row * 2 + (quarter >> 1);
434 const at = (y * bitmap.width + x) * 4;
435 if (x >= bitmap.width || y >= bitmap.height) continue;
436 if (bitmap.rgba[at + 3]! < 128) continue;
437 opaque |= 1 << quarter;
438 quarters.set(bitmap.rgba.subarray(at, at + 3), quarter * 3);
439 }
440 const cell = row * columns + column;
441 if (opaque !== 15) {
442 // Clear quarters are the background; the rest share one color.
443 words[cell * 3] = QUARTER_BLOCKS[opaque]!;
444 foreground[cell] =
445 opaque === 0 ? DEFAULT_COLOR : meanColor(quarters, opaque);
446 background[cell] = DEFAULT_COLOR;
447 continue;
448 }
449 let split = 15;
450 let least = spread(quarters, 15);
451 if (least > FLAT_SPREAD) {
452 // Every way to part four quarters in two: 1 to 7 and their complements.
453 for (let mask = 1; mask <= 7; mask++) {
454 const error = spread(quarters, mask) + spread(quarters, 15 ^ mask);
455 if (error < least) {
456 split = mask;
457 least = error;
458 }
459 }
460 }
461 const a = meanColor(quarters, split);
462 const b = split === 15 ? a : meanColor(quarters, 15 ^ split);
463 // One picture, one spelling: the larger color is the foreground.
464 if (a >= b) {
465 words[cell * 3] = QUARTER_BLOCKS[split]!;
466 foreground[cell] = a;
467 background[cell] = b;
468 } else {
469 words[cell * 3] = QUARTER_BLOCKS[15 ^ split]!;
470 foreground[cell] = b;
471 background[cell] = a;
472 }
473 }
474 }
475 reducePairs(foreground, background, maxPairs);
476 for (let cell = 0; cell < foreground.length; cell++) {
477 words[cell * 3 + 1] = foreground[cell]!;
478 words[cell * 3 + 2] = background[cell]!;
479 }
480
481 return { columns, rows, cells: toBase64(new Uint8Array(words.buffer)) };
482}
483
484/** The mean color of the quarters `mask` names. */
485function meanColor(quarters: Float64Array, mask: number): number {
486 const sum = [0, 0, 0];
487 let count = 0;
488 for (let quarter = 0; quarter < 4; quarter++) {
489 if ((mask & (1 << quarter)) === 0) continue;
490 for (let channel = 0; channel < 3; channel++) {
491 sum[channel]! += quarters[quarter * 3 + channel]!;
492 }
493 count++;
494 }
495
496 return (
497 (Math.round(sum[0]! / count) << 16) |
498 (Math.round(sum[1]! / count) << 8) |
499 Math.round(sum[2]! / count)
500 );
501}
502
503/** How far the quarters `mask` names lie from their mean: summed squares. */
504function spread(quarters: Float64Array, mask: number): number {
505 let total = 0;
506 for (let channel = 0; channel < 3; channel++) {
507 let sum = 0;
508 let squares = 0;
509 let count = 0;
510 for (let quarter = 0; quarter < 4; quarter++) {
511 if ((mask & (1 << quarter)) === 0) continue;
512 const value = quarters[quarter * 3 + channel]!;
513 sum += value;
514 squares += value * value;
515 count++;
516 }
517 total += squares - (sum * sum) / count;
518 }
519
520 return total;
521}
522
523/** A box of the median cut: its colors, and the two it was cut into. */
524type PaletteBox = {
525 colors: number[];
526 /** The channel its colors range widest over, and by how much. */
527 channel: number;
528 range: number;
529 /** The weighted mean of its colors: what they are all drawn as. */
530 center: number;
531 /** How many boxes there were once this one was made. */
532 born: number;
533 halves?: [PaletteBox, PaletteBox];
534};
535
536/**
537 * Redraws the cells from a smaller palette when they need more than
538 * `maxPairs` distinct foreground/background pairs: a median cut of the colors
539 * in use, as large as still fits. In place; the default color is kept.
540 */
541function reducePairs(
542 foreground: Int32Array,
543 background: Int32Array,
544 maxPairs: number,
545): void {
546 if (countPairs(foreground, background) <= maxPairs) return;
547 const weights = new Map<number, number>();
548 for (const colors of [foreground, background]) {
549 for (const color of colors) {
550 if (color !== DEFAULT_COLOR)
551 weights.set(color, (weights.get(color) ?? 0) + 1);
552 }
553 }
554 const root = cutPalette(weights, Math.min(weights.size, MAX_PALETTE));
555 // More colors never need fewer pairs: search for the most that fit.
556 let palette = paletteOf(root, 1);
557 let low = 2;
558 let high = Math.min(weights.size, MAX_PALETTE);
559 while (low <= high) {
560 const size = (low + high) >> 1;
561 const candidate = paletteOf(root, size);
562 if (countPairs(foreground, background, candidate) <= maxPairs) {
563 palette = candidate;
564 low = size + 1;
565 } else {
566 high = size - 1;
567 }
568 }
569 for (let cell = 0; cell < foreground.length; cell++) {
570 foreground[cell] = palette.get(foreground[cell]!) ?? foreground[cell]!;
571 background[cell] = palette.get(background[cell]!) ?? background[cell]!;
572 }
573}
574
575function countPairs(
576 foreground: Int32Array,
577 background: Int32Array,
578 palette?: Map<number, number>,
579): number {
580 const pairs = new Set<number>();
581 for (let cell = 0; cell < foreground.length; cell++) {
582 const a = palette?.get(foreground[cell]!) ?? foreground[cell]!;
583 const b = palette?.get(background[cell]!) ?? background[cell]!;
584 // A color is at most 25 bits: the two fit one exact number.
585 pairs.add(a * 0x2000000 + b);
586 }
587
588 return pairs.size;
589}
590
591/**
592 * Cuts the colors into `size` boxes, each time the box that ranges widest,
593 * at the weighted median of its widest channel, and keeps the order of the
594 * cuts: the first `n` boxes are the palette of `n` colors.
595 */
596function cutPalette(weights: Map<number, number>, size: number): PaletteBox {
597 const root = paletteBox([...weights.keys()], weights, 1);
598 const leaves = [root];
599 while (leaves.length < size) {
600 let widest = -1;
601 for (let i = 0; i < leaves.length; i++) {
602 if (leaves[i]!.range === 0) continue;
603 if (widest < 0 || leaves[i]!.range > leaves[widest]!.range) widest = i;
604 }
605 if (widest < 0) break;
606 const box = leaves[widest]!;
607 const shift = 16 - box.channel * 8;
608 box.colors.sort((a, b) => ((a >> shift) & 255) - ((b >> shift) & 255));
609 let total = 0;
610 for (const color of box.colors) total += weights.get(color)!;
611 let cut = 1;
612 let seen = weights.get(box.colors[0]!)!;
613 while (cut < box.colors.length - 1 && seen < total / 2) {
614 seen += weights.get(box.colors[cut++]!)!;
615 }
616 const born = leaves.length + 1;
617 box.halves = [
618 paletteBox(box.colors.slice(0, cut), weights, born),
619 paletteBox(box.colors.slice(cut), weights, born),
620 ];
621 leaves[widest] = box.halves[0];
622 leaves.push(box.halves[1]);
623 }
624
625 return root;
626}
627
628function paletteBox(
629 colors: number[],
630 weights: Map<number, number>,
631 born: number,
632): PaletteBox {
633 let channel = 0;
634 let range = -1;
635 const sum = [0, 0, 0];
636 let total = 0;
637 for (let c = 0; c < 3; c++) {
638 const shift = 16 - c * 8;
639 let low = 255;
640 let high = 0;
641 for (const color of colors) {
642 const value = (color >> shift) & 255;
643 if (value < low) low = value;
644 if (value > high) high = value;
645 sum[c]! += value * weights.get(color)!;
646 }
647 if (high - low > range) {
648 range = high - low;
649 channel = c;
650 }
651 }
652 for (const color of colors) total += weights.get(color)!;
653 const center =
654 (Math.round(sum[0]! / total) << 16) |
655 (Math.round(sum[1]! / total) << 8) |
656 Math.round(sum[2]! / total);
657
658 return { colors, channel, range, center, born };
659}
660
661/** Each color's stand-in under the palette of the first `size` boxes. */
662function paletteOf(root: PaletteBox, size: number): Map<number, number> {
663 const palette = new Map<number, number>();
664 const walk = (box: PaletteBox) => {
665 if (box.halves !== undefined && box.halves[0].born <= size) {
666 walk(box.halves[0]);
667 walk(box.halves[1]);
668 } else {
669 for (const color of box.colors) palette.set(color, box.center);
670 }
671 };
672 walk(root);
673
674 return palette;
675}
676types/index.d.ts 34 lines1/** The picture the pane shows: where it is, how large, and how it is drawn. */
2export type Shown = {
3 /** The file as resolved: absolute where the file system said where it lands. */
4 path: string;
5 /** Its last path segment, the pane's caption. */
6 name: string;
7 /** Pixels across. */
8 width: number;
9 /** Pixels down. */
10 height: number;
11 /**
12 * Last modification as the `/img` or the click that showed the picture
13 * found it, milliseconds since the epoch. The pane does not watch the file:
14 * a later change is drawn at the next `/img` or click.
15 */
16 mtimeMs: number;
17 /** `pixels`: the terminal draws the file itself; `cells`: quarter blocks. */
18 mode: "cells" | "pixels";
19 /** In `pixels` mode, the absolute path of a PNG the terminal reads. */
20 png?: string;
21 /** True when the mod wrote that PNG itself, so it is the mod's to remove. */
22 isPngTemporary?: true;
23};
24
25declare module "claude-code" {
26 interface PluginState {
27 imgview: {
28 shown: Shown | null;
29 /** The temporary files the mod made and has not removed, by path. */
30 temps: readonly string[];
31 };
32 }
33}
34