Clean View y Context Bar en una caja: oculta las herramientas, muestra la tarea en curso con su progreso y el uso de la ventana de contexto.

Marketplace de mods para Claude Code.
claude plugin marketplace add Dos2Locos/claude-code-mods
claude plugin install clean-view@dos2locos-mods
claude plugin install openspec-tracker@dos2locos-mods
claude plugin install clean-bar@dos2locos-mods
En una sesión abierta, ejecuta /reload-plugins después de instalar o actualizar.
Para actualizar: claude plugin marketplace update dos2locos-mods y claude plugin update clean-view@dos2locos-mods.
Vista limpia mientras Claude trabaja:
Pensando · paso 3/5….0 (prompt vacío), el botón de la franja o /clean-view [on|off].Funciona en la terminal y en la pestaña Code de la app de escritorio. Requiere Claude Code v2.1.287 o posterior.
Clean View y Context Bar en un único mod: una caja sobre el prompt, con dos secciones.
work_state_write de claude-mem): el prompt como cabecera en degradado, Paso n de m con barra y porcentaje, y una fila por tarea con su mini barra y estado (Hecho, En curso, Siguiente, Pendiente). Muestra hasta 6 tareas alrededor de la actual y, debajo, la acción en curso (↳ Editar app.ts)./compact.1 (progreso) y 2 (contexto) en el prompt vacío, o con su botón ▾/▸. Plegadas muestran la tarea actual con mini barra y porcentaje, y el uso de contexto con mini barra apilada y porcentaje. La elección se recuerda entre sesiones.0 (prompt vacío), el botón de la caja o /clean-bar [on|off].Sustituye a clean-view y context-bar: conviene no tenerlos activos a la vez.
Seguimiento del flujo de OpenSpec en el proyecto de la sesión:
/openspec abre un panel con los cambios activos: fase (propuesta → specs → diseño → tareas → implementación → listo para archivar), barra de tareas y última modificación. Pulsa 1–9 para abrir uno.tasks.md por sección y validación con openspec validate.a Aplicar, x Archivar y e Revisar: escriben /opsx:apply <id> (o equivalente) en el prompt sin enviarlo. v Validar y r Actualizar se ejecutan al momento.◐ OpenSpec 05-update-member-screens-ux · 7/15 · implementación.Requiere la CLI openspec (v1.x) instalada. Solo se activa en proyectos con carpeta openspec/.
hooks/register.tsx 585 lines1// Clean Bar: Clean View y Context Bar en una sola caja sobre el prompt.
2// Arriba, el progreso: la tarea en curso, una barra del plan y su porcentaje (sin plan,
3// solo la actividad). Debajo, la ventana de contexto: barra apilada por categoría y leyenda.
4// Cada sección se pliega a una línea con `1` y `2` (prompt vacío) o su botón ▾/▸.
5// Oculta las filas de herramientas. Se alterna con `0` (prompt vacío), el botón o /clean-bar.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register, RenderChildren } from 'claude-code'
8
9import type { Compact, Reading, Run, Step, StepStatus } from '../types'
10import { GLYPH, SPLIT, cells, legend, share, toReading, tokens } from './context'
11
12const isOn = atom({ plugin: 'clean-bar', key: 'isOn' } as const, true)
13const run = atom({ plugin: 'clean-bar', key: 'run' } as const, null as Run | null)
14const reading = atom({ plugin: 'clean-bar', key: 'reading' } as const, null as Reading | null)
15const compact = atom({ plugin: 'clean-bar', key: 'compact' } as const, { progress: false, context: false } as Compact)
16
17const STORE_KEY = 'isOn'
18const COMPACT_KEY = 'compact'
19const TOGGLE_HOTKEY = '0'
20const FOLD_HOTKEY: Record<keyof Compact, string> = { progress: '1', context: '2' }
21/** Ancho de las barras en modo compacto. */
22const MINI_BAR = 16
23const MIN_WIDTH = 20
24const PLAN_TOOLS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'])
25/** La lista de trabajo de claude-mem (`work_state_write`), con el prefijo MCP que tenga instalado. */
26const WORK_STATE_TOOL = /work_state_write$/
27const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
28const HIDDEN_ROWS = ['ToolUse', 'ToolResult', 'ToolGroup', 'ToolProgress'] as const
29
30const ACCENT = '#d97757'
31const OK = '#9ece6a'
32const WARN = '#e0af68'
33const TRACK = '#808080'
34// Degradados del plan: naranja a rosa mientras trabaja, verdes al terminar.
35const ORANGE = '#ff9e64'
36const PINK = '#ff5f87'
37const GREEN_FROM = '#2f9e44'
38const GREEN_TO = '#9ece6a'
39const SHADE = '#3b4252' // el fondo de las barras vacías
40/** Tareas del plan visibles a la vez, alrededor de la actual. */
41const MAX_ROWS = 6
42/** Parte de la mini barra que llena la tarea en curso (no hay progreso real por tarea). */
43const RUNNING_SHARE = 0.4
44const ICON: Record<StepStatus, string> = { pending: '○', running: '◐', done: '✓', error: '✗' }
45const COLOR: Record<StepStatus, string | undefined> = { pending: undefined, running: ACCENT, done: OK, error: 'red' }
46
47const emptyRun = (startedAt: number, title: string): Run => ({
48 isWorking: true,
49 title,
50 startedAt,
51 durationMs: 0,
52 plan: [],
53 toolCalls: 0,
54 changedFiles: [],
55 commands: 0,
56 errors: 0,
57 isAborted: false,
58})
59
60function field(e: object, name: string): string | undefined {
61 const value = (e as Record<string, unknown>)[name]
62 return typeof value === 'string' && value !== '' ? value : undefined
63}
64
65function basename(path: string): string {
66 return path.split('/').filter(Boolean).pop() ?? path
67}
68
69function clip(text: string, max = 48): string {
70 const line = text.replace(/\s+/g, ' ').trim()
71 return line.length > max ? `${line.slice(0, max - 1)}…` : line
72}
73
74/** Una frase legible para cada llamada, en lugar de `Bash(...)`. */
75export function describeCall(tool: string, e: object): string {
76 const file = field(e, 'file_path') ?? field(e, 'notebook_path')
77 switch (tool) {
78 case 'Read':
79 return `Leer ${file ? basename(file) : 'archivo'}`
80 case 'Edit':
81 case 'MultiEdit':
82 case 'NotebookEdit':
83 return `Editar ${file ? basename(file) : 'archivo'}`
84 case 'Write':
85 return `Escribir ${file ? basename(file) : 'archivo'}`
86 case 'Bash': {
87 const what = field(e, 'description') ?? field(e, 'command')
88 return what ? `Ejecutar: ${clip(what)}` : 'Ejecutar comando'
89 }
90 case 'Grep':
91 case 'Glob': {
92 const pattern = field(e, 'pattern')
93 return pattern ? `Buscar «${clip(pattern, 32)}»` : 'Buscar en el código'
94 }
95 case 'WebFetch': {
96 const url = field(e, 'url')
97 return `Consultar ${url ? clip(url.replace(/^https?:\/\//, '').split('/')[0] ?? url, 32) : 'página web'}`
98 }
99 case 'WebSearch':
100 return `Buscar en la web: ${clip(field(e, 'query') ?? '', 36)}`
101 case 'Agent':
102 case 'Task':
103 return `Delegar: ${clip(field(e, 'description') ?? 'subtarea', 40)}`
104 case 'Skill':
105 return `Usar habilidad ${field(e, 'skill') ?? ''}`.trim()
106 default: {
107 const short = tool.startsWith('mcp__') ? tool.split('__').slice(2).join('__') : tool
108 return `Usar ${short}`
109 }
110 }
111}
112
113function todoStatus(status: string): StepStatus {
114 return status === 'completed' || status === 'done'
115 ? 'done'
116 : status === 'in_progress' || status === 'doing'
117 ? 'running'
118 : 'pending'
119}
120
121/** Una entrada de work_state_write con `task` crea o actualiza esa tarea del plan; `dropped` la quita. */
122export function applyWorkState(plan: Step[], e: object): Step[] {
123 const list = field(e, 'list')
124 const fields = (e as { fields?: Record<string, unknown> }).fields ?? {}
125 const task = typeof fields.task === 'string' && fields.task !== '' ? fields.task : undefined
126 if (!list || !task) return plan // estado de la lista, no una tarea
127 const id = `${list}/${task}`
128 const status = typeof fields.status === 'string' ? fields.status : undefined
129 if (status === 'dropped') return plan.filter(s => s.id !== id)
130 const existing = plan.find(s => s.id === id)
131 if (!existing) return [...plan, { id, label: task, status: todoStatus(status ?? 'todo') }]
132 return plan.map(s => (s.id === id && status ? { ...s, status: todoStatus(status) } : s))
133}
134
135export function formatDuration(ms: number): string {
136 const seconds = Math.round(ms / 1000)
137 return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`
138}
139
140/** Progreso del plan: tareas hechas, total y porcentaje. */
141export function progress(plan: Step[]): { done: number; total: number; percent: number } {
142 const done = plan.filter(s => s.status === 'done' || s.status === 'error').length
143 const total = plan.length
144 return { done, total, percent: total === 0 ? 0 : Math.round((done / total) * 100) }
145}
146
147/** La tarea en curso; si ninguna lo está, la siguiente pendiente. */
148export function currentTask(plan: Step[]): { step: Step; index: number } | undefined {
149 const running = plan.findIndex(s => s.status === 'running')
150 const index = running !== -1 ? running : plan.findIndex(s => s.status === 'pending')
151 return index === -1 ? undefined : { step: plan[index]!, index }
152}
153
154export function bar(percent: number, width: number): { filled: string; empty: string } {
155 const n = Math.round((percent / 100) * width)
156 return { filled: '█'.repeat(n), empty: '─'.repeat(width - n) }
157}
158
159/** Mezcla dos colores `#rrggbb`: `t` 0 da `a`, 1 da `b`. */
160export function mix(a: string, b: string, t: number): string {
161 const channel = (hex: string, i: number) => parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16)
162 return `#${[0, 1, 2]
163 .map(i => Math.round(channel(a, i) + (channel(b, i) - channel(a, i)) * t).toString(16).padStart(2, '0'))
164 .join('')}`
165}
166
167/** El estado de cada tarea en palabras; la primera pendiente es «Siguiente». */
168export function stepState(plan: Step[], index: number): string {
169 const status = plan[index]!.status
170 if (status !== 'pending') return { done: 'Hecho', running: 'En curso', error: 'Error' }[status]
171 return plan.findIndex(s => s.status === 'pending') === index ? 'Siguiente' : 'Pendiente'
172}
173
174/** Qué tramo del plan cabe: hasta `max` tareas, con la anterior a la actual arriba. */
175export function visibleRange(total: number, focus: number, max: number): { start: number; end: number } {
176 const start = Math.min(Math.max(focus - 1, 0), Math.max(total - max, 0))
177 return { start, end: Math.min(total, start + max) }
178}
179
180/** El resumen breve que se muestra al terminar. */
181export function summarize(r: Run): string {
182 const parts: string[] = []
183 const files = r.changedFiles
184 if (files.length > 0) {
185 const names = files.slice(0, 4).map(basename).join(', ')
186 const more = files.length > 4 ? ` y ${files.length - 4} más` : ''
187 parts.push(`${files.length} ${files.length === 1 ? 'archivo modificado' : 'archivos modificados'} (${names}${more})`)
188 } else {
189 parts.push('sin cambios en archivos')
190 }
191 if (r.commands > 0) parts.push(`${r.commands} ${r.commands === 1 ? 'comando' : 'comandos'}`)
192 if (r.errors > 0) parts.push(`${r.errors} ${r.errors === 1 ? 'error' : 'errores'}`)
193 return `${r.isAborted ? 'Interrumpido' : 'Listo'} en ${formatDuration(r.durationMs)} · ${parts.join(' · ')}`
194}
195
196async function setOn($: EngineInterface, value: boolean) {
197 await update($, isOn, () => value)
198 await $.store.set(STORE_KEY, value)
199}
200
201// Pide a Claude Code el desglose de /context, estimado en local (sin llamadas de conteo).
202async function refreshContext($: EngineInterface) {
203 const usage = await $.session.usage({ breakdown: 'summary' })
204 const b = usage.context.breakdown
205 if (!b || !(b.rawMaxTokens > 0)) return
206 await update($, reading, () => toReading(b))
207}
208
209async function toggleCompact($: EngineInterface, section: keyof Compact) {
210 const value = await update($, compact, c => ({ ...c, [section]: !c[section] }))
211 await $.store.set(COMPACT_KEY, value).catch(() => {})
212}
213
214async function trackPlan($: EngineInterface, tool: string, e: object, ran: { result?: unknown }) {
215 if (WORK_STATE_TOOL.test(tool)) {
216 await update($, run, r => (r ? { ...r, plan: applyWorkState(r.plan, e) } : r))
217 } else if (tool === 'TodoWrite') {
218 const todos = (e as { todos?: { content: string; status: string }[] }).todos ?? []
219 const plan = todos.map((t, i) => ({ id: String(i), label: t.content, status: todoStatus(t.status) }))
220 await update($, run, r => (r ? { ...r, plan } : r))
221 } else if (tool === 'TaskCreate') {
222 const id = (ran.result as { task?: { id?: string } } | undefined)?.task?.id
223 const subject = field(e, 'subject')
224 if (id && subject) {
225 await update($, run, r => (r ? { ...r, plan: [...r.plan, { id, label: subject, status: 'pending' as StepStatus }] } : r))
226 }
227 } else if (tool === 'TaskUpdate') {
228 const id = field(e, 'taskId')
229 const status = field(e, 'status')
230 const subject = field(e, 'subject')
231 await update($, run, r => {
232 if (!r || !id) return r
233 const plan =
234 status === 'deleted'
235 ? r.plan.filter(s => s.id !== id)
236 : r.plan.map(s =>
237 s.id === id ? { ...s, label: subject ?? s.label, status: status ? todoStatus(status) : s.status } : s,
238 )
239 return { ...r, plan }
240 })
241 }
242}
243
244export const register: Register = on => {
245 on('session.start', async ($, e, next) => {
246 const r = await next(e)
247 const stored = await $.store.get(STORE_KEY).catch(() => undefined)
248 if (typeof stored === 'boolean') await update($, isOn, () => stored)
249 const folded = (await $.store.get(COMPACT_KEY).catch(() => undefined)) as Partial<Compact> | undefined
250 if (folded && typeof folded === 'object') {
251 await update($, compact, () => ({ progress: folded.progress === true, context: folded.context === true }))
252 }
253 await $.command
254 .register({ name: 'clean-bar', description: 'Activa o desactiva Clean Bar (on | off | sin argumento para alternar)', immediate: true })
255 .catch(() => {})
256 void refreshContext($).catch(() => {})
257 return r
258 })
259
260 // Un /compact vacía la ventana sin que termine un turno.
261 on('session.compact', async ($, e, next) => {
262 const r = await next(e)
263 if (!e.agentId && 'messages' in r) void refreshContext($).catch(() => {})
264 return r
265 })
266
267 on('command.run', { command: 'clean-bar' }, async ($, e) => {
268 const arg = String((e as { args?: unknown }).args ?? '').trim().toLowerCase()
269 const value = arg === 'on' ? true : arg === 'off' ? false : !(await read($, isOn))
270 await setOn($, value)
271 return { text: `Clean Bar ${value ? 'activado' : 'desactivado'}.` }
272 })
273
274 // Cada turno del bucle principal empieza una ejecución nueva.
275 on('turn.start', async ($, e, next) => {
276 const now = await $.clock.now()
277 await update($, run, () => emptyRun(now, e.text))
278 return next(e)
279 })
280
281 on('tool.call', async ($, e, next) => {
282 if (e.agentId !== undefined) return next(e)
283
284 const tool = String(e.tool)
285 if (PLAN_TOOLS.has(tool) || WORK_STATE_TOOL.test(tool)) {
286 const ran = await next(e)
287 await trackPlan($, tool, e, ran).catch(() => {}) // el seguimiento nunca bloquea la herramienta
288 return ran
289 }
290
291 const activity: Step = { id: e.tool_use_id, label: describeCall(tool, e), status: 'running' }
292 await update($, run, r => (r ? { ...r, activity, toolCalls: r.toolCalls + 1 } : r)).catch(() => {})
293
294 const ran = await next(e)
295 const isFailed = ran.deny !== undefined || (ran as { isError?: boolean }).isError === true
296 const file = field(e, 'file_path') ?? field(e, 'notebook_path')
297
298 await update($, run, r => {
299 if (!r) return r
300 // Llamadas en paralelo: solo se cierra la actividad si sigue siendo esta.
301 const status: StepStatus = isFailed ? 'error' : 'done'
302 const changedFiles =
303 !isFailed && EDIT_TOOLS.has(tool) && file && !r.changedFiles.includes(file) ? [...r.changedFiles, file] : r.changedFiles
304 return {
305 ...r,
306 activity: r.activity?.id === activity.id ? { ...activity, status } : r.activity,
307 changedFiles,
308 commands: r.commands + (tool === 'Bash' ? 1 : 0),
309 errors: r.errors + (isFailed ? 1 : 0),
310 }
311 }).catch(() => {})
312 return ran
313 })
314
315 on('turn.complete', async ($, e, next) => {
316 if (e.agentId === undefined) {
317 const now = await $.clock.now()
318 await update($, run, r =>
319 r ? { ...r, isWorking: false, durationMs: e.durationMs || now - r.startedAt, isAborted: e.isAborted } : r,
320 )
321 }
322 const result = await next(e)
323 if (e.agentId === undefined) await refreshContext($).catch(() => {}) // un subagente llena su propia ventana
324 return result
325 })
326
327 // Oculta las filas técnicas mientras está activo (ctrl+o sigue mostrando todo).
328 on('ui.render', { component: HIDDEN_ROWS }, async ($, e, next) => {
329 const isExpanded = (e.props as { isExpanded?: boolean }).isExpanded === true
330 if (isExpanded || !(await read($, isOn))) return next(e)
331 const { Box } = $.ui.resolve(e)
332 return <Box />
333 })
334
335 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
336 const rest = await next(e) // lo que dibujan otros mods y Claude Code se queda debajo
337 if (e.props.hasSurvey || e.props.view.agentId !== undefined) return rest
338 const { Box, Text, Button } = $.ui.resolve(e)
339 const active = await read($, isOn)
340
341 const toggle = (
342 <Button key="toggle" hotkey={TOGGLE_HOTKEY} plain label={active ? 'ocultar' : 'Activar Clean Bar'} onPress={() => setOn($, !active)} />
343 )
344 if (!active) {
345 return (
346 <Box flexDirection="column">
347 <Box>
348 <Text dimColor>Clean Bar desactivado · </Text>
349 {toggle}
350 </Box>
351 {rest}
352 </Box>
353 )
354 }
355
356 const inner = e.props.bodyColumns - 4 // el borde y el relleno ocupan 4 celdas
357 if (inner < MIN_WIDTH) return rest
358 const r = await read($, run)
359 const ctx = await read($, reading)
360 const now = await $.clock.now()
361 const folded = await read($, compact)
362
363 const fold = (section: keyof Compact) => (
364 <Button
365 key={`fold-${section}`}
366 hotkey={FOLD_HOTKEY[section]}
367 plain
368 label={folded[section] ? '▸' : '▾'}
369 onPress={() => toggleCompact($, section)}
370 />
371 )
372
373 // Una fila de sección: rombo, nombre y botones, lo que quepa a la izquierda; a la derecha el dato.
374 const row = (title: string, buttons: RenderChildren, left: RenderChildren, right: RenderChildren) => (
375 <Box flexDirection="row" justifyContent="space-between">
376 <Box flexDirection="row" flexShrink={1}>
377 <Text color={ACCENT}>{'◆ '}</Text>
378 <Text bold>{`${title} `}</Text>
379 {buttons}
380 <Text wrap="truncate-end">{left}</Text>
381 </Box>
382 <Text wrap="truncate-start">{right}</Text>
383 </Box>
384 )
385 const badge = (percent: number, tone: string) => (
386 <Text bold color="black" backgroundColor={tone}>{` ${percent}% `}</Text>
387 )
388
389 const progressSection = () => {
390 const buttons = (
391 <Box flexDirection="row">
392 {fold('progress')}
393 <Text> </Text>
394 {toggle}
395 <Text> </Text>
396 </Box>
397 )
398 if (!r) return row('clean view', buttons, null, <Text dimColor>esperando una tarea</Text>)
399 const hasPlan = r.plan.length > 0
400 const { done, total, percent } = progress(r.plan)
401 const current = currentTask(r.plan)
402 const elapsed = formatDuration(r.isWorking ? now - r.startedAt : r.durationMs)
403 const isClean = r.errors === 0 && !r.isAborted
404 const tone = r.isWorking ? ACCENT : isClean ? OK : WARN
405 const position = current && r.isWorking ? current.index + 1 : done
406 const steps = `${r.toolCalls} ${r.toolCalls === 1 ? 'paso' : 'pasos'}`
407
408 if (folded.progress) {
409 // Una línea: lo que se hace ahora (o el resumen) y, a la derecha, mini barra y porcentaje.
410 const doing = r.isWorking ? (current?.step ?? r.activity) : undefined
411 const left = r.isWorking ? (
412 doing ? (
413 <Text>
414 <Text color={COLOR[doing.status]}>{`${ICON[doing.status]} `}</Text>
415 <Text>{doing.label}</Text>
416 </Text>
417 ) : (
418 <Text dimColor>Pensando…</Text>
419 )
420 ) : (
421 <Text color={tone}>{`${isClean ? '✓' : '!'} ${summarize(r)}`}</Text>
422 )
423 const mini = bar(percent, MINI_BAR)
424 const right = hasPlan ? (
425 <Text>
426 <Text color={tone}>{mini.filled}</Text>
427 <Text color={TRACK}>{mini.empty}</Text>
428 <Text dimColor>{` ${position}/${total} `}</Text>
429 {badge(percent, tone)}
430 </Text>
431 ) : (
432 <Text dimColor>{` ${steps} · ${elapsed}`}</Text>
433 )
434 return row('clean view', buttons, left, right)
435 }
436
437 const summary = (
438 <Text color={tone} wrap="wrap">
439 {`${isClean ? '✓' : '!'} ${summarize(r)}`}
440 </Text>
441 )
442
443 if (!hasPlan) {
444 return (
445 <Box flexDirection="column">
446 {row('clean view', buttons, null, <Text dimColor>{`${steps} · ${elapsed}`}</Text>)}
447 {r.isWorking && r.activity && (
448 <Text wrap="truncate-end">
449 <Text color={COLOR[r.activity.status]}>{`${ICON[r.activity.status]} `}</Text>
450 <Text>{r.activity.label}</Text>
451 </Text>
452 )}
453 {r.isWorking && !r.activity && <Text dimColor>Pensando…</Text>}
454 {!r.isWorking && summary}
455 </Box>
456 )
457 }
458
459 // Con plan: cabecera con el prompt, barra del plan y una fila por tarea con su mini barra.
460 const [from, to] = r.isWorking ? [ORANGE, PINK] : isClean ? [GREEN_FROM, GREEN_TO] : [ORANGE, WARN]
461 const blocks = (count: number, width: number, a: string, b: string) => (
462 <Text>
463 {Array.from({ length: width }, (_, i) => (
464 <Text color={i < count ? mix(a, b, count > 1 ? i / (count - 1) : 1) : SHADE}>█</Text>
465 ))}
466 </Text>
467 )
468 const title = clip(r.title, Math.max(inner - 24, MIN_WIDTH)) || 'clean view'
469 const stepText = `Paso ${position} de ${total} `
470 const pct = ` ${percent}%`
471 const width = Math.max(inner - stepText.length - pct.length, 0)
472 const { start, end } = visibleRange(total, current?.index ?? total - 1, MAX_ROWS)
473 const labelWidth = Math.min(Math.max(...r.plan.map(s => s.label.length)) + 2, Math.floor(inner * 0.45))
474 const hidden = total - (end - start)
475 return (
476 <Box flexDirection="column">
477 <Box flexDirection="row" justifyContent="space-between">
478 <Box flexDirection="row" flexShrink={1}>
479 <Text color={to}>{'✱ '}</Text>
480 <Text bold wrap="truncate-end">
481 {[...title].map((c, i, all) => (
482 <Text color={mix(from, to, all.length > 1 ? i / (all.length - 1) : 0)}>{c}</Text>
483 ))}
484 </Text>
485 <Text> </Text>
486 {buttons}
487 </Box>
488 <Text dimColor>{elapsed}</Text>
489 </Box>
490 <Text wrap="truncate-end">
491 <Text dimColor>{stepText}</Text>
492 {blocks(Math.round((percent / 100) * width), width, from, to)}
493 <Text bold color={to}>{pct}</Text>
494 </Text>
495 {r.plan.slice(start, end).map((s, k) => {
496 const isRunning = s.status === 'running'
497 const isPending = s.status === 'pending'
498 const fill = isPending ? 0 : isRunning ? Math.round(MINI_BAR * RUNNING_SHARE) : MINI_BAR
499 const [a, b] = s.status === 'done' ? [GREEN_FROM, GREEN_TO] : s.status === 'error' ? [WARN, 'red'] : [ORANGE, PINK]
500 return (
501 <Text wrap="truncate-end">
502 <Text color={isRunning ? PINK : COLOR[s.status]}>{`${isRunning ? '●' : ICON[s.status]} `}</Text>
503 <Text bold={isRunning} dimColor={isPending}>{clip(s.label, labelWidth - 2).padEnd(labelWidth)}</Text>
504 {blocks(fill, MINI_BAR, a, b)}
505 <Text bold={isRunning} color={isRunning ? PINK : undefined} dimColor={isPending}>
506 {` ${stepState(r.plan, start + k)}`}
507 </Text>
508 </Text>
509 )
510 })}
511 {hidden > 0 && <Text dimColor>{` … y ${hidden} ${hidden === 1 ? 'tarea' : 'tareas'} más`}</Text>}
512 {r.isWorking && r.activity && <Text dimColor wrap="truncate-end">{` ↳ ${r.activity.label}`}</Text>}
513 {!r.isWorking && summary}
514 </Box>
515 )
516 }
517
518 const contextSection = (c: Reading) => {
519 const used = `${tokens(c.total)} de ${tokens(c.window)}`
520 const level = c.compactsAt ? c.total / c.compactsAt : c.total / c.window
521 const tone = level >= 0.9 ? 'red' : level >= 0.7 ? 'yellow' : 'green'
522 const buttons = (
523 <Box flexDirection="row">
524 {fold('context')}
525 <Text> </Text>
526 </Box>
527 )
528 const stacked = (width: number) => (
529 <Text>
530 {cells(c, width).map(cell => (
531 <Text color={cell.color}>{cell.text}</Text>
532 ))}
533 </Text>
534 )
535
536 if (folded.context) {
537 return row('context', buttons, null, (
538 <Text>
539 {stacked(MINI_BAR)}
540 <Text dimColor>{` ${used} `}</Text>
541 {badge(c.percent, tone)}
542 </Text>
543 ))
544 }
545
546 const info = `${used}${c.compactsAt ? ` · compacta en ${tokens(c.compactsAt)}` : ''}`
547 return (
548 <Box flexDirection="column">
549 {row('context', buttons, null, (
550 <Text>
551 <Text dimColor>{`${info} `}</Text>
552 {badge(c.percent, tone)}
553 </Text>
554 ))}
555 {stacked(inner)}
556 {legend(c, inner).map(line => (
557 <Text wrap="truncate-end">
558 {line.map((sl, i) => (
559 <Text>
560 {i > 0 && <Text>{SPLIT}</Text>}
561 <Text color={sl.color}>{sl.kind === 'used' ? '■ ' : `${GLYPH[sl.kind]} `}</Text>
562 <Text dimColor={sl.kind !== 'used'}>{`${sl.name} `}</Text>
563 <Text bold={sl.kind === 'used'}>{tokens(sl.tokens)}</Text>
564 {sl.kind === 'used' && <Text dimColor>{` ${share(sl.tokens, c.window)}`}</Text>}
565 </Text>
566 ))}
567 </Text>
568 ))}
569 </Box>
570 )
571 }
572
573 // Separación entre secciones solo cuando la de arriba ocupa varias líneas.
574 return (
575 <Box flexDirection="column">
576 <Box flexDirection="column" borderStyle="round" borderColor={r?.isWorking && r.plan.length > 0 ? PINK : 'inactive'} paddingX={1}>
577 {progressSection()}
578 {ctx && <Box marginTop={folded.progress ? 0 : 1}>{contextSection(ctx)}</Box>}
579 </Box>
580 {rest}
581 </Box>
582 )
583 })
584}
585hooks/context.ts 85 lines1// La parte de contexto: el desglose de /context como una barra apilada y su leyenda.
2// Adaptado del mod context-bar.
3import type { Reading, Slice } from '../types'
4
5export const SPLIT = ' '
6// El tema de /context repite gris en varias filas: cada fila usada recibe su propio color, en orden.
7const PALETTE = ['#7aa2f7', '#7dcfff', '#bb9af7', '#9ece6a', '#e0af68', '#f7768e', '#73daca', '#ff9e64', '#c0caf5']
8const MESSAGES = '#d97757' // la fila que crece, en el color de acento
9const FREE = '#808080'
10const BUFFER = '#808080'
11export const GLYPH = { used: '█', free: '─', buffer: '░' } as const
12
13export function toReading(b: {
14 categories: { name: string; tokens: number; color: string; kind: string }[]
15 totalTokens: number
16 rawMaxTokens: number
17 percentage: number
18 autoCompactThreshold?: number
19 isAutoCompactEnabled: boolean
20}): Reading {
21 const slices: Slice[] = b.categories
22 .filter(c => c.kind !== 'deferred' && c.tokens > 0)
23 .map(c => ({ name: c.name.toLowerCase(), tokens: c.tokens, color: c.color, kind: c.kind as Slice['kind'] }))
24 const order = { used: 0, free: 1, buffer: 2 }
25 slices.sort((x, y) => order[x.kind] - order[y.kind]) // stable: used rows keep /context's order
26 let next = 0
27 for (const s of slices) {
28 s.color = s.kind === 'free' ? FREE : s.kind === 'buffer' ? BUFFER : s.name === 'messages' ? MESSAGES : PALETTE[next++ % PALETTE.length]!
29 }
30 return {
31 slices,
32 total: b.totalTokens,
33 window: b.rawMaxTokens,
34 percent: b.percentage,
35 compactsAt: b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined,
36 }
37}
38
39// The bar as runs of cells: each slice gets its share of `width`, a used one at least one cell.
40export function cells(r: Reading, width: number) {
41 const sizes = r.slices.map(s => Math.max(s.kind === 'used' ? 1 : 0, Math.round((s.tokens / r.window) * width)))
42 // Rounding leaves the sum a few cells off: free space takes the difference first, then the largest slices.
43 let diff = width - sizes.reduce((a, n) => a + n, 0)
44 const isUsed = (i: number) => r.slices[i]!.kind === 'used'
45 const order = r.slices.map((_, i) => i).sort((a, b) => Number(isUsed(a)) - Number(isUsed(b)) || sizes[b]! - sizes[a]!)
46 for (const i of order) {
47 if (diff === 0) break
48 const size = Math.max(isUsed(i) ? 1 : 0, sizes[i]! + diff)
49 diff -= size - sizes[i]!
50 sizes[i] = size
51 }
52 return r.slices.map((s, i) => ({ color: s.color, kind: s.kind, text: GLYPH[s.kind].repeat(sizes[i]!) })).filter(c => c.text !== '')
53}
54
55// The legend, packed into lines no wider than `width`.
56export function legend(r: Reading, width: number) {
57 const lines: Slice[][] = [[]]
58 let used = 0
59 for (const s of r.slices) {
60 const size = 2 + s.name.length + 1 + tokens(s.tokens).length + (s.kind === 'used' ? 1 + share(s.tokens, r.window).length : 0)
61 const line = lines.at(-1)!
62 if (line.length > 0 && used + SPLIT.length + size > width) {
63 lines.push([s])
64 used = size
65 } else {
66 used += (line.length > 0 ? SPLIT.length : 0) + size
67 line.push(s)
68 }
69 }
70 return lines.filter(l => l.length > 0)
71}
72
73export function tokens(n: number) {
74 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
75 if (n >= 10_000) return `${Math.round(n / 1000)}k`
76 if (n >= 1000) return `${+(n / 1000).toFixed(1)}k`
77 return String(n)
78}
79
80export function share(n: number, window: number) {
81 const p = (n / window) * 100
82 if (p > 0 && p < 0.1) return '<0.1%'
83 return `${p >= 10 ? Math.round(p) : +p.toFixed(1)}%`
84}
85types/index.d.ts 41 lines1export type StepStatus = 'pending' | 'running' | 'done' | 'error'
2
3export type Step = { id: string; label: string; status: StepStatus }
4
5export type Run = {
6 isWorking: boolean
7 /** El prompt que abrió el turno, para la cabecera. */
8 title: string
9 startedAt: number
10 durationMs: number
11 /** El plan del modelo (TodoWrite / TaskCreate), cuando lo hay. */
12 plan: Step[]
13 /** La última llamada a herramienta, en una frase. */
14 activity?: Step
15 toolCalls: number
16 changedFiles: string[]
17 commands: number
18 errors: number
19 isAborted: boolean
20}
21
22export type Slice = { name: string; tokens: number; color: string; kind: 'used' | 'free' | 'buffer' }
23
24/** Lectura de la ventana de contexto, como la desglosa /context. */
25export type Reading = {
26 slices: Slice[]
27 total: number
28 window: number
29 percent: number
30 compactsAt?: number
31}
32
33/** Qué secciones se muestran en una sola línea. */
34export type Compact = { progress: boolean; context: boolean }
35
36declare module 'claude-code' {
37 interface PluginState {
38 'clean-bar': { isOn: boolean; compact: Compact; run: Run | null; reading: Reading | null }
39 }
40}
41