claude-code-hud 0.2.0 → 0.3.1
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 +148 -106
- package/package.json +2 -2
- package/tui/hud.tsx +181 -89
- /package/bin/{claude-hud → claude-hud.js} +0 -0
package/README.md
CHANGED
|
@@ -1,161 +1,203 @@
|
|
|
1
1
|
# claude-code-hud
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
```
|
|
6
|
-
┌────────────────────────────────────────────────────────┐
|
|
7
|
-
│ ◆ HUD [1 TOKENS] 2 PROJECT 3 GIT sonnet-4-6 │
|
|
8
|
-
├────────────────────────────────────────────────────────┤
|
|
9
|
-
│ CONTEXT WINDOW │
|
|
10
|
-
│ ████████████████░░░░░░░░░░░ 34% 67K / 200K OK │
|
|
11
|
-
├────────────────────────────────────────────────────────┤
|
|
12
|
-
│ USAGE WINDOW (Anthropic API) │
|
|
13
|
-
│ 5h ████████████████░░░░ 62.0% resets in 4h │
|
|
14
|
-
│ wk ████░░░░░░░░░░░░░░░░ 15.0% resets in 144h │
|
|
15
|
-
├────────────────────────────────────────────────────────┤
|
|
16
|
-
│ INPUT ██████░░░░░░░░░░░░░░ 48.2K $0.0145 │
|
|
17
|
-
│ OUTPUT ██░░░░░░░░░░░░░░░░░░ 8.1K $0.0122 │
|
|
18
|
-
│ CACHE ████████████░░░░░░░░ 52.0K $0.0047 │
|
|
19
|
-
│ $0.0314 │
|
|
20
|
-
└────────────────────────────────────────────────────────┘
|
|
21
|
-
```
|
|
3
|
+
[한국어](#한국어) | [English](#english)
|
|
22
4
|
|
|
23
5
|
---
|
|
24
6
|
|
|
25
|
-
##
|
|
7
|
+
## 한국어
|
|
26
8
|
|
|
27
|
-
|
|
28
|
-
- Context window usage gauge (█░ progress bar) with percentage and token counts
|
|
29
|
-
- 5-hour and weekly usage window from Anthropic API (real %)
|
|
30
|
-
- Input / output / cache-read / cache-write breakdown with cost
|
|
31
|
-
- Processing sparkline (▁▂▃▄▅▆▇█) over recent turns
|
|
32
|
-
- Model name display
|
|
9
|
+
Claude Code로 작업할 때 토큰 사용량, git 상태, 파일 구조를 IDE나 별도 탭 없이 터미널 하나에서 확인할 수 있는 HUD입니다.
|
|
33
10
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
11
|
+
```
|
|
12
|
+
┌──────────────────────────────────────────────────────────────────────────────┐
|
|
13
|
+
│ ◆ HUD [1 TOKENS] 2 PROJECT 3 GIT sonnet-4-6 · up 4m │
|
|
14
|
+
├──────────────────────────────────────────────────────────────────────────────┤
|
|
15
|
+
│ CONTEXT WINDOW │
|
|
16
|
+
│ ████████████████████░░░░░░░░░░░░░░░░░░░░░░░ 46% 92K / 200K OK │
|
|
17
|
+
├──────────────────────────────────────────────────────────────────────────────┤
|
|
18
|
+
│ USAGE WINDOW (Anthropic API) │
|
|
19
|
+
│ 5h ████████░░░░░░░░░░░░░░░░░░░░ 28.0% resets in 3h │
|
|
20
|
+
│ wk ███░░░░░░░░░░░░░░░░░░░░░░░░░ 9.0% resets in 148h │
|
|
21
|
+
├──────────────────────────────────────────────────────────────────────────────┤
|
|
22
|
+
│ TOKENS (this session) │
|
|
23
|
+
│ input ░░░░░░░░░░░░░░░░░░░░░░░░ 4.8K 0% │
|
|
24
|
+
│ output ░░░░░░░░░░░░░░░░░░░░░░░░ 188.5K 0% │
|
|
25
|
+
│ cache-read ████████████████████████ 51.5M 100% │
|
|
26
|
+
│ cache-write ██░░░░░░░░░░░░░░░░░░░░░░ 3.8M 7% │
|
|
27
|
+
└──────────────────────────────────────────────────────────────────────────────┘
|
|
28
|
+
```
|
|
39
29
|
|
|
40
|
-
###
|
|
41
|
-
- Current branch, ahead/behind counts
|
|
42
|
-
- Changed file list (MOD / ADD / DEL)
|
|
43
|
-
- Per-file diff visualization (+/- bars)
|
|
44
|
-
- Recent commit history with hash, message, and time
|
|
30
|
+
### 사용법
|
|
45
31
|
|
|
46
|
-
|
|
32
|
+
터미널 두 개를 열고 같은 프로젝트 디렉토리에서 실행하면 됩니다.
|
|
47
33
|
|
|
48
|
-
|
|
34
|
+
```
|
|
35
|
+
터미널 A 터미널 B
|
|
36
|
+
───────────────────────────── ─────────────────────────────
|
|
37
|
+
cd ~/my-project cd ~/my-project
|
|
38
|
+
claude npx claude-code-hud
|
|
39
|
+
(Claude Code 작업 중...) (HUD 실시간 표시)
|
|
40
|
+
```
|
|
49
41
|
|
|
50
|
-
|
|
42
|
+
HUD는 현재 디렉토리를 기준으로 토큰, git, 프로젝트 정보를 자동으로 인식합니다.
|
|
51
43
|
|
|
52
44
|
```bash
|
|
53
|
-
|
|
45
|
+
# tmux로 한 화면에서 split
|
|
46
|
+
cd ~/my-project
|
|
47
|
+
tmux split-window -h "npx claude-code-hud"
|
|
54
48
|
```
|
|
55
49
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
### Option 2 — npx (no install required)
|
|
50
|
+
### 설치
|
|
59
51
|
|
|
60
52
|
```bash
|
|
53
|
+
# 설치 없이 바로 실행
|
|
61
54
|
npx claude-code-hud
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
Runs the full interactive TUI in your current terminal. Open a separate terminal window or tmux pane first.
|
|
65
55
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
```bash
|
|
56
|
+
# 전역 설치
|
|
69
57
|
npm install -g claude-code-hud
|
|
70
58
|
claude-hud
|
|
59
|
+
|
|
60
|
+
# Claude Code 플러그인
|
|
61
|
+
/plugin install letsgojh0810/hud-plugin
|
|
71
62
|
```
|
|
72
63
|
|
|
73
|
-
|
|
64
|
+
### 기능
|
|
74
65
|
|
|
75
|
-
|
|
66
|
+
**1 TOKENS 탭**
|
|
67
|
+
- 컨텍스트 윈도우 사용량 게이지 (OK / MID / WARN)
|
|
68
|
+
- Anthropic API 기반 5h / 주간 사용률 (실제 값, 추정치 아님)
|
|
69
|
+
- input / output / cache-read / cache-write 토큰 분류
|
|
70
|
+
- 최근 12시간 output 토큰 sparkline
|
|
76
71
|
|
|
77
|
-
|
|
72
|
+
**2 PROJECT 탭 — 인터랙티브 파일 브라우저**
|
|
73
|
+
- 디렉토리 트리 (펼치기/접기)
|
|
74
|
+
- 파일 선택 시 소스 코드 뷰어 (split 패널)
|
|
75
|
+
- 패키지 의존성 트리
|
|
76
|
+
- API 엔드포인트 감지
|
|
78
77
|
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
```
|
|
79
|
+
TREE │ SOURCE src/index.ts
|
|
80
|
+
▼ src/ 23f │ 1 import React from 'react'
|
|
81
|
+
▼ components/ 8f │ 2 import { render } from 'ink'
|
|
82
|
+
Header.tsx ◀ open │ 3
|
|
83
|
+
▶ hooks/ 4f │ 4 render(<App />)
|
|
84
|
+
▶ scripts/ 6f │ … [j/k] scroll [esc] close
|
|
85
|
+
```
|
|
82
86
|
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
**3 GIT 탭**
|
|
88
|
+
- 현재 브랜치, ahead/behind 카운트
|
|
89
|
+
- 변경 파일 목록 (MOD / ADD / DEL)
|
|
90
|
+
- 파일별 diff 시각화 (+/- 바)
|
|
91
|
+
- 최근 커밋 히스토리
|
|
85
92
|
|
|
86
|
-
|
|
87
|
-
CLAUDE_PROJECT_ROOT=/path/to/project npx claude-code-hud
|
|
88
|
-
```
|
|
93
|
+
### 키보드 단축키
|
|
89
94
|
|
|
90
|
-
|
|
95
|
+
| 키 | 동작 |
|
|
96
|
+
|----|------|
|
|
97
|
+
| `1` `2` `3` | 탭 전환 |
|
|
98
|
+
| `j` / `k` | 스크롤 / 트리 이동 |
|
|
99
|
+
| `→` / `Enter` | 디렉토리 펼치기 / 파일 열기 |
|
|
100
|
+
| `←` / `Esc` | 접기 / 소스 뷰어 닫기 |
|
|
101
|
+
| `d` | 다크 / 라이트 모드 전환 |
|
|
102
|
+
| `r` | 수동 새로고침 |
|
|
103
|
+
| `q` | 종료 |
|
|
91
104
|
|
|
92
|
-
|
|
105
|
+
### 요구사항
|
|
93
106
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
| `3` | Switch to GIT tab |
|
|
99
|
-
| `j` | Scroll down |
|
|
100
|
-
| `k` | Scroll up |
|
|
101
|
-
| `d` | Toggle dark / light mode |
|
|
102
|
-
| `q` | Quit |
|
|
107
|
+
- Node.js 18+
|
|
108
|
+
- Claude Code 설치 및 로그인 (토큰 데이터 수집)
|
|
109
|
+
- Claude Pro / Max 플랜 권장 (5h / 주간 사용률 표시)
|
|
110
|
+
- Git (GIT 탭 사용 시)
|
|
103
111
|
|
|
104
112
|
---
|
|
105
113
|
|
|
106
|
-
##
|
|
114
|
+
## English
|
|
107
115
|
|
|
108
|
-
|
|
109
|
-
- **Claude Code** installed and active (for token data from JSONL session files)
|
|
110
|
-
- **Claude Pro or Max plan** recommended for full 5h/7d usage window data from Anthropic API
|
|
111
|
-
- Git (for git status features)
|
|
116
|
+
When working with Claude Code in the terminal, I kept running into the same friction: how many tokens are left? what's the git status? what does this file structure look like? — answering any of these meant switching to another app or opening more terminal tabs.
|
|
112
117
|
|
|
113
|
-
|
|
118
|
+
So I built this. Two terminals. One for Claude Code, one for the HUD. That's it.
|
|
114
119
|
|
|
115
|
-
|
|
120
|
+
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.
|
|
116
121
|
|
|
117
|
-
|
|
118
|
-
|-----------------------|-------------|-----------------------------------------------------|
|
|
119
|
-
| `CLAUDE_PROJECT_ROOT` | `process.cwd()` | Root directory of the project to monitor |
|
|
122
|
+
### Usage
|
|
120
123
|
|
|
121
|
-
|
|
124
|
+
Open two terminals in the same project directory.
|
|
122
125
|
|
|
123
|
-
|
|
126
|
+
```
|
|
127
|
+
Terminal A Terminal B
|
|
128
|
+
───────────────────────────── ─────────────────────────────
|
|
129
|
+
cd ~/my-project cd ~/my-project
|
|
130
|
+
claude npx claude-code-hud
|
|
131
|
+
(working with Claude Code) (HUD live display)
|
|
132
|
+
```
|
|
124
133
|
|
|
125
|
-
|
|
126
|
-
- **Usage window**: Reads Anthropic API usage limits (5h / weekly) when available
|
|
127
|
-
- **Git status**: Polls `simple-git` every 3–5 seconds for branch, diff, and commit info
|
|
128
|
-
- **Project scan**: Uses `fast-glob` to scan files and detect packages/endpoints once, then caches
|
|
134
|
+
The HUD automatically detects your current directory and shows token, git, and project info for that project.
|
|
129
135
|
|
|
130
|
-
|
|
136
|
+
```bash
|
|
137
|
+
# tmux split pane
|
|
138
|
+
cd ~/my-project
|
|
139
|
+
tmux split-window -h "npx claude-code-hud"
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Installation
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
# No install — run directly
|
|
146
|
+
npx claude-code-hud
|
|
131
147
|
|
|
132
|
-
|
|
148
|
+
# Global install
|
|
149
|
+
npm install -g claude-code-hud
|
|
150
|
+
claude-hud
|
|
133
151
|
|
|
134
|
-
|
|
152
|
+
# Claude Code plugin
|
|
153
|
+
/plugin install letsgojh0810/hud-plugin
|
|
154
|
+
```
|
|
135
155
|
|
|
136
|
-
|
|
137
|
-
Toggle with the `d` key at any time.
|
|
156
|
+
### Features
|
|
138
157
|
|
|
139
|
-
|
|
158
|
+
**1 TOKENS tab**
|
|
159
|
+
- Context window usage gauge (OK / MID / WARN)
|
|
160
|
+
- Real 5h / weekly usage from Anthropic OAuth API — not estimates
|
|
161
|
+
- Input / output / cache-read / cache-write breakdown
|
|
162
|
+
- Output tokens sparkline over the last 12 hours
|
|
140
163
|
|
|
141
|
-
|
|
164
|
+
**2 PROJECT tab — interactive file browser**
|
|
165
|
+
- Navigable directory tree with expand/collapse
|
|
166
|
+
- Source file viewer in a split panel
|
|
167
|
+
- Package dependency tree from `package.json`
|
|
168
|
+
- API endpoint detection (GET / POST / PUT / DELETE / PATCH)
|
|
142
169
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
npm run hud # launches TUI in dev mode
|
|
148
|
-
```
|
|
170
|
+
**3 GIT tab**
|
|
171
|
+
- Branch status, ahead/behind remote
|
|
172
|
+
- Changed file list (MOD / ADD / DEL) with real `+N -N` diff counts
|
|
173
|
+
- Recent commit history
|
|
149
174
|
|
|
150
|
-
|
|
175
|
+
### Keyboard Shortcuts
|
|
151
176
|
|
|
152
|
-
|
|
177
|
+
| Key | Action |
|
|
178
|
+
|-----|--------|
|
|
179
|
+
| `1` `2` `3` | Switch tabs |
|
|
180
|
+
| `j` / `k` | Scroll / move tree cursor |
|
|
181
|
+
| `→` / `Enter` | Expand dir / open file |
|
|
182
|
+
| `←` / `Esc` | Collapse / close source viewer |
|
|
183
|
+
| `d` | Toggle dark / light mode |
|
|
184
|
+
| `r` | Manual refresh |
|
|
185
|
+
| `q` | Quit |
|
|
153
186
|
|
|
154
|
-
|
|
155
|
-
토큰 사용량, Git 상태, 프로젝트 구조를 별도 터미널 창에서 실시간으로 확인할 수 있습니다.
|
|
187
|
+
### Requirements
|
|
156
188
|
|
|
157
|
-
|
|
189
|
+
- Node.js 18+
|
|
190
|
+
- Claude Code installed and authenticated
|
|
191
|
+
- Claude Pro or Max plan recommended (for real 5h / weekly usage %)
|
|
192
|
+
- Git (optional, for GIT tab)
|
|
158
193
|
|
|
159
|
-
|
|
194
|
+
### How it works
|
|
195
|
+
|
|
196
|
+
- **Token data**: Watches `~/.claude/projects/*/sessions/*.jsonl` with chokidar — updates instantly on each Claude response
|
|
197
|
+
- **Usage window**: Calls `api.anthropic.com/api/oauth/usage` using local Claude credentials — cached 5 min
|
|
198
|
+
- **Git**: Polls every 3 seconds
|
|
199
|
+
- **Project scan**: One-time fast-glob scan on startup, `r` to rescan
|
|
200
|
+
|
|
201
|
+
---
|
|
160
202
|
|
|
161
203
|
MIT — [letsgojh0810](https://github.com/letsgojh0810)
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-hud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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());
|
|
@@ -516,7 +574,10 @@ function App() {
|
|
|
516
574
|
// Initial API usage fetch
|
|
517
575
|
getUsage().then(setRateLimits).catch(() => {});
|
|
518
576
|
|
|
519
|
-
const onResize = () =>
|
|
577
|
+
const onResize = () => {
|
|
578
|
+
process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
|
|
579
|
+
setTermWidth(stdout?.columns ?? 80);
|
|
580
|
+
};
|
|
520
581
|
stdout?.on('resize', onResize);
|
|
521
582
|
|
|
522
583
|
const poll = setInterval(refresh, 3000);
|
|
@@ -544,53 +605,84 @@ function App() {
|
|
|
544
605
|
}, []);
|
|
545
606
|
|
|
546
607
|
useInput((input, key) => {
|
|
547
|
-
if (input === 'q' ||
|
|
608
|
+
if (input === 'q' || input === 'ㅂ') process.exit(0);
|
|
609
|
+
|
|
610
|
+
// Escape: close file viewer first, then quit
|
|
611
|
+
if (key.escape) {
|
|
612
|
+
if (selectedFile) { setSelectedFile(null); setFileLines([]); setFileScroll(0); return; }
|
|
613
|
+
process.exit(0);
|
|
614
|
+
}
|
|
615
|
+
|
|
548
616
|
if (input === '1') { setTab(0); setScrollY(0); }
|
|
549
617
|
if (input === '2') { setTab(1); setScrollY(0); }
|
|
550
618
|
if (input === '3') { setTab(2); setScrollY(0); }
|
|
551
|
-
if (input === 'd') setDark(d => !d);
|
|
619
|
+
if (input === 'd' || input === 'ㅇ') setDark(d => !d);
|
|
552
620
|
|
|
553
621
|
// r = manual refresh
|
|
554
|
-
if (input === 'r') {
|
|
622
|
+
if (input === 'r' || input === 'ㄱ') {
|
|
555
623
|
refresh();
|
|
556
624
|
setProject(null);
|
|
625
|
+
setSelectedFile(null); setFileLines([]); setFileScroll(0);
|
|
557
626
|
scanProject(cwd).then(p => { setProject(p); setTreeCursor(0); }).catch(() => {});
|
|
558
627
|
}
|
|
559
628
|
|
|
560
|
-
if (input === 'j' || key.downArrow) {
|
|
561
|
-
if (tab === 1) {
|
|
629
|
+
if (input === 'j' || input === 'ㅓ' || key.downArrow) {
|
|
630
|
+
if (tab === 1 && selectedFile) {
|
|
631
|
+
setFileScroll(s => Math.min(s + 1, Math.max(0, fileLines.length - 5)));
|
|
632
|
+
} else if (tab === 1) {
|
|
562
633
|
const flat = project?.dirTree ? flattenTree(project.dirTree, 0, treeExpanded) : [];
|
|
563
634
|
setTreeCursor(c => Math.min(c + 1, flat.length - 1));
|
|
564
635
|
} else {
|
|
565
636
|
setScrollY(s => Math.min(s + 1, 20));
|
|
566
637
|
}
|
|
567
638
|
}
|
|
568
|
-
if (input === 'k' || key.upArrow) {
|
|
569
|
-
if (tab === 1
|
|
570
|
-
|
|
639
|
+
if (input === 'k' || input === 'ㅏ' || key.upArrow) {
|
|
640
|
+
if (tab === 1 && selectedFile) {
|
|
641
|
+
setFileScroll(s => Math.max(s - 1, 0));
|
|
642
|
+
} else if (tab === 1) {
|
|
643
|
+
setTreeCursor(c => Math.max(c - 1, 0));
|
|
644
|
+
} else {
|
|
645
|
+
setScrollY(s => Math.max(s - 1, 0));
|
|
646
|
+
}
|
|
571
647
|
}
|
|
572
648
|
|
|
573
|
-
// Enter / Space — toggle expand
|
|
649
|
+
// Enter / Space — dir: toggle expand, file: open source viewer
|
|
574
650
|
if ((key.return || input === ' ') && tab === 1 && project?.dirTree) {
|
|
575
651
|
const flat = flattenTree(project.dirTree, 0, treeExpanded);
|
|
576
|
-
const
|
|
577
|
-
if (
|
|
578
|
-
|
|
652
|
+
const sel = flat[treeCursor];
|
|
653
|
+
if (!sel) return;
|
|
654
|
+
if (sel.type === 'dir') {
|
|
655
|
+
const path = sel.node.path;
|
|
579
656
|
setTreeExpanded(prev => ({ ...prev, [path]: !(prev[path] ?? false) }));
|
|
657
|
+
} else {
|
|
658
|
+
// file: toggle source viewer
|
|
659
|
+
if (selectedFile === sel.filePath) {
|
|
660
|
+
setSelectedFile(null); setFileLines([]); setFileScroll(0);
|
|
661
|
+
} else {
|
|
662
|
+
try {
|
|
663
|
+
const content = fs.readFileSync(join(cwd, sel.filePath), 'utf-8');
|
|
664
|
+
setFileLines(content.split('\n'));
|
|
665
|
+
} catch {
|
|
666
|
+
setFileLines(['(cannot read file)']);
|
|
667
|
+
}
|
|
668
|
+
setSelectedFile(sel.filePath);
|
|
669
|
+
setFileScroll(0);
|
|
670
|
+
}
|
|
580
671
|
}
|
|
581
672
|
}
|
|
582
673
|
|
|
583
|
-
// Arrow right = expand, left = collapse
|
|
674
|
+
// Arrow right = expand dir, left = collapse
|
|
584
675
|
if (key.rightArrow && tab === 1 && project?.dirTree) {
|
|
585
676
|
const flat = flattenTree(project.dirTree, 0, treeExpanded);
|
|
586
|
-
const
|
|
587
|
-
if (
|
|
677
|
+
const sel = flat[treeCursor];
|
|
678
|
+
if (sel?.type === 'dir') setTreeExpanded(prev => ({ ...prev, [sel.node.path]: true }));
|
|
588
679
|
}
|
|
589
680
|
if (key.leftArrow && tab === 1) {
|
|
681
|
+
if (selectedFile) { setSelectedFile(null); setFileLines([]); setFileScroll(0); return; }
|
|
590
682
|
if (project?.dirTree) {
|
|
591
683
|
const flat = flattenTree(project.dirTree, 0, treeExpanded);
|
|
592
|
-
const
|
|
593
|
-
if (
|
|
684
|
+
const sel = flat[treeCursor];
|
|
685
|
+
if (sel?.type === 'dir') setTreeExpanded(prev => ({ ...prev, [sel.node.path]: false }));
|
|
594
686
|
}
|
|
595
687
|
}
|
|
596
688
|
});
|
|
@@ -622,7 +714,7 @@ function App() {
|
|
|
622
714
|
{/* ── Content (with scroll offset) ── */}
|
|
623
715
|
<Box flexDirection="column" marginTop={-scrollY}>
|
|
624
716
|
{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} />}
|
|
717
|
+
{tab === 1 && <ProjectTab info={project} treeCursor={treeCursor} treeExpanded={treeExpanded} selectedFile={selectedFile} fileLines={fileLines} fileScroll={fileScroll} termWidth={termWidth} C={C} />}
|
|
626
718
|
{tab === 2 && <GitTab git={git} termWidth={termWidth} C={C} />}
|
|
627
719
|
</Box>
|
|
628
720
|
|
|
@@ -632,7 +724,7 @@ function App() {
|
|
|
632
724
|
<Text color={C.green}>● </Text>
|
|
633
725
|
<Text color={C.dimmer}>[1/2/3] tabs </Text>
|
|
634
726
|
<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
|
|
727
|
+
<Text color={tab === 1 ? C.brand : C.dimmer}>{tab === 1 ? (selectedFile ? '[esc/←] close [j/k] scroll ' : '[enter] open [→←] expand ') : ''}</Text>
|
|
636
728
|
<Text color={C.dimmer}>[r] refresh [d] theme [q] quit</Text>
|
|
637
729
|
</Box>
|
|
638
730
|
<Text color={C.dimmer}>↻ {since}</Text>
|
|
File without changes
|