SLOPSHOPPER

clean-view

Clean View: hides tool calls, diffs and command output, and shows a plain checklist of the plan above the prompt.

newbandspinnerrowsguardcommand
v0.1.0no licenseupdated 2026-10-09pancoh/claude-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, then ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /simple ⎿ clean-view: Clean View desligado. As linhas técnicas voltaram. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ○ clean view

Draws

Prompt hint
? for shortcuts ○ clean view
README

Clean View

Mod para o Claude Code que deixa o trabalho do Claude mais calmo de acompanhar, sem a parte técnica.

Com o Clean View ligado:

  • as chamadas de ferramentas, os diffs de arquivos e a saída de comandos ficam escondidos;
  • as respostas escritas do Claude continuam visíveis;
  • um checklist acima do prompt mostra o plano, a etapa atual e quanto falta.
Criar minha página inicial · 1m 12s
✓ Ler suas anotações da marca       ██████████  Feito
▶ Criar a seção de preços           ██████░░░░  60%
○ Adicionar o formulário de contato ░░░░░░░░░░  Próximo
○ Ajustar o rodapé                  ░░░░░░░░░░  Depois

O cabeçalho muda conforme o estado: Precisa de você (um pedido de permissão ou uma pergunta), Travado (um erro ou uma etapa recusada), Parado (você apertou Esc) e Tudo pronto (o trabalho terminou; 5 segundos depois, a faixa encolhe para uma linha).

Instalação

No Claude Code, num terminal, digite um comando de cada vez no prompt:

/plugin marketplace add pancoh/claude-clean-view
/plugin install clean-view@clean-view

Não é preciso conta no GitHub. O mod foi testado no Claude Code 2.1.295.

Para atualizar: /plugin update clean-view@clean-view.

Para desinstalar: /plugin uninstall clean-view@clean-view. Se quiser remover também o marketplace: /plugin marketplace remove clean-view.

Como usar

  • Clique em ● clean view, no canto direito abaixo do prompt, para desligar. Outro clique liga de novo.
  • Ou use /simple off, /simple on, ou só /simple para alternar.
  • A escolha fica salva entre sessões. O Clean View começa ligado.

O que saber antes de instalar

  • Uso do seu plano: a cada pedido, o mod faz uma chamada curta ao Haiku para dar nome ao trabalho, acrescenta uma instrução ao prompt de sistema e pede ao Claude chamadas extras para publicar o plano e o progresso.
  • Plano primeiro: enquanto o Clean View está ligado, o Claude só usa ferramentas depois de publicar um plano.
  • Menos visibilidade: você deixa de ver os arquivos que o Claude edita e os comandos que ele roda. Os pedidos de permissão continuam aparecendo. Para ver tudo, desligue com /simple off.

Desenvolvimento

