sprag-cli 3.44.0 → 3.45.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 +2 -0
- package/README.md +4 -0
- package/bin/cli.js +17 -24
- package/package.json +1 -1
- package/presets/model-rules.json +50 -9
- package/src/commands/brief.js +13 -1
- package/src/commands/mode.js +22 -2
- package/src/config.js +22 -3
- package/src/formatters/statusline.js +255 -100
- package/src/glyphs.js +78 -0
- package/src/litellm-budget.js +1 -1
- package/src/model-rules.js +53 -17
- package/src/route-inject.js +86 -0
- package/src/statusline-mode.js +95 -0
- package/src/window-labels.js +17 -7
|
@@ -17,7 +17,8 @@
|
|
|
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';
|
|
@@ -81,26 +82,73 @@ function formatPct(v) {
|
|
|
81
82
|
* for ~4% effective precision around the fill edge. Stable monospace width
|
|
82
83
|
* across all terminal fonts that ship Block Elements (U+2580–U+259F).
|
|
83
84
|
*/
|
|
84
|
-
export function
|
|
85
|
+
export function gaugeParts(pct) {
|
|
85
86
|
const cells = 6;
|
|
86
87
|
const clamped = Math.max(0, Math.min(100, pct));
|
|
87
88
|
const filled = (clamped / 100) * cells; // e.g. 4.32 cells filled
|
|
88
89
|
const fullCells = Math.floor(filled);
|
|
89
90
|
const remainder = filled - fullCells; // 0..1 — fill fraction of the boundary cell
|
|
90
|
-
// Boundary cell
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
91
|
+
// Boundary cell in eighths. The earlier version had three steps (░ ▒ ▓), which
|
|
92
|
+
// meant 25% and 31% drew the same bar; eighths keep the resolution the width
|
|
93
|
+
// of the bar can carry, so the gauge moves as the number does. Every character
|
|
94
|
+
// here is present in JetBrains Mono, which is why the gauge was the one part
|
|
95
|
+
// of the line that never garbled under IntelliJ (see src/glyphs.js).
|
|
96
|
+
const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'];
|
|
97
|
+
// Empty cells use ▒ rather than ░. In JetBrains Mono the eighth blocks all sit
|
|
98
|
+
// at yMin -300 / xMin 0, while ░ sits at yMin -240 / xMin 60 — about 4% of the
|
|
99
|
+
// cell height and 10% of its width inset — so a bar mixing them looks visibly
|
|
100
|
+
// misaligned, the empty half riding high and shifted inward. ▒ shares the box
|
|
101
|
+
// with the filled glyphs and still reads clearly lighter than █.
|
|
102
|
+
const step = Math.round(remainder * 8);
|
|
103
|
+
// A boundary cell rounded up to 8/8 is a full cell, not a ninth glyph.
|
|
104
|
+
const extra = step === 8 ? 1 : 0;
|
|
100
105
|
const totalFull = Math.min(cells, fullCells + extra);
|
|
106
|
+
const partial = totalFull < cells && extra === 0 ? EIGHTHS[step] : '';
|
|
101
107
|
const usedCells = totalFull + (partial ? 1 : 0);
|
|
102
|
-
const empty = '
|
|
103
|
-
return '█'.repeat(totalFull) + partial
|
|
108
|
+
const empty = '▒'.repeat(Math.max(0, cells - usedCells));
|
|
109
|
+
return { filled: '█'.repeat(totalFull) + partial, empty };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The bar as one string, for callers that render it in a single color.
|
|
114
|
+
* @param {number} pct
|
|
115
|
+
* @returns {string}
|
|
116
|
+
*/
|
|
117
|
+
export function gaugeBar(pct) {
|
|
118
|
+
const { filled, empty } = gaugeParts(pct);
|
|
119
|
+
return filled + empty;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The bar with the unused cells dimmed.
|
|
124
|
+
*
|
|
125
|
+
* ▒ shares its glyph box with the filled blocks, which is why it replaced ░, but
|
|
126
|
+
* it is also denser than ░ was — in one color the used and unused halves stop
|
|
127
|
+
* reading as different. Graying the remainder restores the contrast that the
|
|
128
|
+
* shading alone used to carry. The segment tone is reinstated at the end so the
|
|
129
|
+
* text after the bar keeps the color it started in.
|
|
130
|
+
*
|
|
131
|
+
* @param {number} pct
|
|
132
|
+
* @param {(code: string) => string} c - color emitter (returns '' when color is off)
|
|
133
|
+
* @param {string} tone - the segment's own color code
|
|
134
|
+
* @param {boolean} color - whether ANSI is allowed. Passed explicitly rather
|
|
135
|
+
* than inferred from `c` emitting nothing: that inference reads the emitter's
|
|
136
|
+
* current implementation, and a caller that wraps or replaces `c` would flip
|
|
137
|
+
* the track glyph as a side effect nobody asked for.
|
|
138
|
+
* @returns {string}
|
|
139
|
+
*/
|
|
140
|
+
function gaugeColored(pct, c, tone, color) {
|
|
141
|
+
const { filled, empty } = gaugeParts(pct);
|
|
142
|
+
if (!empty) return filled;
|
|
143
|
+
// With color available, the unused cells are the same solid block as the used
|
|
144
|
+
// ones, just gray. Shading characters (░ ▒) carry a dither pattern that reads
|
|
145
|
+
// as noise beside a solid fill — the two halves look like different materials
|
|
146
|
+
// rather than two states of one bar. One glyph in two colors reads as a track
|
|
147
|
+
// filling up, and it sidesteps the glyph-box mismatch between the shades and
|
|
148
|
+
// the blocks entirely. Without color there is nothing but texture to tell them
|
|
149
|
+
// apart, so the shading stays.
|
|
150
|
+
const track = color ? '█'.repeat(empty.length) : empty;
|
|
151
|
+
return `${filled}${c(RESET)}${c(GRAY)}${track}${c(RESET)}${c(tone)}`;
|
|
104
152
|
}
|
|
105
153
|
|
|
106
154
|
/**
|
|
@@ -146,7 +194,7 @@ export function pickCapWarn(caps) {
|
|
|
146
194
|
* Korean-style chip builder. Renders only when the session-start injection is
|
|
147
195
|
* enabled, so nothing changes for anyone who never asked for it.
|
|
148
196
|
*/
|
|
149
|
-
function buildKoreanSeg(c, isIcon, verbose) {
|
|
197
|
+
function buildKoreanSeg(c, isIcon, verbose, g) {
|
|
150
198
|
try {
|
|
151
199
|
if (!koreanStyleEnabled()) return null;
|
|
152
200
|
// Deliberately quiet (gray, one glyph): this is a "yes, it is on"
|
|
@@ -154,7 +202,7 @@ function buildKoreanSeg(c, isIcon, verbose) {
|
|
|
154
202
|
// exactly like a working one, because the style only shows up when the
|
|
155
203
|
// model happens to write Korean. The icon says "writing guidance", not
|
|
156
204
|
// "Korean" — the verbose label already carries the language.
|
|
157
|
-
if (isIcon) return `${c(GRAY)}${verbose ?
|
|
205
|
+
if (isIcon) return `${c(GRAY)}${verbose ? `${g.korean} Korean style` : g.korean}${c(RESET)}`;
|
|
158
206
|
return `${c(GRAY)}Korean style${c(RESET)}`;
|
|
159
207
|
} catch {
|
|
160
208
|
return null;
|
|
@@ -177,13 +225,13 @@ function buildKoreanSeg(c, isIcon, verbose) {
|
|
|
177
225
|
* rendering after the user declines — declining hides the session-start
|
|
178
226
|
* question, not the fact that a new version exists.
|
|
179
227
|
*/
|
|
180
|
-
function buildVersionSeg(version, update, c, isIcon, verbose) {
|
|
228
|
+
function buildVersionSeg(version, update, c, isIcon, verbose, g) {
|
|
181
229
|
if (!version) return null;
|
|
182
230
|
if (update && update.available && update.latest) {
|
|
183
231
|
const body = verbose
|
|
184
232
|
? `Update v${version} → ${update.latest}`
|
|
185
233
|
: `v${version} → ${update.latest}`;
|
|
186
|
-
return `${c(YELLOW)}${isIcon ?
|
|
234
|
+
return `${c(YELLOW)}${isIcon ? `${g.upgrade} ` : ''}${body}${c(RESET)}`;
|
|
187
235
|
}
|
|
188
236
|
return `${c(GRAY)}v${version}${c(RESET)}`;
|
|
189
237
|
}
|
|
@@ -193,16 +241,16 @@ function buildVersionSeg(version, update, c, isIcon, verbose) {
|
|
|
193
241
|
* fallback line. Best-effort: never throws into the statusline (corrupted
|
|
194
242
|
* CLAUDE.md, permission issue, etc. → null).
|
|
195
243
|
*/
|
|
196
|
-
function buildHarnessSeg(c, isIcon) {
|
|
244
|
+
function buildHarnessSeg(c, isIcon, g) {
|
|
197
245
|
try {
|
|
198
246
|
const harnessInfo = harnessStatusForStatusline(loadConfig());
|
|
199
247
|
if (!harnessInfo) return null;
|
|
200
|
-
const icon = isIcon ?
|
|
248
|
+
const icon = isIcon ? g.harness : 'H';
|
|
201
249
|
if (harnessInfo.warning) {
|
|
202
250
|
// Warning state outranks the N/5 count — a runtime issue (repeated
|
|
203
251
|
// error / no-evidence / racing edits) is more actionable than a
|
|
204
252
|
// missing ratchet section. Always red so it stands out.
|
|
205
|
-
return `${c(RED)}${icon}
|
|
253
|
+
return `${c(RED)}${icon}${g.spike} ${harnessInfo.warning}${c(RESET)}`;
|
|
206
254
|
}
|
|
207
255
|
if (harnessInfo.custom) return `${c(CYAN)}${icon} custom${c(RESET)}`;
|
|
208
256
|
const tone = harnessInfo.configured >= harnessInfo.total ? GREEN : YELLOW;
|
|
@@ -218,17 +266,23 @@ function buildHarnessSeg(c, isIcon) {
|
|
|
218
266
|
* the wall-clock reset time rides along in the same `🔄 HH:MM` shape as the
|
|
219
267
|
* always-on usage segments.
|
|
220
268
|
*/
|
|
221
|
-
function buildCapWarnSeg(capWarn, c, isIcon) {
|
|
269
|
+
function buildCapWarnSeg(capWarn, c, isIcon, g, color) {
|
|
270
|
+
// `color` is only for the gauge. At 90%+ the bar has no unused cells left, so
|
|
271
|
+
// gaugeColored returns early and never reads it today — it is threaded through
|
|
272
|
+
// anyway, because a change to the cap-warn threshold or the bar width would
|
|
273
|
+
// otherwise start drawing the wrong track with nothing to catch it.
|
|
222
274
|
if (!capWarn) return null;
|
|
223
275
|
const pct = Math.round(capWarn.usedPct);
|
|
224
276
|
const clock = formatResetClock(capWarn.resetsAt);
|
|
225
|
-
|
|
277
|
+
// Text mode has no glyph vocabulary, so it names the thing instead of
|
|
278
|
+
// borrowing an emoji it otherwise avoids.
|
|
279
|
+
const clockTail = clock ? (isIcon ? ` ${g.reset} ${clock}` : ` resets ${clock}`) : '';
|
|
226
280
|
if (isIcon) {
|
|
227
281
|
// Gauge keeps shape parity with the always-on usage segment — the
|
|
228
282
|
// cap-warn is just the same gauge "filled to alarm". Visual continuity
|
|
229
283
|
// helps the eye understand "this is the 5H bar I was watching, just red now."
|
|
230
|
-
const bar =
|
|
231
|
-
return `${c(BOLD)}${c(RED)}
|
|
284
|
+
const bar = gaugeColored(pct, c, RED, color);
|
|
285
|
+
return `${c(BOLD)}${c(RED)}${g.capWarn} ${capWarn.label} ${bar} ${pct}%${clockTail}${c(RESET)}`;
|
|
232
286
|
}
|
|
233
287
|
return `${c(BOLD)}${c(RED)}${capWarn.label} cap ${pct}%${clockTail}${c(RESET)}`;
|
|
234
288
|
}
|
|
@@ -242,18 +296,21 @@ function buildCapWarnSeg(capWarn, c, isIcon) {
|
|
|
242
296
|
*/
|
|
243
297
|
export function formatNoSession({ caps = null, model = null, windowLabel = '', version = '', update = null } = {}, { color = true, mode = 'icon' } = {}) {
|
|
244
298
|
const c = (v) => (color ? v : '');
|
|
245
|
-
|
|
299
|
+
// narrow shares the icon layout and swaps only the glyphs, so both modes take
|
|
300
|
+
// the same branches from here on.
|
|
301
|
+
const isIcon = mode === 'icon' || mode === 'narrow';
|
|
302
|
+
const g = glyphsFor(mode);
|
|
246
303
|
const segs = [];
|
|
247
|
-
const capSeg = buildCapWarnSeg(pickCapWarn(caps), c, isIcon);
|
|
304
|
+
const capSeg = buildCapWarnSeg(pickCapWarn(caps), c, isIcon, g, color);
|
|
248
305
|
if (capSeg) segs.push(capSeg);
|
|
249
|
-
const versionSeg = buildVersionSeg(version, update, c, isIcon, false);
|
|
306
|
+
const versionSeg = buildVersionSeg(version, update, c, isIcon, false, g);
|
|
250
307
|
if (versionSeg && update && update.available) segs.push(versionSeg);
|
|
251
|
-
const harnessSeg = buildHarnessSeg(c, isIcon);
|
|
308
|
+
const harnessSeg = buildHarnessSeg(c, isIcon, g);
|
|
252
309
|
if (harnessSeg) segs.push(harnessSeg);
|
|
253
310
|
if (typeof model === 'string' && model.length > 0) {
|
|
254
|
-
segs.push(isIcon ? `${c(MAGENTA)}
|
|
311
|
+
segs.push(isIcon ? `${c(MAGENTA)}${g.model} ${model}${c(RESET)}` : `${c(MAGENTA)}${model}${c(RESET)}`);
|
|
255
312
|
}
|
|
256
|
-
segs.push(`${c(GRAY)}
|
|
313
|
+
segs.push(`${c(GRAY)}${g.hit} no session data${windowLabel ? ` · ${windowLabel}` : ''}${c(RESET)}`);
|
|
257
314
|
if (versionSeg && !(update && update.available)) segs.push(versionSeg);
|
|
258
315
|
return segs.join(' · ') + (color ? '\x1b[K' : '');
|
|
259
316
|
}
|
|
@@ -265,9 +322,45 @@ export function formatNoSession({ caps = null, model = null, windowLabel = '', v
|
|
|
265
322
|
* @param {boolean} [opts.verbose=false] - longer layout with labels
|
|
266
323
|
* @param {boolean} [opts.timer=true] - show TTL countdown segment
|
|
267
324
|
* @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses 🧠 ⏳ 💰 instead of word labels.
|
|
268
|
-
* @param {string[]|null} [opts.segments] -
|
|
325
|
+
* @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
326
|
* @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
327
|
*/
|
|
328
|
+
/**
|
|
329
|
+
* Default segment order, most actionable first. A rate-limit block or a full
|
|
330
|
+
* context window stops the work outright, so those lead. Cache and cost numbers
|
|
331
|
+
* shape habits rather than the next keystroke, and harness / Korean-style are
|
|
332
|
+
* near-constant identity: they sit at the tail, where a narrow terminal clips
|
|
333
|
+
* the least useful end of the line instead of the most useful one.
|
|
334
|
+
*
|
|
335
|
+
* `version` is listed once, at its identity position. formatReport() moves it
|
|
336
|
+
* forward when an upgrade is actually available, since it becomes an
|
|
337
|
+
* "act on this" chip then.
|
|
338
|
+
*
|
|
339
|
+
* Exported so a test can assert every name here is one the renderer knows:
|
|
340
|
+
* the order list and the segment registry are separate structures, and a name
|
|
341
|
+
* added to only one of them would be dropped in silence.
|
|
342
|
+
*/
|
|
343
|
+
export const DEFAULT_SEGMENT_ORDER = [
|
|
344
|
+
'cap-warn', 'spike',
|
|
345
|
+
'usage', 'ctx',
|
|
346
|
+
'ttl',
|
|
347
|
+
'model',
|
|
348
|
+
// Grouped by the timeframe each figure covers, because mixing them was the
|
|
349
|
+
// problem: a lifetime total, a windowed rate and a calendar-month estimate sat
|
|
350
|
+
// interleaved, with one window label at the end of the line that appeared to
|
|
351
|
+
// qualify all of them.
|
|
352
|
+
//
|
|
353
|
+
// Lifetime, from the savings ledgers. Delegation stays ahead of the cache
|
|
354
|
+
// figure: routing is what the tool is for, the cache number is a brag stat.
|
|
355
|
+
'delegated', 'doc2md',
|
|
356
|
+
// Analysis window. These two are the only chips measured over it, so the label
|
|
357
|
+
// sits with them and the three render as one group.
|
|
358
|
+
'hit', 'saved', 'period',
|
|
359
|
+
// Calendar month.
|
|
360
|
+
'month',
|
|
361
|
+
'harness', 'korean', 'version',
|
|
362
|
+
];
|
|
363
|
+
|
|
271
364
|
export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null, singleLine = false } = {}) {
|
|
272
365
|
const { summary, ttl, cost, options, lastActivity, contextWindow, ctxLive, spikeChip, caps, model } = data;
|
|
273
366
|
const { hitRate } = summary;
|
|
@@ -310,20 +403,23 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
310
403
|
const savings = cost?.savings ?? 0;
|
|
311
404
|
|
|
312
405
|
const c = (v) => (color ? v : '');
|
|
313
|
-
|
|
406
|
+
// narrow shares the icon layout and swaps only the glyphs, so both modes take
|
|
407
|
+
// the same branches from here on.
|
|
408
|
+
const isIcon = mode === 'icon' || mode === 'narrow';
|
|
409
|
+
const g = glyphsFor(mode);
|
|
314
410
|
|
|
315
411
|
// Labels per mode.
|
|
316
412
|
// text: "Cache hit 98.3%" | verbose: "Cache hit 98.3%"
|
|
317
413
|
// icon: "🧠 98.3%" | verbose: "🧠 Cache hit 98.3%"
|
|
318
414
|
const hitLabel = isIcon
|
|
319
|
-
? (verbose ?
|
|
415
|
+
? (verbose ? `${g.hit} Cache hit` : g.hit)
|
|
320
416
|
: 'Cache hit';
|
|
321
417
|
const hitSeg = `${c(BOLD)}${hitLabel}${c(RESET)} ${c(hitColor)}${formatPct(hitRate)}${c(RESET)}`;
|
|
322
418
|
|
|
323
419
|
// text: "Cache saved $1.5K" | same in verbose
|
|
324
420
|
// icon: "💰 $1.5K" | verbose: "💰 Cache saved $1.5K"
|
|
325
421
|
const saveLabel = isIcon
|
|
326
|
-
? (verbose ?
|
|
422
|
+
? (verbose ? `${g.saved} Cache saved` : g.saved)
|
|
327
423
|
: 'Cache saved';
|
|
328
424
|
const saveSeg = `${c(CYAN)}${saveLabel}${c(RESET)} ${formatMoney(savings)}`;
|
|
329
425
|
|
|
@@ -339,7 +435,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
339
435
|
// icon: "🔀 $3.2" | verbose: "🔀 Routing saved $3.2"
|
|
340
436
|
const delegationSaved = Number(data.delegationSaved) || 0;
|
|
341
437
|
const delegateLabel = isIcon
|
|
342
|
-
? (verbose ?
|
|
438
|
+
? (verbose ? `${g.routing} Routing saved` : g.routing)
|
|
343
439
|
: 'Routing saved';
|
|
344
440
|
// Zero savings has two very different causes and, until now, one appearance:
|
|
345
441
|
// nothing at all. "Never delegated" and "delegated plenty, but every run was
|
|
@@ -351,7 +447,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
351
447
|
const delegateSeg = delegationSaved > 0
|
|
352
448
|
? `${c(GREEN)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}`
|
|
353
449
|
: (unresolvedRuns > 0
|
|
354
|
-
? `${c(YELLOW)}
|
|
450
|
+
? `${c(YELLOW)}${g.routing} ${unresolvedRuns} unresolved${c(RESET)}`
|
|
355
451
|
: null);
|
|
356
452
|
|
|
357
453
|
// Document conversions — the same kind of number as "Routing saved", earned
|
|
@@ -369,14 +465,14 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
369
465
|
const doc2mdUsd = Number(doc2md && doc2md.total) || 0;
|
|
370
466
|
const doc2mdDocs = Number(doc2md && doc2md.docs) || 0;
|
|
371
467
|
const doc2mdLabel = isIcon
|
|
372
|
-
? (verbose ?
|
|
468
|
+
? (verbose ? `${g.doc} Doc2md saved` : g.doc)
|
|
373
469
|
: 'Doc2md saved';
|
|
374
470
|
let doc2mdSeg = null;
|
|
375
471
|
if (doc2mdUsd > 0) {
|
|
376
472
|
doc2mdSeg = `${c(GREEN)}${doc2mdLabel}${c(RESET)} ${formatMoney(doc2mdUsd)}`
|
|
377
473
|
+ (verbose ? ` ${c(GRAY)}· ${doc2mdDocs} docs${c(RESET)}` : '');
|
|
378
474
|
} else if (doc2mdDocs > 0) {
|
|
379
|
-
const label = isIcon ?
|
|
475
|
+
const label = isIcon ? g.doc : 'Doc2md';
|
|
380
476
|
doc2mdSeg = `${c(GRAY)}${label} ${doc2mdDocs} docs${c(RESET)}`;
|
|
381
477
|
}
|
|
382
478
|
|
|
@@ -391,7 +487,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
391
487
|
if (month && Number(month.usd) > 0) {
|
|
392
488
|
const amt = formatMoney(Number(month.usd));
|
|
393
489
|
if (isIcon) {
|
|
394
|
-
monthSeg = `${c(GRAY)}
|
|
490
|
+
monthSeg = `${c(GRAY)}${g.month} ${month.label} ${amt}${c(RESET)}`;
|
|
395
491
|
} else if (verbose) {
|
|
396
492
|
monthSeg = `${c(GRAY)}${month.label} spend ${amt} (since ${month.label} 1)${c(RESET)}`;
|
|
397
493
|
} else {
|
|
@@ -413,7 +509,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
413
509
|
const totals = data.delegationTotals;
|
|
414
510
|
let totalsLine = null;
|
|
415
511
|
if (!singleLine && totals && Number(totals.total) > 0) {
|
|
416
|
-
const head = isIcon ?
|
|
512
|
+
const head = isIcon ? `${g.routing} Routing saved` : 'Routing saved';
|
|
417
513
|
// Model changes behind the total, family-level and version-free: `opus →
|
|
418
514
|
// haiku 2× $0.6`. Versions bump constantly and add nothing here — the
|
|
419
515
|
// shape of the trade is the point.
|
|
@@ -444,7 +540,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
444
540
|
// icon: "📄 Doc2md saved $1.8 | pptx 1× $1.55 · pdf 2× $0.27"
|
|
445
541
|
let doc2mdLine = null;
|
|
446
542
|
if (!singleLine && doc2mdUsd > 0) {
|
|
447
|
-
const head = isIcon ?
|
|
543
|
+
const head = isIcon ? `${g.doc} Doc2md saved` : 'Doc2md saved';
|
|
448
544
|
const byExt = Array.isArray(doc2md.byExt) ? doc2md.byExt : [];
|
|
449
545
|
// Formats that earned money show it; formats with no baseline to measure
|
|
450
546
|
// against are counted instead. Printing "$0.00" next to real amounts reads
|
|
@@ -468,9 +564,11 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
468
564
|
// Period label honors hour-precision configs (`mode 6h` → "6h", `mode 1d` → "1d").
|
|
469
565
|
// Fall back to legacy `${days}d` when callers haven't supplied a label.
|
|
470
566
|
const periodLabel = options.windowLabel || `${options.days}d`;
|
|
567
|
+
// Parenthesized so it reads as a qualifier on the two chips before it rather
|
|
568
|
+
// than as one more independent chip.
|
|
471
569
|
const periodSeg = verbose
|
|
472
|
-
? `${c(GRAY)}last ${periodLabel}${c(RESET)}`
|
|
473
|
-
: `${c(GRAY)}${periodLabel}${c(RESET)}`;
|
|
570
|
+
? `${c(GRAY)}(last ${periodLabel})${c(RESET)}`
|
|
571
|
+
: `${c(GRAY)}(${periodLabel})${c(RESET)}`;
|
|
474
572
|
|
|
475
573
|
// TTL countdown — how much time is left on the last API call's cache entry.
|
|
476
574
|
// Matches Anthropic's actual prompt-cache behaviour: each call starts a fresh
|
|
@@ -515,9 +613,9 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
515
613
|
// for the same reason the compact form did. The bucket lives in the
|
|
516
614
|
// text-verbose layout where the "bucket" word + `·` separator make it
|
|
517
615
|
// unambiguous.
|
|
518
|
-
ttlSeg = `${c(timerColor)}
|
|
616
|
+
ttlSeg = `${c(timerColor)}${g.ttl} Cache expires ${text}${c(RESET)}`;
|
|
519
617
|
} else if (isIcon) {
|
|
520
|
-
ttlSeg = `${c(timerColor)}
|
|
618
|
+
ttlSeg = `${c(timerColor)}${g.ttl} ${text}${c(RESET)}`;
|
|
521
619
|
} else if (verbose) {
|
|
522
620
|
ttlSeg = `${c(bucketColor)}Cache ${bucketLabel} bucket${c(RESET)} · ${c(timerColor)}expires in ${text}${c(RESET)}`;
|
|
523
621
|
} else {
|
|
@@ -526,7 +624,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
526
624
|
} else {
|
|
527
625
|
// No-timer fallback: only the bucket is available, so we show just that.
|
|
528
626
|
if (isIcon) {
|
|
529
|
-
const prefix = verbose ?
|
|
627
|
+
const prefix = verbose ? `${g.ttl} Cache bucket ` : `${g.ttl} `;
|
|
530
628
|
ttlSeg = `${c(bucketColor)}${prefix}${bucketLabel}${c(RESET)}`;
|
|
531
629
|
} else if (verbose) {
|
|
532
630
|
ttlSeg = `${c(bucketColor)}Cache ${bucketLabel} bucket${c(RESET)}`;
|
|
@@ -556,9 +654,9 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
556
654
|
: null;
|
|
557
655
|
const longLabel = sizeLabel ? `${pct}% of ${sizeLabel}` : `${pct}%`;
|
|
558
656
|
if (isIcon && verbose) {
|
|
559
|
-
ctxSeg = `${c(tone)}
|
|
657
|
+
ctxSeg = `${c(tone)}${g.ctx} Ctx ${longLabel}${c(RESET)}`;
|
|
560
658
|
} else if (isIcon) {
|
|
561
|
-
ctxSeg = `${c(tone)}
|
|
659
|
+
ctxSeg = `${c(tone)}${g.ctx} ${pct}%${c(RESET)}`;
|
|
562
660
|
} else {
|
|
563
661
|
ctxSeg = `${c(tone)}Ctx ${longLabel}${c(RESET)}`;
|
|
564
662
|
}
|
|
@@ -568,9 +666,9 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
568
666
|
// default on every current model, so colouring it yellow cried wolf.
|
|
569
667
|
const ctxColor = contextWindow.overWarn ? YELLOW : GREEN;
|
|
570
668
|
if (isIcon && verbose) {
|
|
571
|
-
ctxSeg = `${c(ctxColor)}
|
|
669
|
+
ctxSeg = `${c(ctxColor)}${g.ctx} Ctx ${label}${c(RESET)}`;
|
|
572
670
|
} else if (isIcon) {
|
|
573
|
-
ctxSeg = `${c(ctxColor)}
|
|
671
|
+
ctxSeg = `${c(ctxColor)}${g.ctx} ${label}${c(RESET)}`;
|
|
574
672
|
} else {
|
|
575
673
|
ctxSeg = `${c(ctxColor)}Ctx ${label}${c(RESET)}`;
|
|
576
674
|
}
|
|
@@ -583,15 +681,15 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
583
681
|
// Silent when the project hasn't opted in (no CLAUDE.md and no .claude/);
|
|
584
682
|
// otherwise renders 🅷 5/5 (green) / 🅷 N/5 (yellow) so the user can spot
|
|
585
683
|
// a missing section at a glance and know to run `harness init`.
|
|
586
|
-
const harnessSeg = buildHarnessSeg(c, isIcon);
|
|
684
|
+
const harnessSeg = buildHarnessSeg(c, isIcon, g);
|
|
587
685
|
|
|
588
686
|
// Version / upgrade chip. Read from a cache written by a detached background
|
|
589
687
|
// check — this render path never touches the network.
|
|
590
|
-
const versionSeg = buildVersionSeg(options.version, data.update, c, isIcon, verbose);
|
|
688
|
+
const versionSeg = buildVersionSeg(options.version, data.update, c, isIcon, verbose, g);
|
|
591
689
|
const updateAvailable = !!(data.update && data.update.available);
|
|
592
690
|
|
|
593
691
|
// Korean-style chip — rendered only when the session-start injection is on.
|
|
594
|
-
const koreanSeg = buildKoreanSeg(c, isIcon, verbose);
|
|
692
|
+
const koreanSeg = buildKoreanSeg(c, isIcon, verbose, g);
|
|
595
693
|
|
|
596
694
|
// Model chip — pulled from Claude Code's stdin payload (`model.display_name`).
|
|
597
695
|
// Cheap identity context: useful when the user toggles between Sonnet/Opus
|
|
@@ -602,7 +700,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
602
700
|
// is dead weight in icon mode. Text modes keep the bare name; the magenta
|
|
603
701
|
// tone marks it as identity context.
|
|
604
702
|
if (isIcon) {
|
|
605
|
-
modelSeg = `${c(MAGENTA)}
|
|
703
|
+
modelSeg = `${c(MAGENTA)}${g.model} ${model}${c(RESET)}`;
|
|
606
704
|
} else {
|
|
607
705
|
modelSeg = `${c(MAGENTA)}${model}${c(RESET)}`;
|
|
608
706
|
}
|
|
@@ -625,8 +723,14 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
625
723
|
// Show only the wall-clock reset time (e.g. `🔄 21:10`). Absolute time
|
|
626
724
|
// doesn't tick second-by-second so the statusline reads stable, and the
|
|
627
725
|
// 🔄 icon itself separates the percent from the clock — no extra `·` needed.
|
|
628
|
-
|
|
629
|
-
|
|
726
|
+
// A gateway budget can reset weeks out, where a wall-clock time says almost
|
|
727
|
+
// nothing ("Thu 00:00" — which Thursday?). Those windows report how long is
|
|
728
|
+
// left instead. Short windows keep the clock, which is the more useful form
|
|
729
|
+
// when the reset is later today.
|
|
730
|
+
const clock = labels.resetStyle === 'countdown'
|
|
731
|
+
? formatResetIn(info.resetsAt)
|
|
732
|
+
: formatResetClock(info.resetsAt);
|
|
733
|
+
const tail = clock ? (isIcon ? ` ${g.reset} ${clock}` : ` resets ${clock}`) : '';
|
|
630
734
|
// `cap` reads as a rate-limit ceiling rather than a duration. Icon mode
|
|
631
735
|
// leans on the icon to identify the window (✦ = session/now, 📅 = week),
|
|
632
736
|
// so the 5H label is empty while the 7D label spells out "weekly". Text and
|
|
@@ -635,7 +739,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
635
739
|
// a glance at the bar conveys urgency faster than parsing a percent number,
|
|
636
740
|
// and the gauge stays the same width as the percent climbs.
|
|
637
741
|
// LiteLLM 예산 윈도우는 퍼센트만으로는 감이 안 오므로(예산 크기를 모름)
|
|
638
|
-
// 금액을 함께 보여 준다:
|
|
742
|
+
// 금액을 함께 보여 준다: `💳 budget ▰▱ 34% $34/$100`.
|
|
639
743
|
const money =
|
|
640
744
|
Number.isFinite(info.maxBudget) && info.maxBudget > 0
|
|
641
745
|
? ` ${formatMoney(Number(info.spend) || 0)}/${formatMoney(info.maxBudget)}`
|
|
@@ -646,8 +750,9 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
646
750
|
const src = info.source === 'user' ? ' (user)' : '';
|
|
647
751
|
if (isIcon) {
|
|
648
752
|
const labelPart = labels.usageLabel ? `${labels.usageLabel}${src} ` : '';
|
|
649
|
-
const bar =
|
|
650
|
-
|
|
753
|
+
const bar = gaugeColored(pct, c, tone, color);
|
|
754
|
+
const winGlyph = mode === 'narrow' ? (labels.narrowIcon || labels.icon) : labels.icon;
|
|
755
|
+
return `${c(tone)}${winGlyph} ${labelPart}${bar} ${pct}%${money}${tail}${c(RESET)}`;
|
|
651
756
|
}
|
|
652
757
|
if (verbose) {
|
|
653
758
|
return `${c(tone)}${labels.short}${src} cap ${pct}% used${money}${tail}${c(RESET)}`;
|
|
@@ -682,56 +787,106 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
682
787
|
}
|
|
683
788
|
}
|
|
684
789
|
|
|
685
|
-
const capWarnSeg = buildCapWarnSeg(capWarn, c, isIcon);
|
|
790
|
+
const capWarnSeg = buildCapWarnSeg(capWarn, c, isIcon, g, color);
|
|
686
791
|
|
|
687
792
|
// Warning chip leads — a glance at the statusline catches "something's wrong"
|
|
688
793
|
// before parsing any numbers. Healthy states have no chip and look unchanged.
|
|
689
794
|
// Cap-warn outranks spike: an imminent rate-limit block is more urgent than
|
|
690
795
|
// a single spiking session.
|
|
691
796
|
const allow = segments && segments.length
|
|
692
|
-
? new Set(segments.map((s) => s.toLowerCase()))
|
|
797
|
+
? new Set(segments.map((s) => String(s).toLowerCase()))
|
|
693
798
|
: null;
|
|
799
|
+
// Still needed by the headline lines below, which are not part of the
|
|
800
|
+
// ordered segment list.
|
|
694
801
|
const want = (name) => !allow || allow.has(name);
|
|
695
|
-
|
|
696
|
-
//
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
802
|
+
|
|
803
|
+
// Segment registry — a name maps to the segments it contributes, so the
|
|
804
|
+
// sequence is data instead of the order of push calls. That is what lets a
|
|
805
|
+
// caller reorder: `--segments` used to filter only, and its order was
|
|
806
|
+
// silently discarded, so a user who wanted Ctx first had no way to ask.
|
|
807
|
+
const usageByKey = new Map(usageSegs.map(({ key, seg }) => [key, seg]));
|
|
808
|
+
// Legacy `--segments` aliases: `5h` ↔ `five_hour`, `7d` ↔ `seven_day`.
|
|
809
|
+
const USAGE_ALIASES = { '5h': 'five_hour', '7d': 'seven_day' };
|
|
810
|
+
function segmentsNamed(name) {
|
|
811
|
+
switch (name) {
|
|
812
|
+
case 'cap-warn': return [capWarnSeg];
|
|
813
|
+
case 'spike': return [spikeSeg];
|
|
814
|
+
case 'version': return [versionSeg];
|
|
815
|
+
case 'harness': return [harnessSeg];
|
|
816
|
+
case 'korean': return [koreanSeg];
|
|
817
|
+
case 'model': return [modelSeg];
|
|
818
|
+
// Dropped when the same story already owns a headline line above, which
|
|
819
|
+
// would otherwise repeat it on line 2.
|
|
820
|
+
case 'delegated': return totalsLine ? [] : [delegateSeg];
|
|
821
|
+
case 'doc2md': return doc2mdLine ? [] : [doc2mdSeg];
|
|
822
|
+
case 'hit': return [hitSeg];
|
|
823
|
+
case 'ttl': return [ttlSeg];
|
|
824
|
+
case 'month': return [monthSeg];
|
|
825
|
+
case 'ctx': return [ctxSeg];
|
|
826
|
+
case 'saved': return [saveSeg];
|
|
827
|
+
case 'period': return [periodSeg];
|
|
828
|
+
// Every rate-limit window, in payload order.
|
|
829
|
+
case 'usage': return usageSegs.map(({ seg }) => seg);
|
|
830
|
+
default: {
|
|
831
|
+
const seg = usageByKey.get(USAGE_ALIASES[name] || name);
|
|
832
|
+
return seg ? [seg] : [];
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// An available upgrade is an "act on this" chip, so it moves up behind the
|
|
838
|
+
// warning chips. With nothing to upgrade to it stays pure identity, at the
|
|
839
|
+
// tail position DEFAULT_SEGMENT_ORDER gives it.
|
|
840
|
+
let defaultOrder = DEFAULT_SEGMENT_ORDER;
|
|
841
|
+
if (updateAvailable) {
|
|
842
|
+
const rest = DEFAULT_SEGMENT_ORDER.filter((n) => n !== 'version');
|
|
843
|
+
const usageAt = rest.indexOf('usage');
|
|
844
|
+
defaultOrder = [...rest.slice(0, usageAt), 'version', ...rest.slice(usageAt)];
|
|
845
|
+
}
|
|
846
|
+
// A gateway that reports its own spend makes the month chip the second dollar
|
|
847
|
+
// figure on the line, and the two never agree: the budget counts every call
|
|
848
|
+
// that key served, while the month chip estimates from this machine's session
|
|
849
|
+
// logs alone. Two numbers for what looks like one question is worse than one
|
|
850
|
+
// number, so the estimate steps aside when the measured value is present.
|
|
851
|
+
// `--segments month` still asks for it explicitly and still gets it.
|
|
852
|
+
const hasGatewayBudget = !!(caps && Array.isArray(caps.windows)
|
|
853
|
+
&& caps.windows.some((w) => w && w.key === 'litellm_budget'));
|
|
854
|
+
if (hasGatewayBudget) defaultOrder = defaultOrder.filter((n) => n !== 'month');
|
|
855
|
+
|
|
856
|
+
// A caller-supplied list sets the filter AND the order. Warning chips keep
|
|
857
|
+
// the lead wherever they were written: being seen first is their whole job,
|
|
858
|
+
// and someone reordering segments is not asking to bury an alarm.
|
|
859
|
+
const LEAD = ['cap-warn', 'spike'];
|
|
860
|
+
let order = defaultOrder;
|
|
861
|
+
if (allow) {
|
|
862
|
+
const names = segments.map((s) => String(s).toLowerCase());
|
|
863
|
+
const lead = LEAD.filter((n) => names.includes(n));
|
|
864
|
+
order = lead.concat(names.filter((n) => !LEAD.includes(n)));
|
|
865
|
+
}
|
|
866
|
+
|
|
704
867
|
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 (
|
|
868
|
+
// Dedupe so overlapping names (e.g. `usage,five_hour`) cannot print the same
|
|
869
|
+
// chip twice.
|
|
870
|
+
const seen = new Set();
|
|
871
|
+
for (const name of order) {
|
|
872
|
+
for (const seg of segmentsNamed(name)) {
|
|
873
|
+
if (!seg || seen.has(seg)) continue;
|
|
874
|
+
seen.add(seg);
|
|
875
|
+
segs.push(seg);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
// Fuse the analysis-window group with spaces instead of `·`. Separated, each
|
|
879
|
+
// piece reads as an independent chip and the label appears to qualify only the
|
|
880
|
+
// one immediately before it; joined, the three read as a single group and the
|
|
881
|
+
// label covers all of it. Only fused when they actually ended up adjacent, so
|
|
882
|
+
// a caller-supplied order is left alone. Identity comparison is safe here
|
|
883
|
+
// because these are the very strings pushed into `segs`, not rebuilt copies.
|
|
884
|
+
const windowGroup = [hitSeg, saveSeg, periodSeg].filter((seg) => seg && segs.includes(seg));
|
|
885
|
+
if (windowGroup.length > 1) {
|
|
886
|
+
const at = segs.indexOf(windowGroup[0]);
|
|
887
|
+
const adjacent = windowGroup.every((seg, i) => segs[at + i] === seg);
|
|
888
|
+
if (adjacent) segs.splice(at, windowGroup.length, windowGroup.join(' '));
|
|
726
889
|
}
|
|
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
890
|
// Trailing erase-to-end-of-line so any leftover characters from a previous
|
|
736
891
|
// (longer) statusline render don't bleed into ours. \x1b[K is the standard
|
|
737
892
|
// "erase from cursor to EOL" CSI. Only emitted when color (i.e. ANSI) is
|