See every image and file the agent looked at: screenshots, renders and file reads in a pane of the Claude desktop app, built from session output only

See what your Claude Code agent saw. Agents working through CLIs and MCP servers take screenshots, render scenes and read images you never get to see. Looked at collects every one of them, plus every file the agent read or changed, in a pane of the Claude desktop app.

<img src="docs/screenshots/pane-images.png" width="49%" alt="Images tab: thumbnails of what the agent saw"> <img src="docs/screenshots/pane-files.png" width="49%" alt="Files tab: every file read, edited or written">
<img src="docs/screenshots/footer-eye.png" width="100%" alt="The eye button next to the model picker"><br> <sub>The 👁 next to the model picker opens and closes the pane.</sub>
Looked at is a view built from the session's own output. It reads the rows the session stores and passes every one on unchanged. It adds no tools, no slash commands and nothing to the prompt or the context, so the agent never knows it's there.
To make thumbnails, it copies images to your temp folder: $TMPDIR/claude-looked-at/<session> on macOS and %TEMP%\claude-looked-at\<session> on Windows. Nothing leaves your machine.
In any terminal, including the Claude desktop app's Terminal panel, run:
claude plugin marketplace add ozdeger/claude-looked-at-mod
claude plugin install looked-at@looked-at
Or, inside an interactive claude session in a terminal, type /plugin install looked-at --marketplace ozdeger/claude-looked-at-mod, answer y to add the marketplace, then choose the user scope. In the desktop app's own composer, /plugin opens the claude.ai plugin catalog instead, which doesn't list GitHub marketplaces like this one.
Code tab sessions you start in the Claude desktop app after installing load the mod. Click 👁 under the prompt to open the pane.
sips; Windows uses PowerShell and .NET's System.Drawing. Windows support is new and hasn't been run on a Windows machine yet, so issues are welcome.claude plugin validate .
claude plugin test .
claude --plugin-dir .
hooks/register.tsx is the hooks module: it captures rows and draws the pane, the viewer and the 👁.hooks/capture.ts turns stored rows into image and file entries.hooks/platform.ts holds the macOS and Windows commands.hooks/icons.ts draws the file-type icons.The demo images in the screenshots are photos from Unsplash, served by Lorem Picsum under the Unsplash License, and Earthrise by NASA/Bill Anders, which is public domain. The neon game frame comes from a small demo project made for these screenshots.
hooks/register.tsx 614 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LookedFile, LookedImage, LookedTab } from '../types'
5import { resultsOf, toolLabel, toolUsesOf } from './capture'
6import type { FileHit, ImageJob, ToolUse } from './capture'
7import { extensionOf, fileIcon, folderOf, ICON_SIZE, nameOf } from './icons'
8import { INLINE_LIMIT, joinPath, launchCommand, offsetOf, separatorOf, thumbnailCommand, zoneCommand } from './platform'
9import type { Platform } from './platform'
10
11const PANE = 'looked-at'
12const TITLE = 'Looked at'
13const VIEWER = 'looked-at-view'
14const VIEWER_TITLE = 'Image'
15const VIEWER_COLUMNS = 400
16const EYE = '\u2800\u{1F441}\uFE0E\u2800'
17const OPEN = '\u2197'
18const REVEAL = '\u{1F4C2}'
19const ZOOM = '\u2922'
20const PREVIOUS = '\u2190'
21const NEXT = '\u2192'
22const CLOSE = '\u2715'
23const PAGE = 20
24const IMAGE_PAGE = 10
25const PICTURE_BUDGET = 120000
26const ECHO_MS = 400
27const MAX_IMAGES = 400
28const MAX_FILES = 2000
29const THUMB_WIDTH = 120
30const ROW_SPACING = 0.15
31
32const images = atom({ plugin: 'looked-at', key: 'images' } as const, [])
33const files = atom({ plugin: 'looked-at', key: 'files' } as const, [])
34const tab = atom({ plugin: 'looked-at', key: 'tab' } as const, 'images')
35const limit = atom({ plugin: 'looked-at', key: 'limit' } as const, PAGE)
36const imageLimit = atom({ plugin: 'looked-at', key: 'imageLimit' } as const, IMAGE_PAGE)
37const isUnique = atom({ plugin: 'looked-at', key: 'isUnique' } as const, false)
38const isPaneOpen = atom({ plugin: 'looked-at', key: 'isPaneOpen' } as const, false)
39const viewing = atom({ plugin: 'looked-at', key: 'viewing' } as const, null)
40const isBackfilled = atom({ plugin: 'looked-at', key: 'isBackfilled' } as const, false)
41
42const EXTENSIONS: Record<string, string> = {
43 'image/png': 'png',
44 'image/jpeg': 'jpg',
45 'image/gif': 'gif',
46 'image/webp': 'webp',
47}
48
49const uses = new Map<string, ToolUse>()
50const pictures = new Map<string, string>()
51const agents = new Map<string, string>()
52let folder: string | undefined
53let host: { platform: Platform; systemRoot: string } | undefined
54let offsetMinutes: number | undefined
55let queue: Promise<unknown> = Promise.resolve()
56type Action =
57 | { kind: 'tab'; tab: LookedTab }
58 | { kind: 'unique' }
59 | { kind: 'clear'; tab: LookedTab }
60 | { kind: 'more'; tab: LookedTab }
61 | { kind: 'launch'; path: string; isRevealed: boolean }
62 | { kind: 'view'; id: string }
63 | { kind: 'step'; delta: number }
64 | { kind: 'close-viewer' }
65
66const actions = new Map<string, Map<string, Action>>()
67const keyed = new Map<string, boolean>()
68let focusPress: { element: string; at: number } | undefined
69
70const enqueue = (work: () => Promise<unknown>) => {
71 queue = queue.then(work).catch(() => undefined)
72}
73
74const hostOf = async ($: EngineInterface) => {
75 if (host !== undefined) return host
76 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
77 const systemRoot = (await $.env.get('SystemRoot')) ?? 'C:\\Windows'
78 host = { platform: isWindows ? 'windows' : 'mac', systemRoot }
79 return host
80}
81
82const storage = async ($: EngineInterface) => {
83 if (folder !== undefined) return folder
84 const { platform } = await hostOf($)
85 const temp =
86 platform === 'windows'
87 ? ((await $.env.get('TEMP')) ?? (await $.env.get('TMP')) ?? 'C:\\Windows\\Temp')
88 : ((await $.env.get('TMPDIR')) ?? '/tmp')
89 folder = joinPath(platform, joinPath(platform, temp, 'claude-looked-at'), await $.session.id())
90 return folder
91}
92
93const agentLabel = async ($: EngineInterface, agentId: string | undefined) => {
94 if (agentId === undefined) return null
95 if (!agents.has(agentId)) {
96 const found = (await $.agent.list().catch(() => [])).find(agent => agent.id === agentId)
97 if (found !== undefined) agents.set(agentId, `${found.type}: ${found.description}`)
98 }
99 return agents.get(agentId) ?? `agent ${agentId.slice(0, 8)}`
100}
101
102const addFiles = async ($: EngineInterface, hits: FileHit[], at: number | null, agent: string | null) => {
103 if (hits.length === 0) return
104 const fresh: LookedFile[] = hits.map(hit => ({ ...hit, at, agent }))
105 await update($, files, list => {
106 const known = new Set(list.map(one => one.id))
107 return [...list, ...fresh.filter(one => !known.has(one.id))].slice(-MAX_FILES)
108 })
109}
110
111const thumbnail = async ($: EngineInterface, job: ImageJob, at: number | null, agent: string | null) => {
112 const { platform, systemRoot } = await hostOf($)
113 const stem = joinPath(platform, await storage($), job.id.replace(/[^A-Za-z0-9_-]/g, '_'))
114 const paths = {
115 encoded: `${stem}.b64`,
116 file: `${stem}.${EXTENSIONS[job.mediaType] ?? 'img'}`,
117 thumb: `${stem}.thumb.jpg`,
118 preview: `${stem}.preview.jpg`,
119 }
120 const placeholder: LookedImage = {
121 id: job.id,
122 at,
123 tool: job.tool,
124 detail: job.detail,
125 agent,
126 mediaType: job.mediaType,
127 bytes: Math.floor((job.data.length * 3) / 4),
128 width: null,
129 height: null,
130 file: null,
131 thumb: null,
132 preview: null,
133 thumbSize: null,
134 previewSize: null,
135 isError: job.isError,
136 }
137 await update($, images, list => (list.some(one => one.id === job.id) ? list : [...list, placeholder].slice(-MAX_IMAGES)))
138
139 const isWritten = await $.fs.write(paths.encoded, job.data).then(
140 () => true,
141 () => false,
142 )
143 if (!isWritten) return
144 const made = await $.process.run(thumbnailCommand(platform, systemRoot, paths), { timeoutMs: 60000 }).catch(() => undefined)
145 if (made === undefined || made.exitCode === 3) return
146
147 const [width, height, thumbSize, previewSize] = made.stdout.trim().split(/\s+/).map(Number)
148 const isMade = made.exitCode === 0 && [width, height, thumbSize, previewSize].every(n => n !== undefined && Number.isFinite(n) && n > 0)
149 const patch: Partial<LookedImage> = isMade
150 ? { file: paths.file, thumb: paths.thumb, preview: paths.preview, width, height, thumbSize, previewSize }
151 : { file: paths.file }
152 await update($, images, list => list.map(one => (one.id === job.id ? { ...one, ...patch } : one)))
153}
154
155const observe = async ($: EngineInterface, content: unknown, rowId: string, tool: string, agentId: string | undefined, at: number | null) => {
156 const seen = resultsOf(content, rowId, tool, id => uses.get(id))
157 if (seen.images.length === 0 && seen.files.length === 0) return
158 const agent = await agentLabel($, agentId)
159 await addFiles($, seen.files, at, agent)
160 const known = new Set((await read($, images)).map(one => one.id))
161 for (const job of seen.images) {
162 if (!known.has(job.id)) enqueue(() => thumbnail($, job, at, agent))
163 }
164}
165
166const backfill = async ($: EngineInterface) => {
167 const messages = await $.session.messages({ as: 'api' })
168 for (const message of messages) {
169 if (message.role === 'assistant') for (const use of toolUsesOf(message.content)) uses.set(use.id, use)
170 }
171 let index = 0
172 for (const message of messages) {
173 if (message.role === 'user') await observe($, message.content, `earlier-${index++}`, 'unknown', undefined, null)
174 }
175}
176
177const readZone = async ($: EngineInterface) => {
178 const { platform, systemRoot } = await hostOf($)
179 const zone = await $.process.run(zoneCommand(platform, systemRoot)).catch(() => undefined)
180 offsetMinutes = zone === undefined ? undefined : offsetOf(zone.stdout)
181}
182
183const openPane = async ($: EngineInterface) => {
184 await $.ui.open({ id: PANE, title: TITLE, focus: true })
185 await update($, isPaneOpen, () => true)
186}
187
188const perform = async ($: EngineInterface, action: Action) => {
189 if (action.kind === 'tab') await update($, tab, () => action.tab)
190 else if (action.kind === 'unique') await update($, isUnique, was => !was)
191 else if (action.kind === 'more' && action.tab === 'images') await update($, imageLimit, n => n + IMAGE_PAGE)
192 else if (action.kind === 'more') await update($, limit, n => n + PAGE)
193 else if (action.kind === 'clear') {
194 if (action.tab === 'images') await update($, images, () => [])
195 else await update($, files, () => [])
196 } else if (action.kind === 'launch') {
197 const { platform, systemRoot } = await hostOf($)
198 await $.process.run(launchCommand(platform, systemRoot, action.path, action.isRevealed)).catch(() => undefined)
199 } else if (action.kind === 'view') {
200 await update($, viewing, () => action.id)
201 await $.ui.open({ id: VIEWER, title: VIEWER_TITLE, focus: true, closeOnEscape: true, columns: VIEWER_COLUMNS })
202 } else if (action.kind === 'step') {
203 const list = await read($, images)
204 const current = await read($, viewing)
205 const index = list.findIndex(one => one.id === current)
206 const target = list[Math.min(list.length - 1, Math.max(0, index + action.delta))]
207 if (target !== undefined) await update($, viewing, () => target.id)
208 } else {
209 await $.ui.close({ id: VIEWER })
210 }
211}
212
213const togglePane = async ($: EngineInterface) => {
214 const isUp = (await $.ui.panes()).some(pane => pane.id === PANE)
215 if (!isUp) return openPane($)
216 await $.ui.close({ id: PANE })
217 await update($, isPaneOpen, () => false)
218}
219
220const picture = async ($: EngineInterface, path: string) => {
221 const cached = pictures.get(path)
222 if (cached !== undefined) return cached
223 const loaded = await $.fs.read(path, { as: 'bytes' }).catch(() => undefined)
224 if (loaded === undefined) return undefined
225 pictures.set(path, loaded.base64)
226 return loaded.base64
227}
228
229const svgOf = (mediaType: string, base64: string, width: number, height: number) =>
230 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
231 `<image href="data:${mediaType};base64,${base64}" width="${width}" height="${height}"/></svg>`
232
233const scaled = (image: LookedImage, edge: number) => {
234 const width = image.width ?? edge
235 const height = image.height ?? edge
236 const factor = Math.min(1, edge / Math.max(width, height))
237 return { width: Math.max(1, Math.round(width * factor)), height: Math.max(1, Math.round(height * factor)) }
238}
239
240const pictureOf = async ($: EngineInterface, image: LookedImage, isPreview: boolean) => {
241 const path = isPreview ? image.preview : image.thumb
242 if (path !== null) {
243 const base64 = await picture($, path)
244 if (base64 === undefined) return undefined
245 const size = scaled(image, isPreview ? (image.previewSize ?? 1200) : (image.thumbSize ?? 240))
246 return svgOf('image/jpeg', base64, size.width, size.height)
247 }
248 if (image.file === null || image.bytes > INLINE_LIMIT || image.width === null) return undefined
249 const base64 = await picture($, image.file)
250 return base64 === undefined ? undefined : svgOf(image.mediaType, base64, image.width, image.height ?? image.width)
251}
252
253const shortener = async ($: EngineInterface) => {
254 const { platform } = await hostOf($)
255 const cwd = await $.session.cwd()
256 const home = platform === 'windows' ? await $.env.get('USERPROFILE') : await $.env.get('HOME')
257 const spelled = (path: string) => (platform === 'windows' ? path.replace(/\//g, '\\').toLowerCase() : path)
258 const under = (path: string, root: string | undefined) => {
259 if (root === undefined || root === '') return undefined
260 const base = spelled(root).replace(/[\\/]+$/, '')
261 return spelled(path).startsWith(`${base}${separatorOf(platform)}`) ? path.slice(base.length + 1) : undefined
262 }
263 return (path: string) => {
264 const inProject = under(path, cwd)
265 if (inProject !== undefined) return inProject
266 const inHome = under(path, home)
267 return inHome === undefined ? path : `~${separatorOf(platform)}${inHome}`
268 }
269}
270
271const keyOf = (prefix: string, id: string) => `${prefix}-${id.replace(/[^A-Za-z0-9_-]/g, '_')}`
272
273const breakable = (text: string) => text.replace(/([\\/?&=._-])/g, '$1\u200B')
274
275const timeOf = (at: number | null) => {
276 if (at === null) return 'earlier'
277 const local = new Date(at - (offsetMinutes ?? new Date(at).getTimezoneOffset()) * 60000)
278 return [local.getUTCHours(), local.getUTCMinutes(), local.getUTCSeconds()].map(n => String(n).padStart(2, '0')).join(':')
279}
280
281const sizeOf = (bytes: number) =>
282 bytes >= 1048576 ? `${(bytes / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`
283
284export const register: Register = on => {
285 on('session.start', async ($, e, next) => {
286 const started = await next(e)
287 await readZone($)
288 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
289 await update($, isPaneOpen, () => isOpen)
290 const isEmpty = (await read($, images)).length === 0 && (await read($, files)).length === 0
291 if (isEmpty && !(await read($, isBackfilled))) void backfill($).catch(() => undefined)
292 await update($, isBackfilled, () => true)
293 return started
294 })
295
296 on('session.append', { door: 'response' }, ($, e, next) => {
297 for (const use of toolUsesOf(e.message.content)) uses.set(use.id, use)
298 return next(e)
299 })
300
301 on('session.append', { door: 'tool-result' }, async ($, e, next) => {
302 const stored = await next(e)
303 const at = await $.clock.now()
304 const tool = e.origin.kind === 'tool' ? e.origin.tool : 'unknown'
305 void observe($, e.message.content, e.uuid, tool, e.agentId, at).catch(() => undefined)
306 return stored
307 })
308
309 on('session.append', { door: 'tool-message' }, async ($, e, next) => {
310 const stored = await next(e)
311 const at = await $.clock.now()
312 const tool = e.origin.kind === 'tool' ? e.origin.tool : 'unknown'
313 void observe($, e.message.content, e.uuid, tool, e.agentId, at).catch(() => undefined)
314 return stored
315 })
316
317 on('ui.focus', { component: 'Pane' }, async ($, e, next) => {
318 const wasIdle = keyed.get(e.requestId) === false
319 const moved = await next(e)
320 const action = e.element === undefined ? undefined : actions.get(e.requestId)?.get(e.element)
321 const isClickedIn = wasIdle && e.origin.kind === 'person' && moved.deny === undefined
322 if (isClickedIn && action !== undefined && e.element !== undefined) {
323 focusPress = { element: e.element, at: await $.clock.now() }
324 await perform($, action).catch(() => undefined)
325 }
326 return moved
327 }).catch(($, e, next) => next(e))
328
329 on('ui.press', { plugin: 'looked-at' }, async ($, e, next) => {
330 const isEcho = focusPress?.element === e.element && (await $.clock.now()) - focusPress.at < ECHO_MS
331 focusPress = undefined
332 return isEcho ? { element: e.element } : next(e)
333 }).catch(($, e, next) => next(e))
334
335 on('ui.close', { id: PANE }, async ($, e, next) => {
336 const closed = await next(e)
337 await update($, isPaneOpen, () => false)
338 return closed
339 })
340
341 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
342 const below = await next(e)
343 if (e.surface !== 'desktop') return below
344
345 const { Box, Button } = $.ui.resolve(e)
346 const isOpen = await read($, isPaneOpen)
347
348 return (
349 <Box flexDirection="row" gap={1} alignItems="center">
350 <Button key="eye" label={EYE} dimColor={!isOpen} onPress={() => togglePane($)} />
351 {below}
352 </Box>
353 )
354 })
355
356 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
357 const table = $.ui.resolve(e)
358 const { Box, Button, Text } = table
359 const Svg = 'Svg' in table ? table.Svg : undefined
360 keyed.set(PANE, e.props.isFocused)
361 const fullOnHover = (key: string, shown: string, full: string) => (
362 <Box key={key} flexDirection="column" minWidth={0}>
363 <Text dimColor wrap="truncate-middle">
364 {shown}
365 </Text>
366 <Box display="none" hover={{ display: 'flex' }} minWidth={0}>
367 <Text wrap="wrap">{breakable(full)}</Text>
368 </Box>
369 </Box>
370 )
371 const drawn = new Map<string, Action>()
372 const act = (key: string, action: Action) => {
373 drawn.set(key, action)
374 return () => perform($, action)
375 }
376 const seenImages = await read($, images)
377 const seenFiles = await read($, files)
378 const shownTab = await read($, tab)
379 const count = await read($, limit)
380 const imageCount = await read($, imageLimit)
381 const unique = await read($, isUnique)
382 const short = await shortener($)
383
384 const header = (
385 <Box flexDirection="row" gap={1} flexWrap="wrap" alignItems="center">
386 <Button
387 key="tab-images"
388 label={`Images ${seenImages.length}`}
389 variant={shownTab === 'images' ? 'primary' : 'secondary'}
390 onPress={act('tab-images', { kind: 'tab', tab: 'images' })}
391 />
392 <Button
393 key="tab-files"
394 label={`Files ${seenFiles.length}`}
395 variant={shownTab === 'files' ? 'primary' : 'secondary'}
396 onPress={act('tab-files', { kind: 'tab', tab: 'files' })}
397 />
398 {shownTab === 'files' && (
399 <Button key="unique" label={unique ? 'Show history' : 'Group by file'} dimColor onPress={act('unique', { kind: 'unique' })} />
400 )}
401 <Button
402 key="clear"
403 label="Clear"
404 dimColor
405 onPress={act('clear', { kind: 'clear', tab: shownTab })}
406 />
407 </Box>
408 )
409
410 const more = (total: number, page: number, step: number, moreTab: LookedTab) =>
411 total > page && (
412 <Button key="more" label={`Show ${Math.min(step, total - page)} more of ${total - page}`} dimColor onPress={act('more', { kind: 'more', tab: moreTab })} />
413 )
414
415 if (shownTab === 'images') {
416 const shown = seenImages.slice(-imageCount).reverse()
417 const thumbs: Array<string | undefined> = []
418 let budget = PICTURE_BUDGET
419 for (const image of shown) {
420 const thumb = Svg === undefined ? undefined : await pictureOf($, image, false)
421 const fits = thumb !== undefined && thumb.length <= budget
422 if (fits) budget -= thumb.length
423 thumbs.push(fits ? thumb : undefined)
424 }
425 const rows = shown.map((image, index) => {
426 const thumb = thumbs[index]
427 const label = toolLabel(image.tool)
428 const facts = [
429 timeOf(image.at),
430 image.width === null ? image.mediaType.replace('image/', '') : `${image.width}×${image.height}`,
431 sizeOf(image.bytes),
432 image.agent,
433 image.isError ? 'error result' : null,
434 ].filter(Boolean)
435 const placeholder = image.thumb === null && image.file === null ? '[processing]' : image.thumb === null ? '[no preview]' : `[${ZOOM} to view]`
436 return (
437 <Box key={keyOf('image', image.id)} flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1}>
438 <Box flexDirection="row" gap={1} minWidth={0}>
439 <Box flexShrink={0}>
440 {thumb !== undefined && Svg !== undefined ? (
441 <Svg source={thumb} alt={`${label} ${image.detail}`} width={THUMB_WIDTH} />
442 ) : (
443 <Text dimColor>{placeholder}</Text>
444 )}
445 </Box>
446 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
447 <Box flexDirection="row" gap={1} alignItems="center">
448 <Box flexGrow={1} flexShrink={1} minWidth={0}>
449 <Text bold wrap="truncate-end">
450 {label}
451 </Text>
452 </Box>
453 <Box flexDirection="row" gap={1} flexShrink={0}>
454 {image.preview !== null && (
455 <Button key={keyOf('zoom', image.id)} label={ZOOM} dimColor onPress={act(keyOf('zoom', image.id), { kind: 'view', id: image.id })} />
456 )}
457 {image.file !== null && <Button key={keyOf('open', image.id)} label={OPEN} dimColor onPress={act(keyOf('open', image.id), { kind: 'launch', path: image.file, isRevealed: false })} />}
458 {image.file !== null && <Button key={keyOf('reveal', image.id)} label={REVEAL} dimColor onPress={act(keyOf('reveal', image.id), { kind: 'launch', path: image.file, isRevealed: true })} />}
459 </Box>
460 </Box>
461 {image.detail !== '' && fullOnHover(keyOf('detail', image.id), short(image.detail), image.detail)}
462 <Text dimColor wrap="truncate-end">
463 {facts.join(' · ')}
464 </Text>
465 </Box>
466 </Box>
467 </Box>
468 )
469 })
470 actions.set(PANE, drawn)
471 return (
472 <Box flexDirection="column" gap={1}>
473 {header}
474 {seenImages.length === 0 && <Text dimColor>No images seen yet. Screenshots, renders and image reads show up here.</Text>}
475 {rows}
476 {more(seenImages.length, imageCount, IMAGE_PAGE, 'images')}
477 </Box>
478 )
479 }
480
481 const groups = new Map<string, { last: LookedFile; ops: Set<string>; count: number }>()
482 if (unique) {
483 for (const hit of seenFiles) {
484 const group = groups.get(hit.path)
485 groups.set(hit.path, { last: hit, ops: new Set([...(group?.ops ?? []), hit.op]), count: (group?.count ?? 0) + 1 })
486 }
487 }
488 const list = unique
489 ? [...groups.values()].sort((a, b) => (a.last.at ?? 0) - (b.last.at ?? 0))
490 : seenFiles.map(last => ({ last, ops: new Set<string>([last.op]), count: 1 }))
491 const shown = list.slice(-count).reverse()
492
493 actions.set(PANE, drawn)
494 return (
495 <Box flexDirection="column" gap={1}>
496 {header}
497 {seenFiles.length === 0 && <Text dimColor>No files read or written yet.</Text>}
498 <Box flexDirection="column">
499 {shown.map(({ last, ops, count: times }, index) => {
500 const path = short(last.path)
501 const facts = [
502 [...ops].join('+'),
503 timeOf(last.at),
504 last.detail,
505 times > 1 ? `${times}×` : '',
506 last.tool === 'Read' || last.tool === 'Write' || last.tool === 'Edit' ? '' : toolLabel(last.tool),
507 last.agent ?? '',
508 last.isError ? 'error result' : '',
509 ].filter(fact => fact !== '')
510 return (
511 <Box key={keyOf('file', last.id)} marginTop={index === 0 ? 0 : ROW_SPACING} flexDirection="row" gap={1} alignItems="center" borderStyle="round" borderColor="subtle" paddingX={1}>
512 {Svg !== undefined && <Svg source={fileIcon(last.path, ops)} alt={`${extensionOf(last.path) || 'file'} file`} width={ICON_SIZE} />}
513 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
514 <Box flexDirection="row" gap={1} alignItems="center">
515 <Box flexShrink={1} minWidth={0}>
516 <Text bold wrap="truncate-end">
517 {nameOf(path)}
518 </Text>
519 </Box>
520 <Box flexGrow={1} flexShrink={2} minWidth={0}>
521 <Text dimColor wrap="truncate-end">
522 {facts.join(' · ')}
523 </Text>
524 </Box>
525 <Box flexDirection="row" gap={1} flexShrink={0}>
526 <Button key={keyOf('open', last.id)} label={OPEN} dimColor onPress={act(keyOf('open', last.id), { kind: 'launch', path: last.path, isRevealed: false })} />
527 <Button key={keyOf('reveal', last.id)} label={REVEAL} dimColor onPress={act(keyOf('reveal', last.id), { kind: 'launch', path: last.path, isRevealed: true })} />
528 </Box>
529 </Box>
530 {fullOnHover(keyOf('path', last.id), folderOf(path), last.path)}
531 </Box>
532 </Box>
533 )
534 })}
535 </Box>
536 {more(list.length, count, PAGE, 'files')}
537 </Box>
538 )
539 })
540
541 on('ui.render', { component: 'Pane', requestId: VIEWER }, async ($, e) => {
542 const table = $.ui.resolve(e)
543 const { Box, Button, Text } = table
544 const Svg = 'Svg' in table ? table.Svg : undefined
545 keyed.set(VIEWER, e.props.isFocused)
546 const drawn = new Map<string, Action>()
547 const act = (key: string, action: Action) => {
548 drawn.set(key, action)
549 return () => perform($, action)
550 }
551
552 const list = await read($, images)
553 const current = await read($, viewing)
554 const index = list.findIndex(one => one.id === current)
555 const image = list[index]
556 const close = <Button key="close" label={CLOSE} dimColor role="dismiss" onPress={act('close', { kind: 'close-viewer' })} />
557 if (image === undefined) {
558 actions.set(VIEWER, drawn)
559 return (
560 <Box flexDirection="row" gap={1} alignItems="center">
561 <Box flexGrow={1}>
562 <Text dimColor>That image is no longer in the list.</Text>
563 </Box>
564 {close}
565 </Box>
566 )
567 }
568
569 const short = await shortener($)
570 const label = toolLabel(image.tool)
571 const picture = Svg === undefined ? undefined : await pictureOf($, image, true)
572 const facts = [
573 timeOf(image.at),
574 image.width === null ? image.mediaType.replace('image/', '') : `${image.width}×${image.height}`,
575 sizeOf(image.bytes),
576 image.agent,
577 image.detail === '' ? null : short(image.detail),
578 ].filter(Boolean)
579 actions.set(VIEWER, drawn)
580
581 return (
582 <Box flexDirection="column" gap={1}>
583 <Box flexDirection="row" gap={1} alignItems="center">
584 <Box flexDirection="row" gap={1} alignItems="center" flexShrink={0}>
585 <Button key="previous" label={PREVIOUS} dimColor onPress={act('previous', { kind: 'step', delta: -1 })} />
586 <Text dimColor>
587 {index + 1} of {list.length}
588 </Text>
589 <Button key="next" label={NEXT} dimColor onPress={act('next', { kind: 'step', delta: 1 })} />
590 </Box>
591 <Box flexGrow={1} flexShrink={1} minWidth={0}>
592 <Text bold wrap="truncate-end">
593 {label}
594 </Text>
595 </Box>
596 <Box flexDirection="row" gap={1} flexShrink={0}>
597 {image.file !== null && <Button key="open" label={OPEN} dimColor onPress={act('open', { kind: 'launch', path: image.file, isRevealed: false })} />}
598 {image.file !== null && <Button key="reveal" label={REVEAL} dimColor onPress={act('reveal', { kind: 'launch', path: image.file, isRevealed: true })} />}
599 {close}
600 </Box>
601 </Box>
602 <Text dimColor wrap="truncate-middle">
603 {facts.join(' · ')}
604 </Text>
605 {picture !== undefined && Svg !== undefined ? (
606 <Svg source={picture} alt={`${label} ${image.detail}`} />
607 ) : (
608 <Text dimColor>No preview for this image.</Text>
609 )}
610 </Box>
611 )
612 })
613}
614hooks/capture.ts 116 lines1import type { LookedFile, LookedFileOp } from '../types'
2
3export type Block = { readonly type: string; readonly [field: string]: unknown }
4
5export type ToolUse = { id: string; name: string; input: Record<string, unknown> }
6
7export type ImageJob = {
8 id: string
9 tool: string
10 detail: string
11 mediaType: string
12 data: string
13 isError: boolean
14}
15
16export type FileHit = Omit<LookedFile, 'at' | 'agent'>
17
18export type Seen = { images: ImageJob[]; files: FileHit[] }
19
20const FILE_OPS: Record<string, LookedFileOp> = {
21 Read: 'read',
22 Write: 'write',
23 Edit: 'edit',
24 MultiEdit: 'edit',
25 NotebookEdit: 'edit',
26}
27
28const DETAIL_KEYS = ['action', 'file_path', 'path', 'url', 'name', 'query', 'command', 'description']
29
30const isRecord = (value: unknown): value is Record<string, unknown> =>
31 typeof value === 'object' && value !== null && !Array.isArray(value)
32
33const blocksOf = (content: unknown): Block[] =>
34 Array.isArray(content) ? content.filter((b): b is Block => isRecord(b) && typeof b.type === 'string') : []
35
36const text = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
37
38export const toolLabel = (tool: string) => {
39 const parts = tool.split('__')
40 return parts[0] === 'mcp' && parts.length >= 3 ? `${parts[1]} · ${parts.slice(2).join('__')}` : tool
41}
42
43export const describeCall = (input: Record<string, unknown>) => {
44 const parts: string[] = []
45 for (const key of DETAIL_KEYS) {
46 const value = text(input[key])
47 if (value !== '') parts.push(value)
48 if (parts.length === 2) break
49 }
50 return parts.join(' · ').replace(/\s+/g, ' ').slice(0, 200)
51}
52
53const pathOf = (input: Record<string, unknown>) => text(input.file_path) || text(input.notebook_path)
54
55const fileDetail = (op: LookedFileOp, input: Record<string, unknown>) => {
56 if (op !== 'read') return ''
57 if (text(input.pages) !== '') return `pages ${text(input.pages)}`
58 const offset = typeof input.offset === 'number' ? input.offset : undefined
59 const limit = typeof input.limit === 'number' ? input.limit : undefined
60 if (offset === undefined && limit === undefined) return ''
61 const first = offset ?? 1
62 return limit === undefined ? `from line ${first}` : `lines ${first}–${first + limit - 1}`
63}
64
65const imageOf = (block: Block) => {
66 if (block.type !== 'image' || !isRecord(block.source)) return undefined
67 const { type, media_type, data } = block.source
68 if (type !== 'base64' || typeof data !== 'string' || data === '') return undefined
69 return { mediaType: typeof media_type === 'string' ? media_type : 'image/png', data }
70}
71
72export const toolUsesOf = (content: unknown): ToolUse[] =>
73 blocksOf(content)
74 .filter(b => b.type === 'tool_use' && typeof b.id === 'string' && typeof b.name === 'string')
75 .map(b => ({ id: b.id as string, name: b.name as string, input: isRecord(b.input) ? b.input : {} }))
76
77export const resultsOf = (
78 content: unknown,
79 rowId: string,
80 fallbackTool: string,
81 useOf: (id: string) => ToolUse | undefined,
82): Seen => {
83 const seen: Seen = { images: [], files: [] }
84 let lastUse: ToolUse | undefined
85 let loose = 0
86
87 for (const block of blocksOf(content)) {
88 if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') {
89 const id = block.tool_use_id
90 const use = useOf(id)
91 lastUse = use
92 const tool = use?.name ?? fallbackTool
93 const input = use?.input ?? {}
94 const isError = block.is_error === true
95 const op = FILE_OPS[tool]
96 const path = pathOf(input)
97 if (op !== undefined && path !== '') {
98 seen.files.push({ id, tool, op, path, detail: fileDetail(op, input), isError })
99 }
100 const detail = tool === 'Read' ? path : describeCall(input)
101 blocksOf(block.content).forEach((inner, index) => {
102 const image = imageOf(inner)
103 if (image !== undefined) seen.images.push({ id: `${id}#${index}`, tool, detail, isError, ...image })
104 })
105 continue
106 }
107 const image = imageOf(block)
108 if (image === undefined) continue
109 const tool = lastUse?.name ?? fallbackTool
110 const detail = lastUse === undefined ? '' : describeCall(lastUse.input)
111 seen.images.push({ id: `${rowId}~${loose++}`, tool, detail, isError: false, ...image })
112 }
113
114 return seen
115}
116hooks/icons.ts 94 lines1export const ICON_SIZE = 30
2
3const FILLS: Record<string, string> = {
4 cs: '#7B3FA0',
5 ts: '#3178C6',
6 tsx: '#3178C6',
7 js: '#E8C547',
8 jsx: '#E8C547',
9 mjs: '#E8C547',
10 json: '#B7791F',
11 md: '#4B5563',
12 txt: '#6B7280',
13 log: '#6B7280',
14 py: '#3776AB',
15 sh: '#3F8F29',
16 zsh: '#3F8F29',
17 yaml: '#C2410C',
18 yml: '#C2410C',
19 xml: '#C2410C',
20 html: '#E34F26',
21 css: '#264DE4',
22 png: '#0F8B8D',
23 jpg: '#0F8B8D',
24 jpeg: '#0F8B8D',
25 gif: '#0F8B8D',
26 webp: '#0F8B8D',
27 svg: '#0F8B8D',
28 psd: '#0F5E9C',
29 prefab: '#2A3A4A',
30 unity: '#2A3A4A',
31 asset: '#2A3A4A',
32 mat: '#2A3A4A',
33 meta: '#3B4654',
34 anim: '#2A3A4A',
35 controller: '#2A3A4A',
36 spriteatlas: '#2A3A4A',
37 shader: '#2A3A4A',
38 asmdef: '#2A3A4A',
39 xlsx: '#1D6F42',
40 csv: '#1D6F42',
41 pdf: '#B91C1C',
42}
43
44const DARK_INK = new Set(['js', 'jsx', 'mjs'])
45
46const LABELS: Record<string, string> = {
47 prefab: 'PFB',
48 unity: 'SCN',
49 asset: 'AST',
50 controller: 'CTRL',
51 spriteatlas: 'ATL',
52 shader: 'SHD',
53 asmdef: 'ASM',
54 jpeg: 'JPG',
55 yaml: 'YML',
56}
57
58const OP_BADGES: Record<string, string> = { write: '#22C55E', edit: '#F59E0B' }
59
60const lastSeparator = (path: string) => Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
61
62export const nameOf = (path: string) => path.slice(lastSeparator(path) + 1)
63
64export const folderOf = (path: string) => {
65 const cut = lastSeparator(path)
66 return cut < 0 ? '.' : path.slice(0, cut + 1)
67}
68
69export const extensionOf = (path: string) => {
70 const name = nameOf(path)
71 const dot = name.lastIndexOf('.')
72 return dot > 0 ? name.slice(dot + 1).toLowerCase() : ''
73}
74
75const hueOf = (text: string) => [...text].reduce((sum, char) => (sum * 31 + char.charCodeAt(0)) % 360, 7)
76
77export const fileIcon = (path: string, ops: ReadonlySet<string>) => {
78 const extension = extensionOf(path)
79 const label = (LABELS[extension] ?? (extension || 'file')).replace(/[^a-z0-9]/gi, '').slice(0, 4).toUpperCase() || 'FILE'
80 const fill = FILLS[extension] ?? (extension === '' ? '#6B7280' : `hsl(${hueOf(extension)}, 45%, 40%)`)
81 const ink = DARK_INK.has(extension) ? '#1F2937' : '#FFFFFF'
82 const fontSize = label.length <= 2 ? 13 : label.length === 3 ? 11 : 9
83 const badge = ops.has('write') ? OP_BADGES.write : ops.has('edit') ? OP_BADGES.edit : undefined
84 return (
85 `<svg xmlns="http://www.w3.org/2000/svg" width="${ICON_SIZE}" height="${ICON_SIZE}" viewBox="0 0 32 32">` +
86 `<rect x="1" y="1" width="30" height="30" rx="6" fill="${fill}"/>` +
87 `<text x="16" y="16.5" text-anchor="middle" dominant-baseline="central" ` +
88 `font-family="-apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif" font-size="${fontSize}" ` +
89 `font-weight="700" letter-spacing="0.3" fill="${ink}">${label}</text>` +
90 (badge === undefined ? '' : `<circle cx="27" cy="5" r="4" fill="${badge}" stroke="#FFFFFF" stroke-width="1.5"/>`) +
91 `</svg>`
92 )
93}
94hooks/platform.ts 158 lines1export type Platform = 'mac' | 'windows'
2
3export type ImagePaths = { encoded: string; file: string; thumb: string; preview: string }
4
5export const INLINE_LIMIT = 95000
6
7export const THUMB_LIMIT = 9000
8
9const THUMB_STEPS = [
10 [200, 65],
11 [160, 60],
12 [128, 55],
13 [96, 50],
14]
15
16const PREVIEW_STEPS = [
17 [1200, 70],
18 [1000, 65],
19 [800, 60],
20 [640, 55],
21 [480, 50],
22 [320, 45],
23]
24
25const MAC_THUMBNAILER = `
26encoded="$1"; src="$2"; thumb="$3"; preview="$4"
27/usr/bin/base64 -d -i "$encoded" -o "$src" || exit 3
28/bin/rm -f "$encoded"
29w=$(/usr/bin/sips -g pixelWidth "$src" 2>/dev/null | /usr/bin/awk '/pixelWidth/ {print $2}')
30h=$(/usr/bin/sips -g pixelHeight "$src" 2>/dev/null | /usr/bin/awk '/pixelHeight/ {print $2}')
31[ -n "$w" ] && [ -n "$h" ] || exit 4
32max=$w; [ "$h" -gt "$max" ] && max=$h
33for spec in ${THUMB_STEPS.map(([edge, quality]) => `"${edge} ${quality}"`).join(' ')}; do
34 set -- $spec
35 t=$1; [ "$t" -gt "$max" ] && t=$max
36 /usr/bin/sips -Z "$t" -s format jpeg -s formatOptions "$2" "$src" --out "$thumb" >/dev/null 2>&1 || exit 5
37 [ "$(/usr/bin/stat -f%z "$thumb")" -le ${THUMB_LIMIT} ] && break
38done
39for spec in ${PREVIEW_STEPS.map(([edge, quality]) => `"${edge} ${quality}"`).join(' ')}; do
40 set -- $spec
41 z=$1; [ "$z" -gt "$max" ] && z=$max
42 /usr/bin/sips -Z "$z" -s format jpeg -s formatOptions "$2" "$src" --out "$preview" >/dev/null 2>&1 || exit 6
43 [ "$(/usr/bin/stat -f%z "$preview")" -le ${INLINE_LIMIT} ] && break
44done
45echo "$w $h $t $z"
46`
47
48const quoted = (text: string) => `'${text.replace(/'/g, "''")}'`
49
50const windowsThumbnailer = (paths: ImagePaths) => `
51$ErrorActionPreference = 'Stop'
52$encoded = ${quoted(paths.encoded)}; $src = ${quoted(paths.file)}; $thumb = ${quoted(paths.thumb)}; $preview = ${quoted(paths.preview)}
53try {
54 [IO.File]::WriteAllBytes($src, [Convert]::FromBase64String([IO.File]::ReadAllText($encoded)))
55 Remove-Item -LiteralPath $encoded -Force
56} catch { exit 3 }
57try {
58 Add-Type -AssemblyName System.Drawing
59 $img = [System.Drawing.Image]::FromFile($src)
60} catch { exit 4 }
61$w = $img.Width; $h = $img.Height; $max = [Math]::Max($w, $h)
62$codec = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq 'image/jpeg' } | Select-Object -First 1
63function Save-Jpeg([int]$edge, [long]$quality, [string]$out) {
64 $edge = [Math]::Min($edge, $max)
65 $scale = $edge / $max
66 $tw = [Math]::Max(1, [int][Math]::Round($w * $scale))
67 $th = [Math]::Max(1, [int][Math]::Round($h * $scale))
68 $bmp = New-Object System.Drawing.Bitmap($tw, $th)
69 $g = [System.Drawing.Graphics]::FromImage($bmp)
70 $g.Clear([System.Drawing.Color]::Black)
71 $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
72 $g.DrawImage($img, 0, 0, $tw, $th)
73 $params = New-Object System.Drawing.Imaging.EncoderParameters(1)
74 $params.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter([System.Drawing.Imaging.Encoder]::Quality, $quality)
75 $bmp.Save($out, $codec, $params)
76 $g.Dispose(); $bmp.Dispose()
77 return $edge
78}
79$t = 0
80try {
81 foreach ($spec in @(${THUMB_STEPS.map(([edge, quality]) => `@(${edge}, ${quality})`).join(', ')})) {
82 $t = Save-Jpeg $spec[0] $spec[1] $thumb
83 if ((Get-Item -LiteralPath $thumb).Length -le ${THUMB_LIMIT}) { break }
84 }
85} catch { exit 5 }
86$z = 0
87try {
88 foreach ($spec in @(${PREVIEW_STEPS.map(([edge, quality]) => `@(${edge}, ${quality})`).join(', ')})) {
89 $z = Save-Jpeg $spec[0] $spec[1] $preview
90 if ((Get-Item -LiteralPath $preview).Length -le ${INLINE_LIMIT}) { break }
91 }
92} catch { exit 6 }
93$img.Dispose()
94Write-Output "$w $h $t $z"
95exit 0
96`
97
98const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
99
100const base64Of = (bytes: number[]) => {
101 let out = ''
102 for (let i = 0; i < bytes.length; i += 3) {
103 const [a = 0, b = 0, c = 0] = [bytes[i], bytes[i + 1], bytes[i + 2]]
104 const n = (a << 16) | (b << 8) | c
105 out += BASE64[(n >> 18) & 63]! + BASE64[(n >> 12) & 63]!
106 out += i + 1 < bytes.length ? BASE64[(n >> 6) & 63]! : '='
107 out += i + 2 < bytes.length ? BASE64[n & 63]! : '='
108 }
109 return out
110}
111
112export const encodedCommand = (script: string) => {
113 const bytes: number[] = []
114 for (let i = 0; i < script.length; i++) {
115 const unit = script.charCodeAt(i)
116 bytes.push(unit & 255, unit >> 8)
117 }
118 return base64Of(bytes)
119}
120
121const windowsDir = (systemRoot: string) => systemRoot.replace(/[\\/]+$/, '')
122
123const powershell = (systemRoot: string, script: string) => [
124 `${windowsDir(systemRoot)}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`,
125 '-NoProfile',
126 '-NonInteractive',
127 '-ExecutionPolicy',
128 'Bypass',
129 '-EncodedCommand',
130 encodedCommand(script),
131]
132
133export const separatorOf = (platform: Platform) => (platform === 'windows' ? '\\' : '/')
134
135export const joinPath = (platform: Platform, folder: string, name: string) =>
136 `${folder.replace(/[\\/]+$/, '')}${separatorOf(platform)}${name}`
137
138export const thumbnailCommand = (platform: Platform, systemRoot: string, paths: ImagePaths) =>
139 platform === 'windows'
140 ? powershell(systemRoot, windowsThumbnailer(paths))
141 : ['/bin/sh', '-c', MAC_THUMBNAILER, 'looked-at', paths.encoded, paths.file, paths.thumb, paths.preview]
142
143export const launchCommand = (platform: Platform, systemRoot: string, path: string, isRevealed: boolean) => {
144 if (platform === 'mac') return isRevealed ? ['/usr/bin/open', '-R', path] : ['/usr/bin/open', path]
145 return isRevealed
146 ? [`${windowsDir(systemRoot)}\\explorer.exe`, '/select,', path]
147 : powershell(systemRoot, `Invoke-Item -LiteralPath ${quoted(path)}`)
148}
149
150export const zoneCommand = (platform: Platform, systemRoot: string) =>
151 platform === 'windows' ? powershell(systemRoot, "(Get-Date).ToString('zzz')") : ['/bin/date', '+%z']
152
153export const offsetOf = (text: string) => {
154 const match = text.trim().match(/^([+-])(\d\d):?(\d\d)$/)
155 if (!match) return undefined
156 return (match[1] === '-' ? 1 : -1) * (Number(match[2]) * 60 + Number(match[3]))
157}
158types/index.d.ts 49 lines1export type LookedTab = 'images' | 'files'
2
3export type LookedImage = {
4 id: string
5 at: number | null
6 tool: string
7 detail: string
8 agent: string | null
9 mediaType: string
10 bytes: number
11 width: number | null
12 height: number | null
13 file: string | null
14 thumb: string | null
15 preview: string | null
16 thumbSize: number | null
17 previewSize: number | null
18 isError: boolean
19}
20
21export type LookedFileOp = 'read' | 'write' | 'edit'
22
23export type LookedFile = {
24 id: string
25 at: number | null
26 tool: string
27 op: LookedFileOp
28 path: string
29 detail: string
30 agent: string | null
31 isError: boolean
32}
33
34declare module 'claude-code' {
35 interface PluginState {
36 'looked-at': {
37 images: LookedImage[]
38 files: LookedFile[]
39 tab: LookedTab
40 limit: number
41 imageLimit: number
42 isUnique: boolean
43 isPaneOpen: boolean
44 viewing: string | null
45 isBackfilled: boolean
46 }
47 }
48}
49