SLOPSHOPPER

cockpit

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

newpanebandguardcommandtoast
v0.1.15MITupdated 2026-10-15bharath-gp/ai-harness/plugins/cockpit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cockpit
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ Subagents │ ┃ ⏺ Read(src/auth.ts) │ ┃ Cost ≈<$0.01 Context 0 Time 0:00 ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ No subagents yet. They appear here the ⎿ Added 2 lines, removed 1 line │ ┃ moment Claude spawns one. ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /agents-info │ ⎿ cockpit: Agents panel opened. │ │ ● Context 49% 97k of 200k tokens [turn 1] [<$0.01 · $0.42] [auto @50%] 5h 31% × ● Usage limits 5h ███▏░░░░░░ 31% 5h reset pending ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● Context 49% 97k of 200k tokens [turn 1] [<$0.01 · $0.42] [auto @50%] 5h 31% × ● Usage limits 5h ███▏░░░░░░ 31% 5h reset pending
Pane · Agents
Subagents Cost ≈<$0.01 Context 0 Time 0:00 No subagents yet. They appear here the moment Claude spawns one.
Pane · Files: app
app↻ Σ ⊟ ↑ / : search b:/work/app/package.json NaNd ago b:/work/app/src NaNd ago b:/work/app/README.md NaNd ago ✓ Claude ran git push
README

ai-harness

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.

Install

/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.

BundleModWhat it does
guardrailsblast-radiusAsks before destructive shell commands run
secret-sentryBlocks API keys and private keys from being written into files
reflectSpots corrections in your prompts and saves them as rules in CLAUDE.md
assumption-trackerLists 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
cockpitagents-progressLive subagent panel and a running-agents band above the prompt
cache-keeperCache countdown band (shown in the last 5 minutes or when cold), /keepwarm pings and a cold-send guard with cost estimate
context-healthContext fill band with warnings, /ctx-handoff and /autohandoff
usage-limitsAlways-on band: session (5h) and weekly (7d) limit usage, reset countdown, pace
filetreeFile tree pane with git status and highlights for files Claude reads and writes
next-stepsSuggests three next prompts after each turn

Other coding agents

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.

License

MIT, see LICENSE. Security reports: SECURITY.md.

Develop

./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.

Source 4 files
hooks/register.tsx 1117 lines
1import 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}
1117
hooks/band.tsx 176 lines
1// 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}
176
hooks/kit.tsx 93 lines
1// 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)
93
hooks/ui.ts 131 lines
1// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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