SLOPSHOPPER

imgview

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…

newpanerowscommandtoastprompt
v0.2.1MITupdated 2026-10-14hieuvo/claude-imgview
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · imgview
│ ┃ imgview ✕ › fix the failing auth test and add an audit log call │ ┃ Usage: /img [--cells|--pixels] <image path> │ ⏺ 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 │ ⎿ imgview: Usage: /img [--cells|--pixels] <image path> │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · imgview
Usage: /img [--cells|--pixels] <image path>
README

imgview

Preview images without leaving Claude Code, two ways:

  • Hover. Under any reply of Claude's that names an image file, the mod draws a chip per file (▣ 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.

Install

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.

Use

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

What the mod reads, runs and sends

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.

The programs it starts

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.

What it reads

  • The conversation: the text of each of Claude's replies as it is drawn (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.
  • Files: what a path leads to ($.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.
  • The environment, none of it a credential:
VariableUsed for
HOMEexpanding ~ in a path, when it is an absolute path
TMPDIRwhere the temporary files go, when it is an absolute path
TERM, TERM_PROGRAM, KITTY_WINDOW_IDwhether the terminal is kitty or Ghostty, which draw pixels
TMUXwhether it is set: tmux passes no pictures through
SSH_CONNECTION, SSH_CLIENT, SSH_TTYwhether one is set: across ssh the terminal cannot read this machine's files. Their values are not used.
  • The session's state, which it also writes: 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).

What it hooks and adds

  • It adds one command, /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 hover card

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

  • A reply gets a chip for each of its first four image files (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.
  • A file outside the working directory gets a chip and no card. The chip's label is the path, the home directory as ~, 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.
  • A click that opens nothing says why in a toast: the file is gone, it is no picture, it converts to no PNG, or the pane did not take it (could not show …, try again).
  • The card holds at most 80 by 24 cells (CARD_COLUMNS, CARD_ROWS), fewer columns on a terminal narrower than 92. In cells mode that is a 160 by 48 pixel thumbnail.
  • The terminal applies a hover by itself and runs no hook for it, so the card cannot be made on demand: its picture is converted when the reply comes on screen.
  • 32 cards are kept, drawn or waiting their turn (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.
  • What is on screen is what the engine says is. It draws a reply again when the reply comes on screen and when it leaves (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).
  • A card is drawn only by a drawing that holds it. A reply drawn off screen shows its chips with no card behind them, and gets its cards when it comes back.
  • That record has three limits, because the engine names a reply and reports when it scrolls away, and nothing else:
  • A reply taken out of the transcript with no last drawing (a rewind, a compaction) goes on holding its cards until the session ends (a /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).
  • With more than 64 replies that asked for a card on screen at once, the one drawn longest ago stops holding its cards though it is still there.
  • Two terminals attached to one session are one record: a reply one of them reports as gone stops holding its cards while the other may still show it.
  • Cards are converted one after another, the one asked for last first. One whose reply leaves the screen before its turn is given up, so what is on screen now does not wait behind what was (pruneQueue and drain).
  • Where the terminal does not say what is on screen (onScreen absent), the chips are drawn and no card is made or drawn for them; a click still opens the pane.
  • Chips are drawn in the fullscreen layout only ("tui": "fullscreen"), on a terminal surface. A card that would rise past the top of the viewport is clipped there.

What a reply can make the mod open

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 whenEnforced by
the file it leads to, links followed, is named as a bitmaphasImageSuffix(found.realPath) in locateMention, for a chip of any kind
it lands inside the session's working directory, links followedisInside(found.realPath, host.root); host.root follows classic.CwdChanged
the file is at most 20 MiBPREVIEW_MAX_BYTES, on the file before anything runs and again on the copy
sips names the copy's real format a bitmap'sisBitmapFormat(size.format): no SVG, no PDF, whatever the name says
the picture is at most 40 megapixelsPREVIEW_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.
  • One preview is converted at a time (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.
  • Each shell and 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.
  • A preview still unfinished after 30 seconds (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.
  • With eight previews given up and still unanswered (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.
  • On a terminal that draws pixels the card shows a small PNG the mod converted from the copy, never the file the reply named.
  • When the working directory changes, every card, lookup and waiting conversion made under the old one is dropped and the chips on screen are redrawn without them, before anything else happens. Nothing is previewed until the new directory is resolved. A drawing the change came in behind remembers no lookup (io.root is asked after the clock has answered) and hands the reply back with no chips (drawnUnder).

This stops protecting at three edges:

  1. 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.
  2. The engine gives a mod paths, not file handles, so nothing here can hold a file still between two steps. A process already running as you can swap the file for a link, a pipe or a larger file after the mod has looked at it and before the shell opens it. The mod looks again after the copy and gives up if the path then lands elsewhere, is no regular file, or has another modification time; a swap undone in time, with the same modification time, gets the other file copied, decoded and shown, within the size, format and megapixel limits above. A pipe holds the copy for the five seconds it is allowed; a huge file is copied for as long before the size check drops it.

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:

  • It can replace the copy once it is made, and that file is then what is decoded.
  • Only the copy is created exclusively. 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.
  • The mod removes its files by name (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.

  1. /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.

How it draws

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:

ModeWhenWhat you get
pixelskitty or Ghostty, as the environment names themThe engine's Image element: the terminal reads the PNG itself and draws real pixels (the kitty graphics protocol).
cellsevery 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.

Requires

One converter on PATH. For /img the mod tries them in this order; a hover card uses the first alone:

  1. sips, which macOS ships. Checked on macOS 14.5: PNG, JPEG, HEIC, GIF, WebP, SVG and PDF (first page); sips --formats lists the rest.
  2. 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

Source 3 files
hooks/register.tsx 1285 lines
1import { 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 lines
1// 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}
676
types/index.d.ts 34 lines
1/** 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