sprag-cli 3.40.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/LICENSE +21 -0
- package/README.ko.md +637 -0
- package/README.md +758 -0
- package/bin/cli.js +801 -0
- package/examples/statusline-command.ps1 +43 -0
- package/examples/statusline-command.sh +36 -0
- package/package.json +62 -0
- package/presets/cohesion/cohesion-en.md +26 -0
- package/presets/doc2md/convert.py +363 -0
- package/presets/korean-style/LICENSE-fluent-korean +21 -0
- package/presets/korean-style/fluent-korean.md +52 -0
- package/presets/korean-style/supplement.md +93 -0
- package/presets/model-rules.json +115 -0
- package/presets/ratchet-rules.json +38 -0
- package/src/advice.js +564 -0
- package/src/agents.js +52 -0
- package/src/brief.js +264 -0
- package/src/caps-cache.js +84 -0
- package/src/cli-args.js +51 -0
- package/src/cohesion.js +70 -0
- package/src/commands/brief.js +31 -0
- package/src/commands/cohesion.js +59 -0
- package/src/commands/compact-window.js +93 -0
- package/src/commands/doc2md.js +166 -0
- package/src/commands/feedback.js +132 -0
- package/src/commands/handoff.js +33 -0
- package/src/commands/harness.js +459 -0
- package/src/commands/history.js +46 -0
- package/src/commands/install.js +358 -0
- package/src/commands/korean.js +220 -0
- package/src/commands/last.js +151 -0
- package/src/commands/mode.js +46 -0
- package/src/commands/route-scan.js +454 -0
- package/src/commands/seed.js +105 -0
- package/src/commands/uninstall.js +42 -0
- package/src/commands/update-check.js +77 -0
- package/src/commands/upgrade.js +68 -0
- package/src/compact-window.js +205 -0
- package/src/config.js +232 -0
- package/src/cost.js +253 -0
- package/src/debug.js +29 -0
- package/src/demo.js +331 -0
- package/src/doc2md-ledger.cjs +227 -0
- package/src/doc2md.cjs +997 -0
- package/src/fig2md-runner.cjs +21 -0
- package/src/fig2md.cjs +191 -0
- package/src/first-run-note.js +63 -0
- package/src/format-time.js +44 -0
- package/src/formatters/csv.js +8 -0
- package/src/formatters/json.js +3 -0
- package/src/formatters/statusline.js +750 -0
- package/src/formatters/table.js +299 -0
- package/src/handoff.js +161 -0
- package/src/harness-analyzer.cjs +264 -0
- package/src/harness-templates.js +153 -0
- package/src/harness.js +613 -0
- package/src/history.js +383 -0
- package/src/hook-manager.js +96 -0
- package/src/hook.cjs +196 -0
- package/src/installer.js +614 -0
- package/src/korean-lint.cjs +303 -0
- package/src/korean-style.js +187 -0
- package/src/litellm-budget.js +223 -0
- package/src/model-alias.js +484 -0
- package/src/model-rules.js +527 -0
- package/src/month-spend.js +47 -0
- package/src/parser.js +330 -0
- package/src/paths.js +41 -0
- package/src/prompt.js +52 -0
- package/src/route-scan.js +832 -0
- package/src/savings-ledger.js +137 -0
- package/src/seed-rules.js +280 -0
- package/src/session-cache.js +160 -0
- package/src/session-records.js +188 -0
- package/src/stats.js +380 -0
- package/src/stdin-payload.js +122 -0
- package/src/subagent-records.js +214 -0
- package/src/update-check.js +201 -0
- package/src/window-labels.js +64 -0
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statusline formatter β compact single-line output for Claude Code statusline API.
|
|
3
|
+
* Called every ~300ms, so kept minimal and fast. ANSI color codes included by default.
|
|
4
|
+
*
|
|
5
|
+
* Example output (color):
|
|
6
|
+
* π§ 97.5% Β· 1h Β· β± 42:15 Β· π° $4.8K Β· 7d
|
|
7
|
+
*
|
|
8
|
+
* Disable color with NO_COLOR=1 env var or --no-color flag.
|
|
9
|
+
* Disable the TTL countdown with --no-timer.
|
|
10
|
+
*
|
|
11
|
+
* Usage in ~/.claude/settings.json:
|
|
12
|
+
* {
|
|
13
|
+
* "statusLine": {
|
|
14
|
+
* "type": "command",
|
|
15
|
+
* "command": "npx claude-token-saver --statusline"
|
|
16
|
+
* }
|
|
17
|
+
* }
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { formatResetClock } from '../format-time.js';
|
|
21
|
+
import { labelForKey } from '../window-labels.js';
|
|
22
|
+
import { harnessStatusForStatusline } from '../harness.js';
|
|
23
|
+
import { loadConfig } from '../config.js';
|
|
24
|
+
import { koreanStyleEnabled } from '../korean-style.js';
|
|
25
|
+
|
|
26
|
+
// The 8-color ANSI defaults (RED=31, GREEN=32, YELLOW=33β¦) read as garish
|
|
27
|
+
// next to each other β terminal palettes set them with unbalanced perceptual
|
|
28
|
+
// brightness, so the line ends up feeling loud. We emit a Tailwind-inspired
|
|
29
|
+
// muted palette via 24-bit truecolor when the terminal advertises support
|
|
30
|
+
// (`COLORTERM=truecolor`/`24bit`), and gracefully fall back to the legacy
|
|
31
|
+
// 8-color codes on terminals that don't.
|
|
32
|
+
//
|
|
33
|
+
// GREEN β emerald-400 #34D399 (calm, balanced with the others)
|
|
34
|
+
// YELLOW β amber-400 #FBBF24 (warm, not screamy)
|
|
35
|
+
// RED β rose-400 #FB7185 (alarm without the eye-burn of pure red)
|
|
36
|
+
// CYAN β cyan-400 #22D3EE
|
|
37
|
+
// MAGENTA β violet-400 #A78BFA (model identity tone)
|
|
38
|
+
// GRAY β slate-500 #64748B (recedes for the gauge track / period footer)
|
|
39
|
+
const TRUECOLOR =
|
|
40
|
+
process.env.COLORTERM === 'truecolor' || process.env.COLORTERM === '24bit';
|
|
41
|
+
const fg = (r, g, b, fallback) =>
|
|
42
|
+
TRUECOLOR ? `\x1b[38;2;${r};${g};${b}m` : fallback;
|
|
43
|
+
|
|
44
|
+
const RESET = '\x1b[0m';
|
|
45
|
+
const RED = fg(251, 113, 133, '\x1b[31m');
|
|
46
|
+
const GREEN = fg(52, 211, 153, '\x1b[32m');
|
|
47
|
+
const YELLOW = fg(251, 191, 36, '\x1b[33m');
|
|
48
|
+
const CYAN = fg(34, 211, 238, '\x1b[36m');
|
|
49
|
+
const MAGENTA = fg(167, 139, 250, '\x1b[35m');
|
|
50
|
+
const GRAY = fg(100, 116, 139, '\x1b[90m');
|
|
51
|
+
const BOLD = '\x1b[1m';
|
|
52
|
+
|
|
53
|
+
function formatMoney(usd) {
|
|
54
|
+
// Boundaries sit at 999.5/999500 so a value that would round up into the
|
|
55
|
+
// next band's width ($999.9 β "$1000") jumps to that band's unit instead β
|
|
56
|
+
// otherwise "$1000" renders visually larger than "$1.0K".
|
|
57
|
+
if (usd >= 999500) return `$${(usd / 1e6).toFixed(1)}M`;
|
|
58
|
+
if (usd >= 999.5) return `$${(usd / 1000).toFixed(1)}K`;
|
|
59
|
+
if (usd >= 100) return `$${usd.toFixed(0)}`;
|
|
60
|
+
if (usd >= 10) return `$${usd.toFixed(1)}`;
|
|
61
|
+
return `$${usd.toFixed(2)}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function formatPct(v) {
|
|
65
|
+
return `${(v * 100).toFixed(1)}%`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Render a 6-cell density-gradient gauge for `pct` (0..100). All cells share
|
|
70
|
+
* the same Unicode "Block Elements" density family β `β` (100%) β `β` (75%)
|
|
71
|
+
* β `β` (50%) β `β` (25%) β so the fillβempty boundary reads as one smooth
|
|
72
|
+
* gradient instead of an awkward step.
|
|
73
|
+
*
|
|
74
|
+
* Earlier we used partial-fill glyphs (`βββββββ`) for sub-cell precision, but
|
|
75
|
+
* those have transparent halves that clash visually with the `β` track next
|
|
76
|
+
* to them (the eye sees "solid edge | gap | dotted track" β three zones).
|
|
77
|
+
* Density chars are the same shape, just darker/lighter, so the boundary
|
|
78
|
+
* cell reads as a single smooth fade.
|
|
79
|
+
*
|
|
80
|
+
* Each cell is ~17% wide; the boundary cell uses 3 intermediate density steps
|
|
81
|
+
* for ~4% effective precision around the fill edge. Stable monospace width
|
|
82
|
+
* across all terminal fonts that ship Block Elements (U+2580βU+259F).
|
|
83
|
+
*/
|
|
84
|
+
function gaugeBar(pct) {
|
|
85
|
+
const cells = 6;
|
|
86
|
+
const clamped = Math.max(0, Math.min(100, pct));
|
|
87
|
+
const filled = (clamped / 100) * cells; // e.g. 4.32 cells filled
|
|
88
|
+
const fullCells = Math.floor(filled);
|
|
89
|
+
const remainder = filled - fullCells; // 0..1 β fill fraction of the boundary cell
|
|
90
|
+
// Boundary cell: β (empty), β (1/3), β (2/3), or roll over to a full β.
|
|
91
|
+
let partial = '';
|
|
92
|
+
let extra = 0;
|
|
93
|
+
if (remainder >= 0.83) {
|
|
94
|
+
extra = 1; // round up β fill the boundary cell completely
|
|
95
|
+
} else if (remainder >= 0.5) {
|
|
96
|
+
partial = 'β';
|
|
97
|
+
} else if (remainder >= 0.16) {
|
|
98
|
+
partial = 'β';
|
|
99
|
+
} // else: remainder is too small to show β leave the cell empty
|
|
100
|
+
const totalFull = Math.min(cells, fullCells + extra);
|
|
101
|
+
const usedCells = totalFull + (partial ? 1 : 0);
|
|
102
|
+
const empty = 'β'.repeat(Math.max(0, cells - usedCells));
|
|
103
|
+
return 'β'.repeat(totalFull) + partial + empty;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Format a remaining-seconds countdown as MM:SS (or H:MM when β₯ 1h).
|
|
108
|
+
*/
|
|
109
|
+
function formatTimer(remainingSec) {
|
|
110
|
+
// Defensive: non-finite/NaN inputs (e.g. clock skew, stringified Date) used
|
|
111
|
+
// to slip through and render as "NaN:NaN" or stretched seconds. Treat any
|
|
112
|
+
// weird input as expired rather than rendering garbage in the statusline.
|
|
113
|
+
if (!Number.isFinite(remainingSec) || remainingSec <= 0) return 'EXPIRED';
|
|
114
|
+
const totalSec = Math.max(0, Math.floor(remainingSec));
|
|
115
|
+
const h = Math.floor(totalSec / 3600);
|
|
116
|
+
const mRaw = Math.floor((totalSec % 3600) / 60);
|
|
117
|
+
const sRaw = totalSec % 60;
|
|
118
|
+
// Clamp explicitly so a future regression in the math (or padStart no-op
|
|
119
|
+
// truncation) can never produce m:sss like "4:547".
|
|
120
|
+
const m = Math.min(59, Math.max(0, mRaw));
|
|
121
|
+
const s = Math.min(59, Math.max(0, sRaw));
|
|
122
|
+
if (h > 0) return `${h}:${String(m).padStart(2, '0')}`;
|
|
123
|
+
return `${m}:${String(s).padStart(2, '0')}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Pick the cap-warn chip that should surface from any of the rate-limit
|
|
128
|
+
* windows, or null when none are at 90%+. When multiple windows are warning,
|
|
129
|
+
* the one that resets sooner wins (it's the more imminent block).
|
|
130
|
+
*/
|
|
131
|
+
export function pickCapWarn(caps) {
|
|
132
|
+
if (!caps || !Array.isArray(caps.windows)) return null;
|
|
133
|
+
const candidates = caps.windows
|
|
134
|
+
.filter((w) => Number.isFinite(w.usedPct) && w.usedPct >= 90)
|
|
135
|
+
.map((w) => ({ ...w, label: labelForKey(w.key).short }));
|
|
136
|
+
if (candidates.length === 0) return null;
|
|
137
|
+
candidates.sort((a, b) => {
|
|
138
|
+
const ar = Number.isFinite(a.resetsAt) ? a.resetsAt : Infinity;
|
|
139
|
+
const br = Number.isFinite(b.resetsAt) ? b.resetsAt : Infinity;
|
|
140
|
+
return ar - br;
|
|
141
|
+
});
|
|
142
|
+
return candidates[0];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Korean-style chip builder. Renders only when the session-start injection is
|
|
147
|
+
* enabled, so nothing changes for anyone who never asked for it.
|
|
148
|
+
*/
|
|
149
|
+
function buildKoreanSeg(c, isIcon, verbose) {
|
|
150
|
+
try {
|
|
151
|
+
if (!koreanStyleEnabled()) return null;
|
|
152
|
+
// Deliberately quiet (gray, one glyph): this is a "yes, it is on"
|
|
153
|
+
// confirmation, not a warning. Without it a silently-failed hook looks
|
|
154
|
+
// exactly like a working one, because the style only shows up when the
|
|
155
|
+
// model happens to write Korean. The icon says "writing guidance", not
|
|
156
|
+
// "Korean" β the verbose label already carries the language.
|
|
157
|
+
if (isIcon) return `${c(GRAY)}${verbose ? 'βοΈ Korean style' : 'βοΈ'}${c(RESET)}`;
|
|
158
|
+
return `${c(GRAY)}Korean style${c(RESET)}`;
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Version segment builder β "which copy of this tool am I looking at", plus
|
|
166
|
+
* the upgrade nudge when a newer one has been published.
|
|
167
|
+
*
|
|
168
|
+
* Two states, deliberately different in weight:
|
|
169
|
+
* - up to date β `v3.24.0` in gray. Identity context, not news.
|
|
170
|
+
* - update available β `β¬ v3.24.0 β 3.25.0` in yellow. Same tone as the
|
|
171
|
+
* other "you should do something eventually" chips, never red: nothing is
|
|
172
|
+
* broken, and a permanently-red statusline trains the eye to ignore red.
|
|
173
|
+
*
|
|
174
|
+
* A statusline cannot open a dialog, so the *asking* happens at session start
|
|
175
|
+
* (see route-scan --hook, which briefs the model to offer the upgrade). This
|
|
176
|
+
* chip is the persistent reminder between those offers, which is why it keeps
|
|
177
|
+
* rendering after the user declines β declining hides the session-start
|
|
178
|
+
* question, not the fact that a new version exists.
|
|
179
|
+
*/
|
|
180
|
+
function buildVersionSeg(version, update, c, isIcon, verbose) {
|
|
181
|
+
if (!version) return null;
|
|
182
|
+
if (update && update.available && update.latest) {
|
|
183
|
+
const body = verbose
|
|
184
|
+
? `Update v${version} β ${update.latest}`
|
|
185
|
+
: `v${version} β ${update.latest}`;
|
|
186
|
+
return `${c(YELLOW)}${isIcon ? 'β¬ ' : ''}${body}${c(RESET)}`;
|
|
187
|
+
}
|
|
188
|
+
return `${c(GRAY)}v${version}${c(RESET)}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Harness π
· segment builder β shared by the full report and the no-session
|
|
193
|
+
* fallback line. Best-effort: never throws into the statusline (corrupted
|
|
194
|
+
* CLAUDE.md, permission issue, etc. β null).
|
|
195
|
+
*/
|
|
196
|
+
function buildHarnessSeg(c, isIcon) {
|
|
197
|
+
try {
|
|
198
|
+
const harnessInfo = harnessStatusForStatusline(loadConfig());
|
|
199
|
+
if (!harnessInfo) return null;
|
|
200
|
+
const icon = isIcon ? 'π
·' : 'H';
|
|
201
|
+
if (harnessInfo.warning) {
|
|
202
|
+
// Warning state outranks the N/5 count β a runtime issue (repeated
|
|
203
|
+
// error / no-evidence / racing edits) is more actionable than a
|
|
204
|
+
// missing ratchet section. Always red so it stands out.
|
|
205
|
+
return `${c(RED)}${icon}β ${harnessInfo.warning}${c(RESET)}`;
|
|
206
|
+
}
|
|
207
|
+
if (harnessInfo.custom) return `${c(CYAN)}${icon} custom${c(RESET)}`;
|
|
208
|
+
const tone = harnessInfo.configured >= harnessInfo.total ? GREEN : YELLOW;
|
|
209
|
+
return `${c(tone)}${icon} ${harnessInfo.configured}/${harnessInfo.total}${c(RESET)}`;
|
|
210
|
+
} catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Cap-warn chip builder β shared by the full report and the no-session
|
|
217
|
+
* fallback line. At 90%+ the user wants to know "when can I send again", so
|
|
218
|
+
* the wall-clock reset time rides along in the same `π HH:MM` shape as the
|
|
219
|
+
* always-on usage segments.
|
|
220
|
+
*/
|
|
221
|
+
function buildCapWarnSeg(capWarn, c, isIcon) {
|
|
222
|
+
if (!capWarn) return null;
|
|
223
|
+
const pct = Math.round(capWarn.usedPct);
|
|
224
|
+
const clock = formatResetClock(capWarn.resetsAt);
|
|
225
|
+
const clockTail = clock ? ` π ${clock}` : '';
|
|
226
|
+
if (isIcon) {
|
|
227
|
+
// Gauge keeps shape parity with the always-on usage segment β the
|
|
228
|
+
// cap-warn is just the same gauge "filled to alarm". Visual continuity
|
|
229
|
+
// helps the eye understand "this is the 5H bar I was watching, just red now."
|
|
230
|
+
const bar = gaugeBar(pct);
|
|
231
|
+
return `${c(BOLD)}${c(RED)}π¨ ${capWarn.label} ${bar} ${pct}%${clockTail}${c(RESET)}`;
|
|
232
|
+
}
|
|
233
|
+
return `${c(BOLD)}${c(RED)}${capWarn.label} cap ${pct}%${clockTail}${c(RESET)}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Fallback line for when no session data exists in the analysis window.
|
|
238
|
+
* The stdin payload (rate limits, model) is still live in that case, and a
|
|
239
|
+
* 90%+ cap warning is exactly the kind of signal that must not disappear
|
|
240
|
+
* just because the user has been idle past the window β so cap-warn,
|
|
241
|
+
* harness, and model chips still render around the "no session data" note.
|
|
242
|
+
*/
|
|
243
|
+
export function formatNoSession({ caps = null, model = null, windowLabel = '', version = '', update = null } = {}, { color = true, mode = 'icon' } = {}) {
|
|
244
|
+
const c = (v) => (color ? v : '');
|
|
245
|
+
const isIcon = mode === 'icon';
|
|
246
|
+
const segs = [];
|
|
247
|
+
const capSeg = buildCapWarnSeg(pickCapWarn(caps), c, isIcon);
|
|
248
|
+
if (capSeg) segs.push(capSeg);
|
|
249
|
+
const versionSeg = buildVersionSeg(version, update, c, isIcon, false);
|
|
250
|
+
if (versionSeg && update && update.available) segs.push(versionSeg);
|
|
251
|
+
const harnessSeg = buildHarnessSeg(c, isIcon);
|
|
252
|
+
if (harnessSeg) segs.push(harnessSeg);
|
|
253
|
+
if (typeof model === 'string' && model.length > 0) {
|
|
254
|
+
segs.push(isIcon ? `${c(MAGENTA)}π€ ${model}${c(RESET)}` : `${c(MAGENTA)}${model}${c(RESET)}`);
|
|
255
|
+
}
|
|
256
|
+
segs.push(`${c(GRAY)}π§ no session data${windowLabel ? ` Β· ${windowLabel}` : ''}${c(RESET)}`);
|
|
257
|
+
if (versionSeg && !(update && update.available)) segs.push(versionSeg);
|
|
258
|
+
return segs.join(' Β· ') + (color ? '\x1b[K' : '');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* @param {object} data - output of main report pipeline (summary, ttl, cost, options, lastActivity)
|
|
263
|
+
* @param {object} [opts]
|
|
264
|
+
* @param {boolean} [opts.color=true] - emit ANSI escape codes
|
|
265
|
+
* @param {boolean} [opts.verbose=false] - longer layout with labels
|
|
266
|
+
* @param {boolean} [opts.timer=true] - show TTL countdown segment
|
|
267
|
+
* @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses π§ β³ π° instead of word labels.
|
|
268
|
+
* @param {string[]|null} [opts.segments] - whitelist of segments to render. Names: cap-warn, spike, version, harness, korean, model, hit, ttl, saved, delegated, doc2md, ctx, period, plus per-window keys (`five_hour`, `seven_day`, β¦). `5h`/`7d` are kept as aliases for back-compat. Null/undefined = all.
|
|
269
|
+
* @param {boolean} [opts.singleLine=false] - force the legacy one-line layout. By default, when the delegation ledger has lifetime savings, the routing totals lead on their own first line and everything else moves to line 2 (Claude Code renders multi-line statuslines; `--single-line` is the escape hatch for terminals that only show the first line).
|
|
270
|
+
*/
|
|
271
|
+
export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null, singleLine = false } = {}) {
|
|
272
|
+
const { summary, ttl, cost, options, lastActivity, contextWindow, ctxLive, spikeChip, caps, model } = data;
|
|
273
|
+
const { hitRate } = summary;
|
|
274
|
+
|
|
275
|
+
// Hit rate β color signal
|
|
276
|
+
const hitColor =
|
|
277
|
+
hitRate >= 0.85 ? GREEN :
|
|
278
|
+
hitRate >= 0.70 ? YELLOW :
|
|
279
|
+
RED;
|
|
280
|
+
|
|
281
|
+
// TTL dominance β color signal (1h = good, 5m = warning).
|
|
282
|
+
// The subscription plan fixes this, so the bucket rarely changes β it's the countdown that matters.
|
|
283
|
+
// When ttl.total === 0 (no cache writes observed in the window), we cannot
|
|
284
|
+
// infer the bucket. Default to 1h-sized countdown rather than 5m so Max
|
|
285
|
+
// users on idle don't see a misleading "Cache expires 5:00". The bucket
|
|
286
|
+
// label is shown as "?" so the uncertainty is visible.
|
|
287
|
+
//
|
|
288
|
+
// That default is exactly backwards behind a gateway. Bedrock and Vertex
|
|
289
|
+
// never report the per-bucket split, so ttl.total stays 0 there forever, and
|
|
290
|
+
// they offer only the 5m bucket: the countdown opened at 59:59 for a window
|
|
291
|
+
// that was really 5:00, overstating it twelvefold. So the fallback now
|
|
292
|
+
// follows the evidence β gateway seen, assume 5m; otherwise keep 1h. An
|
|
293
|
+
// explicit `ttlBucket` setting outranks both, so a gateway that starts
|
|
294
|
+
// reporting the split correctly does not need a release to be believed.
|
|
295
|
+
const hasTtlData = ttl.total > 0;
|
|
296
|
+
const override = data.ttlBucket === '5m' || data.ttlBucket === '1h' ? data.ttlBucket : null;
|
|
297
|
+
const is1h = override ? override === '1h' : (hasTtlData ? ttl.pct1h >= 0.5 : !ttl.gatewayObserved);
|
|
298
|
+
// Three grades of certainty, three labels: measured (`1h`/`5m`), inferred
|
|
299
|
+
// from a gateway model id (`5m?`), and unknown (`?`). Folding the middle
|
|
300
|
+
// case into `?` would hide a judgement the user could otherwise check.
|
|
301
|
+
const bucketKnown = hasTtlData || !!override;
|
|
302
|
+
const bucketLabel = bucketKnown
|
|
303
|
+
? (is1h ? '1h' : '5m')
|
|
304
|
+
: (ttl.gatewayObserved ? '5m?' : '?');
|
|
305
|
+
const bucketColor = bucketKnown
|
|
306
|
+
? (is1h ? GREEN : YELLOW)
|
|
307
|
+
: (ttl.gatewayObserved ? YELLOW : GRAY);
|
|
308
|
+
const ttlSeconds = is1h ? 3600 : 300;
|
|
309
|
+
|
|
310
|
+
const savings = cost?.savings ?? 0;
|
|
311
|
+
|
|
312
|
+
const c = (v) => (color ? v : '');
|
|
313
|
+
const isIcon = mode === 'icon';
|
|
314
|
+
|
|
315
|
+
// Labels per mode.
|
|
316
|
+
// text: "Cache hit 98.3%" | verbose: "Cache hit 98.3%"
|
|
317
|
+
// icon: "π§ 98.3%" | verbose: "π§ Cache hit 98.3%"
|
|
318
|
+
const hitLabel = isIcon
|
|
319
|
+
? (verbose ? 'π§ Cache hit' : 'π§ ')
|
|
320
|
+
: 'Cache hit';
|
|
321
|
+
const hitSeg = `${c(BOLD)}${hitLabel}${c(RESET)} ${c(hitColor)}${formatPct(hitRate)}${c(RESET)}`;
|
|
322
|
+
|
|
323
|
+
// text: "Cache saved $1.5K" | same in verbose
|
|
324
|
+
// icon: "π° $1.5K" | verbose: "π° Cache saved $1.5K"
|
|
325
|
+
const saveLabel = isIcon
|
|
326
|
+
? (verbose ? 'π° Cache saved' : 'π°')
|
|
327
|
+
: 'Cache saved';
|
|
328
|
+
const saveSeg = `${c(CYAN)}${saveLabel}${c(RESET)} ${formatMoney(savings)}`;
|
|
329
|
+
|
|
330
|
+
// Delegation savings β a DIFFERENT number from "Cache saved" above, which
|
|
331
|
+
// covers the prompt cache only. This one is what running work on a cheaper
|
|
332
|
+
// tier saved, summed from the rule registry route-scan maintains. Hidden
|
|
333
|
+
// when zero or absent: a permanent "$0" is noise for direct-API users and
|
|
334
|
+
// for anyone who has not delegated yet.
|
|
335
|
+
// "Routing saved" says what earned the money β work that ran on a cheaper
|
|
336
|
+
// model instead of this one. It leads the line rather than trailing it
|
|
337
|
+
// because it is the headline number of the whole tool, not a footnote.
|
|
338
|
+
// text: "Routing saved $3.2" | same in verbose
|
|
339
|
+
// icon: "π $3.2" | verbose: "π Routing saved $3.2"
|
|
340
|
+
const delegationSaved = Number(data.delegationSaved) || 0;
|
|
341
|
+
const delegateLabel = isIcon
|
|
342
|
+
? (verbose ? 'π Routing saved' : 'π')
|
|
343
|
+
: 'Routing saved';
|
|
344
|
+
// Zero savings has two very different causes and, until now, one appearance:
|
|
345
|
+
// nothing at all. "Never delegated" and "delegated plenty, but every run was
|
|
346
|
+
// dropped because the gateway model id could not be resolved" looked
|
|
347
|
+
// identical, so users in the second case had no reason to suspect anything
|
|
348
|
+
// was wrong. The count gets a chip; the explanation stays in `route-scan
|
|
349
|
+
// rules`, where there is room for it.
|
|
350
|
+
const unresolvedRuns = Number(data.unresolvedRuns) || 0;
|
|
351
|
+
const delegateSeg = delegationSaved > 0
|
|
352
|
+
? `${c(GREEN)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}`
|
|
353
|
+
: (unresolvedRuns > 0
|
|
354
|
+
? `${c(YELLOW)}π ${unresolvedRuns} unresolved${c(RESET)}`
|
|
355
|
+
: null);
|
|
356
|
+
|
|
357
|
+
// Document conversions β the same kind of number as "Routing saved", earned
|
|
358
|
+
// a different way: a document read as Markdown instead of attached whole.
|
|
359
|
+
// Kept as its own chip rather than folded into the routing total, because a
|
|
360
|
+
// single figure could not tell the reader which habit earned it.
|
|
361
|
+
//
|
|
362
|
+
// Conversions that saved nothing measurable still show as a count. For
|
|
363
|
+
// pptx/xlsx/docx the honest saving is zero β the client extracts much the
|
|
364
|
+
// same text β and a chip that disappeared on those would read as "doc2md
|
|
365
|
+
// did nothing" on the very formats it is the only way to open.
|
|
366
|
+
// icon: "π $0.42" verbose: "π Doc2md saved $0.42 Β· 12 docs"
|
|
367
|
+
// text: "Doc2md saved $0.42"
|
|
368
|
+
const doc2md = data.doc2mdTotals;
|
|
369
|
+
const doc2mdUsd = Number(doc2md && doc2md.total) || 0;
|
|
370
|
+
const doc2mdDocs = Number(doc2md && doc2md.docs) || 0;
|
|
371
|
+
const doc2mdLabel = isIcon
|
|
372
|
+
? (verbose ? 'π Doc2md saved' : 'π')
|
|
373
|
+
: 'Doc2md saved';
|
|
374
|
+
let doc2mdSeg = null;
|
|
375
|
+
if (doc2mdUsd > 0) {
|
|
376
|
+
doc2mdSeg = `${c(GREEN)}${doc2mdLabel}${c(RESET)} ${formatMoney(doc2mdUsd)}`
|
|
377
|
+
+ (verbose ? ` ${c(GRAY)}Β· ${doc2mdDocs} docs${c(RESET)}` : '');
|
|
378
|
+
} else if (doc2mdDocs > 0) {
|
|
379
|
+
const label = isIcon ? 'π' : 'Doc2md';
|
|
380
|
+
doc2mdSeg = `${c(GRAY)}${label} ${doc2mdDocs} docs${c(RESET)}`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// μ΄λ² λ¬ μ§μΆ μΈκ·Έλ¨ΌνΈ. λ¬λ ₯ μ(1μΌ 00μ κΈ°μ€) μ§μΆ μΆμ μΉλΌμ cap μ΄ μλ
|
|
384
|
+
// κ²μ΄νΈμ¨μ΄ μ¬μ©μμκ²λ νμ μλ―Έκ° μλ€. μ λ³΄μ± μ§νμ΄λ―λ‘ νμμΌλ‘,
|
|
385
|
+
// κ²μ΄μ§λ€ λ€μ μ‘°μ©ν λλλ€.
|
|
386
|
+
// icon: "π΅ Sep $42.1"
|
|
387
|
+
// text: "Sep spend $42.1"
|
|
388
|
+
// verbose: "Sep spend $42.1 (since Sep 1)"
|
|
389
|
+
const month = data.monthSpend;
|
|
390
|
+
let monthSeg = null;
|
|
391
|
+
if (month && Number(month.usd) > 0) {
|
|
392
|
+
const amt = formatMoney(Number(month.usd));
|
|
393
|
+
if (isIcon) {
|
|
394
|
+
monthSeg = `${c(GRAY)}π΅ ${month.label} ${amt}${c(RESET)}`;
|
|
395
|
+
} else if (verbose) {
|
|
396
|
+
monthSeg = `${c(GRAY)}${month.label} spend ${amt} (since ${month.label} 1)${c(RESET)}`;
|
|
397
|
+
} else {
|
|
398
|
+
monthSeg = `${c(GRAY)}${month.label} spend ${amt}${c(RESET)}`;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Routing-savings headline line (multi-line layout). The lifetime sum from
|
|
403
|
+
// the delegation ledger β the number the whole tool exists to grow, so it
|
|
404
|
+
// gets line 1 to itself while the diagnostics move to line 2.
|
|
405
|
+
//
|
|
406
|
+
// One figure, not three. The weekly and monthly sums used to sit here too,
|
|
407
|
+
// but the per-model breakdown that follows is a lifetime split, and next to
|
|
408
|
+
// a row of rolling windows it read as a breakdown of whichever one it
|
|
409
|
+
// touched. A single lifetime total makes the whole line one timeframe with
|
|
410
|
+
// nothing to mismatch.
|
|
411
|
+
// icon: "π Routing saved $9.8 | opusβhaiku 2Γ $6.4 Β· fableβsonnet 1Γ $3.4"
|
|
412
|
+
// text: "Routing saved $9.8 | opusβhaiku 2Γ $6.4 Β· fableβsonnet 1Γ $3.4"
|
|
413
|
+
const totals = data.delegationTotals;
|
|
414
|
+
let totalsLine = null;
|
|
415
|
+
if (!singleLine && totals && Number(totals.total) > 0) {
|
|
416
|
+
const head = isIcon ? 'π Routing saved' : 'Routing saved';
|
|
417
|
+
// Model changes behind the total, family-level and version-free: `opus β
|
|
418
|
+
// haiku 2Γ $0.6`. Versions bump constantly and add nothing here β the
|
|
419
|
+
// shape of the trade is the point.
|
|
420
|
+
//
|
|
421
|
+
// Every pair is listed, not a top-N: the amounts are shown next to a
|
|
422
|
+
// total, so a truncated list reads as "this is what the total is made of"
|
|
423
|
+
// and quietly misstates it. Families collapse the list on their own β
|
|
424
|
+
// there are only so many tier-to-tier moves β so it stays short without
|
|
425
|
+
// being cut.
|
|
426
|
+
const pairs = Array.isArray(totals.pairs) ? totals.pairs : [];
|
|
427
|
+
// The breakdown stays entirely gray, amounts included. Only the total is
|
|
428
|
+
// green: it is the headline figure, and repeating that green on every
|
|
429
|
+
// component would flatten the line into one loud block with nothing to
|
|
430
|
+
// land on first.
|
|
431
|
+
const pairText = pairs
|
|
432
|
+
.map((p) => `${c(GRAY)}${p.from}β${p.to} ${p.runs}Γ ${formatMoney(p.usd)}${c(RESET)}`)
|
|
433
|
+
.join(` ${c(GRAY)}Β·${c(RESET)} `);
|
|
434
|
+
totalsLine =
|
|
435
|
+
`${c(GREEN)}${c(BOLD)}${head}${c(RESET)} ` +
|
|
436
|
+
`${c(GREEN)}${formatMoney(Number(totals.total) || 0)}${c(RESET)}` +
|
|
437
|
+
(pairText ? ` ${c(GRAY)}|${c(RESET)} ${pairText}` : '');
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Doc2md's own headline line, same anatomy as the routing one: a green
|
|
441
|
+
// lifetime total, then a gray per-format breakdown. Only built when there
|
|
442
|
+
// is money to report β a bare document count stays an inline chip, since a
|
|
443
|
+
// whole line for "3 docs" would be all frame and no figure.
|
|
444
|
+
// icon: "π Doc2md saved $1.8 | pptx 1Γ $1.55 Β· pdf 2Γ $0.27"
|
|
445
|
+
let doc2mdLine = null;
|
|
446
|
+
if (!singleLine && doc2mdUsd > 0) {
|
|
447
|
+
const head = isIcon ? 'π Doc2md saved' : 'Doc2md saved';
|
|
448
|
+
const byExt = Array.isArray(doc2md.byExt) ? doc2md.byExt : [];
|
|
449
|
+
// Formats that earned money show it; formats with no baseline to measure
|
|
450
|
+
// against are counted instead. Printing "$0.00" next to real amounts reads
|
|
451
|
+
// as "this format saved nothing", when what it means is that nobody has
|
|
452
|
+
// measured what the alternative costs. Only .xls sits there now β the
|
|
453
|
+
// pre-2007 binary, which has no container to price against.
|
|
454
|
+
const paid = byExt.filter((r) => r.usd > 0);
|
|
455
|
+
const unpriced = byExt.filter((r) => !(r.usd > 0));
|
|
456
|
+
const parts = paid.map((r) => `${c(GRAY)}${r.ext} ${r.docs}Γ ${formatMoney(r.usd)}${c(RESET)}`);
|
|
457
|
+
if (unpriced.length) {
|
|
458
|
+
const counted = unpriced.map((r) => `${r.ext} ${r.docs}Γ`).join(' Β· ');
|
|
459
|
+
parts.push(`${c(GRAY)}${counted} ${verbose ? '(no baseline)' : '(n/a)'}${c(RESET)}`);
|
|
460
|
+
}
|
|
461
|
+
const extText = parts.join(` ${c(GRAY)}Β·${c(RESET)} `);
|
|
462
|
+
doc2mdLine =
|
|
463
|
+
`${c(GREEN)}${c(BOLD)}${head}${c(RESET)} ` +
|
|
464
|
+
`${c(GREEN)}${formatMoney(doc2mdUsd)}${c(RESET)}` +
|
|
465
|
+
(extText ? ` ${c(GRAY)}|${c(RESET)} ${extText}` : '');
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Period label honors hour-precision configs (`mode 6h` β "6h", `mode 1d` β "1d").
|
|
469
|
+
// Fall back to legacy `${days}d` when callers haven't supplied a label.
|
|
470
|
+
const periodLabel = options.windowLabel || `${options.days}d`;
|
|
471
|
+
const periodSeg = verbose
|
|
472
|
+
? `${c(GRAY)}last ${periodLabel}${c(RESET)}`
|
|
473
|
+
: `${c(GRAY)}${periodLabel}${c(RESET)}`;
|
|
474
|
+
|
|
475
|
+
// TTL countdown β how much time is left on the last API call's cache entry.
|
|
476
|
+
// Matches Anthropic's actual prompt-cache behaviour: each call starts a fresh
|
|
477
|
+
// TTL window, and the next call (hit) within that window resets it. So the
|
|
478
|
+
// countdown visibly ticks down between prompts, and "resets" happens as a
|
|
479
|
+
// jump back toward the bucket max the moment you send another message.
|
|
480
|
+
// Compact modes drop the bucket label β it's read as part of the clock
|
|
481
|
+
// ("1h 59:58" gets parsed as "1 hour 59 minutes 58 seconds"). The bucket
|
|
482
|
+
// is plan-determined and rarely changes, so verbose mode is where it belongs.
|
|
483
|
+
// text compact: "Expires 59:58"
|
|
484
|
+
// text verbose: "1h bucket Β· expires in 59:58"
|
|
485
|
+
// icon compact: "β³ 59:58"
|
|
486
|
+
// icon verbose: "β³ Expires 1h 59:58"
|
|
487
|
+
let ttlSeg;
|
|
488
|
+
if (timer && lastActivity) {
|
|
489
|
+
// Coerce to a numeric ms timestamp. Some upstream paths handed in a Date,
|
|
490
|
+
// a stringified ISO timestamp, or epoch-seconds β any of which silently
|
|
491
|
+
// produces NaN/huge values when subtracted from Date.now(), which then
|
|
492
|
+
// bypasses formatTimer's normal MM:SS shape.
|
|
493
|
+
const laMs =
|
|
494
|
+
typeof lastActivity === 'number'
|
|
495
|
+
? (lastActivity < 1e12 ? lastActivity * 1000 : lastActivity) // seconds β ms
|
|
496
|
+
: (lastActivity instanceof Date ? lastActivity.getTime() : Date.parse(lastActivity));
|
|
497
|
+
const elapsed = Number.isFinite(laMs) ? (Date.now() - laMs) / 1000 : Infinity;
|
|
498
|
+
// Clamp remaining into the bucket so a clock-skew or stale-state edge case
|
|
499
|
+
// can't display a value larger than the bucket itself.
|
|
500
|
+
const remaining = Math.min(ttlSeconds, ttlSeconds - elapsed);
|
|
501
|
+
const text = formatTimer(remaining);
|
|
502
|
+
const pct = remaining / ttlSeconds;
|
|
503
|
+
// Percentages are the wrong unit in a 5-minute bucket: 30% of it is 90
|
|
504
|
+
// seconds, and green there reads as comfort the user does not have. Below
|
|
505
|
+
// an hour the thresholds are absolute, so the color tracks whether there
|
|
506
|
+
// is time to finish a thought rather than a share of a short window.
|
|
507
|
+
const timerColor =
|
|
508
|
+
remaining <= 0 ? RED :
|
|
509
|
+
is1h
|
|
510
|
+
? (pct > 0.30 ? GREEN : pct > 0.10 ? YELLOW : RED)
|
|
511
|
+
: (remaining > 60 ? GREEN : remaining > 30 ? YELLOW : RED);
|
|
512
|
+
|
|
513
|
+
if (isIcon && verbose) {
|
|
514
|
+
// Drop bucket here too β `β³ Expires 1h 57:20` reads as "1h 57m 20s left"
|
|
515
|
+
// for the same reason the compact form did. The bucket lives in the
|
|
516
|
+
// text-verbose layout where the "bucket" word + `Β·` separator make it
|
|
517
|
+
// unambiguous.
|
|
518
|
+
ttlSeg = `${c(timerColor)}β³ Cache expires ${text}${c(RESET)}`;
|
|
519
|
+
} else if (isIcon) {
|
|
520
|
+
ttlSeg = `${c(timerColor)}β³ ${text}${c(RESET)}`;
|
|
521
|
+
} else if (verbose) {
|
|
522
|
+
ttlSeg = `${c(bucketColor)}Cache ${bucketLabel} bucket${c(RESET)} Β· ${c(timerColor)}expires in ${text}${c(RESET)}`;
|
|
523
|
+
} else {
|
|
524
|
+
ttlSeg = `${c(timerColor)}Cache expires ${text}${c(RESET)}`;
|
|
525
|
+
}
|
|
526
|
+
} else {
|
|
527
|
+
// No-timer fallback: only the bucket is available, so we show just that.
|
|
528
|
+
if (isIcon) {
|
|
529
|
+
const prefix = verbose ? 'β³ Cache bucket ' : 'β³ ';
|
|
530
|
+
ttlSeg = `${c(bucketColor)}${prefix}${bucketLabel}${c(RESET)}`;
|
|
531
|
+
} else if (verbose) {
|
|
532
|
+
ttlSeg = `${c(bucketColor)}Cache ${bucketLabel} bucket${c(RESET)}`;
|
|
533
|
+
} else {
|
|
534
|
+
ttlSeg = `${c(bucketColor)}Cache bucket ${bucketLabel}${c(RESET)}`;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Context chip. Two data sources, best first:
|
|
539
|
+
//
|
|
540
|
+
// 1. Live fill level from Claude Code's stdin (`context_window.used_percentage`)
|
|
541
|
+
// β the current session's actual usage, refreshed every render. Rendered
|
|
542
|
+
// as `π¦ 68%` and colored by fill (green <70, yellow 70β89, red 90+),
|
|
543
|
+
// matching the cap-segment tone scale.
|
|
544
|
+
// 2. Fallback (table view / older Claude Code): transcript-inferred window
|
|
545
|
+
// size. Note the semantics: `size === '1M'` means a real request already
|
|
546
|
+
// carried >210k input tokens β actual heavy usage, not just the model
|
|
547
|
+
// supporting 1M. Current models are all 1M by default with no price
|
|
548
|
+
// premium, so this renders yellow ("your context is genuinely big"),
|
|
549
|
+
// not red ("expensive mode on") like it used to.
|
|
550
|
+
let ctxSeg = null;
|
|
551
|
+
if (ctxLive && Number.isFinite(ctxLive.usedPct)) {
|
|
552
|
+
const pct = Math.max(0, Math.round(ctxLive.usedPct));
|
|
553
|
+
const tone = pct >= 90 ? RED : pct >= 70 ? YELLOW : GREEN;
|
|
554
|
+
const sizeLabel = ctxLive.size
|
|
555
|
+
? (ctxLive.size >= 900_000 ? '1M' : `${Math.round(ctxLive.size / 1000)}k`)
|
|
556
|
+
: null;
|
|
557
|
+
const longLabel = sizeLabel ? `${pct}% of ${sizeLabel}` : `${pct}%`;
|
|
558
|
+
if (isIcon && verbose) {
|
|
559
|
+
ctxSeg = `${c(tone)}π¦ Ctx ${longLabel}${c(RESET)}`;
|
|
560
|
+
} else if (isIcon) {
|
|
561
|
+
ctxSeg = `${c(tone)}π¦ ${pct}%${c(RESET)}`;
|
|
562
|
+
} else {
|
|
563
|
+
ctxSeg = `${c(tone)}Ctx ${longLabel}${c(RESET)}`;
|
|
564
|
+
}
|
|
565
|
+
} else if (contextWindow && contextWindow.size && contextWindow.size !== 'unknown') {
|
|
566
|
+
const label = contextWindow.size === '1M' ? '1M' : '200k';
|
|
567
|
+
// Yellow is reserved for the 500k warn line. A 1M window by itself is the
|
|
568
|
+
// default on every current model, so colouring it yellow cried wolf.
|
|
569
|
+
const ctxColor = contextWindow.overWarn ? YELLOW : GREEN;
|
|
570
|
+
if (isIcon && verbose) {
|
|
571
|
+
ctxSeg = `${c(ctxColor)}π¦ Ctx ${label}${c(RESET)}`;
|
|
572
|
+
} else if (isIcon) {
|
|
573
|
+
ctxSeg = `${c(ctxColor)}π¦ ${label}${c(RESET)}`;
|
|
574
|
+
} else {
|
|
575
|
+
ctxSeg = `${c(ctxColor)}Ctx ${label}${c(RESET)}`;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Spike chip β one word only, keeps the statusline single-line.
|
|
580
|
+
const spikeSeg = spikeChip ? `${c(RED)}${spikeChip}${c(RESET)}` : null;
|
|
581
|
+
|
|
582
|
+
// Harness π
· N/5 β project-scoped completeness of CLAUDE.md harness rules.
|
|
583
|
+
// Silent when the project hasn't opted in (no CLAUDE.md and no .claude/);
|
|
584
|
+
// otherwise renders π
· 5/5 (green) / π
· N/5 (yellow) so the user can spot
|
|
585
|
+
// a missing section at a glance and know to run `harness init`.
|
|
586
|
+
const harnessSeg = buildHarnessSeg(c, isIcon);
|
|
587
|
+
|
|
588
|
+
// Version / upgrade chip. Read from a cache written by a detached background
|
|
589
|
+
// check β this render path never touches the network.
|
|
590
|
+
const versionSeg = buildVersionSeg(options.version, data.update, c, isIcon, verbose);
|
|
591
|
+
const updateAvailable = !!(data.update && data.update.available);
|
|
592
|
+
|
|
593
|
+
// Korean-style chip β rendered only when the session-start injection is on.
|
|
594
|
+
const koreanSeg = buildKoreanSeg(c, isIcon, verbose);
|
|
595
|
+
|
|
596
|
+
// Model chip β pulled from Claude Code's stdin payload (`model.display_name`).
|
|
597
|
+
// Cheap identity context: useful when the user toggles between Sonnet/Opus
|
|
598
|
+
// mid-session and wants to confirm at a glance which one is answering.
|
|
599
|
+
let modelSeg = null;
|
|
600
|
+
if (typeof model === 'string' && model.length > 0) {
|
|
601
|
+
// π€ + name is enough β the emoji disambiguates so the literal word "Model"
|
|
602
|
+
// is dead weight in icon mode. Text modes keep the bare name; the magenta
|
|
603
|
+
// tone marks it as identity context.
|
|
604
|
+
if (isIcon) {
|
|
605
|
+
modelSeg = `${c(MAGENTA)}π€ ${model}${c(RESET)}`;
|
|
606
|
+
} else {
|
|
607
|
+
modelSeg = `${c(MAGENTA)}${model}${c(RESET)}`;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Always-on usage segments β what /usage shows in Claude Code, mirrored
|
|
612
|
+
// to the statusline so the user doesn't have to slash-command for it.
|
|
613
|
+
// Today the stdin payload exposes the 5h ("Current session") and 7-day
|
|
614
|
+
// rolling ("Current week") windows; if Anthropic ships more (e.g. a
|
|
615
|
+
// Sonnet-only weekly), they render automatically with derived labels.
|
|
616
|
+
// Each renders as `{label} {pct}% Β· {countdown}`. The window promoted to the
|
|
617
|
+
// cap-warn chip is suppressed here to avoid duplicate noise β but ONLY that
|
|
618
|
+
// one. When several windows are at 90%+ the chip shows just the most
|
|
619
|
+
// imminent, so the others must keep their always-on segment (red) or they'd
|
|
620
|
+
// vanish from the statusline entirely at the worst possible moment.
|
|
621
|
+
function buildUsageSeg({ labels, info, color: tone, suppressed }) {
|
|
622
|
+
if (!info || !Number.isFinite(info.usedPct)) return null;
|
|
623
|
+
if (suppressed) return null; // cap-warn chip handles this window
|
|
624
|
+
const pct = Math.round(info.usedPct);
|
|
625
|
+
// Show only the wall-clock reset time (e.g. `π 21:10`). Absolute time
|
|
626
|
+
// doesn't tick second-by-second so the statusline reads stable, and the
|
|
627
|
+
// π icon itself separates the percent from the clock β no extra `Β·` needed.
|
|
628
|
+
const clock = formatResetClock(info.resetsAt);
|
|
629
|
+
const tail = clock ? ` π ${clock}` : '';
|
|
630
|
+
// `cap` reads as a rate-limit ceiling rather than a duration. Icon mode
|
|
631
|
+
// leans on the icon to identify the window (β¦ = session/now, π
= week),
|
|
632
|
+
// so the 5H label is empty while the 7D label spells out "weekly". Text and
|
|
633
|
+
// verbose modes keep the `5H`/`7D` short label since they have no icon.
|
|
634
|
+
// Icon mode renders an inline β°β± gauge instead of the literal "cap used" β
|
|
635
|
+
// a glance at the bar conveys urgency faster than parsing a percent number,
|
|
636
|
+
// and the gauge stays the same width as the percent climbs.
|
|
637
|
+
// LiteLLM μμ° μλμ°λ νΌμΌνΈλ§μΌλ‘λ κ°μ΄ μ μ€λ―λ‘(μμ° ν¬κΈ°λ₯Ό λͺ¨λ¦)
|
|
638
|
+
// κΈμ‘μ ν¨κ» λ³΄μ¬ μ€λ€: `π budget β°β± 34% $34/$100`.
|
|
639
|
+
const money =
|
|
640
|
+
Number.isFinite(info.maxBudget) && info.maxBudget > 0
|
|
641
|
+
? ` ${formatMoney(Number(info.spend) || 0)}/${formatMoney(info.maxBudget)}`
|
|
642
|
+
: '';
|
|
643
|
+
if (isIcon) {
|
|
644
|
+
const labelPart = labels.usageLabel ? `${labels.usageLabel} ` : '';
|
|
645
|
+
const bar = gaugeBar(pct);
|
|
646
|
+
return `${c(tone)}${labels.icon} ${labelPart}${bar} ${pct}%${money}${tail}${c(RESET)}`;
|
|
647
|
+
}
|
|
648
|
+
if (verbose) {
|
|
649
|
+
return `${c(tone)}${labels.short} cap ${pct}% used${money}${tail}${c(RESET)}`;
|
|
650
|
+
}
|
|
651
|
+
return `${c(tone)}${labels.short} cap ${pct}%${money}${tail}${c(RESET)}`;
|
|
652
|
+
}
|
|
653
|
+
// Color tone: green <70%, yellow 70-89%, red 90+% (a 90+% window only
|
|
654
|
+
// renders here when a *different* window won the cap-warn chip slot).
|
|
655
|
+
function usageTone(info) {
|
|
656
|
+
if (!info || !Number.isFinite(info.usedPct)) return GRAY;
|
|
657
|
+
if (info.usedPct >= 90) return RED;
|
|
658
|
+
if (info.usedPct >= 70) return YELLOW;
|
|
659
|
+
return GREEN;
|
|
660
|
+
}
|
|
661
|
+
// Cap-warn chip β leads everything when ANY rate-limit window is at 90%+.
|
|
662
|
+
// It's the most actionable signal we can show: no point optimizing cache
|
|
663
|
+
// hits if you're about to be rate-limited anyway. The chip body matches the
|
|
664
|
+
// English shape `π¨ 5H 94%` / `π¨ 7D 92%` so history parsers can dedupe on it.
|
|
665
|
+
// Computed before the usage segments so they know which window it claimed.
|
|
666
|
+
const capWarn = pickCapWarn(caps);
|
|
667
|
+
const usageSegs = [];
|
|
668
|
+
if (caps && Array.isArray(caps.windows)) {
|
|
669
|
+
for (const win of caps.windows) {
|
|
670
|
+
const labels = labelForKey(win.key);
|
|
671
|
+
const seg = buildUsageSeg({
|
|
672
|
+
labels,
|
|
673
|
+
info: win,
|
|
674
|
+
color: usageTone(win),
|
|
675
|
+
suppressed: !!capWarn && capWarn.key === win.key,
|
|
676
|
+
});
|
|
677
|
+
if (seg) usageSegs.push({ key: win.key, seg });
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const capWarnSeg = buildCapWarnSeg(capWarn, c, isIcon);
|
|
682
|
+
|
|
683
|
+
// Warning chip leads β a glance at the statusline catches "something's wrong"
|
|
684
|
+
// before parsing any numbers. Healthy states have no chip and look unchanged.
|
|
685
|
+
// Cap-warn outranks spike: an imminent rate-limit block is more urgent than
|
|
686
|
+
// a single spiking session.
|
|
687
|
+
const allow = segments && segments.length
|
|
688
|
+
? new Set(segments.map((s) => s.toLowerCase()))
|
|
689
|
+
: null;
|
|
690
|
+
const want = (name) => !allow || allow.has(name);
|
|
691
|
+
// Legacy whitelist aliases: `5h` β `five_hour`, `7d` β `seven_day`. So
|
|
692
|
+
// existing `--segments` configs keep working after the generic refactor.
|
|
693
|
+
const usageWant = (key) => {
|
|
694
|
+
if (!allow) return true;
|
|
695
|
+
if (allow.has(key.toLowerCase())) return true;
|
|
696
|
+
if (key === 'five_hour' && allow.has('5h')) return true;
|
|
697
|
+
if (key === 'seven_day' && allow.has('7d')) return true;
|
|
698
|
+
return false;
|
|
699
|
+
};
|
|
700
|
+
const segs = [];
|
|
701
|
+
if (capWarnSeg && want('cap-warn')) segs.push(capWarnSeg);
|
|
702
|
+
if (spikeSeg && want('spike')) segs.push(spikeSeg);
|
|
703
|
+
// An available upgrade rides up front with the other "act on this" chips.
|
|
704
|
+
// When there is nothing to upgrade to, the same segment is pure identity and
|
|
705
|
+
// sits at the tail instead (pushed after `saved`, below).
|
|
706
|
+
if (versionSeg && updateAvailable && want('version')) segs.push(versionSeg);
|
|
707
|
+
if (harnessSeg && want('harness')) segs.push(harnessSeg);
|
|
708
|
+
if (koreanSeg && want('korean')) segs.push(koreanSeg);
|
|
709
|
+
if (modelSeg && want('model')) segs.push(modelSeg);
|
|
710
|
+
// Delegation savings ride up front, next to the model that would otherwise
|
|
711
|
+
// have done the work. "Cache saved" stays at the tail: it is a lifetime brag
|
|
712
|
+
// stat, while this one is the point of the tool.
|
|
713
|
+
// When the totals headline owns line 1, the inline session chip would
|
|
714
|
+
// repeat the same story on line 2 β drop it there.
|
|
715
|
+
if (delegateSeg && want('delegated') && !totalsLine) segs.push(delegateSeg);
|
|
716
|
+
// Only when it did not already earn a headline line above.
|
|
717
|
+
if (doc2mdSeg && want('doc2md') && !doc2mdLine) segs.push(doc2mdSeg);
|
|
718
|
+
if (want('hit')) segs.push(hitSeg);
|
|
719
|
+
if (want('ttl')) segs.push(ttlSeg);
|
|
720
|
+
for (const { key, seg } of usageSegs) {
|
|
721
|
+
if (usageWant(key)) segs.push(seg);
|
|
722
|
+
}
|
|
723
|
+
if (monthSeg && want('month')) segs.push(monthSeg);
|
|
724
|
+
if (ctxSeg && want('ctx')) segs.push(ctxSeg);
|
|
725
|
+
// Cache saved is the "lifetime brag" stat β useful but not actionable, so
|
|
726
|
+
// it sits near the tail. The period label closes the line as a quiet
|
|
727
|
+
// timeframe footer.
|
|
728
|
+
if (want('saved')) segs.push(saveSeg);
|
|
729
|
+
if (versionSeg && !updateAvailable && want('version')) segs.push(versionSeg);
|
|
730
|
+
if (want('period')) segs.push(periodSeg);
|
|
731
|
+
// Trailing erase-to-end-of-line so any leftover characters from a previous
|
|
732
|
+
// (longer) statusline render don't bleed into ours. \x1b[K is the standard
|
|
733
|
+
// "erase from cursor to EOL" CSI. Only emitted when color (i.e. ANSI) is
|
|
734
|
+
// allowed β --no-color/NO_COLOR consumers expect escape-free output.
|
|
735
|
+
const eol = color ? '\x1b[K' : '';
|
|
736
|
+
const rest = segs.join(' Β· ') + eol;
|
|
737
|
+
// Each savings source that earned real money gets a headline line, ordered
|
|
738
|
+
// biggest saver first β the top line is the one the eye lands on, so it
|
|
739
|
+
// goes to whichever habit is actually paying for the tool. The diagnostics
|
|
740
|
+
// line always closes.
|
|
741
|
+
const headlines = [];
|
|
742
|
+
if (totalsLine && want('delegated')) {
|
|
743
|
+
headlines.push({ usd: Number(totals && totals.total) || 0, line: totalsLine });
|
|
744
|
+
}
|
|
745
|
+
if (doc2mdLine && want('doc2md')) {
|
|
746
|
+
headlines.push({ usd: doc2mdUsd, line: doc2mdLine });
|
|
747
|
+
}
|
|
748
|
+
headlines.sort((a, b) => b.usd - a.usd);
|
|
749
|
+
return headlines.map((h) => h.line + eol + '\n').join('') + rest;
|
|
750
|
+
}
|