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

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 311 lines1import { 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}
311types/index.d.ts 18 lines1export 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