SLOPSHOPPER

selector-modelo

Botones sobre el prompt para cambiar el modelo y el esfuerzo con clics

bandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · selector-modelo
› fix the failing auth test and add an audit log call ⏺ 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 ⚙ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⚙
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 311 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Effort, ModelChoice } from '../types'
5
6const model = atom({ plugin: 'selector-modelo', key: 'model' } as const, null)
7const effort = atom({ plugin: 'selector-modelo', key: 'effort' } as const, null)
8const isOpen = atom({ plugin: 'selector-modelo', key: 'isOpen' } as const, false)
9const family = atom({ plugin: 'selector-modelo', key: 'family' } as const, null)
10const choices = atom({ plugin: 'selector-modelo', key: 'choices' } as const, [])
11// Esfuerzo que eligió la persona; null = el que decida Claude Code (auto)
12const chosenEffort = atom({ plugin: 'selector-modelo', key: 'chosenEffort' } as const, null)
13
14export const FAMILIES = [
15  { alias: 'fable', label: 'Fable' },
16  { alias: 'opus', label: 'Opus' },
17  { alias: 'sonnet', label: 'Sonnet' },
18  { alias: 'haiku', label: 'Haiku' },
19] as const
20
21// Si /config no expone la lista de modelos, al menos las versiones actuales
22const FALLBACK_IDS = ['claude-fable-5-1', 'claude-opus-5-5', 'claude-sonnet-5-5', 'claude-haiku-5-5']
23
24export const EFFORTS: Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
25
26const EFFORT_LABELS: Record<Effort, string> = {
27  low: 'Bajo',
28  medium: 'Medio',
29  high: 'Alto',
30  xhigh: 'Muy alto',
31  max: 'Máximo',
32}
33
34const compact = (text: string) => text.toLowerCase().replace(/[^a-z0-9]/g, '')
35
36// "claude-opus-5-5" -> "opus"
37export function familyOf (text: string | null): string | null {
38  if (!text) return null
39  const value = text.toLowerCase()
40  return FAMILIES.find(f => value.includes(f.alias))?.alias ?? null
41}
42
43// "claude-opus-5-5" -> "Opus 5.5"; "claude-opus-4-1-20250805" -> "Opus 4.1"; otro texto se deja tal cual
44export function prettyName (text: string): string {
45  const match = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[1m\])?$/i.exec(text.trim())
46  if (!match) return text
47  const [, name = '', major, minor, longContext] = match
48  const label = name.charAt(0).toUpperCase() + name.slice(1)
49  return `${label} ${major}${minor ? `.${minor}` : ''}${longContext ? ' (1M)' : ''}`
50}
51
52// Nombre legible de una opción: un id ("Opus 5.5") o un alias de /config ("Opus · último")
53export function labelFor (option: string): string {
54  const value = option.trim()
55  if (/^claude-/i.test(value)) return prettyName(value)
56  const fam = FAMILIES.find(f => value.toLowerCase().startsWith(f.alias))
57  if (!fam) return value
58  const rest = value.slice(fam.alias.length).toLowerCase()
59  if (rest === '') return `${fam.label} · último`
60  if (rest === '[1m]') return `${fam.label} · 1M contexto`
61  if (rest === 'plan') return `${fam.label} Plan`
62  return value
63}
64
65// Precio de lista de la API, USD por millón de tokens [entrada, salida] (2026-10)
66const PRICES: Record<string, [number, number]> = {
67  'claude-fable-5-1': [10, 50],
68  'claude-fable-5': [10, 50],
69  'claude-opus-5-5': [4, 20],
70  'claude-opus-5': [5, 25],
71  'claude-opus-4-8': [5, 25],
72  'claude-opus-4-7': [5, 25],
73  'claude-opus-4-6': [5, 25],
74  'claude-sonnet-5-5': [2, 10],
75  'claude-sonnet-5': [2, 10],
76  'claude-sonnet-4-6': [3, 15],
77  'claude-haiku-5-5': [0.1, 0.5],
78  'claude-haiku-4-5': [1, 5],
79}
80
81// A qué versión apunta hoy cada alias
82const LATEST: Record<string, string> = {
83  fable: 'claude-fable-5-1',
84  opus: 'claude-opus-5-5',
85  sonnet: 'claude-sonnet-5-5',
86  haiku: 'claude-haiku-5-5',
87}
88
89const usd = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, '') : n}`
90
91function priceOf (id: string): string | undefined {
92  const price = PRICES[id]
93  return price ? `${usd(price[0])}/${usd(price[1])}` : undefined
94}
95
96// "opus" -> "$4/$20"; "opusplan" -> "$4/$20 → $2/$10"; ids con fecha o [1m] cuentan como su versión
97export function priceFor (option: string): string | undefined {
98  const value = option.trim().toLowerCase().replace(/\[1m\]$/, '').replace(/-\d{8}$/, '')
99  if (value.startsWith('claude-')) return priceOf(value)
100  if (value === 'opusplan') {
101    const plan = priceOf(LATEST.opus ?? '')
102    const run = priceOf(LATEST.sonnet ?? '')
103    return plan && run ? `${plan} → ${run}` : undefined
104  }
105  const latest = LATEST[value]
106  return latest ? priceOf(latest) : undefined
107}
108
109export function toChoices (options: readonly string[]): ModelChoice[] {
110  const seen = new Set<string>()
111  const list: ModelChoice[] = []
112  for (const option of options) {
113    const fam = familyOf(option)
114    if (!fam || seen.has(option)) continue
115    seen.add(option)
116    list.push({ value: option, label: labelFor(option), family: fam, price: priceFor(option) })
117  }
118  // Alias primero, luego versiones concretas de la más nueva a la más vieja
119  return list.sort((a, b) => {
120    const aId = a.value.startsWith('claude-')
121    const bId = b.value.startsWith('claude-')
122    if (aId !== bId) return aId ? 1 : -1
123    return aId ? b.value.localeCompare(a.value, undefined, { numeric: true }) : 0
124  })
125}
126
127export function isCurrent (choice: ModelChoice, current: string | null): boolean {
128  if (!current) return false
129  const a = compact(choice.value)
130  const b = compact(current)
131  return a === b || compact(prettyName(choice.value)) === compact(prettyName(current))
132}
133
134async function loadChoices ($: EngineInterface) {
135  let found: ModelChoice[] = []
136  try {
137    const row = (await $.config.list()).find(r => r.key.toLowerCase() === 'model')
138    if (row?.options?.length) found = toChoices([...row.options, ...FALLBACK_IDS])
139  } catch {}
140  const list = found.length > 0 ? found : toChoices(FALLBACK_IDS)
141  await update($, choices, () => list)
142}
143
144async function chooseModel ($: EngineInterface, choice: ModelChoice) {
145  try {
146    // Mismo cambio que hace el menú /config; si esa fila no lo admite, como /model <id>
147    let isDone = false
148    try {
149      const row = (await $.config.list()).find(r => r.key.toLowerCase() === 'model')
150      if (row?.options?.includes(choice.value)) {
151        const { deny } = await $.config.set({ key: row.key, value: choice.value })
152        isDone = deny === undefined
153      }
154    } catch {}
155    if (!isDone) await $.command.run({ command: 'model', args: choice.value })
156    await update($, model, () => choice.value)
157  } catch {
158    $.ui.toast(`No se pudo cambiar el modelo a ${choice.label}`)
159  }
160}
161
162export function isEffort (value: unknown): value is Effort {
163  return typeof value === 'string' && (EFFORTS as string[]).includes(value)
164}
165
166// /effort <nivel> no aplica el nivel en todas las versiones ("Kept effort level as auto"),
167// así que el nivel elegido se pone en cada petición del hilo principal (turn.step)
168async function chooseEffort ($: EngineInterface, level: Effort | null) {
169  await update($, chosenEffort, () => level)
170  if (level) await update($, effort, () => level)
171  try {
172    await (level ? $.store.set('effort', level) : $.store.delete('effort'))
173  } catch {}
174  $.ui.toast(level
175    ? `Esfuerzo ${EFFORT_LABELS[level]} desde la próxima respuesta`
176    : 'Esfuerzo automático desde la próxima respuesta')
177}
178
179export const register: Register = on => {
180  on('session.start', async ($, e, next) => {
181    const result = await next(e)
182    await loadChoices($)
183    // El esfuerzo elegido se mantiene entre sesiones
184    try {
185      const saved = await $.store.get('effort')
186      if (isEffort(saved)) {
187        await update($, chosenEffort, () => saved)
188        await update($, effort, () => saved)
189      }
190    } catch {}
191    return result
192  })
193
194  // El modelo y el esfuerzo reales de cada petición del hilo principal
195  on('turn.step', async function* ($, e, next) {
196    if (e.agentId === undefined) {
197      const id = e.model
198      await update($, model, () => id)
199      // La versión en uso siempre aparece como opción
200      await update($, choices, list =>
201        list.some(c => c.value === id) || !familyOf(id) ? list : toChoices([...list.map(c => c.value), id]),
202      )
203      const wanted = await read($, chosenEffort)
204      // Un modelo sin esfuerzo (e.effort ausente) se deja como está
205      if (wanted && e.effort !== undefined && e.effort !== wanted) {
206        await update($, effort, () => wanted)
207        return yield* next({ ...e, effort: wanted })
208      }
209      if (isEffort(e.effort)) {
210        const level = e.effort
211        await update($, effort, () => level)
212      }
213    }
214    return yield* next(e)
215  })
216
217  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
218    if (e.props.hasSurvey) return next(e)
219
220    const { Box, Button, Text } = $.ui.resolve(e)
221    const current = await read($, model)
222    const level = await read($, effort)
223    const wanted = await read($, chosenEffort)
224    const open = await read($, isOpen)
225    const list = await read($, choices)
226    const currentFamily = familyOf(current)
227    const shownFamily = (await read($, family)) ?? currentFamily
228    const modelLabel = current ? prettyName(current) : '—'
229    const effortLabel = (level ? EFFORT_LABELS[level] : '—') + (wanted ? '' : ' (auto)')
230
231    if (!open) {
232      return (
233        <Box>
234          <Button key="toggle" plain dimColor onPress={() => update($, isOpen, () => true)}>
235            ⚙ {modelLabel} · esfuerzo {effortLabel} <Text dimColor>(clic para cambiar)</Text>
236          </Button>
237        </Box>
238      )
239    }
240
241    const versions = list.filter(c => c.family === shownFamily)
242
243    return (
244      <Box flexDirection="column">
245        <Box flexWrap="wrap">
246          <Text dimColor>Modelo:   </Text>
247          {FAMILIES.filter(f => list.some(c => c.family === f.alias)).map(f => (
248            <Button
249              key={`family-${f.alias}`}
250              label={f.label}
251              variant={f.alias === shownFamily ? 'primary' : undefined}
252              dimColor={f.alias !== shownFamily}
253              onPress={() => update($, family, () => f.alias)}
254            />
255          ))}
256        </Box>
257        {versions.length > 0 && (
258          <Box flexWrap="wrap">
259            <Text dimColor>Versión:  </Text>
260            {versions.map(c => (
261              <Button
262                key={`model-${c.value}`}
263                label={c.price ? `${c.label} ${c.price}` : c.label}
264                variant={isCurrent(c, current) ? 'primary' : undefined}
265                dimColor={!isCurrent(c, current)}
266                onPress={() => chooseModel($, c)}
267              >
268                {c.label}
269                {c.price && <Text dimColor> {c.price}</Text>}
270              </Button>
271            ))}
272          </Box>
273        )}
274        {versions.some(c => c.price) && (
275          <Text dimColor>          precio API en USD por millón de tokens: entrada/salida</Text>
276        )}
277        <Box flexWrap="wrap">
278          <Text dimColor>Esfuerzo: </Text>
279          <Button
280            key="effort-auto"
281            label="Auto"
282            variant={wanted === null ? 'primary' : undefined}
283            dimColor={wanted !== null}
284            onPress={() => chooseEffort($, null)}
285          />
286          {EFFORTS.map(l => (
287            <Button
288              key={`effort-${l}`}
289              label={EFFORT_LABELS[l]}
290              variant={l === wanted ? 'primary' : undefined}
291              dimColor={l !== wanted}
292              onPress={() => chooseEffort($, l)}
293            />
294          ))}
295          <Text> </Text>
296          <Button
297            key="close"
298            plain
299            dimColor
300            label="✕ cerrar"
301            onPress={async () => {
302              await update($, family, () => null)
303              await update($, isOpen, () => false)
304            }}
305          />
306        </Box>
307      </Box>
308    )
309  })
310}
311
types/index.d.ts 18 lines
1export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
2
3// Una versión de modelo elegible: `value` es lo que se envía a /config o /model
4export type ModelChoice = { value: string; label: string; family: string; price?: string }
5
6declare module 'claude-code' {
7  interface PluginState {
8    'selector-modelo': {
9      model: string | null
10      effort: Effort | null
11      isOpen: boolean
12      family: string | null
13      choices: ModelChoice[]
14      chosenEffort: Effort | null
15    }
16  }
17}
18