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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.15.4",
3
+ "version": "0.15.6",
4
4
  "description": "Discord Rich Presence for Claude Code — live model, project, tokens, and lifetime stats driven by Claude Code's hook system.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.js CHANGED
@@ -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 bars = [];
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
- bars.push(heightChars[idx]);
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 `${c.gray}▪${c.reset}`;
203
- if (ms < 60 * 60_000) return `${c.green}▪${c.reset}`;
204
- if (ms < 3 * 3600_000) return `${c.green}${c.bold}▪${c.reset}`;
205
- return `${c.magenta}${c.bold}▪${c.reset}`;
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} ${c.gray}▪${c.reset} ${c.green}▪${c.reset} ${c.green}${c.bold}▪${c.reset} ${c.magenta}${c.bold}▪${c.reset} ${c.dim}more${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', String(vars.weekPrompts), c.yellow),
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
- return `${prefix}${label.padEnd(12)}${c.reset} ${bar(ms, maxMs)} ${c.cyan}${hStr.padStart(5)}${c.reset}${isToday ? ` ${c.dim}← today${c.reset}` : ''}`;
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, via the PATH-resolved bin.
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
- const child = spawn('claude-rpc', ['daemon'], {
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.magenta}${C.bold}${ch}${C.reset}` : `${C.green}${ch}${C.reset}`);
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
- 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
+ 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} ${C.magenta}${bar(p.activeMs, maxMs, 16)}${C.reset} ${C.cyan}${hStr.padStart(5)}${C.reset}`);
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} ${C.magenta}${bar(val, max, 18)}${C.reset} ${C.cyan}${fmtCost(val).padStart(8)}${C.reset}`);
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} ${C.magenta}${bar(l.edits, max, 18)}${C.reset} ${C.cyan}${String(l.edits).padStart(6)}${C.reset}`);
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
package/src/version.js CHANGED
@@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { ROOT } from './paths.js';
13
13
 
14
- const BAKED = '0.15.4';
14
+ const BAKED = '0.15.6';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {