claude-finops 0.12.0 → 0.14.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/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,46 @@ 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
+ ['jev', 'sparkles', 'Jev (fast decisions)'],
152
+ ['compare', 'scale', 'Compare models', 'priced'],
153
+ ['toolkit', 'wrench', 'Skills & MCP', 'claude'],
154
+ ['recommendations', 'listChecks', 'Recommendations'],
155
+ ['anomalies', 'zap', 'Anomalies'],
155
156
  ]],
156
157
  ['Plan', [
157
- ['forecast', '◭', 'Forecast', 'priced'],
158
- ['budgets', '⊞', 'Budgets', 'priced'],
159
- ['cloud', '☁', 'Billed vs local', 'cloud'],
160
- ['exports', '⇩', 'Export & data'],
158
+ ['forecast', 'trendUp', 'Forecast', 'priced'],
159
+ ['budgets', 'wallet', 'Budgets', 'priced'],
160
+ ['cloud', 'cloud', 'Billed vs local', 'cloud'],
161
+ ['exports', 'download', 'Export & data'],
162
+ ]],
163
+ ['Setup', [
164
+ ['settings', 'cog', 'Settings'],
161
165
  ]],
162
166
  ];
163
167
 
@@ -175,6 +179,78 @@ const ICON = {
175
179
  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
180
  };
177
181
 
182
+ // Every other icon in the app: flat 24px line icons in the same style, drawn in
183
+ // currentColor so they follow the theme and whatever tone they sit in.
184
+ const IP = {
185
+ search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
186
+ chevDown: '<path d="m6 9 6 6 6-6"/>',
187
+ chevRight: '<path d="m9 6 6 6-6 6"/>',
188
+ x: '<path d="M18 6 6 18M6 6l12 12"/>',
189
+ check: '<path d="M20 6 9 17l-5-5"/>',
190
+ checkCircle: '<circle cx="12" cy="12" r="10"/><path d="m8 12 3 3 5-6"/>',
191
+ xCircle: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6M9 9l6 6"/>',
192
+ circle: '<circle cx="12" cy="12" r="9"/>',
193
+ 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"/>',
194
+ siren: '<path d="M7.9 2h8.2L22 7.9v8.2L16.1 22H7.9L2 16.1V7.9z"/><path d="M12 8v4M12 16h.01"/>',
195
+ target: '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>',
196
+ 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"/>',
197
+ compact: '<path d="m15 15 6 6M15 21v-6h6M9 9 3 3M9 3v6H3M15 9l6-6M15 3v6h6M9 15l-6 6M9 21v-6H3"/>',
198
+ 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"/>',
199
+ handover: '<path d="m15 17 5-5-5-5"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/>',
200
+ plus: '<path d="M12 5v14M5 12h14"/>',
201
+ help: '<circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6V14"/><path d="M12 17h.01"/>',
202
+ pause: '<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>',
203
+ stop: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
204
+ play: '<path d="M7 4v16l13-8z"/>',
205
+ 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"/>',
206
+ arrowUp: '<path d="M12 19V5M5 12l7-7 7 7"/>',
207
+ triUp: '<path d="M12 6l7 11H5z"/>',
208
+ triDown: '<path d="M12 18 5 7h14z"/>',
209
+ 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"/>',
210
+ 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"/>',
211
+ starHalf: '<path d="M12 2.5v14.9l-5.9 3.1 1.2-6.5-4.8-4.6 6.6-.9z"/>',
212
+ lock: '<rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>',
213
+ 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"/>',
214
+ 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"/>',
215
+ 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"/>',
216
+ panel: '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 15h18"/>',
217
+ zap: '<path d="M13 2 3 14h9l-1 8 10-12h-9z"/>',
218
+ // sidebar
219
+ gauge: '<path d="m12 14 4-4"/><path d="M3.3 19a10 10 0 1 1 17.4 0"/>',
220
+ 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"/>',
221
+ 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"/>',
222
+ bot: '<rect x="4" y="8" width="16" height="12" rx="2"/><path d="M12 8V4H8M2 14h2M20 14h2M9 13v2M15 13v2"/>',
223
+ activity: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
224
+ award: '<circle cx="12" cy="8" r="6"/><path d="M15.5 12.9 17 22l-5-3-5 3 1.5-9.1"/>',
225
+ barChart: '<path d="M3 3v18h18"/><path d="M8 17v-5M13 17V7M18 17v-8"/>',
226
+ 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"/>',
227
+ 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"/>',
228
+ layers: '<path d="m12 2 10 5-10 5L2 7z"/><path d="m2 12 10 5 10-5M2 17l10 5 10-5"/>',
229
+ 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"/>',
230
+ terminal: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m6 9 3 3-3 3M12 15h6"/>',
231
+ 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"/>',
232
+ sortDesc: '<path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4"/>',
233
+ 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"/>',
234
+ code: '<path d="m16 18 6-6-6-6M8 6l-6 6 6 6"/>',
235
+ 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"/>',
236
+ 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"/>',
237
+ 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"/>',
238
+ 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"/>',
239
+ 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"/>',
240
+ 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"/>',
241
+ listChecks: '<path d="m3 7 2 2 4-4M3 17l2 2 4-4M13 6h8M13 12h8M13 18h8"/>',
242
+ trendUp: '<path d="m22 7-8.5 8.5-5-5L2 17"/><path d="M16 7h6v6"/>',
243
+ 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"/>',
244
+ cloud: '<path d="M17.5 19H9a7 7 0 1 1 6.7-9h1.8a4.5 4.5 0 1 1 0 9z"/>',
245
+ 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"/>',
246
+ };
247
+ const FILLED = new Set(['play', 'triUp', 'triDown', 'star', 'starHalf']);
248
+ // I('name'): the icon as inline SVG. Toolbar icons are shared from ICON above.
249
+ const I = n => ICON[n] ? ICON[n].replace('<svg ', '<svg class="i" ')
250
+ : `<svg class="i${FILLED.has(n) ? ' fill' : ''}" viewBox="0 0 24 24" aria-hidden="true">${IP[n]}</svg>`;
251
+ // Status and severity: a flat filled dot in the matching tone.
252
+ const dot = tone => `<svg class="i dot ${tone}" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="6"/></svg>`;
253
+
178
254
  // The theme button shows the theme it switches to: a moon in light mode, a sun in dark.
