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

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 369 lines1import { 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}
369types/index.d.ts 24 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 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