SLOPSHOPPER

peek

Shows images inline in the transcript: pasted, read, written and linked in replies, with a button to open each in Preview and /peek to put an image on the…

newbandrowscommandtoastprompt
v0.2.0no licenseupdated 2026-10-23vvojciech/claude-kit/plugins/peek
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · peek
› 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 › /peek ⎿ peek: /peek <path> puts that image on the clipboard; /peek last takes the newest screenshot in Desktop or Downloads; ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

peek

Claude Code shows an image as a line of text: [Image #1], Read image (412KB), or the alt text of a markdown link. peek draws a small thumbnail of the picture under that row, with a ⤢ open button that opens the original in Preview. /peek puts an image on the clipboard so ctrl+v attaches it to your next prompt. macOS only, in iTerm2, kitty or Ghostty, outside tmux.

Thumbnails under a pasted image and a Read row, each with its name, pixel size and a ⤢ open button, the second under the pointer. Above the prompt, a 3-row band shows a dragged-in path with ⧉ clip.

Drawn with lucid loaded. The small picture just above the prompt line is the band for the draft you're typing, not part of the conversation.

Install

/plugin marketplace add vvojciech/claude-kit
/plugin install peek@vvojciech

Requirements

  • macOS. Conversion, ⤢ open and /peek use sips, open and the macOS clipboard. peek doesn't check the platform, so elsewhere it loads but those fail.
  • iTerm2, kitty or Ghostty. Terminal.app can't draw pictures.
  • In iTerm2, Claude Code 2.1.305 or later. kitty and Ghostty draw on any version peek runs on.
  • Not inside tmux or screen. Claude Code can't pass pictures through them yet.
  • Fullscreen rendering (/tui fullscreen).

Where pictures can't draw, peek shows one line per image instead, ▣ shot.png ⤢ open, and the button still works.

With lucid loaded, peek has to load before it. See load order.

Features

Thumbnails

  • Images pasted into a prompt, under the prompt row. peek reads them from Claude Code's per-session image cache.
  • Images Claude reads, writes or edits (.png .jpg .jpeg .gif .webp .heic), under the tool row, or under a folded group of rows once it settles.
  • Image files a Bash command names, such as sips ... --out chart.png, if the file exists afterwards.
  • Local alt images in Claude's replies, absolute, ~/ or relative to the working directory.
  • Each thumbnail keeps the image's aspect ratio, is at most 8 rows tall and 48 columns wide, and shows the file name and pixel size beside it.
  • Thumbnails under a row stack in a column, up to 4 per transcript row.
  • Large or non-PNG images are shrunk to a PNG with sips and cached.
  • ⤢ open opens the original file in Preview, or in its default app if Preview refuses. If both fail you get a toast with the reason.

peek adds to the rows other mods draw rather than replacing them, so copy-that's ⧉ chips keep working. With lucid loaded, a reply image lucid already draws in place isn't repeated under the reply.

Band above the prompt

  • While you type, a strip just above the prompt shows the images in the draft.
  • It shows each [Image #N] you pasted, and any bare image path, such as one iTerm2 pastes when you drag a file in.
  • Band thumbnails are at most 3 rows tall and sit side by side.
  • A bare path gets a ⧉ clip button that puts it on the clipboard, so ctrl+v turns it into a real attachment.

Commands

A mod can't attach image bytes to a prompt, so peek puts the image on the macOS clipboard and you press ctrl+v:

  • /peek <path> takes a file, absolute, ~/ or relative. Quotes and \ escapes are fine.
  • /peek last takes the newest screenshot or PNG in your screenshot folder (the one macOS saves screenshots to), ~/Desktop or ~/Downloads.
  • /peek clipboard-check says whether the clipboard already holds an image.
  • /peek on its own, or /peek help, prints the usage.

The image goes on the clipboard as PNG and a toast says Image on clipboard: press ctrl+v. This replaces whatever was on the clipboard.

Lines instead of pictures

Thumbnails fall back to ▣ name ⤢ open lines when:

  • you're inside tmux or screen;
  • fullscreen is off (turn it on with /tui fullscreen);
  • the terminal isn't iTerm2, kitty or Ghostty;
  • you run kitty with a TERM that doesn't contain "kitty";
  • you're in iTerm2 on Claude Code before 2.1.305;
  • Claude Code refused to draw the first picture of the session. peek then turns pictures off and writes peek: pictures off, ... to the debug log.

CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 makes peek try pictures in any terminal outside tmux and screen, such as one that speaks the kitty protocol under another name.

Known limits

  • The picture itself can't be clicked. Use the ⤢ open button beside it.
  • A pasted image shows only while Claude Code's session image cache still holds it. A resumed session whose cache was cleared shows nothing for it.
  • iTerm2 sends picture bytes in-band, so a transcript full of large images redraws slower. Thumbnails are kept small for that reason.
  • Web images in replies are skipped. Fetching whatever a reply links to would let a reply make your machine call out.

For mod authors

peek adds $.peek to the engine. Types live in types/index.d.ts.

  • picture({ path, cwd, columns, isFullscreen, requestId }) draws a local image the way peek's thumbnails do, with the same conversion, cache and capability check.
  • path may be absolute, ~/, file:// or relative to cwd (the session's directory by default). Pass requestId so the capability check can test the picture you drew.
  • picture returns one of:
  • { source: { png }, columns, rows, alt, path, name, caption, open, key }. Draw the Image under key.
  • { fallback: 'pictures-off' } when pictures can't draw.
  • { fallback: 'unresolved' } when the file is missing or not an image.
  • open({ path, cwd }) opens the original in Preview and returns {}, or { error } after a toast with the reason.
  • If a mod draws an image under a key from $.peek.picture, peek doesn't draw it again under the reply.
  • Non-PNG files, and PNGs over 512 KiB, are converted with /usr/bin/sips to an 800 px PNG in /tmp/claude-<uid>/peek, stepping down to 512 and 256 px if the result is still over 2 MiB. Conversions are cached by path and modification time.
Source 15 files
hooks/register.tsx 158 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import { UNRESOLVED, openFor, pictureFor, replyImageKey } from './api'
5import { probe, settleCapability } from './capability'
6import { ARGUMENT_HINT, COMMAND, DESCRIPTION, parseCommand, runPeek } from './commands'
7import { imageKeysIn, itemsFor, maxColumnsFor, picturesFor, withThumbs } from './draws'
8import type { Frame, Wanted } from './draws'
9import { BAND_ROWS, THUMB_ROWS } from './fit'
10import { hostOf } from './host'
11import { barePaths, imageTags, localImages, resolvePath, toolImagePaths } from './paths'
12import type { Places } from './places'
13import { pastedImage, placesOf } from './places'
14import { band } from './view'
15import type { Kit } from './view'
16
17const draft = atom({ plugin: 'peek', key: 'draft' } as const, { text: '' })
18
19let places: Promise<Places> | undefined
20
21function placesFor($: EngineInterface): Promise<Places> {
22  places ??= $.session.cwd().then(cwd => placesOf($, hostOf($), cwd))
23  return places
24}
25
26type Viewed = { requestId: string; viewport?: { columns?: number; isFullscreen?: boolean } }
27
28async function frameOf($: EngineInterface, e: Viewed, kit: Kit, maxRows: number): Promise<Frame> {
29  const pictures = picturesFor(e.viewport?.isFullscreen)
30  return { host: hostOf($), places: await placesFor($), kit, requestId: e.requestId, pictures, maxColumns: maxColumnsFor(e.viewport?.columns), maxRows }
31}
32
33function settledTool(props: { isRunning: boolean; isErrored: boolean; isInterrupted: boolean }): boolean {
34  return !props.isRunning && !props.isErrored && !props.isInterrupted
35}
36
37function wantedPaths(paths: string[], where: Places, prefix: string): Wanted[] {
38  return paths.map((path, i) => ({ id: `${prefix}-${i}`, path: resolvePath(path, where.cwd, where.home) }))
39}
40
41async function pastedWanted($: EngineInterface, where: Places, text: string): Promise<Wanted[]> {
42  const host = hostOf($)
43  const found = await Promise.all(imageTags(text).map(async n => ({ n, path: await pastedImage(host, where, n) })))
44  return found.flatMap(({ n, path }) => (path === undefined ? [] : [{ id: `pasted-${n}`, path, name: `Image #${n}` }]))
45}
46
47async function draftWanted($: EngineInterface, where: Places, text: string): Promise<Wanted[]> {
48  const bare = wantedPaths(barePaths(text), where, 'bare').map(w => ({ ...w, clip: true }))
49  return [...(await pastedWanted($, where, text)), ...bare]
50}
51
52async function syncDraft($: EngineInterface, text: string): Promise<void> {
53  if ((await read($, draft)).text !== text) await update($, draft, () => ({ text }))
54}
55
56function keep($: EngineInterface, drawn: RenderElement, error: unknown): RenderElement {
57  $.ui.log(`peek: ${String(error)}`, { to: 'debug' })
58  return drawn
59}
60
61export const register: Register = on => {
62  on('engine.create', async (_$, e, next) => ({
63    ...(await next(e)),
64    peek: { picture: async () => UNRESOLVED, open: async () => ({ error: 'peek is not ready' }) },
65  }))
66
67  on('peek.picture', async ($, e) => {
68    const picture = await pictureFor(hostOf($), e, await placesFor($))
69    if ('key' in picture && e.requestId !== undefined) probe($, e.requestId, picture.key, picture.source)
70    return { value: picture }
71  }).catch(() => ({ value: UNRESOLVED }))
72
73  on('peek.open', async ($, e) => {
74    const opened = await openFor(hostOf($), e, await placesFor($))
75    if (opened.error !== undefined) $.ui.toast(opened.error)
76    return { value: opened }
77  }).catch(() => ({ value: { error: 'peek could not open the image' } }))
78
79  on('session.start', async ($, e, next) => {
80    places = undefined
81    await settleCapability($)
82    await $.command.register({ name: COMMAND, description: DESCRIPTION, argumentHint: ARGUMENT_HINT })
83    void placesFor($)
84    return next(e)
85  })
86
87  on('command.run', { command: COMMAND }, async ($, e) => {
88    const parsed = parseCommand(e.args)
89    const outcome = await runPeek(hostOf($), await placesFor($), parsed)
90    if (parsed.kind === 'help') return { text: outcome.text }
91    $.ui.toast(outcome.text)
92    return outcome.ok ? {} : { text: outcome.text }
93  }).catch(($, e) => ({ text: `peek could not finish "${e.args}"` }))
94
95  on('prompt.change', async ($, e, next) => {
96    const result = await next(e)
97    await syncDraft($, e.text)
98    return result
99  })
100
101  on('prompt.edit', async ($, e, next) => {
102    const result = await next(e)
103    void syncDraft($, result.text)
104    return result
105  })
106
107  on('prompt.submit', async ($, e, next) => {
108    await update($, draft, () => ({ text: '' }))
109    return next(e)
110  }).catch(($, e, next) => next(e))
111
112  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
113    const drawn = await next(e)
114    if (e.surface !== 'terminal' || !settledTool(e.props)) return drawn
115    const paths = toolImagePaths(e.props.tool, e.props.input)
116    if (paths.length === 0) return drawn
117    const frame = await frameOf($, e, $.ui.resolve(e), THUMB_ROWS)
118    return withThumbs($, frame, drawn, wantedPaths(paths, frame.places, 'tool')).catch(error => keep($, drawn, error))
119  }).catch(($, e, next) => next(e))
120
121  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
122    const drawn = await next(e)
123    if (e.surface !== 'terminal' || e.props.isExpanded || e.props.isActive) return drawn
124    const paths = e.props.calls.filter(settledTool).flatMap(call => toolImagePaths(call.tool, call.input))
125    if (paths.length === 0) return drawn
126    const frame = await frameOf($, e, $.ui.resolve(e), THUMB_ROWS)
127    return withThumbs($, frame, drawn, wantedPaths([...new Set(paths)], frame.places, 'group')).catch(error => keep($, drawn, error))
128  }).catch(($, e, next) => next(e))
129
130  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
131    const drawn = await next(e)
132    if (e.surface !== 'terminal' || e.props.isSummary) return drawn
133    const links = localImages(e.props.text)
134    if (links.length === 0) return drawn
135    const frame = await frameOf($, e, $.ui.resolve(e), THUMB_ROWS)
136    const inPlace = imageKeysIn(drawn)
137    const wanted = wantedPaths(links.map(l => l.src), frame.places, 'reply').filter(w => !inPlace.has(replyImageKey(w.path)))
138    return withThumbs($, frame, drawn, wanted).catch(error => keep($, drawn, error))
139  }).catch(($, e, next) => next(e))
140
141  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
142    const drawn = await next(e)
143    if (e.surface !== 'terminal' || imageTags(e.props.text).length === 0) return drawn
144    const frame = await frameOf($, e, $.ui.resolve(e), THUMB_ROWS)
145    return withThumbs($, frame, drawn, await pastedWanted($, frame.places, e.props.text)).catch(error => keep($, drawn, error))
146  }).catch(($, e, next) => next(e))
147
148  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
149    const below = await next(e)
150    if (e.surface !== 'terminal' || e.props.hasSurvey) return below
151    const { text } = await read($, draft)
152    if (imageTags(text).length === 0 && barePaths(text).length === 0) return below
153    const frame = await frameOf($, e, $.ui.resolve(e), Math.max(1, Math.min(BAND_ROWS, e.props.maxRows - 2)))
154    const items = await itemsFor($, frame, await draftWanted($, frame.places, text)).catch(() => [])
155    return items.length === 0 ? below : band(frame.kit, below, items, frame.pictures)
156  }).catch(($, e, next) => next(e))
157}
158
hooks/api.ts 37 lines
1import { openImage } from './actions'
2import { drawsPictures } from './capability'
3import { hashOf, thumbnail } from './convert'
4import { maxColumnsFor } from './draws'
5import { THUMB_ROWS, fitCells } from './fit'
6import type { Host } from './host'
7import { fromFileUrl, isImagePath, resolvePath } from './paths'
8import type { Places } from './places'
9import { MARK, OPEN, imageKey } from './view'
10import type { PeekFallback, PeekOpenArgs, PeekOpened, PeekPicture, PeekPictureArgs } from '../types'
11
12export const PICTURES_OFF: PeekFallback = { fallback: 'pictures-off' }
13
14export const UNRESOLVED: PeekFallback = { fallback: 'unresolved' }
15
16export function pathFor(path: string, cwd: string | undefined, where: Places): string {
17  return resolvePath(fromFileUrl(path), cwd ?? where.cwd, where.home)
18}
19
20export function replyImageKey(path: string): string {
21  return imageKey(`reply-${hashOf(path)}`)
22}
23
24export async function pictureFor(host: Host, args: PeekPictureArgs, where: Places): Promise<PeekPicture | PeekFallback> {
25  if (!drawsPictures(args.isFullscreen)) return PICTURES_OFF
26  const path = pathFor(args.path, args.cwd, where)
27  const thumb = isImagePath(path) ? await thumbnail(host, path, where.cacheDir) : undefined
28  if (thumb === undefined) return UNRESOLVED
29  const cells = fitCells(thumb.size, THUMB_ROWS, maxColumnsFor(args.columns))
30  return { source: { png: thumb.png }, ...cells, alt: `${MARK} ${thumb.name}`, path, name: thumb.name, caption: `${thumb.size.width}×${thumb.size.height}`, open: OPEN, key: replyImageKey(path) }
31}
32
33export async function openFor(host: Host, args: PeekOpenArgs, where: Places): Promise<PeekOpened> {
34  const error = await openImage(host, pathFor(args.path, args.cwd, where))
35  return error === undefined ? {} : { error }
36}
37
hooks/capability.ts 76 lines
1import type { EngineInterface, ImageSource } from 'claude-code'
2
3export const ITERM_FROM = '2.1.305'
4
5export type Verdict = 'unknown' | 'pictures' | 'line'
6
7export type Terminal = { program?: string; term?: string; tmux?: string; screen?: string; forced?: string; version: string }
8
9let verdict: Verdict = 'unknown'
10
11let probing = false
12
13export function resetCapability(start: Verdict = 'unknown'): void {
14  verdict = start
15  probing = false
16}
17
18export function currentVerdict(): Verdict {
19  return verdict
20}
21
22export function atLeast(version: string, floor: string): boolean {
23  const a = version.split(/[.-]/).slice(0, 3).map(Number)
24  const b = floor.split('.').map(Number)
25  for (let i = 0; i < 3; i++) {
26    const diff = (a[i] ?? 0) - (b[i] ?? 0)
27    if (diff !== 0) return diff > 0
28  }
29  return true
30}
31
32export function mayDraw(t: Terminal): boolean {
33  if (t.tmux || t.screen) return false
34  if (t.forced === '1') return true
35  if (t.program === 'iTerm.app') return atLeast(t.version, ITERM_FROM)
36  return t.program === 'ghostty' || /kitty|ghostty/.test(t.term ?? '')
37}
38
39async function terminalOf($: EngineInterface): Promise<Terminal> {
40  const none = () => undefined
41  const [program, term, tmux, screen, forced, version] = await Promise.all([
42    $.env.get('TERM_PROGRAM').catch(none),
43    $.env.get('TERM').catch(none),
44    $.env.get('TMUX').catch(none),
45    $.env.get('STY').catch(none),
46    $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES').catch(none),
47    $.session.version().then(v => v.version).catch(() => '0.0.0'),
48  ])
49  return { program, term, tmux, screen, forced, version }
50}
51
52export async function settleCapability($: EngineInterface): Promise<void> {
53  resetCapability(mayDraw(await terminalOf($)) ? 'unknown' : 'line')
54}
55
56export function drawsPictures(isFullscreen: boolean | undefined): boolean {
57  return isFullscreen === true && verdict !== 'line'
58}
59
60export function probe($: EngineInterface, requestId: string, key: string, source: ImageSource): void {
61  if (verdict !== 'unknown' || probing) return
62  probing = true
63  $.clock.after(400, async () => {
64    const result = await $.ui.blit({ requestId, key, source }).catch(() => ({ deny: 'blit failed' }))
65    probing = false
66    if (result.deny !== undefined && /alt/i.test(result.deny)) {
67      verdict = 'line'
68      $.ui.log(`peek: pictures off, ${result.deny}`, { to: 'debug' })
69      $.ui.invalidate('ui.render')
70    } else if (result.deny === undefined) {
71      verdict = 'pictures'
72      $.ui.log('peek: pictures on', { to: 'debug' })
73    }
74  })
75}
76
hooks/commands.ts 48 lines
1import { ON_CLIPBOARD, clipboardHoldsImage, copyImage } from './actions'
2import type { Host } from './host'
3import { resolvePath } from './paths'
4import type { Places } from './places'
5import { newestScreenshot } from './places'
6
7export const COMMAND = 'peek'
8
9export const DESCRIPTION = 'Put an image on the clipboard for ctrl+v: a path, the newest screenshot, or check the clipboard'
10
11export const ARGUMENT_HINT = '<path> | last | clipboard-check'
12
13export const USAGE = '/peek <path> puts that image on the clipboard; /peek last takes the newest screenshot in Desktop or Downloads; /peek clipboard-check says whether the clipboard holds an image. Then press ctrl+v.'
14
15export const NO_SCREENSHOT = 'No screenshot found in Desktop or Downloads'
16
17export const CLIPBOARD_EMPTY = 'No image on the clipboard'
18
19export type Parsed = { kind: 'help' } | { kind: 'last' } | { kind: 'check' } | { kind: 'path'; path: string }
20
21function unquote(text: string): string {
22  const quoted = text.match(/^(['"])(.*)\1$/)
23  return quoted ? (quoted[2] ?? '') : text.replace(/\\ /g, ' ')
24}
25
26export function parseCommand(args: string): Parsed {
27  const text = args.trim()
28  if (text === '' || text === 'help') return { kind: 'help' }
29  if (text === 'last') return { kind: 'last' }
30  if (text === 'clipboard-check') return { kind: 'check' }
31  return { kind: 'path', path: unquote(text) }
32}
33
34export type Outcome = { text: string; ok: boolean }
35
36async function copied(host: Host, places: Places, path: string): Promise<Outcome> {
37  const text = await copyImage(host, path, places.cacheDir)
38  return { text, ok: text === ON_CLIPBOARD }
39}
40
41export async function runPeek(host: Host, places: Places, parsed: Parsed): Promise<Outcome> {
42  if (parsed.kind === 'help') return { text: USAGE, ok: true }
43  if (parsed.kind === 'check') return (await clipboardHoldsImage(host)) ? { text: ON_CLIPBOARD, ok: true } : { text: CLIPBOARD_EMPTY, ok: false }
44  if (parsed.kind === 'path') return copied(host, places, resolvePath(parsed.path, places.cwd, places.home))
45  const newest = await newestScreenshot(host, places.home)
46  return newest === undefined ? { text: NO_SCREENSHOT, ok: false } : copied(host, places, newest)
47}
48
hooks/draws.ts 86 lines
1import type { EngineInterface, RenderElement } from 'claude-code'
2
3import { copyImage, openImage } from './actions'
4import { drawsPictures, probe } from './capability'
5import { thumbnail } from './convert'
6import { MAX_COLUMNS, fitCells } from './fit'
7import type { Host } from './host'
8import { nameOf } from './paths'
9import type { Places } from './places'
10import { toneOf } from './tone'
11import { gallery, imageKey, shownOf } from './view'
12import type { Action, Kit, Shown } from './view'
13
14export const MOST = 4
15
16export const CLIP = '⧉ clip'
17
18export type Wanted = { id: string; path: string; name?: string; clip?: boolean }
19
20export type Frame = { host: Host; places: Places; kit: Kit; requestId: string; pictures: boolean; maxColumns: number; maxRows: number }
21
22function openAction($: EngineInterface, frame: Frame, wanted: Wanted): Action {
23  return {
24    key: `peek-open-${wanted.id}`,
25    label: '⤢ open',
26    press: () => void openImage(frame.host, wanted.path).then(error => error !== undefined && $.ui.toast(error)),
27  }
28}
29
30function clipAction($: EngineInterface, frame: Frame, wanted: Wanted): Action {
31  return {
32    key: `peek-clip-${wanted.id}`,
33    label: CLIP,
34    press: () => void copyImage(frame.host, wanted.path, frame.places.cacheDir).then(text => $.ui.toast(text)),
35  }
36}
37
38function actionsOf($: EngineInterface, frame: Frame, wanted: Wanted): Action[] {
39  return [openAction($, frame, wanted), ...(wanted.clip ? [clipAction($, frame, wanted)] : [])]
40}
41
42async function pictured(frame: Frame, wanted: Wanted, base: Shown): Promise<Shown | undefined> {
43  const thumb = await thumbnail(frame.host, wanted.path, frame.places.cacheDir)
44  if (thumb === undefined) return undefined
45  return { ...base, png: thumb.png, caption: `${thumb.size.width}×${thumb.size.height}`, cells: fitCells(thumb.size, frame.maxRows, frame.maxColumns) }
46}
47
48export async function shownFor($: EngineInterface, frame: Frame, wanted: Wanted): Promise<Shown | undefined> {
49  const base: Shown = { id: wanted.id, name: wanted.name ?? nameOf(wanted.path), actions: actionsOf($, frame, wanted) }
50  if (frame.pictures) return pictured(frame, wanted, base)
51  return (await frame.host.stat(wanted.path)) === undefined ? undefined : base
52}
53
54export function maxColumnsFor(columns: number | undefined): number {
55  return Math.max(8, Math.min(MAX_COLUMNS, (columns ?? 80) - 24))
56}
57
58export function picturesFor(isFullscreen: boolean | undefined): boolean {
59  return drawsPictures(isFullscreen)
60}
61
62export async function itemsFor($: EngineInterface, frame: Frame, wanted: Wanted[]): Promise<RenderElement[]> {
63  const shown = (await Promise.all(wanted.slice(0, MOST).map(w => shownFor($, frame, w)))).filter((s): s is Shown => s !== undefined)
64  if (shown.length === 0) return []
65  const tone = await toneOf($)
66  const first = shown.find(s => s.png !== undefined)
67  if (frame.pictures && first?.png !== undefined) probe($, frame.requestId, imageKey(first.id), { png: first.png })
68  return shown.map(s => shownOf(frame.kit, tone, s, frame.pictures))
69}
70
71export async function withThumbs($: EngineInterface, frame: Frame, drawn: RenderElement, wanted: Wanted[]): Promise<RenderElement> {
72  if (wanted.length === 0) return drawn
73  const items = await itemsFor($, frame, wanted)
74  return items.length === 0 ? drawn : gallery(frame.kit, drawn, items)
75}
76
77type Node = { type?: unknown; props?: { key?: unknown }; children?: unknown }
78
79export function imageKeysIn(node: unknown, found: Set<string> = new Set()): Set<string> {
80  if (typeof node !== 'object' || node === null) return found
81  const { type, props, children } = node as Node
82  if (type === 'Image' && typeof props?.key === 'string') found.add(props.key)
83  if (Array.isArray(children)) for (const child of children) imageKeysIn(child, found)
84  return found
85}
86
hooks/fit.ts 25 lines
1import type { Size } from './png'
2
3export const THUMB_ROWS = 8
4
5export const BAND_ROWS = 3
6
7export const MAX_COLUMNS = 48
8
9const CELL_ASPECT = 2
10
11export type Cells = { columns: number; rows: number }
12
13function clamp(value: number, low: number, high: number): number {
14  return Math.min(high, Math.max(low, value))
15}
16
17export function fitCells(size: Size, maxRows: number, maxColumns: number): Cells {
18  const ratio = size.width / size.height
19  const rows = clamp(Math.min(maxRows, Math.ceil(size.height / 16)), 1, 255)
20  const wanted = Math.round(rows * ratio * CELL_ASPECT)
21  if (wanted <= maxColumns) return { columns: clamp(wanted, 1, 255), rows }
22  const columns = clamp(maxColumns, 1, 255)
23  return { columns, rows: clamp(Math.round(columns / ratio / CELL_ASPECT), 1, rows) }
24}
25
hooks/host.ts 26 lines
1import type { EngineInterface } from 'claude-code'
2
3export type Ran = { exitCode: number; stdout: string; stderr: string }
4
5export type Host = {
6  run: (argv: string[], timeoutMs?: number) => Promise<Ran>
7  stat: (path: string) => Promise<{ size: number; mtimeMs: number } | undefined>
8  read: (path: string) => Promise<string>
9  exists: (path: string) => Promise<boolean>
10  list: (dir: string) => Promise<Entry[]>
11}
12
13export type Entry = { name: string; mtimeMs: number }
14
15const FAILED: Ran = { exitCode: -1, stdout: '', stderr: 'could not start' }
16
17export function hostOf($: EngineInterface): Host {
18  return {
19    run: (argv, timeoutMs = 15000) => $.process.run(argv, { timeoutMs }).catch((error: unknown) => ({ ...FAILED, stderr: String(error) })),
20    stat: path => $.fs.stat(path).then(s => (s.kind === 'file' ? { size: s.size, mtimeMs: s.mtimeMs } : undefined)).catch(() => undefined),
21    read: path => $.fs.read(path, { as: 'bytes' }).then(b => b.base64),
22    exists: path => $.fs.exists(path).catch(() => false),
23    list: dir => $.fs.list(dir).then(all => all.filter(f => f.kind === 'file').map(f => ({ name: f.name, mtimeMs: f.mtimeMs }))).catch(() => []),
24  }
25}
26
hooks/paths.ts 100 lines
1export const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'heic'] as const
2
3const IMAGE_FILE = new RegExp(`\\.(?:${IMAGE_EXTENSIONS.join('|')})$`, 'i')
4
5const URL = /^[a-z][a-z0-9+.-]*:/i
6
7const MARKDOWN_IMAGE = /!\[([^\]]*)\]\(\s*(<[^>]+>|[^\s)]+)(?:\s+"[^"]*")?\s*\)/g
8
9const IMAGE_TAG = /\[Image #(\d+)\]/g
10
11const BARE_PATH = new RegExp(`(?:^|\\s)((?:~|/)(?:\\\\ |[^\\s])+\\.(?:${IMAGE_EXTENSIONS.join('|')}))(?=\\s|$)`, 'gi')
12
13const QUOTED_PATH = new RegExp(`(['"])((?:~|/)[^'"]+\\.(?:${IMAGE_EXTENSIONS.join('|')}))\\1`, 'gi')
14
15export type Linked = { alt: string; src: string }
16
17export function isImagePath(path: string): boolean {
18  return IMAGE_FILE.test(path)
19}
20
21export function isUrl(src: string): boolean {
22  return URL.test(src) && !src.startsWith('file:')
23}
24
25export function nameOf(path: string): string {
26  return path.split('/').pop() ?? path
27}
28
29export function extensionOf(path: string): string {
30  return (path.match(/\.([^./]+)$/)?.[1] ?? '').toLowerCase()
31}
32
33function unwrap(src: string): string {
34  return src.startsWith('<') && src.endsWith('>') ? src.slice(1, -1) : src
35}
36
37export function fromFileUrl(src: string): string {
38  return src.startsWith('file://') ? decodeURI(src.slice('file://'.length)) : src
39}
40
41export function markdownImages(text: string): Linked[] {
42  return [...text.matchAll(MARKDOWN_IMAGE)].map(m => ({ alt: m[1] ?? '', src: fromFileUrl(unwrap(m[2] ?? '')) }))
43}
44
45export function localImages(text: string): Linked[] {
46  return markdownImages(text).filter(link => !isUrl(link.src) && isImagePath(link.src))
47}
48
49export function resolvePath(path: string, cwd: string, home: string): string {
50  if (path.startsWith('~/')) return `${home}${path.slice(1)}`
51  if (path.startsWith('/')) return path
52  const base = cwd.replace(/\/$/, '')
53  return normalise(`${base}/${path.replace(/^\.\//, '')}`)
54}
55
56function normalise(path: string): string {
57  const parts: string[] = []
58  for (const part of path.split('/')) {
59    if (part === '..') parts.pop()
60    else if (part !== '.' && part !== '') parts.push(part)
61  }
62  return `/${parts.join('/')}`
63}
64
65export function imageTags(text: string): number[] {
66  return [...new Set([...text.matchAll(IMAGE_TAG)].map(m => Number(m[1])))]
67}
68
69function unescapeSpaces(path: string): string {
70  return path.replace(/\\ /g, ' ')
71}
72
73export function barePaths(text: string): string[] {
74  const bare = [...text.matchAll(BARE_PATH)].map(m => unescapeSpaces(m[1] ?? ''))
75  const quoted = [...text.matchAll(QUOTED_PATH)].map(m => m[2] ?? '')
76  return [...new Set([...quoted, ...bare])]
77}
78
79function inputPath(input: unknown, key: string): string | undefined {
80  if (typeof input !== 'object' || input === null) return undefined
81  const value = (input as Record<string, unknown>)[key]
82  return typeof value === 'string' ? value : undefined
83}
84
85function commandPaths(command: string): string[] {
86  const tokens = command.match(/"[^"]*"|'[^']*'|(?:\\ |[^\s;|&><])+/g) ?? []
87  return tokens.map(t => unescapeSpaces(t.replace(/^['"]|['"]$/g, ''))).filter(t => isImagePath(t) && !isUrl(t))
88}
89
90const FILE_TOOLS = new Set(['Read', 'Write', 'Edit', 'MultiEdit'])
91
92export function toolImagePaths(tool: string, input: unknown): string[] {
93  if (FILE_TOOLS.has(tool)) {
94    const path = inputPath(input, 'file_path')
95    return path !== undefined && isImagePath(path) ? [path] : []
96  }
97  if (tool === 'Bash') return [...new Set(commandPaths(inputPath(input, 'command') ?? ''))]
98  return []
99}
100
hooks/places.ts 57 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { Entry, Host } from './host'
4import { isImagePath } from './paths'
5
6export type Places = { home: string; cwd: string; imagesDir: string; cacheDir: string }
7
8export function projectSlug(cwd: string): string {
9  return cwd.replace(/[^a-zA-Z0-9]/g, '-')
10}
11
12export function claudeTmp(base: string | undefined, uid: string): string {
13  return `${(base ?? '/tmp').replace(/\/$/, '')}/claude-${uid}`
14}
15
16export async function placesOf($: EngineInterface, host: Host, cwd: string): Promise<Places> {
17  const [uid, base, home, session] = await Promise.all([
18    host.run(['/usr/bin/id', '-u']).then(r => r.stdout.trim() || '0'),
19    $.env.get('CLAUDE_CODE_TMPDIR').catch(() => undefined),
20    $.env.get('HOME').catch(() => undefined),
21    $.session.id(),
22  ])
23  const tmp = claudeTmp(base, uid)
24  const places = { home: home ?? '', cwd, imagesDir: `${tmp}/${projectSlug(cwd)}/${session}/images`, cacheDir: `${tmp}/peek` }
25  await host.run(['/bin/mkdir', '-p', places.cacheDir])
26  return places
27}
28
29export function pastedFile(entries: Entry[], n: number): string | undefined {
30  return entries.find(e => e.name.startsWith(`${n}.`) && isImagePath(e.name))?.name
31}
32
33export async function pastedImage(host: Host, places: Places, n: number): Promise<string | undefined> {
34  const name = pastedFile(await host.list(places.imagesDir), n)
35  return name === undefined ? undefined : `${places.imagesDir}/${name}`
36}
37
38export function isScreenshotLike(name: string): boolean {
39  return isImagePath(name) && (/^Screenshot/i.test(name) || /\.png$/i.test(name))
40}
41
42export function screenshotDirs(home: string, configured: string | undefined): string[] {
43  const own = configured?.trim() ? [configured.trim().replace(/^~/, home).replace(/\/$/, '')] : []
44  return [...new Set([...own, `${home}/Desktop`, `${home}/Downloads`])]
45}
46
47async function configuredDir(host: Host): Promise<string | undefined> {
48  const ran = await host.run(['/usr/bin/defaults', 'read', 'com.apple.screencapture', 'location'])
49  return ran.exitCode === 0 ? ran.stdout : undefined
50}
51
52export async function newestScreenshot(host: Host, home: string): Promise<string | undefined> {
53  const dirs = screenshotDirs(home, await configuredDir(host))
54  const found = await Promise.all(dirs.map(async dir => (await host.list(dir)).filter(e => isScreenshotLike(e.name)).map(e => ({ ...e, path: `${dir}/${e.name}` }))))
55  return found.flat().sort((a, b) => b.mtimeMs - a.mtimeMs)[0]?.path
56}
57
hooks/view.tsx 104 lines
1import type { Elements, RenderElement, RenderNode } from 'claude-code'
2
3import type { Cells } from './fit'
4import type { Tone } from './tone'
5
6export type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Image'>
7
8export type Action = { key: string; label: string; press: () => void }
9
10export type Shown = { id: string; name: string; caption?: string; png?: string; cells?: Cells; actions: Action[] }
11
12export const MARK = '▣'
13
14export const OPEN = '⤢ open'
15
16export const GALLERY_KEY = 'peek'
17
18export const BAND_KEY = 'peek-band'
19
20export function imageKey(id: string): string {
21  return `peek-img-${id}`
22}
23
24function button(kit: Kit, tone: Tone, action: Action): RenderNode {
25  return (
26    <kit.Button key={action.key} plain label={action.label} onPress={action.press}>
27      <kit.Text color={tone.muted} hover={{ color: tone.text, underline: true }}>
28        {action.label}
29      </kit.Text>
30    </kit.Button>
31  )
32}
33
34function buttons(kit: Kit, tone: Tone, shown: Shown): RenderNode[] {
35  return shown.actions.flatMap((action, i) => [<kit.Text key={`gap-${i}`}>{'  '}</kit.Text>, button(kit, tone, action)])
36}
37
38export function lineOf(kit: Kit, tone: Tone, shown: Shown): RenderElement {
39  return (
40    <kit.Box key={`peek-${shown.id}`} flexDirection="row" height={1}>
41      <kit.Text color={tone.muted} wrap="truncate-end">
42        {`${MARK} ${shown.name}`}
43      </kit.Text>
44      {buttons(kit, tone, shown)}
45    </kit.Box>
46  )
47}
48
49function caption(kit: Kit, tone: Tone, shown: Shown): RenderNode[] {
50  return [
51    <kit.Text key="name" color={tone.muted} wrap="truncate-end">
52      {shown.name}
53    </kit.Text>,
54    ...(shown.caption === undefined ? [] : [<kit.Text key="caption" color={tone.muted}>{shown.caption}</kit.Text>]),
55  ]
56}
57
58function actionRows(kit: Kit, tone: Tone, shown: Shown): RenderNode[] {
59  return shown.actions.map(action => (
60    <kit.Box key={`row-${action.key}`} height={1}>
61      {button(kit, tone, action)}
62    </kit.Box>
63  ))
64}
65
66export function pictureOf(kit: Kit, tone: Tone, shown: Shown): RenderElement {
67  if (shown.png === undefined || shown.cells === undefined) return lineOf(kit, tone, shown)
68  return (
69    <kit.Box key={`peek-${shown.id}`} flexDirection="row" marginRight={2}>
70      <kit.Image key={imageKey(shown.id)} source={{ png: shown.png }} columns={shown.cells.columns} rows={shown.cells.rows} alt={`${MARK} ${shown.name}`} />
71      <kit.Box key="side" flexDirection="column" paddingLeft={1} flexShrink={1}>
72        {caption(kit, tone, shown)}
73        {actionRows(kit, tone, shown)}
74      </kit.Box>
75    </kit.Box>
76  )
77}
78
79export function shownOf(kit: Kit, tone: Tone, shown: Shown, pictures: boolean): RenderElement {
80  return pictures ? pictureOf(kit, tone, shown) : lineOf(kit, tone, shown)
81}
82
83export function gallery(kit: Kit, drawn: RenderElement, items: RenderElement[]): RenderElement {
84  return (
85    <kit.Box flexDirection="column">
86      {drawn}
87      <kit.Box key={GALLERY_KEY} flexDirection="column" paddingLeft={2}>
88        {items}
89      </kit.Box>
90    </kit.Box>
91  )
92}
93
94export function band(kit: Kit, below: RenderElement, items: RenderElement[], pictures: boolean): RenderElement {
95  return (
96    <kit.Box flexDirection="column">
97      <kit.Box key={BAND_KEY} flexDirection={pictures ? 'row' : 'column'}>
98        {items}
99      </kit.Box>
100      {below}
101    </kit.Box>
102  )
103}
104
hooks/actions.ts 45 lines
1import { fullPng } from './convert'
2import type { Host } from './host'
3import { isImagePath, nameOf } from './paths'
4
5export const OSASCRIPT = '/usr/bin/osascript'
6
7export const OPEN = '/usr/bin/open'
8
9export const ON_CLIPBOARD = 'Image on clipboard: press ctrl+v'
10
11export function appleString(text: string): string {
12  return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
13}
14
15export function clipboardScript(pngPath: string): string {
16  return `set the clipboard to (read (POSIX file ${appleString(pngPath)}) as «class PNGf»)`
17}
18
19export async function openImage(host: Host, path: string): Promise<string | undefined> {
20  const preview = await host.run([OPEN, '-a', 'Preview', path])
21  if (preview.exitCode === 0) return undefined
22  const plain = await host.run([OPEN, path])
23  return plain.exitCode === 0 ? undefined : `Could not open ${nameOf(path)}: ${plain.stderr.trim() || preview.stderr.trim() || 'open failed'}`
24}
25
26export async function clipboardHoldsImage(host: Host): Promise<boolean> {
27  const info = await host.run([OSASCRIPT, '-e', 'clipboard info'])
28  return info.exitCode === 0 && /PNGf|TIFF/.test(info.stdout)
29}
30
31export async function clipboardHoldsPng(host: Host): Promise<boolean> {
32  const info = await host.run([OSASCRIPT, '-e', 'clipboard info'])
33  return info.exitCode === 0 && info.stdout.includes('PNGf')
34}
35
36export async function copyImage(host: Host, path: string, cacheDir: string): Promise<string> {
37  if (!isImagePath(path)) return `Not an image: ${path}`
38  if ((await host.stat(path)) === undefined) return `No such file: ${path}`
39  const png = await fullPng(host, path, cacheDir)
40  if (png === undefined) return `Could not convert ${nameOf(path)} to PNG`
41  const set = await host.run([OSASCRIPT, '-e', clipboardScript(png)])
42  if (set.exitCode !== 0) return `Could not put ${nameOf(path)} on the clipboard: ${set.stderr.trim()}`
43  return (await clipboardHoldsPng(host)) ? ON_CLIPBOARD : `The clipboard did not take ${nameOf(path)}`
44}
45
hooks/convert.ts 79 lines
1import type { Host } from './host'
2import { extensionOf, nameOf } from './paths'
3import { MAX_PNG_BYTES, decodedBytes, pngSize } from './png'
4import type { Size } from './png'
5
6export const SIPS = '/usr/bin/sips'
7
8export const DIRECT_BYTES = 512 * 1024
9
10export const EDGES = [800, 512, 256] as const
11
12const REMEMBERED = 64
13
14export type Thumb = { path: string; name: string; png: string; size: Size }
15
16const made = new Map<string, Promise<Thumb | undefined>>()
17
18export function forgetThumbs(): void {
19  made.clear()
20}
21
22export function hashOf(text: string): string {
23  let hash = 0x811c9dc5
24  for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193)
25  return (hash >>> 0).toString(16).padStart(8, '0')
26}
27
28export function sipsArgs(path: string, out: string, edge?: number): string[] {
29  const scale = edge === undefined ? [] : ['-Z', String(edge)]
30  return [SIPS, '-s', 'format', 'png', ...scale, path, '--out', out]
31}
32
33async function asThumb(host: Host, path: string, file: string): Promise<Thumb | undefined> {
34  const png = await host.read(file).catch(() => undefined)
35  const size = png === undefined ? undefined : pngSize(png)
36  return png === undefined || size === undefined ? undefined : { path, name: nameOf(path), png, size }
37}
38
39async function converted(host: Host, path: string, out: string, edge?: number): Promise<boolean> {
40  if (await host.exists(out)) return true
41  return (await host.run(sipsArgs(path, out, edge))).exitCode === 0
42}
43
44async function scaled(host: Host, path: string, stem: string): Promise<Thumb | undefined> {
45  for (const edge of EDGES) {
46    const out = `${stem}-${edge}.png`
47    if (!(await converted(host, path, out, edge))) return undefined
48    const thumb = await asThumb(host, path, out)
49    if (thumb !== undefined && decodedBytes(thumb.png) <= MAX_PNG_BYTES) return thumb
50  }
51  return undefined
52}
53
54async function make(host: Host, path: string, cacheDir: string, key: string, size: number): Promise<Thumb | undefined> {
55  if (extensionOf(path) === 'png' && size <= DIRECT_BYTES) return asThumb(host, path, path)
56  return scaled(host, path, `${cacheDir}/${hashOf(key)}`)
57}
58
59function remember(key: string, thumb: Promise<Thumb | undefined>): Promise<Thumb | undefined> {
60  if (made.size >= REMEMBERED) made.delete(made.keys().next().value ?? '')
61  made.set(key, thumb)
62  return thumb
63}
64
65export async function thumbnail(host: Host, path: string, cacheDir: string): Promise<Thumb | undefined> {
66  const stat = await host.stat(path)
67  if (stat === undefined) return undefined
68  const key = `${path}@${stat.mtimeMs}`
69  return made.get(key) ?? remember(key, make(host, path, cacheDir, key, stat.size).catch(() => undefined))
70}
71
72export async function fullPng(host: Host, path: string, cacheDir: string): Promise<string | undefined> {
73  if (extensionOf(path) === 'png') return path
74  const stat = await host.stat(path)
75  if (stat === undefined) return undefined
76  const out = `${cacheDir}/${hashOf(`${path}@${stat.mtimeMs}`)}-full.png`
77  return (await converted(host, path, out)) ? out : undefined
78}
79