SLOPSHOPPER

vitrin

Claude'un gönderdiği resim, video, PDF, ses ve web sayfalarını terminalden çıkmadan sağdaki panelde canlı bir sayfa olarak gösterir

newpanerowsguardcommandtoast
v0.2.0MITupdated 2026-10-05yasinozmeen/claude-code-mods/vitrin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vitrin
│ ┃ Vitrin ✕ › fix the failing auth test and add an audit log call │ ┃ Vitrin başlatılıyor…▣ client module ./hit.ts │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /vitrin │ ┃ ⎿ vitrin: Vitrin açıldı. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Vitrin
Vitrin başlatılıyor…▣ client module ./hit.tsx
README

Vitrin

Claude Code modu. Claude'un gönderdiği resim, video, PDF, ses, web sayfası ve markdown belgelerini terminalden çıkmadan sağdaki panelde gösterir.

https://github.com/user-attachments/assets/5ca8d5c2-73b8-4fbf-bb05-477b76b44e5b

Panel canlı bir web sayfasıdır: görünmeyen bir tarayıcı sayfayı çizer, her kare terminale resim olarak akıtılır, tıklama ve kaydırma sayfaya geri iletilir. Bu sayede kaydırma akıcıdır ve video panelin içinde sesli oynar.

Kullanım

  • Claude bir medya dosyasının yolunu cevabına yazınca panel kendiliğinden açılır.
  • Cevaptaki yola tıklamak paneli o dosyada açar.
  • Claude'un okuduğu ya da gönderdiği medya dosyasının araç satırındaki yoluna tıklamak da aynı şeyi yapar.
  • Sohbet yalnızca cevaplarda geçen medyayı, Hepsi araçların ürettiklerini de gösterir.
  • Küçük resmin köşesindeki çarpı o dosyayı listeden çıkarır, üst çubuktaki çöp kutusu listeyi boşaltır; dosyalar diskte kalır.
  • Uzun bir markdown belgesi kendi penceresinde kaydırılır; seçilen yazı fare bırakılınca panoya kopyalanır.
  • Büyüt Finder'ın boşluk tuşu önizlemesini, Aç varsayılan uygulamayı açar.
  • Panele bir kez tıkladıktan sonra ok tuşları medyalar arasında gezdirir, boşluk ya da Enter seçili medyayı büyütür.
  • ctrl ya da option ile ikinci bir medyaya tıklamak ikisini karşılaştırır. Aynı biçimdeki iki resim (önce/sonra) üst üste gelir ve ayırıcı sürüklenir; diğerleri yan yana durur. İki resimde düğmeyle ikisi arasında geçilir.
  • Sürükleme sayfaya iletilir: videonun ilerleme çubuğu kaydırılabilir.
  • Sayfa hareket ederken (kaydırma, sürükleme, oynayan video) kareler yarı çözünürlükte gönderilir; durunca netleşir.
  • ctrl ya da option basılıyken Yolu kopyala düğmesi Dizini aç olur: dosyayı Finder'da gösterir.
KomutNe yapar
/vitrinPaneli açar
/vitrin <dosya>Dosyayı ekler ve gösterir
/vitrin hepsi / /vitrin sohbetSüzgeci değiştirir
/vitrin temizleListeyi boşaltır
/vitrin kapatPaneli ve arkadaki tarayıcıyı kapatır
/vitrin oran 0.47Sayfanın en-boy oranını terminal yazı tipine göre düzeltir

Kurulum

Gerekenler: macOS, Ghostty ya da kitty (resim çizebilen bir terminal; tmux içinde çalışmaz), Claude Code 2.1.287+, Node 22+, Chrome ya da Brave, ffmpeg, Xcode komut satırı araçları (swiftc).

git clone https://github.com/yasinozmeen/claude-code-mods.git ~/claude-code-mods
cd ~/claude-code-mods/vitrin
swiftc -O bin/onizle.swift -o bin/onizle

Ardından ~/.claude/settings.json içindeki env bölümüne klasörü ekle (başka modlarla birlikte kullanmak için ana README):

"CLAUDE_CODE_PLUGIN_DIRS": "~/claude-code-mods/vitrin"

Yeni bir Claude Code oturumu aç ve /vitrin yaz.

Claude Code'un mod (function hooks) arayüzü erken erişimde; sürümler arasında değişebilir.

Geliştirme

claude plugin validate .
claude plugin test .
Dosyaİçerik
hooks/register.tsxModun kendisi: medyayı yakalar, köprüyü çalıştırır, paneli çizer
hooks/paths.tsMetinden dosya yolu çıkarma ve yolu bağlantıya çevirme
hooks/hit.tsxPaneldeki tıklamaları duyan bölge
bin/bridge.mjsGörünmeyen tarayıcıyı yöneten köprü
bin/page.htmlPanelde görünen sayfa
bin/onizle.swiftBüyüt için Finder önizlemesi

İlk commit, panelin yalnızca terminal öğeleriyle çizilen ilk sürümüdür.

Lisans

MIT

