sprag-cli 3.67.0 → 3.69.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 +33 -66
- package/README.md +41 -74
- package/bin/cli.js +150 -62
- package/docs/CODEX.ko.md +32 -0
- package/docs/CODEX.md +34 -0
- package/package.json +15 -4
- package/src/{codex-brief.js → codex/brief.js} +7 -7
- package/src/{codex-budget.js → codex/budget.js} +2 -2
- package/src/{codex-cache-policy.js → codex/cache-policy.js} +9 -9
- package/src/{codex-delegation-diagnostics.js → codex/delegation-diagnostics.js} +2 -2
- package/src/{codex-delegation.js → codex/delegation.js} +11 -11
- package/src/{codex-doc2md-ledger.js → codex/doc2md-ledger.js} +5 -5
- package/src/{codex-doc2md.js → codex/doc2md.js} +4 -4
- package/src/{codex-doctor.js → codex/doctor.js} +4 -4
- package/src/{codex-harness.js → codex/harness.js} +5 -5
- package/src/{codex-hooks.js → codex/hooks.js} +20 -20
- package/src/{codex-installer.js → codex/installer.js} +2 -2
- package/src/{codex-ledger.js → codex/ledger.js} +6 -6
- package/src/{codex-panel-launcher.js → codex/panel-launcher.js} +2 -2
- package/src/{codex-panel-runner.js → codex/panel-runner.js} +2 -2
- package/src/{codex-panel-session.js → codex/panel-session.js} +1 -1
- package/src/{codex-panel-setup.js → codex/panel-setup.js} +1 -1
- package/src/{codex-panel-state.js → codex/panel-state.js} +3 -3
- package/src/{codex-panel.js → codex/panel.js} +43 -34
- package/src/{codex-parser.js → codex/parser.js} +5 -9
- package/src/{codex-reply-language.js → codex/reply-language.js} +2 -2
- package/src/{codex-route-scan.js → codex/route-scan.js} +10 -10
- package/src/{codex-rule-health.js → codex/rule-health.js} +4 -4
- package/src/commands/codex-delegate.js +6 -6
- package/src/commands/codex.js +44 -44
- package/src/commands/install.js +10 -7
- package/src/commands/korean.js +1 -1
- package/src/commands/litellm-budget.js +56 -0
- package/src/commands/saved.js +60 -5
- package/src/commands/uninstall.js +3 -3
- package/src/config.js +4 -0
- package/src/delegation-agents.js +13 -0
- package/src/first-run-note.js +16 -0
- package/src/formatters/statusline.js +725 -504
- package/src/formatters/table.js +33 -2
- package/src/glyphs.js +2 -0
- package/src/installer.js +77 -1
- package/src/model-alias.js +45 -60
- package/src/model-rules.js +25 -4
- package/src/model-share.js +136 -0
- package/src/parser.js +4 -13
- package/src/session-records.js +2 -8
- package/src/star-note.js +62 -0
- package/src/statusline-base.js +9 -3
- package/src/stdin-payload.js +1 -1
- package/src/transcript-lines.js +40 -0
- package/src/changelog.js +0 -70
- /package/src/{codex-cache.js → codex/cache.js} +0 -0
- /package/src/{codex-panel-shell.js → codex/panel-shell.js} +0 -0
|
@@ -25,6 +25,10 @@ import { loadConfig } from '../config.js';
|
|
|
25
25
|
import { koreanStyleEnabled } from '../korean-style.js';
|
|
26
26
|
import { effortTones } from '../effort-palette.js';
|
|
27
27
|
import { MIN_VOTES } from '../model-alias.js';
|
|
28
|
+
import { bedrockDisplayFromId } from '../stdin-payload.js';
|
|
29
|
+
import { modelFamily } from '../savings-ledger.js';
|
|
30
|
+
import { shareLegend } from '../model-share.js';
|
|
31
|
+
import { STAR_URL } from '../star-note.js';
|
|
28
32
|
|
|
29
33
|
// The 8-color ANSI defaults (RED=31, GREEN=32, YELLOW=33…) read as garish
|
|
30
34
|
// next to each other — terminal palettes set them with unbalanced perceptual
|
|
@@ -53,6 +57,9 @@ const MAGENTA = fg(167, 139, 250, '\x1b[35m');
|
|
|
53
57
|
const GRAY = fg(100, 116, 139, '\x1b[90m');
|
|
54
58
|
const BOLD = '\x1b[1m';
|
|
55
59
|
|
|
60
|
+
// Shared with model-share.js so its bar uses the same palette as the chips.
|
|
61
|
+
export { RESET, RED, GREEN, YELLOW, CYAN, MAGENTA, GRAY };
|
|
62
|
+
|
|
56
63
|
export function formatMoney(usd) {
|
|
57
64
|
// The sign leads the currency symbol ("-$0.12"); a loss that rounds to zero
|
|
58
65
|
// prints as plain "$0.00" rather than "-$0.00".
|
|
@@ -242,11 +249,17 @@ export function pickCapWarn(caps) {
|
|
|
242
249
|
|
|
243
250
|
/**
|
|
244
251
|
* Korean-style chip builder. Renders only when the session-start injection is
|
|
245
|
-
* enabled
|
|
252
|
+
* enabled AND `--segments` names `korean`.
|
|
253
|
+
*
|
|
254
|
+
* It used to sit in the default order as a gray "yes, it is on" confirmation, and
|
|
255
|
+
* that is the reason it left: a chip that is on every line of every Korean-style
|
|
256
|
+
* user's statusline carries no information after the first day, and it spent
|
|
257
|
+
* the tail position where a narrow terminal clips. The confirmation is still one
|
|
258
|
+
* flag away for anyone debugging a hook that might have failed silently.
|
|
246
259
|
*/
|
|
247
|
-
function buildKoreanSeg(c, isIcon, verbose, g) {
|
|
260
|
+
function buildKoreanSeg(c, isIcon, verbose, g, explicit = false) {
|
|
248
261
|
try {
|
|
249
|
-
if (!koreanStyleEnabled()) return null;
|
|
262
|
+
if (!explicit || !koreanStyleEnabled()) return null;
|
|
250
263
|
// Deliberately quiet (gray, one glyph): this is a "yes, it is on"
|
|
251
264
|
// confirmation, not a warning. Without it a silently-failed hook looks
|
|
252
265
|
// exactly like a working one, because the style only shows up when the
|
|
@@ -344,18 +357,25 @@ function buildVersionSeg(version, update, c, isIcon, verbose, g) {
|
|
|
344
357
|
// cannot tell "up to date as of this morning" from "never checked since the
|
|
345
358
|
// network went away", and the second is the one worth noticing. Needs a
|
|
346
359
|
// `latest` on record, since a check that never answered confirmed nothing.
|
|
360
|
+
// Written `✓5m ago`, not `✓5m`: the bare form reads as "5 minutes left", the
|
|
361
|
+
// way every other duration on this line (the cache countdown) does.
|
|
347
362
|
const age = verbose && update && update.latest && !update.available
|
|
348
363
|
? formatAge(Date.now() - (toEpochMs(update.checkedAt) ?? NaN))
|
|
349
364
|
: null;
|
|
350
|
-
return `${c(GRAY)}v${version}${age ? ` ✓${age}` : ''}${c(RESET)}`;
|
|
365
|
+
return `${c(GRAY)}v${version}${age ? ` ✓${age} ago` : ''}${c(RESET)}`;
|
|
351
366
|
}
|
|
352
367
|
|
|
353
368
|
/**
|
|
354
369
|
* Harness 🅷 segment builder — shared by the full report and the no-session
|
|
355
370
|
* fallback line. Best-effort: never throws into the statusline (corrupted
|
|
356
371
|
* CLAUDE.md, permission issue, etc. → null).
|
|
372
|
+
*
|
|
373
|
+
* A full score with nothing to warn about draws nothing, unless `explicit` says
|
|
374
|
+
* `--segments` named the chip. `🅷 5/5` on every line says "nothing to do", which
|
|
375
|
+
* is the absence of news; the chip earns its place when it is short of 5/5, red,
|
|
376
|
+
* or asked for.
|
|
357
377
|
*/
|
|
358
|
-
function buildHarnessSeg(c, isIcon, g, liveWindow = null) {
|
|
378
|
+
function buildHarnessSeg(c, isIcon, g, liveWindow = null, explicit = false) {
|
|
359
379
|
try {
|
|
360
380
|
const harnessInfo = harnessStatusForStatusline(loadConfig(), { liveWindow });
|
|
361
381
|
if (!harnessInfo) return null;
|
|
@@ -371,13 +391,65 @@ function buildHarnessSeg(c, isIcon, g, liveWindow = null) {
|
|
|
371
391
|
return `${c(RED)}${icon}${g.spike} ${harnessInfo.warning}${more > 0 ? ` +${more}` : ''}${c(RESET)}`;
|
|
372
392
|
}
|
|
373
393
|
if (harnessInfo.custom) return `${c(CYAN)}${icon} custom${c(RESET)}`;
|
|
374
|
-
const
|
|
394
|
+
const complete = harnessInfo.configured >= harnessInfo.total;
|
|
395
|
+
if (complete && !explicit) return null;
|
|
396
|
+
const tone = complete ? GREEN : YELLOW;
|
|
375
397
|
return `${c(tone)}${icon} ${harnessInfo.configured}/${harnessInfo.total}${c(RESET)}`;
|
|
376
398
|
} catch {
|
|
377
399
|
return null;
|
|
378
400
|
}
|
|
379
401
|
}
|
|
380
402
|
|
|
403
|
+
/**
|
|
404
|
+
* A short model name for the chip: `Fable 5.1`, `Opus 5.5`, `Sonnet 3.5`.
|
|
405
|
+
*
|
|
406
|
+
* Claude Code sends `model.display_name`, and extractModel() already turns a
|
|
407
|
+
* Bedrock-style id into the same shape for the three families it knows. What
|
|
408
|
+
* reaches this point as a raw id is therefore what that function did not
|
|
409
|
+
* recognise: a family it has no entry for (`claude-fable-5-1`) and the legacy
|
|
410
|
+
* order, where the version comes before the family (`claude-3-5-sonnet-20241022`).
|
|
411
|
+
* Left alone, those print as the full id, date stamp and gateway prefix included.
|
|
412
|
+
*
|
|
413
|
+
* The opus/sonnet/haiku case goes through bedrockDisplayFromId so the two cannot
|
|
414
|
+
* disagree about it; only the remainder is handled here. Anything that does not
|
|
415
|
+
* parse (an unknown family, a display name that already reads well, a gateway
|
|
416
|
+
* alias with no `claude-` in it) comes back unchanged, so an unmapped name stays
|
|
417
|
+
* visible rather than being guessed at.
|
|
418
|
+
*/
|
|
419
|
+
export function shortModelName(model) {
|
|
420
|
+
if (typeof model !== 'string' || model === '') return model;
|
|
421
|
+
const viaId = bedrockDisplayFromId(model);
|
|
422
|
+
if (viaId) return viaId;
|
|
423
|
+
const family = modelFamily(model);
|
|
424
|
+
// modelFamily() hands the input back for a name it does not know.
|
|
425
|
+
if (family === model) return model;
|
|
426
|
+
// Minor version is one or two digits; a longer run is the date stamp, which
|
|
427
|
+
// must not be read as one (see bedrockDisplayFromId).
|
|
428
|
+
const ver = '(\\d+)(?:[-_](\\d{1,2})(?!\\d))?';
|
|
429
|
+
const m = model.match(new RegExp(`claude[-_]${family}[-_]${ver}`, 'i'))
|
|
430
|
+
|| model.match(new RegExp(`claude[-_]${ver}[-_]${family}`, 'i'));
|
|
431
|
+
if (!m) return model;
|
|
432
|
+
const name = family.charAt(0).toUpperCase() + family.slice(1);
|
|
433
|
+
return m[2] === undefined ? `${name} ${m[1]}` : `${name} ${m[1]}.${m[2]}`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Model chip — pulled from Claude Code's stdin payload (`model.display_name`).
|
|
438
|
+
* Cheap identity context: useful when the user toggles between Sonnet/Opus
|
|
439
|
+
* mid-session and wants to confirm at a glance which one is answering.
|
|
440
|
+
*
|
|
441
|
+
* Shared by the full report and the no-session line, which used to draw it
|
|
442
|
+
* twice and could disagree about the name.
|
|
443
|
+
*/
|
|
444
|
+
function buildModelSeg(model, c, isIcon, g) {
|
|
445
|
+
if (typeof model !== 'string' || model.length === 0) return null;
|
|
446
|
+
const name = shortModelName(model);
|
|
447
|
+
// 🤖 + name is enough — the emoji disambiguates so the literal word "Model"
|
|
448
|
+
// is dead weight in icon mode. Text modes keep the bare name; the magenta
|
|
449
|
+
// tone marks it as identity context.
|
|
450
|
+
return isIcon ? `${c(MAGENTA)}${g.model} ${name}${c(RESET)}` : `${c(MAGENTA)}${name}${c(RESET)}`;
|
|
451
|
+
}
|
|
452
|
+
|
|
381
453
|
/**
|
|
382
454
|
* Delegation-rule registry chip: `rules 10 · ⚠1`. Rendered only when
|
|
383
455
|
* `--segments` names `rules`, since it describes configuration rather than
|
|
@@ -460,7 +532,7 @@ function buildCapWarnSeg(capWarn, c, isIcon, g, color, mode) {
|
|
|
460
532
|
* harness, model, and effort chips still render around the "no session data"
|
|
461
533
|
* note.
|
|
462
534
|
*/
|
|
463
|
-
export function formatNoSession({ caps = null, model = null, effort = null, windowLabel = '', version = '', update = null } = {}, { color = true, mode = 'icon' } = {}) {
|
|
535
|
+
export function formatNoSession({ caps = null, model = null, effort = null, windowLabel = '', version = '', update = null } = {}, { color = true, mode = 'icon', segments = null } = {}) {
|
|
464
536
|
const c = (v) => (color ? v : '');
|
|
465
537
|
// narrow shares the icon layout and swaps only the glyphs, so both modes take
|
|
466
538
|
// the same branches from here on.
|
|
@@ -471,11 +543,14 @@ export function formatNoSession({ caps = null, model = null, effort = null, wind
|
|
|
471
543
|
if (capSeg) segs.push(capSeg);
|
|
472
544
|
const versionSeg = buildVersionSeg(version, update, c, isIcon, false, g);
|
|
473
545
|
if (versionSeg && update && update.available) segs.push(versionSeg);
|
|
474
|
-
|
|
546
|
+
// Same rule as the full report: a healthy 🅷 stays off the line unless
|
|
547
|
+
// `--segments` named it. This line has no other opt-in, so `segments` is only
|
|
548
|
+
// read for that one name.
|
|
549
|
+
const named = (name) => !!(segments && segments.some((s) => String(s).toLowerCase() === name));
|
|
550
|
+
const harnessSeg = buildHarnessSeg(c, isIcon, g, null, named('harness'));
|
|
475
551
|
if (harnessSeg) segs.push(harnessSeg);
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
}
|
|
552
|
+
const modelSeg = buildModelSeg(model, c, isIcon, g);
|
|
553
|
+
if (modelSeg) segs.push(modelSeg);
|
|
479
554
|
const effortSeg = buildEffortSeg(effort, c, isIcon, g);
|
|
480
555
|
if (effortSeg) segs.push(effortSeg);
|
|
481
556
|
segs.push(`${c(GRAY)}${g.hit} no session data${windowLabel ? ` · ${windowLabel}` : ''}${c(RESET)}`);
|
|
@@ -484,61 +559,62 @@ export function formatNoSession({ caps = null, model = null, effort = null, wind
|
|
|
484
559
|
}
|
|
485
560
|
|
|
486
561
|
/**
|
|
487
|
-
*
|
|
488
|
-
*
|
|
489
|
-
* @param {boolean} [opts.color=true] - emit ANSI escape codes
|
|
490
|
-
* @param {boolean} [opts.verbose=false] - longer layout with labels
|
|
491
|
-
* @param {boolean} [opts.timer=true] - show TTL countdown segment
|
|
492
|
-
* @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses 🧠 ⏳ 💰 instead of word labels.
|
|
493
|
-
* @param {string[]|null} [opts.segments] - segments to render, in the order given. Names: cap-warn, spike, version, harness, korean, model, effort, hit, ttl, month, saved, delegated, doc2md, ctx, period, `subagents` (this session's subagent runs; also rides with `delegated`), `rules` (only when named here), `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.
|
|
494
|
-
* @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).
|
|
495
|
-
*/
|
|
496
|
-
/**
|
|
497
|
-
* Default segment order, most actionable first. A rate-limit block or a full
|
|
498
|
-
* context window stops the work outright, so those lead. Cache and cost numbers
|
|
499
|
-
* shape habits rather than the next keystroke, and harness / Korean-style are
|
|
500
|
-
* near-constant identity: they sit at the tail, where a narrow terminal clips
|
|
501
|
-
* the least useful end of the line instead of the most useful one.
|
|
502
|
-
*
|
|
503
|
-
* `version` is listed once, at its identity position. formatReport() moves it
|
|
504
|
-
* forward when an upgrade is actually available, since it becomes an
|
|
505
|
-
* "act on this" chip then.
|
|
506
|
-
*
|
|
507
|
-
* Exported so a test can assert every name here is one the renderer knows:
|
|
508
|
-
* the order list and the segment registry are separate structures, and a name
|
|
509
|
-
* added to only one of them would be dropped in silence.
|
|
562
|
+
* Cache hit-rate chip. The color is the signal: green at 85%+, yellow from 70%,
|
|
563
|
+
* red below.
|
|
510
564
|
*/
|
|
511
|
-
|
|
512
|
-
'cap-warn', 'spike',
|
|
513
|
-
'usage', 'ctx',
|
|
514
|
-
'ttl',
|
|
515
|
-
'model', 'effort',
|
|
516
|
-
// Grouped by the timeframe each figure covers, because mixing them was the
|
|
517
|
-
// problem: a lifetime total, a windowed rate and a calendar-month estimate sat
|
|
518
|
-
// interleaved, with one window label at the end of the line that appeared to
|
|
519
|
-
// qualify all of them.
|
|
520
|
-
//
|
|
521
|
-
// Lifetime, from the savings ledgers. Delegation stays ahead of the cache
|
|
522
|
-
// figure: routing is what the tool is for, the cache number is a brag stat.
|
|
523
|
-
'delegated', 'doc2md',
|
|
524
|
-
// Analysis window. These two are the only chips measured over it, so the label
|
|
525
|
-
// sits with them and the three render as one group.
|
|
526
|
-
'hit', 'saved', 'period',
|
|
527
|
-
// Calendar month.
|
|
528
|
-
'month',
|
|
529
|
-
'harness', 'korean', 'version',
|
|
530
|
-
];
|
|
531
|
-
|
|
532
|
-
export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null, singleLine = false } = {}) {
|
|
533
|
-
const { summary, ttl, cost, options, lastActivity, contextWindow, ctxLive, spikeChip, caps, model, effort } = data;
|
|
534
|
-
const { hitRate } = summary;
|
|
535
|
-
|
|
565
|
+
function buildHitSeg(hitRate, c, isIcon, verbose, g) {
|
|
536
566
|
// Hit rate → color signal
|
|
537
567
|
const hitColor =
|
|
538
568
|
hitRate >= 0.85 ? GREEN :
|
|
539
569
|
hitRate >= 0.70 ? YELLOW :
|
|
540
570
|
RED;
|
|
541
571
|
|
|
572
|
+
// Labels per mode.
|
|
573
|
+
// text: "Cache hit 98.3%" | verbose: "Cache hit 98.3%"
|
|
574
|
+
// icon: "🧠 98.3%" | verbose: "🧠 Cache hit 98.3%"
|
|
575
|
+
const hitLabel = isIcon
|
|
576
|
+
? (verbose ? `${g.hit} Cache hit` : g.hit)
|
|
577
|
+
: 'Cache hit';
|
|
578
|
+
return `${c(BOLD)}${hitLabel}${c(RESET)} ${c(hitColor)}${formatPct(hitRate)}${c(RESET)}`;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Prompt-cache savings chip, over the analysis window. A different number from
|
|
583
|
+
* the routing and doc2md totals, which are lifetime figures from their ledgers.
|
|
584
|
+
*/
|
|
585
|
+
function buildSavedSeg(cost, c, isIcon, verbose, g) {
|
|
586
|
+
// noCache − actual goes negative in write-heavy sessions (cache writes bill
|
|
587
|
+
// 1.25×/2× with few reads yet); "$-0.42" is not a saving, so floor at zero.
|
|
588
|
+
const savings = Math.max(0, cost?.savings ?? 0);
|
|
589
|
+
|
|
590
|
+
// text: "Cache saved $1.5K" | same in verbose
|
|
591
|
+
// icon: "💰 $1.5K" | verbose: "💰 Cache saved $1.5K"
|
|
592
|
+
const saveLabel = isIcon
|
|
593
|
+
? (verbose ? `${g.saved} Cache saved` : g.saved)
|
|
594
|
+
: 'Cache saved';
|
|
595
|
+
return `${c(CYAN)}${saveLabel}${c(RESET)} ${formatMoney(savings)}`;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* The analysis-window label, as a qualifier on the hit-rate and savings chips
|
|
600
|
+
* before it.
|
|
601
|
+
*/
|
|
602
|
+
function buildPeriodSeg(options, c, verbose) {
|
|
603
|
+
// Period label honors hour-precision configs (`mode 6h` → "6h", `mode 1d` → "1d").
|
|
604
|
+
// Fall back to legacy `${days}d` when callers haven't supplied a label.
|
|
605
|
+
const periodLabel = options.windowLabel || `${options.days}d`;
|
|
606
|
+
// Parenthesized so it reads as a qualifier on the two chips before it rather
|
|
607
|
+
// than as one more independent chip.
|
|
608
|
+
return verbose
|
|
609
|
+
? `${c(GRAY)}(last ${periodLabel})${c(RESET)}`
|
|
610
|
+
: `${c(GRAY)}(${periodLabel})${c(RESET)}`;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Which cache bucket the session is in, and the countdown length that follows
|
|
615
|
+
* from it: `{ is1h, bucketLabel, bucketColor, ttlSeconds }`.
|
|
616
|
+
*/
|
|
617
|
+
function resolveTtlBucket(ttl, ttlBucket) {
|
|
542
618
|
// TTL dominance → color signal (1h = good, 5m = warning).
|
|
543
619
|
// The subscription plan fixes this, so the bucket rarely changes — it's the countdown that matters.
|
|
544
620
|
// When ttl.total === 0 (no cache writes observed in the window), we cannot
|
|
@@ -554,7 +630,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
554
630
|
// explicit `ttlBucket` setting outranks both, so a gateway that starts
|
|
555
631
|
// reporting the split correctly does not need a release to be believed.
|
|
556
632
|
const hasTtlData = ttl.total > 0;
|
|
557
|
-
const override =
|
|
633
|
+
const override = ttlBucket === '5m' || ttlBucket === '1h' ? ttlBucket : null;
|
|
558
634
|
const is1h = override ? override === '1h' : (hasTtlData ? ttl.pct1h >= 0.5 : !ttl.gatewayObserved);
|
|
559
635
|
// Three grades of certainty, three labels: measured (`1h`/`5m`), inferred
|
|
560
636
|
// from a gateway model id (`5m?`), and unknown (`?`). Folding the middle
|
|
@@ -567,148 +643,143 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
567
643
|
? (is1h ? GREEN : YELLOW)
|
|
568
644
|
: (ttl.gatewayObserved ? YELLOW : GRAY);
|
|
569
645
|
const ttlSeconds = is1h ? 3600 : 300;
|
|
646
|
+
return { is1h, bucketLabel, bucketColor, ttlSeconds };
|
|
647
|
+
}
|
|
570
648
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
// the same branches from here on.
|
|
578
|
-
const isIcon = mode === 'icon' || mode === 'narrow';
|
|
579
|
-
const g = glyphsFor(mode);
|
|
649
|
+
/**
|
|
650
|
+
* Cache TTL chip: the countdown on the last call's cache entry, or just the
|
|
651
|
+
* bucket when the timer is off or there is no last call.
|
|
652
|
+
*/
|
|
653
|
+
function buildTtlSeg({ ttl, ttlBucket, lastActivity }, timer, c, isIcon, verbose, g) {
|
|
654
|
+
const { is1h, bucketLabel, bucketColor, ttlSeconds } = resolveTtlBucket(ttl, ttlBucket);
|
|
580
655
|
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
656
|
+
// TTL countdown — how much time is left on the last API call's cache entry.
|
|
657
|
+
// Matches Anthropic's actual prompt-cache behaviour: each call starts a fresh
|
|
658
|
+
// TTL window, and the next call (hit) within that window resets it. So the
|
|
659
|
+
// countdown visibly ticks down between prompts, and "resets" happens as a
|
|
660
|
+
// jump back toward the bucket max the moment you send another message.
|
|
661
|
+
// Compact modes drop the bucket label — it's read as part of the clock
|
|
662
|
+
// ("1h 59:58" gets parsed as "1 hour 59 minutes 58 seconds"). The bucket
|
|
663
|
+
// is plan-determined and rarely changes, so verbose mode is where it belongs.
|
|
664
|
+
// text compact: "Expires 59:58"
|
|
665
|
+
// text verbose: "1h bucket · expires in 59:58"
|
|
666
|
+
// icon compact: "⏳ 59:58"
|
|
667
|
+
// icon verbose: "⏳ Expires 1h 59:58"
|
|
668
|
+
let ttlSeg;
|
|
669
|
+
if (timer && lastActivity) {
|
|
670
|
+
// Coerce to a numeric ms timestamp. Some upstream paths handed in a Date,
|
|
671
|
+
// a stringified ISO timestamp, or epoch-seconds — any of which silently
|
|
672
|
+
// produces NaN/huge values when subtracted from Date.now(), which then
|
|
673
|
+
// bypasses formatTimer's normal MM:SS shape.
|
|
674
|
+
const laMs =
|
|
675
|
+
typeof lastActivity === 'number'
|
|
676
|
+
? (lastActivity < 1e12 ? lastActivity * 1000 : lastActivity) // seconds → ms
|
|
677
|
+
: (lastActivity instanceof Date ? lastActivity.getTime() : Date.parse(lastActivity));
|
|
678
|
+
const elapsed = Number.isFinite(laMs) ? (Date.now() - laMs) / 1000 : Infinity;
|
|
679
|
+
// Clamp remaining into the bucket so a clock-skew or stale-state edge case
|
|
680
|
+
// can't display a value larger than the bucket itself.
|
|
681
|
+
const remaining = Math.min(ttlSeconds, ttlSeconds - elapsed);
|
|
682
|
+
const text = formatTimer(remaining);
|
|
683
|
+
const pct = remaining / ttlSeconds;
|
|
684
|
+
// Percentages are the wrong unit in a 5-minute bucket: 30% of it is 90
|
|
685
|
+
// seconds, and green there reads as comfort the user does not have. Below
|
|
686
|
+
// an hour the thresholds are absolute, so the color tracks whether there
|
|
687
|
+
// is time to finish a thought rather than a share of a short window.
|
|
688
|
+
const timerColor =
|
|
689
|
+
remaining <= 0 ? RED :
|
|
690
|
+
is1h
|
|
691
|
+
? (pct > 0.30 ? GREEN : pct > 0.10 ? YELLOW : RED)
|
|
692
|
+
: (remaining > 60 ? GREEN : remaining > 30 ? YELLOW : RED);
|
|
588
693
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
694
|
+
if (isIcon && verbose) {
|
|
695
|
+
// Drop bucket here too — `⏳ Expires 1h 57:20` reads as "1h 57m 20s left"
|
|
696
|
+
// for the same reason the compact form did. The bucket lives in the
|
|
697
|
+
// text-verbose layout where the "bucket" word + `·` separator make it
|
|
698
|
+
// unambiguous.
|
|
699
|
+
ttlSeg = `${c(timerColor)}${g.ttl} Cache expires ${text}${c(RESET)}`;
|
|
700
|
+
} else if (isIcon) {
|
|
701
|
+
ttlSeg = `${c(timerColor)}${g.ttl} ${text}${c(RESET)}`;
|
|
702
|
+
} else if (verbose) {
|
|
703
|
+
ttlSeg = `${c(bucketColor)}Cache ${bucketLabel} bucket${c(RESET)} · ${c(timerColor)}expires in ${text}${c(RESET)}`;
|
|
704
|
+
} else {
|
|
705
|
+
ttlSeg = `${c(timerColor)}Cache expires ${text}${c(RESET)}`;
|
|
706
|
+
}
|
|
707
|
+
} else {
|
|
708
|
+
// No-timer fallback: only the bucket is available, so we show just that.
|
|
709
|
+
if (isIcon) {
|
|
710
|
+
const prefix = verbose ? `${g.ttl} Cache bucket ` : `${g.ttl} `;
|
|
711
|
+
ttlSeg = `${c(bucketColor)}${prefix}${bucketLabel}${c(RESET)}`;
|
|
712
|
+
} else if (verbose) {
|
|
713
|
+
ttlSeg = `${c(bucketColor)}Cache ${bucketLabel} bucket${c(RESET)}`;
|
|
714
|
+
} else {
|
|
715
|
+
ttlSeg = `${c(bucketColor)}Cache bucket ${bucketLabel}${c(RESET)}`;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return ttlSeg;
|
|
719
|
+
}
|
|
595
720
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
//
|
|
602
|
-
// "Routing saved" says what earned the money — work that ran on a cheaper
|
|
603
|
-
// model instead of this one. It leads the line rather than trailing it
|
|
604
|
-
// because it is the headline number of the whole tool, not a footnote.
|
|
605
|
-
// text: "Routing saved $3.2" | same in verbose
|
|
606
|
-
// icon: "🔀 $3.2" | verbose: "🔀 Routing saved $3.2"
|
|
607
|
-
const delegationSaved = Number(data.delegationSaved) || 0;
|
|
608
|
-
const delegateLabel = isIcon
|
|
609
|
-
? (verbose ? `${g.routing} Routing saved` : g.routing)
|
|
610
|
-
: 'Routing saved';
|
|
611
|
-
// Zero savings has two very different causes and, until now, one appearance:
|
|
612
|
-
// nothing at all. "Never delegated" and "delegated plenty, but every run was
|
|
613
|
-
// dropped because the gateway model id could not be resolved" looked
|
|
614
|
-
// identical, so users in the second case had no reason to suspect anything
|
|
615
|
-
// was wrong. The count gets a chip; the explanation stays in `route-scan
|
|
616
|
-
// rules`, where there is room for it.
|
|
721
|
+
/**
|
|
722
|
+
* Context chip, from the live fill level when Claude Code sent one and from the
|
|
723
|
+
* transcript-inferred window otherwise.
|
|
724
|
+
*/
|
|
725
|
+
function buildCtxSeg(ctxLive, contextWindow, c, isIcon, verbose, g) {
|
|
726
|
+
// Context chip. Two data sources, best first:
|
|
617
727
|
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
728
|
+
// 1. Live fill level from Claude Code's stdin (`context_window.used_percentage`)
|
|
729
|
+
// — the current session's actual usage, refreshed every render. Rendered
|
|
730
|
+
// as `📦 68%` and colored by fill (green <70, yellow 70–89, red 90+),
|
|
731
|
+
// matching the cap-segment tone scale.
|
|
732
|
+
// 2. Fallback (table view / older Claude Code): transcript-inferred window
|
|
733
|
+
// size. Note the semantics: `size === '1M'` means a real request already
|
|
734
|
+
// carried >210k input tokens — actual heavy usage, not just the model
|
|
735
|
+
// supporting 1M. Current models are all 1M by default with no price
|
|
736
|
+
// premium, so this renders yellow ("your context is genuinely big"),
|
|
737
|
+
// not red ("expensive mode on") like it used to.
|
|
738
|
+
let ctxSeg = null;
|
|
739
|
+
if (ctxLive && Number.isFinite(ctxLive.usedPct)) {
|
|
740
|
+
const pct = Math.max(0, Math.round(ctxLive.usedPct));
|
|
741
|
+
const tone = pct >= 90 ? RED : pct >= 70 ? YELLOW : GREEN;
|
|
742
|
+
const sizeLabel = ctxLive.size
|
|
743
|
+
? (ctxLive.size >= 900_000 ? '1M' : `${Math.round(ctxLive.size / 1000)}k`)
|
|
744
|
+
: null;
|
|
745
|
+
const longLabel = sizeLabel ? `${pct}% of ${sizeLabel}` : `${pct}%`;
|
|
746
|
+
if (isIcon && verbose) {
|
|
747
|
+
ctxSeg = `${c(tone)}${g.ctx} Ctx ${longLabel}${c(RESET)}`;
|
|
748
|
+
} else if (isIcon) {
|
|
749
|
+
ctxSeg = `${c(tone)}${g.ctx} ${pct}%${c(RESET)}`;
|
|
750
|
+
} else {
|
|
751
|
+
ctxSeg = `${c(tone)}Ctx ${longLabel}${c(RESET)}`;
|
|
752
|
+
}
|
|
753
|
+
} else if (contextWindow && contextWindow.size && contextWindow.size !== 'unknown') {
|
|
754
|
+
const label = contextWindow.size === '1M' ? '1M' : '200k';
|
|
755
|
+
// Yellow is reserved for the 500k warn line. A 1M window by itself is the
|
|
756
|
+
// default on every current model, so colouring it yellow cried wolf.
|
|
757
|
+
const ctxColor = contextWindow.overWarn ? YELLOW : GREEN;
|
|
758
|
+
if (isIcon && verbose) {
|
|
759
|
+
ctxSeg = `${c(ctxColor)}${g.ctx} Ctx ${label}${c(RESET)}`;
|
|
760
|
+
} else if (isIcon) {
|
|
761
|
+
ctxSeg = `${c(ctxColor)}${g.ctx} ${label}${c(RESET)}`;
|
|
762
|
+
} else {
|
|
763
|
+
ctxSeg = `${c(ctxColor)}Ctx ${label}${c(RESET)}`;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return ctxSeg;
|
|
767
|
+
}
|
|
629
768
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
//
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
const totals = data.delegationTotals;
|
|
636
|
-
const routingSince = formatSince(totals && totals.since);
|
|
637
|
-
const sinceWord = (since) => (since ? ` ${c(GRAY)}since ${since}${c(RESET)}` : '');
|
|
638
|
-
const sinceCompact = (since) => (since ? ` ${c(GRAY)}(${since}~)${c(RESET)}` : '');
|
|
639
|
-
const sinceInline = isIcon && !verbose ? sinceCompact : sinceWord;
|
|
640
|
-
|
|
641
|
-
// Savings come from the ledger route-scan fills, so they are only as fresh as
|
|
642
|
-
// the last scan. Said only once that has gone stale: a recent scan is the
|
|
643
|
-
// normal case and needs no caveat.
|
|
644
|
-
const scanAt = toEpochMs(data.routeScanAt);
|
|
645
|
-
const scanAge = scanAt !== null && Date.now() - scanAt > SCAN_STALE_MS ? formatAge(Date.now() - scanAt) : null;
|
|
646
|
-
const scanTag = scanAge
|
|
647
|
-
? ` ${c(GRAY)}${verbose ? `last scan ${scanAge} ago` : `scan ${scanAge}`}${c(RESET)}`
|
|
648
|
-
: '';
|
|
649
|
-
|
|
650
|
-
// What the same delegated runs cost on the models they ran on, read beside
|
|
651
|
-
// the saving so the reader sees what it was bought with: "saved $14.7 ·
|
|
652
|
-
// spent $5.2". Gray, since it is context for the saving rather than a result.
|
|
653
|
-
// Shown once any run carries a cost; runs whose event has none (recorded
|
|
654
|
-
// before costs were kept and not rescanned since, or unpriced) are counted
|
|
655
|
-
// instead of being read as free.
|
|
656
|
-
// text: "Routing saved $14.7 · spent $5.2 since 10/9"
|
|
657
|
-
// icon: "🔀 $14.7 · spent $5.2 (10/9~)"
|
|
658
|
-
const spentUsd = Number(totals && totals.spent) || 0;
|
|
659
|
-
const spentMissing = Number(totals && totals.spentMissing) || 0;
|
|
660
|
-
const spentTag = spentUsd > 0
|
|
661
|
-
? ` ${c(GRAY)}· spent ${formatMoney(spentUsd)}${spentMissing > 0 ? ` +${spentMissing} unpriced` : ''}${c(RESET)}`
|
|
662
|
-
: '';
|
|
663
|
-
|
|
664
|
-
const savedTone = delegationSaved < 0 ? RED : delegationSaved > 0 ? GREEN : GRAY;
|
|
665
|
-
const savingsChip = delegationSaved !== 0 || routingSince
|
|
666
|
-
? `${c(savedTone)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}${spentTag}${sinceInline(routingSince)}${scanTag}`
|
|
667
|
-
: null;
|
|
668
|
-
// The unresolved count stands in only while there are no savings to show, so
|
|
669
|
-
// a dated zero keeps both: the reset and the dropped runs are separate facts.
|
|
670
|
-
const delegateSeg = savingsChip || (delegationSaved === 0 ? unresolvedSeg : null);
|
|
671
|
-
const delegateExtra = savingsChip && delegationSaved === 0 ? unresolvedSeg : null;
|
|
672
|
-
|
|
673
|
-
// Document conversions — the same kind of number as "Routing saved", earned
|
|
674
|
-
// a different way: a document read as Markdown instead of attached whole.
|
|
675
|
-
// Kept as its own chip rather than folded into the routing total, because a
|
|
676
|
-
// single figure could not tell the reader which habit earned it.
|
|
677
|
-
//
|
|
678
|
-
// Conversions that saved nothing measurable still show as a count. For
|
|
679
|
-
// pptx/xlsx/docx the honest saving is zero — the client extracts much the
|
|
680
|
-
// same text — and a chip that disappeared on those would read as "doc2md
|
|
681
|
-
// did nothing" on the very formats it is the only way to open.
|
|
682
|
-
// icon: "📄 $0.42" verbose: "📄 Doc2md saved $0.42 · 12 docs"
|
|
683
|
-
// text: "Doc2md saved $0.42"
|
|
684
|
-
const doc2md = data.doc2mdTotals;
|
|
685
|
-
const doc2mdUsd = Number(doc2md && doc2md.total) || 0;
|
|
686
|
-
const doc2mdDocs = Number(doc2md && doc2md.docs) || 0;
|
|
687
|
-
// Same reset mark as the routing counter, and the same reason to show a dated
|
|
688
|
-
// zero: after `sprag saved reset` an empty counter and a disabled feature
|
|
689
|
-
// would otherwise look alike.
|
|
690
|
-
const doc2mdSince = formatSince(doc2md && doc2md.since);
|
|
691
|
-
const doc2mdLabel = isIcon
|
|
692
|
-
? (verbose ? `${g.doc} Doc2md saved` : g.doc)
|
|
693
|
-
: 'Doc2md saved';
|
|
694
|
-
let doc2mdSeg = null;
|
|
695
|
-
if (doc2mdUsd > 0) {
|
|
696
|
-
doc2mdSeg = `${c(GREEN)}${doc2mdLabel}${c(RESET)} ${formatMoney(doc2mdUsd)}${sinceInline(doc2mdSince)}`
|
|
697
|
-
+ (verbose ? ` ${c(GRAY)}· ${doc2mdDocs} docs${c(RESET)}` : '');
|
|
698
|
-
} else if (doc2mdDocs > 0) {
|
|
699
|
-
const label = isIcon ? g.doc : 'Doc2md';
|
|
700
|
-
doc2mdSeg = `${c(GRAY)}${label} ${doc2mdDocs} docs${doc2mdSince ? ` ${isIcon && !verbose ? `(${doc2mdSince}~)` : `since ${doc2mdSince}`}` : ''}${c(RESET)}`;
|
|
701
|
-
} else if (doc2mdSince) {
|
|
702
|
-
doc2mdSeg = `${c(GRAY)}${doc2mdLabel} ${formatMoney(0)}${isIcon && !verbose ? ` (${doc2mdSince}~)` : ` since ${doc2mdSince}`}${c(RESET)}`;
|
|
703
|
-
}
|
|
769
|
+
/** The warning chip the analyzer raised for this window, if any. */
|
|
770
|
+
function buildSpikeSeg(spikeChip, c) {
|
|
771
|
+
// Spike chip — one word only, keeps the statusline single-line.
|
|
772
|
+
return spikeChip ? `${c(RED)}${spikeChip}${c(RESET)}` : null;
|
|
773
|
+
}
|
|
704
774
|
|
|
775
|
+
/** Calendar-month spend chip. */
|
|
776
|
+
function buildMonthSeg(month, c, isIcon, verbose, g) {
|
|
705
777
|
// 이번 달 지출 세그먼트. 달력 월(1일 00시 기준) 지출 추정치라서 cap 이 없는
|
|
706
778
|
// 게이트웨이 사용자에게도 항상 의미가 있다. 정보성 지표이므로 회색으로,
|
|
707
779
|
// 게이지들 뒤에 조용히 놓는다.
|
|
708
780
|
// icon: "💵 Sep $42.1"
|
|
709
781
|
// text: "Sep spend $42.1"
|
|
710
782
|
// verbose: "Sep spend $42.1 (since Sep 1)"
|
|
711
|
-
const month = data.monthSpend;
|
|
712
783
|
let monthSeg = null;
|
|
713
784
|
// Gateway sessions whose model the gateway does not price are left out of the
|
|
714
785
|
// sum and counted, so a partial total says so and an all-unpriced month is n/a.
|
|
@@ -724,6 +795,189 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
724
795
|
monthSeg = `${c(GRAY)}${month.label} spend ${amt}${c(RESET)}`;
|
|
725
796
|
}
|
|
726
797
|
}
|
|
798
|
+
return monthSeg;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Always-on usage segments — what /usage shows in Claude Code, mirrored
|
|
802
|
+
// to the statusline so the user doesn't have to slash-command for it.
|
|
803
|
+
// Today the stdin payload exposes the 5h ("Current session") and 7-day
|
|
804
|
+
// rolling ("Current week") windows; if Anthropic ships more (e.g. a
|
|
805
|
+
// Sonnet-only weekly), they render automatically with derived labels.
|
|
806
|
+
// Each renders as `{label} {pct}% · {countdown}`. The window promoted to the
|
|
807
|
+
// cap-warn chip is suppressed here to avoid duplicate noise — but ONLY that
|
|
808
|
+
// one. When several windows are at 90%+ the chip shows just the most
|
|
809
|
+
// imminent, so the others must keep their always-on segment (red) or they'd
|
|
810
|
+
// vanish from the statusline entirely at the worst possible moment.
|
|
811
|
+
function buildUsageSeg({ labels, info, tone, suppressed }, c, isIcon, verbose, g, color, mode) {
|
|
812
|
+
if (!info || !Number.isFinite(info.usedPct)) return null;
|
|
813
|
+
if (suppressed) return null; // cap-warn chip handles this window
|
|
814
|
+
const pct = Math.round(info.usedPct);
|
|
815
|
+
// Show only the wall-clock reset time (e.g. `🔄 21:10`). Absolute time
|
|
816
|
+
// doesn't tick second-by-second so the statusline reads stable, and the
|
|
817
|
+
// 🔄 icon itself separates the percent from the clock — no extra `·` needed.
|
|
818
|
+
// A gateway budget can reset weeks out, where a wall-clock time says almost
|
|
819
|
+
// nothing ("Thu 00:00" — which Thursday?). Those windows report how long is
|
|
820
|
+
// left instead. Short windows keep the clock, which is the more useful form
|
|
821
|
+
// when the reset is later today.
|
|
822
|
+
const clock = labels.resetStyle === 'countdown'
|
|
823
|
+
? formatResetIn(info.resetsAt)
|
|
824
|
+
: formatResetClock(info.resetsAt);
|
|
825
|
+
const tail = clock ? (isIcon ? ` ${g.reset} ${clock}` : ` resets ${clock}`) : '';
|
|
826
|
+
// `cap` reads as a rate-limit ceiling rather than a duration. Icon mode
|
|
827
|
+
// leans on the icon to identify the window (✦ = session/now, 📅 = week),
|
|
828
|
+
// so the 5H label is empty while the 7D label spells out "weekly". Text and
|
|
829
|
+
// verbose modes keep the `5H`/`7D` short label since they have no icon.
|
|
830
|
+
// Icon mode renders an inline ▰▱ gauge instead of the literal "cap used" —
|
|
831
|
+
// a glance at the bar conveys urgency faster than parsing a percent number,
|
|
832
|
+
// and the gauge stays the same width as the percent climbs.
|
|
833
|
+
// LiteLLM 예산 윈도우는 퍼센트만으로는 감이 안 오므로(예산 크기를 모름)
|
|
834
|
+
// 금액을 함께 보여 준다: `💳 budget ▰▱ 34% $34/$100`.
|
|
835
|
+
const money =
|
|
836
|
+
Number.isFinite(info.maxBudget) && info.maxBudget > 0
|
|
837
|
+
? ` ${formatMoney(Number(info.spend) || 0)}/${formatMoney(info.maxBudget)}`
|
|
838
|
+
: '';
|
|
839
|
+
// LiteLLM 의 internal user 한도는 차단 판정에 쓰이는 값이 아니라서, 팀
|
|
840
|
+
// 멤버십 한도와 수백 배 어긋나는 배포가 있습니다. 그 한도로 그린 게이지는
|
|
841
|
+
// 출처를 밝혀 두어야 사용자가 정확한 값으로 오해하지 않습니다.
|
|
842
|
+
const src = info.source === 'user' ? ' (user)' : '';
|
|
843
|
+
if (isIcon) {
|
|
844
|
+
const labelPart = labels.usageLabel ? `${labels.usageLabel}${src} ` : '';
|
|
845
|
+
const bar = gaugeColored(pct, c, tone, color, mode);
|
|
846
|
+
const winGlyph = mode === 'narrow' ? (labels.narrowIcon || labels.icon) : labels.icon;
|
|
847
|
+
return `${c(tone)}${winGlyph} ${labelPart}${bar} ${pct}%${money}${tail}${c(RESET)}`;
|
|
848
|
+
}
|
|
849
|
+
if (verbose) {
|
|
850
|
+
return `${c(tone)}${labels.short}${src} cap ${pct}% used${money}${tail}${c(RESET)}`;
|
|
851
|
+
}
|
|
852
|
+
return `${c(tone)}${labels.short}${src} cap ${pct}%${money}${tail}${c(RESET)}`;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Color tone: green <70%, yellow 70-89%, red 90+% (a 90+% window only
|
|
856
|
+
// renders here when a *different* window won the cap-warn chip slot).
|
|
857
|
+
function usageTone(info) {
|
|
858
|
+
if (!info || !Number.isFinite(info.usedPct)) return GRAY;
|
|
859
|
+
if (info.usedPct >= 90) return RED;
|
|
860
|
+
if (info.usedPct >= 70) return YELLOW;
|
|
861
|
+
return GREEN;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* One always-on segment per rate-limit window, in payload order. The window
|
|
866
|
+
* promoted to the cap-warn chip is suppressed, and only that one.
|
|
867
|
+
* @returns {{ key: string, seg: string }[]}
|
|
868
|
+
*/
|
|
869
|
+
function buildUsageSegs(caps, capWarn, c, isIcon, verbose, g, color, mode) {
|
|
870
|
+
const usageSegs = [];
|
|
871
|
+
if (caps && Array.isArray(caps.windows)) {
|
|
872
|
+
for (const win of caps.windows) {
|
|
873
|
+
const labels = labelForKey(win.key);
|
|
874
|
+
const seg = buildUsageSeg(
|
|
875
|
+
{ labels, info: win, tone: usageTone(win), suppressed: !!capWarn && capWarn.key === win.key },
|
|
876
|
+
c, isIcon, verbose, g, color, mode,
|
|
877
|
+
);
|
|
878
|
+
if (seg) usageSegs.push({ key: win.key, seg });
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return usageSegs;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* The reset mark a savings counter counts from, in the two shapes the chips use.
|
|
886
|
+
* `word` is the sentence form (` since 10/9`). `inline` is what rides on a chip:
|
|
887
|
+
* the same, except compact icon mode, which has no room for a sentence and gets
|
|
888
|
+
* `(10/9~)`. Both are empty for a counter that was never reset.
|
|
889
|
+
*/
|
|
890
|
+
function sinceFormatters(c, isIcon, verbose) {
|
|
891
|
+
const word = (since) => (since ? ` ${c(GRAY)}since ${since}${c(RESET)}` : '');
|
|
892
|
+
const compact = (since) => (since ? ` ${c(GRAY)}(${since}~)${c(RESET)}` : '');
|
|
893
|
+
return { word, inline: isIcon && !verbose ? compact : word };
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Routing-savings chips and headline line.
|
|
898
|
+
*
|
|
899
|
+
* Returns the chips for the `delegated` slot and, in the multi-line layout, the
|
|
900
|
+
* headline line that takes row 1. The two are alternatives: when the headline
|
|
901
|
+
* owns the story the chip steps aside, so line 2 does not repeat it.
|
|
902
|
+
*
|
|
903
|
+
* @returns {{ chips: (string|null)[], headline: { usd: number, line: string } | null }}
|
|
904
|
+
*/
|
|
905
|
+
function buildDelegatedSeg(data, singleLine, c, isIcon, verbose, g, color) {
|
|
906
|
+
// Delegation savings — a DIFFERENT number from the "Cache saved" chip, which
|
|
907
|
+
// covers the prompt cache only. This one is what running work on a cheaper
|
|
908
|
+
// tier saved, the lifetime total of the delegation ledger (counted from the
|
|
909
|
+
// last `sprag saved reset`, when there was one). Hidden when zero or absent
|
|
910
|
+
// and never reset: a permanent "$0" is noise for direct-API users and for
|
|
911
|
+
// anyone who has not delegated yet.
|
|
912
|
+
// "Routing saved" says what earned the money — work that ran on a cheaper
|
|
913
|
+
// model instead of this one. It leads the line rather than trailing it
|
|
914
|
+
// because it is the headline number of the whole tool, not a footnote.
|
|
915
|
+
// text: "Routing saved $3.2" | same in verbose
|
|
916
|
+
// icon: "🔀 $3.2" | verbose: "🔀 Routing saved $3.2"
|
|
917
|
+
const delegationSaved = Number(data.delegationSaved) || 0;
|
|
918
|
+
const delegateLabel = isIcon
|
|
919
|
+
? (verbose ? `${g.routing} Routing saved` : g.routing)
|
|
920
|
+
: 'Routing saved';
|
|
921
|
+
// Zero savings has two very different causes and, until now, one appearance:
|
|
922
|
+
// nothing at all. "Never delegated" and "delegated plenty, but every run was
|
|
923
|
+
// dropped because the gateway model id could not be resolved" looked
|
|
924
|
+
// identical, so users in the second case had no reason to suspect anything
|
|
925
|
+
// was wrong. The count gets a chip; the explanation stays in `route-scan
|
|
926
|
+
// rules`, where there is room for it.
|
|
927
|
+
//
|
|
928
|
+
// But only once there are enough of them to mean anything. A gateway reports
|
|
929
|
+
// an opaque profile ARN, and the fix for that is learned from observed runs —
|
|
930
|
+
// which needs MIN_VOTES of them before it can conclude anything. Below that
|
|
931
|
+
// threshold an unresolved run is not a problem, it is the first day of a
|
|
932
|
+
// working mechanism, and warning about it makes a fresh install look broken.
|
|
933
|
+
// Tied to MIN_VOTES rather than a literal so the two cannot drift: the chip
|
|
934
|
+
// appears exactly when learning has had its chance and still came up short.
|
|
935
|
+
const unresolvedRuns = Number(data.unresolvedRuns) || 0;
|
|
936
|
+
const unresolvedSeg = unresolvedRuns >= MIN_VOTES
|
|
937
|
+
? `${c(YELLOW)}${g.routing} ${unresolvedRuns} unresolved${c(RESET)}`
|
|
938
|
+
: null;
|
|
939
|
+
|
|
940
|
+
// The counter's reset mark ("since 10/9"), when `sprag saved reset` was run.
|
|
941
|
+
// After a reset the total is legitimately zero, and a chip that vanished at
|
|
942
|
+
// zero made "just reset" look the same as "feature off". With a mark on
|
|
943
|
+
// record a zero is shown, dated; with none it stays hidden as before.
|
|
944
|
+
// Compact icon mode has no room for a sentence, so it gets `(10/9~)`.
|
|
945
|
+
const totals = data.delegationTotals;
|
|
946
|
+
const routingSince = formatSince(totals && totals.since);
|
|
947
|
+
const { word: sinceWord, inline: sinceInline } = sinceFormatters(c, isIcon, verbose);
|
|
948
|
+
|
|
949
|
+
// Savings come from the ledger route-scan fills, so they are only as fresh as
|
|
950
|
+
// the last scan. Said only once that has gone stale: a recent scan is the
|
|
951
|
+
// normal case and needs no caveat.
|
|
952
|
+
const scanAt = toEpochMs(data.routeScanAt);
|
|
953
|
+
const scanAge = scanAt !== null && Date.now() - scanAt > SCAN_STALE_MS ? formatAge(Date.now() - scanAt) : null;
|
|
954
|
+
const scanTag = scanAge
|
|
955
|
+
? ` ${c(GRAY)}${verbose ? `last scan ${scanAge} ago` : `scan ${scanAge}`}${c(RESET)}`
|
|
956
|
+
: '';
|
|
957
|
+
|
|
958
|
+
// What the delegated runs cost on their own models is no longer drawn here.
|
|
959
|
+
// Wedged between the saving and its date it read as one more saving, so it
|
|
960
|
+
// moved to `sprag saved`, which has room to say what it is.
|
|
961
|
+
|
|
962
|
+
const savedTone = delegationSaved < 0 ? RED : delegationSaved > 0 ? GREEN : GRAY;
|
|
963
|
+
const savingsChip = delegationSaved !== 0 || routingSince
|
|
964
|
+
? `${c(savedTone)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}${sinceInline(routingSince)}${scanTag}`
|
|
965
|
+
: null;
|
|
966
|
+
// The unresolved count stands in only while there are no savings to show, so
|
|
967
|
+
// a dated zero keeps both: the reset and the dropped runs are separate facts.
|
|
968
|
+
// What the spend was made of, for the same period as the routing counter:
|
|
969
|
+
// "◩ Model used ● Opus 90% ● Sonnet 10%". A segment of its own, after the
|
|
970
|
+
// savings and their breakdown, so a share is never read as another saving.
|
|
971
|
+
// Only with a savings figure beside it, and absent when switched off
|
|
972
|
+
// (`mode no-share`) or empty.
|
|
973
|
+
const ms = data.modelShare;
|
|
974
|
+
const usedLabel = isIcon ? `${g.used} Model used` : 'Model used';
|
|
975
|
+
const usedSeg = ms && Number(ms.totalUsd) > 0 && Array.isArray(ms.families) && ms.families.length
|
|
976
|
+
? `${c(GREEN)}${usedLabel}${c(RESET)} ${shareLegend(ms.families, { color })}`
|
|
977
|
+
: '';
|
|
978
|
+
const withShare = (chip) => (chip && usedSeg ? `${chip} ${c(GRAY)}|${c(RESET)} ${usedSeg}` : chip);
|
|
979
|
+
const delegateSeg = withShare(savingsChip) || (delegationSaved === 0 ? unresolvedSeg : null);
|
|
980
|
+
const delegateExtra = savingsChip && delegationSaved === 0 ? unresolvedSeg : null;
|
|
727
981
|
|
|
728
982
|
// Routing-savings headline line (multi-line layout). The lifetime sum from
|
|
729
983
|
// the delegation ledger — the number the whole tool exists to grow, so it
|
|
@@ -734,8 +988,8 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
734
988
|
// a row of rolling windows it read as a breakdown of whichever one it
|
|
735
989
|
// touched. A single lifetime total makes the whole line one timeframe with
|
|
736
990
|
// nothing to mismatch.
|
|
737
|
-
// icon: "🔀 Routing saved $9.8
|
|
738
|
-
// text: "Routing saved $9.8
|
|
991
|
+
// icon: "🔀 Routing saved $9.8 | opus→haiku ×2 $6.4 · fable→sonnet ×1 $3.4 | 📊 Model used ● Opus 90%"
|
|
992
|
+
// text: "Routing saved $9.8 | opus→haiku ×2 $6.4 · fable→sonnet ×1 $3.4 | Model used ● Opus 90%"
|
|
739
993
|
let totalsLine = null;
|
|
740
994
|
const totalsNum = Number(totals && totals.total);
|
|
741
995
|
// A zero total gets its line only when a reset mark explains it; see the
|
|
@@ -757,22 +1011,82 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
757
1011
|
// component would flatten the line into one loud block with nothing to
|
|
758
1012
|
// land on first.
|
|
759
1013
|
const pairText = pairs
|
|
760
|
-
.map((p) => `${c(GRAY)}${p.from}→${p.to}
|
|
1014
|
+
.map((p) => `${c(GRAY)}${p.from}→${p.to} ×${p.runs} ${formatMoney(p.usd)}${c(RESET)}`)
|
|
761
1015
|
.join(` ${c(GRAY)}·${c(RESET)} `);
|
|
762
1016
|
// A net loss must not read as a win, and a zero is neither.
|
|
763
1017
|
const tone = totalsNum < 0 ? RED : totalsNum > 0 ? GREEN : GRAY;
|
|
764
1018
|
totalsLine =
|
|
765
1019
|
`${c(tone)}${c(BOLD)}${head}${c(RESET)} ` +
|
|
766
1020
|
`${c(tone)}${formatMoney(totalsNum)}${c(RESET)}` +
|
|
767
|
-
|
|
768
|
-
(pairText ? ` ${c(GRAY)}|${c(RESET)} ${pairText}` : '')
|
|
1021
|
+
sinceWord(routingSince) + scanTag +
|
|
1022
|
+
(pairText ? ` ${c(GRAY)}|${c(RESET)} ${pairText}` : '') +
|
|
1023
|
+
(usedSeg ? ` ${c(GRAY)}|${c(RESET)} ${usedSeg}` : '');
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
return {
|
|
1027
|
+
// Dropped when the same story already owns a headline line above, which
|
|
1028
|
+
// would otherwise repeat it on line 2. The unresolved-runs chip survives the
|
|
1029
|
+
// headline when that headline is a dated zero, since a zero is exactly when
|
|
1030
|
+
// the dropped runs matter.
|
|
1031
|
+
chips: totalsLine ? (totalsNum === 0 ? [unresolvedSeg] : []) : [delegateSeg, delegateExtra],
|
|
1032
|
+
headline: totalsLine ? { usd: Number(totals && totals.total) || 0, line: totalsLine } : null,
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/** The one-time star ask (see star-note.js); null unless the CLI passed a chip. */
|
|
1037
|
+
function buildStarSeg(star, c, isIcon) {
|
|
1038
|
+
if (!star || typeof star !== 'object' || !Number.isFinite(star.usd)) return null;
|
|
1039
|
+
const text = `${isIcon ? '⭐ ' : ''}${formatMoney(star.usd)} saved · star ${STAR_URL}`;
|
|
1040
|
+
return `${c(YELLOW)}${text}${c(RESET)}`;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Doc2md chip and headline line. As with routing, they are alternatives: money
|
|
1045
|
+
* earns the headline line, and the chip is for what a line would be all frame
|
|
1046
|
+
* for (a document count, a dated zero).
|
|
1047
|
+
*
|
|
1048
|
+
* @returns {{ chip: string|null, headline: { usd: number, line: string } | null }}
|
|
1049
|
+
*/
|
|
1050
|
+
function buildDoc2mdSeg(doc2md, singleLine, c, isIcon, verbose, g) {
|
|
1051
|
+
const { word: sinceWord, inline: sinceInline } = sinceFormatters(c, isIcon, verbose);
|
|
1052
|
+
// Document conversions — the same kind of number as "Routing saved", earned
|
|
1053
|
+
// a different way: a document read as Markdown instead of attached whole.
|
|
1054
|
+
// Kept as its own chip rather than folded into the routing total, because a
|
|
1055
|
+
// single figure could not tell the reader which habit earned it.
|
|
1056
|
+
//
|
|
1057
|
+
// Conversions that saved nothing measurable still show as a count. For
|
|
1058
|
+
// pptx/xlsx/docx the honest saving is zero — the client extracts much the
|
|
1059
|
+
// same text — and a chip that disappeared on those would read as "doc2md
|
|
1060
|
+
// did nothing" on the very formats it is the only way to open.
|
|
1061
|
+
// icon: "📄 $0.42" verbose: "📄 Doc2md saved $0.42 · 12 docs"
|
|
1062
|
+
// text: "Doc2md saved $0.42"
|
|
1063
|
+
const doc2mdUsd = Number(doc2md && doc2md.total) || 0;
|
|
1064
|
+
const doc2mdDocs = Number(doc2md && doc2md.docs) || 0;
|
|
1065
|
+
// Same reset mark as the routing counter, and the same reason to show a dated
|
|
1066
|
+
// zero: after `sprag saved reset` an empty counter and a disabled feature
|
|
1067
|
+
// would otherwise look alike.
|
|
1068
|
+
const doc2mdSince = formatSince(doc2md && doc2md.since);
|
|
1069
|
+
const doc2mdLabel = isIcon
|
|
1070
|
+
? (verbose ? `${g.doc} Doc2md saved` : g.doc)
|
|
1071
|
+
: 'Doc2md saved';
|
|
1072
|
+
let doc2mdSeg = null;
|
|
1073
|
+
if (doc2mdUsd > 0) {
|
|
1074
|
+
doc2mdSeg = `${c(GREEN)}${doc2mdLabel}${c(RESET)} ${formatMoney(doc2mdUsd)}${sinceInline(doc2mdSince)}`
|
|
1075
|
+
+ (verbose ? ` ${c(GRAY)}· ${doc2mdDocs} docs${c(RESET)}` : '');
|
|
1076
|
+
} else if (doc2mdDocs > 0) {
|
|
1077
|
+
const label = isIcon ? g.doc : 'Doc2md';
|
|
1078
|
+
doc2mdSeg = `${c(GRAY)}${label} ${doc2mdDocs} docs${doc2mdSince ? ` ${isIcon && !verbose ? `(${doc2mdSince}~)` : `since ${doc2mdSince}`}` : ''}${c(RESET)}`;
|
|
1079
|
+
} else if (doc2mdSince) {
|
|
1080
|
+
// "$0", not formatMoney's "$0.00": a counter that was just reset has nothing
|
|
1081
|
+
// to resolve to the cent, and the extra zeros read as a measured amount.
|
|
1082
|
+
doc2mdSeg = `${c(GRAY)}${doc2mdLabel} $0${isIcon && !verbose ? ` (${doc2mdSince}~)` : ` since ${doc2mdSince}`}${c(RESET)}`;
|
|
769
1083
|
}
|
|
770
1084
|
|
|
771
1085
|
// Doc2md's own headline line, same anatomy as the routing one: a green
|
|
772
1086
|
// lifetime total, then a gray per-format breakdown. Only built when there
|
|
773
1087
|
// is money to report — a bare document count stays an inline chip, since a
|
|
774
1088
|
// whole line for "3 docs" would be all frame and no figure.
|
|
775
|
-
// icon: "📄 Doc2md saved $1.8 | pptx 1
|
|
1089
|
+
// icon: "📄 Doc2md saved $1.8 | pptx ×1 $1.55 · pdf ×2 $0.27"
|
|
776
1090
|
let doc2mdLine = null;
|
|
777
1091
|
if (!singleLine && doc2mdUsd > 0) {
|
|
778
1092
|
const head = isIcon ? `${g.doc} Doc2md saved` : 'Doc2md saved';
|
|
@@ -784,9 +1098,9 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
784
1098
|
// pre-2007 binary, which has no container to price against.
|
|
785
1099
|
const paid = byExt.filter((r) => r.usd > 0);
|
|
786
1100
|
const unpriced = byExt.filter((r) => !(r.usd > 0));
|
|
787
|
-
const parts = paid.map((r) => `${c(GRAY)}${r.ext}
|
|
1101
|
+
const parts = paid.map((r) => `${c(GRAY)}${r.ext} ×${r.docs} ${formatMoney(r.usd)}${c(RESET)}`);
|
|
788
1102
|
if (unpriced.length) {
|
|
789
|
-
const counted = unpriced.map((r) => `${r.ext}
|
|
1103
|
+
const counted = unpriced.map((r) => `${r.ext} ×${r.docs}`).join(' · ');
|
|
790
1104
|
parts.push(`${c(GRAY)}${counted} ${verbose ? '(no baseline)' : '(n/a)'}${c(RESET)}`);
|
|
791
1105
|
}
|
|
792
1106
|
const extText = parts.join(` ${c(GRAY)}·${c(RESET)} `);
|
|
@@ -797,304 +1111,107 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
797
1111
|
(extText ? ` ${c(GRAY)}|${c(RESET)} ${extText}` : '');
|
|
798
1112
|
}
|
|
799
1113
|
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
const periodSeg = verbose
|
|
806
|
-
? `${c(GRAY)}(last ${periodLabel})${c(RESET)}`
|
|
807
|
-
: `${c(GRAY)}(${periodLabel})${c(RESET)}`;
|
|
808
|
-
|
|
809
|
-
// TTL countdown — how much time is left on the last API call's cache entry.
|
|
810
|
-
// Matches Anthropic's actual prompt-cache behaviour: each call starts a fresh
|
|
811
|
-
// TTL window, and the next call (hit) within that window resets it. So the
|
|
812
|
-
// countdown visibly ticks down between prompts, and "resets" happens as a
|
|
813
|
-
// jump back toward the bucket max the moment you send another message.
|
|
814
|
-
// Compact modes drop the bucket label — it's read as part of the clock
|
|
815
|
-
// ("1h 59:58" gets parsed as "1 hour 59 minutes 58 seconds"). The bucket
|
|
816
|
-
// is plan-determined and rarely changes, so verbose mode is where it belongs.
|
|
817
|
-
// text compact: "Expires 59:58"
|
|
818
|
-
// text verbose: "1h bucket · expires in 59:58"
|
|
819
|
-
// icon compact: "⏳ 59:58"
|
|
820
|
-
// icon verbose: "⏳ Expires 1h 59:58"
|
|
821
|
-
let ttlSeg;
|
|
822
|
-
if (timer && lastActivity) {
|
|
823
|
-
// Coerce to a numeric ms timestamp. Some upstream paths handed in a Date,
|
|
824
|
-
// a stringified ISO timestamp, or epoch-seconds — any of which silently
|
|
825
|
-
// produces NaN/huge values when subtracted from Date.now(), which then
|
|
826
|
-
// bypasses formatTimer's normal MM:SS shape.
|
|
827
|
-
const laMs =
|
|
828
|
-
typeof lastActivity === 'number'
|
|
829
|
-
? (lastActivity < 1e12 ? lastActivity * 1000 : lastActivity) // seconds → ms
|
|
830
|
-
: (lastActivity instanceof Date ? lastActivity.getTime() : Date.parse(lastActivity));
|
|
831
|
-
const elapsed = Number.isFinite(laMs) ? (Date.now() - laMs) / 1000 : Infinity;
|
|
832
|
-
// Clamp remaining into the bucket so a clock-skew or stale-state edge case
|
|
833
|
-
// can't display a value larger than the bucket itself.
|
|
834
|
-
const remaining = Math.min(ttlSeconds, ttlSeconds - elapsed);
|
|
835
|
-
const text = formatTimer(remaining);
|
|
836
|
-
const pct = remaining / ttlSeconds;
|
|
837
|
-
// Percentages are the wrong unit in a 5-minute bucket: 30% of it is 90
|
|
838
|
-
// seconds, and green there reads as comfort the user does not have. Below
|
|
839
|
-
// an hour the thresholds are absolute, so the color tracks whether there
|
|
840
|
-
// is time to finish a thought rather than a share of a short window.
|
|
841
|
-
const timerColor =
|
|
842
|
-
remaining <= 0 ? RED :
|
|
843
|
-
is1h
|
|
844
|
-
? (pct > 0.30 ? GREEN : pct > 0.10 ? YELLOW : RED)
|
|
845
|
-
: (remaining > 60 ? GREEN : remaining > 30 ? YELLOW : RED);
|
|
846
|
-
|
|
847
|
-
if (isIcon && verbose) {
|
|
848
|
-
// Drop bucket here too — `⏳ Expires 1h 57:20` reads as "1h 57m 20s left"
|
|
849
|
-
// for the same reason the compact form did. The bucket lives in the
|
|
850
|
-
// text-verbose layout where the "bucket" word + `·` separator make it
|
|
851
|
-
// unambiguous.
|
|
852
|
-
ttlSeg = `${c(timerColor)}${g.ttl} Cache expires ${text}${c(RESET)}`;
|
|
853
|
-
} else if (isIcon) {
|
|
854
|
-
ttlSeg = `${c(timerColor)}${g.ttl} ${text}${c(RESET)}`;
|
|
855
|
-
} else if (verbose) {
|
|
856
|
-
ttlSeg = `${c(bucketColor)}Cache ${bucketLabel} bucket${c(RESET)} · ${c(timerColor)}expires in ${text}${c(RESET)}`;
|
|
857
|
-
} else {
|
|
858
|
-
ttlSeg = `${c(timerColor)}Cache expires ${text}${c(RESET)}`;
|
|
859
|
-
}
|
|
860
|
-
} else {
|
|
861
|
-
// No-timer fallback: only the bucket is available, so we show just that.
|
|
862
|
-
if (isIcon) {
|
|
863
|
-
const prefix = verbose ? `${g.ttl} Cache bucket ` : `${g.ttl} `;
|
|
864
|
-
ttlSeg = `${c(bucketColor)}${prefix}${bucketLabel}${c(RESET)}`;
|
|
865
|
-
} else if (verbose) {
|
|
866
|
-
ttlSeg = `${c(bucketColor)}Cache ${bucketLabel} bucket${c(RESET)}`;
|
|
867
|
-
} else {
|
|
868
|
-
ttlSeg = `${c(bucketColor)}Cache bucket ${bucketLabel}${c(RESET)}`;
|
|
869
|
-
}
|
|
870
|
-
}
|
|
1114
|
+
return {
|
|
1115
|
+
chip: doc2mdSeg,
|
|
1116
|
+
headline: doc2mdLine ? { usd: doc2mdUsd, line: doc2mdLine } : null,
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
871
1119
|
|
|
872
|
-
|
|
1120
|
+
/**
|
|
1121
|
+
* Default segment order, most actionable first. A rate-limit block or a full
|
|
1122
|
+
* context window stops the work outright, so those lead. Cache and cost numbers
|
|
1123
|
+
* shape habits rather than the next keystroke, and harness / Korean-style are
|
|
1124
|
+
* near-constant identity: they sit at the tail, where a narrow terminal clips
|
|
1125
|
+
* the least useful end of the line instead of the most useful one.
|
|
1126
|
+
*
|
|
1127
|
+
* Both stay listed even though their builders return null in the healthy case
|
|
1128
|
+
* (see buildHarnessSeg / buildKoreanSeg). The order is where a chip goes when it
|
|
1129
|
+
* has something to say, so a harness warning still lands at the tail, and
|
|
1130
|
+
* keeping the names here is what lets the "every name is known" test cover them.
|
|
1131
|
+
*
|
|
1132
|
+
* `version` is listed once, at its identity position. formatReport() moves it
|
|
1133
|
+
* forward when an upgrade is actually available, since it becomes an
|
|
1134
|
+
* "act on this" chip then.
|
|
1135
|
+
*
|
|
1136
|
+
* Exported so a test can assert every name here is one the renderer knows:
|
|
1137
|
+
* the order list and the segment registry are separate structures, and a name
|
|
1138
|
+
* added to only one of them would be dropped in silence.
|
|
1139
|
+
*/
|
|
1140
|
+
export const DEFAULT_SEGMENT_ORDER = [
|
|
1141
|
+
'cap-warn', 'spike',
|
|
1142
|
+
'usage', 'ctx',
|
|
1143
|
+
'ttl',
|
|
1144
|
+
'model', 'effort',
|
|
1145
|
+
// Grouped by the timeframe each figure covers, because mixing them was the
|
|
1146
|
+
// problem: a lifetime total, a windowed rate and a calendar-month estimate sat
|
|
1147
|
+
// interleaved, with one window label at the end of the line that appeared to
|
|
1148
|
+
// qualify all of them.
|
|
873
1149
|
//
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
|
|
877
|
-
//
|
|
878
|
-
|
|
879
|
-
//
|
|
880
|
-
//
|
|
881
|
-
|
|
882
|
-
//
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
const pct = Math.max(0, Math.round(ctxLive.usedPct));
|
|
887
|
-
const tone = pct >= 90 ? RED : pct >= 70 ? YELLOW : GREEN;
|
|
888
|
-
const sizeLabel = ctxLive.size
|
|
889
|
-
? (ctxLive.size >= 900_000 ? '1M' : `${Math.round(ctxLive.size / 1000)}k`)
|
|
890
|
-
: null;
|
|
891
|
-
const longLabel = sizeLabel ? `${pct}% of ${sizeLabel}` : `${pct}%`;
|
|
892
|
-
if (isIcon && verbose) {
|
|
893
|
-
ctxSeg = `${c(tone)}${g.ctx} Ctx ${longLabel}${c(RESET)}`;
|
|
894
|
-
} else if (isIcon) {
|
|
895
|
-
ctxSeg = `${c(tone)}${g.ctx} ${pct}%${c(RESET)}`;
|
|
896
|
-
} else {
|
|
897
|
-
ctxSeg = `${c(tone)}Ctx ${longLabel}${c(RESET)}`;
|
|
898
|
-
}
|
|
899
|
-
} else if (contextWindow && contextWindow.size && contextWindow.size !== 'unknown') {
|
|
900
|
-
const label = contextWindow.size === '1M' ? '1M' : '200k';
|
|
901
|
-
// Yellow is reserved for the 500k warn line. A 1M window by itself is the
|
|
902
|
-
// default on every current model, so colouring it yellow cried wolf.
|
|
903
|
-
const ctxColor = contextWindow.overWarn ? YELLOW : GREEN;
|
|
904
|
-
if (isIcon && verbose) {
|
|
905
|
-
ctxSeg = `${c(ctxColor)}${g.ctx} Ctx ${label}${c(RESET)}`;
|
|
906
|
-
} else if (isIcon) {
|
|
907
|
-
ctxSeg = `${c(ctxColor)}${g.ctx} ${label}${c(RESET)}`;
|
|
908
|
-
} else {
|
|
909
|
-
ctxSeg = `${c(ctxColor)}Ctx ${label}${c(RESET)}`;
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
// Spike chip — one word only, keeps the statusline single-line.
|
|
914
|
-
const spikeSeg = spikeChip ? `${c(RED)}${spikeChip}${c(RESET)}` : null;
|
|
915
|
-
|
|
916
|
-
// Harness 🅷 N/5 — project-scoped completeness of CLAUDE.md harness rules.
|
|
917
|
-
// Silent when the project hasn't opted in (no CLAUDE.md and no .claude/);
|
|
918
|
-
// otherwise renders 🅷 5/5 (green) / 🅷 N/5 (yellow) so the user can spot
|
|
919
|
-
// a missing section at a glance and know to run `harness init`.
|
|
920
|
-
const harnessSeg = buildHarnessSeg(c, isIcon, g, ctxLive?.size ?? null);
|
|
921
|
-
|
|
922
|
-
// Version / upgrade chip. Read from a cache written by a detached background
|
|
923
|
-
// check — this render path never touches the network.
|
|
924
|
-
const versionSeg = buildVersionSeg(options.version, data.update, c, isIcon, verbose, g);
|
|
925
|
-
const updateAvailable = !!(data.update && data.update.available);
|
|
926
|
-
|
|
927
|
-
// Korean-style chip — rendered only when the session-start injection is on.
|
|
928
|
-
const koreanSeg = buildKoreanSeg(c, isIcon, verbose, g);
|
|
929
|
-
|
|
930
|
-
// Delegation-rule registry (`rules 10 · ⚠1`), opt-in through `--segments`.
|
|
931
|
-
const rulesSeg = buildRulesSeg(data.ruleStats, c, g);
|
|
932
|
-
|
|
933
|
-
// This session's subagent runs by model family. It is a session figure, so it
|
|
934
|
-
// sits with the routing chips rather than with the lifetime totals above it.
|
|
935
|
-
const sessionDelegationSeg = buildSessionDelegationSeg(data.sessionDelegation, c, isIcon, verbose, g);
|
|
936
|
-
|
|
937
|
-
// Model chip — pulled from Claude Code's stdin payload (`model.display_name`).
|
|
938
|
-
// Cheap identity context: useful when the user toggles between Sonnet/Opus
|
|
939
|
-
// mid-session and wants to confirm at a glance which one is answering.
|
|
940
|
-
let modelSeg = null;
|
|
941
|
-
if (typeof model === 'string' && model.length > 0) {
|
|
942
|
-
// 🤖 + name is enough — the emoji disambiguates so the literal word "Model"
|
|
943
|
-
// is dead weight in icon mode. Text modes keep the bare name; the magenta
|
|
944
|
-
// tone marks it as identity context.
|
|
945
|
-
if (isIcon) {
|
|
946
|
-
modelSeg = `${c(MAGENTA)}${g.model} ${model}${c(RESET)}`;
|
|
947
|
-
} else {
|
|
948
|
-
modelSeg = `${c(MAGENTA)}${model}${c(RESET)}`;
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
// Effort chip — how hard the session is being worked (`/effort`). It follows
|
|
953
|
-
// the model chip because it qualifies it: the same model answers differently
|
|
954
|
-
// at `low` and at `max`, and the pair is one glance.
|
|
955
|
-
const effortSeg = buildEffortSeg(effort, c, isIcon, g);
|
|
956
|
-
|
|
957
|
-
// Always-on usage segments — what /usage shows in Claude Code, mirrored
|
|
958
|
-
// to the statusline so the user doesn't have to slash-command for it.
|
|
959
|
-
// Today the stdin payload exposes the 5h ("Current session") and 7-day
|
|
960
|
-
// rolling ("Current week") windows; if Anthropic ships more (e.g. a
|
|
961
|
-
// Sonnet-only weekly), they render automatically with derived labels.
|
|
962
|
-
// Each renders as `{label} {pct}% · {countdown}`. The window promoted to the
|
|
963
|
-
// cap-warn chip is suppressed here to avoid duplicate noise — but ONLY that
|
|
964
|
-
// one. When several windows are at 90%+ the chip shows just the most
|
|
965
|
-
// imminent, so the others must keep their always-on segment (red) or they'd
|
|
966
|
-
// vanish from the statusline entirely at the worst possible moment.
|
|
967
|
-
function buildUsageSeg({ labels, info, color: tone, suppressed }) {
|
|
968
|
-
if (!info || !Number.isFinite(info.usedPct)) return null;
|
|
969
|
-
if (suppressed) return null; // cap-warn chip handles this window
|
|
970
|
-
const pct = Math.round(info.usedPct);
|
|
971
|
-
// Show only the wall-clock reset time (e.g. `🔄 21:10`). Absolute time
|
|
972
|
-
// doesn't tick second-by-second so the statusline reads stable, and the
|
|
973
|
-
// 🔄 icon itself separates the percent from the clock — no extra `·` needed.
|
|
974
|
-
// A gateway budget can reset weeks out, where a wall-clock time says almost
|
|
975
|
-
// nothing ("Thu 00:00" — which Thursday?). Those windows report how long is
|
|
976
|
-
// left instead. Short windows keep the clock, which is the more useful form
|
|
977
|
-
// when the reset is later today.
|
|
978
|
-
const clock = labels.resetStyle === 'countdown'
|
|
979
|
-
? formatResetIn(info.resetsAt)
|
|
980
|
-
: formatResetClock(info.resetsAt);
|
|
981
|
-
const tail = clock ? (isIcon ? ` ${g.reset} ${clock}` : ` resets ${clock}`) : '';
|
|
982
|
-
// `cap` reads as a rate-limit ceiling rather than a duration. Icon mode
|
|
983
|
-
// leans on the icon to identify the window (✦ = session/now, 📅 = week),
|
|
984
|
-
// so the 5H label is empty while the 7D label spells out "weekly". Text and
|
|
985
|
-
// verbose modes keep the `5H`/`7D` short label since they have no icon.
|
|
986
|
-
// Icon mode renders an inline ▰▱ gauge instead of the literal "cap used" —
|
|
987
|
-
// a glance at the bar conveys urgency faster than parsing a percent number,
|
|
988
|
-
// and the gauge stays the same width as the percent climbs.
|
|
989
|
-
// LiteLLM 예산 윈도우는 퍼센트만으로는 감이 안 오므로(예산 크기를 모름)
|
|
990
|
-
// 금액을 함께 보여 준다: `💳 budget ▰▱ 34% $34/$100`.
|
|
991
|
-
const money =
|
|
992
|
-
Number.isFinite(info.maxBudget) && info.maxBudget > 0
|
|
993
|
-
? ` ${formatMoney(Number(info.spend) || 0)}/${formatMoney(info.maxBudget)}`
|
|
994
|
-
: '';
|
|
995
|
-
// LiteLLM 의 internal user 한도는 차단 판정에 쓰이는 값이 아니라서, 팀
|
|
996
|
-
// 멤버십 한도와 수백 배 어긋나는 배포가 있습니다. 그 한도로 그린 게이지는
|
|
997
|
-
// 출처를 밝혀 두어야 사용자가 정확한 값으로 오해하지 않습니다.
|
|
998
|
-
const src = info.source === 'user' ? ' (user)' : '';
|
|
999
|
-
if (isIcon) {
|
|
1000
|
-
const labelPart = labels.usageLabel ? `${labels.usageLabel}${src} ` : '';
|
|
1001
|
-
const bar = gaugeColored(pct, c, tone, color, mode);
|
|
1002
|
-
const winGlyph = mode === 'narrow' ? (labels.narrowIcon || labels.icon) : labels.icon;
|
|
1003
|
-
return `${c(tone)}${winGlyph} ${labelPart}${bar} ${pct}%${money}${tail}${c(RESET)}`;
|
|
1004
|
-
}
|
|
1005
|
-
if (verbose) {
|
|
1006
|
-
return `${c(tone)}${labels.short}${src} cap ${pct}% used${money}${tail}${c(RESET)}`;
|
|
1007
|
-
}
|
|
1008
|
-
return `${c(tone)}${labels.short}${src} cap ${pct}%${money}${tail}${c(RESET)}`;
|
|
1009
|
-
}
|
|
1010
|
-
// Color tone: green <70%, yellow 70-89%, red 90+% (a 90+% window only
|
|
1011
|
-
// renders here when a *different* window won the cap-warn chip slot).
|
|
1012
|
-
function usageTone(info) {
|
|
1013
|
-
if (!info || !Number.isFinite(info.usedPct)) return GRAY;
|
|
1014
|
-
if (info.usedPct >= 90) return RED;
|
|
1015
|
-
if (info.usedPct >= 70) return YELLOW;
|
|
1016
|
-
return GREEN;
|
|
1017
|
-
}
|
|
1018
|
-
// Cap-warn chip — leads everything when ANY rate-limit window is at 90%+.
|
|
1019
|
-
// It's the most actionable signal we can show: no point optimizing cache
|
|
1020
|
-
// hits if you're about to be rate-limited anyway. The chip body matches the
|
|
1021
|
-
// English shape `🚨 5H 94%` / `🚨 7D 92%` so history parsers can dedupe on it.
|
|
1022
|
-
// Computed before the usage segments so they know which window it claimed.
|
|
1023
|
-
const capWarn = pickCapWarn(caps);
|
|
1024
|
-
const usageSegs = [];
|
|
1025
|
-
if (caps && Array.isArray(caps.windows)) {
|
|
1026
|
-
for (const win of caps.windows) {
|
|
1027
|
-
const labels = labelForKey(win.key);
|
|
1028
|
-
const seg = buildUsageSeg({
|
|
1029
|
-
labels,
|
|
1030
|
-
info: win,
|
|
1031
|
-
color: usageTone(win),
|
|
1032
|
-
suppressed: !!capWarn && capWarn.key === win.key,
|
|
1033
|
-
});
|
|
1034
|
-
if (seg) usageSegs.push({ key: win.key, seg });
|
|
1035
|
-
}
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
const capWarnSeg = buildCapWarnSeg(capWarn, c, isIcon, g, color, mode);
|
|
1150
|
+
// Lifetime, from the savings ledgers. Delegation stays ahead of the cache
|
|
1151
|
+
// figure: routing is what the tool is for, the cache number is a brag stat.
|
|
1152
|
+
'delegated', 'doc2md',
|
|
1153
|
+
// One-time star ask; renders only while readStarChip returns an object.
|
|
1154
|
+
'star',
|
|
1155
|
+
// Analysis window. These two are the only chips measured over it, so the label
|
|
1156
|
+
// sits with them and the three render as one group.
|
|
1157
|
+
'hit', 'saved', 'period',
|
|
1158
|
+
// Calendar month.
|
|
1159
|
+
'month',
|
|
1160
|
+
'harness', 'korean', 'version',
|
|
1161
|
+
];
|
|
1039
1162
|
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
// Cap-warn outranks spike: an imminent rate-limit block is more urgent than
|
|
1043
|
-
// a single spiking session.
|
|
1044
|
-
const allow = segments && segments.length
|
|
1045
|
-
? new Set(segments.map((s) => String(s).toLowerCase()))
|
|
1046
|
-
: null;
|
|
1047
|
-
// Still needed by the headline lines below, which are not part of the
|
|
1048
|
-
// ordered segment list.
|
|
1049
|
-
const want = (name) => !allow || allow.has(name);
|
|
1163
|
+
// Legacy `--segments` aliases: `5h` ↔ `five_hour`, `7d` ↔ `seven_day`.
|
|
1164
|
+
const USAGE_ALIASES = { '5h': 'five_hour', '7d': 'seven_day' };
|
|
1050
1165
|
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
const seg = usageByKey.get(USAGE_ALIASES[name] || name);
|
|
1093
|
-
return seg ? [seg] : [];
|
|
1094
|
-
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Which chips a name in the segment list stands for. A name maps to the chips it
|
|
1168
|
+
* contributes, so the sequence is data instead of the order of push calls. That is
|
|
1169
|
+
* what lets a caller reorder: `--segments` used to filter only, and its order was
|
|
1170
|
+
* silently discarded, so a user who wanted Ctx first had no way to ask.
|
|
1171
|
+
*
|
|
1172
|
+
* @param {string} name
|
|
1173
|
+
* @param {object} s the built chips, by slot (see formatReport)
|
|
1174
|
+
* @param {Map<string, string>} usageByKey the usage chips by window key
|
|
1175
|
+
* @returns {(string|null)[]}
|
|
1176
|
+
*/
|
|
1177
|
+
function segmentsNamed(name, s, usageByKey) {
|
|
1178
|
+
switch (name) {
|
|
1179
|
+
case 'cap-warn': return [s.capWarn];
|
|
1180
|
+
case 'spike': return [s.spike];
|
|
1181
|
+
case 'version': return [s.version];
|
|
1182
|
+
// `rules` renders only when asked for by name. Verbose is the shipped
|
|
1183
|
+
// default, so riding with the harness chip would put it on everyone's
|
|
1184
|
+
// line, and its ⚠ count repeats what `rule-health` already raises there.
|
|
1185
|
+
case 'harness': return [s.harness];
|
|
1186
|
+
case 'rules': return [s.rules];
|
|
1187
|
+
case 'subagents': return [s.subagents];
|
|
1188
|
+
case 'korean': return [s.korean];
|
|
1189
|
+
case 'model': return [s.model];
|
|
1190
|
+
case 'effort': return [s.effort];
|
|
1191
|
+
// The routing chips (already empty when a headline line owns the story), then
|
|
1192
|
+
// the session's own subagent runs.
|
|
1193
|
+
case 'delegated': return [...s.delegated, s.subagents];
|
|
1194
|
+
case 'doc2md': return s.doc2md;
|
|
1195
|
+
case 'star': return [s.star];
|
|
1196
|
+
case 'hit': return [s.hit];
|
|
1197
|
+
case 'ttl': return [s.ttl];
|
|
1198
|
+
case 'month': return [s.month];
|
|
1199
|
+
case 'ctx': return [s.ctx];
|
|
1200
|
+
case 'saved': return [s.saved];
|
|
1201
|
+
case 'period': return [s.period];
|
|
1202
|
+
// Every rate-limit window, in payload order.
|
|
1203
|
+
case 'usage': return s.usage.map(({ seg }) => seg);
|
|
1204
|
+
default: {
|
|
1205
|
+
const seg = usageByKey.get(USAGE_ALIASES[name] || name);
|
|
1206
|
+
return seg ? [seg] : [];
|
|
1095
1207
|
}
|
|
1096
1208
|
}
|
|
1209
|
+
}
|
|
1097
1210
|
|
|
1211
|
+
/**
|
|
1212
|
+
* The names to render, in order: the default sequence, or the caller's list.
|
|
1213
|
+
*/
|
|
1214
|
+
function resolveSegmentOrder(segments, caps, updateAvailable) {
|
|
1098
1215
|
// An available upgrade is an "act on this" chip, so it moves up behind the
|
|
1099
1216
|
// warning chips. With nothing to upgrade to it stays pure identity, at the
|
|
1100
1217
|
// tail position DEFAULT_SEGMENT_ORDER gives it.
|
|
@@ -1119,18 +1236,26 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
1119
1236
|
// and someone reordering segments is not asking to bury an alarm.
|
|
1120
1237
|
const LEAD = ['cap-warn', 'spike'];
|
|
1121
1238
|
let order = defaultOrder;
|
|
1122
|
-
if (
|
|
1239
|
+
if (segments && segments.length) {
|
|
1123
1240
|
const names = segments.map((s) => String(s).toLowerCase());
|
|
1124
1241
|
const lead = LEAD.filter((n) => names.includes(n));
|
|
1125
1242
|
order = lead.concat(names.filter((n) => !LEAD.includes(n)));
|
|
1126
1243
|
}
|
|
1244
|
+
return order;
|
|
1245
|
+
}
|
|
1127
1246
|
|
|
1247
|
+
/**
|
|
1248
|
+
* Walk the order and collect the chips, then fuse the analysis-window group.
|
|
1249
|
+
* @returns {string[]}
|
|
1250
|
+
*/
|
|
1251
|
+
function arrangeSegments(order, s) {
|
|
1252
|
+
const usageByKey = new Map(s.usage.map(({ key, seg }) => [key, seg]));
|
|
1128
1253
|
const segs = [];
|
|
1129
1254
|
// Dedupe so overlapping names (e.g. `usage,five_hour`) cannot print the same
|
|
1130
1255
|
// chip twice.
|
|
1131
1256
|
const seen = new Set();
|
|
1132
1257
|
for (const name of order) {
|
|
1133
|
-
for (const seg of segmentsNamed(name)) {
|
|
1258
|
+
for (const seg of segmentsNamed(name, s, usageByKey)) {
|
|
1134
1259
|
if (!seg || seen.has(seg)) continue;
|
|
1135
1260
|
seen.add(seg);
|
|
1136
1261
|
segs.push(seg);
|
|
@@ -1142,29 +1267,125 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
1142
1267
|
// label covers all of it. Only fused when they actually ended up adjacent, so
|
|
1143
1268
|
// a caller-supplied order is left alone. Identity comparison is safe here
|
|
1144
1269
|
// because these are the very strings pushed into `segs`, not rebuilt copies.
|
|
1145
|
-
const windowGroup = [
|
|
1270
|
+
const windowGroup = [s.hit, s.saved, s.period].filter((seg) => seg && segs.includes(seg));
|
|
1146
1271
|
if (windowGroup.length > 1) {
|
|
1147
1272
|
const at = segs.indexOf(windowGroup[0]);
|
|
1148
1273
|
const adjacent = windowGroup.every((seg, i) => segs[at + i] === seg);
|
|
1149
1274
|
if (adjacent) segs.splice(at, windowGroup.length, windowGroup.join(' '));
|
|
1150
1275
|
}
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1276
|
+
return segs;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* The headline lines that sit above the chips, as one string of `line\n` pairs.
|
|
1281
|
+
*/
|
|
1282
|
+
function buildHeadlines(delegated, doc2md, want, eol) {
|
|
1157
1283
|
// Each savings source that earned real money gets a headline line, ordered
|
|
1158
1284
|
// biggest saver first — the top line is the one the eye lands on, so it
|
|
1159
1285
|
// goes to whichever habit is actually paying for the tool. The diagnostics
|
|
1160
1286
|
// line always closes.
|
|
1161
1287
|
const headlines = [];
|
|
1162
|
-
if (
|
|
1163
|
-
|
|
1164
|
-
}
|
|
1165
|
-
if (doc2mdLine && want('doc2md')) {
|
|
1166
|
-
headlines.push({ usd: doc2mdUsd, line: doc2mdLine });
|
|
1167
|
-
}
|
|
1288
|
+
if (delegated.headline && want('delegated')) headlines.push(delegated.headline);
|
|
1289
|
+
if (doc2md.headline && want('doc2md')) headlines.push(doc2md.headline);
|
|
1168
1290
|
headlines.sort((a, b) => b.usd - a.usd);
|
|
1169
|
-
return headlines.map((h) => h.line + eol + '\n').join('')
|
|
1291
|
+
return headlines.map((h) => h.line + eol + '\n').join('');
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/**
|
|
1295
|
+
* @param {object} data - output of main report pipeline (summary, ttl, cost, options, lastActivity)
|
|
1296
|
+
* @param {object} [opts]
|
|
1297
|
+
* @param {boolean} [opts.color=true] - emit ANSI escape codes
|
|
1298
|
+
* @param {boolean} [opts.verbose=false] - longer layout with labels
|
|
1299
|
+
* @param {boolean} [opts.timer=true] - show TTL countdown segment
|
|
1300
|
+
* @param {'text'|'icon'} [opts.mode='text'] - label style. 'icon' uses 🧠 ⏳ 💰 instead of word labels.
|
|
1301
|
+
* @param {string[]|null} [opts.segments] - segments to render, in the order given. Names: cap-warn, spike, version, harness (a full score with no warning draws only when named here), korean (draws only when named here), model, effort, hit, ttl, month, saved, delegated, doc2md, star, ctx, period, `subagents` (this session's subagent runs; also rides with `delegated`), `rules` (only when named here), `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.
|
|
1302
|
+
* @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).
|
|
1303
|
+
*/
|
|
1304
|
+
export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null, singleLine = false } = {}) {
|
|
1305
|
+
const { summary, ttl, cost, options, lastActivity, contextWindow, ctxLive, spikeChip, caps, model, effort } = data;
|
|
1306
|
+
|
|
1307
|
+
const c = (v) => (color ? v : '');
|
|
1308
|
+
// narrow shares the icon layout and swaps only the glyphs, so both modes take
|
|
1309
|
+
// the same branches from here on.
|
|
1310
|
+
const isIcon = mode === 'icon' || mode === 'narrow';
|
|
1311
|
+
const g = glyphsFor(mode);
|
|
1312
|
+
|
|
1313
|
+
// The `--segments` filter, lowercased. Built first because two chips (harness,
|
|
1314
|
+
// Korean style) draw only when it is null-or-names-them, so their builders
|
|
1315
|
+
// need the answer.
|
|
1316
|
+
const allow = segments && segments.length
|
|
1317
|
+
? new Set(segments.map((s) => String(s).toLowerCase()))
|
|
1318
|
+
: null;
|
|
1319
|
+
|
|
1320
|
+
// Still needed by the headline lines, which are not part of the ordered
|
|
1321
|
+
// segment list.
|
|
1322
|
+
const want = (name) => !allow || allow.has(name);
|
|
1323
|
+
|
|
1324
|
+
// Cap-warn chip — leads everything when ANY rate-limit window is at 90%+.
|
|
1325
|
+
// It's the most actionable signal we can show: no point optimizing cache
|
|
1326
|
+
// hits if you're about to be rate-limited anyway. The chip body matches the
|
|
1327
|
+
// English shape `🚨 5H 94%` / `🚨 7D 92%` so history parsers can dedupe on it.
|
|
1328
|
+
// Computed before the usage segments so they know which window it claimed.
|
|
1329
|
+
//
|
|
1330
|
+
// Warning chips lead — a glance at the statusline catches "something's wrong"
|
|
1331
|
+
// before parsing any numbers. Healthy states have no chip and look unchanged.
|
|
1332
|
+
// Cap-warn outranks spike: an imminent rate-limit block is more urgent than
|
|
1333
|
+
// a single spiking session.
|
|
1334
|
+
const capWarn = pickCapWarn(caps);
|
|
1335
|
+
|
|
1336
|
+
// Routing and doc2md each return a chip and, for real money, a headline line.
|
|
1337
|
+
const delegated = buildDelegatedSeg(data, singleLine, c, isIcon, verbose, g, color);
|
|
1338
|
+
const doc2md = buildDoc2mdSeg(data.doc2mdTotals, singleLine, c, isIcon, verbose, g);
|
|
1339
|
+
|
|
1340
|
+
// An available upgrade moves the version chip forward (see resolveSegmentOrder).
|
|
1341
|
+
const updateAvailable = !!(data.update && data.update.available);
|
|
1342
|
+
|
|
1343
|
+
// One slot per chip; segmentsNamed() maps the names in the segment list onto
|
|
1344
|
+
// these slots, so this is the only place that knows how each chip is built.
|
|
1345
|
+
const built = {
|
|
1346
|
+
capWarn: buildCapWarnSeg(capWarn, c, isIcon, g, color, mode),
|
|
1347
|
+
spike: buildSpikeSeg(spikeChip, c),
|
|
1348
|
+
usage: buildUsageSegs(caps, capWarn, c, isIcon, verbose, g, color, mode),
|
|
1349
|
+
ctx: buildCtxSeg(ctxLive, contextWindow, c, isIcon, verbose, g),
|
|
1350
|
+
ttl: buildTtlSeg({ ttl, ttlBucket: data.ttlBucket, lastActivity }, timer, c, isIcon, verbose, g),
|
|
1351
|
+
// Model, then the effort that qualifies it: the same model answers
|
|
1352
|
+
// differently at `low` and at `max`, and the pair is one glance.
|
|
1353
|
+
model: buildModelSeg(model, c, isIcon, g),
|
|
1354
|
+
effort: buildEffortSeg(effort, c, isIcon, g),
|
|
1355
|
+
delegated: delegated.chips,
|
|
1356
|
+
// The inline chip steps aside when a headline line owns the story.
|
|
1357
|
+
doc2md: doc2md.headline ? [] : [doc2md.chip],
|
|
1358
|
+
star: buildStarSeg(data.star, c, isIcon),
|
|
1359
|
+
// This session's subagent runs by model family. It is a session figure, so
|
|
1360
|
+
// it sits with the routing chips rather than with the lifetime totals.
|
|
1361
|
+
subagents: buildSessionDelegationSeg(data.sessionDelegation, c, isIcon, verbose, g),
|
|
1362
|
+
hit: buildHitSeg(summary.hitRate, c, isIcon, verbose, g),
|
|
1363
|
+
saved: buildSavedSeg(cost, c, isIcon, verbose, g),
|
|
1364
|
+
period: buildPeriodSeg(options, c, verbose),
|
|
1365
|
+
month: buildMonthSeg(data.monthSpend, c, isIcon, verbose, g),
|
|
1366
|
+
// Harness 🅷 N/5 — project-scoped completeness of CLAUDE.md harness rules.
|
|
1367
|
+
// Silent when the project hasn't opted in (no CLAUDE.md and no .claude/), and
|
|
1368
|
+
// silent at a full 5/5 with no warning unless `--segments` names it. Otherwise
|
|
1369
|
+
// 🅷 N/5 (yellow) so the user can spot a missing section at a glance and know
|
|
1370
|
+
// to run `harness init`, or the red warning chip.
|
|
1371
|
+
harness: buildHarnessSeg(c, isIcon, g, ctxLive?.size ?? null, !!allow && allow.has('harness')),
|
|
1372
|
+
// Korean-style chip — rendered only when named and the injection is on.
|
|
1373
|
+
korean: buildKoreanSeg(c, isIcon, verbose, g, !!allow && allow.has('korean')),
|
|
1374
|
+
// Version / upgrade chip. Read from a cache written by a detached background
|
|
1375
|
+
// check — this render path never touches the network.
|
|
1376
|
+
version: buildVersionSeg(options.version, data.update, c, isIcon, verbose, g),
|
|
1377
|
+
// Delegation-rule registry (`rules 10 · ⚠1`), opt-in through `--segments`.
|
|
1378
|
+
rules: buildRulesSeg(data.ruleStats, c, g),
|
|
1379
|
+
};
|
|
1380
|
+
|
|
1381
|
+
const order = resolveSegmentOrder(segments, caps, updateAvailable);
|
|
1382
|
+
const segs = arrangeSegments(order, built);
|
|
1383
|
+
|
|
1384
|
+
// Trailing erase-to-end-of-line so any leftover characters from a previous
|
|
1385
|
+
// (longer) statusline render don't bleed into ours. \x1b[K is the standard
|
|
1386
|
+
// "erase from cursor to EOL" CSI. Only emitted when color (i.e. ANSI) is
|
|
1387
|
+
// allowed — --no-color/NO_COLOR consumers expect escape-free output.
|
|
1388
|
+
const eol = color ? '\x1b[K' : '';
|
|
1389
|
+
const rest = segs.join(' · ') + eol;
|
|
1390
|
+
return buildHeadlines(delegated, doc2md, want, eol) + rest;
|
|
1170
1391
|
}
|