claude-code-hud 0.3.4 → 0.3.5

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.
Files changed (3) hide show
  1. package/README.md +2 -26
  2. package/package.json +1 -1
  3. package/tui/hud.tsx +170 -26
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hud",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "Terminal HUD for Claude Code — real-time token usage, git status, project monitor",
5
5
  "type": "module",
6
6
  "bin": {
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
- // Clear terminal before starting
20
- process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
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
- const DARK = {
26
- brand: '#3182F6', text: '#E6EDF3', dim: '#8B949E', dimmer: '#6E7681',
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,62 @@ 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
+ let latestFile: string | null = null;
222
+ let latestMtime = 0;
223
+ try {
224
+ for (const projectHash of fs.readdirSync(projectsDir)) {
225
+ const sessionDir = join(projectsDir, projectHash);
226
+ if (!fs.statSync(sessionDir).isDirectory()) continue;
227
+ for (const file of fs.readdirSync(sessionDir)) {
228
+ if (!file.endsWith('.jsonl')) continue;
229
+ const filePath = join(sessionDir, file);
230
+ try {
231
+ const mtime = fs.statSync(filePath).mtimeMs;
232
+ if (mtime > latestMtime) { latestMtime = mtime; latestFile = filePath; }
233
+ } catch {}
234
+ }
235
+ }
236
+ } catch {}
237
+
238
+ if (!latestFile) return [];
239
+
240
+ const lines = fs.readFileSync(latestFile, 'utf-8').split('\n').filter(Boolean);
241
+ const entries: TimelineEntry[] = [];
242
+
243
+ for (const line of lines) {
244
+ try {
245
+ const obj = JSON.parse(line);
246
+ if (obj.type !== 'user') continue;
247
+ const content = obj.message?.content;
248
+ const textBlock = Array.isArray(content)
249
+ ? content.find((b: any) => b.type === 'text')
250
+ : null;
251
+ const text: string = textBlock?.text ?? (typeof content === 'string' ? content : '');
252
+ if (!text.trim()) continue;
253
+
254
+ const ts: string = obj.timestamp ?? '';
255
+ let time = '';
256
+ if (ts) {
257
+ try { time = new Date(ts).toTimeString().slice(0, 5); } catch {}
258
+ }
259
+
260
+ entries.push({ time, text: text.replace(/\n/g, ' ').slice(0, 80) });
261
+ } catch {}
262
+ }
263
+
264
+ return entries.slice(-30);
265
+ }
266
+
200
267
  // ── UI Components ──────────────────────────────────────────────────────────
201
268
  function Bar({ ratio, width, color, C }: { ratio: number; width: number; color: string; C: typeof DARK }) {
202
269
  const filled = Math.max(0, Math.min(width, Math.round(ratio * width)));
@@ -218,7 +285,7 @@ function Section({ title, children, C, accent }: { title: string; children: Reac
218
285
  }
219
286
 
220
287
  // ── Tab 1: TOKENS ──────────────────────────────────────────────────────────
221
- function TokensTab({ usage, history, rateLimits, termWidth, C }: any) {
288
+ function TokensTab({ usage, history, rateLimits, termWidth, currentActivity, C }: any) {
222
289
  const ctxPct = usage.contextWindow > 0 ? usage.totalTokens / usage.contextWindow : 0;
223
290
  const ctxColor = ctxPct > 0.85 ? C.red : ctxPct > 0.65 ? C.yellow : C.brand;
224
291
  const ctxLabel = ctxPct > 0.85 ? 'WARN' : ctxPct > 0.65 ? 'MID' : 'OK';
@@ -330,6 +397,14 @@ function TokensTab({ usage, history, rateLimits, termWidth, C }: any) {
330
397
  </Section>
331
398
  );
332
399
  })()}
400
+
401
+ {/* Current activity */}
402
+ {currentActivity && (
403
+ <Box borderStyle="single" borderColor={C.border} paddingX={1}>
404
+ <Text color={C.dimmer}>now </Text>
405
+ <Text color={C.brand}>{currentActivity.slice(0, termWidth - 12)}</Text>
406
+ </Box>
407
+ )}
333
408
  </Box>
334
409
  );
335
410
  }
@@ -599,18 +674,46 @@ function GitTab({ git, C, termWidth, branchMode, branchList, branchCursor }: any
599
674
  );
600
675
  }
601
676
 
