claude-rpc 0.15.4 → 0.15.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli.js +123 -43
- package/src/nudge.js +25 -0
- package/src/tui.js +9 -6
- package/src/ui.js +62 -0
- package/src/version.js +1 -1
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -18,7 +18,7 @@ import { runHookCli } from './hook.js';
|
|
|
18
18
|
import { install as runInstall, uninstall as runUninstall, isInstalled, migrateConfig, installHooks, ensureCanonicalExe, installMcp, uninstallMcp, mcpServerCommand, setupOutro } from './install.js';
|
|
19
19
|
import { startTui } from './tui.js';
|
|
20
20
|
import { generateInsights } from './insights.js';
|
|
21
|
-
import { maybeNudge } from './nudge.js';
|
|
21
|
+
import { maybeNudge, pickTodayMilestone } from './nudge.js';
|
|
22
22
|
import { badgeSvg } from './badge.js';
|
|
23
23
|
import { fmtCost } from './pricing.js';
|
|
24
24
|
import { addPrivateCwd, removePrivateCwd, listPrivateCwds, resolveVisibility } from './privacy.js';
|
|
@@ -26,10 +26,10 @@ import { parseDuration, setPause, clearPause, pauseUntil } from './pause.js';
|
|
|
26
26
|
import { loadConfig, hasUserConfig } from './config.js';
|
|
27
27
|
import * as lb from './leaderboard.js';
|
|
28
28
|
import { VERSION } from './version.js';
|
|
29
|
-
import { fail, tailLines, EX_USER_ERROR, EX_BAD_STATE, EX_SYS_ERROR } from './ui.js';
|
|
29
|
+
import { fail, tailLines, heat, sparkline, fmtDelta, topPercentile, EX_USER_ERROR, EX_BAD_STATE, EX_SYS_ERROR } from './ui.js';
|
|
30
30
|
import { randomUUID } from 'node:crypto';
|
|
31
31
|
import { createInterface } from 'node:readline';
|
|
32
|
-
import { basename } from 'node:path';
|
|
32
|
+
import { basename, join } from 'node:path';
|
|
33
33
|
|
|
34
34
|
const cmd = process.argv[2];
|
|
35
35
|
|
|
@@ -144,24 +144,24 @@ function shortPath(p) {
|
|
|
144
144
|
return home && p.startsWith(home) ? '~' + p.slice(home.length) : p;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
// 24-bar histogram of hour-of-day activity
|
|
147
|
+
// 24-bar histogram of hour-of-day activity, heat-graded per hour — the
|
|
148
|
+
// peak hour literally glows (bold rust) instead of relying on a color swap.
|
|
148
149
|
function renderHourHistogram(byHour, opts = {}) {
|
|
149
150
|
const heightChars = ' ▁▂▃▄▅▆▇█';
|
|
150
151
|
let max = 0;
|
|
151
152
|
for (let h = 0; h < 24; h++) max = Math.max(max, byHour?.[h]?.activeMs || 0);
|
|
152
153
|
if (max <= 0) return [' (no hourly data yet)'];
|
|
153
|
-
const
|
|
154
|
+
const colored = [];
|
|
154
155
|
for (let h = 0; h < 24; h++) {
|
|
155
156
|
const ms = byHour?.[h]?.activeMs || 0;
|
|
156
157
|
const idx = ms > 0 ? Math.max(1, Math.min(8, Math.round((ms / max) * 8))) : 0;
|
|
157
|
-
|
|
158
|
+
const ch = heightChars[idx];
|
|
159
|
+
colored.push(h === opts.peakHour ? `${c.bold}${heat(1)}${ch}${c.reset}` : `${heat(ms / max)}${ch}${c.reset}`);
|
|
158
160
|
}
|
|
159
|
-
const peakH = opts.peakHour ?? bars.findIndex((b) => b === heightChars[Math.min(8, ...bars.map((_, i) => i).filter(() => true))]);
|
|
160
|
-
const colored = bars.map((ch, h) => h === opts.peakHour ? `${c.magenta}${c.bold}${ch}${c.reset}` : `${c.green}${ch}${c.reset}`).join('');
|
|
161
161
|
// Hour labels under every 3rd hour.
|
|
162
162
|
const labels = '00 03 06 09 12 15 18 21 ';
|
|
163
163
|
return [
|
|
164
|
-
` ${colored}`,
|
|
164
|
+
` ${colored.join('')}`,
|
|
165
165
|
` ${c.dim}${labels}${c.reset}`,
|
|
166
166
|
];
|
|
167
167
|
}
|
|
@@ -196,13 +196,14 @@ function renderHeatmap(byDay, days = 91) {
|
|
|
196
196
|
}
|
|
197
197
|
const cols = Math.ceil(cells.length / 7);
|
|
198
198
|
|
|
199
|
-
// Quantize: 0 / >0 / >15m / >1h / >3h
|
|
199
|
+
// Quantize: 0 / >0 / >15m / >1h / >3h — mapped onto the shared heat ramp
|
|
200
|
+
// so the heatmap, bars, and histograms all speak the same temperature.
|
|
200
201
|
const shade = (ms) => {
|
|
201
202
|
if (ms <= 0) return `${c.dim}·${c.reset}`;
|
|
202
|
-
if (ms < 15 * 60_000) return `${
|
|
203
|
-
if (ms < 60 * 60_000) return `${
|
|
204
|
-
if (ms < 3 * 3600_000) return `${
|
|
205
|
-
return `${c.
|
|
203
|
+
if (ms < 15 * 60_000) return `${heat(0.2)}▪${c.reset}`;
|
|
204
|
+
if (ms < 60 * 60_000) return `${heat(0.5)}▪${c.reset}`;
|
|
205
|
+
if (ms < 3 * 3600_000) return `${heat(0.8)}▪${c.reset}`;
|
|
206
|
+
return `${c.bold}${heat(1)}▪${c.reset}`;
|
|
206
207
|
};
|
|
207
208
|
|
|
208
209
|
// Month labels along the top (where the month changes within the visible window).
|
|
@@ -234,7 +235,7 @@ function renderHeatmap(byDay, days = 91) {
|
|
|
234
235
|
}
|
|
235
236
|
|
|
236
237
|
// Footer legend.
|
|
237
|
-
const legend = ` ${c.dim}less${c.reset} ${c.dim}·${c.reset} ${
|
|
238
|
+
const legend = ` ${c.dim}less${c.reset} ${c.dim}·${c.reset} ${heat(0.2)}▪${c.reset} ${heat(0.5)}▪${c.reset} ${heat(0.8)}▪${c.reset} ${c.bold}${heat(1)}▪${c.reset} ${c.dim}more${c.reset}`;
|
|
238
239
|
lines.push('');
|
|
239
240
|
lines.push(legend);
|
|
240
241
|
return lines;
|
|
@@ -244,11 +245,69 @@ function pair(label, value, valueColor = c.cyan) {
|
|
|
244
245
|
return `${c.dim}${label.padEnd(14)}${c.reset} ${valueColor}${value}${c.reset}`;
|
|
245
246
|
}
|
|
246
247
|
|
|
247
|
-
// ASCII bar for a value relative to max.
|
|
248
|
+
// ASCII bar for a value relative to max. Fill color is heat-graded by the
|
|
249
|
+
// ratio (calm green → amber → rust), so intensity reads at a glance; with
|
|
250
|
+
// colors off it degrades to the same monochrome bar as before.
|
|
248
251
|
function bar(val, max, width = 22) {
|
|
249
252
|
if (!max || max <= 0) return '';
|
|
250
253
|
const filled = Math.max(0, Math.min(width, Math.round((val / max) * width)));
|
|
251
|
-
return `${c.magenta}${'█'.repeat(filled)}${c.dim}${'░'.repeat(width - filled)}${c.reset}`;
|
|
254
|
+
return `${heat(val / max) || c.magenta}${'█'.repeat(filled)}${c.reset}${c.dim}${'░'.repeat(width - filled)}${c.reset}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// The last n calendar days of byDay entries (oldest → today), null where a
|
|
258
|
+
// day has no activity. Date-stepped rather than ms arithmetic so DST shifts
|
|
259
|
+
// can't skip or duplicate a day.
|
|
260
|
+
function lastDays(byDay, n) {
|
|
261
|
+
const out = [];
|
|
262
|
+
const d = new Date();
|
|
263
|
+
d.setHours(12, 0, 0, 0); // noon — immune to DST midnight edges
|
|
264
|
+
d.setDate(d.getDate() - (n - 1));
|
|
265
|
+
for (let i = 0; i < n; i++) {
|
|
266
|
+
out.push(byDay?.[dayKey(d.getTime())] || null);
|
|
267
|
+
d.setDate(d.getDate() + 1);
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const dayTokens = (d) => d
|
|
273
|
+
? (d.inputTokens || 0) + (d.outputTokens || 0) + (d.cacheReadTokens || 0) + (d.cacheWriteTokens || 0)
|
|
274
|
+
: 0;
|
|
275
|
+
|
|
276
|
+
// The `today` box, shared by `claude-rpc today` and `claude-rpc status`.
|
|
277
|
+
// Each headline metric carries its own context — a ▲/▼ against the trailing
|
|
278
|
+
// 7-day average (today excluded), a percentile callout on standout days, the
|
|
279
|
+
// day's cost, and a 14-day sparkline — so the numbers answer "is that a lot?"
|
|
280
|
+
// instead of making you remember.
|
|
281
|
+
function todayBoxLines(vars, aggregate) {
|
|
282
|
+
const byDay = aggregate?.byDay || {};
|
|
283
|
+
const series = lastDays(byDay, 15);
|
|
284
|
+
const today = series.at(-1);
|
|
285
|
+
const prior7 = series.slice(-8, -1);
|
|
286
|
+
const avg = (xs) => xs.reduce((a, b) => a + b, 0) / (xs.length || 1);
|
|
287
|
+
|
|
288
|
+
const dActive = fmtDelta(today?.activeMs || 0, avg(prior7.map((d) => d?.activeMs || 0)), { vs: 'vs 7-day avg' });
|
|
289
|
+
const dPrompts = fmtDelta(today?.userMessages || 0, avg(prior7.map((d) => d?.userMessages || 0)));
|
|
290
|
+
const dTokens = fmtDelta(dayTokens(today), avg(prior7.map(dayTokens)));
|
|
291
|
+
const pctLabel = topPercentile(Object.values(byDay).map((d) => d?.activeMs || 0), today?.activeMs || 0);
|
|
292
|
+
|
|
293
|
+
const lines = [
|
|
294
|
+
pair('active', `${c.bold}${c.green}${vars.todayHours}${c.reset}${dActive ? ` ${dActive}` : ''}${pctLabel ? ` ${c.magenta}· ${pctLabel}${c.reset}` : ''}`, ''),
|
|
295
|
+
pair('prompts', `${c.yellow}${vars.todayPrompts}${c.reset}${dPrompts ? ` ${dPrompts}` : ''}`, ''),
|
|
296
|
+
pair('tool calls', vars.todayToolsFmt, c.yellow),
|
|
297
|
+
pair('sessions', String(vars.todaySessions || 0)),
|
|
298
|
+
pair('tokens', `${c.bold}${vars.todayTokensFmt}${c.reset} ${c.dim}grand total${c.reset}${dTokens ? ` ${dTokens}` : ''}`, ''),
|
|
299
|
+
pair(' in+out', vars.todayTokensRealFmt, c.gray),
|
|
300
|
+
pair(' cache', vars.todayCacheTokensFmt, c.gray),
|
|
301
|
+
];
|
|
302
|
+
if (today?.cost) {
|
|
303
|
+
lines.push(pair('cost', `${c.green}≈${fmtCost(today.cost)}${c.reset} ${c.dim}+${(today.linesAdded || 0).toLocaleString()} lines today${c.reset}`, ''));
|
|
304
|
+
}
|
|
305
|
+
const spark = sparkline(series.slice(1).map((d) => d?.activeMs || 0));
|
|
306
|
+
if (spark) {
|
|
307
|
+
lines.push('');
|
|
308
|
+
lines.push(pair('last 14d', `${spark} ${c.dim}← today${c.reset}`, ''));
|
|
309
|
+
}
|
|
310
|
+
return lines;
|
|
252
311
|
}
|
|
253
312
|
|
|
254
313
|
function showStatus() {
|
|
@@ -295,15 +354,7 @@ function showStatus() {
|
|
|
295
354
|
}
|
|
296
355
|
|
|
297
356
|
if (aggregate) {
|
|
298
|
-
box('today',
|
|
299
|
-
pair('active', `${c.bold}${c.green}${vars.todayHours}${c.reset}`, ''),
|
|
300
|
-
pair('prompts', String(vars.todayPrompts), c.yellow),
|
|
301
|
-
pair('tool calls', vars.todayToolsFmt, c.yellow),
|
|
302
|
-
pair('sessions', String(vars.todaySessions || 0)),
|
|
303
|
-
pair('tokens', `${c.bold}${vars.todayTokensFmt}${c.reset} ${c.dim}grand total${c.reset}`, ''),
|
|
304
|
-
pair(' in+out', vars.todayTokensRealFmt, c.gray),
|
|
305
|
-
pair(' cache', vars.todayCacheTokensFmt, c.gray),
|
|
306
|
-
]);
|
|
357
|
+
box('today', todayBoxLines(vars, aggregate));
|
|
307
358
|
console.log('');
|
|
308
359
|
|
|
309
360
|
box('streak', [
|
|
@@ -446,17 +497,15 @@ function showToday() {
|
|
|
446
497
|
console.log(` ${c.bold}${c.magenta}◆ Today${c.reset} ${c.dim}— ${new Date().toLocaleDateString()}${c.reset}`);
|
|
447
498
|
console.log('');
|
|
448
499
|
|
|
449
|
-
box('today',
|
|
450
|
-
pair('active', `${c.bold}${c.green}${vars.todayHours}${c.reset}`, ''),
|
|
451
|
-
pair('prompts', String(vars.todayPrompts), c.yellow),
|
|
452
|
-
pair('tool calls', vars.todayToolsFmt, c.yellow),
|
|
453
|
-
pair('sessions', String(vars.todaySessions || 0)),
|
|
454
|
-
pair('tokens', `${c.bold}${vars.todayTokensFmt}${c.reset} ${c.dim}grand total${c.reset}`, ''),
|
|
455
|
-
pair(' in+out', vars.todayTokensRealFmt, c.gray),
|
|
456
|
-
pair(' cache', vars.todayCacheTokensFmt, c.gray),
|
|
457
|
-
]);
|
|
500
|
+
box('today', todayBoxLines(vars, aggregate));
|
|
458
501
|
console.log('');
|
|
459
502
|
|
|
503
|
+
// One quiet celebration line on milestone days (token round numbers
|
|
504
|
+
// crossed today, day-N anniversaries). The share nudge below owns streak
|
|
505
|
+
// records and round session/hour counts — no overlap.
|
|
506
|
+
const milestone = pickTodayMilestone(aggregate, dayTokens(lastDays(aggregate?.byDay, 1)[0]));
|
|
507
|
+
if (milestone) console.log(` ${c.magenta}✶${c.reset} ${c.bold}${milestone}${c.reset}\n`);
|
|
508
|
+
|
|
460
509
|
if (aggregate?.byHour && Object.keys(aggregate.byHour).length) {
|
|
461
510
|
box('when you code · hour of day', renderHourHistogram(aggregate.byHour, { peakHour: vars.peakHourNum }), 40);
|
|
462
511
|
console.log('');
|
|
@@ -481,12 +530,23 @@ function showWeek() {
|
|
|
481
530
|
console.log(` ${c.bold}${c.magenta}◆ This week${c.reset} ${c.dim}— ${weekKey(Date.now())}${c.reset}`);
|
|
482
531
|
console.log('');
|
|
483
532
|
|
|
533
|
+
// Week-over-week context: this week so far vs the whole of last week.
|
|
534
|
+
// Early in the week "▼" is expected — the vs label keeps that honest.
|
|
535
|
+
const prevRef = new Date();
|
|
536
|
+
prevRef.setHours(12, 0, 0, 0); // noon — immune to DST midnight edges
|
|
537
|
+
prevRef.setDate(prevRef.getDate() - 7);
|
|
538
|
+
const wkNow = aggregate?.byWeek?.[weekKey(Date.now())];
|
|
539
|
+
const wkPrev = aggregate?.byWeek?.[weekKey(prevRef.getTime())];
|
|
540
|
+
const dWkActive = fmtDelta(wkNow?.activeMs || 0, wkPrev?.activeMs || 0, { vs: 'vs last week' });
|
|
541
|
+
const dWkPrompts = fmtDelta(wkNow?.userMessages || 0, wkPrev?.userMessages || 0);
|
|
542
|
+
const dWkTokens = fmtDelta(dayTokens(wkNow), dayTokens(wkPrev));
|
|
543
|
+
|
|
484
544
|
box('this week', [
|
|
485
|
-
pair('active', `${c.bold}${c.green}${vars.weekHours}${c.reset}`, ''),
|
|
486
|
-
pair('prompts',
|
|
545
|
+
pair('active', `${c.bold}${c.green}${vars.weekHours}${c.reset}${dWkActive ? ` ${dWkActive}` : ''}`, ''),
|
|
546
|
+
pair('prompts', `${c.yellow}${vars.weekPrompts}${c.reset}${dWkPrompts ? ` ${dWkPrompts}` : ''}`, ''),
|
|
487
547
|
pair('tool calls', vars.weekToolsFmt, c.yellow),
|
|
488
548
|
pair('sessions', String(vars.weekSessions || 0)),
|
|
489
|
-
pair('tokens', `${c.bold}${vars.weekTokensFmt}${c.reset} ${c.dim}grand total${c.reset}`, ''),
|
|
549
|
+
pair('tokens', `${c.bold}${vars.weekTokensFmt}${c.reset} ${c.dim}grand total${c.reset}${dWkTokens ? ` ${dWkTokens}` : ''}`, ''),
|
|
490
550
|
]);
|
|
491
551
|
console.log('');
|
|
492
552
|
|
|
@@ -513,7 +573,8 @@ function showWeek() {
|
|
|
513
573
|
const h = ms / 3_600_000;
|
|
514
574
|
const hStr = h < 1 ? `${Math.round(h * 60)}m` : (h < 10 ? `${h.toFixed(1)}h` : `${Math.round(h)}h`);
|
|
515
575
|
const prefix = isToday ? `${c.bold}` : '';
|
|
516
|
-
|
|
576
|
+
const peak = ms === maxMs && ms > 0 ? ` ${c.bold}${heat(1)}◆${c.reset}` : '';
|
|
577
|
+
return `${prefix}${label.padEnd(12)}${c.reset} ${bar(ms, maxMs)} ${c.cyan}${hStr.padStart(5)}${c.reset}${peak}${isToday ? ` ${c.dim}← today${c.reset}` : ''}`;
|
|
517
578
|
});
|
|
518
579
|
box('this week · daily breakdown', lines);
|
|
519
580
|
console.log('');
|
|
@@ -1788,14 +1849,33 @@ const packagedDefault = IS_PACKAGED && !cmd;
|
|
|
1788
1849
|
try {
|
|
1789
1850
|
if (IS_NPX) {
|
|
1790
1851
|
// Our own tree is npm's throwaway _npx cache; launch from the global
|
|
1791
|
-
// install setup just promoted to
|
|
1852
|
+
// install setup just promoted to. The global copy must be resolved
|
|
1853
|
+
// EXPLICITLY (npm root -g): inside an npx run, PATH has the _npx
|
|
1854
|
+
// cache's .bin first, so a bare `claude-rpc` resolves right back
|
|
1855
|
+
// into the cache we're escaping. And it must be spawned as a direct
|
|
1856
|
+
// `node <script>` child — a shell+shim chain (cmd → .cmd → node)
|
|
1857
|
+
// only hides the FIRST hop's window; the detached cmd loses its
|
|
1858
|
+
// console, the grandchild node allocates a fresh one, and Windows 11
|
|
1859
|
+
// pops it as a visible Windows Terminal window whose closure kills
|
|
1860
|
+
// the daemon.
|
|
1792
1861
|
if (!daemonPid()) {
|
|
1793
|
-
|
|
1862
|
+
let script = null;
|
|
1863
|
+
try {
|
|
1864
|
+
const r = spawnSync('npm', ['root', '-g'], {
|
|
1865
|
+
encoding: 'utf8', timeout: 8000, windowsHide: true,
|
|
1866
|
+
shell: process.platform === 'win32', // npm is npm.cmd on Windows
|
|
1867
|
+
});
|
|
1868
|
+
const root = (r.stdout || '').trim();
|
|
1869
|
+
const candidate = root && join(root, 'claude-rpc', 'src', 'daemon.js');
|
|
1870
|
+
if (candidate && existsSync(candidate)) script = candidate;
|
|
1871
|
+
} catch { /* npm unavailable → fall back below */ }
|
|
1872
|
+
// Fallback: run from this (npx) tree. Still invisible — only the
|
|
1873
|
+
// npx-cache-eviction caveat remains, healed by the next setup.
|
|
1874
|
+
const child = spawn(process.execPath, [script || DAEMON_SCRIPT], {
|
|
1794
1875
|
detached: true, stdio: 'ignore', windowsHide: true,
|
|
1795
|
-
shell: process.platform === 'win32',
|
|
1796
1876
|
});
|
|
1797
1877
|
child.unref();
|
|
1798
|
-
console.log(` ${c.green}✓${c.reset} ${'daemon launched'.padEnd(16)}${c.dim}log ${shortPath(LOG_PATH)}${c.reset}`);
|
|
1878
|
+
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}`);
|
|
1799
1879
|
} else {
|
|
1800
1880
|
console.log(` ${c.cyan}·${c.reset} ${'daemon running'.padEnd(16)}${c.dim}pid ${daemonPid()}${c.reset}`);
|
|
1801
1881
|
}
|
package/src/nudge.js
CHANGED
|
@@ -63,6 +63,31 @@ export function pickShareNudge(agg) {
|
|
|
63
63
|
return out[0];
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// A quiet, local celebration line for `claude-rpc today`. Complements the
|
|
67
|
+
// share nudges above — which own streak records and round session/hour
|
|
68
|
+
// counts — without overlapping them: this detects lifetime-token round
|
|
69
|
+
// numbers CROSSED TODAY (no state file needed — the crossing happened today
|
|
70
|
+
// iff total ≥ mark and total − todayTokens < mark) and round "day N"
|
|
71
|
+
// anniversaries (which are only true on the day itself). Returns one string
|
|
72
|
+
// or null; the caller styles it.
|
|
73
|
+
const TOKEN_MARKS = [1e9, 5e9, 1e10, 2.5e10, 5e10, 1e11, 2.5e11, 5e11, 1e12];
|
|
74
|
+
const DAY_MARKS = new Set([50, 100, 200, 365, 500, 730, 1000]);
|
|
75
|
+
const fmtTok = (n) => n >= 1e12 ? `${n / 1e12}T` : n >= 1e9 ? `${n / 1e9}B` : `${n / 1e6}M`;
|
|
76
|
+
|
|
77
|
+
export function pickTodayMilestone(agg, todayTokens = 0) {
|
|
78
|
+
if (!agg || typeof agg !== 'object') return null;
|
|
79
|
+
const total = (agg.inputTokens || 0) + (agg.outputTokens || 0)
|
|
80
|
+
+ (agg.cacheReadTokens || 0) + (agg.cacheWriteTokens || 0);
|
|
81
|
+
for (let i = TOKEN_MARKS.length - 1; i >= 0; i--) {
|
|
82
|
+
const mark = TOKEN_MARKS[i];
|
|
83
|
+
if (total >= mark && total - todayTokens < mark) {
|
|
84
|
+
return `crossed ${fmtTok(mark)} lifetime tokens today`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (DAY_MARKS.has(agg.daysSinceFirst || 0)) return `day ${agg.daysSinceFirst} with claude`;
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
66
91
|
function readLastKey(path = NUDGE_STATE) {
|
|
67
92
|
try { return JSON.parse(readFileSync(path, 'utf8')).key || null; }
|
|
68
93
|
catch { return null; }
|
package/src/tui.js
CHANGED
|
@@ -13,6 +13,7 @@ import { loadConfig } from './config.js';
|
|
|
13
13
|
import { PID_PATH } from './paths.js';
|
|
14
14
|
import { fmtCost } from './pricing.js';
|
|
15
15
|
import { generateInsights } from './insights.js';
|
|
16
|
+
import { heat } from './ui.js';
|
|
16
17
|
|
|
17
18
|
// ── ANSI ────────────────────────────────────────────────────────────────────
|
|
18
19
|
const ESC = '\x1b[';
|
|
@@ -74,10 +75,11 @@ function width() { return Math.max(50, Math.min(120, process.stdout.columns ||
|
|
|
74
75
|
function height() { return Math.max(20, process.stdout.rows || 24); }
|
|
75
76
|
|
|
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.
|
|
77
79
|
function bar(value, max, w = 16) {
|
|
78
80
|
if (!max || max <= 0) return ''.padEnd(w);
|
|
79
81
|
const filled = Math.max(0, Math.min(w, Math.round((value / max) * w)));
|
|
80
|
-
return '█'.repeat(filled) + ' '.repeat(w - filled);
|
|
82
|
+
return `${heat(value / max) || C.magenta}${'█'.repeat(filled)}${C.reset}` + ' '.repeat(w - filled);
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
// Pad a (possibly ANSI-colored) line with spaces so its VISIBLE width hits n.
|
|
@@ -166,7 +168,7 @@ function tabToday(_, data) {
|
|
|
166
168
|
const ms = agg.byHour?.[h]?.activeMs || 0;
|
|
167
169
|
const idx = ms > 0 ? Math.max(1, Math.min(8, Math.round((ms / max) * 8))) : 0;
|
|
168
170
|
const ch = heightChars[idx];
|
|
169
|
-
bars.push(h === v.peakHourNum ? `${C.
|
|
171
|
+
bars.push(h === v.peakHourNum ? `${C.bold}${heat(1)}${ch}${C.reset}` : `${heat(ms / max)}${ch}${C.reset}`);
|
|
170
172
|
}
|
|
171
173
|
out.push(bars.join(''));
|
|
172
174
|
out.push(`${C.dim}00 03 06 09 12 15 18 21${C.reset}`);
|
|
@@ -212,7 +214,8 @@ function tabWeek(_, data) {
|
|
|
212
214
|
const h = ms / 3_600_000;
|
|
213
215
|
const hStr = h < 1 ? `${Math.round(h * 60)}m` : (h < 10 ? `${h.toFixed(1)}h` : `${Math.round(h)}h`);
|
|
214
216
|
const prefix = isToday ? C.bold : '';
|
|
215
|
-
|
|
217
|
+
const peak = ms === maxMs && ms > 0 ? ` ${C.bold}${heat(1)}◆${C.reset}` : '';
|
|
218
|
+
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}` : ''}`);
|
|
216
219
|
}
|
|
217
220
|
}
|
|
218
221
|
}
|
|
@@ -267,7 +270,7 @@ function tabLifetime(_, data) {
|
|
|
267
270
|
const h = p.activeMs / 3_600_000;
|
|
268
271
|
const hStr = h < 1 ? `${Math.round(h * 60)}m` : (h < 10 ? `${h.toFixed(1)}h` : `${Math.round(h)}h`);
|
|
269
272
|
const pretty = humanProject(name).slice(0, 18).padEnd(20);
|
|
270
|
-
out.push(`${pretty} ${
|
|
273
|
+
out.push(`${pretty} ${bar(p.activeMs, maxMs, 16)} ${C.cyan}${hStr.padStart(5)}${C.reset}`);
|
|
271
274
|
}
|
|
272
275
|
}
|
|
273
276
|
return out;
|
|
@@ -291,7 +294,7 @@ function tabCost(_, data) {
|
|
|
291
294
|
const max = byModel[0][1];
|
|
292
295
|
for (const [m, val] of byModel) {
|
|
293
296
|
const pretty = String(m).padEnd(20);
|
|
294
|
-
out.push(`${pretty} ${
|
|
297
|
+
out.push(`${pretty} ${bar(val, max, 18)} ${C.cyan}${fmtCost(val).padStart(8)}${C.reset}`);
|
|
295
298
|
}
|
|
296
299
|
}
|
|
297
300
|
|
|
@@ -330,7 +333,7 @@ function tabCode(_, data) {
|
|
|
330
333
|
const max = langs[0][1].edits || 1;
|
|
331
334
|
for (const [name, l] of langs) {
|
|
332
335
|
const pretty = name.slice(0, 18).padEnd(20);
|
|
333
|
-
out.push(`${pretty} ${
|
|
336
|
+
out.push(`${pretty} ${bar(l.edits, max, 18)} ${C.cyan}${String(l.edits).padStart(6)}${C.reset}`);
|
|
334
337
|
}
|
|
335
338
|
}
|
|
336
339
|
|
package/src/ui.js
CHANGED
|
@@ -81,6 +81,68 @@ export function fail(label, { hint = '', code = EX_USER_ERROR } = {}) {
|
|
|
81
81
|
process.exit(code);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// ── Heat / sparkline / comparison primitives ──────────────────────────────
|
|
85
|
+
//
|
|
86
|
+
// Shared by the focused views (today / week / status / TUI). All of these
|
|
87
|
+
// degrade to plain glyphs when colors are off, so piped output stays clean.
|
|
88
|
+
|
|
89
|
+
// Intensity 0..1 → a 256-color ramp matching the site palette: calm green
|
|
90
|
+
// for light activity, amber for solid, rust for hot. `tty` is overridable so
|
|
91
|
+
// the ramp is unit-testable in a non-TTY test runner.
|
|
92
|
+
const HEAT_RAMP = [
|
|
93
|
+
[0.25, '\x1b[38;5;65m'], // sage — barely warm
|
|
94
|
+
[0.45, '\x1b[38;5;71m'], // green — steady
|
|
95
|
+
[0.65, '\x1b[38;5;178m'], // amber — solid
|
|
96
|
+
[0.85, '\x1b[38;5;208m'], // orange — heavy
|
|
97
|
+
[Infinity, '\x1b[38;5;166m'], // rust — peak
|
|
98
|
+
];
|
|
99
|
+
export function heat(t, { tty = TTY } = {}) {
|
|
100
|
+
if (!tty) return '';
|
|
101
|
+
if (!(t > 0)) return c.dim;
|
|
102
|
+
for (const [ceil, color] of HEAT_RAMP) if (t < ceil) return color;
|
|
103
|
+
return HEAT_RAMP.at(-1)[1];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Heat-colored sparkline of a numeric series (▁▂▃▄▅▆▇█), scaled to its own
|
|
107
|
+
// max. Zero/empty input → ''. With colors off this is just the glyphs.
|
|
108
|
+
const SPARK_CHARS = ' ▁▂▃▄▅▆▇█';
|
|
109
|
+
export function sparkline(values, { tty = TTY } = {}) {
|
|
110
|
+
const max = Math.max(0, ...values.map((v) => v || 0));
|
|
111
|
+
if (!(max > 0)) return '';
|
|
112
|
+
const out = values.map((raw) => {
|
|
113
|
+
const v = raw || 0;
|
|
114
|
+
const idx = v > 0 ? Math.max(1, Math.min(8, Math.round((v / max) * 8))) : 0;
|
|
115
|
+
return `${heat(v / max, { tty })}${SPARK_CHARS[idx]}`;
|
|
116
|
+
}).join('');
|
|
117
|
+
return out + (tty ? c.reset : '');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// "▲ +18% vs 7-day avg" — current vs a baseline, colored by direction.
|
|
121
|
+
// A quiet day isn't a failure, so down renders gray, not red. Returns ''
|
|
122
|
+
// when the baseline is too small to compare against (fresh installs).
|
|
123
|
+
export function fmtDelta(current, baseline, { vs = '' } = {}) {
|
|
124
|
+
if (!(baseline > 0)) return '';
|
|
125
|
+
const pct = Math.round(((current || 0) - baseline) / baseline * 100);
|
|
126
|
+
const tail = vs ? ` ${c.dim}${vs}${c.reset}` : '';
|
|
127
|
+
if (pct === 0) return `${c.dim}≈ ${vs || 'usual'}${c.reset}`;
|
|
128
|
+
const up = pct > 0;
|
|
129
|
+
const shown = Math.min(Math.abs(pct), 999); // a 40×-average day reads "+999%", not noise
|
|
130
|
+
return `${up ? c.green : c.gray}${up ? '▲' : '▼'} ${up ? '+' : '−'}${shown}%${c.reset}${tail}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Percentile callout for a standout value among its history ("top 10% day").
|
|
134
|
+
// Quiet unless there's real history to rank against and the value is high —
|
|
135
|
+
// a callout on every middling day would train the eye to skip it.
|
|
136
|
+
export function topPercentile(values, v, { min = 14 } = {}) {
|
|
137
|
+
const past = values.filter((x) => x > 0);
|
|
138
|
+
if (past.length < min || !(v > 0)) return '';
|
|
139
|
+
const rank = past.filter((x) => x <= v).length / past.length;
|
|
140
|
+
if (rank >= 1) return 'best day yet';
|
|
141
|
+
if (rank >= 0.9) return 'top 10% day';
|
|
142
|
+
if (rank >= 0.75) return 'top 25% day';
|
|
143
|
+
return '';
|
|
144
|
+
}
|
|
145
|
+
|
|
84
146
|
// Return the last n lines of a log file's raw text, trimming the trailing
|
|
85
147
|
// empty element that split('\n') produces when the file ends with a newline.
|
|
86
148
|
// When the file lacks a trailing newline the last element is the last real
|