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

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:
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).
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.
/simple off, /simple on, ou só /simple para alternar./simple off.claude plugin validate .
claude plugin test .hooks/register.tsx 9 lines1import 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}
9hooks/clean-view.tsx 583 lines1import { 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}
583types/index.d.ts 29 lines1export 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