SLOPSHOPPER

shot-inline

Draws each PNG or JPG the model saves or reads under its tool row: pixels in kitty and Ghostty, quadrant block cells in every other terminal.

newrowsguardcommandprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · shot-inline
› 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 › /shot-inline ⎿ shot-inline: on; 0 picture(s) this session; this terminal draws the picture itself ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

shot-inline

The model takes a screenshot or reads a picture, draws a conclusion from it, and you see only a file path. To check what it looked at you open the file yourself. This mod draws each PNG or JPG the model saves or reads under its tool row, so you see the screenshot the model looked at without opening the file.

What it does

  1. The mod watches three kinds of tool call and takes the image path from each:
  2. a Playwright browser_take_screenshot: the file its result links to, a relative link read against the directory the session started in, where the Playwright server writes it, also after a Bash cd;
  3. a Read of a .png, .jpg or .jpeg file;
  4. a Bash command that names such a file, when the file exists after the command (the last one named first).
  5. A PNG is measured from its header. A PNG over 4 MiB, and a JPG, are measured with sips.
  6. A JPG is copied once to $TMPDIR/shot-inline/<hash>.png with sips -s format png, because the terminal draws PNG only. The hash covers the path and the modification time.
  7. The tool row draws the picture under itself, in the picture's shape: at most 80 columns wide (fewer when the terminal is narrower, and one column per 8 pixels for a small picture, so it is not stretched) and 24 rows tall. The terminal reads the file itself; no pixel crosses the engine.
  8. A terminal with the kitty graphics protocol (kitty, Ghostty, read from TERM, TERM_PROGRAM and KITTY_WINDOW_ID) draws the pixels themselves. Every other terminal draws the same box as block cells: sips writes a BMP of exactly the pixels the cells hold, and the mod reads its rows. By default a cell is a quadrant character (▘, ▞, ▐, ▙ and the rest) over two by two pixels: the cell's pixels are split at the middle of the colour channel that spreads widest, the brighter side is drawn in its mean colour and the darker side is the background. That is twice the pixels of a half block across, at the cost of two colours per four pixels. /shot-inline glyphs half goes back to half blocks (▀), two pixels a cell, each in its own colour. The BMP is made once per picture and pixel size. The cells of each picture are kept for the newest box it was drawn in, so a resize replaces them, and they go with the picture once the newest 200 pictures push it out.

iTerm2 has an inline image protocol of its own, and the engine does not use it, so iTerm2 takes the block-cell path as well. The protocol is chosen inside the engine's Image element, so no mod can change it. Only the terminal surface draws a picture.

The finer sextant (2 by 3) and octant (2 by 4) characters cannot be used: they lie beyond the Basic Multilingual Plane, and Raster refuses them (measured on 2.1.283: cell 42 holds code point 118089, beyond the Basic Multilingual Plane; the engine drew its own).

In the live check a Read of a PNG and of a JPG each drew under its row, the JPG through a sips copy, with no tree refused in the debug log. In tmux, which has no kitty protocol, the same Read drew 24 rows of half-block cells in 23 foreground and 18 background colours. On 2.1.283 a 320 by 200 test picture drew as 40 by 13 quadrant cells in tmux, with no tree refused.

Command

/shot-inline on or off, the pictures of this session, and the cells in use /shot-inline on | off on by default /shot-inline glyphs half | quadrant the cells of a terminal without the kitty protocol; quadrant by default

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install shot-inline@kilimcininkoroglu-mods

Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.

After installing

  1. Use kitty or Ghostty for the picture itself. iTerm2, the VS Code terminal, Terminal.app, Windows Terminal and conhost get the block cells, because the engine sends the kitty protocol only.
  2. sips is part of macOS, and both the JPG copy and the block cells need it. Elsewhere a PNG up to 4 MiB still draws in kitty and Ghostty, and every other path logs a picture was not drawn: ... once.
  3. Restart Claude Code.

What it can reach

Validated with claude plugin validate on Claude Code 2.1.283:

