Makes Claude Code calm for non-technical people. Clean View hides tool rows and shows a plain-English checklist of the job above the prompt.

Ian's Claude Code mods. The repo is a plugin marketplace called ian-mods, and each mod lives in its own folder under mods/.
| Mod | What it does |
|---|---|
prompt-upgrade | /better <prompt> asks up to three quick questions, rewrites the prompt and shows the original and the rewrite side by side. Nothing runs until you press Run. /better on sends what you type to it first. |
work-report | /report opens a pane with the tasks Claude finished for you and an estimate of the hours saved and what that time is worth, for today, the last 7 days, the last 30 days and the year. /report settings shows and changes the minutes per task and the hourly rate. /report backfill fills in past days from this machine's session history (needs /usr/bin/python3). Building software is estimated from the lines of code written (/report settings buildrate 25), and a By project section shows hours per repo. /report add "QBR builder" 160 2026-05-01 adds hours for older work (/report list, /report remove 1). Counts are kept per machine. |
ai-employee-kit | Clean View: while Claude works, tool calls, diffs and command output are hidden and a plain-English checklist of the job sits above the prompt (header, steps, progress meters, Needs you / Stuck / Stopped / All done). /simple on / /simple off, or the "Clean View" button in the footer. In the terminal Claude must lay out its steps first; in the desktop app its own task list is the plan. |
The clone lives at ~/claude-mods on every machine. Install each mod once per machine at user scope. After that it loads in every local session: the desktop app's Code tab and the terminal.
git clone https://github.com/iansutherland75/claude-mods.git ~/claude-mods
claude plugin marketplace add ~/claude-mods
claude plugin install prompt-upgrade@ian-mods --scope user
claude plugin install work-report@ian-mods --scope user
claude plugin install ai-employee-kit@ian-mods --scope user
Run these with Claude Code 2.1.295 or newer. If claude --version shows an older build, run brew upgrade claude-code first.
The marketplace is a local folder, so sessions read each mod straight from ~/claude-mods. Nothing needs reinstalling after a change.
git pull in ~/claude-mods./reload-plugins in any open session. New sessions pick it up on their own.A new mod needs one extra step on each machine: claude plugin install <mod>@ian-mods --scope user.
mods/<mod-name>/ with its .claude-plugin/plugin.json, hooks/ and tests..claude-plugin/marketplace.json with "source": "./mods/<mod-name>".claude plugin validate mods/<mod-name> and claude plugin test mods/<mod-name>.Cloud sessions (claude.ai/code, cloud Cowork) do not see mods installed on a machine.
hooks/register.tsx 9 lines1import type { Register } from 'claude-code'
2
3import { registerCleanView } from './clean-view'
4
5// The AI Employee Kit: each mod registers its own hooks here, side by side.
6export const register: Register = on => {
7 registerCleanView(on)
8}
9hooks/clean-view.tsx 515 lines1// Clean View: hides the tool rows and keeps a plain-English checklist of the
2// job above the prompt. Every engine call lives here; the rules are in
3// clean-view-logic.ts and the desktop drawing in clean-view-desktop.ts.
4
5import type { EngineInterface, On, Timer } from 'claude-code'
6
7import type { CleanViewChecklist } from '../types'
8import { bandSvg, pixelsFor } from './clean-view-desktop'
9import {
10 GATE_MESSAGE,
11 KEEPS_FAILING,
12 METER,
13 NAME_SYSTEM,
14 PERMISSION_REASON,
15 QUESTION_REASON,
16 SAID_NO,
17 addListTask,
18 applyList,
19 applyListStep,
20 applyPlan,
21 applyProgress,
22 clip,
23 composeText,
24 continuesJob,
25 finishTurn,
26 hasPlan,
27 headerOf,
28 isLive,
29 isRealPrompt,
30 isRefusedByPerson,
31 meterCells,
32 nameWidth,
33 namePrompt,
34 needsYou,
35 newJob,
36 notificationReason,
37 readJobName,
38 rowsOf,
39 shouldGate,
40 stuck,
41 titleFromPrompt,
42 updateListTask,
43} from './clean-view-logic'
44
45type D = EngineInterface
46
47const GOLD = '#F2C46D'
48const CORAL = '#F0876A'
49const LINE = '#262B33'
50const MUTED = '#7D8590'
51const TEXT = '#E6EDF3'
52const GREEN = '#3DD68C'
53const AMBER = '#E3B341'
54const INK = '#0D1117'
55
56const FAILED = 'Clean View hit a problem and could not do that. Nothing was changed.'
57const HELPER_PLAN = 'Helpers skip the plan. Just call report_progress as you work.'
58
59// ---------------------------------------------------------------- state
60
61const ENABLED = { plugin: 'ai-employee-kit', key: 'cleanViewEnabled' } as const
62const CHECKLIST = { plugin: 'ai-employee-kit', key: 'checklist' } as const
63const TICK = { plugin: 'ai-employee-kit', key: 'tick' } as const
64
65const getEnabled = async ($: D): Promise<boolean> => (await $.state.get(ENABLED)).value ?? true
66const getChecklist = async ($: D): Promise<CleanViewChecklist | null> =>
67 ((await $.state.get(CHECKLIST)).value as CleanViewChecklist | null | undefined) ?? null
68const getTick = async ($: D): Promise<number> => (await $.state.get(TICK)).value ?? 0
69
70// The task list Claude keeps in the desktop app.
71const isDesktopList = (tool: string) => tool.startsWith('mcp__ccd_') && tool.endsWith('update_plan')
72
73// Only the terminal gates tools until a plan exists; the desktop app's own
74// task list is the plan there.
75let isTerminal = false
76let failStreak = 0
77let lastError: { kind: string | null; details: string } | null = null
78let clock: Timer | null = null
79let interval = 1000
80
81// Read, apply, write with ifVersion; again on a miss. Starts or stops the
82// animation clock to match.
83const change = async ($: D, apply: (list: CleanViewChecklist) => CleanViewChecklist | null): Promise<CleanViewChecklist | null> => {
84 for (let tries = 0; tries < 20; tries++) {
85 const now = await $.state.get(CHECKLIST)
86 const current = (now.value as CleanViewChecklist | null | undefined) ?? null
87 if (current === null) return null
88 const next = apply(current)
89 if (next === current) return current
90 const result = await $.state.set(CHECKLIST, next, { ifVersion: now.version })
91 if (result.isSet) {
92 await syncClock($, next)
93
94 return next
95 }
96 }
97
98 return null
99}
100
101const setChecklist = async ($: D, list: CleanViewChecklist | null) => {
102 await $.state.set(CHECKLIST, list)
103 await syncClock($, list)
104}
105
106// One clock, and only while a job is working or waiting on the person.
107const syncClock = async ($: D, list: CleanViewChecklist | null) => {
108 const shouldRun = isLive(list?.phase) && (await getEnabled($))
109 if (shouldRun && clock === null) {
110 clock = $.clock.every(interval, () => {
111 void (async () => {
112 const held = await $.state.get(TICK)
113 await $.state.set(TICK, ((held.value ?? 0) + 1) % 1_000_000, { ifVersion: held.version })
114 })().catch(() => undefined)
115 })
116 } else if (!shouldRun && clock !== null) {
117 clock.cancel()
118 clock = null
119 }
120}
121
122const setEnabled = async ($: D, isOn: boolean) => {
123 await $.state.set(ENABLED, isOn)
124 await $.store.set('cleanViewEnabled', isOn)
125 await syncClock($, await getChecklist($))
126}
127
128// A short name for the job from Haiku, swapped in unless a newer job started.
129const nameJob = async ($: D, jobId: number, text: string) => {
130 const ask = (withEffort: boolean) =>
131 $.model.complete({
132 model: 'haiku',
133 system: NAME_SYSTEM,
134 prompt: namePrompt(text),
135 maxTokens: 30,
136 timeoutMs: 15_000,
137 ...(withEffort ? { effort: 'low' as const } : {}),
138 })
139 let reply = await ask(true)
140 if (!reply.isAnswered && reply.reason === 'api-error' && reply.status === 400) reply = await ask(false)
141 if (!reply.isAnswered) return
142 const name = readJobName(reply.text)
143 if (name === null) return
144 await change($, list => (list.jobId === jobId ? { ...list, title: name } : list))
145}
146
147// The band's switch: Clean View off, and the band with it.
148const turnOff = async ($: D) => {
149 await setEnabled($, false)
150 toast($, false)
151}
152
153const toast = ($: D, isOn: boolean) =>
154 $.ui.toast(isOn ? 'Clean View is on. Tool rows are hidden.' : 'Clean View is off. Every row is back. Type /simple on to bring it back.')
155
156export const registerCleanView = (on: On) => {
157 // ---------------------------------------------------------------- start
158
159 on('session.start', async ($, e, next) => {
160 isTerminal = e.surface === 'terminal'
161 interval = isTerminal ? 250 : 1000
162 try {
163 await $.tool.register({
164 name: 'plan_steps',
165 description:
166 'Lay out every step of the job up front, in plain English a non-technical person understands: 2 to 8 short names in order, each under 40 characters and starting with a verb. The first step starts right away.',
167 inputSchema: {
168 type: 'object',
169 properties: { steps: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 8 } },
170 required: ['steps'],
171 },
172 isDeferred: false,
173 })
174 await $.tool.register({
175 name: 'report_progress',
176 description:
177 'Report progress on the current step: its plain-English name and a percent from 0 to 100. Report 100 the moment a step finishes; the next one starts by itself.',
178 inputSchema: {
179 type: 'object',
180 properties: { task: { type: 'string' }, percent: { type: 'number', minimum: 0, maximum: 100 } },
181 required: ['task', 'percent'],
182 },
183 isDeferred: false,
184 })
185 await $.command.register({
186 name: 'simple',
187 description: 'Clean View: hide tool rows and show a plain checklist. on, off, or nothing to flip it',
188 argumentHint: '[on | off]',
189 })
190 } catch {
191 // registration refused; the rest still works
192 }
193 try {
194 const kept = await $.store.get('cleanViewEnabled')
195 await $.state.set(ENABLED, typeof kept === 'boolean' ? kept : true)
196 await syncClock($, await getChecklist($))
197 } catch {
198 // nothing kept yet
199 }
200
201 return next(e)
202 })
203
204 on('command.run', { command: 'simple' }, async ($, e) => {
205 try {
206 const word = e.args.trim().toLowerCase()
207 if (word !== '' && word !== 'on' && word !== 'off') return { text: 'Try /simple on, /simple off, or /simple to flip it.' }
208 const isOn = word === '' ? !(await getEnabled($)) : word === 'on'
209 await setEnabled($, isOn)
210
211 return {
212 text: isOn
213 ? 'Clean View is on. Tool rows are hidden and the checklist shows above the prompt while Claude works.'
214 : 'Clean View is off. Every tool row is back.',
215 }
216 } catch {
217 return { text: FAILED }
218 }
219 }).catch(() => ({ text: FAILED }))
220
221 // ---------------------------------------------------------------- what Claude is told
222
223 on('prompt.compose', async ($, e, next) => {
224 const composed = await next(e)
225 if (!(await getEnabled($))) return composed
226 const hasTaskList = e.tools.some(isDesktopList)
227
228 return {
229 ...composed,
230 sections: [...composed.sections, { id: 'ai-employee-kit:clean-view', text: composeText(hasTaskList), scope: 'session' as const }],
231 }
232 }).catch(($, e, next) => next(e))
233
234 // ---------------------------------------------------------------- jobs
235
236 on('turn.start', async ($, e, next) => {
237 lastError = null
238 const list = await getChecklist($)
239 if (continuesJob(list)) {
240 if (list?.phase === 'needsYou') await change($, one => ({ ...one, phase: 'working', needsYouReason: null }))
241 } else if (isRealPrompt(e.text)) {
242 failStreak = 0
243 const jobId = (list?.jobId ?? 0) + 1
244 await setChecklist($, newJob(jobId, titleFromPrompt(e.text), await $.clock.now()))
245 if (await getEnabled($)) void nameJob($, jobId, e.text).catch(() => undefined)
246 }
247
248 return next(e)
249 }).catch(($, e, next) => next(e))
250
251 on('classic.StopFailure', async ($, e, next) => {
252 lastError = { kind: e.error, details: e.error_details ?? '' }
253
254 return next(e)
255 }).catch(($, e, next) => next(e))
256
257 on('classic.Notification', async ($, e, next) => {
258 const reason = notificationReason(e.notification_type)
259 if (reason !== null) await change($, list => (isLive(list.phase) ? needsYou(list, reason) : list))
260
261 return next(e)
262 }).catch(($, e, next) => next(e))
263
264 on('classic.PermissionRequest', async ($, e, next) => {
265 await change($, list => (isLive(list.phase) ? needsYou(list, PERMISSION_REASON) : list))
266
267 return next(e)
268 }).catch(($, e, next) => next(e))
269
270 on('turn.complete', async ($, e, next) => {
271 if (e.agentId === undefined) {
272 const now = await $.clock.now()
273 const finished = await change($, list =>
274 isLive(list.phase) ? finishTurn(list, { reason: e.reason, errorKind: lastError?.kind ?? null, errorDetails: lastError?.details ?? '' }, now) : list,
275 )
276 if (finished?.phase === 'done') {
277 const jobId = finished.jobId
278 $.clock.after(5000, () => {
279 void change($, list => (list.jobId === jobId && list.phase === 'done' ? { ...list, isCollapsed: true } : list)).catch(() => undefined)
280 })
281 }
282 }
283
284 return next(e)
285 }).catch(($, e, next) => next(e))
286
287 // ---------------------------------------------------------------- the two tools
288
289 on('tool.call', { tool: 'mcp__ai-employee-kit__plan_steps' }, async ($, e) => {
290 if (e.agentId !== undefined) return { result: HELPER_PLAN }
291 const steps = Array.isArray(e.steps) ? (e.steps as unknown[]) : []
292 if ((await getChecklist($)) === null) await setChecklist($, newJob(1, 'Working on it', await $.clock.now()))
293 let count = 0
294 await change($, list => {
295 const planned = applyPlan(list, steps)
296 count = planned.count
297
298 return planned.list
299 })
300
301 return {
302 result:
303 count === 0
304 ? 'Give plan_steps 2 to 8 short step names in plain English.'
305 : `Planned ${count} ${count === 1 ? 'step' : 'steps'}. The first one has started.`,
306 }
307 }).catch(() => ({ result: 'Planned. Go ahead.' }))
308
309 on('tool.call', { tool: 'mcp__ai-employee-kit__report_progress' }, async ($, e) => {
310 const raw = typeof e.percent === 'number' ? e.percent : Number(e.percent)
311 let percent = Number.isFinite(raw) ? Math.max(0, Math.min(100, Math.round(raw))) : 0
312 if (e.agentId === undefined) {
313 if ((await getChecklist($)) === null) await setChecklist($, newJob(1, 'Working on it', await $.clock.now()))
314 await change($, list => {
315 const moved = applyProgress(list, typeof e.task === 'string' ? e.task : '', e.percent)
316 percent = moved.percent
317
318 return moved.list
319 })
320 }
321
322 return { result: `Progress noted: ${percent}%.` }
323 }).catch(() => ({ result: 'Progress noted.' }))
324
325 // ---------------------------------------------------------------- every other tool
326
327 on('tool.call', async ($, e, next) => {
328 const tool = String(e.tool)
329 const isMain = e.agentId === undefined
330 const isEnabled = await getEnabled($)
331 const list = await getChecklist($)
332 if (shouldGate({ isEnabled, isTerminal, agentId: e.agentId, tool, isPlanned: hasPlan(list) })) return { deny: GATE_MESSAGE }
333 if (!isMain || list === null) return next(e)
334
335 const input = e as unknown as Record<string, unknown>
336 if (tool === 'AskUserQuestion') await change($, one => needsYou(one, QUESTION_REASON))
337 if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
338 const todos = input.todos as { content?: unknown; status?: unknown }[]
339 await change($, one => applyList(one, todos.map(todo => ({ name: String(todo.content ?? ''), status: String(todo.status ?? '') }))))
340 }
341 if (isDesktopList(tool)) {
342 if (Array.isArray(input.items)) {
343 const items = input.items as { content?: unknown; status?: unknown }[]
344 await change($, one => applyList(one, items.map(item => ({ name: String(item.content ?? ''), status: String(item.status ?? '') }))))
345 } else {
346 await change($, one => applyListStep(one, input))
347 }
348 }
349 if (tool === 'TaskUpdate' && typeof input.taskId === 'string') {
350 const taskId = input.taskId
351 await change($, one => updateListTask(one, taskId, input))
352 }
353
354 const ran = await next(e)
355
356 if (tool === 'TaskCreate' && ran.deny === undefined && !ran.isError) {
357 const created = (ran.result as { task?: { id?: unknown } } | undefined)?.task?.id
358 if (created !== undefined) await change($, one => addListTask(one, String(created), String(input.subject ?? '')))
359 }
360 if (ran.deny === undefined && ran.isError === true) {
361 if (isRefusedByPerson(ran.text)) {
362 failStreak = 0
363 await change($, one => stuck(one, SAID_NO))
364 } else {
365 failStreak += 1
366 if (failStreak >= 3) await change($, one => stuck(one, KEEPS_FAILING))
367 }
368 } else if (ran.deny === undefined) {
369 failStreak = 0
370 // A step ran: Claude is moving again, past a permission or a failure.
371 await change($, one =>
372 one.phase === 'stuck' || (one.phase === 'needsYou' && one.needsYouReason !== QUESTION_REASON)
373 ? { ...one, phase: 'working', needsYouReason: null, stuckReason: null }
374 : one.phase === 'needsYou' && tool === 'AskUserQuestion'
375 ? { ...one, phase: 'working', needsYouReason: null }
376 : one,
377 )
378 }
379
380 return ran
381 }).catch(($, e, next) => next(e))
382
383 // ---------------------------------------------------------------- hiding the technical rows
384
385 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
386 if (!(await getEnabled($))) return next(e)
387 const { Box } = $.ui.resolve(e)
388
389 return <Box display="none" />
390 })
391
392 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
393 if (!(await getEnabled($))) return next(e)
394 const { Box } = $.ui.resolve(e)
395
396 return <Box display="none" />
397 })
398
399 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
400 if (!(await getEnabled($))) return next(e)
401 const { Box } = $.ui.resolve(e)
402
403 return <Box display="none" />
404 })
405
406 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
407 if (!(await getEnabled($))) return next(e)
408
409 return next({ ...e, props: { ...e.props, hint: '' } })
410 })
411
412 // ---------------------------------------------------------------- the on and off button
413
414 // Drawn after whatever else the footer holds (Work Report's button, the
415 // engine's mode labels), so the mods sit side by side.
416 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
417 const below = await next(e)
418 const { Box, Button, Text } = $.ui.resolve(e)
419 const isOn = await getEnabled($)
420
421 return (
422 <Box flexDirection="row" gap={2}>
423 {below}
424 <Button
425 key="clean-view-toggle"
426 plain
427 onPress={() => {
428 void (async () => {
429 const isNowOn = !(await getEnabled($))
430 await setEnabled($, isNowOn)
431 toast($, isNowOn)
432 })().catch(() => undefined)
433 }}
434 >
435 <Text dimColor>{isOn ? 'Clean View: On' : 'Clean View: Off'}</Text>
436 </Button>
437 </Box>
438 )
439 })
440
441 // ---------------------------------------------------------------- the checklist band
442
443 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
444 if (e.props.hasSurvey || !(await getEnabled($))) return next(e)
445 const list = await getChecklist($)
446 if (list === null || list.phase === 'idle') return next(e)
447 const frame = await getTick($)
448 const now = await $.clock.now()
449
450 if (e.surface !== 'terminal') {
451 const table = $.ui.resolve(e)
452 if (!('Svg' in table)) return next(e)
453 const { Box, Button, Svg } = table
454 const band = bandSvg(list, pixelsFor(e.props.bodyColumns), now, frame)
455
456 // The desktop draws no plugin buttons in its footer, so the switch
457 // rides with the band there.
458 return (
459 <Box flexDirection="column">
460 <Svg source={band.source} alt={band.alt} />
461 <Box flexDirection="row">
462 <Button key="clean-view-band-toggle" dimColor onPress={() => void turnOff($).catch(() => undefined)}>
463 Clean View: On
464 </Button>
465 </Box>
466 </Box>
467 )
468 }
469
470 const { Box, Text } = $.ui.resolve(e)
471 const width = Math.max(20, e.props.bodyColumns)
472 const header = headerOf(list, now, 'you pressed Esc')
473 const headerColor = header.kind === 'done' ? GREEN : header.kind === 'stuck' ? AMBER : header.kind === 'stopped' ? MUTED : TEXT
474 const headerRow =
475 header.badge !== null ? (
476 <Box flexDirection="row" width={width}>
477 <Text backgroundColor={AMBER} color={INK} bold>{` ${header.badge} `}</Text>
478 <Text color={TEXT}>{` ${clip(header.text, width - header.badge.length - 3)}`}</Text>
479 </Box>
480 ) : (
481 <Box flexDirection="row" width={width}>
482 <Text color={headerColor} bold>
483 {clip(header.text, width)}
484 </Text>
485 </Box>
486 )
487 if (list.phase === 'done' && list.isCollapsed) return headerRow
488
489 const nameRoom = nameWidth(width)
490 const rows = rowsOf(list)
491
492 return (
493 <Box flexDirection="column" width={width}>
494 {headerRow}
495 {rows.map((row, i) => {
496 const one = list.tasks[i]!
497 const markColor = row.mark === '✓' ? GREEN : row.mark === '‖' ? AMBER : row.mark === '▶' ? GOLD : MUTED
498 const meterColor = one.status === 'done' ? GREEN : one.status === 'active' ? CORAL : LINE
499
500 return (
501 <Box key={`row-${one.id}`} flexDirection="row" width={width}>
502 <Text color={markColor}>{`${row.mark} `}</Text>
503 <Text color={row.tone === 'active' ? TEXT : MUTED} bold={row.tone === 'active'} dimColor={row.tone !== 'active'}>
504 {`${clip(row.name, nameRoom).padEnd(nameRoom)} `}
505 </Text>
506 <Text color={meterColor}>{meterCells(one, frame).slice(0, METER)}</Text>
507 <Text color={row.tone === 'active' ? TEXT : MUTED}>{` ${row.label.padStart(7)}`}</Text>
508 </Box>
509 )
510 })}
511 </Box>
512 )
513 })
514}
515hooks/clean-view-desktop.ts 102 lines1// Clean View's band in the desktop app. The desktop sets text in a
2// proportional font and draws its own buttons, so cell-sized rows drift; the
3// band is one SVG sized in pixels instead, drawn as a plain image: an
4// interactive (animated) SVG is framed at a fixed size and shrinks the text.
5// The sweep on a step that has not reported yet moves one notch per redraw.
6
7import type { CleanViewChecklist } from '../types'
8import { headerOf, rowsOf } from './clean-view-logic'
9
10const GOLD = '#F2C46D'
11const CORAL = '#F0876A'
12const LINE = '#262B33'
13const MUTED = '#7D8590'
14const TEXT = '#E6EDF3'
15const GREEN = '#3DD68C'
16const AMBER = '#E3B341'
17const RED = '#F47067'
18const INK = '#0D1117'
19
20const SANS = `-apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', sans-serif`
21
22export type Band = { source: string; alt: string; height: number }
23
24const esc = (text: string) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
25
26// About how wide a run of text sets, for laying things beside it.
27const textWidth = (text: string, size: number, isBold = false) => text.length * size * (isBold ? 0.6 : 0.54)
28
29// The desktop reports fewer columns than it shows: about 13 pixels each. An
30// SVG wider than its slot is scaled down to fit, never cut.
31export const pixelsFor = (columns: number): number => Math.max(300, Math.round(columns * 13))
32
33const fit = (text: string, room: number, size: number, isBold = false) => {
34 if (textWidth(text, size, isBold) <= room) return text
35 const keep = Math.max(1, Math.floor(room / (size * (isBold ? 0.6 : 0.54))) - 1)
36
37 return `${text.slice(0, keep)}…`
38}
39
40const svg = (width: number, height: number, body: string) =>
41 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="${esc(SANS)}">` +
42 `<defs><linearGradient id="cv-sweep" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="${GOLD}" stop-opacity="0"/><stop offset="0.5" stop-color="${GOLD}" stop-opacity="0.9"/><stop offset="1" stop-color="${GOLD}" stop-opacity="0"/></linearGradient>` +
43 `<linearGradient id="cv-fill" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="${CORAL}"/><stop offset="1" stop-color="${GOLD}"/></linearGradient></defs>${body}</svg>`
44
45const HEADER_TONE: Record<string, string> = { stuck: AMBER, stopped: MUTED, done: GREEN }
46
47const headerSvg = (list: CleanViewChecklist, width: number, now: number) => {
48 const header = headerOf(list, now, 'you pressed Stop')
49 if (header.badge !== null) {
50 const badgeWidth = Math.round(textWidth(header.badge, 12, true) + 20)
51
52 return (
53 `<rect x="0" y="4" width="${badgeWidth}" height="22" rx="11" fill="${AMBER}"/>` +
54 `<text x="${badgeWidth / 2}" y="19" font-size="12" font-weight="700" fill="${INK}" text-anchor="middle">${esc(header.badge)}</text>` +
55 `<text x="${badgeWidth + 10}" y="20" font-size="14" fill="${TEXT}">${esc(fit(header.text, width - badgeWidth - 10, 14))}</text>`
56 )
57 }
58 const color = HEADER_TONE[header.kind] ?? TEXT
59
60 return `<text x="0" y="20" font-size="14" font-weight="600" fill="${color}">${esc(fit(header.text, width, 14, true))}</text>`
61}
62
63// One full row per step: its mark, its name, a meter and a label.
64export const bandSvg = (list: CleanViewChecklist, width: number, now: number, frame = 0): Band => {
65 const header = headerSvg(list, width, now)
66 if (list.phase === 'done' && list.isCollapsed) {
67 return { source: svg(width, 28, header), alt: headerOf(list, now, 'you pressed Stop').text, height: 28 }
68 }
69 const rows = rowsOf(list)
70 const meterWidth = 120
71 const labelWidth = 64
72 const meterX = width - labelWidth - meterWidth - 12
73 const nameX = 26
74 const nameRoom = Math.max(40, meterX - nameX - 12)
75 let body = header + `<line x1="0" y1="34.5" x2="${width}" y2="34.5" stroke="${LINE}"/>`
76 rows.forEach((row, i) => {
77 const one = list.tasks[i]!
78 const y = 58 + i * 28
79 const markColor = row.mark === '✓' ? GREEN : row.mark === '‖' ? AMBER : row.mark === '▶' ? GOLD : MUTED
80 const nameColor = row.tone === 'active' ? TEXT : MUTED
81 body += `<text x="2" y="${y}" font-size="13" fill="${markColor}">${row.mark}</text>`
82 body += `<text x="${nameX}" y="${y}" font-size="14" fill="${nameColor}"${row.tone === 'active' ? ' font-weight="600"' : ''}>${esc(fit(row.name, nameRoom, 14, row.tone === 'active'))}</text>`
83 body += `<rect x="${meterX}" y="${y - 8}" width="${meterWidth}" height="6" rx="3" fill="${LINE}"/>`
84 if (one.status === 'done') {
85 body += `<rect x="${meterX}" y="${y - 8}" width="${meterWidth}" height="6" rx="3" fill="${GREEN}" fill-opacity="0.75"/>`
86 } else if (one.status === 'active' && one.hasReported) {
87 const filled = Math.round((one.percent / 100) * meterWidth)
88 if (filled > 0) body += `<rect x="${meterX}" y="${y - 8}" width="${filled}" height="6" rx="3" fill="url(#cv-fill)"/>`
89 } else if (one.status === 'active' && list.phase === 'working') {
90 const notches = 6
91 const sweep = 40
92 const x = meterX + ((frame % notches) / (notches - 1)) * (meterWidth - sweep)
93 body += `<rect x="${x.toFixed(1)}" y="${y - 8}" width="${sweep}" height="6" rx="3" fill="url(#cv-sweep)"/>`
94 }
95 body += `<text x="${width}" y="${y}" font-size="12" fill="${row.tone === 'active' ? TEXT : MUTED}" text-anchor="end">${esc(row.label)}</text>`
96 })
97 const height = 58 + (rows.length - 1) * 28 + 12
98 const alt = [headerOf(list, now, 'you pressed Stop').text, ...rows.map(row => `${row.mark} ${row.name} ${row.label}`)].join('. ')
99
100 return { source: svg(width, height, body), alt, height }
101}
102hooks/clean-view-logic.ts 431 lines1// Clean View's rules, with no engine calls, so the tests can call them.
2
3import type { CleanViewChecklist, CleanViewPhase, CleanViewTask } from '../types'
4
5export const PLUGIN = 'ai-employee-kit'
6export const PLAN_TOOL = `mcp__${PLUGIN}__plan_steps`
7export const PROGRESS_TOOL = `mcp__${PLUGIN}__report_progress`
8
9// ---------------------------------------------------------------- names
10
11const CODE_FILE =
12 /\b[\w.-]+\.(tsx?|jsx?|mjs|cjs|mts|cts|py|rb|go|rs|java|kt|swift|c|cc|cpp|h|hpp|cs|php|sh|zsh|bash|ps1|json|jsonl|ya?ml|toml|ini|css|scss|less|html?|vue|svelte|sql|md|mdx|lock|env|xml|gradle|plist)\b/gi
13
14export const NAME_LIMIT = 40
15
16// Every step and job name goes through here: no code, no paths, no code file
17// names, one line, a capital first letter, 40 characters at most.
18export const cleanName = (raw: string): string => {
19 let text = String(raw ?? '')
20 .replace(/`[^`]*`/g, ' ')
21 .replace(/`/g, ' ')
22 .split(/\s+/)
23 .filter(word => !word.includes('/') && !word.includes('\\'))
24 .join(' ')
25 .replace(CODE_FILE, ' ')
26 .replace(/\s+/g, ' ')
27 .trim()
28 if (text === '') return 'Working on it'
29 text = text.charAt(0).toUpperCase() + text.slice(1)
30 if (text.length <= NAME_LIMIT) return text
31
32 const room = NAME_LIMIT - 1
33 const cut = text.slice(0, room + 1)
34 const space = cut.lastIndexOf(' ')
35 const kept = (space > 8 ? cut.slice(0, space) : text.slice(0, room)).replace(/[\s,.;:–-]+$/, '')
36
37 return `${kept}…`
38}
39
40// A first name for a job from the prompt itself, until Haiku names it.
41export const titleFromPrompt = (text: string): string => {
42 const first = text.replace(/\s+/g, ' ').trim().split(/(?<=[.?!])\s|[\n;:]/)[0] ?? ''
43 const words = first.split(' ').filter(Boolean).slice(0, 6).join(' ')
44
45 return cleanName(words.replace(/[.?!,]+$/, ''))
46}
47
48export const NAME_SYSTEM = [
49 'Name the job a person just asked an AI assistant to do.',
50 'Reply with 2 to 6 plain English words that start with a verb, like "Build the pricing page" or "Answer a question about invoices".',
51 'No file names, code, quotes or punctuation. Reply with the name only.',
52].join('\n')
53
54export const namePrompt = (text: string): string => `The request:\n<request>\n${text.slice(0, 2000)}\n</request>`
55
56// Haiku's reply as a job name, or null when it is not usable.
57export const readJobName = (reply: string): string | null => {
58 // Quotes and markdown go; a dot stays inside a word ("nnpbc.com") and only
59 // goes at the end.
60 const line = (reply.split('\n').find(one => one.trim() !== '') ?? '')
61 .replace(/["“”‘’*_]/g, '')
62 .replace(/^'+|'+$/g, '')
63 .trim()
64 .replace(/[.!?]+$/, '')
65 const words = line.split(/\s+/).filter(Boolean)
66 if (words.length < 2 || words.length > 8) return null
67
68 return cleanName(words.slice(0, 6).join(' '))
69}
70
71// ---------------------------------------------------------------- jobs and steps
72
73export const PLACEHOLDERS = ['Understand your request', 'Plan the steps'] as const
74
75export const newJob = (jobId: number, title: string, now: number): CleanViewChecklist => ({
76 jobId,
77 title,
78 phase: 'working',
79 tasks: PLACEHOLDERS.map((name, i) => ({
80 id: `p${i}`,
81 name,
82 status: i === 0 ? 'active' : 'upcoming',
83 percent: 0,
84 hasReported: false,
85 })),
86 source: 'placeholder',
87 needsYouReason: null,
88 stuckReason: null,
89 startedAt: now,
90 finishedAt: null,
91 isCollapsed: false,
92})
93
94// Whether a real plan exists: Claude's own steps or its to-do list.
95export const hasPlan = (list: CleanViewChecklist | null): boolean => list !== null && list.source !== 'placeholder'
96
97const task = (id: string, name: string, status: CleanViewTask['status']): CleanViewTask => ({
98 id,
99 name,
100 status,
101 percent: status === 'done' ? 100 : 0,
102 hasReported: status === 'done',
103})
104
105// Back to Working: progress means Claude is moving again.
106const moving = (list: CleanViewChecklist): CleanViewChecklist =>
107 list.phase === 'needsYou' || list.phase === 'stuck'
108 ? { ...list, phase: 'working', needsYouReason: null, stuckReason: null }
109 : list
110
111// plan_steps: 2 to 8 steps in order, the first one started.
112export const applyPlan = (list: CleanViewChecklist, steps: readonly unknown[]): { list: CleanViewChecklist; count: number } => {
113 const names = steps
114 .filter((step): step is string => typeof step === 'string' && step.trim() !== '')
115 .map(cleanName)
116 .slice(0, 8)
117 if (names.length === 0) return { list, count: 0 }
118
119 return {
120 list: moving({
121 ...list,
122 source: 'plan',
123 tasks: names.map((name, i) => task(`s${list.jobId}-${i}`, name, i === 0 ? 'active' : 'upcoming')),
124 }),
125 count: names.length,
126 }
127}
128
129const sameName = (a: string, b: string) => a.toLowerCase() === b.toLowerCase()
130
131// report_progress: everything before the named step is done; at 100 it is done
132// and the next one starts; a name not in the plan becomes a new step.
133export const applyProgress = (list: CleanViewChecklist, rawName: string, rawPercent: unknown): { list: CleanViewChecklist; percent: number } => {
134 const number = typeof rawPercent === 'number' ? rawPercent : Number(rawPercent)
135 const percent = Number.isFinite(number) ? Math.max(0, Math.min(100, Math.round(number))) : 0
136 const name = cleanName(rawName)
137 let tasks = list.source === 'placeholder' ? [] : [...list.tasks]
138 let index = tasks.findIndex(one => sameName(one.name, name))
139 if (index < 0) {
140 tasks = [...tasks, task(`r${list.jobId}-${tasks.length}`, name, 'upcoming')]
141 index = tasks.length - 1
142 }
143 let startNext = false
144 tasks = tasks.map((one, i) => {
145 if (i < index) return { ...one, status: 'done', percent: 100, hasReported: true }
146 if (i === index) {
147 if (percent >= 100) {
148 startNext = true
149
150 return { ...one, status: 'done', percent: 100, hasReported: true }
151 }
152
153 return { ...one, status: 'active', percent, hasReported: true }
154 }
155
156 return one.status === 'active' ? { ...one, status: 'upcoming' } : one
157 })
158 if (startNext) {
159 const next = tasks.findIndex((one, i) => i > index && one.status === 'upcoming')
160 if (next >= 0) tasks[next] = { ...tasks[next]!, status: 'active' }
161 }
162
163 return { list: moving({ ...list, source: list.source === 'placeholder' ? 'plan' : list.source, tasks }), percent }
164}
165
166export type ListItem = { name: string; status: string }
167
168const STATUS: Record<string, CleanViewTask['status']> = {
169 completed: 'done',
170 in_progress: 'active',
171 pending: 'upcoming',
172}
173
174// A whole to-do list (TodoWrite, or the desktop task list's items): its rows
175// become the checklist; an active step keeps the percent it reported.
176export const applyList = (list: CleanViewChecklist, items: readonly ListItem[]): CleanViewChecklist => {
177 const rows = items.filter(item => typeof item.name === 'string' && item.name.trim() !== '')
178 if (rows.length === 0) return list
179 const tasks = rows.map((item, i) => {
180 const name = cleanName(item.name)
181 const status = STATUS[item.status] ?? 'upcoming'
182 const before = list.tasks.find(one => sameName(one.name, name))
183 const kept = status === 'active' && before?.status === 'active' && before.hasReported
184
185 return {
186 id: `l${list.jobId}-${i}`,
187 name,
188 status,
189 percent: status === 'done' ? 100 : kept ? before.percent : 0,
190 hasReported: status === 'done' || kept,
191 }
192 })
193
194 return moving({ ...list, source: 'list', tasks })
195}
196
197// The desktop task list's small updates: advance one step, or mark some done
198// and start another (numbers count from 1).
199export const applyListStep = (list: CleanViewChecklist, step: { advance?: unknown; done?: unknown; start?: unknown }): CleanViewChecklist => {
200 if (list.source !== 'list') return list
201 let tasks = [...list.tasks]
202 if (step.advance === true) {
203 const active = tasks.findIndex(one => one.status === 'active')
204 if (active >= 0) tasks[active] = { ...tasks[active]!, status: 'done', percent: 100, hasReported: true }
205 const next = tasks.findIndex((one, i) => i > active && one.status === 'upcoming')
206 if (next >= 0) tasks[next] = { ...tasks[next]!, status: 'active', percent: 0, hasReported: false }
207 }
208 if (Array.isArray(step.done)) {
209 for (const n of step.done) {
210 const i = Number(n) - 1
211 if (tasks[i] !== undefined) tasks[i] = { ...tasks[i]!, status: 'done', percent: 100, hasReported: true }
212 }
213 }
214 const start = Number(step.start) - 1
215 if (Number.isInteger(start) && tasks[start] !== undefined) {
216 tasks = tasks.map((one, i) =>
217 i === start ? { ...one, status: 'active', percent: 0, hasReported: false } : one.status === 'active' ? { ...one, status: 'upcoming' } : one,
218 )
219 }
220
221 return moving({ ...list, tasks })
222}
223
224// TaskCreate adds a row; TaskUpdate changes one (or removes it when deleted).
225export const addListTask = (list: CleanViewChecklist, id: string, subject: string): CleanViewChecklist => {
226 const tasks = list.source === 'list' ? list.tasks : []
227
228 return moving({ ...list, source: 'list', tasks: [...tasks, task(`t${id}`, cleanName(subject), 'upcoming')] })
229}
230
231export const updateListTask = (list: CleanViewChecklist, id: string, change: { status?: unknown; subject?: unknown }): CleanViewChecklist => {
232 if (list.source !== 'list') return list
233 if (change.status === 'deleted') return { ...list, tasks: list.tasks.filter(one => one.id !== `t${id}`) }
234 const status = typeof change.status === 'string' ? STATUS[change.status] : undefined
235
236 return moving({
237 ...list,
238 tasks: list.tasks.map(one => {
239 if (one.id !== `t${id}`) return status === 'active' && one.status === 'active' ? { ...one, status: 'upcoming' } : one
240 const name = typeof change.subject === 'string' ? cleanName(change.subject) : one.name
241 if (status === undefined) return { ...one, name }
242
243 return { ...one, name, status, percent: status === 'done' ? 100 : one.percent, hasReported: status === 'done' || one.hasReported }
244 }),
245 })
246}
247
248// ---------------------------------------------------------------- waiting and failing
249
250export const PERMISSION_REASON = 'Claude needs your OK to continue'
251export const QUESTION_REASON = 'Claude has a question for you'
252export const REPLY_REASON = 'Claude is waiting for your reply'
253export const SAID_NO = 'you said no to a step, so Claude paused'
254export const KEEPS_FAILING = 'a step keeps failing, Claude is trying another way'
255
256export const needsYou = (list: CleanViewChecklist, reason: string): CleanViewChecklist => ({
257 ...list,
258 phase: 'needsYou',
259 needsYouReason: reason,
260 stuckReason: null,
261})
262
263export const stuck = (list: CleanViewChecklist, reason: string): CleanViewChecklist => ({
264 ...list,
265 phase: 'stuck',
266 stuckReason: reason,
267 needsYouReason: null,
268})
269
270// The tool's answer when a person refused it at the permission prompt.
271export const isRefusedByPerson = (text: string | undefined): boolean =>
272 typeof text === 'string' && /doesn't want to proceed|does not want to proceed|user (rejected|denied|declined)|permission (was )?denied by the user/i.test(text)
273
274// Which notifications mean Claude is waiting on the person.
275export const notificationReason = (type: string): string | null =>
276 /permission/i.test(type) ? PERMISSION_REASON : /elicitation|question/i.test(type) ? QUESTION_REASON : null
277
278// ---------------------------------------------------------------- the end of a turn
279
280// An API error as one calm sentence.
281export const errorSentence = (kind: string | null, details = ''): string => {
282 const text = `${kind ?? ''} ${details}`.toLowerCase()
283 if (/prompt is too long|context|too many tokens/.test(text)) return 'type /compact and try again'
284 if (kind === 'rate_limit' || /rate.?limit|usage limit/.test(text)) return 'you hit your usage limit, try again a little later'
285 if (kind === 'overloaded' || kind === 'server_error' || /overloaded|529|5\d\d/.test(text)) return "Claude's servers are busy, try again in a minute"
286 if (/authentication|oauth|login|401|403/.test(text)) return 'type /login'
287 if (kind === 'billing_error' || /billing/.test(text)) return 'there is a problem with your billing, check your account'
288 if (/network|connect|socket|fetch failed|timed? ?out|econn|enotfound/.test(text)) return 'the internet connection dropped'
289 if (kind === 'max_output_tokens') return 'the answer ran too long, ask for a shorter one'
290
291 return 'something went wrong, try again'
292}
293
294export const REFUSAL = "Claude couldn't help with that request"
295
296export type TurnEnd = { reason: 'answer' | 'aborted' | 'refusal' | 'error'; errorKind: string | null; errorDetails: string }
297
298export const finishTurn = (list: CleanViewChecklist, end: TurnEnd, now: number): CleanViewChecklist => {
299 if (end.reason === 'error') return { ...stuck(list, errorSentence(end.errorKind, end.errorDetails)), finishedAt: now }
300 if (end.reason === 'refusal') return { ...stuck(list, REFUSAL), finishedAt: now }
301 if (end.reason === 'aborted') return { ...list, phase: 'stopped', needsYouReason: null, stuckReason: null, finishedAt: now }
302 const isUnfinished = hasPlan(list) && list.tasks.some(one => one.status !== 'done')
303 if (isUnfinished) return needsYou(list, REPLY_REASON)
304
305 // A job that never had a real plan (a quick question) ends as one line.
306 return {
307 ...list,
308 phase: 'done',
309 needsYouReason: null,
310 stuckReason: null,
311 finishedAt: now,
312 isCollapsed: !hasPlan(list),
313 tasks: list.tasks.map(one => ({ ...one, status: 'done', percent: 100, hasReported: true })),
314 }
315}
316
317// A prompt starts a new job unless one is still going or waits on a reply.
318export const continuesJob = (list: CleanViewChecklist | null): boolean =>
319 list !== null && (list.phase === 'working' || list.phase === 'needsYou')
320
321export const isRealPrompt = (text: string): boolean => {
322 const words = text.trim()
323
324 return words !== '' && !words.startsWith('/') && !words.startsWith('<')
325}
326
327// The clock runs only while a job is working or waits on the person.
328export const isLive = (phase: CleanViewPhase | undefined): boolean => phase === 'working' || phase === 'needsYou'
329
330// ---------------------------------------------------------------- the gate (terminal only)
331
332export const ALWAYS_ALLOWED = new Set(['ToolSearch', 'TodoWrite', 'TaskCreate', 'TaskUpdate', 'AskUserQuestion', PLAN_TOOL, PROGRESS_TOOL])
333
334export const GATE_MESSAGE =
335 `Clean View: call ${PLAN_TOOL} first with the steps of this job in plain English (load it with ToolSearch if it is deferred), or write a to-do list. Then go ahead.`
336
337export const shouldGate = (call: { isEnabled: boolean; isTerminal: boolean; agentId?: string; tool: string; isPlanned: boolean }): boolean =>
338 call.isEnabled &&
339 call.isTerminal &&
340 call.agentId === undefined &&
341 !call.isPlanned &&
342 !ALWAYS_ALLOWED.has(call.tool) &&
343 !call.tool.startsWith('mcp__ccd_')
344
345// ---------------------------------------------------------------- what Claude is told
346
347export const composeText = (hasTaskList: boolean): string =>
348 [
349 'Clean View is on: the person sees a plain-English checklist of your work instead of tool calls.',
350 '- Write every step name in plain English a non-technical person understands, under 40 characters, starting with a verb, like "Build the pricing section".',
351 '- Never put file paths, file names, commands, code or tool names in a step name.',
352 hasTaskList
353 ? `- Your task list is the plan: keep it as you already do. Call ${PROGRESS_TOOL} with a step's name and a percent as real progress happens, and with 100 the moment a step finishes.`
354 : `- For every request, even a quick question, call ${PLAN_TOOL} first with 2 to 8 short steps in order (load it with ToolSearch if it is deferred). Then call ${PROGRESS_TOOL} as real progress happens, and with 100 the moment a step finishes. If you have TodoWrite or TaskCreate, your to-do list can be the plan instead.`,
355 ].join('\n')
356
357// ---------------------------------------------------------------- drawing helpers
358
359export const elapsedText = (ms: number): string => {
360 const seconds = Math.max(0, Math.floor(ms / 1000))
361 if (seconds < 60) return `${seconds}s`
362 const minutes = Math.floor(seconds / 60)
363 if (minutes < 60) return `${minutes}m ${seconds % 60}s`
364
365 return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
366}
367
368export const METER = 10
369
370// Ten cells: full when done, filled to the percent, a moving sweep before the
371// first report, empty for what is still to come.
372export const meterCells = (one: CleanViewTask, frame: number): string => {
373 if (one.status === 'done') return '█'.repeat(METER)
374 if (one.status === 'upcoming') return '░'.repeat(METER)
375 if (!one.hasReported) {
376 const at = frame % (METER + 2)
377
378 return Array.from({ length: METER }, (_, i) => (i >= at - 2 && i <= at ? '█' : '░')).join('')
379 }
380 const filled = Math.round(one.percent / 10)
381
382 return '█'.repeat(filled) + '░'.repeat(METER - filled)
383}
384
385export type RowView = { mark: '✓' | '▶' | '‖' | '○'; name: string; label: string; tone: 'done' | 'active' | 'upcoming' }
386
387export const rowsOf = (list: CleanViewChecklist): RowView[] => {
388 let seenUpcoming = false
389
390 return list.tasks.map(one => {
391 if (one.status === 'done') return { mark: '✓', name: one.name, label: 'Done', tone: 'done' }
392 if (one.status === 'active') {
393 return {
394 mark: list.phase === 'needsYou' ? '‖' : '▶',
395 name: one.name,
396 label: one.hasReported ? `${one.percent}%` : 'Working',
397 tone: 'active',
398 }
399 }
400 const label = seenUpcoming ? 'Up next' : 'Next'
401 seenUpcoming = true
402
403 return { mark: '○', name: one.name, label, tone: 'upcoming' }
404 })
405}
406
407export type HeaderView = { kind: CleanViewPhase; badge: string | null; text: string }
408
409export const headerOf = (list: CleanViewChecklist, now: number, stopWord: string): HeaderView => {
410 const ran = elapsedText((list.finishedAt ?? now) - list.startedAt)
411 switch (list.phase) {
412 case 'needsYou':
413 return { kind: 'needsYou', badge: 'Needs you', text: list.needsYouReason ?? PERMISSION_REASON }
414 case 'stuck':
415 return { kind: 'stuck', badge: null, text: `⚠ Stuck: ${list.stuckReason ?? KEEPS_FAILING}` }
416 case 'stopped':
417 return { kind: 'stopped', badge: null, text: `■ Stopped · ${list.title} · ${stopWord}` }
418 case 'done':
419 return { kind: 'done', badge: null, text: `✓ All done · ${list.title} · took ${ran}` }
420 default:
421 return { kind: list.phase, badge: null, text: `${list.title} · ${ran}` }
422 }
423}
424
425// The name column's width so a row never wraps: mark, gap, name, gap, ten
426// meter cells, gap, the widest label ("Working", "Up next").
427export const nameWidth = (columns: number): number => Math.max(4, columns - 2 - 1 - METER - 1 - 7)
428
429export const clip = (text: string, width: number): string =>
430 width <= 0 ? '' : text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
431types/index.d.ts 42 lines1// The shared state of the AI Employee Kit. Other mods read the checklist from
2// here: { plugin: 'ai-employee-kit', key: 'checklist' }.
3
4export type CleanViewPhase = 'idle' | 'working' | 'needsYou' | 'stuck' | 'stopped' | 'done'
5
6export type CleanViewTaskStatus = 'done' | 'active' | 'upcoming'
7
8export type CleanViewTask = {
9 id: string
10 name: string
11 status: CleanViewTaskStatus
12 percent: number
13 hasReported: boolean
14}
15
16// Where the rows came from: placeholders until a plan arrives, then Claude's
17// plan_steps, or its to-do list (TodoWrite, TaskCreate, the desktop task list).
18export type CleanViewSource = 'placeholder' | 'plan' | 'list'
19
20export type CleanViewChecklist = {
21 jobId: number
22 title: string
23 phase: CleanViewPhase
24 tasks: CleanViewTask[]
25 source: CleanViewSource
26 needsYouReason: string | null
27 stuckReason: string | null
28 startedAt: number
29 finishedAt: number | null
30 isCollapsed: boolean
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'ai-employee-kit': {
36 cleanViewEnabled: boolean
37 checklist: CleanViewChecklist | null
38 tick: number
39 }
40 }
41}
42