SLOPSHOPPER

inline-images

Shows images inline in the Claude Code terminal: pasted images under your prompt and above the prompt box, and the images Claude reads, writes or screenshots…

newbandrowstoastpromptprocess
v0.1.0MITupdated 2026-10-07samuelpatro/claude-code-inline-images
A shopper browsing a rack in a slop shop
README

inline-images

A Claude Code mod that shows images right in the terminal chat.

  • Pasted images show under the prompt that carried them, in place of a bare [Image #N].
  • While you type, images you pasted but have not sent yet show above the prompt box.
  • Images Claude works on (a file it reads or writes, a screenshot a command saves) show under that tool call. A collapsed group like Read 3 files shows them side by side.

Each picture sits in a thin frame with its name, its size and an Open button. Click a picture to open it in the split beside Claude in cmux (a new browser split on the right if there is none). Open hands it to your default image app.

Requirements

  • Claude Code 2.1.287 or later (mods are on by default)
  • macOS: images are sized and converted with the built-in sips
  • A terminal that speaks the kitty graphics protocol: Ghostty, kitty, or cmux

Claude Code only draws pictures in terminals it recognizes by name. In cmux (and other Ghostty-based terminals) turn them on in ~/.claude/settings.json, then start a new session:

{ "env": { "CLAUDE_CODE_FORCE_TERMINAL_IMAGES": "1" } }

Without it, each picture shows its alt text, such as [#3].

Install

In Claude Code:

/plugin marketplace add samuelpatro/claude-code-inline-images
/plugin install inline-images@claude-code-inline-images

Or load a clone for one session:

git clone https://github.com/samuelpatro/claude-code-inline-images.git
claude --plugin-dir ./claude-code-inline-images

How it works

  • Claude Code saves every pasted image as <tmp>/claude-<uid>/<project>/<session>/images/<n>.png. The mod reads those files, so pasted images keep their full quality.
  • The mod checks that folder twice a second for images newer than the last one you sent, and shows them above the prompt box. Mods do not hear prompt box edits in every session, so a timer is the reliable way.
  • Tool images are found by any image path in a tool call's input (.png, .jpg, .gif, .webp, .heic, .tiff, .bmp). An image shows only under the first row that names it.
  • Every picture is drawn from a copy at most 480 px on its long side, made once with sips and kept in /private/tmp/inline-images-cache. The terminal never has to scale a full-size screenshot while you scroll.

Size limits are the SINGLE, MULTI and PREVIEW values at the top of hooks/register.tsx, in terminal cells.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .

License

MIT

Source 2 files
hooks/register.tsx 377 lines
1import type { ElementTable, EngineInterface, Register, RenderElement } from 'claude-code'
2
3/** An image to show: its own file, the small PNG drawn for it, its size in pixels. */
4type Picture = { path: string; thumb: string; width: number; height: number }
5type Shown = { label: string; picture: Picture }
6type Cells = { columns: number; rows: number }
7
8const MARKER = /\[Image #(\d+)\]/g
9const IMAGE_PATH = /\/[^\s'"`<>|;]+\.(?:png|jpe?g|gif|webp|heic|tiff?|bmp)\b/gi
10const CMUX = '/Applications/cmux.app/Contents/Resources/bin/cmux'
11const CACHE = '/private/tmp/inline-images-cache'
12
13// Terminal cells are about twice as tall as they are wide, and ~8 px wide.
14const CELL_ASPECT = 0.5
15const CELL_PX = 8
16// Thumbnails are drawn from a copy at most this many pixels on the long side,
17// so the terminal never scales a full-size screenshot on a redraw.
18const THUMB_PX = 480
19// Size caps for a picture, in cells: alone in a row, several side by side, and
20// the preview above the prompt box. The frame adds a cell on each side.
21const SINGLE: Cells = { columns: 32, rows: 8 }
22const MULTI: Cells = { columns: 22, rows: 6 }
23const PREVIEW: Cells = { columns: 18, rows: 5 }
24const GAP = 2
25// The narrowest caption: room for a size and the Open button.
26const MIN_TILE = 18
27const MAX_IMAGES = 6
28const POLL_MS = 500
29const SEARCH_MS = 5000
30
31// Pictures by image path, or null when the file is gone or cannot be drawn.
32// Module state: a reload of the mod starts it over.
33const pictures = new Map<string, Picture | null>()
34const loading = new Set<string>()
35// This session's paste folder, the image numbers in the prompt box, and the
36// highest number already sent (-1 until the first check).
37let pasteFolder = ''
38let draft: number[] = []
39let maxSent = -1
40let lastSearch = 0
41// The first tool row that named each image file: later rows naming it again
42// (a screenshot, then a Read of it) show nothing more.
43const firstRow = new Map<string, string>()
44
45export const register: Register = on => {
46  // Pasted images not sent yet show above the prompt box. Claude Code saves a
47  // paste at once, and mods do not hear prompt-box edits in every session, so
48  // the paste folder is checked on a timer.
49  on('session.start', async ($, e, next) => {
50    const result = await next(e)
51    $.clock.every(POLL_MS, () => void poll($))
52    return result
53  })
54
55  on('prompt.submit', async ($, e, next) => {
56    const result = await next(e)
57    markSent($, markersOf(e.text))
58    return result
59  })
60
61  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
62    const folder = pasteFolder
63    const shown = folder ? draft.flatMap(n => shownOf(`${folder}/${n}.png`, `#${n}`)) : []
64    if (e.surface !== 'terminal' || e.props.hasSurvey || shown.length === 0) return next(e)
65    const els = $.ui.resolve(e)
66    const limit = { columns: PREVIEW.columns, rows: Math.max(2, Math.min(PREVIEW.rows, e.props.maxRows - 3)) }
67    return (
68      <els.Box flexDirection="row" columnGap={GAP} paddingLeft={2}>
69        {shown.map((item, i) => (
70          <els.Box key={`draft-${i}`} flexDirection="column">
71            {framed(els, item, cellsOf(item.picture, limit), `draft-${i}`)}
72            <els.Text dimColor>{item.label}</els.Text>
73          </els.Box>
74        ))}
75      </els.Box>
76    )
77  })
78
79  // Your prompt rows: the pasted pictures under the text.
80  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
81    const row = await next(e)
82    if (e.surface !== 'terminal') return row
83    const markers = markersOf(e.props.text)
84    if (markers.length === 0) return row
85    markSent($, markers)
86    const folder = pasteFolder || (await pasteFolderOf($))
87    if (!folder) return row
88    const paths = markers.map(n => `${folder}/${n}.png`)
89    request($, paths)
90    const shown = markers.flatMap(n => shownOf(`${folder}/${n}.png`, `#${n}`))
91    return gallery($.ui.resolve(e), row, shown, e.viewport?.columns, actionsOf($))
92  })
93
94  // A tool call that named an image file (Claude read it, wrote it, or took a
95  // screenshot into it): the pictures under its row.
96  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
97    const row = await next(e)
98    if (e.surface !== 'terminal' || e.props.isRunning) return row
99    const shown = toolImages($, [e.props.input], e.requestId)
100    return gallery($.ui.resolve(e), row, shown, e.viewport?.columns, actionsOf($))
101  })
102
103  // A collapsed group (`Read 3 files`): the pictures of all its calls under its line.
104  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
105    const row = await next(e)
106    if (e.surface !== 'terminal' || e.props.isExpanded || e.props.isActive) return row
107    const shown = toolImages($, e.props.calls.map(call => call.input), e.requestId)
108    return gallery($.ui.resolve(e), row, shown, e.viewport?.columns, actionsOf($))
109  })
110
111  // A click on a picture opens it in a cmux preview tab.
112  on('ui.message', async ($, e) => {
113    const path = (e.data as { path?: unknown } | undefined)?.path
114    if (typeof e.element === 'string' && e.element.startsWith('hit-') && typeof path === 'string') {
115      void openFile($, path, true)
116    }
117    return {}
118  })
119}
120
121/** What a picture's buttons do, made in each hook from its own `$`. */
122type Actions = { open: (path: string) => void }
123
124function actionsOf($: EngineInterface): Actions {
125  return { open: path => void openFile($, path, false) }
126}
127
128/** A picture in a thin dim frame, with a clear click layer laid over it. */
129function framed(els: ElementTable<'terminal'>, item: Shown, box: Cells, key: string): RenderElement {
130  const { Box, Client, Image } = els
131  return (
132    <Box key={`frame-${key}`} borderStyle="round" borderDimColor width={box.columns + 2} height={box.rows + 2}>
133      <Image source={{ file: item.picture.thumb, format: 'png' }} alt={`[${item.label}]`} {...box} />
134      <Box position="absolute" top={0} left={0}>
135        <Client key={`hit-${key}`} module="./click.ts" width={box.columns} height={box.rows} props={{ path: item.picture.path }} />
136      </Box>
137    </Box>
138  )
139}
140
141/**
142 * The row with its pictures under it, side by side in tiles of one width:
143 * each picture framed, its name, then its size and an Open button.
144 */
145function gallery(
146  els: ElementTable<'terminal'>,
147  row: RenderElement,
148  shown: Shown[],
149  viewportColumns: number | undefined,
150  actions: Actions,
151): RenderElement {
152  if (shown.length === 0) return row
153  const { Box, Button, Text } = els
154  const width = (viewportColumns ?? 100) - 4
155  const visible = shown.slice(0, MAX_IMAGES)
156  const isSingle = visible.length === 1
157  const limit = isSingle ? SINGLE : MULTI
158  const fit = { columns: Math.min(limit.columns, width - 2), rows: limit.rows }
159  return (
160    <Box flexDirection="column">
161      {row}
162      <Box flexDirection="row" flexWrap="wrap" columnGap={GAP} rowGap={1} marginLeft={2} marginTop={1}>
163        {visible.map((item, i) => {
164          const box = cellsOf(item.picture, fit)
165          const tile = Math.max(MIN_TILE, (isSingle ? box.columns : fit.columns) + 2)
166          return (
167            <Box key={`image-${i}`} flexDirection="column" width={tile}>
168              <Box width={tile} height={(isSingle ? box.rows : fit.rows) + 2} alignItems="flex-end">
169                {framed(els, item, box, `image-${i}`)}
170              </Box>
171              <Text dimColor wrap="truncate-middle">{item.label}</Text>
172              <Box columnGap={2}>
173                <Text dimColor>{`${item.picture.width}×${item.picture.height}`}</Text>
174                <Button key={`open-${i}`} label="Open" plain dimColor onPress={() => actions.open(item.picture.path)} />
175              </Box>
176            </Box>
177          )
178        })}
179        {shown.length > visible.length && <Text dimColor>{`+${shown.length - visible.length}`}</Text>}
180      </Box>
181    </Box>
182  )
183}
184
185function shownOf(path: string, label: string): Shown[] {
186  const picture = pictures.get(path)
187  return picture ? [{ label, picture }] : []
188}
189
190/** The image files named anywhere in these tool inputs, as far as they are loaded. */
191function toolImages($: EngineInterface, inputs: unknown[], requestId: string): Shown[] {
192  const named = [...new Set(inputs.flatMap(input => stringsIn(input).flatMap(text => text.match(IMAGE_PATH) ?? [])))]
193  for (const path of named) if (!firstRow.has(path)) firstRow.set(path, requestId)
194  const paths = named.filter(path => firstRow.get(path) === requestId)
195  request($, paths)
196  return paths.flatMap(path => shownOf(path, path.slice(path.lastIndexOf('/') + 1)))
197}
198
199function stringsIn(value: unknown): string[] {
200  if (typeof value === 'string') return [value]
201  if (Array.isArray(value)) return value.flatMap(stringsIn)
202  if (value && typeof value === 'object') return Object.values(value).flatMap(stringsIn)
203  return []
204}
205
206function markersOf(text: string): number[] {
207  return [...new Set([...text.matchAll(MARKER)].map(match => Number(match[1])))]
208}
209
210/** Loads the pictures not known yet, then redraws. */
211function request($: EngineInterface, paths: string[]): void {
212  const missing = paths.filter(path => !pictures.has(path) && !loading.has(path))
213  if (missing.length > 0) void loadPictures($, missing)
214}
215
216async function loadPictures($: EngineInterface, paths: string[]): Promise<void> {
217  let isChanged = false
218  for (const path of paths) {
219    if (pictures.has(path) || loading.has(path)) continue
220    loading.add(path)
221    try {
222      const picture = await pictureOf($, path)
223      // A paste not written yet is looked for again on the next draw.
224      if (picture || !path.includes('/images/')) pictures.set(path, picture)
225      isChanged = isChanged || picture !== null
226    } catch (error) {
227      pictures.set(path, null)
228      $.ui.log(`inline-images: ${String(error)}`, { to: 'debug' })
229    } finally {
230      loading.delete(path)
231    }
232  }
233  if (isChanged) $.ui.invalidate('ui.render')
234}
235
236/** The image's size and a small PNG copy to draw, made once with macOS `sips`. */
237async function pictureOf($: EngineInterface, path: string): Promise<Picture | null> {
238  const stat = await $.fs.stat(path).catch(() => undefined)
239  if (stat?.kind !== 'file' || stat.size === 0) return null
240  const size = await sizeOf($, path)
241  if (!size) return null
242  const isSmallPng = /\.png$/i.test(path) && Math.max(size.width, size.height) <= THUMB_PX
243  if (isSmallPng) return { path, thumb: path, ...size }
244  const thumb = `${CACHE}/${hashOf(`${path}:${stat.mtimeMs}`)}.png`
245  if (!(await $.fs.exists(thumb))) {
246    await $.process.run(['/bin/mkdir', '-p', CACHE])
247    const made = await $.process.run(['/usr/bin/sips', '-s', 'format', 'png', '-Z', String(THUMB_PX), path, '--out', thumb])
248    if (made.exitCode !== 0) return null
249  }
250  return { path, thumb, ...size }
251}
252
253async function sizeOf($: EngineInterface, file: string): Promise<{ width: number; height: number } | undefined> {
254  const { exitCode, stdout } = await $.process.run(['/usr/bin/sips', '-g', 'pixelWidth', '-g', 'pixelHeight', file])
255  const width = Number(/pixelWidth: (\d+)/.exec(stdout)?.[1])
256  const height = Number(/pixelHeight: (\d+)/.exec(stdout)?.[1])
257  return exitCode === 0 && width > 0 && height > 0 ? { width, height } : undefined
258}
259
260function hashOf(text: string): string {
261  let hash = 0x811c9dc5
262  for (let i = 0; i < text.length; i++) {
263    hash ^= text.charCodeAt(i)
264    hash = Math.imul(hash, 0x01000193)
265  }
266  return (hash >>> 0).toString(16)
267}
268
269/**
270 * Opens an image. In cmux, a click puts it in the split beside Claude: the
271 * workspace's other pane, or a new browser split on the right when there is
272 * none (later clicks reuse it). The Open button, and anywhere outside cmux,
273 * hand it to the Mac's default app.
274 */
275async function openFile($: EngineInterface, path: string, inCmux: boolean): Promise<void> {
276  try {
277    const workspace = inCmux ? await $.env.get('CMUX_WORKSPACE_ID') : undefined
278    const surface = inCmux ? await $.env.get('CMUX_SURFACE_ID') : undefined
279    const hasCmux = workspace !== undefined && surface !== undefined && (await $.fs.exists(CMUX))
280    if (!hasCmux) {
281      const { exitCode, stderr } = await $.process.run(['/usr/bin/open', path])
282      if (exitCode !== 0) $.ui.toast(`Could not open ${path.slice(path.lastIndexOf('/') + 1)}: ${stderr.trim()}`)
283      return
284    }
285    const pane = await otherPane($, workspace, surface)
286    const { exitCode, stderr } = pane
287      ? await $.process.run([CMUX, 'open', path, '--workspace', workspace, '--pane', pane, '--focus', 'false'])
288      : await $.process.run([CMUX, 'new-pane', '--type', 'browser', '--direction', 'right', '--workspace', workspace, '--url', `file://${path}`, '--focus', 'false'])
289    if (exitCode !== 0) $.ui.toast(`Could not open ${path.slice(path.lastIndexOf('/') + 1)}: ${stderr.trim()}`)
290  } catch (error) {
291    $.ui.log(`inline-images: ${String(error)}`, { to: 'debug' })
292  }
293}
294
295/** A pane of the workspace other than the one Claude runs in, docks left out. */
296async function otherPane($: EngineInterface, workspace: string, surface: string): Promise<string | undefined> {
297  const listed = await $.process.run([CMUX, 'list-panes', '--workspace', workspace])
298  const panes = listed.stdout.split('\n').flatMap(line => {
299    const ref = /pane:\d+/.exec(line)?.[0]
300    return ref && !line.includes('[dock') ? [ref] : []
301  })
302  for (const pane of panes) {
303    const surfaces = await $.process.run([CMUX, 'list-pane-surfaces', '--workspace', workspace, '--pane', pane, '--id-format', 'both'])
304    if (!surfaces.stdout.includes(surface)) return pane
305  }
306  return undefined
307}
308
309function markSent($: EngineInterface, markers: number[]): void {
310  const highest = Math.max(maxSent, ...markers)
311  if (highest <= maxSent) return
312  maxSent = highest
313  draft = draft.filter(n => n > maxSent)
314  $.ui.invalidate('ui.render')
315}
316
317/** Shows the pasted images newer than the last one sent. */
318async function poll($: EngineInterface): Promise<void> {
319  try {
320    if (!pasteFolder) {
321      const now = await $.clock.now()
322      if (now - lastSearch < SEARCH_MS) return
323      lastSearch = now
324    }
325    const folder = await pasteFolderOf($)
326    if (!folder) {
327      if (maxSent < 0) maxSent = 0 // no folder yet: nothing pasted in this session
328      return
329    }
330    const numbers = (await $.fs.list(folder)).flatMap(entry => {
331      const match = /^(\d+)\.png$/.exec(entry.name)
332      return match ? [Number(match[1])] : []
333    })
334    if (maxSent < 0) {
335      maxSent = Math.max(0, ...numbers)
336      return
337    }
338    const fresh = numbers.filter(n => n > maxSent).sort((a, b) => a - b)
339    if (fresh.join() === draft.join()) return
340    draft = fresh
341    await loadPictures($, fresh.map(n => `${folder}/${n}.png`))
342    $.ui.invalidate('ui.render')
343  } catch (error) {
344    $.ui.log(`inline-images: ${String(error)}`, { to: 'debug' })
345  }
346}
347
348/** Claude Code keeps pastes in <tmp>/claude-<uid>/<project>/<session>/images. */
349async function pasteFolderOf($: EngineInterface): Promise<string> {
350  if (pasteFolder) return pasteFolder
351  const session = await $.session.id()
352  const uid = (await $.process.run(['/usr/bin/id', '-u'])).stdout.trim()
353  const userTmp = (await $.process.run(['/usr/bin/getconf', 'DARWIN_USER_TEMP_DIR'])).stdout.trim().replace(/\/+$/, '')
354  for (const base of [`/private/tmp/claude-${uid}`, `${userTmp}/claude-${uid}`]) {
355    for (const entry of await $.fs.list(base).catch(() => [])) {
356      const candidate = `${base}/${entry.name}/${session}/images`
357      if (entry.kind === 'dir' && (await $.fs.exists(candidate))) {
358        pasteFolder = candidate
359        return pasteFolder
360      }
361    }
362  }
363  return ''
364}
365
366/** The largest box of cells that keeps the picture's shape within the limits. */
367function cellsOf(picture: Picture, limit: Cells): Cells {
368  const rowsPerColumn = (picture.height / picture.width) * CELL_ASPECT
369  let columns = Math.max(4, Math.min(limit.columns, Math.ceil(picture.width / CELL_PX)))
370  let rows = Math.max(1, Math.round(columns * rowsPerColumn))
371  if (rows > limit.rows) {
372    rows = limit.rows
373    columns = Math.max(1, Math.round(rows / rowsPerColumn))
374  }
375  return { columns: Math.min(columns, 255), rows: Math.min(rows, 255) }
376}
377
hooks/click.ts 12 lines
1import type { ClientModule } from 'claude-code'
2
3/** A clear layer over a picture: a click on it posts the picture's path. */
4const ClickLayer: ClientModule<{ path: string }, undefined> = (props, surface) => {
5  surface.onPointer(event => {
6    if (event.type === 'up') surface.post({ path: props.path })
7  })
8  return surface.elements.Box({})
9}
10
11export default ClickLayer
12