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

Personal Claude Code mods, published as a single plugin marketplace (claude-mods).
| Plugin | Description | |
|---|---|---|
| clean-view | Hides 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 |
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
claude plugin update clean-view@claude-mods
then /reload-plugins in the session.
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>
plugins/<name>/ with .claude-plugin/plugin.json, hooks/hooks.json and hooks/register.tsx.{ "name": "<name>", "source": "./plugins/<name>" } to plugins in .claude-plugin/marketplace.json.hooks/register.tsx 8 lines1import type { Register } from 'claude-code'
2
3import { registerCleanView } from './clean-view'
4
5export const register: Register = on => {
6 registerCleanView(on)
7}
8hooks/clean-view.tsx 689 lines1import { 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}
689types/index.d.ts 37 lines1export 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