claude-finops 0.12.0 → 0.13.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/finops/api.py CHANGED
@@ -575,6 +575,15 @@ def _notify_update():
575
575
  notify()
576
576
 
577
577
 
578
+ def _open_browser(url):
579
+ # A headless box or SSH session has no browser to open; the printed URL is enough there.
580
+ import webbrowser
581
+ try:
582
+ webbrowser.open(url)
583
+ except Exception:
584
+ pass
585
+
586
+
578
587
  def serve(port=8787, db=DB_PATH, background=None):
579
588
  global A
580
589
  ensure_dirs()
@@ -602,6 +611,9 @@ def serve(port=8787, db=DB_PATH, background=None):
602
611
  from .update import disabled
603
612
  if not disabled():
604
613
  threading.Thread(target=_notify_update, daemon=True).start()
614
+ # The socket is already listening, so the page's first request just waits for
615
+ # serve_forever. Off the main thread: some platforms block while the browser starts.
616
+ threading.Thread(target=_open_browser, args=(f"http://127.0.0.1:{port}",), daemon=True).start()
605
617
  with open(PIDFILE, "w") as fh:
606
618
  fh.write(str(os.getpid()))
607
619
  try:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-finops",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Local FinOps dashboard for Claude Code: what you used, what it cost, why it cost that much, and what to change. Reads your own transcripts, no API key, no data leaves the machine.",
