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/README.md +161 -0
- package/bin/claude-hud +31 -0
- package/bin/start.mjs +31 -0
- package/commands/hud.md +40 -0
- package/hooks/hooks.json +29 -0
- package/package.json +44 -0
- package/scripts/full-hud.mjs +35 -0
- package/scripts/lib/formatter.mjs +131 -0
- package/scripts/lib/git-info.mjs +67 -0
- package/scripts/lib/token-reader.mjs +212 -0
- package/scripts/lib/usage-api.mjs +141 -0
- package/scripts/session-start.mjs +42 -0
- package/scripts/statusline.mjs +46 -0
- package/scripts/stop-hud.mjs +31 -0
- package/skills/hud.md +45 -0
- package/tui/hud.tsx +645 -0
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 />);
|