SLOPSHOPPER

image-peek

Preview pasted images at their prompt markers on macOS. Ghostty, plus experimental iTerm2 and Herdr support with a startup override. Claude Code 2.1.287+.

newpanebandcommandpromptprocess
★ 35v0.1.0MITupdated 2026-10-03karanb192/claude-code-mods/plugins/image-peek
A shopper browsing a rack in a slop shop
README

Image Peek

Move the text cursor onto a pasted [Image #1] marker to see its image. Move away to hide it. Wide windows get a large preview pane with the image centered on a dark canvas. Narrow windows use the area above the prompt. Keyboard focus stays in the prompt.

This first version targets macOS and Ghostty with Claude Code 2.1.287 or later. Cursor selection, native paste, reload and cleanup have been exercised in the actual Claude CLI. A user-provided Ghostty screenshot confirms the large image and dark preview canvas.

iTerm2 and local Herdr sessions are also recognized, with the startup override below. Their image pixels still need an end-to-end visual check.

Install

Run in your shell, then start a new Claude session in Ghostty:

claude plugin marketplace add karanb192/claude-code-mods
claude plugin install image-peek@claude-code-mods

If you already added the marketplace, update it with claude plugin marketplace update claude-code-mods before installing. This entry becomes available when the Image Peek change is merged into the marketplace's main branch.

No browser, compiler, separate Mac app or API key is needed. Claude loads the mod and macOS supplies the clipboard reader. Mods are enabled by default in Claude Code 2.1.287+.

To try a local checkout without installing, run from the repository root:

claude --plugin-dir ./plugins/image-peek

Use

iTerm2 and Herdr

Use iTerm2 3.7.3+ or Herdr 0.9.1+ inside Ghostty. Start a new Claude process with the image renderer enabled:

CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 claude

For a local checkout, from the repository root:

CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 claude --plugin-dir ./plugins/image-peek

Claude Code 2.1.287's renderer checks the terminal's reported name against Kitty and Ghostty even after a graphics query receives a reply. iTerm2 and Herdr can fail this name check. This per-process override bypasses that check and the image-file capability probe. It does not change your shell settings or install another application. A mod reload is insufficient because Claude initializes these capabilities before the mod starts.

iTerm2 3.7.3 fixes rendering of the Unicode image placeholders that Claude uses. Herdr 0.9.1 supports file-backed Kitty images and Unicode placements. Its terminal.kitty_graphics setting defaults to true; if you disabled it, enable it and follow Herdr's restart/reattach instructions. This preview route requires a local Mac session and a graphics-capable outer terminal. The override cannot add graphics support to an incompatible terminal or transport.

Preview an attachment

  1. Copy an image and paste it into Claude's prompt using the usual image-paste shortcut.
  2. Use the arrow keys to put the text cursor inside or directly beside its [Image #N] marker. The preview appears automatically.
  3. Move into the surrounding text to hide it. Return to the marker to see the same cached image.

This follows the text cursor, not mouse hover. The conversation remains visible beside or above the preview. The whole image fits within the available width and height, reserving one row for its label. The pane requests up to about 72% of the window's width, adjusted for the image's proportions. Claude may retain a width you previously chose; drag the divider if that makes the pane too narrow.

The inline fallback is smaller because Claude limits the above-prompt area to roughly half the terminal height, including the prompt and other bottom content. There is no zoom or floating overlay. Closing the pane dismisses it until the cursor leaves the marker.

Claude draws the pane's outer frame using its own theme. A Light Claude theme in a dark terminal leaves bright strips around the dark canvas. Choose Dark in Claude's /theme menu to make the surrounding frame dark too; this changes all Claude UI colors. Image Peek does not change your theme.

/image-peek off stops new captures and hides the preview. /image-peek on resumes capture for new pastes. /image-peek reports the current setting. These commands do not call a model.

Limits of this version

Claude's prompt API exposes the marker and cursor but does not provide the draft attachment bytes. Image Peek therefore reads the Mac clipboard when it detects a new native image marker. It checks the draft every 120 ms and saves that clipboard image once.

  • Paste one image at a time and wait for the preview before copying another image. If the clipboard changes between the paste and capture, the preview can show the newer clipboard image. It is a convenience preview, not proof of the submitted attachment's contents.
  • PNG and TIFF clipboard image data are supported. File paths, drag-and-drop attachments and images that were already present when the mod loaded are not supported capture routes. Re-paste the image from the clipboard.
  • Two new markers arriving in the same check produce “Preview unavailable”, since their individual clipboard contents cannot be recovered.
  • Only the most recent 24 captures are kept per session. Older markers, resumed sessions and pastes made while disabled may show “Preview unavailable”. A normal hot reload within the same session preserves captured previews.
  • Capture rejects input or output over 32 MiB and decoded images over 64 million pixels. Animated images are represented as a single PNG frame when macOS can decode them.
  • Clear, resume, compaction and normal exit remove that session's temporary images. A crash, forced termination or cleanup failure can leave files in the macOS temporary directory under claude-image-peek/.
  • Capture starts only on macOS when the environment identifies Ghostty, iTerm2 or Herdr. Other terminals receive a notice and no clipboard capture starts. iTerm2 and Herdr need the startup override above with Claude Code 2.1.287. SSH and nested tmux/screen sessions are not validated.

Validation

Run from the repository root:

claude plugin validate plugins/image-peek --strict
claude plugin test plugins/image-peek

The strict validator on Claude Code 2.1.287 reported:

  ❯ types ./types/index.d.ts declares on $: nothing (no EngineInterface member)
  ❯ types ./types/index.d.ts declares state: image-peek.session
  ❯ ./register.ts hooks: session.start, prompt.edit, prompt.fill, command.run{command=image-peek}, session.end, session.compact, ui.render{component=AbovePrompt}, ui.render{component=Pane, requestId=image-peek}, ui.close{id=image-peek}
  ❯ ./register.ts calls: $.clock.every, $.command.register, $.env.get, $.process.run, $.prompt.read, $.session.id, $.state.get, $.state.set (via save), $.ui.close, $.ui.invalidate, $.ui.log, $.ui.open (via update), $.ui.panes, $.ui.resolve
  ❯ ./register.ts env writes: nothing
  ❯ ./register.ts env reads: CLAUDE_CODE_FORCE_TERMINAL_IMAGES, GHOSTTY_RESOURCES_DIR, HERDR_ENV, TERM_PROGRAM
  ❯ ./register.ts state writes: image-peek.session
  ❯ ./register.ts state reads: image-peek.session

Tests cover cursor boundaries, separate captures, ambiguous pastes, typed marker substitutes, unsupported terminals, enable/disable, failed capture, cache eviction and cleanup failure. A live CLI check also exercised native image paste, leaving and returning to the marker, hot reload and normal-exit cleanup. That terminal rendered the Image element's alternative text, so it did not verify image pixels. No model turn was submitted during these checks.

A controlled layout probe in the actual CLI used the same 180-column, 48-row terminal for both surfaces. The inline area settled at 15 rows and fitted a landscape image into 50 by 14 cells. A requested 128-column pane provided a 128 by 40 cell body and fitted that image into 126 by 34 cells. This verifies available layout space, not rendered pixels or colors.

For a full interaction check, paste two distinct images in Ghostty, select each marker, resize the window, and confirm the larger dark preview appears and disappears without moving keyboard focus. Repeat with conversation output above the prompt. On reload, the plugin closes any pane left from its earlier layout.

Threat model

Reach L2: starts local processes and writes temporary image files.

  1. Reads: draft text and cursor, session ID, four terminal environment variables, and PNG/TIFF clipboard data after detecting a new image marker. Draft text is parsed in memory and is not saved or sent by this plugin.
  2. Runs: /usr/bin/uname -s to check the platform and /usr/bin/osascript -l JavaScript with the bundled clipboard helper. Fixed argument arrays are used; prompt text never becomes a shell command.
  3. Sends: no network requests, model calls or prompt submissions. Claude's normal handling of an attachment when you send your prompt is unchanged.
  4. Persists: local session state holds image paths, dimensions, observed marker IDs and the on/off setting. Temporary PNGs use private session directories (0700) and files (0600), with at most 24 retained captures. Normal session cleanup removes the files; stored paths can outlive them.
  5. Hostile input: session directory names are restricted to UUID characters; existing non-directory cache paths are rejected. Size checks bound accepted image data, but macOS still decodes the clipboard image. A clipboard change during capture discards it; a change before capture cannot be tied back to the original paste. The validator lists $.process.run, so review the helper as well as the hooks module.

Files and API references

  • hooks/register.ts: detects marker selection, captures new images and draws the preview.
  • hooks/clipboard.js: macOS clipboard capture and temporary-file cleanup.
  • hooks/selection.ts: marker boundaries and image sizing.
  • types/index.d.ts: the plugin's session-state shape.
  • tests/register.test.ts: Claude's native mod tests.

The implementation uses Claude's engine interface and native interface elements. Generated engine declarations are local development files and are not shipped.

Source 3 files
hooks/register.ts 254 lines
1import type { EngineInterface, Register, RenderInput } from 'claude-code';
2import type { PreviewImage, PreviewSession } from '../types/index.d.ts';
3import { fitImage, markers, selectedImage } from './selection.ts';
4
5const STATE = { plugin: 'image-peek', key: 'session' } as const;
6const PANE = 'image-peek';
7const LIMIT = 24;
8
9function blank(sessionId = ''): PreviewSession {
10  return { sessionId, images: {}, observed: [], enabled: true, highestNativeId: 0 };
11}
12
13function imageResult(text: string): PreviewImage | null {
14  try {
15    const data = JSON.parse(text);
16    if (data.ok !== true || typeof data.path !== 'string' || !data.path.startsWith('/')
17      || !Number.isInteger(data.width) || !Number.isInteger(data.height)
18      || data.width < 1 || data.height < 1 || data.width * data.height > 64_000_000) return null;
19    return { path: data.path, width: data.width, height: data.height };
20  } catch { return null; }
21}
22
23let session = blank();
24let active: string | null = null;
25let docked = false;
26let dismissed: string | null = null;
27let viewport = { columns: 180, rows: 48 };
28let busy = false;
29let ready = false;
30let generation = 0;
31let lastDraft = '';
32let lastCursor = -1;
33let failed = false;
34
35async function save($: EngineInterface) {
36  await $.state.set(STATE, session);
37}
38
39async function close($: EngineInterface) {
40  active = null;
41  docked = false;
42  await $.ui.close({ id: PANE });
43  $.ui.invalidate('ui.render');
44}
45
46async function cleanup($: EngineInterface, id: string) {
47  try {
48    await $.process.run(['/usr/bin/osascript', '-l', 'JavaScript', `${$.plugin.root}/hooks/clipboard.js`, 'cleanup', id], { timeoutMs: 3000 });
49  } catch {
50    $.ui.log('Image Peek could not remove its temporary preview files.');
51  }
52}
53
54async function reset($: EngineInterface) {
55  generation++;
56  const ending = session.sessionId;
57  session = blank();
58  dismissed = null;
59  lastDraft = '';
60  lastCursor = -1;
61  try { await close($); await save($); }
62  finally { if (ending) await cleanup($, ending); }
63}
64
65async function update($: EngineInterface) {
66  if (!ready || busy) return;
67  busy = true;
68  const epoch = generation;
69  try {
70    if (!session.sessionId) {
71      session = blank(await $.session.id());
72      await save($);
73    }
74    const draft = await $.prompt.read();
75    if (draft.text === lastDraft && draft.cursor === lastCursor) return;
76    const textChanged = draft.text !== lastDraft;
77    lastDraft = draft.text;
78    lastCursor = draft.cursor;
79    const ids = [...new Set(markers(draft.text).map(marker => marker.id))];
80    const fresh = ids.filter(id => !session.observed.includes(id) && !(id in session.images)
81      && Number(id) > session.highestNativeId);
82    session.observed = ids;
83    for (const id of fresh) session.highestNativeId = Math.max(session.highestNativeId, Number(id));
84    for (const id of fresh) session.images[id] = null;
85    if (session.enabled && fresh.length === 1) {
86      const capturedSession = session.sessionId;
87      const result = await $.process.run(['/usr/bin/osascript', '-l', 'JavaScript', `${$.plugin.root}/hooks/clipboard.js`, 'capture', capturedSession], { timeoutMs: 3000 });
88      if (epoch !== generation) { await cleanup($, capturedSession); return; }
89      session.images[fresh[0]!] = result.exitCode === 0 ? imageResult(result.stdout) : null;
90    }
91    const keys = Object.keys(session.images);
92    for (const id of keys.slice(0, Math.max(0, keys.length - LIMIT))) delete session.images[id];
93    if (textChanged || fresh.length) await save($);
94    const current = await $.prompt.read();
95    if (epoch !== generation) return;
96    const id = session.enabled ? selectedImage(current.text, current.cursor) : null;
97    if (id !== dismissed) dismissed = null;
98    if (id === active || (id !== null && id === dismissed)) return;
99    if (!id) { await close($); return; }
100    active = id;
101    const entry = session.images[id];
102    const columns = entry
103      ? fitImage(entry.width, entry.height, Math.floor(viewport.columns * 0.72) - 2, viewport.rows - 8).columns + 2
104      : 64;
105    const opened = await $.ui.open({ id: PANE, title: `Image #${id}`, columns });
106    if (epoch !== generation) return;
107    docked = opened.isPlaced;
108    $.ui.invalidate('ui.render');
109  } catch {
110    await close($);
111    if (!failed) $.ui.log('Image preview is unavailable. Paste the image again, or use /image-peek off.');
112    failed = true;
113  } finally { busy = false; }
114}
115
116function draw($: EngineInterface, e: RenderInput<'Pane' | 'AbovePrompt', 'terminal'>) {
117  const { Box, Text, Image } = $.ui.resolve(e);
118  const entry = active ? session.images[active] : null;
119  const columns = Math.max(1, e.props.bodyColumns - 2);
120  const pane = e.component === 'Pane';
121  const bodyRows = pane ? e.props.scroll.bodyRows : e.props.maxRows;
122  const rows = Math.max(1, bodyRows - 1);
123  const textStyle = pane ? { color: '#d1d5db' } : { dimColor: true };
124  const children = entry ? [
125    Text({ ...textStyle, children: `Image #${active} · ${entry.width} × ${entry.height}` }),
126    Image({ key: `image-${active}`, source: { file: entry.path, format: 'png' },
127      ...fitImage(entry.width, entry.height, columns, rows), alt: `Image #${active} · Terminal image rendering is unavailable. See Image Peek's terminal setup instructions.` }),
128  ] : [Text({ ...textStyle, children: `Image #${active} · Preview unavailable. Paste it again to preview.` })];
129  return Box({ flexDirection: 'column', flexShrink: 0,
130    ...(pane ? { width: e.props.bodyColumns, height: bodyRows, backgroundColor: '#202327', alignItems: 'center', justifyContent: 'center' } as const
131      : { alignItems: 'flex-start' } as const), children });
132}
133
134export const register: Register = on => {
135  on('session.start', async ($, e, next) => {
136    const result = await next(e);
137    if (!e.isInteractive || e.surface !== 'terminal') return result;
138    const terminal = await $.env.get('TERM_PROGRAM');
139    const ghostty = await $.env.get('GHOSTTY_RESOURCES_DIR');
140    const herdr = await $.env.get('HERDR_ENV') === '1';
141    const iterm = terminal === 'iTerm.app';
142    const forced = await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES');
143    const platform = await $.process.run(['/usr/bin/uname', '-s']);
144    if (platform.stdout.trim() !== 'Darwin' || (terminal !== 'ghostty' && !ghostty && !iterm && !herdr)) {
145      $.ui.log('Image Peek needs macOS with Ghostty, iTerm2 or Herdr. No clipboard access started.');
146      return result;
147    }
148    if ((herdr || iterm) && !forced) {
149      $.ui.log('Image Peek: iTerm2 and Herdr need a fresh Claude process launched with CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1. Use iTerm2 3.7.3+ or Herdr 0.9.1+ with graphics enabled in a compatible terminal.');
150    }
151    const id = await $.session.id();
152    const stored = (await $.state.get(STATE)).value;
153    session = stored?.sessionId === id ? JSON.parse(JSON.stringify(stored)) : blank(id);
154    await $.ui.close({ id: PANE });
155    const draft = await $.prompt.read();
156    for (const marker of markers(draft.text)) {
157      if (!(marker.id in session.images)) session.images[marker.id] = null;
158      session.highestNativeId = Math.max(session.highestNativeId, Number(marker.id));
159    }
160    await save($);
161    ready = true;
162    $.clock.every(120, async () => { await update($); });
163    await $.command.register({ name: PANE, description: 'Turn automatic pasted-image previews on or off', argumentHint: '[on|off]', immediate: true });
164    return result;
165  });
166
167  on('prompt.edit', async ($, e, next) => {
168    const result = await next(e);
169    if (ready) {
170      // Native image paste bypasses this event. Text that looks like a chip is not an attachment.
171      const existing = new Set(markers(e.text).map(marker => marker.id));
172      for (const marker of markers(result.text)) {
173        if (!existing.has(marker.id) && !(marker.id in session.images)) session.images[marker.id] = null;
174      }
175    }
176    return result;
177  });
178
179  on('prompt.fill', async ($, e, next) => {
180    for (const marker of markers(e.text)) {
181      if (!(marker.id in session.images)) session.images[marker.id] = null;
182    }
183    return next(e);
184  });
185
186  on('command.run', { command: PANE }, async ($, e) => {
187    const arg = e.args.trim();
188    if (arg !== 'on' && arg !== 'off' && arg !== '') return { text: 'Use /image-peek on or /image-peek off.' };
189    if (arg) {
190      session.enabled = arg === 'on';
191      lastCursor = -1;
192      await save($);
193      if (!session.enabled) await close($);
194    }
195    return { text: `Image Peek is ${session.enabled ? 'on' : 'off'}. Move the cursor onto a pasted image marker to preview it.` };
196  });
197
198  on('session.end', async ($, e, next) => {
199    const running = ready;
200    ready = false;
201    if (running) {
202      try { await reset($); }
203      catch { $.ui.log('Image Peek could not finish preview cleanup.'); }
204    }
205    const result = await next(e);
206    ready = running && (e.reason === 'clear' || e.reason === 'resume');
207    return result;
208  });
209
210  on('session.compact', async ($, e, next) => {
211    const running = ready;
212    ready = false;
213    try {
214      const result = await next(e);
215      if (running) {
216        try { await reset($); }
217        catch { $.ui.log('Image Peek could not finish preview cleanup.'); }
218      }
219      return result;
220    } finally { ready = running; }
221  });
222
223  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
224    if (e.surface !== 'terminal') return next(e);
225    if (!docked && e.viewport) viewport = { columns: e.viewport.columns, rows: e.viewport.rows };
226    if (!active || e.props.hasSurvey || e.props.maxRows < 2) return next(e);
227    const panes = await $.ui.panes();
228    docked = panes.some(pane => pane.id === PANE && pane.isPlaced);
229    if (docked) return next(e);
230    const other = await next(e);
231    const { Box } = $.ui.resolve(e);
232    return Box({ flexDirection: 'column', children: [other, draw($, e)] });
233  });
234
235  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e, next) => {
236    if (!active || e.surface !== 'terminal') return next(e);
237    if (e.props.placement === 'dock' && e.viewport) {
238      viewport = { columns: e.viewport.columns + e.props.bodyColumns + 1, rows: e.viewport.rows };
239    }
240    if (!docked) { docked = true; $.ui.invalidate('ui.render'); }
241    return draw($, e);
242  });
243
244  on('ui.close', { id: PANE }, ($, e, next) => {
245    if (e.origin.kind === 'person') {
246      dismissed = active;
247      active = null;
248      docked = false;
249      $.ui.invalidate('ui.render');
250    }
251    return next(e);
252  });
253};
254
types/index.d.ts 20 lines
1export type PreviewImage = {
2  path: string;
3  width: number;
4  height: number;
5};
6
7export type PreviewSession = {
8  sessionId: string;
9  images: Record<string, PreviewImage | null>;
10  observed: string[];
11  enabled: boolean;
12  highestNativeId: number;
13};
14
15declare module 'claude-code' {
16  interface PluginState {
17    'image-peek': { session: PreviewSession };
18  }
19}
20
hooks/selection.ts 24 lines
1export function markers(text: string) {
2  return [...text.matchAll(/\[Image #([1-9]\d{0,8})\]/g)].map(match => ({
3    id: match[1]!, start: match.index!, end: match.index! + match[0].length,
4  }));
5}
6
7export function selectedImage(text: string, cursor: number): string | null {
8  if (!Number.isInteger(cursor) || cursor < 0 || cursor > text.length) return null;
9  const matches = markers(text);
10  return (matches.find(match => cursor >= match.start && cursor < match.end)
11    ?? matches.find(match => cursor === match.end))?.id ?? null;
12}
13
14export function fitImage(width: number, height: number, columns: number, rows: number) {
15  const maxColumns = Math.max(1, Math.min(255, Math.floor(columns)));
16  const maxRows = Math.max(1, Math.min(255, Math.floor(rows)));
17  // Terminal cells are approximately twice as tall as they are wide.
18  const scale = Math.min(maxColumns / width, maxRows * 2 / height);
19  return {
20    columns: Math.max(1, Math.floor(width * scale)),
21    rows: Math.max(1, Math.floor(height * scale / 2)),
22  };
23}
24