SLOPSHOPPER

clean-view

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…

newpanebandspinnerrowsguard
★ 1v0.2.0no licenseupdated 2026-10-14ataege/claude-skills/plugins/clean-view
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clean-view
│ ┃ Agent Dock ✕ › fix the failing auth tes╭────────────────────────────────────────╮ │ ┃ ◆ A G E N T D O C K S T A N D │ clean-view │ │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ Clean View is off. Showing everything. │ │ ┃ T E A M S I Z E ╭size-1size-3size-5size-1 ⎿ Read 6 lines ╰────────────────────────────────────────╯ │ ┃ Claude decides how many helpers · 10 at a ⏺ Update(src/auth.ts) │ ┃ Helper agents model-auto / model-fast / mod ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Read(/work/app/src/auth.ts) │ ┃ ● ⎿ Denied by clean-view: Clean View: call mcp__clean-view__p │ ┃ Your team of 1 is standing by │ ┃ Send a request and Claude decides how many h ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /dock │ ⎿ clean-view: Agent Dock toggled. Type /dock again to switch back. │ │ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Ready [ ○ Clean View: OFF ] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ open-dock

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Ready [ ○ Clean View: OFF ] │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Pane · Agent Dock
◆ A G E N T D O C K S T A N D I N G B Y ──────────────────────────────────────────────────────── T E A M S I Z E ╭size-1size-3size-5size-10size-20size-50s Claude decides how many helpers · 10 at a time · Au… Helper agents model-auto / model-fast / model-same ● Your team of 1 is standing by Send a request and Claude decides how many helpers.
Prompt hint
⟨Claude Code's own drawing⟩ open-dock
README

claude-skills

A Claude Code plugin marketplace.

PluginWhat it does
clean-viewHides 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-barOne colored line under the prompt: plan, model, effort, context, 7-day and 5-hour limits, and running agents by model and effort.
model-routingRoutes 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-menuA ☰ 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-upgradeTurns 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.

Install

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.

Developing

Each plugin lives in plugins/<name>. Check one with:

claude plugin validate plugins/<name>
claude plugin test plugins/<name>
Source 5 files
hooks/register.tsx 12 lines
1import 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}
12
hooks/clean-view.tsx 1445 lines
1// 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 lines
1// 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}
885
hooks/dock-logic.ts 545 lines
1// 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}
545
types/index.d.ts 138 lines
1// 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