SLOPSHOPPER

kit-sink

cc-kit kitchen sink: every candidate component on one pane, to judge and pick from

newpaneguardcommandtoast
v0.1.0MITupdated 2026-10-05eduardocruz/cc-kit/mods/kit-sink
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kit-sink
│ ┃ cc-kit · kitchen sink ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ 28 components · 11 planned for v1 · the rest │ kit-sink │ │ ┃ are mocks to judge ⏺ Read(src/auth.ts) │ cc-kit installed: run /kit sink to see │ │ ┃ [ All ] [ Data at a glance ] [ Agent observa ⎿ Read 6 lines │ every component │ │ ┃ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ 1 · Data at a glance ⎿ Added 2 lines, removed 1 line │ ┃ ──────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ ────────── ⎿ 3 pass, 1 fail │ ┃ Sparkline [v1] │ ┃ req/min ▆▇▆▇▇▇▇▇▄▄▃▂▂▂▁▂▃▄▅▅▆▇███▆▅▅▄▃▃▁▁▂▃▄ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Gauge [v1] ✻ Worked for 42s · done 4:20 PM │ ┃ context ███████████████▏░░░░░░░░░░░░░░ │ ┃ weekly quota ████████████████████████████▏░ › /kit │ ┃ budget ███████████████████████████▎░ ⎿ kit-sink: Kitchen sink opened. │ ┃ │ ┃ StatTile · KpiRow [v1] │ ┃ ╭────────────────────────╮ ╭──────────────── │ ┃ │ Cost today │ │ Turns │ ┃ │ $3.12 ▲ 12% │ │ 48 ▲ 8% │ ┃ │ ▆▇██▆▆▆▅▄▃▁▁▁▂▂▃▃▄▅▇ │ │ ▇▇▇▇▆▄▃▂▂▂▁▂▃▄▅ │ ┃ ╰────────────────────────╯ ╰──────────────── │ ┃ │ ┃ BarList [v1] │ ┃ Read █████████████████████████ │ ┃ Bash █████████████████▍ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · cc-kit · kitchen sink
28 components · 11 planned for v1 · the rest are mocks to judge [ All ] [ Data at a glance ] [ Agent observability ] [ Code 1 · Data at a glance ────────────────────────────────────────────────────── Sparkline [v1] req/min ▆▇▆▇▇▇▇▇▄▄▃▂▂▂▁▂▃▄▅▅▆▇███▆▅▅▄▃▃▁▁▂▃▄▃▄▆▇ 80 Gauge [v1] context ███████████████▏░░░░░░░░░░░░░░░░░░░░ 42% weekly quota ████████████████████████████▏░░░░░░░ 78% budget ███████████████████████████▎░ $4.70 / $5 StatTile · KpiRow [v1] ╭────────────────────────╮ ╭────────────────────────╮ ╭───── │ Cost today │ │ Turns │ │ Test │ $3.12 ▲ 12% │ │ 48 ▲ 8% │ │ 212 │ ▆▇██▆▆▆▅▄▃▁▁▁▂▂▃▃▄▅▇ │ │ ▇▇▇▇▆▄▃▂▂▂▁▂▃▄▅▇▆▇██ │ │ ▆▃▂▂ ╰────────────────────────╯ ╰────────────────────────╯ ╰───── BarList [v1] Read ███████████████████████████████ 41× Bash █████████████████▍ 23× Edit ████████████▉ 17× Grep ██████▊ 9× Agent ██▎ 3× Heatmap [v1] sessions · last 26 weeks M ▒░▓▒▒▒▒▓▓·▒▒▓▓▓▒▒█▓▓▓▓██▓▓ ▒▒▒░▓▒▒▒▒·▓▓▒▒▓▓▓▓▒█▓▓▓▓██ W ░▓▒▒▒░▓▓▒·▒▓▓▓▒▒█▓▓▓▒██▓▓▓ ▒▒░▓▒▒▒▒▓·▒▒▒▓▓▓▓▒█▓▓▓▓██▓ F ▓▒▒▒░▓▓▒▒·▓▓▓▒▒▓▓▓▓▒██▓▓▓█ ··▒▒░··▒▒·░·▒▒▒▒░▓▒▒▒▒▓▓▓▒ S ░···▒▒░···▒▒░░▓▒▒▒░▓▓▒▒▒▓▓ 2 · Agent observability ────────────────────────────────────────────────────── ToolTimeline [v1] ● live Read ██ 20ms Grep ██ 20ms Edit ██ 20ms Write ██ 20ms Write ██ 20ms Bash ██ 20ms Bash ██ 20ms Bash ██ 20ms Bash ██ 20ms TokenBurn [v1] ● live $0.42 of $5.00 · 97k tok · 1 turns per turn ▁ $0.42 SubagentTree [v1] ○ mock ● session opus ├─ ● explore auth flow Explore └─ ● implement plan general-purpose ├─ ● task 1 · tokens sonnet ├─ ● task 2 · charts sonnet └─ ● review sonnet ContextMap [v1] ○ mock █████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░ 106k of 200k · 53% used ■ system 14k ■ tools 22k ■ memory 9.0k ■ messages 61k FileTouchMap [v1] ● live ██████ auth.ts 1r 1e ████▌ audit.ts 0r 1e ████▌ cache.ts 0r 1e Status line preset [v1] ● live $0.42 · 49% ctx 3 · Code & review ────────────────────────────────────────────────────── DiffCard [sink only] ╭────────────────────────────────────────────────────╮ │ app/Services/InvoiceService.php +3 −1 │ │ 12 public function total(): int │ │ 13 { │ │ 14 - return $this->lines->sum("amount"); │ │ 14 + return $this->lines │ │ 15 + ->reject->isVoided() │ │ 16 + ->sum("amount"); │ │ 17 } │ │ [ Accept ] [ Reject ] │ ╰────────────────────────────────────────────────────╯ FindingList [sink only] HIGH Debug route reachable w… routes/web.php:42 MED Retries forever on 4xx app/Jobs/Sync.php:88 LOW Unused import resources/js/app.tsx:7 TestRun [sink only] ••••••••••••✗••••••••••••••••••✗••••••••○○ 38 passed 2 failed 2 skipped · 4.1s ✗ InvoiceTest › total ignores voided lines CommitComposer [sink only] ✓ app/Services/InvoiceService.php ✓ tests/Unit/InvoiceTest.php ○ CHANGELOG.md message › Exclude voided lines from invoice totals▌ [ Commit 2 files ] 4 · Decision & input ────────────────────────────────────────────────────── ChoiceCard [sink only] How should authors consume the kit? 1 Copy-in registry recommended own the source, no version 2 Dependency plugin one source of truth, no tweaks 3 Both more to maintain TodoBoard [sink only] progress ██████████████████░░░░░░░░░░░░░░░░░░ 2/4 ✓ Scaffold kit-sink ✓ Data components ◐ Observability components ○ Tests on terminal + desktop ConfirmBar [sink only] ╭─────────────────────────────────────────────────────────── │ Delete 3 merged branches? feat/x, fix/y, chore/z[ Delete ] ╰─────────────────────────────────────────────────────────── FormPane [sink only] Project cc-kit License MIT ▾ Surfaces ✓ terminal ✓ desktop ○ mobile 5 · Engine-slot skins ────────────────────────────────────────────────────── ToolUse skin [sink only] ◇ Read app/Models/User.php 42ms ✎ Edit app/Models/User.php +3 −1 $ Bash php artisan test --filter=User 4.1s ✗ Bash npm run build exit 1 Spinner pack [sink only] braille ⣾ ⣽ ⣻ ⢿ ⡿ ⣟ ⣯ ⣷ moon ◐ ◓ ◑ ◒ pulse · • ● • bar ▁ ▃ ▅ ▇ ▅ ▃ ✻ Untangling… ToolGroup summary [sink only] ⏺ Read 14 files · Grep ×3 · Edit ×2 · 1.8s PromptHint [sink only] tip › /observatory opens the live timeline · ⇧⇥ cycles modes 6 · Layout & chrome ────────────────────────────────────────────────────── Table [sink only] Component Group v1 ───────────────────────────────────────────────────── Sparkline data ✓ ToolTimeline observe ✓ DiffCard code — KeyValue [sink only] model opus-5.5 branch main ↑2 surface terminal Badge · Pill [sink only] passing flaky failing v0.1 beta EmptyState [sink only] ╭─╮ ╰─╯ Nothing here yet Run a tool and it shows up. Toast presets [sink only] [ ✓ success ] [ ⚠ warn ] [ ✗ error ]
README

cc-kit

Visual components for Claude Code mods: sparklines, gauges, KPI tiles, heatmaps, and live agent observability (tool timeline, token burn, subagent tree, context map). They draw in the terminal and the desktop app.

Early access. Claude Code's mod API is marked "EARLY ACCESS: this surface may change between releases without notice." cc-kit is tested against Claude Code 2.1.288. Expect breakage on newer releases until CI against the latest release lands.

Status

Phase 0: a kitchen sink. Every candidate component is on one pane, so you can see what's possible and judge each one. The registry and /kit add come next. See the design spec.

The demo, screenshot by screenshot

Every screenshot is the kitchen-sink pane (/kit sink <group>) captured from a real Claude Code 2.1.288 session. Each component carries a tag:

  • [v1] means it is planned for the first release.
  • [sink only] means it is a mock, drawn so it can be judged before anyone builds it properly.
  • ● live means it is drawing your session's real data; ○ mock means it is showing sample data until there is something real to show.

The header row counts the components. The bracketed buttons are tabs, one per group; each one has a hotkey (0–6).

1 · Agent observability (/kit sink observe)

Agent observability

These components can only exist as a mod, because only a mod sees the agent's tool calls as they happen. Everything in this shot is live.

ComponentWhat it shows
ToolTimelineOne row per tool call, laid out on a shared time axis, Gantt-style. Calls that overlap are stacked; the bar length is the duration (shown on the right). A running call is yellow ▸; a failed one is red.
TokenBurnSession spend against a budget ($0.55 of $5.00), tokens and turn count. The sparkline shows the cost of each turn, so a costly turn stands out. The headline turns yellow, then red, as you approach the budget.
SubagentTreeThe live tree of subagents this session spawned: ● yellow while running, green when done, red when failed.
ContextMapWhat fills the context window, as one stacked bar (system prompt, tools, memory, skills, messages, autocompact buffer) plus a legend. Free space is the grey remainder. Data comes from $.session.usage.
FileTouchMapThe files the agent read (r) and edited (e) this session, hottest first. Edits weigh more than reads; paths are relative to the repo.
Status line presetThe same numbers squeezed into one line for $.ui.status: $0.55 · 7% ctx.

2 · Data at a glance (/kit sink data)

Data at a glance

General-purpose charts, drawn with text glyphs so they work in any terminal; the desktop app draws the same tree. These are the building blocks the observability components are made of.

ComponentWhat it shows
SparklineA trend in one line of ▁▂▃▄▅▆▇█, with an optional label and last value. It resamples to whatever width it is given.
GaugeLabel, bar, value. Color follows thresholds (green < 70% ≤ yellow < 90% ≤ red), with eighth-cell precision. The right side can be a custom detail ($4.70 / $5).
StatTile · KpiRowBordered KPI cards: label, value, a ▲/▼ delta and a trend sparkline. Each delta knows whether "up" is good (Turns) or bad (Cost), and colors itself to match. KpiRow wraps the tiles to fit the pane width.
BarListRanked horizontal bars with right-aligned values, for top tools, top files or top errors.
HeatmapA GitHub-style activity grid: 7 rows (days) × N weeks, intensity drawn as ·░▒▓█.

3 · Code & review (/kit sink code) · mocks

Code & review

ComponentWhat it shows
DiffCardA file header with +3 −1, the diff syntax-highlighted by Claude Code's own Code element, and Accept / Reject buttons.
FindingListReview findings with HIGH / MED / LOW severity badges and a file:line for each.
TestRunA dot per test (green pass, red × fail, grey ○ skipped), a summary line, and the first failure.
CommitComposerStaged files (✓) versus unstaged ones (○), a draft message, and a Commit button.

4 · Decision & input (/kit sink decide) · mocks

Decision & input

ComponentWhat it shows
ChoiceCardA question with numbered options, a recommended badge and a one-line trade-off for each, as an alternative to a wall of chat text.
TodoBoardA progress gauge over a checklist: done ✓ (dimmed), active ◐ (yellow), pending ○.
ConfirmBarA red-bordered confirmation for a destructive action, listing exactly what it will touch, with Delete / Cancel buttons.
FormPaneLabeled fields: text, a select (MIT ▾) and multi-choice.

5 · Engine-slot skins (/kit sink skins) · mocks

Engine-slot skins

Claude Code lets a mod redraw its built-in rows. These mocks show what a theme pack could do.

ComponentWhat it shows
ToolUse skinCompact one-line tool rows: an icon per tool, the argument, and the duration or result (+3 −1, exit 1 in red).
Spinner packAlternative spinner frame sets (braille, moon, pulse, bar) and a themed verb (✻ Untangling…).
ToolGroup summaryA burst of calls collapsed into one row: Read 14 files · Grep ×3 · Edit ×2 · 1.8s.
PromptHintA contextual tip line under the prompt.

6 · Layout & chrome (/kit sink layout) · mocks

Layout & chrome

ComponentWhat it shows
TableHeader, rule and rows, with columns sized to the pane width.
KeyValueAligned label/value pairs.
Badge · PillColored status tags using the kit's semantic tones (ok, warn, bad, info, accent).
EmptyStateWhat a component shows before it has any data.
Toast presetsButtons that fire $.ui.toast in success / warn / error formats.

Try the kitchen sink

claude --plugin-dir /path/to/cc-kit/mods/kit-sink

Then, inside Claude Code:

/kit sink            # all groups
/kit sink observe    # or: data, code, decide, skins, layout

The pane has a tab per group. Components tagged [v1] are planned for the first release; [sink only] ones are mocks shown to gather feedback. The observability components switch from ○ mock to ● live as soon as your session runs a tool.

Develop

claude plugin validate mods/kit-sink
claude plugin test mods/kit-sink

License

MIT

Source 7 files
hooks/register.tsx 129 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { SinkCall, SinkUsage } from '../types'
5import { groups, Specimen } from './kit/sections'
6
7const PANE = 'kit-sink'
8const SEEN_KEY = 'kit-sink.hinted'
9const GROUPS = ['all', 'data', 'observe', 'code', 'decide', 'skins', 'layout']
10
11const tab = atom({ plugin: 'kit-sink', key: 'tab' } as const, 'all')
12const calls = atom({ plugin: 'kit-sink', key: 'calls' } as const, [])
13const usage = atom({ plugin: 'kit-sink', key: 'usage' } as const, [])
14
15/** The argument a tool call acts on, for timeline labels and file touches. */
16function targetOf(e: object): string {
17  const args = e as Record<string, unknown>
18  for (const field of ['file_path', 'notebook_path', 'pattern', 'description', 'command', 'url', 'query']) {
19    if (typeof args[field] === 'string') return args[field] as string
20  }
21  return ''
22}
23
24export const register: Register = on => {
25  on('session.start', async ($, e, next) => {
26    await $.command.register({
27      name: 'kit',
28      description: 'cc-kit: `/kit sink` opens the kitchen sink of every candidate component',
29      argumentHint: 'sink [all|data|observe|code|decide|skins|layout]',
30    })
31
32    // The Pane never opens unasked (it would wait below 144 columns); hint once instead.
33    if (!(await $.store.get(SEEN_KEY))) {
34      $.ui.toast('cc-kit installed: run /kit sink to see every component')
35      await $.store.set(SEEN_KEY, true)
36    }
37
38    return next(e)
39  })
40
41  on('command.run', { command: 'kit' }, async ($, e) => {
42    const [sub = 'sink', group] = e.args.trim().split(/\s+/).filter(Boolean)
43    if (sub !== 'sink') return { text: `Unknown /kit command "${sub}". Try /kit sink.` }
44    if (group && !GROUPS.includes(group)) return { text: `Unknown group "${group}". One of: ${GROUPS.join(', ')}.` }
45
46    if (group) await update($, tab, () => group)
47    await $.ui.open({ id: PANE, title: 'cc-kit · kitchen sink' })
48
49    return { text: 'Kitchen sink opened.' }
50  })
51
52  // Live collector: every tool call, with its timing and outcome.
53  on('tool.call', async ($, e, next) => {
54    const call: SinkCall = {
55      id: e.tool_use_id ?? `${e.tool}-${await $.clock.now()}`,
56      tool: e.tool,
57      target: targetOf(e),
58      startedAt: await $.clock.now(),
59    }
60    await update($, calls, list => [...list, call].slice(-200))
61    const ran = await next(e)
62    const endedAt = await $.clock.now()
63    const isError = 'deny' in ran || ran.isError === true
64    await update($, calls, list => list.map(one => (one.id === call.id ? { ...one, endedAt, isError } : one)))
65
66    return ran
67  })
68
69  // Live collector: session usage after every turn.
70  on('turn.complete', async ($, e, next) => {
71    const result = await next(e)
72    const u = await $.session.usage({ breakdown: 'summary' })
73    const point: SinkUsage = {
74      at: await $.clock.now(),
75      tokens: u.context.tokens ?? 0,
76      window: u.context.window,
77      usd: u.cost?.usd ?? 0,
78      categories: (u.context.breakdown?.categories ?? [])
79        .filter(c => !c.isDeferred && c.tokens > 0 && !/free space/i.test(c.name))
80        .map(c => ({ name: c.name, tokens: c.tokens })),
81    }
82    await update($, usage, list => [...list, point].slice(-100))
83
84    return result
85  })
86
87  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
88    const el = $.ui.resolve(e)
89    const { Box, Text, Button } = el
90    const width = Math.max(30, e.props.bodyColumns - 2)
91    const current = await read($, tab)
92    const live = { calls: await read($, calls), usage: await read($, usage), now: await $.clock.now() }
93    const act = (what: string) => $.ui.toast(what)
94    const all = groups(el, width, live, act)
95    const shown = current === 'all' ? all : all.filter(g => g.id === current)
96    const tabs = [{ id: 'all', label: 'All' }, ...all.map(g => ({ id: g.id, label: g.title.split(' · ')[1] }))]
97    const v1 = all.flatMap(g => g.items).filter(i => i.status === 'v1').length
98    const total = all.flatMap(g => g.items).length
99
100    return (
101      <Box flexDirection="column">
102        <Text dimColor>
103          {total} components · {v1} planned for v1 · the rest are mocks to judge
104        </Text>
105        <Box flexWrap="wrap" columnGap={1} marginBottom={1}>
106          {tabs.map((t, i) => (
107            <Button
108              key={`tab-${t.id}`}
109              label={t.label}
110              hotkey={String(i)}
111              variant={t.id === current ? 'primary' : 'secondary'}
112              onPress={() => update($, tab, () => t.id)}
113            />
114          ))}
115        </Box>
116        {shown.map(g => (
117          <Box flexDirection="column" marginBottom={1}>
118            <Text bold color="magenta">
119              {g.title}
120            </Text>
121            <Text dimColor>{'─'.repeat(Math.min(width, 60))}</Text>
122            {g.items.map(item => Specimen(el, item))}
123          </Box>
124        ))}
125      </Box>
126    )
127  })
128}
129
hooks/kit/sections.tsx 274 lines
1import type { RenderChildren } from 'claude-code'
2
3import type { SinkCall, SinkUsage } from '../../types'
4import { BarList, Gauge, Heatmap, KpiRow, Sparkline } from './data'
5import { fmtUsd, tone, type El } from './foundations'
6import * as mock from './mocks'
7import { ContextMap, FileTouchMap, SubagentTree, TokenBurn, ToolTimeline, type AgentNode, type FileTouch } from './observe'
8
9export type Live = { calls: SinkCall[]; usage: SinkUsage[]; now: number }
10type Act = (what: string) => void
11type Item = { name: string; status: 'v1' | 'sink'; isLive?: boolean; draw: () => RenderChildren }
12export type Group = { id: string; title: string; items: Item[] }
13
14// Deterministic mock series so screenshots are stable.
15const wave = (n: number, seed: number) =>
16  Array.from({ length: n }, (_, i) => Math.round(50 + 30 * Math.sin((i + seed) / 3) + ((i * 37 + seed * 11) % 17)))
17
18/** Weekday-heavy activity that grows over the weeks, with quiet weekends and a gap week. */
19const activity = (weeks: number) =>
20  Array.from({ length: weeks * 7 }, (_, i) => {
21    const week = Math.floor(i / 7)
22    const day = i % 7
23    if (week === 9) return 0
24    const base = day >= 5 ? 0.5 : 3
25    const jitter = (i * 37 + 11) % 5
26    return Math.max(0, Math.round(base + (week / weeks) * 5 + jitter - 2.5))
27  })
28
29const MOCK_AGENTS: AgentNode = {
30  name: 'session',
31  type: 'opus',
32  status: 'running',
33  children: [
34    { name: 'explore auth flow', type: 'Explore', status: 'done' },
35    {
36      name: 'implement plan',
37      type: 'general-purpose',
38      status: 'running',
39      children: [
40        { name: 'task 1 · tokens', type: 'sonnet', status: 'done' },
41        { name: 'task 2 · charts', type: 'sonnet', status: 'running' },
42        { name: 'review', type: 'sonnet', status: 'failed' },
43      ],
44    },
45  ],
46}
47
48const MOCK_CONTEXT = {
49  window: 200_000,
50  categories: [
51    { name: 'system', tokens: 14_000 },
52    { name: 'tools', tokens: 22_000 },
53    { name: 'memory', tokens: 9_000 },
54    { name: 'messages', tokens: 61_000 },
55  ],
56}
57
58function mockCalls(now: number): SinkCall[] {
59  const plan: [string, string, number, number, boolean?][] = [
60    ['Read', 'app/Models/User.php', 0, 300],
61    ['Grep', 'InvoiceService', 200, 900],
62    ['Read', 'app/Services/InvoiceService.php', 950, 1200],
63    ['Agent', 'explore auth flow', 1200, 7800],
64    ['Edit', 'app/Services/InvoiceService.php', 8000, 8300],
65    ['Bash', 'php artisan test', 8400, 12600, true],
66    ['Edit', 'app/Services/InvoiceService.php', 12800, 13000],
67    ['Bash', 'php artisan test', 13100, 16900],
68  ]
69  const t0 = now - 18_000
70  return plan.map(([tool, target, from, to, isError], i) => ({
71    id: `mock-${i}`,
72    tool,
73    target,
74    startedAt: t0 + from,
75    endedAt: t0 + to,
76    isError,
77  }))
78}
79
80function touches(calls: SinkCall[]): FileTouch[] {
81  const by = new Map<string, FileTouch>()
82  for (const call of calls) {
83    const isRead = call.tool === 'Read'
84    const isEdit = call.tool === 'Edit' || call.tool === 'Write' || call.tool === 'NotebookEdit'
85    if (!call.target || (!isRead && !isEdit)) continue
86    const f = by.get(call.target) ?? { path: call.target, reads: 0, edits: 0 }
87    if (isRead) f.reads++
88    else f.edits++
89    by.set(call.target, f)
90  }
91  return relativize([...by.values()])
92}
93
94/** Strips the directory every path shares, so rows show `app/x.php`, not `/Users/…/repo/app/x.php`. */
95function relativize(files: FileTouch[]): FileTouch[] {
96  if (files.length === 0) return files
97  const dirs = files.map(f => f.path.split('/').slice(0, -1))
98  let shared = 0
99  while (dirs.every(d => d[shared] !== undefined && d[shared] === dirs[0]?.[shared])) shared++
100  return files.map(f => ({ ...f, path: f.path.split('/').slice(shared).join('/') }))
101}
102
103function agentTree(calls: SinkCall[]): AgentNode | null {
104  const agents = calls.filter(c => c.tool === 'Agent' || c.tool === 'Task')
105  if (agents.length === 0) return null
106  return {
107    name: 'session',
108    type: 'main',
109    status: 'running',
110    children: agents.map(a => ({
111      name: a.target || 'subagent',
112      type: 'Agent',
113      status: a.isError ? 'failed' : a.endedAt === undefined ? 'running' : 'done',
114    })),
115  }
116}
117
118export function groups(el: El, width: number, live: Live, act: Act): Group[] {
119  const { Text } = el
120  const hasCalls = live.calls.length > 0
121  const calls = hasCalls ? live.calls : mockCalls(live.now)
122  const lastUsage = live.usage.at(-1)
123  const hasUsage = lastUsage !== undefined
124  const files = touches(calls)
125  const tree = agentTree(live.calls)
126
127  return [
128    {
129      id: 'data',
130      title: '1 · Data at a glance',
131      items: [
132        { name: 'Sparkline', status: 'v1', draw: () => Sparkline(el, { values: wave(40, 1), width, label: 'req/min', format: n => String(n) }) },
133        {
134          name: 'Gauge',
135          status: 'v1',
136          draw: () => [
137            Gauge(el, { label: 'context', value: 0.42, width }),
138            Gauge(el, { label: 'weekly quota', value: 0.78, width }),
139            Gauge(el, { label: 'budget', value: 0.94, width, detail: '$4.70 / $5' }),
140          ],
141        },
142        {
143          name: 'StatTile · KpiRow',
144          status: 'v1',
145          draw: () =>
146            KpiRow(el, {
147              width,
148              tiles: [
149                { label: 'Cost today', value: '$3.12', delta: 12, isGoodUp: false, trend: wave(20, 2), width: 0 },
150                { label: 'Turns', value: '48', delta: 8, trend: wave(20, 5), width: 0 },
151                { label: 'Tests', value: '212', delta: 3, trend: wave(20, 9), width: 0 },
152              ],
153            }),
154        },
155        {
156          name: 'BarList',
157          status: 'v1',
158          draw: () =>
159            BarList(el, {
160              width,
161              items: [
162                { label: 'Read', value: 41 },
163                { label: 'Bash', value: 23 },
164                { label: 'Edit', value: 17 },
165                { label: 'Grep', value: 9 },
166                { label: 'Agent', value: 3 },
167              ],
168              format: n => `${n}×`,
169            }),
170        },
171        { name: 'Heatmap', status: 'v1', draw: () => Heatmap(el, { days: activity(26), width, label: 'sessions · last 26 weeks' }) },
172      ],
173    },
174    {
175      id: 'observe',
176      title: '2 · Agent observability',
177      items: [
178        { name: 'ToolTimeline', status: 'v1', isLive: hasCalls, draw: () => ToolTimeline(el, { calls, now: live.now, width }) },
179        {
180          name: 'TokenBurn',
181          status: 'v1',
182          isLive: hasUsage,
183          draw: () =>
184            TokenBurn(el, {
185              width,
186              points: hasUsage
187                ? live.usage.map(u => ({ usd: u.usd, tokens: u.tokens }))
188                : wave(14, 4).map((v, i) => ({ usd: (i + 1) * 0.05 + v / 2000, tokens: (i + 1) * 6000 })),
189            }),
190        },
191        { name: 'SubagentTree', status: 'v1', isLive: tree !== null, draw: () => SubagentTree(el, { root: tree ?? MOCK_AGENTS, width }) },
192        {
193          name: 'ContextMap',
194          status: 'v1',
195          isLive: (lastUsage?.categories.length ?? 0) > 0,
196          draw: () =>
197            lastUsage && lastUsage.categories.length > 0
198              ? ContextMap(el, { categories: lastUsage.categories, window: lastUsage.window, width })
199              : ContextMap(el, { ...MOCK_CONTEXT, width }),
200        },
201        { name: 'FileTouchMap', status: 'v1', isLive: hasCalls && files.length > 0, draw: () => FileTouchMap(el, { files, width }) },
202        {
203          name: 'Status line preset',
204          status: 'v1',
205          isLive: hasUsage,
206          draw: () => (
207            <Text dimColor>
208              {lastUsage ? `${fmtUsd(lastUsage.usd)} · ${Math.round((lastUsage.tokens / lastUsage.window) * 100)}% ctx` : '$0.42 · 61% ctx'}
209            </Text>
210          ),
211        },
212      ],
213    },
214    {
215      id: 'code',
216      title: '3 · Code & review',
217      items: [
218        { name: 'DiffCard', status: 'sink', draw: () => mock.DiffCard(el, { width, act }) },
219        { name: 'FindingList', status: 'sink', draw: () => mock.FindingList(el, { width }) },
220        { name: 'TestRun', status: 'sink', draw: () => mock.TestRun(el, { width }) },
221        { name: 'CommitComposer', status: 'sink', draw: () => mock.CommitComposer(el, { act }) },
222      ],
223    },
224    {
225      id: 'decide',
226      title: '4 · Decision & input',
227      items: [
228        { name: 'ChoiceCard', status: 'sink', draw: () => mock.ChoiceCard(el, { width, act }) },
229        { name: 'TodoBoard', status: 'sink', draw: () => mock.TodoBoard(el, { width }) },
230        { name: 'ConfirmBar', status: 'sink', draw: () => mock.ConfirmBar(el, { act }) },
231        { name: 'FormPane', status: 'sink', draw: () => mock.FormPane(el) },
232      ],
233    },
234    {
235      id: 'skins',
236      title: '5 · Engine-slot skins',
237      items: [
238        { name: 'ToolUse skin', status: 'sink', draw: () => mock.ToolUseSkin(el, { width }) },
239        { name: 'Spinner pack', status: 'sink', draw: () => mock.SpinnerPack(el) },
240        { name: 'ToolGroup summary', status: 'sink', draw: () => mock.ToolGroupSummary(el) },
241        { name: 'PromptHint', status: 'sink', draw: () => mock.PromptHint(el) },
242      ],
243    },
244    {
245      id: 'layout',
246      title: '6 · Layout & chrome',
247      items: [
248        { name: 'Table', status: 'sink', draw: () => mock.TableMock(el, { width }) },
249        { name: 'KeyValue', status: 'sink', draw: () => mock.KeyValue(el) },
250        { name: 'Badge · Pill', status: 'sink', draw: () => mock.Pills(el) },
251        { name: 'EmptyState', status: 'sink', draw: () => mock.EmptyState(el) },
252        { name: 'Toast presets', status: 'sink', draw: () => mock.ToastPresets(el, { act }) },
253      ],
254    },
255  ]
256}
257
258/** One labelled specimen: name, v1/sink badge, live/mock tag, then the component. */
259export function Specimen(el: El, item: Item) {
260  const { Box, Text } = el
261  return (
262    <Box flexDirection="column" marginBottom={1}>
263      <Box columnGap={1}>
264        <Text bold>{item.name}</Text>
265        <Text color={item.status === 'v1' ? tone.ok : tone.muted}>{item.status === 'v1' ? '[v1]' : '[sink only]'}</Text>
266        {item.isLive !== undefined && (
267          <Text color={item.isLive ? tone.info : tone.muted}>{item.isLive ? '● live' : '○ mock'}</Text>
268        )}
269      </Box>
270      {item.draw()}
271    </Box>
272  )
273}
274
hooks/kit/data.tsx 135 lines
1import { bar, density, fmtTokens, pad, spark, tone, truncate, type El, type Tone } from './foundations'
2
3/** ▁▂▃▅▇ trend line, optionally labelled with its last value. */
4export function Sparkline(
5  el: El,
6  p: { values: number[]; width: number; color?: string; label?: string; format?: (n: number) => string },
7) {
8  const { Box, Text } = el
9  const last = p.values[p.values.length - 1] ?? 0
10  const suffix = p.format ? ` ${p.format(last)}` : ''
11  const room = Math.max(4, p.width - (p.label ? p.label.length + 1 : 0) - suffix.length)
12
13  return (
14    <Box>
15      {p.label && <Text dimColor>{p.label} </Text>}
16      <Text color={p.color ?? tone.info}>{spark(p.values, room)}</Text>
17      {suffix && <Text bold>{suffix}</Text>}
18    </Box>
19  )
20}
21
22/** label ███████░░░ 61%, colored by the threshold it crossed. */
23export function Gauge(
24  el: El,
25  p: { label: string; value: number; width: number; warnAt?: number; badAt?: number; detail?: string },
26) {
27  const { Box, Text } = el
28  const level: Tone = p.value >= (p.badAt ?? 0.9) ? 'bad' : p.value >= (p.warnAt ?? 0.7) ? 'warn' : 'ok'
29  const right = p.detail ?? `${Math.round(p.value * 100)}%`
30  const labelWidth = Math.min(14, Math.max(6, Math.floor(p.width / 4)))
31  const barWidth = Math.max(4, p.width - labelWidth - right.length - 2)
32
33  return (
34    <Box>
35      <Text dimColor>{pad(p.label, labelWidth)} </Text>
36      <Text color={tone[level]}>{bar(p.value, barWidth)}</Text>
37      <Text bold> {right}</Text>
38    </Box>
39  )
40}
41
42/** A bordered KPI: label, big value, delta arrow, optional trend. */
43export function StatTile(
44  el: El,
45  p: { label: string; value: string; delta?: number; isGoodUp?: boolean; trend?: number[]; width: number },
46) {
47  const { Box, Text } = el
48  const inner = Math.max(8, p.width - 4)
49  const isUp = (p.delta ?? 0) >= 0
50  const isGood = isUp === (p.isGoodUp ?? true)
51
52  return (
53    <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1} width={p.width}>
54      <Text dimColor>{truncate(p.label, inner)}</Text>
55      <Box>
56        <Text bold>{p.value}</Text>
57        {p.delta !== undefined && (
58          <Text color={isGood ? tone.ok : tone.bad}>
59            {' '}
60            {isUp ? '▲' : '▼'} {Math.abs(p.delta)}%
61          </Text>
62        )}
63      </Box>
64      {p.trend && <Text color={tone.info}>{spark(p.trend, inner)}</Text>}
65    </Box>
66  )
67}
68
69/** A row of StatTiles that wraps when narrow. */
70export function KpiRow(el: El, p: { tiles: Parameters<typeof StatTile>[1][]; width: number }) {
71  const { Box } = el
72  const perRow = Math.max(1, Math.min(p.tiles.length, Math.floor(p.width / 20)))
73  const tileWidth = Math.floor(p.width / perRow) - 1
74
75  return (
76    <Box flexWrap="wrap" columnGap={1}>
77      {p.tiles.map(tile => StatTile(el, { ...tile, width: tileWidth }))}
78    </Box>
79  )
80}
81
82/** Ranked horizontal bars: label, bar, value. */
83export function BarList(
84  el: El,
85  p: { items: { label: string; value: number }[]; width: number; color?: string; format?: (n: number) => string },
86) {
87  const { Box, Text } = el
88  const format = p.format ?? fmtTokens
89  const max = Math.max(1, ...p.items.map(item => item.value))
90  const labelWidth = Math.min(18, Math.max(6, Math.floor(p.width / 3)))
91  const valueWidth = Math.max(...p.items.map(item => format(item.value).length), 1)
92  const barWidth = Math.max(4, p.width - labelWidth - valueWidth - 2)
93
94  return (
95    <Box flexDirection="column">
96      {p.items.map(item => (
97        <Box>
98          <Text>{pad(item.label, labelWidth)} </Text>
99          <Text color={p.color ?? tone.accent}>{bar(item.value / max, barWidth, ' ')}</Text>
100          <Text dimColor> {format(item.value).padStart(valueWidth)}</Text>
101        </Box>
102      ))}
103    </Box>
104  )
105}
106
107/** GitHub-style activity grid: 7 rows (days) × N columns (weeks). */
108export function Heatmap(el: El, p: { days: number[]; width: number; color?: string; label?: string }) {
109  const { Box, Text } = el
110  const weeks = Math.max(1, Math.min(Math.ceil(p.days.length / 7), p.width - 4))
111  const recent = p.days.slice(-weeks * 7)
112  const max = Math.max(1, ...recent)
113  const names = ['M', ' ', 'W', ' ', 'F', ' ', 'S']
114  const rows = names.map((name, day) => {
115    let line = ''
116    for (let week = 0; week < weeks; week++) {
117      const value = recent[week * 7 + day] ?? 0
118      line += value === 0 ? '·' : density(0.25 + (0.75 * value) / max)
119    }
120    return { name, line }
121  })
122
123  return (
124    <Box flexDirection="column">
125      {p.label && <Text dimColor>{p.label}</Text>}
126      {rows.map(row => (
127        <Box>
128          <Text dimColor>{row.name} </Text>
129          <Text color={p.color ?? tone.ok}>{row.line}</Text>
130        </Box>
131      ))}
132    </Box>
133  )
134}
135
hooks/kit/foundations.ts 87 lines
1import type { Elements } from 'claude-code'
2
3/** The element table a component draws with: what `$.ui.resolve(e)` returns. */
4export type El = Elements[keyof Elements]
5
6/** Semantic colors. Names every surface understands. */
7export const tone = {
8  ok: 'green',
9  warn: 'yellow',
10  bad: 'red',
11  info: 'cyan',
12  accent: 'magenta',
13  muted: 'gray',
14} as const
15
16export type Tone = keyof typeof tone
17
18/** Distinct series colors, for stacked bars and legends. */
19export const series = ['cyan', 'magenta', 'yellow', 'green', 'blue', 'red', 'white'] as const
20
21const SPARK = '▁▂▃▄▅▆▇█'
22const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
23const DENSITY = ' ░▒▓█'
24
25export const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n))
26
27/** Resample `values` to `width` points (last value wins per bucket). */
28export function resample(values: number[], width: number): number[] {
29  if (values.length <= width) return values
30  const out: number[] = []
31  for (let i = 0; i < width; i++) {
32    out.push(values[Math.floor(((i + 1) * values.length) / width) - 1] ?? 0)
33  }
34  return out
35}
36
37export function spark(values: number[], width = values.length): string {
38  const points = resample(values, Math.max(1, width))
39  if (points.length === 0) return ''
40  const lo = Math.min(...points)
41  const hi = Math.max(...points)
42  const span = hi - lo || 1
43  return points.map(v => SPARK[Math.round(((v - lo) / span) * (SPARK.length - 1))]).join('')
44}
45
46/** A horizontal bar `width` cells wide filled to `fraction`, with eighth-cell precision. */
47export function bar(fraction: number, width: number, empty = '░'): string {
48  const cells = clamp(fraction, 0, 1) * Math.max(0, width)
49  const full = Math.floor(cells)
50  const part = EIGHTHS[Math.round((cells - full) * 8) % 8]
51  const used = full + (part ? 1 : 0)
52  return '█'.repeat(full) + part + empty.repeat(Math.max(0, width - used))
53}
54
55/** One glyph for an intensity in 0..1. */
56export const density = (fraction: number) =>
57  DENSITY[Math.round(clamp(fraction, 0, 1) * (DENSITY.length - 1))]
58
59export function truncate(text: string, width: number): string {
60  if (width <= 0) return ''
61  return text.length <= width ? text : text.slice(0, Math.max(0, width - 1)) + '…'
62}
63
64/** Keeps the end of a path, where the file name is. */
65export function truncateStart(text: string, width: number): string {
66  if (width <= 0) return ''
67  return text.length <= width ? text : '…' + text.slice(text.length - width + 1)
68}
69
70export const pad = (text: string, width: number) => truncate(text, width).padEnd(width)
71
72export function fmtTokens(n: number): string {
73  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
74  if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}k`
75  return String(Math.round(n))
76}
77
78export const fmtUsd = (n: number) => `$${n.toFixed(n < 10 ? 2 : 0)}`
79
80export function fmtMs(ms: number): string {
81  if (ms < 1000) return `${Math.round(ms)}ms`
82  if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`
83  return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`
84}
85
86export const pct = (fraction: number) => `${Math.round(fraction * 100)}%`
87
hooks/kit/mocks.tsx 339 lines
1// Groups 3–6: rough mocks, drawn so they can be judged before being built properly.
2import { Gauge } from './data'
3import { pad, tone, truncateStart, type El } from './foundations'
4
5type Act = (what: string) => void
6
7export function Badge(el: El, p: { text: string; tone?: keyof typeof tone }) {
8  const { Text } = el
9  return (
10    <Text backgroundColor={tone[p.tone ?? 'info']} color="black" bold>
11      {' '}
12      {p.text}{' '}
13    </Text>
14  )
15}
16
17// ── 3 Code & review ────────────────────────────────────────────
18
19export function DiffCard(el: El, p: { width: number; act: Act }) {
20  const { Box, Text, Button, Code } = el
21  const source = [
22    '@@ -12,6 +12,9 @@ class InvoiceService',
23    '     public function total(): int',
24    '     {',
25    '-        return $this->lines->sum("amount");',
26    '+        return $this->lines',
27    '+            ->reject->isVoided()',
28    '+            ->sum("amount");',
29    '     }',
30  ].join('\n')
31  return (
32    <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1} width={p.width}>
33      <Box justifyContent="space-between">
34        <Text bold>{truncateStart('app/Services/InvoiceService.php', p.width - 14)}</Text>
35        <Box>
36          <Text color={tone.ok}>+3 </Text>
37          <Text color={tone.bad}>−1</Text>
38        </Box>
39      </Box>
40      <Code source={source} format="diff" language="php" />
41      <Box columnGap={1}>
42        <Button key="diff-accept" label="Accept" variant="primary" onPress={() => p.act('Accepted diff')} />
43        <Button key="diff-reject" label="Reject" onPress={() => p.act('Rejected diff')} />
44      </Box>
45    </Box>
46  )
47}
48
49export function FindingList(el: El, p: { width: number }) {
50  const { Box, Text } = el
51  const findings: { sev: 'bad' | 'warn' | 'info'; label: string; where: string; text: string }[] = [
52    { sev: 'bad', label: 'HIGH', where: 'routes/web.php:42', text: 'Debug route reachable without auth' },
53    { sev: 'warn', label: 'MED', where: 'app/Jobs/Sync.php:88', text: 'Retries forever on 4xx' },
54    { sev: 'info', label: 'LOW', where: 'resources/js/app.tsx:7', text: 'Unused import' },
55  ]
56  return (
57    <Box flexDirection="column">
58      {findings.map(f => (
59        <Box columnGap={1}>
60          {Badge(el, { text: f.label.padEnd(4), tone: f.sev })}
61          <Text>{pad(f.text, Math.max(10, p.width - 30))}</Text>
62          <Text dimColor>{f.where}</Text>
63        </Box>
64      ))}
65    </Box>
66  )
67}
68
69export function TestRun(el: El, p: { width: number }) {
70  const { Box, Text } = el
71  const dots = '••••••••••••✗••••••••••••••••••✗••••••••○○'
72  return (
73    <Box flexDirection="column">
74      <Box>
75        {[...dots.slice(0, p.width)].map(d => (
76          <Text color={d === '✗' ? tone.bad : d === '○' ? tone.muted : tone.ok}>{d}</Text>
77        ))}
78      </Box>
79      <Box columnGap={2}>
80        <Text color={tone.ok}>38 passed</Text>
81        <Text color={tone.bad}>2 failed</Text>
82        <Text dimColor>2 skipped · 4.1s</Text>
83      </Box>
84      <Text color={tone.bad}>✗ InvoiceTest › total ignores voided lines</Text>
85    </Box>
86  )
87}
88
89export function CommitComposer(el: El, p: { act: Act }) {
90  const { Box, Text, Button } = el
91  return (
92    <Box flexDirection="column">
93      <Text>
94        <Text color={tone.ok}>✓</Text> app/Services/InvoiceService.php
95      </Text>
96      <Text>
97        <Text color={tone.ok}>✓</Text> tests/Unit/InvoiceTest.php
98      </Text>
99      <Text dimColor>○ CHANGELOG.md</Text>
100      <Text>
101        <Text dimColor>message › </Text>Exclude voided lines from invoice totals▌
102      </Text>
103      <Button key="commit" label="Commit 2 files" variant="primary" onPress={() => p.act('Committed (mock)')} />
104    </Box>
105  )
106}
107
108// ── 4 Decision & input ─────────────────────────────────────────
109
110export function ChoiceCard(el: El, p: { width: number; act: Act }) {
111  const { Box, Text, Button } = el
112  const options = [
113    { id: 'a', label: 'Copy-in registry', note: 'own the source, no version hell', isRecommended: true },
114    { id: 'b', label: 'Dependency plugin', note: 'one source of truth, no tweaks' },
115    { id: 'c', label: 'Both', note: 'more to maintain' },
116  ]
117  return (
118    <Box flexDirection="column">
119      <Text bold>How should authors consume the kit?</Text>
120      {options.map((o, i) => (
121        <Box columnGap={1}>
122          <Button key={`choice-${o.id}`} label={`${i + 1}`} plain onPress={() => p.act(`Chose ${o.label}`)} />
123          <Text bold={o.isRecommended}>{o.label}</Text>
124          {o.isRecommended && Badge(el, { text: 'recommended', tone: 'ok' })}
125          <Text dimColor>{o.note}</Text>
126        </Box>
127      ))}
128    </Box>
129  )
130}
131
132export function TodoBoard(el: El, p: { width: number }) {
133  const { Box, Text } = el
134  const todos = [
135    { text: 'Scaffold kit-sink', state: 'done' },
136    { text: 'Data components', state: 'done' },
137    { text: 'Observability components', state: 'active' },
138    { text: 'Tests on terminal + desktop', state: 'todo' },
139  ]
140  const done = todos.filter(t => t.state === 'done').length
141  return (
142    <Box flexDirection="column">
143      {Gauge(el, { label: 'progress', value: done / todos.length, width: p.width, detail: `${done}/${todos.length}`, warnAt: 2, badAt: 2 })}
144      {todos.map(t => (
145        <Text
146          color={t.state === 'active' ? tone.warn : undefined}
147          dimColor={t.state === 'done'}
148          strikethrough={t.state === 'done'}
149        >
150          {t.state === 'done' ? '✓' : t.state === 'active' ? '◐' : '○'} {t.text}
151        </Text>
152      ))}
153    </Box>
154  )
155}
156
157export function ConfirmBar(el: El, p: { act: Act }) {
158  const { Box, Text, Button } = el
159  return (
160    <Box borderStyle="round" borderColor={tone.bad} paddingX={1} justifyContent="space-between">
161      <Text>
162        <Text color={tone.bad} bold>
163          Delete 3 merged branches?
164        </Text>
165        <Text dimColor> feat/x, fix/y, chore/z</Text>
166      </Text>
167      <Box columnGap={1}>
168        <Button key="confirm-yes" label="Delete" variant="primary" onPress={() => p.act('Deleted (mock)')} />
169        <Button key="confirm-no" label="Cancel" role="dismiss" onPress={() => p.act('Cancelled')} />
170      </Box>
171    </Box>
172  )
173}
174
175export function FormPane(el: El) {
176  const { Box, Text } = el
177  const fields: [string, string][] = [
178    ['Project', 'cc-kit'],
179    ['License', 'MIT ▾'],
180    ['Surfaces', '✓ terminal  ✓ desktop  ○ mobile'],
181  ]
182  return (
183    <Box flexDirection="column">
184      {fields.map(([label, value]) => (
185        <Box>
186          <Text dimColor>{pad(label, 10)}</Text>
187          <Text underline>{value}</Text>
188        </Box>
189      ))}
190    </Box>
191  )
192}
193
194// ── 5 Engine-slot skins ────────────────────────────────────────
195
196export function ToolUseSkin(el: El, p: { width: number }) {
197  const { Box, Text } = el
198  const rows = [
199    { icon: '◇', tool: 'Read', arg: 'app/Models/User.php', meta: '42ms', color: tone.info },
200    { icon: '✎', tool: 'Edit', arg: 'app/Models/User.php', meta: '+3 −1', color: tone.warn },
201    { icon: '$', tool: 'Bash', arg: 'php artisan test --filter=User', meta: '4.1s', color: tone.accent },
202    { icon: '✗', tool: 'Bash', arg: 'npm run build', meta: 'exit 1', color: tone.bad },
203  ]
204  return (
205    <Box flexDirection="column">
206      {rows.map(r => (
207        <Box>
208          <Text color={r.color}>{r.icon} </Text>
209          <Text bold>{pad(r.tool, 5)}</Text>
210          <Text>{pad(r.arg, Math.max(10, p.width - 16))}</Text>
211          <Text dimColor> {r.meta}</Text>
212        </Box>
213      ))}
214    </Box>
215  )
216}
217
218export function SpinnerPack(el: El) {
219  const { Box, Text } = el
220  const packs: [string, string][] = [
221    ['braille', '⣾⣽⣻⢿⡿⣟⣯⣷'],
222    ['moon', '◐◓◑◒'],
223    ['pulse', '·•●•'],
224    ['bar', '▁▃▅▇▅▃'],
225  ]
226  return (
227    <Box flexDirection="column">
228      {packs.map(([name, frames]) => (
229        <Box>
230          <Text dimColor>{pad(name, 9)}</Text>
231          <Text color={tone.accent}>{frames.split('').join(' ')}</Text>
232        </Box>
233      ))}
234      <Text color={tone.accent}>✻ Untangling… </Text>
235    </Box>
236  )
237}
238
239export function ToolGroupSummary(el: El) {
240  const { Box, Text } = el
241  return (
242    <Box columnGap={1}>
243      <Text color={tone.info}>⏺</Text>
244      <Text>Read 14 files</Text>
245      <Text dimColor>·</Text>
246      <Text>Grep ×3</Text>
247      <Text dimColor>·</Text>
248      <Text>Edit ×2</Text>
249      <Text dimColor>· 1.8s</Text>
250    </Box>
251  )
252}
253
254export function PromptHint(el: El) {
255  const { Text } = el
256  return (
257    <Text dimColor italic>
258      tip › /observatory opens the live timeline · ⇧⇥ cycles modes
259    </Text>
260  )
261}
262
263// ── 6 Layout & chrome ──────────────────────────────────────────
264
265export function TableMock(el: El, p: { width: number }) {
266  const { Box, Text } = el
267  const head = ['Component', 'Group', 'v1']
268  const rows = [
269    ['Sparkline', 'data', '✓'],
270    ['ToolTimeline', 'observe', '✓'],
271    ['DiffCard', 'code', '—'],
272  ]
273  const w = Math.max(8, Math.floor((p.width - 2) / 3))
274  return (
275    <Box flexDirection="column">
276      <Text bold>{head.map(c => pad(c, w)).join(' ')}</Text>
277      <Text dimColor>{'─'.repeat(Math.min(p.width, w * 3 + 2))}</Text>
278      {rows.map(r => (
279        <Text>{r.map(c => pad(c, w)).join(' ')}</Text>
280      ))}
281    </Box>
282  )
283}
284
285export function KeyValue(el: El) {
286  const { Box, Text } = el
287  const pairs: [string, string][] = [
288    ['model', 'opus-5.5'],
289    ['branch', 'main ↑2'],
290    ['surface', 'terminal'],
291  ]
292  return (
293    <Box flexDirection="column">
294      {pairs.map(([k, v]) => (
295        <Box>
296          <Text dimColor>{pad(k, 9)}</Text>
297          <Text>{v}</Text>
298        </Box>
299      ))}
300    </Box>
301  )
302}
303
304export function Pills(el: El) {
305  const { Box } = el
306  return (
307    <Box columnGap={1} flexWrap="wrap">
308      {Badge(el, { text: 'passing', tone: 'ok' })}
309      {Badge(el, { text: 'flaky', tone: 'warn' })}
310      {Badge(el, { text: 'failing', tone: 'bad' })}
311      {Badge(el, { text: 'v0.1', tone: 'info' })}
312      {Badge(el, { text: 'beta', tone: 'accent' })}
313    </Box>
314  )
315}
316
317export function EmptyState(el: El) {
318  const { Box, Text } = el
319  return (
320    <Box flexDirection="column" alignItems="center" paddingY={1}>
321      <Text dimColor>╭─╮</Text>
322      <Text dimColor>╰─╯</Text>
323      <Text bold>Nothing here yet</Text>
324      <Text dimColor>Run a tool and it shows up.</Text>
325    </Box>
326  )
327}
328
329export function ToastPresets(el: El, p: { act: Act }) {
330  const { Box, Button } = el
331  return (
332    <Box columnGap={1} flexWrap="wrap">
333      <Button key="toast-ok" label="✓ success" onPress={() => p.act('✓ Deployed in 41s')} />
334      <Button key="toast-warn" label="⚠ warn" onPress={() => p.act('⚠ Context at 82%')} />
335      <Button key="toast-bad" label="✗ error" onPress={() => p.act('✗ Build failed: exit 1')} />
336    </Box>
337  )
338}
339
hooks/kit/observe.tsx 198 lines
1import { Sparkline } from './data'
2import {
3  bar,
4  clamp,
5  fmtMs,
6  fmtTokens,
7  fmtUsd,
8  pad,
9  pct,
10  series,
11  tone,
12  truncateStart,
13  type El,
14} from './foundations'
15
16export type TimelineCall = {
17  id: string
18  tool: string
19  target: string
20  startedAt: number
21  endedAt?: number
22  isError?: boolean
23}
24
25/** Gantt lanes: one row per tool call, placed on a shared time axis. */
26export function ToolTimeline(el: El, p: { calls: TimelineCall[]; now: number; width: number; rows?: number }) {
27  const { Box, Text } = el
28  const calls = p.calls.slice(-(p.rows ?? 12))
29  if (calls.length === 0) return <Text dimColor>No tool calls yet.</Text>
30
31  const start = Math.min(...calls.map(call => call.startedAt))
32  const end = Math.max(p.now, ...calls.map(call => call.endedAt ?? p.now))
33  const span = Math.max(1, end - start)
34  const nameWidth = 8
35  const timeWidth = 6
36  const lane = Math.max(8, p.width - nameWidth - timeWidth - 2)
37
38  return (
39    <Box flexDirection="column">
40      {calls.map(call => {
41        const from = Math.floor(((call.startedAt - start) / span) * lane)
42        const to = Math.max(from + 1, Math.ceil((((call.endedAt ?? p.now) - start) / span) * lane))
43        const isRunning = call.endedAt === undefined
44        const color = call.isError ? tone.bad : isRunning ? tone.warn : tone.info
45        const took = (call.endedAt ?? p.now) - call.startedAt
46
47        return (
48          <Box>
49            <Text dimColor>{pad(call.tool, nameWidth)} </Text>
50            <Text>{' '.repeat(clamp(from, 0, lane - 1))}</Text>
51            <Text color={color}>{(isRunning ? '▸' : '█').repeat(clamp(to - from, 1, lane - from))}</Text>
52            <Text>{' '.repeat(Math.max(0, lane - to))}</Text>
53            <Text dimColor> {fmtMs(took).padStart(timeWidth - 1)}</Text>
54          </Box>
55        )
56      })}
57    </Box>
58  )
59}
60
61export type UsagePoint = { usd: number; tokens: number }
62
63/** Session spend, tokens and the per-turn burn trend. */
64export function TokenBurn(el: El, p: { points: UsagePoint[]; width: number; budgetUsd?: number }) {
65  const { Box, Text } = el
66  const last = p.points[p.points.length - 1] ?? { usd: 0, tokens: 0 }
67  const perTurn = p.points.map((point, i) => point.usd - (p.points[i - 1]?.usd ?? 0))
68  const budget = p.budgetUsd ?? 5
69  const level = last.usd / budget
70
71  return (
72    <Box flexDirection="column">
73      <Box>
74        <Text bold color={level > 0.9 ? tone.bad : level > 0.6 ? tone.warn : tone.ok}>
75          {fmtUsd(last.usd)}
76        </Text>
77        <Text dimColor>
78          {' '}
79          of {fmtUsd(budget)} · {fmtTokens(last.tokens)} tok · {p.points.length} turns
80        </Text>
81      </Box>
82      {Sparkline(el, { values: perTurn.length ? perTurn : [0], width: p.width, label: 'per turn', format: fmtUsd })}
83    </Box>
84  )
85}
86
87export type AgentNode = {
88  name: string
89  type: string
90  status: 'running' | 'done' | 'failed'
91  children?: AgentNode[]
92}
93
94/** Live tree of spawned subagents with status dots. */
95export function SubagentTree(el: El, p: { root: AgentNode; width: number }) {
96  const { Box, Text } = el
97  const lines: { prefix: string; node: AgentNode }[] = []
98  const walk = (node: AgentNode, prefix: string, isLast: boolean, depth: number) => {
99    lines.push({ prefix: depth === 0 ? '' : prefix + (isLast ? '└─ ' : '├─ '), node })
100    const kids = node.children ?? []
101    kids.forEach((kid, i) =>
102      walk(kid, depth === 0 ? '' : prefix + (isLast ? '   ' : '│  '), i === kids.length - 1, depth + 1),
103    )
104  }
105  walk(p.root, '', true, 0)
106  const dot = { running: tone.warn, done: tone.ok, failed: tone.bad }
107
108  return (
109    <Box flexDirection="column">
110      {lines.map(({ prefix, node }) => (
111        <Box>
112          <Text dimColor>{prefix}</Text>
113          <Text color={dot[node.status]}>● </Text>
114          <Text>{node.name}</Text>
115          <Text dimColor> {node.type}</Text>
116        </Box>
117      ))}
118    </Box>
119  )
120}
121
122/** What fills the context window: a stacked bar plus legend. */
123export function ContextMap(
124  el: El,
125  p: { categories: { name: string; tokens: number }[]; window: number; width: number },
126) {
127  const { Box, Text } = el
128  const categories = p.categories.filter(c => !/free space/i.test(c.name))
129  const used = categories.filter(c => !isReserve(c.name)).reduce((sum, c) => sum + c.tokens, 0)
130  const cells = Math.max(10, p.width)
131  let placed = 0
132  const segments = categories.map((c, i) => {
133    const target = Math.round(((placed + c.tokens) / p.window) * cells)
134    const from = Math.round((placed / p.window) * cells)
135    placed += c.tokens
136    return { text: '█'.repeat(Math.max(0, target - from)), color: series[i % series.length] }
137  })
138  const free = Math.max(0, cells - segments.reduce((sum, s) => sum + s.text.length, 0))
139
140  return (
141    <Box flexDirection="column">
142      <Box>
143        {segments.map(s => (
144          <Text color={s.color}>{s.text}</Text>
145        ))}
146        <Text dimColor>{'░'.repeat(free)}</Text>
147      </Box>
148      <Text dimColor>
149        {fmtTokens(used)} of {fmtTokens(p.window)} · {pct(used / p.window)} used
150      </Text>
151      <Box flexWrap="wrap" columnGap={2}>
152        {categories.map((c, i) => (
153          <Box>
154            <Text color={series[i % series.length]}>■ </Text>
155            <Text>
156              {c.name} {fmtTokens(c.tokens)}
157            </Text>
158          </Box>
159        ))}
160      </Box>
161    </Box>
162  )
163}
164
165/** Free space and the autocompact buffer are room, not content. */
166const isReserve = (name: string) => /free space|autocompact/i.test(name)
167
168export type FileTouch = { path: string; reads: number; edits: number }
169
170/** Files read and edited this session, hottest first. */
171export function FileTouchMap(el: El, p: { files: FileTouch[]; width: number; rows?: number }) {
172  const { Box, Text } = el
173  const files = [...p.files].sort((a, b) => b.reads + b.edits * 3 - (a.reads + a.edits * 3)).slice(0, p.rows ?? 8)
174  if (files.length === 0) return <Text dimColor>No files touched yet.</Text>
175
176  const max = Math.max(1, ...files.map(f => f.reads + f.edits * 3))
177  const heatWidth = 6
178  const countWidth = 8
179  const pathWidth = Math.max(10, p.width - heatWidth - countWidth - 2)
180
181  return (
182    <Box flexDirection="column">
183      {files.map(f => (
184        <Box>
185          <Text color={f.edits ? tone.warn : tone.info}>
186            {bar((f.reads + f.edits * 3) / max, heatWidth, ' ')}
187          </Text>
188          <Text> {truncateStart(f.path, pathWidth).padEnd(pathWidth)}</Text>
189          <Text dimColor>
190            {' '}
191            {String(f.reads).padStart(2)}r {String(f.edits).padStart(2)}e
192          </Text>
193        </Box>
194      ))}
195    </Box>
196  )
197}
198
types/index.d.ts 25 lines
1export type SinkCall = {
2  id: string
3  tool: string
4  target: string
5  startedAt: number
6  endedAt?: number
7  isError?: boolean
8}
9
10export type SinkCategory = { name: string; tokens: number }
11
12export type SinkUsage = {
13  at: number
14  tokens: number
15  window: number
16  usd: number
17  categories: SinkCategory[]
18}
19
20declare module 'claude-code' {
21  interface PluginState {
22    'kit-sink': { tab: string; calls: SinkCall[]; usage: SinkUsage[] }
23  }
24}
25