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