SLOPSHOPPER

clean-view

Vista limpia: oculta las llamadas a herramientas y muestra pasos, progreso y un resumen final.

newbandspinnerrowsguardcommand
v0.1.0no licenseupdated 2026-10-05Dos2Locos/claude-code-mods/plugins/clean-view
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clean-view
› 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 View · Listo en 42s · 3 archivos modificados (auth.ts, audit.ts, cache.ts) · 4 comandos · 1 error › /clean-view ⎿ clean-view: Clean View desactivado. Clean View desactivado · 0: Activar Clean View ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Clean View desactivado · 0: Activar Clean View ⟨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 2 files
hooks/register.tsx 369 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Run, Step, StepStatus } from '../types'
5
6const isOn = atom({ plugin: 'clean-view', key: 'isOn' } as const, true)
7const run = atom({ plugin: 'clean-view', key: 'run' } as const, null)
8
9const STORE_KEY = 'isOn'
10/** Un dígito: en la banda también funciona escribiéndolo solo en el prompt vacío. */
11const TOGGLE_HOTKEY = '0'
12const MODE_LABEL = 'clean view'
13const PLAN_TOOLS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'])
14const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
15
16const ICON: Record<StepStatus, string> = { pending: '○', running: '◐', done: '✓', error: '✗' }
17const COLOR: Record<StepStatus, string | undefined> = {
18  pending: undefined,
19  running: 'yellow',
20  done: 'green',
21  error: 'red',
22}
23
24const emptyRun = (startedAt: number): Run => ({
25  isWorking: true,
26  startedAt,
27  durationMs: 0,
28  steps: [],
29  plan: [],
30  changedFiles: [],
31  commands: 0,
32  errors: 0,
33  isAborted: false,
34})
35
36function field(e: object, name: string): string | undefined {
37  const value = (e as Record<string, unknown>)[name]
38  return typeof value === 'string' && value !== '' ? value : undefined
39}
40
41function basename(path: string): string {
42  return path.split('/').filter(Boolean).pop() ?? path
43}
44
45function clip(text: string, max = 48): string {
46  const line = text.replace(/\s+/g, ' ').trim()
47  return line.length > max ? `${line.slice(0, max - 1)}…` : line
48}
49
50/** Una frase legible para cada llamada, en lugar de `Bash(...)`. */
51export function describeCall(tool: string, e: object): string {
52  const file = field(e, 'file_path') ?? field(e, 'notebook_path')
53  switch (tool) {
54    case 'Read':
55      return `Leer ${file ? basename(file) : 'archivo'}`
56    case 'Edit':
57    case 'MultiEdit':
58    case 'NotebookEdit':
59      return `Editar ${file ? basename(file) : 'archivo'}`
60    case 'Write':
61      return `Escribir ${file ? basename(file) : 'archivo'}`
62    case 'Bash': {
63      const what = field(e, 'description') ?? field(e, 'command')
64      return what ? `Ejecutar: ${clip(what)}` : 'Ejecutar comando'
65    }
66    case 'Grep':
67    case 'Glob': {
68      const pattern = field(e, 'pattern')
69      return pattern ? `Buscar «${clip(pattern, 32)}»` : 'Buscar en el código'
70    }
71    case 'WebFetch': {
72      const url = field(e, 'url')
73      return `Consultar ${url ? clip(url.replace(/^https?:\/\//, '').split('/')[0] ?? url, 32) : 'página web'}`
74    }
75    case 'WebSearch':
76      return `Buscar en la web: ${clip(field(e, 'query') ?? '', 36)}`
77    case 'Agent':
78    case 'Task':
79      return `Delegar: ${clip(field(e, 'description') ?? 'subtarea', 40)}`
80    case 'Skill':
81      return `Usar habilidad ${field(e, 'skill') ?? ''}`.trim()
82    default: {
83      const short = tool.startsWith('mcp__') ? tool.split('__').slice(2).join('__') : tool
84      return `Usar ${short}`
85    }
86  }
87}
88
89function todoStatus(status: string): StepStatus {
90  return status === 'completed' ? 'done' : status === 'in_progress' ? 'running' : 'pending'
91}
92
93function progressBar(done: number, total: number, width: number): string {
94  const filled = total === 0 ? 0 : Math.round((done / total) * width)
95  return '█'.repeat(filled) + '░'.repeat(width - filled)
96}
97
98function formatDuration(ms: number): string {
99  const seconds = Math.round(ms / 1000)
100  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`
101}
102
103/** El resumen breve que se muestra al terminar. */
104export function summarize(r: Run): string {
105  const parts: string[] = []
106  const files = r.changedFiles
107  if (files.length > 0) {
108    const names = files.slice(0, 4).map(basename).join(', ')
109    const more = files.length > 4 ? ` y ${files.length - 4} más` : ''
110    parts.push(`${files.length} ${files.length === 1 ? 'archivo modificado' : 'archivos modificados'} (${names}${more})`)
111  } else {
112    parts.push('sin cambios en archivos')
113  }
114  if (r.commands > 0) parts.push(`${r.commands} ${r.commands === 1 ? 'comando' : 'comandos'}`)
115  if (r.errors > 0) parts.push(`${r.errors} ${r.errors === 1 ? 'error' : 'errores'}`)
116  const head = r.isAborted ? 'Interrumpido' : 'Listo'
117  return `${head} en ${formatDuration(r.durationMs)} · ${parts.join(' · ')}`
118}
119
120async function setOn($: EngineInterface, value: boolean) {
121  await update($, isOn, () => value)
122  await $.store.set(STORE_KEY, value)
123}
124
125async function shouldHide($: EngineInterface, props: object): Promise<boolean> {
126  const isExpanded = (props as { isExpanded?: boolean }).isExpanded === true
127  return !isExpanded && (await read($, isOn))
128}
129
130async function trackPlan($: EngineInterface, tool: string, e: object, ran: { result?: unknown }) {
131  if (tool === 'TodoWrite') {
132    const todos = (e as { todos?: { content: string; status: string }[] }).todos ?? []
133    const plan = todos.map((t, i) => ({ id: String(i), label: t.content, status: todoStatus(t.status) }))
134    await update($, run, (r: Run | null) => (r ? { ...r, plan } : r))
135  } else if (tool === 'TaskCreate') {
136    const id = (ran.result as { task?: { id?: string } } | undefined)?.task?.id
137    const subject = field(e, 'subject')
138    if (id && subject) {
139      await update($, run, (r: Run | null) =>
140        r ? { ...r, plan: [...r.plan, { id, label: subject, status: 'pending' as StepStatus }] } : r,
141      )
142    }
143  } else if (tool === 'TaskUpdate') {
144    const id = field(e, 'taskId')
145    const status = field(e, 'status')
146    const subject = field(e, 'subject')
147    await update($, run, (r: Run | null) => {
148      if (!r || !id) return r
149      const plan =
150        status === 'deleted'
151          ? r.plan.filter(s => s.id !== id)
152          : r.plan.map(s =>
153              s.id === id
154                ? { ...s, label: subject ?? s.label, status: status ? todoStatus(status) : s.status }
155                : s,
156            )
157      return { ...r, plan }
158    })
159  }
160}
161
162export const register: Register = on => {
163  on('session.start', async ($, e, next) => {
164    const stored = await $.store.get(STORE_KEY)
165    if (typeof stored === 'boolean') await update($, isOn, () => stored)
166    await $.command.register({
167      name: 'clean-view',
168      description: 'Activa o desactiva Clean View (on | off | sin argumento para alternar)',
169      immediate: true,
170    })
171    return next(e)
172  })
173
174  on('command.run', { command: 'clean-view' }, async ($, e) => {
175    const arg = String((e as { args?: unknown }).args ?? '').trim().toLowerCase()
176    const current = await read($, isOn)
177    const value = arg === 'on' ? true : arg === 'off' ? false : !current
178    await setOn($, value)
179    return { text: `Clean View ${value ? 'activado' : 'desactivado'}.` }
180  })
181
182  // Cada turno del bucle principal empieza una ejecución nueva.
183  on('turn.start', async ($, e, next) => {
184    const now = await $.clock.now()
185    await update($, run, () => emptyRun(now))
186    return next(e)
187  })
188
189  on('tool.call', async ($, e, next) => {
190    if (e.agentId !== undefined) return next(e)
191
192    const tool = String(e.tool)
193    if (PLAN_TOOLS.has(tool)) {
194      const ran = await next(e)
195      await trackPlan($, tool, e, ran)
196      return ran
197    }
198
199    const step: Step = { id: e.tool_use_id, label: describeCall(tool, e), status: 'running' }
200    await update($, run, r => (r ? { ...r, steps: [...r.steps, step] } : r))
201
202    const ran = await next(e)
203    const isFailed = ran.deny !== undefined || (ran as { isError?: boolean }).isError === true
204    const file = field(e, 'file_path') ?? field(e, 'notebook_path')
205
206    await update($, run, r => {
207      if (!r) return r
208      const steps = r.steps.map(s => (s.id === step.id ? { ...s, status: isFailed ? 'error' : 'done' } as Step : s))
209      const changedFiles =
210        !isFailed && EDIT_TOOLS.has(tool) && file && !r.changedFiles.includes(file)
211          ? [...r.changedFiles, file]
212          : r.changedFiles
213      return {
214        ...r,
215        steps,
216        changedFiles,
217        commands: r.commands + (tool === 'Bash' ? 1 : 0),
218        errors: r.errors + (isFailed ? 1 : 0),
219      }
220    })
221    return ran
222  })
223
224  on('turn.complete', async ($, e, next) => {
225    if (e.agentId !== undefined) return next(e)
226    const now = await $.clock.now()
227    let finished: Run | null = null
228    await update($, run, r => {
229      if (!r) return r
230      finished = { ...r, isWorking: false, durationMs: e.durationMs || now - r.startedAt, isAborted: e.isAborted }
231      return finished
232    })
233    const r = finished as Run | null
234    const result = await next(e)
235    // Un `text` distinto de la respuesta se muestra como una línea bajo ella.
236    if (r && r.steps.length > 0 && (await read($, isOn))) {
237      return { ...result, text: `Clean View · ${summarize(r)}` }
238    }
239    return result
240  })
241
242  // Oculta las filas técnicas mientras el modo está activo (ctrl+o sigue mostrando todo).
243  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
244    if (!(await shouldHide($, e.props))) return next(e)
245    const { Box } = $.ui.resolve(e)
246    return <Box />
247  })
248  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
249    if (!(await shouldHide($, e.props))) return next(e)
250    const { Box } = $.ui.resolve(e)
251    return <Box />
252  })
253  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
254    if (!(await shouldHide($, e.props))) return next(e)
255    const { Box } = $.ui.resolve(e)
256    return <Box />
257  })
258  on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
259    if (!(await shouldHide($, e.props))) return next(e)
260    const { Box } = $.ui.resolve(e)
261    return <Box />
262  })
263
264  // Progreso junto al spinner: «Pensando · paso 3/5…»
265  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
266    if (!(await read($, isOn))) return next(e)
267    const r = await read($, run)
268    const { done, total } = countProgress(visibleSteps(r))
269    if (!r?.isWorking || total === 0) return next(e)
270    const current = Math.min(done + 1, total)
271    return next({ ...e, props: { ...e.props, suffix: ` · paso ${current}/${total}…` } })
272  })
273
274  // Etiqueta en el pie del prompt mientras el modo está activo.
275  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
276    if (!(await read($, isOn))) return next(e)
277    return next({ ...e, props: { ...e.props, modes: [...e.props.modes, MODE_LABEL] } })
278  })
279
280  // La barra: interruptor, pasos con su estado, progreso y resumen.
281  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
282    if (e.props.hasSurvey || e.props.view.agentId !== undefined) return next(e)
283    const { Box, Text, Button } = $.ui.resolve(e)
284    const active = await read($, isOn)
285    // La banda es de todos los mods: lo que dibujen los demás va debajo del nuestro.
286    const theirs = await next(e)
287
288    const toggle = (
289      <Button
290        key="toggle"
291        hotkey={TOGGLE_HOTKEY}
292        plain
293        label={active ? 'Desactivar' : 'Activar Clean View'}
294        onPress={() => setOn($, !active)}
295      />
296    )
297
298    if (!active) {
299      return (
300        <Box flexDirection="column">
301          <Box>
302            <Text dimColor>Clean View desactivado · </Text>
303            {toggle}
304          </Box>
305          {theirs}
306        </Box>
307      )
308    }
309
310    const r = await read($, run)
311    const steps = visibleSteps(r)
312    const { done, total } = countProgress(steps)
313    const width = Math.max(10, Math.min(30, e.props.bodyColumns - 40))
314    const room = Math.max(1, Math.min(8, e.props.maxRows - 3))
315    const shown = r?.isWorking ? windowAroundCurrent(steps, room) : steps.slice(-room)
316
317    return (
318      <Box flexDirection="column">
319        <Box>
320          <Text bold color="cyan">Clean View </Text>
321          {r && total > 0 && (
322            <Text>
323              <Text color={r.isWorking ? 'yellow' : 'green'}>{progressBar(done, total, width)}</Text>
324              <Text dimColor> {done}/{total} </Text>
325            </Text>
326          )}
327          {!r && <Text dimColor>esperando una tarea · </Text>}
328          {r && <Text dimColor>· </Text>}
329          {toggle}
330        </Box>
331        {shown.length < steps.length && (
332          <Text dimColor>  … {steps.length - shown.length} pasos más</Text>
333        )}
334        {shown.map(s => (
335          <Text wrap="truncate-end">
336            <Text color={COLOR[s.status]} dimColor={s.status === 'pending'}>  {ICON[s.status]} </Text>
337            <Text dimColor={s.status === 'done'}>{s.label}</Text>
338          </Text>
339        ))}
340        {r && !r.isWorking && (
341          <Text color={r.errors > 0 || r.isAborted ? 'yellow' : 'green'} wrap="wrap">
342            {summarize(r)}
343          </Text>
344        )}
345        {theirs}
346      </Box>
347    )
348  })
349}
350
351/** El plan del modelo si lo hay; si no, los pasos de las llamadas. */
352function visibleSteps(r: Run | null): Step[] {
353  return r ? (r.plan.length > 0 ? r.plan : r.steps) : []
354}
355
356function countProgress(steps: Step[]): { done: number; total: number } {
357  const done = steps.filter(s => s.status === 'done' || s.status === 'error').length
358  return { done, total: steps.length }
359}
360
361/** Mientras trabaja, muestra la ventana de pasos que incluye el actual. */
362function windowAroundCurrent(steps: Step[], room: number): Step[] {
363  if (steps.length <= room) return steps
364  const current = steps.findIndex(s => s.status === 'running')
365  const anchor = current === -1 ? steps.length - 1 : current
366  const start = Math.max(0, Math.min(anchor - Math.floor(room / 2), steps.length - room))
367  return steps.slice(start, start + room)
368}
369
types/index.d.ts 24 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  startedAt: number
8  durationMs: number
9  /** Pasos derivados de las llamadas a herramientas. */
10  steps: Step[]
11  /** El plan del modelo (TodoWrite / TaskCreate), cuando lo hay. */
12  plan: Step[]
13  changedFiles: string[]
14  commands: number
15  errors: number
16  isAborted: boolean
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    'clean-view': { isOn: boolean; run: Run | null }
22  }
23}
24