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

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.
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.
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.
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.
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.
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-modelohooks/register.tsx 807 lines1import { 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}
807types/index.d.ts 35 lines1export 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