claude-code-hud 0.3.5 → 0.3.7
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 +41 -18
- package/package.json +1 -1
- package/scripts/lib/token-reader.mjs +27 -15
- package/tui/hud.tsx +125 -33
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
## 한국어
|
|
8
8
|
|
|
9
|
-
Claude Code로 작업할 때 토큰 사용량, git 상태, 파일
|
|
9
|
+
Claude Code로 작업할 때 토큰 사용량, git 상태, 파일 구조, 세션 히스토리를 IDE나 별도 툴 없이 터미널 하나에서 확인할 수 있는 HUD입니다.
|
|
10
10
|
|
|
11
11
|
<img src="./demo.gif" width="700" alt="demo">
|
|
12
12
|
|
|
@@ -22,6 +22,8 @@ claude npx claude-code-hud
|
|
|
22
22
|
(Claude Code 작업 중...) (HUD 실시간 표시)
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
<img src="./capture.png" width="700" alt="side-by-side terminals">
|
|
26
|
+
|
|
25
27
|
HUD는 현재 디렉토리를 기준으로 토큰, git, 프로젝트 정보를 자동으로 인식합니다.
|
|
26
28
|
|
|
27
29
|
```bash
|
|
@@ -33,21 +35,22 @@ tmux split-window -h "npx claude-code-hud"
|
|
|
33
35
|
### 설치
|
|
34
36
|
|
|
35
37
|
```bash
|
|
36
|
-
#
|
|
37
|
-
|
|
38
|
+
# 방법1. 전역 설치 (권장)
|
|
39
|
+
> npm install -g claude-code-hud
|
|
40
|
+
> claude-hud
|
|
38
41
|
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
claude-hud
|
|
42
|
+
# 방법2. 설치 없이 바로 실행
|
|
43
|
+
> npx claude-code-hud
|
|
42
44
|
```
|
|
43
45
|
|
|
44
46
|
### 기능
|
|
45
47
|
|
|
46
48
|
**1 TOKENS 탭**
|
|
47
49
|
- 컨텍스트 윈도우 사용량 게이지 (OK / MID / WARN) — 사용량에 따라 헤더 색상 변경
|
|
48
|
-
- Anthropic API 기반 5h / 주간 사용률
|
|
50
|
+
- Anthropic API 기반 5h / 주간 사용률 — `1h 23m` 형식으로 리셋까지 남은 시간 표시
|
|
49
51
|
- input / output / cache-read / cache-write 토큰 분류
|
|
50
52
|
- 세션 output 통계 (total / avg / peak)
|
|
53
|
+
- `now` — 현재 진행 중인 작업 (마지막 사용자 메시지) 한 줄 표시
|
|
51
54
|
|
|
52
55
|
**2 PROJECT 탭 — 인터랙티브 파일 브라우저**
|
|
53
56
|
- 디렉토리 트리 (펼치기/접기)
|
|
@@ -72,16 +75,20 @@ claude-hud
|
|
|
72
75
|
- 최근 커밋 히스토리
|
|
73
76
|
- **브랜치 전환** — `b` 키로 로컬 브랜치 목록 표시, 선택해서 바로 checkout
|
|
74
77
|
|
|
78
|
+
**4 TIMELINE 탭**
|
|
79
|
+
- 현재 세션에서 사용자가 입력한 메시지 히스토리
|
|
80
|
+
- 시간 + 내용 표시, 10개씩 j/k 스크롤
|
|
81
|
+
|
|
75
82
|
### 키보드 단축키
|
|
76
83
|
|
|
77
84
|
| 키 | 동작 |
|
|
78
85
|
|----|------|
|
|
79
|
-
| `1` `2` `3` | 탭 전환 |
|
|
86
|
+
| `1` `2` `3` `4` | 탭 전환 |
|
|
80
87
|
| `j` / `k` | 스크롤 / 트리 이동 |
|
|
81
88
|
| `→` / `Enter` | 디렉토리 펼치기 / 파일 열기 |
|
|
82
89
|
| `←` / `Esc` | 접기 / 소스 뷰어 닫기 |
|
|
83
90
|
| `b` | 브랜치 전환 (GIT 탭) |
|
|
84
|
-
| `d` |
|
|
91
|
+
| `d` | 액센트 색상 변경 (blue → red → amber → green → pink) |
|
|
85
92
|
| `r` | 수동 새로고침 |
|
|
86
93
|
| `q` | 종료 |
|
|
87
94
|
|
|
@@ -115,7 +122,7 @@ Claude Code를 한 번 실행하면 `~/.claude/.credentials.json`에 credentials
|
|
|
115
122
|
|
|
116
123
|
## English
|
|
117
124
|
|
|
118
|
-
A Terminal HUD (Heads-Up Display) for Claude Code — real-time token usage, git status,
|
|
125
|
+
A Terminal HUD (Heads-Up Display) for Claude Code — real-time token usage, git status, interactive project file browser, and session history. No IDE, no extra tabs. Just a second terminal window.
|
|
119
126
|
|
|
120
127
|
<img src="./demo.gif" width="700" alt="demo">
|
|
121
128
|
|
|
@@ -131,6 +138,8 @@ claude npx claude-code-hud
|
|
|
131
138
|
(working with Claude Code) (HUD live display)
|
|
132
139
|
```
|
|
133
140
|
|
|
141
|
+
<img src="./capture.png" width="700" alt="side-by-side terminals">
|
|
142
|
+
|
|
134
143
|
```bash
|
|
135
144
|
# tmux split pane
|
|
136
145
|
cd ~/my-project
|
|
@@ -140,13 +149,12 @@ tmux split-window -h "npx claude-code-hud"
|
|
|
140
149
|
### Installation
|
|
141
150
|
|
|
142
151
|
```bash
|
|
143
|
-
#
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
# Global install
|
|
147
|
-
npm install -g claude-code-hud
|
|
148
|
-
claude-hud
|
|
152
|
+
# Option 1. Global install (recommended)
|
|
153
|
+
> npm install -g claude-code-hud
|
|
154
|
+
> claude-hud
|
|
149
155
|
|
|
156
|
+
# Option 2. Run directly without install
|
|
157
|
+
> npx claude-code-hud
|
|
150
158
|
```
|
|
151
159
|
|
|
152
160
|
### Features
|
|
@@ -156,6 +164,7 @@ claude-hud
|
|
|
156
164
|
- Real 5h / weekly usage from Anthropic OAuth API — not estimates. Reset time shown as `1h 23m`
|
|
157
165
|
- Input / output / cache-read / cache-write breakdown
|
|
158
166
|
- Session output stats: total / avg / peak per hour
|
|
167
|
+
- `now` line — last user message (current task at a glance)
|
|
159
168
|
|
|
160
169
|
**2 PROJECT tab — interactive file browser**
|
|
161
170
|
- Navigable directory tree with expand/collapse
|
|
@@ -164,6 +173,15 @@ claude-hud
|
|
|
164
173
|
- Package dependency tree from `package.json`
|
|
165
174
|
- API endpoint detection (GET / POST / PUT / DELETE / PATCH)
|
|
166
175
|
|
|
176
|
+
```
|
|
177
|
+
▸ TREE │ ▸ SOURCE src/index.ts
|
|
178
|
+
▼ src/ 23f │ 1 import React from 'react'
|
|
179
|
+
▼ components/ 8f │ 2 import { render } from 'ink'
|
|
180
|
+
Header.tsx M │ 3
|
|
181
|
+
▶ hooks/ 4f │ 4 render(<App />)
|
|
182
|
+
▶ scripts/ 6f │ … [j/k] scroll [esc] close
|
|
183
|
+
```
|
|
184
|
+
|
|
167
185
|
**3 GIT tab**
|
|
168
186
|
- Branch status, ahead/behind remote
|
|
169
187
|
- Changed file list (MOD / ADD / DEL) with real `+N -N` diff counts
|
|
@@ -171,16 +189,20 @@ claude-hud
|
|
|
171
189
|
- Recent commit history
|
|
172
190
|
- **Branch switcher** — press `b` to list local branches and checkout instantly
|
|
173
191
|
|
|
192
|
+
**4 TIMELINE tab**
|
|
193
|
+
- User message history from the current Claude Code session
|
|
194
|
+
- Timestamped entries, 10 per page, j/k to scroll
|
|
195
|
+
|
|
174
196
|
### Keyboard Shortcuts
|
|
175
197
|
|
|
176
198
|
| Key | Action |
|
|
177
199
|
|-----|--------|
|
|
178
|
-
| `1` `2` `3` | Switch tabs |
|
|
200
|
+
| `1` `2` `3` `4` | Switch tabs |
|
|
179
201
|
| `j` / `k` | Scroll / move tree cursor |
|
|
180
202
|
| `→` / `Enter` | Expand dir / open file |
|
|
181
203
|
| `←` / `Esc` | Collapse / close source viewer |
|
|
182
204
|
| `b` | Branch switcher (GIT tab) |
|
|
183
|
-
| `d` |
|
|
205
|
+
| `d` | Cycle accent color (blue → red → amber → green → pink) |
|
|
184
206
|
| `r` | Manual refresh |
|
|
185
207
|
| `q` | Quit |
|
|
186
208
|
|
|
@@ -206,6 +228,7 @@ Run `claude` once to authenticate — credentials are saved to `~/.claude/.crede
|
|
|
206
228
|
### How it works
|
|
207
229
|
|
|
208
230
|
- **Token data**: Watches `~/.claude/projects/*/sessions/*.jsonl` with chokidar — updates instantly on each Claude response
|
|
231
|
+
- **Timeline**: Reads the same JSONL files, filters `type === "user"` entries for message history
|
|
209
232
|
- **Usage window**: Calls `api.anthropic.com/api/oauth/usage` using local Claude credentials — cached 5 min
|
|
210
233
|
- **Git**: Polls every 3 seconds
|
|
211
234
|
- **Project scan**: One-time fast-glob scan on startup, `r` to rescan
|
package/package.json
CHANGED
|
@@ -34,15 +34,23 @@ function getContextWindow(model) {
|
|
|
34
34
|
return 200000;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
/**
|
|
38
|
-
function
|
|
37
|
+
/** Convert cwd to the Claude project directory name (/ replaced with -) */
|
|
38
|
+
function cwdToProjectDir(cwd) {
|
|
39
|
+
return cwd.replace(/\//g, '-');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Find the most recently modified .jsonl session file for the given cwd */
|
|
43
|
+
function findLatestSession(cwd) {
|
|
39
44
|
const projectsDir = path.join(os.homedir(), '.claude', 'projects');
|
|
40
45
|
if (!fs.existsSync(projectsDir)) return null;
|
|
41
46
|
|
|
47
|
+
const targetDir = cwd ? cwdToProjectDir(cwd) : null;
|
|
48
|
+
|
|
42
49
|
let latest = null;
|
|
43
50
|
let latestMtime = 0;
|
|
44
51
|
|
|
45
|
-
const projects = fs.readdirSync(projectsDir)
|
|
52
|
+
const projects = fs.readdirSync(projectsDir)
|
|
53
|
+
.filter(p => !targetDir || p === targetDir);
|
|
46
54
|
for (const proj of projects) {
|
|
47
55
|
const projDir = path.join(projectsDir, proj);
|
|
48
56
|
|
|
@@ -66,12 +74,13 @@ function findLatestSession() {
|
|
|
66
74
|
return latest;
|
|
67
75
|
}
|
|
68
76
|
|
|
69
|
-
/** Collect all JSONL lines
|
|
70
|
-
function readAllLines() {
|
|
77
|
+
/** Collect all JSONL lines for the given cwd (or all projects if no cwd) */
|
|
78
|
+
function readAllLines(cwd) {
|
|
71
79
|
const projectsDir = path.join(os.homedir(), '.claude', 'projects');
|
|
72
80
|
if (!fs.existsSync(projectsDir)) return [];
|
|
81
|
+
const targetDir = cwd ? cwdToProjectDir(cwd) : null;
|
|
73
82
|
const result = [];
|
|
74
|
-
for (const proj of fs.readdirSync(projectsDir)) {
|
|
83
|
+
for (const proj of fs.readdirSync(projectsDir).filter(p => !targetDir || p === targetDir)) {
|
|
75
84
|
const projDir = path.join(projectsDir, proj);
|
|
76
85
|
let files = [];
|
|
77
86
|
try { files = fs.readdirSync(projDir).filter(f => f.endsWith('.jsonl')); } catch { continue; }
|
|
@@ -94,15 +103,17 @@ function readAllLines() {
|
|
|
94
103
|
return result;
|
|
95
104
|
}
|
|
96
105
|
|
|
97
|
-
export function readTokenHistory() {
|
|
98
|
-
const allLines = readAllLines();
|
|
106
|
+
export function readTokenHistory(cwd) {
|
|
107
|
+
const allLines = readAllLines(cwd);
|
|
99
108
|
const now = Date.now();
|
|
100
109
|
const h5 = now - 5 * 60 * 60 * 1000;
|
|
101
110
|
const wk = now - 7 * 24 * 60 * 60 * 1000;
|
|
102
111
|
const h12 = now - 12 * 60 * 60 * 1000;
|
|
112
|
+
const todayMidnight = new Date(); todayMidnight.setHours(0, 0, 0, 0);
|
|
113
|
+
const todayStart = todayMidnight.getTime();
|
|
103
114
|
|
|
104
115
|
const empty = () => ({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } });
|
|
105
|
-
const acc5h = empty(), accWk = empty();
|
|
116
|
+
const acc5h = empty(), accWk = empty(), accToday = empty();
|
|
106
117
|
|
|
107
118
|
// 12 hourly buckets (index 0 = oldest, 11 = most recent)
|
|
108
119
|
const buckets = Array(12).fill(0);
|
|
@@ -126,8 +137,9 @@ export function readTokenHistory() {
|
|
|
126
137
|
acc.cost.cacheWrite += (cw / M) * pricing.cacheWrite;
|
|
127
138
|
};
|
|
128
139
|
|
|
129
|
-
if (ts >= wk)
|
|
130
|
-
if (ts >= h5)
|
|
140
|
+
if (ts >= wk) { addTo(accWk); }
|
|
141
|
+
if (ts >= h5) { addTo(acc5h); }
|
|
142
|
+
if (ts >= todayStart) { addTo(accToday); }
|
|
131
143
|
|
|
132
144
|
if (ts >= h12) {
|
|
133
145
|
const hoursAgo = (now - ts) / (60 * 60 * 1000);
|
|
@@ -136,15 +148,15 @@ export function readTokenHistory() {
|
|
|
136
148
|
}
|
|
137
149
|
}
|
|
138
150
|
|
|
139
|
-
[acc5h, accWk].forEach(acc => {
|
|
151
|
+
[acc5h, accWk, accToday].forEach(acc => {
|
|
140
152
|
acc.cost.total = acc.cost.input + acc.cost.output + acc.cost.cacheRead + acc.cost.cacheWrite;
|
|
141
153
|
});
|
|
142
154
|
|
|
143
|
-
return { last5h: acc5h, lastWeek: accWk, hourlyBuckets: buckets };
|
|
155
|
+
return { last5h: acc5h, lastWeek: accWk, today: accToday, hourlyBuckets: buckets };
|
|
144
156
|
}
|
|
145
157
|
|
|
146
|
-
export function readTokenUsage() {
|
|
147
|
-
const sessionFile = findLatestSession();
|
|
158
|
+
export function readTokenUsage(cwd) {
|
|
159
|
+
const sessionFile = findLatestSession(cwd);
|
|
148
160
|
if (!sessionFile) {
|
|
149
161
|
return empty();
|
|
150
162
|
}
|
package/tui/hud.tsx
CHANGED
|
@@ -218,10 +218,13 @@ async function readSessionTimeline(cwd: string): Promise<TimelineEntry[]> {
|
|
|
218
218
|
const projectsDir = join(os.homedir(), '.claude', 'projects');
|
|
219
219
|
if (!fs.existsSync(projectsDir)) return [];
|
|
220
220
|
|
|
221
|
+
const targetDirName = cwd.replace(/\//g, '-');
|
|
222
|
+
|
|
221
223
|
let latestFile: string | null = null;
|
|
222
224
|
let latestMtime = 0;
|
|
223
225
|
try {
|
|
224
226
|
for (const projectHash of fs.readdirSync(projectsDir)) {
|
|
227
|
+
if (projectHash !== targetDirName) continue;
|
|
225
228
|
const sessionDir = join(projectsDir, projectHash);
|
|
226
229
|
if (!fs.statSync(sessionDir).isDirectory()) continue;
|
|
227
230
|
for (const file of fs.readdirSync(sessionDir)) {
|
|
@@ -245,6 +248,8 @@ async function readSessionTimeline(cwd: string): Promise<TimelineEntry[]> {
|
|
|
245
248
|
const obj = JSON.parse(line);
|
|
246
249
|
if (obj.type !== 'user') continue;
|
|
247
250
|
const content = obj.message?.content;
|
|
251
|
+
// Skip tool_result messages (not direct user prompts)
|
|
252
|
+
if (Array.isArray(content) && content.some((b: any) => b.type === 'tool_result')) continue;
|
|
248
253
|
const textBlock = Array.isArray(content)
|
|
249
254
|
? content.find((b: any) => b.type === 'text')
|
|
250
255
|
: null;
|
|
@@ -398,6 +403,20 @@ function TokensTab({ usage, history, rateLimits, termWidth, currentActivity, C }
|
|
|
398
403
|
);
|
|
399
404
|
})()}
|
|
400
405
|
|
|
406
|
+
{/* Today summary + sparkline */}
|
|
407
|
+
<Section title="TODAY" C={C}>
|
|
408
|
+
<Box>
|
|
409
|
+
<Text color={C.dimmer}>in </Text>
|
|
410
|
+
<Text color={C.brand} bold>{fmtNum(history.today?.inputTokens ?? 0)}</Text>
|
|
411
|
+
<Text color={C.dimmer}> out </Text>
|
|
412
|
+
<Text color={C.purple} bold>{fmtNum(history.today?.outputTokens ?? 0)}</Text>
|
|
413
|
+
<Text color={C.dimmer}> cache </Text>
|
|
414
|
+
<Text color={C.cyan} bold>{fmtNum((history.today?.cacheReadTokens ?? 0) + (history.today?.cacheWriteTokens ?? 0))}</Text>
|
|
415
|
+
<Text color={C.dimmer}> </Text>
|
|
416
|
+
<Text color={costColor(history.today?.cost?.total ?? 0, C)} bold>{fmtCost(history.today?.cost?.total ?? 0)}</Text>
|
|
417
|
+
</Box>
|
|
418
|
+
</Section>
|
|
419
|
+
|
|
401
420
|
{/* Current activity */}
|
|
402
421
|
{currentActivity && (
|
|
403
422
|
<Box borderStyle="single" borderColor={C.border} paddingX={1}>
|
|
@@ -410,7 +429,7 @@ function TokensTab({ usage, history, rateLimits, termWidth, currentActivity, C }
|
|
|
410
429
|
}
|
|
411
430
|
|
|
412
431
|
// ── Tab 2: PROJECT ─────────────────────────────────────────────────────────
|
|
413
|
-
function ProjectTab({ info, treeCursor, treeExpanded, selectedFile, fileLines, fileScroll, termWidth, git, C }: any) {
|
|
432
|
+
function ProjectTab({ info, treeCursor, treeExpanded, selectedFile, fileLines, fileScroll, termWidth, contentH, git, C }: any) {
|
|
414
433
|
if (!info) return (
|
|
415
434
|
<Box borderStyle="single" borderColor={C.border} paddingX={1}>
|
|
416
435
|
<Text color={C.dimmer}>scanning project…</Text>
|
|
@@ -436,16 +455,26 @@ function ProjectTab({ info, treeCursor, treeExpanded, selectedFile, fileLines, f
|
|
|
436
455
|
return result;
|
|
437
456
|
}
|
|
438
457
|
|
|
439
|
-
const
|
|
458
|
+
const ch = contentH ?? 30;
|
|
459
|
+
const hasFile = !!selectedFile;
|
|
460
|
+
|
|
461
|
+
// Budget: summary=3, tree border+header+marginTop=4, packages≈14 when shown
|
|
462
|
+
const showPackages = !hasFile && ch >= 28;
|
|
463
|
+
const packagesBudget = showPackages ? 14 : 0;
|
|
464
|
+
const maxTreeRows = Math.max(4, ch - 7 - packagesBudget);
|
|
465
|
+
const treePanelH = Math.max(4, ch - 3 - packagesBudget);
|
|
466
|
+
const VISIBLE_LINES = Math.max(4, ch - 8);
|
|
467
|
+
|
|
468
|
+
// Flatten nodes
|
|
469
|
+
const allFlatNodes: FlatNode[] = info.dirTree ? flatNodes_inner(info.dirTree, 0) : [];
|
|
470
|
+
const flatNodes = allFlatNodes.slice(0, maxTreeRows);
|
|
440
471
|
const safeCursor = Math.min(treeCursor, Math.max(0, flatNodes.length - 1));
|
|
441
472
|
|
|
442
473
|
const totalEndpoints = Object.values(info.endpoints as Record<string, number>).reduce((a: number, b: number) => a + b, 0);
|
|
443
474
|
|
|
444
475
|
// Split layout when file is open
|
|
445
|
-
const hasFile = !!selectedFile;
|
|
446
476
|
const TREE_W = hasFile ? Math.max(28, Math.floor(termWidth * 0.36)) : termWidth - 2;
|
|
447
477
|
const SOURCE_W = hasFile ? termWidth - TREE_W - 5 : 0;
|
|
448
|
-
const VISIBLE_LINES = 22;
|
|
449
478
|
|
|
450
479
|
// Git changed file sets
|
|
451
480
|
const gitModified = new Set<string>([...(git?.modified ?? []), ...(git?.added ?? []), ...(git?.deleted ?? [])]);
|
|
@@ -460,9 +489,9 @@ function ProjectTab({ info, treeCursor, treeExpanded, selectedFile, fileLines, f
|
|
|
460
489
|
};
|
|
461
490
|
|
|
462
491
|
return (
|
|
463
|
-
<Box flexDirection="column">
|
|
492
|
+
<Box flexDirection="column" height={ch}>
|
|
464
493
|
{/* Summary bar */}
|
|
465
|
-
<Box borderStyle="single" borderColor={C.border} paddingX={1}>
|
|
494
|
+
<Box borderStyle="single" borderColor={C.border} paddingX={1} height={3}>
|
|
466
495
|
<Text color={C.text} bold>{info.totalFiles} files</Text>
|
|
467
496
|
<Text color={C.dim}> │ </Text>
|
|
468
497
|
<Text color={C.text} bold>{info.packages.filter((p: any) => p.depth === 0).length} pkgs</Text>
|
|
@@ -474,10 +503,10 @@ function ProjectTab({ info, treeCursor, treeExpanded, selectedFile, fileLines, f
|
|
|
474
503
|
</Box>
|
|
475
504
|
|
|
476
505
|
{/* Main area: tree + optional source */}
|
|
477
|
-
<Box flexDirection="row">
|
|
506
|
+
<Box flexDirection="row" height={treePanelH}>
|
|
478
507
|
|
|
479
508
|
{/* ── Tree panel ── */}
|
|
480
|
-
<Box flexDirection="column" borderStyle="single" borderColor={hasFile ? C.brand : C.border} paddingX={1} width={TREE_W}>
|
|
509
|
+
<Box flexDirection="column" borderStyle="single" borderColor={hasFile ? C.brand : C.border} paddingX={1} width={TREE_W} height={treePanelH}>
|
|
481
510
|
<Text color={C.dimmer} bold>▸ <Text color={C.text}>TREE</Text></Text>
|
|
482
511
|
<Box marginTop={1} flexDirection="column">
|
|
483
512
|
{flatNodes.length === 0 && <Text color={C.dimmer}> (empty)</Text>}
|
|
@@ -493,7 +522,7 @@ function ProjectTab({ info, treeCursor, treeExpanded, selectedFile, fileLines, f
|
|
|
493
522
|
<Box key={`d_${fn.node.path}_${idx}`}>
|
|
494
523
|
<Text color={C.dimmer}>{indent}</Text>
|
|
495
524
|
<Text color={isSelected ? C.brand : C.dimmer}>{expIcon}</Text>
|
|
496
|
-
<Text color={nameColor} bold={isSelected}>{fn.node.name}/</Text>
|
|
525
|
+
<Text color={nameColor} bold={isSelected} wrap="truncate">{fn.node.name}/</Text>
|
|
497
526
|
<Text color={C.dimmer}> {fn.node.totalFiles}f</Text>
|
|
498
527
|
</Box>
|
|
499
528
|
);
|
|
@@ -510,7 +539,7 @@ function ProjectTab({ info, treeCursor, treeExpanded, selectedFile, fileLines, f
|
|
|
510
539
|
<Box key={`f_${fn.filePath}_${idx}`}>
|
|
511
540
|
<Text color={C.dimmer}>{indent}</Text>
|
|
512
541
|
<Text color={isSelected ? C.brand : C.dimmer}>{isOpen ? '▶ ' : ' '}</Text>
|
|
513
|
-
<Text color={fileColor} bold={isSelected || isOpen}>{fn.fileName}</Text>
|
|
542
|
+
<Text color={fileColor} bold={isSelected || isOpen} wrap="truncate">{fn.fileName}</Text>
|
|
514
543
|
{gitBadge ? <Text color={gitColor!} bold>{gitBadge}</Text> : null}
|
|
515
544
|
</Box>
|
|
516
545
|
);
|
|
@@ -521,7 +550,7 @@ function ProjectTab({ info, treeCursor, treeExpanded, selectedFile, fileLines, f
|
|
|
521
550
|
|
|
522
551
|
{/* ── Source viewer panel ── */}
|
|
523
552
|
{hasFile && (
|
|
524
|
-
<Box flexDirection="column" borderStyle="single" borderColor={C.brand} paddingX={1} width={SOURCE_W}>
|
|
553
|
+
<Box flexDirection="column" borderStyle="single" borderColor={C.brand} paddingX={1} width={SOURCE_W} height={treePanelH}>
|
|
525
554
|
<Text color={C.dimmer} bold>▸ <Text color={C.text}>SOURCE <Text color={C.dim}>{selectedFile}</Text></Text></Text>
|
|
526
555
|
<Box marginTop={1} flexDirection="column">
|
|
527
556
|
{(fileLines as string[]).slice(fileScroll, fileScroll + VISIBLE_LINES).map((line, i) => {
|
|
@@ -533,7 +562,7 @@ function ProjectTab({ info, treeCursor, treeExpanded, selectedFile, fileLines, f
|
|
|
533
562
|
<Text color={C.dimmer}>{lineNo}</Text>
|
|
534
563
|
</Box>
|
|
535
564
|
<Text color={C.dimmer}> </Text>
|
|
536
|
-
<Text color={C.text}>{truncated}</Text>
|
|
565
|
+
<Text color={C.text} wrap="truncate">{truncated}</Text>
|
|
537
566
|
</Box>
|
|
538
567
|
);
|
|
539
568
|
})}
|
|
@@ -546,8 +575,8 @@ function ProjectTab({ info, treeCursor, treeExpanded, selectedFile, fileLines, f
|
|
|
546
575
|
</Box>
|
|
547
576
|
|
|
548
577
|
{/* Packages (hidden when file open to save space) */}
|
|
549
|
-
{
|
|
550
|
-
<Box flexDirection="column" borderStyle="single" borderColor={C.border} paddingX={1}>
|
|
578
|
+
{showPackages && (
|
|
579
|
+
<Box flexDirection="column" borderStyle="single" borderColor={C.border} paddingX={1} height={packagesBudget}>
|
|
551
580
|
<Text color={C.dimmer} bold>▸ <Text color={C.text}>PACKAGES</Text></Text>
|
|
552
581
|
<Box marginTop={1} flexDirection="column">
|
|
553
582
|
{info.packages.slice(0, 10).map((p: any, i: number) => {
|
|
@@ -715,8 +744,8 @@ function App() {
|
|
|
715
744
|
const cwd = process.env.CLAUDE_PROJECT_ROOT || process.cwd();
|
|
716
745
|
const C = makeTheme(accent);
|
|
717
746
|
|
|
718
|
-
const [usage, setUsage] = useState<any>(readTokenUsage());
|
|
719
|
-
const [history, setHistory] = useState<any>(readTokenHistory());
|
|
747
|
+
const [usage, setUsage] = useState<any>(readTokenUsage(cwd));
|
|
748
|
+
const [history, setHistory] = useState<any>(readTokenHistory(cwd));
|
|
720
749
|
const [git, setGit] = useState<any>(readGitInfo(cwd));
|
|
721
750
|
const [project, setProject] = useState<ProjectInfo | null>(null);
|
|
722
751
|
const [rateLimits, setRateLimits] = useState<any>(getUsageSync());
|
|
@@ -730,6 +759,17 @@ function App() {
|
|
|
730
759
|
const [fileLines, setFileLines] = useState<string[]>([]);
|
|
731
760
|
const [fileScroll, setFileScroll] = useState(0);
|
|
732
761
|
|
|
762
|
+
// Help overlay state
|
|
763
|
+
const [showHelp, setShowHelp] = useState(false);
|
|
764
|
+
|
|
765
|
+
// Token warning blink state
|
|
766
|
+
const [blinkOn, setBlinkOn] = useState(true);
|
|
767
|
+
|
|
768
|
+
// Loading spinner state
|
|
769
|
+
const [loading, setLoading] = useState(true);
|
|
770
|
+
const [spinFrame, setSpinFrame] = useState(0);
|
|
771
|
+
const SPIN = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
|
|
772
|
+
|
|
733
773
|
// Branch switcher state
|
|
734
774
|
const [branchMode, setBranchMode] = useState(false);
|
|
735
775
|
const [branchList, setBranchList] = useState<string[]>([]);
|
|
@@ -741,8 +781,8 @@ function App() {
|
|
|
741
781
|
const [currentActivity, setCurrentActivity] = useState<string>('');
|
|
742
782
|
|
|
743
783
|
const refresh = useCallback(() => {
|
|
744
|
-
setUsage(readTokenUsage());
|
|
745
|
-
setHistory(readTokenHistory());
|
|
784
|
+
setUsage(readTokenUsage(cwd));
|
|
785
|
+
setHistory(readTokenHistory(cwd));
|
|
746
786
|
setGit(readGitInfo(cwd));
|
|
747
787
|
setUpdatedAt(Date.now());
|
|
748
788
|
getUsage().then(setRateLimits).catch(() => {});
|
|
@@ -757,7 +797,7 @@ function App() {
|
|
|
757
797
|
|
|
758
798
|
useEffect(() => {
|
|
759
799
|
// Scan project once
|
|
760
|
-
scanProject(cwd).then(setProject).catch(() => {});
|
|
800
|
+
scanProject(cwd).then(p => { setProject(p); setLoading(false); }).catch(() => { setLoading(false); });
|
|
761
801
|
// Initial API usage fetch
|
|
762
802
|
getUsage().then(setRateLimits).catch(() => {});
|
|
763
803
|
// Initial timeline load
|
|
@@ -786,11 +826,15 @@ function App() {
|
|
|
786
826
|
depth: 2, persistent: true, ignoreInitial: true,
|
|
787
827
|
ignored: (p: string) => !p.endsWith('.jsonl'),
|
|
788
828
|
});
|
|
789
|
-
|
|
829
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
830
|
+
watcher.on('change', () => {
|
|
831
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
832
|
+
debounceTimer = setTimeout(refresh, 800);
|
|
833
|
+
});
|
|
790
834
|
});
|
|
791
835
|
}
|
|
792
836
|
|
|
793
|
-
const tickInterval = setInterval(() => setTick(t => t + 1),
|
|
837
|
+
const tickInterval = setInterval(() => setTick(t => t + 1), 5000);
|
|
794
838
|
|
|
795
839
|
return () => {
|
|
796
840
|
stdout?.off('resize', onResize);
|
|
@@ -800,7 +844,16 @@ function App() {
|
|
|
800
844
|
};
|
|
801
845
|
}, []);
|
|
802
846
|
|
|
847
|
+
useEffect(() => {
|
|
848
|
+
if (!loading) return;
|
|
849
|
+
const id = setInterval(() => setSpinFrame(f => f + 1), 80);
|
|
850
|
+
return () => clearInterval(id);
|
|
851
|
+
}, [loading]);
|
|
852
|
+
|
|
803
853
|
useInput((input, key) => {
|
|
854
|
+
if (input === '?') { setShowHelp(h => !h); return; }
|
|
855
|
+
if (key.escape && showHelp) { setShowHelp(false); return; }
|
|
856
|
+
|
|
804
857
|
// Branch switcher intercepts input when active
|
|
805
858
|
if (branchMode) {
|
|
806
859
|
if (input === 'j' || key.downArrow) {
|
|
@@ -932,11 +985,28 @@ function App() {
|
|
|
932
985
|
const uptime = fmtSince(SESSION_START - Date.now() + (Date.now() - SESSION_START)); // forces tick dep
|
|
933
986
|
void tick;
|
|
934
987
|
|
|
988
|
+
const ctxPct = usage.contextWindow > 0 ? usage.totalTokens / usage.contextWindow : 0;
|
|
989
|
+
|
|
990
|
+
useEffect(() => {
|
|
991
|
+
if (ctxPct <= 0.85) { setBlinkOn(true); return; }
|
|
992
|
+
const id = setInterval(() => setBlinkOn(b => !b), 600);
|
|
993
|
+
return () => clearInterval(id);
|
|
994
|
+
}, [ctxPct > 0.85]);
|
|
995
|
+
|
|
996
|
+
if (termWidth < 60 || termHeight < 15) {
|
|
997
|
+
return (
|
|
998
|
+
<Box width={termWidth} height={termHeight} alignItems="center" justifyContent="center" flexDirection="column">
|
|
999
|
+
<Text color={C.yellow} bold>⚠ terminal too small</Text>
|
|
1000
|
+
<Text color={C.dimmer}>{termWidth}×{termHeight} — min 60×15</Text>
|
|
1001
|
+
</Box>
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
935
1005
|
return (
|
|
936
|
-
<Box flexDirection="column">
|
|
1006
|
+
<Box flexDirection="column" height={termHeight}>
|
|
937
1007
|
|
|
938
1008
|
{/* ── Header / Tab bar ── */}
|
|
939
|
-
<Box borderStyle="single" borderColor={
|
|
1009
|
+
<Box height={3} borderStyle="single" borderColor={ctxPct > 0.85 ? (blinkOn ? C.red : C.border) : ctxPct > 0.65 ? C.yellow : C.brand} paddingX={1} justifyContent="space-between">
|
|
940
1010
|
<Box>
|
|
941
1011
|
<Text color={C.brand} bold>◆ HUD</Text>
|
|
942
1012
|
{TAB_NAMES.map((name, i) => (
|
|
@@ -961,31 +1031,53 @@ function App() {
|
|
|
961
1031
|
const contentH = Math.max(4, termHeight - 7);
|
|
962
1032
|
return (
|
|
963
1033
|
<Box flexDirection="column" height={contentH} overflow="hidden">
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
</
|
|
1034
|
+
{showHelp ? (
|
|
1035
|
+
<Box flexDirection="column" borderStyle="round" borderColor={C.brand} paddingX={2} paddingY={1}>
|
|
1036
|
+
<Text color={C.brand} bold> Keyboard Shortcuts</Text>
|
|
1037
|
+
<Text> </Text>
|
|
1038
|
+
<Text><Text color={C.dim}> 1 / 2 / 3 / 4 </Text><Text color={C.text}>switch tabs</Text></Text>
|
|
1039
|
+
<Text><Text color={C.dim}> j / k </Text><Text color={C.text}>scroll / tree move</Text></Text>
|
|
1040
|
+
<Text><Text color={C.dim}> → / Enter </Text><Text color={C.text}>expand dir / open file</Text></Text>
|
|
1041
|
+
<Text><Text color={C.dim}> ← / Esc </Text><Text color={C.text}>collapse / close</Text></Text>
|
|
1042
|
+
<Text><Text color={C.dim}> b </Text><Text color={C.text}>branch switcher (GIT tab)</Text></Text>
|
|
1043
|
+
<Text><Text color={C.dim}> d </Text><Text color={C.text}>cycle accent color</Text></Text>
|
|
1044
|
+
<Text><Text color={C.dim}> r </Text><Text color={C.text}>refresh</Text></Text>
|
|
1045
|
+
<Text><Text color={C.dim}> q / Esc </Text><Text color={C.text}>quit</Text></Text>
|
|
1046
|
+
<Text><Text color={C.dim}> ? </Text><Text color={C.text}>toggle this help</Text></Text>
|
|
1047
|
+
<Text> </Text>
|
|
1048
|
+
<Text color={C.dimmer}> Korean: ㅓ/ㅏ (j/k) ㅇ (d) ㄱ (r) ㅂ (q) ㅠ (b)</Text>
|
|
1049
|
+
</Box>
|
|
1050
|
+
) : loading ? (
|
|
1051
|
+
<Box height={contentH} alignItems="center" justifyContent="center">
|
|
1052
|
+
<Text color={C.brand} bold>{SPIN[spinFrame % SPIN.length]} scanning project…</Text>
|
|
1053
|
+
</Box>
|
|
1054
|
+
) : (
|
|
1055
|
+
<Box flexDirection="column" height={contentH} marginTop={-scrollY}>
|
|
1056
|
+
{tab === 0 && <TokensTab usage={usage} history={history} rateLimits={rateLimits} termWidth={termWidth} currentActivity={currentActivity} C={C} />}
|
|
1057
|
+
{tab === 1 && <ProjectTab info={project} treeCursor={treeCursor} treeExpanded={treeExpanded} selectedFile={selectedFile} fileLines={fileLines} fileScroll={fileScroll} termWidth={termWidth} contentH={contentH - 1} git={git} C={C} />}
|
|
1058
|
+
{tab === 2 && <GitTab git={git} termWidth={termWidth} branchMode={branchMode} branchList={branchList} branchCursor={branchCursor} C={C} />}
|
|
1059
|
+
{tab === 3 && <TimelineTab timeline={timeline} timelineScroll={timelineScroll} C={C} />}
|
|
1060
|
+
</Box>
|
|
1061
|
+
)}
|
|
970
1062
|
</Box>
|
|
971
1063
|
);
|
|
972
1064
|
})()}
|
|
973
1065
|
|
|
974
1066
|
{/* ── Footer row 1: keys ── */}
|
|
975
|
-
<Box justifyContent="space-between" paddingX={1}>
|
|
1067
|
+
<Box height={1} justifyContent="space-between" paddingX={1}>
|
|
976
1068
|
<Box>
|
|
977
1069
|
<Text color={C.green}>● </Text>
|
|
978
1070
|
<Text color={C.dimmer}>[1/2/3/4] tabs </Text>
|
|
979
1071
|
<Text color={tab === 1 ? C.brand : C.dimmer}>[j/k] {tab === 1 ? 'tree' : 'scroll'} </Text>
|
|
980
|
-
<Text color={tab === 1 ? C.brand : C.dimmer}>{tab === 1 ? (selectedFile ? '[esc
|
|
1072
|
+
<Text color={tab === 1 ? C.brand : C.dimmer}>{tab === 1 ? (selectedFile ? '[esc] close ' : '[↵/→←] open ') : ''}</Text>
|
|
981
1073
|
{tab === 2 && !branchMode && <Text color={C.brand}>[b] branch </Text>}
|
|
982
|
-
<Text color={C.dimmer}>[r] refresh [d] color [q] quit</Text>
|
|
1074
|
+
<Text color={C.dimmer}>[r] refresh [d] color [?] help [q] quit</Text>
|
|
983
1075
|
</Box>
|
|
984
1076
|
<Text color={C.dimmer}>↻ {since}</Text>
|
|
985
1077
|
</Box>
|
|
986
1078
|
|
|
987
1079
|
{/* ── Footer row 2: current dir ── */}
|
|
988
|
-
<Box paddingX={1} borderStyle="single" borderColor={C.brand}>
|
|
1080
|
+
<Box height={3} paddingX={1} borderStyle="single" borderColor={C.brand}>
|
|
989
1081
|
<Text color={C.brand} bold>◆ </Text>
|
|
990
1082
|
<Text color={C.text} bold>~/{basename(cwd)}</Text>
|
|
991
1083
|
</Box>
|