sprag-cli 3.44.0 → 3.46.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,11 +17,13 @@
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';
24
25
  import { koreanStyleEnabled } from '../korean-style.js';
26
+ import { MIN_VOTES } from '../model-alias.js';
25
27
 
26
28
  // The 8-color ANSI defaults (RED=31, GREEN=32, YELLOW=33…) read as garish
27
29
  // next to each other — terminal palettes set them with unbalanced perceptual
@@ -66,41 +68,86 @@ function formatPct(v) {
66
68
  }
67
69
 
68
70
  /**
69
- * Render a 6-cell density-gradient gauge for `pct` (0..100). All cells share
70
- * the same Unicode "Block Elements" density family — `█` (100%) → `▓` (75%)
71
- * → `▒` (50%) → `░` (25%) — so the fill→empty boundary reads as one smooth
72
- * gradient instead of an awkward step.
71
+ * Render the gauge for `pct` (0..100) as a row of separate ticks.
73
72
  *
74
- * Earlier we used partial-fill glyphs (`▏▎▍▌▋▊▉`) for sub-cell precision, but
75
- * those have transparent halves that clash visually with the `░` track next
76
- * to them (the eye sees "solid edge | gap | dotted track" — three zones).
77
- * Density chars are the same shape, just darker/lighter, so the boundary
78
- * cell reads as a single smooth fade.
73
+ * The bar used to be six cells of one continuous block, with the boundary cell
74
+ * subdivided into eighths for sub-cell precision. That precision had no reader:
75
+ * every chip that draws a gauge prints the exact percentage immediately after it
76
+ * (`▰▰▰▱▱▱ 26%`), so the bar's job is the glance, not the number. What the solid
77
+ * run cost instead was legibility — adjacent full cells fuse into one shape, and
78
+ * a shape has no quantity you can read off it. Separate ticks do: twelve of them
79
+ * are countable at a glance and land on a familiar 1/12 grid.
79
80
  *
80
- * Each cell is ~17% wide; the boundary cell uses 3 intermediate density steps
81
- * for ~4% effective precision around the fill edge. Stable monospace width
82
- * across all terminal fonts that ship Block Elements (U+2580–U+259F).
81
+ * Two ends get special treatment, because they are the two readings that change
82
+ * a decision. A full row means the limit is reached, so nothing below 100% may
83
+ * draw one; conversely anything above zero draws at least one tick, so "barely
84
+ * started" never looks like "untouched". Rounding alone gets both wrong: it fills
85
+ * the last cell from 96% and leaves the first empty until 5%.
86
+ *
87
+ * Glyphs come from the mode's set (src/glyphs.js) rather than being fixed here.
88
+ * ▰▱ is the shape we want, but JetBrains Mono ships neither character, and a
89
+ * missing glyph is drawn by a fallback font whose advance width does not match
90
+ * the cell grid — which is the whole reason narrow mode exists. So narrow draws
91
+ * the same segmented bar with ■□, which that font does have.
92
+ *
93
+ * @param {number} pct
94
+ * @param {string} [mode] label mode; selects the tick glyphs
95
+ * @returns {{ filled: string, empty: string }} the two halves, so a caller can
96
+ * color them separately
83
97
  */
