See images, screenshots and HTML pages inline in the Claude Code chat

See what Claude builds — right inside Claude Code.
Images, screenshots and rendered HTML pages, drawn inline in the chat, pixel-sharp.
English · Português
<img src="assets/hero.png" alt="Claude writes menu.html and the rendered page appears inline, right under the Write row" width="100%">
Claude writes a landing page, a dashboard, a chart, an email template… and you alt-tab to a browser to see if it's any good. Every. Single. Time.
glance closes that loop. The moment Claude writes or edits an .html file, a headless Chromium renders it and the real page shows up in the chat, under the tool call. Same for every image Claude reads. No browser, no context switch.
Write / Edit of an .html file renders the page inline.Reads (png, jpg, gif, webp, bmp, svg) is drawn under the row./glance anything — /glance mockup.png page.html chart.svg shows any files on demand.⤢ blows a picture up to the whole chat width (⤡ shrinks it back), ↗ opens it in your image viewer or browser.<img src="assets/gallery.png" alt="/glance with three images laid out side by side with captions" width="100%">
curl -fsSL https://raw.githubusercontent.com/Rocha101/claude-code-glance/main/install.sh | bash
Then open a new Claude Code session. That's it.
git clone https://github.com/Rocha101/claude-code-glance ~/.claude/mods/claude-code-glance
Add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json (:-separated), or start Claude Code with claude --plugin-dir ~/.claude/mods/claude-code-glance.
Requirements: Claude Code 2.1.287+ (mods), chromium or google-chrome, ImageMagick (magick), jq, git. Optional: python3, to read the terminal's exact cell size for pixel-perfect pictures.
| You do | glance shows |
|---|---|
| Ask Claude to build any HTML page | the rendered page under the Write row |
| Claude edits the page | the updated render under the Edit row |
| Claude reads a screenshot or image | the picture under the Read row |
/glance a.png b.html c.jpg | all of them, side by side |
click ⤢ / ⤡ under a picture | it grows to the whole chat width / goes back |
click ↗ under a picture | the file opens in your default app (viewer, browser) |
**Tip — make Claude show you things.** Add this to ~/.claude/CLAUDE.md:
To show me an image, screenshot, chart or page, `Read` the image file (glance draws it in the chat).
For a URL or a page you didn't change: `chromium --headless --screenshot=<png> --window-size=1280,900 <url>`, then `Read` the png.
| Terminal | Result |
|---|---|
| Ghostty, kitty, WezTerm | real pixels, sharp (kitty graphics protocol) |
| Others (foot, Alacritty, GNOME Terminal…) | colour block art (2×2 pixels per cell) |
| herdr | real pixels with [experimental] kitty_graphics = true in ~/.config/herdr/config.toml and a server restart; the installer sets CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 |
| tmux | block art (tmux passes no graphics through) |
Set these in the env block of ~/.claude/settings.json:
| Variable | Default | What it does |
|---|---|---|
GLANCE_MODE | auto | image forces real pixels, cells forces block art |
GLANCE_CELL_PX | 12 | image pixels per terminal column — higher = smaller pictures |
GLANCE_CELL_RATIO | auto | cell height ÷ width; read from the terminal, 2.25 when it doesn't report pixels |
CLAUDE_CODE_FORCE_TERMINAL_IMAGES | — | 1 makes Claude Code send images where it can't detect support (multiplexers) |
A Claude Code mod (a hooks module, hooks/register.tsx):
tool.call — after a Write/Edit of HTML or a Read of an image, it renders: HTML through headless Chromium at 1280×900, cropped to the content; images normalised with ImageMagick.ui.render on ToolResult, ToolGroup and CommandOutput — it draws the picture under the row, as an Image (kitty protocol, pre-resized to the box's exact pixels from the pty's cell size) or a Raster of quadrant blocks./glance splits paths on spaces./dev/shm and are gone after a reboot.claude plugin validate . # what the engine will load
claude plugin test . # the test suite
Issues and PRs welcome. If glance saved you an alt-tab, give it a star ⭐ — it helps other people find it.
See also: quickswitch — switch Claude accounts in one click from the status line.
MIT © Rocha101
hooks/register.tsx 331 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Shot } from '../types'
5
6const W = 1280
7const H = 900
8const PAD = 48 // px kept below the last content
9// image px per terminal column; ~8px cells are typical, so 12 draws at ~0.7x. GLANCE_CELL_PX tunes it
10const CELL_PX = 12
11// terminal cell height / width when the terminal doesn't report its pixels; GLANCE_CELL_RATIO overrides
12const CELL_RATIO = 2.25
13const HTML = /\.html?$/i
14const IMG = /\.(png|jpe?g|gif|webp|bmp|svg)$/i
15const BROWSERS = ['chromium', 'google-chrome-stable', 'google-chrome']
16
17const shots = atom({ plugin: 'glance', key: 'shots' } as const, {})
18const generation = atom({ plugin: 'glance', key: 'generation' } as const, 0)
19// pictures the person enlarged, by generation
20const expanded = atom({ plugin: 'glance', key: 'expanded' } as const, {})
21
22// GLANCE_MODE=image|cells overrides; else kitty-protocol terminals get real pixels
23async function wantsImage($: EngineInterface) {
24 const mode = await $.env.get('GLANCE_MODE')
25 if (mode) return mode === 'image'
26 if (await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES')) return true
27 const term = `${(await $.env.get('TERM')) ?? ''} ${(await $.env.get('TERM_PROGRAM')) ?? ''}`
28 return /kitty|ghostty|wezterm/i.test(term)
29}
30
31// cut the empty band under the page's content: a fixed window is mostly blank on short pages
32async function cropToContent($: EngineInterface, img: string) {
33 const box = await $.process.run(['magick', img, '-fuzz', '3%', '-format', '%@', 'info:'])
34 const m = /^(\d+)x(\d+)\+(\d+)\+(\d+)/.exec(box.stdout)
35 if (box.exitCode || !m) return H
36 const h = Math.min(H, Number(m[2]) + Number(m[4]) + PAD)
37 if (h >= H) return H
38 const cut = await $.process.run(['magick', img, '-crop', `${W}x${h}+0+0`, '+repage', img])
39 return cut.exitCode ? H : h
40}
41
42async function shoot($: EngineInterface, id: string, path: string): Promise<Shot> {
43 await update($, generation, n => n + 1)
44 const gen = await read($, generation)
45 const real = await $.process.run(['realpath', '-e', path])
46 const file = real.stdout.trim() || path
47 let shot: Shot = { file, img: file, w: W, h: H, generation: gen }
48 if (real.exitCode) shot.error = `not found: ${path}`
49 else if (HTML.test(file)) {
50 // note: one png per shot in /dev/shm, gone on reboot
51 shot.img = `/dev/shm/claude-glance-${gen}.png`
52 shot.error = 'no chromium/google-chrome found'
53 for (const bin of BROWSERS) {
54 try {
55 const r = await $.process.run(
56 [bin, '--headless', '--disable-gpu', '--hide-scrollbars', `--window-size=${W},${H}`,
57 `--screenshot=${shot.img}`, `file://${file}`],
58 { timeoutMs: 30_000 },
59 )
60 shot.error = r.exitCode ? r.stderr.slice(-300) : undefined
61 if (!shot.error) shot.h = await cropToContent($, shot.img)
62 break
63 } catch {
64 // binary missing, try next
65 }
66 }
67 } else {
68 const size = await $.process.run(['magick', 'identify', '-format', '%w %h', `${file}[0]`])
69 const [w, h] = size.stdout.split(' ').map(Number)
70 if (size.exitCode || !w || !h) shot.error = `cannot read image: ${file}`
71 else {
72 const img = `/dev/shm/claude-glance-${gen}.png`
73 const conv = await $.process.run(['magick', `${file}[0]`, '-resize', '1600x1600>', img])
74 shot = conv.exitCode ? { ...shot, error: `cannot convert image: ${file}` } : { ...shot, img, w, h }
75 }
76 }
77 await update($, shots, all => ({ ...all, [id]: shot }))
78 return shot
79}
80
81// quadrant glyph per fg bitmask (tl=1 tr=2 bl=4 br=8)
82const QUAD = [0x20, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b,
83 0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f, 0x2588]
84
85const cellCache = new Map<string, string>()
86
87async function toCells($: EngineInterface, s: Shot, cols: number, rows: number) {
88 const key = `${s.generation}:${cols}x${rows}`
89 const hit = cellCache.get(key)
90 if (hit) return hit
91 const raw = `/dev/shm/claude-glance-${s.generation}.rgb`
92 const r = await $.process.run(['magick', `${s.img}[0]`, '-resize', `${cols * 2}x${rows * 2}!`, '-depth', '8', `rgb:${raw}`])
93 if (r.exitCode) return undefined
94 const px = Uint8Array.fromBase64((await $.fs.read(raw, { as: 'bytes' })).base64)
95 const pw = cols * 2
96 const out = new Uint32Array(cols * rows * 3)
97 const p = [0, 0, 0, 0].map(() => [0, 0, 0])
98 for (let y = 0; y < rows; y++) {
99 for (let x = 0; x < cols; x++) {
100 for (let k = 0; k < 4; k++) {
101 const i = ((y * 2 + (k >> 1)) * pw + x * 2 + (k & 1)) * 3
102 p[k] = [px[i] ?? 0, px[i + 1] ?? 0, px[i + 2] ?? 0]
103 }
104 // try each 2-colour split of the 4 pixels, keep the least error
105 let best = { err: Infinity, mask: 0, fg: 0, bg: 0 }
106 for (let mask = 0; mask < 8; mask++) {
107 const mean = (on: boolean) => {
108 const sum = [0, 0, 0]
109 let n = 0
110 for (let k = 0; k < 4; k++) if (((mask >> k) & 1) === +on) { n++; for (let c = 0; c < 3; c++) sum[c]! += p[k]![c]! }
111 return n ? sum.map(v => v / n) : [0, 0, 0]
112 }
113 const fg = mean(true)
114 const bg = mean(false)
115 let err = 0
116 for (let k = 0; k < 4; k++) {
117 const m = (mask >> k) & 1 ? fg : bg
118 for (let c = 0; c < 3; c++) err += (p[k]![c]! - m[c]!) ** 2
119 }
120 if (err < best.err) {
121 const rgb = (v: number[]) => (Math.round(v[0]!) << 16) | (Math.round(v[1]!) << 8) | Math.round(v[2]!)
122 best = { err, mask, fg: rgb(fg), bg: rgb(bg) }
123 }
124 }
125 const c = (y * cols + x) * 3
126 out[c] = QUAD[best.mask]!
127 out[c + 1] = best.fg
128 out[c + 2] = best.bg
129 }
130 }
131 const cells = new Uint8Array(out.buffer).toBase64()
132 cellCache.set(key, cells)
133 return cells
134}
135
136// the terminal's cell size in pixels, from Claude Code's own pty (TIOCGWINSZ); undefined where
137// the terminal doesn't report pixels. Cached briefly: a resize shows up within a few seconds
138const PTY_PROBE = `
139import fcntl, os, struct, termios
140p = os.getppid()
141for n in (0, 1, 2):
142 try:
143 t = os.readlink(f"/proc/{p}/fd/{n}")
144 if t.startswith("/dev/pts") or t.startswith("/dev/tty"):
145 f = os.open(t, os.O_RDONLY | os.O_NOCTTY)
146 r, c, x, y = struct.unpack("HHHH", fcntl.ioctl(f, termios.TIOCGWINSZ, bytes(8)))
147 print(c, r, x, y)
148 break
149 except Exception:
150 pass
151`
152type Cell = { w: number; h: number }
153let ptyCell: { at: number; cell: Cell | undefined } | undefined
154
155async function cellSize($: EngineInterface): Promise<Cell | undefined> {
156 const now = await $.clock.now()
157 if (ptyCell && now - ptyCell.at < 5000) return ptyCell.cell
158 let cell: Cell | undefined
159 try {
160 const r = await $.process.run(['python3', '-c', PTY_PROBE], { timeoutMs: 3000 })
161 const [c, rows, x, y] = r.stdout.trim().split(' ').map(Number)
162 if (c && rows && x && y) cell = { w: x / c, h: y / rows }
163 } catch {
164 // no python3: fall back to the estimates
165 }
166 ptyCell = { at: now, cell }
167 return cell
168}
169
170// the picture resized to exactly the box's pixels (Lanczos + light sharpening), so the terminal
171// draws it 1:1 instead of scaling it with its own softer filter
172const fitCache = new Map<string, string>()
173
174async function fitted($: EngineInterface, s: Shot, cols: number, rows: number, cell: Cell | undefined) {
175 if (!cell) return s.img
176 const w = Math.round(cols * cell.w)
177 const h = Math.round(rows * cell.h)
178 const key = `${s.generation}:${w}x${h}`
179 const hit = fitCache.get(key)
180 if (hit) return hit
181 const out = `/dev/shm/claude-glance-${s.generation}-${w}x${h}.png`
182 const r = await $.process.run(['magick', `${s.img}[0]`, '-filter', 'Lanczos', '-resize', `${w}x${h}`, '-unsharp', '0x0.6+0.6+0', out])
183 const img = r.exitCode ? s.img : out
184 fitCache.set(key, img)
185 return img
186}
187
188const GAP = 2 // columns between images in a row
189const MIN_TILE = 36 // columns an image gets at least before the row wraps
190const WIDE = 160 // chat columns from which pictures stop at half the width
191
192type ResolveArg = Parameters<EngineInterface['ui']['resolve']>[0]
193
194async function toggleSize($: EngineInterface, s: Shot) {
195 await update($, expanded, all => ({ ...all, [String(s.generation)]: !all[String(s.generation)] }))
196}
197
198// the source itself in the desktop's default app: images in the viewer, pages in the browser
199async function openOutside($: EngineInterface, s: Shot) {
200 const r = await $.process.run(['setsid', '-f', 'xdg-open', s.file])
201 if (r.exitCode) $.ui.toast(`glance: could not open ${s.file}`)
202}
203
204async function tile($: EngineInterface, e: ResolveArg, s: Shot, cols: number, rows: number, isBig: boolean, cell: Cell | undefined) {
205 if (e.surface !== 'terminal') return null
206 const { Box, Button, Text, Image, Raster } = $.ui.resolve(e)
207 const name = s.file.split('/').pop() ?? s.file
208 if (s.error) return <Text color="red">glance: {s.error}</Text>
209 let pic
210 if (await wantsImage($)) {
211 const { base64 } = await $.fs.read(await fitted($, s, cols, rows, cell), { as: 'bytes' })
212 pic = <Image source={{ png: base64 }} columns={cols} rows={rows} alt={s.file} />
213 } else {
214 const cells = await toCells($, s, cols, rows)
215 pic = cells ? <Raster key={`p${s.generation}`} columns={cols} rows={rows} cells={cells} /> : <Text color="red">glance: resize failed (magick)</Text>
216 }
217 return (
218 <Box flexDirection="column" width={cols}>
219 {pic}
220 <Box flexDirection="row" columnGap={1}>
221 <Box flexShrink={1}><Text dimColor wrap="truncate-end">{name}</Text></Box>
222 <Button key={`zoom${s.generation}`} plain dimColor label={isBig ? '⤡' : '⤢'} onPress={() => toggleSize($, s)} />
223 <Button key={`open${s.generation}`} plain dimColor label="↗" onPress={() => openOutside($, s)} />
224 </Box>
225 </Box>
226 )
227}
228
229// justified rows: as many images per row as fit at MIN_TILE columns, all of a row one height,
230// that height filling the width but never past any image's natural size (px / cellPx: no upscale);
231// an enlarged picture (⤢) takes a row of its own at the whole chat width
232async function gallery($: EngineInterface, e: ResolveArg, list: Shot[], width: number) {
233 const { Box, Text } = $.ui.resolve(e)
234 if (e.surface !== 'terminal') return <Text dimColor>Pictures draw in the terminal only.</Text>
235 // wide chat (one pane, full screen): at most half of it; split chat: the whole width
236 const avail = Math.max(1, Math.min(255, width >= WIDE ? Math.floor(width / 2) : width - 4))
237 const cell = await cellSize($)
238 const ratio = Number(await $.env.get('GLANCE_CELL_RATIO')) || (cell ? cell.h / cell.w : CELL_RATIO)
239 const cellPx = Number(await $.env.get('GLANCE_CELL_PX')) || CELL_PX
240 const perRow = Math.max(1, Math.min(list.length, Math.floor((avail + GAP) / (MIN_TILE + GAP))))
241 const ok = list.filter(s => !s.error)
242 const rows = []
243 for (const s of list.filter(s => s.error)) rows.push(await tile($, e, s, 1, 1, false, cell))
244 const big = await read($, expanded)
245 const full = Math.max(1, Math.min(255, width - 4))
246 // runs of compact pictures between the enlarged ones, each enlarged one a row of its own
247 const groups: Shot[][] = []
248 for (const s of ok) {
249 const last = groups[groups.length - 1]
250 if (big[String(s.generation)]) groups.push([s])
251 else if (last && !big[String(last[0]!.generation)] && last.length < perRow) last.push(s)
252 else groups.push([s])
253 }
254 for (const row of groups) {
255 if (big[String(row[0]!.generation)]) {
256 const s = row[0]!
257 const a = s.w / s.h
258 const height = Math.max(1, Math.min(255, Math.floor(full / (ratio * a))))
259 const cols = Math.max(1, Math.min(full, Math.round(ratio * a * height)))
260 rows.push(await tile($, e, s, cols, height, true, cell))
261 continue
262 }
263 // an image `a = w/h` wide is ratio * a * rows columns
264 const aspect = row.map(s => s.w / s.h)
265 const fill = (avail - GAP * (row.length - 1)) / (ratio * aspect.reduce((x, y) => x + y, 0))
266 const natural = Math.min(...row.map(s => s.h / cellPx / ratio))
267 const height = Math.max(1, Math.min(255, Math.floor(Math.min(fill, natural))))
268 const tiles = []
269 for (const [k, s] of row.entries()) {
270 const cols = Math.max(1, Math.min(avail, Math.round(ratio * aspect[k]! * height)))
271 tiles.push(await tile($, e, s, cols, height, false, cell))
272 }
273 rows.push(<Box flexDirection="row" gap={GAP} alignItems="flex-start">{tiles}</Box>)
274 }
275 return <Box flexDirection="column" rowGap={1}>{rows}</Box>
276}
277
278export const register: Register = on => {
279 on('session.start', async ($, e, next) => {
280 await $.command.register({ name: 'glance', description: 'Show images or HTML pages inline in the chat: /glance <file> [more files...]' })
281 return next(e)
282 })
283
284 on('command.run', { command: 'glance' }, async ($, e) => {
285 // note: paths split on spaces, so a path with a space in it is not supported
286 const paths = e.args.trim().split(/\s+/).filter(Boolean)
287 if (paths.length === 0) return { text: 'Usage: /glance <file.html | image> [more files...]' }
288 const done: Shot[] = []
289 for (const [i, path] of paths.entries()) done.push(await shoot($, `cmd:${e.args.trim()}#${i}`, path))
290 const bad = done.filter(s => s.error)
291 return { text: bad.length ? bad.map(s => `glance: ${s.error}`).join('\n') : `Rendered ${done.map(s => s.file).join(', ')}` }
292 })
293
294 // Claude writes/edits an .html or reads an image → picture under that tool row
295 on('tool.call', async ($, e, next) => {
296 const ran = await next(e)
297 const path = e.tool === 'Write' || e.tool === 'Edit' ? e.file_path : e.tool === 'Read' ? e.file_path : undefined
298 const wanted = path && (HTML.test(path) || (e.tool === 'Read' && IMG.test(path)))
299 if (wanted && !ran.deny && !ran.isError) await shoot($, e.tool_use_id, path)
300 return ran
301 })
302
303 on('ui.render', { component: 'CommandOutput', props: { command: 'glance' } }, async ($, e, next) => {
304 const all = await read($, shots)
305 const list: Shot[] = []
306 for (let i = 0; all[`cmd:${e.props.args.trim()}#${i}`]; i++) list.push(all[`cmd:${e.props.args.trim()}#${i}`]!)
307 const { Box } = $.ui.resolve(e)
308 const row = await next(e)
309 if (list.length === 0) return row
310 return <Box flexDirection="column">{row}{await gallery($, e, list, e.viewport?.columns ?? 80)}</Box>
311 })
312
313 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
314 const row = await next(e)
315 const s = (await read($, shots))[e.props.tool_use_id]
316 if (!s || e.props.isErrored) return row
317 const { Box } = $.ui.resolve(e)
318 return <Box flexDirection="column">{row}{await gallery($, e, [s], e.viewport?.columns ?? 80)}</Box>
319 })
320
321 // reads/edits fold into one summary line ("Read 1 file"): draw under it
322 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
323 const row = await next(e)
324 const all = await read($, shots)
325 const found = e.props.calls.flatMap(c => (c.tool_use_id && all[c.tool_use_id] ? [all[c.tool_use_id]!] : []))
326 if (found.length === 0) return row
327 const { Box } = $.ui.resolve(e)
328 return <Box flexDirection="column">{row}{await gallery($, e, found, e.viewport?.columns ?? 80)}</Box>
329 })
330}
331types/index.d.ts 9 lines1/** One picture drawn in the chat: the source file and the PNG/image shown. */
2export type Shot = { file: string; img: string; w: number; h: number; generation: number; error?: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'glance': { shots: Record<string, Shot>; generation: number; expanded: Record<string, boolean> }
7 }
8}
9