claude-rpc 0.20.2 → 0.20.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.20.2",
3
+ "version": "0.20.4",
4
4
  "description": "Discord Rich Presence for Claude Code — live model, project, tokens, and lifetime stats driven by Claude Code's hook system.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.js CHANGED
@@ -10,12 +10,12 @@ import process from 'node:process';
10
10
  if (process.platform === 'win32' && process.stdout.isTTY) {
11
11
  try { spawnSync('chcp.com', ['65001'], { stdio: 'ignore', windowsHide: true }); } catch { /* chcp absent (Wine, custom shell) — accept whatever code page is set */ }
12
12
  }
13
- import { DAEMON_SCRIPT, PID_PATH, STATE_PATH, LOG_PATH, AGGREGATE_PATH, CONFIG_PATH, IS_PACKAGED, IS_NPX, EXE_PATH } from './paths.js';
13
+ import { DAEMON_SCRIPT, STATE_PATH, LOG_PATH, AGGREGATE_PATH, CONFIG_PATH, IS_PACKAGED, IS_NPX, EXE_PATH } from './paths.js';
14
14
  import { readActiveState } from './state.js';
15
15
  import { runHookCli } from './hook.js';
16
16
  import { parseDuration, setPause, clearPause, pauseUntil } from './pause.js';
17
17
  import { loadConfig, hasUserConfig } from './config.js';
18
- import { spawnDaemonDetached } from './ensure-daemon.js';
18
+ import { spawnDaemonDetached, daemonAlive } from './ensure-daemon.js';
19
19
 
20
20
  // ── Lazy-loaded heavy module graph ───────────────────────────────────────────
21
21
  // format→scanner→pricing→languages→git→usage (plus install/tui/insights/nudge/
@@ -116,18 +116,8 @@ function takeValue(v, flag) {
116
116
  return v;
117
117
  }
118
118
 
