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

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.
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.
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).
/kit sink observe)
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.
| Component | What it shows |
|---|---|
| ToolTimeline | One 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. |
| TokenBurn | Session 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. |
| SubagentTree | The live tree of subagents this session spawned: ● yellow while running, green when done, red when failed. |
| ContextMap | What 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. |
| FileTouchMap | The 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 preset | The same numbers squeezed into one line for $.ui.status: $0.55 · 7% ctx. |
/kit sink data)
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.
| Component | What it shows |
|---|---|
| Sparkline | A trend in one line of ▁▂▃▄▅▆▇█, with an optional label and last value. It resamples to whatever width it is given. |
| Gauge | Label, 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 · KpiRow | Bordered 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. |
| BarList | Ranked horizontal bars with right-aligned values, for top tools, top files or top errors. |
| Heatmap | A GitHub-style activity grid: 7 rows (days) × N weeks, intensity drawn as ·░▒▓█. |
/kit sink code) · mocks
| Component | What it shows |
|---|---|
| DiffCard | A file header with +3 −1, the diff syntax-highlighted by Claude Code's own Code element, and Accept / Reject buttons. |
| FindingList | Review findings with HIGH / MED / LOW severity badges and a file:line for each. |
| TestRun | A dot per test (green pass, red × fail, grey ○ skipped), a summary line, and the first failure. |
| CommitComposer | Staged files (✓) versus unstaged ones (○), a draft message, and a Commit button. |
/kit sink decide) · mocks
| Component | What it shows |
|---|---|
| ChoiceCard | A question with numbered options, a recommended badge and a one-line trade-off for each, as an alternative to a wall of chat text. |
| TodoBoard | A progress gauge over a checklist: done ✓ (dimmed), active ◐ (yellow), pending ○. |
| ConfirmBar | A red-bordered confirmation for a destructive action, listing exactly what it will touch, with Delete / Cancel buttons. |
| FormPane | Labeled fields: text, a select (MIT ▾) and multi-choice. |
/kit sink skins) · mocks
Claude Code lets a mod redraw its built-in rows. These mocks show what a theme pack could do.
| Component | What it shows |
|---|---|
| ToolUse skin | Compact one-line tool rows: an icon per tool, the argument, and the duration or result (+3 −1, exit 1 in red). |
| Spinner pack | Alternative spinner frame sets (braille, moon, pulse, bar) and a themed verb (✻ Untangling…). |
| ToolGroup summary | A burst of calls collapsed into one row: Read 14 files · Grep ×3 · Edit ×2 · 1.8s. |
| PromptHint | A contextual tip line under the prompt. |
/kit sink layout) · mocks
| Component | What it shows |
|---|---|
| Table | Header, rule and rows, with columns sized to the pane width. |
| KeyValue | Aligned label/value pairs. |
| Badge · Pill | Colored status tags using the kit's semantic tones (ok, warn, bad, info, accent). |
| EmptyState | What a component shows before it has any data. |
| Toast presets | Buttons that fire $.ui.toast in success / warn / error formats. |
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.
claude plugin validate mods/kit-sink
claude plugin test mods/kit-sink
MIT
hooks/register.tsx 129 lines1import { 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}
129hooks/kit/sections.tsx 274 lines1import 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}
274hooks/kit/data.tsx 135 lines1import { 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}
135hooks/kit/foundations.ts 87 lines1import 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)}%`
87hooks/kit/mocks.tsx 339 lines1// 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}
339hooks/kit/observe.tsx 198 lines1import { 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}
198types/index.d.ts 25 lines1export 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