5
5
  "bin": {
6
6
  "claude-finops": "bin/claude-finops.js"
package/web/app.js CHANGED
@@ -61,7 +61,7 @@ const BADGE = {actual: '<span class="badge">Actual</span>',
61
61
  estimated: '<span class="badge est">Estimated</span>',
62
62
  forecast: '<span class="badge fc">Forecast</span>',
63
63
  recommendation: '<span class="badge rec">Recommendation</span>'};
64
- const statusGlyph = s => ({healthy: '🟢', high: '🟡', approaching: '🟠', critical: '🔴'}[s] || '⚪');
64
+ const statusGlyph = s => dot({healthy: 'green', high: 'yellow', approaching: 'orange', critical: 'red'}[s] || 'grey');
65
65
  const statusChip = (s, txt) => `<span class="status ${s}"><span class="glyph">${statusGlyph(s)}</span>${esc(txt || s)}</span>`;
66
66
  const modelColor = m => {
67
67
  const list = (S.opts?.models || []).map(x => x.model);
@@ -82,12 +82,12 @@ const dur = s => s == null ? '—' : s < 60 ? `${Math.round(s)}s`
82
82
  const shortDay = d => (d || '').slice(5);
83
83
  const shortId = s => (s || '').slice(0, 8);
84
84
 
85
- // "(▲12% vs yesterday)"; nothing when yesterday had no spend to compare against.
85
+ // "(up 12% vs yesterday)", with a small up/down triangle icon; nothing when yesterday had no spend to compare against.
86
86
  const vsYesterday = o => {
87
87
  const y = o.cost_yesterday?.c, t = o.cost_today?.c;
88
88
  if (!y || t == null) return '';
89
89
  const d = (t - y) / y * 100;
90
- return ` (${d >= 0 ? '▲' : '▼'}${Math.abs(d).toFixed(0)}% vs yesterday)`;
90
+ return ` (${I(d >= 0 ? 'triUp' : 'triDown')}${Math.abs(d).toFixed(0)}% vs yesterday)`;
91
91
  };
92
92
  function kpi(label, value, detail, opts = {}) {
93
93
  const na = value == null;
@@ -98,7 +98,7 @@ function kpi(label, value, detail, opts = {}) {
98
98
  }
99
99
  function card(title, bodyHtml, opts = {}) {
100
100
  return `<section class="card"${opts.style ? ` style="${opts.style}"` : ''}>
101
- <header><h3>${esc(title)}</h3>${opts.badge || ''}
101
+ <header><h3>${opts.icon || ''}${esc(title)}</h3>${opts.badge || ''}
102
102
  ${opts.hint ? `<span class="hint">${esc(opts.hint)}</span>` : ''}
103
103
  <span class="spacer"></span>${opts.actions || ''}</header>
104
104
  <div class="body${opts.flush ? ' flush' : ''}">${bodyHtml}</div>
@@ -122,42 +122,42 @@ function wireTable(host, rows, onRow) {
122
122
  /* ============================ chrome ============================ */
123
123
  const NAV = [
124
124
  ['Command center', [
125
- ['hygiene', '◫', 'Context hygiene'],
126
- ['overview', '◧', 'Executive overview'],
127
- ['advisor', '✦', 'What should I do?'],
128
- ['agents', '◎', 'Agents'],
129
- ['live', '⏻', 'Running sessions'],
130
- ['scorecard', '◉', 'FinOps scorecard'],
125
+ ['hygiene', 'gauge', 'Context hygiene'],
126
+ ['overview', 'dashboard', 'Executive overview'],
127
+ ['advisor', 'bulb', 'What should I do?'],
128
+ ['agents', 'bot', 'Agents'],
129
+ ['live', 'activity', 'Running sessions'],
130
+ ['scorecard', 'award', 'FinOps scorecard'],
131
131
  ]],
132
132
  ['Usage', [
133
- ['usage', '▤', 'Usage timeline'],
134
- ['burn', '◑', 'Burn rate & limits', 'priced'],
135
- ['models', '◈', 'Model analysis'],
136
- ['context', '▭', 'Context & cache'],
133
+ ['usage', 'barChart', 'Usage timeline'],
134
+ ['burn', 'flame', 'Burn rate & limits', 'priced'],
135
+ ['models', 'cpu', 'Model analysis'],
136
+ ['context', 'layers', 'Context & cache'],
137
137
  ]],
138
138
  ['Drill-down', [
139
- ['projects', '▣', 'Projects'],
140
- ['sessions', '▦', 'Sessions'],
141
- ['prompts', '☰', 'Prompt explorer'],
142
- ['rankings', '↕', 'Cost rankings'],
143
- ['categories', '◐', 'Prompt intelligence'],
144
- ['developer', '⌘', 'Developer activity', 'priced'],
139
+ ['projects', 'folder', 'Projects'],
140
+ ['sessions', 'terminal', 'Sessions'],
141
+ ['prompts', 'message', 'Prompt explorer'],
142
+ ['rankings', 'sortDesc', 'Cost rankings'],
143
+ ['categories', 'tag', 'Prompt intelligence'],
144
+ ['developer', 'code', 'Developer activity', 'priced'],
145
145
  ]],
146
146
  ['Optimize', [
147
- ['diagnose', '✚', 'Why so many tokens?', 'priced'],
148
- ['attribution', '⧉', 'Who used the tokens', 'priced'],
149
- ['waste', '⚠', 'Waste detection'],
150
- ['freemodels', '◇', 'Free models', 'claude'],
151
- ['compare', '⚖', 'Compare models', 'priced'],
152
- ['toolkit', '✎', 'Skills & MCP', 'claude'],
153
- ['recommendations', '↯', 'Recommendations'],
154
- ['anomalies', '⚡', 'Anomalies'],
147
+ ['diagnose', 'scan', 'Why so many tokens?', 'priced'],
148
+ ['attribution', 'users', 'Who used the tokens', 'priced'],
149
+ ['waste', 'trash', 'Waste detection'],
150
+ ['freemodels', 'gift', 'Free models', 'claude'],
151
+ ['compare', 'scale', 'Compare models', 'priced'],
152
+ ['toolkit', 'wrench', 'Skills & MCP', 'claude'],
153
+ ['recommendations', 'listChecks', 'Recommendations'],
154
+ ['anomalies', 'zap', 'Anomalies'],
155
155
  ]],
156
156
  ['Plan', [
157
- ['forecast', '◭', 'Forecast', 'priced'],
158
- ['budgets', '⊞', 'Budgets', 'priced'],
159
- ['cloud', '☁', 'Billed vs local', 'cloud'],
160
- ['exports', '⇩', 'Export & data'],
157
+ ['forecast', 'trendUp', 'Forecast', 'priced'],
158
+ ['budgets', 'wallet', 'Budgets', 'priced'],
159
+ ['cloud', 'cloud', 'Billed vs local', 'cloud'],
160
+ ['exports', 'download', 'Export & data'],
161
161
  ]],
162
162
  ];
163
163
 
@@ -175,6 +175,77 @@ const ICON = {
175
175
  sync: toolIcon('<ellipse cx="11" cy="5" rx="7" ry="3"/><path d="M4 5v6c0 1.66 3.13 3 7 3"/><path d="M4 11v6c0 1.66 3.13 3 7 3"/><path d="M18 5v5"/><path d="M19 13v8"/><path d="m16 18 3 3 3-3"/>'),
176
176
  };
177
177
 
178
+ // Every other icon in the app: flat 24px line icons in the same style, drawn in
179
+ // currentColor so they follow the theme and whatever tone they sit in.
180
+ const IP = {
181
+ search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
182
+ chevDown: '<path d="m6 9 6 6 6-6"/>',
183
+ chevRight: '<path d="m9 6 6 6-6 6"/>',
184
+ x: '<path d="M18 6 6 18M6 6l12 12"/>',
185
+ check: '<path d="M20 6 9 17l-5-5"/>',
186
+ checkCircle: '<circle cx="12" cy="12" r="10"/><path d="m8 12 3 3 5-6"/>',
187
+ xCircle: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6M9 9l6 6"/>',
188
+ circle: '<circle cx="12" cy="12" r="9"/>',
189
+ alert: '<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4M12 17h.01"/>',
190
+ siren: '<path d="M7.9 2h8.2L22 7.9v8.2L16.1 22H7.9L2 16.1V7.9z"/><path d="M12 8v4M12 16h.01"/>',
191
+ target: '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>',
192
+ sparkles: '<path d="m12 3 1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 3v4M17 5h4M5 17v4M3 19h4"/>',
193
+ compact: '<path d="m15 15 6 6M15 21v-6h6M9 9 3 3M9 3v6H3M15 9l6-6M15 3v6h6M9 15l-6 6M9 21v-6H3"/>',
194
+ copy: '<rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/>',
195
+ handover: '<path d="m15 17 5-5-5-5"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/>',
196
+ plus: '<path d="M12 5v14M5 12h14"/>',
197
+ pause: '<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>',
198
+ stop: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
199
+ play: '<path d="M7 4v16l13-8z"/>',
200
+ cloudDown: '<path d="M20 16.6A5 5 0 0 0 18 7h-1.3A8 8 0 1 0 4 15.2"/><path d="M12 12v9M8 17l4 4 4-4"/>',
201
+ arrowUp: '<path d="M12 19V5M5 12l7-7 7 7"/>',
202
+ triUp: '<path d="M12 6l7 11H5z"/>',
203
+ triDown: '<path d="M12 18 5 7h14z"/>',
204
+ external: '<path d="M15 3h6v6M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
205
+ star: '<path d="m12 2.5 2.9 6 6.6.9-4.8 4.6 1.2 6.5L12 17.4l-5.9 3.1 1.2-6.5-4.8-4.6 6.6-.9z"/>',
206
+ starHalf: '<path d="M12 2.5v14.9l-5.9 3.1 1.2-6.5-4.8-4.6 6.6-.9z"/>',
207
+ lock: '<rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>',
208
+ link: '<path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/>',
209
+ note: '<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6M8 13h8M8 17h5"/>',
210
+ cog: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.9 4.9 7 7M17 17l2.1 2.1M2 12h3M19 12h3M4.9 19.1 7 17M17 7l2.1-2.1"/>',
211
+ panel: '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 15h18"/>',
212
+ zap: '<path d="M13 2 3 14h9l-1 8 10-12h-9z"/>',
213
+ // sidebar
214
+ gauge: '<path d="m12 14 4-4"/><path d="M3.3 19a10 10 0 1 1 17.4 0"/>',
215
+ dashboard: '<rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/>',
216
+ bulb: '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6M10 22h4"/>',
217
+ bot: '<rect x="4" y="8" width="16" height="12" rx="2"/><path d="M12 8V4H8M2 14h2M20 14h2M9 13v2M15 13v2"/>',
218
+ activity: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
219
+ award: '<circle cx="12" cy="8" r="6"/><path d="M15.5 12.9 17 22l-5-3-5 3 1.5-9.1"/>',
220
+ barChart: '<path d="M3 3v18h18"/><path d="M8 17v-5M13 17V7M18 17v-8"/>',
221
+ flame: '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.4-.5-2-1-3-1.1-2.1-.2-4 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.2.4-2.3 1-3.3.2 1.7 1.2 2.8 2.5 2.8z"/>',
222
+ cpu: '<rect x="6" y="6" width="12" height="12" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 2v4M15 2v4M9 18v4M15 18v4M2 9h4M2 15h4M18 9h4M18 15h4"/>',
223
+ layers: '<path d="m12 2 10 5-10 5L2 7z"/><path d="m2 12 10 5 10-5M2 17l10 5 10-5"/>',
224
+ folder: '<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.7-.9l-.8-1.2A2 2 0 0 0 7.9 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2z"/>',
225
+ terminal: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m6 9 3 3-3 3M12 15h6"/>',
226
+ message: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
227
+ sortDesc: '<path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4"/>',
228
+ tag: '<path d="M12.6 2.6A2 2 0 0 0 11.2 2H4a2 2 0 0 0-2 2v7.2a2 2 0 0 0 .6 1.4l8.7 8.7a2.4 2.4 0 0 0 3.4 0l6.6-6.6a2.4 2.4 0 0 0 0-3.4z"/><circle cx="7.5" cy="7.5" r="1.5"/>',
229
+ code: '<path d="m16 18 6-6-6-6M8 6l-6 6 6 6"/>',
230
+ scan: '<path d="M3 7V5a2 2 0 0 1 2-2h2M17 3h2a2 2 0 0 1 2 2v2M21 17v2a2 2 0 0 1-2 2h-2M7 21H5a2 2 0 0 1-2-2v-2"/><circle cx="11.5" cy="11.5" r="3.5"/><path d="m17 17-3-3"/>',
231
+ users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8"/>',
232
+ trash: '<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
233
+ gift: '<rect x="3" y="8" width="18" height="4" rx="1"/><path d="M12 8v13M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7"/><path d="M7.5 8a2.5 2.5 0 0 1 0-5C10 3 12 8 12 8s2-5 4.5-5a2.5 2.5 0 0 1 0 5"/>',
234
+ scale: '<path d="M12 3v18M7 21h10M3 7h18"/><path d="m6 7-3 7a3 3 0 0 0 6 0zM18 7l-3 7a3 3 0 0 0 6 0z"/>',
235
+ wrench: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.8-3.8a6 6 0 0 1-7.9 7.9l-6.9 6.9a2.1 2.1 0 0 1-3-3l6.9-6.9a6 6 0 0 1 7.9-7.9z"/>',
236
+ listChecks: '<path d="m3 7 2 2 4-4M3 17l2 2 4-4M13 6h8M13 12h8M13 18h8"/>',
237
+ trendUp: '<path d="m22 7-8.5 8.5-5-5L2 17"/><path d="M16 7h6v6"/>',
238
+ wallet: '<path d="M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1"/><path d="M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4"/>',
239
+ cloud: '<path d="M17.5 19H9a7 7 0 1 1 6.7-9h1.8a4.5 4.5 0 1 1 0 9z"/>',
240
+ download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5M12 15V3"/>',
241
+ };
242
+ const FILLED = new Set(['play', 'triUp', 'triDown', 'star', 'starHalf']);
243
+ // I('name'): the icon as inline SVG. Toolbar icons are shared from ICON above.
244
+ const I = n => ICON[n] ? ICON[n].replace('<svg ', '<svg class="i" ')
245
+ : `<svg class="i${FILLED.has(n) ? ' fill' : ''}" viewBox="0 0 24 24" aria-hidden="true">${IP[n]}</svg>`;
246
+ // Status and severity: a flat filled dot in the matching tone.
247
+ const dot = tone => `<svg class="i dot ${tone}" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="6"/></svg>`;
248
+
178
249
  // The theme button shows the theme it switches to: a moon in light mode, a sun in dark.
179
250
  function themeLabel() {
180
251
  const b = $('#theme'); if (!b) return;
@@ -194,7 +265,7 @@ function shell() {
194
265
  <div class="sub">Command Center</div></div>
195
266
  <nav class="nav">${NAV.map(([g, items], gi) => `<div class="group g${gi}">${g}</div>` +
196
267
  items.map(([id, ic, label]) =>
197
- `<a data-view="${id}" class="g${gi}${id === 'diagnose' ? ' start' : ''}"><span class="ic">${ic}</span>${label}<span class="nb" data-nb="${id}"></span></a>`).join('')).join('')}
268
+ `<a data-view="${id}" class="g${gi}${id === 'diagnose' ? ' start' : ''}"><span class="ic">${I(ic)}</span>${label}<span class="nb" data-nb="${id}"></span></a>`).join('')).join('')}
198
269
  </nav>
199
270
  <div class="who" id="who"></div></aside>
200
271
  <div class="main">
@@ -204,7 +275,7 @@ function shell() {
204
275
  <div class="crumbs" id="crumbs"></div></div>
205
276
  <div class="scope" id="scope" aria-label="Totals for the current filters"></div>
206
277
  <span class="spacer"></span>
207
- <div class="search"><span class="mag">⌕</span>
278
+ <div class="search"><span class="mag">${I('search')}</span>
208
279
  <input id="gsearch" placeholder="Search prompts, sessions, models…"></div>
209
280
  <button class="iconbtn upd" id="upd" hidden></button>
210
281
  <div class="tools">
@@ -334,13 +405,13 @@ async function updateChip() {
334
405
  try { u = await fetch('/api/update').then(r => r.json()); } catch { return; }
335
406
  if (!u || !u.update_available) return;
336
407
  el.hidden = false;
337
- el.textContent = `↑ v${u.latest} available`;
408
+ el.innerHTML = `${I('arrowUp')} v${u.latest} available`;
338
409
  el.title = `You are on ${u.current}. Click to copy: ${u.command}`;
339
410
  el.onclick = async () => {
340
411
  try {
341
412
  await navigator.clipboard.writeText(u.command);
342
- el.textContent = '✓ command copied';
343
- setTimeout(() => { el.textContent = `↑ v${u.latest} available`; }, 2200);
413
+ el.innerHTML = `${I('check')} command copied`;
414
+ setTimeout(() => { el.innerHTML = `${I('arrowUp')} v${u.latest} available`; }, 2200);
344
415
  } catch { prompt('Run this to upgrade:', u.command); }
345
416
  };
346
417
  }
@@ -375,18 +446,18 @@ function filterBar() {
375
446
  ${RANGES.map(([k, l]) => `<button class="${S.range === k ? 'on' : ''}"
376
447
  data-range="${k}">${l}</button>`).join('')}</div>
377
448
  <div class="chipsel"><button class="chip ${f.models.length ? 'on' : ''}" data-pop="models">
378
- Model ${nSel(f.models)} ▾</button></div>
449
+ Model ${nSel(f.models)} ${I('chevDown')}</button></div>
379
450
  <div class="chipsel"><button class="chip ${f.projects.length ? 'on' : ''}" data-pop="projects">
380
- Project ${nSel(f.projects)} ▾</button></div>
451
+ Project ${nSel(f.projects)} ${I('chevDown')}</button></div>
381
452
  <div class="chipsel"><button class="chip ${f.categories.length ? 'on' : ''}" data-pop="categories">
382
- Category ${nSel(f.categories)} ▾</button></div>
453
+ Category ${nSel(f.categories)} ${I('chevDown')}</button></div>
383
454
  <div class="chipsel"><button class="chip ${(f.min_cost || f.min_tokens) ? 'on' : ''}"
384
- data-pop="thresholds">Thresholds ▾</button></div>
455
+ data-pop="thresholds">Thresholds ${I('chevDown')}</button></div>
385
456
  <button class="chip toggle ${f.include_sandbox ? '' : 'on'}" id="sbx" role="switch" aria-checked="${!f.include_sandbox}"
386
457
  title="Sandbox agents are throwaway projects. ${f.include_sandbox ? 'They are included; click to leave them out' : 'They are left out; click to include them'}">
387
458
  <i></i>Hide sandbox</button>
388
459
  ${(f.models.length || f.projects.length || f.categories.length || f.min_cost || f.min_tokens)
389
- ? '<button class="chip clr" id="clr">Clear filters ✕</button>' : ''}
460
+ ? `<button class="chip clr" id="clr">Clear filters ${I('x')}</button>` : ''}
390
461
  <span class="spacer"></span>
391
462
  <span class="coverage" title="Data in the warehouse · last rebuilt ${esc((o.meta.built_at||'').slice(0,16).replace('T',' '))}">
392
463
  ${esc(o.meta.transcript_files)} transcripts · since ${esc(o.date_range.first)}</span>`;
@@ -480,7 +551,7 @@ function drawer(title, bodyHtml, sub) {
480
551
  const d = h(`<aside class="drawer"><header>
481
552
  <h2>${esc(title)}</h2>${sub ? `<span class="crumbs">${sub}</span>` : ''}
482
553
  <span class="spacer"></span>
483
- <button class="iconbtn" data-x>Close ✕</button></header>
554
+ <button class="iconbtn" data-x>Close ${I('x')}</button></header>
484
555
  <div class="content">${bodyHtml}</div></aside>`);
485
556
  scrim.onclick = closeDrawer;
486
557
  d.querySelector('[data-x]').onclick = closeDrawer;
@@ -824,19 +895,19 @@ VIEWS.overview = async (page) => {
824
895
  };
825
896
 
826
897
  /* ---------- act now: one-click actions, on the page everyone opens first ---------- */
827
- const ACT_ICON = {session: '🗜', skill: '⚙', memory: '📝', statusline: '▭'};
898
+ const ACT_ICON = {session: 'compact', skill: 'cog', memory: 'note', statusline: 'panel'};
828
899
  const codeTicks = t => esc(t).replace(/`([^`]+)`/g, '<code>$1</code>');
829
900
  function actNowStrip(items) {
830
901
  const btn = it => it.kind === 'session'
831
- ? '<button class="act" data-do="compact" title="Types /compact into that session\'s terminal">🗜 Compact</button>'
832
- + '<button class="act ghost" data-do="handover">⇢ Hand over</button>'
833
- : it.kind === 'skill' ? '<button class="act" data-do="skill">+ Create skill</button>'
902
+ ? '<button class="act" data-do="compact" title="Types /compact into that session\'s terminal">' + I('compact') + ' Compact</button>'
903
+ + '<button class="act ghost" data-do="handover">' + I('handover') + ' Hand over</button>'
904
+ : it.kind === 'skill' ? '<button class="act" data-do="skill">' + I('plus') + ' Create skill</button>'
834
905
  : it.kind === 'memory' ? '<button class="act ghost" data-do="memory">Show examples</button>'
835
906
  : '<button class="act" data-do="statusline">Install statusline</button>';
836
- return `<section class="actnow"><div class="focus-hd">⚡ Act now
907
+ return `<section class="actnow"><div class="focus-hd">${I('zap')} Act now
837
908
  <span class="note">one click each · nothing changes until you click</span></div>
838
909
  ${items.map((it, i) => `<div class="actnow-it" data-i="${i}">
839
- <span class="ai">${ACT_ICON[it.kind] || '•'}</span>
910
+ <span class="ai">${ACT_ICON[it.kind] ? I(ACT_ICON[it.kind]) : '•'}</span>
840
911
  <div class="ft"><b>${codeTicks(MASKED && it.private ? it.title.split(it.private).join(pt(it.private)) : it.title)}</b><div class="note">${esc(it.detail)}</div></div>
841
912
  <div class="live-actions">${btn(it)}<span class="live-msg"></span></div></div>`).join('')}</section>`;
842
913
  }
@@ -892,16 +963,16 @@ async function liveSessions() {
892
963
  const rowActs = (r, live) => {
893
964
  const pid = live && live.get(r.session_id);
894
965
  return `<span class="row-acts">${r.resume ? `<button class="act ghost" data-resume="${esc(r.resume)}"
895
- title="Copy: ${esc(r.resume)}">⧉ Resume</button>` : ''}${pid ? ` <button class="act" data-compact="${pid}"
896
- title="Running now: types /compact into its terminal">🗜 Compact</button>` : ''}</span>`;
966
+ title="Copy: ${esc(r.resume)}">${I('copy')} Resume</button>` : ''}${pid ? ` <button class="act" data-compact="${pid}"
967
+ title="Running now: types /compact into its terminal">${I('compact')} Compact</button>` : ''}</span>`;
897
968
  };
898
969
  function wireRowActs(host) {
899
970
  if (!host) return;
900
971
  host.querySelectorAll('[data-resume]').forEach(b => b.onclick = async e => {
901
972
  e.stopPropagation();
902
- try { await navigator.clipboard.writeText(b.dataset.resume); b.textContent = '✓ Copied'; }
973
+ try { await navigator.clipboard.writeText(b.dataset.resume); b.innerHTML = `${I('check')} Copied`; }
903
974
  catch { b.textContent = b.dataset.resume; }
904
- setTimeout(() => { b.textContent = '⧉ Resume'; }, 1800);
975
+ setTimeout(() => { b.innerHTML = `${I('copy')} Resume`; }, 1800);
905
976
  });
906
977
  host.querySelectorAll('[data-compact]').forEach(b => b.onclick = async e => {
907
978
  e.stopPropagation();
@@ -910,15 +981,15 @@ function wireRowActs(host) {
910
981
  try {
911
982
  const r = await sessionAction(+b.dataset.compact, 'compact', {agent: 'claude'});
912
983
  if (!r.ok && r.copy) { try { await navigator.clipboard.writeText(r.copy); } catch {} }
913
- b.textContent = r.ok ? '✓ Sent' : 'Failed'; b.title = r.ok ? r.message : r.error;
984
+ b.innerHTML = r.ok ? `${I('check')} Sent` : 'Failed'; b.title = r.ok ? r.message : r.error;
914
985
  } catch (err) { b.textContent = 'Failed'; b.title = err.message; }
915
- setTimeout(() => { b.disabled = false; b.textContent = '🗜 Compact'; }, 2500);
986
+ setTimeout(() => { b.disabled = false; b.innerHTML = `${I('compact')} Compact`; }, 2500);
916
987
  });
917
988
  }
918
989
 
919
990
  function renderAdvisorHero(page, advisor) {
920
991
  if (!page || !advisor || !Array.isArray(advisor.actions)) return;
921
- const hero = h(`<div class="hero"><h3>✦ AI FinOps Advisor — ${esc(advisor.question)}
992
+ const hero = h(`<div class="hero"><h3>${I('sparkles')} AI FinOps Advisor — ${esc(advisor.question)}
922
993
  <span class="spacer"></span>
923
994
  <span class="note" style="font-weight:400">${esc(advisor.generated_from)}</span></h3>
924
995
  <div class="advisor-list">${advisor.actions.length ? advisor.actions.map((a, i) =>
@@ -975,7 +1046,7 @@ VIEWS.advisor = async (page) => {
975
1046
  footer: 'These are observations from your own usage. No dollar saving is attached unless the method can support one — a guessed percentage of spend is not a saving.'})}
976
1047
  ${card('Anomalies to inspect', `<div class="stack" id="anolist">${anos.anomalies.map((a, i) =>
977
1048
  `<div class="item sev-${a.severity} clickable" data-i="${i}"><div class="hd">
978
- ${a.severity === 'high' ? '🚨' : '⚠️'} ${esc(a.title)}</div>
1049
+ ${I(a.severity === 'high' ? 'siren' : 'alert')} ${esc(a.title)}</div>
979
1050
  <div class="dt">${esc(a.detail)}</div>
980
1051
  <div class="mt"><span>ratio ${a.ratio}×</span><span>click to inspect</span></div></div>`).join('')
981
1052
  || '<div class="empty">No anomalies detected</div>'}</div>`, {badge: BADGE.estimated})}`;
@@ -1183,7 +1254,7 @@ VIEWS.models = async (page) => {
1183
1254
  const find = k => rows.find(r => r.model === sup[k]);
1184
1255
  page.innerHTML = `
1185
1256
  ${lc.unpriced_requests ? `<div class="hero">
1186
- <h3>⚠️ Some long-context requests are priced low</h3>
1257
+ <h3>${I('alert')} Some long-context requests are priced low</h3>
1187
1258
  <div class="stack">
1188
1259
  <div class="item sev-high">
1189
1260
  <div class="hd">${fmtInt(lc.unpriced_requests)} requests sent more context than their
@@ -1345,7 +1416,7 @@ VIEWS.sessions = async (page) => {
1345
1416
  if (sessMore) sessMore.onclick = () => { sp.offset += sp.limit; bust(); render(); };
1346
1417
  $('#st', page).innerHTML = table([
1347
1418
  {h: 'Session', trunc: 1, title: r => r.session_id,
1348
- f: r => `${r.tokens > avgTok * 3 ? '🔴 ' : ''}${esc(stitle(r))}
1419
+ f: r => `${r.tokens > avgTok * 3 ? dot('red') + ' ' : ''}${esc(stitle(r))}
1349
1420
  <div class="sub mono">${esc(shortId(r.session_id))}</div>`},
1350
1421
  {h: '', f: r => rowActs(r, live)},
1351
1422
  {h: 'Project', f: r => esc(r.project)},
@@ -1393,7 +1464,7 @@ VIEWS.prompts = async (page) => {
1393
1464
  page.innerHTML = `
1394
1465
  ${card('Prompt explorer', `
1395
1466
  <div class="filters" style="margin:0 0 8px">
1396
- <div class="search"><span class="mag">⌕</span>
1467
+ <div class="search"><span class="mag">${I('search')}</span>
1397
1468
  <input id="pq" value="${esc(q)}" placeholder="Search prompt text…"></div>
1398
1469
  <span class="divider"></span>
1399
1470
  ${[['cost', 'Most expensive'], ['tokens', 'Most tokens'], ['length', 'Longest'],
@@ -1686,7 +1757,7 @@ VIEWS.context = async (page) => {
1686
1757
  footer: 'Component costs are re-derived per model from config/pricing.json, since a request stores one blended cost.'});
1687
1758
  })()}
1688
1759
  ${ctx.large_context_cost_pct > 0 ? `<div class="hero">
1689
- <h3>⚠️ Context warnings</h3>
1760
+ <h3>${I('alert')} Context warnings</h3>
1690
1761
  <div class="stack">
1691
1762
  <div class="item sev-${ctx.large_context_cost_pct > 30 ? 'high' : 'medium'}">
1692
1763
  <div class="hd">${fmtPct(ctx.large_context_cost_pct)} of your estimated spend came from
@@ -1765,8 +1836,9 @@ VIEWS.waste = async (page) => {
1765
1836
  ${['high', 'medium', 'low'].map(sev => {
1766
1837
  const list = w.findings.filter(f => f.severity === sev);
1767
1838
  if (!list.length) return '';
1768
- const title = sev === 'high' ? '🔴 High waste'
1769
- : sev === 'medium' ? '🟡 Optimization opportunities' : '⚪ Low-priority observations';
1839
+ const title = sev === 'high' ? 'High waste'
1840
+ : sev === 'medium' ? 'Optimization opportunities' : 'Low-priority observations';
1841
+ const icon = dot(sev === 'high' ? 'red' : sev === 'medium' ? 'yellow' : 'grey');
1770
1842
  return card(title, `<div class="stack">${list.map((f, i) => `
1771
1843
  <div class="item sev-${sev}">
1772
1844
  <div class="hd">${esc(f.title)}<span class="spacer"></span>
@@ -1778,7 +1850,7 @@ VIEWS.waste = async (page) => {
1778
1850
  <details style="margin-top:5px"><summary style="cursor:pointer;font-size:11.5px;color:var(--s1)">
1779
1851
  Show ${f.evidence.length} flagged items</summary>
1780
1852
  <div class="ev" data-kind="${esc(f.kind)}" style="margin-top:6px"></div></details>
1781
- </div>`).join('')}</div>`, {badge: BADGE.estimated});
1853
+ </div>`).join('')}</div>`, {badge: BADGE.estimated, icon});
1782
1854
  }).join('')}
1783
1855
  <div class="note">${esc(w.note)}</div>`;
1784
1856
  // fill evidence tables
@@ -1936,7 +2008,7 @@ VIEWS.live = async (page) => {
1936
2008
  page.innerHTML = `
1937
2009
  ${card('Plan usage right now', `<div id="live-usage">${usageBody(usage0)}</div>`,
1938
2010
  {badge: BADGE.actual, hint: 'Claude Code\'s own /usage: plan limits, not cost. Costs no tokens.',
1939
- actions: '<button class="btn pb-copy" id="usage-refresh">↻ Refresh usage</button>'})}
2011
+ actions: `<button class="btn pb-copy" id="usage-refresh">${I('reload')} Refresh usage</button>`})}
1940
2012
  <div class="grid g4">
1941
2013
  ${kpi('Running sessions', fmtInt(list.length), `${busy.length} working right now`, {badge: BADGE.actual})}
1942
2014
  ${kpi('Working (spending now)', fmtInt(busy.length), 'Only these consume tokens right now')}
@@ -1951,7 +2023,7 @@ VIEWS.live = async (page) => {
1951
2023
  <b>Force kill</b> is for a session that won't close. Each button asks you to click twice.
1952
2024
  Codex, Gemini and Cursor keep no session registry, so they count as running when their transcript was
1953
2025
  written in the last 20 minutes. Cursor sessions live inside the IDE and can't be stopped from here.
1954
- <span class="spacer"></span><button class="btn pb-copy" id="live-refresh">↻ Refresh</button></div>
2026
+ <span class="spacer"></span><button class="btn pb-copy" id="live-refresh">${I('reload')} Refresh</button></div>
1955
2027
  <div class="stack">${list.map((x, i) => `
1956
2028
  <div class="item sev-${sevOf(x)}" data-i="${i}">
1957
2029
  <div class="hd">${x.status === 'busy' ? '<span class="live-dot"></span>' : '<span class="idle-dot"></span>'}
@@ -1967,12 +2039,12 @@ VIEWS.live = async (page) => {
1967
2039
  ? 'Very large context. Use <b>Hand over</b> to continue in a fresh session, or split the remaining work into sub-sessions.'
1968
2040
  : 'Getting heavy. Hit <b>Compact</b> at the next break, or close it if the task is done.'}</div>` : ''}
1969
2041
  <div class="live-actions">
1970
- ${x.signalable ? `${canInterrupt ? `<button class="act" data-a="interrupt" ${x.status !== 'busy' ? 'disabled title="Nothing running"' : ''}>⏸ Interrupt</button>` : ''}
1971
- ${x.agent === 'claude' ? '<button class="act" data-a="compact" title="Types /compact into that session\'s terminal">🗜 Compact</button>' : ''}
1972
- <button class="act warn" data-a="close">⏹ Close session</button>
1973
- <button class="act danger" data-a="kill">✖ Force kill</button>` : `<span class="note">${x.agent === 'cursor' ? 'Runs inside the Cursor IDE: stop it there.' : 'No matching process found: stop it in its terminal.'}</span>`}
2042
+ ${x.signalable ? `${canInterrupt ? `<button class="act" data-a="interrupt" ${x.status !== 'busy' ? 'disabled title="Nothing running"' : ''}>${I('pause')} Interrupt</button>` : ''}
2043
+ ${x.agent === 'claude' ? '<button class="act" data-a="compact" title="Types /compact into that session\'s terminal">' + I('compact') + ' Compact</button>' : ''}
2044
+ <button class="act warn" data-a="close">${I('stop')} Close session</button>
2045
+ <button class="act danger" data-a="kill">${I('x')} Force kill</button>` : `<span class="note">${x.agent === 'cursor' ? 'Runs inside the Cursor IDE: stop it there.' : 'No matching process found: stop it in its terminal.'}</span>`}
1974
2046
  ${x.resume ? `<button class="act ghost" data-copy="${esc(x.resume)}">Copy resume command</button>` : ''}
1975
- ${x.agent === 'claude' ? '<button class="act ghost" data-ho="1">⇢ Hand over / split</button>' : ''}
2047
+ ${x.agent === 'claude' ? '<button class="act ghost" data-ho="1">' + I('handover') + ' Hand over / split</button>' : ''}
1976
2048
  <span class="live-msg"></span>
1977
2049
  </div>
1978
2050
  ${x.agent !== 'claude' ? '' : `<div class="handover" hidden>
@@ -1996,7 +2068,7 @@ VIEWS.live = async (page) => {
1996
2068
  const u = await fetch('/api/usage?refresh=1').then(r => r.json());
1997
2069
  $('#live-usage', page).innerHTML = usageBody(u);
1998
2070
  } catch (e) { $('#live-usage', page).innerHTML = `<div class="empty">${esc(e.message)}</div>`; }
1999
- ub.disabled = false; ub.textContent = '↻ Refresh usage';
2071
+ ub.disabled = false; ub.innerHTML = `${I('reload')} Refresh usage`;
2000
2072
  };
2001
2073
  page.querySelectorAll('.item[data-i]').forEach(row => {
2002
2074
  const x = list[+row.dataset.i], pid = x.pid, msg = row.querySelector('.live-msg');
@@ -2010,7 +2082,7 @@ VIEWS.live = async (page) => {
2010
2082
  const r = await sessionAction(pid, 'handover', {tasks: ta.value.split('\n'), launch,
2011
2083
  close: launch && ho.querySelector('.ho-close').checked});
2012
2084
  hoOut.innerHTML = r.ok ? `<div class="live-msg ok">${esc(r.message)}</div>` + r.sessions.map(s =>
2013
- `<div>${s.launched ? '▶' : '•'} ${esc(s.task || 'Continue the same work')} — <code>${esc(s.brief)}</code>${s.error ? ` <span class="live-msg err">${esc(s.error)}</span>` : ''}</div>`).join('')
2085
+ `<div>${s.launched ? I('play') : '•'} ${esc(s.task || 'Continue the same work')} — <code>${esc(s.brief)}</code>${s.error ? ` <span class="live-msg err">${esc(s.error)}</span>` : ''}</div>`).join('')
2014
2086
  : `<div class="live-msg err">Failed: ${esc(r.error)}</div>`;
2015
2087
  if (r.ok && r.closed && r.closed.exited) { row.classList.add('gone'); setTimeout(() => render(), 2500); }
2016
2088
  } catch (e) { hoOut.innerHTML = `<div class="live-msg err">Failed: ${esc(e.message)}</div>`; }
@@ -2064,12 +2136,12 @@ const pb = x => {
2064
2136
  <ol>${x.steps.map(t => `<li>${esc(t)}</li>`).join('')}</ol>${promptHtml}</details>`;
2065
2137
  };
2066
2138
  const wirePB = page => page.querySelectorAll('.pb-copy').forEach(b => b.onclick = async () => {
2067
- try { await navigator.clipboard.writeText(PB[+b.dataset.pb]); b.textContent = 'Copied ✓'; }
2139
+ try { await navigator.clipboard.writeText(PB[+b.dataset.pb]); b.innerHTML = `Copied ${I('check')}`; }
2068
2140
  catch { b.textContent = 'Copy failed'; }
2069
2141
  setTimeout(() => b.textContent = 'Copy', 1500);
2070
2142
  });
2071
- const HARNESS = {needed: ['🔴 Needed', 'high'], partial: ['🟡 Partial', 'medium'],
2072
- in_place: ['✅ In place', 'low'], not_needed: ['⚪ Not needed', 'low'], unknown: ['— Unknown', 'low']};
2143
+ const HARNESS = {needed: [dot('red') + ' Needed', 'high'], partial: [dot('yellow') + ' Partial', 'medium'],
2144
+ in_place: [I('checkCircle') + ' In place', 'low'], not_needed: [dot('grey') + ' Not needed', 'low'], unknown: ['— Unknown', 'low']};
2073
2145
  const harnessChip = h => h ? `<b>${HARNESS[h.verdict][0]}</b>` : '—';
2074
2146
 
2075
2147
  VIEWS.diagnose = async (page) => {
@@ -2078,7 +2150,7 @@ VIEWS.diagnose = async (page) => {
2078
2150
  const gdir = {claude: '~/.claude', codex: '~/.codex', gemini: '~/.gemini'}[d.agent || 'claude'];
2079
2151
  PB.length = 0;
2080
2152
  const t = d.total || {}, tr = d.trend || {};
2081
- const sevChip = s => s === 'high' ? '🔴' : s === 'medium' ? '🟡' : '⚪';
2153
+ const sevChip = s => dot(s === 'high' ? 'red' : s === 'medium' ? 'yellow' : 'grey');
2082
2154
  page.innerHTML = `${focusStrip(d)}
2083
2155
  <div class="note" style="font-size:14px"><b>${esc(d.headline)}</b>
2084
2156
  ${tr.explanation ? `<br>${esc(tr.explanation)}` : ''}</div>
@@ -2091,7 +2163,7 @@ VIEWS.diagnose = async (page) => {
2091
2163
  </div>
2092
2164
  <div id="dx-live"></div>${card('Running now', d.live_sessions.length ? `<div class="stack">${d.live_sessions.map(x => `
2093
2165
  <div class="item sev-${x.severity === 'ok' ? 'low' : x.severity}">
2094
- <div class="hd">${x.severity === 'high' ? '🔴' : x.severity === 'medium' ? '🟡' : '🟢'}
2166
+ <div class="hd">${dot(x.severity === 'high' ? 'red' : x.severity === 'medium' ? 'yellow' : 'green')}
2095
2167
  ${esc(stitle(x))} <span class="note">· ${esc(x.project)}</span><span class="spacer"></span>
2096
2168
  <span style="font-variant-numeric:tabular-nums">${fmtNum(x.context)} context · ${fmtInt(x.steps)} steps</span></div>
2097
2169
  <div class="dt">${esc(x.advice)} <span class="note">Last write ${x.idle_min} min ago; context grew
@@ -2109,7 +2181,7 @@ VIEWS.diagnose = async (page) => {
2109
2181
  {badge: BADGE.recommendation, hint: '"Above baseline" = context re-read beyond 100K'})}
2110
2182
  <div id="dx-mem"></div>${card(`Add to ${md}${isCl ? ' / memory' : ''} (from your past prompts)`, `<div class="stack">${d.memory_suggestions.map(x => `
2111
2183
  <div class="item sev-${x.kind === 'security' ? 'high' : x.already_saved ? 'low' : 'medium'}">
2112
- <div class="hd">${x.kind === 'security' ? '🔐' : x.kind === 'template' ? '⚙' : x.kind === 'reference' ? '🔗' : '📝'}
2184
+ <div class="hd">${I(x.kind === 'security' ? 'lock' : x.kind === 'template' ? 'cog' : x.kind === 'reference' ? 'link' : 'note')}
2113
2185
  ${esc(pt(x.text.slice(0, 140)))}${x.already_saved ? ' <span class="na">already saved</span>' : ''}
2114
2186
  <span class="spacer"></span><span class="note">${fmtInt(x.sessions)} sessions</span></div>
2115
2187
  <div class="dt">${esc(x.why)}</div>
@@ -2152,7 +2224,7 @@ VIEWS.diagnose = async (page) => {
2152
2224
  ...(isCl ? [{h: 'Memory', num: 1, f: r => r.memory_tokens == null ? '—' : `~${fmtInt(r.memory_tokens)}`}] : []),
2153
2225
  {h: 'Explore %', num: 1, f: r => fmtPct(r.explore_pct)},
2154
2226
  ...(isCl ? [{h: 'Harness', f: r => harnessChip(r.harness)}] : []),
2155
- {h: 'Needs update', f: r => r.issues.length ? r.issues.map(i => sevChip(i.severity) + ' ' + esc(i.title)).join('<br>') : '✓'},
2227
+ {h: 'Needs update', f: r => r.issues.length ? r.issues.map(i => sevChip(i.severity) + ' ' + esc(i.title)).join('<br>') : I('check')},
2156
2228
  ];
2157
2229
  page.querySelectorAll('.sess-link').forEach(l => l.onclick = e => { e.preventDefault(); openSession(l.dataset.sess); });
2158
2230
  $('#dx-proj', page).innerHTML = table(cols, d.projects);
@@ -2191,7 +2263,7 @@ VIEWS.anomalies = async (page) => {
2191
2263
  '<div class="legend" id="anl"></div>', {badge: BADGE.estimated})}
2192
2264
  ${card('Detected anomalies', `<div class="stack" id="anolist">${a.anomalies.length
2193
2265
  ? a.anomalies.map((x, i) => `<div class="item sev-${x.severity} clickable" data-i="${i}">
2194
- <div class="hd">${x.severity === 'high' ? '🚨' : '⚠️'} ${esc(x.title)}
2266
+ <div class="hd">${I(x.severity === 'high' ? 'siren' : 'alert')} ${esc(x.title)}
2195
2267
  <span class="spacer"></span><span class="pill">${esc(x.type)}</span></div>
2196
2268
  <div class="dt">${esc(x.detail)}</div>
2197
2269
  <div class="mt"><span>observed ${fmtNum(x.metric_value)}</span>
@@ -2208,7 +2280,7 @@ VIEWS.anomalies = async (page) => {
2208
2280
  const bars = $('#an', page).querySelectorAll('rect');
2209
2281
  tl.forEach((r, i) => { if (dates.has(r.bucket) && bars[i]) bars[i].setAttribute('fill', 'var(--critical)'); });
2210
2282
  $('#anl', page).innerHTML = `<span class="it"><span class="swatch" style="background:${seriesVar(0)}"></span>Normal day</span>
2211
- <span class="it"><span class="swatch" style="background:var(--critical)"></span>🚨 Anomalous day</span>`;
2283
+ <span class="it"><span class="swatch" style="background:var(--critical)"></span>${I('siren')} Anomalous day</span>`;
2212
2284
  wireAnomalies(page, a.anomalies);
2213
2285
  };
2214
2286
 
@@ -2230,7 +2302,7 @@ VIEWS.forecast = async (page) => {
2230
2302
  ${f.limit_exhaustion_date === S.opts.unavailable_label
2231
2303
  ? kpi('Limit exhaustion date', null, 'Requires a configured allowance')
2232
2304
  : kpi('Limit exhaustion date', esc(f.limit_exhaustion_date),
2233
- f.will_exceed ? '🔴 forecast exceeds allowance' : '🟢 within allowance', {badge: BADGE.forecast})}
2305
+ f.will_exceed ? dot('red') + ' forecast exceeds allowance' : dot('green') + ' within allowance', {badge: BADGE.forecast})}
2234
2306
  </div>
2235
2307
  ${card('Cumulative spend and forecast fan', f.insufficient_history
2236
2308
  ? '<div class="empty">Fewer than 7 priced days in the window: bands not shown.</div>'
@@ -2276,7 +2348,7 @@ VIEWS.budgets = async (page) => {
2276
2348
  </div>
2277
2349
  <div class="meter ${l.status}"><i style="width:${Math.min(l.used_pct, 100)}%"></i></div>
2278
2350
  <div class="mt">
2279
- ${l.thresholds_breached.length ? `<span>⚠️ breached ${l.thresholds_breached.join('%, ')}%</span>` : '<span>No threshold breached</span>'}
2351
+ ${l.thresholds_breached.length ? `<span>${I('alert')} breached ${l.thresholds_breached.join('%, ')}%</span>` : '<span>No threshold breached</span>'}
2280
2352
  ${l.thresholds_forecast_breach.length
2281
2353
  ? `<span style="color:var(--serious-ink)">forecast to breach ${l.thresholds_forecast_breach.join('%, ')}%</span>` : ''}
2282
2354
  </div></div>`
@@ -2358,13 +2430,13 @@ VIEWS.scorecard = async (page) => {
2358
2430
  </div></div>`, {badge: BADGE.estimated,
2359
2431
  footer: 'Each dimension is measured from your transcripts; budget adherence needs a budget in Settings.'})}
2360
2432
  <div class="grid g3">
2361
- ${card('✅ What is good', `<ul style="margin:0 0 0 18px;font-size:12.5px">${
2433
+ ${card('What is good', `<ul style="margin:0 0 0 18px;font-size:12.5px">${
2362
2434
  sc.what_is_good.map(x => `<li style="margin-bottom:6px">${esc(x)}</li>`).join('')
2363
- || '<li class="na">Nothing scored above 60 in this range</li>'}</ul>`)}
2364
- ${card('⚠️ Needs attention', `<ul style="margin:0 0 0 18px;font-size:12.5px">${
2435
+ || '<li class="na">Nothing scored above 60 in this range</li>'}</ul>`, {icon: I('checkCircle')})}
2436
+ ${card('Needs attention', `<ul style="margin:0 0 0 18px;font-size:12.5px">${
2365
2437
  sc.needs_attention.map(x => `<li style="margin-bottom:6px">${esc(x)}</li>`).join('')
2366
- || '<li class="na">Nothing scored below 70</li>'}</ul>`)}
2367
- ${card('🎯 Biggest opportunity', sc.biggest_opportunity ? `
2438
+ || '<li class="na">Nothing scored below 70</li>'}</ul>`, {icon: I('alert')})}
2439
+ ${card('Biggest opportunity', sc.biggest_opportunity ? `
2368
2440
  <div class="item sev-high"><div class="hd">${esc(sc.biggest_opportunity.title)}</div>
2369
2441
  <div class="dt">${esc(sc.biggest_opportunity.detail)}</div>
2370
2442
  <div class="mt"><span>Estimated excess ${fmtUSD(sc.biggest_opportunity.estimated_excess_usd)}</span>
@@ -2584,14 +2656,14 @@ function focusStrip(d, n = 5) {
2584
2656
  const nHidden = all.length - items.length;
2585
2657
  if (!all.length) return '';
2586
2658
  const fixes = items.filter(i => i.kind === 'fix').length, habits = items.filter(i => i.kind === 'habit').length;
2587
- return `<section class="focus"><div class="focus-hd">🎯 What to change
2659
+ return `<section class="focus"><div class="focus-hd">${I('target')} What to change
2588
2660
  <span class="note">${fixes} to fix once · ${habits} habit${habits === 1 ? '' : 's'} from your past sessions</span>
2589
2661
  <span class="spacer"></span>${nHidden ? `<button class="act ghost" data-unhide="1">Show ${nHidden} hidden</button>` : ''}</div>
2590
2662
  ${items.slice(0, n).map((it, k) => `<div class="focus-it l${it.lvl === 1 ? 1 : 2}" data-go="${it.view}" data-anchor="${it.anchor}" data-key="${esc(it.key)}">
2591
2663
  <span class="fn">${k + 1}</span>
2592
2664
  <span class="ft"><span class="fx">${esc(it.text)}</span><span class="fw">${esc(it.why || '')}</span></span>
2593
2665
  <span class="fl">${it.kind === 'habit' ? 'Habit' : 'Fix once'}</span>
2594
- <button class="fd" title="Hide this until its evidence changes" aria-label="Hide">✕</button></div>`).join('')
2666
+ <button class="fd" title="Hide this until its evidence changes" aria-label="Hide">${I('x')}</button></div>`).join('')
2595
2667
  || '<div class="note">Everything here is hidden. Nice.</div>'}</section>`;
2596
2668
  }
2597
2669
  function wireFocus(root) {
@@ -2739,8 +2811,8 @@ function toggleRecent(force) {
2739
2811
  const scrim = h('<div class="recent-scrim"></div>');
2740
2812
  const p = h(`<aside class="recent" aria-label="Recent sessions"><header>
2741
2813
  <h2>Recent</h2><span class="note">all time · agent &amp; project filters apply</span>
2742
- <span class="spacer"></span><button class="iconbtn" data-x>Close ✕</button></header>
2743
- <div class="recent-search"><span class="mag">⌕</span>
2814
+ <span class="spacer"></span><button class="iconbtn" data-x>Close ${I('x')}</button></header>
2815
+ <div class="recent-search"><span class="mag">${I('search')}</span>
2744
2816
  <input id="recent-q" placeholder="Search your prompts…" value="${esc(RECENT.q)}"></div>
2745
2817
  <div class="content"><div class="loading">Loading…</div></div></aside>`);
2746
2818
  scrim.onclick = () => toggleRecent(false);
@@ -2779,7 +2851,7 @@ async function renderRecent() {
2779
2851
  }
2780
2852
 
2781
2853
  const recentLive = list => !list.length ? '' : `<div class="recent-hd">Running now
2782
- <span class="spacer"></span><a href="#" data-go-live>Close or kill ›</a></div>
2854
+ <span class="spacer"></span><a href="#" data-go-live>Close or kill ${I('chevRight')}</a></div>
2783
2855
  ${list.map((x, i) => `<div class="item recent-it sev-${x.severity === 'high' ? 'high' : x.severity === 'medium' ? 'medium' : 'low'}" data-live="${i}">
2784
2856
  <div class="hd"><span class="live-dot ${x.status === 'busy' ? 'busy' : ''}" title="${esc(x.status)}"></span>
2785
2857
  <span class="recent-title">${esc((!MASKED && x.name) || shortId(x.session_id))}</span>
@@ -2788,9 +2860,9 @@ const recentLive = list => !list.length ? '' : `<div class="recent-hd">Running n
2788
2860
  ${x.context ? `<span>${fmtNum(x.context)} context</span>` : ''}
2789
2861
  ${x.est_cost_usd != null ? `<span>${fmtUSD(x.est_cost_usd)}</span>` : ''}</div>
2790
2862
  <div class="live-actions">
2791
- ${x.resume ? `<button class="act ghost" data-resume="${esc(x.resume)}" title="Copy: ${esc(x.resume)}">⧉ Resume</button>` : ''}
2792
- ${x.signalable && x.agent === 'claude' ? '<button class="act" data-la="compact" title="Types /compact into that session\'s terminal">🗜 Compact</button>' : ''}
2793
- ${x.signalable ? `<button class="act warn" data-la="interrupt" ${x.status !== 'busy' ? 'disabled title="Nothing running"' : 'title="Stops the current turn, like pressing Esc"'}>⏸ Stop</button>` : ''}
2863
+ ${x.resume ? `<button class="act ghost" data-resume="${esc(x.resume)}" title="Copy: ${esc(x.resume)}">${I('copy')} Resume</button>` : ''}
2864
+ ${x.signalable && x.agent === 'claude' ? '<button class="act" data-la="compact" title="Types /compact into that session\'s terminal">' + I('compact') + ' Compact</button>' : ''}
2865
+ ${x.signalable ? `<button class="act warn" data-la="interrupt" ${x.status !== 'busy' ? 'disabled title="Nothing running"' : 'title="Stops the current turn, like pressing Esc"'}>${I('pause')} Stop</button>` : ''}
2794
2866
  ${x.session_id ? `<button class="act ghost" data-open-sess="${esc(x.session_id)}">Details</button>` : ''}
2795
2867
  <span class="live-msg"></span></div></div>`).join('')}
2796
2868
  <div class="recent-hd">Past sessions</div>`;
@@ -2803,9 +2875,9 @@ const recentSessions = (rows, liveIds) => rows.map(r => `
2803
2875
  <div class="mt"><span>${esc(r.project || '')}</span><span>${fmtInt(r.prompts)} prompts</span>
2804
2876
  <span>${fmtWhen(r.started_at)}</span></div>
2805
2877
  <div class="live-actions">
2806
- ${r.resume ? `<button class="act ghost" data-resume="${esc(r.resume)}" title="Copy: ${esc(r.resume)}">⧉ Resume</button>` : ''}
2878
+ ${r.resume ? `<button class="act ghost" data-resume="${esc(r.resume)}" title="Copy: ${esc(r.resume)}">${I('copy')} Resume</button>` : ''}
2807
2879
  <button class="act ghost" data-open-sess="${esc(r.session_id)}">Details</button>
2808
- <span class="spacer"></span><span class="note recent-caret">${RECENT.open_ids.has(r.session_id) ? '▾' : '▸'} prompts</span></div>
2880
+ <span class="spacer"></span><span class="note recent-caret">${I(RECENT.open_ids.has(r.session_id) ? 'chevDown' : 'chevRight')} prompts</span></div>
2809
2881
  <div class="recent-prompts"${RECENT.open_ids.has(r.session_id) ? '' : ' hidden'}></div></div>`).join('')
2810
2882
  || '<div class="empty">No sessions for these filters</div>';
2811
2883
 
@@ -2818,7 +2890,7 @@ const recentPrompts = rows => rows.map(x => `<div class="item recent-it">
2818
2890
  ${promptLine(x)}
2819
2891
  <div class="mt"><span>${esc((!MASKED && x.session_title) || shortId(x.session_id))}</span>
2820
2892
  <span>${esc(x.project || '')}</span>
2821
- <a href="#" data-open-sess="${esc(x.session_id)}">Session ›</a></div></div>`).join('');
2893
+ <a href="#" data-open-sess="${esc(x.session_id)}">Session ${I('chevRight')}</a></div></div>`).join('');
2822
2894
 
2823
2895
  async function loadSessionPrompts(host, sid) {
2824
2896
  host.innerHTML = '<div class="note">Loading…</div>';
@@ -2847,7 +2919,7 @@ function wireRecent(root) {
2847
2919
  if (e.target.closest('button, a, .recent-prompts')) return;
2848
2920
  host.hidden = !host.hidden;
2849
2921
  it.classList.toggle('open', !host.hidden);
2850
- it.querySelector('.recent-caret').textContent = (host.hidden ? '▸' : '▾') + ' prompts';
2922
+ it.querySelector('.recent-caret').innerHTML = I(host.hidden ? 'chevRight' : 'chevDown') + ' prompts';
2851
2923
  host.hidden ? RECENT.open_ids.delete(sid) : RECENT.open_ids.add(sid);
2852
2924
  if (!host.hidden && !host.childElementCount) loadSessionPrompts(host, sid);
2853
2925
  };
@@ -3019,7 +3091,7 @@ VIEWS.freemodels = async (page) => {
3019
3091
  ${!d.bin_on_path ? `<div class="item sev-medium"><div class="dt">${esc(d.bin_dir)} is not on your PATH, so the new commands
3020
3092
  won't run by name. Add <code>export PATH="$HOME/.local/bin:$PATH"</code> to ~/.zshrc.</div></div>` : ''}
3021
3093
  ${card('How to use and test a free model', `<div class="stack">
3022
- <div class="dt"><b>1. Test:</b> click <b>▶ Test it</b> on an added model. It asks the model directly, then runs a real one-line Claude Code session through the new command.</div>
3094
+ <div class="dt"><b>1. Test:</b> click <b>${I('play')} Test it</b> on an added model. It asks the model directly, then runs a real one-line Claude Code session through the new command.</div>
3023
3095
  <div class="dt"><b>2. Use:</b> open a terminal in any project and run the command, e.g. <code>claude-qwen-coder</code>. It's normal Claude Code, running on the free model.</div>
3024
3096
  <div class="dt"><b>3. Check which model is on:</b> type <code>/model</code> or <code>/status</code> inside that session. It shows the Qwen model.</div>
3025
3097
  <div class="dt"><b>Will it appear in /model in my normal <code>claude</code>?</b> No. Normal <code>claude</code> talks only to Anthropic, which doesn't serve Qwen. Each free model lives behind its own command, so your Claude sessions stay unchanged. Inside a free-model session, <code>/model</code> can't switch to Opus/Sonnet either; exit and run <code>claude</code> for that.</div>
@@ -3032,11 +3104,11 @@ VIEWS.freemodels = async (page) => {
3032
3104
  ? `Runs on this machine · ~${m.download_gb} GB download · wants ${m.min_ram_gb} GB RAM (you have ${m.ram_gb} GB)`
3033
3105
  : 'Runs in the cloud via OpenRouter · no download'}</div>
3034
3106
  ${m.installed
3035
- ? `<div class="dt">✅ Added. Run <code>${esc(m.command)}</code> in any project.</div>
3036
- <div><button class="act" data-test="${esc(m.id)}">▶ Test it</button>
3107
+ ? `<div class="dt">${I('checkCircle')} Added. Run <code>${esc(m.command)}</code> in any project.</div>
3108
+ <div><button class="act" data-test="${esc(m.id)}">${I('play')} Test it</button>
3037
3109
  <button class="act ghost" data-rm="${esc(m.id)}">Remove</button></div>
3038
3110
  <div data-testlog="${esc(m.id)}"></div>`
3039
- : `<div><button class="act" data-add="${esc(m.id)}" ${m.fits_ram ? '' : 'title="Less RAM than recommended"'}>+ Add ${esc(m.command)}</button></div>`}
3111
+ : `<div><button class="act" data-add="${esc(m.id)}" ${m.fits_ram ? '' : 'title="Less RAM than recommended"'}>${I('plus')} Add ${esc(m.command)}</button></div>`}
3040
3112
  </div>`, {badge: m.installed ? '<span class="badge">Added</span>' : ''})).join('')}</div>`;
3041
3113
  page.querySelectorAll('[data-go]').forEach(a => a.onclick = () => go(a.dataset.go));
3042
3114
  page.querySelectorAll('[data-add]').forEach(b => b.onclick = () => addFreeModel(b.dataset.add));
@@ -3053,7 +3125,7 @@ async function testFreeModel(id, host, btn) {
3053
3125
  const {job} = await doAction(`free_model/${id}/test`);
3054
3126
  const j = await followJob(job, host);
3055
3127
  host.insertAdjacentHTML('beforeend', j.state === 'done'
3056
- ? '<div class="item sev-low"><div class="dt">✅ Working end to end.</div></div>'
3128
+ ? '<div class="item sev-low"><div class="dt">' + I('checkCircle') + ' Working end to end.</div></div>'
3057
3129
  : '<div class="item sev-high"><div class="dt">Test failed. See the log above.</div></div>');
3058
3130
  } catch (e) { host.innerHTML = `<div class="item sev-high"><div class="dt">${esc(e.message)}</div></div>`; }
3059
3131
  btn.disabled = false;
@@ -3069,10 +3141,10 @@ async function addFreeModel(id) {
3069
3141
  <h3>${esc(plan.model.name)}</h3>
3070
3142
  <div class="dt">This will:</div>
3071
3143
  <div class="stack">${plan.steps.map((s, i) => `<div class="item ${s.warn ? 'sev-medium' : ''}"><div class="dt">
3072
- ${s.warn ? '⚠' : `${i + 1}.`} ${esc(s.do)}${s.consent ? ' <span class="pill">needs your OK</span>' : ''}</div></div>`).join('')}</div>
3144
+ ${s.warn ? I('alert') : `${i + 1}.`} ${esc(s.do)}${s.consent ? ' <span class="pill">needs your OK</span>' : ''}</div></div>`).join('')}</div>
3073
3145
  ${plan.needs.map(n => `${n.steps ? `<div class="item"><div class="hd">How to get your ${esc(n.label)}</div>
3074
3146
  <ol class="dt" style="margin:6px 0 8px 18px;padding:0;line-height:1.7">${n.steps.map(x => `<li>${esc(x)}</li>`).join('')}</ol>
3075
- ${n.url ? `<a class="act" href="${esc(n.url)}" target="_blank" rel="noopener" style="text-decoration:none;display:inline-block">Open ${esc(new URL(n.url).host)} ↗</a>` : ''}</div>` : ''}
3147
+ ${n.url ? `<a class="act" href="${esc(n.url)}" target="_blank" rel="noopener" style="text-decoration:none;display:inline-block">Open ${esc(new URL(n.url).host)} ${I('external')}</a>` : ''}</div>` : ''}
3076
3148
  <label class="dt"><b>${esc(n.label)}</b><br>
3077
3149
  <input type="password" data-need="${esc(n.field)}" autocomplete="off" style="width:100%;margin-top:6px;padding:6px 8px;border:1px solid var(--border);border-radius:6px;background:var(--surface-2)">
3078
3150
  <span class="note">${esc(n.help)}</span></label>`).join('')}
@@ -3094,8 +3166,8 @@ async function addFreeModel(id) {
3094
3166
  const {job} = await doAction(`free_model/${id}/install`, body);
3095
3167
  const j = await followJob(job, $('#fm-log', c));
3096
3168
  if (j.state === 'done') { $('#fm-log', c).insertAdjacentHTML('beforeend',
3097
- `<div class="item sev-low"><div class="dt">✅ Added. Open a terminal in any project and run <code>${esc(j.result.command)}</code>. Inside it, <code>/model</code> shows the Qwen model; your normal <code>claude</code> is unchanged.</div>
3098
- <div><button class="act" id="fm-test">▶ Test it now</button></div><div id="fm-testlog"></div></div>`);
3169
+ `<div class="item sev-low"><div class="dt">${I('checkCircle')} Added. Open a terminal in any project and run <code>${esc(j.result.command)}</code>. Inside it, <code>/model</code> shows the Qwen model; your normal <code>claude</code> is unchanged.</div>
3170
+ <div><button class="act" id="fm-test">${I('play')} Test it now</button></div><div id="fm-testlog"></div></div>`);
3099
3171
  $('#fm-test', c).onclick = e => testFreeModel(id, $('#fm-testlog', c), e.target);
3100
3172
  if (S.view === 'freemodels') render(); }
3101
3173
  else $('#fm-go', c).disabled = false;
@@ -3108,12 +3180,12 @@ VIEWS.compare = async (page) => {
3108
3180
  const d = await fetch('/api/compare?agents=' + encodeURIComponent(S.filter.agents.join(','))).then(r => r.json());
3109
3181
  if (d.error) throw new Error(d.error);
3110
3182
  const hasFree = d.rows.some(r => r.kind === 'free');
3111
- const stars = n => n == null ? '<span class="na">—</span>' : '★'.repeat(Math.floor(n)) + (n % 1 ? '½' : '') +
3112
- `<span style="opacity:.25">${'★'.repeat(5 - Math.ceil(n))}</span>`;
3183
+ const stars = n => n == null ? '<span class="na">—</span>' : I('star').repeat(Math.floor(n)) + (n % 1 ? I('starHalf') : '') +
3184
+ `<span style="opacity:.25">${I('star').repeat(5 - Math.ceil(n))}</span>`;
3113
3185
  const price = r => r.kind === 'free' ? '<b>$0</b>' : `${fmtUSD(r.price_in)} / ${fmtUSD(r.price_out)}`;
3114
3186
  const status = r => r.kind !== 'free'
3115
3187
  ? (r.requests ? `${fmtInt(r.requests)} req · ${fmtUSD(r.cost)}` : '<span class="na">not used</span>')
3116
- : !r.fits ? `❌ needs ${r.min_ram_gb} GB RAM` : r.installed ? `✅ <code>${esc(r.command)}</code>` : `<a data-go="freemodels">+ Add</a>`;
3188
+ : !r.fits ? `${I('xCircle')} needs ${r.min_ram_gb} GB RAM` : r.installed ? `${I('checkCircle')} <code>${esc(r.command)}</code>` : `<a data-go="freemodels">${I('plus')} Add</a>`;
3117
3189
  page.innerHTML = `
3118
3190
  ${card(hasFree ? 'Claude vs free models' : `${esc(agentWord())} models side by side`, `<div id="cmp"></div>`, {flush: 1, badge: BADGE.recommendation,
3119
3191
  hint: hasFree ? `This machine: ${esc(d.os)}, ${d.ram_gb} GB RAM` : 'List prices, your usage in range', footer: esc(d.note)})}
@@ -3154,9 +3226,9 @@ VIEWS.toolkit = async (page) => {
3154
3226
  <span class="spacer"></span><span class="note">${fmtInt(m.sessions)} sessions · ${esc(m.projects.join(', '))}</span></div>
3155
3227
  <div class="dt">${esc(m.what)}</div>
3156
3228
  ${m.evidence.length ? `<div class="dt note">Seen: ${ev(m.evidence)}</div>` : ''}
3157
- ${m.installed ? '<div class="dt">✅ Already connected</div>' : `
3229
+ ${m.installed ? '<div class="dt">' + I('checkCircle') + ' Already connected</div>' : `
3158
3230
  <div class="dt"><code>${esc(m.command)}</code></div>
3159
- <div><button class="act" data-mcp="${esc(m.id)}">+ Add to Claude</button><span class="mcp-out"></span></div>`}
3231
+ <div><button class="act" data-mcp="${esc(m.id)}">${I('plus')} Add to Claude</button><span class="mcp-out"></span></div>`}
3160
3232
  </div>`).join('') || '<div class="empty">No recurring pattern points to an MCP server</div>'}</div>`,
3161
3233
  {badge: BADGE.recommendation})}
3162
3234
  ${card('Skills from what you repeat', `<div class="stack">${d.skills.map((s, i) => `
@@ -3164,13 +3236,13 @@ VIEWS.toolkit = async (page) => {
3164
3236
  <span class="spacer"></span><span class="note">${fmtInt(s.sessions)} sessions · ${fmtInt(s.runs)} runs · ${esc(s.projects.join(', '))}</span></div>
3165
3237
  <div class="dt">${esc(s.what)}</div>
3166
3238
  <div class="dt note">Examples: ${ev(s.examples.slice(0, 2))}</div>
3167
- ${s.installed ? '<div class="dt">✅ Skill exists</div>' : `<div><button class="act" data-skill="${i}">+ Create skill</button><span class="sk-out"></span></div>`}
3239
+ ${s.installed ? '<div class="dt">' + I('checkCircle') + ' Skill exists</div>' : `<div><button class="act" data-skill="${i}">${I('plus')} Create skill</button><span class="sk-out"></span></div>`}
3168
3240
  </div>`).join('') || '<div class="empty">Nothing repeats often enough yet</div>'}</div>`,
3169
3241
  {badge: BADGE.recommendation, hint: `Created in ${d.skills_dir}; edit the SKILL.md afterwards`})}`;
3170
3242
  page.querySelectorAll('[data-skill]').forEach(b => b.onclick = async () => {
3171
3243
  const out = b.nextElementSibling; b.disabled = true;
3172
3244
  try { const r = await doAction('skill', d.skills[+b.dataset.skill]);
3173
- out.innerHTML = ` ✅ Created <code>${esc(r.path)}</code>. Use it with <code>${esc(r.use)}</code>.`; }
3245
+ out.innerHTML = ` ${I('checkCircle')} Created <code>${esc(r.path)}</code>. Use it with <code>${esc(r.use)}</code>.`; }
3174
3246
  catch (e) { out.textContent = ' ' + e.message; b.disabled = false; }
3175
3247
  });
3176
3248
  page.querySelectorAll('[data-mcp]').forEach(b => b.onclick = async () => {
@@ -3184,7 +3256,7 @@ VIEWS.toolkit = async (page) => {
3184
3256
  body[n.field] = out.querySelector(`[data-need="${n.field}"]`).value;
3185
3257
  }
3186
3258
  b.disabled = true;
3187
- try { const r = await doAction(`mcp/${m.id}`, body); out.innerHTML = ` ✅ Added. ${esc(r.next)}`; }
3259
+ try { const r = await doAction(`mcp/${m.id}`, body); out.innerHTML = ` ${I('checkCircle')} Added. ${esc(r.next)}`; }
3188
3260
  catch (e) { out.textContent = ' ' + e.message; b.disabled = false; }
3189
3261
  });
3190
3262
  };
@@ -3197,7 +3269,7 @@ VIEWS.cloud = async (page) => {
3197
3269
  const gap = (t.billed_claude_cost || 0) - (t.local_claude_cost || 0);
3198
3270
  const setup = Object.entries(pv).map(([k, p]) => `
3199
3271
  <div class="item sev-${cfg[k] ? 'low' : 'medium'}">
3200
- <div class="hd">${cfg[k] ? '✓' : '○'} ${esc(p.name)}<span class="spacer"></span>
3272
+ <div class="hd">${I(cfg[k] ? 'check' : 'circle')} ${esc(p.name)}<span class="spacer"></span>
3201
3273
  <span class="note">${cfg[k] ? 'key found' : 'no key'}</span></div>
3202
3274
  <div class="dt"><b>Gives you:</b> ${esc(p.covers)}</div>
3203
3275
  <div class="dt"><b>Get a key:</b> ${esc(p.how)}</div>
@@ -3210,7 +3282,7 @@ VIEWS.cloud = async (page) => {
3210
3282
  Nothing is fetched until you click Refresh.
3211
3283
  <span class="spacer"></span>
3212
3284
  <span class="note">Last fetched: ${esc(d.fetched_at || 'never')}</span>
3213
- <button class="act" id="cl-sync" ${none ? 'disabled title="Add a key first"' : ''}>↯ Refresh from APIs</button></div>
3285
+ <button class="act" id="cl-sync" ${none ? 'disabled title="Add a key first"' : ''}>${I('cloudDown')} Refresh from APIs</button></div>
3214
3286
  <div id="cl-log"></div>
3215
3287
  ${Object.keys(d.errors).length ? `<div class="note sev-high">${Object.entries(d.errors)
3216
3288
  .map(([k, v]) => `<div><b>${esc(k)}</b>: ${esc(v)}</div>`).join('')}</div>` : ''}
@@ -3273,7 +3345,7 @@ VIEWS.cloud = async (page) => {
3273
3345
  render();
3274
3346
  } catch (e) {
3275
3347
  $('#cl-log', page).innerHTML = `<div class="note sev-high">Failed: ${esc(e.message)}</div>`;
3276
- btn.disabled = false; btn.textContent = '↯ Refresh from APIs';
3348
+ btn.disabled = false; btn.innerHTML = `${I('cloudDown')} Refresh from APIs`;
3277
3349
  }
3278
3350
  };
3279
3351
  };
@@ -3295,11 +3367,11 @@ const TOUR_WELCOME = [
3295
3367
  act: 'Click <b>Codex</b> or <b>All</b> to switch.'},
3296
3368
  {el: '[data-range]', t: 'Date range & filters', see: 'Range chips, plus Model, Project, Category and Threshold filters.',
3297
3369
  get: 'Narrow any dashboard to a period, a model or a project.',
3298
- act: 'Try <b>7 days</b>, then open <b>Project ▾</b> to focus on one repo.'},
3370
+ act: `Try <b>7 days</b>, then open <b>Project ${I('chevDown')}</b> to focus on one repo.`},
3299
3371
  {el: '#gsearch', t: 'Search', see: 'Search across prompts, sessions, models and dates.',
3300
3372
  get: 'Jump straight to the prompt or session you remember.', act: 'Type a word from a recent prompt and press Enter.'},
3301
3373
  {el: '#sync', t: 'Keep it current', see: 'Sync re-reads every agent\'s local data.',
3302
- get: 'Fresh numbers after you\'ve been working.', act: 'Click <b>⟳ Sync</b> whenever the numbers look stale.'},
3374
+ get: 'Fresh numbers after you\'ve been working.', act: `Click <b>${I('sync')} Sync</b> whenever the numbers look stale.`},
3303
3375
  {el: '#tour-btn', t: 'Tour any dashboard', see: 'This button starts a tour of the dashboard you\'re on.',
3304
3376
  get: 'A short explanation of each section: what it shows, what you get, what you can do.',
3305
3377
  act: 'Open any dashboard and click <b>? Tour</b> to replay it. Next: this dashboard.'},
@@ -3332,13 +3404,13 @@ const TOURS = {
3332
3404
  {el: 'card:What each agent records', t: 'What each agent records', see: 'Which fields each agent writes to disk on this machine.', get: 'Why some dashboards are hidden for some agents: the data simply isn\'t recorded.', act: 'Check here first when a number reads "not recorded".'}],
3333
3405
  live: [
3334
3406
  {el: 'kpis', t: 'Running now', see: 'Sessions running right now, which are working (spending) at this moment, the largest live context and what live sessions have spent.', get: 'What is costing you money this second.', act: 'Only <b>working</b> sessions consume tokens; idle ones cost again on your next message.'},
3335
- {el: '#live-refresh', t: 'Refresh & the rules', see: 'The note explains what Interrupt, Close and Force kill do, and how non-Claude agents are detected.', get: 'Confidence before you stop something.', act: 'Click <b>↻ Refresh</b> for the latest state.'},
3407
+ {el: '#live-refresh', t: 'Refresh & the rules', see: 'The note explains what Interrupt, Close and Force kill do, and how non-Claude agents are detected.', get: 'Confidence before you stop something.', act: `Click <b>${I('reload')} Refresh</b> for the latest state.`},
3336
3408
  {el: '.item[data-i]', t: 'A session', see: 'Agent, project, context size, steps, cost and last activity, plus advice when a session gets heavy.', get: 'Control over sessions that are quietly growing expensive.', act: 'Interrupt, Close or Force kill it (each needs two clicks), copy its resume command, or <b>Hand over</b> a Claude session to a fresh one.'}],
3337
3409
  scorecard: [
3338
3410
  {el: 'card:AI FinOps Score', t: 'Your score', see: 'The overall score with a component breakdown: cache use, model mix, waste and budget.', get: 'Where your habits are strong and where they cost money.', act: 'Hover a component to see how it is calculated.'},
3339
- {el: 'card:✅ What is good', t: 'What is good', see: 'The components you already score well on.', get: 'The habits worth keeping.', act: 'Keep these when you change your setup.'},
3340
- {el: 'card:⚠️ Needs attention', t: 'Needs attention', see: 'The components dragging the score down.', get: 'A short list of what to fix.', act: 'Start at the top: it carries the most weight.'},
3341
- {el: 'card:🎯 Biggest opportunity', t: 'Biggest opportunity', see: 'The single change that would move the score most.', get: 'One concrete action grounded in observed spend.', act: 'Apply it, then Sync and re-check the score.'}],
3411
+ {el: 'card:What is good', t: 'What is good', see: 'The components you already score well on.', get: 'The habits worth keeping.', act: 'Keep these when you change your setup.'},
3412
+ {el: 'card:Needs attention', t: 'Needs attention', see: 'The components dragging the score down.', get: 'A short list of what to fix.', act: 'Start at the top: it carries the most weight.'},
3413
+ {el: 'card:Biggest opportunity', t: 'Biggest opportunity', see: 'The single change that would move the score most.', get: 'One concrete action grounded in observed spend.', act: 'Apply it, then Sync and re-check the score.'}],
3342
3414
  usage: [
3343
3415
  {el: 'kpis', t: 'Token breakdown', see: 'Total, input, output, thinking, cache read and cache write tokens, plus requests, sessions, prompts and active days.', get: 'Exactly which kind of token you spend on. Cache read is cheap; uncached input is not.', act: 'Compare cache read against input: a low ratio means context is being re-sent, not reused.'},
3344
3416
  {el: 'card0', t: 'Usage over time', see: 'Tokens or cost per day (or per hour on short ranges).', get: 'Spikes and quiet periods at a glance.', act: 'Hover a bar for the day\'s numbers; narrow the range to zoom in.'},
@@ -3349,7 +3421,7 @@ const TOURS = {
3349
3421
  {el: 'card0', t: 'Allowance gauges', see: 'One gauge per allowance you configured: cost, tokens or requests.', get: 'How much of each limit is gone, and how many days it lasts at this rate.', act: 'Set your limits in config/settings.json to make these exact.'},
3350
3422
  {el: 'card:Daily consumption within the billing period', t: 'Daily consumption', see: 'Each day of the billing period against the pace you\'d need to stay inside the limit.', get: 'Which days pushed you off pace.', act: 'Hover a bar to see that day against the target line.'}],
3351
3423
  models: [
3352
- {el: 'kpis', t: 'Model headlines', see: 'How many models you used and what the mix costs.', get: 'A first read on whether the mix is right.', act: 'Filter to one model with <b>Model ▾</b> above.'},
3424
+ {el: 'kpis', t: 'Model headlines', see: 'How many models you used and what the mix costs.', get: 'A first read on whether the mix is right.', act: `Filter to one model with <b>Model ${I('chevDown')}</b> above.`},
3353
3425
  {el: 'card:Cost share', t: 'Cost share', see: 'Spend split across models.', get: 'The model that owns your bill.', act: 'Check whether that model is doing work a cheaper one could.'},
3354
3426
  {el: 'card:Token share', t: 'Token share', see: 'The same split by tokens instead of money.', get: 'The gap between the two charts is the price difference at work.', act: 'A model with a small token share but a big cost share is your expensive one.'},
3355
3427
  {el: 'card:Model FinOps table', t: 'Model FinOps table', see: 'Per model: cost, tokens, context, output and cost per 1K output.', get: 'What each model really costs for the work you give it.', act: 'Sort by cost per 1K output, then open <b>Model switch</b>.'},
@@ -3420,12 +3492,12 @@ const TOURS = {
3420
3492
  {el: 'card:Sessions ranked', t: 'Sessions', see: 'Each session\'s context trajectory and what it spent after crossing the threshold.', get: 'The sessions where a fresh start would have mattered most.', act: 'Click a row for its trajectory; alt-click to open the session.'}],
3421
3493
  waste: [
3422
3494
  {el: 'kpis', t: 'Waste headlines', see: 'Estimated excess, exposed spend, and how many prompts, sessions and rules are involved.', get: 'An honest estimate of avoidable spend.', act: '<b>Exposed</b> is what flagged work cost in total; <b>excess</b> is how much more than a fair baseline.'},
3423
- {el: 'card:🔴 High waste', t: 'High waste', see: 'The rules that fired hardest: repeated reads, retries, stale sessions.', get: 'The costly patterns, each with the baseline it is measured against.', act: 'Open <b>Show flagged items</b> to see the evidence, then fix these first.'},
3424
- {el: 'card:🟡 Optimization opportunities', t: 'Medium findings', see: 'Patterns worth changing but not urgent.', get: 'The next tier of opportunities.', act: 'Batch these into one config change.'},
3425
- {el: 'card:⚪ Low-priority observations', t: 'Low-priority observations', see: 'Small findings, kept for completeness.', get: 'Context for the numbers above.', act: 'Skim them; act only if one matches a habit you want to change.'}],
3495
+ {el: 'card:High waste', t: 'High waste', see: 'The rules that fired hardest: repeated reads, retries, stale sessions.', get: 'The costly patterns, each with the baseline it is measured against.', act: 'Open <b>Show flagged items</b> to see the evidence, then fix these first.'},
3496
+ {el: 'card:Optimization opportunities', t: 'Medium findings', see: 'Patterns worth changing but not urgent.', get: 'The next tier of opportunities.', act: 'Batch these into one config change.'},
3497
+ {el: 'card:Low-priority observations', t: 'Low-priority observations', see: 'Small findings, kept for completeness.', get: 'Context for the numbers above.', act: 'Skim them; act only if one matches a habit you want to change.'}],
3426
3498
  freemodels: [
3427
3499
  {el: 'card:How to use and test a free model', t: 'How it works', see: 'How to plug a local or free cloud model into Claude Code, and how to test it.', get: 'Zero-cost options for simple or private work.', act: 'Read the RAM guidance before you pick a model.'},
3428
- {el: 'card1', t: 'A model', see: 'Each model with its size, RAM needs, strengths and limits.', get: 'A realistic idea of what runs on your machine.', act: 'Click <b>+ Add</b> on a model that fits your RAM.'}],
3500
+ {el: 'card1', t: 'A model', see: 'Each model with its size, RAM needs, strengths and limits.', get: 'A realistic idea of what runs on your machine.', act: `Click <b>${I('plus')} Add</b> on a model that fits your RAM.`}],
3429
3501
  compare: [
3430
3502
  {el: 'card0', t: 'Models side by side', see: 'Price, context window, ratings and your own usage per model.', get: 'A clear pick for each kind of work.', act: 'Sort by output price, the one that usually dominates the bill.'},
3431
3503
  {el: 'card:Use Claude for', t: 'Use the big model for', see: 'The work that genuinely needs a top model.', get: 'Where paying more actually pays off.', act: 'Keep multi-file and agentic work here.'},
@@ -3450,7 +3522,7 @@ const TOURS = {
3450
3522
  {el: 'card:Budget vs actual vs forecast', t: 'Budget vs actual', see: 'Each budget line with its budget, actual, forecast and variance.', get: 'A warning before you overspend, not after.', act: 'Watch the variance column: a positive forecast variance means trouble.'},
3451
3523
  {el: 'card:Configure budgets', t: 'Configure budgets', see: 'Your budget lines, limits and alert thresholds.', get: 'Numbers that make the forecast and burn dashboards meaningful.', act: 'Edit a budget and save; every dashboard picks it up.'}],
3452
3524
  cloud: [
3453
- {el: 'kpis', t: 'Billed vs local', see: 'What the vendor billed the whole organisation next to what this machine recorded.', get: 'The gap: usage from other machines, other members, or work off this machine.', act: 'Click <b>↯ Refresh from APIs</b> to fetch — this is the only page that goes online.'},
3525
+ {el: 'kpis', t: 'Billed vs local', see: 'What the vendor billed the whole organisation next to what this machine recorded.', get: 'The gap: usage from other machines, other members, or work off this machine.', act: `Click <b>${I('cloudDown')} Refresh from APIs</b> to fetch — this is the only page that goes online.`},
3454
3526
  {el: 'card:Set up the APIs', t: 'Set up the APIs', see: 'Which provider keys were found, and how to get each one.', get: 'Org-wide Claude Code usage per user, and Cursor team spend.', act: 'Run claude-finops --set-key, or set the environment variable, then restart.'},
3455
3527
  {el: 'card:Billed vs local', t: 'The comparison', see: 'Billed totals against local totals for the same period.', get: 'Proof of how much of the bill this machine explains.', act: 'A large gap means most spend happens elsewhere: check the user tables.'},
3456
3528
  {el: 'card:Claude Code users', t: 'Users org-wide', see: 'Each Claude Code user in the organisation and their usage.', get: 'Who drives the bill across the team.', act: 'Compare your own row with the team average.'},
@@ -3533,7 +3605,7 @@ function showStep() {
3533
3605
  const last = i === steps.length - 1;
3534
3606
  pop.innerHTML = `
3535
3607
  <div class="tour-hd"><span class="tour-n">${esc(name)} · ${i + 1} / ${steps.length}</span>
3536
- <button class="tour-x" data-t="skip" title="Close (Esc)">✕</button></div>
3608
+ <button class="tour-x" data-t="skip" title="Close (Esc)">${I('x')}</button></div>
3537
3609
  <h4>${esc(s.t)}</h4>
3538
3610
  ${s.see ? `<div class="tour-row"><b>What you see</b><span>${s.see}</span></div>` : ''}
3539
3611
  ${s.get ? `<div class="tour-row"><b>What you get</b><span>${s.get}</span></div>` : ''}
package/web/styles.css CHANGED
@@ -590,3 +590,18 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
590
590
  .recent-pv { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
591
591
  .live-dot { width:8px; height:8px; border-radius:50%; background:var(--axis); flex:none; }
592
592
  .live-dot.busy, .recent-it .hd > .live-dot[title="Running now"] { background:var(--s1); }
593
+
594
+ /* Flat line icons from I() in app.js, sized to the text they sit beside. */
595
+ .i { width:1.1em; height:1.1em; vertical-align:-.2em; fill:none; stroke:currentColor; stroke-width:1.9;
596
+ stroke-linecap:round; stroke-linejoin:round; flex:none; }
597
+ .i.fill { fill:currentColor; stroke:none; }
598
+ .i.dot { stroke:none; width:1em; height:1em; vertical-align:-.15em; }
599
+ .i.dot.green { fill:var(--good); } .i.dot.yellow { fill:var(--warning); } .i.dot.orange { fill:var(--serious); }
600
+ .i.dot.red { fill:var(--critical); } .i.dot.grey { fill:var(--muted); }
601
+ .nav a .ic { width:16px; display:inline-flex; justify-content:center; }
602
+ .nav a .ic .i { width:15px; height:15px; }
603
+ .mag .i { display:block; width:14px; height:14px; }
604
+ .card > header h3 .i { margin-right:6px; }
605
+ .actnow-it .ai .i { width:17px; height:17px; vertical-align:middle; }
606
+ .focus-hd .i { align-self:center; }
607
+ .status .glyph .i { display:block; width:10px; height:10px; }