Preview pasted images and media from the last Claude reply, open links and localhost, and copy the whole reply.

A Claude Code mod that keeps pasted images, media from the last reply, clickable links, and a copy button above the prompt.
Built on Claude Image View by Jarrod Watts, with reply previews and actions by yrzhe. Both are MIT licensed.
Inside Claude Code:
/plugin marketplace add Yrzhe/claude-skills
/plugin install reply-view@yrzhe-skills
/reload-plugins
If you already added the marketplace, refresh it first with /plugin marketplace update yrzhe-skills.
From a shell (use claude-work instead of claude if that is your account wrapper):
claude plugin marketplace add Yrzhe/claude-skills
claude plugin install reply-view@yrzhe-skills
Disable the original image-view plugin if you installed it: Reply View includes its pasted-image preview, so running both draws two preview bands.
The default strip is two rows: a fixed toolbar and one horizontal row of compact actions, such as Open image #2, Play video demo.mp4, and Open localhost:3000. Start stays on the far left, Copy-reply on the far right. Move with < / > or the mouse wheel over the strip. Item count never adds rows.
[Image #n] tag in the current draft. Sending the draft or removing a tag removes that action.Maestri: its terminal contains kitty graphics support, but Claude 2.1.288 does not automatically enable this path for it. Restart Claude with the following session-only flags (use your usual account wrapper):
CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 CLAUDE_CODE_NO_FLICKER=1 FORCE_HYPERLINK=1 claude-work --continue
This enables native images and mouse hover for that run. Reloading the plugin alone does not change Claude's startup detection. Reply View transmits PNG bytes directly; it does not ask the terminal to read local image files. Native hover requires a terminal that implements kitty graphics, including Unicode placeholders. Do not enable the flag in a terminal that cannot draw them.
Unknown terminals without image support keep compact one-click Open actions. No blocky substitute is drawn. FORCE_HYPERLINK=1 additionally exposes real file hyperlinks to a terminal application's own link handling; that application's own Quick Look behaviour is separate from this mod's hover preview.
Previous replies are restored from the transcript on load/resume. /clear clears old media. Plain-text replies still get Copy. Missing cached pastes are marked unavailable; failed reply-media preparation keeps its Open action.
sips for supported images; Linux can still preview PNG files.For example, with Homebrew:
brew install python ffmpeg
Videos show a still frame in the terminal; clicking their caption opens the URL or the system's default video player. This does not embed video playback in the terminal. The preview band targets the terminal, not Claude Desktop, VS Code's chat panel, or headless -p sessions.
[Cover](</absolute/path/my cover.png>)
[Video](./output/demo.mp4)

[clip.mp4](https://example.com/download?id=123)
https://example.com/photo.jpg?signature=...
http://localhost:3000
localhost:4321/preview
`./output/chart.webp`
file:///absolute/path/my%20cover.png
Slash commands such as /plugin and repository names such as Yrzhe/claude-skills are not links. Non-media local files must exist before an Open button is shown. Relative paths resolve against the session working directory. Use a Markdown destination in angle brackets or a backtick-wrapped path when a filename contains spaces. File references with :line or #Lline anchors open the file. Supported image extensions are PNG, JPEG, WebP, GIF, AVIF, BMP, TIFF, HEIC, and SVG; actual decoding depends on your converter build. Videos include MP4, M4V, MOV, WebM, MKV, AVI, and OGV. GIF/video previews use one frame.
Media detection uses the destination extension, a filename label, or Markdown image syntax. An extensionless ordinary link is shown as a link. Video-sharing pages and authenticated downloads are not scraped. A file path must exist on the machine running Claude Code; files inside another host/container are not downloaded automatically.
The mod reads the current prompt, the session transcript for restoration, and the files it previews. It never calls an AI model or uploads your files or replies.
On supported fullscreen image terminals, reply previews fetch referenced HTTP(S) media automatically when their actions are visible in the expanded strip, including localhost media. Offscreen items wait until you scroll to them. Other terminals do not download media automatically. A conversion already in progress may finish after scrolling. Requests go directly from your computer to that URL, with no cookies or authentication headers. Query parameters in the original URL are preserved. Ordinary web links are opened only when you click Open. Downloads are limited to 64 MiB and bounded by timeouts; video pages or larger downloads fall back to an Open action.
A Python helper runs FFmpeg (or sips) without a shell. It writes PNG thumbnails to the OS temporary directory under claude-image-view-<uid>, removes temporary download files after conversion, and prunes its own thumbnails older than seven days when another preview is prepared. Original files are never changed. Opening uses open on macOS or xdg-open on Linux. Copy uses Claude Code's native ui.copy API. No dependencies are installed automatically.
From this plugin's directory:
claude-work --plugin-dir .
claude-work plugin validate .claude-plugin/plugin.json
claude-work plugin test .
python3 -m unittest discover -s tests -p 'test_*.py' -v
tsc -p .
Claude writes its version-specific TypeScript declarations and tsconfig.json when it loads the mod. Type-check after that first load. claude works in place of claude-work.
The mod tests exercise parsing, session state, UI element validation, copy/open callbacks, horizontal scrolling with 1,000 items, visible-only loading, pointer enter/leave expansion and compact fallback actions, and failure cases without model calls. The Python suite converts real local and HTTP-served images/videos; it mocks desktop opening so tests do not launch applications. The fullscreen expand/collapse path is additionally exercised with terminal pointer events. The Maestri test terminal also receives native inline PNG transmissions. Physical high-resolution painting and host-specific link preview behaviour still require visual confirmation in the actual terminal application.
The pasted-image cache discovery and aspect-ratio layout derive from jarrodwatts/claude-image-view. Its copyright and MIT license are retained in LICENSE.
hooks/register.tsx 358 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PastedImage } from '../types'
5import { imageNumbers, pngSize } from './layout'
6import { hoverLayout, linkHref, stripItems, stripLayout } from './strip'
7import type { StripItem } from './strip'
8import type { Size } from './layout'
9import { lastReply, makeReply } from './reply'
10import type { Preview, Reply } from '../types'
11
12// Pasting an image raises no prompt.edit (the tag only shows up on the next keystroke),
13// so the draft is polled instead.
14const POLL_MS = 200
15
16const images = atom({ plugin: 'reply-view', key: 'images' } as const, [] as PastedImage[])
17const reply = atom({ plugin: 'reply-view', key: 'reply' } as const, null as Reply | null)
18const hovered = atom({ plugin: 'reply-view', key: 'hovered' } as const, null as string | null)
19let generation = 0
20let scrollOffset = 0
21let nativeImages = false
22let terminalLinks = true
23let wanted: StripItem[] = []
24let previewTimerStarted = false
25let stripBounds: { requestId: string; rows: number; width: number; maxOffset: number } | undefined
26let activeTurn = ''
27let steps = new Map<number, string>()
28let sessionKey = ''
29let interactive = true
30let previewBusy = false
31const nativeSources = new Map<string, { png?: string; size?: Size; error?: string }>()
32
33async function helper($: EngineInterface, request: object): Promise<{ path?: string; size?: Size; error?: string }> {
34 const root = $.plugin.root
35 const result = await $.process.run(['python3', `${root}/scripts/media.py`], {
36 stdin: JSON.stringify(request), timeoutMs: 40000,
37 })
38 let data
39 try { data = JSON.parse(result.stdout) } catch { throw new Error('Media helper returned no result') }
40 if (result.exitCode !== 0 || data.error) throw new Error(data.error || 'System action failed')
41 return data
42}
43
44async function action($: EngineInterface, request: object, success: string) {
45 try {
46 await helper($, request)
47 $.ui.toast(success)
48 } catch (error) {
49 $.ui.toast(error instanceof Error ? error.message : 'System action failed')
50 }
51}
52
53// The render hook records only the visible window. A timer performs one job at
54// a time outside rendering; scrolling stops offscreen downloads.
55function startPreviewTimer($: EngineInterface) {
56 if (previewTimerStarted) return
57 previewTimerStarted = true
58 $.clock.every(100, () => prepareVisible($))
59}
60
61async function prepareVisible($: EngineInterface) {
62 if (previewBusy || wanted.length === 0) return
63 const token = generation
64 const current = await read($, reply)
65 const item = wanted.find(candidate => candidate.target && !nativeSources.has(candidate.target) &&
66 (candidate.kind === 'paste' || current?.media.some(media => media.target === candidate.target && media.status !== 'unavailable')))
67 if (!item?.target || token !== generation || previewBusy) return
68 previewBusy = true
69 try {
70 let result: Partial<Preview>
71 try {
72 let data: { path?: string; size?: Size } = { path: item.path ?? undefined, size: item.size ?? undefined }
73 let png: string | undefined
74 if (item.kind === 'paste') {
75 png = await $.fs.read(item.target, { as: 'bytes' }).then(value => value.base64).catch(() => undefined)
76 if (png && png.length > 2796200) png = undefined
77 }
78 if (!png) {
79 data = await helper($, { action: 'preview', target: item.target, kind: item.kind, cacheKey: `${sessionKey}:${token}` })
80 if (!data.path || !data.size) throw new Error('No preview available')
81 png = (await $.fs.read(data.path, { as: 'bytes' })).base64
82 }
83 if (!png || png.length > 2796200) throw new Error('Preview exceeds image transport limit')
84 if (token !== generation) return
85 nativeSources.set(item.target, { png, size: data.size })
86 while (nativeSources.size > 12) nativeSources.delete(nativeSources.keys().next().value!)
87 result = { path: data.path ?? item.path, size: data.size ?? item.size, status: 'ready' }
88 } catch (error) {
89 if (token !== generation) return
90 nativeSources.set(item.target, { error: 'Preview unavailable' })
91 result = { status: 'unavailable', error: error instanceof Error ? error.message : 'No preview available' }
92 }
93 if (token === generation && item.kind !== 'paste') await update($, reply, value => value && ({ ...value, media: value.media.map(media => media.target === item.target ? { ...media, ...result } : media) }))
94 $.ui.invalidate('ui.render')
95 } finally { previewBusy = false }
96}
97
98async function setReply($: EngineInterface, text: string, answer: string) {
99 const token = ++generation
100 nativeSources.clear()
101 await update($, hovered, () => null)
102 scrollOffset = 0
103 wanted = []
104 const cwd = await $.session.cwd()
105 const id = await $.session.id()
106 if (token !== generation) return
107 sessionKey = id
108 const value = makeReply(text, answer, cwd)
109 if (value.links.some(link => link.target.startsWith('~/'))) {
110 const home = await $.env.get('HOME').catch(() => undefined)
111 if (home) {
112 for (const link of [...value.links, ...value.media]) {
113 if (link.target.startsWith('~/')) link.target = `${home}/${link.target.slice(2)}`
114 }
115 }
116 }
117 const existing = await Promise.all(value.links.map(async link => link.kind !== 'link' || /^https?:/.test(link.target) || await $.fs.exists(link.target).catch(() => false)))
118 value.links = value.links.filter((_, index) => existing[index])
119 if (token !== generation) return
120 await update($, reply, () => value)
121 startPreviewTimer($)
122}
123
124async function restore($: EngineInterface) {
125 const token = generation
126 const saved = lastReply(await $.session.messages().catch(() => []))
127 if (saved && token === generation) await setReply($, saved.text, saved.answer)
128}
129
130let tmpRoot: string | undefined
131let found: { sessionId: string; dir: string } | undefined
132// The image numbers last drawn, so an unchanged draft doesn't rewrite state; undefined
133// while a drawn image's file is still missing, so the next poll looks again.
134let shownKey: string | undefined
135let isChecking = false
136const sizes = new Map<string, Size | null>()
137
138// Claude Code caches each paste as <tmp>/<project>/<session>/images/<n>.png. The project
139// folder is named after a working directory that may since have moved, so find it by the
140// session id instead of rebuilding it.
141async function imagesDir($: EngineInterface): Promise<string | undefined> {
142 const sessionId = await $.session.id()
143 if (found?.sessionId === sessionId) return found.dir
144 if (tmpRoot === undefined) {
145 const fromEnv = await $.env.get('CLAUDE_CODE_TMPDIR')
146 tmpRoot = fromEnv ?? `/tmp/claude-${(await $.process.run(['id', '-u'])).stdout.trim()}`
147 }
148 const entries = await $.fs.list(tmpRoot).catch(() => [])
149 for (const entry of entries) {
150 const dir = `${tmpRoot}/${entry.name}/${sessionId}/images`
151 if (entry.kind === 'dir' && (await $.fs.exists(dir))) {
152 found = { sessionId, dir }
153 return dir
154 }
155 }
156 return undefined
157}
158
159async function describe($: EngineInterface, dir: string | undefined, n: number): Promise<PastedImage> {
160 const path = `${dir}/${n}.png`
161 if (dir === undefined || !(await $.fs.exists(path))) return { n, path: null, size: null }
162 if (!sizes.has(path)) {
163 const head = await $.fs.read(path, { as: 'bytes' }).then(
164 ({ base64 }) => pngSize(base64),
165 () => undefined, // too big to read: still drawable, just without its aspect ratio
166 )
167 if (head === null) return { n, path: null, size: null }
168 sizes.set(path, head ?? null)
169 }
170 return { n, path, size: sizes.get(path) ?? null }
171}
172
173async function show($: EngineInterface, draft: string) {
174 const token = generation
175 const numbers = imageNumbers(draft)
176 const key = numbers.join(',')
177 if (key === shownKey) return
178 const dir = numbers.length > 0 ? await imagesDir($) : undefined
179 const list: PastedImage[] = []
180 for (const n of numbers) list.push(await describe($, dir, n))
181 if (token !== generation) return
182 shownKey = list.every(image => image.path !== null) ? key : undefined
183 await update($, images, () => list)
184}
185
186async function check($: EngineInterface) {
187 if (isChecking) return
188 isChecking = true
189 try {
190 const id = await $.session.id()
191 if (sessionKey !== id) {
192 sessionKey = id
193 shownKey = undefined
194 found = undefined
195 sizes.clear()
196 }
197 await show($, (await $.prompt.read()).text)
198 } finally {
199 isChecking = false
200 }
201}
202
203export const register: Register = on => {
204 on('session.start', async ($, e, next) => {
205 interactive = e.isInteractive && e.surface === 'terminal'
206 if (!interactive) return next(e)
207 const program = (await $.env.get('TERM_PROGRAM').catch(() => '') ?? '').toLowerCase()
208 const term = (await $.env.get('TERM').catch(() => '') ?? '').toLowerCase()
209 nativeImages = program === 'ghostty' || program === 'kitty' || term === 'xterm-kitty' || await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES').catch(() => '') === '1'
210 terminalLinks = nativeImages || await $.env.get('FORCE_HYPERLINK').catch(() => '') === '1'
211 await restore($)
212 startPreviewTimer($)
213 $.clock.every(POLL_MS, () => check($))
214 return next(e)
215 })
216
217 on('session.end', async ($, e, next) => {
218 generation++
219 wanted = []
220 stripBounds = undefined
221 scrollOffset = 0
222 activeTurn = ''
223 steps.clear()
224 found = undefined
225 shownKey = undefined
226 sizes.clear()
227 nativeSources.clear()
228 await update($, hovered, () => null)
229 await update($, reply, () => null)
230 await update($, images, () => [])
231 return next(e)
232 })
233
234 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
235 await restore($)
236 return next(e)
237 })
238
239 on('turn.start', async ($, e, next) => {
240 activeTurn = e.turnId
241 steps.clear()
242 return next(e)
243 })
244
245 on('turn.step', async function* ($, e, next) {
246 const result = yield* next(e)
247 if (!e.agentId && e.turnId === activeTurn && result.answer) steps.set(e.index, result.answer)
248 return result
249 })
250
251 on('turn.complete', async ($, e, next) => {
252 if (!e.agentId && interactive) {
253 const texts = e.turnId === activeTurn ? [...steps.entries()].sort((a, b) => a[0] - b[0]).map(([, text]) => text) : []
254 if (e.answer && texts.at(-1) !== e.answer) texts.push(e.answer)
255 const text = texts.join('\n\n')
256 if (text) await setReply($, text, e.answer)
257 else {
258 generation++
259 await update($, reply, () => null)
260 }
261 }
262 return next(e)
263 })
264
265 on('ui.message', async ($, e, next) => {
266 if (e.module !== 'hooks/hover-link.tsx') return next(e)
267 const data = e.data as { action?: string; id?: string } | null
268 if (!data || typeof data.id !== 'string' || e.element !== `hover-${data.id}`) return next(e)
269 const item = stripItems(await read($, images), await read($, reply)).find(item => item.key === data.id)
270 if (!item?.target) return {}
271 if (data.action === 'open') await action($, { action: 'open', target: item.target }, 'Opened')
272 else if (data.action === 'hover') await update($, hovered, () => item.key)
273 else if (data.action === 'leave') await update($, hovered, value => value === item.key ? null : value)
274 return {}
275 })
276
277 on('ui.scroll', { component: 'AbovePrompt' }, async ($, e, next) => {
278 const box = stripBounds
279 if (!box || e.requestId !== box.requestId || e.origin.kind !== 'person' || !e.pointer ||
280 e.pointer.row < 1 || e.pointer.row >= box.rows || e.pointer.column < 0 || e.pointer.column >= box.width || box.maxOffset === 0) return next(e)
281 scrollOffset = Math.max(0, Math.min(box.maxOffset, scrollOffset + Math.sign(e.by)))
282 wanted = []
283 $.ui.invalidate('ui.render')
284 return {}
285 })
286
287 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
288 if (e.surface !== 'terminal' || e.props.hasSurvey) { wanted = []; return next(e) }
289 const pasted = await read($, images)
290 const previous = await read($, reply)
291 const hoveredId = await read($, hovered)
292 const below = await next(e)
293 const width = Math.floor(e.props.bodyColumns)
294 const all = stripItems(pasted, previous)
295 if ((!previous && all.length === 0) || width < 20 || e.props.maxRows < 1) {
296 wanted = []; stripBounds = undefined; return below
297 }
298 const { Box, Image, Client, Markdown, Text, Button } = $.ui.resolve(e)
299 const layout = stripLayout(all.length, width, e.props.maxRows, scrollOffset)
300 const visible = all.slice(layout.start, layout.start + layout.visibleCount)
301 const expanded = layout.rows >= 2 && all.length > 0
302 const canHoverImage = nativeImages && e.viewport?.isFullscreen === true
303 wanted = expanded && canHoverImage ? visible.filter(item => item.kind !== 'link') : []
304 const hoverItem = expanded && canHoverImage ? visible.find(item => item.key === hoveredId && item.target && item.kind !== 'link') : undefined
305 const hoverSource = hoverItem?.target ? nativeSources.get(hoverItem.target) : undefined
306 const hoverSize = hoverItem && e.props.maxRows > 3 ? hoverLayout(hoverSource?.size ?? hoverItem.size, width, e.props.maxRows - 2) : undefined
307 const extraRows = hoverSize?.rows ?? 0
308 stripBounds = { requestId: e.requestId, rows: expanded ? layout.rows + extraRows : 1, width, maxOffset: layout.maxOffset }
309
310 const move = async (offset: number) => {
311 await update($, hovered, () => null)
312 scrollOffset = Math.max(0, Math.min(layout.maxOffset, offset))
313 wanted = []
314 $.ui.invalidate('ui.render')
315 }
316 return (
317 <Box key="reply-strip" flexDirection="column">
318 {hoverItem && hoverSize && <Box key="hover-preview" width={width} height={hoverSize.rows} justifyContent="center">
319 {hoverSource?.png ? <Image key={`image-${hoverItem.key}`} source={{ png: hoverSource.png }} columns={hoverSize.columns} rows={hoverSize.rows} alt="Open image to view" /> : <Text dimColor>{hoverSource?.error || 'Loading image…'}</Text>}
320 </Box>}
321 <Box key="strip-toolbar" flexDirection="row" justifyContent="space-between" height={1} width={Math.max(1, width - 4)}>
322 <Box key="strip-left" flexDirection="row" columnGap={1}>
323 <Button key="scroll-home" plain label={width >= 60 ? 'Start' : '|<'} dimColor={layout.start === 0} onPress={() => move(0)} />
324 {layout.maxOffset > 0 && <Button key="scroll-left" plain label="<" dimColor={layout.start === 0} onPress={() => move(layout.start - 1)} />}
325 {all.length > 0 && width >= 50 && <Text dimColor>{layout.start + 1}-{Math.min(all.length, layout.start + layout.visibleCount)}/{all.length}</Text>}
326 {layout.maxOffset > 0 && <Button key="scroll-right" plain label=">" dimColor={layout.start === layout.maxOffset} onPress={() => move(layout.start + 1)} />}
327 </Box>
328 <Box key="strip-right" flexDirection="row" columnGap={1}>
329 {previous && <Button key="copy-reply" plain label="Copy-reply" onPress={async press => {
330 try {
331 const result = await $.ui.copy({ text: previous.text, surface: press.surface })
332 $.ui.toast(result.isCopied ? 'Reply copied' : `Could not copy: ${result.reason}`)
333 } catch { $.ui.toast('Could not copy reply') }
334 }} />}
335 </Box>
336 </Box>
337 {expanded && <Box key="strip-window" flexDirection="row" columnGap={2} height={layout.rows - 1} width={width}>
338 {visible.map(item => {
339 const href = item.target ? linkHref(item.target) : undefined
340 const prefix = item.kind === 'link' ? 'Open' : item.kind === 'video' ? 'Play video' : 'Open image'
341 const name = item.kind === 'paste' ? item.label.replace('Pasted ', '') : item.label
342 const label = (name.toLowerCase() === 'image' || name.toLowerCase() === 'video' ? prefix : `${prefix} ${name}`).slice(0, Math.max(8, layout.cardWidth - 1))
343 const safeLabel = label.replace(/[\[\]\\`*_<>]/g, '')
344
345 return <Box key={`card-${item.key}`} flexDirection="column" width={layout.cardWidth} height={1}>
346 {href && canHoverImage && item.kind !== 'link' ? <Client key={`hover-${item.key}`} module="./hover-link.tsx" props={{ id: item.key, text: `[${safeLabel}](${href})`, href }} width={layout.cardWidth} height={1} /> : href && terminalLinks ? <Markdown key={`open-${item.key}`} text={`[${safeLabel}](${href})`} onLinkPress={link => {
347 if (link.href === href) void action($, { action: 'open', target: item.target }, 'Opened')
348 }} /> : item.target ? <Button key={`open-${item.key}`} plain label={label} onPress={() => action($, { action: 'open', target: item.target }, 'Opened')} /> : <Text dimColor>Image unavailable</Text>}
349
350 </Box>
351 })}
352 </Box>}
353 {below}
354 </Box>
355 )
356 })
357}
358hooks/layout.ts 61 lines1export type Size = { width: number; height: number }
2export type Cells = { columns: number; rows: number }
3
4const TILE_ROWS = 6
5const MAX_COLUMNS = 32
6const MIN_COLUMNS = 4
7// A terminal cell is about twice as tall as it is wide.
8const CELL_ASPECT = 2
9// Used when the size is unknown (file over $.fs.read's 4 MiB cap, or no file).
10const FALLBACK: Size = { width: 16, height: 10 }
11// Each tile adds a border on every side and a label row under the picture.
12const TILE_CHROME_ROWS = 3
13const TILE_CHROME_COLUMNS = 2
14const GAP = 1
15
16/** The distinct image numbers a draft references, in the order they first appear. */
17export function imageNumbers(draft: string): number[] {
18 const seen = new Set<number>()
19 for (const match of draft.matchAll(/\[Image #(\d+)\]/g)) seen.add(Number(match[1]))
20 return [...seen]
21}
22
23/** Width and height from a PNG's IHDR chunk, or null when the bytes aren't a PNG. */
24export function pngSize(base64: string): Size | null {
25 // 24 bytes cover the signature and IHDR's width and height; 32 base64 chars decode to exactly 24.
26 const head = Uint8Array.from(atob(base64.slice(0, 32)), char => char.charCodeAt(0))
27 const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
28 if (head.length < 24 || signature.some((byte, i) => head[i] !== byte)) return null
29 const view = new DataView(head.buffer, head.byteOffset, head.byteLength)
30 const width = view.getUint32(16)
31 const height = view.getUint32(20)
32 return width > 0 && height > 0 ? { width, height } : null
33}
34
35/** A picture box `rows` tall that keeps the picture's aspect ratio. */
36export function fitCells(size: Size | null, tileRows = TILE_ROWS): Cells {
37 const { width, height } = size ?? FALLBACK
38 let rows = tileRows
39 let columns = Math.round((rows * CELL_ASPECT * width) / height)
40 if (columns > MAX_COLUMNS) {
41 columns = MAX_COLUMNS
42 rows = Math.max(1, Math.round((MAX_COLUMNS * height) / (CELL_ASPECT * width)))
43 }
44 return { columns: Math.max(MIN_COLUMNS, columns), rows: Math.min(rows, tileRows) }
45}
46
47/**
48 * Picture boxes for one row of tiles that fits the band whole, so it never scrolls:
49 * the tallest tiles whose chrome fits in `maxRows` and whose total width fits in `bodyColumns`.
50 */
51export function fitRow(sizes: readonly (Size | null)[], maxRows: number, bodyColumns: number): Cells[] {
52 const tallest = Math.max(1, Math.min(TILE_ROWS, maxRows - TILE_CHROME_ROWS))
53 for (let tileRows = tallest; tileRows > 1; tileRows--) {
54 const cells = sizes.map(size => fitCells(size, tileRows))
55 const width = cells.reduce((sum, c) => sum + c.columns + TILE_CHROME_COLUMNS, 0) + GAP * (cells.length - 1)
56 if (width <= bodyColumns) return cells
57 }
58 const available = Math.max(1, Math.floor((bodyColumns - GAP * (sizes.length - 1)) / Math.max(1, sizes.length)) - TILE_CHROME_COLUMNS)
59 return sizes.map(size => ({ ...fitCells(size, 1), columns: Math.min(fitCells(size, 1).columns, available) }))
60}
61hooks/strip.ts 53 lines1import type { PastedImage, Reply } from '../types'
2import type { Size } from './layout'
3
4export type StripItem = {
5 key: string
6 label: string
7 kind: 'paste' | 'image' | 'video' | 'link'
8 target: string | null
9 path: string | null
10 size: Size | null
11 status: 'ready' | 'pending' | 'unavailable'
12}
13
14export function stripItems(pasted: PastedImage[], reply: Reply | null): StripItem[] {
15 const media = new Map(reply?.media.map(item => [item.target, item]))
16 return [
17 ...pasted.map(item => ({ key: `paste-${item.n}`, label: `Pasted #${item.n}`, kind: 'paste' as const,
18 target: item.path, path: item.path, size: item.size, status: item.path ? 'ready' as const : 'unavailable' as const })),
19 ...(reply?.links ?? []).map((link, index) => {
20 const preview = media.get(link.target)
21 const label = link.kind === 'link' && /^https?:/.test(link.target) ? new URL(link.target).host : link.label
22 return { ...link, label, key: `reply-${index}`, path: preview?.path ?? null, size: preview?.size ?? null,
23 status: preview?.status ?? 'ready' as const }
24 }),
25 ]
26}
27
28/** One toolbar plus a single horizontal row. Item count never increases height. */
29export function stripLayout(count: number, width: number, maxRows: number, requestedOffset: number) {
30 const visibleCount = Math.max(1, Math.min(4, Math.floor((width + 2) / 26)))
31 const maxOffset = Math.max(0, count - visibleCount)
32 const start = Math.min(Math.max(0, requestedOffset), maxOffset)
33 const cardWidth = Math.max(1, Math.min(32, Math.floor((width - 2 * (visibleCount - 1)) / visibleCount)))
34 const rows = Math.max(0, Math.min(2, maxRows))
35 return { visibleCount, maxOffset, cardWidth, rows, start }
36}
37
38/** A real file URL lets terminal applications handle their own file previews. */
39export function linkHref(target: string): string | undefined {
40 if (/^https?:\/\//.test(target)) return target
41 if (target.startsWith('/')) return `file://${target.split('/').map(encodeURIComponent).join('/')}`
42 return undefined
43}
44
45export function hoverLayout(size: Size | null, width: number, maxRows: number) {
46 const availableColumns = Math.max(1, Math.min(96, width))
47 const availableRows = Math.max(1, Math.min(22, maxRows))
48 const aspect = size ? size.width / size.height : 1.5
49 const columns = Math.max(1, Math.min(availableColumns, Math.round(availableRows * 2 * aspect)))
50 const rows = Math.max(1, Math.min(availableRows, Math.round(columns / (2 * aspect))))
51 return { columns, rows }
52}
53hooks/reply.ts 100 lines1import type { SessionMessage } from 'claude-code'
2import type { ReplyLink, Reply } from '../types'
3
4const IMAGE = /\.(png|jpe?g|webp|gif|avif|bmp|tiff?|heic|svg)$/i
5const VIDEO = /\.(mp4|m4v|mov|webm|mkv|avi|ogv)$/i
6const LOCALHOST = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)(?:[/?#]|$)/i
7
8/** Only web URLs and explicit file paths become actions; never executable schemes. */
9export function normalizeTarget(raw: string, cwd: string): string | null {
10 let value = raw.trim().replace(/^<|>$/g, '')
11 if (!value || /[\x00-\x1f\x7f]/.test(value)) return null
12 if (LOCALHOST.test(value)) value = `http://${value}`
13 if (/^https?:\/\//i.test(value)) {
14 try {
15 const url = new URL(value)
16 if (!url.hostname || url.username || url.password) return null
17 if (url.hostname === '0.0.0.0') url.hostname = 'localhost'
18 return url.href
19 } catch { return null }
20 }
21 if (/^file:\/\//i.test(value)) {
22 try {
23 const url = new URL(value)
24 if (url.hostname && url.hostname !== 'localhost') return null
25 value = decodeURIComponent(url.pathname)
26 } catch { return null }
27 } else if (/^[a-z][a-z\d+.-]*:/i.test(value)) return null
28 // Claude's file citations may have :line or #Lline suffixes.
29 value = value.replace(/(?::\d+(?::\d+)?|#L\d+(?:-L?\d+)?)$/, '')
30 // Slash commands are not root-level file references. Repository slugs and
31 // package names are not relative files unless they carry a file extension.
32 if (/^\/[a-z][\w-]*(?:\s|$)/i.test(value)) return null
33 if (value.startsWith('~/')) return value
34 if (value.startsWith('/')) return value
35 if (value.startsWith('./') || value.startsWith('../') || (/^[\w.-]+\/.+/.test(value) && /\.[a-z\d]{1,10}$/i.test(value))) {
36 try { return decodeURIComponent(new URL(value, `file://${cwd.replace(/\/$/, '')}/`).pathname) }
37 catch { return null }
38 }
39 if (IMAGE.test(value) || VIDEO.test(value)) return `${cwd}/${value}`
40 return null
41}
42
43function trimBare(value: string): string {
44 value = value.replace(/[.,;:!?,。;:!?、]+$/, '')
45 while (value.endsWith(')') && (value.match(/\)/g)?.length ?? 0) > (value.match(/\(/g)?.length ?? 0)) value = value.slice(0, -1)
46 return value
47}
48
49export function extractLinks(text: string, cwd: string): ReplyLink[] {
50 const candidates: { raw: string; label?: string; image?: boolean; index: number }[] = []
51 const covered: [number, number][] = []
52 // Angle-bracket destinations support spaces; bare destinations support balanced parentheses.
53 const markdown = /!?\[([^\]\n]*)\]\(\s*(<[^>\n]+>|(?:[^\s()]|\([^()]*\))+)(?:\s+["'][^\n]*?["'])?\s*\)/g
54 for (const match of text.matchAll(markdown)) {
55 candidates.push({ raw: match[2]!, label: match[1], image: match[0].startsWith('!'), index: match.index! })
56 covered.push([match.index!, match.index! + match[0].length])
57 }
58 for (const match of text.matchAll(/`([^`\n]+)`/g)) {
59 candidates.push({ raw: match[1]!, index: match.index! })
60 covered.push([match.index!, match.index! + match[0].length])
61 }
62 const bare = /(?:https?:\/\/|file:\/\/|(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]):\d+)[^\s<>"'`\u3000-\u303f\uff00-\uffef]*|(?:~\/|\.{0,2}\/)[^\s<>"'`\u3000-\u303f\uff00-\uffef]+\.(?:png|jpe?g|webp|gif|avif|bmp|tiff?|heic|svg|mp4|m4v|mov|webm|mkv|avi|ogv)(?::\d+)?/gi
63 for (const match of text.matchAll(bare)) {
64 if (covered.some(([a, b]) => match.index! >= a && match.index! < b)) continue
65 // Do not turn the // inside a rejected custom scheme into a local path.
66 if (match.index! > 0 && /[\w:/]/.test(text[match.index! - 1]!)) continue
67 candidates.push({ raw: trimBare(match[0]), index: match.index! })
68 }
69 const seen = new Set<string>()
70 const links: ReplyLink[] = []
71 for (const { raw, label, image } of candidates.sort((a, b) => a.index - b.index)) {
72 const target = normalizeTarget(raw, cwd)
73 if (!target || seen.has(target)) continue
74 seen.add(target)
75 const path = /^https?:/.test(target) ? new URL(target).pathname : target
76 const kind = image || IMAGE.test(path) || IMAGE.test(label ?? '') ? 'image' : VIDEO.test(path) || VIDEO.test(label ?? '') ? 'video' : 'link'
77 links.push({ target, label: (label || target.split('/').pop() || target).slice(0, 200), kind })
78 }
79 return links
80}
81
82/** Restore the last completed visible response without including tool output or user text. */
83export function lastReply(messages: readonly SessionMessage[]): { text: string; answer: string } | null {
84 let end = messages.length - 1
85 while (end >= 0 && !(messages[end]!.role === 'assistant' && messages[end]!.text && messages[end]!.toolUses.length === 0)) end--
86 if (end < 0) return null
87 let start = end
88 while (start > 0) {
89 const previous = messages[start - 1]!
90 if (previous.role === 'user' && !previous.toolResults?.length) break
91 start--
92 }
93 return { answer: messages[end]!.text, text: messages.slice(start, end + 1).filter(m => m.role === 'assistant' && m.text).map(m => m.text).join('\n\n') }
94}
95
96export function makeReply(text: string, answer: string, cwd: string): Reply {
97 const links = extractLinks(answer, cwd)
98 return { text, links, media: links.filter(link => link.kind !== 'link').map(link => ({ ...link, path: null, size: null, status: 'pending' })) }
99}
100hooks/hover-link.tsx 19 lines1import type { ClientModule } from 'claude-code'
2
3type Props = { id: string; text: string; href: string }
4const HoverLink: ClientModule<Props, { inside: boolean; pressed: boolean }> = (props, surface) => {
5 const { Markdown } = surface.elements
6 surface.onPointer(event => {
7 const inside = event.type !== 'leave' && event.x >= 0 && event.y >= 0 && event.x < surface.columns && event.y < surface.rows
8 const previous = surface.state ?? { inside: false, pressed: false }
9 const clicked = inside && previous.pressed && event.type === 'up' && event.button === 'left'
10 const pressed = event.type === 'down' && event.button === 'left' ? inside : event.type === 'up' || !inside ? false : previous.pressed
11 surface.setState({ inside, pressed })
12 if (clicked) surface.post({ action: 'open', id: props.id })
13 else if (inside !== previous.inside) surface.post({ action: inside ? 'hover' : 'leave', id: props.id })
14 })
15 surface.onKey(event => { if (event.key === 'return') surface.post({ action: 'open', id: props.id }) })
16 return <Markdown key={`open-${props.id}`} text={props.text} />
17}
18export default HoverLink
19types/index.d.ts 22 lines1export type PastedImage = {
2 n: number
3 /** Absolute path of the cached PNG; null when it can't be found. */
4 path: string | null
5 /** Pixel size; null when unknown, and the tile falls back to a default shape. */
6 size: { width: number; height: number } | null
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'reply-view': { images: PastedImage[]; reply: Reply | null; hovered: string | null }
12 }
13}
14export type ReplyLink = { target: string; label: string; kind: 'image' | 'video' | 'link' }
15export type Preview = ReplyLink & {
16 path: string | null
17 size: { width: number; height: number } | null
18 status: 'pending' | 'ready' | 'unavailable'
19 error?: string
20}
21export type Reply = { text: string; links: ReplyLink[]; media: Preview[] }
22