claude-rpc 0.3.8
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/LICENSE +21 -0
- package/README.md +300 -0
- package/bin/claude-rpc.js +2 -0
- package/config.example.json +67 -0
- package/package.json +53 -0
- package/src/badge.js +144 -0
- package/src/cli.js +765 -0
- package/src/daemon.js +324 -0
- package/src/default-config.js +91 -0
- package/src/format.js +657 -0
- package/src/git.js +74 -0
- package/src/hook.js +169 -0
- package/src/insights.js +138 -0
- package/src/install.js +280 -0
- package/src/languages.js +114 -0
- package/src/paths.js +59 -0
- package/src/pricing.js +73 -0
- package/src/scanner.js +721 -0
- package/src/server.js +1584 -0
- package/src/state.js +73 -0
- package/src/tui.js +420 -0
package/src/state.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
2
|
+
import { dirname, basename } from 'node:path';
|
|
3
|
+
import { STATE_PATH, STATE_DIR } from './paths.js';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_STATE = {
|
|
6
|
+
sessionStart: null,
|
|
7
|
+
lastActivity: null,
|
|
8
|
+
lastUserPrompt: null,
|
|
9
|
+
lastNotification: null,
|
|
10
|
+
status: 'idle',
|
|
11
|
+
currentTool: null,
|
|
12
|
+
currentFile: null,
|
|
13
|
+
model: 'claude',
|
|
14
|
+
cwd: process.cwd(),
|
|
15
|
+
messages: 0,
|
|
16
|
+
tools: 0,
|
|
17
|
+
filesOpened: [],
|
|
18
|
+
filesEdited: [],
|
|
19
|
+
filesRead: [],
|
|
20
|
+
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
21
|
+
toolBreakdown: {},
|
|
22
|
+
// Set true by the SessionEnd hook; cleared by any other hook event.
|
|
23
|
+
// When true, the daemon goes stale instantly instead of waiting on the
|
|
24
|
+
// staleSessionMin timeout — the cleanest "Claude is closed" signal we have.
|
|
25
|
+
claudeClosed: false,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function ensureDir() {
|
|
29
|
+
if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function readState() {
|
|
33
|
+
ensureDir();
|
|
34
|
+
if (!existsSync(STATE_PATH)) return { ...DEFAULT_STATE };
|
|
35
|
+
try {
|
|
36
|
+
const raw = readFileSync(STATE_PATH, 'utf8');
|
|
37
|
+
return { ...DEFAULT_STATE, ...JSON.parse(raw) };
|
|
38
|
+
} catch {
|
|
39
|
+
return { ...DEFAULT_STATE };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function writeState(next) {
|
|
44
|
+
ensureDir();
|
|
45
|
+
const tmp = STATE_PATH + '.tmp';
|
|
46
|
+
writeFileSync(tmp, JSON.stringify(next, null, 2));
|
|
47
|
+
renameSync(tmp, STATE_PATH);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function updateState(mutator) {
|
|
51
|
+
const current = readState();
|
|
52
|
+
const next = mutator({ ...current }) ?? current;
|
|
53
|
+
writeState(next);
|
|
54
|
+
return next;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function resetState(seed = {}) {
|
|
58
|
+
const fresh = { ...DEFAULT_STATE, sessionStart: Date.now(), lastActivity: Date.now(), ...seed };
|
|
59
|
+
writeState(fresh);
|
|
60
|
+
return fresh;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function pushUnique(arr, value, max = 50) {
|
|
64
|
+
if (!value) return arr;
|
|
65
|
+
const filtered = arr.filter((v) => v !== value);
|
|
66
|
+
filtered.unshift(value);
|
|
67
|
+
return filtered.slice(0, max);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function shortFile(path) {
|
|
71
|
+
if (!path) return null;
|
|
72
|
+
return basename(path);
|
|
73
|
+
}
|
package/src/tui.js
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
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.
|
|
6
|
+
|
|
7
|
+
import process from 'node:process';
|
|
8
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
9
|
+
import { readState } from './state.js';
|
|
10
|
+
import { readAggregate, findLiveSessions, dayKey, weekKey } from './scanner.js';
|
|
11
|
+
import { buildVars, applyIdle, humanProject } from './format.js';
|
|
12
|
+
import { CONFIG_PATH, PID_PATH } from './paths.js';
|
|
13
|
+
import { fmtCost } from './pricing.js';
|
|
14
|
+
import { generateInsights } from './insights.js';
|
|
15
|
+
|
|
16
|
+
// ── ANSI ────────────────────────────────────────────────────────────────────
|
|
17
|
+
const ESC = '\x1b[';
|
|
18
|
+
const RESET = ESC + '0m';
|
|
19
|
+
const CLEAR = ESC + '2J' + ESC + 'H';
|
|
20
|
+
const HIDE_CURSOR = ESC + '?25l';
|
|
21
|
+
const SHOW_CURSOR = ESC + '?25h';
|
|
22
|
+
|
|
23
|
+
const C = {
|
|
24
|
+
reset: RESET,
|
|
25
|
+
dim: ESC + '2m',
|
|
26
|
+
bold: ESC + '1m',
|
|
27
|
+
red: ESC + '31m',
|
|
28
|
+
green: ESC + '32m',
|
|
29
|
+
yellow: ESC + '33m',
|
|
30
|
+
magenta: ESC + '35m',
|
|
31
|
+
cyan: ESC + '36m',
|
|
32
|
+
gray: ESC + '90m',
|
|
33
|
+
};
|
|
34
|
+
const ansiRe = /\x1b\[[0-9;]*m/g;
|
|
35
|
+
const visLen = (s) => String(s).replace(ansiRe, '').length;
|
|
36
|
+
|
|
37
|
+
// ── Tabs ────────────────────────────────────────────────────────────────────
|
|
38
|
+
const TABS = [
|
|
39
|
+
{ key: 'now', label: 'Now' },
|
|
40
|
+
{ key: 'today', label: 'Today' },
|
|
41
|
+
{ key: 'week', label: 'Week' },
|
|
42
|
+
{ key: 'streak', label: 'Streak' },
|
|
43
|
+
{ key: 'lifetime', label: 'Lifetime' },
|
|
44
|
+
{ key: 'cost', label: 'Cost' },
|
|
45
|
+
{ key: 'code', label: 'Code' },
|
|
46
|
+
];
|
|
47
|
+
let currentTab = 0;
|
|
48
|
+
let refreshTimer = null;
|
|
49
|
+
let exiting = false;
|
|
50
|
+
|
|
51
|
+
// ── Data ────────────────────────────────────────────────────────────────────
|
|
52
|
+
function loadSnapshot() {
|
|
53
|
+
let state = readState();
|
|
54
|
+
state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
|
|
55
|
+
const config = existsSync(CONFIG_PATH)
|
|
56
|
+
? (() => { try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); } catch { return {}; } })()
|
|
57
|
+
: {};
|
|
58
|
+
state = applyIdle(state, config);
|
|
59
|
+
const aggregate = readAggregate() || {};
|
|
60
|
+
const vars = buildVars(state, config, aggregate);
|
|
61
|
+
return { state, config, aggregate, vars };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function daemonPid() {
|
|
65
|
+
if (!existsSync(PID_PATH)) return null;
|
|
66
|
+
try {
|
|
67
|
+
const pid = Number(readFileSync(PID_PATH, 'utf8'));
|
|
68
|
+
process.kill(pid, 0);
|
|
69
|
+
return pid;
|
|
70
|
+
} catch { return null; }
|
|
71
|
+
}
|
|
72
|
+
|
|
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
|
+
function bar(value, max, w = 16) {
|
|
79
|
+
if (!max || max <= 0) return ''.padEnd(w);
|
|
80
|
+
const filled = Math.max(0, Math.min(w, Math.round((value / max) * w)));
|
|
81
|
+
return '█'.repeat(filled) + ' '.repeat(w - filled);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Pad a (possibly ANSI-colored) line with spaces so its VISIBLE width hits n.
|
|
85
|
+
function padR(s, n) {
|
|
86
|
+
const len = visLen(s);
|
|
87
|
+
return len >= n ? s : s + ' '.repeat(n - len);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Wrap each content line with a 2-space left margin.
|
|
91
|
+
function indent(rows) { return rows.map((r) => ' ' + r); }
|
|
92
|
+
|
|
93
|
+
// ── Header / footer ─────────────────────────────────────────────────────────
|
|
94
|
+
function drawHeader(w) {
|
|
95
|
+
const pid = daemonPid();
|
|
96
|
+
const status = pid
|
|
97
|
+
? `${C.green}● running${C.reset} ${C.dim}pid ${pid}${C.reset}`
|
|
98
|
+
: `${C.red}○ not running${C.reset}`;
|
|
99
|
+
|
|
100
|
+
const title = `${C.bold}Claude RPC${C.reset}`;
|
|
101
|
+
// First line: title left, status right
|
|
102
|
+
const left = title;
|
|
103
|
+
const right = status;
|
|
104
|
+
const innerWidth = w - 4;
|
|
105
|
+
const padCount = Math.max(1, innerWidth - visLen(left) - visLen(right));
|
|
106
|
+
const line1 = ' ' + left + ' '.repeat(padCount) + right;
|
|
107
|
+
|
|
108
|
+
// Tabs line
|
|
109
|
+
const tabBits = TABS.map((t, i) => {
|
|
110
|
+
if (i === currentTab) return `${C.bold}${C.cyan}${t.label}${C.reset}`;
|
|
111
|
+
return `${C.dim}${t.label}${C.reset}`;
|
|
112
|
+
});
|
|
113
|
+
const tabs = tabBits.join(` ${C.gray}·${C.reset} `);
|
|
114
|
+
const line2 = ' ' + tabs;
|
|
115
|
+
|
|
116
|
+
return [line1, line2, ' ' + rule(w)];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function drawFooter(w) {
|
|
120
|
+
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}`;
|
|
121
|
+
return [' ' + rule(w), ' ' + keys];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Tab renderers ───────────────────────────────────────────────────────────
|
|
125
|
+
function tabNow(_, data) {
|
|
126
|
+
const v = data.vars;
|
|
127
|
+
const out = [];
|
|
128
|
+
out.push('');
|
|
129
|
+
out.push(`${C.bold}${v.statusVerbose}${C.reset} ${C.dim}in${C.reset} ${C.bold}${v.project}${C.reset}`);
|
|
130
|
+
out.push(`${C.dim}${v.modelPretty}${C.reset} ${C.cyan}${v.duration}${C.reset} ${C.dim}elapsed${C.reset}`);
|
|
131
|
+
out.push('');
|
|
132
|
+
out.push(`${C.dim}prompts${C.reset} ${C.yellow}${String(v.messages).padStart(8)}${C.reset}`);
|
|
133
|
+
out.push(`${C.dim}tool calls${C.reset} ${C.yellow}${String(v.tools).padStart(8)}${C.reset}`);
|
|
134
|
+
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}`);
|
|
135
|
+
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}`);
|
|
136
|
+
if (v.currentTool) {
|
|
137
|
+
out.push('');
|
|
138
|
+
out.push(`${C.dim}current${C.reset} ${C.bold}${v.currentToolPretty}${C.reset} ${C.cyan}${v.currentFilePretty}${C.reset}`);
|
|
139
|
+
}
|
|
140
|
+
if (v.concurrent > 1) {
|
|
141
|
+
out.push('');
|
|
142
|
+
out.push(`${C.magenta}${v.concurrentLabel}${C.reset} ${C.dim}— ${v.concurrentListPretty}${C.reset}`);
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function tabToday(_, data) {
|
|
148
|
+
const v = data.vars;
|
|
149
|
+
const agg = data.aggregate;
|
|
150
|
+
const out = [];
|
|
151
|
+
out.push('');
|
|
152
|
+
out.push(`${C.bold}Today${C.reset} ${C.green}${v.todayHours}${C.reset} ${C.dim}active${C.reset}`);
|
|
153
|
+
out.push('');
|
|
154
|
+
out.push(`${C.dim}prompts${C.reset} ${C.yellow}${String(v.todayPrompts).padStart(8)}${C.reset}`);
|
|
155
|
+
out.push(`${C.dim}tool calls${C.reset} ${C.yellow}${v.todayToolsFmt.padStart(8)}${C.reset}`);
|
|
156
|
+
out.push(`${C.dim}sessions${C.reset} ${C.cyan}${String(v.todaySessions).padStart(8)}${C.reset}`);
|
|
157
|
+
out.push(`${C.dim}tokens${C.reset} ${C.bold}${v.todayTokensFmt.padStart(8)}${C.reset} ${C.dim}grand total${C.reset}`);
|
|
158
|
+
if (agg.byHour && Object.keys(agg.byHour).length) {
|
|
159
|
+
out.push('');
|
|
160
|
+
out.push(`${C.dim}when you code · hour of day${C.reset}`);
|
|
161
|
+
const heightChars = ' ▁▂▃▄▅▆▇█';
|
|
162
|
+
let max = 0;
|
|
163
|
+
for (let h = 0; h < 24; h++) max = Math.max(max, agg.byHour?.[h]?.activeMs || 0);
|
|
164
|
+
if (max > 0) {
|
|
165
|
+
const bars = [];
|
|
166
|
+
for (let h = 0; h < 24; h++) {
|
|
167
|
+
const ms = agg.byHour?.[h]?.activeMs || 0;
|
|
168
|
+
const idx = ms > 0 ? Math.max(1, Math.min(8, Math.round((ms / max) * 8))) : 0;
|
|
169
|
+
const ch = heightChars[idx];
|
|
170
|
+
bars.push(h === v.peakHourNum ? `${C.magenta}${C.bold}${ch}${C.reset}` : `${C.green}${ch}${C.reset}`);
|
|
171
|
+
}
|
|
172
|
+
out.push(bars.join(''));
|
|
173
|
+
out.push(`${C.dim}00 03 06 09 12 15 18 21${C.reset}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function tabWeek(_, data) {
|
|
180
|
+
const v = data.vars;
|
|
181
|
+
const agg = data.aggregate;
|
|
182
|
+
const out = [];
|
|
183
|
+
out.push('');
|
|
184
|
+
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}`);
|
|
185
|
+
out.push('');
|
|
186
|
+
out.push(`${C.dim}prompts${C.reset} ${C.yellow}${String(v.weekPrompts).padStart(8)}${C.reset}`);
|
|
187
|
+
out.push(`${C.dim}tool calls${C.reset} ${C.yellow}${v.weekToolsFmt.padStart(8)}${C.reset}`);
|
|
188
|
+
out.push(`${C.dim}sessions${C.reset} ${C.cyan}${String(v.weekSessions).padStart(8)}${C.reset}`);
|
|
189
|
+
out.push(`${C.dim}tokens${C.reset} ${C.bold}${v.weekTokensFmt.padStart(8)}${C.reset}`);
|
|
190
|
+
if (agg.byDay) {
|
|
191
|
+
out.push('');
|
|
192
|
+
out.push(`${C.dim}daily breakdown${C.reset}`);
|
|
193
|
+
const now = new Date();
|
|
194
|
+
now.setHours(0, 0, 0, 0);
|
|
195
|
+
const monday = new Date(now);
|
|
196
|
+
monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7));
|
|
197
|
+
const days = [];
|
|
198
|
+
for (let i = 0; i < 7; i++) {
|
|
199
|
+
const d = new Date(monday);
|
|
200
|
+
d.setDate(d.getDate() + i);
|
|
201
|
+
const k = dayKey(d.getTime());
|
|
202
|
+
const dayName = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()];
|
|
203
|
+
const ms = agg.byDay[k]?.activeMs || 0;
|
|
204
|
+
const isFuture = d > now;
|
|
205
|
+
const isToday = k === dayKey(now.getTime());
|
|
206
|
+
days.push({ label: `${dayName} ${k.slice(5)}`, ms, isFuture, isToday });
|
|
207
|
+
}
|
|
208
|
+
const maxMs = Math.max(...days.map((d) => d.ms)) || 1;
|
|
209
|
+
for (const { label, ms, isFuture, isToday } of days) {
|
|
210
|
+
if (isFuture) {
|
|
211
|
+
out.push(`${C.dim}${label.padEnd(11)}${' '.repeat(18)} —${C.reset}`);
|
|
212
|
+
} else {
|
|
213
|
+
const h = ms / 3_600_000;
|
|
214
|
+
const hStr = h < 1 ? `${Math.round(h * 60)}m` : (h < 10 ? `${h.toFixed(1)}h` : `${Math.round(h)}h`);
|
|
215
|
+
const prefix = isToday ? C.bold : '';
|
|
216
|
+
out.push(`${prefix}${label.padEnd(11)}${C.reset} ${C.magenta}${bar(ms, maxMs, 18)}${C.reset} ${C.cyan}${hStr.padStart(5)}${C.reset}${isToday ? ` ${C.dim}← today${C.reset}` : ''}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function tabStreak(_, data) {
|
|
224
|
+
const v = data.vars;
|
|
225
|
+
const out = [];
|
|
226
|
+
out.push('');
|
|
227
|
+
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}`);
|
|
228
|
+
out.push('');
|
|
229
|
+
out.push(`${C.dim}days on Claude${C.reset} ${C.cyan}${String(v.daysSinceFirst).padStart(8)}${C.reset}`);
|
|
230
|
+
if (v.bestDayDate) {
|
|
231
|
+
out.push('');
|
|
232
|
+
out.push(`${C.dim}best day${C.reset} ${C.bold}${v.bestDayHours}${C.reset} ${C.dim}on ${v.bestDayDate}${C.reset}`);
|
|
233
|
+
out.push(` ${C.dim}${v.bestDayPrompts} prompts · ${v.bestDayTokensFmt} tokens${C.reset}`);
|
|
234
|
+
}
|
|
235
|
+
if (v.peakHour) {
|
|
236
|
+
out.push('');
|
|
237
|
+
out.push(`${C.dim}peak hour${C.reset} ${C.bold}${v.peakHour}${C.reset} ${C.dim}${v.peakHourActiveLabel}${C.reset}`);
|
|
238
|
+
}
|
|
239
|
+
if (v.topEditedFile) {
|
|
240
|
+
out.push('');
|
|
241
|
+
out.push(`${C.dim}hotspot${C.reset} ${C.bold}${v.topEditedFile}${C.reset} ${C.dim}${v.topEditedCountLabel}${C.reset}`);
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function tabLifetime(_, data) {
|
|
247
|
+
const v = data.vars;
|
|
248
|
+
const agg = data.aggregate;
|
|
249
|
+
const out = [];
|
|
250
|
+
out.push('');
|
|
251
|
+
out.push(`${C.bold}All-time${C.reset} ${C.green}${v.allHours}${C.reset} ${C.dim}active · ${v.allWallHours} wall${C.reset}`);
|
|
252
|
+
out.push('');
|
|
253
|
+
out.push(`${C.dim}sessions${C.reset} ${C.yellow}${String(v.allSessions).padStart(8)}${C.reset} ${C.dim}(+${v.allSubagentRuns} subagent runs)${C.reset}`);
|
|
254
|
+
out.push(`${C.dim}prompts${C.reset} ${C.yellow}${v.allMessagesFmt.padStart(8)}${C.reset}`);
|
|
255
|
+
out.push(`${C.dim}tool calls${C.reset} ${C.yellow}${v.allToolsFmt.padStart(8)}${C.reset}`);
|
|
256
|
+
out.push(`${C.dim}files${C.reset} ${C.cyan}${v.allFilesFmt.padStart(8)}${C.reset}`);
|
|
257
|
+
out.push(`${C.dim}tokens${C.reset} ${C.bold}${C.magenta}${v.allTokensFmt.padStart(8)}${C.reset} ${C.dim}grand total${C.reset}`);
|
|
258
|
+
out.push(` ${C.dim}${v.allInputTokens} in · ${v.allOutputTokens} out · ${v.allCacheTokens} cache${C.reset}`);
|
|
259
|
+
const projects = agg.projects || {};
|
|
260
|
+
const top = Object.entries(projects)
|
|
261
|
+
.sort((a, b) => b[1].activeMs - a[1].activeMs)
|
|
262
|
+
.slice(0, 4);
|
|
263
|
+
if (top.length) {
|
|
264
|
+
out.push('');
|
|
265
|
+
out.push(`${C.dim}top projects${C.reset}`);
|
|
266
|
+
const maxMs = top[0][1].activeMs;
|
|
267
|
+
for (const [name, p] of top) {
|
|
268
|
+
const h = p.activeMs / 3_600_000;
|
|
269
|
+
const hStr = h < 1 ? `${Math.round(h * 60)}m` : (h < 10 ? `${h.toFixed(1)}h` : `${Math.round(h)}h`);
|
|
270
|
+
const pretty = humanProject(name).slice(0, 18).padEnd(20);
|
|
271
|
+
out.push(`${pretty} ${C.magenta}${bar(p.activeMs, maxMs, 16)}${C.reset} ${C.cyan}${hStr.padStart(5)}${C.reset}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function tabCost(_, data) {
|
|
278
|
+
const v = data.vars;
|
|
279
|
+
const agg = data.aggregate;
|
|
280
|
+
const out = [];
|
|
281
|
+
out.push('');
|
|
282
|
+
out.push(`${C.bold}Estimated cost${C.reset} ${C.green}${v.allCostFmt}${C.reset} ${C.dim}all-time · approximate${C.reset}`);
|
|
283
|
+
out.push('');
|
|
284
|
+
out.push(`${C.dim}today${C.reset} ${C.cyan}${v.todayCostFmt.padStart(10)}${C.reset}`);
|
|
285
|
+
out.push(`${C.dim}this week${C.reset} ${C.cyan}${v.weekCostFmt.padStart(10)}${C.reset}`);
|
|
286
|
+
out.push(`${C.dim}this project${C.reset} ${C.cyan}${v.projectCostFmt.padStart(10)}${C.reset}`);
|
|
287
|
+
|
|
288
|
+
const byModel = Object.entries(agg.costByModel || {}).sort((a, b) => b[1] - a[1]).slice(0, 6);
|
|
289
|
+
if (byModel.length) {
|
|
290
|
+
out.push('');
|
|
291
|
+
out.push(`${C.dim}by model${C.reset}`);
|
|
292
|
+
const max = byModel[0][1];
|
|
293
|
+
for (const [m, val] of byModel) {
|
|
294
|
+
const pretty = String(m).padEnd(20);
|
|
295
|
+
out.push(`${pretty} ${C.magenta}${bar(val, max, 18)}${C.reset} ${C.cyan}${fmtCost(val).padStart(8)}${C.reset}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Month-to-date forecast.
|
|
300
|
+
const now = new Date();
|
|
301
|
+
const ym = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
|
302
|
+
let mtd = 0;
|
|
303
|
+
for (const [k, day] of Object.entries(agg.byDay || {})) {
|
|
304
|
+
if (k.startsWith(ym)) mtd += day.cost || 0;
|
|
305
|
+
}
|
|
306
|
+
if (mtd > 0) {
|
|
307
|
+
const daysIn = now.getDate();
|
|
308
|
+
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
|
309
|
+
const forecast = (mtd / daysIn) * daysInMonth;
|
|
310
|
+
out.push('');
|
|
311
|
+
out.push(`${C.dim}month-to-date${C.reset} ${C.cyan}${fmtCost(mtd).padStart(8)}${C.reset}`);
|
|
312
|
+
out.push(`${C.dim}forecast${C.reset} ${C.bold}${fmtCost(forecast).padStart(8)}${C.reset}`);
|
|
313
|
+
}
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function tabCode(_, data) {
|
|
318
|
+
const v = data.vars;
|
|
319
|
+
const agg = data.aggregate;
|
|
320
|
+
const out = [];
|
|
321
|
+
out.push('');
|
|
322
|
+
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}`);
|
|
323
|
+
out.push('');
|
|
324
|
+
out.push(`${C.dim}today${C.reset} ${C.green}+${v.todayLinesAddedFmt}${C.reset} ${C.dim}(${v.todayLinesNetFmt} net)${C.reset}`);
|
|
325
|
+
out.push(`${C.dim}this week${C.reset} ${C.green}+${v.weekLinesAddedFmt}${C.reset} ${C.dim}(${v.weekLinesNetFmt} net)${C.reset}`);
|
|
326
|
+
|
|
327
|
+
const langs = Object.entries(agg.languages || {}).sort((a, b) => (b[1].edits || 0) - (a[1].edits || 0)).slice(0, 6);
|
|
328
|
+
if (langs.length) {
|
|
329
|
+
out.push('');
|
|
330
|
+
out.push(`${C.dim}languages · by edits${C.reset}`);
|
|
331
|
+
const max = langs[0][1].edits || 1;
|
|
332
|
+
for (const [name, l] of langs) {
|
|
333
|
+
const pretty = name.slice(0, 18).padEnd(20);
|
|
334
|
+
out.push(`${pretty} ${C.magenta}${bar(l.edits, max, 18)}${C.reset} ${C.cyan}${String(l.edits).padStart(6)}${C.reset}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const bash = Object.entries(agg.bashCommands || {}).sort((a, b) => b[1] - a[1]).slice(0, 4);
|
|
339
|
+
if (bash.length) {
|
|
340
|
+
out.push('');
|
|
341
|
+
out.push(`${C.dim}top bash${C.reset}`);
|
|
342
|
+
for (const [k, n] of bash) {
|
|
343
|
+
out.push(`${k.padEnd(20)} ${C.cyan}${String(n).padStart(6)}${C.reset}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return out;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const TAB_RENDERERS = [tabNow, tabToday, tabWeek, tabStreak, tabLifetime, tabCost, tabCode];
|
|
350
|
+
|
|
351
|
+
// ── Render ──────────────────────────────────────────────────────────────────
|
|
352
|
+
function render() {
|
|
353
|
+
const w = width();
|
|
354
|
+
const h = height();
|
|
355
|
+
const data = loadSnapshot();
|
|
356
|
+
|
|
357
|
+
const header = drawHeader(w);
|
|
358
|
+
const footer = drawFooter(w);
|
|
359
|
+
const body = indent(TAB_RENDERERS[currentTab](w, data));
|
|
360
|
+
|
|
361
|
+
// Pad body so footer sits at bottom of viewport.
|
|
362
|
+
const available = h - header.length - footer.length - 1; // -1 for safety
|
|
363
|
+
while (body.length < available) body.push('');
|
|
364
|
+
if (body.length > available) body.length = available;
|
|
365
|
+
|
|
366
|
+
process.stdout.write(CLEAR + [...header, ...body, ...footer].join('\n'));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── Input / lifecycle ───────────────────────────────────────────────────────
|
|
370
|
+
function cleanup() {
|
|
371
|
+
if (exiting) return;
|
|
372
|
+
exiting = true;
|
|
373
|
+
if (refreshTimer) clearInterval(refreshTimer);
|
|
374
|
+
try { process.stdin.setRawMode(false); } catch {}
|
|
375
|
+
process.stdin.pause();
|
|
376
|
+
process.stdout.write(SHOW_CURSOR + CLEAR + '\n');
|
|
377
|
+
process.exit(0);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function handleKey(buf) {
|
|
381
|
+
const key = buf.toString();
|
|
382
|
+
if (key === '\x03' || key.toLowerCase() === 'q') return cleanup();
|
|
383
|
+
if (key.toLowerCase() === 'r') return render();
|
|
384
|
+
if (key.length === 1 && key >= '1' && key <= String(TABS.length)) {
|
|
385
|
+
currentTab = Number(key) - 1;
|
|
386
|
+
return render();
|
|
387
|
+
}
|
|
388
|
+
if (key === '\x1b[C' || key === '\x1b[B' || key === '\t' || key === 'l' || key === 'j') {
|
|
389
|
+
currentTab = (currentTab + 1) % TABS.length;
|
|
390
|
+
return render();
|
|
391
|
+
}
|
|
392
|
+
if (key === '\x1b[D' || key === '\x1b[A' || key === 'h' || key === 'k') {
|
|
393
|
+
currentTab = (currentTab - 1 + TABS.length) % TABS.length;
|
|
394
|
+
return render();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function startTui() {
|
|
399
|
+
if (!process.stdout.isTTY) {
|
|
400
|
+
console.error('claude-rpc status: not a TTY. Use `claude-rpc status --dump` for plain output.');
|
|
401
|
+
process.exit(1);
|
|
402
|
+
}
|
|
403
|
+
process.stdout.write(HIDE_CURSOR);
|
|
404
|
+
|
|
405
|
+
try { process.stdin.setRawMode(true); } catch {}
|
|
406
|
+
process.stdin.resume();
|
|
407
|
+
process.stdin.on('data', handleKey);
|
|
408
|
+
|
|
409
|
+
process.on('SIGINT', cleanup);
|
|
410
|
+
process.on('SIGTERM', cleanup);
|
|
411
|
+
process.on('SIGHUP', cleanup);
|
|
412
|
+
process.on('exit', () => {
|
|
413
|
+
try { process.stdin.setRawMode(false); } catch {}
|
|
414
|
+
process.stdout.write(SHOW_CURSOR);
|
|
415
|
+
});
|
|
416
|
+
process.stdout.on('resize', () => render());
|
|
417
|
+
|
|
418
|
+
refreshTimer = setInterval(render, 3000);
|
|
419
|
+
render();
|
|
420
|
+
}
|