SLOPSHOPPER

clean-view

Hides tool calls and shows a calm checklist above the prompt, in Dark Modern Soft colors

bandrowsguardcommandtoastprompt
v0.2.4no licenseupdated 2026-10-22eduardopicolli/claude-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 ⏺ Read(/work/app/src/auth.ts) ⎿ Denied by clean-view: Clean View: call mcp__clean-view__plan_steps first with the steps of this job (load ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /simple ⎿ clean-view: Clean View desligado: tudo volta a aparecer. clean-view-toggle ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
clean-view-toggle ⟨Claude Code's own drawing⟩
README

claude-mods

Personal Claude Code mods, published as a single plugin marketplace (claude-mods).

PluginDescription
clean-viewHides tool calls and shows a calm checklist above the prompt (plan, current step, progress), in Dark Modern Soft colors. Toggle with `/simple on\off` or the band's button

Install

At the prompt of a Claude Code terminal session:

/plugin install clean-view --marketplace eduardopicolli/claude-mods

Answer y to add the marketplace, then pick the user scope. The plugin is active right away and in every later session.

Or from a local clone:

/plugin install clean-view --marketplace ~/claude-mods

Update

claude plugin update clean-view@claude-mods

then /reload-plugins in the session.

Developing

Add the local clone as a marketplace; plugins with a relative source are read straight from this folder, so an edit only needs /reload-plugins:

claude plugin marketplace add ~/claude-mods

Check and test a plugin:

claude plugin validate plugins/<name>
claude plugin test plugins/<name>

Adding a plugin

  1. Create plugins/<name>/ with .claude-plugin/plugin.json, hooks/hooks.json and hooks/register.tsx.
  2. Add { "name": "<name>", "source": "./plugins/<name>" } to plugins in .claude-plugin/marketplace.json.
  3. Add a row to the table above.
Source 3 files
hooks/register.tsx 8 lines
1import type { Register } from 'claude-code'
2
3import { registerCleanView } from './clean-view'
4
5export const register: Register = on => {
6  registerCleanView(on)
7}
8
hooks/clean-view.tsx 689 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, Timer } from 'claude-code'
3
4import type { Checklist, Phase, Task } from '../types'
5
6// Dark Modern Soft (VS Code)
7const C = {
8  green: '#6AC483',
9  teal: '#4FB0A6',
10  red: '#E4506F',
11  yellow: '#E8BD5F',
12  blue: '#4690D2',
13  link: '#51AED6',
14  cyan: '#68CBE6',
15  text: '#D4D6DD',
16  subtext: '#AEB0B6',
17  overlay: '#84858A',
18  surface: '#3B3C3E',
19  track: '#2E2F33',
20  crust: '#1D1E22',
21}
22
23const PLAN_TOOL = 'mcp__clean-view__plan_steps'
24const REPORT_TOOL = 'mcp__clean-view__report_progress'
25
26// Tools that never wait for a plan: loading the plan tools, the to-do lists
27// that can stand in for a plan, and asking the person.
28const UNGATED = new Set([PLAN_TOOL, REPORT_TOOL, 'ToolSearch', 'TodoWrite', 'TaskCreate', 'TaskUpdate', 'AskUserQuestion'])
29
30// The person has to read the plan to approve it.
31const KEEP_VISIBLE = new Set(['ExitPlanMode'])
32
33const enabled = atom({ plugin: 'clean-view', key: 'cleanViewEnabled' } as const, true)
34const checklist = atom({ plugin: 'clean-view', key: 'checklist' } as const, null)
35const tick = atom({ plugin: 'clean-view', key: 'tick' } as const, 0)
36
37const PLACEHOLDERS = ['Entender seu pedido', 'Planejar os passos']
38const FIRST_TITLE = 'Trabalhando no seu pedido'
39const FALLBACK_NAME = 'Trabalhando nisso'
40const MAX_NAME = 40
41const METER = 10
42const SWEEP = 5
43const LABEL_W = 11
44const FRAME_MS = 250
45const COLLAPSE_MS = 5000
46const FAILS_TO_STUCK = 3
47
48const SAY = {
49  needsOk: 'O Claude precisa do seu OK para continuar',
50  question: 'O Claude tem uma pergunta para você',
51  waiting: 'O Claude está esperando sua resposta',
52  saidNo: 'você recusou uma etapa, então o Claude pausou',
53  keepsFailing: 'uma etapa continua falhando, o Claude está tentando outro caminho',
54  refused: 'o Claude não pôde ajudar com esse pedido',
55  rateLimit: 'você atingiu seu limite de uso, tente de novo daqui a pouco',
56  overloaded: 'os servidores do Claude estão ocupados, tente de novo em um minuto',
57  tooLong: 'a conversa ficou longa demais, digite /compact e tente de novo',
58  network: 'a conexão com a internet caiu',
59  auth: 'sua sessão expirou, digite /login',
60  unknown: 'algo deu errado do lado do Claude, tente de novo',
61}
62
63const GUIDE = `# Clean View
64
65The person sees a simple checklist above the prompt instead of your tool calls. Keep it filled:
66- For every request, even a quick question, first call \`${PLAN_TOOL}\` with every step of the job, 2 to 8 short names in order (load it with ToolSearch if it is deferred). Every other tool is refused until a plan exists.
67- Then call \`${REPORT_TOOL}\` as real progress happens, and with percent 100 the moment a step finishes.
68- If this session has TodoWrite or TaskCreate, your to-do list can be the plan instead.
69- Write every step name in plain words a non-technical person understands, in the language the person writes in. Keep it under 40 characters and start it with a verb, like "Montar a seção de preços".
70- Never put file paths, file names, commands, code or tool names in a step name.`
71
72const GATE = `Clean View: call ${PLAN_TOOL} first with the steps of this job (load it with ToolSearch if it is deferred), then retry.`
73
74const CODE_EXT =
75  'tsx?|jsx?|mjs|cjs|mts|cts|py|rb|go|rs|java|kt|swift|c|cc|cpp|h|hpp|cs|php|sh|zsh|bash|fish|ps1|lua|sql|' +
76  'json|jsonc|ya?ml|toml|ini|env|lock|md|mdx|html?|css|scss|sass|less|vue|svelte|xml|gradle|dart|ex|exs|zig'
77const CODE_FILE = new RegExp(`[\\w.-]*\\.(?:${CODE_EXT})\\b`, 'gi')
78
79/** The one cleaner every name on the checklist passes through. */
80export const cleanName = (raw: unknown): string => {
81  const plain = String(raw ?? '')
82    .replace(/`[^`]*`?/g, ' ')
83    .replace(/\S*[/\\]\S*/g, ' ')
84    .replace(CODE_FILE, ' ')
85    .replace(/\s+/g, ' ')
86    .trim()
87  if (!plain) return FALLBACK_NAME
88  const named = plain[0]!.toUpperCase() + plain.slice(1)
89  if (named.length <= MAX_NAME) return named
90  const head = named.slice(0, MAX_NAME - 1)
91  const space = head.lastIndexOf(' ')
92  return (space > 0 ? head.slice(0, space) : head).replace(/[\s,.;:–-]+$/, '') + '…'
93}
94
95const errorSentence = (kind: string | undefined, text: string): string => {
96  const said = `${kind ?? ''} ${text}`
97  if (/prompt is too long|context|too many tokens|max_output/i.test(said)) return SAY.tooLong
98  if (/rate_limit|billing|usage limit|\b429\b/i.test(said)) return SAY.rateLimit
99  if (/overloaded|server_error|\b5(?:00|02|03|29)\b/i.test(said)) return SAY.overloaded
100  if (/auth|oauth|credential|login|\b401\b|\b403\b|account_on_hold|verification/i.test(said)) return SAY.auth
101  if (/ECONN|ENOTFOUND|ETIMEDOUT|network|fetch failed|socket|offline|connection/i.test(said)) return SAY.network
102  return SAY.unknown
103}
104
105const clock = (ms: number) => {
106  const s = Math.max(0, Math.round(ms / 1000))
107  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
108}
109
110// Linear blend between hex colors along a gradient.
111const blend = (stops: string[], t: number) => {
112  const x = Math.min(0.9999, Math.max(0, t)) * (stops.length - 1)
113  const i = Math.floor(x)
114  const f = x - i
115  const a = stops[i]!
116  const b = stops[i + 1]!
117  const ch = (h: string, k: number) => parseInt(h.slice(1 + k * 2, 3 + k * 2), 16)
118  const mix = [0, 1, 2].map(k => Math.round(ch(a, k) + (ch(b, k) - ch(a, k)) * f))
119  return '#' + mix.map(v => v.toString(16).padStart(2, '0')).join('')
120}
121
122// The meter gradient of each phase, so a paused or failed step stops looking busy.
123const GRAD: Record<Phase, string[]> = {
124  working: [C.blue, C.cyan],
125  needsYou: [C.yellow, blend([C.yellow, '#FFFFFF'], 0.45)],
126  stuck: [C.red, blend([C.red, '#FFFFFF'], 0.4)],
127  stopped: [C.overlay, C.subtext],
128  done: [C.green, C.teal],
129}
130
131let nextId = 0
132const task = (name: string, status: Task['status'] = 'upcoming'): Task => ({
133  id: `step-${++nextId}`,
134  name,
135  status,
136  percent: status === 'done' ? 100 : 0,
137  hasReported: status === 'done',
138})
139
140const done = (t: Task): Task => ({ ...t, status: 'done', percent: 100, hasReported: true })
141
142// With nothing active, the first step not yet done starts.
143const withActive = (tasks: Task[]): Task[] => {
144  if (tasks.some(t => t.status === 'active')) return tasks
145  const k = tasks.findIndex(t => t.status === 'upcoming')
146  return k < 0 ? tasks : tasks.map((t, i) => (i === k ? { ...t, status: 'active', percent: 0, hasReported: false } : t))
147}
148
149const working = (c: Checklist): Checklist => ({
150  ...c,
151  phase: 'working',
152  needsYouReason: null,
153  stuckReason: null,
154  finishedAt: null,
155  isCollapsed: false,
156})
157
158const newJob = (jobId: number, at: number): Checklist => ({
159  jobId,
160  title: FIRST_TITLE,
161  phase: 'working',
162  tasks: withActive(PLACEHOLDERS.map(name => task(name))),
163  hasPlan: false,
164  needsYouReason: null,
165  stuckReason: null,
166  startedAt: at,
167  finishedAt: null,
168  isCollapsed: false,
169})
170
171const ensure = (c: Checklist | null): Checklist => c ?? newJob(1, Date.now())
172
173/** The real steps so far: none while the placeholders are showing. */
174const realTasks = (c: Checklist) => (c.hasPlan ? c.tasks : [])
175
176export const applyPlan = (c: Checklist, names: string[]): Checklist => {
177  const wasDone = new Set(realTasks(c).filter(t => t.status === 'done').map(t => t.name.toLowerCase()))
178  const tasks = names.map(name => task(name, wasDone.has(name.toLowerCase()) ? 'done' : 'upcoming'))
179  return { ...working(c), tasks: withActive(tasks), hasPlan: true }
180}
181
182export const applyReport = (c: Checklist, name: string, percent: number): Checklist => {
183  let tasks = [...realTasks(c)]
184  let k = tasks.findIndex(t => t.name.toLowerCase() === name.toLowerCase())
185  if (k < 0) {
186    // A step the plan did not name goes where the work is now.
187    const at = tasks.findIndex(t => t.status !== 'done')
188    k = at < 0 ? tasks.length : at
189    tasks.splice(k, 0, task(name))
190  }
191  tasks = tasks.map((t, i): Task => {
192    if (i < k) return done(t)
193    if (i === k) return { ...t, status: 'active', percent, hasReported: true }
194    return t.status === 'active' ? { ...t, status: 'upcoming' } : t
195  })
196  if (percent >= 100) {
197    tasks[k] = done(tasks[k]!)
198    tasks = withActive(tasks)
199  }
200  return { ...working(c), tasks, hasPlan: true }
201}
202
203type Todo = { content?: unknown; status?: unknown }
204
205const applyTodos = (c: Checklist, todos: readonly Todo[]): Checklist => {
206  const before = realTasks(c)
207  const tasks = todos.map((todo): Task => {
208    const name = cleanName(todo.content)
209    const was = before.find(t => t.name === name)
210    if (todo.status === 'completed') return { ...done(was ?? task(name)), name }
211    if (todo.status === 'in_progress') {
212      const isSame = was?.status === 'active'
213      return {
214        id: was?.id ?? task(name).id,
215        name,
216        status: 'active',
217        percent: isSame ? was.percent : 0,
218        hasReported: isSame ? was.hasReported : false,
219      }
220    }
221    return { ...(was ?? task(name)), name, status: 'upcoming', percent: 0, hasReported: false }
222  })
223  return { ...working(c), tasks: withActive(tasks), hasPlan: true }
224}
225
226const addTask = (c: Checklist, id: string, name: string): Checklist => {
227  const tasks = [...realTasks(c).filter(t => t.id !== id), { ...task(name), id }]
228  return { ...working(c), tasks: withActive(tasks), hasPlan: true }
229}
230
231const changeTask = (c: Checklist, id: string, status: unknown, subject: unknown): Checklist => {
232  const isRenamed = typeof subject === 'string' && subject.trim() !== ''
233  let tasks = realTasks(c)
234    .filter(t => !(t.id === id && status === 'deleted'))
235    .map((t): Task => {
236      if (t.id !== id) return status === 'in_progress' && t.status === 'active' ? { ...t, status: 'upcoming' } : t
237      const named = isRenamed ? { ...t, name: cleanName(subject) } : t
238      if (status === 'completed') return done(named)
239      if (status === 'in_progress') return { ...named, status: 'active' }
240      if (status === 'pending') return { ...named, status: 'upcoming', percent: 0, hasReported: false }
241      return named
242    })
243  tasks = withActive(tasks)
244  return { ...working(c), tasks, hasPlan: true }
245}
246
247const finish = (c: Checklist, phase: Phase, reason: string | null = null): Checklist => ({
248  ...c,
249  phase,
250  needsYouReason: phase === 'needsYou' ? reason : null,
251  stuckReason: phase === 'stuck' ? reason : null,
252  tasks: phase === 'done' ? c.tasks.map(done) : c.tasks,
253  finishedAt: c.finishedAt ?? Date.now(),
254  isCollapsed: false,
255})
256
257const toText = (text: string) => ({ result: [{ type: 'text' as const, text }] })
258
259let timer: Timer | null = null
260let failStreak = 0
261let apiError: string | null = null
262
263// The frame clock runs only while a job works or waits on the person.
264function syncClock($: EngineInterface, c: Checklist | null) {
265  const isLive = c?.phase === 'working' || c?.phase === 'needsYou'
266  if (isLive && !timer) {
267    timer = $.clock.every(FRAME_MS, () => void update($, tick, n => n + 1))
268  } else if (!isLive && timer) {
269    timer.cancel()
270    timer = null
271  }
272}
273
274async function change($: EngineInterface, fn: (c: Checklist | null) => Checklist | null) {
275  const c = await update($, checklist, fn)
276  syncClock($, c)
277  return c
278}
279
280async function setEnabled($: EngineInterface, want?: boolean) {
281  const v = await update($, enabled, x => want ?? !x)
282  await $.store.set('cleanViewEnabled', v)
283  return v
284}
285
286async function nameJob($: EngineInterface, jobId: number, request: string) {
287  try {
288    const r = await $.model.complete({
289      model: 'haiku',
290      effort: 'low',
291      maxTokens: 40,
292      timeoutMs: 20000,
293      system:
294        'Name the job the request below asks for, in 2 to 6 plain words a non-technical person understands, ' +
295        'starting with a verb, in the language the request is written in (e.g. "Montar a página inicial"). ' +
296        'No file names, paths, code, quotes or final period. Reply with the name only.',
297      prompt: request.slice(0, 4000),
298    })
299    if (!r.isAnswered) return
300    const words = cleanName(r.text.split('\n')[0]!.replace(/["“”'*.]/g, '')).split(' ')
301    const title = words.slice(0, 6).join(' ')
302    await change($, c => (c && c.jobId === jobId ? { ...c, title } : c))
303  } catch {
304    // The placeholder title stays.
305  }
306}
307
308export const registerCleanView = (on: On) => {
309  on('session.start', async ($, e, next) => {
310    const saved = (await $.store.get('cleanViewEnabled')) ?? (await $.store.get('isOn'))
311    if (typeof saved === 'boolean') await update($, enabled, () => saved)
312    await $.command.register({
313      name: 'simple',
314      description: 'Liga ou desliga o Clean View (on, off, ou sem nada para alternar)',
315      argumentHint: 'on|off',
316    })
317    await $.tool.register({
318      name: 'plan_steps',
319      isDeferred: false,
320      description:
321        'Lays out every step of the job up front for the checklist the person watches: 2 to 8 short names in order, ' +
322        'plain words starting with a verb, under 40 characters, no paths, files, commands, code or tool names. The first step starts right away.',
323      inputSchema: {
324        type: 'object',
325        properties: {
326          steps: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 8, description: 'The step names, in order' },
327        },
328        required: ['steps'],
329      },
330    })
331    await $.tool.register({
332      name: 'report_progress',
333      isDeferred: false,
334      description:
335        'Reports progress on a step of the checklist. Every step before it is checked off; at 100 it is checked off and the next one starts. ' +
336        'A name not in the plan becomes a new step.',
337      inputSchema: {
338        type: 'object',
339        properties: {
340          task: { type: 'string', description: 'The step name, as planned' },
341          percent: { type: 'number', description: 'How far along the step is, 0 to 100' },
342        },
343        required: ['task', 'percent'],
344      },
345    })
346    syncClock($, await read($, checklist))
347    return next(e)
348  })
349
350  on('command.run', { command: 'simple' }, async ($, e) => {
351    const arg = e.args.trim().toLowerCase()
352    const want = ['on', 'ligar', 'ligado'].includes(arg)
353      ? true
354      : ['off', 'desligar', 'desligado'].includes(arg)
355        ? false
356        : undefined
357    if (arg && want === undefined) return { text: 'Use /simple on, /simple off, ou só /simple para alternar.' }
358    const v = await setEnabled($, want)
359    return { text: v ? 'Clean View ligado.' : 'Clean View desligado: tudo volta a aparecer.' }
360  })
361
362  on('prompt.compose', async ($, e, next) => {
363    const r = await next(e)
364    if (!(await read($, enabled))) return r
365    return { sections: [...r.sections, { id: 'clean-view:guide', text: GUIDE, scope: 'session' as const }] }
366  })
367
368  on('turn.start', async ($, e, next) => {
369    const text = e.text.trim()
370    const c = await read($, checklist)
371    const isReply = c?.phase === 'needsYou' && c.hasPlan && c.tasks.some(t => t.status !== 'done')
372    if (text && !text.startsWith('/') && !isReply) {
373      const jobId = (c?.jobId ?? 0) + 1
374      failStreak = 0
375      apiError = null
376      await change($, () => newJob(jobId, Date.now()))
377      if (await read($, enabled)) $.clock.after(1, () => void nameJob($, jobId, text))
378    } else if (c && c.phase !== 'done') {
379      apiError = null
380      await change($, x => x && working(x))
381    }
382    return next(e)
383  })
384
385  on('tool.call', async ($, e, next) => {
386    const tool = String(e.tool)
387    const args = e as unknown as Record<string, unknown>
388
389    if (tool === PLAN_TOOL) {
390      const names = (Array.isArray(args.steps) ? args.steps : []).map(cleanName).slice(0, 8)
391      if (names.length === 0) return { deny: 'plan_steps needs at least one step name.' }
392      if (!e.agentId) await change($, c => applyPlan(ensure(c), names))
393      return toText(`Planned ${names.length} steps. The first one has started.`)
394    }
395    if (tool === REPORT_TOOL) {
396      const percent = Math.round(Math.min(100, Math.max(0, Number(args.percent) || 0)))
397      if (!e.agentId) await change($, c => applyReport(ensure(c), cleanName(args.task), percent))
398      return toText(`Progress noted: ${percent}%.`)
399    }
400    if (e.agentId) return next(e)
401
402    const c = await read($, checklist)
403    if (!UNGATED.has(tool) && !c?.hasPlan && (await read($, enabled))) return { deny: GATE }
404
405    if (tool === 'AskUserQuestion') {
406      await change($, x => x && { ...working(x), phase: 'needsYou', needsYouReason: SAY.question })
407    } else if (c?.phase === 'needsYou') {
408      await change($, x => x && working(x))
409    }
410
411    const r = await next(e)
412    if (r.deny !== undefined) return r
413
414    if (r.isError) {
415      if (/doesn't want to proceed|was rejected|user rejected|user denied/i.test(r.text ?? '')) {
416        failStreak = 0
417        await change($, x => x && { ...working(x), phase: 'stuck', stuckReason: SAY.saidNo })
418      } else if (++failStreak >= FAILS_TO_STUCK) {
419        await change($, x => x && { ...working(x), phase: 'stuck', stuckReason: SAY.keepsFailing })
420      }
421      return r
422    }
423
424    failStreak = 0
425    if (tool === 'TodoWrite' && Array.isArray(args.todos)) {
426      await change($, x => applyTodos(ensure(x), args.todos as Todo[]))
427    } else if (tool === 'TaskCreate') {
428      const made = r.result as { task?: { id?: unknown } } | undefined
429      const id = String(made?.task?.id ?? /#(\d+)/.exec(r.text ?? '')?.[1] ?? `task-${Date.now()}`)
430      await change($, x => addTask(ensure(x), id, cleanName(args.subject)))
431    } else if (tool === 'TaskUpdate') {
432      await change($, x => changeTask(ensure(x), String(args.taskId), args.status, args.subject))
433    } else {
434      await change($, x => (x && (x.phase === 'stuck' || x.phase === 'needsYou') ? working(x) : x))
435    }
436    return r
437  }).catch(($, e, next) => next(e))
438
439  on('classic.Notification', async ($, e, next) => {
440    const isAsking =
441      e.notification_type === 'permission_prompt' ||
442      e.notification_type === 'elicitation_dialog' ||
443      /permission/i.test(e.message)
444    if (isAsking) {
445      await change($, c => (c && c.phase !== 'done' ? { ...working(c), phase: 'needsYou', needsYouReason: SAY.needsOk } : c))
446    }
447    return next(e)
448  })
449
450  on('classic.StopFailure', async ($, e, next) => {
451    apiError = errorSentence(e.error, e.error_details ?? '')
452    await change($, c => c && finish({ ...c, phase: 'stuck' }, 'stuck', apiError))
453    return next(e)
454  })
455
456  on('turn.complete', async ($, e, next) => {
457    if (e.agentId) return next(e)
458    const c = await read($, checklist)
459    if (c) {
460      let after: Checklist
461      if (e.reason === 'error') {
462        after = finish(c, 'stuck', apiError ?? errorSentence(undefined, e.answer))
463      } else if (e.reason === 'refusal') {
464        after = finish(c, 'stuck', SAY.refused)
465      } else if (e.isAborted) {
466        after = c.stuckReason === SAY.saidNo ? finish(c, 'stuck', SAY.saidNo) : finish(c, 'stopped')
467      } else if (c.hasPlan && c.tasks.some(t => t.status !== 'done')) {
468        after = finish(c, 'needsYou', SAY.waiting)
469      } else {
470        after = finish(c, 'done')
471        const jobId = c.jobId
472        $.clock.after(COLLAPSE_MS, () => {
473          void change($, x => (x && x.jobId === jobId && x.phase === 'done' ? { ...x, isCollapsed: true } : x))
474        })
475      }
476      apiError = null
477      await change($, () => after)
478    }
479    return next(e)
480  })
481
482  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
483    if (KEEP_VISIBLE.has(e.props.tool) || !(await read($, enabled))) return next(e)
484    const { Box } = $.ui.resolve(e)
485    return <Box />
486  })
487
488  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
489    if (KEEP_VISIBLE.has(e.props.tool) || !(await read($, enabled))) return next(e)
490    const { Box } = $.ui.resolve(e)
491    return <Box />
492  })
493
494  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
495    if (!(await read($, enabled))) return next(e)
496    const { Box } = $.ui.resolve(e)
497    return <Box />
498  })
499
500  on('ui.render', { component: 'ToolProgress' }, async ($, e, next) =>
501    (await read($, enabled)) ? next({ ...e, props: { ...e.props, hint: '' } }) : next(e),
502  )
503
504  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
505    if (e.props.hasSurvey) return next(e)
506    const { Box, Text, Button } = $.ui.resolve(e)
507    const isOn = await read($, enabled)
508
509    const toggle = (
510      <Button
511        key="clean-view-toggle"
512        plain
513        onPress={async () => {
514          const v = await setEnabled($)
515          $.ui.toast(v ? 'Clean View ligado' : 'Clean View desligado: mostrando tudo')
516        }}
517      >
518        <Text color={isOn ? C.green : C.overlay}>{`[ ● Clean View: ${isOn ? 'LIGADO' : 'DESLIGADO'} ]`}</Text>
519      </Button>
520    )
521
522    const c = await read($, checklist)
523    if (!isOn) {
524      const below = await next(e)
525      return (
526        <Box flexDirection="column">
527          <Box flexDirection="row" justifyContent="flex-end">
528            {toggle}
529          </Box>
530          {below}
531        </Box>
532      )
533    }
534    if (!c) {
535      return (
536        <Box flexDirection="row" justifyContent="flex-end">
537          {toggle}
538        </Box>
539      )
540    }
541
542    const frame = await read($, tick)
543    const elapsed = clock((c.finishedAt ?? Date.now()) - c.startedAt)
544
545    const head = (() => {
546      switch (c.phase) {
547        case 'needsYou':
548          return (
549            <Text wrap="truncate-end">
550              <Text backgroundColor={C.yellow} color={C.crust} bold>
551                {' Precisa de você '}
552              </Text>
553              <Text color={C.yellow}>{` ${c.needsYouReason ?? SAY.needsOk}`}</Text>
554            </Text>
555          )
556        case 'stuck':
557          return (
558            <Text wrap="truncate-end">
559              <Text color={C.red} bold>
560                {'⚠ Travou: '}
561              </Text>
562              <Text color={C.text}>{c.stuckReason ?? SAY.unknown}</Text>
563            </Text>
564          )
565        case 'stopped':
566          return (
567            <Text wrap="truncate-end">
568              <Text color={C.overlay} bold>
569                {'■ Parado'}
570              </Text>
571              <Text color={C.subtext}>{` · ${c.title} · você apertou Esc`}</Text>
572            </Text>
573          )
574        case 'done':
575          return (
576            <Text wrap="truncate-end">
577              <Text color={C.green} bold>
578                {'✓ Tudo pronto'}
579              </Text>
580              <Text color={C.subtext}>{` · ${c.title} · levou ${elapsed}`}</Text>
581            </Text>
582          )
583        default:
584          return (
585            <Text wrap="truncate-end">
586              <Text color={C.link} bold>
587                {c.title}
588              </Text>
589              <Text color={C.subtext}>{` · ${elapsed}`}</Text>
590            </Text>
591          )
592      }
593    })()
594
595    const header = (
596      <Box flexDirection="row" justifyContent="space-between">
597        <Box flexShrink={1}>{head}</Box>
598        <Box flexShrink={0} marginLeft={1}>
599          {toggle}
600        </Box>
601      </Box>
602    )
603
604    if (c.phase === 'done' && c.isCollapsed) return header
605
606    const accent = { working: C.blue, needsYou: C.yellow, stuck: C.red, stopped: C.overlay, done: C.green }[c.phase]
607    const inner = Math.max(20, e.props.bodyColumns - 4)
608    const room = inner - 2 - 1 - METER - 2 - LABEL_W
609    const longest = Math.max(...c.tasks.map(t => t.name.length), 6)
610    const nameW = Math.max(6, Math.min(room, longest + 1, MAX_NAME + 1))
611    const firstUpcoming = c.tasks.findIndex(t => t.status === 'upcoming')
612
613    const meter = (t: Task) => {
614      const cells: string[] = []
615      const grad = GRAD[t.status === 'done' ? 'done' : c.phase]
616      for (let k = 0; k < METER; k++) {
617        const at = k / (METER - 1)
618        if (t.status === 'done') {
619          cells.push(blend(grad, at))
620        } else if (t.status === 'active' && t.hasReported) {
621          cells.push(k < Math.round(t.percent / 10) ? blend(grad, at) : C.track)
622        } else if (t.status === 'active') {
623          // No percent yet: a sweep, bright at its head, fades into the track so its edges never cut hard.
624          const head = frame % (METER + SWEEP)
625          const gap = head - k
626          cells.push(gap >= 0 && gap < SWEEP ? blend([C.track, ...grad], 1 - gap / SWEEP) : C.track)
627        } else {
628          cells.push(C.track)
629        }
630      }
631      return (
632        <Text>
633          {cells.map(color => (
634            <Text color={color}>█</Text>
635          ))}
636        </Text>
637      )
638    }
639
640    const rows = c.tasks.map((t, k) => {
641      const isActive = t.status === 'active'
642      const icon =
643        t.status === 'done' ? '✓' : !isActive ? '○' : c.phase === 'needsYou' ? '‖' : c.phase === 'stopped' ? '■' : '▶'
644      const iconColor =
645        t.status === 'done'
646          ? C.green
647          : !isActive
648            ? C.overlay
649            : { working: C.link, needsYou: C.yellow, stuck: C.red, stopped: C.overlay, done: C.green }[c.phase]
650      const label =
651        t.status === 'done'
652          ? 'Concluído'
653          : isActive
654            ? t.hasReported
655              ? `${t.percent}%`
656              : 'Trabalhando'
657            : k === firstUpcoming
658              ? 'Próximo'
659              : 'Na fila'
660      const labelColor = t.status === 'done' ? C.green : isActive ? iconColor : k === firstUpcoming ? C.subtext : C.overlay
661      return (
662        <Box key={t.id} flexDirection="row">
663          <Text color={iconColor}>{`${icon} `}</Text>
664          <Box width={nameW} flexShrink={0}>
665            <Text
666              color={t.status === 'done' ? C.subtext : isActive ? C.text : C.overlay}
667              dimColor={t.status === 'done'}
668              bold={isActive}
669              wrap="truncate-end"
670            >
671              {t.name}
672            </Text>
673          </Box>
674          <Text> </Text>
675          {meter(t)}
676          <Text color={labelColor} bold={isActive}>{`  ${label}`}</Text>
677        </Box>
678      )
679    })
680
681    return (
682      <Box flexDirection="column" borderStyle="round" borderColor={accent} paddingX={1}>
683        {header}
684        {rows}
685      </Box>
686    )
687  })
688}
689
types/index.d.ts 37 lines
1export type TaskStatus = 'done' | 'active' | 'upcoming'
2
3export type Task = {
4  id: string
5  name: string
6  status: TaskStatus
7  percent: number
8  hasReported: boolean
9}
10
11export type Phase = 'working' | 'needsYou' | 'stuck' | 'stopped' | 'done'
12
13export type Checklist = {
14  /** Grows with each new job, so a late answer for an older job is ignored. */
15  jobId: number
16  title: string
17  phase: Phase
18  tasks: Task[]
19  /** False while the rows are still the placeholders shown before a plan. */
20  hasPlan: boolean
21  needsYouReason: string | null
22  stuckReason: string | null
23  startedAt: number
24  finishedAt: number | null
25  isCollapsed: boolean
26}
27
28declare module 'claude-code' {
29  interface PluginState {
30    'clean-view': {
31      cleanViewEnabled: boolean
32      checklist: Checklist | null
33      tick: number
34    }
35  }
36}
37