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

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.
Sohbet yalnızca cevaplarda geçen medyayı, Hepsi araçların ürettiklerini de gösterir.Büyüt Finder'ın boşluk tuşu önizlemesini, Aç varsayılan uygulamayı açar.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.ctrl ya da option basılıyken Yolu kopyala düğmesi Dizini aç olur: dosyayı Finder'da gösterir.| Komut | Ne yapar |
|---|---|
/vitrin | Paneli açar |
/vitrin <dosya> | Dosyayı ekler ve gösterir |
/vitrin hepsi / /vitrin sohbet | Süzgeci değiştirir |
/vitrin temizle | Listeyi boşaltır |
/vitrin kapat | Paneli ve arkadaki tarayıcıyı kapatır |
/vitrin oran 0.47 | Sayfanın en-boy oranını terminal yazı tipine göre düzeltir |
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.
claude plugin validate .
claude plugin test .
| Dosya | İçerik |
|---|---|
hooks/register.tsx | Modun kendisi: medyayı yakalar, köprüyü çalıştırır, paneli çizer |
hooks/paths.ts | Metinden dosya yolu çıkarma ve yolu bağlantıya çevirme |
hooks/hit.tsx | Paneldeki tıklamaları duyan bölge |
bin/bridge.mjs | Görünmeyen tarayıcıyı yöneten köprü |
bin/page.html | Panelde görünen sayfa |
bin/onizle.swift | Büyüt için Finder önizlemesi |
İlk commit, panelin yalnızca terminal öğeleriyle çizilen ilk sürümüdür.
MIT
hooks/register.tsx 806 lines1import { 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}
806hooks/hit.tsx 67 lines1import 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
67hooks/paths.ts 169 lines1import 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}
169types/index.d.ts 31 lines1export 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