SLOPSHOPPER

ai-employee-kit

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.

newbandspinnerrowsguardcommand
v0.1.2no licenseupdated 2026-10-15iansutherland75/claude-mods/mods/ai-employee-kit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ai-employee-kit
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Read(/work/app/src/auth.ts) ⎿ Denied by ai-employee-kit: Clean View: call mcp__ai-employee-kit__plan_steps first with the steps of this ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /simple ⎿ ai-employee-kit: Clean View is off. Every tool row is back. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⟨Claude Code's own drawing⟩ clean-view-toggle
README

claude-mods

Ian's Claude Code mods. The repo is a plugin marketplace called ian-mods, and each mod lives in its own folder under mods/.

ModWhat 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-kitClean 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.

Set up a machine

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.

Keep machines in step

The marketplace is a local folder, so sessions read each mod straight from ~/claude-mods. Nothing needs reinstalling after a change.

  1. git pull in ~/claude-mods.
  2. Run /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.

Add a mod

  1. Put it in mods/<mod-name>/ with its .claude-plugin/plugin.json, hooks/ and tests.
  2. Add an entry to .claude-plugin/marketplace.json with "source": "./mods/<mod-name>".
  3. Check it: claude plugin validate mods/<mod-name> and claude plugin test mods/<mod-name>.
  4. Commit, push, then install it on each machine.

Not covered

Cloud sessions (claude.ai/code, cloud Cowork) do not see mods installed on a machine.

Source 5 files
hooks/register.tsx 9 lines
1import 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}
9
hooks/clean-view.tsx 515 lines
1// 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}
515
hooks/clean-view-desktop.ts 102 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
102
hooks/clean-view-logic.ts 431 lines
1// 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))}…`
431
types/index.d.ts 42 lines
1// 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