SLOPSHOPPER

image-thumbs

Shows pasted images as framed thumbnails under the prompt; click to expand, open in a pane, or /image

newpanerowscommandtoastprompt
v0.2.0no licenseupdated 2026-10-07ohade/claude-mods/image-thumbs
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · image-thumbs
│ ┃ image-view ✕ › fix the failing auth test and add an audit log call │ ┃ No image open. Type /image to open the │ ┃ latest one. ⏺ 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 │ │ › /image │ ⎿ image-thumbs: No pasted images yet. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · image-view
No image open. Type /image to open the latest one.
README

claude-mods

Personal customizations for Claude Code: function-hook plugins ("mods") and the status line.

Get them with git clone https://github.com/ohade/claude-mods.git; each section says how to load its mod.

image-thumbs

Shows each image you paste into a prompt as a small framed thumbnail under your message.

  • Click the picture to expand it in place, at full resolution, up to 24 rows tall. Click again to shrink it.
  • open in pane under the frame opens the image in a pane. The pane docks beside the transcript in the fullscreen layout when the terminal is at least 110 columns wide; otherwise it opens above the prompt.
  • /image [n] opens image #n, or the latest one, in the same pane.

Requirements: macOS (the mod uses sips and base64), Claude Code 2.1.289 or later with function-hook plugins, and a terminal that draws images with the kitty graphics protocol (Ghostty, kitty). Elsewhere the thumbnail shows its [Image #n] label instead.

Limits: thumbnails appear after you submit the prompt, not while you type. Images in a resumed session get no thumbnail. The decoded originals live in a private folder under $TMPDIR and are deleted when the session ends.

Load it in every session with one of:

ln -s ../../git/claude-mods/image-thumbs ~/.claude/skills/image-thumbs   # skills folder
claude --plugin-dir ~/git/claude-mods/image-thumbs                     # one session

or list the folder in CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.

Check it with claude plugin validate image-thumbs, and type-check it with tsc -p image-thumbs once Claude Code has loaded it (loading writes the API types into .claude-plugin/types/).

track

A pane beside the transcript, titled Session Tracker, that keeps substantive questions, their answers, and meaningful work. Claude decides what to register from any sender, including later content in a running turn. Doorbells and informational notifications alone need no row. Track has no transport-specific workflow.

  • Questions. The model sends each question to the pane with track_question and closes it with mark_answered (answered or deferred); a standing rule in the system prompt asks it to. A question with a verified user source links to that source. Otherwise its tracking call is the visible source. The answer update draws one dim ✓ Q<n>. answered line. An answered question turns green. The instruction explicitly includes short follow-up questions before answering; a row still requires Claude to register it.
  • Jump. The fixed [ Q ] and [ A ] columns scroll to the verified source and answer. The row you land on lights up and fades out over about two seconds: your prompt or the actual answer text alone. The acknowledgement is a separate row. [ A ] stays faded until captured answer text exists; an acknowledgement or an older answer with no saved text does not make it ready. Question and step labels use Q1. and S1.. The digits 1–9 press the jumps while the pane has focus (ctrl+x tab).
  • Layout. The title stays at the top and the banner at the bottom. Questions and Steps are fixed regions, about a third and two thirds; each scrolls on its own under the wheel, and its header counts the rows hidden above and below (↑2 ↓5). Empty sections say only "None yet." Below 40 body columns, counts use 12/39, questions place their Q/A controls below the text, and step clocks use a separate line. Short controls and hints fit a 17-column body. The banner keeps the state on one line and omits background counts when space is tight. Claude Code owns the draggable transcript/dock divider. Its 2.1.293 plugin API exposes no hover mouse-cursor control for that divider; Track cannot set a horizontal resize cursor there.
  • Banner. A full-width colored line at the bottom says where the session stands: Working, Waiting on agents (an Agent call or background agents), Waiting on tasks (background shell tasks), Waiting on you (a question dialog or an explicit waiting step), Paused, Activity unknown, Unsaved, or Idle. Use waiting only when the user must act; peer waits use paused or pending with an optional note. mark_step({ delegated: true }) identifies work owned by agents, including agents launched through other tools. It shows the brown hourglass and agents banner. Use delegated: false when the main session resumes that step. This is explicit ownership, not a peer-liveness probe. Completed steps clear it. Concurrent main work stays grey on its own row. Explicit ownership leaves agent counts unknown; only native-only activity supplies a count.
  • Steps. Filled from the model's own TaskCreate, TaskUpdate, TodoWrite and explicit track_steps calls. Successful plan approval adds one reminder to reuse open steps and register missing work. It leaves the entire register unchanged. A Task named like a plan step links to it. The step in progress shows who is on it: a spinner breathing in grey while the main session works, an amber hourglass while it waits on agents, a still purple ◆ while it waits on you.
  • Rings. ◑ 4 of 8 · 50% per section, counted over visible rows. Clear completed (c) hides finished rows and removes them from those counts.
  • Clear all. q and s empty the Questions or the Steps section; cleared open questions are withdrawn, so the model is told not to answer them. The Steps controls (s: clear all, c: clear completed) appear twice, in the Steps header and in the bottom bar, and neither moves when the steps scroll.
  • Chat plans. When the model starts work of more than one step (a skill such as /retro, a plan in chat, a multi-step task), it registers the steps with track_steps and ticks them with mark_step. Work that joins a running plan, such as review comments from Plannotator, is inserted with track_steps({ steps, after }) after the step it follows. While a managed plugin bypasses the system-prompt rule, each typed prompt, skill command and plugin prompt carries the instruction beside it; built-in commands do not. After reload, only the exact current composed instruction suppresses this fallback. A legacy boolean delivery flag is insufficient.
  • Handoffs. A handoff that clears the session and seeds a fresh one leaves the pane empty. restore_tracker({ from_session }) copies the previous session's steps back, in order, with their ids and statuses, and its questions not cleared, with new ids after this session's own. Task ids start again in each session, so a restored Task step's id gains restored: and keeps no link to the old Task. When the model marks a question answered, the mod keeps the answer's text (up to 1,000 characters), and the restore call's row in the transcript shows each restored question with its answer, its deferral note, or "(answer text was not saved)" for one answered before answers were kept. [ Q ] and [ A ] target their own question or answer inside that row. The call refuses to overwrite unrelated steps unless replace: true is passed. Repeated restoration reuses stable source identities and keeps local progress. A model call supplies its displayed restore row; a programmatic call first appends and validates a visible system snapshot. A refused or altered snapshot leaves the ledger unchanged. Repeat calls reuse its native message UUID. A matching saved snapshot receives separate keyed targets through the native InfoNotice render hook. Active target records stay while their questions are uncleared; only inactive snapshot history is capped at three. Store capacity failures remain visible.
  • Withdraw. ✕ removes a question; your next prompt tells the model not to answer it.
  • Nag. While a question is open, each prompt carries a one-line reminder; a Stop hook holds a turn once if a question the model tracked in that turn is neither answered nor deferred. Track publishes its own small gate snapshot for its ordinary command Stop hook; it does not use handoff's relay. Existing Stop blocks are preserved. A managed policy can deny or bypass plugin capabilities; such a denial stays unresolved and does not count as equivalent Stop behavior.
  • Rewind and resume. A /rewind drops the questions asked in the rewound turns. Each finished turn saves the register, and native session startup loads it for /resume. Rewind observations are coalesced and fenced to their session; compaction and capped transcript reads do not imply that older calls were rewound. A refused rewind save holds the prompt until the save can succeed. Answer jumps use event order within the question's actual tracking turn. Text before the tracking call cannot become its answer, even when wall-clock timestamps are equal. A later turn may answer an older question. A mark before text binds the next response only when one question is pending. An explicit answer_request_id must match the latest host-observed response; unknown sources are refused. An already captured answer stays unchanged. Step updates name the affected step.

It opens by itself by the built-in diff panel's rule, less the git condition: in the fullscreen layout, at least 144 columns wide, and never after you closed it by hand (ctrl+x x). A reload of the mod leaves an open pane open. /track shows or hides it at any width, and like /btw it acts at once while a turn runs and adds nothing to the session; /track status prints the open questions. To keep the diff panel out of the slot, type /diff once.

Requirements: Claude Code with function-hook plugins, macOS/POSIX file locks, and Python 3. The first compatibility target is the generated 2.1.292 API. Engine and fresh-process checks use 2.1.293. A 2.1.292 runtime run and a seated managed-policy Stop run are not available on this Mac.

Load it from the clone folder (cd claude-mods), wherever it sits, with one of:

mkdir -p ~/.claude/skills && ln -s "$PWD/track" ~/.claude/skills/track   # every session
claude --plugin-dir "$PWD/track"                                         # one session

or list the clone's track folder in CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.

Check it with claude plugin validate track, test it with claude plugin test track, and type-check it with tsc -p track once Claude Code has loaded it: track/tsconfig.json extends the API types that loading writes into track/.claude-plugin/types/.

What it saves: acknowledged tracking mutations, captured answers, explicit UI actions, completed turns, and session end write through the existing $.store API and verify the result. Save failures remain visibly Unsaved. Rendering performs no persistence. For each session the store holds:

  • the first line of each prompt you type, without image tags, cut to 200 characters, with its time; prompts that start with / are left out; the last 200 prompts;
  • each tracked question as the model summed it up, cut to 200 Unicode code points, with its status, optional note, stable source identity, and answer text (up to 1,000 code points); up to 200;
  • the title and status of each step, cut to 200 characters; the last 300.

The store budgets serialized UTF-8 conservatively below its 4 MiB limit. It prefers 20 sessions and a 3 MiB budget, pruning cleared/completed history first. Unfinished work is never silently discarded. When safe pruning cannot make room, the save returns a visible capacity failure. One process owns a session writer lease, and a shared lock serializes store writes. Stale revisions cannot overwrite a newer durable ledger. Beside the buckets it holds an index and one flag: set when you close the pane by hand, cleared when /track shows it again. Explicit pane choices use the same owned, verified save queue. A refused preference remains unsaved in reload-persistent state and is retried by the next acknowledged save. The mod makes no network calls of its own; what it tells the model, such as tool results and the open-question reminder, goes with the rest of the conversation.

Use one canonical loading path on a machine: the plugin's loading identity selects its store. For a path change, retain both legacy stores and export each with /track export <absolute-path>. Load the destination alone and use /track import <absolute-path>. Import checks the bundle checksum, rejects conflicting session records before writing, and reads back every copied record. Export and import join the save queue, require writer ownership, and reconcile pending save recovery under the shared store lock before reading records. Repeated imports are safe. Do not retire a legacy store until its exact records have been verified.

Local checkpoint/restore contract v1

mcp__track__checkpoint({ expected_session }) saves and reads back the current ledger. It returns JSON as tool-result text, with v, ok, source_session, revision, checksum, and counts: { questions, answers, steps }; failures include reason.

mcp__track__restore_tracker({ from_session, replace?, expected_checkpoint? }) validates an expected checkpoint before restoring. Its receipt also names destination_session and applied_checksum, calculated from the actual destination questions, answer text, notes, and steps in source order, including explicit delegated ownership. The optional true field participates in the checksum; absent fields preserve prior v1 checksums. A successful attempted call alone does not prove complete restoration. Source links and display ids are not authority. Track works independently of handoff.

Run claude plugin test track for engine FIXTURE checks and, from the Track root, python3 -m unittest discover -s tests/helpers -p 'test_*.py' for real helper locks and Stop parsing. Fresh-process acceptance remains separate. The test-only tests/fixtures/performance-probe plugin offers /trackbench <absolute-private-output-path> for 500 runtime-selected native tool updates; its receipts do not measure physical terminal paint. Use matching pane geometry for render comparisons.

Remove it: delete the symlink (rm ~/.claude/skills/track), or take the folder out of CLAUDE_CODE_PLUGIN_DIRS. The saved register stays behind in Claude Code's plugin store.

statusline

A two-line status line: model and effort, folder and branch, prompt-cache state, context use, and live 5-hour and weekly quota. usage-live.py reads Claude Code's OAuth credential from the macOS Keychain to fetch the quota; it never prints or stores the token.

Install it, which copies the scripts into ~/.claude and points statusLine in ~/.claude/settings.json at them after a backup:

./statusline/install.sh

statusline/README.md has the details. This folder was ohade/claude-statusline-setup, merged here with its history on 2026-10-04.

Source 4 files
hooks/register.tsx 647 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { Pasted, Shown, Thumb } from '../types'
4import { freshNumbers, imageNumbers } from './numbers.ts'
5
6const BY_IMAGE = { plugin: 'image-thumbs', key: 'byImage' } as const
7const SHOWN = { plugin: 'image-thumbs', key: 'shown' } as const
8const PASTED = { plugin: 'image-thumbs', key: 'pasted' } as const
9const EXPANDED = { plugin: 'image-thumbs', key: 'expanded' } as const
10const REPAINTS = { plugin: 'image-thumbs', key: 'repaints' } as const
11const CLAIMED = { plugin: 'image-thumbs', key: 'claimed' } as const
12const PASTED_AT = { plugin: 'image-thumbs', key: 'pastedAt' } as const
13const USED = { plugin: 'image-thumbs', key: 'used' } as const
14const SUBMITTED = { plugin: 'image-thumbs', key: 'submittedAt' } as const
15const SLASH_PENDING = { plugin: 'image-thumbs', key: 'slashPending' } as const
16const REPLY_PICTURES = { plugin: 'image-thumbs', key: 'replyPictures' } as const
17
18// A transcript row is drawn under its uuid with the last group zeroed.
19const requestIdOf = (uuid: string): string => `${uuid.slice(0, 24)}000000000000`
20
21const PANE = 'image-view'
22
23// The rows that carry a pasted picture among their blocks: a typed prompt and
24// a slash command's expansion. A skill's expansion (/recall ...) is a meta row
25// of its own by the note door; the command's row before it has the text alone.
26// A message sent mid-turn comes in by the delivery door with its text alone.
27// Most thumbnails are built at submit from the saved files; these rows build
28// the rest from their own bytes.
29const PICTURE_DOORS = ['prompt', 'command', 'attachment', 'note'] as const
30
31// Thumbnail height in terminal rows; the width follows the picture's aspect.
32const ROWS = 5
33// A terminal cell is about twice as tall as it is wide.
34const CELL_ASPECT = 2.1
35// An expanded picture's height cap in rows, so one never fills the screen.
36const EXPANDED_ROWS = 24
37// Docked beside a fullscreen transcript the pane asks for this many columns;
38// seated inline above the prompt (main screen, narrow terminal), this many rows.
39const PANE_COLUMNS = 100
40const PANE_ROWS = 40
41
42type ImageBlock = {
43  type: 'image'
44  source: { type: 'base64'; media_type?: string; data: string }
45}
46
47type Block = { type: string; [field: string]: unknown }
48
49const isImageBlock = (block: Block): block is ImageBlock => {
50  const source = block.source as { type?: string; data?: unknown } | undefined
51
52  return block.type === 'image' && source?.type === 'base64' && typeof source.data === 'string'
53}
54
55const textOf = (blocks: Block[]): string => blocks.map(block => (block.type === 'text' ? String(block.text) : '')).join('\n')
56
57// Pictures: decoding pasted bytes to disk, resizing with macOS sips, and
58// deleting what is no longer needed. They stay in this file because the
59// engine follows $ only into functions declared in the hooks module itself.
60
61// Thumbnail pixels: 240 tall covers 5 rows of a Retina cell (about 40 px)
62// with room to spare; the width cap only bites on very wide pictures.
63const THUMB_HEIGHT = 240
64const THUMB_MAX_WIDTH = 1600
65// The large view's longest side, tried in order while the PNG is over the
66// 2 MiB an Image takes as bytes.
67const VIEW_SIDES = [2048, 1600, 1200, 900]
68const MAX_PNG_BYTES = 2 * 1024 * 1024
69
70type Size = { width: number; height: number }
71
72const runOrThrow = async ($: EngineInterface, argv: string[], stdin?: string): Promise<string> => {
73  const result = await $.process.run(argv, stdin === undefined ? undefined : { stdin })
74  if (result.exitCode !== 0) {
75    throw new Error(`${argv[0]} exited ${result.exitCode}: ${result.stderr.trim()}`)
76  }
77
78  return result.stdout
79}
80
81const pixelSize = async ($: EngineInterface, path: string): Promise<Size> => {
82  const out = await runOrThrow($, ['/usr/bin/sips', '-g', 'pixelWidth', '-g', 'pixelHeight', path])
83  const width = Number(/pixelWidth: (\d+)/.exec(out)?.[1])
84  const height = Number(/pixelHeight: (\d+)/.exec(out)?.[1])
85  if (!(width > 0 && height > 0)) {
86    throw new Error(`sips gave no size for ${path}`)
87  }
88
89  return { width, height }
90}
91
92// Writes `from` as a PNG at `to`, scaled down (never up) to fit the box, with
93// macOS sips, which also reads JPEG, GIF and WebP; an Image draws PNG only.
94const writePng = async ($: EngineInterface, from: string, to: string, box: Size): Promise<Size> => {
95  const size = await pixelSize($, from)
96  const scale = Math.min(1, box.width / size.width, box.height / size.height)
97  const out = {
98    width: Math.max(1, Math.round(size.width * scale)),
99    height: Math.max(1, Math.round(size.height * scale)),
100  }
101  const resample = scale < 1 ? ['-z', String(out.height), String(out.width)] : []
102  await runOrThrow($, ['/usr/bin/sips', '-s', 'format', 'png', ...resample, from, '--out', to])
103
104  return out
105}
106
107// Reads a PNG as base64 and deletes the file. The delete is best effort: a
108// leftover stays in the private $TMPDIR folder, which macOS clears itself.
109const takePng = async ($: EngineInterface, path: string): Promise<string> => {
110  const { base64 } = await $.fs.read(path, { as: 'bytes' })
111  await $.process.run(['/bin/rm', '-f', path])
112
113  return base64
114}
115
116const folderOf = (path: string): string => path.slice(0, path.lastIndexOf('/'))
117
118// Best effort, as in takePng: the folder stays when something is left in it.
119const deleteOriginal = async ($: EngineInterface, originalPath: string): Promise<void> => {
120  await $.process.run(['/bin/rm', '-f', originalPath])
121  await $.process.run(['/bin/rmdir', folderOf(originalPath)])
122}
123
124// Where a picture's bytes are: base64 from a row, or the file Claude Code
125// saved when it was pasted.
126type Source = { data: string } | { file: string }
127
128// Copies one pasted image into a private folder of its own under $TMPDIR,
129// kept for the session so the large view can be drawn from the original,
130// and returns its thumbnail; undefined, with the reason in the debug log.
131const makeThumb = async ($: EngineInterface, source: Source, n: number): Promise<Thumb | undefined> => {
132  const created = await $.process.run(['/usr/bin/mktemp', '-d', '-t', 'claude-image-thumbs'])
133  const dir = created.stdout.trim()
134  if (created.exitCode !== 0 || dir === '') {
135    $.ui.log(`image-thumbs: no folder for image #${n}: mktemp exited ${created.exitCode}`, { to: 'debug' })
136
137    return undefined
138  }
139  const originalPath = `${dir}/original`
140
141  try {
142    if ('file' in source) {
143      await runOrThrow($, ['/bin/cp', source.file, originalPath])
144    } else {
145      await runOrThrow($, ['/usr/bin/base64', '-D', '-o', originalPath], source.data)
146    }
147    const thumbPath = `${dir}/thumb.png`
148    const size = await writePng($, originalPath, thumbPath, { width: THUMB_MAX_WIDTH, height: THUMB_HEIGHT })
149
150    return { png: await takePng($, thumbPath), ...size, n, originalPath }
151  } catch (error) {
152    $.ui.log(`image-thumbs: no thumbnail for image #${n}: ${String(error)}`, { to: 'debug' })
153    await deleteOriginal($, originalPath)
154
155    return undefined
156  }
157}
158
159// The original as the largest PNG under 2 MiB that VIEW_SIDES allows.
160const largeView = async ($: EngineInterface, originalPath: string, n: number): Promise<Shown> => {
161  const viewPath = `${folderOf(originalPath)}/view.png`
162  for (const side of VIEW_SIDES) {
163    const size = await writePng($, originalPath, viewPath, { width: side, height: side })
164    const { size: bytes } = await $.fs.stat(viewPath)
165    if (bytes <= MAX_PNG_BYTES) {
166      return { png: await takePng($, viewPath), ...size, n }
167    }
168  }
169  await $.process.run(['/bin/rm', '-f', viewPath])
170  throw new Error(`image #${n} is over ${MAX_PNG_BYTES} bytes even at ${VIEW_SIDES.at(-1)} px`)
171}
172
173const openImage = async ($: EngineInterface, n: number, originalPath: string): Promise<void> => {
174  try {
175    await $.state.set(SHOWN, await largeView($, originalPath, n))
176  } catch (error) {
177    $.ui.toast(`image-thumbs: cannot open image #${n}: ${String(error)}`)
178
179    return
180  }
181  const opened = await $.ui.open({
182    id: PANE,
183    title: `Image #${n}`,
184    focus: true,
185    closeOnEscape: true,
186    columns: PANE_COLUMNS,
187    rows: PANE_ROWS,
188  })
189  if (!opened.isPlaced) {
190    $.ui.toast(`image-thumbs: the image pane did not open: ${opened.reason}`)
191  }
192}
193
194const isThumb = (thumb: Thumb | undefined): thumb is Thumb => thumb !== undefined
195
196const isStored = async ($: EngineInterface, n: number): Promise<boolean> =>
197  (await $.state.get({ ...BY_IMAGE, id: String(n) })).value !== undefined
198
199const keepThumbs = async ($: EngineInterface, thumbs: Thumb[]): Promise<void> => {
200  if (thumbs.length === 0) {
201    return
202  }
203  await Promise.all(thumbs.map(thumb => $.state.set({ ...BY_IMAGE, id: String(thumb.n) }, thumb)))
204  const { value: pasted = [] } = await $.state.get(PASTED)
205  const added: Pasted[] = thumbs.map(({ n, originalPath }) => ({ n, originalPath }))
206  await $.state.set(PASTED, [...pasted, ...added])
207}
208
209// A text's own pictures: the numbers it names past the newest one claimed.
210const freshOf = async ($: EngineInterface, text: string): Promise<number[]> => {
211  const { value: claimed = 0 } = await $.state.get(CLAIMED)
212
213  return freshNumbers(text, claimed)
214}
215
216const claim = async ($: EngineInterface, numbers: number[]): Promise<void> => {
217  const { value: claimed = 0 } = await $.state.get(CLAIMED)
218  await $.state.set(CLAIMED, Math.max(claimed, ...numbers))
219}
220
221// Builds and stores the thumbnails of a row's own pictures not built yet,
222// matched to `images` in order.
223const storeThumbs = async ($: EngineInterface, text: string, images: ImageBlock[]): Promise<void> => {
224  const numbers = (await freshOf($, text)).slice(0, images.length)
225  const made = await Promise.all(
226    numbers.map(async (n, index) =>
227      (await isStored($, n)) ? undefined : makeThumb($, { data: images[index]?.source.data ?? '' }, n),
228    ),
229  )
230  await keepThumbs($, made.filter(isThumb))
231  await claim($, numbers)
232}
233
234// Claude Code saves each pasted picture when it is pasted, as
235// $TMPDIR/clipboard-YYYY-MM-DD-HHMMSS-<id>.png (local time). A slash
236// command's row, and a message sent mid-turn, are printed before any row
237// carrying the picture's bytes, and a printed row is not drawn again; so when
238// a prompt is submitted its thumbnails are built from these files.
239const SAVED_NAME = /\/clipboard-(\d{4})-(\d{2})-(\d{2})-(\d{2})(\d{2})(\d{2})-[0-9A-Za-z]+\.\w+$/
240// How far a file's name time may be from its paste: the name keeps whole
241// seconds, and the file is written as the paste lands.
242const PASTE_SLACK_MS = 3000
243
244type Saved = { path: string; at: number }
245
246const savedAt = (path: string): number | undefined => {
247  const parts = SAVED_NAME.exec(path)?.slice(1).map(Number)
248  if (parts === undefined) {
249    return undefined
250  }
251  const [year = 0, month = 1, day = 1, hours = 0, minutes = 0, seconds = 0] = parts
252
253  return new Date(year, month - 1, day, hours, minutes, seconds).getTime()
254}
255
256// The pictures Claude Code saved in the last two hours, oldest first.
257const savedPictures = async ($: EngineInterface): Promise<Saved[]> => {
258  const dir = (await runOrThrow($, ['/usr/bin/getconf', 'DARWIN_USER_TEMP_DIR'])).trim().replace(/\/$/, '')
259  const found = await runOrThrow($, ['/usr/bin/find', dir, '-maxdepth', '1', '-name', 'clipboard-*', '-mmin', '-120'])
260
261  return found
262    .split('\n')
263    .flatMap(path => {
264      const at = savedAt(path)
265
266      return at === undefined ? [] : [{ path, at }]
267    })
268    .sort((one, other) => one.at - other.at)
269}
270
271// The saved file of each wanted picture: the one saved nearest its paste,
272// when the paste was seen. Pictures left over take the files saved since the
273// last prompt only when there are exactly as many: one more may be another
274// session's paste, and drawing it here would show the wrong picture.
275const pickFiles = (pastedAt: (number | undefined)[], saved: Saved[], since: number): (string | undefined)[] => {
276  const taken = new Set<string>()
277  const picked = pastedAt.map(at => {
278    if (at === undefined) {
279      return undefined
280    }
281    const [nearest] = saved
282      .filter(file => !taken.has(file.path) && Math.abs(file.at - at) <= PASTE_SLACK_MS)
283      .sort((one, other) => Math.abs(one.at - at) - Math.abs(other.at - at))
284    if (nearest !== undefined) {
285      taken.add(nearest.path)
286    }
287
288    return nearest?.path
289  })
290  const missing = picked.flatMap((path, index) => (path === undefined ? [index] : []))
291  const sinceLast = saved.filter(file => !taken.has(file.path) && file.at >= since - PASTE_SLACK_MS)
292  if (missing.length > 0 && sinceLast.length === missing.length) {
293    missing.forEach((index, order) => {
294      picked[index] = sinceLast[order]?.path
295    })
296  }
297
298  return picked
299}
300
301// A picture pasted from the clipboard is saved under the session's own folder
302// as /private/tmp/claude-<uid>/<project>/<session id>/images/<n>.png, by its
303// number; a picture pasted as a file (a screenshot tool's) is not, and is
304// matched to a clipboard-* file instead. Undefined when there is no folder.
305const sessionImages = async ($: EngineInterface): Promise<string | undefined> => {
306  const uid = (await runOrThrow($, ['/usr/bin/id', '-u'])).trim()
307  const id = await $.session.id()
308  const found = await runOrThrow($, ['/usr/bin/find', `/private/tmp/claude-${uid}`, '-maxdepth', '2', '-type', 'd', '-name', id])
309  const [folder] = found.split('\n').filter(line => line !== '')
310
311  return folder === undefined ? undefined : `${folder}/images`
312}
313
314const isFile = async ($: EngineInterface, path: string): Promise<boolean> => {
315  try {
316    return (await $.fs.stat(path)).kind === 'file'
317  } catch {
318    return false
319  }
320}
321
322// At submit, before any row of the prompt exists: builds the thumbnails of
323// the pictures `text` names that are not built yet, from their saved files.
324const storeSavedThumbs = async ($: EngineInterface, text: string): Promise<void> => {
325  const submittedAt = Date.now()
326  const { value: since = 0 } = await $.state.get(SUBMITTED)
327  await $.state.set(SUBMITTED, submittedAt)
328  const numbers = await freshOf($, text)
329  const wanted = (await Promise.all(numbers.map(async n => ((await isStored($, n)) ? [] : [n])))).flat()
330  if (wanted.length === 0) {
331    return
332  }
333  const images = await sessionImages($).catch((error: unknown) => {
334    $.ui.log(`image-thumbs: no session folder found: ${String(error)}`, { to: 'debug' })
335
336    return undefined
337  })
338  const inFolder = await Promise.all(
339    wanted.map(async n => {
340      const path = images === undefined ? undefined : `${images}/${n}.png`
341
342      return path !== undefined && (await isFile($, path)) ? path : undefined
343    }),
344  )
345  let saved: Saved[] = []
346  if (inFolder.includes(undefined)) {
347    try {
348      saved = await savedPictures($)
349    } catch (error) {
350      $.ui.log(`image-thumbs: no saved pictures listed: ${String(error)}`, { to: 'debug' })
351    }
352  }
353  const { value: used = [] } = await $.state.get(USED)
354  // The numbers without a file in the folder, matched to clipboard-* files.
355  const needing = wanted.flatMap((_, index) => (inFolder[index] === undefined ? [index] : []))
356  const pastedAt = await Promise.all(
357    needing.map(async index => (await $.state.get({ ...PASTED_AT, id: String(wanted[index]) })).value),
358  )
359  const matched = pickFiles(
360    pastedAt,
361    saved.filter(file => !used.includes(file.path)),
362    since,
363  )
364  const files = [...inFolder]
365  needing.forEach((index, order) => {
366    files[index] = matched[order]
367  })
368  const made = await Promise.all(
369    wanted.map((n, index) => {
370      const file = files[index]
371
372      return file === undefined ? undefined : makeThumb($, { file }, n)
373    }),
374  )
375  const thumbs = made.filter(isThumb)
376  await keepThumbs($, thumbs)
377  await $.state.set(USED, [...used, ...files.filter((file): file is string => file !== undefined)])
378  // Claimed only when every picture was built: a row then builds the rest
379  // from its own bytes, where it carries them.
380  if (thumbs.length === wanted.length) {
381    await claim($, numbers)
382  }
383}
384
385// The largest box of cells inside `room` that keeps the picture's aspect.
386const fitCells = (size: Size, room: { columns: number; rows: number }): { columns: number; rows: number } => {
387  const columns = Math.min(room.columns, Math.round((room.rows * CELL_ASPECT * size.width) / size.height), 255)
388  const rows = Math.min(room.rows, Math.round((columns * size.height) / size.width / CELL_ASPECT), 255)
389
390  return { columns: Math.max(2, columns), rows: Math.max(1, rows) }
391}
392
393export const register: Register = on => {
394  on('session.start', async ($, e, next) => {
395    // A pane asked for by code below 144 columns waits unseen; drop any such
396    // leftover so it does not appear later when the terminal widens.
397    await $.ui.close({ id: PANE })
398    await $.command.register({
399      name: 'image',
400      description: 'Open a pasted image large in a pane',
401      argumentHint: '[image number]',
402    })
403
404    return next(e)
405  })
406
407  // Build the thumbnails before the row is stored, so the row's first drawing
408  // already has them: a row printed to the terminal's scrollback is not redrawn.
409  on('session.append', { door: PICTURE_DOORS }, async ($, e, next) => {
410    const images = e.message.content.filter(isImageBlock)
411    if (images.length === 0) {
412      return next(e)
413    }
414    // Claude Code's own numbers, in order, from the row's [Image #n] tags.
415    await storeThumbs($, textOf(e.message.content), images)
416
417    return next(e)
418  })
419
420  // A paste puts [Image #n] in the prompt box: when it landed tells which
421  // saved file is that picture's.
422  on('prompt.edit', async ($, e, next) => {
423    const numbers = imageNumbers(e.inputText)
424    if (numbers.length > 0) {
425      const at = Date.now()
426      await Promise.all(numbers.map(n => $.state.set({ ...PASTED_AT, id: String(n) }, at)))
427    }
428
429    return next(e)
430  })
431
432  // A prompt typed at the prompt or sent while Claude works, before its rows
433  // exist.
434  on('prompt.submit', async ($, e, next) => {
435    if (e.attachments?.some(attachment => attachment.type === 'image') === true) {
436      await storeSavedThumbs($, e.text)
437    }
438
439    return next(e)
440  })
441
442  // A slash command's pictures are in its arguments. The engine draws the
443  // command's own row with no render hook, so they wait for the first row of
444  // the reply that has text, and are drawn above it.
445  on('command.run', async ($, e, next) => {
446    const named = imageNumbers(e.args)
447    if (named.length > 0) {
448      await storeSavedThumbs($, e.args)
449      const stored = (await Promise.all(named.map(async n => ((await isStored($, n)) ? [n] : [])))).flat()
450      await $.state.set(SLASH_PENDING, stored)
451    }
452
453    return next(e)
454  })
455
456  on('session.append', { door: 'response' }, async ($, e, next) => {
457    const { value: pending = [] } = await $.state.get(SLASH_PENDING)
458    if (pending.length > 0 && e.message.content.some(block => block.type === 'text')) {
459      await $.state.set({ ...REPLY_PICTURES, id: requestIdOf(e.uuid) }, pending)
460      await $.state.set(SLASH_PENDING, [])
461    }
462
463    return next(e)
464  })
465
466  // A reply with no text leaves nothing to draw them on; the next turn's
467  // reply is not theirs.
468  on('turn.complete', async ($, e, next) => {
469    if (e.agentId === undefined) {
470      await $.state.set(SLASH_PENDING, [])
471    }
472
473    return next(e)
474  })
475
476  // A /clear ends the session too, and the next one may number its pictures
477  // from #1 again.
478  on('session.end', async ($, e, next) => {
479    const { value: pasted = [] } = await $.state.get(PASTED)
480    await Promise.all(pasted.map(image => deleteOriginal($, image.originalPath)))
481    await $.state.set(PASTED, [])
482
483    return next(e)
484  })
485
486  on('command.run', { command: 'image' }, async ($, e) => {
487    const { value: pasted = [] } = await $.state.get(PASTED)
488    const asked = e.args.trim().replace(/^#/, '')
489    const image = asked === '' ? pasted.at(-1) : pasted.find(one => String(one.n) === asked)
490    if (image === undefined) {
491      const known = pasted.map(one => `#${one.n}`).join(', ')
492
493      return { text: known === '' ? 'No pasted images yet.' : `No image #${asked}. Pasted so far: ${known}.` }
494    }
495    await openImage($, image.n, image.originalPath)
496
497    return { text: `Opened image #${image.n}.` }
498  })
499
500  // A click on a picture, posted by its click area (click-area.ts), expands
501  // the thumbnail in place or shrinks it back. In place rather than in a
502  // pane: a click a Client posts counts as code, and the engine seats a pane
503  // code opens only from 144 columns.
504  on('ui.message', async ($, e, next) => {
505    const data = e.data as { toggle?: unknown; repaint?: unknown } | null
506    // A click area asking, after its first drawing, for its picture to be
507    // drawn again under a new key (click-area.ts).
508    if (typeof data?.repaint === 'number') {
509      const id = String(data.repaint)
510      const { value: repaints = 0 } = await $.state.get({ ...REPAINTS, id })
511      await $.state.set({ ...REPAINTS, id }, repaints + 1)
512
513      return {}
514    }
515    if (typeof data?.toggle !== 'number') {
516      return next(e)
517    }
518    const n = data.toggle
519    const id = String(n)
520    const { value: expandedPicture } = await $.state.get({ ...EXPANDED, id })
521    if (expandedPicture !== undefined && expandedPicture !== null) {
522      await $.state.set({ ...EXPANDED, id }, null)
523
524      return {}
525    }
526    const { value: thumb } = await $.state.get({ ...BY_IMAGE, id })
527    if (thumb === undefined) {
528      return {}
529    }
530    try {
531      await $.state.set({ ...EXPANDED, id }, await largeView($, thumb.originalPath, n))
532    } catch (error) {
533      $.ui.toast(`image-thumbs: cannot expand image #${n}: ${String(error)}`)
534    }
535
536    return {}
537  })
538
539  on('ui.render', { component: ['UserMessage', 'AssistantMessage'] }, async ($, e, next) => {
540    if (e.surface !== 'terminal') {
541      return next(e)
542    }
543    // A prompt's row by the numbers it names, not by the row: a message sent
544    // mid-turn is drawn under an id other than the row its pictures came in
545    // on. A reply's row by the slash command pictures tied to it.
546    const isReply = e.component === 'AssistantMessage'
547    const numbers = isReply
548      ? ((await $.state.get({ ...REPLY_PICTURES, id: e.requestId })).value ?? [])
549      : imageNumbers(e.props.text)
550    if (numbers.length === 0) {
551      return next(e)
552    }
553    const stored = await Promise.all(numbers.map(async n => (await $.state.get({ ...BY_IMAGE, id: String(n) })).value))
554    const thumbs = stored.filter((thumb): thumb is Thumb => thumb !== undefined)
555    if (thumbs.length === 0) {
556      return next(e)
557    }
558    const expanded = await Promise.all(
559      thumbs.map(async thumb => (await $.state.get({ ...EXPANDED, id: String(thumb.n) })).value ?? null),
560    )
561    const repaints = await Promise.all(
562      thumbs.map(async thumb => (await $.state.get({ ...REPAINTS, id: String(thumb.n) })).value ?? 0),
563    )
564    const row = await next(e)
565    const { Box, Button, Client, Image, Text } = $.ui.resolve(e)
566    // Less the row's indent and the frame's two border columns.
567    const columns = (e.viewport?.columns ?? 80) - 6
568    const room = { columns, rows: ROWS }
569    const roomExpanded = { columns, rows: Math.max(ROWS, Math.min(EXPANDED_ROWS, (e.viewport?.rows ?? 40) - 10)) }
570
571    const pictures = (
572      <Box flexDirection="row" flexWrap="wrap" columnGap={2} marginLeft={2}>
573        {thumbs.map((thumb, index) => {
574          const { n, originalPath } = thumb
575          const expandedPicture = expanded[index] ?? null
576          const cells = expandedPicture === null ? fitCells(thumb, room) : fitCells(expandedPicture, roomExpanded)
577          const png = expandedPicture === null ? thumb.png : expandedPicture.png
578          // Drawn again under a new key shortly after the first drawing: a new
579          // image id, sent again with its cells written again, as a window
580          // resize does for every picture. The first drawing can stay blank.
581          const drawing = repaints[index] ?? 0
582
583          return (
584            <Box flexDirection="column" alignItems="flex-start">
585              <Box borderStyle="round" borderDimColor>
586                <Box>
587                  <Image key={`thumb-${n}-${drawing}`} source={{ png }} {...cells} alt={`[Image #${n}]`} />
588                  <Box position="absolute" top={0} left={0}>
589                    <Client
590                      key={`click-${n}`}
591                      module="./click-area.ts"
592                      props={{ n }}
593                      width={cells.columns}
594                      height={cells.rows}
595                    />
596                  </Box>
597                </Box>
598              </Box>
599              <Box flexDirection="row">
600                <Text dimColor>
601                  #{n} · click the picture to {expandedPicture === null ? 'expand' : 'shrink'} ·{' '}
602                </Text>
603                <Button
604                  key={`open-${n}`}
605                  label="open in pane"
606                  plain
607                  dimColor
608                  onPress={() => openImage($, n, originalPath)}
609                />
610              </Box>
611            </Box>
612          )
613        })}
614      </Box>
615    )
616
617    // Under a prompt; above a reply, whose text follows from the pictures.
618    return (
619      <Box flexDirection="column">
620        {isReply && pictures}
621        {row}
622        {!isReply && pictures}
623      </Box>
624    )
625  })
626
627  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
628    const { value: shown } = await $.state.get(SHOWN)
629    if (e.surface !== 'terminal' || shown === undefined || shown === null) {
630      const { Text } = $.ui.resolve(e)
631
632      return <Text dimColor>No image open. Type /image to open the latest one.</Text>
633    }
634    const { Box, Image, Text } = $.ui.resolve(e)
635    const room = { columns: e.props.bodyColumns, rows: Math.max(2, e.props.scroll.bodyRows - 1) }
636
637    return (
638      <Box flexDirection="column">
639        <Image key="view" source={{ png: shown.png }} {...fitCells(shown, room)} alt={`[Image #${shown.n}]`} />
640        <Text dimColor>
641          {shown.width}×{shown.height} px · Esc closes
642        </Text>
643      </Box>
644    )
645  })
646}
647
hooks/numbers.ts 19 lines
1// Claude Code numbers the pictures pasted in a session (#1, #2, ...) and writes
2// [Image #n] where each one sits in the prompt's text. The tag is what ties a
3// picture to the rows that show it: the typed prompt, a slash command's row
4// (its pictures arrive on the command's expansion, a row of their own), and a
5// message sent while Claude works (they arrive on the attachment that folds it
6// into the turn).
7const TAG = /\[Image #(\d+)\]/g
8
9// The picture numbers a text names, each once, in the order first written.
10export const imageNumbers = (text: string): number[] => [
11  ...new Set([...text.matchAll(TAG)].map(match => Number(match[1]))),
12]
13
14// The numbers of a row's own pictures, in order: those its text names past
15// `last`, the newest number seen before. A number at or below it refers to an
16// earlier picture again. A picture the text names nowhere (a file the prompt
17// attached) has none.
18export const freshNumbers = (text: string, last: number): number[] => imageNumbers(text).filter(n => n > last)
19
hooks/click-area.ts 34 lines
1import type { ClientModule } from 'claude-code'
2
3// How long after a thumbnail first draws its click area asks for it to be
4// drawn again.
5const REPAINT_MS = 300
6
7// Lies over a picture in a prompt row and draws nothing, so the picture
8// beneath shows, and tells the hooks module the number of the picture a left
9// click landed on.
10//
11// On a new thumbnail's first drawing cmux's terminal (Ghostty 1.3.2) can leave
12// the box empty until a window resize, which sends every picture again and
13// writes its cells again. Changing cells beside it, or sending it again under
14// the same id, does not; so shortly after, the click area asks for it to be
15// drawn again under a new key, which does both for this picture alone.
16const ClickArea: ClientModule<{ n: number }, { isRepaintAsked: true }> = (props, surface) => {
17  if (surface.state === undefined) {
18    surface.setState({ isRepaintAsked: true })
19    const stop = surface.every(REPAINT_MS, () => {
20      stop()
21      surface.post({ repaint: props.n })
22    })
23  }
24  surface.onPointer(event => {
25    if (event.type === 'up' && event.button === 'left') {
26      surface.post({ toggle: props.n })
27    }
28  })
29
30  return surface.elements.Box({})
31}
32
33export default ClickArea
34
types/index.d.ts 44 lines
1// One pasted picture's thumbnail, under Claude Code's number for it.
2export type Thumb = {
3  png: string
4  width: number
5  height: number
6  n: number
7  // The decoded original on disk, for the large view.
8  originalPath: string
9}
10
11// The picture the image pane draws.
12export type Shown = { png: string; width: number; height: number; n: number }
13
14// Every image pasted this session, for `/image <n>`: Claude Code's number
15// and the decoded original on disk.
16export type Pasted = { n: number; originalPath: string }
17
18declare module 'claude-code' {
19  interface PluginState {
20    'image-thumbs': {
21      // Thumbnails by image number: every row whose text names it draws it.
22      byImage: StateFamily<Thumb>
23      // How many times each thumbnail asked to be drawn again; part of its key.
24      repaints: StateFamily<number>
25      shown: Shown | null
26      // The large picture a thumbnail expanded into, by image number.
27      expanded: StateFamily<Shown | null>
28      pasted: Pasted[]
29      // The newest picture number a row or a submitted prompt took as its own.
30      claimed: number
31      // When each picture's [Image #n] landed in the prompt box, in ms.
32      pastedAt: StateFamily<number>
33      // The saved clipboard files thumbnails were built from.
34      used: string[]
35      // When the last prompt or slash command was submitted, in ms.
36      submittedAt: number
37      // A slash command's pictures, waiting for the first reply row with text.
38      slashPending: number[]
39      // The slash command pictures drawn above a reply row, by its request id.
40      replyPictures: StateFamily<number[]>
41    }
42  }
43}
44