677
+ // ── Tab 4: TIMELINE ────────────────────────────────────────────────────────
678
+ const TIMELINE_VISIBLE = 10;
679
+
680
+ function TimelineTab({ timeline, timelineScroll, C }: any) {
681
+ const entries = timeline as TimelineEntry[];
682
+ const visible = entries.slice(timelineScroll, timelineScroll + TIMELINE_VISIBLE);
683
+ return (
684
+ <Box flexDirection="column" borderStyle="single" borderColor={C.border} paddingX={1}>
685
+ <Text color={C.dimmer} bold>▸ <Text color={C.text}>SESSION HISTORY</Text>
686
+ {entries.length > 0 && <Text color={C.dimmer}> {timelineScroll + 1}–{Math.min(timelineScroll + TIMELINE_VISIBLE, entries.length)} / {entries.length}</Text>}
687
+ </Text>
688
+ <Box flexDirection="column" marginTop={1}>
689
+ {entries.length === 0 && <Text color={C.dimmer}> no messages yet</Text>}
690
+ {visible.map((entry, i) => (
691
+ <Box key={i} marginBottom={1}>
692
+ <Box width={6}><Text color={C.dimmer}>{entry.time}</Text></Box>
693
+ <Text color={C.text}>{entry.text}</Text>
694
+ </Box>
695
+ ))}
696
+ {entries.length > TIMELINE_VISIBLE && (
697
+ <Text color={C.dimmer}> [j/k] scroll</Text>
698
+ )}
699
+ </Box>
700
+ </Box>
701
+ );
702
+ }
703
+
602
704
  // ── Main App ───────────────────────────────────────────────────────────────
