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.
Files changed (54) hide show
  1. package/README.ko.md +33 -66
  2. package/README.md +41 -74
  3. package/bin/cli.js +150 -62
  4. package/docs/CODEX.ko.md +32 -0
  5. package/docs/CODEX.md +34 -0
  6. package/package.json +15 -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/delegation-agents.js +13 -0
  38. package/src/first-run-note.js +16 -0
  39. package/src/formatters/statusline.js +725 -504
  40. package/src/formatters/table.js +33 -2
  41. package/src/glyphs.js +2 -0
  42. package/src/installer.js +77 -1
  43. package/src/model-alias.js +45 -60
  44. package/src/model-rules.js +25 -4
  45. package/src/model-share.js +136 -0
  46. package/src/parser.js +4 -13
  47. package/src/session-records.js +2 -8
  48. package/src/star-note.js +62 -0
  49. package/src/statusline-base.js +9 -3
  50. package/src/stdin-payload.js +1 -1
  51. package/src/transcript-lines.js +40 -0
  52. package/src/changelog.js +0 -70
  53. /package/src/{codex-cache.js → codex/cache.js} +0 -0
  54. /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, so nothing changes for anyone who never asked for it.
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 tone = harnessInfo.configured >= harnessInfo.total ? GREEN : YELLOW;
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
- const harnessSeg = buildHarnessSeg(c, isIcon, g);
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
- if (typeof model === 'string' && model.length > 0) {
477
- segs.push(isIcon ? `${c(MAGENTA)}${g.model} ${model}${c(RESET)}` : `${c(MAGENTA)}${model}${c(RESET)}`);
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
- * @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.
562
+ * Cache hit-rate chip. The color is the signal: green at 85%+, yellow from 70%,
563
+ * red below.
510
564
  */
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
-
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 = data.ttlBucket === '5m' || data.ttlBucket === '1h' ? data.ttlBucket : null;
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
- // 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);
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
- // 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)}`;
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
- // 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)}`;
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
- // 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.
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
- // 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;
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
- // 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
- }
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 · 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"
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} ${p.runs}× ${formatMoney(p.usd)}${c(RESET)}`)
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
- spentTag + sinceWord(routingSince) + scanTag +
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× $1.55 · pdf 2× $0.27"
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} ${r.docs}× ${formatMoney(r.usd)}${c(RESET)}`);
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} ${r.docs}×`).join(' · ');
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
- // 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
- }
1114
+ return {
1115
+ chip: doc2mdSeg,
1116
+ headline: doc2mdLine ? { usd: doc2mdUsd, line: doc2mdLine } : null,
1117
+ };
1118
+ }
871
1119
 
872
- // Context chip. Two data sources, best first:
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
- // 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);
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
- // 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);
1163
+ // Legacy `--segments` aliases: `5h` ↔ `five_hour`, `7d` ↔ `seven_day`.
1164
+ const USAGE_ALIASES = { '5h': 'five_hour', '7d': 'seven_day' };
1050
1165
 
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
- }
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 (allow) {
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 = [hitSeg, saveSeg, periodSeg].filter((seg) => seg && segs.includes(seg));
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
- // 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;
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 (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
- }
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('') + rest;
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
  }