claude-token-saver 2.2.0 โ†’ 2.5.0

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/src/demo.js CHANGED
@@ -10,6 +10,12 @@
10
10
  * it flows through formatReport() unchanged.
11
11
  */
12
12
 
13
+ // Default cap usage attached to every scenario so the always-on โœฆ / ๐Ÿ“…
14
+ // gauge segments render in the demo line. Individual scenarios override `caps`
15
+ // when they're meant to surface a `๐Ÿšจ 5H/7D` cap-warn chip.
16
+ const HEALTHY_CAPS = { fiveHour: 31, sevenDay: 10 };
17
+ const DEFAULT_MODEL = 'Opus 4.7';
18
+
13
19
  const SCENARIOS = [
14
20
  {
15
21
  name: 'healthy',
@@ -21,6 +27,7 @@ const SCENARIOS = [
21
27
  elapsedSec: 30,
22
28
  contextSize: '200k',
23
29
  spikeChip: null,
30
+ caps: HEALTHY_CAPS,
24
31
  },
25
32
  },
26
33
  {
@@ -33,6 +40,7 @@ const SCENARIOS = [
33
40
  elapsedSec: 30,
34
41
  contextSize: '200k',
35
42
  spikeChip: 'โš  Cache miss',
43
+ caps: HEALTHY_CAPS,
36
44
  },
37
45
  },
38
46
  {
@@ -45,6 +53,7 @@ const SCENARIOS = [
45
53
  elapsedSec: 3000, // ~10min remaining of 1h
46
54
  contextSize: '200k',
47
55
  spikeChip: null,
56
+ caps: HEALTHY_CAPS,
48
57
  },
49
58
  },
50
59
  {
@@ -57,6 +66,7 @@ const SCENARIOS = [
57
66
  elapsedSec: 3360, // ~4min remaining of 1h
58
67
  contextSize: '200k',
59
68
  spikeChip: null,
69
+ caps: HEALTHY_CAPS,
60
70
  },
61
71
  },
62
72
  {
@@ -69,6 +79,7 @@ const SCENARIOS = [
69
79
  elapsedSec: 4000, // past 1h
70
80
  contextSize: '200k',
71
81
  spikeChip: null,
82
+ caps: HEALTHY_CAPS,
72
83
  },
73
84
  },
74
85
  {
@@ -82,6 +93,7 @@ const SCENARIOS = [
82
93
  elapsedSec: 60,
83
94
  contextSize: '200k',
84
95
  spikeChip: 'โš  5m TTL',
96
+ caps: HEALTHY_CAPS,
85
97
  },
86
98
  },
87
99
  {
@@ -94,6 +106,7 @@ const SCENARIOS = [
94
106
  elapsedSec: 30,
95
107
  contextSize: '1M',
96
108
  spikeChip: 'โš  1M ON',
109
+ caps: HEALTHY_CAPS,
97
110
  },
98
111
  },
99
112
  {
@@ -106,6 +119,7 @@ const SCENARIOS = [
106
119
  elapsedSec: 30,
107
120
  contextSize: '200k',
108
121
  spikeChip: 'โš  Input spike',
122
+ caps: HEALTHY_CAPS,
109
123
  },
110
124
  },
111
125
  {
@@ -118,6 +132,7 @@ const SCENARIOS = [
118
132
  elapsedSec: 30,
119
133
  contextSize: '200k',
120
134
  spikeChip: 'โš  Rebuild churn',
135
+ caps: HEALTHY_CAPS,
121
136
  },
122
137
  },
123
138
  {
@@ -130,6 +145,7 @@ const SCENARIOS = [
130
145
  elapsedSec: 30,
131
146
  contextSize: '200k',
132
147
  spikeChip: 'โš  Output heavy',
148
+ caps: HEALTHY_CAPS,
133
149
  },
134
150
  },
135
151
  {
@@ -142,6 +158,46 @@ const SCENARIOS = [
142
158
  elapsedSec: 30,
143
159
  contextSize: '200k',
144
160
  spikeChip: 'โš  Call surge',
161
+ caps: HEALTHY_CAPS,
162
+ },
163
+ },
164
+ {
165
+ name: '5h-cap-warn',
166
+ label: '๐Ÿšจ 5H cap 94% (imminent block)',
167
+ data: {
168
+ hitRate: 0.93,
169
+ pct1h: 0.95,
170
+ savings: 1840,
171
+ elapsedSec: 30,
172
+ contextSize: '200k',
173
+ spikeChip: null,
174
+ caps: { fiveHour: 94, sevenDay: 12 },
175
+ },
176
+ },
177
+ {
178
+ name: '7d-cap-warn',
179
+ label: '๐Ÿšจ 7D cap 92% (week pacing)',
180
+ data: {
181
+ hitRate: 0.91,
182
+ pct1h: 0.92,
183
+ savings: 4200,
184
+ elapsedSec: 30,
185
+ contextSize: '200k',
186
+ spikeChip: null,
187
+ caps: { fiveHour: 35, sevenDay: 92 },
188
+ },
189
+ },
190
+ {
191
+ name: 'caps-yellow',
192
+ label: 'โš  5H 78% / 7D 70% (heads-up)',
193
+ data: {
194
+ hitRate: 0.95,
195
+ pct1h: 0.95,
196
+ savings: 2680,
197
+ elapsedSec: 30,
198
+ contextSize: '200k',
199
+ spikeChip: null,
200
+ caps: { fiveHour: 78, sevenDay: 70 },
145
201
  },
146
202
  },
147
203
  ];
