SLOPSHOPPER

claudebar-mod

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

newpanebandguardcommandtoast
★ 1v0.2.2MITupdated 2026-10-08visiblesoft-es/claude-mods/claudebar-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claudebar-mod
│ ┃ claudebar ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ [ Context ] [ Tools ] [ Git ] [ Agents ] [ H │ claudebar-mod │ │ ┃ ⏺ Read(src/auth.ts) │ The claudebar pane is open as a tab behind │ │ ┃ No context breakdown yet. ⎿ Read 6 lines │ another pane: press its tab or ctrl+x tab. │ │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ⎿ Added 2 lines, removed 1 line │ ⏺ 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 │ │ › /claudebar │ ⎿ claudebar-mod: claudebar pane opened (context). │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── app (?) [ Git › ] Opus 5.5 ctx ▓▓▓▓░░░░ 49% ←97kt →1kt [ Context › ] 5h ▓▓░░░░░░ 31% ⏱ 30m 0s cache ▓▓▓▓▓▓▓░ 93% edited +16 -1 $0.42 [ History › ] Tools: Bash×4 Write×2 Edit×1 Grep×1 Read×1 [ Tools › ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
──────────────────────────────────────────────────────────────────────────────────────────────────── app (?) [ Git › ] Opus 5.5 ctx ▓▓▓▓░░░░ 49% ←97kt →1kt [ Context › ] 5h ▓▓░░░░░░ 31% ⏱ 30m 0s cache ▓▓▓▓▓▓▓░ 93% edited +16 -1 $0.42 [ History › ] Tools: Bash×4 Write×2 Edit×1 Grep×1 Read×1 [ Tools › ] ⟨Claude Code's own drawing⟩
Pane · claudebar
[ Context ] [ Tools ] [ Git ] [ Agents ] [ History ] No context breakdown yet.
README

claudebar-mod

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 › ]

Installation

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

What it shows

The same lines and the same color thresholds as the bash claudebar. Each line is dropped when it has nothing to show.

  1. Git: directory, branch (green when clean, yellow with changes or unpushed commits), mod:N, ahead:N, +N -N against HEAD, last commit subject and [worktree: name].
  2. Model: 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).
  3. Rate limits: 5 h and 7 days, with countdowns. Claude.ai subscriptions only.
  4. Session: duration, cache hit rate, lines edited and cost in dollars.
  5. Tools: the 10 most used by the main agent.
  6. Agents and skills: subagent types launched, how many are still active, and skills used.

What's new compared to the bash version

  • Updates itself: countdowns, duration and counters move without waiting for a new turn. Tools are counted live, without re-reading the transcript.
  • One-click compaction: when the hint reaches ⚡ /compact or ⚠ COMPACT, it turns into a button that compacts the conversation.
  • Detail pane (the band's Context ›, Git ›, Tools ›, Agents › and History › buttons, or /claudebar [view]), with tabs:
  • Context: breakdown by category (messages, system prompt, tools, MCP, memory…), the autocompact threshold and a compact button.
  • Tools: every tool used by the main agent and by subagents, agents launched, skills, turns and compactions.
  • Git: changed files and recent commits.
  • Agents: one little robot per agent, its face following its status and its color its type (as in agent-dolls); press one to see its conversation below. 👁 marks the agent you have on screen.
  • History: output tokens, sessions, turns, tools and compactions per day, for the last 14 days.
  • Alerts: when context passes 70%, 85% and 95% (they re-arm after compacting) and when a rate limit passes 80% and 95%, with the reset time.
  • History across sessions, kept in the mod's store (last 30 days).
  • No width problems: each line fits the available width and is truncated instead of overflowing and hiding the lines below it.
  • More places: it draws in the terminal, the desktop app and VS Code.
  • Shares the band: other mods that draw above the prompt (such as pinned-steps) are shown under its lines instead of being hidden.

/claudebar takes the view to open: context, tools, git, agents or history.

Configuration

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

Limitations

  • A mod cannot draw in the 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.
  • Lines edited are computed from the edits (Edit, Write, MultiEdit, NotebookEdit) of the main agent and of subagents, so they may not match the bash version's figure exactly.
  • The cache hit rate is the main agent's last turn.
  • Git is read every 10 s and after each edit or Bash command.
  • It uses Claude Code's mod API, which is in early access and may change between releases. On Claude Code versions without mod support, keep using the bash claudebar.

Development

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 .
Source 7 files
hooks/register.tsx 890 lines
1import { 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}
890
hooks/lib/agents.ts 39 lines
1import 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}
39
hooks/lib/format.ts 126 lines
1// 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}
126
hooks/lib/git.ts 36 lines
1import 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}
36
hooks/lib/robot.ts 68 lines
1// 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}
68
hooks/lib/state.ts 22 lines
1import 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}
22
types/index.d.ts 81 lines
1export 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