sprag-cli 3.44.0 → 3.46.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.ko.md +3 -1
- package/README.md +6 -2
- package/bin/cli.js +23 -25
- package/package.json +2 -1
- package/presets/model-rules.json +50 -9
- package/src/changelog.js +34 -0
- package/src/commands/brief.js +13 -1
- package/src/commands/install.js +1 -0
- package/src/commands/mode.js +22 -2
- package/src/commands/route-scan.js +66 -7
- package/src/config.js +22 -3
- package/src/formatters/statusline.js +279 -119
- package/src/glyphs.js +95 -0
- package/src/installer.js +50 -1
- package/src/litellm-budget.js +16 -2
- package/src/model-rules.js +53 -17
- package/src/route-inject.js +94 -0
- package/src/route-scan.js +24 -1
- package/src/seed-rules.js +26 -14
- package/src/statusline-mode.js +95 -0
- package/src/update-check.js +138 -1
- package/src/window-labels.js +17 -7
|
@@ -17,11 +17,13 @@
|
|
|
17
17
|
* }
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { formatResetClock } from '../format-time.js';
|
|
20
|
+
import { formatResetClock, formatResetIn } from '../format-time.js';
|
|
21
|
+
import { glyphsFor } from '../glyphs.js';
|
|
21
22
|
import { labelForKey } from '../window-labels.js';
|
|
22
23
|
import { harnessStatusForStatusline } from '../harness.js';
|
|
23
24
|
import { loadConfig } from '../config.js';
|
|
24
25
|
import { koreanStyleEnabled } from '../korean-style.js';
|
|
26
|
+
import { MIN_VOTES } from '../model-alias.js';
|
|
25
27
|
|
|
26
28
|
// The 8-color ANSI defaults (RED=31, GREEN=32, YELLOW=33…) read as garish
|
|
27
29
|
// next to each other — terminal palettes set them with unbalanced perceptual
|
|
@@ -66,41 +68,86 @@ function formatPct(v) {
|
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
/**
|
|
69
|
-
* Render
|
|
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.
|
|
71
|
+
* Render the gauge for `pct` (0..100) as a row of separate ticks.
|
|
73
72
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
73
|
+
* The bar used to be six cells of one continuous block, with the boundary cell
|
|
74
|
+
* subdivided into eighths for sub-cell precision. That precision had no reader:
|
|
75
|
+
* every chip that draws a gauge prints the exact percentage immediately after it
|
|
76
|
+
* (`▰▰▰▱▱▱ 26%`), so the bar's job is the glance, not the number. What the solid
|
|
77
|
+
* run cost instead was legibility — adjacent full cells fuse into one shape, and
|
|
78
|
+
* a shape has no quantity you can read off it. Separate ticks do: twelve of them
|
|
79
|
+
* are countable at a glance and land on a familiar 1/12 grid.
|
|
79
80
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
81
|
+
* Two ends get special treatment, because they are the two readings that change
|
|
82
|
+
* a decision. A full row means the limit is reached, so nothing below 100% may
|
|
83
|
+
* draw one; conversely anything above zero draws at least one tick, so "barely
|
|
84
|
+
* started" never looks like "untouched". Rounding alone gets both wrong: it fills
|
|
85
|
+
* the last cell from 96% and leaves the first empty until 5%.
|
|
86
|
+
*
|
|
87
|
+
* Glyphs come from the mode's set (src/glyphs.js) rather than being fixed here.
|
|
88
|
+
* ▰▱ is the shape we want, but JetBrains Mono ships neither character, and a
|
|
89
|
+
* missing glyph is drawn by a fallback font whose advance width does not match
|
|
90
|
+
* the cell grid — which is the whole reason narrow mode exists. So narrow draws
|
|
91
|
+
* the same segmented bar with ■□, which that font does have.
|
|
92
|
+
*
|
|
93
|
+
* @param {number} pct
|
|
94
|
+
* @param {string} [mode] label mode; selects the tick glyphs
|
|
95
|
+
* @returns {{ filled: string, empty: string }} the two halves, so a caller can
|
|
96
|
+
* color them separately
|
|
83
97
|
*/
|
|
84
|
-
export function
|
|
85
|
-
const
|
|
98
|
+
export function gaugeParts(pct, mode = 'icon') {
|
|
99
|
+
const CELLS = 12;
|
|
100
|
+
const g = glyphsFor(mode);
|
|
86
101
|
const clamped = Math.max(0, Math.min(100, pct));
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
let ticks = Math.round((clamped / 100) * CELLS);
|
|
103
|
+
// A full row is reserved for an actual 100%: at the cap the gauge alone has to
|
|
104
|
+
// say so, and 96% rounding up to twelve would spend that signal on a window
|
|
105
|
+
// that still has room.
|
|
106
|
+
if (ticks >= CELLS && clamped < 100) ticks = CELLS - 1;
|
|
107
|
+
// And any nonzero usage earns a tick, so an empty row means untouched.
|
|
108
|
+
if (ticks === 0 && clamped > 0) ticks = 1;
|
|
109
|
+
return {
|
|
110
|
+
filled: g.gaugeFull.repeat(ticks),
|
|
111
|
+
empty: g.gaugeEmpty.repeat(CELLS - ticks),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The bar as one string, for callers that render it in a single color.
|
|
117
|
+
* @param {number} pct
|
|
118
|
+
* @param {string} [mode] label mode; selects the tick glyphs
|
|
119
|
+
* @returns {string}
|
|
120
|
+
*/
|
|
121
|
+
export function gaugeBar(pct, mode = 'icon') {
|
|
122
|
+
const { filled, empty } = gaugeParts(pct, mode);
|
|
123
|
+
return filled + empty;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The bar with the unused ticks dimmed.
|
|
128
|
+
*
|
|
129
|
+
* The hollow tick already distinguishes the two halves by shape, so color is not
|
|
130
|
+
* carrying that distinction the way it had to when both halves were the same
|
|
131
|
+
* solid block. It still helps: graying the remainder keeps the filled run as the
|
|
132
|
+
* only thing at full brightness, which is what the eye should land on. The
|
|
133
|
+
* segment tone is reinstated at the end so the text after the bar keeps the color
|
|
134
|
+
* it started in.
|
|
135
|
+
*
|
|
136
|
+
* @param {number} pct
|
|
137
|
+
* @param {(code: string) => string} c - color emitter (returns '' when color is off)
|
|
138
|
+
* @param {string} tone - the segment's own color code
|
|
139
|
+
* @param {boolean} color - whether ANSI is allowed. Passed explicitly rather
|
|
140
|
+
* than inferred from `c` emitting nothing: that inference reads the emitter's
|
|
141
|
+
* current implementation, and a caller that wraps or replaces `c` would change
|
|
142
|
+
* behaviour here as a side effect nobody asked for.
|
|
143
|
+
* @param {string} [mode] label mode; selects the tick glyphs
|
|
144
|
+
* @returns {string}
|
|
145
|
+
*/
|
|
146
|
+
function gaugeColored(pct, c, tone, color, mode = 'icon') {
|
|
147
|
+
const { filled, empty } = gaugeParts(pct, mode);
|
|
148
|
+
if (!empty) return filled;
|
|
149
|
+
if (!color) return filled + empty;
|
|
150
|
+
return `${filled}${c(RESET)}${c(GRAY)}${empty}${c(RESET)}${c(tone)}`;
|
|
104
151
|
}
|
|
105
152
|
|
|
106
153
|
/**
|
|
@@ -146,7 +193,7 @@ export function pickCapWarn(caps) {
|
|
|
146
193
|
* Korean-style chip builder. Renders only when the session-start injection is
|
|
147
194
|
* enabled, so nothing changes for anyone who never asked for it.
|
|
148
195
|
*/
|
|
149
|
-
function buildKoreanSeg(c, isIcon, verbose) {
|
|
196
|
+
function buildKoreanSeg(c, isIcon, verbose, g) {
|
|
150
197
|
try {
|
|
151
198
|
if (!koreanStyleEnabled()) return null;
|
|
152
199
|
// Deliberately quiet (gray, one glyph): this is a "yes, it is on"
|
|
@@ -154,7 +201,7 @@ function buildKoreanSeg(c, isIcon, verbose) {
|
|
|
154
201
|
// exactly like a working one, because the style only shows up when the
|
|
155
202
|
// model happens to write Korean. The icon says "writing guidance", not
|
|
156
203
|
// "Korean" — the verbose label already carries the language.
|
|
157
|
-
if (isIcon) return `${c(GRAY)}${verbose ?
|
|
204
|
+
if (isIcon) return `${c(GRAY)}${verbose ? `${g.korean} Korean style` : g.korean}${c(RESET)}`;
|
|
158
205
|
return `${c(GRAY)}Korean style${c(RESET)}`;
|
|
159
206
|
} catch {
|
|
160
207
|
return null;
|
|
@@ -177,13 +224,13 @@ function buildKoreanSeg(c, isIcon, verbose) {
|
|
|
177
224
|
* rendering after the user declines — declining hides the session-start
|
|
178
225
|
* question, not the fact that a new version exists.
|
|
179
226
|
*/
|
|
180
|
-
function buildVersionSeg(version, update, c, isIcon, verbose) {
|
|
227
|
+
function buildVersionSeg(version, update, c, isIcon, verbose, g) {
|
|
181
228
|
if (!version) return null;
|
|
182
229
|
if (update && update.available && update.latest) {
|
|
183
230
|
const body = verbose
|
|
184
231
|
? `Update v${version} → ${update.latest}`
|
|
185
232
|
: `v${version} → ${update.latest}`;
|
|
186
|
-
return `${c(YELLOW)}${isIcon ?
|
|
233
|
+
return `${c(YELLOW)}${isIcon ? `${g.upgrade} ` : ''}${body}${c(RESET)}`;
|
|
187
234
|
}
|
|
188
235
|
return `${c(GRAY)}v${version}${c(RESET)}`;
|
|
189
236
|
}
|
|
@@ -193,16 +240,16 @@ function buildVersionSeg(version, update, c, isIcon, verbose) {
|
|
|
193
240
|
* fallback line. Best-effort: never throws into the statusline (corrupted
|
|
194
241
|
* CLAUDE.md, permission issue, etc. → null).
|
|
195
242
|
*/
|
|
196
|
-
function buildHarnessSeg(c, isIcon) {
|
|
243
|
+
function buildHarnessSeg(c, isIcon, g) {
|
|
197
244
|
try {
|
|
198
245
|
const harnessInfo = harnessStatusForStatusline(loadConfig());
|
|
199
246
|
if (!harnessInfo) return null;
|
|
200
|
-
const icon = isIcon ?
|
|
247
|
+
const icon = isIcon ? g.harness : 'H';
|
|
201
248
|
if (harnessInfo.warning) {
|
|
202
249
|
// Warning state outranks the N/5 count — a runtime issue (repeated
|
|
203
250
|
// error / no-evidence / racing edits) is more actionable than a
|
|
204
251
|
// missing ratchet section. Always red so it stands out.
|
|
205
|
-
return `${c(RED)}${icon}
|
|
252
|
+
return `${c(RED)}${icon}${g.spike} ${harnessInfo.warning}${c(RESET)}`;
|
|
206
253
|
}
|
|
207
254
|
if (harnessInfo.custom) return `${c(CYAN)}${icon} custom${c(RESET)}`;
|
|
208
255
|
const tone = harnessInfo.configured >= harnessInfo.total ? GREEN : YELLOW;
|
|
@@ -218,17 +265,21 @@ function buildHarnessSeg(c, isIcon) {
|
|
|
218
265
|
* the wall-clock reset time rides along in the same `🔄 HH:MM` shape as the
|
|
219
266
|
* always-on usage segments.
|
|
220
267
|
*/
|
|
221
|
-
function buildCapWarnSeg(capWarn, c, isIcon) {
|
|
268
|
+
function buildCapWarnSeg(capWarn, c, isIcon, g, color, mode) {
|
|
269
|
+
// `color` and `mode` are both for the gauge: the first decides whether the
|
|
270
|
+
// unused ticks are dimmed, the second which pair of tick glyphs to draw.
|
|
222
271
|
if (!capWarn) return null;
|
|
223
272
|
const pct = Math.round(capWarn.usedPct);
|
|
224
273
|
const clock = formatResetClock(capWarn.resetsAt);
|
|
225
|
-
|
|
274
|
+
// Text mode has no glyph vocabulary, so it names the thing instead of
|
|
275
|
+
// borrowing an emoji it otherwise avoids.
|
|
276
|
+
const clockTail = clock ? (isIcon ? ` ${g.reset} ${clock}` : ` resets ${clock}`) : '';
|
|
226
277
|
if (isIcon) {
|
|
227
278
|
// Gauge keeps shape parity with the always-on usage segment — the
|
|
228
279
|
// cap-warn is just the same gauge "filled to alarm". Visual continuity
|
|
229
280
|
// helps the eye understand "this is the 5H bar I was watching, just red now."
|
|
230
|
-
const bar =
|
|
231
|
-
return `${c(BOLD)}${c(RED)}
|
|
281
|
+
const bar = gaugeColored(pct, c, RED, color, mode);
|
|
282
|
+
return `${c(BOLD)}${c(RED)}${g.capWarn} ${capWarn.label} ${bar} ${pct}%${clockTail}${c(RESET)}`;
|
|
232
283
|
}
|
|
233
284
|
return `${c(BOLD)}${c(RED)}${capWarn.label} cap ${pct}%${clockTail}${c(RESET)}`;
|
|
234
285
|
}
|
|
@@ -242,18 +293,21 @@ function buildCapWarnSeg(capWarn, c, isIcon) {
|
|
|
242
293
|
*/
|
|
243
294
|
export function formatNoSession({ caps = null, model = null, windowLabel = '', version = '', update = null } = {}, { color = true, mode = 'icon' } = {}) {
|
|
244
295
|
const c = (v) => (color ? v : '');
|
|
245
|
-
|
|
296
|
+
// narrow shares the icon layout and swaps only the glyphs, so both modes take
|
|
297
|
+
// the same branches from here on.
|
|
298
|
+
const isIcon = mode === 'icon' || mode === 'narrow';
|
|
299
|
+
const g = glyphsFor(mode);
|
|
246
300
|
const segs = [];
|
|
247
|
-
const capSeg = buildCapWarnSeg(pickCapWarn(caps), c, isIcon);
|
|
301
|
+
const capSeg = buildCapWarnSeg(pickCapWarn(caps), c, isIcon, g, color, mode);
|
|
248
302
|
if (capSeg) segs.push(capSeg);
|
|
249
|
-
const versionSeg = buildVersionSeg(version, update, c, isIcon, false);
|
|
303
|
+
const versionSeg = buildVersionSeg(version, update, c, isIcon, false, g);
|
|
250
304
|
if (versionSeg && update && update.available) segs.push(versionSeg);
|
|
251
|
-
const harnessSeg = buildHarnessSeg(c, isIcon);
|
|
305
|
+
const harnessSeg = buildHarnessSeg(c, isIcon, g);
|
|
252
306
|
if (harnessSeg) segs.push(harnessSeg);
|
|
253
307
|
if (typeof model === 'string' && model.length > 0) {
|
|
254
|
-
segs.push(isIcon ? `${c(MAGENTA)}
|
|
308
|
+
segs.push(isIcon ? `${c(MAGENTA)}${g.model} ${model}${c(RESET)}` : `${c(MAGENTA)}${model}${c(RESET)}`);
|
|
255
309
|
}
|
|
256
|
-
segs.push(`${c(GRAY)}
|
|
310
|
+
segs.push(`${c(GRAY)}${g.hit} no session data${windowLabel ? ` · ${windowLabel}` : ''}${c(RESET)}`);
|
|
257
311
|
if (versionSeg && !(update && update.available)) segs.push(versionSeg);
|
|
258
312
|
return segs.join(' · ') + (color ? '\x1b[K' : '');
|
|
259
313
|
}
|
|
@@ -265,9 +319,45 @@ export function formatNoSession({ caps = null, model = null, windowLabel = '', v
|
|
|
265
319
|
* @param {boolean} [opts.verbose=false] - longer layout with labels
|
|
266
320
|
* @param {boolean} [opts.timer=true] - show TTL countdown segment
|
|
267
321
|
* @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses 🧠 ⏳ 💰 instead of word labels.
|
|
268
|
-
* @param {string[]|null} [opts.segments] -
|
|
322
|
+
* @param {string[]|null} [opts.segments] - segments to render, in the order given. Names: cap-warn, spike, version, harness, korean, model, hit, ttl, month, saved, delegated, doc2md, ctx, period, `usage` (every rate-limit window), plus per-window keys (`five_hour`, `seven_day`, …). `5h`/`7d` are kept as aliases for back-compat. cap-warn and spike keep the lead regardless of where they appear. Null/undefined = all, in the default order.
|
|
269
323
|
* @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
324
|
*/
|
|
325
|
+
/**
|
|
326
|
+
* Default segment order, most actionable first. A rate-limit block or a full
|
|
327
|
+
* context window stops the work outright, so those lead. Cache and cost numbers
|
|
328
|
+
* shape habits rather than the next keystroke, and harness / Korean-style are
|
|
329
|
+
* near-constant identity: they sit at the tail, where a narrow terminal clips
|
|
330
|
+
* the least useful end of the line instead of the most useful one.
|
|
331
|
+
*
|
|
332
|
+
* `version` is listed once, at its identity position. formatReport() moves it
|
|
333
|
+
* forward when an upgrade is actually available, since it becomes an
|
|
334
|
+
* "act on this" chip then.
|
|
335
|
+
*
|
|
336
|
+
* Exported so a test can assert every name here is one the renderer knows:
|
|
337
|
+
* the order list and the segment registry are separate structures, and a name
|
|
338
|
+
* added to only one of them would be dropped in silence.
|
|
339
|
+
*/
|
|
340
|
+
export const DEFAULT_SEGMENT_ORDER = [
|
|
341
|
+
'cap-warn', 'spike',
|
|
342
|
+
'usage', 'ctx',
|
|
343
|
+
'ttl',
|
|
344
|
+
'model',
|
|
345
|
+
// Grouped by the timeframe each figure covers, because mixing them was the
|
|
346
|
+
// problem: a lifetime total, a windowed rate and a calendar-month estimate sat
|
|
347
|
+
// interleaved, with one window label at the end of the line that appeared to
|
|
348
|
+
// qualify all of them.
|
|
349
|
+
//
|
|
350
|
+
// Lifetime, from the savings ledgers. Delegation stays ahead of the cache
|
|
351
|
+
// figure: routing is what the tool is for, the cache number is a brag stat.
|
|
352
|
+
'delegated', 'doc2md',
|
|
353
|
+
// Analysis window. These two are the only chips measured over it, so the label
|
|
354
|
+
// sits with them and the three render as one group.
|
|
355
|
+
'hit', 'saved', 'period',
|
|
356
|
+
// Calendar month.
|
|
357
|
+
'month',
|
|
358
|
+
'harness', 'korean', 'version',
|
|
359
|
+
];
|
|
360
|
+
|
|
271
361
|
export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null, singleLine = false } = {}) {
|
|
272
362
|
const { summary, ttl, cost, options, lastActivity, contextWindow, ctxLive, spikeChip, caps, model } = data;
|
|
273
363
|
const { hitRate } = summary;
|
|
@@ -310,20 +400,23 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
310
400
|
const savings = cost?.savings ?? 0;
|
|
311
401
|
|
|
312
402
|
const c = (v) => (color ? v : '');
|
|
313
|
-
|
|
403
|
+
// narrow shares the icon layout and swaps only the glyphs, so both modes take
|
|
404
|
+
// the same branches from here on.
|
|
405
|
+
const isIcon = mode === 'icon' || mode === 'narrow';
|
|
406
|
+
const g = glyphsFor(mode);
|
|
314
407
|
|
|
315
408
|
// Labels per mode.
|
|
316
409
|
// text: "Cache hit 98.3%" | verbose: "Cache hit 98.3%"
|
|
317
410
|
// icon: "🧠 98.3%" | verbose: "🧠 Cache hit 98.3%"
|
|
318
411
|
const hitLabel = isIcon
|
|
319
|
-
? (verbose ?
|
|
412
|
+
? (verbose ? `${g.hit} Cache hit` : g.hit)
|
|
320
413
|
: 'Cache hit';
|
|
321
414
|
const hitSeg = `${c(BOLD)}${hitLabel}${c(RESET)} ${c(hitColor)}${formatPct(hitRate)}${c(RESET)}`;
|
|
322
415
|
|
|
323
416
|
// text: "Cache saved $1.5K" | same in verbose
|
|
324
417
|
// icon: "💰 $1.5K" | verbose: "💰 Cache saved $1.5K"
|
|
325
418
|
const saveLabel = isIcon
|
|
326
|
-
? (verbose ?
|
|
419
|
+
? (verbose ? `${g.saved} Cache saved` : g.saved)
|
|
327
420
|
: 'Cache saved';
|
|
328
421
|
const saveSeg = `${c(CYAN)}${saveLabel}${c(RESET)} ${formatMoney(savings)}`;
|
|
329
422
|
|
|
@@ -339,7 +432,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
339
432
|
// icon: "🔀 $3.2" | verbose: "🔀 Routing saved $3.2"
|
|
340
433
|
const delegationSaved = Number(data.delegationSaved) || 0;
|
|
341
434
|
const delegateLabel = isIcon
|
|
342
|
-
? (verbose ?
|
|
435
|
+
? (verbose ? `${g.routing} Routing saved` : g.routing)
|
|
343
436
|
: 'Routing saved';
|
|
344
437
|
// Zero savings has two very different causes and, until now, one appearance:
|
|
345
438
|
// nothing at all. "Never delegated" and "delegated plenty, but every run was
|
|
@@ -347,11 +440,19 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
347
440
|
// identical, so users in the second case had no reason to suspect anything
|
|
348
441
|
// was wrong. The count gets a chip; the explanation stays in `route-scan
|
|
349
442
|
// rules`, where there is room for it.
|
|
443
|
+
//
|
|
444
|
+
// But only once there are enough of them to mean anything. A gateway reports
|
|
445
|
+
// an opaque profile ARN, and the fix for that is learned from observed runs —
|
|
446
|
+
// which needs MIN_VOTES of them before it can conclude anything. Below that
|
|
447
|
+
// threshold an unresolved run is not a problem, it is the first day of a
|
|
448
|
+
// working mechanism, and warning about it makes a fresh install look broken.
|
|
449
|
+
// Tied to MIN_VOTES rather than a literal so the two cannot drift: the chip
|
|
450
|
+
// appears exactly when learning has had its chance and still came up short.
|
|
350
451
|
const unresolvedRuns = Number(data.unresolvedRuns) || 0;
|
|
351
452
|
const delegateSeg = delegationSaved > 0
|
|
352
453
|
? `${c(GREEN)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}`
|
|
353
|
-
: (unresolvedRuns
|
|
354
|
-
? `${c(YELLOW)}
|
|
454
|
+
: (unresolvedRuns >= MIN_VOTES
|
|
455
|
+
? `${c(YELLOW)}${g.routing} ${unresolvedRuns} unresolved${c(RESET)}`
|
|
355
456
|
: null);
|
|
356
457
|
|
|
357
458
|
// Document conversions — the same kind of number as "Routing saved", earned
|
|
@@ -369,14 +470,14 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
369
470
|
const doc2mdUsd = Number(doc2md && doc2md.total) || 0;
|
|
370
471
|
const doc2mdDocs = Number(doc2md && doc2md.docs) || 0;
|
|
371
472
|
const doc2mdLabel = isIcon
|
|
372
|
-
? (verbose ?
|
|
473
|
+
? (verbose ? `${g.doc} Doc2md saved` : g.doc)
|
|
373
474
|
: 'Doc2md saved';
|
|
374
475
|
let doc2mdSeg = null;
|
|
375
476
|
if (doc2mdUsd > 0) {
|
|
376
477
|
doc2mdSeg = `${c(GREEN)}${doc2mdLabel}${c(RESET)} ${formatMoney(doc2mdUsd)}`
|
|
377
478
|
+ (verbose ? ` ${c(GRAY)}· ${doc2mdDocs} docs${c(RESET)}` : '');
|
|
378
479
|
} else if (doc2mdDocs > 0) {
|
|
379
|
-
const label = isIcon ?
|
|
480
|
+
const label = isIcon ? g.doc : 'Doc2md';
|
|
380
481
|
doc2mdSeg = `${c(GRAY)}${label} ${doc2mdDocs} docs${c(RESET)}`;
|
|
381
482
|
}
|
|
382
483
|
|
|
@@ -391,7 +492,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
391
492
|
if (month && Number(month.usd) > 0) {
|
|
392
493
|
const amt = formatMoney(Number(month.usd));
|
|
393
494
|
if (isIcon) {
|
|
394
|
-
monthSeg = `${c(GRAY)}
|
|
495
|
+
monthSeg = `${c(GRAY)}${g.month} ${month.label} ${amt}${c(RESET)}`;
|
|
395
496
|
} else if (verbose) {
|
|
396
497
|
monthSeg = `${c(GRAY)}${month.label} spend ${amt} (since ${month.label} 1)${c(RESET)}`;
|
|
397
498
|
} else {
|
|
@@ -413,7 +514,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
413
514
|
const totals = data.delegationTotals;
|
|
414
515
|
let totalsLine = null;
|
|
415
516
|
if (!singleLine && totals && Number(totals.total) > 0) {
|
|
416
|
-
const head = isIcon ?
|
|
517
|
+
const head = isIcon ? `${g.routing} Routing saved` : 'Routing saved';
|
|
417
518
|
// Model changes behind the total, family-level and version-free: `opus →
|
|
418
519
|
// haiku 2× $0.6`. Versions bump constantly and add nothing here — the
|
|
419
520
|
// shape of the trade is the point.
|
|
@@ -444,7 +545,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
444
545
|
// icon: "📄 Doc2md saved $1.8 | pptx 1× $1.55 · pdf 2× $0.27"
|
|
445
546
|
let doc2mdLine = null;
|
|
446
547
|
if (!singleLine && doc2mdUsd > 0) {
|
|
447
|
-
const head = isIcon ?
|
|
548
|
+
const head = isIcon ? `${g.doc} Doc2md saved` : 'Doc2md saved';
|
|
448
549
|
const byExt = Array.isArray(doc2md.byExt) ? doc2md.byExt : [];
|
|
449
550
|
// Formats that earned money show it; formats with no baseline to measure
|
|
450
551
|
// against are counted instead. Printing "$0.00" next to real amounts reads
|
|
@@ -468,9 +569,11 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
468
569
|
// Period label honors hour-precision configs (`mode 6h` → "6h", `mode 1d` → "1d").
|
|
469
570
|
// Fall back to legacy `${days}d` when callers haven't supplied a label.
|
|
470
571
|
const periodLabel = options.windowLabel || `${options.days}d`;
|
|
572
|
+
// Parenthesized so it reads as a qualifier on the two chips before it rather
|
|
573
|
+
// than as one more independent chip.
|
|
471
574
|
const periodSeg = verbose
|
|
472
|
-
? `${c(GRAY)}last ${periodLabel}${c(RESET)}`
|
|
473
|
-
: `${c(GRAY)}${periodLabel}${c(RESET)}`;
|
|
575
|
+
? `${c(GRAY)}(last ${periodLabel})${c(RESET)}`
|
|
576
|
+
: `${c(GRAY)}(${periodLabel})${c(RESET)}`;
|
|
474
577
|
|
|
475
578
|
// TTL countdown — how much time is left on the last API call's cache entry.
|
|
476
579
|
// Matches Anthropic's actual prompt-cache behaviour: each call starts a fresh
|
|
@@ -515,9 +618,9 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
515
618
|
// for the same reason the compact form did. The bucket lives in the
|
|
516
619
|
// text-verbose layout where the "bucket" word + `·` separator make it
|
|
517
620
|
// unambiguous.
|
|
518
|
-
ttlSeg = `${c(timerColor)}
|
|
621
|
+
ttlSeg = `${c(timerColor)}${g.ttl} Cache expires ${text}${c(RESET)}`;
|
|
519
622
|
} else if (isIcon) {
|
|
520
|
-
ttlSeg = `${c(timerColor)}
|
|
623
|
+
ttlSeg = `${c(timerColor)}${g.ttl} ${text}${c(RESET)}`;
|
|
521
624
|
} else if (verbose) {
|
|
522
625
|
ttlSeg = `${c(bucketColor)}Cache ${bucketLabel} bucket${c(RESET)} · ${c(timerColor)}expires in ${text}${c(RESET)}`;
|
|
523
626
|
} else {
|
|
@@ -526,7 +629,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
526
629
|
} else {
|
|
527
630
|
// No-timer fallback: only the bucket is available, so we show just that.
|
|
528
631
|
if (isIcon) {
|
|
529
|
-
const prefix = verbose ?
|
|
632
|
+
const prefix = verbose ? `${g.ttl} Cache bucket ` : `${g.ttl} `;
|
|
530
633
|
ttlSeg = `${c(bucketColor)}${prefix}${bucketLabel}${c(RESET)}`;
|
|
531
634
|
} else if (verbose) {
|
|
532
635
|
ttlSeg = `${c(bucketColor)}Cache ${bucketLabel} bucket${c(RESET)}`;
|
|
@@ -556,9 +659,9 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
556
659
|
: null;
|
|
557
660
|
const longLabel = sizeLabel ? `${pct}% of ${sizeLabel}` : `${pct}%`;
|
|
558
661
|
if (isIcon && verbose) {
|
|
559
|
-
ctxSeg = `${c(tone)}
|
|
662
|
+
ctxSeg = `${c(tone)}${g.ctx} Ctx ${longLabel}${c(RESET)}`;
|
|
560
663
|
} else if (isIcon) {
|
|
561
|
-
ctxSeg = `${c(tone)}
|
|
664
|
+
ctxSeg = `${c(tone)}${g.ctx} ${pct}%${c(RESET)}`;
|
|
562
665
|
} else {
|
|
563
666
|
ctxSeg = `${c(tone)}Ctx ${longLabel}${c(RESET)}`;
|
|
564
667
|
}
|
|
@@ -568,9 +671,9 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
568
671
|
// default on every current model, so colouring it yellow cried wolf.
|
|
569
672
|
const ctxColor = contextWindow.overWarn ? YELLOW : GREEN;
|
|
570
673
|
if (isIcon && verbose) {
|
|
571
|
-
ctxSeg = `${c(ctxColor)}
|
|
674
|
+
ctxSeg = `${c(ctxColor)}${g.ctx} Ctx ${label}${c(RESET)}`;
|
|
572
675
|
} else if (isIcon) {
|
|
573
|
-
ctxSeg = `${c(ctxColor)}
|
|
676
|
+
ctxSeg = `${c(ctxColor)}${g.ctx} ${label}${c(RESET)}`;
|
|
574
677
|
} else {
|
|
575
678
|
ctxSeg = `${c(ctxColor)}Ctx ${label}${c(RESET)}`;
|
|
576
679
|
}
|
|
@@ -583,15 +686,15 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
583
686
|
// Silent when the project hasn't opted in (no CLAUDE.md and no .claude/);
|
|
584
687
|
// otherwise renders 🅷 5/5 (green) / 🅷 N/5 (yellow) so the user can spot
|
|
585
688
|
// a missing section at a glance and know to run `harness init`.
|
|
586
|
-
const harnessSeg = buildHarnessSeg(c, isIcon);
|
|
689
|
+
const harnessSeg = buildHarnessSeg(c, isIcon, g);
|
|
587
690
|
|
|
588
691
|
// Version / upgrade chip. Read from a cache written by a detached background
|
|
589
692
|
// check — this render path never touches the network.
|
|
590
|
-
const versionSeg = buildVersionSeg(options.version, data.update, c, isIcon, verbose);
|
|
693
|
+
const versionSeg = buildVersionSeg(options.version, data.update, c, isIcon, verbose, g);
|
|
591
694
|
const updateAvailable = !!(data.update && data.update.available);
|
|
592
695
|
|
|
593
696
|
// Korean-style chip — rendered only when the session-start injection is on.
|
|
594
|
-
const koreanSeg = buildKoreanSeg(c, isIcon, verbose);
|
|
697
|
+
const koreanSeg = buildKoreanSeg(c, isIcon, verbose, g);
|
|
595
698
|
|
|
596
699
|
// Model chip — pulled from Claude Code's stdin payload (`model.display_name`).
|
|
597
700
|
// Cheap identity context: useful when the user toggles between Sonnet/Opus
|
|
@@ -602,7 +705,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
602
705
|
// is dead weight in icon mode. Text modes keep the bare name; the magenta
|
|
603
706
|
// tone marks it as identity context.
|
|
604
707
|
if (isIcon) {
|
|
605
|
-
modelSeg = `${c(MAGENTA)}
|
|
708
|
+
modelSeg = `${c(MAGENTA)}${g.model} ${model}${c(RESET)}`;
|
|
606
709
|
} else {
|
|
607
710
|
modelSeg = `${c(MAGENTA)}${model}${c(RESET)}`;
|
|
608
711
|
}
|
|
@@ -625,8 +728,14 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
625
728
|
// Show only the wall-clock reset time (e.g. `🔄 21:10`). Absolute time
|
|
626
729
|
// doesn't tick second-by-second so the statusline reads stable, and the
|
|
627
730
|
// 🔄 icon itself separates the percent from the clock — no extra `·` needed.
|
|
628
|
-
|
|
629
|
-
|
|
731
|
+
// A gateway budget can reset weeks out, where a wall-clock time says almost
|
|
732
|
+
// nothing ("Thu 00:00" — which Thursday?). Those windows report how long is
|
|
733
|
+
// left instead. Short windows keep the clock, which is the more useful form
|
|
734
|
+
// when the reset is later today.
|
|
735
|
+
const clock = labels.resetStyle === 'countdown'
|
|
736
|
+
? formatResetIn(info.resetsAt)
|
|
737
|
+
: formatResetClock(info.resetsAt);
|
|
738
|
+
const tail = clock ? (isIcon ? ` ${g.reset} ${clock}` : ` resets ${clock}`) : '';
|
|
630
739
|
// `cap` reads as a rate-limit ceiling rather than a duration. Icon mode
|
|
631
740
|
// leans on the icon to identify the window (✦ = session/now, 📅 = week),
|
|
632
741
|
// so the 5H label is empty while the 7D label spells out "weekly". Text and
|
|
@@ -635,7 +744,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
635
744
|
// a glance at the bar conveys urgency faster than parsing a percent number,
|
|
636
745
|
// and the gauge stays the same width as the percent climbs.
|
|
637
746
|
// LiteLLM 예산 윈도우는 퍼센트만으로는 감이 안 오므로(예산 크기를 모름)
|
|
638
|
-
// 금액을 함께 보여 준다:
|
|
747
|
+
// 금액을 함께 보여 준다: `💳 budget ▰▱ 34% $34/$100`.
|
|
639
748
|
const money =
|
|
640
749
|
Number.isFinite(info.maxBudget) && info.maxBudget > 0
|
|
641
750
|
? ` ${formatMoney(Number(info.spend) || 0)}/${formatMoney(info.maxBudget)}`
|
|
@@ -646,8 +755,9 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
646
755
|
const src = info.source === 'user' ? ' (user)' : '';
|
|
647
756
|
if (isIcon) {
|
|
648
757
|
const labelPart = labels.usageLabel ? `${labels.usageLabel}${src} ` : '';
|
|
649
|
-
const bar =
|
|
650
|
-
|
|
758
|
+
const bar = gaugeColored(pct, c, tone, color, mode);
|
|
759
|
+
const winGlyph = mode === 'narrow' ? (labels.narrowIcon || labels.icon) : labels.icon;
|
|
760
|
+
return `${c(tone)}${winGlyph} ${labelPart}${bar} ${pct}%${money}${tail}${c(RESET)}`;
|
|
651
761
|
}
|
|
652
762
|
if (verbose) {
|
|
653
763
|
return `${c(tone)}${labels.short}${src} cap ${pct}% used${money}${tail}${c(RESET)}`;
|
|
@@ -682,56 +792,106 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
682
792
|
}
|
|
683
793
|
}
|
|
684
794
|
|
|
685
|
-
const capWarnSeg = buildCapWarnSeg(capWarn, c, isIcon);
|
|
795
|
+
const capWarnSeg = buildCapWarnSeg(capWarn, c, isIcon, g, color, mode);
|
|
686
796
|
|
|
687
797
|
// Warning chip leads — a glance at the statusline catches "something's wrong"
|
|
688
798
|
// before parsing any numbers. Healthy states have no chip and look unchanged.
|
|
689
799
|
// Cap-warn outranks spike: an imminent rate-limit block is more urgent than
|
|
690
800
|
// a single spiking session.
|
|
691
801
|
const allow = segments && segments.length
|
|
692
|
-
? new Set(segments.map((s) => s.toLowerCase()))
|
|
802
|
+
? new Set(segments.map((s) => String(s).toLowerCase()))
|
|
693
803
|
: null;
|
|
804
|
+
// Still needed by the headline lines below, which are not part of the
|
|
805
|
+
// ordered segment list.
|
|
694
806
|
const want = (name) => !allow || allow.has(name);
|
|
695
|
-
|
|
696
|
-
//
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
807
|
+
|
|
808
|
+
// Segment registry — a name maps to the segments it contributes, so the
|
|
809
|
+
// sequence is data instead of the order of push calls. That is what lets a
|
|
810
|
+
// caller reorder: `--segments` used to filter only, and its order was
|
|
811
|
+
// silently discarded, so a user who wanted Ctx first had no way to ask.
|
|
812
|
+
const usageByKey = new Map(usageSegs.map(({ key, seg }) => [key, seg]));
|
|
813
|
+
// Legacy `--segments` aliases: `5h` ↔ `five_hour`, `7d` ↔ `seven_day`.
|
|
814
|
+
const USAGE_ALIASES = { '5h': 'five_hour', '7d': 'seven_day' };
|
|
815
|
+
function segmentsNamed(name) {
|
|
816
|
+
switch (name) {
|
|
817
|
+
case 'cap-warn': return [capWarnSeg];
|
|
818
|
+
case 'spike': return [spikeSeg];
|
|
819
|
+
case 'version': return [versionSeg];
|
|
820
|
+
case 'harness': return [harnessSeg];
|
|
821
|
+
case 'korean': return [koreanSeg];
|
|
822
|
+
case 'model': return [modelSeg];
|
|
823
|
+
// Dropped when the same story already owns a headline line above, which
|
|
824
|
+
// would otherwise repeat it on line 2.
|
|
825
|
+
case 'delegated': return totalsLine ? [] : [delegateSeg];
|
|
826
|
+
case 'doc2md': return doc2mdLine ? [] : [doc2mdSeg];
|
|
827
|
+
case 'hit': return [hitSeg];
|
|
828
|
+
case 'ttl': return [ttlSeg];
|
|
829
|
+
case 'month': return [monthSeg];
|
|
830
|
+
case 'ctx': return [ctxSeg];
|
|
831
|
+
case 'saved': return [saveSeg];
|
|
832
|
+
case 'period': return [periodSeg];
|
|
833
|
+
// Every rate-limit window, in payload order.
|
|
834
|
+
case 'usage': return usageSegs.map(({ seg }) => seg);
|
|
835
|
+
default: {
|
|
836
|
+
const seg = usageByKey.get(USAGE_ALIASES[name] || name);
|
|
837
|
+
return seg ? [seg] : [];
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// An available upgrade is an "act on this" chip, so it moves up behind the
|
|
843
|
+
// warning chips. With nothing to upgrade to it stays pure identity, at the
|
|
844
|
+
// tail position DEFAULT_SEGMENT_ORDER gives it.
|
|
845
|
+
let defaultOrder = DEFAULT_SEGMENT_ORDER;
|
|
846
|
+
if (updateAvailable) {
|
|
847
|
+
const rest = DEFAULT_SEGMENT_ORDER.filter((n) => n !== 'version');
|
|
848
|
+
const usageAt = rest.indexOf('usage');
|
|
849
|
+
defaultOrder = [...rest.slice(0, usageAt), 'version', ...rest.slice(usageAt)];
|
|
850
|
+
}
|
|
851
|
+
// A gateway that reports its own spend makes the month chip the second dollar
|
|
852
|
+
// figure on the line, and the two never agree: the budget counts every call
|
|
853
|
+
// that key served, while the month chip estimates from this machine's session
|
|
854
|
+
// logs alone. Two numbers for what looks like one question is worse than one
|
|
855
|
+
// number, so the estimate steps aside when the measured value is present.
|
|
856
|
+
// `--segments month` still asks for it explicitly and still gets it.
|
|
857
|
+
const hasGatewayBudget = !!(caps && Array.isArray(caps.windows)
|
|
858
|
+
&& caps.windows.some((w) => w && w.key === 'litellm_budget'));
|
|
859
|
+
if (hasGatewayBudget) defaultOrder = defaultOrder.filter((n) => n !== 'month');
|
|
860
|
+
|
|
861
|
+
// A caller-supplied list sets the filter AND the order. Warning chips keep
|
|
862
|
+
// the lead wherever they were written: being seen first is their whole job,
|
|
863
|
+
// and someone reordering segments is not asking to bury an alarm.
|
|
864
|
+
const LEAD = ['cap-warn', 'spike'];
|
|
865
|
+
let order = defaultOrder;
|
|
866
|
+
if (allow) {
|
|
867
|
+
const names = segments.map((s) => String(s).toLowerCase());
|
|
868
|
+
const lead = LEAD.filter((n) => names.includes(n));
|
|
869
|
+
order = lead.concat(names.filter((n) => !LEAD.includes(n)));
|
|
870
|
+
}
|
|
871
|
+
|
|
704
872
|
const segs = [];
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
|
|
720
|
-
//
|
|
721
|
-
|
|
722
|
-
if (
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
if (
|
|
873
|
+
// Dedupe so overlapping names (e.g. `usage,five_hour`) cannot print the same
|
|
874
|
+
// chip twice.
|
|
875
|
+
const seen = new Set();
|
|
876
|
+
for (const name of order) {
|
|
877
|
+
for (const seg of segmentsNamed(name)) {
|
|
878
|
+
if (!seg || seen.has(seg)) continue;
|
|
879
|
+
seen.add(seg);
|
|
880
|
+
segs.push(seg);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
// Fuse the analysis-window group with spaces instead of `·`. Separated, each
|
|
884
|
+
// piece reads as an independent chip and the label appears to qualify only the
|
|
885
|
+
// one immediately before it; joined, the three read as a single group and the
|
|
886
|
+
// label covers all of it. Only fused when they actually ended up adjacent, so
|
|
887
|
+
// a caller-supplied order is left alone. Identity comparison is safe here
|
|
888
|
+
// because these are the very strings pushed into `segs`, not rebuilt copies.
|
|
889
|
+
const windowGroup = [hitSeg, saveSeg, periodSeg].filter((seg) => seg && segs.includes(seg));
|
|
890
|
+
if (windowGroup.length > 1) {
|
|
891
|
+
const at = segs.indexOf(windowGroup[0]);
|
|
892
|
+
const adjacent = windowGroup.every((seg, i) => segs[at + i] === seg);
|
|
893
|
+
if (adjacent) segs.splice(at, windowGroup.length, windowGroup.join(' '));
|
|
726
894
|
}
|
|
727
|
-
if (monthSeg && want('month')) segs.push(monthSeg);
|
|
728
|
-
if (ctxSeg && want('ctx')) segs.push(ctxSeg);
|
|
729
|
-
// Cache saved is the "lifetime brag" stat — useful but not actionable, so
|
|
730
|
-
// it sits near the tail. The period label closes the line as a quiet
|
|
731
|
-
// timeframe footer.
|
|
732
|
-
if (want('saved')) segs.push(saveSeg);
|
|
733
|
-
if (versionSeg && !updateAvailable && want('version')) segs.push(versionSeg);
|
|
734
|
-
if (want('period')) segs.push(periodSeg);
|
|
735
895
|
// Trailing erase-to-end-of-line so any leftover characters from a previous
|
|
736
896
|
// (longer) statusline render don't bleed into ours. \x1b[K is the standard
|
|
737
897
|
// "erase from cursor to EOL" CSI. Only emitted when color (i.e. ANSI) is
|