Session dashboard pane: workspace, git, agent, tasks, limits, cost and what the conversation is working on

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 624 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, describeCall, editDelta, duration, heat, oneLine, parseGit, prettyModel, until } from './lib'
6
7const CACHE_TTL_MS = 60 * 60 * 1000
8const MODEL = 'haiku'
9const SUMMARY_INTERVAL_MS = 10_000
10
11const git = atom({ plugin: 'dash', key: 'git' } as const, null)
12const agent = atom({ plugin: 'dash', key: 'agent' } as const, {
13 isWork: false,
14 model: '',
15})
16const title = atom({ plugin: 'dash', key: 'title' } as const, '')
17const tasks = atom({ plugin: 'dash', key: 'tasks' } as const, [])
18const usage = atom({ plugin: 'dash', key: 'usage' } as const, {
19 limits: [],
20})
21const cache = atom({ plugin: 'dash', key: 'cache' } as const, {
22 misses: 0,
23})
24const lines = atom({ plugin: 'dash', key: 'lines' } as const, {
25 added: 0,
26 removed: 0,
27})
28const tick = atom({ plugin: 'dash', key: 'tick' } as const, 0)
29const thread = atom({ plugin: 'dash', key: 'thread' } as const, '')
30const now = atom({ plugin: 'dash', key: 'now' } as const, '')
31const tool = atom({ plugin: 'dash', key: 'tool' } as const, '')
32const panesHidden = { plugin: 'toggle-mods', key: 'isHidden' } as const
33const work = atom({ plugin: 'dash', key: 'work' } as const, {
34 cwd: '',
35 turns: 0,
36})
37
38const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit'])
39const GIT_TOOLS = new Set(['Bash', 'Edit', 'Write', 'NotebookEdit'])
40const TASK_TOOLS = new Set(['TaskCreate', 'TaskUpdate'])
41const ICON = {
42 cache: '\uf06d',
43}
44// Stroke icons in a 24×24 box, drawn on surfaces whose font has no Nerd Font glyphs.
45const ICON_SVG: Record<keyof typeof ICON, string> = {
46 cache:
47 '<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"/>',
48}
49const SVG_COLOR: Record<string, string> = {
50 claude: '#d77757',
51 success: '#4eba65',
52 warning: '#ffc107',
53 dim: '#999999',
54}
55const LIMIT_LABEL: Record<string, string> = {
56 five_hour: '5h',
57 seven_day: '7d',
58}
59
60let configDir = ''
61let home = ''
62let isGitQueued = false
63let prompt = ''
64let prompts: string[] = []
65let recent: string[] = []
66let lastSummaryAt = 0
67let isSummarizing = false
68let isThreading = false
69
70async function refreshGit($: EngineInterface) {
71 const cwd = await $.session.cwd()
72 const run = await $.process.run(
73 [
74 'sh',
75 '-c',
76 'cd "$(git rev-parse --show-toplevel)" || exit 1; ' +
77 'git --no-optional-locks status --porcelain=v2 --branch || exit 1; ' +
78 'printf "# gitdir %s\\n" "$(git rev-parse --absolute-git-dir)"; ' +
79 'git log -1 --format="# last %h%x09%cr%x09%s" 2>/dev/null; ' +
80 'printf "# stash %s\\n" "$(git stash list 2>/dev/null | wc -l | tr -d " ")"; ' +
81 'git --no-optional-locks diff --numstat HEAD 2>/dev/null | sed "s/^/# num /"',
82 ],
83 { cwd, timeoutMs: 3000 },
84 )
85 const root = (await $.session.repo())?.root ?? cwd
86 const repo = root.split('/').pop() ?? root
87 await update($, git, () => (run.exitCode === 0 ? parseGit(repo, run.stdout) : null))
88}
89
90function queueGit($: EngineInterface) {
91 if (isGitQueued) return
92 isGitQueued = true
93 $.clock.after(500, () => {
94 isGitQueued = false
95 void refreshGit($)
96 })
97}
98
99async function refreshTasks($: EngineInterface) {
100 const dir = `${configDir}/tasks/${await $.session.id()}`
101 if (!(await $.fs.exists(dir))) return
102 const files = (await $.fs.list(dir)).filter(f => f.kind === 'file' && f.name.endsWith('.json'))
103 const list: Task[] = []
104 for (const f of files) {
105 try {
106 const t = JSON.parse(await $.fs.read(`${dir}/${f.name}`))
107 list.push({ subject: t.activeForm ?? t.subject ?? '', status: t.status })
108 } catch {}
109 }
110 await update($, tasks, () => list)
111}
112
113async function loadAgent($: EngineInterface) {
114 const paths = await $.process.run([
115 'sh',
116 '-c',
117 'printf "%s\\n%s\\n%s" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}" "${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json" "$HOME"',
118 ])
119 const [dir = '', configFile = '', homeDir = ''] = paths.stdout.split('\n')
120 configDir = dir
121 home = homeDir
122 let account: string | undefined
123 try {
124 account = JSON.parse(await $.fs.read(configFile)).oauthAccount?.emailAddress?.split('@')[0]
125 } catch {}
126 const model = prettyModel(await $.session.model())
127 await update($, agent, a => ({
128 ...a,
129 model,
130 account,
131 isWork: dir.includes('/.claude-work'),
132 }))
133}
134
135async function refreshTitle($: EngineInterface) {
136 const run = await $.process.run([
137 'sh',
138 '-c',
139 'f=$(ls "$1"/projects/*/"$2".jsonl 2>/dev/null | head -1); [ -n "$f" ] || exit 0; ' +
140 'grep -o \'"\\(customTitle\\|aiTitle\\)":"[^"]*"\' "$f" | tail -1 | sed \'s/.*":"//; s/"$//\'',
141 'sh',
142 configDir,
143 await $.session.id(),
144 ])
145 const found = run.stdout.trim()
146 if (found) await update($, title, () => found)
147}
148
149async function refreshWork($: EngineInterface) {
150 const cwd = await $.session.cwd()
151 const turns = await $.session.turns()
152 const run = await $.process.run([
153 'sh',
154 '-c',
155 'f=$(ls "$1"/projects/*/"$2".jsonl 2>/dev/null | head -1); [ -n "$f" ] || exit 0; ' +
156 'grep -o \'"permissionMode":"[^"]*"\' "$f" | tail -1 | sed \'s/.*":"//; s/"$//\'',
157 'sh',
158 configDir,
159 await $.session.id(),
160 ])
161 const mode = run.stdout.trim() || undefined
162 await update($, work, w => ({
163 ...w,
164 cwd: home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd,
165 turns,
166 mode: mode ?? w.mode,
167 }))
168}
169
170async function refreshStyle($: EngineInterface) {
171 const style = await $.settings.read({ key: 'outputStyle' })
172 await update($, agent, a => ({ ...a, style: typeof style === 'string' ? style : undefined }))
173}
174
175function toUsage(u: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>): Usage {
176 return {
177 context: u.context.percent,
178 tokens: u.context.tokens,
179 window: u.context.window,
180 startedAt: 'startedAt' in u ? (u as SessionUsage).startedAt : undefined,
181 limits: u.rateLimits.map(l => ({
182 kind: l.kind,
183 percent: l.percentUsed,
184 resetsAt: l.resetsAt,
185 })),
186 cost: u.cost?.usd,
187 }
188}
189
190async function refreshUsage($: EngineInterface) {
191 const u = await $.session.usage()
192 await update($, usage, () => toUsage(u))
193}
194
195async function summarizeThread($: EngineInterface) {
196 if (isThreading) return
197 isThreading = true
198 try {
199 const r = await $.model.complete({
200 model: MODEL,
201 maxTokens: 30,
202 system:
203 'You say what a coding conversation is working on right now: the thread its latest prompts share, not only the last step. ' +
204 'Keep the current line unless the work has clearly moved on. ' +
205 'Reply with one line of at most 8 words, sentence case, no quotes, no trailing period.',
206 prompt: [
207 `Current line: ${(await read($, thread)) || '(none)'}`,
208 `Developer prompts, oldest first:\n${prompts.map(p => `- ${p.slice(0, 300)}`).join('\n')}`,
209 ].join('\n\n'),
210 })
211 if (r.isAnswered) await update($, thread, () => oneLine(r.text))
212 } finally {
213 isThreading = false
214 }
215}
216
217async function summarizeNow($: EngineInterface, answer: string, isDone: boolean) {
218 const at = await $.clock.now()
219 if (isSummarizing || (!isDone && at - lastSummaryAt < SUMMARY_INTERVAL_MS)) return
220 isSummarizing = true
221 lastSummaryAt = at
222 try {
223 const r = await $.model.complete({
224 model: MODEL,
225 maxTokens: 40,
226 system:
227 'You watch a coding agent work and report its state in one line of at most 12 words. ' +
228 (isDone ? 'The turn just ended: say what it finished or is waiting for.' : 'Say what it is doing right now.') +
229 ' Present tense, no quotes, no trailing period.',
230 prompt: [
231 `Developer asked: ${prompt.slice(0, 1500)}`,
232 `Recent tool calls:\n${recent.slice(-8).join('\n') || '(none)'}`,
233 `Latest agent text: ${answer.slice(-800) || '(none)'}`,
234 ].join('\n\n'),
235 })
236 if (r.isAnswered) await update($, now, () => oneLine(r.text))
237 } finally {
238 isSummarizing = false
239 }
240}
241
242export const register: Register = on => {
243 on('session.start', async ($, e, next) => {
244 await loadAgent($)
245 void refreshTitle($)
246 void refreshWork($)
247 void refreshGit($)
248 void refreshTasks($)
249 void refreshUsage($)
250 void refreshStyle($)
251 $.clock.every(1000, () => void update($, tick, n => n + 1))
252 return next(e)
253 })
254
255 on('prompt.submit', async ($, e, next) => {
256 prompt = e.text
257 recent = []
258 lastSummaryAt = 0
259 if (e.text.trim() !== '' && !e.text.startsWith('/')) {
260 prompts = [...prompts, e.text].slice(-5)
261 void update($, now, () => 'Reading the request')
262 void summarizeThread($)
263 }
264 queueGit($)
265 void refreshUsage($)
266 void refreshWork($)
267 void $.session.model().then(m => update($, agent, a => ({ ...a, model: prettyModel(m) })))
268 return next(e)
269 })
270
271 on('turn.step', async function* ($, e, next) {
272 const result = yield* next(e)
273 if (e.agentId) return result
274 if (result.toolUses.length > 0) void summarizeNow($, result.answer, false)
275 if (e.effort !== undefined) void update($, agent, a => ({ ...a, effort: String(e.effort) }))
276 const u = result.usage
277 if (u) {
278 const at = await $.clock.now()
279 const total = u.cache_read_input_tokens + u.cache_creation_input_tokens + u.input_tokens
280 await update($, cache, c => {
281 const wasWarm = c.lastAt !== undefined && at - c.lastAt < CACHE_TTL_MS
282 const isMiss = wasWarm && u.cache_read_input_tokens === 0
283 return {
284 hit: total > 0 ? u.cache_read_input_tokens / total : c.hit,
285 lastAt: at,
286 misses: c.misses + (isMiss ? 1 : 0),
287 }
288 })
289 }
290 return result
291 })
292
293 on('tool.call', async ($, e, next) => {
294 if (!e.agentId) {
295 const call = describeCall(e as unknown as Record<string, unknown>)
296 recent = [...recent, call].slice(-20)
297 void update($, tool, () => call)
298 }
299 if (EDIT_TOOLS.has(e.tool)) {
300 const delta = editDelta(e as unknown as Record<string, unknown>)
301 const ran = await next(e)
302 if (!ran.isError)
303 void update($, lines, l => ({
304 added: l.added + delta.added,
305 removed: l.removed + delta.removed,
306 }))
307 queueGit($)
308 return ran
309 }
310 const ran = await next(e)
311 if (GIT_TOOLS.has(e.tool)) queueGit($)
312 if (!e.agentId && TASK_TOOLS.has(e.tool)) void refreshTasks($)
313 return ran
314 })
315
316 on('turn.complete', async ($, e, next) => {
317 const result = await next(e)
318 if (!e.agentId) {
319 await update($, tool, () => '')
320 void summarizeNow($, 'answer' in e ? e.answer : '', true)
321 void refreshTitle($)
322 void refreshWork($)
323 void refreshStyle($)
324 }
325 return result
326 })
327
328 on('session.measure', async ($, e, next) => {
329 await update($, usage, prev => ({
330 ...toUsage(e),
331 startedAt: prev.startedAt,
332 }))
333 return next(e)
334 })
335
336 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
337 if (e.props.hasSurvey || (await read($, panesHidden))) return next(e)
338 const els = $.ui.resolve(e)
339 const { Box, Text } = els
340 // The desktop app's font carries no Nerd Font glyphs and lays text out proportionally.
341 const isDesktop = e.surface === 'desktop'
342 const isSplit = e.props.bodyColumns >= 150
343 const below = await next(e)
344 const Svg = 'Svg' in els ? els.Svg : undefined
345 const mark = (key: keyof typeof ICON, color: string) =>
346 isDesktop && Svg ? (
347 <Svg
348 alt={key}
349 width={14}
350 height={14}
351 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>`}
352 />
353 ) : (
354 <Text color={color === 'dim' ? undefined : color} dimColor={color === 'dim'}>
355 {ICON[key]}
356 </Text>
357 )
358 const g = await read($, git)
359 const a: Agent = await read($, agent)
360 const t = await read($, title)
361 const tk = await read($, tasks)
362 const u = await read($, usage)
363 const c = await read($, cache)
364 const ln = await read($, lines)
365 const w = await read($, work)
366 const th = await read($, thread)
367 const nw = await read($, now)
368 const live = e.props.isWorking ? await read($, tool) : ''
369 await read($, tick)
370 const at = await $.clock.now()
371 const done = tk.filter(x => x.status === 'completed').length
372 const isCacheWarm = c.lastAt !== undefined && at - c.lastAt < CACHE_TTL_MS
373
374 const LABEL = 9
375 const kv = (
376 name: string,
377 value: RenderChildren,
378 wrap: 'truncate-end' | 'truncate-start' = 'truncate-end',
379 lead?: RenderChildren,
380 ) => (
381 <Box alignItems="center">
382 <Box width={LABEL} flexShrink={0}>
383 <Text color="inactive">{name}</Text>
384 </Box>
385 {lead && (
386 <Box marginRight={1} flexShrink={0}>
387 {lead}
388 </Box>
389 )}
390 <Text wrap={wrap}>{value}</Text>
391 </Box>
392 )
393
394 const gauge = (percent: number, width: number, fill = 'rate_limit_fill') => {
395 if (isDesktop) {
396 const pct = Math.min(100, Math.max(0, percent))
397 return (
398 <Box width={width} height={1} flexShrink={0} alignItems="center">
399 <Box width="100%" height={0.4} backgroundColor="rate_limit_empty" overflow="hidden">
400 <Box width={`${pct}%`} backgroundColor={fill} />
401 </Box>
402 </Box>
403 )
404 }
405 const b = bar(percent, width)
406 const n = b.lastIndexOf('▰') + 1
407 return (
408 <Text>
409 <Text color={fill}>{b.slice(0, n)}</Text>
410 <Text color="rate_limit_empty">{b.slice(n)}</Text>
411 </Text>
412 )
413 }
414
415 const meter = (name: string, percent: number, extra: string) => (
416 <Box flexShrink={0}>
417 <Text dimColor>{name} </Text>
418 {gauge(percent, 6, heat(percent))}
419 <Text bold> {String(Math.round(percent)).padStart(3)}%</Text>
420 {extra !== '' && <Text dimColor> {extra}</Text>}
421 </Box>
422 )
423
424 const label = (name: string) => (
425 <Box width={LABEL} flexShrink={0}>
426 <Text color="inactive">{name}</Text>
427 </Box>
428 )
429 const dot = <Text dimColor> · </Text>
430 const modelParts = [
431 a.account && (
432 <Text>
433 <Text color="suggestion">{a.account}</Text>
434 {a.isWork && (
435 <Text color="error" bold>
436 {' '}
437 ⚠ WORK
438 </Text>
439 )}
440 </Text>
441 ),
442 a.effort && <Text color="warning">{a.effort}</Text>,
443 w.mode && <Text>{w.mode}</Text>,
444 a.style && a.style !== 'default' && <Text>{a.style}</Text>,
445 <Text>{w.turns} turns</Text>,
446 ].filter(Boolean)
447 const current = tk.find(x => x.status === 'in_progress')
448
449 const lead = t.length + th.length + (t !== '' && th !== '' ? 3 : 0)
450 const room = e.props.bodyColumns - 4 - lead
451 const isNowShown = nw !== '' && (lead === 0 || room >= nw.length + 3)
452 const isLiveShown = live !== '' && isNowShown && room >= nw.length + live.length + 6
453
454 return (
455 <Box flexDirection="column">
456 {below}
457 <Text dimColor wrap="truncate-end">
458 {'╌'.repeat(e.props.bodyColumns)}
459 </Text>
460 <Box flexDirection={isSplit ? 'row' : 'column'} paddingX={1}>
461 <Box
462 flexDirection="column"
463 flexShrink={0}
464 width={isSplit ? Math.floor(e.props.bodyColumns / 2) : undefined}
465 paddingRight={isSplit ? 2 : 0}
466 >
467 <Box>
468 {label('repo')}
469 <Box flexShrink={0}>
470 <Text color="claude" bold>
471 {g?.repo ?? '-'}
472 </Text>
473 {g?.isWorktree && <Text color="suggestion"> worktree</Text>}
474 <Text> </Text>
475 </Box>
476 <Text wrap="truncate-start">{w.cwd}</Text>
477 {u.startedAt !== undefined && (
478 <Box flexShrink={0}>
479 <Text dimColor> · {duration(at - u.startedAt)}</Text>
480 </Box>
481 )}
482 </Box>
483 {(u.context !== undefined || u.limits.length > 0) && (
484 <Box>
485 {label('usage')}
486 <Box gap={3} overflow="hidden">
487 {u.context !== undefined &&
488 meter(
489 'ctx',
490 u.context,
491 u.tokens !== undefined && u.window !== undefined
492 ? `${Math.round(u.tokens / 1000)}k/${u.window >= 1_000_000 ? `${u.window / 1_000_000}M` : `${Math.round(u.window / 1000)}k`}`
493 : '',
494 )}
495 {u.limits.map(l => meter(LIMIT_LABEL[l.kind] ?? l.kind, l.percent, until(l.resetsAt, at)))}
496 </Box>
497 </Box>
498 )}
499 {kv(
500 'cache',
501 <Text>
502 <Text>{c.hit !== undefined ? `${Math.round(c.hit * 100)}% hit` : '–'}</Text>
503 <Text dimColor>
504 {' '}
505 · {isCacheWarm && c.lastAt ? `expires ${until(c.lastAt + CACHE_TTL_MS, at)}` : 'cold'}
506 </Text>
507 {c.misses > 0 && <Text color="error"> · {c.misses} miss</Text>}
508 </Text>,
509 'truncate-end',
510 mark('cache', isCacheWarm ? 'warning' : 'dim'),
511 )}
512 {kv(
513 'spend',
514 <Text>
515 {u.cost !== undefined && <Text color="warning">${u.cost.toFixed(2)}</Text>}
516 {u.cost !== undefined && dot}
517 <Text color="success">+{ln.added}</Text>
518 <Text dimColor>/</Text>
519 <Text color="error">-{ln.removed}</Text>
520 <Text dimColor> lines</Text>
521 </Text>,
522 )}
523 </Box>
524
525 <Box flexDirection="column" flexGrow={1}>
526 {kv(
527 'model',
528 <Text>
529 <Text color="success" bold>
530 {a.model}
531 </Text>
532 <Text> </Text>
533 {modelParts.map((p, i) => (
534 <Text>
535 {i > 0 && dot}
536 {p}
537 </Text>
538 ))}
539 </Text>,
540 )}
541 {g &&
542 kv(
543 'branch',
544 <Text>
545 <Text color="claude" bold>
546 {g.branch ?? 'detached'}
547 </Text>
548 {g.upstream && <Text> {g.upstream}</Text>}
549 {g.ahead > 0 && <Text color="success"> ↑{g.ahead}</Text>}
550 {g.behind > 0 && <Text color="error"> ↓{g.behind}</Text>}
551 {g.upstream && g.ahead === 0 && g.behind === 0 && <Text dimColor> in sync</Text>}
552 {g.stashes > 0 && dot}
553 {g.stashes > 0 && <Text color="suggestion">{g.stashes} stashes</Text>}
554 </Text>,
555 )}
556 {g?.last &&
557 kv(
558 'last',
559 <Text>
560 <Text color="warning">{g.last.hash}</Text>
561 <Text> {g.last.subject}</Text>
562 <Text dimColor> · {g.last.age}</Text>
563 </Text>,
564 )}
565 {g &&
566 kv(
567 'changes',
568 g.files.length === 0 ? (
569 <Text color="success">✓ clean</Text>
570 ) : (
571 <Text>
572 {g.added > 0 && <Text color="success">+{g.added} added </Text>}
573 {g.modified > 0 && <Text color="warning">~{g.modified} modified </Text>}
574 {g.deleted > 0 && <Text color="error">-{g.deleted} deleted </Text>}
575 {g.untracked > 0 && <Text color="suggestion">?{g.untracked} untracked</Text>}
576 </Text>
577 ),
578 )}
579 </Box>
580 </Box>
581 {tk.length > 0 && done < tk.length && (
582 <Box paddingX={1}>
583 {label('tasks')}
584 {gauge((done / tk.length) * 100, 6)}
585 <Text bold>
586 {' '}
587 {done}/{tk.length}
588 </Text>
589 {current && (
590 <Text color="warning" wrap="truncate-end">
591 {' ▸ '}
592 {current.subject}
593 </Text>
594 )}
595 </Box>
596 )}
597 {(lead > 0 || nw !== '') && (
598 <Box paddingX={1}>
599 <Text wrap="truncate-end">
600 <Text color="claude" bold>
601 ▸{' '}
602 </Text>
603 {t !== '' && (
604 <Text color="suggestion" italic>
605 {t}
606 </Text>
607 )}
608 {t !== '' && th !== '' && dot}
609 {th !== '' && <Text bold>{th}</Text>}
610 {isNowShown && (
611 <Text dimColor>
612 {lead > 0 ? ' · ' : ''}
613 {nw}
614 </Text>
615 )}
616 {isLiveShown && <Text dimColor> ↳ {live}</Text>}
617 </Text>
618 </Box>
619 )}
620 </Box>
621 )
622 })
623}
624hooks/lib.ts 118 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}
107
108export function describeCall(e: Record<string, unknown>): string {
109 const arg =
110 [e.file_path, e.path, e.notebook_path].find(v => typeof v === 'string')?.toString().split('/').pop() ??
111 [e.description, e.pattern, e.query, e.subject, e.command, e.url, e.skill].find(v => typeof v === 'string')
112 return `${e.tool}${arg ? ` ${String(arg).split('\n')[0]}` : ''}`.slice(0, 80)
113}
114
115export function oneLine(text: string): string {
116 return (text.split('\n').find(l => l.trim() !== '') ?? '').trim().replace(/[.。]$/, '')
117}
118types/index.d.ts 61 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: {
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 thread: string
56 now: string
57 tool: string
58 }
59 }
60}
61