claudebar as a mod: an interactive band with git, model, effort, context, rate limits, cache, tools and agents, plus detail panes, alerts and history

claudebar rebuilt as a Claude Code mod. It shows the same six lines, now in an interactive band above the prompt that keeps itself up to date, and adds detail panes, alerts and history.
────────────────────────────────────────────────────────────────────────────────────
my-app (main mod:3 ahead:2) +47 -12 last: feat(auth): refresh token rotation [ Git › ]
Opus 5.5 ⚙ high ctx ▓▓▓▓▓▓▓░ 88% ←150kt →52kt [ ⚠ COMPACT now ] [ Context › ]
5h ▓▓░░░░░░ 28% (1h 56m) 7d ▓▓▓░░░░░ 38% (2d 23h)
⏱ 1h 8m cache ▓▓▓▓▓▓▓░ 90% edited +308 -66 $1.50 [ History › ]
Tools: Read×38 Edit×17 Bash×9 [ Tools › ]
Agents: code-reviewer×2 Explore×1 ● 1 active Skills: commit feature-dev [ Agents › ]
Requires Claude Code with mod support (tested on the latest version).
From a Claude Code session in the terminal:
/plugin install claudebar-mod --marketplace visiblesoft-es/claude-mods
Answer y to add the marketplace and pick a scope (the user scope is the first one). No jq, no /claudebar:setup, no restart: it is active as soon as it is installed.
Or from your shell:
claude plugin marketplace add visiblesoft-es/claude-mods
claude plugin install claudebar-mod@claude-mods
To try it from a local clone, without installing:
git clone https://github.com/visiblesoft-es/claude-mods.git
claude --plugin-dir claude-mods/claudebar-mod
To update or uninstall, see the repository README.
If you also have the bash claudebar set up as your statusLine, you will see both bars. Remove the bash one by deleting statusLine from ~/.claude/settings.json, or keep it and hide lines in the mod (see Configuration).
The same lines and the same color thresholds as the bash claudebar. Each line is dropped when it has nothing to show.
mod:N, ahead:N, +N -N against HEAD, last commit subject and [worktree: name].⚙ effort, a context bar with ← input tokens and → session output tokens, and the compact hint with the usual score (context, cache below 60% and sessions over 90 minutes; silent below 30% context).⚡ /compact or ⚠ COMPACT, it turns into a button that compacts the conversation.Context ›, Git ›, Tools ›, Agents › and History › buttons, or /claudebar [view]), with tabs:/claudebar takes the view to open: context, tools, git, agents or history.
In /config, each line can be hidden (showGit, showModel, showLimits, showStats, showTools, showAgents), and so can the dim rule that separates the band from the conversation (showSeparator); alerts can be turned off (alerts).
statusLine slot (below the prompt), so the band sits above it. In fullscreen it shares at most half the terminal with the prompt; if it does not fit, it scrolls.hooks/register.tsx everything that talks to the engine: events, band and pane
hooks/lib/ pure functions: formatting, git parsing, agents, robots
types/index.d.ts the mod's state contract
tests/ format.test.ts (formatting and git), robot.test.ts, band.test.tsx (band and pane on terminal and desktop)
Everything that receives $ has to live in register.tsx: Claude Code's validator does not let $ be passed to a function imported from another file.
claude plugin validate .
claude plugin test .hooks/register.tsx 890 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register, RenderElement, RenderInput, Timer } from 'claude-code'
3
4import type { Detail, DayStats, Doll, Stats, UsageSnap, View } from '../types'
5import { MAIN, STATUS, toDoll, toLines } from './lib/agents'
6import {
7 bar, cacheColor, cacheRate, clip, compactHint, countdown, duration, effortColor, kt, lineCount, modelName,
8 plural, today, topCounts, usageColor,
9} from './lib/format'
10import { basename, parseNumstat, parseStatus } from './lib/git'
11import { miniRobot, robot } from './lib/robot'
12import { EMPTY_STATS, same } from './lib/state'
13import type { GitInfo } from '../types'
14
15// $ may only be passed to functions declared in this file, so everything that
16// talks to the engine lives here; lib/ holds the pure parts (formatting, parsing).
17
18// ── state ──
19
20const PANE = 'claudebar'
21
22const git = atom({ plugin: 'claudebar-mod', key: 'git' } as const, null)
23const usage = atom({ plugin: 'claudebar-mod', key: 'usage' } as const, null)
24const stats = atom({ plugin: 'claudebar-mod', key: 'stats' } as const, EMPTY_STATS)
25const view = atom({ plugin: 'claudebar-mod', key: 'view' } as const, 'context')
26const isMainBusy = atom({ plugin: 'claudebar-mod', key: 'isMainBusy' } as const, false)
27const dolls = atom({ plugin: 'claudebar-mod', key: 'dolls' } as const, [])
28const selected = atom({ plugin: 'claudebar-mod', key: 'selected' } as const, null)
29const detail = atom({ plugin: 'claudebar-mod', key: 'detail' } as const, null)
30const history = atom({ plugin: 'claudebar-mod', key: 'history' } as const, [])
31const alerted = atom({ plugin: 'claudebar-mod', key: 'alerted' } as const, [])
32const frame = atom({ plugin: 'claudebar-mod', key: 'frame' } as const, 0)
33
34// ── git ──
35
36async function runGit($: EngineInterface, cwd: string, args: string[]): Promise<string | null> {
37 try {
38 const r = await $.process.run(['git', '-C', cwd, ...args], { timeoutMs: 3000 })
39 return r.exitCode === 0 ? r.stdout : null
40 } catch {
41 return null
42 }
43}
44
45async function readGit($: EngineInterface): Promise<GitInfo> {
46 const cwd = await $.session.cwd()
47 const dir = basename(cwd)
48 const status = await runGit($, cwd, ['status', '--porcelain=v1', '--branch'])
49 if (status === null) {
50 return {
51 dir, isRepo: false, branch: null, isDirty: false, modified: 0, ahead: 0, added: 0,
52 removed: 0, lastSubject: null, worktree: null, files: [], commits: [],
53 }
54 }
55 const [numstat, log, dirs] = await Promise.all([
56 runGit($, cwd, ['diff', 'HEAD', '--numstat']),
57 runGit($, cwd, ['log', '-5', '--format=%h %s']),
58 runGit($, cwd, ['rev-parse', '--show-toplevel', '--git-dir', '--git-common-dir']),
59 ])
60 const { branch, ahead, files } = parseStatus(status)
61 const { added, removed } = parseNumstat(numstat ?? '')
62 const commits = (log ?? '').split('\n').filter(Boolean)
63 const [top, gitDir, commonDir] = (dirs ?? '').split('\n')
64 const isWorktree = !!gitDir && !!commonDir && gitDir !== commonDir && gitDir.includes('/worktrees/')
65
66 return {
67 dir,
68 isRepo: true,
69 branch,
70 isDirty: files.length > 0 || ahead > 0,
71 modified: files.length,
72 ahead,
73 added,
74 removed,
75 lastSubject: commits[0]?.replace(/^\S+ /, '') ?? null,
76 worktree: isWorktree && top ? basename(top) : null,
77 files,
78 commits,
79 }
80}
81
82// ── agents ──
83
84async function refreshAgents($: EngineInterface) {
85 const next = (await $.agent.list()).map(toDoll)
86 if (!same(next, await read($, dolls))) await update($, dolls, () => next)
87 // Only a working robot animates, so only then is it worth a redraw.
88 const isAnyWorking = (await read($, isMainBusy)) || next.some(d => d.status === 'running')
89 if (isAnyWorking) await update($, frame, n => n + 1)
90
91 const id = await read($, selected)
92 if (id === null || id === MAIN) return
93 const answer = await $.session.messages({ agentId: id })
94 const fresh: Detail = Array.isArray(answer)
95 ? { agentId: id, lines: toLines(answer) }
96 : { agentId: id, lines: (await read($, detail))?.lines ?? [], deny: answer.deny }
97 if (!same(fresh, await read($, detail))) await update($, detail, () => fresh)
98}
99
100async function selectAgent($: EngineInterface, doll: Doll | null) {
101 const isSame = doll !== null && (await read($, selected)) === doll.id
102 if (doll === null || isSame) {
103 await update($, selected, () => MAIN)
104 await update($, detail, () => null)
105 return
106 }
107 await update($, selected, () => doll.id)
108 await update($, detail, () => ({ agentId: doll.id, lines: [] }))
109 await refreshAgents($)
110}
111
112// ── band ──
113
114
115// The press must await the open: a pane opened after the press has settled
116// counts as opened unasked, and on a terminal under 144 columns it waits undrawn.
117async function openPane($: EngineInterface, next: View) {
118 const opened = await $.ui.open({ id: PANE, title: 'claudebar', focus: true })
119 await update($, view, () => next)
120 if (!opened.isPlaced) {
121 $.ui.toast(`The claudebar pane is open but cannot be shown: ${opened.reason}`, { timeoutMs: 8000 })
122 return
123 }
124 const pane = (await $.ui.panes()).find(p => p.id === PANE)
125 if (pane && !pane.isShown) {
126 $.ui.toast('The claudebar pane is open as a tab behind another pane: press its tab or ctrl+x tab.', { timeoutMs: 8000 })
127 }
128}
129
130async function compactNow($: EngineInterface) {
131 $.ui.toast('Compacting the conversation…')
132 const result = await $.session.compact()
133 if ('skip' in result && result.skip) $.ui.toast(`Not compacted: ${result.skip}`)
134}
135
136const LIMIT_LABELS: Record<string, string> = {
137 five_hour: '5h',
138 seven_day: '7d',
139 seven_day_opus: '7d opus',
140 seven_day_sonnet: '7d sonnet',
141}
142
143async function renderBand($: EngineInterface, e: RenderInput<'AbovePrompt'>, options: PluginOptions, below: RenderElement) {
144 const { Box, Text, Button } = $.ui.resolve(e)
145 const g = await read($, git)
146 const u = await read($, usage)
147 const s = await read($, stats)
148 const agentList = await read($, dolls)
149 const columns = e.props.bodyColumns
150 const rows: RenderElement[] = []
151 const now = u?.now ?? 0
152 const hit = cacheRate(s.lastTurn)
153 const link = (key: string, label: string, onPress: () => unknown) => (
154 <Button key={key} label={label} onPress={() => onPress()} />
155 )
156
157 // Line 1: directory, branch and changes.
158 if (options.showGit !== false && g) {
159 const branchColor = g.isDirty ? 'yellow' : 'green'
160 const used = g.dir.length + (g.branch?.length ?? 0) + 40
161 rows.push(
162 <Box key="l-git" flexDirection="row" gap={1}>
163 <Text bold>{g.dir}</Text>
164 {g.isRepo && (
165 <Text color={branchColor}>
166 ({g.branch ?? '?'}
167 {g.modified > 0 ? ` mod:${g.modified}` : ''}
168 {g.ahead > 0 ? ` ahead:${g.ahead}` : ''})
169 </Text>
170 )}
171 {g.isRepo && (g.added > 0 || g.removed > 0) && (
172 <Text>
173 <Text color="green">+{g.added}</Text> <Text color="red">-{g.removed}</Text>
174 </Text>
175 )}
176 {g.worktree && <Text color="magenta">[worktree: {g.worktree}]</Text>}
177 {g.lastSubject && columns - used > 12 && (
178 <Text dimColor wrap="truncate-end">last: {clip(g.lastSubject, columns - used)}</Text>
179 )}
180 {g.isRepo && link('open-git', 'Git ›', () => openPane($, 'git'))}
181 </Box>,
182 )
183 }
184
185 // Line 2: model, effort, context and the compact hint.
186 if (options.showModel !== false) {
187 const ctx = u?.ctxPercent ?? null
188 const minutes = u ? (now - u.startedAt) / 60000 : 0
189 const hint = ctx === null ? null : compactHint(ctx, hit, minutes)
190 rows.push(
191 <Box key="l-model" flexDirection="row" gap={1}>
192 <Text bold>{s.model ? modelName(s.model) : '…'}</Text>
193 {s.effort && <Text color={effortColor(s.effort)}>⚙ {s.effort}</Text>}
194 {ctx !== null && (
195 <Text>
196 ctx <Text color={usageColor(ctx)}>{bar(ctx)}</Text> {Math.round(ctx)}%
197 </Text>
198 )}
199 {u?.ctxTokens != null && <Text dimColor>←{kt(u.ctxTokens)} →{kt(s.outputTokens)}</Text>}
200 {hint && hint.score >= 2 && (
201 <Button key="compact" label={`${hint.label} now`} variant="primary" onPress={() => compactNow($)} />
202 )}
203 {hint && hint.score < 2 && <Text color={hint.color}>{hint.label}</Text>}
204 {link('open-context', 'Context ›', () => openPane($, 'context'))}
205 </Box>,
206 )
207 }
208
209 // Line 3: subscription rate limits.
210 if (options.showLimits !== false && u && u.limits.length > 0) {
211 rows.push(
212 <Box key="l-limits" flexDirection="row" gap={2}>
213 {u.limits.map(l => {
214 const left = countdown(l.resetsAt, now)
215 return (
216 <Text key={`limit-${l.kind}`}>
217 {LIMIT_LABELS[l.kind] ?? l.kind} <Text color={usageColor(l.percent)}>{bar(l.percent)}</Text>{' '}
218 {Math.round(l.percent)}%{left ? ` (${left})` : ''}
219 </Text>
220 )
221 })}
222 </Box>,
223 )
224 }
225
226 // Line 4: duration, cache, lines edited and cost.
227 if (options.showStats !== false && u) {
228 rows.push(
229 <Box key="l-stats" flexDirection="row" gap={1}>
230 <Text>⏱ {duration(now - u.startedAt)}</Text>
231 {hit !== null && (
232 <Text>
233 cache <Text color={cacheColor(hit)}>{bar(hit)}</Text> {hit}%
234 </Text>
235 )}
236 {(s.added > 0 || s.removed > 0) && (
237 <Text>
238 edited <Text color="green">+{s.added}</Text> <Text color="red">-{s.removed}</Text>
239 </Text>
240 )}
241 {u.costUsd !== null && <Text dimColor>${u.costUsd.toFixed(2)}</Text>}
242 {link('open-history', 'History ›', () => openPane($, 'history'))}
243 </Box>,
244 )
245 }
246
247 // Line 5: tools.
248 const tools = topCounts(s.tools, 10)
249 if (options.showTools !== false && tools.length > 0) {
250 rows.push(
251 <Box key="l-tools" flexDirection="row" gap={1}>
252 <Text wrap="truncate-end">
253 Tools: {tools.map(([name, n]) => `${name}×${n}`).join(' ')}
254 </Text>
255 {link('open-tools', 'Tools ›', () => openPane($, 'tools'))}
256 </Box>,
257 )
258 }
259
260 // Line 6: agents and skills.
261 const agents = topCounts(s.agents, 6)
262 const running = agentList.filter(a => a.status === 'running' || a.status === 'pending').length
263 if (options.showAgents !== false && (agents.length > 0 || s.skills.length > 0 || agentList.length > 0)) {
264 rows.push(
265 <Box key="l-agents" flexDirection="row" gap={1}>
266 {agents.length > 0 && (
267 <Text wrap="truncate-end">
268 Agents: <Text color="yellow">{agents.map(([name, n]) => `${name}×${n}`).join(' ')}</Text>
269 </Text>
270 )}
271 {running > 0 && <Text color={STATUS.running!.color}>● {running} active</Text>}
272 {s.skills.length > 0 && (
273 <Text wrap="truncate-end">
274 Skills: <Text color="cyan">{s.skills.join(' ')}</Text>
275 </Text>
276 )}
277 {link('open-agents', 'Agents ›', () => openPane($, 'agents'))}
278 </Box>,
279 )
280 }
281
282 // A dim rule above the band, so it reads apart from the conversation above it.
283 if (rows.length > 0 && options.showSeparator !== false) {
284 rows.unshift(
285 <Text key="separator" dimColor>
286 {'─'.repeat(Math.max(1, columns))}
287 </Text>,
288 )
289 }
290
291 if (rows.length === 0) return below
292 // What the plugins below draw (another mod's band) stays, under these lines.
293 return (
294 <Box flexDirection="column">
295 {rows}
296 {below}
297 </Box>
298 )
299}
300
301// ── pane ──
302
303const TABS: Array<[View, string]> = [
304 ['context', 'Context'],
305 ['tools', 'Tools'],
306 ['git', 'Git'],
307 ['agents', 'Agents'],
308 ['history', 'History'],
309]
310
311type PaneEvent = RenderInput<'Pane'>
312type Els = ReturnType<EngineInterface['ui']['resolve']>
313
314async function renderPane($: EngineInterface, e: PaneEvent) {
315 const els = $.ui.resolve(e)
316 const { Box, Button } = els
317 const current = await read($, view)
318 const columns = e.props.bodyColumns
319 const rows = e.props.scroll.bodyRows
320
321 const body =
322 current === 'context' ? await contextView($, els, columns)
323 : current === 'tools' ? await toolsView($, els, columns)
324 : current === 'git' ? await gitView($, els, columns)
325 : current === 'agents' ? await agentsView($, els, e, columns, rows)
326 : await historyView($, els, columns)
327
328 return (
329 <Box flexDirection="column" gap={1}>
330 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
331 {TABS.map(([id, label]) => (
332 <Button
333 key={`tab-${id}`}
334 label={label}
335 variant={id === current ? 'primary' : 'secondary'}
336 onPress={() => update($, view, () => id)}
337 />
338 ))}
339 </Box>
340 {body}
341 </Box>
342 )
343}
344
345type CellStyle = { color?: string; dimColor?: boolean; bold?: boolean }
346
347// A fixed-width table cell: it neither shrinks nor wraps, so columns stay
348// aligned even in a narrow pane.
349function cellOf({ Box, Text }: Els) {
350 return (key: string, width: number, text: string, style: CellStyle = {}, isRight = false) => (
351 <Box key={key} width={width} flexShrink={0}>
352 <Text {...style} wrap="truncate-end">
353 {isRight ? text.padStart(width) : text}
354 </Text>
355 </Box>
356 )
357}
358
359async function contextView($: EngineInterface, els: Els, columns: number) {
360 const { Box, Text, Button } = els
361 const cell = cellOf(els)
362 const u = await $.session.usage({ breakdown: 'summary', columns })
363 const b = u.context.breakdown
364
365 if (!b) return <Text dimColor>No context breakdown yet.</Text>
366
367 const categories = [...b.categories].filter(c => c.tokens > 0 && c.kind !== 'free').sort((x, y) => y.tokens - x.tokens)
368 // name · bar · tokens · percent, sized to the pane's width.
369 const barWidth = columns >= 44 ? 10 : 6
370 const nameWidth = Math.max(8, Math.min(28, columns - barWidth - 16))
371 const hasDeferred = categories.some(c => c.isDeferred)
372 const threshold = b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined
373
374 return (
375 <Box flexDirection="column">
376 <Text>
377 <Text bold>{Math.round(b.percentage)}%</Text> of {kt(b.maxTokens)} · {kt(b.totalTokens)} used
378 </Text>
379 {threshold !== undefined && threshold > 0 && (
380 <Text dimColor>
381 Autocompact at {kt(threshold)} ({Math.round((threshold / b.maxTokens) * 100)}%)
382 </Text>
383 )}
384 <Box flexDirection="column" marginTop={1}>
385 {categories.map(c => {
386 const pct = b.maxTokens > 0 ? (c.tokens / b.maxTokens) * 100 : 0
387 return (
388 <Box key={`cat-${c.name}`} flexDirection="row">
389 {cell('name', nameWidth + 1, clip(c.name, nameWidth), { dimColor: c.isDeferred })}
390 {cell('bar', barWidth + 1, bar(pct, barWidth), { color: c.color })}
391 {cell('tokens', 6, kt(c.tokens), { dimColor: true }, true)}
392 {cell('pct', 8, `${pct.toFixed(1)}%${c.isDeferred ? '*' : ''}`, { dimColor: true }, true)}
393 </Box>
394 )
395 })}
396 </Box>
397 {hasDeferred && <Text dimColor>* deferred: loaded only when used</Text>}
398 {b.memoryFiles.length > 0 && (
399 <Text dimColor wrap="truncate-end">
400 Memory: {b.memoryFiles.map(f => `${clip(f.path.split('/').pop() ?? f.path, 24)} ${kt(f.tokens)}`).join(' · ')}
401 </Text>
402 )}
403 {b.mcpTools.length > 0 && <Text dimColor>MCP tools: {b.mcpTools.length}</Text>}
404 <Box marginTop={1}>
405 <Button key="compact-pane" label="Compact now" variant="primary" onPress={() => compactNow($)} />
406 </Box>
407 </Box>
408 )
409}
410
411function countList(els: Els, title: string, counts: Record<string, number>, color: string, columns: number) {
412 const { Box, Text } = els
413 const cell = cellOf(els)
414 const list = topCounts(counts, 100)
415 if (list.length === 0) return null
416 const max = list[0]![1]
417 const barWidth = columns >= 40 ? 10 : 6
418 const nameWidth = Math.max(8, Math.min(30, columns - barWidth - 8))
419 return (
420 <Box flexDirection="column">
421 <Text bold>{title}</Text>
422 {list.map(([name, n]) => (
423 <Box key={`${title}-${name}`} flexDirection="row">
424 {cell('name', nameWidth + 1, clip(name, nameWidth))}
425 {cell('bar', barWidth + 1, bar((n / max) * 100, barWidth), { color })}
426 {cell('n', 5, String(n), { dimColor: true }, true)}
427 </Box>
428 ))}
429 </Box>
430 )
431}
432
433async function toolsView($: EngineInterface, els: Els, columns: number) {
434 const { Box, Text } = els
435 const s = await read($, stats)
436 const isEmpty = Object.keys(s.tools).length === 0 && Object.keys(s.subagentTools).length === 0
437
438 return (
439 <Box flexDirection="column" gap={1}>
440 {isEmpty && <Text dimColor>No tools used yet.</Text>}
441 {countList(els, 'Main agent', s.tools, 'cyan', columns)}
442 {countList(els, 'Subagents', s.subagentTools, 'magenta', columns)}
443 {countList(els, 'Agents launched', s.agents, 'yellow', columns)}
444 {s.skills.length > 0 && (
445 <Text>
446 <Text bold>Skills: </Text>
447 <Text color="cyan">{s.skills.join(' · ')}</Text>
448 </Text>
449 )}
450 <Text dimColor>
451 {plural(s.turns, 'turn')} · {kt(s.outputTokens)} output · {plural(s.compactions, 'compaction')}
452 </Text>
453 </Box>
454 )
455}
456
457const FILE_COLORS: Record<string, string> = { M: 'yellow', A: 'green', D: 'red', R: 'cyan', '??': 'gray' }
458
459async function gitView($: EngineInterface, { Box, Text }: Els, columns: number) {
460 const g = await read($, git)
461 if (!g || !g.isRepo) return <Text dimColor>This directory is not a git repository.</Text>
462
463 return (
464 <Box flexDirection="column" gap={1}>
465 <Text>
466 <Text bold>{g.dir}</Text> <Text color={g.isDirty ? 'yellow' : 'green'}>{g.branch ?? '?'}</Text>
467 {g.ahead > 0 ? ` ${plural(g.ahead, 'unpushed commit')}` : ''}
468 {g.worktree ? ` [worktree: ${g.worktree}]` : ''}
469 </Text>
470 <Box flexDirection="column">
471 <Text bold>
472 Changes ({g.files.length}) <Text color="green">+{g.added}</Text> <Text color="red">-{g.removed}</Text>
473 </Text>
474 {g.files.length === 0 && <Text dimColor>No changes.</Text>}
475 {g.files.slice(0, 40).map(f => (
476 <Text key={`file-${f.path}`} wrap="truncate-start">
477 <Text color={FILE_COLORS[f.status] ?? 'yellow'}>{f.status.padEnd(2)}</Text> {f.path}
478 </Text>
479 ))}
480 {g.files.length > 40 && <Text dimColor>… and {g.files.length - 40} more</Text>}
481 </Box>
482 <Box flexDirection="column">
483 <Text bold>Recent commits</Text>
484 {g.commits.map(c => (
485 <Text key={`commit-${c}`} wrap="truncate-end">
486 <Text color="yellow">{c.slice(0, c.indexOf(' '))}</Text> {clip(c.slice(c.indexOf(' ') + 1), columns - 10)}
487 </Text>
488 ))}
489 </Box>
490 </Box>
491 )
492}
493
494async function agentsView($: EngineInterface, { Box, Text, Button }: Els, e: PaneEvent, columns: number, rows: number) {
495 const list = await read($, dolls)
496 const current = (await read($, selected)) ?? MAIN
497 const shown = await read($, detail)
498 const viewed = e.props.view.agentId ?? MAIN
499 const tick = await read($, frame)
500 const main: Doll = {
501 id: MAIN,
502 type: 'main',
503 description: 'Main agent',
504 status: (await read($, isMainBusy)) ? 'running' : 'idle',
505 }
506 const width = Math.max(12, columns - 6)
507 const depth = (d: Doll): number =>
508 d.parentId ? 1 + depth(list.find(p => p.id === d.parentId) ?? { ...d, parentId: undefined }) : 1
509 const agents = [main, ...list]
510 const room = Math.max(3, rows - agents.length - 8)
511 const isCompact = shown !== null
512 const owner = shown ? list.find(d => d.id === shown.agentId) : undefined
513
514 return (
515 <Box flexDirection="column" gap={isCompact ? 0 : 1}>
516 {agents.map(d => {
517 const s = STATUS[d.status] ?? { label: d.status, color: 'gray' }
518 const isCurrent = d.id === current
519 const indent = d.id === MAIN ? 0 : Math.min(depth(d), 3) * 2
520 const pick = (
521 <Button
522 key={`pick-${d.id}`}
523 label={d.id === MAIN ? 'Main' : d.type}
524 variant={isCurrent ? 'primary' : 'secondary'}
525 onPress={() => selectAgent($, d.id === MAIN ? null : d)}
526 />
527 )
528 const state = (
529 <Text color={s.color}>
530 {isCurrent ? '▶ ' : ''}
531 {s.label}
532 {d.id === viewed ? ' · 👁 on screen' : ''}
533 </Text>
534 )
535
536 if (isCompact) {
537 const mini = miniRobot(d.type, d.status, tick)
538 return (
539 <Box key={`doll-${d.id}`} flexDirection="row" gap={1} marginLeft={indent}>
540 <Text color={mini.color} dimColor={mini.isDim} bold={isCurrent}>{mini.rows[0]}</Text>
541 {pick}
542 {state}
543 </Box>
544 )
545 }
546
547 const bot = robot(d.type, d.status, tick)
548 return (
549 <Box key={`doll-${d.id}`} flexDirection="row" gap={1} marginLeft={indent}>
550 <Box flexDirection="column" flexShrink={0}>
551 {bot.rows.map((row, i) => (
552 <Text key={`r${i}`} color={bot.color} dimColor={bot.isDim} bold={isCurrent}>
553 {row}
554 </Text>
555 ))}
556 </Box>
557 <Box flexDirection="column" marginTop={1}>
558 {pick}
559 <Text dimColor wrap="truncate-end">{clip(d.description, width - 14)}</Text>
560 {state}
561 </Box>
562 </Box>
563 )
564 })}
565 {list.length === 0 && <Text dimColor>No subagents yet.</Text>}
566 {shown && (
567 <Box flexDirection="column" marginTop={1}>
568 <Text dimColor>{'─'.repeat(Math.max(4, columns - 2))}</Text>
569 <Box flexDirection="row" justifyContent="space-between">
570 <Box flexDirection="row" gap={1}>
571 <Text color={miniRobot(owner?.type ?? '', owner?.status ?? 'idle', tick).color}>
572 {miniRobot(owner?.type ?? '', owner?.status ?? 'idle', tick).rows[0]}
573 </Text>
574 <Text bold wrap="truncate-end">{clip(owner?.description ?? 'Subagent', width - 16)}</Text>
575 </Box>
576 <Button key="back" label="Close" role="dismiss" onPress={() => selectAgent($, null)} />
577 </Box>
578 {shown.deny && <Text color="yellow">{clip(shown.deny, width * 2)}</Text>}
579 {shown.lines.length === 0 && !shown.deny && <Text dimColor>Loading…</Text>}
580 {shown.lines.slice(-room).map((line, i) =>
581 line.role === 'tool' ? (
582 <Text key={`ln-${i}`} color={line.isError ? 'red' : 'gray'} wrap="truncate-end">
583 › {clip(line.text, width)}
584 </Text>
585 ) : (
586 <Text key={`ln-${i}`} color={line.role === 'user' ? 'cyan' : undefined} wrap="wrap">
587 {line.role === 'user' ? '» ' : ''}
588 {clip(line.text, width * 3)}
589 </Text>
590 ),
591 )}
592 </Box>
593 )}
594 </Box>
595 )
596}
597
598async function historyView($: EngineInterface, els: Els, columns: number) {
599 const { Box, Text } = els
600 const cell = cellOf(els)
601 const days = (await read($, history)).slice(-14)
602 if (days.length === 0) return <Text dimColor>No history yet: it is saved at the end of each turn.</Text>
603
604 const max = Math.max(1, ...days.map(d => d.outputTokens))
605 // day · bar · tokens · sessions · turns · tools · compact = 6 + bar + 34;
606 // a narrow pane drops turns and tools (6 + bar + 20).
607 const isNarrow = columns < 50
608 const barWidth = Math.max(4, Math.min(20, columns - (isNarrow ? 27 : 41)))
609 const totals = days.reduce(
610 (t, d) => ({ s: t.s + d.sessions, o: t.o + d.outputTokens, c: t.c + d.compactions }),
611 { s: 0, o: 0, c: 0 },
612 )
613 type Cell = [number, string, CellStyle?, boolean?]
614 const row = (key: string, cells: Cell[]) => (
615 <Box key={key} flexDirection="row">
616 {cells
617 .filter((_, i) => !isNarrow || (i !== 4 && i !== 5))
618 .map(([w, text, style, isRight], i) => cell(`c${i}`, w, text, style, isRight))}
619 </Box>
620 )
621 const dim = { dimColor: true }
622
623 return (
624 <Box flexDirection="column">
625 {row('head', [
626 [6, 'day', dim], [barWidth + 1, 'output', dim], [6, 'tokens', dim, true], [5, 'sess', dim, true],
627 [7, 'turns', dim, true], [7, 'tools', dim, true], [8, 'compact', dim, true],
628 ])}
629 {days.map(d =>
630 row(`day-${d.date}`, [
631 [6, d.date.slice(5)],
632 [barWidth + 1, bar((d.outputTokens / max) * 100, barWidth), { color: 'cyan' }],
633 [6, kt(d.outputTokens), {}, true],
634 [5, String(d.sessions), dim, true],
635 [7, String(d.turns), dim, true],
636 [7, String(d.toolCalls), dim, true],
637 [8, String(d.compactions), dim, true],
638 ]),
639 )}
640 <Box marginTop={1}>
641 <Text dimColor wrap="wrap">
642 {plural(days.length, 'day')} · {plural(totals.s, 'session')} · {kt(totals.o)} output ·{' '}
643 {plural(totals.c, 'compaction')}
644 </Text>
645 </Box>
646 </Box>
647 )
648}
649
650// ── events ──
651
652// As in claudebar: these are counted elsewhere (agents, skills) or add nothing.
653const META_TOOLS = new Set(['Agent', 'Task', 'Skill', 'ToolSearch'])
654const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
655const GIT_TOUCHING = new Set([...EDIT_TOOLS, 'Bash'])
656
657const CONTEXT_ALERTS = [70, 85, 95]
658const LIMIT_ALERTS = [80, 95]
659const HISTORY_DAYS = 30
660
661const VIEW_ARGS: Record<string, View> = {
662 context: 'context', ctx: 'context',
663 tools: 'tools',
664 git: 'git',
665 agents: 'agents',
666 history: 'history',
667}
668
669const EMPTY_DAY = (date: string): DayStats => ({ date, sessions: 0, turns: 0, outputTokens: 0, toolCalls: 0, compactions: 0 })
670
671function bump(counts: Record<string, number>, key: string): Record<string, number> {
672 return { ...counts, [key]: (counts[key] ?? 0) + 1 }
673}
674
675// Lines an edit adds and removes. At tool.call the tool's arguments sit on the
676// event itself (e.old_string), not under e.input.
677export function editDelta(tool: string, input: Record<string, unknown>): { added: number; removed: number } {
678 if (tool === 'Edit') return { added: lineCount(input.new_string), removed: lineCount(input.old_string) }
679 if (tool === 'Write') return { added: lineCount(input.content), removed: 0 }
680 if (tool === 'NotebookEdit') return { added: lineCount(input.new_source), removed: 0 }
681 if (tool === 'MultiEdit' && Array.isArray(input.edits)) {
682 return input.edits.reduce(
683 (t: { added: number; removed: number }, edit: Record<string, unknown>) => ({
684 added: t.added + lineCount(edit.new_string),
685 removed: t.removed + lineCount(edit.old_string),
686 }),
687 { added: 0, removed: 0 },
688 )
689 }
690 return { added: 0, removed: 0 }
691}
692
693// Module state: a reload resets it, which is fine for what it holds.
694let options: PluginOptions = {}
695let toolCallsSinceSave = 0
696let gitTimer: Timer | null = null
697
698async function updateStats($: EngineInterface, fn: (s: Stats) => Stats) {
699 await update($, stats, fn)
700}
701
702async function refreshGit($: EngineInterface) {
703 const next = await readGit($)
704 if (!same(next, await read($, git))) await update($, git, () => next)
705}
706
707function refreshGitSoon($: EngineInterface) {
708 gitTimer?.cancel()
709 gitTimer = $.clock.after(800, () => void refreshGit($))
710}
711
712async function alertOnce($: EngineInterface, key: string, text: string) {
713 const done = await read($, alerted)
714 if (done.includes(key)) return
715 await update($, alerted, list => [...list, key])
716 if (options.alerts !== false) $.ui.toast(text, { timeoutMs: 8000 })
717}
718
719async function checkAlerts($: EngineInterface, snap: UsageSnap) {
720 const ctx = snap.ctxPercent
721 if (ctx !== null) {
722 // Compacting drops the context: the context alerts re-arm.
723 if (ctx < 30) await update($, alerted, list => list.filter(k => !k.startsWith('ctx-')))
724 const level = [...CONTEXT_ALERTS].reverse().find(t => ctx >= t)
725 if (level !== undefined) {
726 const advice = level >= 85 ? 'Compact soon (button in the band, or /compact).' : 'Consider compacting.'
727 await alertOnce($, `ctx-${level}`, `Context at ${Math.round(ctx)}%. ${advice}`)
728 }
729 }
730 for (const l of snap.limits) {
731 const level = [...LIMIT_ALERTS].reverse().find(t => l.percent >= t)
732 if (level === undefined) continue
733 const at = l.resetsAt ? new Date(l.resetsAt) : null
734 const when = at ? ` Resets at ${at.toTimeString().slice(0, 5)}.` : ''
735 await alertOnce($, `limit-${l.kind}-${level}-${l.resetsAt ?? ''}`, `${LIMIT_LABELS[l.kind] ?? l.kind} limit at ${Math.round(l.percent)}%.${when}`)
736 }
737}
738
739async function refreshUsage($: EngineInterface) {
740 const u = await $.session.usage()
741 const snap: UsageSnap = {
742 ctxPercent: u.context.percent ?? null,
743 ctxTokens: u.context.tokens ?? null,
744 window: u.context.window,
745 limits: u.rateLimits.map(l => ({ kind: l.kind, percent: l.percentUsed, resetsAt: l.resetsAt ?? null })),
746 startedAt: u.startedAt,
747 costUsd: u.cost?.usd ?? null,
748 now: await $.clock.now(),
749 }
750 await update($, usage, () => snap)
751 await checkAlerts($, snap)
752}
753
754async function bumpHistory($: EngineInterface, patch: (d: DayStats) => DayStats) {
755 const date = today(await $.clock.now())
756 const stored = ((await $.store.get('history')) as DayStats[] | undefined) ?? []
757 const days = stored.some(d => d.date === date) ? stored : [...stored, EMPTY_DAY(date)]
758 const next = days.map(d => (d.date === date ? patch(d) : d)).slice(-HISTORY_DAYS)
759 await $.store.set('history', next)
760 await update($, history, () => next)
761}
762
763async function countSession($: EngineInterface) {
764 const date = today(await $.clock.now())
765 const id = await $.session.id()
766 const key = `sessions:${date}`
767 const seen = ((await $.store.get(key)) as string[] | undefined) ?? []
768 if (seen.includes(id)) return
769 await $.store.set(key, [...seen, id])
770 await bumpHistory($, d => ({ ...d, sessions: d.sessions + 1 }))
771}
772
773export const register: Register = (on, opts) => {
774 options = opts
775
776 on('session.start', async ($, e, next) => {
777 await $.command.register({
778 name: 'claudebar',
779 description: 'Open the claudebar pane (context, tools, git, agents or history)',
780 argumentHint: '[context|tools|git|agents|history]',
781 })
782 const model = await $.session.model()
783 await updateStats($, s => ({ ...s, model: s.model ?? model }))
784 const stored = ((await $.store.get('history')) as DayStats[] | undefined) ?? []
785 await update($, history, () => stored)
786
787 void countSession($)
788 void refreshGit($)
789 void refreshUsage($)
790 void refreshAgents($)
791 $.clock.every(1500, () => void refreshAgents($))
792 $.clock.every(15000, () => void refreshUsage($))
793 $.clock.every(10000, () => void refreshGit($))
794
795 return next(e)
796 })
797
798 on('command.run', { command: 'claudebar' }, async ($, e) => {
799 const arg = e.args.trim().toLowerCase()
800 const target = VIEW_ARGS[arg] ?? (await read($, view))
801 await openPane($, target)
802 return { text: `claudebar pane opened (${target}).` }
803 })
804
805 on('prompt.submit', async ($, e, next) => {
806 await update($, isMainBusy, () => true)
807 return next(e)
808 }).catch(($, e, next) => next(e))
809
810 on('turn.step', async function* ($, e, next) {
811 if (e.agentId === undefined) {
812 const effort = e.effort === undefined ? null : String(e.effort)
813 await updateStats($, s => (s.model === e.model && s.effort === effort ? s : { ...s, model: e.model, effort }))
814 }
815 return yield* next(e)
816 })
817
818 on('tool.call', async ($, e, next) => {
819 const tool = String(e.tool)
820 const isMain = e.agentId === undefined
821 if (!META_TOOLS.has(tool)) {
822 toolCallsSinceSave += 1
823 await updateStats($, s =>
824 isMain ? { ...s, tools: bump(s.tools, tool) } : { ...s, subagentTools: bump(s.subagentTools, tool) },
825 )
826 }
827 const result = await next(e)
828 const isOk = !('deny' in result && result.deny) && result.isError !== true
829 if (isOk && EDIT_TOOLS.has(tool)) {
830 const { added, removed } = editDelta(tool, e as unknown as Record<string, unknown>)
831 await updateStats($, s => ({ ...s, added: s.added + added, removed: s.removed + removed }))
832 }
833 if (GIT_TOUCHING.has(tool)) refreshGitSoon($)
834 return result
835 }).catch(($, e, next) => next(e))
836
837 on('agent.spawn', async ($, e, next) => {
838 const spawned = await next(e)
839 await updateStats($, s => ({ ...s, agents: bump(s.agents, e.subagentType || 'general-purpose') }))
840 void refreshAgents($)
841 return spawned
842 }).catch(($, e, next) => next(e))
843
844 on('skill.prompt', async ($, e, next) => {
845 await updateStats($, s => (s.skills.includes(e.skill) ? s : { ...s, skills: [...s.skills, e.skill] }))
846 return next(e)
847 })
848
849 on('session.compact', async ($, e, next) => {
850 const result = await next(e)
851 if (e.agentId === undefined && !('skip' in result && result.skip)) {
852 await updateStats($, s => ({ ...s, compactions: s.compactions + 1 }))
853 void bumpHistory($, d => ({ ...d, compactions: d.compactions + 1 }))
854 void refreshUsage($)
855 }
856 return result
857 }).catch(($, e, next) => next(e))
858
859 on('turn.complete', async ($, e, next) => {
860 if (e.agentId === undefined) {
861 await update($, isMainBusy, () => false)
862 const u = e.usage
863 const out = u?.output_tokens ?? 0
864 await updateStats($, s => ({
865 ...s,
866 turns: s.turns + 1,
867 outputTokens: s.outputTokens + out,
868 lastTurn: u
869 ? { input: u.input_tokens, output: u.output_tokens, cacheRead: u.cache_read_input_tokens, cacheCreation: u.cache_creation_input_tokens }
870 : s.lastTurn,
871 }))
872 const calls = toolCallsSinceSave
873 toolCallsSinceSave = 0
874 void bumpHistory($, d => ({ ...d, turns: d.turns + 1, outputTokens: d.outputTokens + out, toolCalls: d.toolCalls + calls }))
875 void refreshUsage($)
876 refreshGitSoon($)
877 }
878 void refreshAgents($)
879 return next(e)
880 })
881
882 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
883 const below = await next(e)
884 if (e.props.hasSurvey) return below
885 return renderBand($, e, options, below)
886 })
887
888 on('ui.render', { component: 'Pane', requestId: 'claudebar' }, ($, e) => renderPane($, e))
889}
890hooks/lib/agents.ts 39 lines1import type { AgentInfo, SessionMessage } from 'claude-code'
2
3import type { Doll, Line } from '../../types'
4
5export const MAIN = 'main'
6const MAX_LINES = 60
7
8export const STATUS: Record<string, { label: string; color: string }> = {
9 running: { label: 'working', color: 'green' },
10 pending: { label: 'starting', color: 'yellow' },
11 waiting: { label: 'waiting', color: 'yellow' },
12 idle: { label: 'idle', color: 'gray' },
13 completed: { label: 'finished', color: 'gray' },
14 failed: { label: 'failed', color: 'red' },
15 killed: { label: 'stopped', color: 'red' },
16}
17
18export function toDoll(agent: AgentInfo): Doll {
19 return { id: agent.id, type: agent.type, description: agent.description, status: agent.status, parentId: agent.parentId }
20}
21
22function briefInput(input: Record<string, unknown>): string {
23 const first = ['description', 'command', 'file_path', 'pattern', 'path', 'url', 'prompt']
24 .map(k => input[k])
25 .find(v => typeof v === 'string')
26 return typeof first === 'string' ? first : ''
27}
28
29export function toLines(messages: SessionMessage[]): Line[] {
30 const lines: Line[] = []
31 for (const m of messages) {
32 if (m.text) lines.push({ role: m.role, text: m.text })
33 for (const use of m.toolUses) {
34 lines.push({ role: 'tool', text: `${use.tool} ${briefInput(use.input)}`.trim(), isError: use.isError === true })
35 }
36 }
37 return lines.slice(-MAX_LINES)
38}
39hooks/lib/format.ts 126 lines1// Formatting shared with the bash claudebar: 8-cell bars, same thresholds.
2
3export const BAR_WIDTH = 8
4
5export function bar(percent: number, width = BAR_WIDTH): string {
6 const filled = Math.max(0, Math.min(width, Math.round((percent / 100) * width)))
7 return '▓'.repeat(filled) + '░'.repeat(width - filled)
8}
9
10// Usage traffic light: green <70, yellow 70–89, red ≥90.
11export function usageColor(percent: number): string {
12 if (percent >= 90) return 'red'
13 if (percent >= 70) return 'yellow'
14 return 'green'
15}
16
17// Cache is the other way round, higher is better: green ≥80, yellow ≥50, red <50.
18export function cacheColor(percent: number): string {
19 if (percent >= 80) return 'green'
20 if (percent >= 50) return 'yellow'
21 return 'red'
22}
23
24const EFFORT_COLORS: Record<string, string> = {
25 low: 'green',
26 medium: 'cyan',
27 high: 'yellow',
28 xhigh: 'redBright',
29 max: 'red',
30}
31
32export function effortColor(level: string): string {
33 return EFFORT_COLORS[level] ?? 'gray'
34}
35
36export function kt(tokens: number): string {
37 return `${Math.round(tokens / 1000)}kt`
38}
39
40export function duration(ms: number): string {
41 const total = Math.max(0, Math.floor(ms / 1000))
42 const h = Math.floor(total / 3600)
43 const m = Math.floor((total % 3600) / 60)
44 const s = total % 60
45 if (h > 0) return `${h}h ${m}m`
46 return `${m}m ${s}s`
47}
48
49export function countdown(resetsAt: string | null, now: number): string | null {
50 if (!resetsAt) return null
51 const at = Date.parse(resetsAt)
52 if (Number.isNaN(at)) return null
53 const mins = Math.max(0, Math.round((at - now) / 60000))
54 const d = Math.floor(mins / 1440)
55 const h = Math.floor((mins % 1440) / 60)
56 const m = mins % 60
57 if (d > 0) return `${d}d ${h}h`
58 if (h > 0) return `${h}h ${m}m`
59 return `${m}m`
60}
61
62// "claude-opus-5-5[1m]" → "Opus 5.5 1M"
63export function modelName(id: string): string {
64 const isLong = /\[1m\]/i.test(id)
65 const parts = id
66 .replace(/\[1m\]/i, '')
67 .replace(/^claude-/, '')
68 .replace(/-\d{8}$/, '')
69 .split('-')
70 const family = parts.find(p => /^[a-z]+$/i.test(p))
71 const version = parts.filter(p => /^\d+$/.test(p)).join('.')
72 if (!family) return id
73 const name = family[0]!.toUpperCase() + family.slice(1)
74 return [name, version, isLong ? '1M' : ''].filter(Boolean).join(' ')
75}
76
77export function clip(text: string, max: number): string {
78 const flat = text.replace(/\s+/g, ' ').trim()
79 if (max <= 1) return ''
80 return flat.length > max ? flat.slice(0, max - 1) + '…' : flat
81}
82
83export function lineCount(text: unknown): number {
84 if (typeof text !== 'string' || text === '') return 0
85 return text.split('\n').length
86}
87
88export type CompactHint = { label: string; color: string; score: number }
89
90// Same score as statusline.sh: context (1–3), cache <60 (+1), >90 min (+1).
91// Silent below 30% context.
92export function compactHint(ctx: number, cacheRate: number | null, minutes: number): CompactHint | null {
93 if (ctx < 30) return null
94 let score = 0
95 if (ctx >= 85) score += 3
96 else if (ctx >= 70) score += 2
97 else if (ctx >= 50) score += 1
98 if (cacheRate !== null && cacheRate < 60) score += 1
99 if (minutes > 90) score += 1
100 if (score >= 4) return { label: '⚠ COMPACT', color: 'red', score }
101 if (score >= 2) return { label: '⚡ /compact', color: 'yellow', score }
102 if (score >= 1) return { label: '✦ compact?', color: 'gray', score }
103 return null
104}
105
106export function cacheRate(t: { input: number; cacheRead: number; cacheCreation: number } | null): number | null {
107 if (!t) return null
108 const total = t.input + t.cacheRead + t.cacheCreation
109 return total > 0 ? Math.round((t.cacheRead / total) * 100) : null
110}
111
112export function today(now: number): string {
113 return new Date(now).toISOString().slice(0, 10)
114}
115
116export function topCounts(counts: Record<string, number>, limit: number): Array<[string, number]> {
117 return Object.entries(counts)
118 .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
119 .slice(0, limit)
120}
121
122// "1 session", "2 sessions"
123export function plural(n: number, word: string): string {
124 return `${n} ${word}${n === 1 ? '' : 's'}`
125}
126hooks/lib/git.ts 36 lines1import type { GitFile, GitInfo } from '../../types'
2
3export function basename(path: string): string {
4 const parts = path.replace(/\/+$/, '').split('/')
5 return parts[parts.length - 1] || path
6}
7
8// `## main...origin/main [ahead 2, behind 1]` → branch and unpushed commits.
9export function parseBranch(line: string): { branch: string | null; ahead: number } {
10 const body = line.replace(/^## /, '')
11 const ahead = Number(/ahead (\d+)/.exec(body)?.[1] ?? 0)
12 if (body.startsWith('No commits yet on ')) return { branch: body.slice(18).split('...')[0] ?? null, ahead }
13 if (body.startsWith('HEAD (no branch)')) return { branch: 'HEAD', ahead }
14 return { branch: body.split('...')[0]?.split(' ')[0] ?? null, ahead }
15}
16
17export function parseStatus(stdout: string): { branch: string | null; ahead: number; files: GitFile[] } {
18 const lines = stdout.split('\n').filter(Boolean)
19 const head = lines[0]?.startsWith('## ') ? parseBranch(lines[0]) : { branch: null, ahead: 0 }
20 const files = lines
21 .filter(l => !l.startsWith('## '))
22 .map(l => ({ status: l.slice(0, 2).trim() || '?', path: l.slice(3) }))
23 return { ...head, files }
24}
25
26export function parseNumstat(stdout: string): { added: number; removed: number } {
27 let added = 0
28 let removed = 0
29 for (const line of stdout.split('\n')) {
30 const [a, r] = line.split('\t')
31 if (a && a !== '-') added += Number(a) || 0
32 if (r && r !== '-') removed += Number(r) || 0
33 }
34 return { added, removed }
35}
36hooks/lib/robot.ts 68 lines1// Block-character robots, one per agent: the face follows the agent's status,
2// the color its type. Every glyph is a single terminal cell wide.
3
4export type Robot = {
5 rows: string[]
6 color: string
7 isDim: boolean
8}
9
10// Width of the full robot: the head is 7 cells, plus 2 on each side for the
11// arms a finished robot raises.
12export const ROBOT_WIDTH = 11
13
14const TYPE_COLORS: Record<string, string> = {
15 main: 'claude',
16 Explore: 'cyan',
17 Plan: 'magenta',
18 'general-purpose': 'yellow',
19 teammate: 'blue',
20}
21
22type Face = { eyes: string; antenna: string; isCheering?: boolean }
23
24// `frame` ticks while an agent works: the antenna lights up and the eyes blink.
25function face(status: string, frame: number): Face {
26 switch (status) {
27 case 'running':
28 return frame % 4 === 3 ? { eyes: '▁ ▁', antenna: '╻' } : { eyes: '● ●', antenna: frame % 2 === 0 ? '✦' : '╻' }
29 case 'pending':
30 return { eyes: '◦ ◦', antenna: '╻' }
31 case 'waiting':
32 return { eyes: '● ●', antenna: '?' }
33 case 'completed':
34 return { eyes: '^ ^', antenna: '╻', isCheering: true }
35 case 'failed':
36 case 'killed':
37 return { eyes: '× ×', antenna: '╷' }
38 default:
39 return { eyes: '– –', antenna: '╷' }
40 }
41}
42
43export function robotColor(type: string, status: string): string {
44 if (status === 'failed' || status === 'killed') return 'red'
45 return TYPE_COLORS[type] ?? 'green'
46}
47
48export function robot(type: string, status: string, frame = 0): Robot {
49 const f = face(status, frame)
50 const arm = f.isCheering ? '▝▘' : ' '
51 return {
52 rows: [
53 ` ${f.antenna} `,
54 ' ▗▄▄█▄▄▖ ',
55 `${arm}▐ ${f.eyes} ▌${arm}`,
56 ' ▝▀▙▄▟▀▘ ',
57 ],
58 color: robotColor(type, status),
59 isDim: status === 'idle' || status === 'completed',
60 }
61}
62
63// The one-line robot, for rows where the conversation takes the room below.
64export function miniRobot(type: string, status: string, frame = 0): Robot {
65 const eyes = face(status, frame).eyes.replace(' ', '')
66 return { rows: [`▐${eyes}▌`], color: robotColor(type, status), isDim: status === 'idle' || status === 'completed' }
67}
68hooks/lib/state.ts 22 lines1import type { Stats } from '../../types'
2
3export const EMPTY_STATS: Stats = {
4 model: null,
5 effort: null,
6 outputTokens: 0,
7 lastTurn: null,
8 tools: {},
9 subagentTools: {},
10 agents: {},
11 skills: [],
12 added: 0,
13 removed: 0,
14 compactions: 0,
15 turns: 0,
16}
17
18
19export function same(a: unknown, b: unknown): boolean {
20 return JSON.stringify(a) === JSON.stringify(b)
21}
22types/index.d.ts 81 lines1export type GitFile = { status: string; path: string }
2
3export type GitInfo = {
4 dir: string
5 isRepo: boolean
6 branch: string | null
7 isDirty: boolean
8 modified: number
9 ahead: number
10 added: number
11 removed: number
12 lastSubject: string | null
13 worktree: string | null
14 files: GitFile[]
15 commits: string[]
16}
17
18export type Limit = { kind: string; percent: number; resetsAt: string | null }
19
20export type UsageSnap = {
21 ctxPercent: number | null
22 ctxTokens: number | null
23 window: number
24 limits: Limit[]
25 startedAt: number
26 costUsd: number | null
27 now: number
28}
29
30export type TurnTokens = { input: number; output: number; cacheRead: number; cacheCreation: number }
31
32export type Stats = {
33 model: string | null
34 effort: string | null
35 outputTokens: number
36 lastTurn: TurnTokens | null
37 tools: Record<string, number>
38 subagentTools: Record<string, number>
39 agents: Record<string, number>
40 skills: string[]
41 added: number
42 removed: number
43 compactions: number
44 turns: number
45}
46
47export type View = 'context' | 'tools' | 'git' | 'agents' | 'history'
48
49export type Doll = { id: string; type: string; description: string; status: string; parentId?: string }
50
51export type Line = { role: 'user' | 'assistant' | 'tool'; text: string; isError?: boolean }
52
53export type Detail = { agentId: string; lines: Line[]; deny?: string }
54
55export type DayStats = {
56 date: string
57 sessions: number
58 turns: number
59 outputTokens: number
60 toolCalls: number
61 compactions: number
62}
63
64declare module 'claude-code' {
65 interface PluginState {
66 'claudebar-mod': {
67 git: GitInfo | null
68 usage: UsageSnap | null
69 stats: Stats
70 view: View
71 isMainBusy: boolean
72 dolls: Doll[]
73 selected: string | null
74 detail: Detail | null
75 history: DayStats[]
76 alerted: string[]
77 frame: number
78 }
79 }
80}
81