❯ ./register.tsx hooks: session.start, command.run{command=shot-inline}, tool.call{tool=Read}, tool.call{tool=Bash}, tool.call{tool=/"^mcp__(plugin_playwright_)?playwright__browser_take_screenshot$"/}, ui.render{component=ToolUse} ❯ ./register.tsx calls: $.command.register, $.env.get, $.fs.exists (via bmpCopy, pngCopy, prepare), $.fs.read (via gridFor, measure), $.fs.stat (via prepare), $.process.run (via sips, tempDir), $.session.cwd, $.store.get (via readSettings), $.store.set (via runCommand, setGlyphs), $.ui.invalidate (via readSettings, remember, runCommand, setGlyphs), $.ui.log (via report), $.ui.resolve ❯ ./register.tsx env writes: nothing ❯ ./register.tsx env reads: KITTY_WINDOW_ID, TERM, TERM_PROGRAM, TMPDIR

Reach L2, runs processes and writes files.

  1. Reads: the input of Read and Bash calls, the Playwright screenshot result, the header of each image file named, the pixels of the BMP it wrote itself, and TERM, TERM_PROGRAM, KITTY_WINDOW_ID and TMPDIR
  2. Runs: sips (size, JPG to PNG, the BMP of the cells) and mkdir -p, by argv
  3. Sends: nothing to the model; nothing leaves the machine
  4. Persists: PNG copies of JPGs and the BMPs of the drawn boxes under $TMPDIR/shot-inline, and the on/off and glyphs settings in $.store
  5. Hostile input: a path comes from the model's command text; it reaches sips as one argv item, never through a shell, and only after the file is found to exist

Limits

  • The pictures live in memory: a resumed session draws its old rows without them.
  • A Bash command that writes an image under a name it builds at run time (a variable, a glob) is not seen.
  • A path starting with ~ is not expanded.
  • The copies under $TMPDIR/shot-inline are not deleted by the mod; the system clears the temp directory.
  • A quadrant cell holds four pixels in two colours, so an 80 by 24 box is 160 by 48 pixels; a half-block cell holds two in their own colours, 80 by 48. The picture is recognizable, not sharp.
  • The terminal's colour depth is the engine's to pick: in tmux it wrote 256-colour codes, not 24-bit ones.
  • The block-cell path needs sips, so it is macOS only. The kitty path needs no process for a PNG.

Development

make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test

