After a prompt is sent, an IMAGES band above the prompt draws each pasted [Image #n] as a half-block thumbnail (macOS sips turns the engine's PNG into a small…

After you send a prompt with pasted images, a ▣ IMAGES row above the prompt draws each [Image #n] as a small half-block thumbnail, with [Image #n] WxH (the image's own size in pixels) beneath it. The row clears when you send the next prompt.
▣ IMAGES ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ [ clear ]
▀▀▀▀ (24 columns, rows ▀▀▀▀ from the image's shape)
[Image #1] 1376x1584 [Image #2] 1920x1080
prompt.submit notes the [Image #n] tags of the prompt you sent. The main loop's next turn.start starts the work from a timer, one image after another:<n>.png in the session's images folder, /private/tmp/claude-<uid>/<cwd with every / as ->/<session id>/images/ (looked for every 250 ms, up to 5 s). /private/tmp is shared by every user and the hooks cannot read their uid, so the session's folder must be the only one of that name under any claude-<uid>, and the PNG must be a plain file with no symbolic link on its path; otherwise only the tag is shown.sips -s format bmp -Z 48 <png> --out ~/.cache/zehui-mods/images/<session>-<n>.bmp (3 s limit) turns it into an uncompressed BMP no wider or taller than 48 pixels; sips -g pixelWidth -g pixelHeight <png> reads its own size.size columns by round(size × height / width / 2) half-block rows (at most 24 rows; a taller image gets a narrower tile so it keeps its shape), and packed as Raster cells: ▀, the upper pixel as the foreground, the lower as the background.~/.cache/zehui-mods/images/).+n more. When the tallest tile and its caption do not fit the band's height beside the other bands, only the captions show.[Image #n] tag as text only.Raster half-block cells only, never Image (in herdr an Image draws only its alt text). Terminal only; hidden while a survey is up.zm-90-images, under NEXT 80 (SYS 10, PLAN 20, HANDOFF 30, CACHE 40, AGENTS 50, GOAL 60, HERE 70, NEXT 80, IMAGES 90); other bands follow.While the tag sits in the prompt box, a user-scope mod gets no pasted image's bytes: $.prompt.media answers only a managed-settings install, and only inside its prompt.submit hook. No PNG is on disk yet, and the hooks environment has no zlib or DecompressionStream to inflate a PNG with. After you send, the engine writes each pasted image to the session's images folder, and sips turns it into a BMP, which needs no decoder. (images 0.1.0 drew placeholder tiles in the box; that is gone.)
| Option | Values | Default | What it does |
|---|---|---|---|
size | 12, 16, 24, 32 | 24 | Tile width in columns; the height follows the image, at most 24 rows. |
auto | on, off | on | Draw a sent prompt's images on their own. /img on and /img off override it. |
linger | turn, 2min, until-cleared | turn | turn: until the next prompt is sent. 2min: two minutes after it showed, or the next prompt if sooner. until-cleared: until /img clear; new images are added to the row. |
/img [<n> | on | off | clear]
/img: show the last tiles again (after the next prompt cleared them)./img <n>: draw [Image #n] of this session (works with auto off too)./img off: stop drawing a sent prompt's images on their own and hide the row; kept for the next session./img on: draw them again./img clear: drop the tiles.The button works only while the band has focus (ctrl+x tab, or a click in Claude Code's fullscreen renderer; the classic renderer reports no clicks), so it has a command:
| Button | Command |
|---|---|
clear | /img clear |
claude plugin install images@zehui-mods --scope user
After an edit: bump version in .claude-plugin/plugin.json, then claude plugin update images@zehui-mods and /reload-plugins.
sips (convert, and read the size), each with a 3-second limit.HOME for the scratch folder; with no HOME, tiles are text only.See PROVENANCE.md for where it came from.
hooks/register.tsx 244 lines1// The images mod: after a prompt is sent, the ▣ IMAGES row (row.tsx) draws
2// each pasted `[Image #n]` as a half-block thumbnail. `prompt.submit` notes
3// the sent prompt's tags (before `next`, since the main loop's `turn.start`
4// runs inside it); the next main-loop `turn.start` starts the pipeline
5// (pipeline.ts) from a $.clock timer, one image after another, each tile
6// written to $.state `images.tiles` (at most 6, newest last) as it is built.
7// The next prompt clears the row (option linger: turn, 2min or
8// until-cleared). /img shows the last tiles again, /img <n> draws one image of
9// this session, /img off|on (kept in the store) and /img clear; the row's
10// `clear` Button is /img clear, run from a timer.
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, PluginOptions, PromptSubmitInput, Register, Timer } from 'claude-code'
13
14import type { ImageTile } from '../types'
15import { COMMAND, DELAY_MS, LINGER_MS, STORE_BAND, USAGE, captionOf, isAutoOn, lingerOf, parseArgs, sizeOf, tagText, tagsOf, withTile } from './kit'
16import { buildTile, pipelineOf } from './pipeline'
17import { drawRow } from './row'
18
19const tiles = atom({ plugin: 'images', key: 'tiles' } as const, [])
20const shown = atom({ plugin: 'images', key: 'shown' } as const, false)
21const band = atom({ plugin: 'images', key: 'band' } as const, '')
22
23// The tags of the last sent prompt, waiting for its main-loop turn.start.
24let pending: number[] = []
25// Bumped by every sent prompt, new batch, /img off, /img clear and session change: a run
26// that started under an older value stops and writes nothing.
27let generation = 0
28// linger=2min: the timer that clears the row.
29let lingerTimer: Timer | null = null
30
31const whyOf = (err: unknown): string => (err instanceof Error ? err.message : String(err))
32
33const stopLinger = () => {
34 lingerTimer?.cancel()
35 lingerTimer = null
36}
37
38/** The row hides; the tiles stay for /img. */
39async function hide($: EngineInterface): Promise<void> {
40 stopLinger()
41 await update($, shown, () => false)
42}
43
44/** The row shows; with linger=2min it clears two minutes later. */
45async function show($: EngineInterface, options: PluginOptions): Promise<void> {
46 stopLinger()
47 await update($, shown, () => true)
48 if (lingerOf(options.linger) === '2min') {
49 lingerTimer = $.clock.after(LINGER_MS, () => {
50 lingerTimer = null
51 void update($, shown, () => false).catch(() => {})
52 })
53 }
54}
55
56/** A built tile into the state: a hidden row's tiles are replaced, a shown row's added to (at most 6, newest last). */
57async function addTile($: EngineInterface, tile: ImageTile, options: PluginOptions): Promise<void> {
58 const isShown = await read($, shown)
59 await update($, tiles, list => (isShown ? withTile(list, tile) : [tile]))
60 if (!isShown) await show($, options)
61}
62
63/** A sent prompt's images, one after another; stops when a newer prompt, batch or clear comes. */
64async function runBatch($: EngineInterface, list: readonly number[], options: PluginOptions): Promise<void> {
65 const mine = ++generation
66 const p = await pipelineOf($, () => mine === generation)
67 for (const n of list) {
68 const tile = await buildTile($, n, sizeOf(options.size), p)
69 if (tile === null || mine !== generation) return
70 await addTile($, tile, options)
71 }
72}
73
74/** /img <n>: that image of this session; a toast when only its tag could be drawn. */
75async function runOne($: EngineInterface, n: number, options: PluginOptions): Promise<void> {
76 const mine = generation
77 const p = await pipelineOf($, () => mine === generation)
78 const tile = await buildTile($, n, sizeOf(options.size), p)
79 if (tile === null || mine !== generation) return
80 await addTile($, tile, options)
81 if (!tile.cells) await $.ui.toast(`${tagText(n)}: no thumbnail (not in this session's images folder, or no sips) (/${COMMAND} ${n})`)
82}
83
84/** /img clear and the clear button: no tiles, no row, any run stopped. */
85async function clearAll($: EngineInterface): Promise<void> {
86 generation++
87 pending = []
88 stopLinger()
89 await update($, tiles, () => [])
90 await update($, shown, () => false)
91}
92
93/** Writes /img on|off to the store; the words to add to the reply when it could not be kept. */
94async function persist($: EngineInterface, value: 'on' | 'off'): Promise<string> {
95 try {
96 await $.store.set(STORE_BAND, value)
97 return ''
98 } catch {
99 return ' (not saved for the next session)'
100 }
101}
102
103/** A prompt the person sent: typed or from the bridge, not a slash command, not one they do not see. */
104const isPersonPrompt = (e: PromptSubmitInput): boolean =>
105 !e.command && !e.isMeta && (e.origin?.kind === 'composer' || e.origin?.kind === 'bridge')
106
107export const register: Register = (on, options: PluginOptions) => {
108 on('session.start', async ($, e, next) => {
109 const started = await next(e)
110 pending = []
111 generation++
112 stopLinger()
113 await $.command.register({
114 name: 'img',
115 description: 'IMAGES row: show the last image tiles again, draw [Image #n] of this session, turn the automatic tiles on or off, or clear them',
116 argumentHint: '<n> | on | off | clear',
117 })
118 try {
119 const stored = await $.store.get(STORE_BAND)
120 await update($, band, () => (stored === 'on' || stored === 'off' ? stored : ''))
121 } catch {
122 // No store: the auto option applies.
123 }
124 return started
125 })
126
127 // A sent prompt clears the row (unless linger=until-cleared) and leaves its
128 // tags for its turn. Done before next: the main loop's turn.start runs inside it.
129 on('prompt.submit', async ($, e, next) => {
130 let mine: number[] = []
131 try {
132 if (isPersonPrompt(e)) {
133 generation++
134 pending = []
135 if (lingerOf(options.linger) !== 'until-cleared') await hide($)
136 if (isAutoOn(await read($, band), options.auto)) mine = tagsOf(e.text)
137 pending = mine
138 }
139 } catch {
140 // The row keeps what it shows; the next prompt tries again.
141 }
142 const r = await next(e)
143 try {
144 if (pending === mine && mine.length > 0) {
145 if (r.drop !== undefined) {
146 pending = []
147 } else if (e.turnId !== undefined) {
148 // Typed over a running turn: no turn.start of its own may come soon, so the run starts now.
149 pending = []
150 $.clock.after(DELAY_MS, () => {
151 void runBatch($, mine, options).catch(() => {})
152 })
153 }
154 }
155 } catch {
156 // The tags wait for the next turn.start.
157 }
158 return r
159 }).catch(($, e, next) => (next.called ? undefined : next(e)))
160
161 // The main loop's turn: the sent prompt's images, from a timer.
162 on('turn.start', async ($, e, next) => {
163 const started = await next(e)
164 try {
165 if (e.agentId === undefined && pending.length > 0) {
166 const list = pending
167 pending = []
168 $.clock.after(DELAY_MS, () => {
169 void runBatch($, list, options).catch(() => {})
170 })
171 }
172 } catch {
173 // Nothing drawn for this prompt; /img <n> draws one.
174 }
175 return started
176 }).catch(($, e, next) => (next.called ? undefined : next(e)))
177
178 // A /clear or an in-process resume puts another conversation in this process.
179 on('session.end', async ($, e, next) => {
180 const result = await next(e)
181 if (e.reason === 'clear' || e.reason === 'resume') {
182 try {
183 await clearAll($)
184 } catch {
185 // Nothing more to do.
186 }
187 }
188 return result
189 })
190
191 // The command and its matcher are spelled literally, so the host reads it as answering its own command.
192 on('command.run', { command: 'img' }, async ($, e) => {
193 const asked = parseArgs(e.args)
194 try {
195 switch (asked.kind) {
196 case 'show': {
197 const list = await read($, tiles)
198 if (list.length === 0) return { text: `No image tiles yet: send a prompt with a pasted image, or /${COMMAND} <n> for [Image #n] of this session.` }
199 await show($, options)
200 return { text: `Showing ${list.map(captionOf).join(', ')}.` }
201 }
202 case 'draw': {
203 const n = asked.n
204 $.clock.after(DELAY_MS, () => {
205 void runOne($, n, options).catch(err => $.ui.toast(`/${COMMAND} ${n}: ${whyOf(err)}`))
206 })
207 return { text: `Drawing ${tagText(n)} of this session above the prompt.` }
208 }
209 case 'on': {
210 await update($, band, () => 'on')
211 return { text: `Image tiles on: a sent prompt's images are drawn above the prompt.${await persist($, 'on')}` }
212 }
213 case 'off': {
214 await update($, band, () => 'off')
215 // A run in flight stops before its next tile, so the row stays hidden.
216 generation++
217 pending = []
218 await hide($)
219 return { text: `Image tiles off: /${COMMAND} on turns them on again; /${COMMAND} <n> still draws one.${await persist($, 'off')}` }
220 }
221 case 'clear':
222 await clearAll($)
223 return { text: 'Image tiles cleared.' }
224 default:
225 return { text: USAGE }
226 }
227 } catch (err) {
228 return { text: `/${COMMAND}: ${whyOf(err)}` }
229 }
230 })
231
232 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) =>
233 drawRow($, e, () => next(e), {
234 // The press's work runs from a timer, never inside the press; the toast names the command twin.
235 clear: () =>
236 $.clock.after(DELAY_MS, () => {
237 void clearAll($)
238 .then(() => $.ui.toast(`Image tiles cleared (/${COMMAND} clear).`))
239 .catch(err => $.ui.toast(`/${COMMAND} clear: ${whyOf(err)}`))
240 }),
241 }),
242 )
243}
244hooks/kit.ts 423 lines1// Shared by register.tsx, pipeline.ts and row.tsx: the look, the options held
2// to their listed values, the tags read from a sent prompt, the session's
3// images folder by name, the sips command lines, the BMP reader, the
4// box-average into half-block Raster cells, the command's words, the
5// terminal-column measure and the stack-order helper. Pure functions only; the
6// state atoms are declared again in each file that reads or writes them (the
7// engine reads a state source only from a const of the calling file).
8import type { RenderElement, RenderNode } from 'claude-code'
9
10import type { ImageTile } from '../types'
11
12export const CHIP = ' ▣ IMAGES ' // the word padded to 7 letters, like every zehui-mods chip, so the colour bars line up
13export const CHIP_FG = '#0b0b0f'
14export const CHIP_BG = '#e879f9'
15export const COMMAND = 'img'
16/** Where the store keeps /img on|off across sessions. */
17export const STORE_BAND = 'band'
18/** A timer's work starts this long after the hook or press that set it. */
19export const DELAY_MS = 10
20/** How often the image file is looked for, and for how long in all. */
21export const POLL_MS = 250
22export const WAIT_MS = 5_000
23/** Each sips run's limit. */
24export const SIPS_TIMEOUT_MS = 3_000
25/** The tallest tile, in half-block rows (two pixels each). */
26export const MAX_ROWS = 24
27/** The most tiles kept; the newest last. */
28export const MAX_TILES = 6
29/** linger=2min: the row clears this long after it first showed a tile. */
30export const LINGER_MS = 120_000
31/** Where the engine keeps each user's `claude-<uid>` folder. */
32export const TMP_ROOT = '/private/tmp'
33/** The scratch folder for the BMPs, under HOME. */
34export const CACHE_DIR = '.cache/zehui-mods/images'
35
36export const USAGE = `Usage: /${COMMAND} [<n> | on | off | clear] (/${COMMAND} shows the last tiles again; <n> draws [Image #n] of this session; off stops the automatic tiles; clear drops them)`
37
38// --- the options, held to their listed values
39
40/** The tile widths the `size` option lists, in terminal columns. */
41export const SIZES = [12, 16, 24, 32] as const
42export type TileSize = (typeof SIZES)[number]
43
44/** The `size` option: 12, 16, 24 or 32 (as the string the settings hold, or a number); 24 for anything else. */
45export function sizeOf(option: unknown): TileSize {
46 const n = typeof option === 'string' && /^\d+$/.test(option) ? Number(option) : option
47 return SIZES.find(s => s === n) ?? 24
48}
49
50/** The `auto` option: 'on' unless it says 'off'. */
51export const autoOf = (option: unknown): 'on' | 'off' => (option === 'off' ? 'off' : 'on')
52
53export const LINGERS = ['turn', '2min', 'until-cleared'] as const
54export type Linger = (typeof LINGERS)[number]
55
56/** The `linger` option: turn, 2min or until-cleared; turn for anything else. */
57export const lingerOf = (option: unknown): Linger => LINGERS.find(l => l === option) ?? 'turn'
58
59/** Whether a sent prompt's images are drawn on their own: the store's on|off, else the `auto` option. */
60export const isAutoOn = (band: string, option: unknown): boolean => (band === 'on' || band === 'off' ? band === 'on' : autoOf(option) === 'on')
61
62// --- the tags
63
64/** `[Image #3]` as the engine writes a pasted image's tag in the prompt. */
65const TAG = /\[Image #(\d{1,6})\]/g
66
67/** The n of every `[Image #n]` tag in `text`, in order of first appearance, each once. */
68export function tagsOf(text: string): number[] {
69 const out: number[] = []
70 for (const m of text.matchAll(TAG)) {
71 const n = Number(m[1])
72 if (n > 0 && !out.includes(n)) out.push(n)
73 }
74 return out
75}
76
77export const tagText = (n: number): string => `[Image #${n}]`
78
79// --- where the engine writes the pasted images
80
81/**
82 * The names the engine may give the session's working directory under
83 * `claude-<uid>`: every '/' as '-' (`/Users/zehui/Documents/GitHub` is
84 * `-Users-zehui-Documents-GitHub`), then every character but a letter or a
85 * digit as '-' (a '.' in the path), when that differs.
86 */
87export function slugsOf(cwd: string): string[] {
88 const slashes = cwd.replaceAll('/', '-')
89 const all = cwd.replace(/[^A-Za-z0-9]/g, '-')
90 return slashes === all ? [slashes] : [slashes, all]
91}
92
93/** The `claude-<uid>` folders among a listing of TMP_ROOT, by name, in order. */
94export const claudeDirsOf = (entries: readonly { name: string; kind: string }[]): string[] =>
95 entries.filter(e => e.kind === 'dir' && /^claude-\d+$/.test(e.name)).map(e => e.name).sort()
96
97/** Every folder that may be the session's own (its pasted images are in `images` beneath), most likely first. */
98export const sessionDirsOf = (claudeDirs: readonly string[], cwd: string, sessionId: string): string[] =>
99 claudeDirs.flatMap(d => slugsOf(cwd).map(slug => `${TMP_ROOT}/${d}/${slug}/${sessionId}`))
100
101// --- sips
102
103/** The command that turns the PNG into an uncompressed BMP no taller or wider than 2 × MAX_ROWS pixels. */
104export const sipsArgv = (png: string, bmp: string): string[] => ['sips', '-s', 'format', 'bmp', '-Z', String(2 * MAX_ROWS), png, '--out', bmp]
105
106/** The command that reads the PNG's own size (sips refuses a query and a change in one run). */
107export const sizeArgv = (png: string): string[] => ['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', png]
108
109/** The width and height from `sips -g pixelWidth -g pixelHeight`, or null. */
110export function parseSipsSize(stdout: string): { w: number; h: number } | null {
111 const w = /pixelWidth:\s*(\d+)/.exec(stdout)
112 const h = /pixelHeight:\s*(\d+)/.exec(stdout)
113 return w && h && Number(w[1]) > 0 && Number(h[1]) > 0 ? { w: Number(w[1]), h: Number(h[1]) } : null
114}
115
116// --- the BMP
117
118/** A decoded bitmap: `width` by `height` pixels, `rgb` three bytes each, top row first. */
119export type Bitmap = { width: number; height: number; rgb: Uint8Array }
120
121/** The largest side read; sips is asked for 2 × MAX_ROWS. */
122const MAX_SIDE = 4096
123
124/** A channel's shift and its largest value, from a BI_BITFIELDS mask. */
125function channelOf(mask: number): { shift: number; max: number } | null {
126 if (mask === 0) return null
127 let shift = 0
128 while (((mask >>> shift) & 1) === 0) shift++
129 return { shift, max: mask >>> shift }
130}
131
132/**
133 * Reads an uncompressed BMP: `BM`, the pixels' offset (bfOffBits at 10), the
134 * width and height (int32 at 18 and 22; a negative height is top-down), the
135 * bits per pixel (28: 24 or 32) and the compression (30: 0 BI_RGB, or 3
136 * BI_BITFIELDS with the red, green and blue masks at 54). Rows are padded to
137 * 4 bytes. Alpha is ignored. Null for anything else or a short file.
138 */
139export function parseBmp(bytes: Uint8Array): Bitmap | null {
140 if (bytes.length < 54 || bytes[0] !== 0x42 || bytes[1] !== 0x4d) return null
141 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
142 const offset = view.getUint32(10, true)
143 const width = view.getInt32(18, true)
144 const rawHeight = view.getInt32(22, true)
145 const bpp = view.getUint16(28, true)
146 const compression = view.getUint32(30, true)
147 const height = Math.abs(rawHeight)
148 const isTopDown = rawHeight < 0
149 if (bpp !== 24 && bpp !== 32) return null
150 if (compression !== 0 && compression !== 3) return null
151 if (width <= 0 || height <= 0 || width > MAX_SIDE || height > MAX_SIDE) return null
152 const stride = Math.floor((bpp * width + 31) / 32) * 4
153 if (offset < 54 || offset + stride * (height - 1) + width * (bpp / 8) > bytes.length) return null
154 const step = bpp / 8
155 let masks: { r: { shift: number; max: number }; g: { shift: number; max: number }; b: { shift: number; max: number } } | null = null
156 if (compression === 3 && bpp === 32) {
157 if (bytes.length < 66) return null
158 const r = channelOf(view.getUint32(54, true))
159 const g = channelOf(view.getUint32(58, true))
160 const b = channelOf(view.getUint32(62, true))
161 if (!r || !g || !b) return null
162 masks = { r, g, b }
163 }
164 const rgb = new Uint8Array(width * height * 3)
165 for (let y = 0; y < height; y++) {
166 const row = offset + (isTopDown ? y : height - 1 - y) * stride
167 for (let x = 0; x < width; x++) {
168 const at = row + x * step
169 const out = (y * width + x) * 3
170 if (masks) {
171 const px = view.getUint32(at, true)
172 rgb[out] = Math.round((((px >>> masks.r.shift) & masks.r.max) * 255) / masks.r.max)
173 rgb[out + 1] = Math.round((((px >>> masks.g.shift) & masks.g.max) * 255) / masks.g.max)
174 rgb[out + 2] = Math.round((((px >>> masks.b.shift) & masks.b.max) * 255) / masks.b.max)
175 } else {
176 // BI_RGB stores blue, green, red (and an unused byte at 32 bits).
177 rgb[out] = bytes[at + 2]!
178 rgb[out + 1] = bytes[at + 1]!
179 rgb[out + 2] = bytes[at]!
180 }
181 }
182 }
183 return { width, height, rgb }
184}
185
186// --- the tile
187
188/**
189 * A tile's box in cells for an image `w` by `h`: `size` columns and
190 * round(size × h / w / 2) half-block rows (a cell is about twice as tall as
191 * wide, and holds two pixels), at least one; past MAX_ROWS rows the tile is
192 * MAX_ROWS rows and narrower, so the image keeps its shape.
193 */
194export function tileGrid(size: number, w: number, h: number): { columns: number; rows: number } {
195 if (w <= 0 || h <= 0) return { columns: size, rows: 1 }
196 const rows = Math.max(1, Math.round((size * h) / w / 2))
197 if (rows <= MAX_ROWS) return { columns: size, rows }
198 return { columns: Math.max(1, Math.min(size, Math.round((MAX_ROWS * 2 * w) / h))), rows: MAX_ROWS }
199}
200
201const UPPER = 0x2580 // ▀: the top pixel as the foreground, the bottom one as the background
202
203/**
204 * The bitmap box-averaged to `columns` by `rows × 2` pixels and packed as
205 * Raster cells, row-major base64 u32 triplets [codePoint, fg, bg]: each cell
206 * `▀`, the upper pixel its foreground and the lower its background. A source
207 * smaller than the grid repeats its pixels.
208 */
209export function rasterCells(bmp: Bitmap, columns: number, rows: number): string {
210 const height = rows * 2
211 const px = new Uint32Array(columns * height)
212 for (let y = 0; y < height; y++) {
213 const y0 = Math.floor((y * bmp.height) / height)
214 const y1 = Math.max(y0 + 1, Math.floor(((y + 1) * bmp.height) / height))
215 for (let x = 0; x < columns; x++) {
216 const x0 = Math.floor((x * bmp.width) / columns)
217 const x1 = Math.max(x0 + 1, Math.floor(((x + 1) * bmp.width) / columns))
218 let r = 0
219 let g = 0
220 let b = 0
221 for (let sy = y0; sy < y1; sy++) {
222 for (let sx = x0; sx < x1; sx++) {
223 const i = (sy * bmp.width + sx) * 3
224 r += bmp.rgb[i]!
225 g += bmp.rgb[i + 1]!
226 b += bmp.rgb[i + 2]!
227 }
228 }
229 const count = (y1 - y0) * (x1 - x0)
230 px[y * columns + x] = (Math.round(r / count) << 16) | (Math.round(g / count) << 8) | Math.round(b / count)
231 }
232 }
233 const words = new Uint32Array(columns * rows * 3)
234 for (let y = 0; y < rows; y++) {
235 for (let x = 0; x < columns; x++) {
236 const i = (y * columns + x) * 3
237 words[i] = UPPER
238 words[i + 1] = px[2 * y * columns + x]!
239 words[i + 2] = px[(2 * y + 1) * columns + x]!
240 }
241 }
242 return toBase64(new Uint8Array(words.buffer))
243}
244
245/** A tile that shows only its tag: no sips, the file never came, or a BMP that would not parse. */
246export const textTile = (n: number, w = 0, h = 0): ImageTile => ({ n, w, h, columns: 0, rows: 0, cells: '' })
247
248/** The tiles with `tile` added: one of the same n replaced, at most MAX_TILES, the newest last. */
249export const withTile = (tiles: readonly ImageTile[], tile: ImageTile): ImageTile[] => [...tiles.filter(t => t.n !== tile.n), tile].slice(-MAX_TILES)
250
251/** The caption beneath a tile: `[Image #2] 1376x1584`, or the tag alone when the size is not known. */
252export const captionOf = (t: ImageTile): string => (t.w > 0 && t.h > 0 ? `${tagText(t.n)} ${t.w}x${t.h}` : tagText(t.n))
253
254/** The columns a tile takes in the row: its Raster or its caption, whichever is wider. */
255export const tileWidth = (t: ImageTile): number => Math.max(t.cells ? t.columns : 0, cellsOf(captionOf(t)))
256
257// The hooks environment has the web's Uint8Array.prototype.toBase64 and
258// Uint8Array.fromBase64; the es2023 lib types neither.
259export const toBase64 = (bytes: Uint8Array): string => (bytes as Uint8Array & { toBase64: () => string }).toBase64()
260export const fromBase64 = (text: string): Uint8Array => (Uint8Array as unknown as { fromBase64: (s: string) => Uint8Array }).fromBase64(text)
261
262// --- the command
263
264/** What /img was asked. */
265export type ImgArgs = { kind: 'show' } | { kind: 'draw'; n: number } | { kind: 'on' } | { kind: 'off' } | { kind: 'clear' } | { kind: 'usage' }
266
267export function parseArgs(args: string): ImgArgs {
268 const words = args.trim().toLowerCase().split(/\s+/).filter(Boolean)
269 if (words.length === 0) return { kind: 'show' }
270 if (words.length > 1) return { kind: 'usage' }
271 const [w] = words
272 const tag = /^(?:#)?(\d{1,6})$/.exec(w!)
273 if (tag && Number(tag[1]) > 0) return { kind: 'draw', n: Number(tag[1]) }
274 if (w === 'on' || w === 'off' || w === 'clear') return { kind: w }
275 return { kind: 'usage' }
276}
277
278// --- terminal columns
279
280/** East Asian Wide and Fullwidth ranges (CJK, Hangul, fullwidth forms) and emoji: two columns each. */
281const WIDE: readonly (readonly [number, number])[] = [
282 [0x1100, 0x115f],
283 [0x2e80, 0xa4cf],
284 [0xac00, 0xd7a3],
285 [0xf900, 0xfaff],
286 [0xfe30, 0xfe4f],
287 [0xff00, 0xff60],
288 [0xffe0, 0xffe6],
289 [0x1f300, 0x1faff],
290 [0x20000, 0x3fffd],
291]
292
293/** The columns one character takes in the terminal: 2 for a wide one, else 1. */
294export function charCells(ch: string): number {
295 const cp = ch.codePointAt(0) ?? 0
296 return WIDE.some(([lo, hi]) => cp >= lo && cp <= hi) ? 2 : 1
297}
298
299/** The columns a string takes in the terminal. */
300export const cellsOf = (s: string): number => [...s].reduce((w, ch) => w + charCells(ch), 0)
301
302const cellsIn = (space: unknown): number => (typeof space === 'number' && space > 0 ? space : 0)
303
304/**
305 * The rows a drawn tree takes, as near as its props tell: a string or a Text
306 * its lines, a Raster its `rows`, a Box its `height`, else its children summed
307 * down a column (with the gaps) or the tallest across a row, plus its border,
308 * padding and margin; an unknown element one row, the engine's placeholder none.
309 */
310export function rowsOf(node: unknown): number {
311 if (typeof node === 'string') return node.split('\n').length
312 if (typeof node === 'number') return 1
313 if (Array.isArray(node)) return node.reduce((sum: number, c) => sum + rowsOf(c), 0)
314 if (!node || typeof node !== 'object') return 0
315 const n = node as Loose
316 const p = n.props ?? {}
317 const kids = Array.isArray(n.children) ? n.children : n.children === undefined || n.children === null ? [] : [n.children]
318 if (n.type === 'engine' || p.display === 'none') return 0
319 if (n.type === 'Raster') return typeof p.rows === 'number' ? p.rows : 1
320 if (n.type === 'Text') return Math.max(1, kids.filter(c => typeof c === 'string').join('').split('\n').length)
321 if (n.type !== 'Box') return 1
322 const outer = (cellsIn(p.margin) || cellsIn(p.marginY)) * 2 + cellsIn(p.marginTop) + cellsIn(p.marginBottom)
323 if (typeof p.height === 'number') return p.height + outer
324 const sizes = kids.map(rowsOf).filter(r => r > 0)
325 const isColumn = p.flexDirection === 'column' || p.flexDirection === 'column-reverse'
326 const inner = isColumn
327 ? sizes.reduce((sum, r) => sum + r, 0) + Math.max(0, sizes.length - 1) * (cellsIn(p.rowGap) || cellsIn(p.gap))
328 : Math.max(0, ...sizes)
329 const border = typeof p.borderStyle === 'string' ? (p.borderTop === false ? 0 : 1) + (p.borderBottom === false ? 0 : 1) : 0
330 const padding = (cellsIn(p.padding) || cellsIn(p.paddingY)) * 2 + cellsIn(p.paddingTop) + cellsIn(p.paddingBottom)
331 return inner + border + padding + outer
332}
333
334// --- the stack
335
336/**
337 * This row's key in the zehui-mods stack: IMAGES 90, under NEXT 80. Spelled
338 * here rather than added to STACK below, so the block below stays the
339 * canonical copy unchanged; stackOrderOf reads the number from any
340 * `zm-<order>-<name>` key.
341 */
342export const ROW_KEY = 'zm-90-images'
343
344// Stack order of the zehui-mods band rows (plan 2026-10-20-boards-codemap-recap,
345// section 0): SYS 10, PLAN 20, HANDOFF 30, CACHE 40, AGENTS 50, GOAL 60, HERE 70,
346// NEXT 80 (and IMAGES 90, ROW_KEY above). Copied unchanged from the canonical
347// ~/.claude/skills/building-claude-code-mods/references/stack-row.tsx; change it
348// there first, then in every band mod.
349
350/** The rows each zehui-mods band draws, by stack order. */
351export const STACK = { sys: 10, plan: 20, handoff: 30, cache: 40, agents: 50, goal: 60, here: 70, next: 80 } as const
352
353/** A row's key: `zm-<order>-<name>`. */
354export const stackKey = (name: keyof typeof STACK): string => `zm-${STACK[name]}-${name}`
355
356type Loose = { type?: unknown; props?: Record<string, unknown> | null; part?: unknown; children?: unknown }
357
358/** The order of a zehui-mods row (`zm-<order>-<name>`), or null for any other node. */
359export const stackOrderOf = (node: unknown): number | null => {
360 const key = node && typeof node === 'object' ? (node as Loose).props?.key : undefined
361 const m = typeof key === 'string' ? /^zm-(\d+)-/.exec(key) : null
362 return m ? Number(m[1]) : null
363}
364
365/**
366 * The column a band hook beneath built: a Box whose only prop is
367 * `flexDirection: 'column'` and whose direct children hold a zm row. A Box with
368 * other props (a border, a padding) is another mod's own drawing, kept whole.
369 */
370const isStack = (node: Loose): node is Loose & { children: unknown[] } =>
371 node.type === 'Box' &&
372 node.props?.flexDirection === 'column' &&
373 Object.keys(node.props).every(k => k === 'flexDirection') &&
374 Array.isArray(node.children) &&
375 node.children.some(c => stackOrderOf(c) !== null)
376
377/**
378 * `row` put in its slot among the zm rows of `below` (what `next(e)` drew):
379 * zm rows sorted by their number, then every other child in its original order.
380 * Nothing beneath (null, a failed next, the engine placeholder): the row alone.
381 */
382export function stackRow(row: RenderElement, below: unknown): RenderElement {
383 const tree = below && typeof below === 'object' && (below as Loose).type !== 'engine' ? (below as Loose) : null
384 if (!tree) return row
385 let items: unknown[] = [tree]
386 if (isStack(tree)) {
387 items = [...tree.children]
388 // The column's mark (whose drawing started there) moves to the one row
389 // that carries none, its own; the engine lets a mark move, never multiply.
390 const bare = items.filter(c => c && typeof c === 'object' && !(c as Loose).part)
391 if (tree.part && bare.length === 1) items = items.map(c => (c === bare[0] ? { ...(c as object), part: tree.part } : c))
392 }
393 const rows = items.filter(c => stackOrderOf(c) !== null)
394 const others = items.filter(c => stackOrderOf(c) === null)
395 // A stable sort: two rows of one number keep their order, this row after.
396 const sorted = [...rows, row].sort((a, b) => (stackOrderOf(a) ?? 0) - (stackOrderOf(b) ?? 0))
397 return { type: 'Box', props: { flexDirection: 'column' }, children: [...sorted, ...others] as RenderNode[] }
398}
399
400/**
401 * The tile's cells drawn again at `toRows` rows, the width scaled to keep the
402 * image's shape (nearest cell, no averaging): for a band with fewer rows free
403 * than the tile is tall. Returns the tile itself when it already fits.
404 */
405export function shrinkTile(t: ImageTile, toRows: number): ImageTile {
406 if (!t.cells || t.rows <= toRows || toRows < 1) return t
407 const src = new Uint32Array(fromBase64(t.cells).buffer)
408 const columns = Math.max(1, Math.round((t.columns * toRows) / t.rows))
409 const words = new Uint32Array(columns * toRows * 3)
410 for (let y = 0; y < toRows; y++) {
411 const sy = Math.min(t.rows - 1, Math.floor((y * t.rows) / toRows))
412 for (let x = 0; x < columns; x++) {
413 const sx = Math.min(t.columns - 1, Math.floor((x * t.columns) / columns))
414 const from = (sy * t.columns + sx) * 3
415 const to = (y * columns + x) * 3
416 words[to] = src[from]!
417 words[to + 1] = src[from + 1]!
418 words[to + 2] = src[from + 2]!
419 }
420 }
421 return { ...t, columns, rows: toRows, cells: toBase64(new Uint8Array(words.buffer)) }
422}
423hooks/pipeline.ts 133 lines1// One pasted image to one tile, run from a timer (never inside a hook): wait
2// for `<n>.png` in the session's own images folder (every 250 ms, up to 5 s), turn
3// it into a small BMP with macOS's sips (3 s limit), read and parse the BMP,
4// box-average it into half-block Raster cells, and empty the BMP. Every
5// failure ends in a text-only tile; the caller writes the state.
6import type { EngineInterface } from 'claude-code'
7
8import type { ImageTile } from '../types'
9import {
10 CACHE_DIR,
11 POLL_MS,
12 SIPS_TIMEOUT_MS,
13 TMP_ROOT,
14 WAIT_MS,
15 claudeDirsOf,
16 fromBase64,
17 parseBmp,
18 parseSipsSize,
19 rasterCells,
20 sessionDirsOf,
21 sipsArgv,
22 sizeArgv,
23 textTile,
24 tileGrid,
25} from './kit'
26import type { Bitmap } from './kit'
27
28/** What one run needs: the session's id and directory, HOME, and whether the run is still wanted. */
29export type Pipeline = {
30 sessionId: string
31 cwd: string
32 home: string | undefined
33 isLive: () => boolean
34}
35
36/** The session's id, directory and HOME, read once per run. */
37export async function pipelineOf($: EngineInterface, isLive: () => boolean): Promise<Pipeline> {
38 const [sessionId, cwd, home] = await Promise.all([$.session.id(), $.session.cwd(), $.env.get('HOME')])
39 return { sessionId, cwd, home, isLive }
40}
41
42/** Resolves after `ms` on the session's clock. */
43function pause($: EngineInterface, ms: number): Promise<void> {
44 return new Promise<void>(resolve => {
45 $.clock.after(ms, () => resolve())
46 })
47}
48
49/** True when `path` is a regular file reached with no symbolic link on the way. */
50async function isPlainFile($: EngineInterface, path: string): Promise<boolean> {
51 try {
52 const stat = await $.fs.stat(path, { resolve: true })
53 return stat.kind === 'file' && !stat.isLink && stat.realPath === path
54 } catch {
55 return false
56 }
57}
58
59/**
60 * The path of `<n>.png` once it exists, looked for every POLL_MS up to WAIT_MS
61 * in the session's own `claude-<uid>/<cwd slug>/<session id>/images` folder;
62 * null when it never came or the run stopped being wanted. The hooks cannot
63 * read their uid, and /private/tmp is world-writable: the session's folder
64 * must be the only one of that name under every `claude-<uid>` (the engine
65 * makes it when the session starts, so another user's copy shows as a second
66 * one and nothing is drawn), and the PNG a plain file with no link on its path.
67 */
68export async function waitForPng($: EngineInterface, n: number, p: Pipeline): Promise<string | null> {
69 let claudeDirs: string[] = []
70 for (let waited = 0; ; waited += POLL_MS) {
71 if (!p.isLive()) return null
72 try {
73 if (claudeDirs.length === 0) claudeDirs = claudeDirsOf(await $.fs.list(TMP_ROOT))
74 const found: string[] = []
75 for (const dir of sessionDirsOf(claudeDirs, p.cwd, p.sessionId)) if (await $.fs.exists(dir)) found.push(dir)
76 if (found.length > 1) return null
77 const png = `${found[0]}/images/${n}.png`
78 if (found.length === 1 && (await isPlainFile($, png))) return png
79 } catch {
80 // Looked for again at the next poll.
81 }
82 if (waited >= WAIT_MS) return null
83 await pause($, POLL_MS)
84 }
85}
86
87/** The BMP sips wrote, parsed; null when sips failed (none, not macOS, too slow) or the file would not parse. */
88async function bitmapOf($: EngineInterface, png: string, bmp: string): Promise<Bitmap | null> {
89 try {
90 const run = await $.process.run(sipsArgv(png, bmp), { timeoutMs: SIPS_TIMEOUT_MS })
91 if (run.exitCode !== 0) return null
92 const { base64 } = await $.fs.read(bmp, { as: 'bytes' })
93 return parseBmp(fromBase64(base64))
94 } catch {
95 return null
96 } finally {
97 // The plugin API has no delete: the BMP is emptied, one zero-byte file per
98 // session and tag, also after a failed or killed sips that wrote part of it.
99 await $.fs.write(bmp, '').catch(() => {})
100 }
101}
102
103/** The PNG's own size from sips; null when not known. */
104async function sizeOf($: EngineInterface, png: string): Promise<{ w: number; h: number } | null> {
105 try {
106 const run = await $.process.run(sizeArgv(png), { timeoutMs: SIPS_TIMEOUT_MS })
107 return run.exitCode === 0 ? parseSipsSize(run.stdout) : null
108 } catch {
109 return null
110 }
111}
112
113/** `[Image #n]` of this session as a tile `size` columns wide; null when the run stopped being wanted. */
114export async function buildTile($: EngineInterface, n: number, size: number, p: Pipeline): Promise<ImageTile | null> {
115 const png = await waitForPng($, n, p)
116 if (!p.isLive()) return null
117 if (png === null || !p.home) return textTile(n)
118 const dir = `${p.home}/${CACHE_DIR}`
119 try {
120 // sips writes to a file named after a missing folder, so the folder is made first.
121 await $.fs.write(`${dir}/.keep`, '')
122 } catch {
123 return textTile(n)
124 }
125 const bitmap = await bitmapOf($, png, `${dir}/${p.sessionId}-${n}.bmp`)
126 if (!p.isLive()) return null
127 if (bitmap === null) return textTile(n)
128 const own = await sizeOf($, png)
129 if (!p.isLive()) return null
130 const grid = tileGrid(size, bitmap.width, bitmap.height)
131 return { n, w: own?.w ?? bitmap.width, h: own?.h ?? bitmap.height, ...grid, cells: rasterCells(bitmap, grid.columns, grid.rows) }
132}
133hooks/row.tsx 96 lines1// The ▣ IMAGES row above the prompt: the chip, then the last sent prompt's
2// images side by side, each a half-block Raster (never `Image`: in herdr an
3// `Image` draws only its alt text) with `[Image #n] WxH` beneath, dim; a tile
4// without a thumbnail (no sips, the file never came) is its tag alone. The
5// newest tiles that fit the width, `+n more` for the rest; captions only when
6// the tallest tile would not fit the band's height beside the rows beneath.
7// A framed `clear` Button (twin: /img clear). Hidden on a survey, off the
8// terminal, and with nothing shown. The row sits in the zehui-mods stack as
9// IMAGES 90 (stackRow), under NEXT; other mods' bands follow.
10import { atom, read } from 'claude-code'
11import type { EngineInterface, RenderElement, RenderInput } from 'claude-code'
12
13import type { ImageTile } from '../types'
14import { CHIP, CHIP_BG, CHIP_FG, ROW_KEY, captionOf, cellsOf, rowsOf, shrinkTile, stackRow, tileWidth } from './kit'
15
16const tiles = atom({ plugin: 'images', key: 'tiles' } as const, [])
17const shown = atom({ plugin: 'images', key: 'shown' } as const, false)
18
19/** What the row's button does: the same as `/img clear`. */
20export type RowActions = { clear: () => void }
21
22/** Columns between two tiles, and between the chip and the first. */
23const GAP = 1
24/** `[ clear ]`, the framed Button at the row's end. */
25const CLEAR_WIDTH = 9
26/** `+6 more`, the dim note when not all tiles fit. */
27const MORE_WIDTH = 7
28
29/** The newest tiles that fit `room` columns, oldest first, room kept for `+n more` when not all fit; at least one. */
30export function tilesThatFit(list: readonly ImageTile[], room: number): ImageTile[] {
31 const fit = (width: number): ImageTile[] => {
32 const out: ImageTile[] = []
33 let used = 0
34 for (let i = list.length - 1; i >= 0; i--) {
35 const t = list[i]!
36 const w = tileWidth(t) + (out.length > 0 ? GAP : 0)
37 if (out.length > 0 && used + w > width) break
38 out.unshift(t)
39 used += w
40 }
41 return out
42 }
43 const all = fit(room)
44 return all.length === list.length ? all : fit(room - MORE_WIDTH - GAP)
45}
46
47/** A tile drawn in fewer rows than this is no picture: captions only. */
48const MIN_TILE_ROWS = 3
49
50export async function drawRow($: EngineInterface, e: RenderInput<'AbovePrompt'>, pass: () => Promise<RenderElement>, act: RowActions) {
51 if (e.surface !== 'terminal' || e.props.hasSurvey) return pass()
52 if (!(await read($, shown))) return pass()
53 const list = await read($, tiles)
54 if (list.length === 0) return pass()
55
56 // Other mods' bands beneath this row; stackRow puts this row in its slot
57 // among the zehui-mods rows and never nests the engine's own placeholder.
58 const below = await pass().catch(() => null)
59 const cols = e.props.bodyColumns > 0 ? e.props.bodyColumns : 80
60 const room = cols - cellsOf(CHIP) - GAP - CLEAR_WIDTH - GAP
61 // The rows the band has left for a tile beside the rows beneath and the caption:
62 // a tile taller than that is drawn smaller (its shape kept); under MIN_TILE_ROWS only the captions show.
63 const free = e.props.maxRows <= 0 ? Number.POSITIVE_INFINITY : e.props.maxRows - 1 - rowsOf(below)
64 const fitted = list.map(t => (t.cells && t.rows > free ? shrinkTile(t, Math.max(0, Math.floor(free))) : t))
65 const drawn = tilesThatFit(fitted, room)
66 const more = fitted.length - drawn.length
67 const hasRoom = free >= MIN_TILE_ROWS
68 const { Box, Text, Button, Raster } = $.ui.resolve(e)
69 const row = (
70 <Box key={ROW_KEY} flexDirection="row" gap={GAP}>
71 <Box key="images-chip" flexShrink={0}>
72 <Text bold color={CHIP_FG} backgroundColor={CHIP_BG}>
73 {CHIP}
74 </Text>
75 </Box>
76 {more > 0 ? (
77 <Box key="images-more-box" flexShrink={0}>
78 <Text key="images-more" dimColor>{`+${more} more`}</Text>
79 </Box>
80 ) : null}
81 {drawn.map(t => (
82 <Box key={`images-tile-${t.n}-box`} flexDirection="column" flexShrink={0}>
83 {hasRoom && t.cells ? <Raster key={`images-tile-${t.n}`} columns={t.columns} rows={t.rows} cells={t.cells} /> : null}
84 <Text key={`images-caption-${t.n}`} dimColor wrap="truncate-end">
85 {captionOf(t)}
86 </Text>
87 </Box>
88 ))}
89 <Box key="images-clear-box" flexShrink={0}>
90 <Button key="images-clear" label="clear" role="dismiss" dimColor onPress={() => act.clear()} />
91 </Box>
92 </Box>
93 )
94 return stackRow(row, below)
95}
96types/index.d.ts 33 lines1// The images mod's state contract.
2
3/** /img on|off as kept in the store; '' while the `auto` option applies. */
4export type ImagesBand = '' | 'on' | 'off'
5
6/**
7 * One drawn image: the `[Image #n]` tag's n, the source image's size in pixels
8 * (0 by 0 when not known), and its half-block Raster: `columns` by `rows`
9 * cells, `cells` the Raster's base64 u32 triplets. A text-only tile (no sips,
10 * the file never came, a BMP that would not parse) has `cells` '' and 0 by 0.
11 */
12export type ImageTile = {
13 n: number
14 w: number
15 h: number
16 columns: number
17 rows: number
18 cells: string
19}
20
21declare module 'claude-code' {
22 interface PluginState {
23 images: {
24 /** The last tiles built, at most 6, newest last; [] after /img clear. */
25 tiles: ImageTile[]
26 /** Whether the row shows the tiles: true once a tile is built, false after the next prompt (linger), /img off and /img clear. */
27 shown: boolean
28 /** /img on|off as kept in the store; '' while the `auto` option applies. */
29 band: ImagesBand
30 }
31 }
32}
33