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…

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.

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.
/plugin marketplace add vvojciech/claude-kit
/plugin install peek@vvojciech
⤢ open and /peek use sips, open and the macOS clipboard. peek doesn't check the platform, so elsewhere it loads but those fail./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.
.png .jpg .jpeg .gif .webp .heic), under the tool row, or under a folded group of rows once it settles.sips ... --out chart.png, if the file exists afterwards.
images in Claude's replies, absolute, ~/ or relative to the working directory.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.
[Image #N] you pasted, and any bare image path, such as one iTerm2 pastes when you drag a file in.⧉ clip button that puts it on the clipboard, so ctrl+v turns it into a real attachment.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.
Thumbnails fall back to ▣ name ⤢ open lines when:
/tui fullscreen);TERM that doesn't contain "kitty";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.
⤢ open button beside it.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.$.peek.picture, peek doesn't draw it again under the reply./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.hooks/register.tsx 158 lines1import { 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}
158hooks/api.ts 37 lines1import { 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}
37hooks/capability.ts 76 lines1import 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}
76hooks/commands.ts 48 lines1import { 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}
48hooks/draws.ts 86 lines1import 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}
86hooks/fit.ts 25 lines1import 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}
25hooks/host.ts 26 lines1import 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}
26hooks/paths.ts 100 lines1export 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}
100hooks/places.ts 57 lines1import 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}
57hooks/view.tsx 104 lines1import 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}
104hooks/actions.ts 45 lines1import { 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}
45hooks/convert.ts 79 lines1import 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