Source 2 files
hooks/register.tsx 245 lines
1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2import { absolute, allBytes, blockCells, bmpName, cells, commandImagePaths, copyName, GLYPHS, hasGraphics, isGlyphs, isImagePath, isPng, pngSize, readBmp, screenshotPath, sipsSize, type Glyphs, type Size } from './shot.ts'
3
4const ENABLED_KEY = 'enabled'
5const GLYPHS_KEY = 'glyphs'
6
7/** Quadrants hold twice the pixels of a half block across, in two colours a cell. */
8const DEFAULT_GLYPHS: Glyphs = 'quadrant'
9
10const USAGE = 'expects nothing (the status), on, off, or glyphs half | quadrant'
11
12/** The Playwright screenshot tool, as a plugin install and as a plain MCP server name it. */
13const SCREENSHOT_TOOL = /^mcp__(plugin_playwright_)?playwright__browser_take_screenshot$/
14
15/** `$.fs.read` refuses a larger file, so a larger PNG is measured with sips. */
16const MAX_READ_BYTES = 4 * 1024 * 1024
17
18/** The newest pictures kept; an older row draws without its picture. */
19const MAX_SHOTS = 200
20
21/** A picture ready to draw: the PNG the terminal reads, its pixel size, and the path the model named. */
22type Shot = { png: string; size: Size; source: string; stamp: number }
23
24/** The box of cells a picture is drawn in. */
25type Box = { columns: number; rows: number }
26
27type State = {
28  shots: Map<string, Shot>
29  enabled: boolean
30  /**
31   * The block cells of each picture at the newest box it was drawn in, keyed by the tool row: one
32   * grid per picture, dropped with its picture, so a resize replaces a grid instead of adding one.
33   */
34  grids: Map<string, { box: string; grid: string }>
35  /** The directory the session started in, read before a Bash `cd` can move it. */
36  root: string
37  /** The terminal takes the kitty graphics protocol, so the picture itself is drawn. */
38  graphics: boolean
39  /** The block characters of a terminal without it. */
40  glyphs: Glyphs
41  lastError?: string
42}
43
44function errorText(err: unknown): string {
45  return err instanceof Error ? err.message : String(err)
46}
47
48/** Logs an error once until a different one comes. */
49function report($: EngineInterface, state: State, err: unknown): void {
50  const text = errorText(err)
51  if (text !== state.lastError) $.ui.log(`a picture was not drawn: ${text}`)
52  state.lastError = text
53}
54
55async function sips($: EngineInterface, argv: string[]): Promise<string> {
56  const r = await $.process.run(['sips', ...argv], { timeoutMs: 20_000 })
57  if (r.exitCode !== 0) throw new Error(`sips failed: ${(r.stderr || r.stdout).trim().slice(0, 200)}`)
58  return r.stdout
59}
60
61async function measure($: EngineInterface, path: string, bytes: number): Promise<Size> {
62  const size = isPng(path) && bytes <= MAX_READ_BYTES
63    ? pngSize((await $.fs.read(path, { as: 'bytes' })).base64)
64    : sipsSize(await sips($, ['-g', 'pixelWidth', '-g', 'pixelHeight', path]))
65  if (size === undefined) throw new Error(`${path} has no readable picture size`)
66  return size
67}
68
69/** A PNG copy of a JPG under the temp directory, made once per path and modification time. */
70async function pngCopy($: EngineInterface, path: string, mtimeMs: number): Promise<string> {
71  const out = `${await tempDir($)}/${copyName(path, mtimeMs)}`
72  if (await $.fs.exists(out)) return out
73  await sips($, ['-s', 'format', 'png', path, '--out', out])
74  return out
75}
76
77/** The picture for an image file, or undefined when the path is not a file. */
78async function prepare($: EngineInterface, path: string): Promise<Shot | undefined> {
79  if (!(await $.fs.exists(path))) return undefined
80  const st = await $.fs.stat(path)
81  if (st.kind !== 'file') return undefined
82  const size = await measure($, path, st.size ?? 0)
83  const png = isPng(path) ? path : await pngCopy($, path, st.mtimeMs)
84  return { png, size, source: path, stamp: st.mtimeMs }
85}
86
87/** The temp directory the mod writes its copies into. */
88async function tempDir($: EngineInterface): Promise<string> {
89  const dir = `${((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/+$/, '')}/shot-inline`
90  const made = await $.process.run(['mkdir', '-p', dir], { timeoutMs: 5_000 })
91  if (made.exitCode !== 0) throw new Error(`mkdir ${dir} failed: ${made.stderr.trim()}`)
92  return dir
93}
94
95/** A BMP of the picture with exactly the pixels the box's cells hold, made once per picture and size. */
96async function bmpCopy($: EngineInterface, shot: Shot, box: Box, glyphs: Glyphs): Promise<string> {
97  const width = box.columns * GLYPHS[glyphs].across
98  const height = box.rows * GLYPHS[glyphs].down
99  const out = `${await tempDir($)}/${bmpName(shot.png, shot.stamp, width, height)}`
100  if (await $.fs.exists(out)) return out
101  await sips($, ['-z', String(height), String(width), '-s', 'format', 'bmp', shot.png, '--out', out])
102  return out
103}
104
105/** The block cells for one picture at one box, kept while the picture is and until the box or the glyphs change. */
106async function gridFor($: EngineInterface, state: State, id: string, shot: Shot, box: Box, key: string): Promise<string | undefined> {
107  const kept = state.grids.get(id)
108  if (kept?.box === key) return kept.grid
109  try {
110    const bmp = readBmp(allBytes((await $.fs.read(await bmpCopy($, shot, box, state.glyphs), { as: 'bytes' })).base64))
111    if (bmp === undefined) throw new Error(`${shot.source}: sips wrote a BMP this reader does not take`)
112    const grid = blockCells(bmp, box.columns, box.rows, state.glyphs)
113    state.grids.set(id, { box: key, grid })
114    return grid
115  } catch (err) {
116    // A redraw drops the dispatch under it, so the work of the old one is not a failure.
117    if (!/\baborted\b/.test(errorText(err))) report($, state, err)
118    return undefined
119  }
120}
121
122/**
123 * Keeps the picture for a tool row and redraws the rows. A relative path is read against `base`: the
124 * directory the tool itself ran in.
125 */
126async function remember($: EngineInterface, state: State, id: string, paths: readonly string[], base: string): Promise<void> {
127  try {
128    for (const path of paths) {
129      const shot = await prepare($, absolute(path, base))
130      if (shot === undefined) continue
131      state.shots.set(id, shot)
132      if (state.shots.size > MAX_SHOTS) {
133        const oldest = state.shots.keys().next().value ?? ''
134        state.shots.delete(oldest)
135        state.grids.delete(oldest)
136      }
137      $.ui.invalidate('ui.render')
138      return
139    }
140  } catch (err) {
141    report($, state, err)
142  }
143}
144
145const answered = (r: ToolCallResult): boolean => r.deny === undefined && r.isError !== true
146
147/** Stores the block characters and redraws; every grid is made again for the new family. */
148async function setGlyphs($: EngineInterface, state: State, word: string): Promise<string> {
149  if (!isGlyphs(word)) return `glyphs expects half or quadrant; now ${state.glyphs}`
150  await $.store.set(GLYPHS_KEY, word)
151  state.glyphs = word
152  $.ui.invalidate('ui.render')
153  const { across, down } = GLYPHS[word]
154  return `glyphs ${word}: ${across}x${down} pixels a cell on a terminal without the kitty graphics protocol`
155}
156
157/**
158 * Reads the on/off setting and the block characters from the store, which every window shares, so a
159 * change made in another window applies here at the next hook that acts on it. A row is drawn with what
160 * its own tool call read, because `ui.render` runs on every redraw and never reads the store; a changed
161 * setting redraws the rows, as `on`, `off` and `glyphs` do. Answers whether the mod is on.
162 */
163async function readSettings($: EngineInterface, state: State): Promise<boolean> {
164  const was = `${state.enabled}:${state.glyphs}`
165  state.enabled = (await $.store.get(ENABLED_KEY)) !== false
166  const glyphs = await $.store.get(GLYPHS_KEY)
167  state.glyphs = isGlyphs(glyphs) ? glyphs : DEFAULT_GLYPHS
168  if (`${state.enabled}:${state.glyphs}` !== was) $.ui.invalidate('ui.render')
169  return state.enabled
170}
171
172async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
173  const word = args.trim()
174  await readSettings($, state)
175  if (word === 'on' || word === 'off') {
176    await $.store.set(ENABLED_KEY, word === 'on')
177    state.enabled = word === 'on'
178    $.ui.invalidate('ui.render')
179    return word === 'on' ? 'on: saved and read pictures draw under their tool row' : 'off: no picture is drawn'
180  }
181  if (word.startsWith('glyphs')) return setGlyphs($, state, word.slice('glyphs'.length).trim())
182  const how = state.graphics ? 'this terminal draws the picture itself' : `this terminal has no kitty graphics protocol, so a picture is drawn as ${state.glyphs} block cells`
183  return word === '' ? `${state.enabled ? 'on' : 'off'}; ${state.shots.size} picture(s) this session; ${how}` : USAGE
184}
185
186export const register: Register = on => {
187  const state: State = { shots: new Map(), enabled: true, grids: new Map(), root: '', graphics: false, glyphs: DEFAULT_GLYPHS }
188
189  on('session.start', async ($, e, next) => {
190    const r = await next(e)
191    await $.command.register({ name: 'shot-inline', description: 'Pictures under their tool row: status, on, off, glyphs (shot-inline)', argumentHint: '[on | off | glyphs half|quadrant]' })
192    await readSettings($, state)
193    state.root = await $.session.cwd()
194    state.graphics = hasGraphics((await $.env.get('TERM')) ?? '', (await $.env.get('TERM_PROGRAM')) ?? '', (await $.env.get('KITTY_WINDOW_ID')) ?? '')
195    return r
196  })
197
198  // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
199  on('command.run', { command: 'shot-inline' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
200
201  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
202    const r = await next(e)
203    if (!answered(r) || !isImagePath(e.file_path) || !(await readSettings($, state))) return r
204    await remember($, state, e.tool_use_id, [e.file_path], await $.session.cwd())
205    return r
206  })
207
208  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
209    const r = await next(e)
210    const paths = answered(r) ? commandImagePaths(e.command) : []
211    if (paths.length === 0 || !(await readSettings($, state))) return r
212    // A Bash command runs where its shell stands, which a `cd` moves, so its paths read against that.
213    await remember($, state, e.tool_use_id, paths, await $.session.cwd())
214    return r
215  })
216
217  on('tool.call', { tool: SCREENSHOT_TOOL }, async ($, e, next) => {
218    const r = await next(e)
219    const path = answered(r) ? screenshotPath(r.text ?? '') : undefined
220    if (path === undefined || !(await readSettings($, state))) return r
221    // The Playwright server writes a relative path under the directory it started in, the session's own,
222    // which a Bash `cd` does not move.
223    await remember($, state, e.tool_use_id, [path], state.root)
224    return r
225  })
226
227  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
228    const shot = state.enabled && e.surface === 'terminal' ? state.shots.get(e.requestId) : undefined
229    if (shot === undefined || e.surface !== 'terminal') return next(e)
230    const drawn = await next(e)
231    const { Box, Image, Raster } = $.ui.resolve(e)
232    const box = cells(shot.size, Math.min(80, Math.max(10, (e.viewport?.columns ?? 84) - 4)))
233    const key = `${e.requestId}:${box.columns}x${box.rows}:${state.glyphs}`
234    const grid = state.graphics ? undefined : await gridFor($, state, e.requestId, shot, box, key)
235    return (
236      <Box flexDirection="column">
237        {drawn}
238        {grid === undefined
239          ? <Image source={{ file: shot.png, format: 'png' }} columns={box.columns} rows={box.rows} alt={`picture: ${shot.source}`} />
240          : <Raster key={key} columns={box.columns} rows={box.rows} cells={grid} />}
241      </Box>
242    )
243  })
244}
245
hooks/shot.ts 264 lines
1/** Which image a tool call saved or read, its pixel size, and the box of cells it is drawn in. */
2
3const IMAGE = /\.(png|jpe?g)$/i
4
5export const isImagePath = (path: string): boolean => IMAGE.test(path)
6
7export const isPng = (path: string): boolean => /\.png$/i.test(path)
8
9/** The file a Playwright screenshot result links to: `[Screenshot of viewport](.playwright-mcp/page.png)`. */
10export function screenshotPath(result: string): string | undefined {
11  return /\]\(([^)\s]+\.(?:png|jpe?g))\)/i.exec(result)?.[1]
12}
13
14/** The image paths a shell command names, the last first, because a command usually writes its output last. */
15export function commandImagePaths(command: string): string[] {
16  const quotedOrBare = /(["'])([^"']+?\.(?:png|jpe?g))\1|(?:^|[\s=])([^\s'"|;&<>()=]+\.(?:png|jpe?g))(?=$|[\s|;&<>)])/gi
17  const paths = [...command.matchAll(quotedOrBare)].map(m => m[2] ?? m[3] ?? '')
18  return [...new Set(paths.reverse())].filter(p => p !== '')
19}
20
21/** `path` as an absolute path; `~` is left as it is, so it fails the existence check instead of guessing a home. */
22export function absolute(path: string, cwd: string): string {
23  return path.startsWith('/') || path.startsWith('~') ? path : `${cwd.replace(/\/+$/, '')}/${path.replace(/^\.\//, '')}`
24}
25
26const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
27
28/** The first bytes of a base64 string; a module has no Node Buffer. */
29export function headBytes(base64: string, count: number): number[] {
30  const bytes: number[] = []
31  let bits = 0
32  let value = 0
33  for (const ch of base64) {
34    const n = B64.indexOf(ch)
35    if (n < 0 || bytes.length >= count) break
36    value = (value << 6) | n
37    bits += 6
38    if (bits >= 8) {
39      bits -= 8
40      bytes.push((value >> bits) & 0xff)
41    }
42  }
43  return bytes
44}
45
46/** Every byte of a base64 string. */
47export function allBytes(text: string): Uint8Array {
48  const out = new Uint8Array(Math.floor((text.length * 3) / 4))
49  let at = 0
50  let bits = 0
51  let value = 0
52  for (const ch of text) {
53    const n = B64.indexOf(ch)
54    if (n < 0) continue
55    value = ((value << 6) | n) >>> 0
56    bits += 6
57    if (bits < 8) continue
58    bits -= 8
59    out[at++] = (value >> bits) & 0xff
60  }
61  return out.subarray(0, at)
62}
63
64export type Size = { width: number; height: number }
65
66const u32 = (b: number[], at: number): number => (((b[at] ?? 0) << 24) >>> 0) + ((b[at + 1] ?? 0) << 16) + ((b[at + 2] ?? 0) << 8) + (b[at + 3] ?? 0)
67
68/** The size in a PNG's IHDR chunk, or undefined for bytes that are not a PNG. */
69export function pngSize(base64: string): Size | undefined {
70  const b = headBytes(base64, 24)
71  const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
72  if (!signature.every((v, i) => b[i] === v) || String.fromCharCode(...b.slice(12, 16)) !== 'IHDR') return undefined
73  const size = { width: u32(b, 16), height: u32(b, 20) }
74  return size.width > 0 && size.height > 0 ? size : undefined
75}
76
77/** The size in `sips -g pixelWidth -g pixelHeight` output. */
78export function sipsSize(stdout: string): Size | undefined {
79  const width = Number(/pixelWidth:\s*(\d+)/.exec(stdout)?.[1] ?? 0)
80  const height = Number(/pixelHeight:\s*(\d+)/.exec(stdout)?.[1] ?? 0)
81  return width > 0 && height > 0 ? { width, height } : undefined
82}
83
84/** The tallest picture, in rows, so a screenshot does not fill the screen. */
85export const MAX_ROWS = 24
86
87/** Pixels per cell across; a cell is about twice as tall as it is wide. */
88const PX_PER_COLUMN = 8
89
90/** The box of cells that keeps the picture's shape, at most `maxColumns` wide and MAX_ROWS tall. */
91export function cells(size: Size, maxColumns: number): { columns: number; rows: number } {
92  const fit = Math.max(1, Math.min(maxColumns, Math.round(size.width / PX_PER_COLUMN)))
93  const rows = Math.max(1, Math.round((fit * size.height) / size.width / 2))
94  if (rows <= MAX_ROWS) return { columns: fit, rows }
95  return { columns: Math.max(1, Math.round((MAX_ROWS * 2 * size.width) / size.height)), rows: MAX_ROWS }
96}
97
98/** A name for the PNG copy of a JPG, from its path and modification time, FNV-1a 32. */
99export function copyName(path: string, mtimeMs: number): string {
100  return `${hash(`${path}\0${mtimeMs}`)}.png`
101}
102
103/** A name for the BMP of one picture at one size in pixels. */
104export function bmpName(path: string, mtimeMs: number, width: number, height: number): string {
105  return `${hash(`${path}\0${mtimeMs}\0${width}x${height}`)}.bmp`
106}
107
108function hash(text: string): string {
109  let h = 0x811c9dc5
110  for (const ch of text) h = Math.imul(h ^ (ch.codePointAt(0) ?? 0), 0x01000193) >>> 0
111  return h.toString(16).padStart(8, '0')
112}
113
114/** The terminal draws a picture only with the kitty graphics protocol (kitty, Ghostty). */
115export function hasGraphics(term: string, termProgram: string, kittyWindow: string): boolean {
116  if (kittyWindow !== '') return true
117  const name = `${term} ${termProgram}`.toLowerCase()
118  return name.includes('kitty') || name.includes('ghostty')
119}
120
121/** Upper half block: the cell's top pixel is its foreground, its bottom pixel its background. */
122const UPPER_HALF = 0x2580
123
124/** A BMP as `sips -s format bmp` writes one: 24 or 32 bits a pixel, uncompressed, BGR(A). */
125export type Bitmap = { width: number; height: number; offset: number; topDown: boolean; step: number; stride: number; bytes: Uint8Array }
126
127const u32le = (b: Uint8Array, at: number): number => (((b[at] ?? 0) | ((b[at + 1] ?? 0) << 8) | ((b[at + 2] ?? 0) << 16) | ((b[at + 3] ?? 0) << 24)) >>> 0)
128
129/** The header of an uncompressed BMP, or undefined for bytes this reader does not take. */
130export function readBmp(bytes: Uint8Array): Bitmap | undefined {
131  const bpp = (bytes[28] ?? 0) | ((bytes[29] ?? 0) << 8)
132  if (bytes[0] !== 0x42 || bytes[1] !== 0x4d || (bpp !== 24 && bpp !== 32)) return undefined
133  const signedHeight = u32le(bytes, 22) | 0
134  const width = u32le(bytes, 18)
135  const step = bpp / 8
136  if (width < 1 || signedHeight === 0) return undefined
137  return { width, height: Math.abs(signedHeight), offset: u32le(bytes, 10), topDown: signedHeight < 0, step, stride: Math.ceil((width * step) / 4) * 4, bytes }
138}
139
140/** One pixel as `0x00RRGGBB`; a point outside the picture reads its nearest edge. */
141export function pixel(bmp: Bitmap, x: number, y: number): number {
142  const col = Math.min(Math.max(x, 0), bmp.width - 1)
143  const row = Math.min(Math.max(y, 0), bmp.height - 1)
144  const at = bmp.offset + (bmp.topDown ? row : bmp.height - 1 - row) * bmp.stride + col * bmp.step
145  return ((bmp.bytes[at + 2] ?? 0) << 16) | ((bmp.bytes[at + 1] ?? 0) << 8) | (bmp.bytes[at] ?? 0)
146}
147
148/** The picture as `columns * rows` half-block cells, packed as `RasterProps.cells` takes them. */
149export function halfBlocks(bmp: Bitmap, columns: number, rows: number): string {
150  const words = new Uint32Array(columns * rows * 3)
151  for (let y = 0; y < rows; y++) {
152    for (let x = 0; x < columns; x++) {
153      const at = (y * columns + x) * 3
154      words[at] = UPPER_HALF
155      words[at + 1] = pixel(bmp, x, y * 2)
156      words[at + 2] = pixel(bmp, x, y * 2 + 1)
157    }
158  }
159  return base64(new Uint8Array(words.buffer))
160}
161
162/**
163 * The block characters a cell is drawn with, by how finely they split it. The finer sextant (U+1FB00)
164 * and octant (U+1CD00) families lie beyond the Basic Multilingual Plane, which `Raster` refuses
165 * (measured on 2.1.283: "holds code point 118089, beyond the Basic Multilingual Plane").
166 */
167export type Glyphs = 'half' | 'quadrant'
168
169/** Pixels one cell holds across and down for each family. */
170export const GLYPHS: Readonly<Record<Glyphs, { across: number; down: number }>> = {
171  half: { across: 1, down: 2 },
172  quadrant: { across: 2, down: 2 },
173}
174
175export const isGlyphs = (value: unknown): value is Glyphs => typeof value === 'string' && Object.hasOwn(GLYPHS, value)
176
177/** Quadrant characters by mask: bit 0 upper left, 1 upper right, 2 lower left, 3 lower right. */
178const QUADRANTS = [
179  0x20, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b, 0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f, 0x2588,
180]
181
182/** The quadrant character for a mask of lit pixels. */
183export function quadrant(mask: number): number {
184  return QUADRANTS[mask] ?? 0x20
185}
186
187const channel = (color: number, k: number): number => (color >> (16 - k * 8)) & 0xff
188
189/** The channel (0 red, 1 green, 2 blue) whose values spread widest, with its lowest and highest value. */
190function widest(pixels: readonly number[]): { k: number; low: number; high: number } {
191  let best = { k: 0, low: 0, high: 0 }
192  for (let k = 0; k < 3; k++) {
193    const values = pixels.map(p => channel(p, k))
194    const low = Math.min(...values)
195    const high = Math.max(...values)
196    if (high - low > best.high - best.low) best = { k, low, high }
197  }
198  return best
199}
200
201/** The mean colour of some pixels, `0x00RRGGBB`. */
202function mean(pixels: readonly number[]): number {
203  if (pixels.length === 0) return 0
204  const sum = [0, 1, 2].map(k => pixels.reduce((s, p) => s + channel(p, k), 0) / pixels.length)
205  return (Math.round(sum[0] ?? 0) << 16) | (Math.round(sum[1] ?? 0) << 8) | Math.round(sum[2] ?? 0)
206}
207
208/**
209 * One cell's pixels as two colours: split at the middle of the channel that spreads widest, the higher
210 * side lit in the glyph's colour, the lower the background. A cell of one colour lights nothing.
211 */
212export function splitCell(pixels: readonly number[]): { mask: number; fg: number; bg: number } {
213  const { k, low, high } = widest(pixels)
214  const middle = (low + high) / 2
215  let mask = 0
216  pixels.forEach((p, i) => {
217    if (high > low && channel(p, k) > middle) mask |= 1 << i
218  })
219  const lit = pixels.filter((_, i) => (mask >> i) & 1)
220  const dark = pixels.filter((_, i) => !((mask >> i) & 1))
221  return { mask, fg: lit.length === 0 ? mean(dark) : mean(lit), bg: mean(dark) }
222}
223
224/** The pixels of one cell in reading order. */
225function cellPixels(bmp: Bitmap, x: number, y: number, across: number, down: number): number[] {
226  const out: number[] = []
227  for (let dy = 0; dy < down; dy++) {
228    for (let dx = 0; dx < across; dx++) out.push(pixel(bmp, x * across + dx, y * down + dy))
229  }
230  return out
231}
232
233/**
234 * The picture as `columns * rows` cells of one glyph family, packed as `RasterProps.cells` takes them;
235 * the BMP holds `across` by `down` pixels a cell. A half block keeps each pixel's own colour; a quadrant
236 * cell's four pixels share two.
237 */
238export function blockCells(bmp: Bitmap, columns: number, rows: number, glyphs: Glyphs): string {
239  if (glyphs === 'half') return halfBlocks(bmp, columns, rows)
240  const { across, down } = GLYPHS[glyphs]
241  const words = new Uint32Array(columns * rows * 3)
242  for (let y = 0; y < rows; y++) {
243    for (let x = 0; x < columns; x++) {
244      const cell = splitCell(cellPixels(bmp, x, y, across, down))
245      words.set([quadrant(cell.mask), cell.fg, cell.bg], (y * columns + x) * 3)
246    }
247  }
248  return base64(new Uint8Array(words.buffer))
249}
250
251/** Standard padded base64 of the bytes; a module has no Node Buffer. */
252export function base64(bytes: Uint8Array): string {
253  let out = ''
254  for (let i = 0; i < bytes.length; i += 3) {
255    const a = bytes[i] ?? 0
256    const b = bytes[i + 1]
257    const c = bytes[i + 2]
258    const word = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
259    const digit = (shift: number): string => B64[(word >> shift) & 63] ?? ''
260    out += digit(18) + digit(12) + (b === undefined ? '=' : digit(6)) + (c === undefined ? '=' : digit(0))
261  }
262  return out
263}
264