claude-code-hud 0.3.4 → 0.3.6
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 +2 -26
- package/package.json +1 -1
- package/scripts/lib/token-reader.mjs +19 -10
- package/tui/hud.tsx +177 -30
package/README.md
CHANGED
|
@@ -8,27 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
Claude Code로 작업할 때 토큰 사용량, git 상태, 파일 구조를 IDE나 별도 탭 없이 터미널 하나에서 확인할 수 있는 HUD입니다.
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
┌──────────────────────────────────────────────────────────────────────────────┐
|
|
13
|
-
│ ◆ HUD │ ◉ TOKENS │ ○ PROJECT │ ○ GIT sonnet-4-6 · up 4m │
|
|
14
|
-
├──────────────────────────────────────────────────────────────────────────────┤
|
|
15
|
-
│ ▸ CONTEXT WINDOW │
|
|
16
|
-
│ ████████████████████░░░░░░░░░░░░░░░░░░░░░░░ 46% 92K / 200K OK │
|
|
17
|
-
│ │
|
|
18
|
-
│ ▸ USAGE WINDOW │
|
|
19
|
-
│ 5h ████████░░░░░░░░░░░░░░░░░░░░ 28.0% resets in 3h 12m │
|
|
20
|
-
│ wk ███░░░░░░░░░░░░░░░░░░░░░░░░░ 9.0% resets in 6h 48m │
|
|
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
|
-
│ ▸ OUTPUT TOKENS / HR │
|
|
29
|
-
│ total 2.1M │ avg 48.2K/hr │ peak 312K/hr │
|
|
30
|
-
└──────────────────────────────────────────────────────────────────────────────┘
|
|
31
|
-
```
|
|
11
|
+
<img src="./demo.gif" width="700" alt="demo">
|
|
32
12
|
|
|
33
13
|
### 사용법
|
|
34
14
|
|
|
@@ -137,11 +117,7 @@ Claude Code를 한 번 실행하면 `~/.claude/.credentials.json`에 credentials
|
|
|
137
117
|
|
|
138
118
|
A Terminal HUD (Heads-Up Display) for Claude Code — real-time token usage, git status, and interactive project file browser. No IDE, no extra tabs. Just a second terminal window.
|
|
139
119
|
|
|
140
|
-
|
|
141
|
-
┌──────────────────────────────────────────────────────────────────────────────┐
|
|
142
|
-
│ ◆ HUD │ ◉ TOKENS │ ○ PROJECT │ ○ GIT sonnet-4-6 · up 4m │
|
|
143
|
-
└──────────────────────────────────────────────────────────────────────────────┘
|
|
144
|
-
```
|
|
120
|
+
<img src="./demo.gif" width="700" alt="demo">
|
|
145
121
|
|
|
146
122
|
### Usage
|
|
147
123
|
|
package/package.json
CHANGED
|
@@ -34,15 +34,23 @@ function getContextWindow(model) {
|
|
|
34
34
|
return 200000;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
/**
|
|
38
|
-
function
|
|
37
|
+
/** Convert cwd to the Claude project directory name (/ replaced with -) */
|
|
38
|
+
function cwdToProjectDir(cwd) {
|
|
39
|
+
return cwd.replace(/\//g, '-');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Find the most recently modified .jsonl session file for the given cwd */
|
|
43
|
+
function findLatestSession(cwd) {
|
|
39
44
|
const projectsDir = path.join(os.homedir(), '.claude', 'projects');
|
|
40
45
|
if (!fs.existsSync(projectsDir)) return null;
|
|
41
46
|
|
|
47
|
+
const targetDir = cwd ? cwdToProjectDir(cwd) : null;
|
|
48
|
+
|
|
42
49
|
let latest = null;
|
|
43
50
|
let latestMtime = 0;
|
|
44
51
|
|
|
45
|
-
const projects = fs.readdirSync(projectsDir)
|
|
52
|
+
const projects = fs.readdirSync(projectsDir)
|
|
53
|
+
.filter(p => !targetDir || p === targetDir);
|
|
46
54
|
for (const proj of projects) {
|
|
47
55
|
const projDir = path.join(projectsDir, proj);
|
|
48
56
|
|
|
@@ -66,12 +74,13 @@ function findLatestSession() {
|
|
|
66
74
|
return latest;
|
|
67
75
|
}
|
|
68
76
|
|
|
69
|
-
/** Collect all JSONL lines
|
|
70
|
-
function readAllLines() {
|
|
77
|
+
/** Collect all JSONL lines for the given cwd (or all projects if no cwd) */
|
|
78
|
+
function readAllLines(cwd) {
|
|
71
79
|
const projectsDir = path.join(os.homedir(), '.claude', 'projects');
|
|
72
80
|
if (!fs.existsSync(projectsDir)) return [];
|
|
81
|
+
const targetDir = cwd ? cwdToProjectDir(cwd) : null;
|
|
73
82
|
const result = [];
|
|
74
|
-
for (const proj of fs.readdirSync(projectsDir)) {
|
|
83
|
+
for (const proj of fs.readdirSync(projectsDir).filter(p => !targetDir || p === targetDir)) {
|
|
75
84
|
const projDir = path.join(projectsDir, proj);
|
|
76
85
|
let files = [];
|
|
77
86
|
try { files = fs.readdirSync(projDir).filter(f => f.endsWith('.jsonl')); } catch { continue; }
|
|
@@ -94,8 +103,8 @@ function readAllLines() {
|
|
|
94
103
|
return result;
|
|
95
104
|
}
|
|
96
105
|
|
|
97
|
-
export function readTokenHistory() {
|
|
98
|
-
const allLines = readAllLines();
|
|
106
|
+
export function readTokenHistory(cwd) {
|
|
107
|
+
const allLines = readAllLines(cwd);
|
|
99
108
|
const now = Date.now();
|
|
100
109
|
const h5 = now - 5 * 60 * 60 * 1000;
|
|
101
110
|
const wk = now - 7 * 24 * 60 * 60 * 1000;
|
|
@@ -143,8 +152,8 @@ export function readTokenHistory() {
|
|
|
143
152
|
return { last5h: acc5h, lastWeek: accWk, hourlyBuckets: buckets };
|
|
144
153
|
}
|
|
145
154
|
|
|
146
|
-
export function readTokenUsage() {
|
|
147
|
-
const sessionFile = findLatestSession();
|
|
155
|
+
export function readTokenUsage(cwd) {
|
|
156
|
+
const sessionFile = findLatestSession(cwd);
|
|
148
157
|
if (!sessionFile) {
|
|
149
158
|
return empty();
|
|
150
159
|
}
|
package/tui/hud.tsx
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
7
7
|
import { render, Box, Text, useStdout, useInput } from 'ink';
|
|
8
8
|
import { fileURLToPath } from 'url';
|
|
9
|
-
import { dirname, join } from 'path';
|
|
9
|
+
import { dirname, join, basename } from 'path';
|
|
10
10
|
import fs from 'fs';
|
|
11
11
|
import os from 'os';
|
|
12
12
|
import { execSync } from 'child_process';
|
|
@@ -16,23 +16,34 @@ const { readTokenUsage, readTokenHistory } = await import(join(__dir, '../script
|
|
|
16
16
|
const { readGitInfo } = await import(join(__dir, '../scripts/lib/git-info.mjs'));
|
|
17
17
|
const { getUsage, getUsageSync } = await import(join(__dir, '../scripts/lib/usage-api.mjs'));
|
|
18
18
|
|
|
19
|
-
//
|
|
20
|
-
process.stdout.write('\x1b[
|
|
19
|
+
// Enter alternate screen buffer (like vim/htop — terminal never scrolls, header stays fixed)
|
|
20
|
+
process.stdout.write('\x1b[?1049h\x1b[2J\x1b[H');
|
|
21
|
+
process.on('exit', () => process.stdout.write('\x1b[?1049l'));
|
|
22
|
+
process.on('SIGINT', () => { process.stdout.write('\x1b[?1049l'); process.exit(0); });
|
|
23
|
+
process.on('SIGTERM', () => { process.stdout.write('\x1b[?1049l'); process.exit(0); });
|
|
21
24
|
|
|
22
25
|
const SESSION_START = Date.now();
|
|
23
26
|
|
|
24
27
|
// ── Themes ─────────────────────────────────────────────────────────────────
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
// Base is always dark. Only accent colors cycle with `d`.
|
|
29
|
+
const BASE = {
|
|
30
|
+
text: '#E6EDF3', dim: '#8B949E', dimmer: '#6E7681',
|
|
27
31
|
border: '#30363D', green: '#3FB950', yellow: '#D29922', red: '#F85149',
|
|
28
|
-
purple: '#A371F7', cyan: '#58A6FF',
|
|
29
|
-
};
|
|
30
|
-
const LIGHT = {
|
|
31
|
-
brand: '#3182F6', text: '#1F2328', dim: '#656D76', dimmer: '#8C959F',
|
|
32
|
-
border: '#D8DEE4', green: '#1A7F37', yellow: '#9A6700', red: '#CF222E',
|
|
33
|
-
purple: '#8250DF', cyan: '#0969DA',
|
|
34
32
|
};
|
|
35
33
|
|
|
34
|
+
const ACCENTS = [
|
|
35
|
+
{ brand: '#3B82F6', cyan: '#60A5FA', purple: '#A78BFA' }, // blue
|
|
36
|
+
{ brand: '#F43F5E', cyan: '#FB7185', purple: '#F9A8D4' }, // red
|
|
37
|
+
{ brand: '#F59E0B', cyan: '#FCD34D', purple: '#FDE68A' }, // amber
|
|
38
|
+
{ brand: '#10B981', cyan: '#34D399', purple: '#6EE7B7' }, // emerald
|
|
39
|
+
{ brand: '#EC4899', cyan: '#F472B6', purple: '#F9A8D4' }, // pink
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
type Theme = typeof BASE & typeof ACCENTS[number];
|
|
43
|
+
function makeTheme(accentIdx: number): Theme {
|
|
44
|
+
return { ...BASE, ...ACCENTS[accentIdx] };
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
37
48
|
const fmtNum = (n: number) =>
|
|
38
49
|
n >= 1_000_000 ? (n / 1_000_000).toFixed(1) + 'M' :
|
|
@@ -197,6 +208,65 @@ function getBranches(cwd: string): string[] {
|
|
|
197
208
|
}
|
|
198
209
|
}
|
|
199
210
|
|
|
211
|
+
// ── Timeline ────────────────────────────────────────────────────────────────
|
|
212
|
+
type TimelineEntry = {
|
|
213
|
+
time: string;
|
|
214
|
+
text: string;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
async function readSessionTimeline(cwd: string): Promise<TimelineEntry[]> {
|
|
218
|
+
const projectsDir = join(os.homedir(), '.claude', 'projects');
|
|
219
|
+
if (!fs.existsSync(projectsDir)) return [];
|
|
220
|
+
|
|
221
|
+
const targetDirName = cwd.replace(/\//g, '-');
|
|
222
|
+
|
|
223
|
+
let latestFile: string | null = null;
|
|
224
|
+
let latestMtime = 0;
|
|
225
|
+
try {
|
|
226
|
+
for (const projectHash of fs.readdirSync(projectsDir)) {
|
|
227
|
+
if (projectHash !== targetDirName) continue;
|
|
228
|
+
const sessionDir = join(projectsDir, projectHash);
|
|
229
|
+
if (!fs.statSync(sessionDir).isDirectory()) continue;
|
|
230
|
+
for (const file of fs.readdirSync(sessionDir)) {
|
|
231
|
+
if (!file.endsWith('.jsonl')) continue;
|
|
232
|
+
const filePath = join(sessionDir, file);
|
|
233
|
+
try {
|
|
234
|
+
const mtime = fs.statSync(filePath).mtimeMs;
|
|
235
|
+
if (mtime > latestMtime) { latestMtime = mtime; latestFile = filePath; }
|
|
236
|
+
} catch {}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
} catch {}
|
|
240
|
+
|
|
241
|
+
if (!latestFile) return [];
|
|
242
|
+
|
|
243
|
+
const lines = fs.readFileSync(latestFile, 'utf-8').split('\n').filter(Boolean);
|
|
244
|
+
const entries: TimelineEntry[] = [];
|
|
245
|
+
|
|
246
|
+
for (const line of lines) {
|
|
247
|
+
try {
|
|
248
|
+
const obj = JSON.parse(line);
|
|
249
|
+
if (obj.type !== 'user') continue;
|
|
250
|
+
const content = obj.message?.content;
|
|
251
|
+
const textBlock = Array.isArray(content)
|
|
252
|
+
? content.find((b: any) => b.type === 'text')
|
|
253
|
+
: null;
|
|
254
|
+
const text: string = textBlock?.text ?? (typeof content === 'string' ? content : '');
|
|
255
|
+
if (!text.trim()) continue;
|
|
256
|
+
|
|
257
|
+
const ts: string = obj.timestamp ?? '';
|
|
258
|
+
let time = '';
|
|
259
|
+
if (ts) {
|
|
260
|
+
try { time = new Date(ts).toTimeString().slice(0, 5); } catch {}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
entries.push({ time, text: text.replace(/\n/g, ' ').slice(0, 80) });
|
|
264
|
+
} catch {}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return entries.slice(-30);
|
|
268
|
+
}
|
|
269
|
+
|
|
200
270
|
// ── UI Components ──────────────────────────────────────────────────────────
|
|
201
271
|
function Bar({ ratio, width, color, C }: { ratio: number; width: number; color: string; C: typeof DARK }) {
|
|
202
272
|
const filled = Math.max(0, Math.min(width, Math.round(ratio * width)));
|
|
@@ -218,7 +288,7 @@ function Section({ title, children, C, accent }: { title: string; children: Reac
|
|
|
218
288
|
}
|
|
219
289
|
|
|
220
290
|
// ── Tab 1: TOKENS ──────────────────────────────────────────────────────────
|
|
221
|
-
function TokensTab({ usage, history, rateLimits, termWidth, C }: any) {
|
|
291
|
+
function TokensTab({ usage, history, rateLimits, termWidth, currentActivity, C }: any) {
|
|
222
292
|
const ctxPct = usage.contextWindow > 0 ? usage.totalTokens / usage.contextWindow : 0;
|
|
223
293
|
const ctxColor = ctxPct > 0.85 ? C.red : ctxPct > 0.65 ? C.yellow : C.brand;
|
|
224
294
|
const ctxLabel = ctxPct > 0.85 ? 'WARN' : ctxPct > 0.65 ? 'MID' : 'OK';
|
|
@@ -330,6 +400,14 @@ function TokensTab({ usage, history, rateLimits, termWidth, C }: any) {
|
|
|
330
400
|
</Section>
|
|
331
401
|
);
|
|
332
402
|
})()}
|
|
403
|
+
|
|
404
|
+
{/* Current activity */}
|
|
405
|
+
{currentActivity && (
|
|
406
|
+
<Box borderStyle="single" borderColor={C.border} paddingX={1}>
|
|
407
|
+
<Text color={C.dimmer}>now </Text>
|
|
408
|
+
<Text color={C.brand}>{currentActivity.slice(0, termWidth - 12)}</Text>
|
|
409
|
+
</Box>
|
|
410
|
+
)}
|
|
333
411
|
</Box>
|
|
334
412
|
);
|
|
335
413
|
}
|
|
@@ -599,21 +677,49 @@ function GitTab({ git, C, termWidth, branchMode, branchList, branchCursor }: any
|
|
|
599
677
|
);
|
|
600
678
|
}
|
|
601
679
|
|
|
680
|
+
// ── Tab 4: TIMELINE ────────────────────────────────────────────────────────
|
|
681
|
+
const TIMELINE_VISIBLE = 10;
|
|
682
|
+
|
|
683
|
+
function TimelineTab({ timeline, timelineScroll, C }: any) {
|
|
684
|
+
const entries = timeline as TimelineEntry[];
|
|
685
|
+
const visible = entries.slice(timelineScroll, timelineScroll + TIMELINE_VISIBLE);
|
|
686
|
+
return (
|
|
687
|
+
<Box flexDirection="column" borderStyle="single" borderColor={C.border} paddingX={1}>
|
|
688
|
+
<Text color={C.dimmer} bold>▸ <Text color={C.text}>SESSION HISTORY</Text>
|
|
689
|
+
{entries.length > 0 && <Text color={C.dimmer}> {timelineScroll + 1}–{Math.min(timelineScroll + TIMELINE_VISIBLE, entries.length)} / {entries.length}</Text>}
|
|
690
|
+
</Text>
|
|
691
|
+
<Box flexDirection="column" marginTop={1}>
|
|
692
|
+
{entries.length === 0 && <Text color={C.dimmer}> no messages yet</Text>}
|
|
693
|
+
{visible.map((entry, i) => (
|
|
694
|
+
<Box key={i} marginBottom={1}>
|
|
695
|
+
<Box width={6}><Text color={C.dimmer}>{entry.time}</Text></Box>
|
|
696
|
+
<Text color={C.text}>{entry.text}</Text>
|
|
697
|
+
</Box>
|
|
698
|
+
))}
|
|
699
|
+
{entries.length > TIMELINE_VISIBLE && (
|
|
700
|
+
<Text color={C.dimmer}> [j/k] scroll</Text>
|
|
701
|
+
)}
|
|
702
|
+
</Box>
|
|
703
|
+
</Box>
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
602
707
|
// ── Main App ───────────────────────────────────────────────────────────────
|
|
603
708
|
function App() {
|
|
604
709
|
const { stdout } = useStdout();
|
|
605
|
-
const [termWidth,
|
|
710
|
+
const [termWidth, setTermWidth] = useState(stdout?.columns ?? 80);
|
|
711
|
+
const [termHeight, setTermHeight] = useState(stdout?.rows ?? 24);
|
|
606
712
|
const [tab, setTab] = useState(0); // 0=TOKENS 1=PROJECT 2=GIT
|
|
607
|
-
const [
|
|
713
|
+
const [accent, setAccent] = useState(3); // 0=blue 1=red 2=amber 3=emerald 4=pink
|
|
608
714
|
const [scrollY, setScrollY] = useState(0);
|
|
609
715
|
const [tick, setTick] = useState(0);
|
|
610
716
|
const [updatedAt, setUpdatedAt] = useState(Date.now());
|
|
611
717
|
|
|
612
718
|
const cwd = process.env.CLAUDE_PROJECT_ROOT || process.cwd();
|
|
613
|
-
const C =
|
|
719
|
+
const C = makeTheme(accent);
|
|
614
720
|
|
|
615
|
-
const [usage, setUsage] = useState<any>(readTokenUsage());
|
|
616
|
-
const [history, setHistory] = useState<any>(readTokenHistory());
|
|
721
|
+
const [usage, setUsage] = useState<any>(readTokenUsage(cwd));
|
|
722
|
+
const [history, setHistory] = useState<any>(readTokenHistory(cwd));
|
|
617
723
|
const [git, setGit] = useState<any>(readGitInfo(cwd));
|
|
618
724
|
const [project, setProject] = useState<ProjectInfo | null>(null);
|
|
619
725
|
const [rateLimits, setRateLimits] = useState<any>(getUsageSync());
|
|
@@ -632,12 +738,24 @@ function App() {
|
|
|
632
738
|
const [branchList, setBranchList] = useState<string[]>([]);
|
|
633
739
|
const [branchCursor, setBranchCursor] = useState(0);
|
|
634
740
|
|
|
741
|
+
// Timeline state
|
|
742
|
+
const [timeline, setTimeline] = useState<TimelineEntry[]>([]);
|
|
743
|
+
const [timelineScroll, setTimelineScroll] = useState(0);
|
|
744
|
+
const [currentActivity, setCurrentActivity] = useState<string>('');
|
|
745
|
+
|
|
635
746
|
const refresh = useCallback(() => {
|
|
636
|
-
setUsage(readTokenUsage());
|
|
637
|
-
setHistory(readTokenHistory());
|
|
747
|
+
setUsage(readTokenUsage(cwd));
|
|
748
|
+
setHistory(readTokenHistory(cwd));
|
|
638
749
|
setGit(readGitInfo(cwd));
|
|
639
750
|
setUpdatedAt(Date.now());
|
|
640
751
|
getUsage().then(setRateLimits).catch(() => {});
|
|
752
|
+
readSessionTimeline(cwd).then(entries => {
|
|
753
|
+
setTimeline(entries);
|
|
754
|
+
if (entries.length > 0) {
|
|
755
|
+
const last = entries[entries.length - 1];
|
|
756
|
+
setCurrentActivity(last.text);
|
|
757
|
+
}
|
|
758
|
+
}).catch(() => {});
|
|
641
759
|
}, [cwd]);
|
|
642
760
|
|
|
643
761
|
useEffect(() => {
|
|
@@ -645,10 +763,19 @@ function App() {
|
|
|
645
763
|
scanProject(cwd).then(setProject).catch(() => {});
|
|
646
764
|
// Initial API usage fetch
|
|
647
765
|
getUsage().then(setRateLimits).catch(() => {});
|
|
766
|
+
// Initial timeline load
|
|
767
|
+
readSessionTimeline(cwd).then(entries => {
|
|
768
|
+
setTimeline(entries);
|
|
769
|
+
if (entries.length > 0) {
|
|
770
|
+
const last = entries[entries.length - 1];
|
|
771
|
+
setCurrentActivity(last.text);
|
|
772
|
+
}
|
|
773
|
+
}).catch(() => {});
|
|
648
774
|
|
|
649
775
|
const onResize = () => {
|
|
650
776
|
process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
|
|
651
777
|
setTermWidth(stdout?.columns ?? 80);
|
|
778
|
+
setTermHeight(stdout?.rows ?? 24);
|
|
652
779
|
};
|
|
653
780
|
stdout?.on('resize', onResize);
|
|
654
781
|
|
|
@@ -727,7 +854,8 @@ function App() {
|
|
|
727
854
|
if (input === '1') { setTab(0); setScrollY(0); }
|
|
728
855
|
if (input === '2') { setTab(1); setScrollY(0); }
|
|
729
856
|
if (input === '3') { setTab(2); setScrollY(0); }
|
|
730
|
-
if (input === '
|
|
857
|
+
if (input === '4') { setTab(3); setScrollY(0); }
|
|
858
|
+
if (input === 'd' || input === 'ㅇ') setAccent(a => (a + 1) % ACCENTS.length);
|
|
731
859
|
|
|
732
860
|
// r = manual refresh
|
|
733
861
|
if (input === 'r' || input === 'ㄱ') {
|
|
@@ -743,6 +871,8 @@ function App() {
|
|
|
743
871
|
} else if (tab === 1) {
|
|
744
872
|
const flat = project?.dirTree ? flattenTree(project.dirTree, 0, treeExpanded) : [];
|
|
745
873
|
setTreeCursor(c => Math.min(c + 1, flat.length - 1));
|
|
874
|
+
} else if (tab === 3) {
|
|
875
|
+
setTimelineScroll(s => Math.min(s + 1, Math.max(0, timeline.length - 10)));
|
|
746
876
|
} else {
|
|
747
877
|
setScrollY(s => Math.min(s + 1, 20));
|
|
748
878
|
}
|
|
@@ -752,6 +882,8 @@ function App() {
|
|
|
752
882
|
setFileScroll(s => Math.max(s - 1, 0));
|
|
753
883
|
} else if (tab === 1) {
|
|
754
884
|
setTreeCursor(c => Math.max(c - 1, 0));
|
|
885
|
+
} else if (tab === 3) {
|
|
886
|
+
setTimelineScroll(s => Math.max(s - 1, 0));
|
|
755
887
|
} else {
|
|
756
888
|
setScrollY(s => Math.max(s - 1, 0));
|
|
757
889
|
}
|
|
@@ -798,7 +930,7 @@ function App() {
|
|
|
798
930
|
}
|
|
799
931
|
});
|
|
800
932
|
|
|
801
|
-
const TAB_NAMES = ['TOKENS', 'PROJECT', 'GIT'];
|
|
933
|
+
const TAB_NAMES = ['TOKENS', 'PROJECT', 'GIT', 'TIMELINE'];
|
|
802
934
|
const since = fmtSince(Date.now() - updatedAt);
|
|
803
935
|
const uptime = fmtSince(SESSION_START - Date.now() + (Date.now() - SESSION_START)); // forces tick dep
|
|
804
936
|
void tick;
|
|
@@ -826,26 +958,41 @@ function App() {
|
|
|
826
958
|
</Box>
|
|
827
959
|
</Box>
|
|
828
960
|
|
|
829
|
-
{/* ── Content
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
961
|
+
{/* ── Content: fixed height so header/footer never get pushed off screen ── */}
|
|
962
|
+
{(() => {
|
|
963
|
+
// header ~3 rows, footer key row ~1, footer dir row ~3 = 7 total chrome
|
|
964
|
+
const contentH = Math.max(4, termHeight - 7);
|
|
965
|
+
return (
|
|
966
|
+
<Box flexDirection="column" height={contentH} overflow="hidden">
|
|
967
|
+
<Box flexDirection="column" marginTop={-scrollY}>
|
|
968
|
+
{tab === 0 && <TokensTab usage={usage} history={history} rateLimits={rateLimits} termWidth={termWidth} currentActivity={currentActivity} C={C} />}
|
|
969
|
+
{tab === 1 && <ProjectTab info={project} treeCursor={treeCursor} treeExpanded={treeExpanded} selectedFile={selectedFile} fileLines={fileLines} fileScroll={fileScroll} termWidth={termWidth} git={git} C={C} />}
|
|
970
|
+
{tab === 2 && <GitTab git={git} termWidth={termWidth} branchMode={branchMode} branchList={branchList} branchCursor={branchCursor} C={C} />}
|
|
971
|
+
{tab === 3 && <TimelineTab timeline={timeline} timelineScroll={timelineScroll} C={C} />}
|
|
972
|
+
</Box>
|
|
973
|
+
</Box>
|
|
974
|
+
);
|
|
975
|
+
})()}
|
|
835
976
|
|
|
836
|
-
{/* ── Footer ── */}
|
|
977
|
+
{/* ── Footer row 1: keys ── */}
|
|
837
978
|
<Box justifyContent="space-between" paddingX={1}>
|
|
838
979
|
<Box>
|
|
839
980
|
<Text color={C.green}>● </Text>
|
|
840
|
-
<Text color={C.dimmer}>[1/2/3] tabs </Text>
|
|
981
|
+
<Text color={C.dimmer}>[1/2/3/4] tabs </Text>
|
|
841
982
|
<Text color={tab === 1 ? C.brand : C.dimmer}>[j/k] {tab === 1 ? 'tree' : 'scroll'} </Text>
|
|
842
983
|
<Text color={tab === 1 ? C.brand : C.dimmer}>{tab === 1 ? (selectedFile ? '[esc/←] close [j/k] scroll ' : '[enter] open [→←] expand ') : ''}</Text>
|
|
843
984
|
{tab === 2 && !branchMode && <Text color={C.brand}>[b] branch </Text>}
|
|
844
|
-
<Text color={C.dimmer}>[r] refresh [d]
|
|
985
|
+
<Text color={C.dimmer}>[r] refresh [d] color [q] quit</Text>
|
|
845
986
|
</Box>
|
|
846
987
|
<Text color={C.dimmer}>↻ {since}</Text>
|
|
847
988
|
</Box>
|
|
848
989
|
|
|
990
|
+
{/* ── Footer row 2: current dir ── */}
|
|
991
|
+
<Box paddingX={1} borderStyle="single" borderColor={C.brand}>
|
|
992
|
+
<Text color={C.brand} bold>◆ </Text>
|
|
993
|
+
<Text color={C.text} bold>~/{basename(cwd)}</Text>
|
|
994
|
+
</Box>
|
|
995
|
+
|
|
849
996
|
</Box>
|
|
850
997
|
);
|
|
851
998
|
}
|