claude-code-hud 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/tui/hud.tsx ADDED
@@ -0,0 +1,645 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * HUD Live — Ink TUI
4
+ * Run: npm run hud (from hud-plugin root)
5
+ */
6
+ import React, { useState, useEffect, useCallback } from 'react';
7
+ import { render, Box, Text, useStdout, useInput } from 'ink';
8
+ import { fileURLToPath } from 'url';
9
+ import { dirname, join } from 'path';
10
+ import fs from 'fs';
11
+ import os from 'os';
12
+
13
+ const __dir = dirname(fileURLToPath(import.meta.url));
14
+ const { readTokenUsage, readTokenHistory } = await import(join(__dir, '../scripts/lib/token-reader.mjs'));
15
+ const { readGitInfo } = await import(join(__dir, '../scripts/lib/git-info.mjs'));
16
+ const { getUsage, getUsageSync } = await import(join(__dir, '../scripts/lib/usage-api.mjs'));
17
+
18
+ // Clear terminal before starting
19
+ process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
20
+
21
+ const SESSION_START = Date.now();
22
+
23
+ // ── Themes ─────────────────────────────────────────────────────────────────
24
+ const DARK = {
25
+ brand: '#3182F6', text: '#E6EDF3', dim: '#8B949E', dimmer: '#6E7681',
26
+ border: '#30363D', green: '#3FB950', yellow: '#D29922', red: '#F85149',
27
+ purple: '#A371F7', cyan: '#58A6FF',
28
+ };
29
+ const LIGHT = {
30
+ brand: '#3182F6', text: '#1F2328', dim: '#656D76', dimmer: '#8C959F',
31
+ border: '#D8DEE4', green: '#1A7F37', yellow: '#9A6700', red: '#CF222E',
32
+ purple: '#8250DF', cyan: '#0969DA',
33
+ };
34
+
35
+ // ── Helpers ────────────────────────────────────────────────────────────────
36
+ const fmtNum = (n: number) =>
37
+ n >= 1_000_000 ? (n / 1_000_000).toFixed(1) + 'M' :
38
+ n >= 1_000 ? (n / 1_000).toFixed(1) + 'K' : String(n);
39
+
40
+ const fmtCost = (n: number) => '$' + n.toFixed(n >= 1 ? 2 : 4);
41
+
42
+ const costColor = (n: number, C: typeof DARK) =>
43
+ n >= 1 ? C.red : n >= 0.1 ? C.yellow : C.green;
44
+
45
+ const fmtSince = (ms: number) => {
46
+ const s = Math.floor(ms / 1000);
47
+ if (s < 5) return 'just now';
48
+ if (s < 60) return s + 's ago';
49
+ const m = Math.floor(s / 60);
50
+ return m < 60 ? m + 'm ago' : Math.floor(m / 60) + 'h ago';
51
+ };
52
+
53
+ const modelShort = (m: string) =>
54
+ m.replace('claude-', '').replace(/-202\d+(-\d+)?$/, '');
55
+
56
+ const SPARK_CHARS = ' ▁▂▃▄▅▆▇█';
57
+ function sparkline(buckets: number[]): string {
58
+ const max = Math.max(...buckets, 1);
59
+ return buckets.map(v => SPARK_CHARS[Math.round((v / max) * 8)]).join('');
60
+ }
61
+
62
+ // ── Directory tree types ────────────────────────────────────────────────────
63
+ type DirNode = {
64
+ name: string;
65
+ path: string;
66
+ fileCount: number; // direct files only
67
+ totalFiles: number; // recursive total
68
+ children: DirNode[];
69
+ expanded: boolean;
70
+ };
71
+
72
+ type FlatNode = {
73
+ node: DirNode;
74
+ depth: number;
75
+ };
76
+
77
+ // ── Project scanner ────────────────────────────────────────────────────────
78
+ type ProjectInfo = {
79
+ totalFiles: number;
80
+ byExt: Record<string, number>;
81
+ packages: { name: string; version: string; depth: number }[];
82
+ endpoints: Record<string, number>;
83
+ dirTree: DirNode;
84
+ };
85
+
86
+ async function scanProject(cwd: string): Promise<ProjectInfo> {
87
+ const { default: fg } = await import('fast-glob');
88
+
89
+ // File counts by extension
90
+ const files: string[] = await fg('**/*', {
91
+ cwd, ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'],
92
+ onlyFiles: true, dot: false,
93
+ });
94
+
95
+ const byExt: Record<string, number> = {};
96
+ for (const f of files) {
97
+ const ext = f.includes('.') ? '.' + f.split('.').pop()! : 'other';
98
+ byExt[ext] = (byExt[ext] || 0) + 1;
99
+ }
100
+
101
+ // Build directory tree
102
+ function buildTree(filePaths: string[]): DirNode {
103
+ const root: DirNode = { name: '.', path: '', fileCount: 0, totalFiles: 0, children: [], expanded: true };
104
+ for (const file of filePaths) {
105
+ const parts = file.split('/');
106
+ let cur = root;
107
+ for (let i = 0; i < parts.length - 1; i++) {
108
+ const seg = parts[i];
109
+ let child = cur.children.find(c => c.name === seg);
110
+ if (!child) {
111
+ child = { name: seg, path: parts.slice(0, i + 1).join('/'), fileCount: 0, totalFiles: 0, children: [], expanded: false };
112
+ cur.children.push(child);
113
+ }
114
+ cur = child;
115
+ }
116
+ cur.fileCount++;
117
+ }
118
+ function calcTotal(n: DirNode): number {
119
+ n.totalFiles = n.fileCount + n.children.reduce((s, c) => s + calcTotal(c), 0);
120
+ return n.totalFiles;
121
+ }
122
+ calcTotal(root);
123
+ return root;
124
+ }
125
+ const dirTree = buildTree(files);
126
+
127
+ // Packages from package.json files
128
+ const pkgFiles: string[] = await fg('**/package.json', {
129
+ cwd, ignore: ['**/node_modules/**', '**/.git/**'], depth: 3,
130
+ });
131
+ const packages: ProjectInfo['packages'] = [];
132
+ for (const pf of pkgFiles.slice(0, 1)) {
133
+ try {
134
+ const pkg = JSON.parse(fs.readFileSync(join(cwd, pf), 'utf8'));
135
+ if (pkg.name) packages.push({ name: pkg.name, version: pkg.version || '?', depth: 0 });
136
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
137
+ for (const [n, v] of Object.entries(deps).slice(0, 8)) {
138
+ packages.push({ name: n, version: String(v).replace(/[\^~]/, ''), depth: 1 });
139
+ }
140
+ } catch {}
141
+ }
142
+
143
+ // Endpoint detection
144
+ const srcFiles: string[] = await fg('**/*.{ts,tsx,js,jsx,py,java,go}', {
145
+ cwd, ignore: ['**/node_modules/**', '**/.git/**', '**/*.test.*', '**/*.spec.*'], onlyFiles: true,
146
+ });
147
+ const endpoints: Record<string, number> = { GET: 0, POST: 0, PUT: 0, DELETE: 0, PATCH: 0 };
148
+ const PATTERNS: [string, RegExp][] = [
149
+ ['GET', /\.(get|GetMapping)\s*[(['"\/]/gi],
150
+ ['POST', /\.(post|PostMapping)\s*[(['"\/]/gi],
151
+ ['PUT', /\.(put|PutMapping)\s*[(['"\/]/gi],
152
+ ['DELETE', /\.(delete|DeleteMapping)\s*[(['"\/]/gi],
153
+ ['PATCH', /\.(patch|PatchMapping)\s*[(['"\/]/gi],
154
+ ];
155
+ for (const sf of srcFiles.slice(0, 100)) {
156
+ try {
157
+ const src = fs.readFileSync(join(cwd, sf), 'utf8');
158
+ for (const [method, re] of PATTERNS) {
159
+ endpoints[method] += (src.match(re) || []).length;
160
+ }
161
+ } catch {}
162
+ }
163
+
164
+ return { totalFiles: files.length, byExt, packages, endpoints, dirTree };
165
+ }
166
+
167
+ // ── flatten visible tree nodes ──────────────────────────────────────────────
168
+ function flattenTree(node: DirNode, depth: number, expanded: Record<string, boolean>): FlatNode[] {
169
+ const result: FlatNode[] = [];
170
+ const sorted = [...node.children].sort((a, b) => b.totalFiles - a.totalFiles);
171
+ for (const child of sorted) {
172
+ result.push({ node: child, depth });
173
+ const isExp = expanded[child.path] ?? false;
174
+ if (isExp && child.children.length > 0) {
175
+ result.push(...flattenTree(child, depth + 1, expanded));
176
+ }
177
+ }
178
+ return result;
179
+ }
180
+
181
+ // ── UI Components ──────────────────────────────────────────────────────────
182
+ function Bar({ ratio, width, color, C }: { ratio: number; width: number; color: string; C: typeof DARK }) {
183
+ const filled = Math.max(0, Math.min(width, Math.round(ratio * width)));
184
+ return (
185
+ <>
186
+ <Text color={color}>{'█'.repeat(filled)}</Text>
187
+ <Text color={C.border}>{'░'.repeat(filled < width ? width - filled : 0)}</Text>
188
+ </>
189
+ );
190
+ }
191
+
192
+ function Section({ title, children, C, accent }: { title: string; children: React.ReactNode; C: typeof DARK; accent?: string }) {
193
+ return (
194
+ <Box flexDirection="column" borderStyle="single" borderColor={C.border} paddingX={1} marginBottom={0}>
195
+ <Text color={accent ?? C.dim} bold>{title}</Text>
196
+ <Box flexDirection="column">{children}</Box>
197
+ </Box>
198
+ );
199
+ }
200
+
201
+ // ── Tab 1: TOKENS ──────────────────────────────────────────────────────────
202
+ function TokensTab({ usage, history, rateLimits, termWidth, C }: any) {
203
+ const ctxPct = usage.contextWindow > 0 ? usage.totalTokens / usage.contextWindow : 0;
204
+ const ctxColor = ctxPct > 0.85 ? C.red : ctxPct > 0.65 ? C.yellow : C.brand;
205
+ const ctxLabel = ctxPct > 0.85 ? 'WARN' : ctxPct > 0.65 ? 'MID' : 'OK';
206
+ const ctxLabelC = ctxPct > 0.85 ? C.red : ctxPct > 0.65 ? C.yellow : C.green;
207
+ const CTX_BAR = Math.max(20, Math.min(44, termWidth - 32));
208
+
209
+ const maxTok = Math.max(usage.inputTokens, usage.outputTokens, usage.cacheReadTokens, usage.cacheWriteTokens, 1);
210
+ const BAR_W = Math.max(12, Math.min(24, termWidth - 54));
211
+
212
+ const spark = sparkline(history.hourlyBuckets);
213
+
214
+ const totalTok = (w: any) =>
215
+ w.inputTokens + w.outputTokens + w.cacheReadTokens + w.cacheWriteTokens;
216
+
217
+ return (
218
+ <Box flexDirection="column">
219
+ {/* Context Window */}
220
+ <Section title="CONTEXT WINDOW" C={C}>
221
+ <Box>
222
+ <Bar ratio={ctxPct} width={CTX_BAR} color={ctxColor} C={C} />
223
+ <Text color={ctxColor} bold> {Math.round(ctxPct * 100)}%</Text>
224
+ <Text color={C.dim}> {fmtNum(usage.totalTokens)} / {fmtNum(usage.contextWindow)}</Text>
225
+ <Text color={ctxLabelC} bold> {ctxLabel}</Text>
226
+ </Box>
227
+ </Section>
228
+
229
+ {/* Usage windows — real data from Anthropic OAuth API */}
230
+ {(() => {
231
+ const WIN_BAR = Math.max(14, Math.min(28, termWidth - 38));
232
+ const hasApi = rateLimits != null;
233
+ const pct5h = hasApi ? rateLimits.fiveHourPercent : null;
234
+ const pctWk = hasApi ? rateLimits.weeklyPercent : null;
235
+
236
+ const color5h = pct5h != null ? (pct5h > 80 ? C.red : pct5h > 50 ? C.yellow : C.brand) : C.brand;
237
+ const colorWk = pctWk != null ? (pctWk > 80 ? C.red : pctWk > 50 ? C.yellow : C.brand) : C.brand;
238
+
239
+ const fmtReset = (d: Date | null) => {
240
+ if (!d) return '';
241
+ const mins = Math.round((d.getTime() - Date.now()) / 60000);
242
+ if (mins <= 0) return ' resets soon';
243
+ if (mins < 60) return ` resets in ${mins}m`;
244
+ return ` resets in ${Math.round(mins / 60)}h`;
245
+ };
246
+
247
+ return (
248
+ <Section title={hasApi ? "USAGE WINDOW (Anthropic API)" : "USAGE WINDOW (from JSONL)"} C={C} accent={hasApi ? C.green : C.dim}>
249
+ <Box>
250
+ <Text color={C.dim}>5h </Text>
251
+ <Bar ratio={(pct5h ?? 0) / 100} width={WIN_BAR} color={color5h} C={C} />
252
+ <Text color={color5h} bold> {pct5h != null ? pct5h.toFixed(1) : '--'}%</Text>
253
+ {rateLimits?.fiveHourResetsAt && (
254
+ <Text color={C.dimmer}>{fmtReset(rateLimits.fiveHourResetsAt)}</Text>
255
+ )}
256
+ </Box>
257
+ <Box>
258
+ <Text color={C.dim}>wk </Text>
259
+ <Bar ratio={(pctWk ?? 0) / 100} width={WIN_BAR} color={colorWk} C={C} />
260
+ <Text color={colorWk} bold> {pctWk != null ? pctWk.toFixed(1) : '--'}%</Text>
261
+ {rateLimits?.weeklyResetsAt && (
262
+ <Text color={C.dimmer}>{fmtReset(rateLimits.weeklyResetsAt)}</Text>
263
+ )}
264
+ </Box>
265
+ {!hasApi && (
266
+ <Text color={C.dimmer}> ⚠ OAuth unavailable — run `claude` to authenticate</Text>
267
+ )}
268
+ </Section>
269
+ );
270
+ })()}
271
+
272
+ {/* Token breakdown */}
273
+ <Section title="TOKENS (this session)" C={C}>
274
+ {[
275
+ { label: 'input', tokens: usage.inputTokens, color: C.brand },
276
+ { label: 'output', tokens: usage.outputTokens, color: C.purple },
277
+ { label: 'cache-read', tokens: usage.cacheReadTokens, color: C.cyan },
278
+ { label: 'cache-write', tokens: usage.cacheWriteTokens, color: C.green },
279
+ ].map(({ label, tokens, color }) => {
280
+ const pct = maxTok > 0 ? Math.round(tokens / maxTok * 100) : 0;
281
+ return (
282
+ <Box key={label}>
283
+ <Box width={14}><Text color={C.dim}>{label}</Text></Box>
284
+ <Box width={BAR_W}><Bar ratio={maxTok > 0 ? tokens / maxTok : 0} width={BAR_W} color={color} C={C} /></Box>
285
+ <Box width={9} justifyContent="flex-end"><Text color={C.text}> {fmtNum(tokens)}</Text></Box>
286
+ <Box width={5} justifyContent="flex-end"><Text color={C.dimmer}> {pct}%</Text></Box>
287
+ </Box>
288
+ );
289
+ })}
290
+ </Section>
291
+
292
+ {/* Sparkline */}
293
+ <Section title="OUTPUT TOKENS / HR" C={C}>
294
+ <Text color={C.brand}>{spark}</Text>
295
+ <Box justifyContent="space-between">
296
+ <Text color={C.dimmer}>12h ago</Text>
297
+ <Text color={C.dimmer}>now</Text>
298
+ </Box>
299
+ </Section>
300
+ </Box>
301
+ );
302
+ }
303
+
304
+ // ── Tab 2: PROJECT ─────────────────────────────────────────────────────────
305
+ function ProjectTab({ info, treeCursor, treeExpanded, termWidth, C }: any) {
306
+ if (!info) return (
307
+ <Box borderStyle="single" borderColor={C.border} paddingX={1}>
308
+ <Text color={C.dimmer}>scanning project…</Text>
309
+ </Box>
310
+ );
311
+
312
+ // Flatten visible tree using treeExpanded from props (closure)
313
+ function flatNodes_inner(node: DirNode, depth: number): FlatNode[] {
314
+ const result: FlatNode[] = [];
315
+ const sorted = [...node.children].sort((a, b) => b.totalFiles - a.totalFiles);
316
+ for (const child of sorted) {
317
+ result.push({ node: child, depth });
318
+ const isExp = treeExpanded[child.path] ?? false;
319
+ if (isExp && child.children.length > 0) {
320
+ result.push(...flatNodes_inner(child, depth + 1));
321
+ }
322
+ }
323
+ return result;
324
+ }
325
+
326
+ const flatNodes = info.dirTree ? flatNodes_inner(info.dirTree, 0) : [];
327
+ const safeCursor = Math.min(treeCursor, Math.max(0, flatNodes.length - 1));
328
+
329
+ const EXT_LABELS: Record<string, string> = {
330
+ '.ts': 'TypeScript', '.tsx': 'TypeScript', '.js': 'JavaScript', '.jsx': 'JavaScript',
331
+ '.py': 'Python', '.go': 'Go', '.java': 'Java', '.rs': 'Rust',
332
+ '.json': 'JSON', '.md': 'Markdown', '.css': 'CSS', '.html': 'HTML',
333
+ };
334
+ const extGroups: Record<string, number> = {};
335
+ for (const [ext, cnt] of Object.entries(info.byExt as Record<string, number>)) {
336
+ const label = EXT_LABELS[ext] || 'Other';
337
+ extGroups[label] = (extGroups[label] || 0) + cnt;
338
+ }
339
+ const sortedExts = Object.entries(extGroups).sort((a, b) => b[1] - a[1]).slice(0, 4);
340
+ const totalEndpoints = Object.values(info.endpoints as Record<string, number>).reduce((a: number, b: number) => a + b, 0);
341
+ const langs = sortedExts.slice(0, 2).map(([l]) => l).join(' / ');
342
+
343
+ return (
344
+ <Box flexDirection="column">
345
+ {/* Summary bar */}
346
+ <Box borderStyle="single" borderColor={C.border} paddingX={1}>
347
+ <Text color={C.text} bold>{info.totalFiles} files</Text>
348
+ <Text color={C.dim}> │ </Text>
349
+ <Text color={C.text} bold>{info.packages.filter((p: any) => p.depth === 0).length} packages</Text>
350
+ <Text color={C.dim}> │ </Text>
351
+ <Text color={C.text} bold>~{totalEndpoints} endpoints</Text>
352
+ <Text color={C.dim}> │ {langs}</Text>
353
+ </Box>
354
+
355
+ {/* Directory tree */}
356
+ <Box flexDirection="column" borderStyle="single" borderColor={C.border} paddingX={1}>
357
+ <Text color={C.dim} bold>TREE <Text color={C.dimmer}>[j/k] move [enter/→←] expand</Text></Text>
358
+ {flatNodes.length === 0 && <Text color={C.dimmer}> (empty)</Text>}
359
+ {flatNodes.map((fn, idx) => {
360
+ const isSelected = idx === safeCursor;
361
+ const isExp = treeExpanded[fn.node.path] ?? false;
362
+ const hasChildren = fn.node.children.length > 0;
363
+ const indent = ' '.repeat(fn.depth);
364
+ const expIcon = hasChildren ? (isExp ? '▼ ' : '▶ ') : ' ';
365
+ const nameColor = isSelected ? C.brand : fn.depth === 0 ? C.text : C.dim;
366
+ return (
367
+ <Box key={`${fn.node.path}__${idx}`}>
368
+ <Text color={C.dimmer}>{indent}</Text>
369
+ <Text color={isSelected ? C.brand : C.dimmer}>{expIcon}</Text>
370
+ <Text color={nameColor} bold={isSelected}>{fn.node.name}/</Text>
371
+ <Text color={C.dimmer}> {fn.node.totalFiles}f</Text>
372
+ {isSelected && fn.node.fileCount > 0 && (
373
+ <Text color={C.dimmer}> ({fn.node.fileCount} direct)</Text>
374
+ )}
375
+ </Box>
376
+ );
377
+ })}
378
+ </Box>
379
+
380
+ {/* Packages */}
381
+ <Box flexDirection="column" borderStyle="single" borderColor={C.border} paddingX={1}>
382
+ <Text color={C.dim} bold>PACKAGES</Text>
383
+ {info.packages.slice(0, 12).map((p: any, i: number) => {
384
+ const isRoot = p.depth === 0;
385
+ const nextIsRoot = i + 1 < info.packages.length && info.packages[i + 1].depth === 0;
386
+ const isLastInGroup = nextIsRoot || i === Math.min(11, info.packages.length - 1);
387
+ const prefix = isRoot ? '' : (isLastInGroup ? '└─ ' : '├─ ');
388
+ return (
389
+ <Box key={i}>
390
+ <Text color={C.dimmer}>{isRoot ? '' : ' '}{prefix}</Text>
391
+ <Text color={isRoot ? C.brand : C.text}>{p.name}</Text>
392
+ <Text color={C.dimmer}> {p.version}</Text>
393
+ </Box>
394
+ );
395
+ })}
396
+ </Box>
397
+ </Box>
398
+ );
399
+ }
400
+
401
+ // ── Tab 3: GIT ─────────────────────────────────────────────────────────────
402
+ function GitTab({ git, C, termWidth }: any) {
403
+ const gitFiles = [
404
+ ...(git.modified ?? []).map((f: string) => ({ status: 'MOD', path: f })),
405
+ ...(git.added ?? []).map((f: string) => ({ status: 'ADD', path: f })),
406
+ ...(git.deleted ?? []).map((f: string) => ({ status: 'DEL', path: f })),
407
+ ].slice(0, 10);
408
+
409
+ const diffMaxLen = Math.max(8, Math.min(16, termWidth - 20));
410
+
411
+ return (
412
+ <Box flexDirection="column">
413
+ {/* Branch */}
414
+ <Box borderStyle="single" borderColor={C.border} paddingX={1}>
415
+ <Text color={C.dim} bold>GIT </Text>
416
+ <Text color={C.brand} bold>⎇ {git.branch ?? 'unknown'}</Text>
417
+ {(git.ahead ?? 0) > 0 && <Text color={C.green}> ↑{git.ahead}</Text>}
418
+ {(git.behind ?? 0) > 0 && <Text color={C.red}> ↓{git.behind}</Text>}
419
+ {(git.totalChanges ?? 0) === 0 && <Text color={C.dimmer}> clean</Text>}
420
+ </Box>
421
+
422
+ {/* Changed files */}
423
+ {gitFiles.length > 0 && (
424
+ <Section title="CHANGES" C={C}>
425
+ {gitFiles.map((f, i) => {
426
+ const color = f.status === 'MOD' ? C.yellow : f.status === 'ADD' ? C.green : C.red;
427
+ const sym = f.status === 'MOD' ? 'M' : f.status === 'ADD' ? 'A' : 'D';
428
+ return (
429
+ <Text key={i} color={color}>{sym} <Text color={C.dim}>{f.path}</Text></Text>
430
+ );
431
+ })}
432
+ </Section>
433
+ )}
434
+
435
+ {/* Diff visualization — real +/- counts */}
436
+ {gitFiles.length > 0 && (
437
+ <Section title="DIFF" C={C}>
438
+ {gitFiles.slice(0, 6).map((f, i) => {
439
+ const stat = (git.diffStats ?? {})[f.path];
440
+ const totalLines = stat ? stat.add + stat.del : 0;
441
+ const maxDiff = Math.max(...gitFiles.slice(0, 6).map((ff: any) => {
442
+ const s = (git.diffStats ?? {})[ff.path];
443
+ return s ? s.add + s.del : (ff.status !== 'MOD' ? 10 : 5);
444
+ }), 1);
445
+ const barTotal = diffMaxLen;
446
+ const addLen = stat
447
+ ? Math.round((stat.add / maxDiff) * barTotal)
448
+ : f.status === 'ADD' ? barTotal : f.status === 'MOD' ? Math.round(barTotal * 0.6) : 0;
449
+ const delLen = stat
450
+ ? Math.round((stat.del / maxDiff) * barTotal)
451
+ : f.status === 'DEL' ? barTotal : f.status === 'MOD' ? Math.round(barTotal * 0.3) : 0;
452
+ const name = f.path.length > 22 ? '…' + f.path.slice(-21) : f.path;
453
+ return (
454
+ <Box key={i}>
455
+ <Box width={24}><Text color={C.dimmer}>{name}</Text></Box>
456
+ <Text color={C.green}>{'▐'.repeat(addLen)}</Text>
457
+ <Text color={C.red}>{'▌'.repeat(delLen)}</Text>
458
+ {stat && <Text color={C.dimmer}> +{stat.add} -{stat.del}</Text>}
459
+ </Box>
460
+ );
461
+ })}
462
+ </Section>
463
+ )}
464
+
465
+ {/* Recent commits */}
466
+ <Section title="RECENT COMMITS" C={C}>
467
+ {(git.recentCommits ?? []).slice(0, 5).map((c: any, i: number) => (
468
+ <Box key={i}>
469
+ <Text color={C.brand}>{c.hash} </Text>
470
+ <Box flexGrow={1}>
471
+ <Text color={C.text}>{(c.msg ?? '').slice(0, Math.max(20, termWidth - 32))}</Text>
472
+ </Box>
473
+ <Text color={C.dimmer}> {c.time}</Text>
474
+ </Box>
475
+ ))}
476
+ {(git.recentCommits ?? []).length === 0 && <Text color={C.dimmer}> no commits</Text>}
477
+ </Section>
478
+ </Box>
479
+ );
480
+ }
481
+
482
+ // ── Main App ───────────────────────────────────────────────────────────────
483
+ function App() {
484
+ const { stdout } = useStdout();
485
+ const [termWidth, setTermWidth] = useState(stdout?.columns ?? 80);
486
+ const [tab, setTab] = useState(0); // 0=TOKENS 1=PROJECT 2=GIT
487
+ const [dark, setDark] = useState(true);
488
+ const [scrollY, setScrollY] = useState(0);
489
+ const [tick, setTick] = useState(0);
490
+ const [updatedAt, setUpdatedAt] = useState(Date.now());
491
+
492
+ const cwd = process.env.CLAUDE_PROJECT_ROOT || process.cwd();
493
+ const C = dark ? DARK : LIGHT;
494
+
495
+ const [usage, setUsage] = useState<any>(readTokenUsage());
496
+ const [history, setHistory] = useState<any>(readTokenHistory());
497
+ const [git, setGit] = useState<any>(readGitInfo(cwd));
498
+ const [project, setProject] = useState<ProjectInfo | null>(null);
499
+ const [rateLimits, setRateLimits] = useState<any>(getUsageSync());
500
+
501
+ // Tree navigation state
502
+ const [treeCursor, setTreeCursor] = useState(0);
503
+ const [treeExpanded, setTreeExpanded] = useState<Record<string, boolean>>({});
504
+
505
+ const refresh = useCallback(() => {
506
+ setUsage(readTokenUsage());
507
+ setHistory(readTokenHistory());
508
+ setGit(readGitInfo(cwd));
509
+ setUpdatedAt(Date.now());
510
+ getUsage().then(setRateLimits).catch(() => {});
511
+ }, [cwd]);
512
+
513
+ useEffect(() => {
514
+ // Scan project once
515
+ scanProject(cwd).then(setProject).catch(() => {});
516
+ // Initial API usage fetch
517
+ getUsage().then(setRateLimits).catch(() => {});
518
+
519
+ const onResize = () => setTermWidth(stdout?.columns ?? 80);
520
+ stdout?.on('resize', onResize);
521
+
522
+ const poll = setInterval(refresh, 3000);
523
+
524
+ const projectsDir = join(os.homedir(), '.claude', 'projects');
525
+ let watcher: any = null;
526
+ if (fs.existsSync(projectsDir)) {
527
+ import('chokidar').then(({ default: chokidar }) => {
528
+ watcher = chokidar.watch(projectsDir, {
529
+ depth: 2, persistent: true, ignoreInitial: true,
530
+ ignored: (p: string) => !p.endsWith('.jsonl'),
531
+ });
532
+ watcher.on('change', refresh);
533
+ });
534
+ }
535
+
536
+ const tickInterval = setInterval(() => setTick(t => t + 1), 1000);
537
+
538
+ return () => {
539
+ stdout?.off('resize', onResize);
540
+ clearInterval(poll);
541
+ clearInterval(tickInterval);
542
+ watcher?.close();
543
+ };
544
+ }, []);
545
+
546
+ useInput((input, key) => {
547
+ if (input === 'q' || key.escape) process.exit(0);
548
+ if (input === '1') { setTab(0); setScrollY(0); }
549
+ if (input === '2') { setTab(1); setScrollY(0); }
550
+ if (input === '3') { setTab(2); setScrollY(0); }
551
+ if (input === 'd') setDark(d => !d);
552
+
553
+ // r = manual refresh
554
+ if (input === 'r') {
555
+ refresh();
556
+ setProject(null);
557
+ scanProject(cwd).then(p => { setProject(p); setTreeCursor(0); }).catch(() => {});
558
+ }
559
+
560
+ if (input === 'j' || key.downArrow) {
561
+ if (tab === 1) {
562
+ const flat = project?.dirTree ? flattenTree(project.dirTree, 0, treeExpanded) : [];
563
+ setTreeCursor(c => Math.min(c + 1, flat.length - 1));
564
+ } else {
565
+ setScrollY(s => Math.min(s + 1, 20));
566
+ }
567
+ }
568
+ if (input === 'k' || key.upArrow) {
569
+ if (tab === 1) setTreeCursor(c => Math.max(c - 1, 0));
570
+ else setScrollY(s => Math.max(s - 1, 0));
571
+ }
572
+
573
+ // Enter / Space — toggle expand in tree
574
+ if ((key.return || input === ' ') && tab === 1 && project?.dirTree) {
575
+ const flat = flattenTree(project.dirTree, 0, treeExpanded);
576
+ const selected = flat[treeCursor];
577
+ if (selected && selected.node.children.length > 0) {
578
+ const path = selected.node.path;
579
+ setTreeExpanded(prev => ({ ...prev, [path]: !(prev[path] ?? false) }));
580
+ }
581
+ }
582
+
583
+ // Arrow right = expand, left = collapse
584
+ if (key.rightArrow && tab === 1 && project?.dirTree) {
585
+ const flat = flattenTree(project.dirTree, 0, treeExpanded);
586
+ const selected = flat[treeCursor];
587
+ if (selected) setTreeExpanded(prev => ({ ...prev, [selected.node.path]: true }));
588
+ }
589
+ if (key.leftArrow && tab === 1) {
590
+ if (project?.dirTree) {
591
+ const flat = flattenTree(project.dirTree, 0, treeExpanded);
592
+ const selected = flat[treeCursor];
593
+ if (selected) setTreeExpanded(prev => ({ ...prev, [selected.node.path]: false }));
594
+ }
595
+ }
596
+ });
597
+
598
+ const TAB_NAMES = ['TOKENS', 'PROJECT', 'GIT'];
599
+ const since = fmtSince(Date.now() - updatedAt);
600
+ const uptime = fmtSince(SESSION_START - Date.now() + (Date.now() - SESSION_START)); // forces tick dep
601
+ void tick;
602
+
603
+ return (
604
+ <Box flexDirection="column">
605
+
606
+ {/* ── Header / Tab bar ── */}
607
+ <Box borderStyle="single" borderColor={C.brand} paddingX={1} justifyContent="space-between">
608
+ <Box>
609
+ <Text color={C.brand} bold>◆ HUD </Text>
610
+ {TAB_NAMES.map((name, i) => (
611
+ <Text key={i} color={tab === i ? C.text : C.dimmer} bold={tab === i}>
612
+ {tab === i ? `[${i + 1} ${name}]` : ` ${i + 1} ${name} `}
613
+ </Text>
614
+ ))}
615
+ </Box>
616
+ <Box>
617
+ <Text color={C.dimmer}>{modelShort(usage.model)}</Text>
618
+ <Text color={C.dimmer}> · up {fmtSince(Date.now() - SESSION_START)}</Text>
619
+ </Box>
620
+ </Box>
621
+
622
+ {/* ── Content (with scroll offset) ── */}
623
+ <Box flexDirection="column" marginTop={-scrollY}>
624
+ {tab === 0 && <TokensTab usage={usage} history={history} rateLimits={rateLimits} termWidth={termWidth} C={C} />}
625
+ {tab === 1 && <ProjectTab info={project} treeCursor={treeCursor} treeExpanded={treeExpanded} termWidth={termWidth} C={C} />}
626
+ {tab === 2 && <GitTab git={git} termWidth={termWidth} C={C} />}
627
+ </Box>
628
+
629
+ {/* ── Footer ── */}
630
+ <Box justifyContent="space-between" paddingX={1}>
631
+ <Box>
632
+ <Text color={C.green}>● </Text>
633
+ <Text color={C.dimmer}>[1/2/3] tabs </Text>
634
+ <Text color={tab === 1 ? C.brand : C.dimmer}>[j/k] {tab === 1 ? 'tree' : 'scroll'} </Text>
635
+ <Text color={tab === 1 ? C.brand : C.dimmer}>{tab === 1 ? '[enter/→←] expand ' : ''}</Text>
636
+ <Text color={C.dimmer}>[r] refresh [d] theme [q] quit</Text>
637
+ </Box>
638
+ <Text color={C.dimmer}>↻ {since}</Text>
639
+ </Box>
640
+
641
+ </Box>
642
+ );
643
+ }
644
+
645
+ render(<App />);