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

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.
Shows each image you paste into a prompt as a small framed thumbnail under your message.
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/).
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.
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.[ 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).↑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.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.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.◑ 4 of 8 · 50% per section, counted over visible rows. Clear completed (c) hides finished rows and removes them from those counts.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./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.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.✕ removes a question; your next prompt tells the model not to answer it./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:
/ are left out; the last 200 prompts;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.
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.
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.
hooks/register.tsx 647 lines1import 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}
647hooks/numbers.ts 19 lines1// 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)
19hooks/click-area.ts 34 lines1import 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
34types/index.d.ts 44 lines1// 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