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 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 info in a separate terminal window or tmux pane.
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 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
- └────────────────────────────────────────────────────────┘
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 (█░ 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
33
-
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
39
-
40
- ### GIT tab
41
- - Current branch, ahead/behind counts
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 (+/- bars)
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 — Claude Code Plugin (recommended)
61
+ ### Option 1 — npx (no install required)
51
62
 
52
63
  ```bash
53
- /plugin install letsgojh0810/hud-plugin
64
+ npx claude-code-hud
54
65
  ```
55
66
 
56
- Then use the `/hud` command inside Claude Code to get a status snapshot.
57
-
58
- ### Option 2 — npx (no install required)
67
+ ### Option 2 npm global install
59
68
 
60
69
  ```bash
61
- npx claude-code-hud
70
+ npm install -g claude-code-hud
71
+ claude-hud
62
72
  ```
63
73
 
64
- Runs the full interactive TUI in your current terminal. Open a separate terminal window or tmux pane first.
65
-
66
- ### Option 3 — npm global install
74
+ ### Option 3 Claude Code Plugin
67
75
 
68
76
  ```bash
69
- npm install -g claude-code-hud
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 in another pane:
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 (open right pane with HUD)
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
- # Point to a specific project directory
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
- | 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 |
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 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)
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 | Description |
118
- |-----------------------|-------------|-----------------------------------------------------|
119
- | `CLAUDE_PROJECT_ROOT` | `process.cwd()` | Root directory of the project to monitor |
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**: 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
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 with full dark and light mode support.
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 # launches TUI in dev mode
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.2.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 && 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());
@@ -544,7 +602,14 @@ function App() {
544
602
  }, []);
545
603
 
546
604
  useInput((input, key) => {
547
- if (input === 'q' || key.escape) process.exit(0);
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) setTreeCursor(c => Math.max(c - 1, 0));
570
- else setScrollY(s => Math.max(s - 1, 0));
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 in tree
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 selected = flat[treeCursor];
577
- if (selected && selected.node.children.length > 0) {
578
- const path = selected.node.path;
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 selected = flat[treeCursor];
587
- if (selected) setTreeExpanded(prev => ({ ...prev, [selected.node.path]: true }));
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 selected = flat[treeCursor];
593
- if (selected) setTreeExpanded(prev => ({ ...prev, [selected.node.path]: false }));
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/→←] expand ' : ''}</Text>
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>