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 +1 -1
- package/src/cli.js +11 -21
- package/src/format.js +3 -1
- package/src/scanner.js +15 -11
- package/src/tui.js +387 -275
- package/src/version.js +1 -1
package/package.json
CHANGED
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,
|
|
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 =
|
|
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 (
|
|
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 =
|
|
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 (!
|
|
179
|
+
if (!daemonAlive()) { startDaemon(); return; }
|
|
190
180
|
if (Date.now() >= deadline) {
|
|
191
|
-
const pid =
|
|
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 =
|
|
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 =
|
|
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 (!
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
const
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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,
|
|
11
|
-
import {
|
|
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:
|
|
27
|
-
dim:
|
|
28
|
-
bold:
|
|
29
|
-
red:
|
|
30
|
-
green:
|
|
31
|
-
yellow:
|
|
32
|
-
magenta:
|
|
33
|
-
cyan:
|
|
34
|
-
gray:
|
|
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
|
-
// ──
|
|
54
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
function
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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(
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
238
|
+
return [...head, ...columns([session, panel('live', liveBody, rw)])];
|
|
142
239
|
}
|
|
143
240
|
|
|
144
|
-
function tabToday(
|
|
241
|
+
function tabToday(data, cw) {
|
|
145
242
|
const v = data.vars;
|
|
146
243
|
const agg = data.aggregate;
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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(
|
|
177
|
-
const v = data.vars;
|
|
282
|
+
function tabWeek(data, cw) {
|
|
178
283
|
const agg = data.aggregate;
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
return
|
|
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(
|
|
311
|
+
function tabStreak(data, cw) {
|
|
207
312
|
const v = data.vars;
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}
|
|
218
|
-
if (v.peakHour) {
|
|
219
|
-
|
|
220
|
-
|
|
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(
|
|
329
|
+
function tabLifetime(data, cw) {
|
|
230
330
|
const v = data.vars;
|
|
231
331
|
const agg = data.aggregate;
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
.
|
|
246
|
-
|
|
247
|
-
|
|
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(
|
|
350
|
+
function tabCost(data, cw) {
|
|
261
351
|
const v = data.vars;
|
|
262
352
|
const agg = data.aggregate;
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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(
|
|
386
|
+
function tabCode(data, cw) {
|
|
301
387
|
const v = data.vars;
|
|
302
388
|
const agg = data.aggregate;
|
|
303
|
-
const
|
|
304
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
// ──
|
|
335
|
-
function
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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 +
|
|
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
|
-
|
|
369
|
-
|
|
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
|
|