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+.

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.
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 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.
[Image #N] marker. The preview appears automatically.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.
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.
claude-image-peek/.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.
Reach L2: starts local processes and writes temporary image files.
/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.$.process.run, so review the helper as well as the hooks module.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.
hooks/register.ts 254 lines1import 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};
254types/index.d.ts 20 lines1export 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}
20hooks/selection.ts 24 lines1export 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