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 CHANGED
@@ -1,161 +1,203 @@
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 info in a separate terminal window or tmux pane.
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
- ## Features
7
+ ## 한국어
26
8
 
27
- ### TOKENS tab
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
- ### PROJECT tab
35
- - Total file count, package count, detected endpoints
36
- - Package dependency tree (├─ └─)
37
- - Endpoint summary (GET / POST / PUT / DELETE counts)
38
- - Alerts and anomalies
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
- ### GIT tab
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
- ## Installation
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
- ### Option 1 Claude Code Plugin (recommended)
42
+ HUD는 현재 디렉토리를 기준으로 토큰, git, 프로젝트 정보를 자동으로 인식합니다.
51
43
 
52
44
  ```bash
53
- /plugin install letsgojh0810/hud-plugin
45
+ # tmux로 한 화면에서 split
46
+ cd ~/my-project
47
+ tmux split-window -h "npx claude-code-hud"
54
48
  ```
55
49
 
56
- Then use the `/hud` command inside Claude Code to get a status snapshot.
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
- ### Option 3 — npm global install
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
- ## Usage
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
- Run in a **separate terminal window** or **tmux split pane** while Claude Code is active in another pane:
72
+ **2 PROJECT 인터랙티브 파일 브라우저**
73
+ - 디렉토리 트리 (펼치기/접기)
74
+ - 파일 선택 시 소스 코드 뷰어 (split 패널)
75
+ - 패키지 의존성 트리
76
+ - API 엔드포인트 감지
78
77
 
79
- ```bash
80
- # Separate terminal
81
- npx claude-code-hud
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
- # tmux split (open right pane with HUD)
84
- tmux split-window -h "npx claude-code-hud"
87
+ **3 GIT 탭**
88
+ - 현재 브랜치, ahead/behind 카운트
89
+ - 변경 파일 목록 (MOD / ADD / DEL)
90
+ - 파일별 diff 시각화 (+/- 바)
91
+ - 최근 커밋 히스토리
85
92
 
86
- # Point to a specific project directory
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
- ## Keyboard Shortcuts
105
+ ### 요구사항
93
106
 
94
- | Key | Action |
95
- |-------|----------------------------|
96
- | `1` | Switch to TOKENS tab |
97
- | `2` | Switch to PROJECT tab |
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
- ## Requirements
114
+ ## English
107
115
 
108
- - **Node.js 18+**
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
- ## Environment Variables
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
- | Variable | Default | Description |
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
- ## How it works
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
- - **Token data**: Parses `~/.claude/projects/<hash>/sessions/*.jsonl` in real-time using chokidar file watching
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
- ## Color Theme
148
+ # Global install
149
+ npm install -g claude-code-hud
150
+ claude-hud
133
151
 
134
- Toss Blue (`#3182F6`) based palette with full dark and light mode support.
152
+ # Claude Code plugin
153
+ /plugin install letsgojh0810/hud-plugin
154
+ ```
135
155
 
136
- Dark mode uses `#0E1117` background. Light mode uses `#FFFFFF`.
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
- ## Development
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
- ```bash
144
- git clone https://github.com/letsgojh0810/hud-plugin.git
145
- cd hud-plugin
146
- npm install
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
- ## Notes for Korean users
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
- 플러그인은 Claude Code를 터미널에서 집중적으로 사용하는 개발자를 위해 만들어졌습니다.
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
- ## License
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.2.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 && child.children.length > 0) {
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 (closure)
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 && child.children.length > 0) {
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
- const langs = sortedExts.slice(0, 2).map(([l]) => l).join(' / ');
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} packages</Text>
350
- <Text color={C.dim}> │ </Text>
351
- <Text color={C.text} bold>~{totalEndpoints} endpoints</Text>
352
- <Text color={C.dim}>{langs}</Text>
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
- {/* 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>
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
- {/* 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
- })}
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 = () => setTermWidth(stdout?.columns ?? 80);
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' || key.escape) process.exit(0);
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) setTreeCursor(c => Math.max(c - 1, 0));
570
- else setScrollY(s => Math.max(s - 1, 0));
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 in tree
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 selected = flat[treeCursor];
577
- if (selected && selected.node.children.length > 0) {
578
- const path = selected.node.path;
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 selected = flat[treeCursor];
587
- if (selected) setTreeExpanded(prev => ({ ...prev, [selected.node.path]: true }));
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 selected = flat[treeCursor];
593
- if (selected) setTreeExpanded(prev => ({ ...prev, [selected.node.path]: false }));
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/→←] expand ' : ''}</Text>
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