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/README.md +18 -0
- package/bin/cli.js +194 -28
- package/package.json +1 -1
- package/src/advice.js +120 -0
- package/src/caps-cache.js +46 -13
- package/src/demo.js +73 -1
- package/src/format-time.js +44 -0
- package/src/formatters/statusline.js +176 -38
- package/src/formatters/table.js +15 -21
- package/src/handoff.js +19 -20
- package/src/history.js +96 -31
- package/src/installer.js +39 -28
- package/src/window-labels.js +61 -0
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
|
|
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
|
|
54
|
-
*
|
|
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
|
-
|
|
61
|
-
|
|
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)}๐ฆ
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/formatters/table.js
CHANGED
|
@@ -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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
149
|
-
const
|
|
150
|
-
const
|
|
151
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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] - {
|
|
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
|
*/
|