SLOPSHOPPER

clean-view

/simple on oculta el detalle técnico de la conversación y muestra los pasos del turno; /simple abre un dashboard interactivo (interruptor, contadores, desglose…

panebandrowsguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clean-view
│ ┃ Vista limpia ✕ › fix the failing auth test and add an audit log call │ ┃ Vista limpia [ Desactivada ] [ Historial ] [ │ ┃ La vista limpia está apagada: la ⏺ Read(src/auth.ts) │ ┃ transcripción muestra el detalle completo. ⎿ Read 6 lines │ ┃ Pulsa v para ocultarlo. ⏺ Update(src/auth.ts) │ ┃ Turno: 9 pasos · 8 ✓ · 0 ● · 1 ✗ · 0s ⎿ Added 2 lines, removed 1 line │ ┃ Lectura ▇▇ 2 ⏺ Bash(bun test) │ ┃ Edición ▇▇▇ 3 ⎿ 3 pass, 1 fail │ ┃ Comandos ▇▇▇▇ 4 │ ┃ Filtro: [ Todos ][ En curso ][ Hechos ][ Err ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /simple │ ┃ ⎿ clean-view: Dashboard de Vista limpia abierto. Teclas: v interru │ ┃ │ ┃ │ ┃ │ ┃ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Vista limpia
Vista limpia [ Desactivada ] [ Historial ] [ Limpiar ] [ Cer La vista limpia está apagada: la transcripción muestra el detalle completo. Pulsa v para ocultarlo. Turno: 9 pasos · 8 ✓ · 0 ● · 1 ✗ · 0s Lectura ▇▇ 2 Edición ▇▇▇ 3 Comandos ▇▇▇▇ 4 Filtro: [ Todos ][ En curso ][ Hechos ][ Errores ]
README

clean-view

Un mod de Claude Code: /simple on oculta el detalle técnico de la conversación y muestra los pasos del turno; /simple abre un dashboard interactivo; /simple off lo restaura.

Vista limpia

  • Filas de herramientas: cada llamada se dibuja como una línea tenue en lenguaje llano («Leyendo src/app.ts», «Ejecutando: instalar dependencias», «Buscando "TODO"»).
  • Resultados: ocultos; una línea dice que lo están. Un resultado con error lo avisa.
  • Grupos de lecturas: una sola línea con el conteo.
  • Banda encima del prompt: los últimos pasos con ● en curso, ✓ hecho y ✗ error, el contador «n/m pasos» y el botón Panel (tecla p) que abre el dashboard.

Dashboard interactivo (/simple)

Un panel lateral (en la terminal necesita /tui fullscreen y 110 columnas; en la app de escritorio se acopla solo) con:

ZonaQué haceTecla
InterruptorEnciende o apaga la vista limpia sin salir del panelv
Resumen del turnoPasos totales, hechos, en curso, con error y duración del turno
DesgloseBarras por tipo: lectura, edición, comandos, web, agentes, plan
FiltrosTodos · En curso · Hechos · Errores1 2 3 4
Lista de pasosCada paso es un botón; al pulsarlo se abre su detalleclic o Tab + Enter
DetalleHerramienta, tipo, estado, duración, ruta o comando completo y el texto del error
HistorialLos últimos 12 turnos: pasos, aciertos, errores, duración y mezcla de tiposh
Limpiar / CerrarVacía la lista del turno / cierra el panell c

Las teclas funcionan cuando el panel tiene el foco (/simple lo abre enfocado; ctrl+x tab lo enfoca después).

Nada se pierde: la transcripción guarda todo y el modelo ve lo mismo de siempre. El mod no llama al modelo ni consume tokens.

Instalar

/plugin install clean-view --marketplace alvaroruizr2024-cpu/www.innovaqsolution.com

O desde la terminal: claude plugin install clean-view@innovaq-mods tras claude plugin marketplace add alvaroruizr2024-cpu/www.innovaqsolution.com.

Cómo funciona

hooks/register.tsx engancha tool.call para registrar cada paso (tipo, etiqueta, detalle, tiempos y error al resolverse), turn.start para vaciar la lista, turn.complete para guardar el resumen del turno en el historial, command.run para /simple, y ui.render sobre ToolUse, ToolResult, ToolGroup, AbovePrompt y Pane para dibujar la vista limpia y el dashboard. El estado vive en $.state bajo clean-view, declarado en types/index.d.ts.

Desarrollar

claude plugin validate .
claude plugin test .
claude --plugin-dir .
Source 2 files
hooks/register.tsx 446 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Filter, Step, StepKind, TurnSummary } from '../types'
5
6// Clean View: `/simple on` oculta el detalle técnico de la conversación y
7// muestra los pasos del turno; `/simple` abre el dashboard interactivo, un
8// panel con el interruptor, contadores, desglose por tipo, filtros, la lista
9// de pasos con su detalle y el historial de turnos. `/simple off` lo quita.
10// Toda la información sigue en la transcripción: solo cambia lo que se dibuja.
11
12const PANE = 'clean-view'
13const MAX_STEPS = 80
14const MAX_HISTORY = 12
15const BAR_MAX = 10
16
17const isOn = atom({ plugin: 'clean-view', key: 'isOn' } as const, false)
18const steps = atom({ plugin: 'clean-view', key: 'steps' } as const, [])
19const filter = atom({ plugin: 'clean-view', key: 'filter' } as const, 'all')
20const selectedId = atom({ plugin: 'clean-view', key: 'selectedId' } as const, null)
21const history = atom({ plugin: 'clean-view', key: 'history' } as const, [])
22const showHistory = atom({ plugin: 'clean-view', key: 'showHistory' } as const, false)
23const turnStartedAt = atom({ plugin: 'clean-view', key: 'turnStartedAt' } as const, 0)
24
25const GLYPH: Record<Step['status'], string> = { running: '●', done: '✓', error: '✗' }
26const KINDS: StepKind[] = ['read', 'edit', 'shell', 'web', 'agent', 'plan', 'other']
27const KIND_NAME: Record<StepKind, string> = {
28  read: 'Lectura',
29  edit: 'Edición',
30  shell: 'Comandos',
31  web: 'Web',
32  agent: 'Agentes',
33  plan: 'Plan',
34  other: 'Otros',
35}
36const FILTERS: Array<{ id: Filter; name: string; hotkey: string }> = [
37  { id: 'all', name: 'Todos', hotkey: '1' },
38  { id: 'running', name: 'En curso', hotkey: '2' },
39  { id: 'done', name: 'Hechos', hotkey: '3' },
40  { id: 'error', name: 'Errores', hotkey: '4' },
41]
42
43function short(path: string): string {
44  const parts = path.split('/')
45  return parts.length > 2 ? parts.slice(-2).join('/') : path
46}
47
48function str(value: unknown, max = 48): string {
49  const text = typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''
50  return text.length > max ? `${text.slice(0, max - 1)}…` : text
51}
52
53function elapsed(ms: number): string {
54  const seconds = Math.max(0, Math.round(ms / 1000))
55  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`
56}
57
58function bar(count: number): string {
59  return count === 0 ? '·' : '▇'.repeat(Math.min(count, BAR_MAX)) + (count > BAR_MAX ? '+' : '')
60}
61
62function kindOf(tool: string): StepKind {
63  switch (tool) {
64    case 'Read':
65    case 'Grep':
66    case 'Glob':
67    case 'NotebookRead':
68      return 'read'
69    case 'Edit':
70    case 'MultiEdit':
71    case 'Write':
72    case 'NotebookEdit':
73      return 'edit'
74    case 'Bash':
75      return 'shell'
76    case 'WebFetch':
77    case 'WebSearch':
78      return 'web'
79    case 'Agent':
80    case 'Workflow':
81      return 'agent'
82    case 'TaskCreate':
83    case 'TaskUpdate':
84    case 'TaskList':
85    case 'TaskGet':
86      return 'plan'
87    default:
88      return 'other'
89  }
90}
91
92// Una etiqueta en lenguaje llano y el detalle (ruta, comando, patrón) de cada llamada.
93function describe(tool: string, input: Record<string, unknown>): { label: string; detail: string } {
94  const path = typeof input.file_path === 'string' ? input.file_path : ''
95  const name = path ? short(path) : ''
96  switch (tool) {
97    case 'Read':
98      return { label: `Leyendo ${name || 'un archivo'}`, detail: path }
99    case 'Edit':
100    case 'MultiEdit':
101      return { label: `Editando ${name || 'un archivo'}`, detail: path }
102    case 'Write':
103      return { label: `Escribiendo ${name || 'un archivo'}`, detail: path }
104    case 'Bash':
105      return {
106        label: `Ejecutando: ${str(input.description) || str(input.command, 40) || 'un comando'}`,
107        detail: str(input.command, 160),
108      }
109    case 'Grep':
110      return { label: `Buscando "${str(input.pattern, 30)}"`, detail: str(input.pattern, 120) }
111    case 'Glob':
112      return { label: `Listando ${str(input.pattern, 30) || 'archivos'}`, detail: str(input.pattern, 120) }
113    case 'WebFetch':
114      return { label: `Consultando ${str(input.url, 40)}`, detail: str(input.url, 160) }
115    case 'WebSearch':
116      return { label: `Buscando en la web: ${str(input.query, 36)}`, detail: str(input.query, 160) }
117    case 'Agent':
118      return { label: `Delegando: ${str(input.description, 40) || 'un subagente'}`, detail: str(input.prompt, 160) }
119    case 'TaskCreate':
120      return { label: `Planificando: ${str(input.subject, 40)}`, detail: str(input.description, 160) }
121    case 'TaskUpdate':
122      return { label: 'Actualizando el plan', detail: str(input.status, 40) }
123    case 'AskUserQuestion':
124      return { label: 'Preguntándote algo', detail: '' }
125    default: {
126      const label = tool.startsWith('mcp__') ? `Usando ${tool.split('__').slice(1).join(' / ')}` : `Usando ${tool}`
127      return { label, detail: str(JSON.stringify(input), 160) }
128    }
129  }
130}
131
132function countKinds(list: Step[]): Record<StepKind, number> {
133  const counts = { read: 0, edit: 0, shell: 0, web: 0, agent: 0, plan: 0, other: 0 }
134  for (const step of list) {
135    counts[step.kind] += 1
136  }
137
138  return counts
139}
140
141export const register: Register = on => {
142  on('session.start', async ($, e, next) => {
143    await $.command.register({
144      name: 'simple',
145      description: 'Vista limpia: on | off, o sin argumento abre el dashboard de pasos',
146      argumentHint: 'on|off|panel',
147      immediate: true,
148    })
149
150    return next(e)
151  })
152
153  on('command.run', { command: 'simple' }, async ($, e) => {
154    const arg = e.args.trim().toLowerCase()
155    if (arg === 'on' || arg === 'off') {
156      const next = arg === 'on'
157      await update($, isOn, () => next)
158      if (next) {
159        void $.ui.open({ id: PANE, title: 'Vista limpia' })
160      }
161      $.ui.invalidate('ui.render')
162
163      return {
164        text: next
165          ? 'Clean View activado: el detalle técnico queda oculto y los pasos se muestran en el panel y encima del prompt. `/simple off` lo restaura.'
166          : 'Clean View desactivado: vuelven a verse las herramientas y sus resultados. `/simple` abre el dashboard.',
167      }
168    }
169    const opened = await $.ui.open({ id: PANE, title: 'Vista limpia', focus: true })
170
171    return {
172      text: opened.isPlaced
173        ? 'Dashboard de Vista limpia abierto. Teclas: v interruptor, 1-4 filtros, h historial, l limpiar, c cerrar.'
174        : `El dashboard espera una terminal más ancha o la vista a pantalla completa (/tui fullscreen): ${opened.reason}`,
175    }
176  })
177
178  // Cada turno del hilo principal empieza con la lista en blanco.
179  on('turn.start', async ($, e, next) => {
180    if (e.agentId === undefined) {
181      await update($, steps, () => [])
182      await update($, selectedId, () => null)
183      await update($, turnStartedAt, () => Date.now())
184    }
185
186    return next(e)
187  })
188
189  // Al cerrar el turno principal, su resumen pasa al historial.
190  on('turn.complete', async ($, e, next) => {
191    if (e.agentId === undefined) {
192      const list = await read($, steps)
193      if (list.length > 0) {
194        const startedAt = await read($, turnStartedAt)
195        const endedAt = Date.now()
196        const summary: TurnSummary = {
197          endedAt,
198          durationMs: startedAt > 0 ? endedAt - startedAt : 0,
199          total: list.length,
200          done: list.filter(step => step.status === 'done').length,
201          errors: list.filter(step => step.status === 'error').length,
202          kinds: countKinds(list),
203        }
204        await update($, history, past => [...past, summary].slice(-MAX_HISTORY))
205      }
206    }
207
208    return next(e)
209  })
210
211  // Cada llamada a herramienta es un paso: corriendo, hecho o con error.
212  on('tool.call', async ($, e, next) => {
213    if (e.agentId !== undefined) {
214      return next(e) // los pasos de un subagente no se listan
215    }
216    const { label, detail } = describe(e.tool, e as unknown as Record<string, unknown>)
217    const step: Step = {
218      id: e.tool_use_id,
219      tool: e.tool,
220      kind: kindOf(e.tool),
221      label,
222      detail,
223      status: 'running',
224      startedAt: Date.now(),
225    }
226    await update($, steps, list => [...list, step].slice(-MAX_STEPS))
227
228    const ran = await next(e)
229    const failed = ran.deny !== undefined || ran.isError === true
230    const error = ran.deny ?? (failed ? str(ran.text, 200) || 'La herramienta devolvió un error.' : undefined)
231    const endedAt = Date.now()
232    await update($, steps, list =>
233      list.map(one => (one.id === step.id ? { ...one, status: failed ? 'error' : 'done', endedAt, error } : one)),
234    )
235
236    return ran
237  })
238
239  // Con la vista limpia, cada fila de herramienta es una línea tenue.
240  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
241    if (!(await read($, isOn))) {
242      return next(e)
243    }
244    const { Text } = $.ui.resolve(e)
245    const input = (e.props.input ?? {}) as Record<string, unknown>
246    const mark = e.props.isRunning ? '●' : e.props.isErrored ? '✗' : '✓'
247
248    return (
249      <Text dimColor>
250        {mark} {describe(e.props.tool, input).label}
251      </Text>
252    )
253  })
254
255  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
256    if (!(await read($, isOn))) {
257      return next(e)
258    }
259    const { Text } = $.ui.resolve(e)
260
261    return <Text dimColor>{e.props.isErrored ? '  resultado con error (/simple off para verlo)' : '  resultado oculto'}</Text>
262  })
263
264  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
265    if (!(await read($, isOn))) {
266      return next(e)
267    }
268    const { Text } = $.ui.resolve(e)
269    const total = e.props.calls.length
270    const running = e.props.calls.filter(call => call.isRunning).length
271
272    return (
273      <Text dimColor>
274        {running > 0 ? '●' : '✓'} {total} {total === 1 ? 'paso' : 'pasos'} de lectura
275        {running > 0 ? ` (${running} en curso)` : ''}
276      </Text>
277    )
278  })
279
280  // La banda encima del prompt: los últimos pasos y un botón al dashboard.
281  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
282    const list = await read($, steps)
283    if (!(await read($, isOn)) || e.props.hasSurvey || list.length === 0) {
284      return next(e)
285    }
286    const { Box, Button, Text } = $.ui.resolve(e)
287    const done = list.filter(step => step.status === 'done').length
288    const errors = list.filter(step => step.status === 'error').length
289    const room = Math.max(1, e.props.maxRows - 1)
290    const shown = list.slice(-room)
291
292    return (
293      <Box flexDirection="column">
294        <Box>
295          <Text>
296            Vista limpia · {done}/{list.length} pasos
297            {errors > 0 ? ` · ${errors} con error` : ''}
298            {e.props.isWorking ? ' · trabajando…' : ' · listo'}{' '}
299          </Text>
300          <Button key="panel" label="Panel" hotkey="p" onPress={() => $.ui.open({ id: PANE, title: 'Vista limpia', focus: true })} />
301        </Box>
302        {shown.map(step => (
303          <Text key={step.id} dimColor={step.status === 'done'}>
304            {GLYPH[step.status]} {step.label}
305          </Text>
306        ))}
307      </Box>
308    )
309  })
310
311  // El dashboard.
312  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
313    const { Box, Button, Text } = $.ui.resolve(e)
314    const enabled = await read($, isOn)
315    const list = await read($, steps)
316    const active = await read($, filter)
317    const selected = await read($, selectedId)
318    const past = await read($, history)
319    const inHistory = await read($, showHistory)
320    const startedAt = await read($, turnStartedAt)
321    const now = Date.now()
322
323    const running = list.filter(step => step.status === 'running').length
324    const done = list.filter(step => step.status === 'done').length
325    const errors = list.filter(step => step.status === 'error').length
326    const kinds = countKinds(list)
327    const visible = active === 'all' ? list : list.filter(step => step.status === active)
328    const current = list.find(step => step.id === selected)
329    const turnMs = startedAt > 0 ? (running > 0 ? now - startedAt : Math.max(...list.map(s => s.endedAt ?? now), startedAt) - startedAt) : 0
330
331    const toggle = async () => {
332      await update($, isOn, value => !value)
333      $.ui.invalidate('ui.render')
334    }
335
336    return (
337      <Box flexDirection="column">
338        <Box>
339          <Text weight="bold">Vista limpia </Text>
340          <Button key="toggle" label={enabled ? 'Activada' : 'Desactivada'} hotkey="v" variant={enabled ? 'primary' : 'secondary'} onPress={toggle} />
341          <Text> </Text>
342          <Button
343            key="history"
344            label={inHistory ? 'Turno actual' : 'Historial'}
345            hotkey="h"
346            onPress={async () => {
347              await update($, showHistory, value => !value)
348              $.ui.invalidate('ui.render')
349            }}
350          />
351          <Text> </Text>
352          <Button
353            key="clear"
354            label="Limpiar"
355            hotkey="l"
356            onPress={async () => {
357              await update($, steps, () => [])
358              await update($, selectedId, () => null)
359              $.ui.invalidate('ui.render')
360            }}
361          />
362          <Text> </Text>
363          <Button key="close" label="Cerrar" hotkey="c" onPress={() => $.ui.close({ id: PANE })} />
364        </Box>
365        {!enabled && <Text dimColor>La vista limpia está apagada: la transcripción muestra el detalle completo. Pulsa v para ocultarlo.</Text>}
366
367        {inHistory ? (
368          <Box flexDirection="column">
369            <Text>Últimos turnos</Text>
370            {past.length === 0 && <Text dimColor>Aún no hay turnos cerrados en esta sesión.</Text>}
371            {[...past].reverse().map((turn, i) => (
372              <Text key={`turn-${turn.endedAt}`} dimColor={i > 0}>
373                {GLYPH[turn.errors > 0 ? 'error' : 'done']} hace {elapsed(now - turn.endedAt)} · {turn.total} pasos · {turn.done} ✓
374                {turn.errors > 0 ? ` · ${turn.errors} ✗` : ''} · {elapsed(turn.durationMs)} ·{' '}
375                {KINDS.filter(kind => turn.kinds[kind] > 0)
376                  .map(kind => `${KIND_NAME[kind].toLowerCase()} ${turn.kinds[kind]}`)
377                  .join(', ')}
378              </Text>
379            ))}
380          </Box>
381        ) : (
382          <Box flexDirection="column">
383            <Text>
384              Turno: {list.length} pasos · {done} ✓ · {running} ● · {errors} ✗ · {elapsed(turnMs)}
385              {running > 0 ? ' · trabajando…' : ''}
386            </Text>
387            <Box flexDirection="column">
388              {KINDS.filter(kind => kinds[kind] > 0 || kind === 'read' || kind === 'edit' || kind === 'shell').map(kind => (
389                <Text key={`kind-${kind}`} dimColor={kinds[kind] === 0}>
390                  {KIND_NAME[kind].padEnd(9)} {bar(kinds[kind])} {kinds[kind]}
391                </Text>
392              ))}
393            </Box>
394            <Box>
395              <Text dimColor>Filtro: </Text>
396              {FILTERS.map(option => (
397                <Button
398                  key={`filter-${option.id}`}
399                  label={option.name}
400                  hotkey={option.hotkey}
401                  variant={active === option.id ? 'primary' : 'secondary'}
402                  onPress={async () => {
403                    await update($, filter, () => option.id)
404                    $.ui.invalidate('ui.render')
405                  }}
406                />
407              ))}
408            </Box>
409            {visible.length === 0 && (
410              <Text dimColor>{list.length === 0 ? 'Aún no hay pasos en este turno.' : 'Ningún paso con ese filtro.'}</Text>
411            )}
412            {visible.map(step => (
413              <Button
414                key={`step-${step.id}`}
415                plain
416                dimColor={step.status === 'done' && step.id !== selected}
417                onPress={async () => {
418                  await update($, selectedId, value => (value === step.id ? null : step.id))
419                  $.ui.invalidate('ui.render')
420                }}
421              >
422                {step.id === selected ? '▸ ' : '  '}
423                {GLYPH[step.status]} {step.label}
424                <Text dimColor> {elapsed((step.endedAt ?? now) - step.startedAt)}</Text>
425              </Button>
426            ))}
427            {current !== undefined && (
428              <Box flexDirection="column">
429                <Text> </Text>
430                <Text weight="bold">Detalle</Text>
431                <Text>
432                  {current.tool} · {KIND_NAME[current.kind].toLowerCase()} ·{' '}
433                  {current.status === 'running' ? 'en curso' : current.status === 'done' ? 'hecho' : 'error'} ·{' '}
434                  {elapsed((current.endedAt ?? now) - current.startedAt)}
435                </Text>
436                {current.detail !== '' && <Text dimColor>{current.detail}</Text>}
437                {current.error !== undefined && <Text color="red">{current.error}</Text>}
438              </Box>
439            )}
440          </Box>
441        )}
442      </Box>
443    )
444  })
445}
446
types/index.d.ts 41 lines
1export type StepStatus = 'running' | 'done' | 'error'
2
3export type StepKind = 'read' | 'edit' | 'shell' | 'web' | 'agent' | 'plan' | 'other'
4
5export type Filter = 'all' | 'running' | 'done' | 'error'
6
7export type Step = {
8  id: string
9  tool: string
10  kind: StepKind
11  label: string
12  detail: string
13  status: StepStatus
14  startedAt: number
15  endedAt?: number
16  error?: string
17}
18
19export type TurnSummary = {
20  endedAt: number
21  durationMs: number
22  total: number
23  done: number
24  errors: number
25  kinds: Record<StepKind, number>
26}
27
28declare module 'claude-code' {
29  interface PluginState {
30    'clean-view': {
31      isOn: boolean
32      steps: Step[]
33      filter: Filter
34      selectedId: string | null
35      history: TurnSummary[]
36      showHistory: boolean
37      turnStartedAt: number
38    }
39  }
40}
41