SLOPSHOPPER

reply-view

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

newbandtoastprocesstimer
★ 58v0.4.1MITupdated 2026-10-03Yrzhe/claude-skills/plugins/reply-view
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · reply-view
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM Start Copy-reply ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Start Copy-reply ⟨Claude Code's own drawing⟩
README

Reply View

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.

Install

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.

What appears

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.

  • Pasted images: an action for each [Image #n] tag in the current draft. Sending the draft or removing a tag removes that action.
  • Last-reply media and links: the most recent main-agent final answer supplies the images, videos, websites and local files. Click once to open the original target.
  • Copy-reply: all visible main-agent text from the preceding turn, with Markdown and newlines intact. User prompts, thinking, tool output and other agents are excluded.
  • Hover images in supported terminals: with an enabled image terminal and Claude's fullscreen renderer, move onto an image action to temporarily expand a large native image above the toolbar. Moving away collapses it. Video links show a still frame. There is no separate thumbnail tile or duplicate Open row.

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.

Requirements

  • Claude Code 2.1.287+; validated on 2.1.288.
  • macOS or Linux, with Python 3 for reply-media preparation and Open actions.
  • FFmpeg for video posters and broad image-format support. Without it, macOS uses sips for supported images; Linux can still preview PNG files.
  • A terminal with the kitty graphics protocol, including Unicode placeholders, plus Claude fullscreen mode, for native hover images. Ghostty/kitty can be detected automatically; see the Maestri startup flags above. Other terminals retain compact Open/Copy actions.

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.

Recognized references

[Cover](</absolute/path/my cover.png>)
[Video](./output/demo.mp4)
![Chart](https://example.com/render?id=123)
[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.

Data and permissions

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.

Development

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.

Attribution

The pasted-image cache discovery and aspect-ratio layout derive from jarrodwatts/claude-image-view. Its copyright and MIT license are retained in LICENSE.

Source 6 files
hooks/register.tsx 358 lines
1import { 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}
358
hooks/layout.ts 61 lines
1export 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}
61
hooks/strip.ts 53 lines
1import 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}
53
hooks/reply.ts 100 lines
1import 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}
100
hooks/hover-link.tsx 19 lines
1import 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
19
types/index.d.ts 22 lines
1export 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