SLOPSHOPPER

visor-medios

Ve imágenes y videos dentro de Claude Code con /ver, sin abrir el explorador de archivos

panerowsguardcommandtoasttool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · visor-medios
│ ┃ visor-medios ✕ › fix the failing auth test and add an audit log call │ ┃ Galería │ ⏺ 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 │ │ › /ver │ ⎿ visor-medios: ENOENT: │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · visor-medios
Galería
README

claude-code-mods

Tres mods para Claude Code que hice porque me cansé de escribir comandos para cosas que quería ver de un vistazo o cambiar con un clic.

limites-uso

Si usas Claude Code con una suscripción de claude.ai, tienes un límite que se reinicia cada 5 horas y otro semanal. Este mod los muestra debajo del prompt, con la hora del reinicio y cuánto falta:

Sesión 5h ▰▰▰▱▱▱▱▱▱▱ 70% libre · reinicia 16:15 (en 2h 15m)   │   Semana ▰▰▰▰▰▰▱▱▱▱ 37.5% libre · reinicia vie 09:00 (en 4d 1h)

La cuenta regresiva se actualiza cada minuto. Cuando pasas del 80% y del 95% de un límite te salta un aviso (uno por periodo, no cada vez que respondes). Con /limites ves el detalle en texto.

Ojo con dos cosas. Anthropic no da el número de tokens que te quedan, solo el porcentaje usado, así que eso es lo que muestra. Y al abrir una sesión no hay datos hasta que llega la primera respuesta del modelo. Si usas una API key en vez de suscripción, este mod no tiene nada que mostrar.

selector-modelo

Una línea pequeña encima del prompt con el modelo y el esfuerzo que estás usando:

⚙ Opus 5.5 · esfuerzo Alto (clic para cambiar)

Al hacer clic se abre esto:

Modelo:   [Fable] [ Opus ] [Sonnet] [Haiku]
Versión:  [Opus · último $4/$20] [Opus · 1M contexto $4/$20] [Opus Plan $4/$20 → $2/$10] [ Opus 5.5 $4/$20 ]
          precio API en USD por millón de tokens: entrada/salida
Esfuerzo: [Auto] [Bajo] [Medio] [ Alto ] [Muy alto] [Máximo]   ✕ cerrar

Las versiones salen del menú /config de tu cuenta, así que ves las que tu plan permite. El precio al lado de cada una es el de lista de la API. Con suscripción no pagas por token, pero sirve para comparar: Fable 5.1 gasta unas dos veces y media lo que Opus 5.5, y Haiku casi nada.

Los precios los escribí a mano en plugins/selector-modelo/hooks/register.tsx (octubre de 2026). Si cambian, hay que actualizarlos ahí. Un modelo que el mod no conoce aparece sin precio en vez de con uno inventado.

Para que los clics funcionen en la terminal necesitas el modo de pantalla completa ("tui": "fullscreen" en tu settings.json). Sin mouse, ctrl+x y luego tab te lleva a los botones.

El esfuerzo no pasa por /effort. En algunas versiones ese comando responde "Kept effort level as auto" y no cambia nada, así que el mod pone el nivel que elegiste en cada petición al modelo. Se aplica desde la siguiente respuesta y se recuerda entre sesiones. Con Auto vuelves a dejar que Claude Code decida. Los subagentes siguen con su propio esfuerzo, y en un modelo que no usa niveles de esfuerzo el botón no hace nada.

visor-medios

Para ver una imagen o un video sin salir de Claude Code ni abrir el explorador de archivos:

/ver captura.png
/ver ~/Videos/demo.mp4
/ver                      # galería de la carpeta actual

Se abre una ventanita flotante sin bordes en la esquina inferior derecha de la pantalla, siempre encima, con la imagen o el video en su calidad real (480×360 como mucho) y un botón ✕ en su esquina para cerrarla. También la cierran Esc o q, y un video se cierra solo al terminar. Arrastrándola la mueves, y en un video un clic o espacio pausan. La ventana es un script pequeño de Python con GTK y GStreamer (plugins/visor-medios/viewer/preview.py), que Ubuntu con GNOME ya trae; donde falten, se usa ffplay, igual de flotante pero sin el botón.

Donde no hay escritorio gráfico (por SSH, por ejemplo) se usa un panel dentro de la terminal que dibuja con bloques de colores (▀, dos píxeles por celda). Ahí la vista previa es de 64×16 celdas como mucho, pixeleada pero suficiente para reconocer qué es un archivo, y tiene botones para pausar, adelantar o atrasar 5 segundos, silenciar y pasar al archivo anterior o siguiente. Con el panel enfocado sirven las teclas p, a/d, m, j/k, g y Esc.

Con /ver sobre una carpeta, la galería siempre es un panel en la terminal: das clic en un archivo y se abre la vista previa.

También le puedes pedir a Claude "muéstrame la imagen que generaste" y la abre él, porque el mod le da una herramienta para eso.

