SLOPSHOPPER

clean-bar

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.

newbandrowsguardcommand
v0.3.0no licenseupdated 2026-10-09Dos2Locos/claude-code-mods/plugins/clean-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clean-bar
› 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 › /clean-bar ⎿ clean-bar: Clean Bar desactivado. Clean Bar desactivado · 0: Activar Clean Bar ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Clean Bar desactivado · 0: Activar Clean Bar ⟨Claude Code's own drawing⟩
README

Mods de Claude Code · Dos2Locos

Marketplace de mods para Claude Code.

Instalar

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.

Mods

clean-view

Vista limpia mientras Claude trabaja:

  • Oculta las filas de llamadas a herramientas (ctrl+o sigue mostrándolas).
  • Lista de pasos con su estado (○ pendiente, ◐ en curso, ✓ hecho, ✗ error) y barra de progreso sobre el prompt.
  • Progreso junto al spinner: Pensando · paso 3/5….
  • Resumen breve al terminar: archivos modificados, comandos y errores.
  • Se activa y desactiva con 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-bar

Clean View y Context Bar en un único mod: una caja sobre el prompt, con dos secciones.

  • Oculta las filas de llamadas a herramientas (ctrl+o sigue mostrándolas).
  • Con plan (TodoWrite, Tasks o 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).
  • Sin plan, muestra la acción actual y el número de pasos (no hay porcentaje que calcular).
  • Al terminar, resumen breve: archivos modificados, comandos y errores.
  • Sección de contexto (como context-bar): tokens usados de la ventana, umbral de autocompactado, porcentaje, barra apilada por categoría y leyenda. Se refresca al acabar cada turno y tras /compact.
  • Cada sección se pliega a una sola línea con 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.
  • Se activa y desactiva con 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.

openspec-tracker

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.
  • Detalle de un cambio: artefactos (✓ hecho, ○ pendiente), tareas de tasks.md por sección y validación con openspec validate.
  • Botones 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.
  • Línea de estado con el cambio en curso, detectado por lo que Claude edita o ejecuta: ◐ OpenSpec 05-update-member-screens-ux · 7/15 · implementación.
  • Aviso cuando un cambio completa todas sus tareas.

Requiere la CLI openspec (v1.x) instalada. Solo se activa en proyectos con carpeta openspec/.

Source 3 files
hooks/register.tsx 585 lines
1// 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}
585
hooks/context.ts 85 lines
1// 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}
85
types/index.d.ts 41 lines
1export 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