Live panes and bands for session awareness: agents-progress, cache-keeper, context-health, filetree, next-steps, usage-limits

Claude Code mods (needs Claude Code 2.1.287+). Inspired by savvy-progress, claude-code-filetree, cache-tax, claude-reflect and the "9 Claude Code Mods" article.
/plugin marketplace add bharath-gp/ai-harness
/plugin install guardrails@ai-harness
/plugin install cockpit@ai-harness
/plugin install toolbox@ai-harness
toolbox holds general-purpose skills, not mods. Today: github-repo-setup, which sets up or hardens a GitHub repo (new or existing) with branch protection, CI, CodeQL, Dependabot, secret scanning and optional signed commits via gh.
The other two plugins are bundles of mods. Every mod is on by default; turn individual mods off in the config menu (each is a boolean userConfig field, e.g. cacheKeeper) and the plugin reloads. Mods are not sandboxed: read the source first.
| Bundle | Mod | What it does |
|---|---|---|
guardrails | blast-radius | Asks before destructive shell commands run |
secret-sentry | Blocks API keys and private keys from being written into files | |
reflect | Spots corrections in your prompts and saves them as rules in CLAUDE.md | |
assumption-tracker | Lists the assumptions Claude made and sends fixes for the ones you mark wrong | |
self-critique | /critique panel that reviews Claude's last answer, with one-click fixes | |
cockpit | agents-progress | Live subagent panel and a running-agents band above the prompt |
cache-keeper | Cache countdown band (shown in the last 5 minutes or when cold), /keepwarm pings and a cold-send guard with cost estimate | |
context-health | Context fill band with warnings, /ctx-handoff and /autohandoff | |
usage-limits | Always-on band: session (5h) and weekly (7d) limit usage, reset countdown, pace | |
filetree | File tree pane with git status and highlights for files Claude reads and writes | |
next-steps | Suggests three next prompts after each turn |
The toolbox skills are plain SKILL.md folders, so the open skills installer puts them into Cursor, Codex, Gemini CLI, GitHub Copilot, OpenCode and other agents. Claude Code users keep using /plugin install above.
npx skills add bharath-gp/ai-harness # pick skills and agents interactively
npx skills add bharath-gp/ai-harness --skill github-repo-setup --agent codex cursor -g
Only skills install this way; the guardrails and cockpit mods are Claude Code only.
MIT, see LICENSE. Security reports: SECURITY.md.
./scripts/sync-ui.sh # copy shared/ into each plugins/<bundle>/hooks/ (commit the copies)
./scripts/dev-sync.sh <mods-folder> # rsync plugins/ into a hot-reloaded mods folder
claude plugin validate plugins/<bundle>
claude plugin test plugins/<bundle>
claude --plugin-dir plugins/<bundle>
Each bundle is plugins/<bundle>/{.claude-plugin/plugin.json, hooks/hooks.json, hooks/register.tsx}. register.tsx holds every mod as a section of named function declarations (the engine follows $ only into declarations in the same file, which is what the desktop app's bundled CLI requires) and wires them: one on(...) per event+matcher (a plugin may not register one twice), chaining the mods that share it and skipping any whose userConfig flag is false. To add a mod: add a section to register.tsx, add a boolean field to plugin.json, and wire its hooks. Edit shared UI only in shared/ (ui.ts palette, band.tsx band/pane header/item, kit.tsx pills/badges/tiles), then run sync-ui.sh. The palette is dark-only for now.
Install reflect (in guardrails) or the Python claude-reflect plugin, not both.
hooks/register.tsx 1117 lines1import type { Register } from 'claude-code'
2import { band, beginPass, endPass, item } from './band'
3import { fileBadge, folderIcon, iconTile, panel, pill, sectionLabel, tile } from './kit'
4import { P, RAMP, type Tone, below, fmtCost, fmtDur, fmtTok, isRich, zoneTone } from './ui'
5
6// ===== agents-progress =====
7type Agent = { id: string; type: string; desc: string; model?: string; startedAt: number; endedAt?: number; failed?: boolean }
8
9let agents: Agent[] = []
10let subTools = 0 // tool calls made inside subagents; ponytail: not split per agent, no id link on spawn
11let cost = 0
12let ctxTokens = 0
13let showFinished = true
14type Todo = { id?: string; content: string; status: string; activeForm?: string }
15let todos: Todo[] = [] // main session's TodoWrite list, or its TaskCreate/TaskUpdate tasks (keyed by id)
16
17function agentsProgressRedraw($: any) { return $.ui.invalidate('ui.render') }
18const running = () => agents.filter(a => a.endedAt === undefined)
19const finished = () => agents.filter(a => a.endedAt !== undefined)
20const pctDone = () => (agents.length ? (finished().length / agents.length) * 100 : 0)
21const TIERS = [P.accent, P.info, P.ok, P.warn, P.purple, P.teal]
22// A stable colour per subagent type, so "Explore" is always the same tier colour.
23const tierColor = (type: string) => TIERS[[...type].reduce((n, c) => n + c.charCodeAt(0), 0) % TIERS.length] as string
24const tone = (a: Agent): Tone => (a.endedAt === undefined ? 'accent' : a.failed ? 'err' : 'ok')
25const clock = (ms: number) => `${Math.floor(ms / 60_000)}:${String(Math.floor((ms % 60_000) / 1000)).padStart(2, '0')}`
26
27async function agentsProgressSync($: any) {
28 const u = await $.session.usage()
29 cost = u.cost?.usd ?? cost
30 ctxTokens = u.context.tokens ?? ctxTokens
31 agentsProgressRedraw($)
32}
33
34export async function agentsProgressSessionStart($: any, e: any, next: any) {
35 await $.command.register({ name: 'agents-info', description: 'Show the subagents panel' })
36 $.clock.every(1000, () => running().length && agentsProgressRedraw($)) // elapsed-time tick
37 $.clock.every(8000, () => running().length && void agentsProgressSync($))
38 return next(e)
39}
40
41export async function agentsProgressCommandRunAgentsInfo($: any) {
42 await $.ui.open({ id: 'agents', title: 'Agents' })
43 return { text: 'Agents panel opened.' }
44}
45
46export async function agentsProgressAgentSpawn($: any, e: any, next: any) {
47 const a: Agent = { id: e.tool_use_id, type: e.subagentType, desc: e.description, startedAt: await $.clock.now() }
48 agents = [...agents, a].slice(-50)
49 agentsProgressRedraw($)
50 const r = await next(e)
51 a.endedAt = await $.clock.now()
52 a.failed = r.deny !== undefined
53 a.model = r.model
54 void agentsProgressSync($)
55 return r
56}
57
58export async function agentsProgressToolCall($: any, e: any, next: any) {
59 if (e.agentId !== undefined) {
60 subTools += 1
61 return next(e)
62 }
63 if (e.tool === 'TodoWrite' && Array.isArray(e.todos)) {
64 todos = e.todos
65 agentsProgressRedraw($)
66 } else if (e.tool === 'TaskCreate') {
67 const r = await next(e)
68 const id = r.result?.task?.id
69 if (id !== undefined && !r.isError) {
70 todos = [...todos, { id: String(id), content: e.subject, status: 'pending', activeForm: e.activeForm }]
71 agentsProgressRedraw($)
72 }
73 return r
74 } else if (e.tool === 'TaskUpdate') {
75 const r = await next(e)
76 if (!r.isError && r.deny === undefined) {
77 todos = (e.status === 'deleted'
78 ? todos.filter(t => t.id !== e.taskId)
79 : todos.map(t => (t.id === e.taskId ? { ...t, content: e.subject ?? t.content, status: e.status ?? t.status, activeForm: e.activeForm ?? t.activeForm } : t)))
80 agentsProgressRedraw($)
81 }
82 return r
83 }
84 return next(e)
85}
86
87export async function agentsProgressUiRenderAbovePrompt($: any, e: any, next: any) {
88 if (e.props.hasSurvey) return next(e)
89 const ui = $.ui.resolve(e)
90 const live = running()
91 let nested = await below(next, e)
92 if (live.length) {
93 nested = band(ui, e, nested, {
94 title: 'Agents',
95 tone: 'accent',
96 busy: true,
97 pct: pctDone(),
98 pctLabel: `${live.length} running · ${finished().length}/${agents.length}`,
99 detail: undefined,
100 buttons: [{ key: 'open', label: `×${agents.length}`, plain: true, onPress: () => $.ui.open({ id: 'agents', title: 'Agents' }) }],
101 })
102 }
103 const done = todos.filter(t => t.status === 'completed').length
104 if (todos.length === 0 || done === todos.length) return nested
105 const cur = todos.find(t => t.status === 'in_progress')
106 return band(ui, e, nested, {
107 title: cur ? cur.activeForm || cur.content : 'Next: ' + (todos.find(t => t.status !== 'completed')?.content ?? ''),
108 tone: 'accent',
109 busy: Boolean(cur),
110 pct: (done / todos.length) * 100,
111 pctLabel: `Progress ${done}/${todos.length}`,
112 })
113}
114
115export async function agentsProgressUiRenderPaneAgents($: any, e: any) {
116 const ui = $.ui.resolve(e)
117 const { Box, Text, Button } = ui
118 const now = await $.clock.now()
119 const first = agents.length ? Math.min(...agents.map(a => a.startedAt)) : now
120 const last = running().length ? now : Math.max(first, ...agents.map(a => a.endedAt ?? 0))
121 const card = (a: Agent) =>
122 item(ui, e, {
123 key: a.id,
124 tone: tone(a),
125 avatar: [...a.type.replace(/^[^:]*:/, '')][0]?.toUpperCase() ?? '?',
126 title: a.desc || a.type,
127 sub: `${a.type}${a.model ? ` · ${a.model}` : ''}`,
128 subColor: tierColor(a.type),
129 chip: a.endedAt === undefined ? 'working' : a.failed ? 'failed' : 'done',
130 meta: clock((a.endedAt ?? now) - a.startedAt),
131 pct: a.endedAt === undefined ? 'busy' : 100,
132 busy: a.endedAt === undefined,
133 })
134 const live = running()
135 const done = [...finished()].reverse()
136
137 return (
138 <Box flexDirection="column" gap={1}>
139 <Text bold>Subagents</Text>
140 <Box flexDirection="row" gap={1}>
141 {tile(ui, e, 'Cost', `≈${fmtCost(cost)}`)}
142 {tile(ui, e, 'Context', fmtTok(ctxTokens))}
143 {tile(ui, e, 'Time', clock(last - first))}
144 </Box>
145 {agents.length === 0 ? <Text color={P.muted}>No subagents yet. They appear here the moment Claude spawns one.</Text> : null}
146 {live.length ? <Text color={P.muted}>{`Running · ${live.length}`}</Text> : null}
147 {[...live].reverse().map(card)}
148 {done.length ? (
149 <Button key="fin" plain label={`${showFinished ? '▾' : '▸'} Finished · ${done.length}`} onPress={() => { showFinished = !showFinished; agentsProgressRedraw($) }} />
150 ) : null}
151 {showFinished ? done.map(card) : null}
152 {subTools ? <Text color={P.muted}>{`${subTools} tool calls inside subagents`}</Text> : null}
153 </Box>
154 )
155}
156
157// ===== cache-keeper =====
158const TTL = 60 * 60_000 // main cache lifetime; ponytail: 1h only, read promptCacheTtl if 5m billing matters
159const PING_AFTER = 50 * 60_000
160const WARN_BEFORE = 5 * 60_000
161const MIN_GUARD_TOKENS = 50_000
162const DEFAULT_WINDOW = 6 * 3_600_000
163
164// $/MTok: [1h cache write, cache read]. ponytail: two rows, extend the table per model.
165const price = (model: string) => (/fable/i.test(model) ? [20, 0.25] : [4, 0.2])
166
167const parseDuration = (s: string) => {
168 const m = /^(?:(\d+)h)?(?:(\d+)m)?$/.exec(s.trim())
169 return m && (m[1] || m[2]) ? (Number(m[1] ?? 0) * 60 + Number(m[2] ?? 0)) * 60_000 : undefined
170}
171
172const mins = (ms: number) => `${Math.max(0, Math.round(ms / 60_000))}m`
173
174let lastTouch = 0 // last time the cache was written or read
175let warnedCold = false
176let warnedSoon = false
177let warmUntil = 0
178let lastPing = ''
179let cacheKeeperTokens = 0
180let cacheKeeperRates = [4, 0.2]
181
182async function cacheKeeperSync($: any) {
183 const { context } = await $.session.usage()
184 cacheKeeperTokens = context.tokens ?? cacheKeeperTokens
185 cacheKeeperRates = price(await $.session.model())
186 $.ui.invalidate('ui.render')
187}
188
189async function cacheKeeperTouch($: any) {
190 lastTouch = await $.clock.now()
191 warnedCold = false
192 warnedSoon = false
193 await cacheKeeperSync($)
194}
195
196export async function cacheKeeperSessionStart($: any, e: any, next: any) {
197 await $.command.register({ name: 'keepwarm', description: 'Keep the prompt cache warm: /keepwarm [90m|2h|off|status]' })
198 await $.command.register({ name: 'cache-tax', description: 'Show prompt cache state and rewrite cost' })
199 lastTouch = await $.clock.now()
200 void cacheKeeperSync($)
201
202 $.clock.every(30_000, async () => {
203 const now = await $.clock.now()
204 const idle = now - lastTouch
205 if (lastTouch && TTL - idle < WARN_BEFORE && TTL - idle > 0 && !warnedSoon && warmUntil < now) {
206 warnedSoon = true
207 $.ui.toast('Cache goes cold in under 5 minutes. /keepwarm to hold it.')
208 }
209 if (warmUntil > now && idle >= PING_AFTER && idle < TTL) {
210 const r = await $.model.fork({ prompt: 'Reply with the single word: ok' })
211 if (r.isAnswered) {
212 lastTouch = now
213 lastPing = `read ${fmtTok(r.usage.cache_read_input_tokens)}`
214 if (r.usage.cache_read_input_tokens === 0) {
215 warmUntil = 0 // ping wrote instead of read: cache was already cold, stop paying
216 lastPing += ', cold: stopped'
217 }
218 } else if (r.reason !== 'nothing-to-fork') {
219 lastPing = `ping failed: ${r.reason}`
220 }
221 }
222 $.ui.invalidate('ui.render')
223 })
224 return next(e)
225}
226
227export async function cacheKeeperCommandRunKeepwarm($: any, e: any) {
228 try {
229 const now = await $.clock.now()
230 const arg = (e.args ?? "").trim()
231 if (arg === 'off') {
232 warmUntil = 0
233 $.ui.invalidate('ui.render')
234 return { text: 'keepwarm off.' }
235 }
236 if (arg === 'status') {
237 return { text: warmUntil > now ? `keepwarm armed ${mins(warmUntil - now)} more. Last ping: ${lastPing || 'none'}` : 'keepwarm off.' }
238 }
239 const window = arg === '' ? DEFAULT_WINDOW : parseDuration(arg)
240 if (window === undefined) return { text: 'Usage: /keepwarm [90m|2h30m|off|status]' }
241 warmUntil = now + window
242 $.ui.invalidate('ui.render')
243 return { text: `keepwarm armed for ${mins(window)}. Pings are cheap cache reads, but they do cost tokens.` }
244 } catch (err) {
245 return { text: `keepwarm failed: ${(err as Error).message}` }
246 }
247}
248
249export async function cacheKeeperCommandRunCacheTax($: any) {
250 await cacheKeeperSync($)
251 const now = await $.clock.now()
252 const left = TTL - (now - lastTouch)
253 return {
254 text: `cache ${left > 0 ? `warm ${mins(left)} left` : 'cold'} | context ${cacheKeeperTokens} tok | cold send ≈ $${((cacheKeeperTokens * cacheKeeperRates[0]) / 1e6).toFixed(2)} vs warm $${((cacheKeeperTokens * cacheKeeperRates[1]) / 1e6).toFixed(2)}`,
255 }
256}
257
258export async function cacheKeeperPromptSubmit($: any, e: any, next: any) {
259 const now = await $.clock.now()
260 const isSlash = e.text.trimStart().startsWith('/')
261 const isCold = lastTouch > 0 && now - lastTouch >= TTL
262 if (isCold && !warnedCold && !isSlash && e.origin.kind !== 'plugin') {
263 await cacheKeeperSync($)
264 if (cacheKeeperTokens >= MIN_GUARD_TOKENS) {
265 warnedCold = true
266 return {
267 drop: `cache-keeper: the cache went cold ${mins(now - lastTouch - TTL)} ago. Sending rewrites up to ${cacheKeeperTokens} tokens ≈ $${((cacheKeeperTokens * cacheKeeperRates[0]) / 1e6).toFixed(2)} (warm: $${((cacheKeeperTokens * cacheKeeperRates[1]) / 1e6).toFixed(2)}). Send again to pay it, or /clear.`,
268 }
269 }
270 }
271 return next(e)
272}
273
274export async function cacheKeeperTurnComplete($: any, e: any, next: any) {
275 if (e.agentId === undefined) await cacheKeeperTouch($)
276 return next(e)
277}
278
279export async function cacheKeeperUiRenderAbovePrompt($: any, e: any, next: any) {
280 if (e.props.hasSurvey || lastTouch === 0) return next(e)
281 const now = await $.clock.now()
282 const left = TTL - (now - lastTouch)
283 if (left >= WARN_BEFORE) return next(e) // only when the cache is about to go cold, or already is
284 const warm = left > 0
285 const tone: Tone = !warm ? 'err' : left < WARN_BEFORE ? 'warn' : 'ok'
286 const keeping = warmUntil > now
287 const cold$ = fmtCost((cacheKeeperTokens * cacheKeeperRates[0]) / 1e6)
288 const warm$ = fmtCost((cacheKeeperTokens * cacheKeeperRates[1]) / 1e6)
289 return band($.ui.resolve(e), e, await below(next, e), {
290 title: warm ? `Cache warm · ${mins(left)} left` : 'Cache cold',
291 tone,
292 ring: true,
293 pct: warm ? Math.max(0, (left / TTL) * 100) : 100,
294 busy: !warm,
295 detail: warm ? `${fmtTok(cacheKeeperTokens)} ctx · next send ${warm$}` : `${fmtTok(cacheKeeperTokens)} ctx · next send rewrites it ${cold$} (warm ${warm$})`,
296 chips: keeping ? [`keepwarm ${mins(warmUntil - now)}`] : [],
297 right: keeping ? lastPing || 'no ping yet' : undefined,
298 buttons: [{ key: 'kw', label: keeping ? 'Stop keepwarm' : 'Keep warm', onPress: () => void $.command.run({ command: 'keepwarm', args: keeping ? 'off' : '' }) }],
299 })
300}
301
302// ===== context-health =====
303const HANDOFF_FILE = '.claude/handoff.md'
304const HANDOFF_PROMPT = `Write a handoff brief to ${HANDOFF_FILE} so a fresh session can continue this work: goal, decisions made, files touched, current state, exact next steps. Keep it under 60 lines. Reply "handoff written" when done.`
305
306let percent = 0
307let contextHealthTokens = 0
308let windowSize = 0
309let contextHealthRates: { kind: string; percentUsed: number }[] = []
310let threshold = 50 // ponytail: stored per session; persist via $.store if wanted across sessions
311let auto = false
312let handingOff = false
313
314let lastRefresh = 0
315let turnNo = 0
316let speed = 0 // last measured output tok/s of the main session
317let costNow = 0
318let costAtStart = 0
319let bandHidden = false // closed with ×, for the rest of the session
320
321async function refresh($: any, force = false) {
322 const now = await $.clock.now()
323 if (!force && now - lastRefresh < 2000) return
324 lastRefresh = now
325 const { context, rateLimits, cost } = await $.session.usage()
326 costNow = cost?.usd ?? costNow
327 percent = Math.round(context.percent ?? 0)
328 contextHealthTokens = context.tokens ?? 0
329 windowSize = context.window ?? 0
330 contextHealthRates = rateLimits ?? []
331 $.ui.invalidate('ui.render')
332}
333
334function startHandoff($: any) {
335 handingOff = true
336 void $.prompt.submit({ text: HANDOFF_PROMPT })
337}
338
339export async function contextHealthSessionStart($: any, e: any, next: any) {
340 await $.command.register({ name: 'ctx-handoff', description: 'Write a handoff brief, clear, and continue in a fresh context' })
341 await $.command.register({ name: 'autohandoff', description: 'Auto handoff at a context %: /autohandoff [percent|off]' })
342 return next(e)
343}
344
345export async function contextHealthTurnStart($: any, e: any, next: any) {
346 if (e.agentId === undefined) {
347 turnNo += 1
348 costAtStart = (await $.session.usage()).cost?.usd ?? costNow
349 costNow = costAtStart
350 }
351 return next(e)
352}
353
354// Times each main-session response from its first chunk to its stop usage; stubs and instant bursts are dropped.
355export async function* contextHealthTurnStep($: any, e: any, next: any) {
356 if (e.agentId !== undefined) return yield* next(e)
357 let startedAt: number | null = null
358 for await (const chunk of next(e)) {
359 if (startedAt === null) startedAt = await $.clock.now()
360 if (chunk.kind === 'stop' && chunk.usage) {
361 const secs = ((await $.clock.now()) - startedAt) / 1000
362 const rate = chunk.usage.output_tokens / secs
363 if (chunk.usage.output_tokens >= 50 && secs >= 1 && rate <= 400) speed = Math.round(rate)
364 }
365 yield chunk
366 }
367}
368
369export async function contextHealthCommandRunHandoff($: any) {
370 startHandoff($)
371 return { text: 'Writing handoff…' }
372}
373
374export async function contextHealthCommandRunAutohandoff(_$: any, e: any) {
375 const n = Number((e.args ?? "").trim())
376 if ((e.args ?? "").trim() === 'off') auto = false
377 else {
378 auto = true
379 if (n >= 10 && n <= 95) threshold = n
380 }
381 return { text: auto ? `Auto handoff at ${threshold}% context.` : 'Auto handoff off.' }
382}
383
384// Keep the band live while context grows (tool results) or shrinks (compact/clear).
385export async function contextHealthToolCall($: any, e: any, next: any) {
386 const done = await next(e)
387 if (e.agentId === undefined) await refresh($)
388 return done
389}
390
391export async function contextHealthPromptSubmit($: any, e: any, next: any) {
392 const done = await next(e)
393 await refresh($)
394 return done
395}
396
397export async function contextHealthTurnComplete($: any, e: any, next: any) {
398 if (e.agentId !== undefined) return next(e)
399 const done = await next(e)
400 if (handingOff) {
401 handingOff = false
402 await $.command.run({ command: 'clear', args: '' })
403 await refresh($, true)
404 await $.prompt.fill({ text: `Read ${HANDOFF_FILE} and continue from there.` })
405 $.ui.toast('Handoff ready: press Enter to continue in the fresh session.')
406 } else {
407 await refresh($, true)
408 if (auto && percent >= threshold) startHandoff($)
409 }
410 return done
411}
412
413export async function contextHealthUiRenderAbovePrompt($: any, e: any, next: any) {
414 if (e.props.hasSurvey || percent === 0 || bandHidden) return next(e)
415 const high = percent >= threshold
416 const limits = contextHealthRates.map(r => `${r.kind.replace('five_hour', '5h').replace('seven_day', '7d')} ${Math.round(r.percentUsed)}%`).join(' · ')
417 return band($.ui.resolve(e), e, await below(next, e), {
418 title: `Context ${percent}%`,
419 tone: zoneTone(percent, threshold - 10, threshold),
420 ring: true,
421 pct: percent,
422 detail: high ? 'quality may drop: hand off or start fresh' : `${fmtTok(contextHealthTokens)} of ${fmtTok(windowSize)} tokens`,
423 right: limits || undefined,
424 chips: [
425 ...(turnNo ? [`turn ${turnNo}`] : []),
426 ...(speed ? [`${speed} tok/s`] : []),
427 ...(costNow ? [`${fmtCost(Math.max(0, costNow - costAtStart))} · ${fmtCost(costNow)}`] : []),
428 ...(auto ? [`auto @${threshold}%`] : []),
429 ],
430 buttons: [
431 ...(high
432 ? [
433 { key: 'h', label: 'Handoff', hotkey: 'h', onPress: () => startHandoff($) },
434 { key: 'f', label: 'Fresh', hotkey: 'f', plain: true, onPress: () => void $.command.run({ command: 'clear', args: '' }) },
435 ]
436 : []),
437 { key: 'x', label: '×', plain: true, onPress: () => { bandHidden = true; void $.ui.invalidate('ui.render') } },
438 ],
439 })
440}
441
442// ===== filetree =====
443type State = 'reading' | 'opened' | 'editing' | 'edited' | 'committed'
444type Row = { path: string; name: string; depth: number; isDir: boolean; size: number; mtime: number }
445type Change = { st: string; add: number; del: number }
446type Activity = { state: State; label: string; target: string; running: boolean; at: number; failed?: boolean }
447type Touch = { state: State; at: number }
448
449const HIDE = new Set(['node_modules', '.DS_Store', '.next', 'dist', '__pycache__', '.venv'])
450const GLOW_MS = 9_000
451const MAX_ROWS = 300
452// One colour family per kind of activity: purple reads, orange edits, green commits (as in the video).
453const FAMILY: Record<State, { fg: string; bg: string; ramp: string[]; label: string }> = {
454 reading: { fg: '#c084fc', bg: '#2a2140', ramp: RAMP.purple, label: 'Reading' },
455 opened: { fg: '#c084fc', bg: '#2a2140', ramp: RAMP.purple, label: 'Opened' },
456 editing: { fg: '#fb923c', bg: '#3a2a16', ramp: RAMP.orange, label: 'Editing' },
457 edited: { fg: '#fb923c', bg: '#3a2a16', ramp: RAMP.orange, label: 'Edited' },
458 committed: { fg: '#4ade80', bg: '#16301f', ramp: RAMP.green, label: 'Committed' },
459}
460const GIT_COLOR: Record<string, string> = { M: P.warn, T: P.warn, A: P.ok, '?': P.ok, R: P.info, C: P.info, D: P.err, U: P.err }
461const COMMITTED = '#4ade80'
462const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
463
464const open = new Set<string>() // expanded dirs (absolute)
465const touched = new Map<string, Touch>()
466let cwd = ''
467let root = ''
468let selected = ''
469let query = ''
470let showSize = false
471let showHidden = true
472let changes = new Map<string, Change>() // absolute path -> change
473let branch = ''
474let upstream = ''
475let ahead = 0
476let behind = 0
477let list: Row[] = []
478let allFiles: Row[] | undefined // flat index for search, built on first use
479let activity: Activity | undefined
480let phase = 0
481let ticking = false
482let dismissed = false
483let committedSet = new Set<string>() // files in commits not pushed yet (or the last commit)
484const seen = new Set<string>() // changed files already auto-expanded once
485
486function filetreeRedraw($: any) { return $.ui.invalidate('ui.render') }
487const rel = (p: string) => (p.startsWith(`${root}/`) ? p.slice(root.length + 1) : p)
488const dirOf = (p: string) => p.slice(0, p.lastIndexOf('/'))
489const base = (p: string) => p.slice(p.lastIndexOf('/') + 1)
490const fmtSize = (n: number) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(n < 10240 ? 1 : 0)} KB` : `${(n / 1048576).toFixed(1)} MB`)
491const ago = (ms: number) => {
492 const m = Math.floor(ms / 60_000)
493 return m < 1 ? 'just now' : m < 60 ? `${m}m ago` : m < 1440 ? `${Math.floor(m / 60)}h ago` : `${Math.floor(m / 1440)}d ago`
494}
495// Git state in words, not letters: "new", "modified", "deleted".
496const WORD: Record<string, string> = { '?': 'new', A: 'new', M: 'modified', T: 'modified', D: 'deleted', R: 'renamed', C: 'copied', U: 'conflict' }
497
498async function refreshGit($: any) {
499 const st = await $.process.run(['git', 'status', '-sb', '--porcelain'], { cwd: root, timeoutMs: 5000 }).catch(() => undefined)
500 changes = new Map()
501 branch = upstream = ''
502 ahead = behind = 0
503 if (st?.exitCode === 0) {
504 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: root }).catch(() => undefined)
505 const b = top?.exitCode === 0 ? top.stdout.trim() : root
506 const lines: string[] = st.stdout.split('\n')
507 const head = /^## (\S+?)(?:\.\.\.(\S+))?(?: \[(.+)\])?$/.exec(lines[0] ?? '')
508 branch = head?.[1] ?? ''
509 upstream = head?.[2] ?? ''
510 ahead = Number(/ahead (\d+)/.exec(head?.[3] ?? '')?.[1] ?? 0)
511 behind = Number(/behind (\d+)/.exec(head?.[3] ?? '')?.[1] ?? 0)
512 for (const line of lines.slice(1)) if (line.length > 3) changes.set(`${b}/${line.slice(3).replace(/^"|"$/g, '')}`, { st: line.slice(0, 2).trim()[0] ?? '?', add: 0, del: 0 })
513 const ns = await $.process.run(['git', 'diff', '--numstat', 'HEAD'], { cwd: root, timeoutMs: 5000 }).catch(() => undefined)
514 for (const l of (ns?.stdout ?? '').split('\n')) {
515 const [a, d, p] = l.split('\t')
516 const c = p && changes.get(`${b}/${p}`)
517 if (c) Object.assign(c, { add: Number(a) || 0, del: Number(d) || 0 })
518 }
519 }
520 // Committed but not pushed: what the branch has that its upstream has not (else just the last commit).
521 committedSet = new Set()
522 if (branch) {
523 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: root }).catch(() => undefined)
524 const b = top?.exitCode === 0 ? top.stdout.trim() : root
525 const range = upstream ? [`${upstream}..HEAD`] : ['HEAD~1', 'HEAD']
526 const cm = await $.process.run(['git', 'diff', '--name-only', ...range], { cwd: root, timeoutMs: 5000 }).catch(() => undefined)
527 for (const f of (cm?.exitCode === 0 ? cm.stdout : '').split('\n').filter(Boolean)) if (!changes.has(`${b}/${f}`)) committedSet.add(`${b}/${f}`)
528 }
529 await expandRecent($)
530 filetreeRedraw($)
531}
532
533// Open the folders of the files that changed most recently, so nested changes are visible without hunting.
534// Each file is expanded once: a folder you collapse yourself stays collapsed until something new changes.
535async function expandRecent($: any) {
536 const fresh = [...changes.keys(), ...committedSet].filter(f => !seen.has(f) && f.startsWith(`${root}/`))
537 for (const f of fresh) seen.add(f)
538 const dated = await Promise.all(fresh.map(async f => ({ f, t: (await $.fs.stat(f).catch(() => undefined))?.mtimeMs ?? 0 })))
539 for (const { f } of dated.sort((a, b) => b.t - a.t).slice(0, 12)) for (let d = dirOf(f); d.length > root.length; d = dirOf(d)) open.add(d)
540 await rebuild($)
541}
542
543async function walk($: any, dir: string, depth: number, out: Row[], expand: (p: string) => boolean) {
544 const entries = (await $.fs.list(dir).catch(() => [])).filter((x: any) => !HIDE.has(x.name) && (showHidden ? x.name !== '.DS_Store' : !x.name.startsWith('.')))
545 entries.sort((a: any, b: any) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'dir' ? -1 : 1))
546 for (const x of entries) {
547 if (out.length >= 2500) return
548 const path = `${dir}/${x.name}`
549 out.push({ path, name: x.name, depth, isDir: x.kind === 'dir', size: x.size, mtime: x.mtimeMs })
550 if (x.kind === 'dir' && expand(path) && x.name !== '.git') await walk($, path, depth + 1, out, expand)
551 }
552}
553
554async function rebuild($: any) {
555 if (query) {
556 allFiles ??= await (async () => {
557 const out: Row[] = []
558 await walk($, root, 0, out, () => true)
559 return out.filter(r => !r.isDir)
560 })()
561 const q = query.toLowerCase()
562 list = allFiles.filter(r => r.path.toLowerCase().includes(q)).slice(0, MAX_ROWS)
563 } else {
564 const out: Row[] = []
565 await walk($, root, 0, out, p => open.has(p))
566 list = out
567 }
568 filetreeRedraw($)
569}
570
571function startTick($: any) {
572 if (ticking) return
573 ticking = true
574 const loop = () => {
575 const live = activity?.running || [...touched.values()].some(m => Date.now() - m.at < GLOW_MS)
576 phase += 1
577 filetreeRedraw($)
578 if (live) $.clock.after(130, loop)
579 else ticking = false
580 }
581 $.clock.after(130, loop)
582}
583
584function filetreeTouch($: any, path: string | undefined, state: State) {
585 if (!path) return
586 const abs = path.startsWith('/') ? path : `${root}/${path}`
587 touched.set(abs, { state, at: Date.now() })
588 dismissed = false // a new touch brings the band back
589 for (let d = dirOf(abs); d.length > root.length; d = dirOf(d)) open.add(d)
590 if (state === 'edited' || state === 'editing') allFiles = undefined
591 void rebuild($)
592 startTick($)
593}
594
595function say($: any, a: Activity) {
596 activity = a
597 startTick($)
598 filetreeRedraw($)
599}
600
601// Per-character shimmer: a bright band sweeping along the name.
602const shimmer = (i: number, len: number, ramp: string[]) => {
603 const band = ((phase * 1.4) % (len + 8)) - 4
604 const d = Math.abs(i - band)
605 return ramp[d < 0.8 ? 3 : d < 1.8 ? 2 : d < 2.8 ? 1 : 0] ?? ramp[0]
606}
607
608const counts = (dir: string) => {
609 let n = 0
610 let m = 0
611 let d = 0
612 let c = 0
613 for (const [k, ch] of changes) {
614 if (!k.startsWith(`${dir}/`)) continue
615 if (ch.st === 'D') d += 1
616 else if (ch.st === 'M' || ch.st === 'T') m += 1
617 else n += 1
618 }
619 for (const k of committedSet) if (k.startsWith(`${dir}/`)) c += 1
620 return { n, m, d, c }
621}
622
623// What every drawing needs from the hook: the callbacks (they close over `$`, which cannot cross an import
624// but never leaves this file) and nothing else; state is read from the module.
625type Actions = {
626 toggle: (r: Row) => void
627 reveal: (path: string) => void
628 search: (q: string) => void
629 refresh: () => void
630 collapse: () => void
631 size: () => void
632 hidden: () => void
633 up: () => void
634 home: () => void
635 unselect: () => void
636}
637
638// A tinted row is one Claude is working on right now (or did in the last few seconds), or just committed.
639const rowState = (path: string, now: number): Touch | undefined => {
640 const t = touched.get(path)
641 return t && (now - t.at < GLOW_MS || t.state === 'reading' || t.state === 'editing') ? t : undefined
642}
643
644const nameEl = (ui: any, r: Row, name: string, t: Touch | undefined, own: Change | undefined, isSel: boolean) => {
645 const { Text } = ui
646 if (t) {
647 const fam = FAMILY[t.state]
648 return [...name].map((c, i) => <Text bold color={shimmer(i, name.length, fam.ramp)}>{c}</Text>)
649 }
650 const gc = own ? GIT_COLOR[own.st] : undefined
651 return <Text color={gc ?? (r.name.startsWith('.') ? P.muted : undefined)} bold={isSel || r.isDir} strikethrough={own?.st === 'D'}>{name}</Text>
652}
653
654const heroOf = (now: number) => {
655 const a = activity
656 if (a && (a.running || now - a.at < 6_000)) {
657 const fam = FAMILY[a.state]
658 return { glyph: a.state === 'committed' ? '⎇' : a.state === 'reading' || a.state === 'opened' ? '◉' : '✎', color: fam.fg, head: a.label, sub: a.target ? `in ${a.target}` : '' }
659 }
660 return { glyph: '◌', color: P.muted, head: 'Claude is idle', sub: a ? `last: ${a.label.toLowerCase()}` : 'nothing touched yet' }
661}
662
663const drawRich = (ui: any, e: any, a: Actions) => {
664 const { Box, Text, Button, Input } = ui
665 const now = Date.now()
666 const hero = heroOf(now)
667 const addAll = [...changes.values()].reduce((n, c) => n + c.add, 0)
668 const delAll = [...changes.values()].reduce((n, c) => n + c.del, 0)
669 const recent = [...touched.entries()].sort((x, y) => y[1].at - x[1].at)
670 const shown = list.slice(0, MAX_ROWS)
671 const btn = (key: string, label: string, onPress: () => void) => <Button key={key} label={label} onPress={onPress} />
672
673 return (
674 <Box flexDirection="column" gap={1}>
675 {panel(
676 ui,
677 <Box flexDirection="column" paddingY={1} gap={1}>
678 <Box flexDirection="row" gap={2} alignItems="center">
679 {iconTile(ui, e, hero.glyph, hero.color, 54)}
680 <Box flexDirection="column">
681 <Text bold color={hero.color}>{activity?.running || (activity && now - activity.at < 6_000) ? 'RIGHT NOW' : 'STATUS'}</Text>
682 <Text bold>{hero.head}</Text>
683 <Text color={P.muted}>{hero.sub}</Text>
684 </Box>
685 </Box>
686 <Text color="#30343c">{'─'.repeat(Math.max(10, (e.props.bodyColumns ?? 40) - 6))}</Text>
687 <Box flexDirection="row">
688 <Text color={P.info}>⎇ </Text>
689 <Text bold>{branch || base(root)}</Text>
690 {ahead > 0 ? <Text color={P.teal}>{` ↑${ahead}`}</Text> : null}
691 {behind > 0 ? <Text color={P.info}>{` ↓${behind}`}</Text> : null}
692 <Box flexGrow={1} />
693 <Text color={P.muted}>{changes.size === 0 ? (committedSet.size ? `${committedSet.size} committed, nothing uncommitted` : 'clean, nothing changed') : `${changes.size} not committed${committedSet.size ? ` · ${committedSet.size} committed` : ''}`}</Text>
694 {addAll + delAll > 0 ? <Text color={P.muted}>{' · lines '}<Text color={P.ok}>{`+${addAll}`}</Text><Text color={P.err}>{` −${delAll}`}</Text></Text> : null}
695 </Box>
696 <Box flexDirection="row" gap={2} flexWrap="wrap">
697 <Text color={FAMILY.reading.fg}>● <Text color={undefined}>Claude read</Text></Text>
698 <Text color={FAMILY.editing.fg}>● <Text color={undefined}>Claude edited</Text></Text>
699 </Box>
700 <Box flexDirection="row" gap={2} flexWrap="wrap">
701 <Text color={P.muted}>Not committed:</Text>
702 <Text color={P.ok}>● <Text color={undefined}>new</Text></Text>
703 <Text color={P.warn}>● <Text color={undefined}>modified</Text></Text>
704 <Text color={P.err}>● <Text color={undefined}>deleted</Text></Text>
705 <Text color={P.muted}>Committed, not pushed:</Text>
706 <Text color={COMMITTED}>● <Text color={undefined}>committed</Text></Text>
707 </Box>
708 </Box>,
709 { color: '#30343c' },
710 )}
711 <Box borderStyle="round" borderColor="#30343c" paddingX={1}>
712 <Input key="q" label="⌕ " placeholder="Find a file" value={query} onInput={(v: string) => a.search(v)} onSubmit={() => undefined} />
713 </Box>
714 <Box flexDirection="row" flexWrap="wrap" gap={1}>
715 {btn('refresh', 'Refresh', a.refresh)}
716 {btn('collapse', 'Collapse all', a.collapse)}
717 {btn('size', showSize ? 'Show dates' : 'Show sizes', a.size)}
718 {btn('up', 'Up a folder', a.up)}
719 {btn('home', 'Back to project', a.home)}
720 {btn('hidden', showHidden ? 'Hide hidden files' : 'Show hidden files', a.hidden)}
721 {selected ? btn('unselect', 'Clear selection', a.unselect) : null}
722 </Box>
723 {recent.length
724 ? panel(
725 ui,
726 <Box flexDirection="column" paddingY={1}>
727 {sectionLabel(ui, 'Claude touched', `${recent.length} file${recent.length > 1 ? 's' : ''}`)}
728 {recent.slice(0, 6).map(([path, t]) => (
729 <Box key={`t:${path}`} flexDirection="row" alignItems="center" gap={1} paddingY={0}>
730 {fileBadge(ui, e, base(path))}
731 <Button key={`tb:${path}`} plain onPress={() => a.reveal(path)}>
732 <Text bold>{base(path)}</Text>
733 <Text color={P.muted}>{` ${base(dirOf(path))}`}</Text>
734 </Button>
735 <Box flexGrow={1} />
736 {pill(ui, e, FAMILY[t.state].label, FAMILY[t.state].fg)}
737 </Box>
738 ))}
739 </Box>,
740 { color: '#30343c' },
741 )
742 : null}
743 {sectionLabel(ui, 'All files', base(root))}
744 <Box flexDirection="column">
745 {shown.length === 0 ? <Text color={P.muted}>{query ? 'no matches' : 'empty'}</Text> : null}
746 {shown.map(r => {
747 const t = rowState(r.path, now)
748 const own = changes.get(r.path)
749 const dc = r.isDir ? counts(r.path) : undefined
750 const dirty = dc && dc.n + dc.m + dc.d + dc.c > 0
751 const isSel = r.path === selected
752 const fam = t ? FAMILY[t.state] : undefined
753 const kids = r.isDir ? [...touched.entries()].find(([p, tt]) => p.startsWith(`${r.path}/`) && rowState(p, now)) : undefined
754 const name = query ? rel(r.path) : r.name
755 return (
756 <Box key={r.path} flexDirection="row" alignItems="center" gap={1} paddingX={1} backgroundColor={fam ? fam.bg : isSel ? P.selection : undefined}>
757 <Text>{query ? '' : ' '.repeat(r.depth)}</Text>
758 {r.isDir ? <Button key={`c:${r.path}`} plain dimColor label={open.has(r.path) ? '▾' : '▸'} onPress={() => a.toggle(r)} /> : <Text> </Text>}
759 {r.isDir ? folderIcon(ui, e, kids ? FAMILY[rowState(kids[0], now)!.state].fg : r.name.startsWith('.') ? P.muted : '#61afef') : fileBadge(ui, e, r.name)}
760 <Button key={`b:${r.path}`} plain onPress={() => a.toggle(r)}>
761 {nameEl(ui, r, name, t ?? (kids ? rowState(kids[0], now) : undefined), own, isSel)}
762 </Button>
763 <Box flexGrow={1} />
764 {t ? (
765 pill(ui, e, t.state === 'committed' ? 'committed' : FAMILY[t.state].label.toLowerCase(), fam!.fg)
766 ) : (
767 <Box flexDirection="row" gap={1}>
768 {dirty && dc.n > 0 ? <Text color={P.ok}>{`${dc.n} new`}</Text> : null}
769 {dirty && dc.m > 0 ? <Text color={P.warn}>{`${dc.m} modified`}</Text> : null}
770 {dirty && dc.d > 0 ? <Text color={P.err}>{`${dc.d} deleted`}</Text> : null}
771 {dirty && dc.c > 0 ? <Text color={COMMITTED}>{`${dc.c} committed`}</Text> : null}
772 {!own && !r.isDir && committedSet.has(r.path) ? <Text bold color={COMMITTED}>committed</Text> : null}
773 {own && own.add + own.del > 0 ? <Text color={P.muted}>{'lines '}<Text color={P.ok}>{`+${own.add}`}</Text><Text color={P.err}>{` −${own.del}`}</Text></Text> : null}
774 {own ? <Text bold color={GIT_COLOR[own.st]}>{WORD[own.st] ?? own.st}</Text> : null}
775 <Text color={P.muted}>{showSize ? (r.isDir ? '' : fmtSize(r.size)) : r.isDir ? '' : ago(now - r.mtime)}</Text>
776 </Box>
777 )}
778 </Box>
779 )
780 })}
781 </Box>
782 </Box>
783 )
784}
785
786// Terminal: dense one-line rows, the claude-code-filetree way.
787const drawText = (ui: any, e: any, a: Actions) => {
788 const { Box, Text, Button, Input } = ui
789 const now = Date.now()
790 const room = Math.max(5, (e.viewport?.rows ?? 30) - 8)
791 const idx = Math.max(0, list.findIndex(r => r.path === selected))
792 const from = Math.max(0, Math.min(idx - Math.floor(room / 2), list.length - room))
793 const act = activity && (activity.running || now - activity.at < GLOW_MS) ? activity : undefined
794 const addAll = [...changes.values()].reduce((n, c) => n + c.add, 0)
795 const delAll = [...changes.values()].reduce((n, c) => n + c.del, 0)
796 const all = counts(root)
797 const hdr = (id: string, label: string, dim: boolean, onPress: () => void) => <Button key={id} plain dimColor={dim} label={label} onPress={onPress} />
798 return (
799 <Box flexDirection="column">
800 <Box flexDirection="row">
801 <Text bold color={P.accent} wrap="truncate-start">{base(root)}</Text>
802 <Box flexGrow={1} />
803 <Box flexDirection="row" gap={1}>
804 {hdr('refresh', '↻', true, a.refresh)}
805 {hdr('size', 'Σ', !showSize, a.size)}
806 {hdr('collapse', '⊟', true, a.collapse)}
807 {hdr('up', '↑', true, a.up)}
808 {selected ? hdr('unselect', '⊘', false, a.unselect) : null}
809 <Text> </Text>
810 </Box>
811 </Box>
812 {branch ? (
813 <Box flexDirection="row">
814 <Text bold color={P.accent}>{`⎇ ${branch}`}</Text>
815 {ahead > 0 ? <Text color={P.teal}>{` ↑${ahead}`}</Text> : null}
816 {behind > 0 ? <Text color={P.info}>{` ↓${behind}`}</Text> : null}
817 <Text color={P.muted} wrap="truncate-end">{upstream ? ` ${upstream}` : ''}</Text>
818 <Box flexGrow={1} />
819 {addAll > 0 ? <Text color={P.ok}>{` +${addAll}`}</Text> : null}
820 {delAll > 0 ? <Text color={P.err}>{` -${delAll}`}</Text> : null}
821 {all.n > 0 ? <Text color={P.ok}>{` ${all.n} new`}</Text> : null}
822 {all.m > 0 ? <Text color={P.warn}>{` ${all.m} mod`}</Text> : null}
823 {all.d > 0 ? <Text color={P.err}>{` ${all.d} del`}</Text> : null}
824 {changes.size === 0 ? <Text color={P.muted}> clean</Text> : null}
825 </Box>
826 ) : null}
827 <Input key="q" label="/ " placeholder="search" value={query} onInput={(v: string) => a.search(v)} onSubmit={() => undefined} />
828 {list.length === 0 ? <Text color={P.muted}>{query ? 'no matches' : 'empty'}</Text> : null}
829 {list.slice(from, from + room).map(r => {
830 const t = rowState(r.path, now)
831 const own = changes.get(r.path)
832 const dc = r.isDir ? counts(r.path) : undefined
833 const isSel = r.path === selected
834 const gc = own ? GIT_COLOR[own.st] : undefined
835 return (
836 <Box key={r.path} flexDirection="row" height={1} overflow="hidden" backgroundColor={t ? FAMILY[t.state].bg : isSel ? P.selection : undefined}>
837 <Button key={`b:${r.path}`} plain onPress={() => a.toggle(r)}>
838 <Text>{query ? '' : ' '.repeat(r.depth)}</Text>
839 <Text color={P.muted}>{r.isDir ? (open.has(r.path) ? '▾ ' : '▸ ') : ' '}</Text>
840 <Text color={t ? FAMILY[t.state].fg : (gc ?? (r.isDir ? P.accent : P.muted))}>{r.isDir ? '■ ' : '· '}</Text>
841 {nameEl(ui, r, query ? rel(r.path) : r.name, t, own, isSel)}
842 </Button>
843 <Box flexGrow={1} />
844 <Box flexDirection="row" flexShrink={0}>
845 <Text color={P.muted}>{r.isDir ? '' : ` ${showSize ? fmtSize(r.size) : ago(now - r.mtime)}`}</Text>
846 {own && own.add > 0 ? <Text color={P.ok}>{` +${own.add}`}</Text> : null}
847 {own && own.del > 0 ? <Text color={P.err}>{` −${own.del}`}</Text> : null}
848 {dc && dc.n > 0 ? <Text color={P.ok}>{` ${dc.n} new`}</Text> : null}
849 {dc && dc.m > 0 ? <Text color={P.warn}>{` ${dc.m} mod`}</Text> : null}
850 {dc && dc.c > 0 ? <Text color={COMMITTED}>{` ${dc.c} commit`}</Text> : null}
851 {own ? <Text bold color={gc}>{` ${WORD[own.st] ?? own.st}`}</Text> : committedSet.has(r.path) ? <Text bold color={COMMITTED}> committed</Text> : <Text>{' '}</Text>}
852 </Box>
853 </Box>
854 )
855 })}
856 {selected || act ? (
857 <Box flexDirection="row">
858 <Box flexShrink={1}>
859 <Text color={P.muted} wrap="truncate-start">{selected ? `selected: ${rel(selected)}` : ''}</Text>
860 </Box>
861 <Box flexGrow={1} />
862 {act ? (
863 <Text bold={act.running} color={act.failed ? P.err : FAMILY[act.state].fg}>
864 {`${act.running ? SPIN[phase % SPIN.length] : '✓'} ${act.running ? `${act.label}…` : act.label}`}
865 </Text>
866 ) : null}
867 </Box>
868 ) : null}
869 </Box>
870 )
871}
872
873// What Claude is doing to which file: shown while it runs, kept in "Claude touched" after.
874// selected file goes to Claude as context; @path mentions reveal the file
875// A way in: as soon as Claude has read or edited a file, offer the tree on the bar above the prompt.
876
877export async function filetreeSessionStart($: any, e: any, next: any) {
878 cwd = root = await $.session.cwd()
879 await $.command.register({ name: 'filetree', description: 'Open the file tree pane' })
880 void refreshGit($)
881 void rebuild($)
882 $.clock.every(10_000, () => void refreshGit($))
883 return next(e)
884}
885
886export async function filetreeCommandRunFiletree($: any) {
887 await $.ui.open({ id: 'filetree', title: `Files: ${base(root || cwd)}` })
888 return { text: 'File tree opened.' }
889}
890
891export async function filetreeToolCall($: any, e: any, next: any) {
892 const t = String(e.tool)
893 const p = (e as { file_path?: string; path?: string }).file_path ?? (e as { path?: string }).path
894 const cmd = (e as { command?: string }).command ?? ''
895 const git = t === 'Bash' ? /\bgit\s+(commit|push|pull|merge|checkout|switch|rebase)\b/.exec(cmd)?.[1] : undefined
896 const isRead = ['Read', 'Grep', 'Glob'].includes(t)
897 const isWrite = ['Edit', 'Write', 'NotebookEdit'].includes(t)
898 if (!isRead && !isWrite && !git) return next(e)
899
900 const target = p ? base(p) : git ? `git ${git}` : t
901 const where = p ? base(dirOf(p)) : ''
902 const state: State = isRead ? 'reading' : isWrite ? 'editing' : 'committed'
903 if (p) filetreeTouch($, p, state)
904 say($, { state, label: isRead ? `Claude is reading ${target}` : isWrite ? `Claude is editing ${target}` : `Claude is running ${target}`, target: where, running: true, at: Date.now() })
905 const ran = await next(e)
906 const failed = ran.deny !== undefined || ran.isError === true
907 const doneState: State = isRead ? 'opened' : isWrite ? 'edited' : 'committed'
908 if (p && !failed) filetreeTouch($, p, doneState)
909 say($, { state: doneState, label: isRead ? `Claude read ${target}` : isWrite ? `Claude edited ${target}` : `Claude ran ${target}`, target: where, running: false, at: Date.now(), failed })
910 if (!isRead) void refreshGit($)
911 if (git === 'commit' && !failed) {
912 const shown = await $.process.run(['git', 'show', '--name-only', '--pretty=format:', 'HEAD'], { cwd: root, timeoutMs: 5000 }).catch(() => undefined)
913 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: root }).catch(() => undefined)
914 for (const f of (shown?.stdout ?? '').split('\n').filter(Boolean)) filetreeTouch($, `${top?.stdout.trim() ?? root}/${f}`, 'committed')
915 }
916 return ran
917}
918
919export function filetreePromptSubmit($: any, e: any, next: any) {
920 for (const m of e.text.matchAll(/@([\w./-]+)/g)) if (m[1].includes('/') || m[1].includes('.')) filetreeTouch($, m[1], 'opened')
921 return selected ? next({ ...e, context: [...(e.context ?? []), `The user has ${selected} selected in the file tree.`] }) : next(e)
922}
923
924export function filetreeUiRenderPaneFiletree($: any, e: any) {
925 const ui = $.ui.resolve(e)
926 const actions: Actions = {
927 toggle: r => {
928 if (r.isDir) open.has(r.path) ? open.delete(r.path) : open.add(r.path)
929 else selected = selected === r.path ? '' : r.path
930 void rebuild($)
931 },
932 reveal: path => {
933 for (let d = dirOf(path); d.length > root.length; d = dirOf(d)) open.add(d)
934 selected = path
935 void rebuild($)
936 },
937 search: q => { query = q; void rebuild($) },
938 refresh: () => { allFiles = undefined; void refreshGit($); void rebuild($) },
939 collapse: () => { open.clear(); void rebuild($) },
940 size: () => { showSize = !showSize; filetreeRedraw($) },
941 hidden: () => { showHidden = !showHidden; allFiles = undefined; void rebuild($) },
942 up: () => { root = dirOf(root) || '/'; open.clear(); allFiles = undefined; void refreshGit($); void rebuild($) },
943 home: () => { root = cwd; open.clear(); allFiles = undefined; void refreshGit($); void rebuild($) },
944 unselect: () => { selected = ''; filetreeRedraw($) },
945 }
946 return isRich(ui, e) ? drawRich(ui, e, actions) : drawText(ui, e, actions)
947}
948
949export async function filetreeUiRenderAbovePrompt($: any, e: any, next: any) {
950 if (e.props.hasSurvey || touched.size === 0 || dismissed) return next(e)
951 if ((await $.ui.panes()).some(p => p.id === 'filetree')) return next(e)
952 const now = Date.now()
953 const live = activity && (activity.running || now - activity.at < 6_000) ? activity : undefined
954 const edits = [...touched.values()].filter(t => t.state === 'edited' || t.state === 'editing').length
955 const reads = touched.size - edits
956 return band($.ui.resolve(e), e, await below(next, e), {
957 title: live ? live.label : `Claude touched ${touched.size} file${touched.size > 1 ? 's' : ''}`,
958 tone: live ? (live.state === 'editing' || live.state === 'edited' ? 'accent' : live.state === 'committed' ? 'ok' : 'purple') : 'purple',
959 busy: live?.running,
960 detail: `${reads} read · ${edits} edited`,
961 buttons: [
962 { key: 'open', label: 'Open file tree', onPress: () => $.ui.open({ id: 'filetree', title: `Files: ${base(root || cwd)}` }) },
963 { key: 'x', label: '×', plain: true, onPress: () => { dismissed = true; filetreeRedraw($) } },
964 ],
965 })
966}
967
968// ===== usage-limits =====
969const LIMIT_MS: Record<string, number> = { five_hour: 5 * 3600_000, seven_day: 7 * 86_400_000 }
970const LIMIT_SHORT: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
971
972let usageLimitRates: { kind: string; percentUsed: number; resetsAt?: string }[] = []
973let usageLimitWarned: Record<string, boolean> = {} // toast once per window until it drops back under 90%
974
975function usageLimitsSet($: any, rates: any[]) {
976 usageLimitRates = (rates ?? []).filter((r: any) => r.kind in LIMIT_MS)
977 for (const r of usageLimitRates) {
978 if (r.percentUsed >= 90 && !usageLimitWarned[r.kind]) {
979 usageLimitWarned[r.kind] = true
980 $.ui.toast(`${LIMIT_SHORT[r.kind]} limit at ${Math.round(r.percentUsed)}%`)
981 } else if (r.percentUsed < 90) usageLimitWarned[r.kind] = false
982 }
983 $.ui.invalidate('ui.render')
984}
985
986const usageLeft = (ms: number) => (ms >= 86_400_000 ? `${Math.floor(ms / 86_400_000)}d${Math.floor((ms % 86_400_000) / 3600_000)}h` : fmtDur(ms))
987
988// Burn vs time: +12% means 12 points more used than the window's elapsed share.
989const usagePace = (r: { kind: string; percentUsed: number }, ms: number) => {
990 const d = Math.round(r.percentUsed - 100 * (1 - ms / LIMIT_MS[r.kind]))
991 return `pace ${d > 0 ? '+' : ''}${d}%`
992}
993
994export async function usageLimitsSessionStart($: any, e: any, next: any) {
995 const sync = async () => usageLimitsSet($, (await $.session.usage()).rateLimits)
996 await sync()
997 $.clock.every(15_000, sync) // measure only fires on whole-point moves; this keeps the bars and countdowns fresh
998 return next(e)
999}
1000
1001export async function usageLimitsSessionMeasure($: any, e: any, next: any) {
1002 usageLimitsSet($, e.rateLimits)
1003 return next(e)
1004}
1005
1006export async function usageLimitsUiRenderAbovePrompt($: any, e: any, next: any) {
1007 if (e.props.hasSurvey || !usageLimitRates.length) return next(e)
1008 const now = await $.clock.now()
1009 const left = (r: { resetsAt?: string }) => (r.resetsAt ? Date.parse(r.resetsAt) - now : 0)
1010 const rates = [...usageLimitRates].sort((a, b) => (a.kind === 'five_hour' ? -1 : 0) - (b.kind === 'five_hour' ? -1 : 0))
1011 return band($.ui.resolve(e), e, await below(next, e), {
1012 title: 'Usage limits',
1013 tone: zoneTone(Math.max(...rates.map(r => r.percentUsed)), 70, 90),
1014 detail: rates.map(r => `${LIMIT_SHORT[r.kind]} ${left(r) > 0 ? `${usageLeft(left(r))} (${usagePace(r, left(r)).replace('pace ', '')})` : 'reset pending'}`).join(' · '),
1015 bars: rates.map(r => ({ pct: r.percentUsed, label: LIMIT_SHORT[r.kind], tone: zoneTone(r.percentUsed, 70, 90) })),
1016 })
1017}
1018
1019// ===== next-steps =====
1020const PROMPT = (answer: string) =>
1021 `Given the assistant's last reply below, suggest exactly 3 short, concrete next prompts the user would plausibly send next (max 12 words each, imperative). Reply with only a JSON array of 3 strings.\n\n<reply>\n${answer.slice(0, 6000)}\n</reply>`
1022
1023const parse = (text: string): string[] => {
1024 try {
1025 const arr = JSON.parse(text.slice(text.indexOf('['), text.lastIndexOf(']') + 1))
1026 return Array.isArray(arr) ? arr.filter((s): s is string => typeof s === 'string').slice(0, 3) : []
1027 } catch {
1028 return []
1029 }
1030}
1031
1032let steps: string[] = []
1033
1034export function nextStepsPromptSubmit($: any, e: any, next: any) {
1035 steps = []
1036 $.ui.invalidate('ui.render')
1037 return next(e)
1038}
1039
1040export async function nextStepsTurnComplete($: any, e: any, next: any) {
1041 if (e.agentId === undefined && e.reason === 'answer') {
1042 void $.model
1043 .complete({ model: 'haiku', prompt: PROMPT(e.answer), maxTokens: 200, effort: 'low', timeoutMs: 15_000 })
1044 .then(r => {
1045 steps = r.isAnswered ? parse(r.text) : []
1046 $.ui.invalidate('ui.render')
1047 })
1048 }
1049 return next(e)
1050}
1051
1052export async function nextStepsUiRenderAbovePrompt($: any, e: any, next: any) {
1053 if (e.props.hasSurvey || steps.length === 0) return next(e)
1054 return band($.ui.resolve(e), e, await below(next, e), {
1055 title: 'Next steps',
1056 tone: 'info',
1057 detail: 'click one to put it in the prompt',
1058 rows: steps.map((s, i) => ({ key: `s${i}`, label: s, hotkey: String(i + 1), onPress: () => $.prompt.fill({ text: s }) })),
1059 buttons: [{ key: 'x', label: '×', plain: true, onPress: () => { steps = []; $.ui.invalidate('ui.render') } }],
1060 })
1061}
1062
1063// Each mod is on unless its userConfig flag is false (toggle in the config menu; the module reloads).
1064export const register: Register = (on, options) => {
1065 on('session.start', ($, e, next) => {
1066 const n5 = (x: any) => next(x)
1067 const n4 = (x: any) => (options.usageLimits === false ? n5(x) : usageLimitsSessionStart($, x, n5))
1068 const n3 = (x: any) => (options.filetree === false ? n4(x) : filetreeSessionStart($, x, n4))
1069 const n2 = (x: any) => (options.contextHealth === false ? n3(x) : contextHealthSessionStart($, x, n3))
1070 const n1 = (x: any) => (options.cacheKeeper === false ? n2(x) : cacheKeeperSessionStart($, x, n2))
1071 return options.agentsProgress === false ? n1(e) : agentsProgressSessionStart($, e, n1)
1072 })
1073 if (options.agentsProgress !== false) on('command.run', { command: 'agents-info' }, agentsProgressCommandRunAgentsInfo)
1074 if (options.agentsProgress !== false) on('agent.spawn', agentsProgressAgentSpawn)
1075 on('tool.call', ($, e, next) => {
1076 const n3 = (x: any) => next(x)
1077 const n2 = (x: any) => (options.contextHealth === false ? n3(x) : contextHealthToolCall($, x, n3))
1078 const n1 = (x: any) => (options.filetree === false ? n2(x) : filetreeToolCall($, x, n2))
1079 return options.agentsProgress === false ? n1(e) : agentsProgressToolCall($, e, n1)
1080 })
1081 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1082 beginPass()
1083 const n6 = (x: any) => next(x)
1084 const n5 = (x: any) => (options.nextSteps === false ? n6(x) : nextStepsUiRenderAbovePrompt($, x, n6))
1085 const n4 = (x: any) => (options.usageLimits === false ? n5(x) : usageLimitsUiRenderAbovePrompt($, x, n5))
1086 const n3 = (x: any) => (options.filetree === false ? n4(x) : filetreeUiRenderAbovePrompt($, x, n4))
1087 const n2 = (x: any) => (options.contextHealth === false ? n3(x) : contextHealthUiRenderAbovePrompt($, x, n3))
1088 const n1 = (x: any) => (options.cacheKeeper === false ? n2(x) : cacheKeeperUiRenderAbovePrompt($, x, n2))
1089 const drawn = await (options.agentsProgress === false ? n1(e) : agentsProgressUiRenderAbovePrompt($, e, n1))
1090 if (endPass()) $.ui.invalidate('ui.render')
1091 return drawn
1092 })
1093 if (options.agentsProgress !== false) on('ui.render', { component: 'Pane', requestId: 'agents' }, agentsProgressUiRenderPaneAgents)
1094 if (options.cacheKeeper !== false) on('command.run', { command: 'keepwarm' }, cacheKeeperCommandRunKeepwarm)
1095 if (options.cacheKeeper !== false) on('command.run', { command: 'cache-tax' }, cacheKeeperCommandRunCacheTax)
1096 on('prompt.submit', ($, e, next) => {
1097 const n4 = (x: any) => next(x)
1098 const n3 = (x: any) => (options.contextHealth === false ? n4(x) : contextHealthPromptSubmit($, x, n4))
1099 const n2 = (x: any) => (options.nextSteps === false ? n3(x) : nextStepsPromptSubmit($, x, n3))
1100 const n1 = (x: any) => (options.filetree === false ? n2(x) : filetreePromptSubmit($, x, n2))
1101 return options.cacheKeeper === false ? n1(e) : cacheKeeperPromptSubmit($, e, n1)
1102 })
1103 if (options.contextHealth !== false) on('turn.start', contextHealthTurnStart)
1104 if (options.contextHealth !== false) on('turn.step', contextHealthTurnStep)
1105 on('turn.complete', ($, e, next) => {
1106 const n3 = (x: any) => next(x)
1107 const n2 = (x: any) => (options.nextSteps === false ? n3(x) : nextStepsTurnComplete($, x, n3))
1108 const n1 = (x: any) => (options.contextHealth === false ? n2(x) : contextHealthTurnComplete($, x, n2))
1109 return options.cacheKeeper === false ? n1(e) : cacheKeeperTurnComplete($, e, n1)
1110 })
1111 if (options.contextHealth !== false) on('command.run', { command: 'ctx-handoff' }, contextHealthCommandRunHandoff)
1112 if (options.contextHealth !== false) on('command.run', { command: 'autohandoff' }, contextHealthCommandRunAutohandoff)
1113 if (options.usageLimits !== false) on('session.measure', usageLimitsSessionMeasure)
1114 if (options.filetree !== false) on('command.run', { command: 'filetree' }, filetreeCommandRunFiletree)
1115 if (options.filetree !== false) on('ui.render', { component: 'Pane', requestId: 'filetree' }, filetreeUiRenderPaneFiletree)
1116}
1117hooks/band.tsx 176 lines1// Shared band and pane pieces, so every mod looks the same. Single source: scripts/sync-ui.sh copies it to plugins/*/hooks/band.tsx.
2import { bar, chip, dot, fit, hbar, isRich, P, ring, svg, svgWidth, text, tw, type Tone } from './ui'
3
4export type Btn = { key: string; label: string; onPress: () => unknown; hotkey?: string; plain?: boolean }
5export type RowBtn = { key: string; label: string; hint?: string; hotkey?: string; onPress: () => unknown }
6export type BandSpec = {
7 title: string
8 tone: Tone
9 detail?: string
10 pct?: number // 0-100: a ring (ring: true) or a pill bar
11 bars?: { pct: number; label: string; tone?: Tone }[] // several pill bars in one band (label inside the pill), instead of pct
12 pctLabel?: string // text inside the pill bar, e.g. "Review 3/4"
13 ring?: boolean
14 busy?: boolean // pulse the dot
15 chips?: string[]
16 right?: string
17 buttons?: Btn[]
18 rows?: RowBtn[] // one clickable line each, under the card
19 maxW?: number
20}
21
22const H = 36
23// Button column: as wide as the widest button set in the last pass (never under MIN_ACTION_COLS, so it matches across plugins),
24// the same in every band so the buttons line up. A render pass is `beginPass()` ... `endPass()` around the AbovePrompt chain.
25const MIN_ACTION_COLS = 13
26let actionCols = MIN_ACTION_COLS
27let needs: Record<string, number> = {}
28const widest = () => Math.max(MIN_ACTION_COLS, ...Object.values(needs))
29export const beginPass = () => {
30 actionCols = widest()
31 needs = {}
32}
33// true when this pass found a different width: the caller redraws once so every band uses it
34export const endPass = () => widest() !== actionCols
35const RIGHT_PX = 250 // fixed right region (bar, chips, side text): titles truncate at the same x in every band
36
37// The band above the prompt: one card, same anatomy in every mod. `nested` is `await below(next, e)` (the bands beneath),
38// `ui` is `$.ui.resolve(e)`: `$` never crosses an import, so the mod passes both in.
39export const band = (ui: any, e: any, nested: unknown, s: BandSpec) => {
40 const { Box, Text, Button } = ui
41 const color = P[s.tone]
42 const bars = s.bars ?? (s.pct !== undefined && !s.ring ? [{ pct: s.pct, label: s.pctLabel ?? '', tone: undefined }] : [])
43 const bs = s.buttons ?? []
44 needs[s.title] = bs.reduce((n, b) => n + b.label.length + (b.plain ? 1 : 2) + (b.hotkey ? 2 : 0) + 1, 1)
45 const ACTION_COLS = actionCols
46 const buttons = (s.buttons ?? []).map(b => <Button key={b.key} label={b.label} hotkey={b.hotkey} plain={b.plain} onPress={b.onPress} />)
47 const rows = (s.rows ?? []).map(r => (
48 <Button key={r.key} plain hotkey={r.hotkey} onPress={r.onPress}>
49 <Text color={P.muted}>{r.hotkey ? `${r.hotkey} ` : '› '}</Text>
50 <Text>{r.label}</Text>
51 <Text color={P.muted}>{r.hint ? ` ${r.hint}` : ''}</Text>
52 </Button>
53 ))
54
55 if (isRich(ui, e)) {
56 const { Svg } = ui
57 const W = Math.min(s.maxW ?? 1600, svgWidth(e.props.bodyColumns, ACTION_COLS * 8 + 24)) // every band the same width
58 let right = W - 12
59 let body = `<rect class="c" x="0.5" y="0.5" width="${W - 1}" height="${H - 1}" rx="9"/>`
60 for (const c of [...(s.chips ?? [])].reverse()) {
61 const k = chip(0, 9, c, color)
62 right -= k.w
63 body += chip(right, 9, c, color).svg
64 right -= 6
65 }
66 if (s.right) {
67 body += text(right - 2, 22, s.right, { size: 11, cls: 'm', anchor: 'end' })
68 right -= tw(s.right, 11) + 14
69 }
70 const tx = 38
71 body += s.ring && s.pct !== undefined ? ring(20, 18, 9, s.pct, color, s.busy) : dot(20, 18, color, s.busy)
72 const barW = bars.length > 1 ? 110 : bars.length ? 170 : 0
73 const hasRight = Boolean(bars.length || s.chips?.length || s.right)
74 const textW = Math.max(120, W - tx - 16 - (bars.length > 1 ? bars.length * (barW + 56) + 10 : hasRight ? RIGHT_PX : 0))
75 body += text(tx, s.detail ? 16 : 22, fit(s.title, textW, 13), { size: 13, weight: 600 })
76 if (s.detail) body += text(tx, 29, fit(s.detail, textW, 11), { size: 11, cls: 'm' })
77 for (const b of [...bars].reverse()) {
78 // pill bar with its label inside, as in savvy-progress, then the percent
79 const bc = b.tone ? P[b.tone] : color
80 const bx = right - barW - 44
81 const fillW = Math.max(26, (Math.min(100, b.pct) / 100) * barW)
82 body += `<defs><clipPath id="pb${bx}"><rect x="${bx}" y="9" width="${barW}" height="18" rx="9"/></clipPath></defs><rect class="k" x="${bx}" y="9" width="${barW}" height="18" rx="9"/><g clip-path="url(#pb${bx})"><rect${s.busy ? ' class="live"' : ''} x="${bx}" y="9" width="${fillW}" height="18" rx="9" fill="${bc}"/></g>`
83 if (b.label) body += text(bx + fillW / 2, 22, fit(b.label, fillW - 8, 11), { size: 11, weight: 700, anchor: 'middle', fill: '#1b1d22' })
84 body += text(bx + barW + 8, 22, `${Math.round(b.pct)}%`, { size: 12, cls: 'm' })
85 right = bx - 12
86 }
87 return (
88 <Box flexDirection="column">
89 <Box flexDirection="row" alignItems="center" gap={1}>
90 <Svg source={svg(W, H, body)} alt={[s.title, s.detail].filter(Boolean).join(': ')} width={W} height={H} />
91 <Box width={ACTION_COLS} flexDirection="row" alignItems="center" gap={1}>
92 {buttons}
93 </Box>
94 </Box>
95 {rows}
96 {nested}
97 </Box>
98 )
99 }
100
101 return (
102 <Box flexDirection="column">
103 <Box flexDirection="row" gap={1}>
104 <Text color={color} bold>● {s.title}</Text>
105 {bars.map(b => (
106 <Text key={b.label || 'bar'} color={b.tone ? P[b.tone] : color}>{b.label ? `${b.label} ` : ''}{hbar(b.pct, 10)} {Math.round(b.pct)}%</Text>
107 ))}
108 {s.detail && <Text color={P.muted}>{s.detail}</Text>}
109 {(s.chips ?? []).map(c => (
110 <Text key={c} color={color}>[{c}]</Text>
111 ))}
112 {s.right && <Text color={P.muted}>{s.right}</Text>}
113 {buttons}
114 </Box>
115 {rows}
116 {nested}
117 </Box>
118 )
119}
120
121// Pane header: title, a muted subtitle, an optional bar on the right.
122export const paneHeader = (ui: any, e: any, o: { title: string; sub?: string; pct?: number; tone?: Tone; right?: string }) => {
123 const { Box, Text } = ui
124 const color = P[o.tone ?? 'accent']
125 if (isRich(ui, e)) {
126 const { Svg } = ui
127 const W = svgWidth(e.props.bodyColumns, 24)
128 let body = text(2, 17, o.title, { size: 15, weight: 600 })
129 if (o.sub) body += text(2, 33, fit(o.sub, W - 160, 11.5), { size: 11.5, cls: 'm' })
130 if (o.pct !== undefined) body += bar(W - 120, 12, 118, 6, o.pct, color)
131 if (o.right) body += text(W - 2, 33, o.right, { size: 11, cls: 'm', anchor: 'end' })
132 return <Svg source={svg(W, 40, body)} alt={`${o.title}${o.sub ? `: ${o.sub}` : ''}`} width={W} height={40} />
133 }
134 return (
135 <Box flexDirection="row" gap={1}>
136 <Text bold color={color}>{o.title}</Text>
137 <Text color={P.muted}>{o.sub ?? ''}</Text>
138 </Box>
139 )
140}
141
142const GLYPH: Record<Tone, string> = { accent: '●', ok: '✓', warn: '◐', err: '✗', info: '●', purple: '●' }
143
144// One card in a pane: status dot, title, muted line, a chip or text on the right, optional bar (undefined pct = sliding).
145export const item = (ui: any, e: any, o: { key: string; tone: Tone; title: string; sub?: string; subColor?: string; chip?: string; meta?: string; avatar?: string; pct?: number | 'busy'; busy?: boolean }) => {
146 const { Box, Text } = ui
147 const color = P[o.tone]
148 if (isRich(ui, e)) {
149 const { Svg } = ui
150 const W = svgWidth(e.props.bodyColumns, 24)
151 const rowH = o.pct !== undefined ? 64 : 48
152 const ax = o.avatar ? 50 : 0 // room for the avatar tile
153 let body = `<rect class="c" x="0.5" y="0.5" width="${W - 1}" height="${rowH - 1}" rx="10"/>`
154 if (o.avatar) body += `<rect x="10" y="8" width="34" height="34" rx="10" fill="${color}" fill-opacity=".18"/>` + text(27, 31, o.avatar, { size: 15, weight: 700, anchor: 'middle', fill: color })
155 else body += `<rect x="0.5" y="6" width="3.5" height="${rowH - 12}" rx="1.75" fill="${color}"/>${dot(20, 18, color, o.busy)}`
156 const tx = o.avatar ? ax + 8 : 32
157 let right = W - 14
158 if (o.chip) {
159 const k = chip(0, 8, o.chip, color)
160 right -= k.w
161 body += chip(right, 8, o.chip, color).svg
162 }
163 if (o.meta) body += text(W - 14, 38, o.meta, { size: 10.5, cls: 'm', anchor: 'end' })
164 body += text(tx, 22, fit(o.title, right - tx - 8, 13), { size: 13, weight: 600 })
165 if (o.sub) body += text(tx, 38, fit(o.sub, W - tx - (o.meta ? tw(o.meta, 10.5) + 30 : 20), 11), { size: 11, ...(o.subColor ? { fill: o.subColor } : { cls: 'm' as const }) })
166 if (o.pct !== undefined) body += bar(tx, 50, W - tx - 14, 4, o.pct === 'busy' ? undefined : o.pct, color)
167 return <Svg key={o.key} source={svg(W, rowH, body)} alt={`${o.title}${o.sub ? `: ${o.sub}` : ''}`} width={W} height={rowH} />
168 }
169 return (
170 <Box key={o.key} flexDirection="column">
171 <Text color={color}>{GLYPH[o.tone]} {o.title}<Text color={P.muted}>{o.chip ? ` ${o.chip}` : ''}</Text></Text>
172 <Text color={P.muted}>{o.sub ? ` ${o.sub}` : ''}</Text>
173 </Box>
174 )
175}
176hooks/kit.tsx 93 lines1// Card pieces shared by the pane mods: pills, file badges, folder icons, stat tiles, icon tiles, section labels.
2// Single source: scripts/sync-ui.sh copies it to plugins/*/hooks/kit.tsx. `$` never crosses an import, so every
3// piece takes `ui` (= $.ui.resolve(e)) and `e`.
4import { esc, FONT, isRich, P, svg, svgBare, text, tw } from './ui'
5
6const EXT: Record<string, [string, string]> = {
7 js: ['JS', '#e5c07b'], jsx: ['JSX', '#e5c07b'], mjs: ['JS', '#e5c07b'], ts: ['TS', '#61afef'], tsx: ['TSX', '#61afef'],
8 json: ['JSON', '#e5c07b'], md: ['MD', '#8aa0b8'], pdf: ['PDF', '#e06c75'], csv: ['CSV', '#98c379'], py: ['PY', '#98c379'],
9 sh: ['SH', '#d19a66'], html: ['HTML', '#e06c75'], css: ['CSS', '#c678dd'], yml: ['YML', '#c678dd'], yaml: ['YML', '#c678dd'],
10 go: ['GO', '#56b6c2'], rs: ['RS', '#d19a66'], txt: ['TXT', P.muted], png: ['PNG', '#56b6c2'], jpg: ['JPG', '#56b6c2'],
11 svg: ['SVG', '#56b6c2'], map: ['MAP', P.muted], lock: ['LOCK', P.muted], toml: ['TOML', '#d19a66'],
12}
13
14// "client.test.js" -> ["JS", colour]; no extension -> a plain file mark.
15export const extBadge = (name: string): [string, string] => {
16 const dot = name.lastIndexOf('.')
17 const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : name.startsWith('.') ? name.slice(1).toLowerCase() : ''
18 return EXT[ext] ?? [ext ? ext.slice(0, 4).toUpperCase() : 'FILE', P.muted]
19}
20
21const badgeCache = new Map<string, string>()
22const badgeSvg = (label: string, color: string) => {
23 const k = `${label}${color}`
24 let s = badgeCache.get(k)
25 if (!s) {
26 const W = Math.max(24, Math.ceil(tw(label, 8.5)) + 10)
27 s = svgBare(W, 16, `<rect x="0.5" y="0.5" width="${W - 1}" height="15" rx="4" fill="${color}" fill-opacity=".16" stroke="${color}" stroke-opacity=".45"/>${text(W / 2, 11.5, label, { size: 8.5, weight: 700, anchor: 'middle', fill: color })}`)
28 badgeCache.set(k, s)
29 }
30 return { s, W: Math.max(24, Math.ceil(tw(label, 8.5)) + 10) }
31}
32
33export const fileBadge = (ui: any, e: any, name: string) => {
34 const [label, color] = extBadge(name)
35 if (!isRich(ui, e)) return <ui.Text color={color}>{`${label} `}</ui.Text>
36 const b = badgeSvg(label, color)
37 return <ui.Svg source={b.s} alt={label} width={b.W} height={16} />
38}
39
40const FOLDER = (color: string) => svgBare(18, 16, `<path d="M1.5 3.5h5l1.6 1.8h8.4v8.2a1 1 0 0 1-1 1h-13a1 1 0 0 1-1-1z" fill="none" stroke="${color}" stroke-width="1.4" stroke-linejoin="round"/>`)
41export const folderIcon = (ui: any, e: any, color: string = '#61afef') =>
42 isRich(ui, e) ? <ui.Svg source={FOLDER(color)} alt="folder" width={18} height={16} /> : <ui.Text color={color}>{'■ '}</ui.Text>
43
44// A small outlined pill: "Reading", "Edited", "committed".
45export const pill = (ui: any, e: any, label: string, color: string) => {
46 if (!isRich(ui, e)) return <ui.Text color={color} bold>{` ${label} `}</ui.Text>
47 const W = Math.ceil(tw(label, 11)) + 20
48 const body = `<rect x="0.5" y="0.5" width="${W - 1}" height="21" rx="11" fill="${color}" fill-opacity=".16" stroke="${color}" stroke-opacity=".35"/>${text(W / 2, 15, label, { size: 11, weight: 700, anchor: 'middle', fill: color })}`
49 return <ui.Svg source={svgBare(W, 22, body)} alt={label} width={W} height={22} />
50}
51
52// Rounded tile with a glyph, the "RIGHT NOW" / trash icon.
53export const iconTile = (ui: any, e: any, glyph: string, color: string, size = 48) => {
54 if (!isRich(ui, e)) return <ui.Text color={color} bold>{`${glyph} `}</ui.Text>
55 const body = `<rect width="${size}" height="${size}" rx="${size * 0.3}" fill="${color}" fill-opacity=".16"/><text x="${size / 2}" y="${size * 0.65}" text-anchor="middle" font-family="${FONT}" font-size="${size * 0.46}" fill="${color}">${esc(glyph)}</text>`
56 return <ui.Svg source={svgBare(size, size, body)} alt="" width={size} height={size} />
57}
58
59// Stat tile: a muted label over a bold value (Cost, Tokens, Time). Themed, so it uses the CSS-carrying svg().
60export const tile = (ui: any, e: any, label: string, value: string, W = 118) => {
61 if (!isRich(ui, e)) return <ui.Text>{`${label} `}<ui.Text bold>{value}</ui.Text></ui.Text>
62 const body = `<rect class="c" x="0.5" y="0.5" width="${W - 1}" height="45" rx="9"/>${text(12, 18, label, { size: 11, cls: 'm' })}${text(12, 37, value, { size: 15, weight: 700 })}`
63 return <ui.Svg source={svg(W, 46, body)} alt={`${label} ${value}`} width={W} height={46} />
64}
65
66// "CLAUDE TOUCHED 1 FILE": small caps label left, muted count right.
67export const sectionLabel = (ui: any, left: string, right?: string) => (
68 <ui.Box flexDirection="row">
69 <ui.Text bold color={P.muted}>{left.toUpperCase()}</ui.Text>
70 <ui.Box flexGrow={1} />
71 <ui.Text bold color={P.muted}>{right ? right.toUpperCase() : ''}</ui.Text>
72 </ui.Box>
73)
74
75// Rounded bordered panel.
76export const panel = (ui: any, children: unknown, o: { color?: string; bg?: string } = {}) => (
77 <ui.Box flexDirection="column" borderStyle="round" borderColor={o.color ?? '#30343c'} backgroundColor={o.bg} paddingX={1}>
78 {children}
79 </ui.Box>
80)
81
82// A card whose text wraps in full (no truncation): a coloured stripe on the left, text, optional muted line and actions.
83export const textCard = (ui: any, o: { key: string; color: string; text: string; sub?: string; children?: unknown }) => (
84 <ui.Box key={o.key} flexDirection="row" borderStyle="round" borderColor="#30343c" backgroundColor="#212226">
85 <ui.Box width={1} backgroundColor={o.color} />
86 <ui.Box flexDirection="column" paddingX={1} flexGrow={1} flexShrink={1}>
87 <ui.Text wrap="wrap">{o.text}</ui.Text>
88 {o.sub ? <ui.Text wrap="wrap" color={P.muted}>{o.sub}</ui.Text> : null}
89 {o.children}
90 </ui.Box>
91 </ui.Box>
92)
93hooks/ui.ts 131 lines1// Shared drawing kit. Single source: scripts/sync-ui.sh copies it to plugins/*/hooks/ui.ts.
2// Desktop draws Svg (animated, themed through prefers-color-scheme); the terminal gets text.
3
4// One palette for every mod (the filetree and savvy-progress look: One Dark accents on a dark surface).
5export const P = {
6 accent: '#d97757', ok: '#98c379', warn: '#e5c07b', err: '#e06c75', info: '#61afef', purple: '#c084fc', teal: '#2dd4bf',
7 muted: '#8b8f98', selection: '#2c313a', hover: '#232831',
8}
9export type Tone = 'accent' | 'ok' | 'warn' | 'err' | 'info' | 'purple'
10export const C = P // svg and text read the same colours
11export const T = P
12
13// Shimmer ramps, dim to bright, from claude-code-filetree.
14export const RAMP: Record<string, string[]> = {
15 orange: ['#f97316', '#fb923c', '#fdba74', '#ffedd5'],
16 green: ['#22c55e', '#4ade80', '#86efac', '#dcfce7'],
17 purple: ['#a855f7', '#c084fc', '#d8b4fe', '#f3e8ff'],
18 blue: ['#3b82f6', '#60a5fa', '#93c5fd', '#dbeafe'],
19 red: ['#ef4444', '#f87171', '#fca5a5', '#fee2e2'],
20}
21
22export const FONT = "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif"
23
24const CSS = `<style>
25.t{fill:#1f1f1f}.m{fill:#6f6f6f}.k{fill:#e4e4e2}.c{fill:#f4f4f2;stroke:#dcdcd8}
26@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.c{fill:#262625;stroke:#3a3a38}}
27.live{animation:pulse 1.6s ease-in-out infinite}.slide{animation:slide 1.5s linear infinite}
28@keyframes pulse{50%{opacity:.3}}@keyframes slide{from{transform:translateX(-45%)}to{transform:translateX(100%)}}
29@media (prefers-reduced-motion: reduce){.live,.slide{animation:none}}
30</style>`
31
32export const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] as string)
33
34// Rough text width in px: CJK and emoji advance about 1em, the rest about 0.58em.
35export const tw = (s: string, size: number) => [...s].reduce((w, ch) => w + (ch.charCodeAt(0) > 0x2e7f ? size : size * 0.58), 0)
36
37export const fit = (s: string, px: number, size: number) => {
38 if (tw(s, size) <= px) return s
39 let out = ''
40 for (const ch of s) {
41 if (tw(out + ch + '…', size) > px) break
42 out += ch
43 }
44 return `${out}…`
45}
46
47export const fmtTok = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${n}`)
48export const fmtCost = (usd: number) => (usd < 0.01 ? '<$0.01' : usd < 10 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`)
49export const fmtDur = (ms: number) => {
50 const s = Math.max(0, Math.round(ms / 1000))
51 return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
52}
53
54// Green below `warnAt`, amber below `errAt`, red above (percent scales).
55export const zone = (pct: number, warnAt = 60, errAt = 85) => (pct >= errAt ? P.err : pct >= warnAt ? P.warn : P.ok)
56export const zoneT = zone
57export const zoneTone = (pct: number, warnAt = 60, errAt = 85): Tone => (pct >= errAt ? 'err' : pct >= warnAt ? 'warn' : 'ok')
58
59// ---- terminal text ----
60const EIGHTHS = ' ▏▎▍▌▋▊▉█'
61export const hbar = (pct: number, width: number) => {
62 const cells = (Math.max(0, Math.min(100, pct)) / 100) * width
63 const full = Math.floor(cells)
64 const part = EIGHTHS[Math.round((cells - full) * 8)] ?? ''
65 return ('█'.repeat(full) + (full < width ? part.trim() : '')).padEnd(width, '░').slice(0, width)
66}
67export const spark = (vs: number[]) => {
68 const max = Math.max(...vs, 1)
69 return vs.map(v => '▁▂▃▄▅▆▇█'[Math.min(7, Math.floor((v / max) * 7.99))]).join('')
70}
71
72// ---- svg ----
73let uid = 0
74export const svg = (W: number, H: number, body: string) =>
75 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${CSS}${body}</svg>`
76
77type TextOpts = { size?: number; cls?: 't' | 'm'; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string }
78export const text = (x: number, y: number, s: string, o: TextOpts = {}) =>
79 `<text x="${x}" y="${y}" ${o.fill ? `fill="${o.fill}"` : `class="${o.cls ?? 't'}"`} text-anchor="${o.anchor ?? 'start'}" font-family="${FONT}" font-size="${o.size ?? 12.5}" font-weight="${o.weight ?? 400}" font-variant-numeric="tabular-nums">${esc(s)}</text>`
80
81// Rounded track with a fill. `pct` undefined = indeterminate (a bar sliding across while live).
82export const bar = (x: number, y: number, w: number, h: number, pct: number | undefined, color: string, opts: { live?: boolean; ticks?: number[] } = {}) => {
83 const id = `b${++uid}`
84 const r = h / 2
85 const fill =
86 pct === undefined
87 ? `<rect class="slide" x="${x}" y="${y}" width="${w * 0.4}" height="${h}" rx="${r}" fill="${color}"/>`
88 : `<rect${opts.live ? ' class="live"' : ''} x="${x}" y="${y}" width="${(Math.max(0, Math.min(100, pct)) / 100) * w}" height="${h}" rx="${r}" fill="${color}"/>`
89 const ticks = (opts.ticks ?? []).map(p => `<rect x="${x + (p / 100) * w - 0.5}" y="${y - 2}" width="1" height="${h + 4}" class="m" opacity=".6"/>`).join('')
90 return `<defs><clipPath id="${id}"><rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${r}"/></clipPath></defs><rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${r}"/><g clip-path="url(#${id})">${fill}</g>${ticks}`
91}
92
93export const dot = (cx: number, cy: number, color: string, live = false) => `<circle${live ? ' class="live"' : ''} cx="${cx}" cy="${cy}" r="4" fill="${color}"/>`
94
95// A filled pill with white text; returns its width via `w` so rows can chain chips.
96export const chip = (x: number, y: number, s: string, color: string, size = 11) => {
97 const w = Math.ceil(tw(s, size)) + 14
98 return { w, svg: `<rect x="${x}" y="${y}" width="${w}" height="18" rx="9" fill="${color}"/>${text(x + w / 2, y + 12.5, s, { size, weight: 600, anchor: 'middle', fill: '#fff' })}` }
99}
100
101// Ring gauge, `pct` of the circle filled clockwise from the top.
102export const ring = (cx: number, cy: number, r: number, pct: number, color: string, live = false) => {
103 const c = 2 * Math.PI * r
104 const f = (Math.max(0, Math.min(100, pct)) / 100) * c
105 return `<circle class="k" cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke-width="4" style="stroke:currentColor;opacity:.15"/><circle${live ? ' class="live"' : ''} cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${color}" stroke-width="4" stroke-linecap="round" stroke-dasharray="${f} ${c}" transform="rotate(-90 ${cx} ${cy})"/>`
106}
107
108// Svg px width for a band/pane row: about 8px per column, minus room for buttons.
109export const svgWidth = (columns: number | undefined, reserve = 96) => Math.max(180, Math.min(1600, (columns || 100) * 8 - reserve))
110
111// The terminal's element table carries an Svg stub that draws nothing: test the surface too.
112export const isRich = (ui: object, e: { surface: string }) => e.surface !== 'terminal' && 'Svg' in ui
113
114// AbovePrompt is one strip shared by every mod: a hook that returns a tree hides the mods beneath it
115// unless it nests what `next(e)` drew. Call this first, put the result in your column. Claude Code's
116// own placeholder ({ type: 'engine' }) is never nested: the desktop drops a band that nests it.
117export const below = async (next: (e: any) => unknown, e: unknown) => {
118 const drawn = await Promise.resolve(next(e)).catch(() => null)
119 return drawn && typeof drawn === 'object' && (drawn as { type?: string }).type !== 'engine' ? drawn : null
120}
121
122// Age in a few characters, as the filetree draws it: now, 12m, 3h, 5d.
123export const age = (ms: number) => {
124 const m = Math.floor(ms / 60_000)
125 return m < 1 ? 'now' : m < 60 ? `${m}m` : m < 1440 ? `${Math.floor(m / 60)}h` : `${Math.floor(m / 1440)}d`
126}
127
128// Static shapes need no theme CSS: skip it, hundreds of rows would carry it each.
129export const svgBare = (W: number, H: number, body: string) =>
130 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${body}</svg>`
131