179
255
  function themeLabel() {
180
256
  const b = $('#theme'); if (!b) return;
@@ -194,7 +270,7 @@ function shell() {
194
270
  <div class="sub">Command Center</div></div>
195
271
  <nav class="nav">${NAV.map(([g, items], gi) => `<div class="group g${gi}">${g}</div>` +
196
272
  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('')}
273
+ `<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
274
  </nav>
199
275
  <div class="who" id="who"></div></aside>
200
276
  <div class="main">
@@ -204,7 +280,7 @@ function shell() {
204
280
  <div class="crumbs" id="crumbs"></div></div>
205
281
  <div class="scope" id="scope" aria-label="Totals for the current filters"></div>
206
282
  <span class="spacer"></span>
207
- <div class="search"><span class="mag">⌕</span>
283
+ <div class="search"><span class="mag">${I('search')}</span>
208
284
  <input id="gsearch" placeholder="Search prompts, sessions, models…"></div>
209
285
  <button class="iconbtn upd" id="upd" hidden></button>
210
286
  <div class="tools">
@@ -334,13 +410,13 @@ async function updateChip() {
334
410
  try { u = await fetch('/api/update').then(r => r.json()); } catch { return; }
335
411
  if (!u || !u.update_available) return;
336
412
  el.hidden = false;
337
- el.textContent = `↑ v${u.latest} available`;
413
+ el.innerHTML = `${I('arrowUp')} v${u.latest} available`;
338
414
  el.title = `You are on ${u.current}. Click to copy: ${u.command}`;
339
415
  el.onclick = async () => {
340
416
  try {
341
417
  await navigator.clipboard.writeText(u.command);
342
- el.textContent = '✓ command copied';
343
- setTimeout(() => { el.textContent = `↑ v${u.latest} available`; }, 2200);
418
+ el.innerHTML = `${I('check')} command copied`;
419
+ setTimeout(() => { el.innerHTML = `${I('arrowUp')} v${u.latest} available`; }, 2200);
344
420
  } catch { prompt('Run this to upgrade:', u.command); }
345
421
  };
346
422
  }
@@ -375,24 +451,24 @@ function filterBar() {
375
451
  ${RANGES.map(([k, l]) => `<button class="${S.range === k ? 'on' : ''}"
376
452
  data-range="${k}">${l}</button>`).join('')}</div>
377
453
  <div class="chipsel"><button class="chip ${f.models.length ? 'on' : ''}" data-pop="models">
378
- Model ${nSel(f.models)} ▾</button></div>
454
+ Model ${nSel(f.models)} ${I('chevDown')}</button></div>
379
455
  <div class="chipsel"><button class="chip ${f.projects.length ? 'on' : ''}" data-pop="projects">
380
- Project ${nSel(f.projects)} ▾</button></div>
456
+ Project ${nSel(f.projects)} ${I('chevDown')}</button></div>
381
457
  <div class="chipsel"><button class="chip ${f.categories.length ? 'on' : ''}" data-pop="categories">
382
- Category ${nSel(f.categories)} ▾</button></div>
458
+ Category ${nSel(f.categories)} ${I('chevDown')}</button></div>
383
459
  <div class="chipsel"><button class="chip ${(f.min_cost || f.min_tokens) ? 'on' : ''}"
384
- data-pop="thresholds">Thresholds ▾</button></div>
460
+ data-pop="thresholds">Thresholds ${I('chevDown')}</button></div>
385
461
  <button class="chip toggle ${f.include_sandbox ? '' : 'on'}" id="sbx" role="switch" aria-checked="${!f.include_sandbox}"
386
462
  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
463
  <i></i>Hide sandbox</button>
388
464
  ${(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>' : ''}
465
+ ? `<button class="chip clr" id="clr">Clear filters ${I('x')}</button>` : ''}
390
466
  <span class="spacer"></span>
391
467
  <span class="coverage" title="Data in the warehouse · last rebuilt ${esc((o.meta.built_at||'').slice(0,16).replace('T',' '))}">
392
468
  ${esc(o.meta.transcript_files)} transcripts · since ${esc(o.date_range.first)}</span>`;
393
469
 
394
470
  $('#filters').querySelectorAll('[data-range]').forEach(b =>
395
- b.onclick = () => { if (b.dataset.range === 'custom') return openCustom(b);
471
+ b.onclick = e => { if (b.dataset.range === 'custom') { e.stopPropagation(); return openCustom(b); }
396
472
  applyRange(b.dataset.range); bust(); render(); });
397
473
  $('#filters').querySelectorAll('[data-agent]').forEach(b => b.onclick = e => {
398
474
  const id = b.dataset.agent;
@@ -454,13 +530,15 @@ function openPop(btn, kind) {
454
530
  }
455
531
  }
456
532
  function openCustom(btn) {
457
- closePops();
458
- const pop = h(`<div class="pop"><div class="hd">From</div>
533
+ const open = btn.parentElement.querySelector('.pop');
534
+ closePops(); if (open) return;
535
+ const pop = h(`<div class="pop" style="left:auto;right:0"><div class="hd">From</div>
459
536
  <input type="date" id="cs" value="${S.filter.start || S.opts.date_range.first}">
460
537
  <div class="hd">To</div>
461
538
  <input type="date" id="ce" value="${S.filter.end || S.opts.date_range.last}">
462
539
  <button class="chip on" id="ca" style="width:100%;justify-content:center">Apply</button></div>`);
463
- btn.parentElement.insertBefore(pop, btn.nextSibling);
540
+ btn.parentElement.style.position = 'relative';
541
+ btn.parentElement.appendChild(pop);
464
542
  pop.onclick = e => e.stopPropagation();
465
543
  pop.querySelector('#ca').onclick = () => {
466
544
  S.filter.start = pop.querySelector('#cs').value;
@@ -480,7 +558,7 @@ function drawer(title, bodyHtml, sub) {
480
558
  const d = h(`<aside class="drawer"><header>
481
559
  <h2>${esc(title)}</h2>${sub ? `<span class="crumbs">${sub}</span>` : ''}
482
560
  <span class="spacer"></span>
483
- <button class="iconbtn" data-x>Close ✕</button></header>
561
+ <button class="iconbtn" data-x>Close ${I('x')}</button></header>
484
562
  <div class="content">${bodyHtml}</div></aside>`);
485
563
  scrim.onclick = closeDrawer;
486
564
  d.querySelector('[data-x]').onclick = closeDrawer;
@@ -824,19 +902,19 @@ VIEWS.overview = async (page) => {
824
902
  };
825
903
 
826
904
  /* ---------- act now: one-click actions, on the page everyone opens first ---------- */
827
- const ACT_ICON = {session: '🗜', skill: '⚙', memory: '📝', statusline: '▭'};
905
+ const ACT_ICON = {session: 'compact', skill: 'cog', memory: 'note', statusline: 'panel'};
828
906
  const codeTicks = t => esc(t).replace(/`([^`]+)`/g, '<code>$1</code>');
829
907
  function actNowStrip(items) {
830
908
  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>'
909
+ ? '<button class="act" data-do="compact" title="Types /compact into that session\'s terminal">' + I('compact') + ' Compact</button>'
910
+ + '<button class="act ghost" data-do="handover">' + I('handover') + ' Hand over</button>'
911
+ : it.kind === 'skill' ? '<button class="act" data-do="skill">' + I('plus') + ' Create skill</button>'
834
912
  : it.kind === 'memory' ? '<button class="act ghost" data-do="memory">Show examples</button>'
835
913
  : '<button class="act" data-do="statusline">Install statusline</button>';
836
- return `<section class="actnow"><div class="focus-hd">⚡ Act now
914
+ return `<section class="actnow"><div class="focus-hd">${I('zap')} Act now
837
915
  <span class="note">one click each · nothing changes until you click</span></div>
838
916
  ${items.map((it, i) => `<div class="actnow-it" data-i="${i}">
839
- <span class="ai">${ACT_ICON[it.kind] || '•'}</span>
917
+ <span class="ai">${ACT_ICON[it.kind] ? I(ACT_ICON[it.kind]) : '•'}</span>
840
918
  <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
919
  <div class="live-actions">${btn(it)}<span class="live-msg"></span></div></div>`).join('')}</section>`;
842
920
  }
@@ -892,16 +970,16 @@ async function liveSessions() {
892
970
  const rowActs = (r, live) => {
893
971
  const pid = live && live.get(r.session_id);
894
972
  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>`;
973
+ title="Copy: ${esc(r.resume)}">${I('copy')} Resume</button>` : ''}${pid ? ` <button class="act" data-compact="${pid}"
974
+ title="Running now: types /compact into its terminal">${I('compact')} Compact</button>` : ''}</span>`;
897
975
  };
898
976
  function wireRowActs(host) {
899
977
  if (!host) return;
900
978
  host.querySelectorAll('[data-resume]').forEach(b => b.onclick = async e => {
901
979
  e.stopPropagation();
902
- try { await navigator.clipboard.writeText(b.dataset.resume); b.textContent = '✓ Copied'; }
980
+ try { await navigator.clipboard.writeText(b.dataset.resume); b.innerHTML = `${I('check')} Copied`; }
903
981
  catch { b.textContent = b.dataset.resume; }
904
- setTimeout(() => { b.textContent = '⧉ Resume'; }, 1800);
982
+ setTimeout(() => { b.innerHTML = `${I('copy')} Resume`; }, 1800);
905
983
  });
906
984
  host.querySelectorAll('[data-compact]').forEach(b => b.onclick = async e => {
907
985
  e.stopPropagation();
@@ -910,15 +988,15 @@ function wireRowActs(host) {
910
988
  try {
911
989
  const r = await sessionAction(+b.dataset.compact, 'compact', {agent: 'claude'});
912
990
  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;
991
+ b.innerHTML = r.ok ? `${I('check')} Sent` : 'Failed'; b.title = r.ok ? r.message : r.error;
914
992
  } catch (err) { b.textContent = 'Failed'; b.title = err.message; }
915
- setTimeout(() => { b.disabled = false; b.textContent = '🗜 Compact'; }, 2500);
993
+ setTimeout(() => { b.disabled = false; b.innerHTML = `${I('compact')} Compact`; }, 2500);
916
994
  });
917
995
  }
918
996
 
919
997
  function renderAdvisorHero(page, advisor) {
920
998
  if (!page || !advisor || !Array.isArray(advisor.actions)) return;
921
- const hero = h(`<div class="hero"><h3>✦ AI FinOps Advisor — ${esc(advisor.question)}
999
+ const hero = h(`<div class="hero"><h3>${I('sparkles')} AI FinOps Advisor — ${esc(advisor.question)}
922
1000
  <span class="spacer"></span>
923
1001
  <span class="note" style="font-weight:400">${esc(advisor.generated_from)}</span></h3>
924
1002
  <div class="advisor-list">${advisor.actions.length ? advisor.actions.map((a, i) =>
@@ -975,7 +1053,7 @@ VIEWS.advisor = async (page) => {
975
1053
  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
1054
  ${card('Anomalies to inspect', `<div class="stack" id="anolist">${anos.anomalies.map((a, i) =>
977
1055
  `<div class="item sev-${a.severity} clickable" data-i="${i}"><div class="hd">
978
- ${a.severity === 'high' ? '🚨' : '⚠️'} ${esc(a.title)}</div>
1056
+ ${I(a.severity === 'high' ? 'siren' : 'alert')} ${esc(a.title)}</div>
979
1057
  <div class="dt">${esc(a.detail)}</div>
980
1058
  <div class="mt"><span>ratio ${a.ratio}×</span><span>click to inspect</span></div></div>`).join('')
981
1059
  || '<div class="empty">No anomalies detected</div>'}</div>`, {badge: BADGE.estimated})}`;
@@ -1183,7 +1261,7 @@ VIEWS.models = async (page) => {
1183
1261
  const find = k => rows.find(r => r.model === sup[k]);
1184
1262
  page.innerHTML = `
1185
1263
  ${lc.unpriced_requests ? `<div class="hero">
1186
- <h3>⚠️ Some long-context requests are priced low</h3>
1264
+ <h3>${I('alert')} Some long-context requests are priced low</h3>
1187
1265
  <div class="stack">
1188
1266
  <div class="item sev-high">
1189
1267
  <div class="hd">${fmtInt(lc.unpriced_requests)} requests sent more context than their
@@ -1297,6 +1375,15 @@ VIEWS.projects = async (page) => {
1297
1375
  };
1298
1376
 
1299
1377
  /* ---------- sessions ---------- */
1378
+ // With a session token budget the dot follows it (whole-session tokens); without one,
1379
+ // it keeps flagging rows far above the loaded average.
1380
+ function sessFlag(r, avgTok) {
1381
+ if (!r.budget_tokens) return r.tokens > avgTok * 3 ? dot('red') + ' ' : '';
1382
+ const pct = 100 * (r.session_tokens || 0) / r.budget_tokens;
1383
+ if (pct < 75) return '';
1384
+ return `<span title="${fmtPct(pct)} of your ${fmtNum(r.budget_tokens)}-token session budget">${
1385
+ dot(pct >= 100 ? 'red' : 'yellow')}</span> `;
1386
+ }
1300
1387
  VIEWS.sessions = async (page) => {
1301
1388
  const order = S.sessOrder || 'cost';
1302
1389
  const sp = S.sessPage;
@@ -1318,7 +1405,9 @@ VIEWS.sessions = async (page) => {
1318
1405
  ['prompts', 'Most prompts'], ['recent', 'Most recent']].map(([k, l]) =>
1319
1406
  `<button class="chip ${order === k ? 'on' : ''}" data-so="${k}">${l}</button>`).join('')}
1320
1407
  <span class="spacer"></span>
1321
- <span class="note">rows above ${fmtNum(avgTok * 3)} tokens are unusually expensive</span>
1408
+ <span class="note">${rows.some(r => r.budget_tokens)
1409
+ ? 'dots mark sessions at 75% (amber) and 100% (red) of your session token budget'
1410
+ : `rows above ${fmtNum(avgTok * 3)} tokens are unusually expensive`}</span>
1322
1411
  </div><div id="st"></div>
1323
1412
  <div class="filters" style="margin-top:8px">
1324
1413
  <span class="note">Showing ${fmtInt(Math.min(rows.length, total))} of ${fmtInt(total)}</span>
@@ -1345,7 +1434,7 @@ VIEWS.sessions = async (page) => {
1345
1434
  if (sessMore) sessMore.onclick = () => { sp.offset += sp.limit; bust(); render(); };
1346
1435
  $('#st', page).innerHTML = table([
1347
1436
  {h: 'Session', trunc: 1, title: r => r.session_id,
1348
- f: r => `${r.tokens > avgTok * 3 ? '🔴 ' : ''}${esc(stitle(r))}
1437
+ f: r => `${sessFlag(r, avgTok)}${esc(stitle(r))}
1349
1438
  <div class="sub mono">${esc(shortId(r.session_id))}</div>`},
1350
1439
  {h: '', f: r => rowActs(r, live)},
1351
1440
  {h: 'Project', f: r => esc(r.project)},
@@ -1393,7 +1482,7 @@ VIEWS.prompts = async (page) => {
1393
1482
  page.innerHTML = `
1394
1483
  ${card('Prompt explorer', `
1395
1484
  <div class="filters" style="margin:0 0 8px">
1396
- <div class="search"><span class="mag">⌕</span>
1485
+ <div class="search"><span class="mag">${I('search')}</span>
1397
1486
  <input id="pq" value="${esc(q)}" placeholder="Search prompt text…"></div>
1398
1487
  <span class="divider"></span>
1399
1488
  ${[['cost', 'Most expensive'], ['tokens', 'Most tokens'], ['length', 'Longest'],
@@ -1686,7 +1775,7 @@ VIEWS.context = async (page) => {
1686
1775
  footer: 'Component costs are re-derived per model from config/pricing.json, since a request stores one blended cost.'});
1687
1776
  })()}
1688
1777
  ${ctx.large_context_cost_pct > 0 ? `<div class="hero">
1689
- <h3>⚠️ Context warnings</h3>
1778
+ <h3>${I('alert')} Context warnings</h3>
1690
1779
  <div class="stack">
1691
1780
  <div class="item sev-${ctx.large_context_cost_pct > 30 ? 'high' : 'medium'}">
1692
1781
  <div class="hd">${fmtPct(ctx.large_context_cost_pct)} of your estimated spend came from
@@ -1765,8 +1854,9 @@ VIEWS.waste = async (page) => {
1765
1854
  ${['high', 'medium', 'low'].map(sev => {
1766
1855
  const list = w.findings.filter(f => f.severity === sev);
1767
1856
  if (!list.length) return '';
1768
- const title = sev === 'high' ? '🔴 High waste'
1769
- : sev === 'medium' ? '🟡 Optimization opportunities' : '⚪ Low-priority observations';
1857
+ const title = sev === 'high' ? 'High waste'
1858
+ : sev === 'medium' ? 'Optimization opportunities' : 'Low-priority observations';
1859
+ const icon = dot(sev === 'high' ? 'red' : sev === 'medium' ? 'yellow' : 'grey');
1770
1860
  return card(title, `<div class="stack">${list.map((f, i) => `
1771
1861
  <div class="item sev-${sev}">
1772
1862
  <div class="hd">${esc(f.title)}<span class="spacer"></span>
@@ -1778,7 +1868,7 @@ VIEWS.waste = async (page) => {
1778
1868
  <details style="margin-top:5px"><summary style="cursor:pointer;font-size:11.5px;color:var(--s1)">
1779
1869
  Show ${f.evidence.length} flagged items</summary>
1780
1870
  <div class="ev" data-kind="${esc(f.kind)}" style="margin-top:6px"></div></details>
1781
- </div>`).join('')}</div>`, {badge: BADGE.estimated});
1871
+ </div>`).join('')}</div>`, {badge: BADGE.estimated, icon});
1782
1872
  }).join('')}
1783
1873
  <div class="note">${esc(w.note)}</div>`;
1784
1874
  // fill evidence tables
@@ -1923,6 +2013,71 @@ const usageBody = u => {
1923
2013
  <div class="note">${esc(u.note || '')}${u.cached ? ` · read ${dur(u.age_s)} ago` : ''}${u.stale
1924
2014
  ? ` · <b>last good read</b> (refresh failed: ${esc(u.error || '')})` : ''}</div>`;
1925
2015
  };
2016
+ /* ---------- one running session's own token limit ---------- */
2017
+ function limitBadge(sid) {
2018
+ const o = ((S.opts?.settings?.guard || {}).sessions || {})[sid];
2019
+ if (!o) return '';
2020
+ return o.off ? '<span class="pill" title="The session guard is off for this conversation">Guard off</span>'
2021
+ : `<span class="pill" data-limit-badge="${esc(sid)}" title="This conversation's own token limit">Limit ${esc(fmtNum(o.session_tokens))}</span>`;
2022
+ }
2023
+ // The Set limit panel: tokens used so far (as the guard counts them), the limit box with
2024
+ // suggestions from that number, and save / remove / turn off.
2025
+ async function drawLimit(box, x, i) {
2026
+ const sid = x.session_id;
2027
+ box.innerHTML = '<div class="note">Counting this conversation\'s tokens…</div>';
2028
+ let d;
2029
+ try {
2030
+ const r = await fetch(`/api/guard/session/${encodeURIComponent(sid)}`);
2031
+ d = await r.json();
2032
+ if (!r.ok) throw new Error(d.error || 'Could not read this session.');
2033
+ } catch (e) { box.innerHTML = `<div class="live-msg err">${esc(e.message)}</div>`; return; }
2034
+ const own = d.limit, id = `lim-${i}`, used = d.tokens || 0;
2035
+ const now = own?.off ? 'The guard is off for this conversation.'
2036
+ : own ? `This conversation's limit: <b>${fmtNum(own.session_tokens)}</b> (${fmtPct(100 * used / own.session_tokens, 0)} used).`
2037
+ : d.applies ? `No limit of its own yet. It uses the per-session budget from Budgets: <b>${fmtNum(d.applies)}</b>.`
2038
+ : 'No limit yet.';
2039
+ box.innerHTML = `<div class="cfg">
2040
+ <div class="fld-hint">Used so far: <b>${fmtNum(used)}</b> tokens. This counts everything the conversation has used,
2041
+ including re-reading its context at every step, so it is much bigger than the context size above.</div>
2042
+ ${d.installed ? '' : `<div class="lb-state warn" style="margin-top:6px">Live warnings are not installed, so a limit
2043
+ here won't do anything yet. <button class="act" data-lim-setup>Set up live warnings</button></div>`}
2044
+ <div class="fld-hint" style="margin-top:6px">${now}</div>
2045
+ ${amountField({id, label: 'Token limit for this conversation', kind: 'tokens', value: own?.session_tokens ?? null,
2046
+ chips: tokChips([[used * 1.1, '+10%'], [used * 1.25, '+25%'], [used * 1.5, '+50%'], [used * 2, '2×']], 3)})}
2047
+ <div class="fld-hint">When this conversation reaches its limit, Claude Code asks you "continue?" before its next
2048
+ step. Your warn percentages from Budgets apply too. The limit is forgotten after 30 days.</div>
2049
+ <div class="live-actions">
2050
+ <button class="act" data-lim-save>Save limit</button>
2051
+ ${own ? '<button class="act ghost" data-lim-rm>Remove limit</button>' : ''}
2052
+ ${own?.off ? '' : '<button class="act ghost" data-lim-off>Turn the guard off for this conversation</button>'}
2053
+ <span class="live-msg" role="status"></span>
2054
+ </div></div>`;
2055
+ const inp = $('#' + id, box), msg = box.querySelector('.live-msg');
2056
+ box.querySelectorAll('[data-fill]').forEach(c => c.onclick = () => {
2057
+ inp.value = shortAmount(+c.dataset.v); inp.dispatchEvent(new Event('input')); });
2058
+ inp.addEventListener('input', () => clearErr(box, id));
2059
+ const setup = box.querySelector('[data-lim-setup]');
2060
+ if (setup) setup.onclick = () => go('budgets');
2061
+ const send = async (body, done) => {
2062
+ try {
2063
+ await doAction(`session_limit/${encodeURIComponent(sid)}`, body);
2064
+ S.opts = await fetch('/api/options').then(r => r.json());
2065
+ const hd = box.closest('.item')?.querySelector('.hd');
2066
+ hd?.querySelectorAll('.pill[title]').forEach(p => p.remove());
2067
+ hd?.querySelector('.spacer')?.insertAdjacentHTML('beforebegin', limitBadge(sid));
2068
+ await drawLimit(box, x, i);
2069
+ const m = box.querySelector('.live-msg'); if (m) { m.classList.add('ok'); m.textContent = done; }
2070
+ } catch (e) { msg.classList.add('err'); msg.textContent = e.message; }
2071
+ };
2072
+ box.querySelector('[data-lim-save]').onclick = () => {
2073
+ const e = amountError(inp) || (inp.value.trim() ? '' : 'Type a limit, or tap a suggestion.');
2074
+ if (e) return setErr(box, id, e);
2075
+ send({session_tokens: parseAmount(inp.value, 'tokens').value}, 'Saved. It applies from the next step.');
2076
+ };
2077
+ box.querySelector('[data-lim-rm]')?.addEventListener('click', () => send({remove: true}, 'Removed.'));
2078
+ box.querySelector('[data-lim-off]')?.addEventListener('click', () => send({off: true}, 'The guard is off for this conversation.'));
2079
+ }
2080
+
1926
2081
  VIEWS.live = async (page) => {
1927
2082
  const [res, usage0] = await Promise.all([
1928
2083
  fetch('/api/live?agents=' + encodeURIComponent(S.filter.agents.join(','))).then(r => r.json()), // never cached: always live
@@ -1936,7 +2091,7 @@ VIEWS.live = async (page) => {
1936
2091
  page.innerHTML = `
1937
2092
  ${card('Plan usage right now', `<div id="live-usage">${usageBody(usage0)}</div>`,
1938
2093
  {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>'})}
2094
+ actions: `<button class="btn pb-copy" id="usage-refresh">${I('reload')} Refresh usage</button>`})}
1940
2095
  <div class="grid g4">
1941
2096
  ${kpi('Running sessions', fmtInt(list.length), `${busy.length} working right now`, {badge: BADGE.actual})}
1942
2097
  ${kpi('Working (spending now)', fmtInt(busy.length), 'Only these consume tokens right now')}
@@ -1951,13 +2106,14 @@ VIEWS.live = async (page) => {
1951
2106
  <b>Force kill</b> is for a session that won't close. Each button asks you to click twice.
1952
2107
  Codex, Gemini and Cursor keep no session registry, so they count as running when their transcript was
1953
2108
  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>
2109
+ <span class="spacer"></span><button class="btn pb-copy" id="live-refresh">${I('reload')} Refresh</button></div>
1955
2110
  <div class="stack">${list.map((x, i) => `
1956
2111
  <div class="item sev-${sevOf(x)}" data-i="${i}">
1957
2112
  <div class="hd">${x.status === 'busy' ? '<span class="live-dot"></span>' : '<span class="idle-dot"></span>'}
1958
2113
  <span class="pill">${esc(agentName(x.agent))}</span>
1959
2114
  ${esc((!MASKED && x.name) || shortId(x.session_id))} <span class="note">· ${esc(x.project)}${x.pid ? ` · pid ${x.pid}` : ''}${x.model ? ` · ${esc(x.model)}` : ''}</span>
1960
2115
  ${x.hosts_dashboard ? '<span class="status high">runs this dashboard</span>' : ''}
2116
+ ${x.agent === 'claude' ? limitBadge(x.session_id) : ''}
1961
2117
  <span class="spacer"></span>
1962
2118
  <span style="font-variant-numeric:tabular-nums">${x.context == null ? 'context not recorded' : fmtNum(x.context) + ' context'} · ${fmtInt(x.steps)} steps${x.est_cost_usd == null ? '' : ' · ' + fmtUSD(x.est_cost_usd)}</span></div>
1963
2119
  <div class="dt">${x.status === 'busy' ? '<b>Working now</b>' : 'Idle'}${x.uptime ? ` · up ${esc(x.uptime)}` : ''} ·
@@ -1967,12 +2123,13 @@ VIEWS.live = async (page) => {
1967
2123
  ? 'Very large context. Use <b>Hand over</b> to continue in a fresh session, or split the remaining work into sub-sessions.'
1968
2124
  : 'Getting heavy. Hit <b>Compact</b> at the next break, or close it if the task is done.'}</div>` : ''}
1969
2125
  <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>`}
2126
+ ${x.signalable ? `${canInterrupt ? `<button class="act" data-a="interrupt" ${x.status !== 'busy' ? 'disabled title="Nothing running"' : ''}>${I('pause')} Interrupt</button>` : ''}
2127
+ ${x.agent === 'claude' ? '<button class="act" data-a="compact" title="Types /compact into that session\'s terminal">' + I('compact') + ' Compact</button>' : ''}
2128
+ <button class="act warn" data-a="close">${I('stop')} Close session</button>
2129
+ <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
2130
  ${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>' : ''}
2131
+ ${x.agent === 'claude' ? '<button class="act ghost" data-ho="1">' + I('handover') + ' Hand over / split</button>' : ''}
2132
+ ${x.agent === 'claude' && x.session_id ? '<button class="act ghost" data-lim="1">' + I('gauge') + ' Set limit</button>' : ''}
1976
2133
  <span class="live-msg"></span>
1977
2134
  </div>
1978
2135
  ${x.agent !== 'claude' ? '' : `<div class="handover" hidden>
@@ -1987,6 +2144,7 @@ VIEWS.live = async (page) => {
1987
2144
  </div>
1988
2145
  <div class="ho-out note"></div>
1989
2146
  </div>`}
2147
+ ${x.agent === 'claude' && x.session_id ? `<div class="handover limitbox" hidden></div>` : ''}
1990
2148
  </div>`).join('') || `<div class="empty">No ${esc(agentWord())} sessions are running</div>`}</div>`;
1991
2149
  $('#live-refresh', page).onclick = () => render();
1992
2150
  const ub = $('#usage-refresh', page);
@@ -1996,7 +2154,7 @@ VIEWS.live = async (page) => {
1996
2154
  const u = await fetch('/api/usage?refresh=1').then(r => r.json());
1997
2155
  $('#live-usage', page).innerHTML = usageBody(u);
1998
2156
  } catch (e) { $('#live-usage', page).innerHTML = `<div class="empty">${esc(e.message)}</div>`; }
1999
- ub.disabled = false; ub.textContent = '↻ Refresh usage';
2157
+ ub.disabled = false; ub.innerHTML = `${I('reload')} Refresh usage`;
2000
2158
  };
2001
2159
  page.querySelectorAll('.item[data-i]').forEach(row => {
2002
2160
  const x = list[+row.dataset.i], pid = x.pid, msg = row.querySelector('.live-msg');
@@ -2010,7 +2168,7 @@ VIEWS.live = async (page) => {
2010
2168
  const r = await sessionAction(pid, 'handover', {tasks: ta.value.split('\n'), launch,
2011
2169
  close: launch && ho.querySelector('.ho-close').checked});
2012
2170
  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('')
2171
+ `<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
2172
  : `<div class="live-msg err">Failed: ${esc(r.error)}</div>`;
2015
2173
  if (r.ok && r.closed && r.closed.exited) { row.classList.add('gone'); setTimeout(() => render(), 2500); }
2016
2174
  } catch (e) { hoOut.innerHTML = `<div class="live-msg err">Failed: ${esc(e.message)}</div>`; }
@@ -2022,6 +2180,11 @@ VIEWS.live = async (page) => {
2022
2180
  }
2023
2181
  row.querySelectorAll('button.act:not(.ho-go):not(.ho-brief)').forEach(b => {
2024
2182
  if (b.dataset.ho) { b.onclick = () => { ho.hidden = !ho.hidden; if (!ho.hidden) ta.focus(); }; return; }
2183
+ if (b.dataset.lim) {
2184
+ const box = row.querySelector('.limitbox');
2185
+ b.onclick = () => { box.hidden = !box.hidden; if (!box.hidden) drawLimit(box, x, +row.dataset.i); };
2186
+ return;
2187
+ }
2025
2188
  if (b.dataset.copy) { b.onclick = async () => { try { await navigator.clipboard.writeText(b.dataset.copy); msg.textContent = 'Copied'; } catch { msg.textContent = b.dataset.copy; } }; return; }
2026
2189
  const label = b.textContent;
2027
2190
  b.onclick = async () => {
@@ -2064,12 +2227,12 @@ const pb = x => {
2064
2227
  <ol>${x.steps.map(t => `<li>${esc(t)}</li>`).join('')}</ol>${promptHtml}</details>`;
2065
2228
  };
2066
2229
  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 ✓'; }
2230
+ try { await navigator.clipboard.writeText(PB[+b.dataset.pb]); b.innerHTML = `Copied ${I('check')}`; }
2068
2231
  catch { b.textContent = 'Copy failed'; }
2069
2232
  setTimeout(() => b.textContent = 'Copy', 1500);
2070
2233
  });
2071
- const HARNESS = {needed: ['🔴 Needed', 'high'], partial: ['🟡 Partial', 'medium'],
2072
- in_place: ['✅ In place', 'low'], not_needed: ['⚪ Not needed', 'low'], unknown: ['— Unknown', 'low']};
2234
+ const HARNESS = {needed: [dot('red') + ' Needed', 'high'], partial: [dot('yellow') + ' Partial', 'medium'],
2235
+ in_place: [I('checkCircle') + ' In place', 'low'], not_needed: [dot('grey') + ' Not needed', 'low'], unknown: ['— Unknown', 'low']};
2073
2236
  const harnessChip = h => h ? `<b>${HARNESS[h.verdict][0]}</b>` : '—';
2074
2237
 
2075
2238
  VIEWS.diagnose = async (page) => {
@@ -2078,7 +2241,7 @@ VIEWS.diagnose = async (page) => {
2078
2241
  const gdir = {claude: '~/.claude', codex: '~/.codex', gemini: '~/.gemini'}[d.agent || 'claude'];
2079
2242
  PB.length = 0;
2080
2243
  const t = d.total || {}, tr = d.trend || {};
2081
- const sevChip = s => s === 'high' ? '🔴' : s === 'medium' ? '🟡' : '⚪';
2244
+ const sevChip = s => dot(s === 'high' ? 'red' : s === 'medium' ? 'yellow' : 'grey');
2082
2245
  page.innerHTML = `${focusStrip(d)}
2083
2246
  <div class="note" style="font-size:14px"><b>${esc(d.headline)}</b>
2084
2247
  ${tr.explanation ? `<br>${esc(tr.explanation)}` : ''}</div>
@@ -2091,7 +2254,7 @@ VIEWS.diagnose = async (page) => {
2091
2254
  </div>
2092
2255
  <div id="dx-live"></div>${card('Running now', d.live_sessions.length ? `<div class="stack">${d.live_sessions.map(x => `
2093
2256
  <div class="item sev-${x.severity === 'ok' ? 'low' : x.severity}">
2094
- <div class="hd">${x.severity === 'high' ? '🔴' : x.severity === 'medium' ? '🟡' : '🟢'}
2257
+ <div class="hd">${dot(x.severity === 'high' ? 'red' : x.severity === 'medium' ? 'yellow' : 'green')}
2095
2258
  ${esc(stitle(x))} <span class="note">· ${esc(x.project)}</span><span class="spacer"></span>
2096
2259
  <span style="font-variant-numeric:tabular-nums">${fmtNum(x.context)} context · ${fmtInt(x.steps)} steps</span></div>
2097
2260
  <div class="dt">${esc(x.advice)} <span class="note">Last write ${x.idle_min} min ago; context grew
@@ -2109,7 +2272,7 @@ VIEWS.diagnose = async (page) => {
2109
2272
  {badge: BADGE.recommendation, hint: '"Above baseline" = context re-read beyond 100K'})}
2110
2273
  <div id="dx-mem"></div>${card(`Add to ${md}${isCl ? ' / memory' : ''} (from your past prompts)`, `<div class="stack">${d.memory_suggestions.map(x => `
2111
2274
  <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' ? '🔗' : '📝'}
2275
+ <div class="hd">${I(x.kind === 'security' ? 'lock' : x.kind === 'template' ? 'cog' : x.kind === 'reference' ? 'link' : 'note')}
2113
2276
  ${esc(pt(x.text.slice(0, 140)))}${x.already_saved ? ' <span class="na">already saved</span>' : ''}
2114
2277
  <span class="spacer"></span><span class="note">${fmtInt(x.sessions)} sessions</span></div>
2115
2278
  <div class="dt">${esc(x.why)}</div>
@@ -2152,7 +2315,7 @@ VIEWS.diagnose = async (page) => {
2152
2315
  ...(isCl ? [{h: 'Memory', num: 1, f: r => r.memory_tokens == null ? '—' : `~${fmtInt(r.memory_tokens)}`}] : []),
2153
2316
  {h: 'Explore %', num: 1, f: r => fmtPct(r.explore_pct)},
2154
2317
  ...(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>') : '✓'},
2318
+ {h: 'Needs update', f: r => r.issues.length ? r.issues.map(i => sevChip(i.severity) + ' ' + esc(i.title)).join('<br>') : I('check')},
2156
2319
  ];
2157
2320
  page.querySelectorAll('.sess-link').forEach(l => l.onclick = e => { e.preventDefault(); openSession(l.dataset.sess); });
2158
2321
  $('#dx-proj', page).innerHTML = table(cols, d.projects);
@@ -2191,7 +2354,7 @@ VIEWS.anomalies = async (page) => {
2191
2354
  '<div class="legend" id="anl"></div>', {badge: BADGE.estimated})}
2192
2355
  ${card('Detected anomalies', `<div class="stack" id="anolist">${a.anomalies.length
2193
2356
  ? 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)}
2357
+ <div class="hd">${I(x.severity === 'high' ? 'siren' : 'alert')} ${esc(x.title)}
2195
2358
  <span class="spacer"></span><span class="pill">${esc(x.type)}</span></div>
2196
2359
  <div class="dt">${esc(x.detail)}</div>
2197
2360
  <div class="mt"><span>observed ${fmtNum(x.metric_value)}</span>
@@ -2208,7 +2371,7 @@ VIEWS.anomalies = async (page) => {
2208
2371
  const bars = $('#an', page).querySelectorAll('rect');
2209
2372
  tl.forEach((r, i) => { if (dates.has(r.bucket) && bars[i]) bars[i].setAttribute('fill', 'var(--critical)'); });
2210
2373
  $('#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>`;
2374
+ <span class="it"><span class="swatch" style="background:var(--critical)"></span>${I('siren')} Anomalous day</span>`;
2212
2375
  wireAnomalies(page, a.anomalies);
2213
2376
  };
2214
2377
 
@@ -2230,7 +2393,7 @@ VIEWS.forecast = async (page) => {
2230
2393
  ${f.limit_exhaustion_date === S.opts.unavailable_label
2231
2394
  ? kpi('Limit exhaustion date', null, 'Requires a configured allowance')
2232
2395
  : kpi('Limit exhaustion date', esc(f.limit_exhaustion_date),
2233
- f.will_exceed ? '🔴 forecast exceeds allowance' : '🟢 within allowance', {badge: BADGE.forecast})}
2396
+ f.will_exceed ? dot('red') + ' forecast exceeds allowance' : dot('green') + ' within allowance', {badge: BADGE.forecast})}
2234
2397
  </div>
2235
2398
  ${card('Cumulative spend and forecast fan', f.insufficient_history
2236
2399
  ? '<div class="empty">Fewer than 7 priced days in the window: bands not shown.</div>'
@@ -2255,30 +2418,406 @@ VIEWS.forecast = async (page) => {
2255
2418
  };
2256
2419
 
2257
2420
  /* ---------- budgets ---------- */
2421
+ /* ---------- budgets form helpers ---------- */
2422
+ // "20M", "500k", "$3,000", "2.5B" -> number; blank -> null. kind: 'tokens' | 'usd' | 'count'
2423
+ function parseAmount(raw, kind) {
2424
+ const s = String(raw ?? '').trim().replace(/[,\s_]/g, '').replace(/^\$/, '');
2425
+ if (s === '') return {value: null};
2426
+ const m = /^(\d+(?:\.\d+)?|\.\d+)([kmb])?$/i.exec(s);
2427
+ if (!m) return {error: kind === 'usd' ? 'Enter an amount like 3000, $3,000 or 3k.'
2428
+ : 'Enter a number like 20M, 500k or 2,000,000.'};
2429
+ const v = parseFloat(m[1]) * ({k: 1e3, m: 1e6, b: 1e9}[(m[2] || '').toLowerCase()] || 1);
2430
+ return {value: kind === 'usd' ? Math.round(v * 100) / 100 : Math.round(v)};
2431
+ }
2432
+ // Round up to `sig` significant figures (two by default), so suggestions are round numbers.
2433
+ const niceUp = (v, sig = 2) => {
2434
+ if (!(v > 0)) return 0;
2435
+ const p = 10 ** (Math.floor(Math.log10(v)) - sig + 1);
2436
+ return Math.ceil(v / p - 1e-9) * p;
2437
+ };
2438
+ // 8400000 -> "8.4M": compact, and parseAmount reads it back exactly for round numbers.
2439
+ function shortAmount(v) {
2440
+ const u = [[1e9, 'B'], [1e6, 'M'], [1e3, 'k']].find(([d]) => Math.abs(v) >= d);
2441
+ return u ? +(v / u[0]).toFixed(2) + u[1] : String(Math.round(v));
2442
+ }
2443
+ // items: a number, or [value, label, title]; data-driven ones are rounded up, zeros dropped
2444
+ function suggChips(items, fmt, sig = 2) {
2445
+ const seen = new Set();
2446
+ return items.map(it => Array.isArray(it) ? {v: niceUp(it[0], sig), l: it[1], t: it[2]} : {v: it})
2447
+ .filter(c => c.v > 0 && !seen.has(c.v) && seen.add(c.v))
2448
+ .map(c => ({...c, l: c.l ? `${c.l} · ${fmt(c.v)}` : fmt(c.v)}));
2449
+ }
2450
+ const usdChips = items => suggChips(items, v => '$' + shortAmount(v));
2451
+ const tokChips = (items, sig) => suggChips(items, shortAmount, sig);
2452
+
2453
+ function amountField({id, label, value, kind, zero, chips = [], hint = ''}) {
2454
+ const shown = value == null ? '' : kind === 'usd' ? String(value) : Number(value).toLocaleString('en-US');
2455
+ return `<div class="fld">
2456
+ <div class="hd"><label for="${id}">${label}</label></div>
2457
+ <input type="text" inputmode="decimal" id="${id}" data-kind="${kind}" ${zero ? 'data-zero="1"' : ''}
2458
+ value="${esc(shown)}" placeholder="not set" autocomplete="off" spellcheck="false"
2459
+ aria-describedby="${id}-hint ${id}-err">
2460
+ ${chips.length ? `<div class="sugg" aria-label="Suggestions">${chips.map(c =>
2461
+ `<button type="button" class="chip" data-fill="${id}" data-v="${c.v}" title="${esc(c.t || 'Use this value')}">${esc(c.l)}</button>`).join('')}</div>` : ''}
2462
+ <div class="fld-hint" id="${id}-hint">${hint}</div>
2463
+ <div class="fld-err" id="${id}-err" role="alert"></div>
2464
+ </div>`;
2465
+ }
2466
+ // The reason a typed amount is not acceptable, or '' when it is (blank is always fine).
2467
+ function amountError(el) {
2468
+ const r = parseAmount(el.value, el.dataset.kind);
2469
+ if (r.error) return r.error;
2470
+ if (r.value == null) return '';
2471
+ if (r.value < 0) return 'Cannot be negative.';
2472
+ if (r.value === 0 && !el.dataset.zero) return 'Must be more than 0, or leave it blank.';
2473
+ return '';
2474
+ }
2475
+ // Toggle chips for a list of percentages, plus a box to add another one.
2476
+ function pctField({id, label, values, presets, max}) {
2477
+ const all = [...new Set([...presets, ...values])].sort((a, b) => a - b);
2478
+ return `<div class="fld"><div class="hd">${label}</div>
2479
+ <div class="sugg" id="${id}" data-max="${max}">${all.map(p =>
2480
+ `<button type="button" class="chip ${values.includes(p) ? 'on' : ''}" data-pct="${p}" aria-pressed="${values.includes(p)}">${p}%</button>`).join('')}
2481
+ <input type="text" inputmode="decimal" class="pct-add" placeholder="+ other %" aria-label="Add another percentage"
2482
+ style="width:92px;padding:2px 8px;border-radius:99px">
2483
+ </div>
2484
+ <div class="fld-err" id="${id}-err" role="alert"></div></div>`;
2485
+ }
2486
+ function wirePct(page) {
2487
+ page.querySelectorAll('.cfg [data-max]').forEach(box => {
2488
+ const toggle = b => b.onclick = () => {
2489
+ b.classList.toggle('on'); b.setAttribute('aria-pressed', b.classList.contains('on'));
2490
+ clearErr(page, box.id);
2491
+ };
2492
+ box.querySelectorAll('[data-pct]').forEach(toggle);
2493
+ const add = box.querySelector('.pct-add');
2494
+ const commit = () => {
2495
+ const raw = add.value.trim().replace(/%$/, '');
2496
+ if (!raw) return;
2497
+ const v = +raw, max = +box.dataset.max;
2498
+ if (!Number.isFinite(v) || v < 1 || v > max) return setErr(page, box.id, `Enter a percentage from 1 to ${max}.`);
2499
+ let b = box.querySelector(`[data-pct="${v}"]`);
2500
+ if (!b) {
2501
+ b = h(`<button type="button" class="chip" data-pct="${v}" aria-pressed="false">${v}%</button>`);
2502
+ const after = [...box.querySelectorAll('[data-pct]')].find(x => +x.dataset.pct > v);
2503
+ box.insertBefore(b, after || add);
2504
+ toggle(b);
2505
+ }
2506
+ if (!b.classList.contains('on')) b.click();
2507
+ add.value = '';
2508
+ clearErr(page, box.id);
2509
+ };
2510
+ add.onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); commit(); } };
2511
+ add.onblur = commit;
2512
+ });
2513
+ }
2514
+ function setErr(page, id, text) {
2515
+ const e = $('#' + id + '-err', page); if (e) e.textContent = text;
2516
+ const el = $('#' + id, page) || $('#' + id + '-tok', page);
2517
+ if (el && el.tagName !== 'DIV') el.classList.add('bad');
2518
+ }
2519
+ function clearErr(page, id) {
2520
+ const e = $('#' + id + '-err', page); if (e) e.textContent = '';
2521
+ [$('#' + id, page), $('#' + id + '-tok', page), $('#' + id + '-path', page)]
2522
+ .forEach(el => el && el.classList.remove('bad'));
2523
+ }
2524
+
2525
+ // API keys: status only ever comes back from the server, never the key itself.
2526
+ async function drawKeys(page) {
2527
+ const host = $('#cfg-keys', page);
2528
+ if (!host) return;
2529
+ let ks;
2530
+ try { ks = await fetch('/api/keys').then(r => r.json()); }
2531
+ catch (e) { host.innerHTML = '<div class="note">Could not read the key status.</div>'; return; }
2532
+ host.innerHTML = Object.entries(ks).map(([pid, k]) => `
2533
+ <div class="item">
2534
+ <div class="hd">${esc(k.name)}<span class="spacer"></span>
2535
+ <span class="badge ${k.source ? '' : 'na'}">${k.source === 'env' ? 'set by $' + esc(k.env)
2536
+ : k.source === 'stored' ? 'stored' + (k.last4 ? ' · ends ' + esc(k.last4) : '') : 'not set'}</span></div>
2537
+ <div class="dt"><b>Gives you:</b> ${esc(k.covers)}</div>
2538
+ <div class="dt"><b>Get a key:</b> ${esc(k.how)}</div>
2539
+ ${k.source === 'env'
2540
+ ? `<div class="fld-hint">The ${esc(k.env)} environment variable takes priority. Unset it to manage the key here.</div>`
2541
+ : `<div style="display:flex;gap:6px;align-items:center;margin-top:6px">
2542
+ <input type="password" id="key-${pid}" autocomplete="off" spellcheck="false" style="flex:1;min-width:0"
2543
+ aria-label="${esc(k.name)} key" placeholder="${k.source ? 'paste a new key to replace it' : 'paste the key'}">
2544
+ <button class="act" data-keysave="${pid}">Save key</button>
2545
+ ${k.source === 'stored' ? `<button class="act" data-keyrm="${pid}">Remove</button>` : ''}
2546
+ </div>`}
2547
+ <div class="fld-err" id="key-${pid}-err" role="alert"></div>
2548
+ <div class="fld-hint ok" id="key-${pid}-ok" role="status"></div>
2549
+ </div>`).join('');
2550
+ const done = async (pid, text) => {
2551
+ S.opts = await fetch('/api/options').then(r => r.json());
2552
+ applyAgentChrome(); // Billed vs local appears once a key exists
2553
+ await drawKeys(page);
2554
+ const ok = $('#key-' + pid + '-ok', page); if (ok) ok.textContent = text;
2555
+ };
2556
+ host.querySelectorAll('[data-keysave]').forEach(b => {
2557
+ const pid = b.dataset.keysave, inp = $('#key-' + pid, host), err = $('#key-' + pid + '-err', host);
2558
+ inp.oninput = () => { err.textContent = ''; inp.classList.remove('bad'); };
2559
+ inp.onkeydown = e => { if (e.key === 'Enter') b.click(); };
2560
+ b.onclick = async () => {
2561
+ if (!inp.value.trim()) { err.textContent = 'Paste a key first.'; inp.classList.add('bad'); return; }
2562
+ b.disabled = true;
2563
+ try {
2564
+ await doAction('key/' + pid, {value: inp.value});
2565
+ inp.value = '';
2566
+ await done(pid, 'Saved. Billed vs local uses it on the next Refresh.');
2567
+ } catch (e) { err.textContent = e.message; inp.classList.add('bad'); b.disabled = false; }
2568
+ };
2569
+ });
2570
+ host.querySelectorAll('[data-keyrm]').forEach(b => b.onclick = async () => {
2571
+ if (!b.dataset.armed) { // two clicks, like the other destructive actions
2572
+ b.dataset.armed = '1'; b.textContent = 'Click again to remove';
2573
+ setTimeout(() => { if (b.isConnected) { delete b.dataset.armed; b.textContent = 'Remove'; } }, 4000);
2574
+ return;
2575
+ }
2576
+ const pid = b.dataset.keyrm;
2577
+ try { await doAction('key/' + pid, {remove: true}); await done(pid, 'Removed.'); }
2578
+ catch (e) { const err = $('#key-' + pid + '-err', host); if (err) err.textContent = e.message; }
2579
+ });
2580
+ }
2581
+
2582
+ /* ---------- guides: picture slides that explain a page ---------- */
2583
+ const GUIDES = {};
2584
+ let GUIDE = null; // {name, i, opener, el}
2585
+ const guideSeenKey = name => `finops.guide.${name}.seen`;
2586
+
2587
+ function openGuide(name, slideId, opener) {
2588
+ const slides = GUIDES[name];
2589
+ if (!slides || !slides.length) return;
2590
+ closeGuide();
2591
+ const el = document.createElement('div');
2592
+ el.className = 'guide-layer';
2593
+ el.innerHTML = `<div class="guide" role="dialog" aria-modal="true" aria-labelledby="guide-title" tabindex="-1"></div>`;
2594
+ document.body.appendChild(el);
2595
+ el.addEventListener('click', e => { if (e.target === el) closeGuide(); });
2596
+ GUIDE = {name, i: Math.max(0, slides.findIndex(s => s.id === slideId)), opener: opener || document.activeElement, el};
2597
+ document.addEventListener('keydown', guideKeys, true);
2598
+ try { localStorage.setItem(guideSeenKey(name), '1'); } catch (_) {}
2599
+ drawGuide();
2600
+ }
2601
+ function closeGuide() {
2602
+ if (!GUIDE) return;
2603
+ const {el, opener} = GUIDE;
2604
+ GUIDE = null;
2605
+ document.removeEventListener('keydown', guideKeys, true);
2606
+ el.remove();
2607
+ if (opener && opener.isConnected && opener.focus) opener.focus();
2608
+ }
2609
+ function guideKeys(e) {
2610
+ if (!GUIDE) return;
2611
+ e.stopPropagation();
2612
+ const slides = GUIDES[GUIDE.name];
2613
+ if (e.key === 'Escape') { e.preventDefault(); closeGuide(); }
2614
+ else if (e.key === 'ArrowRight' && GUIDE.i < slides.length - 1) { e.preventDefault(); GUIDE.i++; drawGuide(); }
2615
+ else if (e.key === 'ArrowLeft' && GUIDE.i > 0) { e.preventDefault(); GUIDE.i--; drawGuide(); }
2616
+ else if (e.key === 'Tab') { // keep focus inside the dialog
2617
+ const f = [...GUIDE.el.querySelectorAll('button:not([disabled]), a[href]')];
2618
+ if (!f.length) return;
2619
+ const first = f[0], last = f[f.length - 1];
2620
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
2621
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
2622
+ }
2623
+ }
2624
+ function drawGuide() {
2625
+ const slides = GUIDES[GUIDE.name], s = slides[GUIDE.i], n = slides.length, last = GUIDE.i === n - 1;
2626
+ const box = GUIDE.el.querySelector('.guide');
2627
+ box.innerHTML = `
2628
+ <header><span class="g-step">${GUIDE.i + 1} of ${n}</span>
2629
+ <h2 id="guide-title">${esc(s.title)}</h2>
2630
+ <button class="act g-x" data-g="close" aria-label="Close guide">${I('x')}</button></header>
2631
+ <div class="g-pic">${s.img
2632
+ ? `<span class="g-fig"><img src="${esc(s.img)}" alt="${esc(s.alt || '')}">${(s.marks || []).map(m =>
2633
+ `<span class="g-mark" style="left:${m.x}%;top:${m.y}%">${m.n}</span>`).join('')}</span>`
2634
+ : '<div class="g-ex">Example</div><div class="g-live"></div>'}</div>
2635
+ <div class="g-text" aria-live="polite">${s.text.map(t => `<p>${t}</p>`).join('')}</div>
2636
+ <footer>
2637
+ <div class="g-dots" aria-hidden="true">${slides.map((_, i) => `<i class="${i === GUIDE.i ? 'on' : ''}"></i>`).join('')}</div>
2638
+ <span class="spacer"></span>
2639
+ ${s.target ? '<button class="act" data-g="show">Show me on the page</button>' : ''}
2640
+ <button class="act" data-g="back" ${GUIDE.i ? '' : 'disabled'}>Back</button>
2641
+ <button class="chip on" data-g="${last ? 'close' : 'next'}">${last ? 'Done' : 'Next'}</button>
2642
+ </footer>`;
2643
+ if (s.live) s.live(box.querySelector('.g-live'));
2644
+ box.querySelectorAll('[data-g]').forEach(b => b.onclick = () => {
2645
+ const a = b.dataset.g;
2646
+ if (a === 'close') closeGuide();
2647
+ else if (a === 'next') { GUIDE.i++; drawGuide(); }
2648
+ else if (a === 'back') { GUIDE.i--; drawGuide(); }
2649
+ else if (a === 'show') { const t = s.target; closeGuide(); showMe(t); }
2650
+ });
2651
+ (box.querySelector('[data-g="next"], [data-g="close"]:not(.g-x)') || box).focus();
2652
+ }
2653
+ function showMe(selector) {
2654
+ const t = $(selector);
2655
+ if (!t) return;
2656
+ if (t.tagName === 'DETAILS') t.open = true;
2657
+ t.scrollIntoView({block: 'center', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'});
2658
+ t.classList.remove('pulse'); void t.offsetWidth; t.classList.add('pulse');
2659
+ setTimeout(() => t.classList.remove('pulse'), 2200);
2660
+ }
2661
+ // First visit to a page with a guide: open it once. Never on top of the welcome tour.
2662
+ function maybeGuide(name) {
2663
+ let seen = true;
2664
+ try { seen = localStorage.getItem(guideSeenKey(name)) === '1'; } catch (_) {}
2665
+ if (seen || !GUIDES[name]) return;
2666
+ const tryOpen = () => {
2667
+ if (S.view !== name || GUIDE) return;
2668
+ try { if (localStorage.getItem(guideSeenKey(name)) === '1') return; } catch (_) {}
2669
+ if (TOUR) return setTimeout(tryOpen, 800); // wait for the welcome tour to finish
2670
+ openGuide(name);
2671
+ };
2672
+ setTimeout(tryOpen, 400);
2673
+ }
2674
+
2675
+ // A frozen copy of a form block for a guide picture: demo values, no ids that clash.
2676
+ function guideCopy(host, html) {
2677
+ host.innerHTML = `<div class="cfg">${html}</div>`;
2678
+ host.querySelectorAll('[id]').forEach(el => el.id = 'gcopy-' + el.id);
2679
+ host.querySelectorAll('[for]').forEach(el => el.setAttribute('for', 'gcopy-' + el.getAttribute('for')));
2680
+ host.querySelectorAll('[aria-describedby]').forEach(el => el.setAttribute('aria-describedby',
2681
+ el.getAttribute('aria-describedby').split(/\s+/).filter(Boolean).map(t => 'gcopy-' + t).join(' ')));
2682
+ host.querySelectorAll('input, select, button, textarea').forEach(el => { el.disabled = true; el.tabIndex = -1; });
2683
+ }
2684
+ const DEMO = {spend: 312, day: 14, busy: 29, tokens: 7.4e8, typical: 8.5e6, large: 3.3e7};
2685
+
2686
+ GUIDES.budgets = [
2687
+ {id: 'overview', title: 'What is this page?', img: 'guide/budgets.png',
2688
+ alt: 'The Budgets page: your results on one side, your limits on the other',
2689
+ marks: [{n: 1, x: 30, y: 8.5}, {n: 2, x: 96, y: 26}, {n: 3, x: 94, y: 51.5}],
2690
+ text: ['This page helps you stop spending too much on AI.',
2691
+ '<b>①</b> The results show how you are doing so far. <b>②</b> Here you set your money and token limits. <b>③</b> The session limit stops one conversation from getting too big. (On the real page the results are at the top and the limits are below them.)',
2692
+ 'You only need to fill in what you care about. Everything else can stay empty.'],
2693
+ target: '[data-blk="money"]'},
2694
+ {id: 'money', title: '① Money limits', target: '[data-blk="money"]',
2695
+ live: h => guideCopy(h, `<div class="grid g2">
2696
+ ${amountField({id: 'b-monthly', label: 'Monthly budget (USD)', kind: 'usd', value: 400,
2697
+ chips: usdChips([[DEMO.spend, 'Last 30 days'], 250, 500])})}
2698
+ ${amountField({id: 'b-daily', label: 'Daily budget (USD)', kind: 'usd', value: null,
2699
+ chips: usdChips([[DEMO.day, 'Average day'], [DEMO.busy, 'Busy day']])})}</div>`),
2700
+ text: ['Type the most money you want to spend in a month, or in a day.',
2701
+ 'Not sure? Tap a suggestion. They come from your own recent spending.',
2702
+ 'We warn you as you get close. Leave a box empty if you don\'t need it.']},
2703
+ {id: 'tokens', title: '② Token limits', target: '[data-blk="tokens"]',
2704
+ live: h => guideCopy(h, amountField({id: 'b-tokens', label: 'Monthly token budget', kind: 'tokens',
2705
+ value: 9e8, chips: tokChips([[DEMO.tokens, 'Last 30 days'], 1e9, 5e9])})),
2706
+ text: ['A token is a small piece of text, about ¾ of a word. Claude counts all its work in tokens.',
2707
+ 'Set how many tokens you want to use in a month. You can type short numbers like <b>900M</b> or <b>2B</b>.']},
2708
+ {id: 'session', title: '③ Session limit', target: '[data-blk="session"]',
2709
+ live: h => guideCopy(h, `<div class="grid g2">
2710
+ ${amountField({id: 'g-tokens', label: 'Per-session token budget', kind: 'tokens', value: 8e6,
2711
+ chips: tokChips([[DEMO.typical, 'Typical'], [DEMO.large, 'Large'], 5e6, 10e6])})}
2712
+ ${pctField({id: 'g-warn', label: 'Warn me at', values: [75, 80], presets: [50, 60, 70, 75, 80, 90], max: 99})}</div>`),
2713
+ text: ['A session is one Claude Code conversation.',
2714
+ 'Long conversations cost more, because Claude re-reads everything each time.',
2715
+ 'Set a limit for one conversation. Then pick when you want a warning, like at 75% and 80%.']},
2716
+ {id: 'after', title: 'After you say "continue", and project limits', target: '#g-projects',
2717
+ live: h => guideCopy(h, `<div class="grid g2">
2718
+ <div class="fld"><div class="hd">After I say "continue"</div>
2719
+ <label style="display:flex;gap:6px;margin:6px 0"><input type="radio" checked> Ask again every +25%</label>
2720
+ <label style="display:flex;gap:6px;margin:6px 0"><input type="radio"> Once per session</label></div>
2721
+ <div class="fld"><div class="hd">Different limit for a project</div>
2722
+ <div style="display:flex;gap:6px;align-items:center"><select><option>shop-app</option></select>
2723
+ <input type="text" value="15M" style="width:90px"><label><input type="checkbox"> off</label></div></div></div>`),
2724
+ text: ['When a conversation reaches its limit, you can let it keep going.',
2725
+ 'Choose if we ask you again a bit later, or never again for that conversation.',
2726
+ 'A big project can have its own, bigger limit. Or you can turn the limit off for it.']},
2727
+ {id: 'live', title: 'Live warnings (optional)', target: '#livebox',
2728
+ live: h => { h.innerHTML = `<div class="cfg"><div class="livebox"><div class="lb-head"><b>Live warnings in Claude Code</b>
2729
+ <span class="blk-sub">(optional)</span><span class="spacer"></span><span class="lb-state">○ Not installed</span>
2730
+ <button class="act" disabled>Install</button></div></div>
2731
+ <div class="g-cc">Session guard: this session has used 8.4M tokens, 105% of its 8M budget.
2732
+ Allow this tool call? <b>❯ Yes</b> &nbsp; No</div></div>`; },
2733
+ text: ['<b>You don\'t need this for your limits to work.</b>',
2734
+ 'Install it if you want Claude Code itself to warn you while you work, and to ask "continue?" when a conversation reaches its limit. The dark box shows what that looks like.',
2735
+ 'You can uninstall it any time.']},
2736
+ {id: 'advanced', title: 'Advanced (you can skip this)', target: '[data-blk="advanced"]',
2737
+ live: h => guideCopy(h, `<div class="grid g2">
2738
+ ${amountField({id: 'l-cost', label: 'Monthly cost allowance (USD)', kind: 'usd', zero: 1, value: null})}
2739
+ ${pctField({id: 't-thr', label: 'Warn when a budget reaches', values: [50, 75, 90, 100], presets: [25, 50, 75, 90, 100, 110], max: 1000})}</div>`),
2740
+ text: ['<b>Plan limits:</b> only fill these in if you know your plan\'s real numbers.',
2741
+ '<b>Alert thresholds:</b> when a money or token limit reaches these percentages, it gets marked.',
2742
+ 'Most people never need to change these.']},
2743
+ {id: 'save', title: 'Save, then check your results', target: '#savecfg',
2744
+ live: h => { h.innerHTML = `<div class="cfg"><div class="save-row"><button class="chip on" disabled>Save</button>
2745
+ <span class="ok">Saved.</span></div>
2746
+ <div class="meter high" style="margin:8px 0"><i style="width:82%"></i></div>
2747
+ <div class="fld-hint">Monthly spend · 82% used</div>
2748
+ <div style="margin-top:8px">${dot('yellow')} a conversation at 75% of its limit &nbsp; ${dot('red')} over its limit</div></div>`; },
2749
+ text: ['Press <b>Save</b>. Your limits are kept on this computer.',
2750
+ 'The top of this page then shows how you are doing against each limit.',
2751
+ 'On the <b>Sessions</b> page, amber and red dots show conversations near or over their limit.']},
2752
+ ];
2753
+
2754
+
2258
2755
  VIEWS.budgets = async (page) => {
2259
2756
  const b = await api('budgets');
2260
2757
  const st = S.opts.settings;
2758
+ const sg = st.guard || {};
2759
+ // suggestions from your own recent usage (see Analytics.budget_suggestions)
2760
+ const SG = b.suggest || {}, sizes = SG.session_tokens || [];
2761
+ const pctile = q => sizes.length ? sizes[Math.min(sizes.length - 1, Math.floor(q * sizes.length))] : 0;
2762
+ const overShare = v => {
2763
+ let lo = 0, hi = sizes.length;
2764
+ while (lo < hi) { const m = (lo + hi) >> 1; if (sizes[m] > v) hi = m; else lo = m + 1; }
2765
+ const n = sizes.length - lo;
2766
+ return n ? `${fmtInt(n)} of your ${fmtInt(sizes.length)} past sessions (${fmtPct(100 * n / sizes.length, 0)}) went over this.`
2767
+ : `None of your ${fmtInt(sizes.length)} past sessions went over this.`;
2768
+ };
2769
+ const HINTS = {
2770
+ 'b-monthly': v => v ? `At your last-30-days pace (${fmtUSD(SG.spend_30d)}) you would use ${fmtPct(100 * SG.spend_30d / v, 0)} of this.`
2771
+ : `Last 30 days: ${fmtUSD(SG.spend_30d)} (estimated).`,
2772
+ 'b-daily': () => `Your average day is ${fmtUSD(SG.daily_avg)}; 1 day in 10 costs more than ${fmtUSD(SG.daily_p90)}.`,
2773
+ 'b-tokens': v => v ? `The last 30 days used ${fmtNum(SG.tokens_30d)} tokens, ${fmtPct(100 * SG.tokens_30d / v, 0)} of this.`
2774
+ : `Last 30 days: ${fmtNum(SG.tokens_30d)} tokens.`,
2775
+ 'g-tokens': v => !sizes.length ? '' : v ? overShare(v) : `Half your sessions stay under ${fmtNum(pctile(.5))} tokens.`,
2776
+ };
2777
+ const planHint = `Your own figure, from your plan or invoice. ${esc(agentWord())} data does not include it.`;
2778
+ const stepVal = sg.step_pct ?? 25;
2779
+ // Does any per-session budget exist (global or a project override)? The hook needs one.
2780
+ const anyBudget = !!sg.session_tokens || [sg.projects, sg.sessions].some(m => Object.values(m || {}).some(o => o.session_tokens));
2781
+ const liveBox = () => {
2782
+ const on = !!S.opts.guard_installed;
2783
+ const state = !on ? `<span class="lb-state">○ Not installed</span>`
2784
+ : anyBudget ? `<span class="lb-state on">● Installed · works in new Claude Code sessions</span>`
2785
+ : `<span class="lb-state warn">Installed, but it does nothing until you set a per-session token budget above.</span>`;
2786
+ return `<div class="livebox" id="livebox">
2787
+ <div class="lb-head"><b>Live warnings in Claude Code</b> <span class="blk-sub">(optional)</span>
2788
+ <span class="spacer"></span>${state}
2789
+ <button class="act" id="g-install">${on ? 'Uninstall' : 'Install'}</button></div>
2790
+ <p>Your limits above already work without this. Install it if you want Claude Code itself to warn
2791
+ you at your warn percentages, and ask "continue?" when a conversation reaches its limit.</p>
2792
+ <p class="blk-sub">It is a small helper that runs inside Claude Code. Needs: a per-session token budget.
2793
+ It cannot end a conversation, and if it ever fails it lets Claude carry on. Undo any time.</p>
2794
+ <div class="fld-hint" id="g-msg" role="status"></div></div>`;
2795
+ };
2261
2796
  page.innerHTML = `
2262
- ${card('Budget vs actual vs forecast', `<div class="stack">${b.lines.map(l => l.configured ? `
2797
+ ${card('Budget vs actual vs forecast', `<p class="blk-intro" style="margin-top:0">How you're doing against your limits. Set your limits below.</p><div class="stack">${b.lines.map(l => l.configured ? `
2263
2798
  <div class="item">
2264
2799
  <div class="hd">${esc(l.name)}<span class="spacer"></span>${statusChip(l.status)}</div>
2265
2800
  <div class="grid g4" style="gap:8px;margin:4px 0">
2266
2801
  ${kpi('Budget', l.unit === 'tokens' ? fmtNum(l.budget) : fmtUSD(l.budget), null, {small: 1})}
2267
2802
  ${kpi('Actual', l.unit === 'tokens' ? fmtNum(l.actual) : fmtUSD(l.actual),
2268
2803
  fmtPct(l.used_pct) + ' used', {small: 1, badge: BADGE.estimated})}
2269
- ${kpi('Forecast', l.forecast == null ? null
2804
+ ${kpi('Forecast', l.sessions_over != null ? '—' : l.forecast == null ? null
2270
2805
  : (l.unit === 'tokens' ? fmtNum(l.forecast) : fmtUSD(l.forecast)),
2271
- l.forecast_pct == null ? '' : fmtPct(l.forecast_pct) + ' of budget',
2806
+ l.sessions_over != null ? 'not forecast per session'
2807
+ : l.forecast_pct == null ? '' : fmtPct(l.forecast_pct) + ' of budget',
2272
2808
  {small: 1, badge: BADGE.forecast})}
2273
- ${kpi('Variance', l.variance == null ? null
2809
+ ${kpi('Variance', l.sessions_over != null ? '—' : l.variance == null ? null
2274
2810
  : (l.variance >= 0 ? '+' : '') + (l.unit === 'tokens' ? fmtNum(l.variance) : fmtUSD(l.variance)),
2275
2811
  l.variance == null ? '' : (l.variance > 0 ? 'over budget' : 'under budget'), {small: 1})}
2276
2812
  </div>
2277
2813
  <div class="meter ${l.status}"><i style="width:${Math.min(l.used_pct, 100)}%"></i></div>
2278
2814
  <div class="mt">
2279
- ${l.thresholds_breached.length ? `<span>⚠️ breached ${l.thresholds_breached.join('%, ')}%</span>` : '<span>No threshold breached</span>'}
2815
+ ${l.thresholds_breached.length ? `<span>${I('alert')} breached ${l.thresholds_breached.join('%, ')}%</span>` : '<span>No threshold breached</span>'}
2280
2816
  ${l.thresholds_forecast_breach.length
2281
2817
  ? `<span style="color:var(--serious-ink)">forecast to breach ${l.thresholds_forecast_breach.join('%, ')}%</span>` : ''}
2818
+ ${l.sessions_over == null ? '' : `<span title="Most of a long session's tokens are cache reads, priced at about a tenth of the input rate.">
2819
+ ${l.sessions_over ? `<a href="#" data-over="1">${fmtInt(l.sessions_over)} session${l.sessions_over === 1 ? '' : 's'} over budget this period</a>`
2820
+ : 'No session over budget this period'} · largest session shown</span>`}
2282
2821
  </div></div>`
2283
2822
  : `<div class="item"><div class="hd">${esc(l.name)}<span class="spacer"></span>
2284
2823
  <span class="badge na">not configured</span></div>
@@ -2286,57 +2825,238 @@ VIEWS.budgets = async (page) => {
2286
2825
  (estimated). Set a budget below to track variance and get threshold warnings.</div></div>`
2287
2826
  ).join('')}</div>`, {badge: BADGE.estimated,
2288
2827
  hint: `alert thresholds: ${b.thresholds_pct.join('%, ')}%`})}
2289
- ${card('Configure budgets, limits and thresholds', `
2290
- <div class="grid g3">
2291
- <div><div class="hd" style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;font-weight:620;margin-bottom:6px">Budgets</div>
2292
- <label class="pop" style="position:static;display:block;border:none;box-shadow:none;padding:0">
2293
- <div class="hd">Monthly budget (USD)</div>
2294
- <input type="number" step="1" id="b-monthly" value="${st.budgets.monthly_usd ?? ''}" placeholder="not set">
2295
- <div class="hd">Daily budget (USD)</div>
2296
- <input type="number" step="0.5" id="b-daily" value="${st.budgets.daily_usd ?? ''}" placeholder="not set">
2297
- <div class="hd">Monthly token budget</div>
2298
- <input type="number" id="b-tokens" value="${st.budgets.monthly_tokens ?? ''}" placeholder="not set">
2299
- </label></div>
2300
- <div><div class="hd" style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;font-weight:620;margin-bottom:6px">Plan limits</div>
2301
- <label class="pop" style="position:static;display:block;border:none;box-shadow:none;padding:0">
2302
- <div class="hd">Monthly cost allowance (USD)</div>
2303
- <input type="number" step="1" id="l-cost" value="${st.limits.monthly_cost_allowance_usd ?? ''}" placeholder="not exposed by ${agentWord()} data">
2304
- <div class="hd">Monthly token allowance</div>
2305
- <input type="number" id="l-tok" value="${st.limits.monthly_token_allowance ?? ''}" placeholder="not exposed by ${agentWord()} data">
2306
- <div class="hd">Monthly request allowance</div>
2307
- <input type="number" id="l-req" value="${st.limits.monthly_request_allowance ?? ''}" placeholder="not exposed by ${agentWord()} data">
2308
- <div class="hd">Remaining credits (USD)</div>
2309
- <input type="number" step="0.01" id="l-cred" value="${st.limits.remaining_credits_usd ?? ''}" placeholder="not exposed by ${agentWord()} data">
2310
- </label></div>
2311
- <div><div class="hd" style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;font-weight:620;margin-bottom:6px">Alert thresholds</div>
2312
- <label class="pop" style="position:static;display:block;border:none;box-shadow:none;padding:0">
2313
- <div class="hd">Comma-separated %</div>
2314
- <input type="text" id="t-thr" value="${st.alert_thresholds_pct.join(',')}"
2315
- style="width:100%;padding:5px 7px;border-radius:6px;border:1px solid var(--border);background:var(--surface-2)">
2316
- </label>
2317
- <button class="chip on" id="savecfg" style="margin-top:10px;width:100%;justify-content:center">Save configuration</button>
2318
- <div class="note" style="margin-top:8px">Writes to <span class="mono">config/settings.json</span>.
2319
- Leave a field blank to keep it unconfigured — the dashboard will report it as unavailable
2320
- rather than inventing a value.</div>
2828
+ ${card('Set your limits', `<div class="cfg">
2829
+ <section class="blk" data-blk="money">
2830
+ <h4><span class="num">①</span> Money limits <button type="button" class="blk-help" data-guide="money" aria-label="Help: money limits">?</button></h4>
2831
+ <p class="blk-intro">Set the most money you want to spend. We warn you before you go over.</p>
2832
+ <div class="grid g2">
2833
+ ${amountField({id: 'b-monthly', label: 'Monthly budget (USD)', kind: 'usd', value: st.budgets.monthly_usd,
2834
+ chips: usdChips([[SG.spend_30d, 'Last 30 days', 'Your estimated spend over the last 30 days, rounded up'],
2835
+ [SG.spend_30d * 1.1, 'Last 30 days +10%', 'Some headroom over your recent spend'],
2836
+ 1000, 2500, 5000])})}
2837
+ ${amountField({id: 'b-daily', label: 'Daily budget (USD)', kind: 'usd', value: st.budgets.daily_usd,
2838
+ chips: usdChips([[SG.daily_avg, 'Average day', 'Your average day over the last 30 days, rounded up'],
2839
+ [SG.daily_p90, 'Busy day', '1 day in 10 costs more than this'], 50, 100, 250])})}
2321
2840
  </div>
2322
- </div>`, {footer: `Plan allowances are NOT available from ${agentWord()} data. Anything you enter here is your own declared figure, used only to compute usage-vs-limit and days-until-limit.`})}`;
2841
+ </section>
2842
+ <section class="blk" data-blk="tokens">
2843
+ <h4><span class="num">②</span> Token limits <button type="button" class="blk-help" data-guide="tokens" aria-label="Help: token limits">?</button></h4>
2844
+ <p class="blk-intro">Tokens are the small pieces of text Claude reads and writes (about ¾ of a word).
2845
+ Set how many you want to use in a month.</p>
2846
+ <div class="grid g2">
2847
+ ${amountField({id: 'b-tokens', label: 'Monthly token budget', kind: 'tokens', value: st.budgets.monthly_tokens,
2848
+ chips: tokChips([[SG.tokens_30d, 'Last 30 days', 'Billable tokens over the last 30 days, rounded up'],
2849
+ 1e9, 5e9, 10e9])})}
2850
+ <div></div>
2851
+ </div>
2852
+ </section>
2853
+ <section class="blk" data-blk="session">
2854
+ <h4><span class="num">③</span> Session limit <button type="button" class="blk-help" data-guide="session" aria-label="Help: session limit">?</button></h4>
2855
+ <p class="blk-intro">A session is one Claude Code conversation. Stop one conversation from getting too big.</p>
2856
+ <div class="grid g3">
2857
+ <div>
2858
+ ${amountField({id: 'g-tokens', label: 'Per-session token budget', kind: 'tokens', value: sg.session_tokens,
2859
+ chips: tokChips([[pctile(.75), 'Typical', '3 in 4 of your sessions stay under this'],
2860
+ [pctile(.9), 'Large', '9 in 10 of your sessions stay under this'],
2861
+ 5e6, 10e6, 25e6, 50e6])})}
2862
+ ${pctField({id: 'g-warn', label: 'Warn me at', values: sg.warn_pct || [], presets: [50, 60, 70, 75, 80, 90], max: 99})}
2863
+ </div>
2864
+ <div>
2865
+ <div class="fld"><div class="hd">After I say "continue"</div>
2866
+ <label style="display:flex;gap:6px;align-items:center;margin:6px 0;flex-wrap:wrap">
2867
+ <input type="radio" name="g-after" value="step" ${sg.after_approval !== 'once' ? 'checked' : ''}>
2868
+ Ask again every
2869
+ <select id="g-step-sel" style="width:auto">
2870
+ ${[10, 25, 50, 100].map(v => `<option value="${v}" ${stepVal === v ? 'selected' : ''}>+${v}%</option>`).join('')}
2871
+ <option value="other" ${[10, 25, 50, 100].includes(stepVal) ? '' : 'selected'}>other…</option>
2872
+ </select>
2873
+ <input type="text" inputmode="decimal" id="g-step" value="${stepVal}" placeholder="%"
2874
+ style="width:64px;${[10, 25, 50, 100].includes(stepVal) ? 'display:none' : ''}"></label>
2875
+ <label style="display:flex;gap:6px;align-items:center;margin:6px 0">
2876
+ <input type="radio" name="g-after" value="once" ${sg.after_approval === 'once' ? 'checked' : ''}>
2877
+ Once per session</label>
2878
+ <div class="fld-err" id="g-step-err" role="alert"></div>
2879
+ </div>
2880
+ </div>
2881
+ <div>
2882
+ <div class="fld"><div class="hd">Different limit for a project</div>
2883
+ <div id="g-projects"></div>
2884
+ <datalist id="gtok-list">${[pctile(.75), pctile(.9), 5e6, 10e6, 25e6, 50e6].filter(v => v > 0)
2885
+ .map(v => `<option value="${shortAmount(niceUp(v))}">`).join('')}</datalist>
2886
+ <button class="act" id="g-add" style="margin-top:6px">${I('plus')} Add override</button>
2887
+ </div>
2888
+ </div>
2889
+ </div>
2890
+ ${liveBox()}
2891
+ </section>
2892
+ <details class="blk adv" data-blk="advanced">
2893
+ <summary><h4>Advanced: plan limits and alert thresholds <span class="blk-sub">(most people can skip this)</span>
2894
+ <button type="button" class="blk-help" data-guide="advanced" aria-label="Help: advanced">?</button></h4></summary>
2895
+ <div class="grid g2">
2896
+ <div><div class="sec">Plan limits</div>
2897
+ <div class="fld-hint" style="margin:-2px 2px 6px">${planHint}</div>
2898
+ ${amountField({id: 'l-cost', label: 'Monthly cost allowance (USD)', kind: 'usd', zero: 1,
2899
+ value: st.limits.monthly_cost_allowance_usd})}
2900
+ ${amountField({id: 'l-tok', label: 'Monthly token allowance', kind: 'tokens', zero: 1,
2901
+ value: st.limits.monthly_token_allowance})}
2902
+ ${amountField({id: 'l-req', label: 'Monthly request allowance', kind: 'count', zero: 1,
2903
+ value: st.limits.monthly_request_allowance})}
2904
+ ${amountField({id: 'l-cred', label: 'Remaining credits (USD)', kind: 'usd', zero: 1,
2905
+ value: st.limits.remaining_credits_usd})}
2906
+ </div>
2907
+ <div><div class="sec">Alert thresholds</div>
2908
+ <p class="blk-intro">When a money or token limit reaches these percentages, the dashboard marks it.</p>
2909
+ ${pctField({id: 't-thr', label: 'Warn when a budget reaches', values: st.alert_thresholds_pct,
2910
+ presets: [25, 50, 75, 90, 100, 110], max: 1000})}
2911
+ </div>
2912
+ </div>
2913
+ </details>
2914
+ <div class="save-row">
2915
+ <button class="chip on" id="savecfg">Save</button><span id="cfg-msg" role="status"></span>
2916
+ <span class="fld-hint">Saved on this computer only. Amounts take shorthand: 20M, 500k, $3,000.</span>
2917
+ </div>
2918
+ </div>`, {actions: `<button class="act" id="guide-open">${I('help')} How does this work?</button>`})}`;
2919
+ // amounts: chips fill the field; hints follow what is typed; errors clear as you edit
2920
+ page.querySelectorAll('.cfg [data-fill]').forEach(c => c.onclick = () => {
2921
+ const el = $('#' + c.dataset.fill, page);
2922
+ el.value = el.dataset.kind === 'usd' ? String(+c.dataset.v) : shortAmount(+c.dataset.v);
2923
+ el.dispatchEvent(new Event('input'));
2924
+ });
2925
+ page.querySelectorAll('.cfg input[data-kind]').forEach(el => {
2926
+ const upd = () => {
2927
+ clearErr(page, el.id);
2928
+ const hint = HINTS[el.id], h = $('#' + el.id + '-hint', page);
2929
+ if (hint && h) { const r = parseAmount(el.value, el.dataset.kind); h.textContent = r.error ? '' : hint(r.value); }
2930
+ };
2931
+ el.addEventListener('input', upd);
2932
+ el.addEventListener('blur', () => { const e = amountError(el); if (e) setErr(page, el.id, e); });
2933
+ upd();
2934
+ });
2935
+ wirePct(page);
2936
+ const stepSel = $('#g-step-sel', page), stepIn = $('#g-step', page);
2937
+ stepSel.onchange = () => {
2938
+ stepIn.style.display = stepSel.value === 'other' ? '' : 'none';
2939
+ if (stepSel.value !== 'other') stepIn.value = stepSel.value; else stepIn.focus();
2940
+ clearErr(page, 'g-step');
2941
+ };
2942
+ // project overrides: [{path, off, tokens}], edited in place, saved with the rest
2943
+ const gp = Object.entries(sg.projects || {}).map(([path, o]) =>
2944
+ ({path, off: !!o.off, tokens: o.session_tokens == null ? '' : o.session_tokens.toLocaleString('en-US')}));
2945
+ const known = [...new Map((S.opts.projects || [])
2946
+ .filter(p => p.path && (p.agent || 'claude') === 'claude' && !p.is_sandbox)
2947
+ .map(p => [p.path, p])).values()].sort((a, b) => a.name.localeCompare(b.name));
2948
+ const drawProjects = () => {
2949
+ $('#g-projects', page).innerHTML = gp.length ? gp.map((o, i) => `
2950
+ <div style="display:flex;gap:6px;align-items:center;margin:4px 0">
2951
+ <select data-gp="${i}" data-k="path" id="gp-${i}-path" style="flex:1;min-width:0">
2952
+ <option value="">choose a project</option>
2953
+ ${[...new Set([o.path, ...known.map(p => p.path)].filter(Boolean))].map(path => {
2954
+ const p = known.find(k => k.path === path);
2955
+ return `<option value="${esc(path)}" ${path === o.path ? 'selected' : ''} title="${esc(path)}">${esc(p ? p.name : path)}</option>`;
2956
+ }).join('')}
2957
+ </select>
2958
+ <input type="text" inputmode="decimal" list="gtok-list" data-gp="${i}" data-k="tokens" id="gp-${i}-tok"
2959
+ value="${esc(o.tokens)}" placeholder="budget, e.g. 20M" style="width:130px;flex:none" ${o.off ? 'disabled' : ''}>
2960
+ <label style="display:flex;gap:3px;align-items:center"><input type="checkbox" data-gp="${i}" data-k="off" ${o.off ? 'checked' : ''}>off</label>
2961
+ <button class="act" data-gp-rm="${i}" title="Remove override" aria-label="Remove override">${I('x')}</button>
2962
+ </div>
2963
+ <div class="fld-err" id="gp-${i}-err" role="alert"></div>`).join('')
2964
+ : '<div class="fld-hint">None: every project uses the per-session budget.</div>';
2965
+ page.querySelectorAll('[data-gp]').forEach(el => el[el.type === 'text' ? 'oninput' : 'onchange'] = () => {
2966
+ const o = gp[+el.dataset.gp], k = el.dataset.k;
2967
+ o[k] = k === 'off' ? el.checked : el.value;
2968
+ clearErr(page, `gp-${el.dataset.gp}`);
2969
+ if (k === 'off') drawProjects();
2970
+ });
2971
+ page.querySelectorAll('[data-gp-rm]').forEach(b => b.onclick = () => { gp.splice(+b.dataset.gpRm, 1); drawProjects(); });
2972
+ };
2973
+ drawProjects();
2974
+ $('#g-add', page).onclick = () => { gp.push({path: '', off: false, tokens: ''}); drawProjects(); };
2975
+ $('#g-install', page).onclick = async () => {
2976
+ const msg = $('#g-msg', page);
2977
+ try {
2978
+ const r = await doAction('guard', {remove: !!S.opts.guard_installed});
2979
+ S.opts = await fetch('/api/options').then(r => r.json());
2980
+ bust(); await render();
2981
+ const m = $('#g-msg'); if (m) m.textContent = r.message;
2982
+ } catch (e) { msg.textContent = e.message; }
2983
+ };
2984
+ page.querySelectorAll('[data-over]').forEach(a => a.onclick = e => {
2985
+ e.preventDefault(); S.sessOrder = 'tokens'; S.sessPage.offset = 0; bust(); go('sessions'); });
2323
2986
  $('#savecfg', page).onclick = async () => {
2324
- const v = id => { const x = $('#' + id, page).value.trim(); return x === '' ? null : +x; };
2325
- await fetch('/api/settings', {method: 'POST', headers: {'Content-Type': 'application/json', 'X-FinOps-Action': '1'},
2326
- body: JSON.stringify({
2327
- budgets: {monthly_usd: v('b-monthly'), daily_usd: v('b-daily'), monthly_tokens: v('b-tokens')},
2328
- limits: {monthly_cost_allowance_usd: v('l-cost'), monthly_token_allowance: v('l-tok'),
2329
- monthly_request_allowance: v('l-req'), remaining_credits_usd: v('l-cred')},
2330
- alert_thresholds_pct: $('#t-thr', page).value.split(',').map(x => +x.trim()).filter(Boolean),
2331
- })}).then(r => r.json());
2987
+ const msg = $('#cfg-msg', page);
2988
+ msg.className = ''; msg.textContent = '';
2989
+ page.querySelectorAll('.cfg .fld-err').forEach(e => { if (!e.id.startsWith('key-')) e.textContent = ''; });
2990
+ page.querySelectorAll('.cfg .bad').forEach(e => e.classList.remove('bad'));
2991
+ const errs = [];
2992
+ const amt = id => { const el = $('#' + id, page), e = amountError(el); if (e) errs.push([id, e]);
2993
+ return e ? null : parseAmount(el.value, el.dataset.kind).value; };
2994
+ const pcts = id => [...page.querySelectorAll(`#${id} [data-pct].on`)].map(b => +b.dataset.pct).sort((a, b) => a - b);
2995
+ const budgets = {monthly_usd: amt('b-monthly'), daily_usd: amt('b-daily'), monthly_tokens: amt('b-tokens')};
2996
+ const limits = {monthly_cost_allowance_usd: amt('l-cost'), monthly_token_allowance: amt('l-tok'),
2997
+ monthly_request_allowance: amt('l-req'), remaining_credits_usd: amt('l-cred')};
2998
+ const thr = pcts('t-thr');
2999
+ if (!thr.length) errs.push(['t-thr', 'Pick at least one percentage.']);
3000
+ const after = (page.querySelector('[name=g-after]:checked') || {}).value || 'step';
3001
+ let step = stepSel.value === 'other' ? parseAmount(stepIn.value, 'count').value : +stepSel.value;
3002
+ if (after === 'step' && !(step >= 1 && step <= 1000)) errs.push(['g-step', 'Enter a step between 1 and 1000%.']);
3003
+ if (!(step >= 1 && step <= 1000)) step = 25;
3004
+ const projects = {};
3005
+ gp.forEach((o, i) => {
3006
+ const id = `gp-${i}`;
3007
+ if (!o.path) return errs.push([id, 'Pick a project, or remove this row.']);
3008
+ if (projects[o.path]) return errs.push([id, 'This project already has an override above.']);
3009
+ if (o.off) return (projects[o.path] = {off: true});
3010
+ const r = parseAmount(o.tokens, 'tokens');
3011
+ if (r.error) return errs.push([id, r.error]);
3012
+ if (!(r.value > 0)) return errs.push([id, 'Enter a budget, or tick off.']);
3013
+ projects[o.path] = {session_tokens: r.value};
3014
+ });
3015
+ const guard = {session_tokens: amt('g-tokens'), warn_pct: pcts('g-warn'), after_approval: after,
3016
+ step_pct: step, projects};
3017
+ if (errs.length) {
3018
+ errs.forEach(([id, e]) => setErr(page, id, e));
3019
+ page.querySelectorAll('details.adv').forEach(d => { if (d.querySelector('.fld-err:not(:empty)')) d.open = true; });
3020
+ msg.className = 'err';
3021
+ msg.textContent = `Fix ${errs.length} field${errs.length === 1 ? '' : 's'} before saving.`;
3022
+ const first = $('#' + errs[0][0], page) || $('#' + errs[0][0] + '-err', page);
3023
+ if (first) { first.scrollIntoView({block: 'center', behavior: 'smooth'}); first.focus && first.focus(); }
3024
+ return;
3025
+ }
3026
+ let res;
3027
+ try {
3028
+ res = await fetch('/api/settings', {method: 'POST', headers: {'Content-Type': 'application/json', 'X-FinOps-Action': '1'},
3029
+ body: JSON.stringify({budgets, limits, alert_thresholds_pct: thr, guard})}).then(r => r.json());
3030
+ } catch (e) { res = {error: 'Could not reach the dashboard server. Is it still running?'}; }
3031
+ if (res.error) { msg.className = 'err'; msg.textContent = 'Not saved: ' + res.error; return; }
2332
3032
  S.opts = await fetch('/api/options').then(r => r.json());
2333
- bust(); render();
3033
+ bust(); await render();
3034
+ const m = $('#cfg-msg'); if (m) { m.className = 'ok'; m.textContent = 'Saved.'; }
2334
3035
  };
2335
3036
  const bl = b.lines.filter(l => l.configured && l.budget);
2336
3037
  if (bl.length) addChart(page, 'Budget used', el => C.barsH(el, {
2337
3038
  rows: bl, label: l => l.name, value: l => 100 * l.actual / l.budget, fmt: v => fmtPct(v), max: 100,
2338
3039
  color: l => l.actual >= l.budget ? 'var(--critical)' : l.actual >= 0.75 * l.budget ? 'var(--warning)' : seriesVar(2)}),
2339
3040
  {badge: BADGE.estimated, after: '.nothing'});
3041
+ $('#guide-open', page).onclick = e => openGuide('budgets', null, e.currentTarget);
3042
+ page.querySelectorAll('[data-guide]').forEach(b => b.onclick = e => {
3043
+ e.preventDefault(); e.stopPropagation(); // inside <summary>: don't toggle Advanced
3044
+ openGuide('budgets', b.dataset.guide, b);
3045
+ });
3046
+ maybeGuide('budgets');
3047
+ };
3048
+
3049
+ /* ---------- settings ---------- */
3050
+ VIEWS.settings = async (page) => {
3051
+ page.innerHTML = card('API keys', `<div class="cfg">
3052
+ <div class="fld-hint" style="margin-bottom:8px">An API key is a password that lets this dashboard ask
3053
+ Anthropic or Cursor for your company's bill. You only need one for the <b>Billed vs local</b> page,
3054
+ which compares what was billed with what this computer recorded. That page shows up in the sidebar
3055
+ once a key is saved. Skip this if you don't use it. Keys stay on this computer, in a file only you
3056
+ can read. They are never shown again.</div>
3057
+ <div id="cfg-keys" class="stack"><div class="note">Loading…</div></div></div>`,
3058
+ {hint: 'optional'});
3059
+ drawKeys(page);
2340
3060
  };
2341
3061
 
2342
3062
  /* ---------- scorecard ---------- */
@@ -2356,15 +3076,15 @@ VIEWS.scorecard = async (page) => {
2356
3076
  : d.score >= 30 ? 'approaching' : 'critical'}"><i style="width:${d.score}%"></i></div>
2357
3077
  <div class="note">${esc(d.detail)}</div></div>`).join('')}
2358
3078
  </div></div>`, {badge: BADGE.estimated,
2359
- footer: 'Each dimension is measured from your transcripts; budget adherence needs a budget in Settings.'})}
3079
+ footer: 'Each dimension is measured from your transcripts; budget adherence needs a budget on the Budgets page.'})}
2360
3080
  <div class="grid g3">
2361
- ${card('✅ What is good', `<ul style="margin:0 0 0 18px;font-size:12.5px">${
3081
+ ${card('What is good', `<ul style="margin:0 0 0 18px;font-size:12.5px">${
2362
3082
  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">${
3083
+ || '<li class="na">Nothing scored above 60 in this range</li>'}</ul>`, {icon: I('checkCircle')})}
3084
+ ${card('Needs attention', `<ul style="margin:0 0 0 18px;font-size:12.5px">${
2365
3085
  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 ? `
3086
+ || '<li class="na">Nothing scored below 70</li>'}</ul>`, {icon: I('alert')})}
3087
+ ${card('Biggest opportunity', sc.biggest_opportunity ? `
2368
3088
  <div class="item sev-high"><div class="hd">${esc(sc.biggest_opportunity.title)}</div>
2369
3089
  <div class="dt">${esc(sc.biggest_opportunity.detail)}</div>
2370
3090
  <div class="mt"><span>Estimated excess ${fmtUSD(sc.biggest_opportunity.estimated_excess_usd)}</span>
@@ -2584,14 +3304,14 @@ function focusStrip(d, n = 5) {
2584
3304
  const nHidden = all.length - items.length;
2585
3305
  if (!all.length) return '';
2586
3306
  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
3307
+ return `<section class="focus"><div class="focus-hd">${I('target')} What to change
2588
3308
  <span class="note">${fixes} to fix once · ${habits} habit${habits === 1 ? '' : 's'} from your past sessions</span>
2589
3309
  <span class="spacer"></span>${nHidden ? `<button class="act ghost" data-unhide="1">Show ${nHidden} hidden</button>` : ''}</div>
2590
3310
  ${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
3311
  <span class="fn">${k + 1}</span>
2592
3312
  <span class="ft"><span class="fx">${esc(it.text)}</span><span class="fw">${esc(it.why || '')}</span></span>
2593
3313
  <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('')
3314
+ <button class="fd" title="Hide this until its evidence changes" aria-label="Hide">${I('x')}</button></div>`).join('')
2595
3315
  || '<div class="note">Everything here is hidden. Nice.</div>'}</section>`;
2596
3316
  }
2597
3317
  function wireFocus(root) {
@@ -2739,8 +3459,8 @@ function toggleRecent(force) {
2739
3459
  const scrim = h('<div class="recent-scrim"></div>');
2740
3460
  const p = h(`<aside class="recent" aria-label="Recent sessions"><header>
2741
3461
  <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>
3462
+ <span class="spacer"></span><button class="iconbtn" data-x>Close ${I('x')}</button></header>
3463
+ <div class="recent-search"><span class="mag">${I('search')}</span>
2744
3464
  <input id="recent-q" placeholder="Search your prompts…" value="${esc(RECENT.q)}"></div>
2745
3465
  <div class="content"><div class="loading">Loading…</div></div></aside>`);
2746
3466
  scrim.onclick = () => toggleRecent(false);
@@ -2779,7 +3499,7 @@ async function renderRecent() {
2779
3499
  }
2780
3500
 
2781
3501
  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>
3502
+ <span class="spacer"></span><a href="#" data-go-live>Close or kill ${I('chevRight')}</a></div>
2783
3503
  ${list.map((x, i) => `<div class="item recent-it sev-${x.severity === 'high' ? 'high' : x.severity === 'medium' ? 'medium' : 'low'}" data-live="${i}">
2784
3504
  <div class="hd"><span class="live-dot ${x.status === 'busy' ? 'busy' : ''}" title="${esc(x.status)}"></span>
2785
3505
  <span class="recent-title">${esc((!MASKED && x.name) || shortId(x.session_id))}</span>
@@ -2788,9 +3508,9 @@ const recentLive = list => !list.length ? '' : `<div class="recent-hd">Running n
2788
3508
  ${x.context ? `<span>${fmtNum(x.context)} context</span>` : ''}
2789
3509
  ${x.est_cost_usd != null ? `<span>${fmtUSD(x.est_cost_usd)}</span>` : ''}</div>
2790
3510
  <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>` : ''}
3511
+ ${x.resume ? `<button class="act ghost" data-resume="${esc(x.resume)}" title="Copy: ${esc(x.resume)}">${I('copy')} Resume</button>` : ''}
3512
+ ${x.signalable && x.agent === 'claude' ? '<button class="act" data-la="compact" title="Types /compact into that session\'s terminal">' + I('compact') + ' Compact</button>' : ''}
3513
+ ${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
3514
  ${x.session_id ? `<button class="act ghost" data-open-sess="${esc(x.session_id)}">Details</button>` : ''}
2795
3515
  <span class="live-msg"></span></div></div>`).join('')}
2796
3516
  <div class="recent-hd">Past sessions</div>`;
@@ -2803,9 +3523,9 @@ const recentSessions = (rows, liveIds) => rows.map(r => `
2803
3523
  <div class="mt"><span>${esc(r.project || '')}</span><span>${fmtInt(r.prompts)} prompts</span>
2804
3524
  <span>${fmtWhen(r.started_at)}</span></div>
2805
3525
  <div class="live-actions">
2806
- ${r.resume ? `<button class="act ghost" data-resume="${esc(r.resume)}" title="Copy: ${esc(r.resume)}">⧉ Resume</button>` : ''}
3526
+ ${r.resume ? `<button class="act ghost" data-resume="${esc(r.resume)}" title="Copy: ${esc(r.resume)}">${I('copy')} Resume</button>` : ''}
2807
3527
  <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>
3528
+ <span class="spacer"></span><span class="note recent-caret">${I(RECENT.open_ids.has(r.session_id) ? 'chevDown' : 'chevRight')} prompts</span></div>
2809
3529
  <div class="recent-prompts"${RECENT.open_ids.has(r.session_id) ? '' : ' hidden'}></div></div>`).join('')
2810
3530
  || '<div class="empty">No sessions for these filters</div>';
2811
3531
 
@@ -2818,7 +3538,7 @@ const recentPrompts = rows => rows.map(x => `<div class="item recent-it">
2818
3538
  ${promptLine(x)}
2819
3539
  <div class="mt"><span>${esc((!MASKED && x.session_title) || shortId(x.session_id))}</span>
2820
3540
  <span>${esc(x.project || '')}</span>
2821
- <a href="#" data-open-sess="${esc(x.session_id)}">Session ›</a></div></div>`).join('');
3541
+ <a href="#" data-open-sess="${esc(x.session_id)}">Session ${I('chevRight')}</a></div></div>`).join('');
2822
3542
 
2823
3543
  async function loadSessionPrompts(host, sid) {
2824
3544
  host.innerHTML = '<div class="note">Loading…</div>';
@@ -2847,7 +3567,7 @@ function wireRecent(root) {
2847
3567
  if (e.target.closest('button, a, .recent-prompts')) return;
2848
3568
  host.hidden = !host.hidden;
2849
3569
  it.classList.toggle('open', !host.hidden);
2850
- it.querySelector('.recent-caret').textContent = (host.hidden ? '▸' : '▾') + ' prompts';
3570
+ it.querySelector('.recent-caret').innerHTML = I(host.hidden ? 'chevRight' : 'chevDown') + ' prompts';
2851
3571
  host.hidden ? RECENT.open_ids.delete(sid) : RECENT.open_ids.add(sid);
2852
3572
  if (!host.hidden && !host.childElementCount) loadSessionPrompts(host, sid);
2853
3573
  };
@@ -3019,7 +3739,7 @@ VIEWS.freemodels = async (page) => {
3019
3739
  ${!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
3740
  won't run by name. Add <code>export PATH="$HOME/.local/bin:$PATH"</code> to ~/.zshrc.</div></div>` : ''}
3021
3741
  ${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>
3742
+ <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
3743
  <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
3744
  <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
3745
  <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 +3752,11 @@ VIEWS.freemodels = async (page) => {
3032
3752
  ? `Runs on this machine · ~${m.download_gb} GB download · wants ${m.min_ram_gb} GB RAM (you have ${m.ram_gb} GB)`
3033
3753
  : 'Runs in the cloud via OpenRouter · no download'}</div>
3034
3754
  ${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>
3755
+ ? `<div class="dt">${I('checkCircle')} Added. Run <code>${esc(m.command)}</code> in any project.</div>
3756
+ <div><button class="act" data-test="${esc(m.id)}">${I('play')} Test it</button>
3037
3757
  <button class="act ghost" data-rm="${esc(m.id)}">Remove</button></div>
3038
3758
  <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>`}
3759
+ : `<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
3760
  </div>`, {badge: m.installed ? '<span class="badge">Added</span>' : ''})).join('')}</div>`;
3041
3761
  page.querySelectorAll('[data-go]').forEach(a => a.onclick = () => go(a.dataset.go));
3042
3762
  page.querySelectorAll('[data-add]').forEach(b => b.onclick = () => addFreeModel(b.dataset.add));
@@ -3053,7 +3773,7 @@ async function testFreeModel(id, host, btn) {
3053
3773
  const {job} = await doAction(`free_model/${id}/test`);
3054
3774
  const j = await followJob(job, host);
3055
3775
  host.insertAdjacentHTML('beforeend', j.state === 'done'
3056
- ? '<div class="item sev-low"><div class="dt">✅ Working end to end.</div></div>'
3776
+ ? '<div class="item sev-low"><div class="dt">' + I('checkCircle') + ' Working end to end.</div></div>'
3057
3777
  : '<div class="item sev-high"><div class="dt">Test failed. See the log above.</div></div>');
3058
3778
  } catch (e) { host.innerHTML = `<div class="item sev-high"><div class="dt">${esc(e.message)}</div></div>`; }
3059
3779
  btn.disabled = false;
@@ -3069,10 +3789,10 @@ async function addFreeModel(id) {
3069
3789
  <h3>${esc(plan.model.name)}</h3>
3070
3790
  <div class="dt">This will:</div>
3071
3791
  <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>
3792
+ ${s.warn ? I('alert') : `${i + 1}.`} ${esc(s.do)}${s.consent ? ' <span class="pill">needs your OK</span>' : ''}</div></div>`).join('')}</div>
3073
3793
  ${plan.needs.map(n => `${n.steps ? `<div class="item"><div class="hd">How to get your ${esc(n.label)}</div>
3074
3794
  <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>` : ''}
3795
+ ${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
3796
  <label class="dt"><b>${esc(n.label)}</b><br>
3077
3797
  <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
3798
  <span class="note">${esc(n.help)}</span></label>`).join('')}
@@ -3094,8 +3814,8 @@ async function addFreeModel(id) {
3094
3814
  const {job} = await doAction(`free_model/${id}/install`, body);
3095
3815
  const j = await followJob(job, $('#fm-log', c));
3096
3816
  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>`);
3817
+ `<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>
3818
+ <div><button class="act" id="fm-test">${I('play')} Test it now</button></div><div id="fm-testlog"></div></div>`);
3099
3819
  $('#fm-test', c).onclick = e => testFreeModel(id, $('#fm-testlog', c), e.target);
3100
3820
  if (S.view === 'freemodels') render(); }
3101
3821
  else $('#fm-go', c).disabled = false;
@@ -3108,12 +3828,12 @@ VIEWS.compare = async (page) => {
3108
3828
  const d = await fetch('/api/compare?agents=' + encodeURIComponent(S.filter.agents.join(','))).then(r => r.json());
3109
3829
  if (d.error) throw new Error(d.error);
3110
3830
  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>`;
3831
+ const stars = n => n == null ? '<span class="na">—</span>' : I('star').repeat(Math.floor(n)) + (n % 1 ? I('starHalf') : '') +
3832
+ `<span style="opacity:.25">${I('star').repeat(5 - Math.ceil(n))}</span>`;
3113
3833
  const price = r => r.kind === 'free' ? '<b>$0</b>' : `${fmtUSD(r.price_in)} / ${fmtUSD(r.price_out)}`;
3114
3834
  const status = r => r.kind !== 'free'
3115
3835
  ? (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>`;
3836
+ : !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
3837
  page.innerHTML = `
3118
3838
  ${card(hasFree ? 'Claude vs free models' : `${esc(agentWord())} models side by side`, `<div id="cmp"></div>`, {flush: 1, badge: BADGE.recommendation,
3119
3839
  hint: hasFree ? `This machine: ${esc(d.os)}, ${d.ram_gb} GB RAM` : 'List prices, your usage in range', footer: esc(d.note)})}
@@ -3144,6 +3864,121 @@ VIEWS.compare = async (page) => {
3144
3864
  };
3145
3865
 
3146
3866
  /* ---------- skills & MCP from recurring work ---------- */
3867
+ /* ---------- jev: TypeSafe's fast decision model ---------- */
3868
+ let JEV_MSG = null; // {where: 'install'|'key', ok, text}: shown once after the page redraws
3869
+ VIEWS.jev = async (page) => {
3870
+ const d = await api('jev');
3871
+ const st = d.status, k = d.key, fit = d.fit;
3872
+ const flash = where => JEV_MSG && JEV_MSG.where === where
3873
+ ? `<div class="fld-hint ${JEV_MSG.ok ? 'ok' : ''}" style="${JEV_MSG.ok ? '' : 'color:var(--critical-ink)'}">${esc(JEV_MSG.text)}</div>` : '';
3874
+ const state = !st.claude
3875
+ ? `<span class="lb-state warn">Claude Code's <code>claude</code> command was not found on this computer, so Jev can't be installed from here.</span>`
3876
+ : st.installed ? `<span class="lb-state on">● Installed${st.version ? ' · version ' + esc(st.version) : ''}${st.enabled === false ? ' (turned off in Claude Code)' : ''}</span>`
3877
+ : '<span class="lb-state">○ Not installed</span>';
3878
+ const keyState = k.source === 'shell' ? `set in your shell${k.last4 ? ' · ends ' + esc(k.last4) : ''}`
3879
+ : k.source ? `saved${k.last4 ? ' · ends ' + esc(k.last4) : ''}` : 'not set';
3880
+ const small = fit.share_pct < 1;
3881
+ const usd = v => v ? fmtUSD(v) : '$0';
3882
+ page.innerHTML = `
3883
+ ${card('What is Jev?', `<div class="cfg">
3884
+ <p class="blk-intro">Jev is a very fast, very cheap AI that makes decisions. It picks one option, answers yes or no,
3885
+ gives a score, or pulls out a value. It can't write text or code.</p>
3886
+ <p class="blk-intro">It doesn't make Claude Code itself cheaper. It makes the apps and scripts you build cheaper, by
3887
+ replacing AI calls that only make a decision, like sorting tickets or checking a pull request.</p>
3888
+ <p class="blk-intro">Installing it teaches Claude how to use Jev when you build those.
3889
+ <a href="https://docs.typesafe.ai" target="_blank" rel="noopener">Read Jev's docs</a></p></div>`,
3890
+ {hint: 'made by TypeSafe AI'})}
3891
+ ${card('Set up Jev', `<div class="cfg">
3892
+ <section class="blk">
3893
+ <h4><span class="num">①</span> Install the Jev plugin for Claude Code</h4>
3894
+ <div class="lb-head">${state}<span class="spacer"></span>
3895
+ ${st.claude && !st.installed ? '<button class="act" id="jev-install">Install</button>' : ''}
3896
+ ${st.installed ? '<button class="act" id="jev-uninstall">Uninstall</button>' : ''}</div>
3897
+ <div class="livebox" id="jev-confirm" hidden>
3898
+ <p><b>This adds TypeSafe's plugin to Claude Code for all your projects.</b> It runs these two commands:</p>
3899
+ <pre class="mono" style="margin:6px 0;white-space:pre-wrap">claude plugin marketplace add typesafe-ai/skills
3900
+ claude plugin install typesafe@typesafe-ai</pre>
3901
+ <p class="blk-sub">Jev only sees what the apps you build send it. Uninstall any time.</p>
3902
+ <div class="live-actions"><button class="chip on" id="jev-go">Confirm install</button>
3903
+ <button class="act ghost" id="jev-cancel">Cancel</button></div></div>
3904
+ <div id="jev-log"></div>${flash('install')}
3905
+ </section>
3906
+ <section class="blk">
3907
+ <h4><span class="num">②</span> Add your Jev API key</h4>
3908
+ <p class="blk-intro">The key lets your apps and Claude talk to Jev. Get one at
3909
+ <a href="https://console.typesafe.ai" target="_blank" rel="noopener">console.typesafe.ai</a>.</p>
3910
+ <div class="lb-head"><span class="lb-state ${k.source ? 'on' : ''}">Key: ${keyState}</span></div>
3911
+ <div style="display:flex;gap:6px;align-items:center;margin-top:6px">
3912
+ <input type="password" id="jev-key" autocomplete="off" spellcheck="false" style="flex:1;min-width:0"
3913
+ aria-label="Jev API key" placeholder="${k.source ? 'paste a new key to replace it' : 'paste your Jev API key'}">
3914
+ <button class="act" id="jev-key-save">Save key</button>
3915
+ ${k.source === 'claude_settings' ? '<button class="act" id="jev-key-rm">Remove</button>' : ''}</div>
3916
+ <div class="fld-err" id="jev-key-err" role="alert"></div>${flash('key')}
3917
+ <p class="fld-hint">It is saved in Claude Code's settings (<span class="mono">~/.claude/settings.json</span>), so new
3918
+ Claude Code sessions can use it. Restart any open ones. This page never shows the key again.
3919
+ ${k.source === 'shell' ? ' A key set in your shell takes priority over a saved one.' : ''}</p>
3920
+ </section></div>`)}
3921
+ ${card('Where Jev fits in your work', `<div class="cfg">
3922
+ <p class="blk-intro">We looked for your Claude Code prompts that were really just a decision: short, no tools, and
3923
+ asking "which one", "yes or no", "classify" and the like.</p>
3924
+ <div class="grid g4">
3925
+ ${kpi('Decision-like prompts', fmtInt(fit.prompts), `of ${fmtInt(fit.total_prompts)} in this range`)}
3926
+ ${kpi('Cost on Claude', usd(fit.claude_cost), fmtPct(fit.share_pct, 2) + ' of your spend', {badge: BADGE.estimated})}
3927
+ ${kpi('Rough cost on Jev', usd(fit.jev_cost), 'at TypeSafe\'s published price', {badge: BADGE.estimated})}
3928
+ ${kpi('Saving', usd(Math.max(fit.claude_cost - fit.jev_cost, 0)), 'if these had gone to Jev')}
3929
+ </div>
3930
+ ${small ? `<p class="fld-hint" style="margin-top:8px">Your Claude Code work is mostly writing code, which Jev can't do.
3931
+ Jev pays off in apps and scripts that make many decisions, like sorting support tickets or reviewing pull requests.</p>` : ''}
3932
+ ${fit.examples.length ? `<div class="sec" style="margin-top:12px">Examples</div><div class="stack">${fit.examples.map(e =>
3933
+ `<div class="item"><div class="dt">${MASKED ? '<i>Prompt text hidden</i>' : esc(e.text)}
3934
+ <span class="spacer"></span> ${fmtUSD(e.cost)}</div></div>`).join('')}</div>` : ''}</div>`,
3935
+ {badge: BADGE.estimated, hint: 'read-only, from this computer\'s transcripts'})}`;
3936
+ JEV_MSG = null;
3937
+ const redraw = msg => { JEV_MSG = msg; bust(); render(); };
3938
+ const confirmBox = $('#jev-confirm', page);
3939
+ const inst = $('#jev-install', page);
3940
+ if (inst) inst.onclick = () => { confirmBox.hidden = false; $('#jev-go', page).focus(); };
3941
+ $('#jev-cancel', page).onclick = () => { confirmBox.hidden = true; };
3942
+ const runJob = async (what, btn) => {
3943
+ btn.disabled = true;
3944
+ try {
3945
+ const {job} = await doAction('jev/' + what);
3946
+ const j = await followJob(job, $('#jev-log', page));
3947
+ redraw({where: 'install', ok: j.state === 'done',
3948
+ text: j.state === 'done' ? (what === 'install' ? 'Installed. Restart any open Claude Code sessions to use it.' : 'Removed.')
3949
+ : (j.log[j.log.length - 1] || 'It did not finish.')});
3950
+ } catch (e) { redraw({where: 'install', ok: false, text: e.message}); }
3951
+ };
3952
+ $('#jev-go', page).onclick = e => runJob('install', e.currentTarget);
3953
+ const un = $('#jev-uninstall', page);
3954
+ if (un) un.onclick = () => {
3955
+ if (!un.dataset.armed) {
3956
+ un.dataset.armed = '1'; un.textContent = 'Click again to uninstall';
3957
+ setTimeout(() => { if (un.isConnected) { delete un.dataset.armed; un.textContent = 'Uninstall'; } }, 4000);
3958
+ return;
3959
+ }
3960
+ runJob('uninstall', un);
3961
+ };
3962
+ const keyIn = $('#jev-key', page), keyErr = $('#jev-key-err', page);
3963
+ keyIn.oninput = () => { keyErr.textContent = ''; keyIn.classList.remove('bad'); };
3964
+ keyIn.onkeydown = e => { if (e.key === 'Enter') $('#jev-key-save', page).click(); };
3965
+ $('#jev-key-save', page).onclick = async () => {
3966
+ if (!keyIn.value.trim()) { keyErr.textContent = 'Paste your Jev API key first.'; keyIn.classList.add('bad'); return; }
3967
+ try { await doAction('jev/key', {value: keyIn.value}); redraw({where: 'key', ok: true, text: 'Saved. New Claude Code sessions will use it.'}); }
3968
+ catch (e) { keyErr.textContent = e.message; keyIn.classList.add('bad'); }
3969
+ };
3970
+ const rm = $('#jev-key-rm', page);
3971
+ if (rm) rm.onclick = async () => {
3972
+ if (!rm.dataset.armed) {
3973
+ rm.dataset.armed = '1'; rm.textContent = 'Click again to remove';
3974
+ setTimeout(() => { if (rm.isConnected) { delete rm.dataset.armed; rm.textContent = 'Remove'; } }, 4000);
3975
+ return;
3976
+ }
3977
+ try { await doAction('jev/key', {remove: true}); redraw({where: 'key', ok: true, text: 'Removed.'}); }
3978
+ catch (e) { keyErr.textContent = e.message; }
3979
+ };
3980
+ };
3981
+
3147
3982
  VIEWS.toolkit = async (page) => {
3148
3983
  const d = await fetch('/api/suggestions').then(r => r.json());
3149
3984
  const ev = x => (x || []).map(e => `<code>${esc(e)}</code>`).join(' ');
@@ -3154,9 +3989,9 @@ VIEWS.toolkit = async (page) => {
3154
3989
  <span class="spacer"></span><span class="note">${fmtInt(m.sessions)} sessions · ${esc(m.projects.join(', '))}</span></div>
3155
3990
  <div class="dt">${esc(m.what)}</div>
3156
3991
  ${m.evidence.length ? `<div class="dt note">Seen: ${ev(m.evidence)}</div>` : ''}
3157
- ${m.installed ? '<div class="dt">✅ Already connected</div>' : `
3992
+ ${m.installed ? '<div class="dt">' + I('checkCircle') + ' Already connected</div>' : `
3158
3993
  <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>`}
3994
+ <div><button class="act" data-mcp="${esc(m.id)}">${I('plus')} Add to Claude</button><span class="mcp-out"></span></div>`}
3160
3995
  </div>`).join('') || '<div class="empty">No recurring pattern points to an MCP server</div>'}</div>`,
3161
3996
  {badge: BADGE.recommendation})}
3162
3997
  ${card('Skills from what you repeat', `<div class="stack">${d.skills.map((s, i) => `
@@ -3164,13 +3999,13 @@ VIEWS.toolkit = async (page) => {
3164
3999
  <span class="spacer"></span><span class="note">${fmtInt(s.sessions)} sessions · ${fmtInt(s.runs)} runs · ${esc(s.projects.join(', '))}</span></div>
3165
4000
  <div class="dt">${esc(s.what)}</div>
3166
4001
  <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>`}
4002
+ ${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
4003
  </div>`).join('') || '<div class="empty">Nothing repeats often enough yet</div>'}</div>`,
3169
4004
  {badge: BADGE.recommendation, hint: `Created in ${d.skills_dir}; edit the SKILL.md afterwards`})}`;
3170
4005
  page.querySelectorAll('[data-skill]').forEach(b => b.onclick = async () => {
3171
4006
  const out = b.nextElementSibling; b.disabled = true;
3172
4007
  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>.`; }
4008
+ out.innerHTML = ` ${I('checkCircle')} Created <code>${esc(r.path)}</code>. Use it with <code>${esc(r.use)}</code>.`; }
3174
4009
  catch (e) { out.textContent = ' ' + e.message; b.disabled = false; }
3175
4010
  });
3176
4011
  page.querySelectorAll('[data-mcp]').forEach(b => b.onclick = async () => {
@@ -3184,7 +4019,7 @@ VIEWS.toolkit = async (page) => {
3184
4019
  body[n.field] = out.querySelector(`[data-need="${n.field}"]`).value;
3185
4020
  }
3186
4021
  b.disabled = true;
3187
- try { const r = await doAction(`mcp/${m.id}`, body); out.innerHTML = ` ✅ Added. ${esc(r.next)}`; }
4022
+ try { const r = await doAction(`mcp/${m.id}`, body); out.innerHTML = ` ${I('checkCircle')} Added. ${esc(r.next)}`; }
3188
4023
  catch (e) { out.textContent = ' ' + e.message; b.disabled = false; }
3189
4024
  });
3190
4025
  };
@@ -3197,20 +4032,19 @@ VIEWS.cloud = async (page) => {
3197
4032
  const gap = (t.billed_claude_cost || 0) - (t.local_claude_cost || 0);
3198
4033
  const setup = Object.entries(pv).map(([k, p]) => `
3199
4034
  <div class="item sev-${cfg[k] ? 'low' : 'medium'}">
3200
- <div class="hd">${cfg[k] ? '✓' : '○'} ${esc(p.name)}<span class="spacer"></span>
4035
+ <div class="hd">${I(cfg[k] ? 'check' : 'circle')} ${esc(p.name)}<span class="spacer"></span>
3201
4036
  <span class="note">${cfg[k] ? 'key found' : 'no key'}</span></div>
3202
4037
  <div class="dt"><b>Gives you:</b> ${esc(p.covers)}</div>
3203
4038
  <div class="dt"><b>Get a key:</b> ${esc(p.how)}</div>
3204
- <div class="dt">Then set <code>${esc(p.env)}</code> in your environment, or add
3205
- <code>"${esc(p.field)}"</code> to <code>~/.claude-finops/secrets.local.json</code>
3206
- (gitignored, never packaged). Restart the dashboard afterwards.</div>
4039
+ <div class="dt">Then add it on the <a href="#" data-go-settings>Settings</a> page,
4040
+ run <code>claude-finops --set-key</code>, or set <code>${esc(p.env)}</code> in your environment.</div>
3207
4041
  </div>`).join('');
3208
4042
  page.innerHTML = `
3209
4043
  <div class="note"><b>The only page that talks to the internet.</b> ${esc(d.note)}
3210
4044
  Nothing is fetched until you click Refresh.
3211
4045
  <span class="spacer"></span>
3212
4046
  <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>
4047
+ <button class="act" id="cl-sync" ${none ? 'disabled title="Add a key first"' : ''}>${I('cloudDown')} Refresh from APIs</button></div>
3214
4048
  <div id="cl-log"></div>
3215
4049
  ${Object.keys(d.errors).length ? `<div class="note sev-high">${Object.entries(d.errors)
3216
4050
  .map(([k, v]) => `<div><b>${esc(k)}</b>: ${esc(v)}</div>`).join('')}</div>` : ''}
@@ -3225,7 +4059,7 @@ VIEWS.cloud = async (page) => {
3225
4059
  `${fmtInt(t.cursor_members)} members`, {badge: BADGE.actual})}
3226
4060
  </div>
3227
4061
  ${card('Set up the APIs', `<div class="stack">${setup}</div>`,
3228
- {hint: 'Keys are read from your environment or a local file; the dashboard never stores them'})}
4062
+ {hint: 'Stored keys stay on this machine (0600) and are never shown again'})}
3229
4063
  ${card('Billed vs local, per day', `<div id="cl-day"></div>`, {flush: 1, badge: BADGE.actual,
3230
4064
  hint: 'Claude Code: what the vendor billed against what this machine recorded'})}
3231
4065
  ${card('Claude Code users (org-wide)', `<div id="cl-users"></div>`, {flush: 1, badge: BADGE.actual,
@@ -3233,6 +4067,7 @@ VIEWS.cloud = async (page) => {
3233
4067
  ${card('Cursor members', `<div id="cl-cur"></div>`, {flush: 1, badge: BADGE.actual})}
3234
4068
  ${card('Billed API cost by line item', `<div id="cl-api"></div>`, {flush: 1, badge: BADGE.actual,
3235
4069
  hint: 'From the Anthropic cost report: API spend only, not subscription plans'})}`;
4070
+ page.querySelectorAll('[data-go-settings]').forEach(a => a.onclick = e => { e.preventDefault(); go('settings'); });
3236
4071
  $('#cl-day', page).innerHTML = table([
3237
4072
  {h: 'Day', f: r => esc(r.day)},
3238
4073
  {h: 'Billed $', num: 1, f: r => fmtUSD(r.billed_cost)},
@@ -3273,7 +4108,7 @@ VIEWS.cloud = async (page) => {
3273
4108
  render();
3274
4109
  } catch (e) {
3275
4110
  $('#cl-log', page).innerHTML = `<div class="note sev-high">Failed: ${esc(e.message)}</div>`;
3276
- btn.disabled = false; btn.textContent = '↯ Refresh from APIs';
4111
+ btn.disabled = false; btn.innerHTML = `${I('cloudDown')} Refresh from APIs`;
3277
4112
  }
3278
4113
  };
3279
4114
  };
@@ -3295,11 +4130,11 @@ const TOUR_WELCOME = [
3295
4130
  act: 'Click <b>Codex</b> or <b>All</b> to switch.'},
3296
4131
  {el: '[data-range]', t: 'Date range & filters', see: 'Range chips, plus Model, Project, Category and Threshold filters.',
3297
4132
  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.'},
4133
+ act: `Try <b>7 days</b>, then open <b>Project ${I('chevDown')}</b> to focus on one repo.`},
3299
4134
  {el: '#gsearch', t: 'Search', see: 'Search across prompts, sessions, models and dates.',
3300
4135
  get: 'Jump straight to the prompt or session you remember.', act: 'Type a word from a recent prompt and press Enter.'},
3301
4136
  {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.'},
4137
+ get: 'Fresh numbers after you\'ve been working.', act: `Click <b>${I('sync')} Sync</b> whenever the numbers look stale.`},
3303
4138
  {el: '#tour-btn', t: 'Tour any dashboard', see: 'This button starts a tour of the dashboard you\'re on.',
3304
4139
  get: 'A short explanation of each section: what it shows, what you get, what you can do.',
3305
4140
  act: 'Open any dashboard and click <b>? Tour</b> to replay it. Next: this dashboard.'},
@@ -3332,13 +4167,13 @@ const TOURS = {
3332
4167
  {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
4168
  live: [
3334
4169
  {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.'},
4170
+ {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
4171
  {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
4172
  scorecard: [
3338
4173
  {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.'}],
4174
+ {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.'},
4175
+ {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.'},
4176
+ {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
4177
  usage: [
3343
4178
  {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
4179
  {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 +4184,7 @@ const TOURS = {
3349
4184
  {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
4185
  {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
4186
  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.'},
4187
+ {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
4188
  {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
4189
  {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
4190
  {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 +4255,16 @@ const TOURS = {
3420
4255
  {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
4256
  waste: [
3422
4257
  {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.'}],
4258
+ {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.'},
4259
+ {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.'},
4260
+ {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.'}],
4261
+ jev: [
4262
+ {el: 'card:What is Jev?', t: 'What Jev is', see: 'A fast, cheap AI that only makes decisions: pick one, yes or no, a score, a value.', get: 'Cheaper, faster apps and scripts, wherever an AI call only decides something.', act: 'Read the three lines. Jev does not make Claude Code itself cheaper.'},
4263
+ {el: 'card:Set up Jev', t: 'Install and key', see: 'One click installs the Jev plugin for Claude Code; the key is saved in Claude Code\'s settings.', get: 'Claude knows how to use Jev when you build something.', act: 'Press Install, check the two commands, then Confirm. Paste your key and Save.'},
4264
+ {el: 'card:Where Jev fits', t: 'Where Jev fits', see: 'Your prompts that were really just a decision, and what they would cost on Jev.', get: 'An honest number before you change anything.', act: 'If it is tiny, Jev belongs in your apps, not your coding sessions.'}],
3426
4265
  freemodels: [
3427
4266
  {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.'}],
4267
+ {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
4268
  compare: [
3430
4269
  {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
4270
  {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.'},
@@ -3448,10 +4287,14 @@ const TOURS = {
3448
4287
  {el: 'card:Scenarios', t: 'Scenarios', see: 'Optimistic, expected and pessimistic end-of-period totals.', get: 'A best and worst case to plan against.', act: 'Budget against the pessimistic number.'}],
3449
4288
  budgets: [
3450
4289
  {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
- {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.'}],
4290
+ {el: 'card:Set your limits', 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.'},
4291
+ {el: 'card:Set your limits', t: 'Suggestions and shorthand', see: 'Chips under each amount suggest values from your own last 30 days and session sizes; amounts accept 20M, 500k or $3,000.', get: 'A sensible budget in one click, and a clear message when a value will not work.', act: 'Click a suggestion, adjust it, and Save.'},
4292
+ {el: 'card:Set your limits', t: 'Session guard', see: 'A token budget for each Claude Code session, warn percentages, what happens after you approve, per-project overrides, and the Live warnings box (optional).', get: 'A warning while a session grows, and a pause for your approval once it reaches its budget.', act: 'Set a budget, save, then, if you want live warnings, <b>Install</b> in the Live warnings box. It is optional. It applies to new sessions.'}],
4293
+ settings: [
4294
+ {el: 'card:API keys', t: 'API keys', see: 'Optional keys for the Anthropic Admin API and Cursor.', get: 'The Billed vs local page, without the terminal.', act: 'Paste a key and press Save key. It is never shown again, only its last four characters.'}],
3452
4295
  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.'},
3454
- {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.'},
4296
+ {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.`},
4297
+ {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: 'Add a key on the Settings page, or run claude-finops --set-key.'},
3455
4298
  {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
4299
  {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.'},
3457
4300
  {el: 'card:Cursor members', t: 'Cursor members', see: 'Cursor team members and their spend.', get: 'The same picture for Cursor seats.', act: 'Look for seats with no usage at all.'},
@@ -3533,7 +4376,7 @@ function showStep() {
3533
4376
  const last = i === steps.length - 1;
3534
4377
  pop.innerHTML = `
3535
4378
  <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>
4379
+ <button class="tour-x" data-t="skip" title="Close (Esc)">${I('x')}</button></div>
3537
4380
  <h4>${esc(s.t)}</h4>
3538
4381
  ${s.see ? `<div class="tour-row"><b>What you see</b><span>${s.see}</span></div>` : ''}
3539
4382
  ${s.get ? `<div class="tour-row"><b>What you get</b><span>${s.get}</span></div>` : ''}