Si Claude escribe en su respuesta un enlace a una imagen o un video (https://…/foto.jpg, file:///…/clip.mp4), dale un clic y la vista previa se abre directo junto al puntero, sin tapar el enlace (con /ver o cuando la abre Claude, en la esquina inferior derecha). Los demás enlaces se abren como siempre, y ctrl+clic abre cualquiera en el navegador. /ver también acepta URLs. El tipo se reconoce por la extensión, así que un enlace sin .jpg, .mp4 o similar no ofrece el visor.

Necesita ffmpeg y ffprobe, y para la ventana Python 3 con GTK 3 y GStreamer (o, en su defecto, ffplay). La ventana se coloca con lo que dice xrandr; en Wayland funciona a través de XWayland, como en Ubuntu con GNOME.

Instalación

Dentro de Claude Code, en la terminal:

/plugin install limites-uso --marketplace jairsemblantes01/claude-code-mods
/plugin install selector-modelo --marketplace jairsemblantes01/claude-code-mods
/plugin install visor-medios --marketplace jairsemblantes01/claude-code-mods

La primera vez te pregunta si quieres agregar el marketplace: responde y y luego elige el alcance (con Enter queda para tu usuario).

Desde la shell también funciona:

claude plugin install limites-uso --marketplace jairsemblantes01/claude-code-mods
claude plugin install selector-modelo --marketplace jairsemblantes01/claude-code-mods
claude plugin install visor-medios --marketplace jairsemblantes01/claude-code-mods

En la app de escritorio no existe /plugin. Ahí es: el botón + junto al prompt, Plugins, Add marketplace con jairsemblantes01/claude-code-mods, y después Add plugin.

Si quieres tocar el código

Cada mod está en plugins/<nombre>/. El código vive en hooks/ y los tests en tests/. Para probar cambios sin instalar nada:

claude --plugin-dir plugins/selector-modelo

Y para validar y correr los tests:

claude plugin validate plugins/selector-modelo
claude plugin test plugins/selector-modelo
Source 2 files
hooks/register.tsx 807 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Gallery, GalleryItem, Media, MediaKind } from '../types'
5
6const PANE = 'visor-medios'
7
8const media = atom({ plugin: 'visor-medios', key: 'media' } as const, null)
9const gallery = atom({ plugin: 'visor-medios', key: 'gallery' } as const, null)
10const message = atom({ plugin: 'visor-medios', key: 'message' } as const, null)
11const isPlaying = atom({ plugin: 'visor-medios', key: 'isPlaying' } as const, false)
12const isMuted = atom({ plugin: 'visor-medios', key: 'isMuted' } as const, false)
13const second = atom({ plugin: 'visor-medios', key: 'second' } as const, 0)
14
15const IMAGE_EXT = ['png', 'jpg', 'jpeg', 'webp', 'bmp', 'tif', 'tiff', 'avif', 'heic', 'ico', 'svg']
16const VIDEO_EXT = ['mp4', 'mkv', 'webm', 'mov', 'avi', 'm4v', 'mpg', 'mpeg', 'wmv', 'flv', '3gp', 'ogv', 'gif']
17
18// Cuadros por segundo como máximo: más no se nota en celdas y cuesta CPU
19const MAX_FPS = 15
20// Tamaño máximo de la vista previa en celdas: lo justo para reconocer qué es
21const MAX_COLUMNS = 64
22const MAX_ROWS = 16
23// Una lectura de $.fs no pasa de 4 MiB, así que el video se extrae en tramos
24const CHUNK_BYTES = 3.5 * 1024 * 1024
25// La ventana flotante cabe en este recuadro, en píxeles de pantalla
26const FLOAT_MAX = { width: 480, height: 360 }
27// Separación de la ventana con el borde de la pantalla (abajo deja sitio a la barra)
28const FLOAT_MARGIN = { right: 24, bottom: 64 }
29// Filas del panel que ocupan el título y los botones
30const CHROME_ROWS = 4
31
32export function kindOf (name: string): MediaKind | null {
33  const ext = name.toLowerCase().split('.').pop() ?? ''
34  if (IMAGE_EXT.includes(ext)) return 'imagen'
35  if (VIDEO_EXT.includes(ext)) return 'video'
36  return null
37}
38
39const baseName = (path: string) => path.split('/').filter(Boolean).pop() ?? path
40
41export const isUrl = (text: string) => /^https?:\/\//i.test(text.trim())
42
43// El nombre que se muestra: el del archivo, también al final de una URL
44export function nameOf (path: string): string {
45  if (!isUrl(path)) return baseName(path)
46  try {
47    const url = new URL(path)
48    return decodeURIComponent(url.pathname.split('/').filter(Boolean).pop() ?? url.hostname)
49  } catch {
50    return path
51  }
52}
53
54// Si un enlace (http, https o file) apunta a una imagen o un video, por su extensión
55export function linkKind (href: string): MediaKind | null {
56  try {
57    const url = new URL(href)
58    if (!['http:', 'https:', 'file:'].includes(url.protocol)) return null
59    return kindOf(decodeURIComponent(url.pathname))
60  } catch {
61    return null
62  }
63}
64
65// Los enlaces a imágenes y videos de un texto markdown: [texto](url) y URLs sueltas
66export function mediaLinks (text: string): string[] {
67  const found = new Set<string>()
68  for (const match of text.matchAll(/\]\(\s*<?([^)\s>]+)>?/g)) if (match[1]) found.add(match[1])
69  for (const match of text.matchAll(/\b(?:https?|file):\/\/[^\s<>()[\]"'`]+/gi)) {
70    found.add(match[0].replace(/[.,;:!?*_]+$/, ''))
71  }
72  return [...found].filter(href => linkKind(href) !== null).slice(0, 256)
73}
74
75const MEDIA_EXT = [...IMAGE_EXT, ...VIDEO_EXT].join('|')
76
77// Una ruta local a una imagen o un video: absoluta o con ~/, sin saltos de línea
78const isLocalMedia = (text: string) => /^(?:\/|~\/)[^\n`\]]*$/.test(text) && kindOf(text) !== null
79
80// El file:// de una ruta local, codificado para que la URL no se corte en espacios, # o ?
81export function fileHref (path: string, home: string | undefined): string | null {
82  const full = cleanPath(path, home)
83  if (!full.startsWith('/')) return null
84  return 'file://' + encodeURI(full).replace(/[#?]/g, c => encodeURIComponent(c))
85}
86
87// Un enlace markdown, un código en línea o una ruta suelta a un medio; en ese orden para que no se pisen
88const LINKIFY = new RegExp(
89  '(\\[[^\\]\\n]*\\]\\([^)\\n]*\\))|`([^`\\n]+)`|' +
90  `(^|[\\s>])((?:~/|/)[^\\s\`'"<>()[\\]]*\\.(?:${MEDIA_EXT}))(?=$|[\\s.,;:!?)])`,
91  'gim',
92)
93
94// Las rutas locales a medios que escribe el modelo no son enlaces (Markdown solo dibuja http, https y
95// file): se vuelven enlaces file:// para que un clic las abra. Los bloques de código quedan intactos.
96export function linkifyMedia (text: string, home: string | undefined): string {
97  return text.split(/(```[\s\S]*?```|~~~[\s\S]*?~~~)/).map((part, i) => i % 2 === 1 ? part : part.replace(
98    LINKIFY,
99    (whole, link?: string, code?: string, lead?: string, path?: string) => {
100      if (link) {
101        const target = /\]\(\s*<?([^)>]*?)>?\s*\)$/.exec(link)?.[1] ?? ''
102        const href = isLocalMedia(target) ? fileHref(target, home) : null
103        return href ? link.slice(0, link.lastIndexOf('](')) + `](${href})` : whole
104      }
105      if (code) {
106        const href = isLocalMedia(code.trim()) ? fileHref(code.trim(), home) : null
107        return href ? `[\`${code}\`](${href})` : whole
108      }
109      const href = path ? fileHref(path, home) : null
110      return href ? `${lead}[${path}](${href})` : whole
111    },
112  )).join('')
113}
114
115// Los href clicables, también como los normaliza una URL: la superficie compara con el que dibujó
116export function pressable (links: string[]): string[] {
117  const all = new Set<string>()
118  for (const href of links) {
119    all.add(href)
120    try { all.add(new URL(href).href) } catch {}
121  }
122  return [...all].slice(0, 256)
123}
124
125// La ruta que pidió el modelo: los argumentos de la herramienta llegan junto al evento, no dentro de e.input
126export function toolPath (e: object): string {
127  const { ruta } = e as { ruta?: unknown }
128  return typeof ruta === 'string' ? ruta : '.'
129}
130
131const dirName = (path: string) => path.slice(0, Math.max(1, path.lastIndexOf('/')))
132
133// 75 -> "1:15"
134export function clock (seconds: number): string {
135  const s = Math.max(0, Math.floor(seconds))
136  const h = Math.floor(s / 3600)
137  const m = Math.floor((s % 3600) / 60)
138  const rest = String(s % 60).padStart(2, '0')
139  return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${rest}` : `${m}:${rest}`
140}
141
142export function sizeLabel (bytes: number): string {
143  if (bytes < 1024) return `${bytes} B`
144  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
145  return `${(bytes / 1024 / 1024).toFixed(1)} MB`
146}
147
148// Quita comillas y expande ~ de una ruta escrita a mano
149export function cleanPath (text: string, home: string | undefined): string {
150  let path = text.trim().replace(/^(['"])(.*)\1$/, '$2').replace(/\\ /g, ' ')
151  if (home && (path === '~' || path.startsWith('~/'))) path = home + path.slice(1)
152  return path
153}
154
155const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
156
157// Uint8Array.toBase64/fromBase64 cuando el entorno los tiene; si no, a mano
158export function toBase64 (bytes: Uint8Array): string {
159  const native = (bytes as Uint8Array & { toBase64?: () => string }).toBase64
160  if (native) return native.call(bytes)
161  let out = ''
162  for (let i = 0; i < bytes.length; i += 3) {
163    const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
164    out += BASE64[(n >> 18) & 63]! + BASE64[(n >> 12) & 63]!
165    out += i + 1 < bytes.length ? BASE64[(n >> 6) & 63]! : '='
166    out += i + 2 < bytes.length ? BASE64[n & 63]! : '='
167  }
168  return out
169}
170
171export function fromBase64 (text: string): Uint8Array {
172  const native = (Uint8Array as { fromBase64?: (text: string) => Uint8Array }).fromBase64
173  if (native) return native.call(Uint8Array, text)
174  const clean = text.replace(/[^A-Za-z0-9+/]/g, '')
175  const out = new Uint8Array(Math.floor((clean.length * 3) / 4))
176  let o = 0
177  for (let i = 0; i < clean.length; i += 4) {
178    const n = (BASE64.indexOf(clean[i] ?? 'A') << 18) | (BASE64.indexOf(clean[i + 1] ?? 'A') << 12) |
179      ((BASE64.indexOf(clean[i + 2] ?? 'A') & 63) << 6) | (BASE64.indexOf(clean[i + 3] ?? 'A') & 63)
180    if (o < out.length) out[o++] = (n >> 16) & 255
181    if (o < out.length) out[o++] = (n >> 8) & 255
182    if (o < out.length) out[o++] = n & 255
183  }
184  return out
185}
186
187// Cada celda pinta dos píxeles con "▀" (arriba el color de letra, abajo el de fondo),
188// y una celda es el doble de alta que de ancha, así que cada píxel queda cuadrado
189export function fitSize (width: number, height: number, columns: number, rows: number) {
190  const maxW = Math.max(1, Math.min(columns, MAX_COLUMNS))
191  const maxH = Math.max(2, Math.min(rows, MAX_ROWS) * 2)
192  const scale = Math.min(maxW / width, maxH / height)
193  const pxW = Math.max(1, Math.round(width * scale))
194  const pxH = Math.max(2, Math.round((height * scale) / 2) * 2)
195  return { columns: pxW, rows: pxH / 2, pxW, pxH }
196}
197
198// El tamaño de la ventana flotante: la proporción del archivo dentro de FLOAT_MAX, sin agrandarlo
199export function floatSize (width: number, height: number) {
200  const scale = Math.min(1, FLOAT_MAX.width / width, FLOAT_MAX.height / height)
201  return { width: Math.max(64, Math.round(width * scale)), height: Math.max(64, Math.round(height * scale)) }
202}
203
204// La pantalla principal en la salida de `xrandr --current`: "1920x1080+0+0"
205export function primaryScreen (xrandr: string) {
206  const lines = xrandr.split('\n').filter(line => / connected/.test(line))
207  const line = lines.find(l => l.includes(' primary ')) ?? lines[0] ?? ''
208  const match = /(\d+)x(\d+)\+(\d+)\+(\d+)/.exec(line)
209  if (!match) return null
210  const [, w, h, x, y] = match.map(Number)
211  return { width: w ?? 0, height: h ?? 0, x: x ?? 0, y: y ?? 0 }
212}
213
214// RGB crudo (3 bytes por píxel) a las celdas de un Raster
215export function packCells (rgb: Uint8Array, offset: number, pxW: number, pxH: number): string {
216  const rows = pxH / 2
217  const words = new Uint32Array(pxW * rows * 3)
218  for (let y = 0; y < rows; y++) {
219    for (let x = 0; x < pxW; x++) {
220      const top = offset + ((2 * y) * pxW + x) * 3
221      const bottom = offset + ((2 * y + 1) * pxW + x) * 3
222      const i = (y * pxW + x) * 3
223      words[i] = 0x2580
224      words[i + 1] = ((rgb[top] ?? 0) << 16) | ((rgb[top + 1] ?? 0) << 8) | (rgb[top + 2] ?? 0)
225      words[i + 2] = ((rgb[bottom] ?? 0) << 16) | ((rgb[bottom + 1] ?? 0) << 8) | (rgb[bottom + 2] ?? 0)
226    }
227  }
228  return toBase64(new Uint8Array(words.buffer))
229}
230
231type Probe = {
232  streams?: { codec_type?: string; width?: number; height?: number; avg_frame_rate?: string; nb_frames?: string }[]
233  format?: { duration?: string }
234}
235
236// La salida JSON de ffprobe a lo que el visor necesita; null si no hay imagen
237export function parseProbe (path: string, json: string): Media | null {
238  let probe: Probe
239  try {
240    probe = JSON.parse(json) as Probe
241  } catch {
242    return null
243  }
244  const video = probe.streams?.find(s => s.codec_type === 'video' && (s.width ?? 0) > 0)
245  if (!video?.width || !video.height) return null
246  const [num = 0, den = 1] = (video.avg_frame_rate ?? '0/1').split('/').map(Number)
247  const rate = den > 0 ? num / den : 0
248  const duration = Number(probe.format?.duration ?? 0) || 0
249  const isStill = kindOf(path) === 'imagen' || duration <= 0 || video.nb_frames === '1'
250  return {
251    path,
252    name: nameOf(path),
253    kind: isStill ? 'imagen' : 'video',
254    width: video.width,
255    height: video.height,
256    duration: isStill ? 0 : duration,
257    fps: Math.min(MAX_FPS, rate > 0 ? rate : 10),
258    hasAudio: probe.streams?.some(s => s.codec_type === 'audio') ?? false,
259  }
260}
261
262// --- Lo que solo vive mientras el módulo está cargado: los píxeles y la reproducción ---
263
264type Layout = { columns: number; rows: number; pxW: number; pxH: number }
265
266let layout: Layout | null = null
267let cells: string | null = null
268// Lo que la terminal le dejó al panel la última vez que lo dibujó
269let room = { columns: 0, rows: 0 }
270let generation = 0
271let isPreparing = false
272let tmpDir: string | null = null
273const chunks = new Map<number, Uint8Array>()
274const loading = new Set<number>()
275let ticker: Timer | null = null
276let audio: AsyncIterator<unknown> | null = null
277// El segundo del video que se ve y cuándo empezó a correr desde ahí
278let position = 0
279let startedAt = 0
280let shownFrame = -1
281let hasFfplay: boolean | null = null
282let canFloat: boolean | null = null
283// Python con GTK y GStreamer: la ventana propia, con el botón ✕ encima de la imagen
284let hasGtk: boolean | null = null
285let screen: { width: number; height: number; x: number; y: number } | null = null
286// La ventana flotante abierta: ffplay corre mientras se lee esta salida
287let floating: AsyncIterator<unknown> | null = null
288
289async function tempDir ($: EngineInterface): Promise<string> {
290  if (tmpDir) return tmpDir
291  const { stdout, exitCode } = await $.process.run(['mktemp', '-d', '-t', 'visor-medios.XXXXXX'])
292  if (exitCode !== 0) throw new Error('No se pudo crear la carpeta temporal')
293  tmpDir = stdout.trim()
294  return tmpDir
295}
296
297async function ffmpeg ($: EngineInterface, args: string[]) {
298  const { exitCode, stderr } = await $.process.run(['ffmpeg', '-v', 'error', '-nostdin', '-y', ...args], { timeoutMs: 120_000 })
299  if (exitCode !== 0) throw new Error(stderr.trim().split('\n').pop() || 'ffmpeg falló')
300}
301
302const scaleFilter = (l: Layout) => `scale=${l.pxW}:${l.pxH}:flags=area`
303
304const framesPerChunk = (l: Layout) => Math.max(1, Math.floor(CHUNK_BYTES / (l.pxW * l.pxH * 3)))
305
306async function readRaw ($: EngineInterface, file: string): Promise<Uint8Array> {
307  const { base64 } = await $.fs.read(file, { as: 'bytes' })
308  void $.process.run(['rm', '-f', file])
309  return fromBase64(base64)
310}
311
312async function loadChunk ($: EngineInterface, item: Media, l: Layout, k: number) {
313  if (chunks.has(k) || loading.has(k)) return
314  const gen = generation
315  loading.add(k)
316  try {
317    const perChunk = framesPerChunk(l)
318    const from = (k * perChunk) / item.fps
319    if (from >= item.duration) return
320    const file = `${await tempDir($)}/g${gen}-c${k}.rgb`
321    await ffmpeg($, [
322      '-ss', from.toFixed(3), '-i', item.path, '-an',
323      '-vf', `fps=${item.fps},${scaleFilter(l)}`,
324      '-frames:v', String(perChunk), '-f', 'rawvideo', '-pix_fmt', 'rgb24', file,
325    ])
326    const bytes = await readRaw($, file)
327    if (gen !== generation) return
328    chunks.set(k, bytes)
329    // Solo se guardan el tramo anterior, el actual y los siguientes
330    for (const old of chunks.keys()) if (old < k - 1) chunks.delete(old)
331  } finally {
332    loading.delete(k)
333  }
334}
335
336// Las celdas del cuadro `frame`, o null si su tramo aún no está
337function frameCells (l: Layout, frame: number): string | null {
338  const perChunk = framesPerChunk(l)
339  const bytes = chunks.get(Math.floor(frame / perChunk))
340  const size = l.pxW * l.pxH * 3
341  const offset = (frame % perChunk) * size
342  if (!bytes || offset + size > bytes.length) return null
343  return packCells(bytes, offset, l.pxW, l.pxH)
344}
345
346// Extrae la imagen, o el cuadro actual del video, al tamaño que cabe en el panel
347async function prepare ($: EngineInterface) {
348  const item = await read($, media)
349  if (!item || room.columns < 1 || room.rows < 1) return
350  const next = fitSize(item.width, item.height, room.columns, room.rows)
351  if (layout && cells && layout.columns === next.columns && layout.rows === next.rows) return
352  const gen = ++generation
353  chunks.clear()
354  isPreparing = true
355  try {
356    if (item.kind === 'imagen') {
357      const file = `${await tempDir($)}/g${gen}.rgb`
358      await ffmpeg($, ['-i', item.path, '-frames:v', '1', '-vf', scaleFilter(next), '-f', 'rawvideo', '-pix_fmt', 'rgb24', file])
359      const bytes = await readRaw($, file)
360      if (gen !== generation) return
361      layout = next
362      cells = packCells(bytes, 0, next.pxW, next.pxH)
363    } else {
364      layout = next
365      const frame = Math.floor(position * item.fps)
366      await loadChunk($, item, next, Math.floor(frame / framesPerChunk(next)))
367      if (gen !== generation) return
368      cells = frameCells(next, frame) ?? cells
369      shownFrame = frame
370    }
371    await update($, message, () => null)
372  } catch (error) {
373    if (gen === generation) await update($, message, () => `No se pudo mostrar: ${(error as Error).message}`)
374  } finally {
375    if (gen === generation) isPreparing = false
376    $.ui.invalidate('ui.render')
377  }
378}
379
380function stopAudio () {
381  void audio?.return?.()
382  audio = null
383}
384
385async function startAudio ($: EngineInterface, item: Media) {
386  stopAudio()
387  if (!item.hasAudio || (await read($, isMuted))) return
388  if (hasFfplay === null) {
389    hasFfplay = (await $.process.run(['ffplay', '-version']).catch(() => ({ exitCode: 1 }))).exitCode === 0
390  }
391  if (!hasFfplay) return
392  const stream = $.process.spawn({
393    argv: ['ffplay', '-nodisp', '-autoexit', '-loglevel', 'quiet', '-ss', position.toFixed(2), item.path],
394  })[Symbol.asyncIterator]()
395  audio = stream
396  void (async () => {
397    try {
398      while (!(await stream.next()).done) {}
399    } catch {}
400  })()
401}
402
403async function now ($: EngineInterface) {
404  return (await $.clock.now()) / 1000
405}
406
407async function pause ($: EngineInterface) {
408  if (ticker) position += (await now($)) - startedAt
409  ticker?.cancel()
410  ticker = null
411  stopAudio()
412  await update($, isPlaying, () => false)
413}
414
415async function play ($: EngineInterface) {
416  const item = await read($, media)
417  if (!item || item.kind !== 'video' || ticker) return
418  if (position >= item.duration - 0.05) position = 0
419  startedAt = await now($)
420  await update($, isPlaying, () => true)
421  await startAudio($, item)
422  ticker = $.clock.every(Math.round(1000 / item.fps), () => void tick($, item))
423}
424
425async function tick ($: EngineInterface, item: Media) {
426  const l = layout
427  if (!l || isPreparing) return
428  const at = position + (await now($)) - startedAt
429  if (at >= item.duration) {
430    await pause($)
431    position = item.duration
432    return
433  }
434  const frame = Math.floor(at * item.fps)
435  const perChunk = framesPerChunk(l)
436  const k = Math.floor(frame / perChunk)
437  // El siguiente tramo se pide a mitad del actual para que no haya cortes
438  if (frame % perChunk > perChunk / 2) void loadChunk($, item, l, k + 1).catch(() => {})
439  if (frame !== shownFrame) {
440    const next = frameCells(l, frame)
441    if (next) {
442      cells = next
443      shownFrame = frame
444      await $.ui.blit({ requestId: PANE, key: 'frame', cells: next })
445    } else {
446      void loadChunk($, item, l, k).catch(() => {})
447    }
448  }
449  const whole = Math.floor(at)
450  if (whole !== (await read($, second))) await update($, second, () => whole)
451}
452
453async function seek ($: EngineInterface, delta: number) {
454  const item = await read($, media)
455  if (!item || item.kind !== 'video') return
456  const wasPlaying = ticker !== null
457  await pause($)
458  position = Math.min(Math.max(0, position + delta), Math.max(0, item.duration - 0.1))
459  const l = layout
460  if (l) {
461    const frame = Math.floor(position * item.fps)
462    await loadChunk($, item, l, Math.floor(frame / framesPerChunk(l))).catch(() => {})
463    cells = frameCells(l, frame) ?? cells
464    shownFrame = frame
465  }
466  await update($, second, () => Math.floor(position))
467  $.ui.invalidate('ui.render')
468  if (wasPlaying) await play($)
469}
470
471async function reset ($: EngineInterface) {
472  await pause($)
473  generation++
474  layout = null
475  cells = null
476  chunks.clear()
477  position = 0
478  shownFrame = -1
479  await update($, second, () => 0)
480}
481
482async function resolvePath ($: EngineInterface, text: string): Promise<string> {
483  let path = cleanPath(text, await $.env.get('HOME'))
484  // ffmpeg lee URLs directamente; un file:// es una ruta local
485  if (isUrl(path)) return path
486  if (path.startsWith('file://')) path = decodeURIComponent(new URL(path).pathname)
487  const { exitCode, stdout } = await $.process.run(['realpath', '-e', '--', path])
488  if (exitCode !== 0) throw new Error(`No existe: ${path}`)
489  return stdout.trim()
490}
491
492async function listGallery ($: EngineInterface, dir: string): Promise<Gallery> {
493  const entries = await $.fs.list(dir)
494  const items: GalleryItem[] = []
495  for (const entry of entries) {
496    const kind = entry.kind === 'file' ? kindOf(entry.name) : null
497    if (kind && !entry.name.startsWith('.')) {
498      items.push({ path: `${dir.replace(/\/$/, '')}/${entry.name}`, name: entry.name, kind, size: entry.size })
499    }
500  }
501  items.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
502  return { dir, items }
503}
504
505async function probeMedia ($: EngineInterface, path: string): Promise<Media> {
506  const { exitCode, stdout, stderr } = await $.process.run([
507    'ffprobe', '-v', 'error', '-show_entries',
508    'stream=codec_type,width,height,avg_frame_rate,nb_frames:format=duration', '-of', 'json', path,
509  ])
510  if (exitCode !== 0) throw new Error(stderr.trim() || 'ffprobe no pudo leer el archivo')
511  const item = parseProbe(path, stdout)
512  if (!item) throw new Error(`${nameOf(path)} no es una imagen ni un video`)
513  return item
514}
515
516// Hay escritorio gráfico y ffplay: la vista previa va en una ventana flotante, con calidad real
517async function floats ($: EngineInterface): Promise<boolean> {
518  if (canFloat !== null) return canFloat
519  const hasDisplay = Boolean((await $.env.get('DISPLAY')) || (await $.env.get('WAYLAND_DISPLAY')))
520  const ffplay = hasDisplay && (await $.process.run(['ffplay', '-version']).catch(() => ({ exitCode: 1 }))).exitCode === 0
521  canFloat = ffplay
522  return canFloat
523}
524
525function closeFloating () {
526  void floating?.return?.()
527  floating = null
528}
529
530async function gtkWorks ($: EngineInterface): Promise<boolean> {
531  if (hasGtk !== null) return hasGtk
532  const check = "import gi; gi.require_version('Gtk', '3.0'); gi.require_version('Gst', '1.0'); from gi.repository import Gtk, Gst"
533  hasGtk = (await $.process.run(['python3', '-c', check]).catch(() => ({ exitCode: 1 }))).exitCode === 0
534  return hasGtk
535}
536
537// Una ventana sin bordes, siempre encima, con ✕ para cerrarla: junto al puntero tras un clic
538// en un enlace (`atCursor`), si no en la esquina inferior derecha
539async function openFloating ($: EngineInterface, item: Media, atCursor: boolean) {
540  closeFloating()
541  if (!screen) {
542    const { stdout } = await $.process.run(['xrandr', '--current']).catch(() => ({ stdout: '' }))
543    screen = primaryScreen(stdout) ?? { width: 1920, height: 1080, x: 0, y: 0 }
544  }
545  const size = floatSize(item.width, item.height)
546  const left = screen.x + screen.width - size.width - FLOAT_MARGIN.right
547  const top = screen.y + screen.height - size.height - FLOAT_MARGIN.bottom
548  const title = `Vista previa · ${item.name}`
549  const place = [String(size.width), String(size.height), String(Math.max(0, left)), String(Math.max(0, top))]
550  let argv: string[]
551  if (await gtkWorks($)) {
552    let source = item.path
553    // La imagen se convierte antes a un PNG del tamaño justo: así sirve cualquier formato y también una URL
554    if (item.kind === 'imagen') {
555      source = `${await tempDir($)}/vista-${++generation}.png`
556      await ffmpeg($, ['-i', item.path, '-frames:v', '1', '-vf', `scale=${size.width}:${size.height}:flags=lanczos`, source])
557    }
558    const where = atCursor ? ['cursor', 'cursor'] : place.slice(2)
559    argv = ['python3', `${$.plugin.root}/viewer/preview.py`, item.kind, source, ...place.slice(0, 2), ...where, title]
560  } else {
561    // Sin GTK queda ffplay: misma ventana, pero sin botón (Esc o q la cierran)
562    argv = [
563      'ffplay', '-loglevel', 'quiet', '-noborder', '-alwaysontop', '-window_title', title,
564      '-x', place[0]!, '-y', place[1]!, '-left', place[2]!, '-top', place[3]!,
565      ...(item.kind === 'video' ? ['-autoexit'] : []),
566      item.path,
567    ]
568  }
569  const stream = $.process.spawn({ argv })[Symbol.asyncIterator]()
570  floating = stream
571  void (async () => {
572    try {
573      while (!(await stream.next()).done) {}
574    } catch {}
575    if (floating === stream) floating = null
576  })()
577}
578
579// Muestra un archivo: en la ventana flotante si se puede, si no en el panel
580async function show ($: EngineInterface, path: string, atCursor = false): Promise<Media> {
581  if (await floats($)) {
582    const item = await probeMedia($, path)
583    await pause($)
584    await openFloating($, item, atCursor)
585    return item
586  }
587  const item = await openFile($, path)
588  if (item.kind === 'video') await play($)
589  return item
590}
591
592async function openFile ($: EngineInterface, path: string) {
593  const item = await probeMedia($, path)
594  await reset($)
595  await update($, media, () => item)
596  await update($, message, () => 'Cargando…')
597  // La galería de su carpeta sirve para pasar al anterior y al siguiente
598  const current = await read($, gallery)
599  if (isUrl(path)) {
600    await update($, gallery, () => null)
601  } else if (current?.dir !== dirName(path)) {
602    const list = await listGallery($, dirName(path)).catch(() => null)
603    await update($, gallery, () => list)
604  }
605  await $.ui.open({ id: PANE, title: item.name, focus: true, closeOnEscape: true, rows: MAX_ROWS + CHROME_ROWS, columns: MAX_COLUMNS + 2 })
606  $.ui.invalidate('ui.render')
607  return item
608}
609
610async function openGallery ($: EngineInterface, dir: string) {
611  await reset($)
612  const list = await listGallery($, dir)
613  await update($, gallery, () => list)
614  await update($, media, () => null)
615  await update($, message, () => list.items.length === 0 ? 'No hay imágenes ni videos en esta carpeta' : null)
616  await $.ui.open({ id: PANE, title: 'Galería', focus: true, closeOnEscape: true, rows: MAX_ROWS + CHROME_ROWS, columns: MAX_COLUMNS + 2 })
617  return list
618}
619
620// Abre una ruta escrita por la persona o el modelo: un archivo, o la galería de una carpeta
621async function openPath ($: EngineInterface, text: string, atCursor = false): Promise<string> {
622  const path = await resolvePath($, text || '.')
623  const { kind } = isUrl(path) ? { kind: 'file' } : await $.fs.stat(path)
624  if (kind === 'dir') {
625    const list = await openGallery($, path)
626    return `Galería de ${path}: ${list.items.length} archivos`
627  }
628  const item = await show($, path, atCursor)
629  return item.kind === 'video'
630    ? `Reproduciendo ${item.name} (${clock(item.duration)}, ${item.width}×${item.height})`
631    : `Mostrando ${item.name} (${item.width}×${item.height})`
632}
633
634// Un clic en un enlace a una imagen o un video lo abre directo, sin preguntar
635async function openLink ($: EngineInterface, href: string) {
636  try {
637    await openPath($, href, true)
638  } catch (error) {
639    $.ui.toast(`No se pudo abrir ${nameOf(href)}: ${(error as Error).message}`)
640  }
641}
642
643async function step ($: EngineInterface, delta: number) {
644  const item = await read($, media)
645  const list = await read($, gallery)
646  if (!item || !list || list.items.length < 2) return
647  const at = list.items.findIndex(i => i.path === item.path)
648  const next = list.items[(at + delta + list.items.length) % list.items.length]
649  if (!next) return
650  try {
651    await show($, next.path)
652  } catch (error) {
653    await update($, message, () => (error as Error).message)
654  }
655}
656
657export const register: Register = on => {
658  on('session.start', async ($, e, next) => {
659    const result = await next(e)
660    await $.command.register({
661      name: 'ver',
662      description: 'Ve una imagen o un video aquí mismo; sin ruta, la galería de la carpeta',
663      argumentHint: '[archivo o carpeta]',
664    })
665    await $.tool.register({
666      name: 'mostrar',
667      description: 'Muestra a la persona una imagen o un video (o la galería de una carpeta) en un panel de la terminal. Úsalo cuando pida ver un archivo multimedia.',
668      inputSchema: {
669        type: 'object',
670        properties: { ruta: { type: 'string', description: 'Ruta del archivo o la carpeta' } },
671        required: ['ruta'],
672      },
673      isDeferred: false,
674    })
675    return result
676  })
677
678  on('command.run', { command: 'ver' }, async ($, e) => {
679    try {
680      return { text: await openPath($, e.args) }
681    } catch (error) {
682      return { text: (error as Error).message }
683    }
684  })
685
686  on('tool.call', { tool: 'mcp__visor-medios__mostrar' }, async ($, e) => {
687    try {
688      const text = await openPath($, toolPath(e))
689      return { result: text, text }
690    } catch (error) {
691      return { deny: (error as Error).message }
692    }
693  })
694
695  on('ui.close', { id: PANE }, async ($, e, next) => {
696    await reset($)
697    return next(e)
698  })
699
700  on('session.end', async ($, e, next) => {
701    ticker?.cancel()
702    stopAudio()
703    closeFloating()
704    if (tmpDir) await $.process.run(['rm', '-rf', tmpDir]).catch(() => {})
705    return next(e)
706  })
707
708  // Los enlaces a imágenes y videos de las respuestas se vuelven clicables para el visor;
709  // los demás enlaces siguen abriéndose como siempre
710  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
711    if (e.surface !== 'terminal' || e.props.isSummary) return next(e)
712    const text = linkifyMedia(e.props.text, await $.env.get('HOME'))
713    const links = mediaLinks(text)
714    if (links.length === 0) return next(e)
715    const { Markdown } = $.ui.resolve(e)
716    return (
717      <Markdown key="respuesta" text={text} pressableLinks={pressable(links)}
718        onLinkPress={link => void openLink($, link.href)} />
719    )
720  })
721
722  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
723    if (e.surface !== 'terminal') {
724      const { Text } = $.ui.resolve(e)
725      return <Text dimColor>El visor solo dibuja en la terminal.</Text>
726    }
727    const { Box, Text, Button, Raster } = $.ui.resolve(e)
728    const item = await read($, media)
729    const list = await read($, gallery)
730    const note = await read($, message)
731    const columns = e.props.bodyColumns
732    const rows = Math.max(2, e.props.scroll.bodyRows - CHROME_ROWS)
733
734    if (!item) {
735      return (
736        <Box flexDirection="column">
737          <Text bold>{list ? list.dir : 'Galería'}</Text>
738          {note && <Text dimColor>{note}</Text>}
739          {list?.items.map(one => (
740            <Button key={`open-${one.path}`} plain onPress={async () => {
741              try {
742                await show($, one.path)
743              } catch (error) {
744                await update($, message, () => (error as Error).message)
745              }
746            }}>
747              {one.kind === 'video' ? '▶ ' : '▣ '}{one.name} <Text dimColor>{sizeLabel(one.size)}</Text>
748            </Button>
749          ))}
750        </Box>
751      )
752    }
753
754    // Si cambió el espacio, se vuelve a extraer al tamaño nuevo
755    if (columns !== room.columns || rows !== room.rows) {
756      room = { columns, rows }
757      $.clock.after(0, () => void prepare($))
758    } else if (!cells && !isPreparing) {
759      $.clock.after(0, () => void prepare($))
760    }
761
762    const playing = await read($, isPlaying)
763    const muted = await read($, isMuted)
764    const at = await read($, second)
765    const many = (list?.items.length ?? 0) > 1
766    const index = list ? list.items.findIndex(i => i.path === item.path) + 1 : 0
767    const facts = item.kind === 'video'
768      ? `${clock(at)} / ${clock(item.duration)} · ${item.width}×${item.height}`
769      : `${item.width}×${item.height}`
770
771    return (
772      <Box flexDirection="column">
773        <Text>
774          <Text bold>{item.name}</Text> <Text dimColor>{facts}{many ? ` · ${index} de ${list?.items.length}` : ''}</Text>
775        </Text>
776        {note && <Text dimColor>{note}</Text>}
777        {layout && cells && (
778          <Raster key="frame" columns={layout.columns} rows={layout.rows} cells={cells} />
779        )}
780        <Box flexDirection="row" gap={1}>
781          {item.kind === 'video' && (
782            <Button key="play" hotkey="p" variant="primary" label={playing ? 'Pausa' : 'Reproducir'}
783              onPress={() => (ticker ? pause($) : play($))} />
784          )}
785          {item.kind === 'video' && <Button key="back" hotkey="a" label="« 5s" onPress={() => seek($, -5)} />}
786          {item.kind === 'video' && <Button key="forward" hotkey="d" label="5s »" onPress={() => seek($, 5)} />}
787          {item.kind === 'video' && item.hasAudio && (
788            <Button key="mute" hotkey="m" label={muted ? 'Sonido: no' : 'Sonido: sí'} onPress={async () => {
789              const isNowMuted = !(await read($, isMuted))
790              await update($, isMuted, () => isNowMuted)
791              if (isNowMuted) stopAudio()
792              else if (ticker) {
793                position += (await now($)) - startedAt
794                startedAt = await now($)
795                await startAudio($, item)
796              }
797            }} />
798          )}
799          {many && <Button key="prev" hotkey="j" label="◀ Anterior" onPress={() => step($, -1)} />}
800          {many && <Button key="next" hotkey="k" label="Siguiente ▶" onPress={() => step($, 1)} />}
801          {list && <Button key="gallery" hotkey="g" label="Galería" onPress={() => openGallery($, list.dir)} />}
802        </Box>
803      </Box>
804    )
805  })
806}
807
types/index.d.ts 35 lines
1export type MediaKind = 'imagen' | 'video'
2
3// Lo que ffprobe dice del archivo abierto
4export type Media = {
5  path: string
6  name: string
7  kind: MediaKind
8  width: number
9  height: number
10  // Segundos; 0 en una imagen
11  duration: number
12  fps: number
13  hasAudio: boolean
14}
15
16// Un archivo de la carpeta que muestra la galería
17export type GalleryItem = { path: string; name: string; kind: MediaKind; size: number }
18
19export type Gallery = { dir: string; items: GalleryItem[] }
20
21declare module 'claude-code' {
22  interface PluginState {
23    'visor-medios': {
24      media: Media | null
25      gallery: Gallery | null
26      // Texto bajo el título: "cargando…", un error
27      message: string | null
28      isPlaying: boolean
29      isMuted: boolean
30      // Segundo del video que se ve, redondeado (para la etiqueta de tiempo)
31      second: number
32    }
33  }
34}
35