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.
package/src/glyphs.js ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Statusline glyph sets, one per label mode.
3
+ *
4
+ * Why a narrow set exists at all: IntelliJ's terminal draws a character its
5
+ * configured font lacks through a fallback font, and the fallback's advance
6
+ * width does not match the cell grid. The mismatch is invisible on static text,
7
+ * but a segment whose value changes every second (the TTL countdown, a gauge
8
+ * percentage) repaints partially, and the leftover characters read as garbage:
9
+ * `Cache expires 4:54` renders as `4:545`, `$4.0K` as `$4.:0K`. Dragging a
10
+ * selection forces a full repaint and the line looks correct again, which is the
11
+ * tell that the buffer was always right and only the painting was off.
12
+ *
13
+ * Measured on this machine (JetBrainsMono-Regular.ttf, the IDE default):
14
+ * every emoji the icon set uses is absent from the font, while the gauge
15
+ * characters (█ ▉ … ░) are present — and the gauge is the one part of the line
16
+ * that never garbled. So the narrow set is chosen by font coverage, not by
17
+ * Unicode width: each glyph below is one JetBrains Mono actually ships.
18
+ *
19
+ * Per-window glyphs (the 5h star, the weekly grid, the budget box) live in
20
+ * src/window-labels.js instead, next to the labels they belong to.
21
+ *
22
+ * Width still matters as a second filter — a fullwidth glyph would occupy two
23
+ * cells — so every narrow glyph is also East Asian Width N/Na. The test suite
24
+ * enforces both properties.
25
+ */
26
+
27
+ /** Emoji set: what a terminal with proper emoji support should show. */
28
+ const ICON = {
29
+ capWarn: '🚨',
30
+ spike: '⚠',
31
+ upgrade: '⬆',
32
+ harness: '🅷',
33
+ korean: '✍️',
34
+ model: '🤖',
35
+ routing: '🔀',
36
+ doc: '📄',
37
+ hit: '🧠',
38
+ ttl: '⏳',
39
+ month: '💵',
40
+ ctx: '📦',
41
+ saved: '💰',
42
+ reset: '🔄',
43
+ };
44
+
45
+ /**
46
+ * Narrow set: every glyph is present in JetBrains Mono and one cell wide.
47
+ * Shapes were picked to stay distinguishable from each other at a glance, since
48
+ * compact mode shows the glyph with no label beside it.
49
+ */
50
+ const NARROW = {
51
+ capWarn: '‼', // doubled bang reads as an alarm
52
+ spike: '⚠', // the one emoji-adjacent glyph the font does ship
53
+ upgrade: '↑',
54
+ harness: '⍟', // circled star: a score out of five
55
+ korean: '⌨', // an input/typing convention rather than a hand
56
+ model: '◈',
57
+ routing: '⇉', // sent down a different path
58
+ doc: '≣', // stacked lines: a page of text
59
+ hit: '◉', // bullseye: a cache hit
60
+ ttl: '◔', // a partly elapsed dial
61
+ month: '∑', // a running total
62
+ ctx: '◧', // a box filling up
63
+ saved: '✓',
64
+ reset: '↩', // the window turning over
65
+ };
66
+
67
+ const SETS = { icon: ICON, narrow: NARROW };
68
+
69
+ /**
70
+ * Glyphs for a label mode. Text mode never reads a glyph, but it resolves to the
71
+ * emoji set so a caller that asks anyway gets a complete object rather than
72
+ * undefined lookups.
73
+ */
74
+ export function glyphsFor(mode) {
75
+ return SETS[mode] || ICON;
76
+ }
77
+
78
+ export { ICON as ICON_GLYPHS, NARROW as NARROW_GLYPHS };
@@ -170,7 +170,7 @@ export function formatBudgetReport(state, now = new Date()) {
170
170
  const left = Math.max(0, max - used);
171
171
  const pct = Math.min(100, (used / max) * 100);
172
172
  const lines = [
173
- `🔑 budget ${gaugeBar(pct)} ${Math.round(pct)}% ${formatMoney(used)}/${formatMoney(max)}`,
173
+ `💳 budget ${gaugeBar(pct)} ${Math.round(pct)}% ${formatMoney(used)}/${formatMoney(max)}`,
174
174
  ` 사용 ${formatMoney(used)} · 잔여 ${formatMoney(left)} (${(100 - pct).toFixed(1)}%)`,
175
175
  ];
176
176
  const resetMs = Number(state?.budgetResetAt);
@@ -94,10 +94,14 @@ export function budgetCapPhrase(rule, lang = userLanguage()) {
94
94
  */
95
95
  export function composeRuleText(baseText, rule, lang = userLanguage()) {
96
96
  const cap = budgetCapPhrase(rule, lang);
97
- const clause = lang === 'ko'
98
- ? `위임 상한은 ${cap}이며, 넘길 것 같거나 에러가 나면 거기서 멈춰 진행분만 보고하고 메인 모델이 이어받는다. 세션 모델이 이미 위임 목표와 같은 급 이하면 위임하지 않는다`
99
- : `Cap the run at ${cap}; if it looks likely to exceed that or hits an error, it stops there and reports partial progress while the main model takes over. Skip delegation entirely when the session model is already at or below the target tier`;
100
- return `${baseText.replace(/\s*$/, '')}. ${clause}`;
97
+ // Only the cap. The stop condition and the same-tier skip used to repeat in
98
+ // every rule; they are identical across all of them, so they now live once in
99
+ // the file header's shared-clause section. A rule the model re-reads on every
100
+ // request should be one line it can match against, not four clauses it has to
101
+ // re-derive — three sentences per rule made delegating cost more thought than
102
+ // doing the work, and measured delegation ran at 3.6% of eligible episodes.
103
+ const clause = lang === 'ko' ? `상한 ${cap}` : `cap ${cap}`;
104
+ return `${baseText.replace(/\s*$/, '')} (${clause})`;
101
105
  }
102
106
 
103
107
  /**
@@ -109,16 +113,27 @@ export function composeRuleText(baseText, rule, lang = userLanguage()) {
109
113
  *
110
114
  * `g` carries { tier, label, labelEn, example, exampleEn, agent }.
111
115
  */
116
+ /**
117
+ * Trim an example to something a reader takes in at a glance. Scan-derived
118
+ * examples are raw user messages, and some ran long enough to bury the rule that
119
+ * followed them.
120
+ */
121
+ const EXAMPLE_MAX = 30;
122
+ function shortExample(text) {
123
+ const one = String(text || '').replace(/\s+/g, ' ').trim();
124
+ return one.length <= EXAMPLE_MAX ? one : `${one.slice(0, EXAMPLE_MAX - 1)}…`;
125
+ }
126
+
112
127
  export function modelRuleBaseText(g, lang = userLanguage()) {
113
128
  if (lang === 'ko') {
114
129
  return g.tier === 'T2'
115
- ? `"${g.label}" 유형의 단순 요청(예: "${g.example}")은 ${agentPhrase(g.agent)} 서브에이전트로 위임한다 (설계 판단·배포·스토어 제출 같은 비가역 작업이 섞이면 위임하지 않음)`
116
- : `"${g.label}" 유형의 중간 난도 요청(예: "${g.example}")은 model: sonnet 서브에이전트로 위임한다 (설계 판단·비가역 작업·반복 에러 발생 시 메인 모델이 이어받음)`;
130
+ ? `"${g.label}" 단순 요청(예: "${shortExample(g.example)}")은 ${agentPhrase(g.agent)}에 위임`
131
+ : `"${g.label}" 중간 난도 요청(예: "${shortExample(g.example)}")은 model: sonnet에 위임`;
117
132
  }
118
133
  const example = g.exampleEn || g.example;
119
134
  return g.tier === 'T2'
120
- ? `Delegate simple "${g.labelEn}" requests (e.g. "${example}") to ${agentPhraseEn(g.agent)} — never when the request mixes in design judgement or irreversible work like deploy/release/submission`
121
- : `Delegate moderate "${g.labelEn}" requests (e.g. "${example}") to a model: sonnet subagent — hand back to the main model on design judgement, irreversible work, or repeated errors`;
135
+ ? `Simple "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to ${agentPhraseEn(g.agent)}`
136
+ : `Moderate "${g.labelEn}" requests (e.g. "${shortExample(example)}") go to a model: sonnet subagent`;
122
137
  }
123
138
 
124
139
  // See the note in route-scan.js — paths.js is the only place that resolves
@@ -214,6 +229,15 @@ export function renderModelRatchet(rules, lang = userLanguage()) {
214
229
  '(어떤 도구가 토큰을 아끼고 있는지 가시화):',
215
230
  '`🔀 [sprag] 모델 피팅: "<유형>" → <agent> 위임`',
216
231
  '',
232
+ '## 공통 조항 (룰마다 반복하지 않으므로 여기서 한 번만 읽으십시오)',
233
+ '',
234
+ '- 상한을 넘길 것 같거나 에러가 나면 그 자리에서 멈추고 진행분만 보고합니다.',
235
+ ' 메인 모델이 이어받습니다.',
236
+ '- 세션 모델이 위임 목표와 같은 급이거나 그보다 낮으면 위임하지 않습니다.',
237
+ ' 절감이 발생하지 않기 때문입니다.',
238
+ '- 설계 판단이나 되돌릴 수 없는 작업(배포·릴리스·머지·제출)이 섞여 있으면',
239
+ ' 위임하지 않습니다.',
240
+ '',
217
241
  '## 티어 판별 기준 (모든 룰에 공통 적용)',
218
242
  '',
219
243
  '- **도구 오케스트레이션**: 한 도구의 결과를 다음 호출의 입력으로 잇는 다단계',
@@ -262,6 +286,15 @@ export function renderModelRatchet(rules, lang = userLanguage()) {
262
286
  'visible which tool is saving tokens:',
263
287
  '`🔀 [sprag] model fitting: "<category>" → delegated to <agent>`',
264
288
  '',
289
+ '## Shared clauses (stated once here, not repeated per rule)',
290
+ '',
291
+ '- A run likely to exceed its cap, or hitting an error, stops there and reports',
292
+ ' partial progress. The main model takes over.',
293
+ '- Skip delegation when the session model is already at or below the target',
294
+ ' tier: there is nothing to save.',
295
+ '- Skip it when the request mixes in design judgement or irreversible work',
296
+ ' (deploy, release, merge, submission).',
297
+ '',
265
298
  '## Tier criteria (apply to every rule below)',
266
299
  '',
267
300
  '- **Tool orchestration**: a multi-step flow that feeds one tool result into',
@@ -329,8 +362,8 @@ export function renderModelRatchet(rules, lang = userLanguage()) {
329
362
  // Merged T2+T1 rules carry two caps, so they state both once rather than
330
363
  // repeating the whole stop-condition per tier.
331
364
  const mergedBudget = (t2, t1) => ko
332
- ? `위임 상한은 haiku ${budgetCapPhrase(t2, 'ko')}, sonnet ${budgetCapPhrase(t1, 'ko')}이며, 넘길 것 같거나 에러가 나면 거기서 멈춰 진행분만 보고하고 메인 모델이 이어받는다. 세션 모델이 이미 위임 목표와 같은 급 이하면 위임하지 않는다`
333
- : `Cap haiku runs at ${budgetCapPhrase(t2, 'en')} and sonnet runs at ${budgetCapPhrase(t1, 'en')}; a run likely to exceed its cap, or hitting an error, stops there and reports partial progress while the main model takes over. Skip delegation entirely when the session model is already at or below the target tier`;
365
+ ? `상한 haiku ${budgetCapPhrase(t2, 'ko')} / sonnet ${budgetCapPhrase(t1, 'ko')}`
366
+ : `cap haiku ${budgetCapPhrase(t2, 'en')} / sonnet ${budgetCapPhrase(t1, 'en')}`;
334
367
 
335
368
  // A category can carry both a T2 (haiku) and a T1 (sonnet) rule. Tier is
336
369
  // only known after an episode finishes, so two separate bullets give the
@@ -349,15 +382,18 @@ export function renderModelRatchet(rules, lang = userLanguage()) {
349
382
  if (t2 && t1) {
350
383
  // Seeded rules carry a localized example; scan-produced ones only have the
351
384
  // user's own prompt, so exampleEn is a preference, not a requirement.
352
- const ex = (r) => (ko ? r.example : (r.exampleEn || r.example));
385
+ const ex = (r) => shortExample(ko ? r.example : (r.exampleEn || r.example));
386
+ // One line per category, stating only what differs between the two tiers.
387
+ // The exceptions this used to spell out (design judgement, irreversible
388
+ // work) are identical in every rule and now sit in the header's shared
389
+ // clauses, so a rule the model re-reads on every request is short enough to
390
+ // match at a glance.
353
391
  const rule = ko
354
- ? `"${t2.label}" 유형 요청은 기본적으로 ${agentPhrase(t2.agent)} 서브에이전트로 위임한다(예: "${ex(t2)}"). ` +
355
- `여러 단계·여러 파일 수정이 얽힌 중간 난도 요청(예: "${ex(t1)}")은 model: sonnet 서브에이전트로 위임한다. ` +
356
- `설계 판단이나 배포·릴리스·스토어 제출 같은 비가역 작업이 섞여 있을 때만 위임하지 않는다. ` +
392
+ ? `"${t2.label}": 기본 ${agentPhrase(t2.agent)}(예: "${ex(t2)}"), ` +
393
+ `여러 단계·여러 파일이 얽히면 model: sonnet(예: "${ex(t1)}"). ` +
357
394
  mergedBudget(t2, t1)
358
- : `Delegate "${t2.labelEn || t2.label}" requests to ${agentPhraseEn(t2.agent)} by default (e.g. "${ex(t2)}"). ` +
359
- `Escalate moderate ones that span multiple steps or file edits (e.g. "${ex(t1)}") to a model: sonnet subagent. ` +
360
- `Keep it on the main model only when the request mixes in design judgement or irreversible work (deploy, release, store submission). ` +
395
+ : `"${t2.labelEn || t2.label}": ${agentPhraseEn(t2.agent)} by default (e.g. "${ex(t2)}"), ` +
396
+ `model: sonnet when it spans multiple steps or file edits (e.g. "${ex(t1)}"). ` +
361
397
  mergedBudget(t2, t1);
362
398
  lines.push(`- ${rule}${healthOf(t2)}${healthOf(t1)} <!-- T2 ${statsOf(t2)} / T1 ${statsOf(t1)} -->`);
363
399
  for (const r of group) {
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Turn a registered delegation rule into a per-request instruction.
3
+ *
4
+ * Why this exists: rules were registered and then not used. Measured on the
5
+ * author's logs, 337 of 1296 episodes were delegation-eligible and 12 were
6
+ * delegated (3.6%); five of ten registered rules had never fired. Rule wording
7
+ * was not the whole story — the session also carries a general instruction not
8
+ * to spawn subagents unless asked, and ratchet-model.md tries to overrule it in
9
+ * prose. Between two instructions that disagree, the cautious one wins.
10
+ *
11
+ * So this stops arguing in prose. The hook classifies the request that just
12
+ * arrived and states the matching rule as a fact about THIS request, which
13
+ * leaves the model nothing to resolve: no rule list to scan, no self-matching
14
+ * step, no conflict to weigh.
15
+ *
16
+ * Deliberately silent when it cannot be sure:
17
+ * - the request text does not classify (about half of them, by measurement —
18
+ * difficulty mostly surfaces after the first tool call, and at prompt time
19
+ * there are no tool calls yet)
20
+ * - the text trips ESCALATE_RE (design, analysis, deploy, merge …), where the
21
+ * top tier is the right answer and a nudge downward would be wrong
22
+ * - no registered rule covers that category
23
+ * A hint that fires on the wrong request costs more than one that stays quiet.
24
+ */
25
+
26
+ import { categorize, ESCALATE_RE } from './route-scan.js';
27
+ import { loadModelRules, budgetCapPhrase } from './model-rules.js';
28
+ import { agentPhrase, agentPhraseEn } from './agents.js';
29
+ import { userLanguage } from './config.js';
30
+
31
+ /** Shortest text worth classifying; below this a prompt is an ack, not a task. */
32
+ const MIN_LEN = 8;
33
+
34
+ /**
35
+ * @param {string} text the prompt just submitted
36
+ * @param {object} [opts]
37
+ * @param {Array} [opts.rules] registered rules (defaults to the stored registry)
38
+ * @param {string} [opts.lang] 'ko' | 'en'
39
+ * @returns {string|null} the line to inject, or null to stay quiet
40
+ */
41
+ export function routeHint(text, { rules, lang = userLanguage() } = {}) {
42
+ const t = String(text || '').trim();
43
+ if (t.length < MIN_LEN) return null;
44
+ // Judgement and irreversible work stay on the top tier. This check comes
45
+ // before classification because such a request often looks like a light
46
+ // "run" or "check" in its wording.
47
+ if (ESCALATE_RE.test(t)) return null;
48
+
49
+ const cat = categorize(t, null);
50
+ if (!cat) return null;
51
+
52
+ const all = rules || loadModelRules().rules || [];
53
+ const matched = all.filter((r) => r && r.category === cat.id);
54
+ if (!matched.length) return null;
55
+
56
+ const t2 = matched.find((r) => r.tier === 'T2');
57
+ const t1 = matched.find((r) => r.tier === 'T1');
58
+ const ko = lang === 'ko';
59
+ const label = ko ? (cat.label || cat.id) : (cat.labelEn || cat.label || cat.id);
60
+ const phrase = ko ? agentPhrase : agentPhraseEn;
61
+
62
+ let target;
63
+ if (t2 && t1) {
64
+ target = ko
65
+ ? `기본 ${phrase(t2.agent)}, 여러 단계·여러 파일이 얽히면 model: sonnet`
66
+ : `${phrase(t2.agent)} by default, model: sonnet when it spans multiple steps or file edits`;
67
+ } else {
68
+ const only = t2 || t1;
69
+ target = ko ? phrase(only.agent) : phrase(only.agent);
70
+ }
71
+
72
+ const caps = [t2, t1].filter(Boolean).map((r) => budgetCapPhrase(r, lang));
73
+ const capText = caps.length === 2
74
+ ? (ko ? `상한 haiku ${caps[0]} / sonnet ${caps[1]}` : `cap haiku ${caps[0]} / sonnet ${caps[1]}`)
75
+ : (ko ? `상한 ${caps[0]}` : `cap ${caps[0]}`);
76
+
77
+ // The marker line is the one in ratchet-model.md, so the user sees the same
78
+ // string whichever path decided to delegate.
79
+ return ko
80
+ ? `[sprag] 이 요청은 사용자가 이미 승인한 위임 룰 "${label}" 에 해당합니다: ${target} 로 위임하십시오 (${capText}). `
81
+ + `되묻지 말고 위임하고, 위임할 때 \`🔀 [sprag] 모델 피팅: "${label}" → <agent> 위임\` 을 먼저 표시하십시오. `
82
+ + `상한을 넘길 것 같거나 에러가 나면 그 자리에서 멈추고 메인 모델이 이어받습니다.`
83
+ : `[sprag] This request matches a delegation rule the user already approved — "${label}": delegate to ${target} (${capText}). `
84
+ + `Do not ask again; print \`🔀 [sprag] model fit: "${label}" → <agent>\` first. `
85
+ + `If the run is likely to exceed its cap or hits an error, stop there and the main model takes over.`;
86
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Statusline label-mode resolution — the single place that decides icon vs text.
3
+ *
4
+ * The real statusline path and the `--demo` path both route through here. They
5
+ * used to carry separate copies of the decision and the copies drifted: only
6
+ * the real path applied the IntelliJ guard, so inside IntelliJ
7
+ * `--statusline --demo` rendered emoji while the actual statusline rendered
8
+ * plain text. That gap makes our own deliberate downgrade look like a terminal
9
+ * bug, and it cost a full diagnosis session to rule out.
10
+ */
11
+
12
+ const JETBRAINS_TERMINAL = 'JetBrains-JediTerm';
13
+
14
+ /**
15
+ * IntelliJ's built-in terminal sets this for every shell it opens, whether or
16
+ * not the Claude Code plugin is involved.
17
+ */
18
+ export function isJetBrainsTerminal(env = process.env) {
19
+ return env.TERMINAL_EMULATOR === JETBRAINS_TERMINAL;
20
+ }
21
+
22
+ /**
23
+ * Whether the user has explicitly asked for icons inside IntelliJ regardless.
24
+ *
25
+ * Scope: this lifts the IntelliJ guard and nothing else. It is not a general
26
+ * "always icons" switch, and outside IntelliJ it is never consulted — there is
27
+ * no guard there to lift, and `icon` / `text` already own that choice. Letting
28
+ * it win everywhere would mean `sprag mode text` could no longer turn icons off
29
+ * while it was set, which is a worse trade than the narrow name.
30
+ *
31
+ * The guard below exists for the IntelliJ Claude Code *plugin*, whose
32
+ * statusline widget fuses consecutive frames at the character level when emoji
33
+ * are present ("59:548"). JediTerm itself renders our output cleanly, so a user
34
+ * who runs `claude` straight in the built-in terminal loses icons for a bug
35
+ * that cannot reach them. TERMINAL_EMULATOR alone cannot tell the two apart,
36
+ * so the escape hatch is explicit rather than inferred:
37
+ *
38
+ * SPRAG_ICON=force one-off, e.g. while checking a render
39
+ * sprag mode icon-force persisted, so wrappers that hardcode --icon
40
+ * do not have to be edited
41
+ */
42
+ export function iconForceRequested({ cfg = {}, env = process.env } = {}) {
43
+ const raw = String(env.SPRAG_ICON ?? '').trim().toLowerCase();
44
+ if (raw === 'force') return true;
45
+ return cfg.iconForce === true;
46
+ }
47
+
48
+ /**
49
+ * The user's stored label preference, as one of the three modes.
50
+ *
51
+ * `labels` is the current key. Configs written before it existed carry a boolean
52
+ * `icon` instead, so that is read as a fallback: absent means icons, since icons
53
+ * are the default for a fresh install.
54
+ */
55
+ export function labelPreference(cfg = {}) {
56
+ if (cfg.labels === 'icon' || cfg.labels === 'narrow' || cfg.labels === 'text') return cfg.labels;
57
+ return cfg.icon === false ? 'text' : 'icon';
58
+ }
59
+
60
+ /**
61
+ * Resolve the label mode.
62
+ *
63
+ * @param {object} opts
64
+ * @param {(name: string) => boolean} [opts.hasFlag] argv accessor
65
+ * @param {object} [opts.cfg] statuslineDefaults() result
66
+ * @param {object} [opts.env] environment to read
67
+ * @returns {{ mode: 'icon'|'narrow'|'text', reason: string }} `reason` names the rule
68
+ * that decided, so `sprag mode` can explain a downgrade instead of leaving
69
+ * the user to guess why `--icon` did nothing. One of `flag:--no-icon`,
70
+ * `flag:--text`, `flag:--narrow`, `intellij-narrow`, `intellij-forced`,
71
+ * `flag:--icon`, `config`.
72
+ *
73
+ * Precedence: explicit opt-out, then the IntelliJ guard (and the escape hatch
74
+ * out of it, which applies only there), then `--icon`, then persisted config.
75
+ */
76
+ export function resolveLabelMode({ hasFlag = () => false, cfg = {}, env = process.env } = {}) {
77
+ // An explicit opt-out wins everywhere. Nothing below should talk a user out
78
+ // of the mode they just asked for. The two flags are reported separately so
79
+ // the diagnostic line names the flag the user actually typed.
80
+ if (hasFlag('--no-icon')) return { mode: 'text', reason: 'flag:--no-icon' };
81
+ if (hasFlag('--text')) return { mode: 'text', reason: 'flag:--text' };
82
+ if (hasFlag('--narrow')) return { mode: 'narrow', reason: 'flag:--narrow' };
83
+ const pref = labelPreference(cfg);
84
+ if (isJetBrainsTerminal(env)) {
85
+ // Emoji on request, accepting that they garble here.
86
+ if (iconForceRequested({ cfg, env })) return { mode: 'icon', reason: 'intellij-forced' };
87
+ // Someone who asked for plain text gets plain text.
88
+ if (pref === 'text') return { mode: 'text', reason: 'config' };
89
+ // Otherwise narrow: the glyphs still mark each chip, and every one of them
90
+ // is a character the IDE font actually has, so the line does not garble.
91
+ return { mode: 'narrow', reason: 'intellij-narrow' };
92
+ }
93
+ if (hasFlag('--icon')) return { mode: 'icon', reason: 'flag:--icon' };
94
+ return { mode: pref, reason: 'config' };
95
+ }
@@ -1,5 +1,9 @@
1
1
  /**
2
- * Friendly labels + icons for `rate_limits.*` keys from Claude Code's stdin
2
+ * Friendly labels + icons for `rate_limits.*` keys from Claude Code's stdin.
3
+ *
4
+ * Each window carries two glyphs. `icon` is the emoji; `narrowIcon` is the
5
+ * one-cell glyph used where emoji garble the line, chosen by JetBrains Mono
6
+ * coverage for the reason src/glyphs.js explains.
3
7
  * payload. Known keys (`five_hour`, `seven_day`) get curated short/long labels
4
8
  * and dedicated icons. Unknown keys are passed through with a derived label so
5
9
  * any future window Anthropic adds (e.g. `seven_day_sonnet`) renders without
@@ -17,15 +21,21 @@ const KNOWN = {
17
21
  // 'current' mirrors how `weekly` reads next to 7D — names the window in
18
22
  // plain English so a glance at `✦ current ████▒░ 72%` tells the eye what's
19
23
  // being measured without parsing the icon's meaning.
20
- five_hour: { short: '5H', long: 'Current session', icon: '✦', usageLabel: 'current' },
21
- seven_day: { short: '7D', long: 'Current week', icon: '📅', usageLabel: 'weekly' },
24
+ five_hour: { short: '5H', long: 'Current session', icon: '✦', narrowIcon: '✶', usageLabel: 'current' },
25
+ seven_day: { short: '7D', long: 'Current week', icon: '📅', narrowIcon: '⌸', usageLabel: 'weekly' },
22
26
  // Speculative — `/usage` shows a Sonnet-only weekly bucket, so if Anthropic
23
27
  // ever surfaces it on stdin we render with a sensible default already.
24
- seven_day_sonnet: { short: '7D-S', long: 'Current week (Sonnet)', icon: '🅂', usageLabel: 'weekly (Sonnet)' },
25
- seven_day_opus: { short: '7D-O', long: 'Current week (Opus)', icon: '🅾', usageLabel: 'weekly (Opus)' },
28
+ seven_day_sonnet: { short: '7D-S', long: 'Current week (Sonnet)', icon: '🅂', narrowIcon: '◇', usageLabel: 'weekly (Sonnet)' },
29
+ seven_day_opus: { short: '7D-O', long: 'Current week (Opus)', icon: '🅾', narrowIcon: '◆', usageLabel: 'weekly (Opus)' },
30
+ // 💳 는 지출 한도를 가리킨다. 이전에 쓰던 🔑 은 인증 수단을 연상시키므로,
31
+ // 이 칩이 새로 보는 사람에게 키 자신의 상태로 읽힐 여지가 있었다.
26
32
  // LiteLLM 게이트웨이 키 예산. rate_limits 가 없는 환경에서 cap 게이지를
27
33
  // 대신하는 합성 윈도우라서, 여기 라벨만 있으면 나머지 렌더는 공용 경로를 탄다.
28
- litellm_budget: { short: 'BUDGET', long: 'LiteLLM key budget', icon: '🔑', usageLabel: 'budget' },
34
+ litellm_budget: {
35
+ short: 'BUDGET', long: 'LiteLLM key budget', icon: '💳', narrowIcon: '◫', usageLabel: 'budget',
36
+ // Budgets reset on a billing cycle, so "in 3d 4h" beats a bare weekday.
37
+ resetStyle: 'countdown',
38
+ },
29
39
  };
30
40
 
31
41
  function deriveShort(key) {
@@ -60,5 +70,5 @@ function deriveLong(key) {
60
70
  export function labelForKey(key) {
61
71
  if (KNOWN[key]) return KNOWN[key];
62
72
  const short = deriveShort(key);
63
- return { short, long: deriveLong(key), icon: '⏱', usageLabel: short };
73
+ return { short, long: deriveLong(key), icon: '⏱', narrowIcon: '◕', usageLabel: short };
64
74
  }