Session dashboard pane: workspace, git, agent, tasks, limits and cost

A clean, organized dotfiles repository using symlinks for easy management and deployment.
This dotfiles system uses a unified YAML configuration for symlink management:
symlinks.yml file with OS-specific sectionsdots link updates everything automatically (removes broken symlinks + creates new ones)"common/.local/bin/*": "~/.local/bin")SSH access to GitHub via ProtonPass is required before cloning.
See install/README.md for the full bootstrap guide.
Quick version:
git clone git@github.com:nikbrunner/dots.git ~/repos/nikbrunner/dots
cd ~/repos/nikbrunner/dots
./install/install.sh
Flags: --dry-run (preview), --debug (diagnostics)
dots — Dotfiles management (pull, push, link, chores, purge). Run dots with no args for usage.dots pull — Installs missing mise and platform-native packages without upgrading existing packages. Add --upgrade to upgrade declared packages.dots chores — Refreshes the mise lock metadata and stages config.toml with mise.lock alongside routine changes. It does not bump fuzzy version selectors.dots purge — Reviews undeclared Homebrew, mise, and Arch entries. Nothing is selected by default; removal requires explicit selection and confirmation. Use --dry-run to inspect candidates without changes.mole uninstall — Separate macOS application cleanup. It does not replace dots purge for package-manager entries.shiplog — AI-powered git operations (commit, branch). Run shiplog --help for usage.helm — External tool for multi-repo management. Invoked by dots pull and dots push.common/ (cross-platform) or macos//arch/ (OS-specific), mirroring home directory structuresymlinks.yml in the appropriate sectiondots linksymlinks.ymldots link (broken symlinks are automatically cleaned up)dots link (old symlink removed, new one created)dots link --dry-run --verbose
When you run dots link:
symlinks.yml file for your platform"common/.local/bin/*": "~/.local/bin"--no-backup is used)Options:
--dry-run: Preview what would happen without making changes--no-backup: Overwrite existing files instead of backing them up--verbose: Show detailed output for each symlink operationPlace OS-specific files in macos/, linux/, or arch/ following the home directory structure. The system uses the symlinks.yml configuration file with OS-specific sections to define which files get symlinked.
Important: The YAML section names must match the OS detection output:
| Detected OS | YAML Section | Description |
|---|---|---|
common | common: | Always processed on all platforms |
macos | macos: | macOS systems |
arch | arch: | Arch Linux systems |
The system always processes the common section first, then adds the platform-specific section if it exists. OS detection is handled by scripts/dots/detect-os.sh.
Black Atom Livery owns theme provisioning. The tracked configuration at common/.config/black-atom/livery/config.json records the active theme and enabled app integrations.
dots pull runs livery reapply after linking files and refreshing repositories when the configuration contains setup data and an active theme. Run livery apply to choose a different theme interactively.
hooks/register.tsx 571 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, SessionUsage } from 'claude-code'
3
4import type { Agent, Task, Usage } from '../types'
5import { bar, editDelta, duration, heat, parseGit, prettyModel, until } from './lib'
6
7const DASH = 'dash'
8const CACHE_TTL_MS = 60 * 60 * 1000
9
10const git = atom({ plugin: 'dash-pane', key: 'git' } as const, null)
11const agent = atom({ plugin: 'dash-pane', key: 'agent' } as const, {
12 isWork: false,
13 model: '',
14})
15const title = atom({ plugin: 'dash-pane', key: 'title' } as const, '')
16const tasks = atom({ plugin: 'dash-pane', key: 'tasks' } as const, [])
17const usage = atom({ plugin: 'dash-pane', key: 'usage' } as const, {
18 limits: [],
19})
20const cache = atom({ plugin: 'dash-pane', key: 'cache' } as const, {
21 misses: 0,
22})
23const lines = atom({ plugin: 'dash-pane', key: 'lines' } as const, {
24 added: 0,
25 removed: 0,
26})
27const tick = atom({ plugin: 'dash-pane', key: 'tick' } as const, 0)
28const work = atom({ plugin: 'dash-pane', key: 'work' } as const, {
29 cwd: '',
30 turns: 0,
31})
32
33const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit'])
34const GIT_TOOLS = new Set(['Bash', 'Edit', 'Write', 'NotebookEdit'])
35const TASK_TOOLS = new Set(['TaskCreate', 'TaskUpdate'])
36const FILE_COLOR: Record<string, string> = {
37 A: 'success',
38 M: 'warning',
39 D: 'error',
40 R: 'suggestion',
41 C: 'suggestion',
42 '?': 'suggestion',
43 U: 'error',
44}
45const ICON = {
46 dir: '\uf07b',
47 branch: '\uf418',
48 ctx: '\uf0e4',
49 cache: '\uf06d',
50 brain: '\u{f09d1}',
51 tasks: '☰',
52}
53// Stroke icons in a 24×24 box, drawn on surfaces whose font has no Nerd Font glyphs.
54const ICON_SVG: Record<keyof typeof ICON, string> = {
55 dir: '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
56 branch: '<path d="M6 3v12"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
57 ctx: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
58 cache:
59 '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.07-2.14-.22-4.05 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.15.43-2.29 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
60 brain: '<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>',
61 tasks: '<path d="M10 6h11"/><path d="M10 12h11"/><path d="M10 18h11"/><path d="m3 6 1.5 1.5L7 5"/><path d="m3 12 1.5 1.5L7 11"/><path d="m3 18 1.5 1.5L7 17"/>',
62}
63const SVG_COLOR: Record<string, string> = {
64 claude: '#d77757',
65 success: '#4eba65',
66 warning: '#ffc107',
67 dim: '#999999',
68}
69const LIMIT_LABEL: Record<string, string> = {
70 five_hour: '5h',
71 seven_day: '7d',
72}
73
74let configDir = ''
75let home = ''
76let isGitQueued = false
77
78async function refreshGit($: EngineInterface) {
79 const cwd = await $.session.cwd()
80 const run = await $.process.run(
81 [
82 'sh',
83 '-c',
84 'cd "$(git rev-parse --show-toplevel)" || exit 1; ' +
85 'git --no-optional-locks status --porcelain=v2 --branch || exit 1; ' +
86 'printf "# gitdir %s\\n" "$(git rev-parse --absolute-git-dir)"; ' +
87 'git log -1 --format="# last %h%x09%cr%x09%s" 2>/dev/null; ' +
88 'printf "# stash %s\\n" "$(git stash list 2>/dev/null | wc -l | tr -d " ")"; ' +
89 'git --no-optional-locks diff --numstat HEAD 2>/dev/null | sed "s/^/# num /"',
90 ],
91 { cwd, timeoutMs: 3000 },
92 )
93 const root = (await $.session.repo())?.root ?? cwd
94 const repo = root.split('/').pop() ?? root
95 await update($, git, () => (run.exitCode === 0 ? parseGit(repo, run.stdout) : null))
96}
97
98function queueGit($: EngineInterface) {
99 if (isGitQueued) return
100 isGitQueued = true
101 $.clock.after(500, () => {
102 isGitQueued = false
103 void refreshGit($)
104 })
105}
106
107async function refreshTasks($: EngineInterface) {
108 const dir = `${configDir}/tasks/${await $.session.id()}`
109 if (!(await $.fs.exists(dir))) return
110 const files = (await $.fs.list(dir)).filter(f => f.kind === 'file' && f.name.endsWith('.json'))
111 const list: Task[] = []
112 for (const f of files) {
113 try {
114 const t = JSON.parse(await $.fs.read(`${dir}/${f.name}`))
115 list.push({ subject: t.activeForm ?? t.subject ?? '', status: t.status })
116 } catch {}
117 }
118 await update($, tasks, () => list)
119}
120
121async function loadAgent($: EngineInterface) {
122 const paths = await $.process.run([
123 'sh',
124 '-c',
125 'printf "%s\\n%s\\n%s" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}" "${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json" "$HOME"',
126 ])
127 const [dir = '', configFile = '', homeDir = ''] = paths.stdout.split('\n')
128 configDir = dir
129 home = homeDir
130 const auth = await $.session.authorize()
131 let account: string | undefined
132 try {
133 account = JSON.parse(await $.fs.read(configFile)).oauthAccount?.emailAddress?.split('@')[0]
134 } catch {}
135 const model = prettyModel(await $.session.model())
136 await update($, agent, a => ({
137 ...a,
138 model,
139 account,
140 isWork: auth?.kind === 'api-key',
141 }))
142}
143
144async function refreshTitle($: EngineInterface) {
145 const run = await $.process.run([
146 'sh',
147 '-c',
148 'f=$(ls "$1"/projects/*/"$2".jsonl 2>/dev/null | head -1); [ -n "$f" ] || exit 0; ' +
149 'grep -o \'"\\(customTitle\\|aiTitle\\)":"[^"]*"\' "$f" | tail -1 | sed \'s/.*":"//; s/"$//\'',
150 'sh',
151 configDir,
152 await $.session.id(),
153 ])
154 const found = run.stdout.trim()
155 if (found) await update($, title, () => found)
156}
157
158async function refreshWork($: EngineInterface) {
159 const cwd = await $.session.cwd()
160 const turns = await $.session.turns()
161 await update($, work, w => ({
162 ...w,
163 cwd: home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd,
164 turns,
165 }))
166}
167
168function toUsage(u: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>): Usage {
169 return {
170 context: u.context.percent,
171 tokens: u.context.tokens,
172 window: u.context.window,
173 startedAt: 'startedAt' in u ? (u as SessionUsage).startedAt : undefined,
174 limits: u.rateLimits.map(l => ({
175 kind: l.kind,
176 percent: l.percentUsed,
177 resetsAt: l.resetsAt,
178 })),
179 cost: u.cost?.usd,
180 }
181}
182
183async function refreshUsage($: EngineInterface) {
184 const u = await $.session.usage()
185 await update($, usage, () => toUsage(u))
186}
187
188export const register: Register = on => {
189 on('session.start', async ($, e, next) => {
190 await $.command.register({
191 name: 'dash-pane',
192 description: 'Open the session dashboard pane',
193 })
194 void $.ui.open({ id: DASH, title: 'Dash' })
195 await loadAgent($)
196 void refreshTitle($)
197 void refreshWork($)
198 void refreshGit($)
199 void refreshTasks($)
200 void refreshUsage($)
201 $.clock.every(1000, () => void update($, tick, n => n + 1))
202 return next(e)
203 })
204
205 on('command.run', { command: 'dash-pane' }, async $ => {
206 await $.ui.open({ id: DASH, title: 'Dash' })
207 return { text: 'Dash pane opened.' }
208 })
209
210 on('classic.UserPromptSubmit', async ($, e, next) => {
211 if (e.session_title) await update($, title, () => e.session_title ?? '')
212 const mode = e.permission_mode
213 if (mode) void update($, work, w => ({ ...w, mode }))
214 return next(e)
215 })
216
217 on('prompt.submit', async ($, e, next) => {
218 queueGit($)
219 void refreshUsage($)
220 void refreshWork($)
221 void $.session.model().then(m => update($, agent, a => ({ ...a, model: prettyModel(m) })))
222 return next(e)
223 })
224
225 on('prompt.compose', async ($, e, next) => {
226 const style = e.outputStyle?.name
227 void update($, agent, a => ({ ...a, style }))
228 return next(e)
229 })
230
231 on('turn.step', async function* ($, e, next) {
232 const result = yield* next(e)
233 if (e.agentId) return result
234 if (e.effort !== undefined) void update($, agent, a => ({ ...a, effort: String(e.effort) }))
235 const u = result.usage
236 if (u) {
237 const at = await $.clock.now()
238 const total = u.cache_read_input_tokens + u.cache_creation_input_tokens + u.input_tokens
239 await update($, cache, c => {
240 const wasWarm = c.lastAt !== undefined && at - c.lastAt < CACHE_TTL_MS
241 const isMiss = wasWarm && u.cache_read_input_tokens === 0
242 return {
243 hit: total > 0 ? u.cache_read_input_tokens / total : c.hit,
244 lastAt: at,
245 misses: c.misses + (isMiss ? 1 : 0),
246 }
247 })
248 }
249 return result
250 })
251
252 on('tool.call', async ($, e, next) => {
253 if (EDIT_TOOLS.has(e.tool)) {
254 const delta = editDelta(e as unknown as Record<string, unknown>)
255 const ran = await next(e)
256 if (!ran.isError)
257 void update($, lines, l => ({
258 added: l.added + delta.added,
259 removed: l.removed + delta.removed,
260 }))
261 queueGit($)
262 return ran
263 }
264 const ran = await next(e)
265 if (GIT_TOOLS.has(e.tool)) queueGit($)
266 if (!e.agentId && TASK_TOOLS.has(e.tool)) void refreshTasks($)
267 return ran
268 })
269
270 on('turn.complete', async ($, e, next) => {
271 const result = await next(e)
272 if (!e.agentId) {
273 void refreshTitle($)
274 void refreshWork($)
275 }
276 return result
277 })
278
279 on('session.measure', async ($, e, next) => {
280 await update($, usage, prev => ({
281 ...toUsage(e),
282 startedAt: prev.startedAt,
283 }))
284 return next(e)
285 })
286
287 on('ui.render', { component: 'Pane', requestId: DASH }, async ($, e) => {
288 const els = $.ui.resolve(e)
289 const { Box, Text } = els
290 // The desktop app's font carries no Nerd Font glyphs and lays text out proportionally.
291 const isDesktop = e.surface === 'desktop'
292 const Svg = 'Svg' in els ? els.Svg : undefined
293 const mark = (key: keyof typeof ICON, color: string) =>
294 isDesktop && Svg ? (
295 <Svg
296 alt={key}
297 width={14}
298 height={14}
299 source={`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${SVG_COLOR[color] ?? color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${ICON_SVG[key]}</svg>`}
300 />
301 ) : (
302 <Text color={color === 'dim' ? undefined : color} dimColor={color === 'dim'}>
303 {ICON[key]}
304 </Text>
305 )
306 const g = await read($, git)
307 const a: Agent = await read($, agent)
308 const t = await read($, title)
309 const tk = await read($, tasks)
310 const u = await read($, usage)
311 const c = await read($, cache)
312 const ln = await read($, lines)
313 const w = await read($, work)
314 await read($, tick)
315 const at = await $.clock.now()
316 const done = tk.filter(x => x.status === 'completed').length
317 const isCacheWarm = c.lastAt !== undefined && at - c.lastAt < CACHE_TTL_MS
318 const room = Math.max(4, (e.viewport?.rows ?? 40) - 32 - (tk.length > 0 && done < tk.length ? tk.length + 2 : 0))
319
320 const rule = '╌'.repeat(Math.max(10, (e.viewport?.columns ?? 40) - 2))
321 let isFirstCard = true
322 const card = (icon: keyof typeof ICON, name: string, meta: RenderChildren, body: RenderChildren, accent = 'claude') => {
323 const isFirst = isFirstCard
324 isFirstCard = false
325 return (
326 <Box flexDirection="column" paddingX={1}>
327 {!isFirst &&
328 (isDesktop ? (
329 <Box marginTop={1} />
330 ) : (
331 <Text dimColor wrap="truncate-end">
332 {rule}
333 </Text>
334 ))}
335 <Box justifyContent="space-between">
336 <Box gap={1} alignItems="center">
337 {mark(icon, accent)}
338 <Text color={accent} bold>
339 {name}
340 </Text>
341 </Box>
342 {meta}
343 </Box>
344 {body}
345 </Box>
346 )
347 }
348
349 const LABEL = 9
350 const kv = (
351 name: string,
352 value: RenderChildren,
353 wrap: 'truncate-end' | 'truncate-start' = 'truncate-end',
354 lead?: RenderChildren,
355 ) => (
356 <Box alignItems="center">
357 <Box width={LABEL} flexShrink={0}>
358 <Text color="inactive">{name}</Text>
359 </Box>
360 {lead && (
361 <Box marginRight={1} flexShrink={0}>
362 {lead}
363 </Box>
364 )}
365 <Text wrap={wrap}>{value}</Text>
366 </Box>
367 )
368
369 const gauge = (percent: number, width: number, fill = 'rate_limit_fill') => {
370 if (isDesktop) {
371 const pct = Math.min(100, Math.max(0, percent))
372 return (
373 <Box width={width} height={1} flexShrink={0} alignItems="center">
374 <Box width="100%" height={0.4} backgroundColor="rate_limit_empty" overflow="hidden">
375 <Box width={`${pct}%`} backgroundColor={fill} />
376 </Box>
377 </Box>
378 )
379 }
380 const b = bar(percent, width)
381 const n = b.lastIndexOf('▰') + 1
382 return (
383 <Text>
384 <Text color={fill}>{b.slice(0, n)}</Text>
385 <Text color="rate_limit_empty">{b.slice(n)}</Text>
386 </Text>
387 )
388 }
389
390 const meter = (name: string, percent: number, extra: string) => (
391 <Box justifyContent="space-between">
392 <Box>
393 <Box width={LABEL} flexShrink={0}>
394 <Text color="inactive">{name}</Text>
395 </Box>
396 {gauge(percent, 12, heat(percent))}
397 <Text bold> {String(Math.round(percent)).padStart(3)}%</Text>
398 </Box>
399 <Text dimColor>{extra}</Text>
400 </Box>
401 )
402
403 return (
404 <Box flexDirection="column">
405 {card(
406 'dir',
407 g?.repo ?? '-',
408 g?.isWorktree ? <Text color="suggestion">worktree</Text> : null,
409 <Box flexDirection="column">
410 {kv('cwd', <Text>{w.cwd}</Text>, 'truncate-start')}
411 {t !== '' &&
412 kv(
413 'session',
414 <Text color="suggestion" italic>
415 {t}
416 </Text>,
417 )}
418 {u.startedAt !== undefined && kv('age', <Text>{duration(at - u.startedAt)}</Text>)}
419 </Box>,
420 )}
421
422 {card(
423 'brain',
424 a.model,
425 a.isWork ? (
426 <Text color="error" bold>
427 ⚠ WORK
428 </Text>
429 ) : null,
430 <Box flexDirection="column">
431 {a.account && kv('account', <Text color="suggestion">{a.account}</Text>)}
432 {a.effort && kv('effort', <Text color="warning">{a.effort}</Text>)}
433 {w.mode && kv('mode', <Text>{w.mode}</Text>)}
434 {a.style && a.style !== 'default' && kv('style', <Text>{a.style}</Text>)}
435 {kv('turns', <Text>{w.turns}</Text>)}
436 </Box>,
437 'success',
438 )}
439
440 {tk.length > 0 &&
441 done < tk.length &&
442 card(
443 'tasks',
444 'tasks',
445 <Box>
446 {gauge((done / tk.length) * 100, 8)}
447 <Text bold>
448 {' '}
449 {done}/{tk.length}
450 </Text>
451 </Box>,
452 <Box flexDirection="column">
453 {tk.map(x => (
454 <Text
455 dimColor={x.status === 'completed'}
456 color={x.status === 'in_progress' ? 'warning' : undefined}
457 wrap="truncate-end"
458 >
459 {x.status === 'completed' ? '✓ ' : x.status === 'in_progress' ? '▸ ' : '· '}
460 {x.subject}
461 </Text>
462 ))}
463 </Box>,
464 )}
465
466 {card(
467 'ctx',
468 'session',
469 null,
470 <Box flexDirection="column">
471 {u.context !== undefined &&
472 meter(
473 'context',
474 u.context,
475 u.tokens !== undefined && u.window !== undefined
476 ? `${Math.round(u.tokens / 1000)}k/${u.window >= 1_000_000 ? `${u.window / 1_000_000}M` : `${Math.round(u.window / 1000)}k`}`
477 : '',
478 )}
479 {u.limits.map(l => meter(LIMIT_LABEL[l.kind] ?? l.kind, l.percent, until(l.resetsAt, at)))}
480 {kv(
481 'cache',
482 <Text>
483 <Text>{c.hit !== undefined ? `${Math.round(c.hit * 100)}% hit` : '–'}</Text>
484 <Text dimColor>
485 {' '}
486 · {isCacheWarm && c.lastAt ? `expires ${until(c.lastAt + CACHE_TTL_MS, at)}` : 'cold'}
487 </Text>
488 {c.misses > 0 && <Text color="error"> · {c.misses} miss</Text>}
489 </Text>,
490 'truncate-end',
491 mark('cache', isCacheWarm ? 'warning' : 'dim'),
492 )}
493 {kv(
494 'edits',
495 <Text>
496 <Text color="success">+{ln.added}</Text>
497 <Text dimColor>/</Text>
498 <Text color="error">-{ln.removed}</Text>
499 <Text dimColor> lines</Text>
500 </Text>,
501 )}
502 {u.cost !== undefined && kv('cost', <Text color="warning">${u.cost.toFixed(2)}</Text>)}
503 </Box>,
504 )}
505
506 {g &&
507 card(
508 'branch',
509 g.branch ?? 'detached',
510 null,
511 <Box flexDirection="column">
512 {g.upstream &&
513 kv(
514 'upstream',
515 <Text>
516 <Text>{g.upstream}</Text>
517 {g.ahead > 0 && <Text color="success"> ↑{g.ahead}</Text>}
518 {g.behind > 0 && <Text color="error"> ↓{g.behind}</Text>}
519 {g.ahead === 0 && g.behind === 0 && <Text dimColor> in sync</Text>}
520 </Text>,
521 )}
522 {g.last &&
523 kv(
524 'last',
525 <Text>
526 <Text color="warning">{g.last.hash}</Text>
527 <Text> {g.last.subject}</Text>
528 <Text dimColor> · {g.last.age}</Text>
529 </Text>,
530 )}
531 {g.stashes > 0 && kv('stashes', <Text color="suggestion">{g.stashes}</Text>)}
532 {kv(
533 'changes',
534 g.files.length === 0 ? (
535 <Text color="success">✓ clean</Text>
536 ) : (
537 <Text>
538 {g.added > 0 && <Text color="success">+{g.added} added </Text>}
539 {g.modified > 0 && <Text color="warning">~{g.modified} modified </Text>}
540 {g.deleted > 0 && <Text color="error">-{g.deleted} deleted </Text>}
541 {g.untracked > 0 && <Text color="suggestion">?{g.untracked} untracked</Text>}
542 </Text>
543 ),
544 )}
545 {g.files.length > 0 && (
546 <Box flexDirection="column" marginTop={1}>
547 {g.files.slice(0, room).map(f => (
548 <Box justifyContent="space-between">
549 <Text wrap="truncate-start">
550 <Text color={FILE_COLOR[f.status] ?? 'warning'} bold={f.isStaged}>
551 {f.isStaged ? '●' : '○'} {f.status}{' '}
552 </Text>
553 <Text>{f.path}</Text>
554 </Text>
555 <Text>
556 {f.added !== undefined && <Text color="success">+{f.added}</Text>}
557 {f.removed !== undefined && <Text color="error"> -{f.removed}</Text>}
558 </Text>
559 </Box>
560 ))}
561 {g.files.length > room && <Text dimColor>… {g.files.length - room} more</Text>}
562 <Text dimColor>● staged ○ unstaged</Text>
563 </Box>
564 )}
565 </Box>,
566 )}
567 </Box>
568 )
569 })
570}
571hooks/lib.ts 107 lines1import type { Git, GitFile } from '../types'
2
3export function parseGit(repo: string, out: string): Git {
4 const g: Git = {
5 repo,
6 isWorktree: false,
7 ahead: 0,
8 behind: 0,
9 added: 0,
10 modified: 0,
11 deleted: 0,
12 untracked: 0,
13 stashes: 0,
14 files: [],
15 }
16 const numstat = new Map<string, [number, number]>()
17 const file = (xy: string, path: string): GitFile => {
18 const [x = '.', y = '.'] = xy
19 return { path, status: x !== '.' ? x : y, isStaged: x !== '.' }
20 }
21 for (const line of out.split('\n')) {
22 if (line.startsWith('# branch.head ')) {
23 const head = line.slice(14)
24 g.branch = head === '(detached)' ? undefined : head
25 } else if (line.startsWith('# branch.ab ')) {
26 const [a, b] = line.slice(12).split(' ')
27 g.ahead = Math.abs(Number(a))
28 g.behind = Math.abs(Number(b))
29 } else if (line.startsWith('# branch.upstream ')) {
30 g.upstream = line.slice(18)
31 } else if (line.startsWith('# last ')) {
32 const [hash = '', age = '', subject = ''] = line.slice(7).split('\t')
33 g.last = { hash, age, subject }
34 } else if (line.startsWith('# stash ')) {
35 g.stashes = Number(line.slice(8)) || 0
36 } else if (line.startsWith('# num ')) {
37 const [a, r, path] = line.slice(6).split('\t')
38 if (path && a !== '-') numstat.set(path, [Number(a), Number(r)])
39 } else if (line.startsWith('# gitdir ')) {
40 g.isWorktree = line.includes('/worktrees/')
41 } else if (line.startsWith('? ')) {
42 g.untracked++
43 g.files.push({ path: line.slice(2), status: '?', isStaged: false })
44 } else if (line.startsWith('1 ') || line.startsWith('2 ')) {
45 const parts = line.split(' ')
46 const xy = parts[1] ?? ''
47 const path = (line.startsWith('2 ') ? parts.slice(9) : parts.slice(8)).join(' ').split('\t')[0] ?? ''
48 g.files.push(file(xy, path))
49 if (xy.includes('D')) g.deleted++
50 else if (xy.startsWith('A')) g.added++
51 else g.modified++
52 }
53 }
54 for (const f of g.files) {
55 const n = numstat.get(f.path)
56 if (n) [f.added, f.removed] = n
57 }
58 return g
59}
60
61export function prettyModel(id: string): string {
62 const m = id.match(/^claude-([a-z]+)-(\d+)-(\d+)/)
63 if (!m) return id
64 const name = `${m[1]![0]!.toUpperCase()}${m[1]!.slice(1)} ${m[2]}.${m[3]}`
65 return id.includes('[1m]') ? `${name} (1M context)` : name
66}
67
68export function bar(percent: number, width = 10): string {
69 const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
70 return '▰'.repeat(filled) + '▱'.repeat(width - filled)
71}
72
73export function heat(percent: number): string | undefined {
74 if (percent >= 85) return 'error'
75 if (percent >= 60) return 'warning'
76 return undefined
77}
78
79export function duration(ms: number): string {
80 const s = Math.max(0, Math.floor(ms / 1000))
81 if (s < 60) return `${s}s`
82 if (s < 3600) return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}`
83 return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}`
84}
85
86export function until(iso: string | number | undefined, now: number): string {
87 if (iso === undefined) return ''
88 const left = (typeof iso === 'number' ? iso : Date.parse(iso)) - now
89 if (!(left > 0)) return ''
90 if (left >= 86_400_000) return `↓${Math.floor(left / 86_400_000)}d`
91 return `↓${duration(left)}`
92}
93
94const countLines = (v: unknown) => (typeof v === 'string' && v !== '' ? v.split('\n').length : 0)
95
96export function editDelta(e: Record<string, unknown>): { added: number; removed: number } {
97 if (e.tool === 'Write') return { added: countLines(e.content), removed: 0 }
98 const edits = Array.isArray(e.edits) ? (e.edits as Record<string, unknown>[]) : [e]
99 let added = 0
100 let removed = 0
101 for (const one of edits) {
102 added += countLines(one.new_string)
103 removed += countLines(one.old_string)
104 }
105 return { added, removed }
106}
107types/index.d.ts 58 lines1export type Task = { subject: string; status: 'pending' | 'in_progress' | 'completed' }
2
3export type GitFile = {
4 path: string
5 status: string
6 isStaged: boolean
7 added?: number
8 removed?: number
9}
10
11export type Git = {
12 repo: string
13 upstream?: string
14 last?: { hash: string; age: string; subject: string }
15 stashes: number
16 files: GitFile[]
17 isWorktree: boolean
18 branch?: string
19 ahead: number
20 behind: number
21 added: number
22 modified: number
23 deleted: number
24 untracked: number
25}
26
27export type Limit = { kind: string; percent: number; resetsAt?: string }
28
29export type Usage = { context?: number; tokens?: number; window?: number; startedAt?: number; limits: Limit[]; cost?: number }
30
31export type Agent = {
32 account?: string
33 isWork: boolean
34 model: string
35 effort?: string
36 style?: string
37}
38
39export type Work = { cwd: string; mode?: string; turns: number }
40
41export type Cache = { hit?: number; lastAt?: number; misses: number }
42
43declare module 'claude-code' {
44 interface PluginState {
45 'dash-pane': {
46 git: Git | null
47 agent: Agent
48 title: string
49 work: Work
50 tasks: Task[]
51 usage: Usage
52 cache: Cache
53 lines: { added: number; removed: number }
54 tick: number
55 }
56 }
57}
58