claude plugin validate .
claude plugin test .
Source 3 files
hooks/register.tsx 9 lines
1import type { Register } from 'claude-code'
2
3import { registerCleanView } from './clean-view'
4
5// Each mod registers its hooks here, one call per mod.
6export const register: Register = on => {
7  registerCleanView(on)
8}
9
hooks/clean-view.tsx 583 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, Timer } from 'claude-code'
3
4import type { Checklist, ChecklistTask, TaskStatus } from '../types'
5
6const PLUGIN = 'clean-view'
7const PLAN_TOOL = `mcp__${PLUGIN}__plan_steps`
8const REPORT_TOOL = `mcp__${PLUGIN}__report_progress`
9
10// Tools that run before a plan exists: planning, to-do lists and questions.
11const ALWAYS_ALLOWED = new Set(['ToolSearch', 'TodoWrite', 'TaskCreate', 'TaskUpdate', 'AskUserQuestion', PLAN_TOOL, REPORT_TOOL])
12
13const PLACEHOLDER = 'placeholder-'
14const METER = 10
15// The widest row label: Próximo. Every label gets this width so the meters line up.
16const LABEL = 7
17// Right margin of the checklist rows, in cells.
18const MARGIN = 4
19const MAX_NAME = 40
20const COLLAPSE_MS = 5000
21
22const IDLE: Checklist = {
23  title: '',
24  phase: 'idle',
25  tasks: [],
26  needsYouReason: null,
27  stuckReason: null,
28  startedAt: null,
29  finishedAt: null,
30  isCollapsed: false,
31}
32
33const enabled = atom({ plugin: 'clean-view', key: 'cleanViewEnabled' } as const, true)
34const checklist = atom({ plugin: 'clean-view', key: 'checklist' } as const, IDLE)
35const tick = atom({ plugin: 'clean-view', key: 'tick' } as const, 0)
36
37const SYSTEM_SECTION = {
38  id: `${PLUGIN}:plan`,
39  scope: 'session' as const,
40  text: [
41    'Clean View is on: the person sees only your written replies and a checklist of steps above the prompt. Tool calls, diffs and command output are hidden from them. The person reads Brazilian Portuguese.',
42    `- For every request, even a quick question, first call ${PLAN_TOOL} with every step of the job: 2 to 8 short names, in order. If it is deferred, load it with ToolSearch first. Other tools are refused until a plan exists.`,
43    `- Call ${REPORT_TOOL} as real progress happens on the current step, and with percent 100 the moment a step finishes.`,
44    '- Write every step name in plain Brazilian Portuguese a non-technical person understands, under 40 characters, starting with a verb in the infinitive, like "Criar a seção de preços".',
45    '- Never put file paths, file names, commands, code or tool names in a step name.',
46    '- If the session has TodoWrite or TaskCreate, you may use your to-do list as the plan instead.',
47  ].join('\n'),
48}
49
50const NAME_PROMPT =
51  'Dê um nome a este pedido em 2 a 6 palavras simples, em português do Brasil, que uma pessoa leiga entenda, começando com um verbo no infinitivo. ' +
52  'Sem nomes de arquivo, código, aspas ou ponto final. Responda só com o nome.\n\nPedido:\n'
53
54// Plain names ------------------------------------------------------------------
55
56const CODE_FILE =
57  /\b[\w.-]+\.(?:tsx?|jsx?|mjs|cjs|py|rb|go|rs|java|kt|swift|c|cc|cpp|h|hpp|cs|php|sh|zsh|bash|json|ya?ml|toml|md|mdx|html?|css|scss|sql|lock|env|xml|vue|svelte|ipynb)\b/gi
58
59// Every name on the checklist passes through here.
60export function cleanName(raw: string): string {
61  let name = raw.replace(/`[^`]*`/g, ' ').replace(/`/g, ' ')
62  name = name
63    .split(/\s+/)
64    .filter(word => !word.includes('/') && !word.includes('\\'))
65    .join(' ')
66  name = name.replace(CODE_FILE, ' ').replace(/\s+/g, ' ').trim()
67  if (name === '') return 'Trabalhando nisso'
68  name = name.charAt(0).toUpperCase() + name.slice(1)
69  if (name.length <= MAX_NAME) return name
70  const cut = name.slice(0, MAX_NAME - 1)
71  const space = cut.lastIndexOf(' ')
72  return `${(space > 0 ? cut.slice(0, space) : cut).trimEnd()}…`
73}
74
75// The checklist's arithmetic ---------------------------------------------------
76
77const isPlaceholder = (t: ChecklistTask) => t.id.startsWith(PLACEHOLDER)
78
79export const hasPlan = (c: Checklist) => c.tasks.some(t => !isPlaceholder(t))
80
81const isOpen = (c: Checklist) => c.phase !== 'idle' && c.finishedAt === null
82
83const newTask = (id: string, name: string, status: TaskStatus): ChecklistTask => ({
84  id,
85  name: cleanName(name),
86  status,
87  percent: status === 'done' ? 100 : 0,
88  hasReported: false,
89})
90
91export function placeholders(): ChecklistTask[] {
92  return [newTask(`${PLACEHOLDER}0`, 'Entender seu pedido', 'active'), newTask(`${PLACEHOLDER}1`, 'Planejar as etapas', 'upcoming')]
93}
94
95export function planSteps(c: Checklist, steps: readonly string[]): Checklist {
96  const tasks = steps
97    .slice(0, 8)
98    .map((step, i) => newTask(`plan-${i}`, step, i === 0 ? 'active' : 'upcoming'))
99  return { ...c, tasks }
100}
101
102// Progress on one step: the steps before it are checked off, and at 100 the
103// next one starts. A name the plan lacks becomes a new step before the upcoming ones.
104export function reportProgress(c: Checklist, rawName: string, rawPercent: number): Checklist {
105  const percent = Math.max(0, Math.min(100, Math.round(Number.isFinite(rawPercent) ? rawPercent : 0)))
106  const name = cleanName(rawName)
107  let tasks = c.tasks.filter(t => !isPlaceholder(t))
108  let at = tasks.findIndex(t => t.name.toLowerCase() === name.toLowerCase())
109  if (at < 0) {
110    const firstUpcoming = tasks.findIndex(t => t.status === 'upcoming')
111    at = firstUpcoming < 0 ? tasks.length : firstUpcoming
112    tasks = [...tasks.slice(0, at), newTask(`step-${tasks.length}`, name, 'upcoming'), ...tasks.slice(at)]
113  }
114  const isFinished = percent >= 100
115  let startsNext = isFinished
116  tasks = tasks.map((t, i): ChecklistTask => {
117    if (i < at) return { ...t, status: 'done', percent: 100 }
118    if (i === at) return isFinished ? { ...t, status: 'done', percent: 100, hasReported: true } : { ...t, status: 'active', percent, hasReported: true }
119    if (t.status === 'done') return t
120    if (startsNext) {
121      startsNext = false
122      return { ...t, status: 'active', percent: 0, hasReported: false }
123    }
124    return { ...t, status: 'upcoming', percent: 0, hasReported: false }
125  })
126  return { ...c, tasks }
127}
128
129type Todo = { content?: unknown; status?: unknown }
130
131export function fromTodos(c: Checklist, todos: readonly Todo[]): Checklist {
132  const tasks = todos.map((todo, i) => {
133    const status: TaskStatus = todo.status === 'completed' ? 'done' : todo.status === 'in_progress' ? 'active' : 'upcoming'
134    return newTask(`todo-${i}`, typeof todo.content === 'string' ? todo.content : '', status)
135  })
136  return { ...c, tasks }
137}
138
139// One calm sentence for an API error.
140export function apiErrorSentence(kind: string, details: string): string {
141  const s = `${kind} ${details}`.toLowerCase()
142  if (/rate_limit|rate limit|usage limit|\b429\b/.test(s)) return 'você atingiu seu limite de uso, tente de novo daqui a pouco'
143  if (/overloaded|\b529\b/.test(s)) return 'os servidores do Claude estão ocupados, tente de novo em um minuto'
144  if (/too long|context|prompt_too_long/.test(s)) return 'digite /compact e tente de novo'
145  if (/auth|oauth|login|credential|\b401\b|\b403\b/.test(s)) return 'digite /login'
146  if (/network|connection|econn|enotfound|etimedout|socket|fetch failed|offline/.test(s)) return 'a conexão com a internet caiu'
147  return 'algo deu errado no caminho, tente de novo'
148}
149
150const REJECTED = /doesn.t want to proceed|does not want to proceed|user (rejected|denied|declined)|rejected by the user/i
151
152export function duration(ms: number): string {
153  const s = Math.max(0, Math.floor(ms / 1000))
154  if (s < 60) return `${s}s`
155  const m = Math.floor(s / 60)
156  if (m < 60) return `${m}m ${s % 60}s`
157  return `${Math.floor(m / 60)}h ${m % 60}m`
158}
159
160// The engine's hint can come in more than one line; here it is one.
161function oneLine(hint: string) {
162  return hint
163    .split(/\r?\n/)
164    .map(line => line.trim())
165    .filter(line => line !== '')
166    .join(' · ')
167}
168
169// Moving parts -----------------------------------------------------------------
170
171// The animation clock runs only while a job works or waits on the person.
172let ticker: Timer | undefined
173// Which job a background name belongs to.
174let jobSeq = 0
175// Failed tool calls in a row, and whether the person refused a step.
176let failures = 0
177let saidNo = false
178let apiError: string | null = null
179
180async function syncTicker($: EngineInterface) {
181  const { phase } = await read($, checklist)
182  const animate = phase === 'working' || phase === 'needs-you'
183  if (animate && ticker === undefined) {
184    ticker = $.clock.every(250, () => {
185      void update($, tick, n => n + 1)
186    })
187  } else if (!animate && ticker !== undefined) {
188    ticker.cancel()
189    ticker = undefined
190  }
191}
192
193async function change($: EngineInterface, fn: (c: Checklist) => Checklist) {
194  const updated = await update($, checklist, fn)
195  await syncTicker($)
196  return updated
197}
198
199const running = (c: Checklist): Checklist => ({
200  ...c,
201  phase: c.stuckReason !== null ? 'stuck' : 'working',
202  needsYouReason: null,
203})
204
205async function needsYou($: EngineInterface, reason: string) {
206  await change($, c => (isOpen(c) ? { ...c, phase: 'needs-you', needsYouReason: reason } : c))
207}
208
209async function clearNeedsYou($: EngineInterface) {
210  if ((await read($, checklist)).phase === 'needs-you') await change($, running)
211}
212
213async function stuck($: EngineInterface, reason: string) {
214  await change($, c => (isOpen(c) ? { ...c, phase: 'stuck', stuckReason: reason, needsYouReason: null } : c))
215}
216
217async function unstick($: EngineInterface) {
218  await change($, c => (c.stuckReason === null ? c : { ...c, stuckReason: null, phase: c.phase === 'stuck' ? 'working' : c.phase }))
219}
220
221async function startJob($: EngineInterface, text: string) {
222  jobSeq += 1
223  failures = 0
224  saidNo = false
225  apiError = null
226  const seq = jobSeq
227  await change($, () => ({
228    ...IDLE,
229    title: cleanName(text.split('\n')[0] ?? ''),
230    phase: 'working',
231    tasks: placeholders(),
232    startedAt: Date.now(),
233  }))
234  if (await read($, enabled)) void nameJob($, seq, text)
235}
236
237// Asks Haiku for a short name; an answer for an older job is dropped.
238async function nameJob($: EngineInterface, seq: number, text: string) {
239  try {
240    const reply = await $.model.complete({ model: 'haiku', effort: 'low', maxTokens: 40, prompt: NAME_PROMPT + text.slice(0, 1000) })
241    if (!reply.isAnswered || seq !== jobSeq) return
242    const name = cleanName((reply.text.split('\n')[0] ?? '').replace(/["“”.]/g, ''))
243    await update($, checklist, c => (isOpen(c) ? { ...c, title: name } : c))
244  } catch {}
245}
246
247async function finishTurn($: EngineInterface, reason: string, answer: string) {
248  const c = await read($, checklist)
249  if (!isOpen(c)) return
250  const now = Date.now()
251  if (reason === 'error') {
252    await change($, x => ({ ...x, phase: 'stuck', stuckReason: apiError ?? apiErrorSentence('', answer), needsYouReason: null, finishedAt: now }))
253  } else if (reason === 'refusal') {
254    await change($, x => ({ ...x, phase: 'stuck', stuckReason: 'o Claude não conseguiu ajudar com esse pedido', needsYouReason: null, finishedAt: now }))
255  } else if (saidNo) {
256    // The refusal keeps its Stuck header; the person's reply resumes the job.
257  } else if (reason === 'aborted') {
258    await change($, x => ({ ...x, phase: 'stopped', needsYouReason: null, finishedAt: now }))
259  } else if (c.tasks.some(t => !isPlaceholder(t) && t.status !== 'done')) {
260    await change($, x => ({ ...x, phase: 'needs-you', needsYouReason: 'o Claude está esperando sua resposta', stuckReason: null }))
261  } else {
262    const tasks = c.tasks.map(t => ({ ...t, status: 'done' as const, percent: 100 }))
263    await change($, x => ({ ...x, tasks, phase: 'done', needsYouReason: null, stuckReason: null, finishedAt: now }))
264    $.clock.after(COLLAPSE_MS, () => {
265      void update($, checklist, x => (x.phase === 'done' && x.finishedAt === now ? { ...x, isCollapsed: true } : x))
266    })
267  }
268}
269
270async function setEnabled($: EngineInterface, isOn: boolean) {
271  await update($, enabled, () => isOn)
272  await $.store.set('cleanViewEnabled', isOn)
273}
274
275// Drawing ------------------------------------------------------------------------
276
277function meter(t: ChecklistTask, frame: number) {
278  if (t.status === 'done') return { filled: METER, sweep: null }
279  if (t.status === 'upcoming') return { filled: 0, sweep: null }
280  if (t.hasReported) return { filled: Math.round(t.percent / 10), sweep: null }
281  return { filled: 0, sweep: frame % METER }
282}
283
284// Hooks --------------------------------------------------------------------------
285
286export function registerCleanView(on: On) {
287  on('session.start', async ($, e, next) => {
288    const started = await next(e)
289    const saved = await $.store.get('cleanViewEnabled')
290    await update($, enabled, () => saved !== false)
291    await $.tool.register({
292      name: 'plan_steps',
293      description:
294        'Lays out every step of the job before you start: 2 to 8 short names in plain Brazilian Portuguese, in order, each starting with a verb. The first step starts right away.',
295      inputSchema: {
296        type: 'object',
297        properties: { steps: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 8 } },
298        required: ['steps'],
299      },
300      isDeferred: false,
301    })
302    await $.tool.register({
303      name: 'report_progress',
304      description:
305        'Reports progress on the current step of the plan, by its name, from 0 to 100. Send 100 the moment a step finishes; the next step then starts.',
306      inputSchema: {
307        type: 'object',
308        properties: { task: { type: 'string' }, percent: { type: 'number', minimum: 0, maximum: 100 } },
309        required: ['task', 'percent'],
310      },
311      isDeferred: false,
312    })
313    await $.command.register({ name: 'simple', description: 'Liga ou desliga o Clean View', argumentHint: 'on|off' })
314    await syncTicker($)
315    return started
316  })
317
318  on('command.run', { command: 'simple' }, async ($, e) => {
319    const arg = e.args.trim().toLowerCase()
320    if (arg !== '' && arg !== 'on' && arg !== 'off') return { text: 'Use /simple on, /simple off ou só /simple para alternar.' }
321    const isOn = arg === '' ? !(await read($, enabled)) : arg === 'on'
322    await setEnabled($, isOn)
323    return { text: isOn ? 'Clean View ligado.' : 'Clean View desligado. As linhas técnicas voltaram.' }
324  })
325
326  on('prompt.compose', async ($, e, next) => {
327    const composed = await next(e)
328    if (!(await read($, enabled))) return composed
329    return { sections: [...composed.sections, SYSTEM_SECTION] }
330  }).catch(($, e, next) => next(e))
331
332  on('turn.start', async ($, e, next) => {
333    const text = e.text.trim()
334    const c = await read($, checklist)
335    if (isOpen(c)) {
336      saidNo = false
337      await change($, x => ({ ...running(x), stuckReason: null, phase: 'working' }))
338    } else if (text !== '' && !text.startsWith('/')) {
339      await startJob($, text)
340    }
341    return next(e)
342  }).catch(($, e, next) => next(e))
343
344  on('tool.call', async ($, e, next) => {
345    const isMain = e.agentId === undefined
346    const input = e as unknown as Record<string, unknown>
347
348    if (e.tool === PLAN_TOOL) {
349      const steps = (Array.isArray(input.steps) ? input.steps : []).filter((s): s is string => typeof s === 'string')
350      if (steps.length === 0) return { deny: 'plan_steps needs 2 to 8 step names.' }
351      const c = await read($, checklist)
352      if (!isOpen(c)) {
353        jobSeq += 1
354        await change($, () => ({ ...IDLE, title: cleanName(steps[0] ?? ''), phase: 'working', startedAt: Date.now() }))
355      }
356      const planned = await change($, x => planSteps(x, steps))
357      return { result: `Planned ${planned.tasks.length} steps. The first one has started.` }
358    }
359
360    if (e.tool === REPORT_TOOL) {
361      const percent = Math.max(0, Math.min(100, Math.round(Number(input.percent) || 0)))
362      await change($, x => reportProgress(x, typeof input.task === 'string' ? input.task : '', percent))
363      return { result: `Progress noted: ${percent}%.` }
364    }
365
366    if (isMain && !ALWAYS_ALLOWED.has(e.tool) && (await read($, enabled)) && !hasPlan(await read($, checklist))) {
367      return { deny: `Clean View: call ${PLAN_TOOL} first with the steps of this job, then try again.` }
368    }
369
370    if (isMain) await clearNeedsYou($)
371    const asks = e.tool === 'AskUserQuestion'
372    if (asks) await needsYou($, 'o Claude tem uma pergunta para você')
373    const ran = await next(e)
374    if (asks) await clearNeedsYou($)
375    if (!isMain) return ran
376
377    if (ran.deny === undefined && ran.isError !== true) {
378      if (e.tool === 'TodoWrite' && Array.isArray(input.todos)) {
379        await change($, x => fromTodos(x, input.todos as Todo[]))
380      } else if (e.tool === 'TaskCreate') {
381        const id = (ran.result as { task?: { id?: unknown } } | undefined)?.task?.id
382        if (typeof id === 'string') {
383          const task = newTask(id, typeof input.subject === 'string' ? input.subject : '', 'upcoming')
384          await change($, x => ({ ...x, tasks: [...x.tasks.filter(t => !isPlaceholder(t) && t.id !== id), task] }))
385        }
386      } else if (e.tool === 'TaskUpdate') {
387        await change($, x => {
388          if (input.status === 'deleted') return { ...x, tasks: x.tasks.filter(t => t.id !== input.taskId) }
389          const tasks = x.tasks.map((t): ChecklistTask => {
390            if (t.id !== input.taskId) return t
391            const name = typeof input.subject === 'string' ? cleanName(input.subject) : t.name
392            if (input.status === 'completed') return { ...t, name, status: 'done', percent: 100 }
393            if (input.status === 'in_progress') return { ...t, name, status: 'active' }
394            if (input.status === 'pending') return { ...t, name, status: 'upcoming', percent: 0 }
395            return { ...t, name }
396          })
397          return { ...x, tasks }
398        })
399      }
400    }
401
402    // Failures and refusals feed the Stuck header; a success clears it.
403    const said = `${ran.deny ?? ''} ${ran.isError === true ? (ran.text ?? '') : ''}`
404    if (REJECTED.test(said)) {
405      saidNo = true
406      await stuck($, 'você recusou uma etapa, então o Claude parou')
407    } else if (ran.isError === true) {
408      failures += 1
409      if (failures >= 3) await stuck($, 'uma etapa continua falhando, o Claude está tentando outro caminho')
410    } else if (ran.deny === undefined) {
411      failures = 0
412      saidNo = false
413      await unstick($)
414    }
415    return ran
416  }).catch(($, e, next) => next(e))
417
418  on('classic.Notification', async ($, e, next) => {
419    if (/permission/i.test(`${e.notification_type} ${e.message}`)) await needsYou($, 'o Claude precisa do seu OK para continuar')
420    return next(e)
421  }).catch(($, e, next) => next(e))
422
423  on('classic.StopFailure', async ($, e, next) => {
424    if (e.agent_id === undefined) {
425      apiError = apiErrorSentence(e.error, e.error_details ?? '')
426      await change($, c => (c.phase === 'stuck' && c.finishedAt !== null ? { ...c, stuckReason: apiError } : c))
427    }
428    return next(e)
429  }).catch(($, e, next) => next(e))
430
431  on('turn.complete', async ($, e, next) => {
432    if (e.agentId === undefined) await finishTurn($, e.reason, e.answer)
433    return next(e)
434  }).catch(($, e, next) => next(e))
435
436  // The technical rows, hidden while Clean View is on.
437  on('ui.render', { component: 'ToolUse' }, async ($, e, next) =>
438    (await read($, enabled)) ? $.ui.resolve(e).Box({ display: 'none' }) : next(e),
439  )
440  on('ui.render', { component: 'ToolResult' }, async ($, e, next) =>
441    (await read($, enabled)) ? $.ui.resolve(e).Box({ display: 'none' }) : next(e),
442  )
443  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) =>
444    (await read($, enabled)) ? $.ui.resolve(e).Box({ display: 'none' }) : next(e),
445  )
446  on('ui.render', { component: 'ToolProgress' }, async ($, e, next) =>
447    (await read($, enabled)) ? next({ ...e, props: { ...e.props, hint: '' } }) : next(e),
448  )
449
450  // The switch, at the right of the hint row under the prompt. Another mod's row
451  // (the Zen button) stays, with this switch after it.
452  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
453    const { Box, Text, Button } = $.ui.resolve(e)
454    const isOn = await read($, enabled)
455    const below = await next(e)
456    const row =
457      below.type === 'engine' ? (
458        <Text dimColor wrap="truncate">
459          {oneLine(e.props.hint)}
460        </Text>
461      ) : (
462        <Box flexGrow={1} flexShrink={1}>
463          {below}
464        </Box>
465      )
466    return (
467      <Box justifyContent="space-between" flexGrow={1} gap={1}>
468        {row}
469        <Button
470          key="clean-view-toggle"
471          plain
472          dimColor={!isOn}
473          label={isOn ? '● clean view' : '○ clean view'}
474          onPress={async () => {
475            const isNowOn = !(await read($, enabled))
476            await setEnabled($, isNowOn)
477            $.ui.toast(isNowOn ? 'Clean View ligado' : 'Clean View desligado: as linhas técnicas voltaram')
478          }}
479        />
480      </Box>
481    )
482  })
483
484  // The checklist above the prompt.
485  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
486    if (e.props.hasSurvey) return next(e)
487    const isOn = await read($, enabled)
488    const c = await read($, checklist)
489    if (!isOn || c.phase === 'idle') return next(e)
490    const { Box, Text } = $.ui.resolve(e)
491    const frame = await read($, tick)
492
493    const now = Date.now()
494    const took = duration((c.finishedAt ?? now) - (c.startedAt ?? now))
495    const title = c.title || 'Trabalhando nisso'
496
497    if (c.phase === 'done' && c.isCollapsed) {
498      return (
499        <Text wrap="wrap">
500          <Text color="success">✓ Tudo pronto</Text>
501          <Text dimColor>{` · ${title} · levou ${took}`}</Text>
502        </Text>
503      )
504    }
505
506    const header =
507      c.phase === 'needs-you' ? (
508        <Text wrap="wrap">
509          <Text inverse bold color="warning">
510            {' Precisa de você '}
511          </Text>
512          <Text>{` ${c.needsYouReason ?? ''}`}</Text>
513        </Text>
514      ) : c.phase === 'stuck' ? (
515        <Text wrap="wrap" color="warning">{`⚠ Travado: ${c.stuckReason ?? ''}`}</Text>
516      ) : c.phase === 'stopped' ? (
517        <Text wrap="wrap">{`■ Parado · ${title} · você apertou Esc`}</Text>
518      ) : c.phase === 'done' ? (
519        <Text wrap="wrap">
520          <Text color="success" bold>
521            ✓ Tudo pronto
522          </Text>
523          <Text>{` · ${title} · levou ${took}`}</Text>
524        </Text>
525      ) : (
526        <Text wrap="wrap">
527          <Text bold>{title}</Text>
528          <Text dimColor>{` · ${took}`}</Text>
529        </Text>
530      )
531
532    // The name takes what is left of the row and wraps instead of being cut. The row
533    // keeps a right margin: the desktop app draws its padding inside the width the
534    // engine reports, and without it the label runs off the edge.
535    let upcomingSeen = 0
536    const rows = c.tasks.map(t => {
537      const { filled, sweep } = meter(t, frame)
538      const cells = Array.from({ length: METER }, (_, i) => (sweep === null ? i < filled : i >= sweep && i < sweep + 3))
539      const isActive = t.status === 'active'
540      const icon = t.status === 'done' ? '✓' : isActive ? (c.phase === 'needs-you' ? '‖' : '▶') : '○'
541      const label =
542        t.status === 'done' ? 'Feito' : isActive ? (t.hasReported ? `${t.percent}%` : 'Fazendo') : upcomingSeen++ === 0 ? 'Próximo' : 'Depois'
543      return (
544        <Box key={`task-${t.id}`} gap={1} paddingRight={MARGIN}>
545          <Text color={t.status === 'done' ? 'success' : isActive ? 'claude' : undefined} dimColor={t.status === 'upcoming'}>
546            {icon}
547          </Text>
548          <Box flexGrow={1} flexShrink={1} minWidth={8}>
549            <Text wrap="wrap" bold={isActive} dimColor={!isActive}>
550              {t.name}
551            </Text>
552          </Box>
553          <Box flexShrink={0}>
554          <Text>
555            {cells.map((isFilled, i) =>
556              isFilled ? (
557                <Text key={`cell-${i}`} color={t.status === 'done' ? 'success' : 'claude'}>
558                  █
559                </Text>
560              ) : (
561                <Text key={`cell-${i}`} dimColor>
562                  █
563                </Text>
564              ),
565            )}
566          </Text>
567          </Box>
568          <Box width={LABEL} flexShrink={0}>
569            <Text dimColor={!isActive}>{label}</Text>
570          </Box>
571        </Box>
572      )
573    })
574
575    return (
576      <Box flexDirection="column">
577        <Box key="header">{header}</Box>
578        {rows}
579      </Box>
580    )
581  })
582}
583
types/index.d.ts 29 lines
1export type TaskStatus = 'done' | 'active' | 'upcoming'
2
3export type ChecklistTask = {
4  id: string
5  name: string
6  status: TaskStatus
7  percent: number
8  hasReported: boolean
9}
10
11export type Phase = 'idle' | 'working' | 'needs-you' | 'stuck' | 'stopped' | 'done'
12
13export type Checklist = {
14  title: string
15  phase: Phase
16  tasks: ChecklistTask[]
17  needsYouReason: string | null
18  stuckReason: string | null
19  startedAt: number | null
20  finishedAt: number | null
21  isCollapsed: boolean
22}
23
24declare module 'claude-code' {
25  interface PluginState {
26    'clean-view': { cleanViewEnabled: boolean; checklist: Checklist; tick: number }
27  }
28}
29