SLOPSHOPPER

looked-at

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

newpanespinnerprocess
v0.1.2MITupdated 2026-10-10ozdeger/claude-looked-at-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · looked-at
│ ┃ looked-at ✕ › fix the failing auth test and add an audit log call │ ┃ [ Images 0 ] [ Files 0 ] [ Clear ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ No images seen yet. Screenshots, renders and ⎿ Read 6 lines │ ┃ image reads show up here. ⏺ 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 │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · looked-at
[ Images 0 ] [ Files 0 ] [ Clear ] No images seen yet. Screenshots, renders and image reads show up here.
Pane · looked-at-view
That image is no longer in the list. [ ✕ ]
README

Looked at

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.

The Looked at pane: the Images tab and the Files tab

What you get

  • Images tab: every image a tool handed the agent, newest first. That covers browser and simulator screenshots, renders, image files it read, and the ones its subagents saw. Each row shows a thumbnail, the tool, its input (path, action or URL), the time, the pixel size and the file size.
  • Viewer: ⤢ opens an image large in a pane of its own. ← and → step through the history, and Esc or ✕ closes it.
  • Files tab: every Read, Edit and Write, with a file-type icon, line ranges and times. An orange dot on the icon marks an edit and a green dot a write. Group by file folds repeats into one row per file.
  • Buttons: ↗ opens a file or image in its default app, and 📂 shows it in Finder or Explorer. Hover over a path to see it in full.
  • 👁 in the footer, next to the model picker, opens and closes the pane.

<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>

It only watches

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.

Install

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.

Requirements

  • Claude Code: built and tested on 2.1.293. Mods (plugins made of function hooks) are early access, and their API can change between releases.
  • The Claude desktop app: the pane and the 👁 are drawn only there.
  • macOS or Windows: macOS makes thumbnails with the built-in 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.

Develop

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.

Credits

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.

License

MIT

Source 5 files
hooks/register.tsx 614 lines
1import { 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}
614
hooks/capture.ts 116 lines
1import 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}
116
hooks/icons.ts 94 lines
1export 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}
94
hooks/platform.ts 158 lines
1export 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}
158
types/index.d.ts 49 lines
1export 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