Source 4 files
hooks/register.tsx 806 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { MediaFilter, MediaItem, MediaSource } from '../types'
5import type { HitPost, HitProps } from './hit'
6import { absolute, hashOf, hrefOf, kindOf, linked, mediaPaths, namesMedia, pathOf } from './paths'
7
8const PANE = 'vitrin'
9const TITLE = 'Vitrin'
10const KEEP = 60
11const MAX_EDGE = 1600
12const THUMB_EDGE = 480
13// A cell's width over its height; Ghostty's default font is close to this.
14const RATIO = 0.47
15// How long before a tool call began a file may have been written and still
16// count as that call's own.
17const FRESH_MS = 2000
18const PER_CALL = 8
19// The longest reply the mod redraws to make its paths pressable.
20const REPLY_MAX = 9000
21// The columns the pane asks for (a width the person dragged wins); the points
22// a column is wide when the page is laid out, the pixels a wheel step moves
23// it, and where node may be.
24const COLUMNS = 64
25const CELL_POINTS = 9
26const WHEEL_PIXELS = 48
27const NODES = ['node', '/opt/homebrew/opt/node@22/bin/node', '/opt/homebrew/bin/node', '/usr/local/bin/node']
28
29const items = atom({ plugin: 'vitrin', key: 'items' } as const, [])
30const ratio = atom({ plugin: 'vitrin', key: 'ratio' } as const, RATIO)
31const filter = atom({ plugin: 'vitrin', key: 'filter' } as const, 'chat')
32const isReady = atom({ plugin: 'vitrin', key: 'isReady' } as const, false)
33
34// What a button of the page asks the mod to do.
35type Act = { act?: string; id?: string; mode?: string; text?: string }
36
37const LABELS: Record<string, string> = { image: '[resim]', video: '[video]', audio: '[ses]', pdf: '[pdf]', page: '[sayfa]', doc: '[belge]' }
38const busy = new Set<string>()
39// Every file the pane has held this session: a reply's path to one stays a
40// link after the list is cleared, and a press brings the file back.
41const named = new Set<string>()
42// Paths a reply named that were not files when looked for; forgotten when
43// new files are taken in, since one of them may be there by then.
44const absent = new Set<string>()
45const place = { home: '', cwd: '' }
46// The bridge as this module knows it: its port once it listens, the newest
47// frame, the pane's size in cells, the key the picture is drawn under, and
48// a file the page has yet to be told to show.
49const web = { x: 0.5, y: 0, port: 0, isStarting: false, file: '', seq: 0, columns: 0, rows: 0, key: 'view', pick: '' }
50
51// Every string a tool call's input holds, a few levels down.
52function strings(value: unknown, depth = 3): string[] {
53  if (typeof value === 'string') {
54    return [value]
55  }
56
57  if (depth === 0 || value === null || typeof value !== 'object') {
58    return []
59  }
60
61  return Object.values(value).flatMap(one => strings(one, depth - 1))
62}
63
64async function ran($: EngineInterface, argv: string[], timeoutMs = 60_000) {
65  try {
66    return await $.process.run(argv, { timeoutMs })
67  } catch {
68    return undefined
69  }
70}
71
72async function located($: EngineInterface) {
73  place.home = (await $.env.get('HOME')) ?? ''
74  place.cwd = await $.session.cwd()
75
76  return place
77}
78
79async function sizeOf($: EngineInterface, file: string) {
80  const out = await ran($, ['/usr/bin/sips', '-g', 'pixelWidth', '-g', 'pixelHeight', file])
81  const width = Number(/pixelWidth:\s*(\d+)/.exec(out?.stdout ?? '')?.[1] ?? 0)
82  const height = Number(/pixelHeight:\s*(\d+)/.exec(out?.stdout ?? '')?.[1] ?? 0)
83
84  return width > 0 && height > 0 ? { width, height } : undefined
85}
86
87// Quick Look's thumbnail of the file, moved to `preview`: what draws an SVG
88// and anything sips cannot read.
89async function thumbnail($: EngineInterface, source: string, preview: string, dir: string) {
90  await ran($, ['/usr/bin/qlmanage', '-t', '-s', String(MAX_EDGE), '-o', dir, source])
91  const made = `${dir}/${source.slice(source.lastIndexOf('/') + 1)}.png`
92
93  if (await $.fs.exists(made)) {
94    await ran($, ['/bin/mv', '-f', made, preview])
95  }
96}
97
98// A PNG of the file, at most MAX_EDGE pixels a side: the picture itself, a
99// PDF's first page, a frame from a video's first second, a sound's waveform,
100// or Quick Look's thumbnail of a web page or a markdown file.
101async function previewOf($: EngineInterface, item: MediaItem, dir: string) {
102  if (await $.fs.exists(item.preview)) {
103    return
104  }
105
106  await ran($, ['/bin/mkdir', '-p', dir])
107
108  if (item.kind === 'video' || item.kind === 'audio') {
109    const scale = `scale='min(${MAX_EDGE},iw)':-2`
110    const wave = 'showwavespic=s=1200x300:colors=0x8ab4f8'
111    const takes =
112      item.kind === 'audio'
113        ? [['-i', item.path, '-filter_complex', wave]]
114        : [['-ss', '1'], []].map(seek => [...seek, '-i', item.path, '-vf', scale])
115
116    for (const ffmpeg of ['ffmpeg', '/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg']) {
117      for (const take of takes) {
118        await ran($, [ffmpeg, '-y', '-v', 'error', ...take, '-frames:v', '1', item.preview])
119
120        if (await $.fs.exists(item.preview)) {
121          return
122        }
123      }
124    }
125
126    if (item.kind === 'audio') {
127      return
128    }
129  } else if (item.kind !== 'page' && item.kind !== 'doc' && !item.path.toLowerCase().endsWith('.svg')) {
130    const size = await sizeOf($, item.path)
131    const isLarge = size === undefined || Math.max(size.width, size.height) > MAX_EDGE
132    const resize = isLarge ? ['-Z', String(MAX_EDGE)] : []
133    const argv = ['/usr/bin/sips', '-s', 'format', 'png', ...resize, item.path]
134    await ran($, [...argv, '--out', item.preview])
135
136    if (await $.fs.exists(item.preview)) {
137      return
138    }
139  }
140
141  await thumbnail($, item.path, item.preview, dir)
142}
143
144// The item with its preview's size and a small copy of it for the grid; with
145// no preview, the item as it is, its card drawn empty.
146async function sized($: EngineInterface, item: MediaItem, dir: string): Promise<MediaItem> {
147  await previewOf($, item, dir)
148  const size = await sizeOf($, item.preview)
149
150  if (size === undefined) {
151    return { ...item, preview: '', thumb: '' }
152  }
153
154  if (Math.max(size.width, size.height) <= THUMB_EDGE) {
155    return { ...item, ...size, thumb: item.preview }
156  }
157
158  const argv = ['/usr/bin/sips', '-Z', String(THUMB_EDGE), item.preview, '--out', item.thumb]
159  await ran($, argv)
160  const hasThumb = await $.fs.exists(item.thumb)
161
162  return { ...item, ...size, thumb: hasThumb ? item.thumb : item.preview }
163}
164
165// One request to the bridge; nothing is said when it is not up.
166async function tell($: EngineInterface, route: string, data: unknown) {
167  if (web.port === 0) {
168    return
169  }
170
171  try {
172    await $.http.fetch(`http://127.0.0.1:${web.port}/${route}`, {
173      method: 'POST',
174      headers: { 'content-type': 'application/json' },
175      body: JSON.stringify(data),
176    })
177  } catch {
178    // The bridge went away; the stream's end resets the rest.
179  }
180}
181
182// The list as the page shows it, and with `pick` the file it shows large. A
183// pick made while the bridge is down is kept for when it is up.
184async function sync($: EngineInterface, pick = '') {
185  if (pick !== '') {
186    web.pick = pick
187  }
188
189  if (web.port === 0) {
190    return
191  }
192
193  const sent = { items: await read($, items), filter: await read($, filter), pick: web.pick }
194  web.pick = ''
195  await tell($, 'items', sent)
196}
197
198// The page's size for the pane's cells: laid out in points, a cell
199// CELL_POINTS wide and as tall as the cell ratio makes it.
200async function measure($: EngineInterface) {
201  if (web.columns > 0) {
202    const cell = await read($, ratio)
203    await tell($, 'size', { width: web.columns * CELL_POINTS, height: (web.rows * CELL_POINTS) / cell })
204  }
205}
206
207// The Finder's space-bar preview over the terminal, left running: the mod's
208// own helper, or the default app where the helper is not built.
209function quickLook($: EngineInterface, path: string) {
210  const script = 'if [ -x "$1" ]; then nohup "$1" "$2" >/dev/null 2>&1 & else /usr/bin/open "$2"; fi'
211  void ran($, ['/bin/sh', '-c', script, 'sh', `${$.plugin.root}/bin/onizle`, path])
212}
213
214async function choose($: EngineInterface, mode: MediaFilter) {
215  await update($, filter, () => mode)
216  await $.store.set('filter', mode)
217  await sync($)
218}
219
220async function clear($: EngineInterface) {
221  await update($, items, () => [])
222  await sync($)
223}
224
225// A button pressed in the page: what the browser cannot do itself.
226async function act($: EngineInterface, sent: Act) {
227  if (sent.act === 'clear') {
228    await clear($)
229
230    return
231  }
232
233  if (sent.act === 'filter') {
234    await choose($, sent.mode === 'all' ? 'all' : 'chat')
235
236    return
237  }
238
239  // Text selected in a document: cmd+C stays with the terminal, so letting
240  // go of the selection is what copies it.
241  if (sent.act === 'text') {
242    if (typeof sent.text === 'string' && sent.text !== '') {
243      await $.ui.copy({ text: sent.text.slice(0, 200_000) })
244      $.ui.toast('Seçim kopyalandı')
245    }
246
247    return
248  }
249
250  const item = (await read($, items)).find(one => one.id === sent.id)
251
252  if (item === undefined) {
253    return
254  }
255
256  if (sent.act === 'look') {
257    quickLook($, item.path)
258  } else if (sent.act === 'open') {
259    await ran($, ['/usr/bin/open', item.path])
260  } else if (sent.act === 'copy') {
261    await $.ui.copy({ text: item.path })
262  } else if (sent.act === 'reveal') {
263    await ran($, ['/usr/bin/open', '-R', item.path])
264  } else if (sent.act === 'remove') {
265    // Out of the list alone: the file is the person's and stays where it is.
266    await update($, items, list => list.filter(one => one.id !== item.id))
267    await sync($)
268  }
269}
270
271// Runs the bridge for as long as the module lives and the pane is open: each
272// frame the browser paints replaces the picture in the pane, with no redraw.
273async function stream($: EngineInterface) {
274  if (web.isStarting || web.port !== 0) {
275    return
276  }
277
278  web.isStarting = true
279  const { home } = await located($)
280  const dir = `${home}/.claude/cache/vitrin/kare-${hashOf(await $.session.id())}`
281
282  for (const node of NODES) {
283    try {
284      let rest = ''
285
286      for await (const piece of $.process.spawn({ argv: [node, `${$.plugin.root}/bin/bridge.mjs`, dir] })) {
287        if (piece.stream !== 'stdout') {
288          continue
289        }
290
291        const lines = `${rest}${piece.text}`.split('\n')
292        rest = lines.pop() ?? ''
293        // Only the newest of the frames that came together is worth drawing.
294        const frame = lines.findLast(line => line.startsWith('F '))
295
296        for (const line of lines) {
297          if (line.startsWith('READY ')) {
298            web.port = Number(line.slice(6))
299            await measure($)
300            await sync($)
301          } else if (line.startsWith('ACT ')) {
302            await act($, JSON.parse(line.slice(4)) as Act)
303          }
304        }
305
306        if (frame !== undefined) {
307          const [, file = '', seq = '0'] = frame.split(' ')
308          web.file = file
309          web.seq = Number(seq)
310
311          if (await read($, isReady)) {
312            await $.ui.blit({ requestId: PANE, key: web.key, source: { file, format: 'png', generation: web.seq } })
313          } else {
314            await update($, isReady, () => true)
315          }
316        }
317      }
318
319      break
320    } catch {
321      // This node is not there; the next may be.
322    }
323  }
324
325  web.port = 0
326  web.isStarting = false
327
328  try {
329    await update($, isReady, () => false)
330  } catch {
331    // The module is being unloaded: there is no pane left to tell.
332  }
333}
334
335// Opens the pane and starts the page behind it; where the terminal is too
336// narrow for a pane nobody asked for, a toast says how to open it.
337// Whether the session draws in a terminal. The pane is a picture only a
338// terminal shows, so in the desktop app and the other surfaces the mod keeps
339// still: it takes nothing in and opens nothing.
340async function inTerminal($: EngineInterface): Promise<boolean> {
341  return (await $.session.surfaces()).includes('terminal')
342}
343
344async function show($: EngineInterface) {
345  const opened = await $.ui.open({ id: PANE, title: TITLE, columns: COLUMNS })
346
347  if (opened.isPlaced) {
348    void stream($)
349  } else {
350    $.ui.toast('Yeni medya hazır: görmek için /vitrin yaz')
351  }
352}
353
354// Adds the files that exist and are media to the top of the list, newest
355// first, and answers how many it added. A file a reply names is the chat's
356// (the pane opens on it), one a tool made is kept for the "all" view; a file
357// last written before `since` is not this call's and is left out.
358async function ingest(
359  $: EngineInterface,
360  paths: readonly string[],
361  from: MediaSource,
362  since = 0,
363  most = KEEP,
364): Promise<number> {
365  if (paths.length === 0 || !(await inTerminal($))) {
366    return 0
367  }
368
369  const { home, cwd } = await located($)
370  const dir = `${home}/.claude/cache/vitrin`
371  let added = 0
372  let newest = ''
373
374  for (const spelled of paths) {
375    const path = absolute(spelled, cwd, home)
376    const kind = kindOf(path)
377
378    if (kind === undefined || busy.has(path) || added >= most) {
379      continue
380    }
381
382    busy.add(path)
383
384    try {
385      const stat = await $.fs.stat(path)
386
387      if (stat.kind !== 'file' || stat.mtimeMs < since) {
388        continue
389      }
390
391      named.add(path)
392      absent.clear()
393      const id = hashOf(`${path}:${stat.mtimeMs}:${stat.size}`)
394      const known = (await read($, items)).find(one => one.id === id)
395
396      if (known !== undefined) {
397        if (from === 'chat' && known.from !== 'chat') {
398          const mine: MediaItem = { ...known, from: 'chat' }
399          await update($, items, list => [mine, ...list.filter(one => one.id !== id)])
400          added += 1
401          newest = id
402        }
403
404        continue
405      }
406
407      const item = await sized(
408        $,
409        {
410          id,
411          path,
412          name: path.slice(path.lastIndexOf('/') + 1),
413          kind,
414          from,
415          preview: `${dir}/${id}.png`,
416          thumb: `${dir}/${id}-k.png`,
417          width: 0,
418          height: 0,
419          addedAt: await $.clock.now(),
420        },
421        dir,
422      )
423      await update($, items, list => {
424        // A file the chat named stays the chat's when a tool writes it again.
425        const wasChat = list.some(one => one.path === path && one.from === 'chat')
426        const next: MediaItem = wasChat ? { ...item, from: 'chat' } : item
427
428        return [next, ...list.filter(one => one.path !== path)].slice(0, KEEP)
429      })
430      added += 1
431      newest = id
432    } catch {
433      // A path the text named that is not there is not media.
434    } finally {
435      busy.delete(path)
436    }
437  }
438
439  if (added > 0 && from === 'chat') {
440    await sync($, newest)
441    await show($)
442  } else if (added > 0) {
443    await sync($)
444  }
445
446  return added
447}
448
449// The media a tool call made: the files its input or output names that were
450// written while it ran. Web pages and markdown are left to the chat, where
451// naming one says it is meant to be seen; a tool writing one is editing code
452// or notes.
453async function capture($: EngineInterface, input: string, output: string, since: number) {
454  const text = `${input}\n${output}`.slice(0, 200_000)
455
456  if (!namesMedia(text)) {
457    return
458  }
459
460  const made = mediaPaths(text)
461    .filter(path => !['page', 'doc'].includes(kindOf(path) ?? ''))
462    .slice(0, 40)
463  await ingest($, made, 'tool', since - FRESH_MS, PER_CALL)
464}
465
466// The media the newest reply names: what a load or reload of the mod missed.
467async function rescan($: EngineInterface) {
468  try {
469    const last = (await $.session.messages()).findLast(one => one.role === 'assistant' && one.text !== '')
470
471    if (last !== undefined) {
472      await ingest($, mediaPaths(last.text), 'chat')
473    }
474  } catch {
475    // No transcript to read yet.
476  }
477}
478
479// A press on a media path in a reply: the pane opens on that file.
480async function jump($: EngineInterface, href: string) {
481  const path = pathOf(href)
482  const item = (await read($, items)).find(one => one.path === path)
483
484  if (!(await inTerminal($))) {
485    return
486  }
487
488  if (item === undefined) {
489    // Cleared from the list since the reply named it: taken in again.
490    await ingest($, [path], 'chat')
491
492    return
493  }
494
495  if (item.from !== 'chat') {
496    const mine: MediaItem = { ...item, from: 'chat' }
497    await update($, items, list => list.map(one => (one.id === item.id ? mine : one)))
498  }
499
500  await sync($, item.id)
501  await show($)
502}
503
504export const register: Register = on => {
505  on('session.start', async ($, e, next) => {
506    await $.command.register({
507      name: 'vitrin',
508      description: 'Medya panelini aç; /vitrin <dosya> ekler, /vitrin hepsi, /vitrin sohbet, /vitrin temizle, /vitrin kapat',
509    })
510    const kept = Number(await $.store.get('ratio'))
511
512    if (kept > 0.2 && kept < 1) {
513      await update($, ratio, () => kept)
514    }
515
516    if ((await $.store.get('filter')) === 'all') {
517      await update($, filter, () => 'all')
518    }
519
520    await located($)
521    await update($, isReady, () => false)
522    void rescan($)
523
524    // A reload ends the bridge with the old module; an open pane wants it back.
525    if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
526      void stream($)
527    }
528
529    return next(e)
530  })
531
532  on('session.append', { door: 'response' }, ($, e, next) => {
533    if (e.agentId === undefined) {
534      const text = e.message.content
535        .map(block => (block.type === 'text' && typeof block.text === 'string' ? block.text : ''))
536        .join('\n')
537      void ingest($, mediaPaths(text), 'chat')
538    }
539
540    return next(e)
541  })
542
543  on('tool.call', async ($, e, next) => {
544    const since = await $.clock.now()
545    const done = await next(e)
546
547    if (e.tool === 'SendUserFile' && e.agentId === undefined) {
548      void ingest($, e.files, 'chat')
549    } else {
550      void capture($, strings(e).join('\n'), done.text ?? '', since)
551    }
552
553    return done
554  })
555
556  on('command.run', { command: 'vitrin' }, async ($, e) => {
557    const args = e.args.trim()
558
559    if (!(await inTerminal($))) {
560      return { text: 'Vitrin yalnızca terminalde çalışır.' }
561    }
562
563    if (args === 'kapat') {
564      await $.ui.close({ id: PANE })
565
566      return { text: 'Vitrin kapatıldı.' }
567    }
568
569    if (args === 'temizle') {
570      await clear($)
571
572      return { text: 'Medya listesi boşaltıldı.' }
573    }
574
575    const cell = /^oran\s+([\d.]+)$/.exec(args)
576
577    if (cell !== null) {
578      const value = Number(cell[1])
579
580      if (!(value > 0.2 && value < 1)) {
581        return { text: 'Oran 0.2 ile 1 arasında olmalı (varsayılan 0.47).' }
582      }
583
584      await update($, ratio, () => value)
585      await $.store.set('ratio', value)
586      await measure($)
587
588      return { text: `Sayfanın en-boy düzeltmesi ${value} yapıldı.` }
589    }
590
591    await $.ui.open({ id: PANE, title: TITLE, columns: COLUMNS })
592    void stream($)
593
594    if (args === '') {
595      return { text: 'Vitrin açıldı.' }
596    }
597
598    if (args === 'hepsi') {
599      await choose($, 'all')
600
601      return { text: 'Araçların ürettikleri dahil bütün medya gösteriliyor.' }
602    }
603
604    if (args === 'sohbet') {
605      await choose($, 'chat')
606
607      return { text: 'Yalnızca sohbette gönderilen medya gösteriliyor.' }
608    }
609
610    const added = await ingest($, [args.replace(/^["']|["']$/g, '')], 'chat')
611
612    return { text: added > 0 ? 'Medya vitrine eklendi.' : `Gösterilemedi: ${args}` }
613  })
614
615  // The pane's region said its size, or heard a click: both go to the page.
616  on('ui.message', { requestId: PANE }, async ($, e, next) => {
617    const sent = e.data as HitPost | undefined
618
619    if (sent?.kind === 'size') {
620      web.columns = sent.columns
621      web.rows = sent.rows
622      await measure($)
623    } else if (sent !== undefined) {
624      if ('x' in sent) {
625        web.x = sent.x
626        web.y = sent.y
627      }
628
629      // Where the pointer rests is kept for the wheel; the page is not told.
630      if (sent.kind !== 'at') {
631        await tell($, 'input', sent)
632      }
633    }
634
635    return next(e)
636  })
637
638  // The wheel over the pane moves the page, not the pane: the pane's own
639  // window has nowhere to go. The page is told where the pointer is, since
640  // a document shown in it scrolls on its own under the pointer.
641  on('ui.scroll', { requestId: PANE }, ($, e) => {
642    void tell($, 'input', { kind: 'wheel', dy: e.by * WHEEL_PIXELS, x: web.x, y: web.y })
643
644    return {}
645  })
646
647  // A closed pane needs no browser behind it.
648  on('ui.close', { id: PANE }, async ($, e, next) => {
649    const closed = await next(e)
650    void tell($, 'quit', {})
651
652    return closed
653  })
654
655  // The pane is one picture, the page as the browser last painted it, under
656  // an empty region that hears the pointer.
657  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
658    const { Box, Text } = $.ui.resolve(e)
659    const hasFrame = await read($, isReady)
660    const columns = Math.max(10, e.props.bodyColumns)
661    const rows = Math.max(5, e.props.scroll.bodyRows || (e.viewport?.rows ?? 40) - 8)
662
663    if (e.surface !== 'terminal') {
664      return <Text dimColor>Vitrin yalnızca terminalde çizilir.</Text>
665    }
666
667    const { Client, Image } = $.ui.resolve(e)
668    const region: HitProps = { columns, rows }
669    // A new size is a new picture to the terminal: under the old key it
670    // keeps the old box and crops or shrinks every frame that follows.
671    web.key = `view-${columns}x${rows}`
672
673    return (
674      <Box width={columns} height={rows}>
675        {hasFrame && web.file !== '' ? (
676          <Image
677            key={web.key}
678            source={{ file: web.file, format: 'png', generation: web.seq }}
679            columns={Math.min(255, columns)}
680            rows={Math.min(255, rows)}
681            alt="Vitrin"
682          />
683        ) : (
684          <Text dimColor>Vitrin başlatılıyor…</Text>
685        )}
686        <Box position="absolute" top={0} left={0}>
687          <Client key="hit" module="./hit.tsx" props={region} width={columns} height={rows} />
688        </Box>
689      </Box>
690    )
691  })
692
693  // A tool row that names media (a picture Claude read, files it sent) is
694  // drawn with the path pressable: a click shows the file in the pane, where
695  // the engine's own row would hand the path to the Finder. A row another
696  // mod has already drawn or folded away is left as that mod made it.
697  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
698    const given = typeof e.props.input === 'object' && e.props.input !== null ? (e.props.input as Record<string, unknown>) : {}
699    const sent = e.props.tool === 'SendUserFile' && Array.isArray(given.files) ? given.files : []
700    const read = e.props.tool === 'Read' && typeof given.file_path === 'string' ? [given.file_path] : []
701    const files = [...sent, ...read].filter((one): one is string => typeof one === 'string')
702
703    if (e.surface !== 'terminal' || !files.some(file => kindOf(file) !== undefined)) {
704      return next(e)
705    }
706
707    const drawn = await next(e)
708
709    if (typeof drawn === 'object' && drawn !== null && 'type' in drawn && drawn.type === 'Box') {
710      return drawn
711    }
712
713    const { home, cwd } = place.home === '' ? await located($) : place
714    const { Box, Markdown, Text } = $.ui.resolve(e)
715    const links: string[] = []
716    const linkOf = (file: string) => {
717      const href = hrefOf(absolute(file, cwd, home))
718
719      if (kindOf(file) === undefined) {
720        return file
721      }
722
723      links.push(href)
724
725      return `[${home !== '' && file.startsWith(`${home}/`) ? `~${file.slice(home.length)}` : file}](${href})`
726    }
727    const caption = typeof given.caption === 'string' && given.caption.trim() !== '' ? given.caption.trim() : 'Dosya gönderildi'
728    const listed = files.map(file => `› ${LABELS[kindOf(file) ?? ''] ?? '[dosya]'} ${linkOf(file)}`).join('  \n')
729    const text = e.props.tool === 'Read' ? `**Read**(${linkOf(files[0] ?? '')})` : `${caption}\n\n${listed}`
730    const mark = e.props.isErrored ? { color: 'red' } : e.props.isRunning ? { dimColor: true } : { color: 'green' }
731
732    return (
733      <Box flexDirection="row">
734        <Text {...mark}>⏺ </Text>
735        <Box flexDirection="column" flexGrow={1} flexShrink={1}>
736          <Markdown key="tool" text={text} pressableLinks={links} onLinkPress={link => void jump($, link.href)} />
737        </Box>
738      </Box>
739    )
740  })
741
742  // The files Claude sent are listed by the row above; the engine's own list
743  // of them under it would say the same twice.
744  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
745    if (e.surface !== 'terminal' || e.props.tool !== 'SendUserFile' || e.props.isErrored) {
746      return next(e)
747    }
748
749    const drawn = await next(e)
750    const isMods = typeof drawn === 'object' && drawn !== null && 'type' in drawn && drawn.type === 'Box'
751    const { Box } = $.ui.resolve(e)
752
753    return isMods ? drawn : <Box />
754  })
755
756  // A reply that names media the pane holds is drawn with those paths as
757  // links: a click shows the file in the pane. Every other reply is the
758  // engine's own drawing.
759  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
760    const reply = e.props.text
761
762    if (e.surface !== 'terminal' || reply.length > REPLY_MAX || !namesMedia(reply)) {
763      return next(e)
764    }
765
766    const list = await read($, items)
767    const { home, cwd } = place.home === '' ? await located($) : place
768
769    // A file the pane never held (named before the mod loaded, or cleared
770    // in an earlier session) is pressable too, as long as it is still there.
771    for (const spelled of mediaPaths(reply).slice(0, 40)) {
772      const path = absolute(spelled, cwd, home)
773
774      if (!named.has(path) && !absent.has(path)) {
775        ;((await $.fs.exists(path)) ? named : absent).add(path)
776      }
777    }
778
779    const made = linked(reply, spelled => {
780      const path = absolute(spelled, cwd, home)
781
782      return named.has(path) || list.some(one => one.path === path) ? hrefOf(path) : undefined
783    })
784
785    if (made.links.length === 0) {
786      return next(e)
787    }
788
789    const { Box, Markdown, Text } = $.ui.resolve(e)
790
791    return (
792      <Box flexDirection="row">
793        <Text>{e.props.isFirstOfReply ? '⏺ ' : '  '}</Text>
794        <Box flexDirection="column" flexGrow={1} flexShrink={1}>
795          <Markdown
796            key="reply"
797            text={made.text}
798            pressableLinks={made.links}
799            onLinkPress={link => void jump($, link.href)}
800          />
801        </Box>
802      </Box>
803    )
804  })
805}
806
hooks/hit.tsx 67 lines
1import type { ClientModule } from 'claude-code'
2
3export type HitProps = { columns: number; rows: number }
4
5// A pointer post's `mods` is a bit set: 1 alt, 2 ctrl (cmd and shift never
6// arrive: the terminal keeps them, shift for selecting text).
7export type HitPost =
8  | { kind: 'size'; columns: number; rows: number }
9  | { kind: 'down' | 'move' | 'up'; x: number; y: number; mods: number }
10  | { kind: 'held'; mods: number }
11  | { kind: 'at'; x: number; y: number }
12  | { kind: 'key'; key: string }
13
14// What the region remembers between calls: its size, the modifier keys down
15// at the last pointer event, and the cell the pointer was last said to be at.
16type HitState = HitProps & { mods: number; cx?: number; cy?: number }
17
18// The empty region over the pane's picture. It says how large it is, posts
19// the pointer's presses and drags as points from 0 to 1 across and down,
20// says when a modifier goes down or up while the pointer is over it and
21// where the pointer rests (the wheel has no place of its own), and,
22// once a click has given it the keyboard, posts the keys pressed.
23const Hit: ClientModule<HitProps, HitState> = (props, surface) => {
24  const { Box } = surface.elements
25  const known = surface.state
26
27  if (known === undefined || known.columns !== props.columns || known.rows !== props.rows) {
28    surface.setState({ columns: props.columns, rows: props.rows, mods: known?.mods ?? 0 })
29    surface.post({ kind: 'size', columns: props.columns, rows: props.rows })
30  }
31
32  surface.onPointer(event => {
33    const mods = (event.alt === true ? 1 : 0) | (event.ctrl === true ? 2 : 0)
34    const isInside = event.x >= 0 && event.y >= 0 && event.x < props.columns && event.y < props.rows
35    const at = (cell: number, fine: number | undefined, cells: number) =>
36      Math.max(0, Math.min(1, (fine ?? cell + 0.5) / cells))
37    const point = { x: at(event.x, event.fine?.x, props.columns), y: at(event.y, event.fine?.y, props.rows), mods }
38
39    if (event.type === 'down' && isInside) {
40      surface.post({ kind: 'down', ...point })
41    } else if (event.type === 'up') {
42      surface.post({ kind: 'up', ...point })
43    } else if (event.type === 'move' && event.button !== undefined) {
44      surface.post({ kind: 'move', ...point })
45    } else if (event.type === 'move' && mods !== (surface.state?.mods ?? 0)) {
46      surface.setState({ ...surface.state, columns: props.columns, rows: props.rows, mods })
47      surface.post({ kind: 'held', mods })
48    } else if (event.type === 'move' && isInside) {
49      const was = surface.state
50
51      // Said a few cells apart, not at every cell crossed.
52      if (was?.cx === undefined || was.cy === undefined || Math.abs(event.x - was.cx) > 2 || event.y !== was.cy) {
53        surface.setState({ columns: props.columns, rows: props.rows, mods, cx: event.x, cy: event.y })
54        surface.post({ kind: 'at', x: point.x, y: point.y })
55      }
56    }
57  })
58
59  surface.onKey(event => {
60    surface.post({ kind: 'key', key: event.key })
61  })
62
63  return <Box width={props.columns} height={props.rows} />
64}
65
66export default Hit
67
hooks/paths.ts 169 lines
1import type { MediaKind } from '../types'
2
3const IMAGE = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tif', 'tiff', 'heic', 'avif', 'svg']
4const VIDEO = ['mp4', 'mov', 'm4v', 'webm', 'mkv']
5const AUDIO = ['mp3', 'wav', 'm4a', 'aac', 'flac', 'ogg']
6const PAGE = ['html', 'htm']
7const DOC = ['md', 'markdown']
8// Longest first, so `.html` is not read as `.htm` and `.jpeg` as `.jpg`.
9const EXT = [...IMAGE, ...VIDEO, ...AUDIO, ...PAGE, ...DOC, 'pdf']
10  .sort((a, b) => b.length - a.length)
11  .join('|')
12
13const ENDS = new RegExp(`\\.(${EXT})$`, 'i')
14const BACKTICK = /`([^`\n]+)`/g
15const LINK = /\]\(([^)\n]+)\)/g
16const QUOTED = /"([^"\n]+)"|'([^'\n]+)'/g
17const FENCE = /(```[\s\S]*?(?:```|$))/
18const BARE = new RegExp(`(?:file://)?[^\\s\`"'<>()\\[\\]{}|,;:*?]+\\.(?:${EXT})(?![\\p{L}\\p{N}_])`, 'giu')
19
20export function kindOf(path: string): MediaKind | undefined {
21  const ext = ENDS.exec(path)?.[1]?.toLowerCase()
22
23  if (ext === undefined) {
24    return undefined
25  }
26
27  if (ext === 'pdf') {
28    return 'pdf'
29  }
30
31  if (PAGE.includes(ext)) {
32    return 'page'
33  }
34
35  if (DOC.includes(ext)) {
36    return 'doc'
37  }
38
39  if (AUDIO.includes(ext)) {
40    return 'audio'
41  }
42
43  return VIDEO.includes(ext) ? 'video' : 'image'
44}
45
46function decoded(text: string): string {
47  try {
48    return decodeURIComponent(text)
49  } catch {
50    return text
51  }
52}
53
54// The media file paths a reply's text names, in the order written, as written
55// but for a `file://` prefix and percent escapes; web links are left out.
56export function mediaPaths(text: string): string[] {
57  const found: string[] = []
58  const take = (raw: string) => {
59    const spelled = raw.trim()
60
61    if (/^https?:/i.test(spelled) || !ENDS.test(spelled)) {
62      return
63    }
64
65    const path = spelled.startsWith('file://') ? decoded(spelled.slice(7)) : spelled
66
67    if (!found.includes(path)) {
68      found.push(path)
69    }
70  }
71
72  for (const match of text.matchAll(BACKTICK)) {
73    take(match[1] ?? '')
74  }
75
76  for (const match of text.matchAll(LINK)) {
77    take(match[1] ?? '')
78  }
79
80  for (const match of text.matchAll(QUOTED)) {
81    take(match[1] ?? match[2] ?? '')
82  }
83
84  // A path already taken whole (one with spaces, in backticks) is not taken
85  // again in pieces; a web link's tail is not a file.
86  const rest = text.replace(BACKTICK, ' ').replace(LINK, ' ').replace(QUOTED, ' ').replace(/https?:\/\/\S+/gi, ' ')
87
88  for (const match of rest.matchAll(BARE)) {
89    take(match[0])
90  }
91
92  return found
93}
94
95export function absolute(path: string, cwd: string, home: string): string {
96  if (path === '~' || path.startsWith('~/')) {
97    return home + path.slice(1)
98  }
99
100  return path.startsWith('/') ? path : `${cwd}/${path.replace(/^\.\//, '')}`
101}
102
103export function hashOf(text: string): string {
104  let hash = 0x811c9dc5
105
106  for (let i = 0; i < text.length; i++) {
107    hash ^= text.charCodeAt(i)
108    hash = Math.imul(hash, 0x01000193)
109  }
110
111  return (hash >>> 0).toString(16).padStart(8, '0')
112}
113
114export function namesMedia(text: string): boolean {
115  return new RegExp(`\\.(?:${EXT})(?![\\p{L}\\p{N}_])`, 'iu').test(text)
116}
117
118export function hrefOf(path: string): string {
119  return `file://${encodeURI(path)}`
120}
121
122// The path a `file:` link leads to, however the surface spelled the link.
123export function pathOf(href: string): string {
124  return decoded(href.replace(/^file:\/\//, ''))
125}
126
127// A reply's markdown with each media path `hrefFor` knows turned into a link
128// to it, and those links; code fences and paths inside a longer code span are
129// left as written.
130export function linked(
131  text: string,
132  hrefFor: (spelled: string) => string | undefined,
133): { text: string; links: string[] } {
134  const links: string[] = []
135  const parts = text.split(FENCE).map((part, index) => {
136    const names = mediaPaths(part)
137      .filter(name => part.includes(name) && hrefFor(name) !== undefined)
138      .sort((a, b) => b.length - a.length)
139
140    if (index % 2 === 1 || names.length === 0) {
141      return part
142    }
143
144    const escaped = names.map(name => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
145    const named = new RegExp(
146      `(?<![^\\s\`("'\\[])(\\]\\()?(\`)?(${escaped})(\`)?(?![\\p{L}\\p{N}_/])`,
147      'gu',
148    )
149
150    return part.replace(named, (whole, target, open, name: string, close) => {
151      const href = hrefFor(name)
152
153      if (href === undefined || (open === undefined) !== (close === undefined)) {
154        return whole
155      }
156
157      links.push(href)
158
159      if (target !== undefined) {
160        return `](${href}`
161      }
162
163      return open === undefined ? `[${name}](${href})` : `[\`${name}\`](${href})`
164    })
165  })
166
167  return { text: parts.join(''), links: [...new Set(links)] }
168}
169
types/index.d.ts 31 lines
1export type MediaKind = 'image' | 'video' | 'audio' | 'pdf' | 'page' | 'doc'
2
3// Where the mod learnt of a file: named in a reply, or made by a tool call.
4export type MediaSource = 'chat' | 'tool'
5
6export type MediaFilter = 'chat' | 'all'
7
8export type MediaItem = {
9  id: string
10  path: string
11  name: string
12  kind: MediaKind
13  from: MediaSource
14  preview: string
15  thumb: string
16  width: number
17  height: number
18  addedAt: number
19}
20
21declare module 'claude-code' {
22  interface PluginState {
23    vitrin: {
24      items: MediaItem[]
25      ratio: number
26      filter: MediaFilter
27      isReady: boolean
28    }
29  }
30}
31