claude-code-hud 0.2.0 → 0.3.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 +98 -80
- package/bin/claude-hud.js +31 -0
- package/package.json +2 -2
- package/tui/hud.tsx +173 -84
package/README.md
CHANGED
|
@@ -1,89 +1,97 @@
|
|
|
1
1
|
# claude-code-hud
|
|
2
2
|
|
|
3
|
-
A Terminal HUD (Heads-Up Display) for Claude Code — real-time token usage, git status, and project
|
|
3
|
+
A Terminal HUD (Heads-Up Display) for Claude Code — real-time token usage, git status, and interactive project file browser in a separate terminal window or tmux pane.
|
|
4
4
|
|
|
5
5
|
```
|
|
6
|
-
|
|
7
|
-
│ ◆ HUD [1 TOKENS] 2 PROJECT 3 GIT
|
|
8
|
-
|
|
9
|
-
│ CONTEXT WINDOW
|
|
10
|
-
│
|
|
11
|
-
|
|
12
|
-
│ USAGE WINDOW (Anthropic API)
|
|
13
|
-
│ 5h
|
|
14
|
-
│ wk
|
|
15
|
-
|
|
16
|
-
│
|
|
17
|
-
│
|
|
18
|
-
│
|
|
19
|
-
│
|
|
20
|
-
|
|
6
|
+
┌──────────────────────────────────────────────────────────────────────────────┐
|
|
7
|
+
│ ◆ HUD [1 TOKENS] 2 PROJECT 3 GIT sonnet-4-6 · up 4m │
|
|
8
|
+
├──────────────────────────────────────────────────────────────────────────────┤
|
|
9
|
+
│ CONTEXT WINDOW │
|
|
10
|
+
│ ████████████████████░░░░░░░░░░░░░░░░░░░░░░░ 46% 92K / 200K OK │
|
|
11
|
+
├──────────────────────────────────────────────────────────────────────────────┤
|
|
12
|
+
│ USAGE WINDOW (Anthropic API) │
|
|
13
|
+
│ 5h ████████░░░░░░░░░░░░░░░░░░░░ 28.0% resets in 3h │
|
|
14
|
+
│ wk ███░░░░░░░░░░░░░░░░░░░░░░░░░ 9.0% resets in 148h │
|
|
15
|
+
├──────────────────────────────────────────────────────────────────────────────┤
|
|
16
|
+
│ TOKENS (this session) │
|
|
17
|
+
│ input ░░░░░░░░░░░░░░░░░░░░░░░░ 4.8K 0% │
|
|
18
|
+
│ output ░░░░░░░░░░░░░░░░░░░░░░░░ 188.5K 0% │
|
|
19
|
+
│ cache-read ████████████████████████ 51.5M 100% │
|
|
20
|
+
│ cache-write ██░░░░░░░░░░░░░░░░░░░░░░ 3.8M 7% │
|
|
21
|
+
└──────────────────────────────────────────────────────────────────────────────┘
|
|
21
22
|
```
|
|
22
23
|
|
|
23
24
|
---
|
|
24
25
|
|
|
25
26
|
## Features
|
|
26
27
|
|
|
27
|
-
### TOKENS tab
|
|
28
|
-
- Context window usage gauge
|
|
29
|
-
-
|
|
30
|
-
- Input / output / cache-read / cache-write breakdown with
|
|
31
|
-
-
|
|
32
|
-
- Model name
|
|
33
|
-
|
|
34
|
-
### PROJECT tab
|
|
35
|
-
-
|
|
36
|
-
-
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
28
|
+
### 1 TOKENS tab
|
|
29
|
+
- Context window usage gauge with percentage (OK / MID / WARN)
|
|
30
|
+
- **5h and weekly usage** from Anthropic OAuth API — real percentages, not estimates
|
|
31
|
+
- Input / output / cache-read / cache-write token breakdown with bars
|
|
32
|
+
- Output tokens sparkline (▁▂▃▄▅▆▇█) over the last 12 hours
|
|
33
|
+
- Model name and session uptime
|
|
34
|
+
|
|
35
|
+
### 2 PROJECT tab — interactive file browser
|
|
36
|
+
- Directory tree with `▶`/`▼` expand/collapse
|
|
37
|
+
- **Source file viewer** — select any file and read its contents in a split panel
|
|
38
|
+
- File count per directory, extension-based color coding
|
|
39
|
+
- Package dependency tree from `package.json`
|
|
40
|
+
- API endpoint detection (GET / POST / PUT / DELETE / PATCH)
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
TREE │ SOURCE src/index.ts
|
|
44
|
+
▼ src/ 23f │ 1 import React from 'react'
|
|
45
|
+
▼ components/ 8f │ 2 import { render } from 'ink'
|
|
46
|
+
Header.tsx ◀ open │ 3
|
|
47
|
+
▶ hooks/ 4f │ 4 render(<App />)
|
|
48
|
+
▶ scripts/ 6f │ … [j/k] scroll [esc] close
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 3 GIT tab
|
|
52
|
+
- Current branch, ahead/behind remote counts
|
|
42
53
|
- Changed file list (MOD / ADD / DEL)
|
|
43
|
-
- Per-file diff visualization
|
|
44
|
-
- Recent commit history with hash, message, and time
|
|
54
|
+
- Per-file diff visualization with real `+N -N` line counts
|
|
55
|
+
- Recent commit history with hash, message, and relative time
|
|
45
56
|
|
|
46
57
|
---
|
|
47
58
|
|
|
48
59
|
## Installation
|
|
49
60
|
|
|
50
|
-
### Option 1 —
|
|
61
|
+
### Option 1 — npx (no install required)
|
|
51
62
|
|
|
52
63
|
```bash
|
|
53
|
-
|
|
64
|
+
npx claude-code-hud
|
|
54
65
|
```
|
|
55
66
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
### Option 2 — npx (no install required)
|
|
67
|
+
### Option 2 — npm global install
|
|
59
68
|
|
|
60
69
|
```bash
|
|
61
|
-
|
|
70
|
+
npm install -g claude-code-hud
|
|
71
|
+
claude-hud
|
|
62
72
|
```
|
|
63
73
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
### Option 3 — npm global install
|
|
74
|
+
### Option 3 — Claude Code Plugin
|
|
67
75
|
|
|
68
76
|
```bash
|
|
69
|
-
|
|
70
|
-
claude-hud
|
|
77
|
+
/plugin install letsgojh0810/hud-plugin
|
|
71
78
|
```
|
|
72
79
|
|
|
73
80
|
---
|
|
74
81
|
|
|
75
82
|
## Usage
|
|
76
83
|
|
|
77
|
-
Run in a **separate terminal window** or **tmux split pane** while Claude Code is active
|
|
84
|
+
Run in a **separate terminal window** or **tmux split pane** while Claude Code is active:
|
|
78
85
|
|
|
79
86
|
```bash
|
|
80
|
-
# Separate terminal
|
|
87
|
+
# Separate terminal — run from your project directory
|
|
88
|
+
cd ~/my-project
|
|
81
89
|
npx claude-code-hud
|
|
82
90
|
|
|
83
|
-
# tmux split
|
|
84
|
-
tmux split-window -h "npx claude-code-hud"
|
|
91
|
+
# tmux split pane
|
|
92
|
+
tmux split-window -h "cd ~/my-project && npx claude-code-hud"
|
|
85
93
|
|
|
86
|
-
#
|
|
94
|
+
# Specify project root explicitly
|
|
87
95
|
CLAUDE_PROJECT_ROOT=/path/to/project npx claude-code-hud
|
|
88
96
|
```
|
|
89
97
|
|
|
@@ -91,50 +99,67 @@ CLAUDE_PROJECT_ROOT=/path/to/project npx claude-code-hud
|
|
|
91
99
|
|
|
92
100
|
## Keyboard Shortcuts
|
|
93
101
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
|
97
|
-
|
|
98
|
-
| `
|
|
99
|
-
| `
|
|
100
|
-
| `
|
|
101
|
-
| `d`
|
|
102
|
-
| `
|
|
102
|
+
### Global
|
|
103
|
+
|
|
104
|
+
| Key | Action |
|
|
105
|
+
|---------|--------------------------|
|
|
106
|
+
| `1` | Switch to TOKENS tab |
|
|
107
|
+
| `2` | Switch to PROJECT tab |
|
|
108
|
+
| `3` | Switch to GIT tab |
|
|
109
|
+
| `d` | Toggle dark / light mode |
|
|
110
|
+
| `r` | Manual refresh |
|
|
111
|
+
| `q` | Quit |
|
|
112
|
+
|
|
113
|
+
### TOKENS / GIT tab
|
|
114
|
+
|
|
115
|
+
| Key | Action |
|
|
116
|
+
|---------|--------------|
|
|
117
|
+
| `j` / `↓` | Scroll down |
|
|
118
|
+
| `k` / `↑` | Scroll up |
|
|
119
|
+
|
|
120
|
+
### PROJECT tab — file browser
|
|
121
|
+
|
|
122
|
+
| Key | Action |
|
|
123
|
+
|--------------|-------------------------------|
|
|
124
|
+
| `j` / `↓` | Move cursor down |
|
|
125
|
+
| `k` / `↑` | Move cursor up |
|
|
126
|
+
| `→` / `Enter`| Expand directory |
|
|
127
|
+
| `←` | Collapse directory / close viewer |
|
|
128
|
+
| `Enter` on file | Open source viewer |
|
|
129
|
+
| `Esc` | Close source viewer |
|
|
130
|
+
| `j` / `k` | Scroll source (when open) |
|
|
103
131
|
|
|
104
132
|
---
|
|
105
133
|
|
|
106
134
|
## Requirements
|
|
107
135
|
|
|
108
136
|
- **Node.js 18+**
|
|
109
|
-
- **Claude Code** installed and
|
|
110
|
-
- **Claude Pro or Max plan** recommended
|
|
111
|
-
- Git (for
|
|
137
|
+
- **Claude Code** installed and authenticated (for token data)
|
|
138
|
+
- **Claude Pro or Max plan** recommended — enables real 5h/weekly usage % from Anthropic API
|
|
139
|
+
- Git (optional, for GIT tab)
|
|
112
140
|
|
|
113
141
|
---
|
|
114
142
|
|
|
115
143
|
## Environment Variables
|
|
116
144
|
|
|
117
|
-
| Variable | Default
|
|
118
|
-
|
|
119
|
-
| `CLAUDE_PROJECT_ROOT` | `process.cwd()` |
|
|
145
|
+
| Variable | Default | Description |
|
|
146
|
+
|-----------------------|-----------------|------------------------------------------|
|
|
147
|
+
| `CLAUDE_PROJECT_ROOT` | `process.cwd()` | Project root directory to monitor |
|
|
120
148
|
|
|
121
149
|
---
|
|
122
150
|
|
|
123
151
|
## How it works
|
|
124
152
|
|
|
125
|
-
- **Token data**:
|
|
126
|
-
- **Usage window**:
|
|
127
|
-
- **Git status**: Polls
|
|
128
|
-
- **Project scan**:
|
|
153
|
+
- **Token data**: Watches `~/.claude/projects/*/sessions/*.jsonl` with chokidar — updates instantly when Claude responds
|
|
154
|
+
- **Usage window**: Calls `api.anthropic.com/api/oauth/usage` with your local Claude credentials (same as Claude Code uses) — cached 5 min
|
|
155
|
+
- **Git status**: Polls git every 3 seconds
|
|
156
|
+
- **Project scan**: One-time fast-glob scan on startup, `r` to rescan
|
|
129
157
|
|
|
130
158
|
---
|
|
131
159
|
|
|
132
160
|
## Color Theme
|
|
133
161
|
|
|
134
|
-
Toss Blue (`#3182F6`) based palette
|
|
135
|
-
|
|
136
|
-
Dark mode uses `#0E1117` background. Light mode uses `#FFFFFF`.
|
|
137
|
-
Toggle with the `d` key at any time.
|
|
162
|
+
Toss Blue (`#3182F6`) based palette. Full dark and light mode — toggle with `d`.
|
|
138
163
|
|
|
139
164
|
---
|
|
140
165
|
|
|
@@ -144,18 +169,11 @@ Toggle with the `d` key at any time.
|
|
|
144
169
|
git clone https://github.com/letsgojh0810/hud-plugin.git
|
|
145
170
|
cd hud-plugin
|
|
146
171
|
npm install
|
|
147
|
-
npm run hud
|
|
172
|
+
npm run hud
|
|
148
173
|
```
|
|
149
174
|
|
|
150
175
|
---
|
|
151
176
|
|
|
152
|
-
## Notes for Korean users
|
|
153
|
-
|
|
154
|
-
이 플러그인은 Claude Code를 터미널에서 집중적으로 사용하는 개발자를 위해 만들어졌습니다.
|
|
155
|
-
토큰 사용량, Git 상태, 프로젝트 구조를 별도 터미널 창에서 실시간으로 확인할 수 있습니다.
|
|
156
|
-
|
|
157
|
-
---
|
|
158
|
-
|
|
159
177
|
## License
|
|
160
178
|
|
|
161
179
|
MIT — [letsgojh0810](https://github.com/letsgojh0810)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* claude-code-hud entry point
|
|
4
|
+
* Launches the Ink TUI for Claude Code token/git monitoring
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from 'child_process';
|
|
7
|
+
import { existsSync } from 'fs';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import { dirname, join } from 'path';
|
|
10
|
+
|
|
11
|
+
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const hudFile = join(__dir, '..', 'tui', 'hud.tsx');
|
|
13
|
+
|
|
14
|
+
// Use local tsx if available, otherwise try PATH
|
|
15
|
+
const localTsx = join(__dir, '..', 'node_modules', '.bin', 'tsx');
|
|
16
|
+
const tsxBin = existsSync(localTsx) ? localTsx : 'tsx';
|
|
17
|
+
|
|
18
|
+
const proc = spawn(tsxBin, [hudFile], {
|
|
19
|
+
stdio: 'inherit',
|
|
20
|
+
env: { ...process.env, CLAUDE_PROJECT_ROOT: process.env.CLAUDE_PROJECT_ROOT || process.cwd() },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
proc.on('exit', (code) => process.exit(code ?? 0));
|
|
24
|
+
proc.on('error', (err) => {
|
|
25
|
+
if (err.code === 'ENOENT') {
|
|
26
|
+
console.error('tsx not found. Run: npm install -g tsx');
|
|
27
|
+
} else {
|
|
28
|
+
console.error('Failed to start HUD:', err.message);
|
|
29
|
+
}
|
|
30
|
+
process.exit(1);
|
|
31
|
+
});
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-hud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Terminal HUD for Claude Code — real-time token usage, git status, project monitor",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"claude-hud": "./bin/claude-hud"
|
|
7
|
+
"claude-hud": "./bin/claude-hud.js"
|
|
8
8
|
},
|
|
9
9
|
"keywords": [
|
|
10
10
|
"claude",
|
package/tui/hud.tsx
CHANGED
|
@@ -66,13 +66,13 @@ type DirNode = {
|
|
|
66
66
|
fileCount: number; // direct files only
|
|
67
67
|
totalFiles: number; // recursive total
|
|
68
68
|
children: DirNode[];
|
|
69
|
+
files: string[]; // direct file names
|
|
69
70
|
expanded: boolean;
|
|
70
71
|
};
|
|
71
72
|
|
|
72
|
-
type FlatNode =
|
|
73
|
-
node: DirNode;
|
|
74
|
-
depth: number;
|
|
75
|
-
};
|
|
73
|
+
type FlatNode =
|
|
74
|
+
| { type: 'dir'; node: DirNode; depth: number }
|
|
75
|
+
| { type: 'file'; filePath: string; fileName: string; depth: number };
|
|
76
76
|
|
|
77
77
|
// ── Project scanner ────────────────────────────────────────────────────────
|
|
78
78
|
type ProjectInfo = {
|
|
@@ -100,7 +100,7 @@ async function scanProject(cwd: string): Promise<ProjectInfo> {
|
|
|
100
100
|
|
|
101
101
|
// Build directory tree
|
|
102
102
|
function buildTree(filePaths: string[]): DirNode {
|
|
103
|
-
const root: DirNode = { name: '.', path: '', fileCount: 0, totalFiles: 0, children: [], expanded: true };
|
|
103
|
+
const root: DirNode = { name: '.', path: '', fileCount: 0, totalFiles: 0, children: [], files: [], expanded: true };
|
|
104
104
|
for (const file of filePaths) {
|
|
105
105
|
const parts = file.split('/');
|
|
106
106
|
let cur = root;
|
|
@@ -108,12 +108,13 @@ async function scanProject(cwd: string): Promise<ProjectInfo> {
|
|
|
108
108
|
const seg = parts[i];
|
|
109
109
|
let child = cur.children.find(c => c.name === seg);
|
|
110
110
|
if (!child) {
|
|
111
|
-
child = { name: seg, path: parts.slice(0, i + 1).join('/'), fileCount: 0, totalFiles: 0, children: [], expanded: false };
|
|
111
|
+
child = { name: seg, path: parts.slice(0, i + 1).join('/'), fileCount: 0, totalFiles: 0, children: [], files: [], expanded: false };
|
|
112
112
|
cur.children.push(child);
|
|
113
113
|
}
|
|
114
114
|
cur = child;
|
|
115
115
|
}
|
|
116
116
|
cur.fileCount++;
|
|
117
|
+
cur.files.push(parts[parts.length - 1]);
|
|
117
118
|
}
|
|
118
119
|
function calcTotal(n: DirNode): number {
|
|
119
120
|
n.totalFiles = n.fileCount + n.children.reduce((s, c) => s + calcTotal(c), 0);
|
|
@@ -169,10 +170,15 @@ function flattenTree(node: DirNode, depth: number, expanded: Record<string, bool
|
|
|
169
170
|
const result: FlatNode[] = [];
|
|
170
171
|
const sorted = [...node.children].sort((a, b) => b.totalFiles - a.totalFiles);
|
|
171
172
|
for (const child of sorted) {
|
|
172
|
-
result.push({ node: child, depth });
|
|
173
|
+
result.push({ type: 'dir', node: child, depth });
|
|
173
174
|
const isExp = expanded[child.path] ?? false;
|
|
174
|
-
if (isExp
|
|
175
|
+
if (isExp) {
|
|
175
176
|
result.push(...flattenTree(child, depth + 1, expanded));
|
|
177
|
+
const sortedFiles = [...child.files].sort();
|
|
178
|
+
for (const f of sortedFiles) {
|
|
179
|
+
const filePath = child.path ? `${child.path}/${f}` : f;
|
|
180
|
+
result.push({ type: 'file', filePath, fileName: f, depth: depth + 1 });
|
|
181
|
+
}
|
|
176
182
|
}
|
|
177
183
|
}
|
|
178
184
|
return result;
|
|
@@ -302,43 +308,49 @@ function TokensTab({ usage, history, rateLimits, termWidth, C }: any) {
|
|
|
302
308
|
}
|
|
303
309
|
|
|
304
310
|
// ── Tab 2: PROJECT ─────────────────────────────────────────────────────────
|
|
305
|
-
function ProjectTab({ info, treeCursor, treeExpanded, termWidth, C }: any) {
|
|
311
|
+
function ProjectTab({ info, treeCursor, treeExpanded, selectedFile, fileLines, fileScroll, termWidth, C }: any) {
|
|
306
312
|
if (!info) return (
|
|
307
313
|
<Box borderStyle="single" borderColor={C.border} paddingX={1}>
|
|
308
314
|
<Text color={C.dimmer}>scanning project…</Text>
|
|
309
315
|
</Box>
|
|
310
316
|
);
|
|
311
317
|
|
|
312
|
-
// Flatten visible tree using treeExpanded from props
|
|
318
|
+
// Flatten visible tree using treeExpanded from props
|
|
313
319
|
function flatNodes_inner(node: DirNode, depth: number): FlatNode[] {
|
|
314
320
|
const result: FlatNode[] = [];
|
|
315
321
|
const sorted = [...node.children].sort((a, b) => b.totalFiles - a.totalFiles);
|
|
316
322
|
for (const child of sorted) {
|
|
317
|
-
result.push({ node: child, depth });
|
|
323
|
+
result.push({ type: 'dir', node: child, depth });
|
|
318
324
|
const isExp = treeExpanded[child.path] ?? false;
|
|
319
|
-
if (isExp
|
|
325
|
+
if (isExp) {
|
|
320
326
|
result.push(...flatNodes_inner(child, depth + 1));
|
|
327
|
+
const sortedFiles = [...child.files].sort();
|
|
328
|
+
for (const f of sortedFiles) {
|
|
329
|
+
const filePath = child.path ? `${child.path}/${f}` : f;
|
|
330
|
+
result.push({ type: 'file', filePath, fileName: f, depth: depth + 1 });
|
|
331
|
+
}
|
|
321
332
|
}
|
|
322
333
|
}
|
|
323
334
|
return result;
|
|
324
335
|
}
|
|
325
336
|
|
|
326
|
-
const flatNodes = info.dirTree ? flatNodes_inner(info.dirTree, 0) : [];
|
|
337
|
+
const flatNodes: FlatNode[] = info.dirTree ? flatNodes_inner(info.dirTree, 0) : [];
|
|
327
338
|
const safeCursor = Math.min(treeCursor, Math.max(0, flatNodes.length - 1));
|
|
328
339
|
|
|
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
340
|
const totalEndpoints = Object.values(info.endpoints as Record<string, number>).reduce((a: number, b: number) => a + b, 0);
|
|
341
|
-
|
|
341
|
+
|
|
342
|
+
// Split layout when file is open
|
|
343
|
+
const hasFile = !!selectedFile;
|
|
344
|
+
const TREE_W = hasFile ? Math.max(28, Math.floor(termWidth * 0.36)) : termWidth - 2;
|
|
345
|
+
const SOURCE_W = hasFile ? termWidth - TREE_W - 5 : 0;
|
|
346
|
+
const VISIBLE_LINES = 22;
|
|
347
|
+
|
|
348
|
+
const EXT_COLOR: Record<string, string> = {
|
|
349
|
+
'.ts': C.brand, '.tsx': C.brand, '.js': C.cyan, '.jsx': C.cyan,
|
|
350
|
+
'.py': C.yellow, '.go': C.cyan, '.java': C.yellow, '.rs': C.red,
|
|
351
|
+
'.json': C.dim, '.md': C.green, '.css': C.purple, '.html': C.yellow,
|
|
352
|
+
'.mjs': C.cyan, '.cjs': C.cyan,
|
|
353
|
+
};
|
|
342
354
|
|
|
343
355
|
return (
|
|
344
356
|
<Box flexDirection="column">
|
|
@@ -346,54 +358,95 @@ function ProjectTab({ info, treeCursor, treeExpanded, termWidth, C }: any) {
|
|
|
346
358
|
<Box borderStyle="single" borderColor={C.border} paddingX={1}>
|
|
347
359
|
<Text color={C.text} bold>{info.totalFiles} files</Text>
|
|
348
360
|
<Text color={C.dim}> │ </Text>
|
|
349
|
-
<Text color={C.text} bold>{info.packages.filter((p: any) => p.depth === 0).length}
|
|
350
|
-
<Text color={C.dim}> │ </Text>
|
|
351
|
-
|
|
352
|
-
|
|
361
|
+
<Text color={C.text} bold>{info.packages.filter((p: any) => p.depth === 0).length} pkgs</Text>
|
|
362
|
+
<Text color={C.dim}> │ ~{totalEndpoints} endpoints │ </Text>
|
|
363
|
+
{hasFile
|
|
364
|
+
? <Text color={C.brand}>{selectedFile}</Text>
|
|
365
|
+
: <Text color={C.dimmer}>[enter] open file [←] collapse</Text>
|
|
366
|
+
}
|
|
353
367
|
</Box>
|
|
354
368
|
|
|
355
|
-
{/*
|
|
356
|
-
<Box flexDirection="
|
|
357
|
-
|
|
358
|
-
{
|
|
359
|
-
{
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
369
|
+
{/* Main area: tree + optional source */}
|
|
370
|
+
<Box flexDirection="row">
|
|
371
|
+
|
|
372
|
+
{/* ── Tree panel ── */}
|
|
373
|
+
<Box flexDirection="column" borderStyle="single" borderColor={hasFile ? C.brand : C.border} paddingX={1} width={TREE_W}>
|
|
374
|
+
<Text color={C.dim} bold>TREE</Text>
|
|
375
|
+
{flatNodes.length === 0 && <Text color={C.dimmer}> (empty)</Text>}
|
|
376
|
+
{flatNodes.map((fn, idx) => {
|
|
377
|
+
const isSelected = idx === safeCursor;
|
|
378
|
+
const indent = ' '.repeat(fn.depth);
|
|
379
|
+
if (fn.type === 'dir') {
|
|
380
|
+
const isExp = treeExpanded[fn.node.path] ?? false;
|
|
381
|
+
const hasChildren = fn.node.children.length > 0 || fn.node.files.length > 0;
|
|
382
|
+
const expIcon = hasChildren ? (isExp ? '▼ ' : '▶ ') : ' ';
|
|
383
|
+
const nameColor = isSelected ? C.brand : fn.depth === 0 ? C.text : C.dim;
|
|
384
|
+
return (
|
|
385
|
+
<Box key={`d_${fn.node.path}_${idx}`}>
|
|
386
|
+
<Text color={C.dimmer}>{indent}</Text>
|
|
387
|
+
<Text color={isSelected ? C.brand : C.dimmer}>{expIcon}</Text>
|
|
388
|
+
<Text color={nameColor} bold={isSelected}>{fn.node.name}/</Text>
|
|
389
|
+
<Text color={C.dimmer}> {fn.node.totalFiles}f</Text>
|
|
390
|
+
</Box>
|
|
391
|
+
);
|
|
392
|
+
} else {
|
|
393
|
+
const ext = fn.fileName.includes('.') ? '.' + fn.fileName.split('.').pop()! : '';
|
|
394
|
+
const fileColor = isSelected ? C.brand : (EXT_COLOR[ext] ?? C.text);
|
|
395
|
+
const isOpen = selectedFile === fn.filePath;
|
|
396
|
+
return (
|
|
397
|
+
<Box key={`f_${fn.filePath}_${idx}`}>
|
|
398
|
+
<Text color={C.dimmer}>{indent}</Text>
|
|
399
|
+
<Text color={isSelected ? C.brand : C.dimmer}>{isOpen ? '▶ ' : ' '}</Text>
|
|
400
|
+
<Text color={fileColor} bold={isSelected || isOpen}>{fn.fileName}</Text>
|
|
401
|
+
</Box>
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
})}
|
|
405
|
+
</Box>
|
|
379
406
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
407
|
+
{/* ── Source viewer panel ── */}
|
|
408
|
+
{hasFile && (
|
|
409
|
+
<Box flexDirection="column" borderStyle="single" borderColor={C.brand} paddingX={1} width={SOURCE_W}>
|
|
410
|
+
<Text color={C.brand} bold>SOURCE <Text color={C.dim}>{selectedFile}</Text></Text>
|
|
411
|
+
{(fileLines as string[]).slice(fileScroll, fileScroll + VISIBLE_LINES).map((line, i) => {
|
|
412
|
+
const lineNo = fileScroll + i + 1;
|
|
413
|
+
const truncated = line.length > SOURCE_W - 6 ? line.slice(0, SOURCE_W - 7) + '…' : line;
|
|
414
|
+
return (
|
|
415
|
+
<Box key={i}>
|
|
416
|
+
<Box width={4} justifyContent="flex-end">
|
|
417
|
+
<Text color={C.dimmer}>{lineNo}</Text>
|
|
418
|
+
</Box>
|
|
419
|
+
<Text color={C.dimmer}> </Text>
|
|
420
|
+
<Text color={C.text}>{truncated}</Text>
|
|
421
|
+
</Box>
|
|
422
|
+
);
|
|
423
|
+
})}
|
|
424
|
+
{(fileLines as string[]).length > VISIBLE_LINES && (
|
|
425
|
+
<Text color={C.dimmer}> ↕ {fileScroll + 1}–{Math.min(fileScroll + VISIBLE_LINES, fileLines.length)} / {fileLines.length} lines [j/k] scroll [esc] close</Text>
|
|
426
|
+
)}
|
|
427
|
+
</Box>
|
|
428
|
+
)}
|
|
396
429
|
</Box>
|
|
430
|
+
|
|
431
|
+
{/* Packages (hidden when file open to save space) */}
|
|
432
|
+
{!hasFile && (
|
|
433
|
+
<Box flexDirection="column" borderStyle="single" borderColor={C.border} paddingX={1}>
|
|
434
|
+
<Text color={C.dim} bold>PACKAGES</Text>
|
|
435
|
+
{info.packages.slice(0, 10).map((p: any, i: number) => {
|
|
436
|
+
const isRoot = p.depth === 0;
|
|
437
|
+
const nextIsRoot = i + 1 < info.packages.length && info.packages[i + 1].depth === 0;
|
|
438
|
+
const isLast = nextIsRoot || i === Math.min(9, info.packages.length - 1);
|
|
439
|
+
const prefix = isRoot ? '' : (isLast ? '└─ ' : '├─ ');
|
|
440
|
+
return (
|
|
441
|
+
<Box key={i}>
|
|
442
|
+
<Text color={C.dimmer}>{isRoot ? '' : ' '}{prefix}</Text>
|
|
443
|
+
<Text color={isRoot ? C.brand : C.text}>{p.name}</Text>
|
|
444
|
+
<Text color={C.dimmer}> {p.version}</Text>
|
|
445
|
+
</Box>
|
|
446
|
+
);
|
|
447
|
+
})}
|
|
448
|
+
</Box>
|
|
449
|
+
)}
|
|
397
450
|
</Box>
|
|
398
451
|
);
|
|
399
452
|
}
|
|
@@ -502,6 +555,11 @@ function App() {
|
|
|
502
555
|
const [treeCursor, setTreeCursor] = useState(0);
|
|
503
556
|
const [treeExpanded, setTreeExpanded] = useState<Record<string, boolean>>({});
|
|
504
557
|
|
|
558
|
+
// Source viewer state
|
|
559
|
+
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
560
|
+
const [fileLines, setFileLines] = useState<string[]>([]);
|
|
561
|
+
const [fileScroll, setFileScroll] = useState(0);
|
|
562
|
+
|
|
505
563
|
const refresh = useCallback(() => {
|
|
506
564
|
setUsage(readTokenUsage());
|
|
507
565
|
setHistory(readTokenHistory());
|
|
@@ -544,7 +602,14 @@ function App() {
|
|
|
544
602
|
}, []);
|
|
545
603
|
|
|
546
604
|
useInput((input, key) => {
|
|
547
|
-
if (input === 'q'
|
|
605
|
+
if (input === 'q') process.exit(0);
|
|
606
|
+
|
|
607
|
+
// Escape: close file viewer first, then quit
|
|
608
|
+
if (key.escape) {
|
|
609
|
+
if (selectedFile) { setSelectedFile(null); setFileLines([]); setFileScroll(0); return; }
|
|
610
|
+
process.exit(0);
|
|
611
|
+
}
|
|
612
|
+
|
|
548
613
|
if (input === '1') { setTab(0); setScrollY(0); }
|
|
549
614
|
if (input === '2') { setTab(1); setScrollY(0); }
|
|
550
615
|
if (input === '3') { setTab(2); setScrollY(0); }
|
|
@@ -554,11 +619,14 @@ function App() {
|
|
|
554
619
|
if (input === 'r') {
|
|
555
620
|
refresh();
|
|
556
621
|
setProject(null);
|
|
622
|
+
setSelectedFile(null); setFileLines([]); setFileScroll(0);
|
|
557
623
|
scanProject(cwd).then(p => { setProject(p); setTreeCursor(0); }).catch(() => {});
|
|
558
624
|
}
|
|
559
625
|
|
|
560
626
|
if (input === 'j' || key.downArrow) {
|
|
561
|
-
if (tab === 1) {
|
|
627
|
+
if (tab === 1 && selectedFile) {
|
|
628
|
+
setFileScroll(s => Math.min(s + 1, Math.max(0, fileLines.length - 5)));
|
|
629
|
+
} else if (tab === 1) {
|
|
562
630
|
const flat = project?.dirTree ? flattenTree(project.dirTree, 0, treeExpanded) : [];
|
|
563
631
|
setTreeCursor(c => Math.min(c + 1, flat.length - 1));
|
|
564
632
|
} else {
|
|
@@ -566,31 +634,52 @@ function App() {
|
|
|
566
634
|
}
|
|
567
635
|
}
|
|
568
636
|
if (input === 'k' || key.upArrow) {
|
|
569
|
-
if (tab === 1
|
|
570
|
-
|
|
637
|
+
if (tab === 1 && selectedFile) {
|
|
638
|
+
setFileScroll(s => Math.max(s - 1, 0));
|
|
639
|
+
} else if (tab === 1) {
|
|
640
|
+
setTreeCursor(c => Math.max(c - 1, 0));
|
|
641
|
+
} else {
|
|
642
|
+
setScrollY(s => Math.max(s - 1, 0));
|
|
643
|
+
}
|
|
571
644
|
}
|
|
572
645
|
|
|
573
|
-
// Enter / Space — toggle expand
|
|
646
|
+
// Enter / Space — dir: toggle expand, file: open source viewer
|
|
574
647
|
if ((key.return || input === ' ') && tab === 1 && project?.dirTree) {
|
|
575
648
|
const flat = flattenTree(project.dirTree, 0, treeExpanded);
|
|
576
|
-
const
|
|
577
|
-
if (
|
|
578
|
-
|
|
649
|
+
const sel = flat[treeCursor];
|
|
650
|
+
if (!sel) return;
|
|
651
|
+
if (sel.type === 'dir') {
|
|
652
|
+
const path = sel.node.path;
|
|
579
653
|
setTreeExpanded(prev => ({ ...prev, [path]: !(prev[path] ?? false) }));
|
|
654
|
+
} else {
|
|
655
|
+
// file: toggle source viewer
|
|
656
|
+
if (selectedFile === sel.filePath) {
|
|
657
|
+
setSelectedFile(null); setFileLines([]); setFileScroll(0);
|
|
658
|
+
} else {
|
|
659
|
+
try {
|
|
660
|
+
const content = fs.readFileSync(join(cwd, sel.filePath), 'utf-8');
|
|
661
|
+
setFileLines(content.split('\n'));
|
|
662
|
+
} catch {
|
|
663
|
+
setFileLines(['(cannot read file)']);
|
|
664
|
+
}
|
|
665
|
+
setSelectedFile(sel.filePath);
|
|
666
|
+
setFileScroll(0);
|
|
667
|
+
}
|
|
580
668
|
}
|
|
581
669
|
}
|
|
582
670
|
|
|
583
|
-
// Arrow right = expand, left = collapse
|
|
671
|
+
// Arrow right = expand dir, left = collapse
|
|
584
672
|
if (key.rightArrow && tab === 1 && project?.dirTree) {
|
|
585
673
|
const flat = flattenTree(project.dirTree, 0, treeExpanded);
|
|
586
|
-
const
|
|
587
|
-
if (
|
|
674
|
+
const sel = flat[treeCursor];
|
|
675
|
+
if (sel?.type === 'dir') setTreeExpanded(prev => ({ ...prev, [sel.node.path]: true }));
|
|
588
676
|
}
|
|
589
677
|
if (key.leftArrow && tab === 1) {
|
|
678
|
+
if (selectedFile) { setSelectedFile(null); setFileLines([]); setFileScroll(0); return; }
|
|
590
679
|
if (project?.dirTree) {
|
|
591
680
|
const flat = flattenTree(project.dirTree, 0, treeExpanded);
|
|
592
|
-
const
|
|
593
|
-
if (
|
|
681
|
+
const sel = flat[treeCursor];
|
|
682
|
+
if (sel?.type === 'dir') setTreeExpanded(prev => ({ ...prev, [sel.node.path]: false }));
|
|
594
683
|
}
|
|
595
684
|
}
|
|
596
685
|
});
|
|
@@ -622,7 +711,7 @@ function App() {
|
|
|
622
711
|
{/* ── Content (with scroll offset) ── */}
|
|
623
712
|
<Box flexDirection="column" marginTop={-scrollY}>
|
|
624
713
|
{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} />}
|
|
714
|
+
{tab === 1 && <ProjectTab info={project} treeCursor={treeCursor} treeExpanded={treeExpanded} selectedFile={selectedFile} fileLines={fileLines} fileScroll={fileScroll} termWidth={termWidth} C={C} />}
|
|
626
715
|
{tab === 2 && <GitTab git={git} termWidth={termWidth} C={C} />}
|
|
627
716
|
</Box>
|
|
628
717
|
|
|
@@ -632,7 +721,7 @@ function App() {
|
|
|
632
721
|
<Text color={C.green}>● </Text>
|
|
633
722
|
<Text color={C.dimmer}>[1/2/3] tabs </Text>
|
|
634
723
|
<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
|
|
724
|
+
<Text color={tab === 1 ? C.brand : C.dimmer}>{tab === 1 ? (selectedFile ? '[esc/←] close [j/k] scroll ' : '[enter] open [→←] expand ') : ''}</Text>
|
|
636
725
|
<Text color={C.dimmer}>[r] refresh [d] theme [q] quit</Text>
|
|
637
726
|
</Box>
|
|
638
727
|
<Text color={C.dimmer}>↻ {since}</Text>
|