603
705
  function App() {
604
706
  const { stdout } = useStdout();
605
- const [termWidth, setTermWidth] = useState(stdout?.columns ?? 80);
707
+ const [termWidth, setTermWidth] = useState(stdout?.columns ?? 80);
708
+ const [termHeight, setTermHeight] = useState(stdout?.rows ?? 24);
606
709
  const [tab, setTab] = useState(0); // 0=TOKENS 1=PROJECT 2=GIT
607
- const [dark, setDark] = useState(true);
710
+ const [accent, setAccent] = useState(3); // 0=blue 1=red 2=amber 3=emerald 4=pink
608
711
  const [scrollY, setScrollY] = useState(0);
609
712
  const [tick, setTick] = useState(0);
610
713
  const [updatedAt, setUpdatedAt] = useState(Date.now());
611
714
 
612
715
  const cwd = process.env.CLAUDE_PROJECT_ROOT || process.cwd();
613
- const C = dark ? DARK : LIGHT;
716
+ const C = makeTheme(accent);
614
717
 
615
718
  const [usage, setUsage] = useState<any>(readTokenUsage());
616
719
  const [history, setHistory] = useState<any>(readTokenHistory());
@@ -632,12 +735,24 @@ function App() {
632
735
  const [branchList, setBranchList] = useState<string[]>([]);
633
736
  const [branchCursor, setBranchCursor] = useState(0);
634
737
 
738
+ // Timeline state
739
+ const [timeline, setTimeline] = useState<TimelineEntry[]>([]);
740
+ const [timelineScroll, setTimelineScroll] = useState(0);
741
+ const [currentActivity, setCurrentActivity] = useState<string>('');
742
+
635
743
  const refresh = useCallback(() => {
636
744
  setUsage(readTokenUsage());
637
745
  setHistory(readTokenHistory());
638
746
  setGit(readGitInfo(cwd));
639
747
  setUpdatedAt(Date.now());
640
748
  getUsage().then(setRateLimits).catch(() => {});
749
+ readSessionTimeline(cwd).then(entries => {
750
+ setTimeline(entries);
751
+ if (entries.length > 0) {
752
+ const last = entries[entries.length - 1];
753
+ setCurrentActivity(last.text);
754
+ }
755
+ }).catch(() => {});
641
756
  }, [cwd]);
642
757
 
643
758
  useEffect(() => {
@@ -645,10 +760,19 @@ function App() {
645
760
  scanProject(cwd).then(setProject).catch(() => {});
646
761
  // Initial API usage fetch
647
762
  getUsage().then(setRateLimits).catch(() => {});
763
+ // Initial timeline load
764
+ readSessionTimeline(cwd).then(entries => {
765
+ setTimeline(entries);
766
+ if (entries.length > 0) {
767
+ const last = entries[entries.length - 1];
768
+ setCurrentActivity(last.text);
769
+ }
770
+ }).catch(() => {});
648
771
 
649
772
  const onResize = () => {
650
773
  process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
651
774
  setTermWidth(stdout?.columns ?? 80);
775
+ setTermHeight(stdout?.rows ?? 24);
652
776
  };
653
777
  stdout?.on('resize', onResize);
654
778
 
@@ -727,7 +851,8 @@ function App() {
727
851
  if (input === '1') { setTab(0); setScrollY(0); }
728
852
  if (input === '2') { setTab(1); setScrollY(0); }
729
853
  if (input === '3') { setTab(2); setScrollY(0); }
730
- if (input === 'd' || input === 'ㅇ') setDark(d => !d);
854
+ if (input === '4') { setTab(3); setScrollY(0); }
855
+ if (input === 'd' || input === 'ㅇ') setAccent(a => (a + 1) % ACCENTS.length);
731
856
 
732
857
  // r = manual refresh
733
858
  if (input === 'r' || input === 'ㄱ') {
@@ -743,6 +868,8 @@ function App() {
743
868
  } else if (tab === 1) {
744
869
  const flat = project?.dirTree ? flattenTree(project.dirTree, 0, treeExpanded) : [];
745
870
  setTreeCursor(c => Math.min(c + 1, flat.length - 1));
871
+ } else if (tab === 3) {
872
+ setTimelineScroll(s => Math.min(s + 1, Math.max(0, timeline.length - 10)));
746
873
  } else {
747
874
  setScrollY(s => Math.min(s + 1, 20));
748
875
  }
@@ -752,6 +879,8 @@ function App() {
752
879
  setFileScroll(s => Math.max(s - 1, 0));
753
880
  } else if (tab === 1) {
754
881
  setTreeCursor(c => Math.max(c - 1, 0));
882
+ } else if (tab === 3) {
883
+ setTimelineScroll(s => Math.max(s - 1, 0));
755
884
  } else {
756
885
  setScrollY(s => Math.max(s - 1, 0));
757
886
  }
@@ -798,7 +927,7 @@ function App() {
798
927
  }
799
928
  });
800
929
 
801
- const TAB_NAMES = ['TOKENS', 'PROJECT', 'GIT'];
930
+ const TAB_NAMES = ['TOKENS', 'PROJECT', 'GIT', 'TIMELINE'];
802
931
  const since = fmtSince(Date.now() - updatedAt);
803
932
  const uptime = fmtSince(SESSION_START - Date.now() + (Date.now() - SESSION_START)); // forces tick dep
804
933
  void tick;
@@ -826,26 +955,41 @@ function App() {
826
955
  </Box>
827
956
  </Box>
828
957
 
829
- {/* ── Content (with scroll offset) ── */}
830
- <Box flexDirection="column" marginTop={-scrollY}>
831
- {tab === 0 && <TokensTab usage={usage} history={history} rateLimits={rateLimits} termWidth={termWidth} C={C} />}
832
- {tab === 1 && <ProjectTab info={project} treeCursor={treeCursor} treeExpanded={treeExpanded} selectedFile={selectedFile} fileLines={fileLines} fileScroll={fileScroll} termWidth={termWidth} git={git} C={C} />}
833
- {tab === 2 && <GitTab git={git} termWidth={termWidth} branchMode={branchMode} branchList={branchList} branchCursor={branchCursor} C={C} />}
834
- </Box>
958
+ {/* ── Content: fixed height so header/footer never get pushed off screen ── */}
959
+ {(() => {
960
+ // header ~3 rows, footer key row ~1, footer dir row ~3 = 7 total chrome
961
+ const contentH = Math.max(4, termHeight - 7);
962
+ return (
963
+ <Box flexDirection="column" height={contentH} overflow="hidden">
964
+ <Box flexDirection="column" marginTop={-scrollY}>
965
+ {tab === 0 && <TokensTab usage={usage} history={history} rateLimits={rateLimits} termWidth={termWidth} currentActivity={currentActivity} C={C} />}
966
+ {tab === 1 && <ProjectTab info={project} treeCursor={treeCursor} treeExpanded={treeExpanded} selectedFile={selectedFile} fileLines={fileLines} fileScroll={fileScroll} termWidth={termWidth} git={git} C={C} />}
967
+ {tab === 2 && <GitTab git={git} termWidth={termWidth} branchMode={branchMode} branchList={branchList} branchCursor={branchCursor} C={C} />}
968
+ {tab === 3 && <TimelineTab timeline={timeline} timelineScroll={timelineScroll} C={C} />}
969
+ </Box>
970
+ </Box>
971
+ );
972
+ })()}
835
973
 
836
- {/* ── Footer ── */}
974
+ {/* ── Footer row 1: keys ── */}
837
975
  <Box justifyContent="space-between" paddingX={1}>
838
976
  <Box>
839
977
  <Text color={C.green}>● </Text>
840
- <Text color={C.dimmer}>[1/2/3] tabs </Text>
978
+ <Text color={C.dimmer}>[1/2/3/4] tabs </Text>
841
979
  <Text color={tab === 1 ? C.brand : C.dimmer}>[j/k] {tab === 1 ? 'tree' : 'scroll'} </Text>
842
980
  <Text color={tab === 1 ? C.brand : C.dimmer}>{tab === 1 ? (selectedFile ? '[esc/←] close [j/k] scroll ' : '[enter] open [→←] expand ') : ''}</Text>
843
981
  {tab === 2 && !branchMode && <Text color={C.brand}>[b] branch </Text>}
844
- <Text color={C.dimmer}>[r] refresh [d] theme [q] quit</Text>
982
+ <Text color={C.dimmer}>[r] refresh [d] color [q] quit</Text>
845
983
  </Box>
846
984
  <Text color={C.dimmer}>↻ {since}</Text>
847
985
  </Box>
848
986
 
987
+ {/* ── Footer row 2: current dir ── */}
988
+ <Box paddingX={1} borderStyle="single" borderColor={C.brand}>
989
+ <Text color={C.brand} bold>◆ </Text>
990
+ <Text color={C.text} bold>~/{basename(cwd)}</Text>
991
+ </Box>
992
+
849
993
  </Box>
850
994
  );
851
995
  }