SLOPSHOPPER

images

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…

newbandcommandtoastpromptprocess
v0.2.1MITupdated 2026-10-20ZH-L1N/agent-window-mods/images
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · images
› fix the failing auth test and add an audit log call ⏺ 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 ⎿ images: No image tiles yet: send a prompt with a pasted image, or /img <n> for [Image #n] of this session. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

images

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

What it does

  • 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:
  • Wait for <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.
  • The BMP is read and parsed (24 or 32 bits, BI_RGB or BI_BITFIELDS, bottom-up or top-down, rows padded to 4 bytes), box-averaged to 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.
  • The BMP is emptied (the plugin API has no delete; one zero-byte file per session and tag stays in ~/.cache/zehui-mods/images/).
  • At most 6 tiles are kept, newest last. Tiles sit side by side; when not all fit the width, the newest that fit and +n more. When the tallest tile and its caption do not fit the band's height beside the other bands, only the captions show.
  • No sips (not macOS, or sips fails), the file never came, or the BMP would not parse: the tile is its [Image #n] tag as text only.
  • Drawn with Raster half-block cells only, never Image (in herdr an Image draws only its alt text). Terminal only; hidden while a survey is up.
  • Stack: the row is keyed 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.

Why not before you send

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

Options

OptionValuesDefaultWhat it does
size12, 16, 24, 3224Tile width in columns; the height follows the image, at most 24 rows.
autoon, offonDraw a sent prompt's images on their own. /img on and /img off override it.
lingerturn, 2min, until-clearedturnturn: 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.

Commands

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

ButtonCommand
clear/img clear

Install (local)

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.

Notes

  • No network, no model calls. The only processes are sips (convert, and read the size), each with a 3-second limit.
  • Reads HOME for the scratch folder; with no HOME, tiles are text only.

See PROVENANCE.md for where it came from.

Source 5 files
hooks/register.tsx 244 lines
1// 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}
244
hooks/kit.ts 423 lines
1// 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}
423
hooks/pipeline.ts 133 lines
1// 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}
133
hooks/row.tsx 96 lines
1// 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}
96
types/index.d.ts 33 lines
1// 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