84
- export function gaugeBar(pct) {
85
- const cells = 6;
98
+ export function gaugeParts(pct, mode = 'icon') {
99
+ const CELLS = 12;
100
+ const g = glyphsFor(mode);
86
101
  const clamped = Math.max(0, Math.min(100, pct));
87
- const filled = (clamped / 100) * cells; // e.g. 4.32 cells filled
88
- const fullCells = Math.floor(filled);
89
- 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
100
- const totalFull = Math.min(cells, fullCells + extra);
101
- const usedCells = totalFull + (partial ? 1 : 0);
102
- const empty = '░'.repeat(Math.max(0, cells - usedCells));
103
- return '█'.repeat(totalFull) + partial + empty;
102
+ let ticks = Math.round((clamped / 100) * CELLS);
103
+ // A full row is reserved for an actual 100%: at the cap the gauge alone has to
104
+ // say so, and 96% rounding up to twelve would spend that signal on a window
105
+ // that still has room.
106
+ if (ticks >= CELLS && clamped < 100) ticks = CELLS - 1;
107
+ // And any nonzero usage earns a tick, so an empty row means untouched.
108
+ if (ticks === 0 && clamped > 0) ticks = 1;
109
+ return {
110
+ filled: g.gaugeFull.repeat(ticks),
111
+ empty: g.gaugeEmpty.repeat(CELLS - ticks),
112
+ };
113
+ }
114
+
115
+ /**
116
+ * The bar as one string, for callers that render it in a single color.
117
+ * @param {number} pct
118
+ * @param {string} [mode] label mode; selects the tick glyphs
119
+ * @returns {string}
120
+ */
121
+ export function gaugeBar(pct, mode = 'icon') {
122
+ const { filled, empty } = gaugeParts(pct, mode);
123
+ return filled + empty;
124
+ }
125
+
126
+ /**
127
+ * The bar with the unused ticks dimmed.
128
+ *
129
+ * The hollow tick already distinguishes the two halves by shape, so color is not
130
+ * carrying that distinction the way it had to when both halves were the same
131
+ * solid block. It still helps: graying the remainder keeps the filled run as the
132
+ * only thing at full brightness, which is what the eye should land on. The
133
+ * segment tone is reinstated at the end so the text after the bar keeps the color
134
+ * it started in.
135
+ *
136
+ * @param {number} pct
137
+ * @param {(code: string) => string} c - color emitter (returns '' when color is off)
138
+ * @param {string} tone - the segment's own color code
139
+ * @param {boolean} color - whether ANSI is allowed. Passed explicitly rather
140
+ * than inferred from `c` emitting nothing: that inference reads the emitter's
141
+ * current implementation, and a caller that wraps or replaces `c` would change
142
+ * behaviour here as a side effect nobody asked for.
143
+ * @param {string} [mode] label mode; selects the tick glyphs
144
+ * @returns {string}
145
+ */
146
+ function gaugeColored(pct, c, tone, color, mode = 'icon') {
147
+ const { filled, empty } = gaugeParts(pct, mode);
148
+ if (!empty) return filled;
149
+ if (!color) return filled + empty;
150
+ return `${filled}${c(RESET)}${c(GRAY)}${empty}${c(RESET)}${c(tone)}`;
104
151
  }
105
152
 
106
153
  /**
@@ -146,7 +193,7 @@ export function pickCapWarn(caps) {
146
193
  * Korean-style chip builder. Renders only when the session-start injection is
147
194
  * enabled, so nothing changes for anyone who never asked for it.
148
195
  */
149
- function buildKoreanSeg(c, isIcon, verbose) {
196
+ function buildKoreanSeg(c, isIcon, verbose, g) {
150
197
  try {
151
198
  if (!koreanStyleEnabled()) return null;
152
199
  // Deliberately quiet (gray, one glyph): this is a "yes, it is on"
@@ -154,7 +201,7 @@ function buildKoreanSeg(c, isIcon, verbose) {
154
201
  // exactly like a working one, because the style only shows up when the
155
202
  // model happens to write Korean. The icon says "writing guidance", not
156
203
  // "Korean" — the verbose label already carries the language.
157
- if (isIcon) return `${c(GRAY)}${verbose ? '✍️ Korean style' : '✍️'}${c(RESET)}`;
204
+ if (isIcon) return `${c(GRAY)}${verbose ? `${g.korean} Korean style` : g.korean}${c(RESET)}`;
158
205
  return `${c(GRAY)}Korean style${c(RESET)}`;
159
206
  } catch {
160
207
  return null;
@@ -177,13 +224,13 @@ function buildKoreanSeg(c, isIcon, verbose) {
177
224
  * rendering after the user declines — declining hides the session-start
178
225
  * question, not the fact that a new version exists.
179
226
  */
180
- function buildVersionSeg(version, update, c, isIcon, verbose) {
227
+ function buildVersionSeg(version, update, c, isIcon, verbose, g) {
181
228
  if (!version) return null;
182
229
  if (update && update.available && update.latest) {
183
230
  const body = verbose
184
231
  ? `Update v${version} → ${update.latest}`
185
232
  : `v${version} → ${update.latest}`;
186
- return `${c(YELLOW)}${isIcon ? '⬆ ' : ''}${body}${c(RESET)}`;
233
+ return `${c(YELLOW)}${isIcon ? `${g.upgrade} ` : ''}${body}${c(RESET)}`;
187
234
  }
188
235
  return `${c(GRAY)}v${version}${c(RESET)}`;
189
236
  }
@@ -193,16 +240,16 @@ function buildVersionSeg(version, update, c, isIcon, verbose) {
193
240
  * fallback line. Best-effort: never throws into the statusline (corrupted
194
241
  * CLAUDE.md, permission issue, etc. → null).
195
242
  */
196
- function buildHarnessSeg(c, isIcon) {
243
+ function buildHarnessSeg(c, isIcon, g) {
197
244
  try {
198
245
  const harnessInfo = harnessStatusForStatusline(loadConfig());
199
246
  if (!harnessInfo) return null;
200
- const icon = isIcon ? '🅷' : 'H';
247
+ const icon = isIcon ? g.harness : 'H';
201
248
  if (harnessInfo.warning) {
202
249
  // Warning state outranks the N/5 count — a runtime issue (repeated
203
250
  // error / no-evidence / racing edits) is more actionable than a
204
251
  // missing ratchet section. Always red so it stands out.
205
- return `${c(RED)}${icon}⚠ ${harnessInfo.warning}${c(RESET)}`;
252
+ return `${c(RED)}${icon}${g.spike} ${harnessInfo.warning}${c(RESET)}`;
206
253
  }
207
254
  if (harnessInfo.custom) return `${c(CYAN)}${icon} custom${c(RESET)}`;
208
255
  const tone = harnessInfo.configured >= harnessInfo.total ? GREEN : YELLOW;
@@ -218,17 +265,21 @@ function buildHarnessSeg(c, isIcon) {
218
265
  * the wall-clock reset time rides along in the same `🔄 HH:MM` shape as the
219
266
  * always-on usage segments.
220
267
  */
221
- function buildCapWarnSeg(capWarn, c, isIcon) {
268
+ function buildCapWarnSeg(capWarn, c, isIcon, g, color, mode) {
269
+ // `color` and `mode` are both for the gauge: the first decides whether the
270
+ // unused ticks are dimmed, the second which pair of tick glyphs to draw.
222
271
  if (!capWarn) return null;
223
272
  const pct = Math.round(capWarn.usedPct);
224
273
  const clock = formatResetClock(capWarn.resetsAt);
225
- const clockTail = clock ? ` 🔄 ${clock}` : '';
274
+ // Text mode has no glyph vocabulary, so it names the thing instead of
275
+ // borrowing an emoji it otherwise avoids.
276
+ const clockTail = clock ? (isIcon ? ` ${g.reset} ${clock}` : ` resets ${clock}`) : '';
226
277
  if (isIcon) {
227
278
  // Gauge keeps shape parity with the always-on usage segment — the
228
279
  // cap-warn is just the same gauge "filled to alarm". Visual continuity
229
280
  // 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)}`;
281
+ const bar = gaugeColored(pct, c, RED, color, mode);
282
+ return `${c(BOLD)}${c(RED)}${g.capWarn} ${capWarn.label} ${bar} ${pct}%${clockTail}${c(RESET)}`;
232
283
  }
233
284
  return `${c(BOLD)}${c(RED)}${capWarn.label} cap ${pct}%${clockTail}${c(RESET)}`;
234
285
  }
@@ -242,18 +293,21 @@ function buildCapWarnSeg(capWarn, c, isIcon) {
242
293
  */
243
294
  export function formatNoSession({ caps = null, model = null, windowLabel = '', version = '', update = null } = {}, { color = true, mode = 'icon' } = {}) {
244
295
  const c = (v) => (color ? v : '');
245
- const isIcon = mode === 'icon';
296
+ // narrow shares the icon layout and swaps only the glyphs, so both modes take
297
+ // the same branches from here on.
298
+ const isIcon = mode === 'icon' || mode === 'narrow';
299
+ const g = glyphsFor(mode);
246
300
  const segs = [];
247
- const capSeg = buildCapWarnSeg(pickCapWarn(caps), c, isIcon);
301
+ const capSeg = buildCapWarnSeg(pickCapWarn(caps), c, isIcon, g, color, mode);
248
302
  if (capSeg) segs.push(capSeg);
249
- const versionSeg = buildVersionSeg(version, update, c, isIcon, false);
303
+ const versionSeg = buildVersionSeg(version, update, c, isIcon, false, g);
250
304
  if (versionSeg && update && update.available) segs.push(versionSeg);
251
- const harnessSeg = buildHarnessSeg(c, isIcon);
305
+ const harnessSeg = buildHarnessSeg(c, isIcon, g);
252
306
  if (harnessSeg) segs.push(harnessSeg);
253
307
  if (typeof model === 'string' && model.length > 0) {
254
- segs.push(isIcon ? `${c(MAGENTA)}🤖 ${model}${c(RESET)}` : `${c(MAGENTA)}${model}${c(RESET)}`);
308
+ segs.push(isIcon ? `${c(MAGENTA)}${g.model} ${model}${c(RESET)}` : `${c(MAGENTA)}${model}${c(RESET)}`);
255
309
  }
256
- segs.push(`${c(GRAY)}🧠 no session data${windowLabel ? ` · ${windowLabel}` : ''}${c(RESET)}`);
310
+ segs.push(`${c(GRAY)}${g.hit} no session data${windowLabel ? ` · ${windowLabel}` : ''}${c(RESET)}`);
257
311
  if (versionSeg && !(update && update.available)) segs.push(versionSeg);
258
312
  return segs.join(' · ') + (color ? '\x1b[K' : '');
259
313
  }
@@ -265,9 +319,45 @@ export function formatNoSession({ caps = null, model = null, windowLabel = '', v
265
319
  * @param {boolean} [opts.verbose=false] - longer layout with labels
266
320
  * @param {boolean} [opts.timer=true] - show TTL countdown segment
267
321
  * @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.
322
+ * @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
323
  * @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
324
  */
325
+ /**
326
+ * Default segment order, most actionable first. A rate-limit block or a full
327
+ * context window stops the work outright, so those lead. Cache and cost numbers
328
+ * shape habits rather than the next keystroke, and harness / Korean-style are
329
+ * near-constant identity: they sit at the tail, where a narrow terminal clips
330
+ * the least useful end of the line instead of the most useful one.
331
+ *
332
+ * `version` is listed once, at its identity position. formatReport() moves it
333
+ * forward when an upgrade is actually available, since it becomes an
334
+ * "act on this" chip then.
335
+ *
336
+ * Exported so a test can assert every name here is one the renderer knows:
337
+ * the order list and the segment registry are separate structures, and a name
338
+ * added to only one of them would be dropped in silence.
339
+ */
340
+ export const DEFAULT_SEGMENT_ORDER = [
341
+ 'cap-warn', 'spike',
342
+ 'usage', 'ctx',
343
+ 'ttl',
344
+ 'model',
345
+ // Grouped by the timeframe each figure covers, because mixing them was the
346
+ // problem: a lifetime total, a windowed rate and a calendar-month estimate sat
347
+ // interleaved, with one window label at the end of the line that appeared to
348
+ // qualify all of them.
349
+ //
350
+ // Lifetime, from the savings ledgers. Delegation stays ahead of the cache
351
+ // figure: routing is what the tool is for, the cache number is a brag stat.
352
+ 'delegated', 'doc2md',
353
+ // Analysis window. These two are the only chips measured over it, so the label
354
+ // sits with them and the three render as one group.
355
+ 'hit', 'saved', 'period',
356
+ // Calendar month.
357
+ 'month',
358
+ 'harness', 'korean', 'version',
359
+ ];
360
+
271
361
  export function formatReport(data, { color = true, verbose = false, timer = true, mode = 'text', segments = null, singleLine = false } = {}) {
272
362
  const { summary, ttl, cost, options, lastActivity, contextWindow, ctxLive, spikeChip, caps, model } = data;
273
363
  const { hitRate } = summary;
@@ -310,20 +400,23 @@ export function formatReport(data, { color = true, verbose = false, timer = true
310
400
  const savings = cost?.savings ?? 0;
311
401
 
312
402
  const c = (v) => (color ? v : '');
313
- const isIcon = mode === 'icon';
403
+ // narrow shares the icon layout and swaps only the glyphs, so both modes take
404
+ // the same branches from here on.
405
+ const isIcon = mode === 'icon' || mode === 'narrow';
406
+ const g = glyphsFor(mode);
314
407
 
315
408
  // Labels per mode.
316
409
  // text: "Cache hit 98.3%" | verbose: "Cache hit 98.3%"
317
410
  // icon: "🧠 98.3%" | verbose: "🧠 Cache hit 98.3%"
318
411
  const hitLabel = isIcon
319
- ? (verbose ? '🧠 Cache hit' : '🧠')
412
+ ? (verbose ? `${g.hit} Cache hit` : g.hit)
320
413
  : 'Cache hit';
321
414
  const hitSeg = `${c(BOLD)}${hitLabel}${c(RESET)} ${c(hitColor)}${formatPct(hitRate)}${c(RESET)}`;
322
415
 
323
416
  // text: "Cache saved $1.5K" | same in verbose
324
417
  // icon: "💰 $1.5K" | verbose: "💰 Cache saved $1.5K"
325
418
  const saveLabel = isIcon
326
- ? (verbose ? '💰 Cache saved' : '💰')
419
+ ? (verbose ? `${g.saved} Cache saved` : g.saved)
327
420
  : 'Cache saved';
328
421
  const saveSeg = `${c(CYAN)}${saveLabel}${c(RESET)} ${formatMoney(savings)}`;
329
422
 
@@ -339,7 +432,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
339
432
  // icon: "🔀 $3.2" | verbose: "🔀 Routing saved $3.2"
340
433
  const delegationSaved = Number(data.delegationSaved) || 0;
341
434
  const delegateLabel = isIcon
342
- ? (verbose ? '🔀 Routing saved' : '🔀')
435
+ ? (verbose ? `${g.routing} Routing saved` : g.routing)
343
436
  : 'Routing saved';
344
437
  // Zero savings has two very different causes and, until now, one appearance:
345
438
  // nothing at all. "Never delegated" and "delegated plenty, but every run was
@@ -347,11 +440,19 @@ export function formatReport(data, { color = true, verbose = false, timer = true
347
440
  // identical, so users in the second case had no reason to suspect anything
348
441
  // was wrong. The count gets a chip; the explanation stays in `route-scan
349
442
  // rules`, where there is room for it.
443
+ //
444
+ // But only once there are enough of them to mean anything. A gateway reports
445
+ // an opaque profile ARN, and the fix for that is learned from observed runs —
446
+ // which needs MIN_VOTES of them before it can conclude anything. Below that
447
+ // threshold an unresolved run is not a problem, it is the first day of a
448
+ // working mechanism, and warning about it makes a fresh install look broken.
449
+ // Tied to MIN_VOTES rather than a literal so the two cannot drift: the chip
450
+ // appears exactly when learning has had its chance and still came up short.
350
451
  const unresolvedRuns = Number(data.unresolvedRuns) || 0;
351
452
  const delegateSeg = delegationSaved > 0
352
453
  ? `${c(GREEN)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}`
353
- : (unresolvedRuns > 0
354
- ? `${c(YELLOW)}🔀 ${unresolvedRuns} unresolved${c(RESET)}`
454
+ : (unresolvedRuns >= MIN_VOTES
455
+ ? `${c(YELLOW)}${g.routing} ${unresolvedRuns} unresolved${c(RESET)}`
355
456
  : null);
356
457
 
357
458
  // Document conversions — the same kind of number as "Routing saved", earned
@@ -369,14 +470,14 @@ export function formatReport(data, { color = true, verbose = false, timer = true
369
470
  const doc2mdUsd = Number(doc2md && doc2md.total) || 0;
370
471
  const doc2mdDocs = Number(doc2md && doc2md.docs) || 0;
371
472
  const doc2mdLabel = isIcon
372
- ? (verbose ? '📄 Doc2md saved' : '📄')
473
+ ? (verbose ? `${g.doc} Doc2md saved` : g.doc)
373
474
  : 'Doc2md saved';
374
475
  let doc2mdSeg = null;
375
476
  if (doc2mdUsd > 0) {
376
477
  doc2mdSeg = `${c(GREEN)}${doc2mdLabel}${c(RESET)} ${formatMoney(doc2mdUsd)}`
377
478
  + (verbose ? ` ${c(GRAY)}· ${doc2mdDocs} docs${c(RESET)}` : '');
378
479
  } else if (doc2mdDocs > 0) {
379
- const label = isIcon ? '📄' : 'Doc2md';
480
+ const label = isIcon ? g.doc : 'Doc2md';
380
481
  doc2mdSeg = `${c(GRAY)}${label} ${doc2mdDocs} docs${c(RESET)}`;
381
482
  }
382
483
 
@@ -391,7 +492,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
391
492
  if (month && Number(month.usd) > 0) {
392
493
  const amt = formatMoney(Number(month.usd));
393
494
  if (isIcon) {
394
- monthSeg = `${c(GRAY)}💵 ${month.label} ${amt}${c(RESET)}`;
495
+ monthSeg = `${c(GRAY)}${g.month} ${month.label} ${amt}${c(RESET)}`;
395
496
  } else if (verbose) {
396
497
  monthSeg = `${c(GRAY)}${month.label} spend ${amt} (since ${month.label} 1)${c(RESET)}`;
397
498
  } else {
@@ -413,7 +514,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
413
514
  const totals = data.delegationTotals;
414
515
  let totalsLine = null;
415
516
  if (!singleLine && totals && Number(totals.total) > 0) {
416
- const head = isIcon ? '🔀 Routing saved' : 'Routing saved';
517
+ const head = isIcon ? `${g.routing} Routing saved` : 'Routing saved';
417
518
  // Model changes behind the total, family-level and version-free: `opus →
418
519
  // haiku 2× $0.6`. Versions bump constantly and add nothing here — the
419
520
  // shape of the trade is the point.
@@ -444,7 +545,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
444
545
  // icon: "📄 Doc2md saved $1.8 | pptx 1× $1.55 · pdf 2× $0.27"
445
546
  let doc2mdLine = null;
446
547
  if (!singleLine && doc2mdUsd > 0) {
447
- const head = isIcon ? '📄 Doc2md saved' : 'Doc2md saved';
548
+ const head = isIcon ? `${g.doc} Doc2md saved` : 'Doc2md saved';
448
549
  const byExt = Array.isArray(doc2md.byExt) ? doc2md.byExt : [];
449
550
  // Formats that earned money show it; formats with no baseline to measure
450
551
  // against are counted instead. Printing "$0.00" next to real amounts reads
@@ -468,9 +569,11 @@ export function formatReport(data, { color = true, verbose = false, timer = true
468
569
  // Period label honors hour-precision configs (`mode 6h` → "6h", `mode 1d` → "1d").
469
570
  // Fall back to legacy `${days}d` when callers haven't supplied a label.
470
571
  const periodLabel = options.windowLabel || `${options.days}d`;
572
+ // Parenthesized so it reads as a qualifier on the two chips before it rather
573
+ // than as one more independent chip.
471
574
  const periodSeg = verbose
472
- ? `${c(GRAY)}last ${periodLabel}${c(RESET)}`
473
- : `${c(GRAY)}${periodLabel}${c(RESET)}`;
575
+ ? `${c(GRAY)}(last ${periodLabel})${c(RESET)}`
576
+ : `${c(GRAY)}(${periodLabel})${c(RESET)}`;
474
577
 
475
578
  // TTL countdown — how much time is left on the last API call's cache entry.
476
579
  // Matches Anthropic's actual prompt-cache behaviour: each call starts a fresh
@@ -515,9 +618,9 @@ export function formatReport(data, { color = true, verbose = false, timer = true
515
618
  // for the same reason the compact form did. The bucket lives in the
516
619
  // text-verbose layout where the "bucket" word + `·` separator make it
517
620
  // unambiguous.
518
- ttlSeg = `${c(timerColor)}⏳ Cache expires ${text}${c(RESET)}`;
621
+ ttlSeg = `${c(timerColor)}${g.ttl} Cache expires ${text}${c(RESET)}`;
519
622
  } else if (isIcon) {
520
- ttlSeg = `${c(timerColor)}⏳ ${text}${c(RESET)}`;
623
+ ttlSeg = `${c(timerColor)}${g.ttl} ${text}${c(RESET)}`;
521
624
  } else if (verbose) {
522
625
  ttlSeg = `${c(bucketColor)}Cache ${bucketLabel} bucket${c(RESET)} · ${c(timerColor)}expires in ${text}${c(RESET)}`;
523
626
  } else {
@@ -526,7 +629,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
526
629
  } else {
527
630
  // No-timer fallback: only the bucket is available, so we show just that.
528
631
  if (isIcon) {
529
- const prefix = verbose ? '⏳ Cache bucket ' : '⏳ ';
632
+ const prefix = verbose ? `${g.ttl} Cache bucket ` : `${g.ttl} `;
530
633
  ttlSeg = `${c(bucketColor)}${prefix}${bucketLabel}${c(RESET)}`;
531
634
  } else if (verbose) {
532
635
  ttlSeg = `${c(bucketColor)}Cache ${bucketLabel} bucket${c(RESET)}`;
@@ -556,9 +659,9 @@ export function formatReport(data, { color = true, verbose = false, timer = true
556
659
  : null;
557
660
  const longLabel = sizeLabel ? `${pct}% of ${sizeLabel}` : `${pct}%`;
558
661
  if (isIcon && verbose) {
559
- ctxSeg = `${c(tone)}📦 Ctx ${longLabel}${c(RESET)}`;
662
+ ctxSeg = `${c(tone)}${g.ctx} Ctx ${longLabel}${c(RESET)}`;
560
663
  } else if (isIcon) {
561
- ctxSeg = `${c(tone)}📦 ${pct}%${c(RESET)}`;
664
+ ctxSeg = `${c(tone)}${g.ctx} ${pct}%${c(RESET)}`;
562
665
  } else {
563
666
  ctxSeg = `${c(tone)}Ctx ${longLabel}${c(RESET)}`;
564
667
  }
@@ -568,9 +671,9 @@ export function formatReport(data, { color = true, verbose = false, timer = true
568
671
  // default on every current model, so colouring it yellow cried wolf.
569
672
  const ctxColor = contextWindow.overWarn ? YELLOW : GREEN;
570
673
  if (isIcon && verbose) {
571
- ctxSeg = `${c(ctxColor)}📦 Ctx ${label}${c(RESET)}`;
674
+ ctxSeg = `${c(ctxColor)}${g.ctx} Ctx ${label}${c(RESET)}`;
572
675
  } else if (isIcon) {
573
- ctxSeg = `${c(ctxColor)}📦 ${label}${c(RESET)}`;
676
+ ctxSeg = `${c(ctxColor)}${g.ctx} ${label}${c(RESET)}`;
574
677
  } else {
575
678
  ctxSeg = `${c(ctxColor)}Ctx ${label}${c(RESET)}`;
576
679
  }
@@ -583,15 +686,15 @@ export function formatReport(data, { color = true, verbose = false, timer = true
583
686
  // Silent when the project hasn't opted in (no CLAUDE.md and no .claude/);
584
687
  // otherwise renders 🅷 5/5 (green) / 🅷 N/5 (yellow) so the user can spot
585
688
  // a missing section at a glance and know to run `harness init`.
586
- const harnessSeg = buildHarnessSeg(c, isIcon);
689
+ const harnessSeg = buildHarnessSeg(c, isIcon, g);
587
690
 
588
691
  // Version / upgrade chip. Read from a cache written by a detached background
589
692
  // check — this render path never touches the network.
590
- const versionSeg = buildVersionSeg(options.version, data.update, c, isIcon, verbose);
693
+ const versionSeg = buildVersionSeg(options.version, data.update, c, isIcon, verbose, g);
591
694
  const updateAvailable = !!(data.update && data.update.available);
592
695
 
593
696
  // Korean-style chip — rendered only when the session-start injection is on.
594
- const koreanSeg = buildKoreanSeg(c, isIcon, verbose);
697
+ const koreanSeg = buildKoreanSeg(c, isIcon, verbose, g);
595
698
 
596
699
  // Model chip — pulled from Claude Code's stdin payload (`model.display_name`).
597
700
  // Cheap identity context: useful when the user toggles between Sonnet/Opus
@@ -602,7 +705,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
602
705
  // is dead weight in icon mode. Text modes keep the bare name; the magenta
603
706
  // tone marks it as identity context.
604
707
  if (isIcon) {
605
- modelSeg = `${c(MAGENTA)}🤖 ${model}${c(RESET)}`;
708
+ modelSeg = `${c(MAGENTA)}${g.model} ${model}${c(RESET)}`;
606
709
  } else {
607
710
  modelSeg = `${c(MAGENTA)}${model}${c(RESET)}`;
608
711
  }
@@ -625,8 +728,14 @@ export function formatReport(data, { color = true, verbose = false, timer = true
625
728
  // Show only the wall-clock reset time (e.g. `🔄 21:10`). Absolute time
626
729
  // doesn't tick second-by-second so the statusline reads stable, and the
627
730
  // 🔄 icon itself separates the percent from the clock — no extra `·` needed.
628
- const clock = formatResetClock(info.resetsAt);
629
- const tail = clock ? ` 🔄 ${clock}` : '';
731
+ // A gateway budget can reset weeks out, where a wall-clock time says almost
732
+ // nothing ("Thu 00:00" — which Thursday?). Those windows report how long is
733
+ // left instead. Short windows keep the clock, which is the more useful form
734
+ // when the reset is later today.
735
+ const clock = labels.resetStyle === 'countdown'
736
+ ? formatResetIn(info.resetsAt)
737
+ : formatResetClock(info.resetsAt);
738
+ const tail = clock ? (isIcon ? ` ${g.reset} ${clock}` : ` resets ${clock}`) : '';
630
739
  // `cap` reads as a rate-limit ceiling rather than a duration. Icon mode
631
740
  // leans on the icon to identify the window (✦ = session/now, 📅 = week),
632
741
  // so the 5H label is empty while the 7D label spells out "weekly". Text and
@@ -635,7 +744,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
635
744
  // a glance at the bar conveys urgency faster than parsing a percent number,
636
745
  // and the gauge stays the same width as the percent climbs.
637
746
  // LiteLLM 예산 윈도우는 퍼센트만으로는 감이 안 오므로(예산 크기를 모름)
638
- // 금액을 함께 보여 준다: `🔑 budget ▰▱ 34% $34/$100`.
747
+ // 금액을 함께 보여 준다: `💳 budget ▰▱ 34% $34/$100`.
639
748
  const money =
640
749
  Number.isFinite(info.maxBudget) && info.maxBudget > 0
641
750
  ? ` ${formatMoney(Number(info.spend) || 0)}/${formatMoney(info.maxBudget)}`
@@ -646,8 +755,9 @@ export function formatReport(data, { color = true, verbose = false, timer = true
646
755
  const src = info.source === 'user' ? ' (user)' : '';
647
756
  if (isIcon) {
648
757
  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)}`;
758
+ const bar = gaugeColored(pct, c, tone, color, mode);
759
+ const winGlyph = mode === 'narrow' ? (labels.narrowIcon || labels.icon) : labels.icon;
760
+ return `${c(tone)}${winGlyph} ${labelPart}${bar} ${pct}%${money}${tail}${c(RESET)}`;
651
761
  }
652
762
  if (verbose) {
653
763
  return `${c(tone)}${labels.short}${src} cap ${pct}% used${money}${tail}${c(RESET)}`;
@@ -682,56 +792,106 @@ export function formatReport(data, { color = true, verbose = false, timer = true
682
792
  }
683
793
  }
684
794
 
685
- const capWarnSeg = buildCapWarnSeg(capWarn, c, isIcon);
795
+ const capWarnSeg = buildCapWarnSeg(capWarn, c, isIcon, g, color, mode);
686
796
 
687
797
  // Warning chip leads — a glance at the statusline catches "something's wrong"
688
798
  // before parsing any numbers. Healthy states have no chip and look unchanged.
689
799
  // Cap-warn outranks spike: an imminent rate-limit block is more urgent than
690
800
  // a single spiking session.
691
801
  const allow = segments && segments.length
692
- ? new Set(segments.map((s) => s.toLowerCase()))
802
+ ? new Set(segments.map((s) => String(s).toLowerCase()))
693
803
  : null;
804
+ // Still needed by the headline lines below, which are not part of the
805
+ // ordered segment list.
694
806
  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
- };
807
+
808
+ // Segment registry — a name maps to the segments it contributes, so the
809
+ // sequence is data instead of the order of push calls. That is what lets a
810
+ // caller reorder: `--segments` used to filter only, and its order was
811
+ // silently discarded, so a user who wanted Ctx first had no way to ask.
812
+ const usageByKey = new Map(usageSegs.map(({ key, seg }) => [key, seg]));
813
+ // Legacy `--segments` aliases: `5h` ↔ `five_hour`, `7d` ↔ `seven_day`.
814
+ const USAGE_ALIASES = { '5h': 'five_hour', '7d': 'seven_day' };
815
+ function segmentsNamed(name) {
816
+ switch (name) {
817
+ case 'cap-warn': return [capWarnSeg];
818
+ case 'spike': return [spikeSeg];
819
+ case 'version': return [versionSeg];
820
+ case 'harness': return [harnessSeg];
821
+ case 'korean': return [koreanSeg];
822
+ case 'model': return [modelSeg];
823
+ // Dropped when the same story already owns a headline line above, which
824
+ // would otherwise repeat it on line 2.
825
+ case 'delegated': return totalsLine ? [] : [delegateSeg];
826
+ case 'doc2md': return doc2mdLine ? [] : [doc2mdSeg];
827
+ case 'hit': return [hitSeg];
828
+ case 'ttl': return [ttlSeg];
829
+ case 'month': return [monthSeg];
830
+ case 'ctx': return [ctxSeg];
831
+ case 'saved': return [saveSeg];
832
+ case 'period': return [periodSeg];
833
+ // Every rate-limit window, in payload order.
834
+ case 'usage': return usageSegs.map(({ seg }) => seg);
835
+ default: {
836
+ const seg = usageByKey.get(USAGE_ALIASES[name] || name);
837
+ return seg ? [seg] : [];
838
+ }
839
+ }
840
+ }
841
+
842
+ // An available upgrade is an "act on this" chip, so it moves up behind the
843
+ // warning chips. With nothing to upgrade to it stays pure identity, at the
844
+ // tail position DEFAULT_SEGMENT_ORDER gives it.
845
+ let defaultOrder = DEFAULT_SEGMENT_ORDER;
846
+ if (updateAvailable) {
847
+ const rest = DEFAULT_SEGMENT_ORDER.filter((n) => n !== 'version');
848
+ const usageAt = rest.indexOf('usage');
849
+ defaultOrder = [...rest.slice(0, usageAt), 'version', ...rest.slice(usageAt)];
850
+ }
851
+ // A gateway that reports its own spend makes the month chip the second dollar
852
+ // figure on the line, and the two never agree: the budget counts every call
853
+ // that key served, while the month chip estimates from this machine's session
854
+ // logs alone. Two numbers for what looks like one question is worse than one
855
+ // number, so the estimate steps aside when the measured value is present.
856
+ // `--segments month` still asks for it explicitly and still gets it.
857
+ const hasGatewayBudget = !!(caps && Array.isArray(caps.windows)
858
+ && caps.windows.some((w) => w && w.key === 'litellm_budget'));
859
+ if (hasGatewayBudget) defaultOrder = defaultOrder.filter((n) => n !== 'month');
860
+
861
+ // A caller-supplied list sets the filter AND the order. Warning chips keep
862
+ // the lead wherever they were written: being seen first is their whole job,
863
+ // and someone reordering segments is not asking to bury an alarm.
864
+ const LEAD = ['cap-warn', 'spike'];
865
+ let order = defaultOrder;
866
+ if (allow) {
867
+ const names = segments.map((s) => String(s).toLowerCase());
868
+ const lead = LEAD.filter((n) => names.includes(n));
869
+ order = lead.concat(names.filter((n) => !LEAD.includes(n)));
870
+ }
871
+
704
872
  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);
873
+ // Dedupe so overlapping names (e.g. `usage,five_hour`) cannot print the same
874
+ // chip twice.
875
+ const seen = new Set();
876
+ for (const name of order) {
877
+ for (const seg of segmentsNamed(name)) {
878
+ if (!seg || seen.has(seg)) continue;
879
+ seen.add(seg);
880
+ segs.push(seg);
881
+ }
882
+ }
883
+ // Fuse the analysis-window group with spaces instead of `·`. Separated, each
884
+ // piece reads as an independent chip and the label appears to qualify only the
885
+ // one immediately before it; joined, the three read as a single group and the
886
+ // label covers all of it. Only fused when they actually ended up adjacent, so
887
+ // a caller-supplied order is left alone. Identity comparison is safe here
888
+ // because these are the very strings pushed into `segs`, not rebuilt copies.
889
+ const windowGroup = [hitSeg, saveSeg, periodSeg].filter((seg) => seg && segs.includes(seg));
890
+ if (windowGroup.length > 1) {
891
+ const at = segs.indexOf(windowGroup[0]);
892
+ const adjacent = windowGroup.every((seg, i) => segs[at + i] === seg);
893
+ if (adjacent) segs.splice(at, windowGroup.length, windowGroup.join(' '));
726
894
  }
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
895
  // Trailing erase-to-end-of-line so any leftover characters from a previous
736
896
  // (longer) statusline render don't bleed into ours. \x1b[K is the standard
737
897
  // "erase from cursor to EOL" CSI. Only emitted when color (i.e. ANSI) is