119
- function isAlive(pid) {
120
- try { process.kill(pid, 0); return true; } catch { return false; }
121
- }
122
-
123
- function daemonPid() {
124
- if (!existsSync(PID_PATH)) return null;
125
- const pid = Number(readFileSync(PID_PATH, 'utf8'));
126
- return pid && isAlive(pid) ? pid : null;
127
- }
128
-
129
119
  function startDaemon({ quiet = false } = {}) {
130
- const pid = daemonPid();
120
+ const pid = daemonAlive();
131
121
  if (pid) {
132
122
  if (!quiet) console.log(` ${c.yellow}!${c.reset} ${'daemon running'.padEnd(16)}${c.dim}already up (pid ${pid}) · bounce it with ${c.reset}${c.cyan}claude-rpc restart${c.reset}`);
133
123
  return false;
@@ -154,7 +144,7 @@ async function confirmDaemonUp({ timeoutMs = 3000, intervalMs = 100 } = {}) {
154
144
  const deadline = Date.now() + timeoutMs;
155
145
  for (;;) {
156
146
  await new Promise((r) => setTimeout(r, intervalMs));
157
- if (daemonPid()) {
147
+ if (daemonAlive()) {
158
148
  console.log(` ${c.green}✓${c.reset} ${'daemon confirmed'.padEnd(16)}${c.dim}up — connecting to Discord${c.reset}`);
159
149
  return true;
160
150
  }
@@ -166,7 +156,7 @@ async function confirmDaemonUp({ timeoutMs = 3000, intervalMs = 100 } = {}) {
166
156
  }
167
157
 
168
158
  function stopDaemon({ quiet = false } = {}) {
169
- const pid = daemonPid();
159
+ const pid = daemonAlive();
170
160
  if (!pid) { if (!quiet) console.log(` ${c.cyan}·${c.reset} daemon not running`); return false; }
171
161
  try {
172
162
  process.kill(pid, 'SIGTERM');
@@ -186,9 +176,9 @@ function restartDaemon() {
186
176
  // one exited. Give up after ~3s, force-kill the wedged pid, and start anyway.
187
177
  const deadline = Date.now() + 3000;
188
178
  const tick = () => {
189
- if (!daemonPid()) { startDaemon(); return; }
179
+ if (!daemonAlive()) { startDaemon(); return; }
190
180
  if (Date.now() >= deadline) {
191
- const pid = daemonPid();
181
+ const pid = daemonAlive();
192
182
  if (pid) { try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ } }
193
183
  startDaemon();
194
184
  return;
@@ -411,7 +401,7 @@ function showStatus() {
411
401
  state.liveSessions = live;
412
402
  state.usage = readUsageCache();
413
403
  const vars = buildVars(state, config, aggregate);
414
- const pid = daemonPid();
404
+ const pid = daemonAlive();
415
405
 
416
406
  console.log('');
417
407
  console.log(` ${c.bold}${c.magenta}◆ Claude RPC${c.reset} ${c.dim}— Discord Rich Presence for Claude Code${c.reset}`);
@@ -1863,7 +1853,7 @@ function tailLog() {
1863
1853
  function overview() {
1864
1854
  const setUp = hasUserConfig();
1865
1855
  const cfg = loadConfig();
1866
- const pid = daemonPid();
1856
+ const pid = daemonAlive();
1867
1857
 
1868
1858
  console.log('');
1869
1859
  console.log(` ${c.bold}${c.magenta}◆ claude-rpc${c.reset} ${c.dim}v${VERSION} — Discord Rich Presence for Claude Code${c.reset}`);
@@ -2045,7 +2035,7 @@ process.on('unhandledRejection', (e) => {
2045
2035
  // console, the grandchild node allocates a fresh one, and Windows 11
2046
2036
  // pops it as a visible Windows Terminal window whose closure kills
2047
2037
  // the daemon.
2048
- if (!daemonPid()) {
2038
+ if (!daemonAlive()) {
2049
2039
  let script = null;
2050
2040
  try {
2051
2041
  const r = spawnSync('npm', ['root', '-g'], {
@@ -2065,7 +2055,7 @@ process.on('unhandledRejection', (e) => {
2065
2055
  child.unref();
2066
2056
  console.log(` ${c.green}✓${c.reset} ${'daemon launched'.padEnd(16)}${c.dim}pid ${c.reset}${c.cyan}${child.pid}${c.reset}${c.dim} · log ${shortPath(LOG_PATH)}${c.reset}`);
2067
2057
  } else {
2068
- console.log(` ${c.cyan}·${c.reset} ${'daemon running'.padEnd(16)}${c.dim}pid ${daemonPid()}${c.reset}`);
2058
+ console.log(` ${c.cyan}·${c.reset} ${'daemon running'.padEnd(16)}${c.dim}pid ${daemonAlive()}${c.reset}`);
2069
2059
  }
2070
2060
  } else {
2071
2061
  startDaemon();
package/src/format.js CHANGED
@@ -870,7 +870,9 @@ export function applyIdle(state, cfg = {}) {
870
870
  if (state.status === 'notification') {
871
871
  const notifAge = now - (state.lastNotification || 0);
872
872
  if (notifAge <= notificationMs) return state;
873
- state = { ...state, status: 'idle' };
873
+ // Going idle after a notification — wipe the current-activity slots so idle
874
+ // frames don't render stale file/tool names (same as the idle path below).
875
+ state = { ...state, status: 'idle', currentTool: null, currentFile: null, filesOpened: [], filesEdited: [], filesRead: [] };
874
876
  }
875
877
 
876
878
  // Truly dormant: no live transcripts AND local state is old → stale.
package/src/scanner.js CHANGED
@@ -699,18 +699,22 @@ function writeCache(cache) {
699
699
  // Per-day notification counts come from a hook-side append log, since
700
700
  // transcripts don't carry Notification events reliably.
701
701
  function readNotificationsByDay() {
702
- if (!existsSync(EVENTS_LOG_PATH)) return {};
703
702
  const out = {};
704
- try {
705
- const raw = readFileSync(EVENTS_LOG_PATH, 'utf8');
706
- for (const line of raw.split('\n')) {
707
- if (!line) continue;
708
- const e = safeJson(line);
709
- if (!e || e.type !== 'notification' || !e.ts) continue;
710
- const k = dayKey(e.ts);
711
- out[k] = (out[k] || 0) + 1;
712
- }
713
- } catch { /* events log unreadable/truncated return whatever we got, the aggregate will just under-count notifications */ }
703
+ // The hook rotates events.jsonl to `.1` at 5MB; read both (oldest first) so a
704
+ // rotation doesn't silently drop a day's notification counts.
705
+ for (const path of [EVENTS_LOG_PATH + '.1', EVENTS_LOG_PATH]) {
706
+ if (!existsSync(path)) continue;
707
+ try {
708
+ const raw = readFileSync(path, 'utf8');
709
+ for (const line of raw.split('\n')) {
710
+ if (!line) continue;
711
+ const e = safeJson(line);
712
+ if (!e || e.type !== 'notification' || !e.ts) continue;
713
+ const k = dayKey(e.ts);
714
+ out[k] = (out[k] || 0) + 1;
715
+ }
716
+ } catch { /* a rotation file unreadable/truncated — count what we can */ }
717
+ }
714
718
  return out;
715
719
  }
716
720
 
package/src/tui.js CHANGED
@@ -1,15 +1,16 @@
1
- // Interactive terminal dashboard. Keyboard-navigable tabs, live refresh.
2
- // Designed to render correctly on Windows 10 cmd.exe, PowerShell 5.1, and
3
- // Windows Terminal: no full outer box, no alternative screen buffer (some
4
- // older terminals don't honor it), only ANSI color + horizontal separator
5
- // lines for structure.
1
+ // Interactive terminal dashboard — full-screen framed panels, keyboard tabs,
2
+ // live refresh. Uses the alternate screen buffer (restored on quit) and box
3
+ // drawing, so it targets modern terminals (Windows Terminal, iTerm, kitty,
4
+ // etc.). NO_COLOR is honored: color drops out, the box structure stays.
5
+ //
6
+ // renderFrame() is pure (data in → string out) so the layout is testable and
7
+ // previewable without a live TTY; startTui() owns the IO/loop.
6
8
 
7
9
  import process from 'node:process';
8
10
  import { readFileSync, existsSync } from 'node:fs';
9
11
  import { readActiveState } from './state.js';
10
- import { readAggregate, findLiveSessions, weekKey } from './scanner.js';
11
- import { weekGrid } from './week.js';
12
- import { buildVars, applyIdle, humanProject } from './format.js';
12
+ import { readAggregate, findLiveSessions, dayKey } from './scanner.js';
13
+ import { buildVars, applyIdle, humanProject, fmtNum } from './format.js';
13
14
  import { loadConfig } from './config.js';
14
15
  import { PID_PATH } from './paths.js';
15
16
  import { fmtCost } from './pricing.js';
@@ -17,25 +18,32 @@ import { heat } from './ui.js';
17
18
 
18
19
  // ── ANSI ────────────────────────────────────────────────────────────────────
19
20
  const ESC = '\x1b[';
20
- const RESET = ESC + '0m';
21
21
  const CLEAR = ESC + '2J' + ESC + 'H';
22
22
  const HIDE_CURSOR = ESC + '?25l';
23
23
  const SHOW_CURSOR = ESC + '?25h';
24
+ const ALT_ON = ESC + '?1049h'; // alternate screen buffer — restored on quit
25
+ const ALT_OFF = ESC + '?1049l';
24
26
 
27
+ // Color drops out entirely under NO_COLOR; box-drawing (not color) stays.
28
+ const COLOR = !process.env.NO_COLOR;
29
+ const sgr = (code) => (COLOR ? ESC + code : '');
25
30
  const C = {
26
- reset: RESET,
27
- dim: ESC + '2m',
28
- bold: ESC + '1m',
29
- red: ESC + '31m',
30
- green: ESC + '32m',
31
- yellow: ESC + '33m',
32
- magenta: ESC + '35m',
33
- cyan: ESC + '36m',
34
- gray: ESC + '90m',
31
+ reset: sgr('0m'),
32
+ dim: sgr('2m'),
33
+ bold: sgr('1m'),
34
+ red: sgr('31m'),
35
+ green: sgr('32m'),
36
+ yellow: sgr('33m'),
37
+ magenta: sgr('35m'),
38
+ cyan: sgr('36m'),
39
+ gray: sgr('90m'),
35
40
  };
36
41
  const ansiRe = /\x1b\[[0-9;]*m/g;
37
42
  const visLen = (s) => String(s).replace(ansiRe, '').length;
38
43
 
44
+ // Box-drawing — rounded corners for the "serious" look.
45
+ const BX = { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│', sl: '├', sr: '┤' };
46
+
39
47
  // ── Tabs ────────────────────────────────────────────────────────────────────
40
48
  const TABS = [
41
49
  { key: 'now', label: 'Now' },
@@ -50,15 +58,124 @@ let currentTab = 0;
50
58
  let refreshTimer = null;
51
59
  let exiting = false;
52
60
 
53
- // ── Data ────────────────────────────────────────────────────────────────────
54
- function loadSnapshot() {
61
+ // ── Width-aware string helpers ────────────────────────────────────────────────
62
+ // Truncate/pad a (possibly ANSI-coloured) string to exactly `w` visible columns.
63
+ // Padding resets colour first so trailing spaces never inherit a fill; truncation
64
+ // appends an ellipsis and a reset so colour can't bleed past the cut.
65
+ function fit(s, w) {
66
+ s = String(s);
67
+ const len = visLen(s);
68
+ if (len === w) return s;
69
+ if (len < w) return s + C.reset + ' '.repeat(w - len);
70
+ let out = '', vis = 0, i = 0;
71
+ while (i < s.length && vis < w - 1) {
72
+ if (s[i] === '\x1b') {
73
+ const m = s.indexOf('m', i);
74
+ if (m !== -1) { out += s.slice(i, m + 1); i = m + 1; continue; }
75
+ }
76
+ out += s[i]; vis++; i++;
77
+ }
78
+ return out + '…' + C.reset;
79
+ }
80
+
81
+ // Heat-graded fill bar. `ratio` in 0..1 (or value/max). NO_COLOR-safe via heat().
82
+ function bar(ratio, w) {
83
+ const r = Math.max(0, Math.min(1, ratio || 0));
84
+ const filled = Math.max(0, Math.min(w, Math.round(r * w)));
85
+ return `${heat(r)}${'█'.repeat(filled)}${C.reset}${' '.repeat(w - filled)}`;
86
+ }
87
+
88
+ // Centre a (possibly ANSI) string within width `w` (left pad only; row() right-pads).
89
+ function center(s, w) {
90
+ const len = visLen(s);
91
+ if (len >= w) return s;
92
+ return ' '.repeat(Math.floor((w - len) / 2)) + s;
93
+ }
94
+
95
+ // ── Panels & columns ──────────────────────────────────────────────────────────
96
+ // A bordered panel of exact total width `w`, title embedded in the top border.
97
+ // Returns an array of lines (height = body.length + 2).
98
+ function panel(title, body, w) {
99
+ const out = [];
100
+ if (title) {
101
+ const fill = Math.max(0, w - 5 - visLen(title));
102
+ out.push(`${C.gray}${BX.tl}${BX.h} ${C.bold}${title}${C.reset}${C.gray} ${BX.h.repeat(fill)}${BX.tr}${C.reset}`);
103
+ } else {
104
+ out.push(`${C.gray}${BX.tl}${BX.h.repeat(w - 2)}${BX.tr}${C.reset}`);
105
+ }
106
+ for (const line of body) {
107
+ out.push(`${C.gray}${BX.v}${C.reset} ${fit(line, w - 4)} ${C.gray}${BX.v}${C.reset}`);
108
+ }
109
+ out.push(`${C.gray}${BX.bl}${BX.h.repeat(w - 2)}${BX.br}${C.reset}`);
110
+ return out;
111
+ }
112
+
113
+ // Lay panel arrays side by side, padding shorter ones with blanks of their width.
114
+ function columns(panels, gap = 3) {
115
+ const hgt = Math.max(...panels.map((p) => p.length));
116
+ const ws = panels.map((p) => visLen(p[0] || ''));
117
+ const spacer = ' '.repeat(gap);
118
+ const out = [];
119
+ for (let i = 0; i < hgt; i++) {
120
+ out.push(panels.map((p, j) => (p[i] !== undefined ? p[i] : ' '.repeat(ws[j]))).join(spacer));
121
+ }
122
+ return out;
123
+ }
124
+
125
+ // Split a content width into two column widths (with a gap between).
126
+ function split2(cw, gap = 3) {
127
+ const tot = cw - gap;
128
+ const l = Math.floor(tot / 2);
129
+ return [l, tot - l];
130
+ }
131
+
132
+ // "label ............ value" justified to width `w` (panel content width).
133
+ function statRows(pairs, w) {
134
+ return pairs.map(([label, val]) => {
135
+ const pad = Math.max(1, w - visLen(label) - visLen(val));
136
+ return `${C.dim}${label}${C.reset}${' '.repeat(pad)}${val}`;
137
+ });
138
+ }
139
+
140
+ // "name ████████ value" justified to width `w` (panel content width).
141
+ function barRow(label, valStr, ratio, w) {
142
+ const labelW = Math.max(8, Math.min(16, Math.floor(w * 0.42)));
143
+ const valW = Math.max(5, Math.min(11, visLen(valStr)));
144
+ const barW = Math.max(3, w - labelW - valW - 2);
145
+ const name = String(label).length > labelW ? String(label).slice(0, labelW - 1) + '…' : String(label).padEnd(labelW);
146
+ return `${C.dim}${name}${C.reset} ${bar(ratio, barW)} ${C.cyan}${valStr.padStart(valW)}${C.reset}`;
147
+ }
148
+
149
+ function hms(ms) {
150
+ const h = (ms || 0) / 3_600_000;
151
+ if (h < 1) return `${Math.round(h * 60)}m`;
152
+ return h < 10 ? `${h.toFixed(1)}h` : `${Math.round(h)}h`;
153
+ }
154
+
155
+ function statusColor(s) {
156
+ return s === 'working' ? C.green
157
+ : s === 'thinking' ? C.yellow
158
+ : s === 'notification' ? C.magenta
159
+ : s === 'shipped' ? C.green
160
+ : s === 'stale' ? C.dim
161
+ : C.cyan;
162
+ }
163
+
164
+ function headline(title, value, sub) {
165
+ const v = value ? ` ${C.bold}${C.cyan}${value}${C.reset}` : '';
166
+ const s = sub ? ` ${C.dim}${sub}${C.reset}` : '';
167
+ return [`${C.bold}${title.toUpperCase()}${C.reset}${v}${s}`, ''];
168
+ }
169
+
170
+ // ── Data ──────────────────────────────────────────────────────────────────────
171
+ export function loadSnapshot() {
55
172
  let state = readActiveState();
56
173
  state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
57
174
  const config = loadConfig();
58
175
  state = applyIdle(state, config);
59
176
  const aggregate = readAggregate() || {};
60
177
  const vars = buildVars(state, config, aggregate);
61
- return { state, config, aggregate, vars };
178
+ return { state, config, aggregate, vars, pid: daemonPid() };
62
179
  }
63
180
 
64
181
  function daemonPid() {
@@ -70,293 +187,298 @@ function daemonPid() {
70
187
  } catch { return null; }
71
188
  }
72
189
 
73
- // ── Layout helpers ──────────────────────────────────────────────────────────
74
- function width() { return Math.max(50, Math.min(120, process.stdout.columns || 80)); }
75
- function height() { return Math.max(20, process.stdout.rows || 24); }
76
-
77
- function rule(w) { return C.gray + '─'.repeat(w - 4) + C.reset; }
78
- // Heat-graded fill (same ramp as the CLI views) — intensity reads at a glance.
79
- function bar(value, max, w = 16) {
80
- if (!max || max <= 0) return ''.padEnd(w);
81
- const filled = Math.max(0, Math.min(w, Math.round((value / max) * w)));
82
- // heat() is NO_COLOR-aware (returns '' when color is off); the old
83
- // `|| C.magenta` fallback wasn't, so it injected a raw escape under NO_COLOR.
84
- return `${heat(value / max)}${'█'.repeat(filled)}${C.reset}` + ' '.repeat(w - filled);
85
- }
86
-
87
- // Wrap each content line with a 2-space left margin.
88
- function indent(rows) { return rows.map((r) => ' ' + r); }
89
-
90
- // ── Header / footer ─────────────────────────────────────────────────────────
91
- function drawHeader(w) {
92
- const pid = daemonPid();
93
- const status = pid
94
- ? `${C.green}● running${C.reset} ${C.dim}pid ${pid}${C.reset}`
95
- : `${C.red}○ not running${C.reset}`;
96
-
97
- const title = `${C.bold}Claude RPC${C.reset}`;
98
- // First line: title left, status right
99
- const left = title;
100
- const right = status;
101
- const innerWidth = w - 4;
102
- const padCount = Math.max(1, innerWidth - visLen(left) - visLen(right));
103
- const line1 = ' ' + left + ' '.repeat(padCount) + right;
104
-
105
- // Tabs line
106
- const tabBits = TABS.map((t, i) => {
107
- if (i === currentTab) return `${C.bold}${C.cyan}${t.label}${C.reset}`;
108
- return `${C.dim}${t.label}${C.reset}`;
109
- });
110
- const tabs = tabBits.join(` ${C.gray}·${C.reset} `);
111
- const line2 = ' ' + tabs;
112
-
113
- return [line1, line2, ' ' + rule(w)];
114
- }
115
-
116
- function drawFooter(w) {
117
- const keys = `${C.dim}1-7 jump${C.reset} ${C.gray}·${C.reset} ${C.dim}←→ h l${C.reset} ${C.gray}·${C.reset} ${C.dim}r refresh${C.reset} ${C.gray}·${C.reset} ${C.dim}q quit${C.reset}`;
118
- return [' ' + rule(w), ' ' + keys];
190
+ const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
191
+
192
+ // Rolling last-7-days window (today + the 6 prior days), summed from byDay.
193
+ // Anchored at local noon so a day subtraction can't slip across a DST boundary.
194
+ function last7Days(byDay = {}) {
195
+ const days = [];
196
+ let totMs = 0, prompts = 0, tools = 0, sessions = 0, tokens = 0, cost = 0;
197
+ for (let i = 6; i >= 0; i--) {
198
+ const d = new Date(); d.setHours(12, 0, 0, 0); d.setDate(d.getDate() - i);
199
+ const key = dayKey(d.getTime());
200
+ const e = byDay[key] || {};
201
+ const ms = e.activeMs || 0;
202
+ days.push({ label: `${WD[d.getDay()]} ${key.slice(5)}`, ms, isToday: i === 0 });
203
+ totMs += ms; prompts += e.userMessages || 0; tools += e.toolCalls || 0; sessions += e.sessions || 0;
204
+ tokens += (e.inputTokens || 0) + (e.outputTokens || 0) + (e.cacheReadTokens || 0) + (e.cacheWriteTokens || 0);
205
+ cost += e.cost || 0;
206
+ }
207
+ return { days, maxMs: Math.max(0, ...days.map((d) => d.ms)), totMs, prompts, tools, sessions, tokens, cost };
119
208
  }
120
209
 
121
- // ── Tab renderers ───────────────────────────────────────────────────────────
122
- function tabNow(_, data) {
210
+ // ── Tab renderers — each returns content lines (row() fits them to width) ──────
211
+ function tabNow(data, cw) {
123
212
  const v = data.vars;
124
- const out = [];
125
- out.push('');
126
- out.push(`${C.bold}${v.statusVerbose}${C.reset} ${C.dim}in${C.reset} ${C.bold}${v.project}${C.reset}`);
127
- out.push(`${C.dim}${v.modelPretty}${C.reset} ${C.cyan}${v.duration}${C.reset} ${C.dim}elapsed${C.reset}`);
128
- out.push('');
129
- out.push(`${C.dim}prompts${C.reset} ${C.yellow}${String(v.messages).padStart(8)}${C.reset}`);
130
- out.push(`${C.dim}tool calls${C.reset} ${C.yellow}${String(v.tools).padStart(8)}${C.reset}`);
131
- out.push(`${C.dim}files${C.reset} ${C.cyan}${String(v.filesOpened).padStart(8)}${C.reset} ${C.dim}opened · ${v.filesEdited} edited · ${v.filesRead} read${C.reset}`);
132
- out.push(`${C.dim}tokens${C.reset} ${C.bold}${v.tokensFmt.padStart(8)}${C.reset} ${C.dim}(${v.inputTokens} in · ${v.outputTokens} out · ${v.cacheTokens} cache)${C.reset}`);
133
- if (v.currentTool) {
134
- out.push('');
135
- out.push(`${C.dim}current${C.reset} ${C.bold}${v.currentToolPretty}${C.reset} ${C.cyan}${v.currentFilePretty}${C.reset}`);
136
- }
137
- if (v.concurrent > 1) {
138
- out.push('');
139
- out.push(`${C.magenta}${v.concurrentLabel}${C.reset} ${C.dim}— ${v.concurrentListPretty}${C.reset}`);
213
+ const sc = statusColor(v.status);
214
+ const head = [
215
+ `${C.bold}${sc}${String(v.statusVerbose).toUpperCase()}${C.reset} ${C.dim}in${C.reset} ${C.bold}${v.project}${C.reset}`,
216
+ `${C.dim}${v.modelPretty} · ${v.duration} elapsed${C.reset}`,
217
+ '',
218
+ ];
219
+ const [lw, rw] = split2(cw);
220
+ const session = panel('session', statRows([
221
+ ['prompts', `${C.yellow}${v.messages}${C.reset}`],
222
+ ['tool calls', `${C.yellow}${v.tools}${C.reset}`],
223
+ ['files', `${C.cyan}${v.filesOpened}${C.reset} ${C.dim}open · ${v.filesEdited} edit · ${v.filesRead} read${C.reset}`],
224
+ ['tokens', `${C.bold}${v.tokensFmt}${C.reset}`],
225
+ ['', `${C.dim}${v.inputTokens} in · ${v.outputTokens} out · ${v.cacheTokens} cache${C.reset}`],
226
+ ], lw - 4), lw);
227
+ const liveBody = [];
228
+ if (v.currentTool) liveBody.push(...statRows([['doing', `${C.bold}${v.currentToolPretty}${C.reset}`]], rw - 4));
229
+ if (v.currentFilePretty) liveBody.push(`${C.dim}file${C.reset} ${C.cyan}${v.currentFilePretty}${C.reset}`);
230
+ liveBody.push(...statRows([['model', `${C.bold}${v.modelPretty}${C.reset}`]], rw - 4));
231
+ if (Number(v.concurrent) > 1) {
232
+ liveBody.push('');
233
+ liveBody.push(`${C.magenta}${v.concurrentLabel}${C.reset}`);
234
+ liveBody.push(`${C.dim}${v.concurrentListPretty}${C.reset}`);
235
+ } else {
236
+ liveBody.push(`${C.dim}single session${C.reset}`);
140
237
  }
141
- return out;
238
+ return [...head, ...columns([session, panel('live', liveBody, rw)])];
142
239
  }
143
240
 
144
- function tabToday(_, data) {
241
+ function tabToday(data, cw) {
145
242
  const v = data.vars;
146
243
  const agg = data.aggregate;
147
- const out = [];
148
- out.push('');
149
- out.push(`${C.bold}Today${C.reset} ${C.green}${v.todayHours}${C.reset} ${C.dim}active${C.reset}`);
150
- out.push('');
151
- out.push(`${C.dim}prompts${C.reset} ${C.yellow}${String(v.todayPrompts).padStart(8)}${C.reset}`);
152
- out.push(`${C.dim}tool calls${C.reset} ${C.yellow}${v.todayToolsFmt.padStart(8)}${C.reset}`);
153
- out.push(`${C.dim}sessions${C.reset} ${C.cyan}${String(v.todaySessions).padStart(8)}${C.reset}`);
154
- out.push(`${C.dim}tokens${C.reset} ${C.bold}${v.todayTokensFmt.padStart(8)}${C.reset} ${C.dim}grand total${C.reset}`);
155
- if (agg.byHour && Object.keys(agg.byHour).length) {
156
- out.push('');
157
- out.push(`${C.dim}when you code · hour of day${C.reset}`);
158
- const heightChars = ' ▁▂▃▄▅▆▇█';
159
- let max = 0;
160
- for (let h = 0; h < 24; h++) max = Math.max(max, agg.byHour?.[h]?.activeMs || 0);
161
- if (max > 0) {
162
- const bars = [];
163
- for (let h = 0; h < 24; h++) {
164
- const ms = agg.byHour?.[h]?.activeMs || 0;
165
- const idx = ms > 0 ? Math.max(1, Math.min(8, Math.round((ms / max) * 8))) : 0;
166
- const ch = heightChars[idx];
167
- bars.push(h === v.peakHourNum ? `${C.bold}${heat(1)}${ch}${C.reset}` : `${heat(ms / max)}${ch}${C.reset}`);
168
- }
169
- out.push(bars.join(''));
170
- out.push(`${C.dim}00 03 06 09 12 15 18 21${C.reset}`);
244
+ const head = headline('today', v.todayHours, 'active');
245
+ const [lw, rw] = split2(cw);
246
+ const stats = panel('today', statRows([
247
+ ['prompts', `${C.yellow}${v.todayPrompts}${C.reset}`],
248
+ ['tool calls', `${C.yellow}${v.todayToolsFmt}${C.reset}`],
249
+ ['sessions', `${C.cyan}${v.todaySessions}${C.reset}`],
250
+ ['spend', `${C.green}${v.todayCostFmt}${C.reset}`],
251
+ ], lw - 4), lw);
252
+ const toks = panel('tokens', statRows([
253
+ ['total', `${C.bold}${v.todayTokensFmt}${C.reset}`],
254
+ ['fresh', `${C.cyan}${v.todayTokensRealFmt}${C.reset}`],
255
+ ['cache', `${C.dim}${v.todayCacheTokensFmt}${C.reset}`],
256
+ ['lines', `${C.green}+${v.todayLinesAddedFmt}${C.reset} ${C.dim}(${v.todayLinesNetFmt} net)${C.reset}`],
257
+ ], rw - 4), rw);
258
+ const out = [...head, ...columns([stats, toks])];
259
+
260
+ // Full-width hour-of-day histogram.
261
+ const heightChars = ' ▁▂▃▄▅▆▇█';
262
+ let max = 0;
263
+ for (let h = 0; h < 24; h++) max = Math.max(max, agg.byHour?.[h]?.activeMs || 0);
264
+ if (max > 0) {
265
+ const bars = [];
266
+ for (let h = 0; h < 24; h++) {
267
+ const ms = agg.byHour?.[h]?.activeMs || 0;
268
+ const idx = ms > 0 ? Math.max(1, Math.min(8, Math.round((ms / max) * 8))) : 0;
269
+ const ch = heightChars[idx];
270
+ bars.push(h === v.peakHourNum ? `${C.bold}${heat(1)}${ch}${C.reset}` : `${heat(ms / max)}${ch}${C.reset}`);
171
271
  }
272
+ // Two cells per hour so the 24h strip reads wide.
273
+ out.push('');
274
+ out.push(...panel('when you code · hour of day', [
275
+ bars.map((b) => b + b).join(''),
276
+ `${C.dim}00 03 06 09 12 15 18 21${C.reset}`,
277
+ ], cw));
172
278
  }
173
279
  return out;
174
280
  }
175
281
 
176
- function tabWeek(_, data) {
177
- const v = data.vars;
282
+ function tabWeek(data, cw) {
178
283
  const agg = data.aggregate;
179
- const out = [];
180
- out.push('');
181
- out.push(`${C.bold}This week${C.reset} ${C.dim}${weekKey(Date.now())}${C.reset} ${C.green}${v.weekHours}${C.reset} ${C.dim}active${C.reset}`);
182
- out.push('');
183
- out.push(`${C.dim}prompts${C.reset} ${C.yellow}${String(v.weekPrompts).padStart(8)}${C.reset}`);
184
- out.push(`${C.dim}tool calls${C.reset} ${C.yellow}${v.weekToolsFmt.padStart(8)}${C.reset}`);
185
- out.push(`${C.dim}sessions${C.reset} ${C.cyan}${String(v.weekSessions).padStart(8)}${C.reset}`);
186
- out.push(`${C.dim}tokens${C.reset} ${C.bold}${v.weekTokensFmt.padStart(8)}${C.reset}`);
187
- if (agg.byDay) {
188
- out.push('');
189
- out.push(`${C.dim}daily breakdown${C.reset}`);
190
- const { days, maxMs } = weekGrid(agg.byDay);
191
- for (const { label, ms, isFuture, isToday } of days) {
192
- if (isFuture) {
193
- out.push(`${C.dim}${label.padEnd(11)}${' '.repeat(18)} —${C.reset}`);
194
- } else {
195
- const h = ms / 3_600_000;
196
- const hStr = h < 1 ? `${Math.round(h * 60)}m` : (h < 10 ? `${h.toFixed(1)}h` : `${Math.round(h)}h`);
197
- const prefix = isToday ? C.bold : '';
198
- const peak = ms === maxMs && ms > 0 ? ` ${C.bold}${heat(1)}◆${C.reset}` : '';
199
- out.push(`${prefix}${label.padEnd(11)}${C.reset} ${bar(ms, maxMs, 18)} ${C.cyan}${hStr.padStart(5)}${C.reset}${peak}${isToday ? ` ${C.dim}← today${C.reset}` : ''}`);
200
- }
201
- }
202
- }
203
- return out;
284
+ const wk = last7Days(agg.byDay || {});
285
+ const head = headline('last 7 days', hms(wk.totMs), 'active');
286
+ const [lw, rw] = split2(cw);
287
+
288
+ const cwBody = lw - 4;
289
+ const dailyBody = wk.days.map(({ label, ms, isToday }) => {
290
+ const marker = isToday ? `${C.bold}${C.cyan} ‹today${C.reset}` : '';
291
+ const lbl = isToday ? `${C.bold}${label}${C.reset}` : `${C.dim}${label}${C.reset}`;
292
+ // Reserve room for the value (1 space + 5) and, on today's row, the marker
293
+ // (' ‹today' = 7) so the bar never pushes the value off the panel edge.
294
+ const barW = Math.max(3, cwBody - 11 - 6 - (isToday ? 7 : 0));
295
+ return `${lbl}${' '.repeat(Math.max(1, 11 - visLen(label)))}${bar(wk.maxMs ? ms / wk.maxMs : 0, barW)} ${C.cyan}${hms(ms).padStart(5)}${C.reset}${marker}`;
296
+ });
297
+ const daily = panel('daily', dailyBody, lw);
298
+
299
+ const totals = panel('last 7 days', statRows([
300
+ ['active', `${C.green}${hms(wk.totMs)}${C.reset}`],
301
+ ['prompts', `${C.yellow}${fmtNum(wk.prompts)}${C.reset}`],
302
+ ['tool calls', `${C.yellow}${fmtNum(wk.tools)}${C.reset}`],
303
+ ['sessions', `${C.cyan}${fmtNum(wk.sessions)}${C.reset}`],
304
+ ['tokens', `${C.bold}${fmtNum(wk.tokens)}${C.reset}`],
305
+ ['spend', `${C.green}${fmtCost(wk.cost)}${C.reset}`],
306
+ ], rw - 4), rw);
307
+
308
+ return [...head, ...columns([daily, totals])];
204
309
  }
205
310
 
206
- function tabStreak(_, data) {
311
+ function tabStreak(data, cw) {
207
312
  const v = data.vars;
208
- const out = [];
209
- out.push('');
210
- out.push(`${C.bold}${C.magenta}${v.streak}${C.reset} ${C.dim}day streak${C.reset} ${C.dim}longest${C.reset} ${C.cyan}${v.longestStreak}${C.reset}`);
211
- out.push('');
212
- out.push(`${C.dim}days on Claude${C.reset} ${C.cyan}${String(v.daysSinceFirst).padStart(8)}${C.reset}`);
213
- if (v.bestDayDate) {
214
- out.push('');
215
- out.push(`${C.dim}best day${C.reset} ${C.bold}${v.bestDayHours}${C.reset} ${C.dim}on ${v.bestDayDate}${C.reset}`);
216
- out.push(` ${C.dim}${v.bestDayPrompts} prompts · ${v.bestDayTokensFmt} tokens${C.reset}`);
217
- }
218
- if (v.peakHour) {
219
- out.push('');
220
- out.push(`${C.dim}peak hour${C.reset} ${C.bold}${v.peakHour}${C.reset} ${C.dim}${v.peakHourActiveLabel}${C.reset}`);
221
- }
222
- if (v.topEditedFile) {
223
- out.push('');
224
- out.push(`${C.dim}hotspot${C.reset} ${C.bold}${v.topEditedFile}${C.reset} ${C.dim}${v.topEditedCountLabel}${C.reset}`);
225
- }
226
- return out;
313
+ const [lw, rw] = split2(cw);
314
+ const head = headline('streak', `${v.streak}d`, `longest ${v.longestStreak}d`);
315
+ const left = panel('streak', statRows([
316
+ ['current', `${C.bold}${C.magenta}${v.streak}${C.reset} ${C.dim}days${C.reset}`],
317
+ ['longest', `${C.cyan}${v.longestStreak}${C.reset} ${C.dim}days${C.reset}`],
318
+ ['days on Claude', `${C.cyan}${v.daysSinceFirst}${C.reset}`],
319
+ ], lw - 4), lw);
320
+ const rb = [];
321
+ if (v.bestDayDate) rb.push(...statRows([['best day', `${C.bold}${v.bestDayHours}${C.reset} ${C.dim}${v.bestDayDate}${C.reset}`]], rw - 4));
322
+ if (v.bestDayDate) rb.push(`${C.dim}${v.bestDayPrompts} prompts · ${v.bestDayTokensFmt} tokens${C.reset}`);
323
+ if (v.peakHour) rb.push(...statRows([['peak hour', `${C.bold}${v.peakHour}${C.reset} ${C.dim}${v.peakHourActiveLabel}${C.reset}`]], rw - 4));
324
+ if (v.topEditedFile) rb.push(...statRows([['hotspot', `${C.bold}${v.topEditedFile}${C.reset}`]], rw - 4));
325
+ if (!rb.length) rb.push(`${C.dim}keep going to unlock records${C.reset}`);
326
+ return [...head, ...columns([left, panel('records', rb, rw)])];
227
327
  }
228
328
 
229
- function tabLifetime(_, data) {
329
+ function tabLifetime(data, cw) {
230
330
  const v = data.vars;
231
331
  const agg = data.aggregate;
232
- const out = [];
233
- out.push('');
234
- out.push(`${C.bold}All-time${C.reset} ${C.green}${v.allHours}${C.reset} ${C.dim}active · ${v.allWallHours} wall${C.reset}`);
235
- out.push('');
236
- out.push(`${C.dim}sessions${C.reset} ${C.yellow}${String(v.allSessions).padStart(8)}${C.reset} ${C.dim}(+${v.allSubagentRuns} subagent runs)${C.reset}`);
237
- out.push(`${C.dim}prompts${C.reset} ${C.yellow}${v.allMessagesFmt.padStart(8)}${C.reset}`);
238
- out.push(`${C.dim}tool calls${C.reset} ${C.yellow}${v.allToolsFmt.padStart(8)}${C.reset}`);
239
- out.push(`${C.dim}files${C.reset} ${C.cyan}${v.allFilesFmt.padStart(8)}${C.reset}`);
240
- out.push(`${C.dim}tokens${C.reset} ${C.bold}${C.magenta}${v.allTokensFmt.padStart(8)}${C.reset} ${C.dim}grand total${C.reset}`);
241
- out.push(` ${C.dim}${v.allInputTokens} in · ${v.allOutputTokens} out · ${v.allCacheTokens} cache${C.reset}`);
242
- const projects = agg.projects || {};
243
- const top = Object.entries(projects)
244
- .sort((a, b) => b[1].activeMs - a[1].activeMs)
245
- .slice(0, 4);
246
- if (top.length) {
247
- out.push('');
248
- out.push(`${C.dim}top projects${C.reset}`);
249
- const maxMs = top[0][1].activeMs;
250
- for (const [name, p] of top) {
251
- const h = p.activeMs / 3_600_000;
252
- const hStr = h < 1 ? `${Math.round(h * 60)}m` : (h < 10 ? `${h.toFixed(1)}h` : `${Math.round(h)}h`);
253
- const pretty = humanProject(name).slice(0, 18).padEnd(20);
254
- out.push(`${pretty} ${bar(p.activeMs, maxMs, 16)} ${C.cyan}${hStr.padStart(5)}${C.reset}`);
255
- }
256
- }
257
- return out;
332
+ const [lw, rw] = split2(cw);
333
+ const head = headline('all-time', v.allHours, `active · ${v.allWallHours} wall`);
334
+ const stats = panel('all-time', statRows([
335
+ ['sessions', `${C.yellow}${v.allSessions}${C.reset} ${C.dim}+${v.allSubagentRuns} sub${C.reset}`],
336
+ ['prompts', `${C.yellow}${v.allMessagesFmt}${C.reset}`],
337
+ ['tool calls', `${C.yellow}${v.allToolsFmt}${C.reset}`],
338
+ ['files', `${C.cyan}${v.allFilesFmt}${C.reset}`],
339
+ ['tokens', `${C.bold}${C.magenta}${v.allTokensFmt}${C.reset}`],
340
+ ['spend', `${C.green}${v.allCostFmt}${C.reset}`],
341
+ ], lw - 4), lw);
342
+ const top = Object.entries(agg.projects || {}).sort((a, b) => b[1].activeMs - a[1].activeMs).slice(0, 6);
343
+ const maxMs = top[0] ? top[0][1].activeMs : 1;
344
+ const projBody = top.length
345
+ ? top.map(([name, p]) => barRow(humanProject(name), hms(p.activeMs), p.activeMs / maxMs, rw - 4))
346
+ : [`${C.dim}no projects yet${C.reset}`];
347
+ return [...head, ...columns([stats, panel('top projects · by time', projBody, rw)])];
258
348
  }
259
349
 
260
- function tabCost(_, data) {
350
+ function tabCost(data, cw) {
261
351
  const v = data.vars;
262
352
  const agg = data.aggregate;
263
- const out = [];
264
- out.push('');
265
- out.push(`${C.bold}Estimated cost${C.reset} ${C.green}${v.allCostFmt}${C.reset} ${C.dim}all-time · approximate${C.reset}`);
266
- out.push('');
267
- out.push(`${C.dim}today${C.reset} ${C.cyan}${v.todayCostFmt.padStart(10)}${C.reset}`);
268
- out.push(`${C.dim}this week${C.reset} ${C.cyan}${v.weekCostFmt.padStart(10)}${C.reset}`);
269
- out.push(`${C.dim}this project${C.reset} ${C.cyan}${v.projectCostFmt.padStart(10)}${C.reset}`);
270
-
271
- const byModel = Object.entries(agg.costByModel || {}).sort((a, b) => b[1] - a[1]).slice(0, 6);
272
- if (byModel.length) {
273
- out.push('');
274
- out.push(`${C.dim}by model${C.reset}`);
275
- const max = byModel[0][1];
276
- for (const [m, val] of byModel) {
277
- const pretty = String(m).padEnd(20);
278
- out.push(`${pretty} ${bar(val, max, 18)} ${C.cyan}${fmtCost(val).padStart(8)}${C.reset}`);
279
- }
280
- }
281
-
282
- // Month-to-date forecast.
353
+ const [lw, rw] = split2(cw);
354
+ const head = headline('spend', v.allCostFmt, 'all-time · approximate');
355
+
356
+ // Per-project spend — the headline ask.
357
+ const projs = Object.entries(agg.projects || {})
358
+ .map(([k, p]) => [humanProject(k), p.cost || 0]).filter(([, c]) => c > 0)
359
+ .sort((a, b) => b[1] - a[1]).slice(0, 8);
360
+ const maxP = projs[0] ? projs[0][1] : 1;
361
+ const projBody = projs.length
362
+ ? projs.map(([name, c]) => barRow(name, fmtCost(c), c / maxP, lw - 4))
363
+ : [`${C.dim}no spend recorded${C.reset}`];
364
+ const byProject = panel('by project', projBody, lw);
365
+
366
+ const models = Object.entries(agg.costByModel || {}).sort((a, b) => b[1] - a[1]).slice(0, 8);
367
+ const maxM = models[0] ? models[0][1] : 1;
368
+ const modelBody = models.length
369
+ ? models.map(([m, c]) => barRow(m, fmtCost(c), c / maxM, rw - 4))
370
+ : [`${C.dim}no model data${C.reset}`];
371
+ const byModel = panel('by model', modelBody, rw);
372
+
373
+ const out = [...head, ...columns([byProject, byModel])];
374
+
375
+ // Month-to-date + forecast strip.
283
376
  const now = new Date();
284
377
  const ym = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
285
378
  let mtd = 0;
286
- for (const [k, day] of Object.entries(agg.byDay || {})) {
287
- if (k.startsWith(ym)) mtd += day.cost || 0;
288
- }
289
- if (mtd > 0) {
290
- const daysIn = now.getDate();
291
- const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
292
- const forecast = (mtd / daysIn) * daysInMonth;
293
- out.push('');
294
- out.push(`${C.dim}month-to-date${C.reset} ${C.cyan}${fmtCost(mtd).padStart(8)}${C.reset}`);
295
- out.push(`${C.dim}forecast${C.reset} ${C.bold}${fmtCost(forecast).padStart(8)}${C.reset}`);
296
- }
379
+ for (const [k, day] of Object.entries(agg.byDay || {})) if (k.startsWith(ym)) mtd += day.cost || 0;
380
+ const forecast = mtd > 0 ? (mtd / now.getDate()) * new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate() : 0;
381
+ out.push('');
382
+ out.push(`${C.dim}today${C.reset} ${C.cyan}${v.todayCostFmt}${C.reset} ${C.dim}week${C.reset} ${C.cyan}${v.weekCostFmt}${C.reset} ${C.dim}month-to-date${C.reset} ${C.cyan}${fmtCost(mtd)}${C.reset} ${C.dim}forecast${C.reset} ${C.bold}${fmtCost(forecast)}${C.reset}`);
297
383
  return out;
298
384
  }
299
385
 
300
- function tabCode(_, data) {
386
+ function tabCode(data, cw) {
301
387
  const v = data.vars;
302
388
  const agg = data.aggregate;
303
- const out = [];
304
- out.push('');
305
- out.push(`${C.bold}Code churn${C.reset} ${C.green}+${v.linesAddedFmt}${C.reset} / ${C.red}−${v.linesRemovedFmt}${C.reset} ${C.dim}net ${v.linesNetFmt}${C.reset}`);
306
- out.push('');
307
- out.push(`${C.dim}today${C.reset} ${C.green}+${v.todayLinesAddedFmt}${C.reset} ${C.dim}(${v.todayLinesNetFmt} net)${C.reset}`);
308
- out.push(`${C.dim}this week${C.reset} ${C.green}+${v.weekLinesAddedFmt}${C.reset} ${C.dim}(${v.weekLinesNetFmt} net)${C.reset}`);
309
-
389
+ const [lw, rw] = split2(cw);
390
+ const head = headline('code churn', `+${v.linesAddedFmt} / −${v.linesRemovedFmt}`, `net ${v.linesNetFmt}`);
310
391
  const langs = Object.entries(agg.languages || {}).sort((a, b) => (b[1].edits || 0) - (a[1].edits || 0)).slice(0, 6);
311
- if (langs.length) {
312
- out.push('');
313
- out.push(`${C.dim}languages · by edits${C.reset}`);
314
- const max = langs[0][1].edits || 1;
315
- for (const [name, l] of langs) {
316
- const pretty = name.slice(0, 18).padEnd(20);
317
- out.push(`${pretty} ${bar(l.edits, max, 18)} ${C.cyan}${String(l.edits).padStart(6)}${C.reset}`);
318
- }
319
- }
320
-
321
- const bash = Object.entries(agg.bashCommands || {}).sort((a, b) => b[1] - a[1]).slice(0, 4);
322
- if (bash.length) {
323
- out.push('');
324
- out.push(`${C.dim}top bash${C.reset}`);
325
- for (const [k, n] of bash) {
326
- out.push(`${k.padEnd(20)} ${C.cyan}${String(n).padStart(6)}${C.reset}`);
327
- }
328
- }
329
- return out;
392
+ const maxL = langs[0] ? (langs[0][1].edits || 1) : 1;
393
+ const langBody = [
394
+ ...statRows([
395
+ ['today', `${C.green}+${v.todayLinesAddedFmt}${C.reset} ${C.dim}(${v.todayLinesNetFmt} net)${C.reset}`],
396
+ ['this week', `${C.green}+${v.weekLinesAddedFmt}${C.reset} ${C.dim}(${v.weekLinesNetFmt} net)${C.reset}`],
397
+ ], lw - 4),
398
+ '',
399
+ ...(langs.length ? langs.map(([n, l]) => barRow(n, fmtNum(l.edits || 0), (l.edits || 0) / maxL, lw - 4)) : [`${C.dim}no edits yet${C.reset}`]),
400
+ ];
401
+ const churn = panel('languages · by edits', langBody, lw);
402
+
403
+ const bash = Object.entries(agg.bashCommands || {}).sort((a, b) => b[1] - a[1]).slice(0, 5);
404
+ const dom = Object.entries(agg.webDomains || {}).sort((a, b) => b[1] - a[1]).slice(0, 3);
405
+ const rb = [];
406
+ if (bash.length) { rb.push(`${C.dim}top bash${C.reset}`); for (const [k, n] of bash) rb.push(`${String(k).slice(0, rw - 12).padEnd(rw - 12)} ${C.cyan}${String(n).padStart(6)}${C.reset}`); }
407
+ if (dom.length) { rb.push(''); rb.push(`${C.dim}web domains${C.reset}`); for (const [k, n] of dom) rb.push(`${String(k).slice(0, rw - 12).padEnd(rw - 12)} ${C.cyan}${String(n).padStart(6)}${C.reset}`); }
408
+ if (!rb.length) rb.push(`${C.dim}no shell/web activity${C.reset}`);
409
+ return [...head, ...columns([churn, panel('shell & web', rb, rw)])];
330
410
  }
331
411
 
332
412
  const TAB_RENDERERS = [tabNow, tabToday, tabWeek, tabStreak, tabLifetime, tabCost, tabCode];
333
413
 
334
- // ── Render ──────────────────────────────────────────────────────────────────
335
- function render() {
336
- const w = width();
337
- const h = height();
338
- const data = loadSnapshot();
339
-
340
- const header = drawHeader(w);
341
- const footer = drawFooter(w);
342
- const body = indent(TAB_RENDERERS[currentTab](w, data));
414
+ // ── Frame composition ─────────────────────────────────────────────────────────
415
+ function topBorder(w, title, statusColored) {
416
+ const fill = Math.max(0, w - title.length - visLen(statusColored) - 8);
417
+ return `${C.gray}${BX.tl}${BX.h} ${C.bold}${title}${C.reset}${C.gray} ${BX.h.repeat(fill)} ${C.reset}${statusColored}${C.gray} ${BX.h}${BX.tr}${C.reset}`;
418
+ }
419
+ function bottomBorder(w) { return `${C.gray}${BX.bl}${BX.h.repeat(w - 2)}${BX.br}${C.reset}`; }
420
+ function sepLine(w) { return `${C.gray}${BX.sl}${BX.h.repeat(w - 2)}${BX.sr}${C.reset}`; }
421
+ function row(content, w) { return `${C.gray}${BX.v}${C.reset} ${fit(content, w - 4)} ${C.gray}${BX.v}${C.reset}`; }
422
+
423
+ export function renderFrame(data, { width, height, tab }) {
424
+ const w = Math.max(64, Math.min(120, width || 100));
425
+ const h = Math.max(20, Math.min(32, height || 28));
426
+ const cw = w - 4;
427
+
428
+ const status = data.pid
429
+ ? `${C.green}●${C.reset} ${C.dim}running · pid ${data.pid}${C.reset}`
430
+ : `${C.red}○${C.reset} ${C.dim}daemon not running${C.reset}`;
431
+ const tabBar = TABS.map((t, i) => (i === tab
432
+ ? `${C.bold}${C.cyan}‹ ${t.label} ›${C.reset}`
433
+ : `${C.dim}${t.label}${C.reset}`)).join(' ');
434
+ const footer = `${C.dim}1-7${C.reset} jump ${C.gray}·${C.reset} ${C.dim}←→ h l${C.reset} tab ${C.gray}·${C.reset} ${C.dim}r${C.reset} refresh ${C.gray}·${C.reset} ${C.dim}q${C.reset} quit`;
435
+
436
+ // Header breathes: blank line, centred tab bar, blank line, then the rule —
437
+ // so the top reads as a header band with air, not a squished edge strip.
438
+ const B = Math.max(1, h - 8); // header(border+blank+tabs+blank+rule=5) + footer(rule+keys+border=3)
439
+ const bodyContent = TAB_RENDERERS[tab](data, cw);
440
+ const body = bodyContent.slice(0, B);
441
+ while (body.length < B) body.push('');
442
+
443
+ const lines = [
444
+ topBorder(w, 'claude-rpc', status),
445
+ row('', w),
446
+ row(center(tabBar, cw), w),
447
+ row('', w),
448
+ sepLine(w),
449
+ ];
450
+ for (const b of body) lines.push(row(b, w));
451
+ lines.push(sepLine(w), row(center(footer, cw), w), bottomBorder(w));
452
+ return lines.join('\n');
453
+ }
343
454
 
344
- // Pad body so footer sits at bottom of viewport.
345
- const available = h - header.length - footer.length - 1; // -1 for safety
346
- while (body.length < available) body.push('');
347
- if (body.length > available) body.length = available;
455
+ // ── Live render / input / lifecycle ───────────────────────────────────────────
456
+ // Frame size + centring margins. The frame caps out (max 120×32) and centres
457
+ // in the terminal, so a big full-screen window reads as a centred dashboard with
458
+ // breathing room instead of a frame glued to the top-left edge.
459
+ function frameLayout(termW, termH) {
460
+ const w = Math.max(64, Math.min(120, termW - 4));
461
+ const h = Math.max(20, Math.min(28, termH - 2));
462
+ return { w, h, left: Math.max(0, (termW - w) >> 1), top: Math.max(0, (termH - h) >> 1) };
463
+ }
348
464
 
349
- process.stdout.write(CLEAR + [...header, ...body, ...footer].join('\n'));
465
+ function render() {
466
+ const termW = process.stdout.columns || 100;
467
+ const termH = process.stdout.rows || 30;
468
+ const { w, h, left, top } = frameLayout(termW, termH);
469
+ const pad = ' '.repeat(left);
470
+ const frame = renderFrame(loadSnapshot(), { width: w, height: h, tab: currentTab })
471
+ .split('\n').map((l) => pad + l).join('\n');
472
+ process.stdout.write(CLEAR + '\n'.repeat(top) + frame);
350
473
  }
351
474
 
352
- // ── Input / lifecycle ───────────────────────────────────────────────────────
353
475
  function cleanup() {
354
476
  if (exiting) return;
355
477
  exiting = true;
356
478
  if (refreshTimer) clearInterval(refreshTimer);
357
479
  try { process.stdin.setRawMode(false); } catch { /* not a tty (CI, pipe) — no-op */ }
358
480
  process.stdin.pause();
359
- process.stdout.write(SHOW_CURSOR + CLEAR + '\n');
481
+ process.stdout.write(SHOW_CURSOR + ALT_OFF);
360
482
  process.exit(0);
361
483
  }
362
484
 
@@ -364,18 +486,9 @@ function handleKey(buf) {
364
486
  const key = buf.toString();
365
487
  if (key === '\x03' || key.toLowerCase() === 'q') return cleanup();
366
488
  if (key.toLowerCase() === 'r') return render();
367
- if (key.length === 1 && key >= '1' && key <= String(TABS.length)) {
368
- currentTab = Number(key) - 1;
369
- return render();
370
- }
371
- if (key === '\x1b[C' || key === '\x1b[B' || key === '\t' || key === 'l' || key === 'j') {
372
- currentTab = (currentTab + 1) % TABS.length;
373
- return render();
374
- }
375
- if (key === '\x1b[D' || key === '\x1b[A' || key === 'h' || key === 'k') {
376
- currentTab = (currentTab - 1 + TABS.length) % TABS.length;
377
- return render();
378
- }
489
+ if (key.length === 1 && key >= '1' && key <= String(TABS.length)) { currentTab = Number(key) - 1; return render(); }
490
+ if (key === '\x1b[C' || key === '\x1b[B' || key === '\t' || key === 'l' || key === 'j') { currentTab = (currentTab + 1) % TABS.length; return render(); }
491
+ if (key === '\x1b[D' || key === '\x1b[A' || key === 'h' || key === 'k') { currentTab = (currentTab - 1 + TABS.length) % TABS.length; return render(); }
379
492
  }
380
493
 
381
494
  export function startTui() {
@@ -383,8 +496,7 @@ export function startTui() {
383
496
  console.error('claude-rpc status: not a TTY. Use `claude-rpc status --dump` for plain output.');
384
497
  process.exit(1);
385
498
  }
386
- process.stdout.write(HIDE_CURSOR);
387
-
499
+ process.stdout.write(ALT_ON + HIDE_CURSOR);
388
500
  try { process.stdin.setRawMode(true); } catch { /* not a tty — TUI will print once and exit */ }
389
501
  process.stdin.resume();
390
502
  process.stdin.on('data', handleKey);
@@ -394,7 +506,7 @@ export function startTui() {
394
506
  process.on('SIGHUP', cleanup);
395
507
  process.on('exit', () => {
396
508
  try { process.stdin.setRawMode(false); } catch { /* not a tty (CI, pipe) — no-op */ }
397
- process.stdout.write(SHOW_CURSOR);
509
+ process.stdout.write(SHOW_CURSOR + ALT_OFF);
398
510
  });
399
511
  process.stdout.on('resize', () => render());
400
512
 
package/src/version.js CHANGED
@@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { ROOT } from './paths.js';
13
13
 
14
- const BAKED = '0.20.2';
14
+ const BAKED = '0.20.4';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {