Clean View hides tool calls and shows a calm checklist above the prompt; Agent Dock splits each request across a team of helpers you pick and shows each one…

A Claude Code plugin marketplace.
| Plugin | What it does |
|---|---|
clean-view | Hides tool calls, diffs and command output, and shows a calm checklist of Claude's plan above the prompt, with a row for each helper Claude starts. /simple on or /simple off switches it. Also holds Agent Dock: pick a Team Size from 1 to 100 and every request is split across that many helpers, each shown live in a pane. /dock opens or folds it, /dock 10 sets the size. |
status-bar | One colored line under the prompt: plan, model, effort, context, 7-day and 5-hour limits, and running agents by model and effort. |
model-routing | Routes every delegated agent to the cheapest model tier that can do the job well, at an explicit effort. Refuses agents with no model, fills in effort by tier, blocks spawns from haiku agents and past two levels, and keeps a routing guide in Claude's instructions. |
mods-menu | A ☰ Mods button beside the Clean View toggle. It opens a small menu listing your mods with their controls: Clean View on or off, the Agent Dock and its Team Size, and the mods that are always on. /mods opens or closes it. |
prompt-upgrade | Turns a rough prompt into a great one before it is sent. /better <prompt> opens a pane that asks a few quick multiple-choice questions, rewrites the prompt, and shows your original and the upgrade side by side; nothing runs until you press Run. /better on checks every typed prompt first: one that already has what it needs is sent as typed, one that falls short opens the pane, and the same prompt sent twice goes straight through. Press Enter twice to send a prompt as typed. A Detail setting (Auto, Low, Medium or High; /better level auto) sets the questions and how long and deep the upgrade is: Low, Medium and High ask up to 3, 5 or 7; Auto also turns automatic mode on, asks as many questions as needed (up to 9) and, if you don't answer within 30 seconds or press Do it for me, fills in the rest and sends the upgrade itself. /better history shows the last 20. |
At the prompt of a terminal session:
/plugin install clean-view --marketplace ataege/claude-skills
/plugin install status-bar --marketplace ataege/claude-skills
/plugin install model-routing --marketplace ataege/claude-skills
/plugin install mods-menu --marketplace ataege/claude-skills
/plugin install prompt-upgrade --marketplace ataege/claude-skills
Answer y to add the marketplace, then pick a scope with Enter.
From a shell:
claude plugin install clean-view --marketplace ataege/claude-skills
claude plugin install status-bar --marketplace ataege/claude-skills
claude plugin install model-routing --marketplace ataege/claude-skills
claude plugin install mods-menu --marketplace ataege/claude-skills
claude plugin install prompt-upgrade --marketplace ataege/claude-skills
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with ataege/claude-skills, then Add plugin.
Each plugin lives in plugins/<name>. Check one with:
claude plugin validate plugins/<name>
claude plugin test plugins/<name>hooks/register.tsx 12 lines1import type { Register } from 'claude-code'
2
3import { registerCleanView } from './clean-view'
4import { registerAgentDock } from './dock'
5
6// Each mod registers itself here. The dock goes first, so its hook answers
7// helpers' report_progress calls before Clean View's would.
8export const register: Register = on => {
9 registerAgentDock(on)
10 registerCleanView(on)
11}
12hooks/clean-view.tsx 1445 lines1// Clean View: hides tool rows and shows a calm checklist of Claude's plan above the prompt.
2import { atom, read, update } from 'claude-code'
3import type {
4 BoxProps,
5 ElementConstructor,
6 EngineInterface,
7 On,
8 RenderElement,
9 RenderInput,
10 RenderNode,
11 TextProps,
12 Timer,
13} from 'claude-code'
14
15import type { CleanViewChecklist, CleanViewHelper, CleanViewTask, CleanViewTaskStatus } from '../types'
16
17// ---------------------------------------------------------------------------
18// Names, limits and wording
19// ---------------------------------------------------------------------------
20
21const PLAN_TOOL_NAME = 'plan_steps'
22const REPORT_TOOL_NAME = 'report_progress'
23const PLAN_TOOL = 'mcp__clean-view__plan_steps'
24const REPORT_TOOL = 'mcp__clean-view__report_progress'
25const STORE_KEY = 'cleanViewEnabled'
26
27const MAX_NAME_LENGTH = 40
28const MAX_PLAN_STEPS = 8
29const METER_CELLS = 10
30const LABEL_CELLS = 7 // fits "Working" and "Up next"
31const ROW_CHROME_CELLS = 2 + 1 + METER_CELLS + 2 + LABEL_CELLS // marker, gaps, meter, label
32const BOX_CHROME_COLUMNS = 4 // border and padding on both sides
33const MAX_HELPER_ROWS = 5 // per step; the rest fold into one line
34const TICK_MS = 250
35const COLLAPSE_AFTER_MS = 5000
36const FAILS_BEFORE_STUCK = 3
37
38const FALLBACK_NAME = 'Working on it'
39const DEFAULT_TITLE = 'Working on your request'
40const PLACEHOLDER_STEPS = ['Understand your request', 'Plan the steps']
41
42// Tools the plan-first gate always lets through.
43const ALWAYS_ALLOWED_TOOLS = new Set([
44 'ToolSearch',
45 'TodoWrite',
46 'TaskCreate',
47 'TaskUpdate',
48 'AskUserQuestion',
49 PLAN_TOOL,
50 REPORT_TOOL,
51])
52
53// Plain sentences the header shows.
54export const REASONS = {
55 permission: 'Claude needs your OK to continue',
56 question: 'Claude has a question for you',
57 waitingForReply: 'Claude is waiting for your reply',
58 saidNo: 'you said no to a step, so Claude paused',
59 keepsFailing: 'a step keeps failing, Claude is trying another way',
60 refusal: "Claude couldn't help with that request",
61 rateLimit: 'you hit your usage limit, try again a little later',
62 busy: "Claude's servers are busy, try again in a minute",
63 tooLong: 'type /compact and try again',
64 network: 'the internet connection dropped',
65 login: 'type /login',
66 billing: 'your account needs attention, check your plan',
67 unknownError: 'something went wrong, try again in a moment',
68} as const
69
70// Stuck reasons that came from an API error, so a late error detail may refine them.
71const API_ERROR_REASONS: ReadonlySet<string> = new Set([
72 REASONS.rateLimit,
73 REASONS.busy,
74 REASONS.tooLong,
75 REASONS.network,
76 REASONS.login,
77 REASONS.billing,
78 REASONS.unknownError,
79])
80
81const GATE_MESSAGE =
82 `Clean View: call ${PLAN_TOOL} first to lay out 2 to 8 plain-English steps for this request ` +
83 '(load it with ToolSearch if it is deferred), then try this tool again.'
84
85const CHECKLIST_PROMPT = `# Clean View checklist
86The person is using Clean View: tool calls are hidden from them and they follow your work through a simple checklist above the prompt. Keep that checklist accurate.
87- For every request, even a quick question, call ${PLAN_TOOL} first, before any other tool, with 2 to 8 short step names in order. If it is deferred, load it with ToolSearch first.
88- Then call ${REPORT_TOOL} with the current step's name and a percent as real progress happens, and with 100 the moment a step finishes.
89- If this session has TodoWrite or TaskCreate, you may use your to-do list as the plan instead.
90- Write every step name in plain English a non-technical person understands. Keep it under 40 characters and start it with a verb, like "Build the pricing section".
91- Never put file paths, file names, commands, code or tool names in a step name.
92- When you start agents, add to each agent's prompt: 'As you work, call ${REPORT_TOOL} with your task name and a percent at about 25, 50, 75 and 100. Do not call ${PLAN_TOOL}.'`
93
94// Text a tool result carries when the person turned down a permission prompt.
95// Where a new request comes from; helpers' reports, peers' messages and plugins' prompts carry on the current job.
96const NEW_JOB_ORIGINS: ReadonlySet<string> = new Set([
97 'composer',
98 'bridge',
99 'sdk',
100 'unclassified',
101 'scheduled-trigger',
102 'channel',
103 'slack-ping',
104])
105
106const USER_SAID_NO = /user doesn't want to (proceed|take this action)|tool use was rejected|the user (rejected|denied|declined)/i
107
108// File names with a code or config extension, which never belong in a step name.
109const CODE_FILE_NAME =
110 /^[\w.@-]*\w\.(tsx?|jsx?|mjs|cjs|mts|cts|py|rb|go|rs|java|kt|swift|c|cc|cpp|h|hpp|cs|php|css|scss|sass|less|html?|json|jsonc|ya?ml|toml|md|mdx|sh|bash|zsh|ps1|sql|vue|svelte|astro|xml|lock|env|ini|cfg|conf|txt|csv|ipynb|dart|lua|scala|exs?|hs|gradle|tf|graphql|proto|prisma)$/i
111
112const BORDER_COLOR = '#3A3A46' // the muted frame around the band
113
114// ---------------------------------------------------------------------------
115// Shared state (declared in types/index.d.ts)
116// ---------------------------------------------------------------------------
117
118const enabledAtom = atom({ plugin: 'clean-view', key: 'cleanViewEnabled' } as const, true)
119const checklistAtom = atom({ plugin: 'clean-view', key: 'checklist' } as const, null)
120const tickAtom = atom({ plugin: 'clean-view', key: 'tick' } as const, 0)
121
122// Module-only bookkeeping: none of it is drawn, so a reload may reset it.
123let animationTimer: Timer | undefined
124let areToolsRegistered = false
125let failedCallsInRow = 0
126let lastApiErrorText = ''
127let lastSubmitKind: 'prompt' | 'command' | undefined
128let isMainTurnRunning = false
129
130// ---------------------------------------------------------------------------
131// The one name cleaner
132// ---------------------------------------------------------------------------
133
134/** Turns any step or job name into plain words: no code, paths or file names, at most 40 characters. */
135export function cleanStepName(rawName: unknown): string {
136 const withoutCode = String(rawName ?? '').replace(/`[^`]*`?/g, ' ')
137
138 const keptWords = withoutCode.split(/\s+/).filter(word => {
139 if (word.includes('/') || word.includes('\\')) return false
140 const bareWord = word.replace(/^[("'[{<]+|[)"'\]}>,.;:!?]+$/g, '')
141 return !CODE_FILE_NAME.test(bareWord)
142 })
143
144 const tidied = keptWords
145 .join(' ')
146 .replace(/\(\s*\)|\[\s*\]|\{\s*\}|"\s*"/g, ' ')
147 .replace(/\s+([,.;:!?])/g, '$1')
148 .replace(/\s+/g, ' ')
149 .trim()
150 .replace(/^[,.;:!?\-–—]+\s*/, '')
151 .replace(/[\s,;:\-–—(]+$/, '')
152
153 if (tidied === '') return FALLBACK_NAME
154 const capitalised = tidied.charAt(0).toUpperCase() + tidied.slice(1)
155
156 return trimToLength(capitalised, MAX_NAME_LENGTH)
157}
158
159/** Cuts text at a word boundary so it, with its "…", fits in `maxLength`. */
160function trimToLength(text: string, maxLength: number): string {
161 if (text.length <= maxLength) return text
162 const room = maxLength - 1
163 let cut = text.slice(0, room)
164 const lastSpace = cut.lastIndexOf(' ')
165 if (lastSpace > room / 3) cut = cut.slice(0, lastSpace)
166
167 return cut.replace(/[\s,;:\-–—.]+$/, '') + '…'
168}
169
170/** Haiku's job name: its first line, cleaned, at most six words. */
171export function cleanJobTitle(rawTitle: string): string {
172 const firstLine = rawTitle.split('\n').find(line => line.trim() !== '') ?? ''
173 const unquoted = firstLine.replace(/^[\s"'*#>-]+|[\s"'*.!]+$/g, '')
174 const shortened = unquoted.split(/\s+/).slice(0, 6).join(' ')
175
176 return cleanStepName(shortened)
177}
178
179// ---------------------------------------------------------------------------
180// Checklist changes: pure functions over the shared value
181// ---------------------------------------------------------------------------
182
183function makeTask(id: string, name: string, status: CleanViewTaskStatus): CleanViewTask {
184 return { id, name, status, percent: status === 'done' ? 100 : 0, hasReported: false }
185}
186
187function isSameName(left: string, right: string): boolean {
188 return left.toLowerCase() === right.toLowerCase()
189}
190
191export function clampPercent(rawPercent: unknown): number {
192 const percent = Number(rawPercent)
193 if (!Number.isFinite(percent)) return 0
194
195 return Math.round(Math.min(100, Math.max(0, percent)))
196}
197
198/** A fresh job with placeholder steps, shown until the real plan arrives. */
199export function startJob(previousJobId: number, now: number): CleanViewChecklist {
200 return {
201 jobId: previousJobId + 1,
202 title: DEFAULT_TITLE,
203 phase: 'working',
204 tasks: PLACEHOLDER_STEPS.map((name, index) =>
205 makeTask(`placeholder-${index + 1}`, name, index === 0 ? 'active' : 'upcoming'),
206 ),
207 hasPlan: false,
208 needsYouReason: null,
209 stuckReason: null,
210 startedAt: now,
211 finishedAt: null,
212 isCollapsed: false,
213 helpers: [],
214 }
215}
216
217/** Keeps exactly one step active while any step is unfinished. */
218export function settleActiveStep(tasks: readonly CleanViewTask[]): CleanViewTask[] {
219 let hasActive = false
220 const withOneActive = tasks.map(task => {
221 if (task.status !== 'active') return task
222 if (hasActive) return { ...task, status: 'upcoming' as const }
223 hasActive = true
224 return task
225 })
226 if (hasActive) return withOneActive
227
228 const firstOpenIndex = withOneActive.findIndex(task => task.status === 'upcoming')
229 return withOneActive.map((task, index) =>
230 index === firstOpenIndex ? { ...task, status: 'active' as const } : task,
231 )
232}
233
234/** Brings a finished job back to life when Claude keeps working on it. */
235function reopen(checklist: CleanViewChecklist): CleanViewChecklist {
236 const isFinished = checklist.phase === 'done' || checklist.phase === 'stopped' || checklist.phase === 'idle'
237 return isFinished ? { ...checklist, phase: 'working', finishedAt: null, isCollapsed: false } : checklist
238}
239
240export function clearNeedsYou(checklist: CleanViewChecklist): CleanViewChecklist {
241 return checklist.phase === 'needsYou' ? { ...checklist, phase: 'working', needsYouReason: null } : checklist
242}
243
244export function clearStuck(checklist: CleanViewChecklist): CleanViewChecklist {
245 return checklist.phase === 'stuck' ? { ...checklist, phase: 'working', stuckReason: null } : checklist
246}
247
248export function markNeedsYou(checklist: CleanViewChecklist, reason: string): CleanViewChecklist {
249 const isRunning = checklist.phase === 'working' || checklist.phase === 'needsYou' || checklist.phase === 'stuck'
250 return isRunning ? { ...checklist, phase: 'needsYou', needsYouReason: reason, stuckReason: null } : checklist
251}
252
253export function markStuck(checklist: CleanViewChecklist, reason: string): CleanViewChecklist {
254 const isRunning = checklist.phase === 'working' || checklist.phase === 'needsYou' || checklist.phase === 'stuck'
255 return isRunning ? { ...checklist, phase: 'stuck', stuckReason: reason, needsYouReason: null } : checklist
256}
257
258/** `plan_steps`: the plan replaces whatever was there; the first step starts. */
259export function applyPlan(checklist: CleanViewChecklist, rawNames: readonly unknown[]): CleanViewChecklist {
260 const names: string[] = []
261 for (const rawName of rawNames) {
262 const name = cleanStepName(rawName)
263 if (!names.some(existing => isSameName(existing, name))) names.push(name)
264 }
265 if (names.length === 0) return checklist
266
267 const tasks = names
268 .slice(0, MAX_PLAN_STEPS)
269 .map((name, index) => makeTask(`step-${index + 1}`, name, index === 0 ? 'active' : 'upcoming'))
270
271 return { ...checklist, tasks, hasPlan: true }
272}
273
274/** A step id no other row holds. */
275function freshStepId(tasks: readonly CleanViewTask[]): string {
276 let counter = tasks.length + 1
277 while (tasks.some(task => task.id === `step-${counter}`)) counter += 1
278
279 return `step-${counter}`
280}
281
282/** `report_progress`: earlier steps are checked off; 100 finishes the step and starts the next. */
283export function applyProgress(
284 checklist: CleanViewChecklist,
285 rawTaskName: unknown,
286 rawPercent: unknown,
287): CleanViewChecklist {
288 const name = cleanStepName(rawTaskName)
289 const percent = clampPercent(rawPercent)
290 let tasks = checklist.hasPlan ? [...checklist.tasks] : []
291 let reportedIndex = tasks.findIndex(task => isSameName(task.name, name))
292
293 if (reportedIndex === -1) {
294 // A step that isn't in the plan: slot it in where the work is happening now.
295 const activeIndex = tasks.findIndex(task => task.status === 'active')
296 const firstOpenIndex = tasks.findIndex(task => task.status !== 'done')
297 const insertAt = activeIndex !== -1 ? activeIndex : firstOpenIndex !== -1 ? firstOpenIndex : tasks.length
298 const newTask = makeTask(freshStepId(tasks), name, 'active')
299 tasks = tasks.map(task => (task.status === 'active' ? { ...task, status: 'upcoming' as const } : task))
300 tasks.splice(insertAt, 0, newTask)
301 reportedIndex = insertAt
302 }
303
304 tasks = tasks.map((task, index) => {
305 if (index < reportedIndex) {
306 return task.status === 'done' ? task : { ...task, status: 'done' as const, percent: 100 }
307 }
308 if (index === reportedIndex) {
309 return percent >= 100
310 ? { ...task, status: 'done' as const, percent: 100, hasReported: true }
311 : { ...task, status: 'active' as const, percent, hasReported: true }
312 }
313 return task.status === 'active' ? { ...task, status: 'upcoming' as const } : task
314 })
315
316 return { ...checklist, tasks: settleActiveStep(tasks), hasPlan: true }
317}
318
319type TodoItem = { readonly content: string; readonly status: string }
320
321/** TodoWrite: the to-do list becomes the plan, keeping progress already reported. */
322export function applyTodos(checklist: CleanViewChecklist, todos: readonly TodoItem[]): CleanViewChecklist {
323 if (todos.length === 0) return checklist
324 const previousByName = new Map(checklist.tasks.map(task => [task.name.toLowerCase(), task]))
325
326 const tasks = todos.map((todo, index): CleanViewTask => {
327 const name = cleanStepName(todo.content)
328 const status = todoStatus(todo.status)
329 const previous = previousByName.get(name.toLowerCase())
330 const keepsProgress = previous !== undefined && status === 'active' && previous.hasReported
331 return {
332 id: `todo-${index + 1}`,
333 name,
334 status,
335 percent: status === 'done' ? 100 : keepsProgress ? previous.percent : 0,
336 hasReported: keepsProgress,
337 }
338 })
339
340 return { ...checklist, tasks: settleActiveStep(tasks), hasPlan: true }
341}
342
343function todoStatus(status: string | undefined): CleanViewTaskStatus {
344 if (status === 'completed') return 'done'
345 if (status === 'in_progress') return 'active'
346
347 return 'upcoming'
348}
349
350/** TaskCreate: one more row; the first task created replaces any other kind of plan. */
351export function applyTaskCreate(checklist: CleanViewChecklist, taskId: string, subject: string): CleanViewChecklist {
352 const existingTasks = checklist.tasks.filter(task => task.id.startsWith('task-'))
353 const tasks = [...existingTasks, makeTask(`task-${taskId}`, cleanStepName(subject), 'upcoming')]
354
355 return { ...checklist, tasks: settleActiveStep(tasks), hasPlan: true }
356}
357
358/** TaskUpdate: moves, renames or removes a task row. */
359export function applyTaskUpdate(
360 checklist: CleanViewChecklist,
361 taskId: string,
362 status: string | undefined,
363 subject: string | undefined,
364): CleanViewChecklist {
365 const rowId = `task-${taskId}`
366 if (!checklist.tasks.some(task => task.id === rowId)) return checklist
367 if (status === 'deleted') {
368 return { ...checklist, tasks: settleActiveStep(checklist.tasks.filter(task => task.id !== rowId)) }
369 }
370
371 const tasks = checklist.tasks.map((task): CleanViewTask => {
372 if (task.id !== rowId) {
373 return status === 'in_progress' && task.status === 'active' ? { ...task, status: 'upcoming' } : task
374 }
375 const name = subject ? cleanStepName(subject) : task.name
376 if (status === 'completed') return { ...task, name, status: 'done', percent: 100 }
377 if (status === 'in_progress') return { ...task, name, status: 'active' }
378 if (status === 'pending') return { ...task, name, status: 'upcoming' }
379 return { ...task, name }
380 })
381
382 return { ...checklist, tasks: settleActiveStep(tasks) }
383}
384
385// ---------------------------------------------------------------------------
386// Helpers (the main chat's subagents): pure changes over the shared value
387// ---------------------------------------------------------------------------
388
389/** The checklist's helpers; state saved before helpers existed has none. */
390function helpersOf(checklist: CleanViewChecklist): readonly CleanViewHelper[] {
391 return checklist.helpers ?? []
392}
393
394function changeHelpers(
395 checklist: CleanViewChecklist,
396 isTarget: (helper: CleanViewHelper) => boolean,
397 change: (helper: CleanViewHelper) => CleanViewHelper,
398): CleanViewChecklist {
399 const helpers = helpersOf(checklist)
400 if (!helpers.some(isTarget)) return checklist
401
402 return { ...checklist, helpers: helpers.map(helper => (isTarget(helper) ? change(helper) : helper)) }
403}
404
405/** An Agent call started: a working row under the step that is active now. */
406export function addHelper(checklist: CleanViewChecklist, toolUseId: string, description: unknown, now: number): CleanViewChecklist {
407 const helpers = helpersOf(checklist)
408 if (helpers.some(helper => helper.id === toolUseId)) return checklist
409
410 const helper: CleanViewHelper = {
411 id: toolUseId,
412 agentId: null,
413 name: cleanStepName(description),
414 stepId: checklist.tasks.find(task => task.status === 'active')?.id ?? null,
415 status: 'working',
416 percent: 0,
417 hasReported: false,
418 startedAt: now,
419 finishedAt: null,
420 }
421
422 return { ...checklist, helpers: [...helpers, helper] }
423}
424
425/** An Agent call that was refused never became a helper. */
426export function removeHelper(checklist: CleanViewChecklist, toolUseId: string): CleanViewChecklist {
427 const helpers = helpersOf(checklist)
428 return helpers.some(helper => helper.id === toolUseId)
429 ? { ...checklist, helpers: helpers.filter(helper => helper.id !== toolUseId) }
430 : checklist
431}
432
433export function markHelperStuck(checklist: CleanViewChecklist, toolUseId: string, now: number): CleanViewChecklist {
434 return changeHelpers(checklist, helper => helper.id === toolUseId, helper => ({ ...helper, status: 'stuck', finishedAt: now }))
435}
436
437/** Links the Agent call to the agent that runs it; a link already made stays. */
438export function linkHelperAgent(checklist: CleanViewChecklist, toolUseId: string, agentId: string): CleanViewChecklist {
439 return changeHelpers(
440 checklist,
441 helper => helper.id === toolUseId && helper.agentId === null,
442 helper => ({ ...helper, agentId }),
443 )
444}
445
446/** A helper's own report_progress, found by its agent id. */
447export function reportHelperProgress(checklist: CleanViewChecklist, agentId: string, percent: number): CleanViewChecklist {
448 return changeHelpers(checklist, helper => helper.agentId === agentId, helper => ({ ...helper, percent, hasReported: true }))
449}
450
451/** A helper's turn began (or a message woke it again): it is working. */
452export function startHelperTurn(checklist: CleanViewChecklist, agentId: string): CleanViewChecklist {
453 return changeHelpers(
454 checklist,
455 helper => helper.agentId === agentId,
456 helper => ({ ...helper, status: 'working', finishedAt: null }),
457 )
458}
459
460/** A helper's turn ended: done at 100%, or stuck. */
461export function endHelperTurn(checklist: CleanViewChecklist, agentId: string, isStuck: boolean, now: number): CleanViewChecklist {
462 return changeHelpers(
463 checklist,
464 helper => helper.agentId === agentId,
465 helper =>
466 isStuck
467 ? { ...helper, status: 'stuck', finishedAt: now }
468 : { ...helper, status: 'done', percent: 100, finishedAt: now },
469 )
470}
471
472export function workingHelperCount(checklist: CleanViewChecklist): number {
473 return helpersOf(checklist).filter(helper => helper.status === 'working').length
474}
475
476/** After a reload: settle rows from what the engine says about each agent. */
477export function settleHelpers(
478 checklist: CleanViewChecklist,
479 agents: readonly { id: string; status: string }[],
480 now: number,
481): CleanViewChecklist {
482 const statusByAgent = new Map(agents.map(agent => [agent.id, agent.status]))
483
484 return changeHelpers(
485 checklist,
486 helper => helper.agentId !== null && helper.status === 'working' && statusByAgent.has(helper.agentId),
487 helper => {
488 const agentStatus = statusByAgent.get(helper.agentId ?? '')
489 if (agentStatus === 'completed') return { ...helper, status: 'done', percent: 100, finishedAt: now }
490 if (agentStatus === 'failed' || agentStatus === 'killed') return { ...helper, status: 'stuck', finishedAt: now }
491 return agentStatus === 'idle' ? { ...helper, status: 'idle' } : helper
492 },
493 )
494}
495
496/** The helpers' combined progress, 0 to 100, or null until one has reported or finished. */
497export function helperProgress(helpers: readonly CleanViewHelper[]): number | null {
498 const hasNews = helpers.some(helper => helper.hasReported || helper.status === 'done')
499 if (!hasNews || helpers.length === 0) return null
500 const percentSum = helpers.reduce((sum, helper) => {
501 if (helper.status === 'done') return sum + 100
502 return helper.hasReported ? sum + helper.percent : sum
503 }, 0)
504
505 return Math.round(percentSum / helpers.length)
506}
507
508/** The percent a step's meter shows: its own report, else its helpers' combined progress. */
509export function stepPercent(task: CleanViewTask, helpers: readonly CleanViewHelper[]): { percent: number; hasProgress: boolean } {
510 if (task.hasReported || task.status === 'done') return { percent: task.percent, hasProgress: true }
511 const combined = helperProgress(helpers.filter(helper => helper.stepId === task.id))
512
513 return combined === null ? { percent: task.percent, hasProgress: false } : { percent: combined, hasProgress: true }
514}
515
516export type TurnOutcome =
517 | { kind: 'error'; reason: string }
518 | { kind: 'refusal' }
519 | { kind: 'aborted' }
520 | { kind: 'answer' }
521
522/** The end of a turn: an error, a refusal, Esc, unfinished steps, or all done. */
523export function finishTurn(checklist: CleanViewChecklist, outcome: TurnOutcome, now: number): CleanViewChecklist {
524 if (checklist.finishedAt !== null || checklist.phase === 'idle') return checklist
525
526 if (outcome.kind === 'error') return { ...markStuckAlways(checklist, outcome.reason), finishedAt: now }
527 if (outcome.kind === 'refusal') return { ...markStuckAlways(checklist, REASONS.refusal), finishedAt: now }
528 // The person turned a step down: keep saying so rather than "Stopped".
529 if (checklist.phase === 'stuck' && checklist.stuckReason === REASONS.saidNo) return checklist
530 if (outcome.kind === 'aborted') {
531 return { ...checklist, phase: 'stopped', needsYouReason: null, stuckReason: null, finishedAt: now }
532 }
533
534 const hasUnfinishedSteps = checklist.hasPlan && checklist.tasks.some(task => task.status !== 'done')
535 if (hasUnfinishedSteps) {
536 return { ...checklist, phase: 'needsYou', needsYouReason: REASONS.waitingForReply, stuckReason: null }
537 }
538
539 return {
540 ...checklist,
541 phase: 'done',
542 tasks: checklist.tasks.map(task => ({ ...task, status: 'done' as const, percent: 100 })),
543 needsYouReason: null,
544 stuckReason: null,
545 finishedAt: now,
546 isCollapsed: false,
547 }
548}
549
550function markStuckAlways(checklist: CleanViewChecklist, reason: string): CleanViewChecklist {
551 return { ...checklist, phase: 'stuck', stuckReason: reason, needsYouReason: null }
552}
553
554/** One calm sentence for an API error, from its kind, details and message. */
555export function describeApiError(errorText: string): string {
556 const text = errorText.toLowerCase()
557 if (/rate.?limit|usage limit|\b429\b|quota/.test(text)) return REASONS.rateLimit
558 if (/overload|\b529\b|server.?error|\b50[0234]\b|unavailable/.test(text)) return REASONS.busy
559 if (/prompt is too long|too many tokens|context (window|length|limit)|exceeds? the (model'?s )?context/.test(text)) {
560 return REASONS.tooLong
561 }
562 if (/billing|credit balance|payment/.test(text)) return REASONS.billing
563 if (/auth|oauth|log ?in\b|\b401\b|\b403\b|credential|api.?key|verification_required|account_on_hold/.test(text)) {
564 return REASONS.login
565 }
566 if (/network|connect|econn|enotfound|etimedout|socket|fetch failed|offline|dns/.test(text)) return REASONS.network
567
568 return REASONS.unknownError
569}
570
571/** Which "Needs you" reason a notification means, if any. */
572export function notificationReason(notificationType: string, message: string): string | null {
573 if (/idle|auth_success/i.test(notificationType)) return null
574 const combined = `${notificationType} ${message}`.toLowerCase()
575 if (combined.includes('permission')) return REASONS.permission
576 if (/elicitation|question/.test(combined)) return REASONS.question
577
578 return null
579}
580
581// ---------------------------------------------------------------------------
582// Drawing helpers
583// ---------------------------------------------------------------------------
584
585/** "45s", "1m 12s", "1h 3m". */
586export function formatDuration(milliseconds: number): string {
587 const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000))
588 const hours = Math.floor(totalSeconds / 3600)
589 const minutes = Math.floor((totalSeconds % 3600) / 60)
590 const seconds = totalSeconds % 60
591 if (hours > 0) return `${hours}h ${minutes}m`
592 if (minutes > 0) return `${minutes}m ${seconds}s`
593
594 return `${seconds}s`
595}
596
597/** True when a tree shows any non-blank text or any Button. Never changes the tree. */
598export function hasVisibleContent(tree: RenderNode | null | undefined): boolean {
599 if (tree === null || tree === undefined) return false
600 if (typeof tree === 'string') return tree.trim() !== ''
601 if (tree.type === 'Button') return true
602
603 return childrenOf(tree).some(hasVisibleContent)
604}
605
606/** A tree's children; elements without any (the engine's own marker) give none. */
607function childrenOf(tree: RenderElement): readonly RenderNode[] {
608 return 'children' in tree ? (tree.children ?? []) : []
609}
610
611/** The direct child of a tree whose key matches, if any. */
612function childWithKey(tree: RenderElement, key: string): RenderNode | undefined {
613 return childrenOf(tree).find(
614 child => typeof child !== 'string' && 'props' in child && (child.props as { key?: unknown } | undefined)?.key === key,
615 )
616}
617
618/** A 10-cell meter filled to `percent`. */
619function filledMeter(percent: number): string {
620 const filledCells = Math.round((clampPercent(percent) / 100) * METER_CELLS)
621 return '█'.repeat(filledCells) + '░'.repeat(METER_CELLS - filledCells)
622}
623
624/** A 10-cell meter with a 3-cell block bouncing across it. */
625function sweepMeter(frame: number): string {
626 const blockCells = 3
627 const travel = METER_CELLS - blockCells
628 const step = frame % (travel * 2)
629 const start = step <= travel ? step : travel * 2 - step
630 let meter = ''
631 for (let cell = 0; cell < METER_CELLS; cell += 1) {
632 meter += cell >= start && cell < start + blockCells ? '█' : '░'
633 }
634
635 return meter
636}
637
638/** Pads or cuts a name to exactly `width` cells so rows never wrap. */
639function fitToWidth(text: string, width: number): string {
640 if (text.length <= width) return text.padEnd(width)
641 return text.slice(0, Math.max(0, width - 1)) + '…'
642}
643
644// ---------------------------------------------------------------------------
645// Engine-facing helpers
646// ---------------------------------------------------------------------------
647
648type Engine = EngineInterface
649
650/** Applies a change to the current job (if any) and keeps the animation timer in step. */
651async function changeChecklist(
652 $: Engine,
653 change: (checklist: CleanViewChecklist) => CleanViewChecklist,
654): Promise<CleanViewChecklist | null> {
655 const changed = await update($, checklistAtom, checklist => (checklist === null ? null : change(checklist)))
656 await syncAnimationTimer($)
657
658 return changed
659}
660
661/** Runs the 250ms frame clock only while a step is working; a paused sweep just stops. */
662async function syncAnimationTimer($: Engine): Promise<void> {
663 const isEnabled = await read($, enabledAtom)
664 const checklist = await read($, checklistAtom)
665 const shouldAnimate = isEnabled && checklist !== null && checklist.phase === 'working'
666
667 if (shouldAnimate && animationTimer === undefined) {
668 animationTimer = $.clock.every(TICK_MS, () => {
669 void update($, tickAtom, frame => frame + 1)
670 })
671 } else if (!shouldAnimate && animationTimer !== undefined) {
672 animationTimer.cancel()
673 animationTimer = undefined
674 }
675}
676
677/** Shrinks a finished job to one line after five seconds. */
678function scheduleCollapse($: Engine, jobId: number, delayMs: number): void {
679 $.clock.after(Math.max(1, delayMs), () => {
680 void update($, checklistAtom, checklist =>
681 checklist !== null && checklist.jobId === jobId && checklist.phase === 'done'
682 ? { ...checklist, isCollapsed: true }
683 : checklist,
684 )
685 })
686}
687
688/** When the last helper ends after the main turn did, the job finishes as an answered turn does. */
689async function finishJobIfHelpersDone($: Engine): Promise<void> {
690 if (isMainTurnRunning) return
691 const now = await $.clock.now()
692 let wasWaiting = false
693 const finished = await changeChecklist($, checklist => {
694 wasWaiting = checklist.phase === 'working' && workingHelperCount(checklist) === 0
695 return wasWaiting ? finishTurn(checklist, { kind: 'answer' }, now) : checklist
696 })
697 if (wasWaiting && finished !== null && finished.phase === 'done' && !finished.isCollapsed) {
698 scheduleCollapse($, finished.jobId, COLLAPSE_AFTER_MS)
699 }
700}
701
702/** Asks Haiku for a short job name in the background; a newer job wins. */
703function scheduleJobNaming($: Engine, jobId: number, promptText: string): void {
704 $.clock.after(1, () => {
705 void nameJob($, jobId, promptText)
706 })
707}
708
709async function nameJob($: Engine, jobId: number, promptText: string): Promise<void> {
710 try {
711 const reply = await $.model.complete({
712 model: 'haiku',
713 effort: 'low',
714 maxTokens: 32,
715 timeoutMs: 15000,
716 system:
717 'You name jobs for a progress checklist. Reply with the name only: 2 to 6 plain English words, ' +
718 'starting with a verb, with no quotes, file names, code or ending punctuation.',
719 prompt: `Name this request:\n\n${promptText.slice(0, 2000)}`,
720 })
721 if (!reply.isAnswered) return
722
723 const title = cleanJobTitle(reply.text)
724 if (title === FALLBACK_NAME) return
725 await update($, checklistAtom, checklist =>
726 checklist !== null && checklist.jobId === jobId ? { ...checklist, title } : checklist,
727 )
728 } catch {
729 // Naming is a nicety: the placeholder title stays.
730 }
731}
732
733/** Turns Clean View on, off or over; saves the choice and says so. */
734async function setCleanView($: Engine, wanted: boolean | 'flip'): Promise<boolean> {
735 const isEnabled = await update($, enabledAtom, current => (wanted === 'flip' ? !current : wanted))
736 try {
737 await $.store.set(STORE_KEY, isEnabled)
738 } catch {
739 // The choice still holds for this session.
740 }
741 showToast($, isEnabled ? 'Clean View is on. Tool details are hidden.' : 'Clean View is off. Showing everything.')
742 await syncAnimationTimer($)
743
744 return isEnabled
745}
746
747function showToast($: Engine, text: string): void {
748 try {
749 $.ui.toast(text)
750 } catch {
751 // A toast that cannot show is no reason to fail the toggle.
752 }
753}
754
755function readStepList(rawSteps: unknown): string[] {
756 return Array.isArray(rawSteps) ? rawSteps.filter((step): step is string => typeof step === 'string') : []
757}
758
759// ---------------------------------------------------------------------------
760// Drawing the band
761// ---------------------------------------------------------------------------
762
763type BandInput = RenderInput<'AbovePrompt'>
764
765type TextElement = ElementConstructor<TextProps>
766type BoxElement = ElementConstructor<BoxProps>
767
768/** What a row needs to line its columns up. */
769type RowLayout = { nameWidth: number; rowWidth: number; frame: number }
770
771/** A piece of the header line; the cuttable one gives way when the line is too long. */
772type HeaderSegment = {
773 text: string
774 weight?: 'bold'
775 color?: string
776 backgroundColor?: string
777 isDim?: boolean
778 isCuttable?: boolean
779}
780
781/** The header line for each state of the job, as pieces. */
782function headerSegments(checklist: CleanViewChecklist, now: number, waitingHelpers: number): HeaderSegment[] {
783 switch (checklist.phase) {
784 case 'needsYou':
785 return [
786 { text: ' Needs you ', backgroundColor: 'warning', color: 'inverseText', weight: 'bold' },
787 { text: ' ' },
788 { text: checklist.needsYouReason ?? REASONS.waitingForReply, isCuttable: true },
789 ]
790 case 'stuck':
791 return [
792 { text: '⚠ Stuck:', color: 'warning', weight: 'bold' },
793 { text: ' ' },
794 { text: checklist.stuckReason ?? REASONS.unknownError, isCuttable: true },
795 ]
796 case 'stopped':
797 return [
798 { text: '■ Stopped', weight: 'bold' },
799 { text: ` · ${checklist.title} · you pressed Esc`, isDim: true, isCuttable: true },
800 ]
801 case 'done':
802 return [
803 { text: '✓ All done', color: 'success', weight: 'bold' },
804 {
805 text: ` · ${checklist.title} · took ${formatDuration((checklist.finishedAt ?? now) - checklist.startedAt)}`,
806 isDim: true,
807 isCuttable: true,
808 },
809 ]
810 default:
811 return [
812 { text: checklist.title, weight: 'bold', isCuttable: true },
813 ...(waitingHelpers > 0
814 ? [{ text: ` · Waiting for ${waitingHelpers} ${waitingHelpers === 1 ? 'helper' : 'helpers'}`, isDim: true }]
815 : []),
816 { text: ` · ${formatDuration(now - checklist.startedAt)}`, isDim: true },
817 ]
818 }
819}
820
821/** Cuts the cuttable piece with … so the whole line fits in `maxCells`. */
822function fitSegments(segments: readonly HeaderSegment[], maxCells: number): HeaderSegment[] {
823 const totalCells = segments.reduce((sum, segment) => sum + segment.text.length, 0)
824 const excessCells = totalCells - maxCells
825 if (excessCells <= 0) return [...segments]
826
827 return segments.map(segment =>
828 segment.isCuttable === true
829 ? { ...segment, text: segment.text.slice(0, Math.max(1, segment.text.length - excessCells - 1)).trimEnd() + '…' }
830 : segment,
831 )
832}
833
834function drawSegments(Text: TextElement, segments: readonly HeaderSegment[]): RenderElement {
835 return (
836 <Text>
837 {segments.map(segment => {
838 const style: TextProps = {}
839 if (segment.color !== undefined) style.color = segment.color
840 if (segment.backgroundColor !== undefined) style.backgroundColor = segment.backgroundColor
841 if (segment.weight !== undefined) style.weight = segment.weight
842 if (segment.isDim === true) style.dimColor = true
843 return <Text {...style}>{segment.text}</Text>
844 })}
845 </Text>
846 )
847}
848
849/** How many cells a tree takes in a row: its text, and a Button's label with its brackets. */
850function contentWidth(tree: RenderNode | undefined): number {
851 if (tree === undefined) return 0
852 if (typeof tree === 'string') return tree.length
853 if (tree.type === 'Button') return String((tree.props as { label?: unknown }).label ?? '').length + 4
854
855 return childrenOf(tree).reduce((sum, child) => sum + contentWidth(child), 0)
856}
857
858/** How many helpers the job waits on once the main turn has ended. */
859function waitingHelperCount(checklist: CleanViewChecklist): number {
860 return checklist.phase === 'working' && !isMainTurnRunning ? workingHelperCount(checklist) : 0
861}
862
863/** Up to five helper rows for one step, then one dim line for the rest. */
864function drawHelperRows(
865 Box: BoxElement,
866 Text: TextElement,
867 stepHelpers: readonly CleanViewHelper[],
868 { nameWidth, rowWidth, frame }: RowLayout,
869): RenderElement[] {
870 // Helpers still needing a look come first, finished ones after.
871 const isFinished = (helper: CleanViewHelper) => helper.status === 'done'
872 const ordered = [...stepHelpers.filter(helper => !isFinished(helper)), ...stepHelpers.filter(isFinished)]
873 const shown = ordered.slice(0, MAX_HELPER_ROWS)
874 const hidden = ordered.slice(MAX_HELPER_ROWS)
875
876 const rows: RenderElement[] = shown.map((helper, index) => {
877 const isLastRow = index === shown.length - 1 && hidden.length === 0
878 const name = fitToWidth(helper.name, nameWidth - 2)
879 const glyph = <Text dimColor>{` ${isLastRow ? '└' : '├'} `}</Text>
880 let nameText: RenderElement = <Text>{name}</Text>
881 let meter: RenderElement = <Text color="claude">{helper.hasReported ? filledMeter(helper.percent) : sweepMeter(frame)}</Text>
882 let label: RenderElement = <Text>{helper.hasReported ? `${helper.percent}%` : 'Working'}</Text>
883 if (helper.status === 'done') {
884 nameText = <Text dimColor>{name}</Text>
885 meter = <Text color="success">{filledMeter(100)}</Text>
886 label = <Text dimColor>Done</Text>
887 } else if (helper.status === 'stuck') {
888 meter = <Text color="warning">{filledMeter(helper.percent)}</Text>
889 label = <Text color="warning">Stuck</Text>
890 } else if (helper.status === 'idle') {
891 meter = <Text dimColor>{filledMeter(helper.percent)}</Text>
892 label = <Text dimColor>Idle</Text>
893 }
894
895 return (
896 <Box key={`helper-${helper.id}`} width={rowWidth}>
897 <Text wrap="truncate-end">
898 {glyph}
899 {nameText} {meter}
900 {' '}
901 {label}
902 </Text>
903 </Box>
904 )
905 })
906
907 if (hidden.length > 0) {
908 const hiddenDoneCount = hidden.filter(isFinished).length
909 rows.push(
910 <Box key="helper-more" width={rowWidth}>
911 <Text dimColor wrap="truncate-end">
912 {' '}└ +{hidden.length} more helpers · {hiddenDoneCount} done
913 </Text>
914 </Box>,
915 )
916 }
917
918 return rows
919}
920
921async function drawBand($: Engine, e: BandInput, beneath: RenderElement | undefined): Promise<RenderElement> {
922 const { Box, Text, Button } = $.ui.resolve(e)
923 const isEnabled = await read($, enabledAtom)
924 const checklist = await read($, checklistAtom)
925 const frame = await read($, tickAtom)
926 const bodyColumns = Math.max(24, e.props.bodyColumns)
927
928 const toggleLabel = isEnabled ? '● Clean View: ON' : '○ Clean View: OFF'
929 const toggleButton = (
930 <Button
931 key="toggle"
932 label={toggleLabel}
933 onPress={() => {
934 void setCleanView($, 'flip')
935 }}
936 />
937 )
938
939 // What a hook beneath offers: its corner and body slots, or a whole tree to show under the rows.
940 const slots = beneath !== undefined && beneath.type === 'Box' && beneath.props?.key === 'band-slots' ? beneath : undefined
941 const cornerContent = slots === undefined ? undefined : childWithKey(slots, 'corner')
942 const bodyContent = slots === undefined ? undefined : childWithKey(slots, 'body')
943 const extraTree = slots === undefined && hasVisibleContent(beneath) ? beneath : undefined
944 const beneathRows = extraTree === undefined ? [] : [<Box key="band-beneath">{extraTree}</Box>]
945
946 // The header never outgrows the band: it leaves room for the toggle, the corner and the box.
947 const toggleCells = toggleLabel.length + 4
948 const cornerCells = cornerContent === undefined ? 0 : contentWidth(cornerContent) + 1
949 const headerCells = Math.max(10, bodyColumns - BOX_CHROME_COLUMNS - toggleCells - cornerCells - 2)
950
951 const showsJob = isEnabled && checklist !== null && checklist.phase !== 'idle'
952 const header = showsJob
953 ? drawSegments(Text, fitSegments(headerSegments(checklist, await $.clock.now(), waitingHelperCount(checklist)), headerCells))
954 : <Text dimColor>Ready</Text>
955
956 // The box hugs its widest row; the spacer pushes the toggle and corner to its right edge.
957 const headerRow = (
958 <Box key="band-header" flexDirection="row">
959 <Box flexShrink={0}>{header}</Box>
960 <Box flexGrow={1} />
961 <Box flexShrink={0} flexDirection="row" marginLeft={2}>
962 {toggleButton}
963 <Box key="band-corner" flexDirection="row" flexShrink={0} marginLeft={cornerContent === undefined ? 0 : 1}>
964 {cornerContent}
965 </Box>
966 </Box>
967 </Box>
968 )
969 const bodySlot = (
970 <Box key="band-body" flexDirection="column" alignItems="flex-end">
971 {bodyContent}
972 </Box>
973 )
974 const drawFrame = (children: RenderNode[]) => (
975 <Box flexDirection="column" alignItems="flex-start">
976 <Box key="band-frame" flexDirection="column" borderStyle="round" borderColor={BORDER_COLOR} paddingX={1}>
977 {children}
978 </Box>
979 </Box>
980 )
981 if (!showsJob || checklist.isCollapsed) return drawFrame([headerRow, bodySlot, ...beneathRows])
982
983 // "Step 3 of 5" and one parallelogram per step: filled for done steps.
984 const helpers = helpersOf(checklist)
985 const stepCount = checklist.tasks.length
986 const doneCount = checklist.tasks.filter(task => task.status === 'done').length
987 const stepLabel = `Step ${Math.min(stepCount, doneCount + 1)} of ${stepCount}`
988 const progressRow = (
989 <Box key="band-progress" width={stepLabel.length + 2 + stepCount}>
990 <Text>
991 {stepLabel}
992 {' '}
993 <Text color="claude">{'▰'.repeat(doneCount)}</Text>
994 <Text dimColor>{'▱'.repeat(stepCount - doneCount)}</Text>
995 </Text>
996 </Box>
997 )
998
999 // Size the name column to the longest name (helpers sit two cells further in) that fits beside the meter.
1000 const longestName = Math.max(
1001 ...checklist.tasks.map(task => task.name.length),
1002 ...helpers.map(helper => helper.name.length + 2),
1003 8,
1004 )
1005 const nameWidth = Math.max(6, Math.min(longestName, MAX_NAME_LENGTH, bodyColumns - BOX_CHROME_COLUMNS - ROW_CHROME_CELLS))
1006 const rowWidth = 2 + nameWidth + 1 + METER_CELLS + 2 + LABEL_CELLS
1007 const rowLayout: RowLayout = { nameWidth, rowWidth, frame }
1008 const isWorking = checklist.phase === 'working'
1009 const isPaused = checklist.phase === 'needsYou'
1010 const firstUpcomingId = checklist.tasks.find(task => task.status === 'upcoming')?.id
1011 const stepIds = new Set(checklist.tasks.map(task => task.id))
1012
1013 const rows = checklist.tasks.flatMap((task, taskIndex) => {
1014 const name = fitToWidth(task.name, nameWidth)
1015 let stepRow: RenderElement
1016 if (task.status === 'done') {
1017 stepRow = (
1018 <Box key={`row-${task.id}`} width={rowWidth}>
1019 <Text wrap="truncate-end">
1020 <Text color="success">✓</Text> <Text dimColor>{name}</Text>{' '}
1021 <Text color="success">{filledMeter(100)}</Text>{' '}
1022 <Text dimColor>Done</Text>
1023 </Text>
1024 </Box>
1025 )
1026 } else if (task.status === 'active') {
1027 // Helpers' combined progress fills the meter until the step reports its own.
1028 const stepProgress = stepPercent(task, helpers)
1029 const meter = stepProgress.hasProgress ? filledMeter(stepProgress.percent) : sweepMeter(isWorking ? frame : 0)
1030 stepRow = (
1031 <Box key={`row-${task.id}`} width={rowWidth}>
1032 <Text wrap="truncate-end">
1033 <Text color={isPaused ? 'warning' : 'claude'}>{isPaused ? '‖' : '▶'}</Text>{' '}
1034 <Text weight="bold">{name}</Text> <Text color="claude">{meter}</Text>{' '}
1035 <Text>{stepProgress.hasProgress ? `${stepProgress.percent}%` : 'Working'}</Text>
1036 </Text>
1037 </Box>
1038 )
1039 } else {
1040 stepRow = (
1041 <Box key={`row-${task.id}`} width={rowWidth}>
1042 <Text wrap="truncate-end" dimColor>
1043 ○ {name} {'░'.repeat(METER_CELLS)}
1044 {' '}
1045 {task.id === firstUpcomingId ? 'Next' : 'Up next'}
1046 </Text>
1047 </Box>
1048 )
1049 }
1050
1051 // Helper rows hang under their step while it is active or a helper still works; none once done.
1052 const isLastStep = taskIndex === checklist.tasks.length - 1
1053 const stepHelpers = helpers.filter(
1054 helper => helper.stepId === task.id || (isLastStep && (helper.stepId === null || !stepIds.has(helper.stepId))),
1055 )
1056 const showsHelpers =
1057 checklist.phase !== 'done' &&
1058 stepHelpers.length > 0 &&
1059 (task.status === 'active' || stepHelpers.some(helper => helper.status === 'working'))
1060 if (!showsHelpers) return [stepRow]
1061
1062 return [stepRow, ...drawHelperRows(Box, Text, stepHelpers, { ...rowLayout, frame: isWorking ? frame : 0 })]
1063 })
1064
1065 return drawFrame([
1066 headerRow,
1067 bodySlot,
1068 progressRow,
1069 <Box key="band-rows" flexDirection="column">
1070 {rows}
1071 </Box>,
1072 ...beneathRows,
1073 ])
1074}
1075
1076// ---------------------------------------------------------------------------
1077// Hooks
1078// ---------------------------------------------------------------------------
1079
1080export function registerCleanView(on: On): void {
1081 // Session start: restore the saved choice, register the tools and /simple, resume timers.
1082 on('session.start', async ($, e, next) => {
1083 try {
1084 const savedChoice = await $.store.get(STORE_KEY)
1085 if (typeof savedChoice === 'boolean') await update($, enabledAtom, () => savedChoice)
1086 } catch {
1087 // No saved choice: Clean View starts on.
1088 }
1089
1090 await $.tool.register({
1091 name: PLAN_TOOL_NAME,
1092 description:
1093 'Lay out every step of the current job up front, before any other tool: 2 to 8 short plain-English ' +
1094 'names in order, each under 40 characters and starting with a verb (like "Build the pricing section"). ' +
1095 'Never put file paths, file names, commands, code or tool names in a step. The first step starts right away.',
1096 inputSchema: {
1097 type: 'object',
1098 properties: {
1099 steps: {
1100 type: 'array',
1101 items: { type: 'string' },
1102 minItems: 2,
1103 maxItems: MAX_PLAN_STEPS,
1104 description: 'Every step of the job, in order.',
1105 },
1106 },
1107 required: ['steps'],
1108 },
1109 isDeferred: false,
1110 })
1111 await $.tool.register({
1112 name: REPORT_TOOL_NAME,
1113 description:
1114 "Report progress on the current step of the plan: the step's name and a percent from 0 to 100. " +
1115 'Reporting a step checks off every step before it; 100 finishes it and starts the next one. ' +
1116 'A name that is not in the plan becomes a new step.',
1117 inputSchema: {
1118 type: 'object',
1119 properties: {
1120 task: { type: 'string', description: 'The step name, as planned.' },
1121 percent: { type: 'number', minimum: 0, maximum: 100, description: 'How far along the step is.' },
1122 },
1123 required: ['task', 'percent'],
1124 },
1125 isDeferred: false,
1126 })
1127 areToolsRegistered = true
1128
1129 await $.command.register({
1130 name: 'simple',
1131 description: 'Turn Clean View on or off (no argument switches it)',
1132 argumentHint: 'on|off',
1133 immediate: true,
1134 })
1135
1136 // A reload drops timers: pick the animation and any pending collapse back up.
1137 await syncAnimationTimer($)
1138 const checklist = await read($, checklistAtom)
1139 if (checklist !== null && checklist.phase === 'done' && !checklist.isCollapsed && checklist.finishedAt !== null) {
1140 const elapsedSinceDone = (await $.clock.now()) - checklist.finishedAt
1141 scheduleCollapse($, checklist.jobId, COLLAPSE_AFTER_MS - elapsedSinceDone)
1142 }
1143
1144 // Helpers that ended while the plugin was away settle from the engine's list.
1145 try {
1146 const agents = await $.agent.list()
1147 const now = await $.clock.now()
1148 await changeChecklist($, current => settleHelpers(current, agents, now))
1149 } catch {
1150 // Rows stay as they were.
1151 }
1152
1153 return next(e)
1154 })
1155
1156 // Tell Claude to plan in plain English while Clean View is on.
1157 on('prompt.compose', async ($, e, next) => {
1158 const composed = await next(e)
1159 const offersPlanTool = e.tools.includes(PLAN_TOOL) || e.tools.includes('ToolSearch')
1160 if (!areToolsRegistered || !offersPlanTool || e.traits.includes('teammate')) return composed
1161 if (!(await read($, enabledAtom))) return composed
1162
1163 return {
1164 sections: [...composed.sections, { id: 'clean-view:checklist', text: CHECKLIST_PROMPT, scope: 'session' }],
1165 }
1166 })
1167
1168 // Note whether the next turn comes from a typed prompt or a slash command.
1169 on('prompt.submit', async ($, e, next) => {
1170 // A plugin that sends the person's own words (asUser), like Prompt Upgrade's Run, starts a job too.
1171 const isSentAsPerson = e.origin.kind === 'plugin' && e.origin.asUser === true
1172 const isPersonsRequest = !e.isMeta && (NEW_JOB_ORIGINS.has(e.origin.kind) || isSentAsPerson)
1173 lastSubmitKind = e.command !== undefined ? 'command' : isPersonsRequest ? 'prompt' : undefined
1174
1175 const submitted = await next(e)
1176 // A prompt another mod held back never starts a turn, so it must not mark the next one.
1177 if (submitted.drop !== undefined) lastSubmitKind = undefined
1178 return submitted
1179 }).catch(($, e, next) => next(e))
1180
1181 // A real prompt while nothing is running starts a new job; a reply resumes a waiting one.
1182 on('turn.start', async ($, e, next) => {
1183 // A helper's turn: its row is working again.
1184 const helperAgentId = e.agentId
1185 if (helperAgentId !== undefined) {
1186 await changeChecklist($, checklist => startHelperTurn(checklist, helperAgentId))
1187 return next(e)
1188 }
1189 isMainTurnRunning = true
1190
1191 const submitKind = lastSubmitKind
1192 lastSubmitKind = undefined
1193 failedCallsInRow = 0
1194 lastApiErrorText = ''
1195
1196 const promptText = e.text.trim()
1197 // Only a typed prompt starts a job; a meta or engine-made turn (a helper's report) carries on the current one.
1198 const isRealPrompt = promptText !== '' && !promptText.startsWith('/') && submitKind === 'prompt'
1199 const current = await read($, checklistAtom)
1200 const isWaitingOnPerson =hooks/dock.tsx 885 lines1// Agent Dock: splits each request across a chosen team of helpers and shows each one live.
2import { atom, read, update } from 'claude-code'
3import type { ElementConstructor, Elements, EngineInterface, On, RenderElement, RenderInput, TextProps, Timer } from 'claude-code'
4
5import type { DockCard, DockHelperModel, DockMission, DockSettings } from '../types'
6import {
7 BAD_COMMAND_SIZE,
8 BAD_CUSTOM_SIZE,
9 COLORS,
10 DEFAULT_DOCK_SETTINGS,
11 HELPERS_SKIP_PLAN,
12 MAX_SEATS,
13 NARROW_WINDOW,
14 PRESET_SIZES,
15 STOPPED_MESSAGE,
16 badgeText,
17 bigTeamQuestion,
18 blend,
19 capMessage,
20 cardElapsed,
21 cardLayout,
22 clampPercent,
23 countCards,
24 dockCommand,
25 dropCard,
26 removeCard,
27 endTurn,
28 finishCard,
29 formatClock,
30 HELPER_MODELS,
31 autoModelMessage,
32 defaultEffortFor,
33 helperModelLabel,
34 helperRules,
35 helpersAtOnce,
36 infoLine,
37 instructionText,
38 isLive,
39 linkAgent,
40 meterCells,
41 missionElapsed,
42 missionPercent,
43 missionShares,
44 needsConfirmation,
45 nudgeText,
46 parseSize,
47 queueCard,
48 reportHelper,
49 restoreHelperModel,
50 restoreSize,
51 seatColor,
52 shouldNudge,
53 startCard,
54 startMission,
55 summaryLine,
56 sweepStart,
57} from './dock-logic'
58
59// ---------------------------------------------------------------------------
60// Names and timings
61// ---------------------------------------------------------------------------
62
63const PANE_ID = 'agent-dock'
64const PANE_TITLE = 'Agent Dock'
65// Wide enough for two cards a row when docked beside the transcript.
66const PANE_COLUMNS = 80
67const STORE_TEAM_SIZE = 'dock.teamSize'
68const STORE_HELPER_MODEL = 'panel.helperModel'
69const STORE_HELPER_MODEL_CHOSEN = 'panel.helperModelChosen'
70// Agent types with no model of their own: Auto asks Claude to name one for them.
71const UNPINNED_AGENT_TYPES: ReadonlySet<string> = new Set(['general-purpose', 'claude'])
72const DOCK_TICK_MS = 500
73const HEADSHOT_FOLDER = 'employee-headshots'
74
75const dockRef = { plugin: 'clean-view', key: 'dock' } as const
76const settingsAtom = atom(dockRef, DEFAULT_DOCK_SETTINGS)
77const missionAtom = atom({ plugin: 'clean-view', key: 'dockMission' } as const, null)
78const tickAtom = atom({ plugin: 'clean-view', key: 'dockTick' } as const, 0)
79const headshotsAtom = atom({ plugin: 'clean-view', key: 'dockHeadshots' } as const, [])
80
81// Module-only bookkeeping: none of it is drawn, so a reload may reset it.
82let dockClock: Timer | undefined
83let runningHelpers = 0
84
85type Engine = EngineInterface
86type TextElement = ElementConstructor<TextProps>
87
88// ---------------------------------------------------------------------------
89// Engine helpers
90// ---------------------------------------------------------------------------
91
92/** The session's model as an Agent tool alias, or null when it names none. */
93async function sessionModelAlias($: Engine): Promise<string | null> {
94 try {
95 return (await $.session.model()).toLowerCase().match(/haiku|sonnet|opus|fable/)?.[0] ?? null
96 } catch {
97 return null
98 }
99}
100
101/** How many helpers run at once, from the engine's two limits. */
102async function atOnceOf($: Engine): Promise<number> {
103 try {
104 const maxSubagents = await $.env.get('CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS')
105 const maxToolConcurrency = await $.env.get('CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY')
106 return helpersAtOnce(maxSubagents, maxToolConcurrency)
107 } catch {
108 return helpersAtOnce(undefined, undefined)
109 }
110}
111
112function showToast($: Engine, text: string): void {
113 try {
114 $.ui.toast(text)
115 } catch {
116 // A toast that cannot show is no reason to fail.
117 }
118}
119
120/** Applies a change to the mission (made when missing) and keeps clock and badge in step. */
121async function changeMission(
122 $: Engine,
123 change: (mission: DockMission) => DockMission,
124): Promise<DockMission | null> {
125 const now = await $.clock.now()
126 const { teamSize } = await read($, settingsAtom)
127 const changed = await update($, missionAtom, mission =>
128 change(mission ?? startMission(0, 'Your request', teamSize, now)),
129 )
130 await afterMissionChange($)
131
132 return changed
133}
134
135async function afterMissionChange($: Engine): Promise<void> {
136 await syncDockClock($)
137 await refreshBadge($)
138}
139
140/** One clock, running only while helpers are queued or working. */
141async function syncDockClock($: Engine): Promise<void> {
142 const shouldRun = isLive(await read($, missionAtom))
143 if (shouldRun && dockClock === undefined) {
144 dockClock = $.clock.every(DOCK_TICK_MS, () => {
145 void update($, tickAtom, frame => frame + 1)
146 })
147 } else if (!shouldRun && dockClock !== undefined) {
148 dockClock.cancel()
149 dockClock = undefined
150 }
151}
152
153/** The status-bar badge, shown only while the dock is folded. */
154async function refreshBadge($: Engine): Promise<void> {
155 const settings = await read($, settingsAtom)
156 try {
157 $.ui.status(settings.isFolded ? badgeText(await read($, missionAtom), settings.teamSize) : undefined)
158 } catch {
159 // The badge is a nicety.
160 }
161}
162
163async function saveSettings($: Engine, settings: DockSettings): Promise<void> {
164 try {
165 await $.store.set(STORE_TEAM_SIZE, settings.teamSize)
166 await $.store.set(STORE_HELPER_MODEL, settings.helperModel)
167 await $.store.set(STORE_HELPER_MODEL_CHOSEN, settings.hasChosenHelperModel)
168 } catch {
169 // The choice still holds for this session.
170 }
171}
172
173async function changeSettings($: Engine, change: (settings: DockSettings) => DockSettings): Promise<DockSettings> {
174 const changed = await update($, settingsAtom, change)
175 await saveSettings($, changed)
176 await refreshBadge($)
177
178 return changed
179}
180
181/** Picks a size: small ones apply at once, big ones wait for Continue. */
182async function chooseSize($: Engine, size: number): Promise<void> {
183 await changeSettings($, settings =>
184 needsConfirmation(size)
185 ? { ...settings, pendingSize: size, isCustomOpen: false }
186 : { ...settings, teamSize: size, pendingSize: null, isCustomOpen: false },
187 )
188}
189
190async function confirmPendingSize($: Engine): Promise<void> {
191 await changeSettings($, settings =>
192 settings.pendingSize === null ? settings : { ...settings, teamSize: settings.pendingSize, pendingSize: null },
193 )
194}
195
196/** Opens the dock beside the transcript, or says why it cannot. */
197async function openDock($: Engine): Promise<void> {
198 const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE, columns: PANE_COLUMNS })
199 if (!opened.isPlaced) showToast($, NARROW_WINDOW)
200 await changeSettings($, settings => ({ ...settings, isFolded: false }))
201}
202
203/** Folds an open dock to its badge, or opens a folded or closed one. */
204async function toggleDock($: Engine): Promise<void> {
205 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE_ID)
206 if (!isOpen) {
207 await openDock($)
208 return
209 }
210 await $.ui.close({ id: PANE_ID })
211 await changeSettings($, settings => ({ ...settings, isFolded: true }))
212}
213
214/** Loads the headshots the plugin ships, if any. */
215async function loadHeadshots($: Engine): Promise<void> {
216 try {
217 const entries = await $.fs.list(`${$.plugin.root}/${HEADSHOT_FOLDER}`)
218 const pictures = entries
219 .map(entry => entry.name)
220 .filter(name => name.toLowerCase().endsWith('.png'))
221 .sort()
222 await update($, headshotsAtom, () => pictures)
223 } catch {
224 await update($, headshotsAtom, () => [])
225 }
226}
227
228// ---------------------------------------------------------------------------
229// Drawing: small pieces
230// ---------------------------------------------------------------------------
231
232/** Spaced capitals, like "A G E N T". */
233function spaced(text: string): string {
234 return text.split('').join(' ')
235}
236
237/** Text drawn letter by letter from coral to gold. */
238function gradientText(Text: TextElement, text: string, weight?: TextProps['weight']): RenderElement[] {
239 const letters = text.split('')
240 return letters.map((letter, index) => (
241 <Text color={blend(COLORS.coral, COLORS.gold, letters.length <= 1 ? 0 : index / (letters.length - 1))} weight={weight}>
242 {letter}
243 </Text>
244 ))
245}
246
247/** The full-width mission bar: progress coral to gold, work in flight shimmering green, the rest a hairline. */
248function missionBar(Text: TextElement, cards: readonly DockCard[], width: number, frame: number): RenderElement {
249 const { progress, inFlight } = missionShares(cards)
250 const progressCells = Math.round(progress * width)
251 const flightCells = Math.min(width - progressCells, Math.round(inFlight * width))
252 const restCells = width - progressCells - flightCells
253 const pieces: RenderElement[] = []
254
255 // The progress part in up to ten color steps, so the tree stays small.
256 const steps = Math.min(10, progressCells)
257 for (let step = 0; step < steps; step += 1) {
258 const from = Math.round((step / steps) * progressCells)
259 const to = Math.round(((step + 1) / steps) * progressCells)
260 pieces.push(<Text color={blend(COLORS.coral, COLORS.gold, steps <= 1 ? 1 : step / (steps - 1))}>{'━'.repeat(to - from)}</Text>)
261 }
262 // The work in flight shimmers: a lighter pair of cells walks along it.
263 for (let cell = 0; cell < flightCells; cell += 2) {
264 const isBright = Math.floor(cell / 2) % 4 === frame % 4
265 pieces.push(<Text color={isBright ? COLORS.green : COLORS.greenDim}>{'━'.repeat(Math.min(2, flightCells - cell))}</Text>)
266 }
267 if (restCells > 0) pieces.push(<Text color={COLORS.hairline}>{'─'.repeat(restCells)}</Text>)
268
269 return <Text wrap="truncate-end">{pieces}</Text>
270}
271
272/** A card's meter: filled to its percent, or a moving sweep before its first report. */
273function cardMeter(Text: TextElement, card: DockCard, width: number, frame: number): RenderElement {
274 if (card.status === 'working' && !card.hasReported) {
275 const start = sweepStart(frame, width)
276 return (
277 <Text>
278 <Text color={COLORS.hairline}>{'─'.repeat(start)}</Text>
279 <Text color={COLORS.green}>{'━'.repeat(Math.min(3, width))}</Text>
280 <Text color={COLORS.hairline}>{'─'.repeat(Math.max(0, width - start - 3))}</Text>
281 </Text>
282 )
283 }
284 const { filled, empty } = meterCells(card.status === 'done' ? 100 : card.percent, width)
285 const fillColor = card.status === 'stuck' ? COLORS.red : card.status === 'done' ? COLORS.gold : COLORS.coral
286
287 return (
288 <Text>
289 <Text color={fillColor} dimColor={card.status === 'queued'}>{'━'.repeat(filled)}</Text>
290 <Text color={COLORS.hairline}>{'─'.repeat(empty)}</Text>
291 </Text>
292 )
293}
294
295function percentLabel(card: DockCard): string {
296 if (card.status === 'done') return '100%'
297 if (card.status === 'stuck') return 'stuck'
298 if (card.status === 'queued') return 'queued'
299
300 return card.hasReported ? `${card.percent}%` : '…'
301}
302
303// ---------------------------------------------------------------------------
304// Drawing: the pane
305// ---------------------------------------------------------------------------
306
307type PaneInput = RenderInput<'Pane'>
308
309async function drawDock($: Engine, e: PaneInput): Promise<RenderElement> {
310 const elements = $.ui.resolve(e)
311 const { Box, Text, Button } = elements
312 const Input = (elements as Partial<Elements['terminal']>).Input
313 const Image = e.surface === 'terminal' ? (elements as Partial<Elements['terminal']>).Image : undefined
314
315 const settings = await read($, settingsAtom)
316 const mission = await read($, missionAtom)
317 const frame = await read($, tickAtom)
318 const headshots = await read($, headshotsAtom)
319 const now = await $.clock.now()
320 const atOnce = await atOnceOf($)
321 const width = Math.max(40, e.props.bodyColumns)
322
323 const live = isLive(mission)
324 const hasCards = mission !== null && mission.cards.length > 0
325 const isComplete = hasCards && mission.finishedAt !== null
326
327 // Masthead: the wordmark and the live state.
328 const stateLabel = live ? (
329 <Text color={frame % 2 === 0 ? COLORS.green : COLORS.greenDim}>● {spaced('LIVE')}</Text>
330 ) : isComplete ? (
331 <Text color={COLORS.gold}>{spaced('COMPLETE')}</Text>
332 ) : (
333 <Text color={COLORS.muted}>{spaced('STANDING BY')}</Text>
334 )
335 const masthead = (
336 <Box flexDirection="row" justifyContent="space-between" width={width}>
337 <Text>
338 <Text color={COLORS.coral}>◆ </Text>
339 {gradientText(Text, `${spaced('AGENT')} ${spaced('DOCK')}`, 'bold')}
340 </Text>
341 {stateLabel}
342 </Box>
343 )
344
345 // Team Size: one segmented control.
346 const chips = PRESET_SIZES.map(size => {
347 const isPicked = settings.teamSize === size && settings.pendingSize === null
348 return (
349 <Button key={`size-${size}`} plain onPress={() => void chooseSize($, size)}>
350 {isPicked ? (
351 <Text backgroundColor={COLORS.coral} color={COLORS.ink} weight="bold">{` ${size} `}</Text>
352 ) : (
353 <Text color={COLORS.muted}>{` ${size} `}</Text>
354 )}
355 </Button>
356 )
357 })
358 const isCustomSize = !PRESET_SIZES.includes(settings.teamSize)
359 const sizeControl = (
360 <Box flexDirection="row" flexWrap="wrap">
361 <Text color={COLORS.muted}>{`${spaced('TEAM')} ${spaced('SIZE')} `}</Text>
362 <Text color={COLORS.hairline}>╭</Text>
363 {chips}
364 <Text color={COLORS.hairline}> │ </Text>
365 <Button key="size-custom" plain onPress={() => void changeSettings($, s => ({ ...s, isCustomOpen: !s.isCustomOpen }))}>
366 {isCustomSize ? (
367 <Text backgroundColor={COLORS.coral} color={COLORS.ink} weight="bold">{` Custom ${settings.teamSize} `}</Text>
368 ) : (
369 <Text color={COLORS.muted}>Custom</Text>
370 )}
371 </Button>
372 <Text color={COLORS.hairline}> ╮</Text>
373 </Box>
374 )
375 const customBox =
376 settings.isCustomOpen && Input !== undefined ? (
377 <Box flexDirection="row">
378 <Input
379 key="custom-size"
380 label="How many helpers?"
381 placeholder="1 to 100"
382 submitLabel="Set"
383 autoFocus
384 onSubmit={value => {
385 const size = parseSize(value)
386 if (size === null) {
387 showToast($, BAD_CUSTOM_SIZE)
388 return
389 }
390 void chooseSize($, size)
391 }}
392 />
393 </Box>
394 ) : null
395
396 // Helper model: Auto per agent (the default), Fast & Cheap, or the session's own model.
397 const modelChoice = (choice: DockHelperModel) => {
398 const isPicked = settings.helperModel === choice
399 return (
400 <Button key={`model-${choice}`} plain onPress={() => void changeSettings($, s => ({ ...s, helperModel: choice, hasChosenHelperModel: true }))}>
401 {isPicked ? (
402 <Text backgroundColor={COLORS.coral} color={COLORS.ink} weight="bold">{` ${helperModelLabel(choice)} `}</Text>
403 ) : (
404 <Text color={COLORS.muted}>{` ${helperModelLabel(choice)} `}</Text>
405 )}
406 </Button>
407 )
408 }
409 const modelRow = (
410 <Box flexDirection="row" flexWrap="wrap">
411 <Text color={COLORS.muted}>Helper agents </Text>
412 {HELPER_MODELS.map((choice, index) => [
413 index > 0 ? <Text color={COLORS.hairline}> / </Text> : null,
414 modelChoice(choice),
415 ])}
416 </Box>
417 )
418
419 // A big team waits for Continue.
420 const bigTeamBox =
421 settings.pendingSize !== null ? (
422 <Box key="big-team" borderStyle="round" borderColor={COLORS.gold} paddingX={1} flexDirection="column" width={width}>
423 <Text color={COLORS.gold}>{bigTeamQuestion()}</Text>
424 <Box flexDirection="row" gap={2}>
425 <Button key="big-team-continue" variant="primary" onPress={() => void confirmPendingSize($)}>
426 {`Continue with ${settings.pendingSize}`}
427 </Button>
428 <Button key="big-team-cancel" onPress={() => void changeSettings($, s => ({ ...s, pendingSize: null }))}>
429 Cancel
430 </Button>
431 </Box>
432 </Box>
433 ) : null
434
435 const header = (
436 <Box flexDirection="column" width={width}>
437 {masthead}
438 <Text color={COLORS.hairline}>{'─'.repeat(width)}</Text>
439 {sizeControl}
440 {customBox}
441 <Text color={COLORS.muted} wrap="truncate-end">
442 {infoLine(settings.teamSize, atOnce, settings.helperModel)}
443 </Text>
444 {modelRow}
445 {bigTeamBox}
446 </Box>
447 )
448
449 if (!hasCards || mission === null) {
450 return (
451 <Box flexDirection="column" width={width} gap={1}>
452 {header}
453 {drawIdle(Text, Box, settings.teamSize)}
454 </Box>
455 )
456 }
457
458 // Mission: name, percent, time, the bar and the counts.
459 const counts = countCards(mission.cards)
460 const missionRow = (
461 <Box flexDirection="row" justifyContent="space-between" width={width}>
462 <Text wrap="truncate-end">
463 <Text color={COLORS.muted}>{`${spaced('MISSION')} `}</Text>
464 <Text weight="bold">{mission.title}</Text>
465 </Text>
466 <Text>
467 <Text weight="bold">{`${missionPercent(mission.cards)}%`}</Text>
468 <Text color={COLORS.muted}>{` ${formatClock(missionElapsed(mission, now))}`}</Text>
469 </Text>
470 </Box>
471 )
472 const countsRow = (
473 <Text wrap="truncate-end">
474 <Text color={COLORS.green}>{`● ${counts.working} working`}</Text>
475 <Text color={COLORS.muted}>{` ○ ${counts.queued} queued`}</Text>
476 <Text color={COLORS.gold}>{` ✓ ${counts.done} done`}</Text>
477 <Text color={counts.stuck > 0 ? COLORS.red : COLORS.muted}>{` ✕ ${counts.stuck} stuck`}</Text>
478 </Text>
479 )
480 const summary =
481 mission.finishedAt !== null ? (
482 <Box key="summary" borderStyle="round" borderColor={COLORS.green} paddingX={1} width={width}>
483 <Text wrap="truncate-end">
484 <Text color={COLORS.green}>✓ </Text>
485 {summaryLine(mission, now)}
486 </Text>
487 </Box>
488 ) : null
489
490 const layout = cardLayout(width, mission.cards.length)
491 const faceOf = (card: DockCard) => {
492 const picture = headshots.length > 0 ? headshots[card.seat % headshots.length] : undefined
493 if (Image !== undefined && picture !== undefined) {
494 return (
495 <Image
496 key={`face-${card.id}`}
497 source={{ file: `${$.plugin.root}/${HEADSHOT_FOLDER}/${picture}`, format: 'png' }}
498 columns={2}
499 rows={1}
500 alt={card.initials}
501 />
502 )
503 }
504 return (
505 <Text backgroundColor={card.status === 'stuck' ? COLORS.red : seatColor(card.seat)} color={COLORS.ink} weight="bold">
506 {card.initials}
507 </Text>
508 )
509 }
510
511 const helpers =
512 layout.mode === 'cards'
513 ? mission.cards.map(card => drawCard(Text, Box, card, faceOf(card), layout.width, frame, now))
514 : mission.cards.map(card => drawTile(Text, Box, card, layout.width, frame))
515
516 return (
517 <Box flexDirection="column" width={width}>
518 {header}
519 <Text> </Text>
520 {missionRow}
521 {missionBar(Text, mission.cards, width, frame)}
522 {countsRow}
523 <Text> </Text>
524 <Box flexDirection="row" flexWrap="wrap" columnGap={1} width={width}>
525 {helpers}
526 </Box>
527 {summary}
528 </Box>
529 )
530}
531
532type BoxElement = Elements['terminal']['Box']
533
534/** One helper's card: face, task, timer and meter. */
535function drawCard(
536 Text: TextElement,
537 Box: BoxElement,
538 card: DockCard,
539 face: RenderElement,
540 cardWidth: number,
541 frame: number,
542 now: number,
543): RenderElement {
544 const isQueued = card.status === 'queued'
545 const innerWidth = Math.max(10, cardWidth - 4)
546 const meterWidth = Math.max(4, innerWidth - 4 - 7)
547 const clock = isQueued ? '–:––' : formatClock(cardElapsed(card, now))
548
549 return (
550 <Box
551 key={`card-${card.id}`}
552 borderStyle="round"
553 borderColor={card.status === 'stuck' ? COLORS.red : COLORS.hairline}
554 hover={{ borderColor: COLORS.coral }}
555 width={cardWidth}
556 paddingX={1}
557 flexDirection="column"
558 >
559 <Box flexDirection="row" justifyContent="space-between">
560 <Box flexDirection="row" flexShrink={1}>
561 {face}
562 <Text dimColor={isQueued} weight="bold" wrap="truncate-end">{` ${card.task}`}</Text>
563 </Box>
564 <Text color={COLORS.muted} dimColor={isQueued}>{` ${clock}`}</Text>
565 </Box>
566 <Text wrap="truncate-end">
567 {' '}
568 {cardMeter(Text, card, meterWidth, frame)}
569 <Text dimColor={isQueued} color={card.status === 'stuck' ? COLORS.red : undefined}>{` ${percentLabel(card).padStart(6)}`}</Text>
570 </Text>
571 </Box>
572 )
573}
574
575/** One helper as a small tile, for teams above 12. */
576function drawTile(Text: TextElement, Box: BoxElement, card: DockCard, tileWidth: number, frame: number): RenderElement {
577 const isQueued = card.status === 'queued'
578 const badgeColor = card.status === 'stuck' ? COLORS.red : seatColor(card.seat)
579 const label = card.status === 'done' ? ' ✓ ' : card.status === 'stuck' ? ' ✕ ' : isQueued ? ' · ' : card.hasReported ? `${String(card.percent).padStart(3)}%` : ' … '
580
581 return (
582 <Box key={`tile-${card.id}`} width={tileWidth}>
583 <Text wrap="truncate-end" dimColor={isQueued}>
584 <Text backgroundColor={badgeColor} color={COLORS.ink} weight="bold">{card.initials}</Text>
585 {' '}
586 {cardMeter(Text, card, 7, frame)}
587 <Text color={card.status === 'done' ? COLORS.gold : card.status === 'stuck' ? COLORS.red : undefined}>{` ${label}`}</Text>
588 </Text>
589 </Box>
590 )
591}
592
593/** The idle dock: one seat per team member, and what a request will do. */
594function drawIdle(Text: TextElement, Box: BoxElement, teamSize: number): RenderElement {
595 const seats = Array.from({ length: Math.min(teamSize, MAX_SEATS) }, (_, seat) => (
596 <Text color={seatColor(seat)}>{'● '}</Text>
597 ))
598 const more = teamSize > MAX_SEATS ? <Text color={COLORS.muted}>{`+${teamSize - MAX_SEATS}`}</Text> : null
599 const promise =
600 teamSize <= 1 ? 'Send a request and Claude decides how many helpers.' : `Send a request and it splits across ${teamSize} helpers.`
601
602 return (
603 <Box flexDirection="column" gap={0}>
604 <Text>
605 {seats}
606 {more}
607 </Text>
608 <Text weight="bold">{`Your team of ${teamSize} is standing by`}</Text>
609 <Text color={COLORS.muted}>{promise}</Text>
610 </Box>
611 )
612}
613
614// ---------------------------------------------------------------------------
615// Hooks
616// ---------------------------------------------------------------------------
617
618export function registerAgentDock(on: On): void {
619 // Session start (and each reload): settings from the store once a session, /dock, headshots, clock.
620 on('session.start', { cwd: /^/ }, async ($, e, next) => {
621 try {
622 const held = await $.state.get(dockRef)
623 if (held.version === 0) {
624 const savedSize = await $.store.get(STORE_TEAM_SIZE)
625 const savedModel = await $.store.get(STORE_HELPER_MODEL)
626 const savedChosen = await $.store.get(STORE_HELPER_MODEL_CHOSEN)
627 const teamSize = restoreSize(savedSize)
628 const helperModel = restoreHelperModel(savedModel, savedChosen)
629 await update($, settingsAtom, () => ({
630 ...DEFAULT_DOCK_SETTINGS,
631 teamSize,
632 helperModel,
633 hasChosenHelperModel: savedChosen === true,
634 }))
635 if (savedSize !== undefined && savedSize !== teamSize) await $.store.set(STORE_TEAM_SIZE, teamSize)
636 } else {
637 // A model the person never picked follows the default, Auto (this also moves older sessions over).
638 await update($, settingsAtom, (settings): DockSettings =>
639 settings.hasChosenHelperModel === true
640 ? settings
641 : { ...settings, helperModel: 'auto', hasChosenHelperModel: false },
642 )
643 }
644 } catch {
645 // Defaults stand: a team of 1.
646 }
647 await $.command.register({
648 name: 'dock',
649 description: 'Open or fold the Agent Dock, or set its Team Size (/dock 10)',
650 argumentHint: '[size]',
651 immediate: true,
652 })
653 await loadHeadshots($)
654 await afterMissionChange($)
655
656 return next(e)
657 })
658
659 // Every request at a Team Size above 1 carries the split instruction.
660 on('prompt.submit', { text: /^/ }, async ($, e, next) => {
661 if (e.command !== undefined || e.isMeta || e.turnId !== undefined) return next(e)
662
663 const settings = await read($, settingsAtom)
664 // A plugin sending the person's own words (Prompt Upgrade's Run) counts as the person's request.
665 const isSentAsPerson = e.origin.kind === 'plugin' && e.origin.asUser === true
666 const isPerson = e.origin.kind === 'composer' || e.origin.kind === 'bridge' || e.origin.kind === 'sdk' || isSentAsPerson
667 const isOwnNudge = e.origin.kind === 'plugin' && e.origin.name === $.plugin.name
668
669 if (isPerson) {
670 const now = await $.clock.now()
671 await update($, missionAtom, mission => startMission(mission?.id ?? 0, e.text, settings.teamSize, now))
672 await afterMissionChange($)
673 }
674 if (settings.teamSize <= 1 || (!isPerson && !isOwnNudge)) return next(e)
675
676 const sessionModel = await sessionModelAlias($)
677 const instruction = isOwnNudge
678 ? helperRules(settings.helperModel, sessionModel)
679 : instructionText(settings.teamSize, settings.helperModel, sessionModel)
680
681 return next({ ...e, context: [...(e.context ?? []), instruction] })
682 }).catch(($, e, next) => next(e))
683
684 // Cards appear as soon as Claude writes its Agent calls.
685 on('session.append', { door: 'response' }, async ($, e, next) => {
686 try {
687 if (e.agentId === undefined) {
688 const agentCalls = e.message.content.filter(block => block.type === 'tool_use' && block.name === 'Agent')
689 if (agentCalls.length > 0) {
690 const now = await $.clock.now()
691 await changeMission($, mission =>
692 agentCalls.reduce((current, block) => {
693 const input = (block.input ?? {}) as { description?: unknown }
694 return queueCard(current, String(block.id), input.description, now)
695 }, mission),
696 )
697 }
698 }
699 } catch {
700 // Cards are a view: the row is stored either way.
701 }
702 return next(e)
703 }).catch(($, e, next) => next(e))
704
705 // Each Agent call: the Team Size cap, the queue past the engine's limit, the helper model, the card.
706 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
707 // Helpers' own agents pass untouched.
708 if (e.agentId !== undefined) return next(e)
709
710 const settings = await read($, settingsAtom)
711 const size = settings.teamSize
712 const isFork = e.subagent_type === 'fork'
713
714 // Auto: Claude names each helper's model for its own task; a helper with none is sent back with the fix.
715 const agentType = e.subagent_type ?? 'general-purpose'
716 if (size > 1 && settings.helperModel === 'auto' && e.model === undefined && !isFork && UNPINNED_AGENT_TYPES.has(agentType)) {
717 return { deny: autoModelMessage(await sessionModelAlias($)) }
718 }
719
720 // The cap: at most Team Size helpers per request.
721 let isOverCap = false
722 await changeMission($, mission => {
723 isOverCap = size > 1 && mission.admittedCalls >= size
724 return isOverCap ? mission : { ...mission, admittedCalls: mission.admittedCalls + 1 }
725 })
726 if (isOverCap) {
727 const now = await $.clock.now()
728 await changeMission($, mission => removeCard(mission, e.tool_use_id, now))
729 return { deny: capMessage(size) }
730 }
731
732 // The queue: hold the call while the engine's limit is full; a held call does not burn the hook's budget.
733 const limit = await atOnceOf($)
734 while (runningHelpers >= limit) {
735 if (next.signal.aborted) {
736 const now = await $.clock.now()
737 await changeMission($, mission => finishCard(mission, e.tool_use_id, true, now))
738 return { deny: STOPPED_MESSAGE }
739 }
740 try {
741 await $.process.run(['/bin/sleep', '1'])
742 } catch {
743 break
744 }
745 }
746 if (next.signal.aborted) {
747 const now = await $.clock.now()
748 await changeMission($, mission => finishCard(mission, e.tool_use_id, true, now))
749 return { deny: STOPPED_MESSAGE }
750 }
751
752 // The helper model: Fast & Cheap and Same as you fill in a model Claude left out;
753 // Auto fills only the effort of the tier Claude picked, unless that tier is the session's own.
754 let call = e
755 if (size > 1 && !isFork) {
756 const sessionModel = await sessionModelAlias($)
757 if (e.model === undefined && settings.helperModel !== 'auto') {
758 const model = settings.helperModel === 'fast' ? 'haiku' : sessionModel
759 if (model === 'haiku' || model === 'sonnet' || model === 'opus' || model === 'fable') {
760 call = { ...e, model, ...(model === 'haiku' && e.effort === undefined ? { effort: 'low' as const } : {}) }
761 }
762 } else if (e.model !== undefined && e.effort === undefined && e.model !== sessionModel) {
763 const effort = defaultEffortFor(e.model)
764 if (effort !== undefined) call = { ...e, effort }
765 }
766 }
767
768 const startedAt = await $.clock.now()
769 await changeMission($, mission => startCard(mission, e.tool_use_id, e.description, startedAt))
770 runningHelpers += 1
771 const ran = await next(call).finally(() => {
772 runningHelpers = Math.max(0, runningHelpers - 1)
773 })
774
775 const finishedAt = await $.clock.now()
776 if (ran.deny !== undefined) {
777 // Refused below the dock: it never became a helper.
778 await changeMission($, mission => dropCard(mission, e.tool_use_id, finishedAt))
779 } else {
780 await changeMission($, mission => finishCard(mission, e.tool_use_id, ran.isError === true, finishedAt))
781 }
782
783 return ran
784 }).catch(($, e, next) => next(e)) // Never block work on a fault of the dock.
785
786 // Link each helper's agent id to its card.
787 on('agent.spawn', async ($, e, next) => {
788 const spawned = await next(e)
789 if (spawned.deny === undefined && spawned.agentId !== undefined && e.parentAgentId === undefined && e.workflow === undefined) {
790 const agentId = spawned.agentId
791 await update($, missionAtom, mission => (mission === null ? null : linkAgent(mission, e.tool_use_id, agentId)))
792 }
793 return spawned
794 }).catch(($, e, next) => next(e))
795
796 // Helpers report progress onto their own card, then the report goes on to Clean View; they skip the plan.
797 on('tool.call', { tool: /^mcp__clean-view__(report_progress|plan_steps)$/, agentId: /./ }, async ($, e, next) => {
798 const toolName = String(e.tool)
799 if (toolName.endsWith('plan_steps')) return { result: HELPERS_SKIP_PLAN }
800
801 const callInput = e as unknown as Record<string, unknown>
802 const percent = clampPercent(callInput.percent)
803 const agentId = String(callInput.agentId ?? '')
804 await update($, missionAtom, mission => (mission === null ? null : reportHelper(mission, agentId, callInput.task, percent)))
805 await afterMissionChange($)
806
807 return next(e)
808 }).catch(($, e, next) => (next.called ? next(e) : { result: 'Progress noted.' }))
809
810 // The end of the main turn: unstarted helpers are stuck; a short-handed request gets one nudge.
811 on('turn.complete', { turnId: /^/ }, async ($, e, next) => {
812 const result = await next(e)
813 if (e.agentId !== undefined) return result
814
815 try {
816 const now = await $.clock.now()
817 const settings = await read($, settingsAtom)
818 let nudge: string | null = null
819 await update($, missionAtom, mission => {
820 nudge = null
821 if (mission === null) return null
822 const ended = endTurn(mission, now)
823 const isAnswered = e.reason === 'answer' && !e.isAborted
824 if (!isAnswered || !shouldNudge(ended, settings.teamSize)) return ended
825 nudge = nudgeText(ended.admittedCalls, settings.teamSize)
826 return { ...ended, hasNudged: true }
827 })
828 await afterMissionChange($)
829 const nudgeToSend: string | null = nudge
830 if (nudgeToSend !== null) void $.prompt.submit({ text: nudgeToSend }).catch(() => undefined)
831 } catch {
832 // The answer stands either way.
833 }
834 return result
835 })
836
837 // /dock folds or opens the pane; /dock N sets the Team Size.
838 on('command.run', { command: 'dock' }, ($, e) => {
839 try {
840 const action = dockCommand(e.args)
841 if (action.kind === 'invalid') return { text: BAD_COMMAND_SIZE }
842 if (action.kind === 'toggle') {
843 void toggleDock($).catch(() => undefined)
844 return { text: 'Agent Dock toggled. Type /dock again to switch back.' }
845 }
846 if (action.kind === 'confirm') {
847 void (async () => {
848 await changeSettings($, settings => ({ ...settings, pendingSize: action.size, isCustomOpen: false }))
849 await openDock($)
850 })().catch(() => undefined)
851 return { text: `Confirm the team of ${action.size} in the Agent Dock.` }
852 }
853 void (async () => {
854 await chooseSize($, action.size)
855 await openDock($)
856 })().catch(() => undefined)
857 return { text: `Team Size is now ${action.size}.` }
858 } catch {
859 return { text: 'The Agent Dock hit a problem. Try /dock again.' }
860 }
861 })
862
863 // The dock pane.
864 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => drawDock($, e))
865
866 // A small ◆ Dock button beside the line under the prompt.
867 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
868 const engineLine = await next(e)
869 const { Box, Button, Text } = $.ui.resolve(e)
870 const settings = await read($, settingsAtom)
871
872 return (
873 <Box flexDirection="row" alignItems="flex-start">
874 {engineLine}
875 <Box marginLeft={2} flexShrink={0}>
876 <Button key="open-dock" plain onPress={() => void toggleDock($).catch(() => undefined)}>
877 <Text color={COLORS.coral}>◆</Text>
878 <Text color={COLORS.muted}>{settings.teamSize > 1 ? ` Dock ${settings.teamSize}` : ' Dock'}</Text>
879 </Button>
880 </Box>
881 </Box>
882 )
883 })
884}
885hooks/dock-logic.ts 545 lines1// Agent Dock's logic: everything that needs no engine, so tests can call it directly.
2import type { DockCard, DockCardStatus, DockHelperModel, DockMission, DockSettings } from '../types'
3
4// ---------------------------------------------------------------------------
5// Sizes and limits
6// ---------------------------------------------------------------------------
7
8export const MIN_TEAM_SIZE = 1
9export const MAX_TEAM_SIZE = 100
10// Sizes above this need a confirmation and never carry over to a new session.
11export const BIG_TEAM_SIZE = 20
12export const PRESET_SIZES: readonly number[] = [1, 3, 5, 10, 20, 50, 100]
13// Up to this many helpers show as full cards; more become small tiles.
14export const CARD_LIMIT = 12
15// Idle seats shown at most.
16export const MAX_SEATS = 25
17// Claude Code's own defaults when the settings name no limit.
18export const DEFAULT_MAX_SUBAGENTS = 20
19export const DEFAULT_MAX_TOOL_CONCURRENCY = 10
20
21export const DEFAULT_DOCK_SETTINGS: DockSettings = {
22 teamSize: 1,
23 pendingSize: null,
24 helperModel: 'auto',
25 hasChosenHelperModel: false,
26 isCustomOpen: false,
27 isFolded: false,
28}
29
30/** A Team Size typed by the person: a whole number from 1 to 100, else null. */
31export function parseSize(text: unknown): number | null {
32 if (typeof text !== 'string' && typeof text !== 'number') return null
33 const trimmed = String(text).trim()
34 if (!/^\d+$/.test(trimmed)) return null
35 const size = Number(trimmed)
36
37 return size >= MIN_TEAM_SIZE && size <= MAX_TEAM_SIZE ? size : null
38}
39
40/** The size a new session starts with: a big team never carries over. */
41export function restoreSize(saved: unknown): number {
42 const size = parseSize(typeof saved === 'number' ? String(saved) : saved)
43 if (size === null) return MIN_TEAM_SIZE
44
45 return size > BIG_TEAM_SIZE ? MIN_TEAM_SIZE : size
46}
47
48export function needsConfirmation(size: number): boolean {
49 return size > BIG_TEAM_SIZE
50}
51
52function positiveWholeNumber(value: unknown): number | null {
53 const parsed = typeof value === 'string' || typeof value === 'number' ? Number(String(value).trim()) : NaN
54 return Number.isInteger(parsed) && parsed > 0 ? parsed : null
55}
56
57/** How many helpers run at once: the lower of the two engine limits. */
58export function helpersAtOnce(maxSubagents: unknown, maxToolConcurrency: unknown): number {
59 const subagentLimit = positiveWholeNumber(maxSubagents) ?? DEFAULT_MAX_SUBAGENTS
60 const toolLimit = positiveWholeNumber(maxToolConcurrency) ?? DEFAULT_MAX_TOOL_CONCURRENCY
61
62 return Math.min(subagentLimit, toolLimit)
63}
64
65// ---------------------------------------------------------------------------
66// Names
67// ---------------------------------------------------------------------------
68
69// Openers that carry no meaning in a job name.
70const LEADING_FILLER =
71 /^(please|pls|kindly|hey|hi|ok|okay|so|now|can you|could you|would you|will you|i want you to|i need you to|i'd like you to|help me|let's|lets)\b[\s,]*/i
72
73/** Cuts text at a word boundary so it, with its "…", fits in `maxLength`. */
74export function trimWords(text: string, maxLength: number): string {
75 if (text.length <= maxLength) return text
76 const room = maxLength - 1
77 let cut = text.slice(0, room)
78 const lastSpace = cut.lastIndexOf(' ')
79 if (lastSpace > room / 3) cut = cut.slice(0, lastSpace)
80
81 return cut.replace(/[\s,;:\-–—.]+$/, '') + '…'
82}
83
84function capitalise(text: string): string {
85 return text.charAt(0).toUpperCase() + text.slice(1)
86}
87
88/** The job's name: the first clause of the request, about 40 characters. */
89export function jobName(prompt: string): string {
90 const firstLine = prompt.split('\n').find(line => line.trim() !== '') ?? ''
91 let clause = firstLine.split(/[.!?;](?:\s|$)|,\s|:\s|\s[-–—]\s/)[0] ?? ''
92 let previous = ''
93 while (clause !== previous) {
94 previous = clause
95 clause = clause.trim().replace(LEADING_FILLER, '')
96 }
97 clause = clause.replace(/[`*_#>]/g, '').replace(/\s+/g, ' ').trim().replace(/[\s,;:.!?]+$/, '')
98 if (clause === '') return 'Your request'
99
100 return trimWords(capitalise(clause), 40)
101}
102
103/** A helper's task name: the first 3 to 5 words of its description. */
104export function taskName(description: unknown): string {
105 const words = String(description ?? '')
106 .replace(/[`*_#]/g, '')
107 .replace(/\s+/g, ' ')
108 .trim()
109 .split(' ')
110 .filter(Boolean)
111 .slice(0, 5)
112 if (words.length === 0) return 'Helper'
113
114 return trimWords(capitalise(words.join(' ')), 30)
115}
116
117/** Two letters for a helper's badge: its task's first and last words. */
118export function initials(task: string): string {
119 const words = task.replace(/[^\p{L}\p{N}\s]/gu, ' ').split(/\s+/).filter(Boolean)
120 const first = words[0]
121 const last = words[words.length - 1]
122 if (first === undefined || last === undefined) return '··'
123 const pair = words.length === 1 ? first.slice(0, 2) : (first[0] ?? '') + (last[0] ?? '')
124
125 return pair.toUpperCase().padEnd(2, '·')
126}
127
128// The helper model choices, in the order the dock shows them.
129export const HELPER_MODELS: readonly DockHelperModel[] = ['auto', 'fast', 'same']
130
131export function helperModelLabel(model: DockHelperModel): string {
132 if (model === 'fast') return 'Fast & Cheap'
133 if (model === 'same') return 'Same as you'
134
135 return 'Auto per agent'
136}
137
138/** The helper model a session starts with: the person's own pick, else Auto. */
139export function restoreHelperModel(savedModel: unknown, savedChosen: unknown): DockHelperModel {
140 const isKnown = typeof savedModel === 'string' && (HELPER_MODELS as readonly string[]).includes(savedModel)
141 return savedChosen === true && isKnown ? (savedModel as DockHelperModel) : 'auto'
142}
143
144// The effort a tier runs at when its helper names none.
145const TIER_EFFORT: Readonly<Record<string, 'low' | 'medium' | 'high' | 'xhigh'>> = {
146 haiku: 'low',
147 sonnet: 'medium',
148 opus: 'high',
149 fable: 'xhigh',
150}
151
152export function defaultEffortFor(model: string): 'low' | 'medium' | 'high' | 'xhigh' | undefined {
153 return TIER_EFFORT[model]
154}
155
156/** Auto: how Claude picks each helper's model and effort for its own task. */
157function autoModelRule(sessionModel: string | null): string {
158 const ceiling = sessionModel ?? 'this session\'s model'
159 return [
160 '- Pick each helper\'s model and effort for its own task: the cheapest tier whose judgment is enough. Set both on every helper.',
161 ' - haiku, effort low: mechanical work fixed by its input, like a lookup, a price check, extracting fields, a short summary or formatting.',
162 ' - sonnet, effort medium: work that must decide what matters, like research, a comparison, drafting a section or a contained change.',
163 ' - opus, effort high: judging or verifying, design tradeoffs, or risky changes to migrations, auth, payments or data.',
164 ` - Never above this session's model (${ceiling}). Helpers of one request may differ: mix tiers when the pieces differ.`,
165 ].join('\n')
166}
167
168/** The fix for an Auto helper that names no model. */
169export function autoModelMessage(sessionModel: string | null): string {
170 const ceiling = sessionModel ?? "this session's model"
171 return (
172 'Agent Dock: helper agents are on Auto, so set model and effort on this helper for its own task, then call again: ' +
173 'haiku with effort low for mechanical work, sonnet with effort medium for research or drafting, ' +
174 `opus with effort high for judging or risky changes, never above ${ceiling}.`
175 )
176}
177
178// ---------------------------------------------------------------------------
179// What Claude reads
180// ---------------------------------------------------------------------------
181
182/** The rules every helper call follows, for the first wave and for a nudge. */
183export function helperRules(helperModel: DockHelperModel, sessionModel: string | null): string {
184 const modelRule =
185 helperModel === 'auto'
186 ? autoModelRule(sessionModel)
187 : helperModel === 'fast'
188 ? '- On every helper set model "haiku" and effort "low".'
189 : sessionModel !== null
190 ? `- On every helper set model "${sessionModel}", the same model as this session.`
191 : '- On every helper set model to the same model as this session.'
192
193 return [
194 '- Give each helper a short plain-English description of 3 to 5 words, like "Price check: Panera".',
195 modelRule,
196 '- In each helper\'s prompt add: "As you work, call report_progress (mcp__clean-view__report_progress) with your task name and a percent at about 25, 50, 75 and 100. Do not call plan_steps."',
197 '- When they finish, combine their results into one answer.',
198 ].join('\n')
199}
200
201/** The split instruction added to a request when Team Size is above 1. */
202export function instructionText(size: number, helperModel: DockHelperModel, sessionModel: string | null): string {
203 return [
204 `Agent Dock: Team Size is ${size}.`,
205 `- Split this request into exactly ${size} independent pieces and launch one helper (the Agent tool) per piece, all ${size} in one message so they run in parallel.`,
206 '- Find a real split, one helper per item (per store, per task, per file, per section). Never argue that it cannot be split, and never pad with useless work.',
207 helperRules(helperModel, sessionModel),
208 ].join('\n')
209}
210
211export function nudgeText(used: number, size: number): string {
212 return `You used ${used} of ${size} helpers. Split the remaining work across the other ${size - used}, one helper per piece, all in parallel.`
213}
214
215export function capMessage(size: number): string {
216 return `Team Size is ${size}: this request already has ${size} helpers. Finish with the helpers you have.`
217}
218
219export const STOPPED_MESSAGE = 'Stopped before this helper started.'
220export const HELPERS_SKIP_PLAN = 'Helpers skip the plan. Just call report_progress as you work.'
221export const BAD_CUSTOM_SIZE = 'Type a whole number from 1 to 100'
222export const BAD_COMMAND_SIZE = 'Team Size is a whole number from 1 to 100, e.g. /dock 10.'
223export const NARROW_WINDOW =
224 'The window is too narrow to show the Agent Dock. Widen it or watch the status bar.'
225
226export function bigTeamQuestion(): string {
227 return 'Big team: this uses your plan quickly. Continue?'
228}
229
230/** The info line under the size control. */
231export function infoLine(size: number, atOnce: number, helperModel: DockHelperModel): string {
232 const split = size <= 1 ? 'Claude decides how many helpers' : `Splits each request across ${size} helpers`
233 return `${split} · ${atOnce} at a time · ${helperModelLabel(helperModel)}`
234}
235
236/** What `/dock <args>` asks for. */
237export type DockCommand =
238 | { kind: 'toggle' }
239 | { kind: 'set'; size: number }
240 | { kind: 'confirm'; size: number }
241 | { kind: 'invalid' }
242
243export function dockCommand(args: string): DockCommand {
244 if (args.trim() === '') return { kind: 'toggle' }
245 const size = parseSize(args)
246 if (size === null) return { kind: 'invalid' }
247
248 return needsConfirmation(size) ? { kind: 'confirm', size } : { kind: 'set', size }
249}
250
251// ---------------------------------------------------------------------------
252// The mission and its cards: pure changes over the shared value
253// ---------------------------------------------------------------------------
254
255export function clampPercent(rawPercent: unknown): number {
256 const percent = Number(rawPercent)
257 if (!Number.isFinite(percent)) return 0
258
259 return Math.round(Math.min(100, Math.max(0, percent)))
260}
261
262/** A fresh mission for a new request. */
263export function startMission(previousId: number, prompt: string, teamSize: number, now: number): DockMission {
264 return {
265 id: previousId + 1,
266 title: jobName(prompt),
267 teamSize,
268 startedAt: now,
269 finishedAt: null,
270 cards: [],
271 admittedCalls: 0,
272 hasNudged: false,
273 }
274}
275
276function isFinished(status: DockCardStatus): boolean {
277 return status === 'done' || status === 'stuck'
278}
279
280function makeCard(id: string, description: unknown, seat: number, now: number): DockCard {
281 const task = taskName(description)
282 return {
283 id,
284 task,
285 initials: initials(task),
286 seat,
287 status: 'queued',
288 percent: 0,
289 hasReported: false,
290 agentId: null,
291 queuedAt: now,
292 startedAt: null,
293 finishedAt: null,
294 }
295}
296
297/** Sets `finishedAt` once every card has finished, and clears it when work comes back. */
298export function settleMission(mission: DockMission, now: number): DockMission {
299 const isAllFinished = mission.cards.length > 0 && mission.cards.every(card => isFinished(card.status))
300 if (isAllFinished) return mission.finishedAt === null ? { ...mission, finishedAt: now } : mission
301
302 return mission.finishedAt === null ? mission : { ...mission, finishedAt: null }
303}
304
305/** A card for an Agent call Claude just wrote, before it runs. */
306export function queueCard(mission: DockMission, toolUseId: string, description: unknown, now: number): DockMission {
307 if (mission.cards.some(card => card.id === toolUseId)) return mission
308 const card = makeCard(toolUseId, description, mission.cards.length, now)
309
310 return settleMission({ ...mission, cards: [...mission.cards, card] }, now)
311}
312
313/** The Agent call started: its card goes working, adopting a waiting card of the same task, else made now. */
314export function startCard(mission: DockMission, toolUseId: string, description: unknown, now: number): DockMission {
315 const wantedTask = taskName(description).toLowerCase()
316 const waiting = mission.cards.findIndex(
317 card => card.status === 'queued' && card.task.toLowerCase() === wantedTask,
318 )
319 const hasOwnCard = mission.cards.some(card => card.id === toolUseId)
320 const adopted =
321 !hasOwnCard && waiting !== -1
322 ? { ...mission, cards: mission.cards.map((card, index) => (index === waiting ? { ...card, id: toolUseId } : card)) }
323 : mission
324 const queued = queueCard(adopted, toolUseId, description, now)
325 const cards = queued.cards.map(card =>
326 card.id === toolUseId ? { ...card, status: 'working' as const, startedAt: now } : card,
327 )
328
329 return settleMission({ ...queued, cards }, now)
330}
331
332/** The Agent call returned: done, or stuck when it errored. */
333export function finishCard(mission: DockMission, toolUseId: string, isStuck: boolean, now: number): DockMission {
334 const cards = mission.cards.map(card =>
335 card.id === toolUseId
336 ? {
337 ...card,
338 status: isStuck ? ('stuck' as const) : ('done' as const),
339 percent: isStuck ? card.percent : 100,
340 startedAt: card.startedAt ?? now,
341 finishedAt: now,
342 }
343 : card,
344 )
345
346 return settleMission({ ...mission, cards }, now)
347}
348
349/** Takes a card away, as when its call is refused over the Team Size cap. */
350export function removeCard(mission: DockMission, toolUseId: string, now: number): DockMission {
351 const cards = mission.cards.filter(card => card.id !== toolUseId).map((card, seat) => ({ ...card, seat }))
352 return settleMission({ ...mission, cards }, now)
353}
354
355/** A call the dock let through but that never became a helper (refused below the dock): no card, no count. */
356export function dropCard(mission: DockMission, toolUseId: string, now: number): DockMission {
357 const removed = removeCard(mission, toolUseId, now)
358 return { ...removed, admittedCalls: Math.max(0, mission.admittedCalls - 1) }
359}
360
361/** Links a running helper's agent id to its card. */
362export function linkAgent(mission: DockMission, toolUseId: string, agentId: string): DockMission {
363 const cards = mission.cards.map(card => (card.id === toolUseId ? { ...card, agentId } : card))
364 return { ...mission, cards }
365}
366
367/** A helper's own report: by its agent id, else by its task name among working cards. */
368export function reportHelper(mission: DockMission, agentId: string, task: unknown, percent: number): DockMission {
369 const byAgent = mission.cards.findIndex(card => card.agentId === agentId)
370 const wantedTask = taskName(task).toLowerCase()
371 const byTask = mission.cards.findIndex(
372 card => card.status === 'working' && card.agentId === null && card.task.toLowerCase() === wantedTask,
373 )
374 const index = byAgent !== -1 ? byAgent : byTask
375 if (index === -1) return mission
376
377 const cards = mission.cards.map((card, position) =>
378 position === index && !isFinished(card.status)
379 ? { ...card, agentId, percent: clampPercent(percent), hasReported: true }
380 : card,
381 )
382 return { ...mission, cards }
383}
384
385/** The main turn ended: helpers that never started are stuck, so nothing waits forever. */
386export function endTurn(mission: DockMission, now: number): DockMission {
387 const cards = mission.cards.map(card =>
388 card.status === 'queued' ? { ...card, status: 'stuck' as const, finishedAt: now } : card,
389 )
390 return settleMission({ ...mission, cards }, now)
391}
392
393export function shouldNudge(mission: DockMission, size: number): boolean {
394 return size > 1 && !mission.hasNudged && mission.admittedCalls < size
395}
396
397export type DockCounts = { working: number; queued: number; done: number; stuck: number; total: number }
398
399export function countCards(cards: readonly DockCard[]): DockCounts {
400 const counts: DockCounts = { working: 0, queued: 0, done: 0, stuck: 0, total: cards.length }
401 for (const card of cards) counts[card.status] += 1
402
403 return counts
404}
405
406export function isLive(mission: DockMission | null): boolean {
407 return mission !== null && mission.cards.some(card => card.status === 'working' || card.status === 'queued')
408}
409
410/** The mission's overall percent: finished cards count as whole. */
411export function missionPercent(cards: readonly DockCard[]): number {
412 if (cards.length === 0) return 0
413 const total = cards.reduce((sum, card) => sum + (isFinished(card.status) ? 100 : card.percent), 0)
414
415 return Math.round(total / cards.length)
416}
417
418/** The mission bar's shares: progress made, work in flight, and the rest. */
419export function missionShares(cards: readonly DockCard[]): { progress: number; inFlight: number } {
420 if (cards.length === 0) return { progress: 0, inFlight: 0 }
421 const progress = missionPercent(cards) / 100
422 const inFlight =
423 cards.filter(card => card.status === 'working').reduce((sum, card) => sum + (100 - card.percent) / 100, 0) /
424 cards.length
425
426 return { progress, inFlight }
427}
428
429export function missionElapsed(mission: DockMission, now: number): number {
430 return (mission.finishedAt ?? now) - mission.startedAt
431}
432
433export function cardElapsed(card: DockCard, now: number): number {
434 if (card.startedAt === null) return 0
435 return (card.finishedAt ?? now) - card.startedAt
436}
437
438// ---------------------------------------------------------------------------
439// Words and numbers on screen
440// ---------------------------------------------------------------------------
441
442/** "0:41", "1:12", "1:02:03". */
443export function formatClock(milliseconds: number): string {
444 const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000))
445 const hours = Math.floor(totalSeconds / 3600)
446 const minutes = Math.floor((totalSeconds % 3600) / 60)
447 const seconds = String(totalSeconds % 60).padStart(2, '0')
448
449 return hours > 0 ? `${hours}:${String(minutes).padStart(2, '0')}:${seconds}` : `${minutes}:${seconds}`
450}
451
452/** "45s", "2m 14s", "1h 3m". */
453export function formatDuration(milliseconds: number): string {
454 const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000))
455 const hours = Math.floor(totalSeconds / 3600)
456 const minutes = Math.floor((totalSeconds % 3600) / 60)
457 const seconds = totalSeconds % 60
458 if (hours > 0) return `${hours}h ${minutes}m`
459 if (minutes > 0) return `${minutes}m ${seconds}s`
460
461 return `${seconds}s`
462}
463
464/** "50 agents finished Research bakery pricing in 2m 14s (2 got stuck)". */
465export function summaryLine(mission: DockMission, now: number): string {
466 const counts = countCards(mission.cards)
467 const agentWord = counts.total === 1 ? 'agent' : 'agents'
468 const stuckNote = counts.stuck > 0 ? ` (${counts.stuck} got stuck)` : ''
469
470 return `${counts.total} ${agentWord} finished ${mission.title} in ${formatDuration(missionElapsed(mission, now))}${stuckNote}`
471}
472
473/** The folded dock's status-bar badge. */
474export function badgeText(mission: DockMission | null, teamSize: number): string {
475 if (mission === null || mission.cards.length === 0) return `◆ Agent Dock · team of ${teamSize} standing by`
476 const counts = countCards(mission.cards)
477 const stuckPart = counts.stuck > 0 ? ` · ${counts.stuck} stuck` : ''
478
479 return `◆ ${counts.working} working · ${counts.queued} queued · ${counts.done} done${stuckPart}`
480}
481
482/** A meter `width` cells wide, filled to `percent`. */
483export function meterCells(percent: number, width: number): { filled: number; empty: number } {
484 const filled = Math.min(width, Math.max(0, Math.round((clampPercent(percent) / 100) * width)))
485 return { filled, empty: width - filled }
486}
487
488/** Where a 3-cell sweep sits on frame `frame` of a meter `width` cells wide. */
489export function sweepStart(frame: number, width: number): number {
490 const travel = Math.max(0, width - 3)
491 if (travel === 0) return 0
492 const step = frame % (travel * 2)
493
494 return step <= travel ? step : travel * 2 - step
495}
496
497/** How cards are laid out for this many helpers in this width. */
498export function cardLayout(bodyColumns: number, helperCount: number): { mode: 'cards' | 'tiles'; perRow: number; width: number } {
499 if (helperCount > CARD_LIMIT) {
500 const tileWidth = 16
501 return { mode: 'tiles', perRow: Math.max(1, Math.floor((bodyColumns + 1) / (tileWidth + 1))), width: tileWidth }
502 }
503 const perRow = bodyColumns >= 100 ? 3 : bodyColumns >= 56 ? 2 : 1
504 const width = Math.floor((bodyColumns - (perRow - 1)) / perRow)
505
506 return { mode: 'cards', perRow, width }
507}
508
509// ---------------------------------------------------------------------------
510// Colors: dark, minimal, coral to gold
511// ---------------------------------------------------------------------------
512
513export const COLORS = {
514 coral: '#F0806A',
515 gold: '#E5B85C',
516 green: '#4CC38A',
517 greenDim: '#2F7D5A',
518 red: '#E5534B',
519 hairline: '#3A3F44',
520 muted: '#8B949E',
521 ink: '#16181C',
522} as const
523
524// Muted seat colors for badges and idle seats: no rainbow.
525const SEAT_COLORS = ['#E8836B', '#D9B26A', '#8FB39A', '#7F9CC4', '#B08CB5', '#C9A98A', '#6FB3B0', '#C98C99'] as const
526
527export function seatColor(seat: number): string {
528 return SEAT_COLORS[((seat % SEAT_COLORS.length) + SEAT_COLORS.length) % SEAT_COLORS.length] ?? COLORS.coral
529}
530
531function hexToRgb(hex: string): [number, number, number] {
532 const value = Number.parseInt(hex.replace('#', ''), 16)
533 return [(value >> 16) & 255, (value >> 8) & 255, value & 255]
534}
535
536/** A color `t` of the way from `from` to `to`, as hex. */
537export function blend(from: string, to: string, t: number): string {
538 const share = Math.min(1, Math.max(0, t))
539 const start = hexToRgb(from)
540 const end = hexToRgb(to)
541 const mixed = start.map((channel, index) => Math.round(channel + ((end[index] ?? channel) - channel) * share))
542
543 return `#${mixed.map(channel => channel.toString(16).padStart(2, '0')).join('')}`
544}
545types/index.d.ts 138 lines1// Clean View's shared state: other mods can read the checklist from here.
2
3/** Where the current job stands; drives the band's header. */
4export type CleanViewPhase = 'idle' | 'working' | 'needsYou' | 'stuck' | 'stopped' | 'done'
5
6/** One checklist row's state. */
7export type CleanViewTaskStatus = 'done' | 'active' | 'upcoming'
8
9/** One step of the plan, as the checklist shows it. */
10export type CleanViewTask = {
11 id: string
12 name: string
13 status: CleanViewTaskStatus
14 /** 0 to 100, as last reported. */
15 percent: number
16 /** False until Claude reports a percent, so the label says Working instead of a percent. */
17 hasReported: boolean
18}
19
20/** Where one of the main chat's helpers stands. */
21export type CleanViewHelperStatus = 'working' | 'idle' | 'done' | 'stuck'
22
23/** One helper (subagent) the main chat started, shown under the step it began in. */
24export type CleanViewHelper = {
25 /** The Agent call's tool_use id. */
26 id: string
27 /** The helper's agent id once known. */
28 agentId: string | null
29 /** The Agent call's description, cleaned. */
30 name: string
31 /** The step that was active when it started, or null. */
32 stepId: string | null
33 status: CleanViewHelperStatus
34 /** 0 to 100, as the helper last reported. */
35 percent: number
36 /** False until the helper reports, so its meter sweeps. */
37 hasReported: boolean
38 startedAt: number
39 finishedAt: number | null
40}
41
42/** The job the band describes: its name, plan and timing. */
43export type CleanViewChecklist = {
44 /** Grows by one per job, so late answers for an older job are ignored. */
45 jobId: number
46 title: string
47 phase: CleanViewPhase
48 tasks: CleanViewTask[]
49 /** True once a real plan replaced the placeholder steps. */
50 hasPlan: boolean
51 needsYouReason: string | null
52 stuckReason: string | null
53 startedAt: number
54 finishedAt: number | null
55 /** True once a finished job has shrunk to one line. */
56 isCollapsed: boolean
57 /** The main chat's helpers; older saved state may lack it. */
58 helpers: CleanViewHelper[]
59}
60
61/** Agent Dock: where one helper stands. */
62export type DockCardStatus = 'queued' | 'working' | 'done' | 'stuck'
63
64/** Agent Dock: one helper's card. */
65export type DockCard = {
66 /** The Agent call's tool_use id. */
67 id: string
68 /** Its 3 to 5 word task name. */
69 task: string
70 /** Two letters for its badge. */
71 initials: string
72 /** Its place in the team, which picks its badge color and headshot. */
73 seat: number
74 status: DockCardStatus
75 /** 0 to 100, as the helper last reported. */
76 percent: number
77 /** False until the helper reports, so its meter sweeps. */
78 hasReported: boolean
79 /** The helper's agent id once it started. */
80 agentId: string | null
81 queuedAt: number
82 startedAt: number | null
83 finishedAt: number | null
84}
85
86/** Agent Dock: one request and its helpers. */
87export type DockMission = {
88 id: number
89 /** The job's name, from the request's first clause. */
90 title: string
91 /** The Team Size when the request was sent. */
92 teamSize: number
93 startedAt: number
94 /** Set once every card has finished. */
95 finishedAt: number | null
96 cards: DockCard[]
97 /** Agent calls let through for this request: the Team Size cap counts these. */
98 admittedCalls: number
99 /** True once the request got its one follow-up. */
100 hasNudged: boolean
101}
102
103/** Agent Dock: which model helpers run on; Auto lets Claude pick per helper. */
104export type DockHelperModel = 'auto' | 'fast' | 'same'
105
106/** Agent Dock: the person's choices. */
107export type DockSettings = {
108 teamSize: number
109 /** A size above 20 waiting for Continue or Cancel. */
110 pendingSize: number | null
111 helperModel: DockHelperModel
112 /** True once the person picked a helper model; until then it follows the default, Auto. */
113 hasChosenHelperModel: boolean
114 /** True while the Custom size box is open. */
115 isCustomOpen: boolean
116 /** True while the dock is folded to its status-bar badge. */
117 isFolded: boolean
118}
119
120declare module 'claude-code' {
121 interface PluginState {
122 'clean-view': {
123 cleanViewEnabled: boolean
124 checklist: CleanViewChecklist | null
125 /** Animation frame counter, advanced every 250ms while a job runs. */
126 tick: number
127 /** Agent Dock: Team Size, helper model and pane choices. */
128 dock: DockSettings
129 /** Agent Dock: the current request's helpers. */
130 dockMission: DockMission | null
131 /** Agent Dock: animation frame, advanced only while helpers are live. */
132 dockTick: number
133 /** Agent Dock: headshot file names found in employee-headshots. */
134 dockHeadshots: string[]
135 }
136 }
137}
138