sprag-cli 3.44.0 → 3.45.0

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