@@ -220,6 +276,20 @@ export function buildTableDemoData(options = {}) {
220
276
  }
221
277
 
222
278
 
279
+ // Build the rate-limit caps shape that statusline.js consumes. Reset times
280
+ // are anchored to "wall-clock-ish" offsets so the cycle reads stable: 5H
281
+ // resets ~2h out, 7D resets a few days out โ€” matches what real /usage shows.
282
+ function buildCapsShape(caps) {
283
+ if (!caps) return null;
284
+ const nowSec = Math.floor(Date.now() / 1000);
285
+ return {
286
+ windows: [
287
+ { key: 'five_hour', usedPct: caps.fiveHour, resetsAt: nowSec + 60 * 130 }, // ~2h10m
288
+ { key: 'seven_day', usedPct: caps.sevenDay, resetsAt: nowSec + 60 * 60 * 76 }, // ~3d4h
289
+ ],
290
+ };
291
+ }
292
+
223
293
  export function buildScenarioData(scenarioName, options) {
224
294
  let scenario;
225
295
  if (scenarioName === 'cycle') {
@@ -231,7 +301,7 @@ export function buildScenarioData(scenarioName, options) {
231
301
  if (!scenario) return null;
232
302
  }
233
303
 
234
- const { hitRate, pct1h, pct5m, savings, elapsedSec, contextSize, spikeChip } = scenario.data;
304
+ const { hitRate, pct1h, pct5m, savings, elapsedSec, contextSize, spikeChip, caps } = scenario.data;
235
305
  return {
236
306
  summary: { hitRate },
237
307
  ttl: { pct1h, pct5m: pct5m ?? (1 - pct1h) },
@@ -245,6 +315,8 @@ export function buildScenarioData(scenarioName, options) {
245
315
  lastActivity: Date.now() - elapsedSec * 1000,
246
316
  contextWindow: { size: contextSize },
247
317
  spikeChip,
318
+ caps: buildCapsShape(caps),
319
+ model: DEFAULT_MODEL,
248
320
  _demoLabel: scenario.label,
249
321
  _demoName: scenario.name,
250
322
  };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Shared "resets in" countdown formatter. The 7-day window can be ~5 days
3
+ * away, so we promote whole-day spans into a `Xd Yh` shape; otherwise it's
4
+ * `Xh Ym` for โ‰ฅ1h and `Xm` under that.
5
+ *
6
+ * @param {number} resetsAt - Unix-epoch seconds when the window resets.
7
+ * @param {Date} [now=new Date()]
8
+ * @returns {string|null} formatted countdown, or null when the input isn't usable.
9
+ */
10
+ export function formatResetIn(resetsAt, now = new Date()) {
11
+ if (!Number.isFinite(resetsAt)) return null;
12
+ const remainingSec = Math.max(0, resetsAt - Math.floor(now.getTime() / 1000));
13
+ if (remainingSec <= 0) return '0m';
14
+ const d = Math.floor(remainingSec / 86400);
15
+ const h = Math.floor((remainingSec % 86400) / 3600);
16
+ const m = Math.floor((remainingSec % 3600) / 60);
17
+ if (d > 0) return `${d}d ${h}h`;
18
+ if (h > 0) return `${h}h ${m}m`;
19
+ return `${m}m`;
20
+ }
21
+
22
+ /**
23
+ * Wall-clock time at which the window resets, in the user's local timezone.
24
+ * Same-day resets show `HH:MM`; resets that cross midnight prepend the weekday
25
+ * (e.g. `Sat 21:30`) so a glance at the statusline doesn't mislead.
26
+ *
27
+ * @param {number} resetsAt - Unix-epoch seconds.
28
+ * @param {Date} [now=new Date()]
29
+ * @returns {string|null}
30
+ */
31
+ export function formatResetClock(resetsAt, now = new Date()) {
32
+ if (!Number.isFinite(resetsAt)) return null;
33
+ const reset = new Date(resetsAt * 1000);
34
+ if (Number.isNaN(reset.getTime())) return null;
35
+ const hh = String(reset.getHours()).padStart(2, '0');
36
+ const mm = String(reset.getMinutes()).padStart(2, '0');
37
+ const sameDay =
38
+ reset.getFullYear() === now.getFullYear() &&
39
+ reset.getMonth() === now.getMonth() &&
40
+ reset.getDate() === now.getDate();
41
+ if (sameDay) return `${hh}:${mm}`;
42
+ const dow = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][reset.getDay()];
43
+ return `${dow} ${hh}:${mm}`;
44
+ }
@@ -17,12 +17,34 @@
17
17
  * }
18
18
  */
19
19
 
20
+ import { formatResetClock } from '../format-time.js';
21
+ import { labelForKey } from '../window-labels.js';
22
+
23
+ // The 8-color ANSI defaults (RED=31, GREEN=32, YELLOW=33โ€ฆ) read as garish
24
+ // next to each other โ€” terminal palettes set them with unbalanced perceptual
25
+ // brightness, so the line ends up feeling loud. We emit a Tailwind-inspired
26
+ // muted palette via 24-bit truecolor when the terminal advertises support
27
+ // (`COLORTERM=truecolor`/`24bit`), and gracefully fall back to the legacy
28
+ // 8-color codes on terminals that don't.
29
+ //
30
+ // GREEN โ†’ emerald-400 #34D399 (calm, balanced with the others)
31
+ // YELLOW โ†’ amber-400 #FBBF24 (warm, not screamy)
32
+ // RED โ†’ rose-400 #FB7185 (alarm without the eye-burn of pure red)
33
+ // CYAN โ†’ cyan-400 #22D3EE
34
+ // MAGENTA โ†’ violet-400 #A78BFA (model identity tone)
35
+ // GRAY โ†’ slate-500 #64748B (recedes for the gauge track / period footer)
36
+ const TRUECOLOR =
37
+ process.env.COLORTERM === 'truecolor' || process.env.COLORTERM === '24bit';
38
+ const fg = (r, g, b, fallback) =>
39
+ TRUECOLOR ? `\x1b[38;2;${r};${g};${b}m` : fallback;
40
+
20
41
  const RESET = '\x1b[0m';
21
- const RED = '\x1b[31m';
22
- const GREEN = '\x1b[32m';
23
- const YELLOW = '\x1b[33m';
24
- const CYAN = '\x1b[36m';
25
- const GRAY = '\x1b[90m';
42
+ const RED = fg(251, 113, 133, '\x1b[31m');
43
+ const GREEN = fg(52, 211, 153, '\x1b[32m');
44
+ const YELLOW = fg(251, 191, 36, '\x1b[33m');
45
+ const CYAN = fg(34, 211, 238, '\x1b[36m');
46
+ const MAGENTA = fg(167, 139, 250, '\x1b[35m');
47
+ const GRAY = fg(100, 116, 139, '\x1b[90m');
26
48
  const BOLD = '\x1b[1m';
27
49
 
28
50
  function formatMoney(usd) {
@@ -36,6 +58,44 @@ function formatPct(v) {
36
58
  return `${(v * 100).toFixed(1)}%`;
37
59
  }
38
60
 
61
+ /**
62
+ * Render a 6-cell density-gradient gauge for `pct` (0..100). All cells share
63
+ * the same Unicode "Block Elements" density family โ€” `โ–ˆ` (100%) โ†’ `โ–“` (75%)
64
+ * โ†’ `โ–’` (50%) โ†’ `โ–‘` (25%) โ€” so the fillโ†’empty boundary reads as one smooth
65
+ * gradient instead of an awkward step.
66
+ *
67
+ * Earlier we used partial-fill glyphs (`โ–โ–Žโ–โ–Œโ–‹โ–Šโ–‰`) for sub-cell precision, but
68
+ * those have transparent halves that clash visually with the `โ–‘` track next
69
+ * to them (the eye sees "solid edge | gap | dotted track" โ€” three zones).
70
+ * Density chars are the same shape, just darker/lighter, so the boundary
71
+ * cell reads as a single smooth fade.
72
+ *
73
+ * Each cell is ~17% wide; the boundary cell uses 3 intermediate density steps
74
+ * for ~4% effective precision around the fill edge. Stable monospace width
75
+ * across all terminal fonts that ship Block Elements (U+2580โ€“U+259F).
76
+ */
77
+ function gaugeBar(pct) {
78
+ const cells = 6;
79
+ const clamped = Math.max(0, Math.min(100, pct));
80
+ const filled = (clamped / 100) * cells; // e.g. 4.32 cells filled
81
+ const fullCells = Math.floor(filled);
82
+ const remainder = filled - fullCells; // 0..1 โ€” fill fraction of the boundary cell
83
+ // Boundary cell: โ–‘ (empty), โ–’ (1/3), โ–“ (2/3), or roll over to a full โ–ˆ.
84
+ let partial = '';
85
+ let extra = 0;
86
+ if (remainder >= 0.83) {
87
+ extra = 1; // round up โ€” fill the boundary cell completely
88
+ } else if (remainder >= 0.5) {
89
+ partial = 'โ–“';
90
+ } else if (remainder >= 0.16) {
91
+ partial = 'โ–’';
92
+ } // else: remainder is too small to show โ€” leave the cell empty
93
+ const totalFull = Math.min(cells, fullCells + extra);
94
+ const usedCells = totalFull + (partial ? 1 : 0);
95
+ const empty = 'โ–‘'.repeat(Math.max(0, cells - usedCells));
96
+ return 'โ–ˆ'.repeat(totalFull) + partial + empty;
97
+ }
98
+
39
99
  /**
40
100
  * Format a remaining-seconds countdown as MM:SS (or H:MM when โ‰ฅ 1h).
41
101
  */
@@ -50,29 +110,15 @@ function formatTimer(remainingSec) {
50
110
  }
51
111
 
52
112
  /**
53
- * Pick the cap-warn chip ({ kind, usedPct, resetsAt, label }) that should
54
- * surface, or null if neither window is at 90%+. When both windows are warning,
113
+ * Pick the cap-warn chip that should surface from any of the rate-limit
114
+ * windows, or null when none are at 90%+. When multiple windows are warning,
55
115
  * the one that resets sooner wins (it's the more imminent block).
56
116
  */
57
117
  export function pickCapWarn(caps) {
58
- if (!caps) return null;
59
- const candidates = [];
60
- if (caps.fiveHour && caps.fiveHour.usedPct >= 90) {
61
- candidates.push({
62
- kind: 'five_hour',
63
- label: '5H',
64
- usedPct: caps.fiveHour.usedPct,
65
- resetsAt: caps.fiveHour.resetsAt,
66
- });
67
- }
68
- if (caps.sevenDay && caps.sevenDay.usedPct >= 90) {
69
- candidates.push({
70
- kind: 'seven_day',
71
- label: '7D',
72
- usedPct: caps.sevenDay.usedPct,
73
- resetsAt: caps.sevenDay.resetsAt,
74
- });
75
- }
118
+ if (!caps || !Array.isArray(caps.windows)) return null;
119
+ const candidates = caps.windows
120
+ .filter((w) => Number.isFinite(w.usedPct) && w.usedPct >= 90)
121
+ .map((w) => ({ ...w, label: labelForKey(w.key).short }));
76
122
  if (candidates.length === 0) return null;
77
123
  candidates.sort((a, b) => {
78
124
  const ar = Number.isFinite(a.resetsAt) ? a.resetsAt : Infinity;
@@ -89,10 +135,10 @@ export function pickCapWarn(caps) {
89
135
  * @param {boolean} [opts.verbose=false] - longer layout with labels
90
136
  * @param {boolean} [opts.timer=true] - show TTL countdown segment
91
137
  * @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses ๐Ÿง  โณ ๐Ÿ’ฐ instead of word labels.
92
- * @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, hit, ttl, saved, ctx, period. Null/undefined = all.
138
+ * @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, model, hit, ttl, saved, ctx, period, plus per-window keys (`five_hour`, `seven_day`, โ€ฆ). `5h`/`7d` are kept as aliases for back-compat. Null/undefined = all.
93
139
  */
94
140
  export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null } = {}) {
95
- const { summary, ttl, cost, options, lastActivity, contextWindow, spikeChip, caps } = data;
141
+ const { summary, ttl, cost, options, lastActivity, contextWindow, spikeChip, caps, model } = data;
96
142
  const { hitRate } = summary;
97
143
 
98
144
  // Hit rate โ†’ color signal
@@ -190,12 +236,13 @@ export function formatReport(data, { color = true, verbose = false, timer = true
190
236
  if (contextWindow && contextWindow.size && contextWindow.size !== 'unknown') {
191
237
  const label = contextWindow.size === '1M' ? '1M' : '200k';
192
238
  const ctxColor = contextWindow.size === '1M' ? RED : GREEN;
239
+ // `Ctx` (not the full word `Context`) across every mode โ€” the icon already
240
+ // tells the eye what the chip is, and the short form fits the same cadence
241
+ // as `Hit`/`Saved` peers when we eventually shorten those too.
193
242
  if (isIcon && verbose) {
194
- ctxSeg = `${c(ctxColor)}๐Ÿ“ฆ Context ${label}${c(RESET)}`;
243
+ ctxSeg = `${c(ctxColor)}๐Ÿ“ฆ Ctx ${label}${c(RESET)}`;
195
244
  } else if (isIcon) {
196
245
  ctxSeg = `${c(ctxColor)}๐Ÿ“ฆ ${label}${c(RESET)}`;
197
- } else if (verbose) {
198
- ctxSeg = `${c(ctxColor)}Context ${label}${c(RESET)}`;
199
246
  } else {
200
247
  ctxSeg = `${c(ctxColor)}Ctx ${label}${c(RESET)}`;
201
248
  }
@@ -204,6 +251,76 @@ export function formatReport(data, { color = true, verbose = false, timer = true
204
251
  // Spike chip โ€” one word only, keeps the statusline single-line.
205
252
  const spikeSeg = spikeChip ? `${c(RED)}${spikeChip}${c(RESET)}` : null;
206
253
 
254
+ // Model chip โ€” pulled from Claude Code's stdin payload (`model.display_name`).
255
+ // Cheap identity context: useful when the user toggles between Sonnet/Opus
256
+ // mid-session and wants to confirm at a glance which one is answering.
257
+ let modelSeg = null;
258
+ if (typeof model === 'string' && model.length > 0) {
259
+ // ๐Ÿค– + name is enough โ€” the emoji disambiguates so the literal word "Model"
260
+ // is dead weight in icon mode. Text modes keep the bare name; the magenta
261
+ // tone marks it as identity context.
262
+ if (isIcon) {
263
+ modelSeg = `${c(MAGENTA)}๐Ÿค– ${model}${c(RESET)}`;
264
+ } else {
265
+ modelSeg = `${c(MAGENTA)}${model}${c(RESET)}`;
266
+ }
267
+ }
268
+
269
+ // Always-on usage segments โ€” what /usage shows in Claude Code, mirrored
270
+ // to the statusline so the user doesn't have to slash-command for it.
271
+ // Today the stdin payload exposes the 5h ("Current session") and 7-day
272
+ // rolling ("Current week") windows; if Anthropic ships more (e.g. a
273
+ // Sonnet-only weekly), they render automatically with derived labels.
274
+ // Each renders as `{label} {pct}% ยท {countdown}`. When a window is at >=90%
275
+ // the cap-warn chip already shouts about it, so we suppress the always-on
276
+ // segment to avoid duplicate noise.
277
+ function buildUsageSeg({ labels, info, color: tone }) {
278
+ if (!info || !Number.isFinite(info.usedPct)) return null;
279
+ if (info.usedPct >= 90) return null; // cap-warn chip handles this case
280
+ const pct = Math.round(info.usedPct);
281
+ // Show only the wall-clock reset time (e.g. `๐Ÿ”„ 21:10`). Absolute time
282
+ // doesn't tick second-by-second so the statusline reads stable, and the
283
+ // ๐Ÿ”„ icon itself separates the percent from the clock โ€” no extra `ยท` needed.
284
+ const clock = formatResetClock(info.resetsAt);
285
+ const tail = clock ? ` ๐Ÿ”„ ${clock}` : '';
286
+ // `cap` reads as a rate-limit ceiling rather than a duration. Icon mode
287
+ // leans on the icon to identify the window (โœฆ = session/now, ๐Ÿ“… = week),
288
+ // so the 5H label is empty while the 7D label spells out "weekly". Text and
289
+ // verbose modes keep the `5H`/`7D` short label since they have no icon.
290
+ // Icon mode renders an inline โ–ฐโ–ฑ gauge instead of the literal "cap used" โ€”
291
+ // a glance at the bar conveys urgency faster than parsing a percent number,
292
+ // and the gauge stays the same width as the percent climbs.
293
+ if (isIcon) {
294
+ const labelPart = labels.usageLabel ? `${labels.usageLabel} ` : '';
295
+ const bar = gaugeBar(pct);
296
+ return `${c(tone)}${labels.icon} ${labelPart}${bar} ${pct}%${tail}${c(RESET)}`;
297
+ }
298
+ if (verbose) {
299
+ return `${c(tone)}${labels.short} cap ${pct}% used${tail}${c(RESET)}`;
300
+ }
301
+ return `${c(tone)}${labels.short} cap ${pct}%${tail}${c(RESET)}`;
302
+ }
303
+ // Color tone: green when <70%, yellow 70-89% (the segment is suppressed at
304
+ // 90+% in favor of cap-warn). Lets the user spot "I'm getting close" without
305
+ // waiting for the alarm chip.
306
+ function usageTone(info) {
307
+ if (!info || !Number.isFinite(info.usedPct)) return GRAY;
308
+ if (info.usedPct >= 70) return YELLOW;
309
+ return GREEN;
310
+ }
311
+ const usageSegs = [];
312
+ if (caps && Array.isArray(caps.windows)) {
313
+ for (const win of caps.windows) {
314
+ const labels = labelForKey(win.key);
315
+ const seg = buildUsageSeg({
316
+ labels,
317
+ info: win,
318
+ color: usageTone(win),
319
+ });
320
+ if (seg) usageSegs.push({ key: win.key, seg });
321
+ }
322
+ }
323
+
207
324
  // Cap-warn chip โ€” leads everything when ANY rate-limit window is at 90%+.
208
325
  // It's the most actionable signal we can show: no point optimizing cache
209
326
  // hits if you're about to be rate-limited anyway. The chip body matches the
@@ -212,14 +329,19 @@ export function formatReport(data, { color = true, verbose = false, timer = true
212
329
  let capWarnSeg = null;
213
330
  if (capWarn) {
214
331
  const pct = Math.round(capWarn.usedPct);
215
- if (isIcon && verbose) {
216
- capWarnSeg = `${c(BOLD)}${c(RED)}๐Ÿšจ ${capWarn.label} cap ${pct}%${c(RESET)}`;
217
- } else if (isIcon) {
218
- capWarnSeg = `${c(BOLD)}${c(RED)}๐Ÿšจ ${capWarn.label} ${pct}%${c(RESET)}`;
219
- } else if (verbose) {
220
- capWarnSeg = `${c(BOLD)}${c(RED)}${capWarn.label} cap ${pct}%${c(RESET)}`;
332
+ // At 90%+ the user wants to know "when can I send again" โ€” wall-clock is
333
+ // the actionable bit. Same `๐Ÿ”„ HH:MM` shape as the always-on segments so
334
+ // the icon's meaning carries over to the alarm chip.
335
+ const clock = formatResetClock(capWarn.resetsAt);
336
+ const clockTail = clock ? ` ๐Ÿ”„ ${clock}` : '';
337
+ if (isIcon) {
338
+ // Gauge keeps shape parity with the always-on usage segment โ€” the
339
+ // cap-warn is just the same gauge "filled to alarm". Visual continuity
340
+ // helps the eye understand "this is the 5H bar I was watching, just red now."
341
+ const bar = gaugeBar(pct);
342
+ capWarnSeg = `${c(BOLD)}${c(RED)}๐Ÿšจ ${capWarn.label} ${bar} ${pct}%${clockTail}${c(RESET)}`;
221
343
  } else {
222
- capWarnSeg = `${c(BOLD)}${c(RED)}${capWarn.label} ${pct}%${c(RESET)}`;
344
+ capWarnSeg = `${c(BOLD)}${c(RED)}${capWarn.label} cap ${pct}%${clockTail}${c(RESET)}`;
223
345
  }
224
346
  }
225
347
 
@@ -231,13 +353,29 @@ export function formatReport(data, { color = true, verbose = false, timer = true
231
353
  ? new Set(segments.map((s) => s.toLowerCase()))
232
354
  : null;
233
355
  const want = (name) => !allow || allow.has(name);
356
+ // Legacy whitelist aliases: `5h` โ†” `five_hour`, `7d` โ†” `seven_day`. So
357
+ // existing `--segments` configs keep working after the generic refactor.
358
+ const usageWant = (key) => {
359
+ if (!allow) return true;
360
+ if (allow.has(key.toLowerCase())) return true;
361
+ if (key === 'five_hour' && allow.has('5h')) return true;
362
+ if (key === 'seven_day' && allow.has('7d')) return true;
363
+ return false;
364
+ };
234
365
  const segs = [];
235
366
  if (capWarnSeg && want('cap-warn')) segs.push(capWarnSeg);
236
367
  if (spikeSeg && want('spike')) segs.push(spikeSeg);
368
+ if (modelSeg && want('model')) segs.push(modelSeg);
237
369
  if (want('hit')) segs.push(hitSeg);
238
370
  if (want('ttl')) segs.push(ttlSeg);
239
- if (want('saved')) segs.push(saveSeg);
371
+ for (const { key, seg } of usageSegs) {
372
+ if (usageWant(key)) segs.push(seg);
373
+ }
240
374
  if (ctxSeg && want('ctx')) segs.push(ctxSeg);
375
+ // Cache saved is the "lifetime brag" stat โ€” useful but not actionable, so
376
+ // it sits near the tail. The period label closes the line as a quiet
377
+ // timeframe footer.
378
+ if (want('saved')) segs.push(saveSeg);
241
379
  if (want('period')) segs.push(periodSeg);
242
380
  return segs.join(' ยท ');
243
381
  }
@@ -2,6 +2,8 @@
2
2
  * Terminal table formatter โ€” zero dependencies.
3
3
  */
4
4
  import { ISSUE_MESSAGES } from '../advice.js';
5
+ import { formatResetIn, formatResetClock } from '../format-time.js';
6
+ import { labelForKey } from '../window-labels.js';
5
7
 
6
8
  function pad(str, len, align = 'left') {
7
9
  const s = String(str);
@@ -123,32 +125,24 @@ function renderSpikeSection(spikes, contextWindow) {
123
125
  return lines;
124
126
  }
125
127
 
126
- function formatResetIn(resetsAt, now = new Date()) {
127
- if (!Number.isFinite(resetsAt)) return null;
128
- const remaining = Math.max(0, resetsAt - Math.floor(now.getTime() / 1000));
129
- if (remaining <= 0) return '0m';
130
- const h = Math.floor(remaining / 3600);
131
- const m = Math.floor((remaining % 3600) / 60);
132
- if (h > 0) return `${h}h ${m}m`;
133
- return `${m}m`;
134
- }
135
-
136
128
  function renderCapWarnSection(caps) {
137
- const warning = [];
138
- if (caps.fiveHour && caps.fiveHour.usedPct >= 90) {
139
- warning.push({ label: '5-hour window', info: caps.fiveHour });
140
- }
141
- if (caps.sevenDay && caps.sevenDay.usedPct >= 90) {
142
- warning.push({ label: '7-day window', info: caps.sevenDay });
143
- }
129
+ if (!caps || !Array.isArray(caps.windows)) return [];
130
+ const warning = caps.windows.filter(
131
+ (w) => Number.isFinite(w.usedPct) && w.usedPct >= 90,
132
+ );
144
133
  if (warning.length === 0) return [];
145
134
  const lines = [];
146
135
  lines.push(' ๐Ÿšจ Rate-limit cap is closing in');
147
136
  lines.push(` ${'โ”€'.repeat(50)}`);
148
- for (const { label, info } of warning) {
149
- const reset = formatResetIn(info.resetsAt);
150
- const tail = reset ? `, resets in ${reset}` : '';
151
- lines.push(` โ€ข ${label}: ${Math.round(info.usedPct)}% used${tail}`);
137
+ for (const win of warning) {
138
+ const label = labelForKey(win.key).long;
139
+ const reset = formatResetIn(win.resetsAt);
140
+ const clock = formatResetClock(win.resetsAt);
141
+ let tail = '';
142
+ if (reset && clock) tail = `, resets in ${reset} (at ${clock})`;
143
+ else if (reset) tail = `, resets in ${reset}`;
144
+ else if (clock) tail = `, resets at ${clock}`;
145
+ lines.push(` โ€ข ${label}: ${Math.round(win.usedPct)}% used${tail}`);
152
146
  }
153
147
  lines.push('');
154
148
  lines.push(' Back up work before the cap hits:');
package/src/handoff.js CHANGED
@@ -16,6 +16,9 @@ import { writeFileSync, existsSync } from 'node:fs';
16
16
  import { execSync } from 'node:child_process';
17
17
  import { join, resolve } from 'node:path';
18
18
 
19
+ import { formatResetIn, formatResetClock } from './format-time.js';
20
+ import { labelForKey } from './window-labels.js';
21
+
19
22
  function pad(n) {
20
23
  return String(n).padStart(2, '0');
21
24
  }
@@ -52,16 +55,6 @@ function gitSnapshot(cwd) {
52
55
  return { branch, head, status };
53
56
  }
54
57
 
55
- function formatResetIn(resetsAt, now = new Date()) {
56
- if (!Number.isFinite(resetsAt)) return null;
57
- const remaining = Math.max(0, resetsAt - Math.floor(now.getTime() / 1000));
58
- if (remaining <= 0) return '0m';
59
- const h = Math.floor(remaining / 3600);
60
- const m = Math.floor((remaining % 3600) / 60);
61
- if (h > 0) return `${h}h ${m}m`;
62
- return `${m}m`;
63
- }
64
-
65
58
  function pickPath(cwd, now) {
66
59
  const stem = `HANDOFF-${ymd(now)}-${hhmm(now)}`;
67
60
  const direct = join(cwd, `${stem}.md`);
@@ -99,15 +92,21 @@ function renderTemplate({ now, cwd, git, caps }) {
99
92
 
100
93
  lines.push('## Cap snapshot');
101
94
  lines.push('');
102
- if (caps) {
103
- const fmtRow = (label, info) => {
104
- if (!info) return `- ${label}: (unknown โ€” stdin had no rate-limit info)`;
105
- const reset = formatResetIn(info.resetsAt, now);
106
- const tail = reset ? `, resets in ${reset}` : '';
107
- return `- ${label}: ${Math.round(info.usedPct)}%${tail}`;
108
- };
109
- lines.push(fmtRow('5-hour window', caps.fiveHour));
110
- lines.push(fmtRow('7-day window', caps.sevenDay));
95
+ if (caps && Array.isArray(caps.windows) && caps.windows.length > 0) {
96
+ for (const win of caps.windows) {
97
+ const label = labelForKey(win.key).long;
98
+ if (!Number.isFinite(win.usedPct)) {
99
+ lines.push(`- ${label}: (unknown)`);
100
+ continue;
101
+ }
102
+ const reset = formatResetIn(win.resetsAt, now);
103
+ const clock = formatResetClock(win.resetsAt, now);
104
+ let tail = '';
105
+ if (reset && clock) tail = `, resets in ${reset} (at ${clock})`;
106
+ else if (reset) tail = `, resets in ${reset}`;
107
+ else if (clock) tail = `, resets at ${clock}`;
108
+ lines.push(`- ${label}: ${Math.round(win.usedPct)}%${tail}`);
109
+ }
111
110
  } else {
112
111
  lines.push('- (no cap data โ€” run `handoff` from a Claude Code session for live numbers)');
113
112
  }
@@ -148,7 +147,7 @@ function renderTemplate({ now, cwd, git, caps }) {
148
147
  *
149
148
  * @param {object} [opts]
150
149
  * @param {string} [opts.cwd=process.cwd()]
151
- * @param {object|null} [opts.caps] - { fiveHour, sevenDay } from extractCaps
150
+ * @param {object|null} [opts.caps] - { windows: [...] } from extractCaps
152
151
  * @param {Date} [opts.now=new Date()]
153
152
  * @returns {{ path: string, git: { branch: string, head: string, status: string } | null }}
154
153
  */