burner-cc 0.1.2 → 0.3.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.
@@ -0,0 +1,752 @@
1
+ import { themeCss, categoryOverrideCss, PAPER_CATEGORY_COLORS } from './themes.js';
2
+ import { loadConfig } from './config.js';
3
+ import { skillSetupPrompt } from './install.js';
4
+
5
+ export function renderSettingsPage(theme = null) {
6
+ const cfg = loadConfig();
7
+ const catOverrideCss = categoryOverrideCss(cfg.categories);
8
+ const paperCatJson = JSON.stringify(PAPER_CATEGORY_COLORS);
9
+ const skillPromptJson = JSON.stringify(skillSetupPrompt(cfg));
10
+ return `<!doctype html>
11
+ <html lang="en" data-theme="paper">
12
+ <head>
13
+ <meta charset="utf-8" />
14
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
15
+ <title>Burner · Settings</title>
16
+ <script>(function(){var t=${JSON.stringify(theme)};try{t=t||localStorage.getItem('burner-theme');}catch(e){}if(['paper','thermal','matrix','dark'].indexOf(t)<0)t='paper';document.documentElement.setAttribute('data-theme',t);})();</script>
17
+ <style>
18
+ ${themeCss()}
19
+ ${catOverrideCss}
20
+ *{box-sizing:border-box;}
21
+ body{margin:0;font-family:'IBM Plex Mono','Courier New',monospace;background:var(--page-bg);color:var(--ink);padding:24px 16px 80px;transition:background .15s,color .15s;font-size:12px;}
22
+ .page{max-width:860px;margin:0 auto;}
23
+ .nav{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:18px;}
24
+ .nav .sp{flex:1;}
25
+ a.back{color:var(--ink);text-decoration:none;font-size:11px;letter-spacing:.05em;border:1px solid var(--rule);padding:5px 11px;border-radius:3px;text-transform:uppercase;}
26
+ a.back:hover{background:var(--ink);color:var(--page-bg);}
27
+ button{font-family:inherit;font-size:11px;letter-spacing:.05em;border:1px solid var(--rule);background:transparent;color:var(--ink);padding:5px 11px;cursor:pointer;border-radius:3px;text-transform:uppercase;}
28
+ button.active,button:hover:not(:disabled){background:var(--ink);color:var(--page-bg);}
29
+ button:disabled{opacity:.45;cursor:default;}
30
+ button.sm{padding:2px 7px;font-size:10px;}
31
+ input,select,textarea{font-family:inherit;font-size:11px;background:var(--bg);border:1px solid var(--rule);color:var(--ink);padding:5px 8px;border-radius:3px;}
32
+ input[type=color]{padding:0;width:32px;height:24px;}
33
+ input[type=checkbox]{padding:0;accent-color:var(--accent);}
34
+ .card{background:var(--bg);padding:18px 22px 20px;box-shadow:0 3px 10px var(--shadow);margin-bottom:16px;}
35
+ .card.hl{outline:1px solid var(--ink);}
36
+ h3{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim);font-weight:bold;margin:0 0 8px;}
37
+ .dim{color:var(--dim);}
38
+ .small{font-size:10px;}
39
+ .note{color:var(--dim);font-size:11px;line-height:1.6;margin:0 0 14px;}
40
+ .rule{border:none;border-top:1px dashed var(--rule);margin:12px 0;}
41
+ .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:6px 0;}
42
+ .row.sb{justify-content:space-between;}
43
+ .grow{flex:1;min-width:120px;}
44
+ .chip{display:inline-block;border:1px solid var(--rule);padding:2px 8px;border-radius:10px;font-size:10px;cursor:pointer;user-select:none;}
45
+ .chip.on{background:var(--accent);color:var(--accent-fg);border-color:var(--accent);}
46
+ .chip.static{cursor:default;}
47
+ .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:4px;}
48
+ table{width:100%;border-collapse:collapse;font-size:11px;}
49
+ th{text-align:left;padding:6px 6px;border-bottom:1px solid var(--rule);color:var(--dim);font-weight:bold;letter-spacing:.05em;font-size:10px;text-transform:uppercase;}
50
+ td{padding:6px 6px;border-bottom:1px dashed var(--rule);vertical-align:middle;}
51
+ td.num,th.num{text-align:right;}
52
+ .tblwrap{overflow-x:auto;}
53
+ .pin{width:72px;}
54
+ .savebar{position:sticky;bottom:12px;background:var(--bg);box-shadow:0 3px 10px rgba(0,0,0,.25);padding:10px 16px;display:flex;justify-content:space-between;align-items:center;gap:8px;border:1px solid var(--ink);}
55
+ .badge{font-size:9px;letter-spacing:.08em;border:1px solid var(--rule);padding:1px 6px;border-radius:2px;white-space:nowrap;}
56
+ .badge.o{background:var(--ink);color:var(--page-bg);}
57
+ .mark{color:var(--ink);font-weight:bold;}
58
+ .slider-wrap{padding:6px 10px 0;}
59
+ input[type=range]{width:100%;padding:0;accent-color:var(--accent);}
60
+ .ticks{display:flex;justify-content:space-between;font-size:10px;color:var(--dim);margin-top:4px;gap:4px;}
61
+ .ticks span{flex:1;text-align:center;cursor:pointer;}
62
+ .ticks span.sel{color:var(--ink);font-weight:bold;}
63
+ .ticks span:first-child{text-align:left;}.ticks span:last-child{text-align:right;}
64
+ .lvdesc{margin-top:12px;font-size:11px;line-height:1.6;}
65
+ .field{display:flex;flex-direction:column;gap:3px;margin:8px 0;}
66
+ .field label{font-size:10px;color:var(--dim);letter-spacing:.08em;text-transform:uppercase;}
67
+ .field input{width:100%;}
68
+ .result{font-size:11px;margin-top:8px;line-height:1.6;white-space:pre-wrap;word-break:break-word;}
69
+ .err{color:var(--warn);}
70
+ .pathrow{display:flex;gap:8px;align-items:center;font-size:11px;padding:3px 0;border-bottom:1px dashed var(--rule);}
71
+ .pathrow span{flex:1;word-break:break-all;}
72
+ .toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:var(--ink);color:var(--page-bg);padding:7px 14px;border-radius:3px;font-size:11px;letter-spacing:.05em;opacity:0;transition:opacity .2s;pointer-events:none;z-index:300;max-width:90vw;}
73
+ .toast.show{opacity:1;}
74
+ .toast.error{background:var(--warn);color:var(--accent-fg);}
75
+ .pricing-foot{margin-top:14px;padding-top:12px;border-top:1px dashed var(--rule);font-size:10px;color:var(--dim);text-align:center;line-height:1.7;}
76
+ .pricing-foot a{color:var(--dim);text-decoration:none;border-bottom:1px dashed var(--rule);}
77
+ .pricing-foot a:hover{color:var(--ink);}
78
+ .add-model{margin-top:14px;padding-top:12px;border-top:1px dashed var(--rule);}
79
+ .copyblock{width:100%;font-size:10px;line-height:1.5;resize:vertical;min-height:52px;}
80
+ .card-section{margin-top:14px;padding-top:14px;border-top:1px dashed var(--rule);}
81
+ .card-section h4{font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--dim);font-weight:bold;margin:0 0 8px;}
82
+ .setup-pills{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px;align-items:center;}
83
+ .setup-pills .badge{padding:4px 10px;font-size:10px;}
84
+ .setup-pills .badge.ok{outline:1px solid var(--accent);}
85
+ .setup-pills .badge.dimmed{opacity:.5;}
86
+ .setup-pills label.row{margin:0;gap:6px;}
87
+ </style>
88
+ </head>
89
+ <body>
90
+ <div class="page">
91
+ <div class="nav">
92
+ <a class="back" href="/">&larr; Report</a>
93
+ <button data-tab="projects">Projects</button>
94
+ <button data-tab="categories">Categories</button>
95
+ <button data-tab="pricing">Pricing</button>
96
+ <button data-tab="tagging">Tagging</button>
97
+ <span class="sp"></span>
98
+ <button data-theme="paper">Paper</button>
99
+ <button data-theme="thermal">Thermal</button>
100
+ <button data-theme="matrix">Matrix</button>
101
+ <button data-theme="dark">Dark</button>
102
+ </div>
103
+ <div id="view"></div>
104
+ </div>
105
+ <div class="toast" id="toast"></div>
106
+ <script>
107
+ (function(){
108
+ var PAPER_CAT = ${paperCatJson};
109
+ function applyCatOverrides(){
110
+ var el = document.getElementById('catOverrides');
111
+ if (!el) { el = document.createElement('style'); el.id = 'catOverrides'; document.head.appendChild(el); }
112
+ if (!S || !S.config) { el.textContent = ''; return; }
113
+ var lines = [];
114
+ (S.config.categories || []).forEach(function(c){
115
+ if (PAPER_CAT[c.id] && String(c.color).toLowerCase() === PAPER_CAT[c.id]) return;
116
+ lines.push('--cat-' + c.id + ':' + c.color);
117
+ });
118
+ el.textContent = lines.length ? ':root{' + lines.join(';') + '}' : '';
119
+ }
120
+ var S = null; // server state
121
+ var tab = 'projects';
122
+ var draftProjects = null, projDirty = false;
123
+ var draftCats = null, catDirty = false;
124
+ var priceEdits = {}, priceResets = {};
125
+ var addDraft = '', addMsg = '', addCands = [];
126
+ var openProj = [];
127
+ var dirOpen = {}, allSessions = null; // draft project objects currently expanded
128
+ var trainText = '', trainErr = false;
129
+ var busy = {};
130
+ var view = document.getElementById('view');
131
+ var toastEl = document.getElementById('toast');
132
+ var toastT = null;
133
+ var SKILL_SETUP_PROMPT = ${skillPromptJson};
134
+
135
+ function copyField(label, text){
136
+ var ta = h('textarea', {class:'copyblock', readOnly:true, rows: String(Math.min(10, text.split('\\n').length + 1)), value: text});
137
+ var wrap = h('div', {class:'field'});
138
+ if (label) wrap.appendChild(h('label', {text: label}));
139
+ var row = h('div', {class:'row'});
140
+ row.appendChild(ta);
141
+ row.appendChild(h('button', {class:'sm', text:'Copy', onclick:function(){ navigator.clipboard.writeText(text).then(function(){ toast('copied'); }).catch(function(){ toast('copy failed', true); }); }}));
142
+ wrap.appendChild(row);
143
+ return wrap;
144
+ }
145
+
146
+ function escapeHtml(s){ return String(s == null ? '' : s).replace(/[&<>"']/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]; }); }
147
+ function money(n){ n = Number(n) || 0; return '$' + (n > 0 && n < 0.01 ? n.toFixed(4) : n.toFixed(2)); }
148
+ function fmtTok(n){ n = Number(n) || 0; if (n >= 1e6) return (n/1e6).toFixed(2) + 'M'; if (n >= 1e3) return (n/1e3).toFixed(1) + 'K'; return String(Math.round(n)); }
149
+ function rel(t){
150
+ if (t == null || t === '') return 'never';
151
+ var ms = typeof t === 'number' ? t : Date.parse(String(t));
152
+ if (!ms || isNaN(ms)) return 'unknown';
153
+ var d = Date.now() - ms;
154
+ if (d < 0) return 'just now';
155
+ if (d < 60000) return 'just now';
156
+ if (d < 3600000) return Math.floor(d / 60000) + 'm ago';
157
+ if (d < 86400000) return Math.floor(d / 3600000) + 'h ago';
158
+ return Math.floor(d / 86400000) + 'd ago';
159
+ }
160
+ function h(tag, attrs, kids){
161
+ var e = document.createElement(tag);
162
+ if (attrs) for (var k in attrs){
163
+ var v = attrs[k];
164
+ if (v == null || v === false) continue;
165
+ if (k === 'class') e.className = v;
166
+ else if (k === 'text') e.textContent = v;
167
+ else if (k.slice(0,2) === 'on') e.addEventListener(k.slice(2), v);
168
+ else if (k === 'value') e.value = v;
169
+ else if (k === 'checked' || k === 'disabled') e[k] = !!v;
170
+ else if (k === 'style') e.setAttribute('style', v);
171
+ else e.setAttribute(k, v === true ? '' : v);
172
+ }
173
+ if (kids != null) (Array.isArray(kids) ? kids : [kids]).forEach(function(c){
174
+ if (c == null || c === false) return;
175
+ e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
176
+ });
177
+ return e;
178
+ }
179
+ function toast(msg, isErr){
180
+ toastEl.textContent = msg;
181
+ toastEl.className = 'toast show' + (isErr ? ' error' : '');
182
+ clearTimeout(toastT);
183
+ toastT = setTimeout(function(){ toastEl.className = 'toast' + (isErr ? ' error' : ''); }, isErr ? 5000 : 1800);
184
+ }
185
+ function api(method, url, body){
186
+ var opt = { method: method, headers: {} };
187
+ if (method !== 'GET'){ opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body || {}); }
188
+ return fetch(url, opt).then(function(r){
189
+ return r.text().then(function(t){
190
+ var j = null; try { j = t ? JSON.parse(t) : {}; } catch(e){}
191
+ if (!r.ok) throw new Error((j && j.error) || ('HTTP ' + r.status));
192
+ return j || {};
193
+ });
194
+ });
195
+ }
196
+ function clone(o){ return JSON.parse(JSON.stringify(o)); }
197
+
198
+ /* theme */
199
+ function setTheme(t, persist){
200
+ document.documentElement.setAttribute('data-theme', t);
201
+ try { localStorage.setItem('burner-theme', t); } catch(e){}
202
+ if (persist !== false) fetch('/api/theme', {method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify({theme:t})}).catch(function(){});
203
+ document.querySelectorAll('[data-theme]').forEach(function(b){ if (b.tagName === 'BUTTON') b.classList.toggle('active', b.getAttribute('data-theme') === t); });
204
+ if (S) rerenderKeep();
205
+ }
206
+ document.querySelectorAll('button[data-theme]').forEach(function(b){ b.addEventListener('click', function(){ setTheme(b.getAttribute('data-theme'), true); }); });
207
+ setTheme(document.documentElement.getAttribute('data-theme'), false);
208
+
209
+ /* tabs */
210
+ var TABS = ['projects','categories','pricing','tagging'];
211
+ function readHash(){ var t = location.hash.replace('#',''); tab = TABS.indexOf(t) >= 0 ? t : 'projects'; }
212
+ document.querySelectorAll('button[data-tab]').forEach(function(b){ b.addEventListener('click', function(){ location.hash = b.getAttribute('data-tab'); }); });
213
+ window.addEventListener('hashchange', function(){ readHash(); render(); });
214
+
215
+ function projectStats(name){
216
+ var s = {dirs:0, sessions:0, cost:0};
217
+ (S.projectTotals || []).forEach(function(t){ if (t.name === name && !t.implicit){ s.sessions = t.sessions; s.cost = t.cost; } });
218
+ return s;
219
+ }
220
+ function loadSessions(){ return api('GET', '/api/sessions').then(function(l){ allSessions = l; }); }
221
+ function assignSession(id, project){
222
+ return api('POST', '/api/session-project', {sessionId:id, project:project || null})
223
+ .then(function(){ return Promise.all([api('GET', '/api/state'), loadSessions()]); })
224
+ .then(function(res){ S.directories = res[0].directories; S.projectTotals = res[0].projectTotals; toast(project ? 'moved to ' + project : 'back to its directory'); rerenderKeep(); })
225
+ .catch(function(e){ toast(e.message, true); });
226
+ }
227
+ function sessionList(d){
228
+ var box = h('div', {style:'padding:6px 0 10px 14px;border-bottom:1px dashed var(--rule)'});
229
+ if (!allSessions){ box.appendChild(h('div', {class:'dim small', text:'loading sessions...'})); loadSessions().then(rerenderKeep); return box; }
230
+ var mine = allSessions.filter(function(s){ return s.cwd === d.cwd; });
231
+ box.appendChild(h('div', {class:'dim small', style:'margin-bottom:4px', text:'Move individual sessions into a saved project (create and save the project first).'}));
232
+ mine.forEach(function(s){
233
+ var sel = h('select', {onchange:function(e){ assignSession(s.sessionId, e.target.value); }});
234
+ sel.appendChild(h('option', {value:'', text:'stays in directory'}));
235
+ (S.config.projects || []).forEach(function(p){ var o = h('option', {value:p.name, text:p.name}); if (s.projectSource === 'session' && s.project === p.name) o.selected = true; sel.appendChild(o); });
236
+ box.appendChild(h('div', {class:'row sb', style:'margin:2px 0'}, [
237
+ h('span', {class:'dim small', text:s.date}),
238
+ h('span', {class:'grow', style:'overflow:hidden;text-overflow:ellipsis;white-space:nowrap', text:s.title}),
239
+ h('span', {class:'small', text:money(s.totalCost.total)}), sel]));
240
+ });
241
+ return box;
242
+ }
243
+ // Sessions that were moved into this project from other directories. Shown on
244
+ // the project card so a move can be undone after its directory row is gone.
245
+ function movedList(name){
246
+ var box = h('div');
247
+ if (!allSessions){ box.appendChild(h('div', {class:'dim small', text:'loading sessions...'})); loadSessions().then(rerenderKeep); return box; }
248
+ var mine = allSessions.filter(function(s){ return s.projectSource === 'session' && s.project === name; });
249
+ if (!mine.length) return null;
250
+ box.appendChild(h('h3', {text:'Sessions moved here', style:'margin-top:10px'}));
251
+ mine.forEach(function(s){
252
+ var sel = h('select', {onchange:function(e){ assignSession(s.sessionId, e.target.value); }});
253
+ sel.appendChild(h('option', {value:'', text:'back to its directory'}));
254
+ (S.config.projects || []).forEach(function(p){ var o = h('option', {value:p.name, text:p.name}); if (p.name === name) o.selected = true; sel.appendChild(o); });
255
+ box.appendChild(h('div', {class:'row sb', style:'margin:2px 0'}, [
256
+ h('span', {class:'dim small', text:s.date}),
257
+ h('span', {class:'grow', style:'overflow:hidden;text-overflow:ellipsis;white-space:nowrap', title:s.cwd, text:s.title}),
258
+ h('span', {class:'small', text:money(s.totalCost.total)}), sel]));
259
+ });
260
+ return box;
261
+ }
262
+ function sortedProjects(list){
263
+ return list.map(function(p, i){ return {p:p, i:i, cost:projectStats(p.name).cost}; })
264
+ .sort(function(a, b){ return b.cost - a.cost || a.p.name.toLowerCase().localeCompare(b.p.name.toLowerCase()); })
265
+ .map(function(x){ return x.p; });
266
+ }
267
+ function applyState(st){
268
+ S = st;
269
+ draftProjects = sortedProjects(clone(S.config.projects || [])); projDirty = false; openProj = [];
270
+ draftCats = clone(S.config.categories || []).map(function(c){ c._existing = true; return c; }); catDirty = false;
271
+ priceEdits = {}; priceResets = {};
272
+ applyCatOverrides();
273
+ }
274
+ function render(){
275
+ document.querySelectorAll('button[data-tab]').forEach(function(b){ b.classList.toggle('active', b.getAttribute('data-tab') === tab); });
276
+ view.textContent = '';
277
+ if (!S){ view.appendChild(h('div', {class:'card dim', text:'loading…'})); return; }
278
+ var fn = { projects: rProjects, categories: rCategories, pricing: rPricing, tagging: rTagging }[tab];
279
+ view.appendChild(fn());
280
+ }
281
+ function rerenderKeep(){ var y = window.scrollY; render(); window.scrollTo(0, y); }
282
+
283
+ function save(promise, okMsg, after){
284
+ return promise.then(function(st){ applyState(st); toast(okMsg || 'saved'); rerenderKeep(); if (after) after(); }).catch(function(e){ toast(e.message, true); });
285
+ }
286
+
287
+ /* ---------- PROJECTS ---------- */
288
+ function catById(id){ var c = (S.config.categories || []).filter(function(x){ return x.id === id; })[0]; return c; }
289
+ function rProjects(){
290
+ var wrap = h('div');
291
+ wrap.appendChild(h('p', {class:'note', text:'A project groups one or more directories. Sessions started anywhere under a directory belong to its project.'}));
292
+ draftProjects.forEach(function(p, i){
293
+ var card = h('div', {class:'card'});
294
+ var isOpen = openProj.indexOf(p) >= 0;
295
+ var st = projectStats(p.name);
296
+ var toggle = function(){ var k = openProj.indexOf(p); if (k >= 0) openProj.splice(k, 1); else openProj.push(p); rerenderKeep(); };
297
+ if (!isOpen){
298
+ card.appendChild(h('div', {class:'row sb', style:'margin:0;cursor:pointer', onclick:toggle}, [
299
+ h('span', null, [h('b', {text:p.name || '(unnamed)'}), h('span', {class:'dim small', text:' ' + p.paths.length + ' dir' + (p.paths.length === 1 ? '' : 's') + ' | ' + st.sessions + ' sessions | ' + money(st.cost) + (p.categories.length ? ' | ' + p.categories.length + ' categories' : '') + (p.defaultCategory ? ' | default ' + p.defaultCategory : '')})]),
300
+ h('button', {class:'sm', text:'Edit', onclick:function(e){ e.stopPropagation(); toggle(); }})]));
301
+ wrap.appendChild(card);
302
+ return;
303
+ }
304
+ var nameIn = h('input', {class:'grow', value:p.name, placeholder:'project name', oninput:function(e){ p.name = e.target.value; setProjDirty(); }});
305
+ card.appendChild(h('div', {class:'row'}, [nameIn,
306
+ h('button', {class:'sm', text:'Collapse', onclick:toggle}),
307
+ h('button', {class:'sm', text:'Delete project', onclick:function(){ draftProjects.splice(i,1); var k = openProj.indexOf(p); if (k >= 0) openProj.splice(k,1); setProjDirty(); rerenderKeep(); }})]));
308
+ card.appendChild(h('h3', {text:'Directories', style:'margin-top:10px'}));
309
+ if (!p.paths.length) card.appendChild(h('div', {class:'dim small', text:'no paths yet'}));
310
+ p.paths.forEach(function(pa, j){
311
+ card.appendChild(h('div', {class:'pathrow'}, [h('span', {text:pa}),
312
+ h('button', {class:'sm', text:'remove', onclick:function(){ p.paths.splice(j,1); setProjDirty(); rerenderKeep(); }})]));
313
+ });
314
+ var addIn = h('input', {class:'grow', placeholder:'add path, e.g. C:\\Users\\you\\code\\app'});
315
+ var doAdd = function(){ var v = addIn.value.trim(); if (!v) return; if (p.paths.indexOf(v) < 0) p.paths.push(v); setProjDirty(); rerenderKeep(); };
316
+ addIn.addEventListener('keydown', function(e){ if (e.key === 'Enter') doAdd(); });
317
+ card.appendChild(h('div', {class:'row'}, [addIn, h('button', {class:'sm', text:'add path', onclick:doAdd})]));
318
+ var moved = (S.projectTotals || []).some(function(t){ return t.name === p.name; }) ? movedList(p.name) : null;
319
+ if (moved) card.appendChild(moved);
320
+ card.appendChild(h('hr', {class:'rule'}));
321
+ card.appendChild(h('h3', {text:'Categories (none selected = all allowed)'}));
322
+ var chips = h('div', {class:'row'});
323
+ (S.config.categories || []).forEach(function(c){
324
+ var on = p.categories.indexOf(c.id) >= 0;
325
+ chips.appendChild(h('span', {class:'chip' + (on ? ' on' : ''), onclick:function(){
326
+ var k = p.categories.indexOf(c.id);
327
+ if (k >= 0) p.categories.splice(k,1); else p.categories.push(c.id);
328
+ if (p.defaultCategory && p.categories.length && p.categories.indexOf(p.defaultCategory) < 0) p.defaultCategory = null;
329
+ setProjDirty(); rerenderKeep();
330
+ }}, [h('span', {class:'dot', style:'background:var(--cat-' + escapeHtml(c.id) + ')'}), c.label]));
331
+ });
332
+ card.appendChild(chips);
333
+ var allowed = (S.config.categories || []).filter(function(c){ return !p.categories.length || p.categories.indexOf(c.id) >= 0; });
334
+ var sel = h('select', {onchange:function(e){ p.defaultCategory = e.target.value || null; setProjDirty(); }});
335
+ sel.appendChild(h('option', {value:'', text:'— none —'}));
336
+ allowed.forEach(function(c){ var o = h('option', {value:c.id, text:c.label}); if (p.defaultCategory === c.id) o.selected = true; sel.appendChild(o); });
337
+ card.appendChild(h('div', {class:'row'}, [h('span', {class:'dim small', text:'DEFAULT CATEGORY'}), sel, h('span', {class:'dim small', text:'used for untagged sessions'})]));
338
+ wrap.appendChild(card);
339
+ });
340
+ wrap.appendChild(h('div', {class:'row', style:'margin-bottom:16px'}, [h('button', {text:'New project', onclick:function(){
341
+ var np = {name:'', paths:[], categories:[], defaultCategory:null};
342
+ draftProjects.push(np); openProj.push(np); setProjDirty(); rerenderKeep();
343
+ setTimeout(function(){ var ins = view.querySelectorAll('input[placeholder="project name"]'); var last = ins[ins.length - 1]; if (last){ last.focus(); last.scrollIntoView({block:'center'}); } }, 0);
344
+ }})]));
345
+
346
+ var un = (S.directories || []).filter(function(d){ return d.implicit; });
347
+ var ucard = h('div', {class:'card'}, [h('h3', {text:'Unassigned directories'})]);
348
+ var assigned = {}; draftProjects.forEach(function(p){ p.paths.forEach(function(x){ assigned[x] = 1; }); });
349
+ var shown = un.filter(function(d){ return !assigned[d.cwd] && d.sessions > 0; });
350
+ if (!shown.length) ucard.appendChild(h('div', {class:'dim', text:'none'}));
351
+ shown.forEach(function(d){
352
+ var sel = h('select', {onchange:function(e){
353
+ var v = e.target.value; if (v === '') return;
354
+ var target;
355
+ if (v === 'new'){
356
+ var base = d.cwd.replace(/[\\\/]+$/, '').split(/[\\\/]/).pop() || d.cwd;
357
+ target = {name:base, paths:[], categories:[], defaultCategory:null};
358
+ draftProjects.push(target);
359
+ } else target = draftProjects[Number(v)];
360
+ if (target.paths.indexOf(d.cwd) < 0) target.paths.push(d.cwd);
361
+ if (openProj.indexOf(target) < 0) openProj.push(target);
362
+ setProjDirty(); rerenderKeep();
363
+ }});
364
+ sel.appendChild(h('option', {value:'', text:'add to project…'}));
365
+ draftProjects.forEach(function(p, i){ sel.appendChild(h('option', {value:String(i), text:p.name || '(unnamed)'})); });
366
+ sel.appendChild(h('option', {value:'new', text:'new project named after folder'}));
367
+ var isOpenDir = !!dirOpen[d.cwd];
368
+ ucard.appendChild(h('div', {class:'row sb', style:'border-bottom:' + (isOpenDir ? 'none' : '1px dashed var(--rule)') + ';padding:4px 0'}, [
369
+ h('span', {class:'grow', style:'word-break:break-all', text:d.cwd}),
370
+ h('span', {class:'dim small', text:d.sessions + ' sessions' + (d.moved ? ' (+' + d.moved + ' moved)' : '')}),
371
+ h('span', {text:money(d.cost)}),
372
+ h('button', {class:'sm', text:isOpenDir ? 'Hide sessions' : 'Sessions', onclick:function(){ dirOpen[d.cwd] = !dirOpen[d.cwd]; rerenderKeep(); }}), sel]));
373
+ if (isOpenDir) ucard.appendChild(sessionList(d));
374
+ });
375
+ wrap.appendChild(ucard);
376
+ if (projDirty) wrap.appendChild(projBar());
377
+ return wrap;
378
+ }
379
+ function projBar(){ return h('div', {class:'savebar', id:'projbar'}, [h('span', {class:'dim', text:'unsaved changes'}), h('span', {class:'row', style:'margin:0'}, [h('button', {text:'Cancel', onclick:cancelProjects}), h('button', {text:'Save', onclick:saveProjects})])]); }
380
+ function cancelProjects(){ draftProjects = sortedProjects(clone(S.config.projects || [])); projDirty = false; openProj = []; rerenderKeep(); toast('changes discarded'); }
381
+ function setProjDirty(){
382
+ if (projDirty) return; projDirty = true;
383
+ if (tab === 'projects' && !document.getElementById('projbar')) view.firstChild.appendChild(projBar());
384
+ }
385
+ function saveProjects(){
386
+ var names = {};
387
+ for (var i = 0; i < draftProjects.length; i++){
388
+ var n = draftProjects[i].name.trim();
389
+ if (!n) return toast('project names must not be empty', true);
390
+ if (names[n.toLowerCase()]) return toast('duplicate project name: ' + n, true);
391
+ names[n.toLowerCase()] = 1;
392
+ draftProjects[i].name = n;
393
+ }
394
+ save(api('PUT', '/api/config', {projects: draftProjects}));
395
+ }
396
+
397
+ /* ---------- CATEGORIES ---------- */
398
+ function rCategories(){
399
+ var wrap = h('div');
400
+ var card = h('div', {class:'card'}, [h('h3', {text:'Categories'}),
401
+ h('p', {class:'note', text:'These are the choices for tagging a session. Up / down changes the order they appear in (menus, charts, and the list /burner-tag shows Claude). Delete removes a category; sessions already tagged with it become untagged. A category used by a project must be removed from that project first.'})]);
402
+ var tb = h('tbody');
403
+ draftCats.forEach(function(c, i){
404
+ var idCell = c._existing ? h('span', {text:c.id}) : h('input', {class:'pin', style:'width:110px', value:c.id, placeholder:'my-id', oninput:function(e){ c.id = e.target.value.trim().toLowerCase(); setCatDirty(); }});
405
+ tb.appendChild(h('tr', null, [
406
+ h('td', null, h('input', {type:'color', value:/^#[0-9a-fA-F]{6}$/.test(c.color) ? c.color : '#888888', oninput:function(e){ c.color = e.target.value; setCatDirty(); }})),
407
+ h('td', null, idCell),
408
+ h('td', null, h('input', {style:'width:120px', value:c.label, oninput:function(e){ c.label = e.target.value; setCatDirty(); }})),
409
+ h('td', null, h('input', {style:'width:100%;min-width:140px', value:c.description || '', oninput:function(e){ c.description = e.target.value; setCatDirty(); }})),
410
+ h('td', {style:'white-space:nowrap'}, [
411
+ h('button', {class:'sm', text:'up', title:'move up in the list', disabled:i === 0, onclick:function(){ var t = draftCats[i]; draftCats[i] = draftCats[i-1]; draftCats[i-1] = t; setCatDirty(); rerenderKeep(); }}), ' ',
412
+ h('button', {class:'sm', text:'down', title:'move down in the list', disabled:i === draftCats.length - 1, onclick:function(){ var t = draftCats[i]; draftCats[i] = draftCats[i+1]; draftCats[i+1] = t; setCatDirty(); rerenderKeep(); }}), ' ',
413
+ h('button', {class:'sm', text:'delete', title:'remove this category', onclick:function(){
414
+ var users = draftProjects.filter(function(p){ return p.categories.indexOf(c.id) >= 0 || p.defaultCategory === c.id; });
415
+ if (users.length) return toast('category "' + c.id + '" is used by project: ' + users.map(function(p){ return p.name; }).join(', '), true);
416
+ draftCats.splice(i,1); setCatDirty(); rerenderKeep();
417
+ }})])
418
+ ]));
419
+ });
420
+ card.appendChild(h('div', {class:'tblwrap'}, h('table', null, [
421
+ h('thead', null, h('tr', null, [h('th', {text:'Color'}), h('th', {text:'Id'}), h('th', {text:'Label'}), h('th', {text:'Description'}), h('th', {text:''})])), tb])));
422
+ card.appendChild(h('div', {class:'row'}, [h('button', {text:'Add category', onclick:function(){
423
+ draftCats.push({id:'', label:'', color:'#8a8478', description:'', _existing:false}); setCatDirty(); rerenderKeep();
424
+ }})]));
425
+ wrap.appendChild(card);
426
+ if (catDirty) wrap.appendChild(catBar());
427
+ return wrap;
428
+ }
429
+ function catBar(){ return h('div', {class:'savebar', id:'catbar'}, [h('span', {class:'dim', text:'unsaved changes'}), h('span', {class:'row', style:'margin:0'}, [h('button', {text:'Cancel', onclick:cancelCats}), h('button', {text:'Save', onclick:saveCats})])]); }
430
+ function cancelCats(){ draftCats = clone(S.config.categories || []).map(function(c){ c._existing = true; return c; }); catDirty = false; rerenderKeep(); toast('changes discarded'); }
431
+ function setCatDirty(){ if (catDirty) return; catDirty = true; if (tab === 'categories' && !document.getElementById('catbar')) view.firstChild.appendChild(catBar()); }
432
+ function saveCats(){
433
+ var seen = {};
434
+ for (var i = 0; i < draftCats.length; i++){
435
+ var c = draftCats[i];
436
+ if (!/^[a-z][a-z0-9-]*$/.test(c.id)) return toast('invalid id "' + c.id + '" (lowercase letters, digits, dashes)', true);
437
+ if (c.id === 'untagged') return toast('"untagged" is reserved', true);
438
+ if (seen[c.id]) return toast('duplicate id: ' + c.id, true);
439
+ seen[c.id] = 1;
440
+ if (!c.label.trim()) return toast('label required for ' + c.id, true);
441
+ }
442
+ var out = draftCats.map(function(c){ return {id:c.id, label:c.label.trim(), color:c.color, description:c.description || ''}; });
443
+ save(api('PUT', '/api/config', {categories: out}));
444
+ }
445
+
446
+ /* ---------- PRICING ---------- */
447
+ var FIELDS = [['input','input'],['output','output'],['cacheWrite5m','cw 5m'],['cacheWrite1h','cw 1h'],['cacheRead','cache read']];
448
+ var SRC = {live:'LIVE', cache:'CACHED', 'stale-cache':'CACHED (STALE)', 'bundled-fallback':'OFFLINE SNAPSHOT'};
449
+ function ratesEqual(a, b){ return FIELDS.every(function(f){ return Number(a[f[0]]) === Number(b[f[0]]); }); }
450
+ function pricingModelRows(){
451
+ var rows = (S.models || []).map(function(m){ return Object.assign({customOnly:false}, m); });
452
+ var ovs = S.pricingOverrides || {};
453
+ Object.keys(ovs).forEach(function(id){
454
+ if (rows.some(function(m){ return m.rawModel === id; })) return;
455
+ rows.push({ rawModel: id, sessions: 0, tokens: {}, cost: 0, fidelity: 'override', rates: ovs[id], suggested: null, customOnly: true });
456
+ });
457
+ return rows;
458
+ }
459
+ function rPricing(){
460
+ var wrap = h('div');
461
+ var p = S.pricing || {};
462
+ var head = h('div', {class:'card'});
463
+ var refBtn = h('button', {text: busy.refresh ? 'refreshing…' : 'Refresh prices', disabled:!!busy.refresh, onclick:function(){
464
+ busy.refresh = true; rerenderKeep();
465
+ api('POST', '/api/pricing/refresh', {}).then(function(st){ busy.refresh = false; applyState(st); toast('prices refreshed'); rerenderKeep(); })
466
+ .catch(function(e){ busy.refresh = false; toast(e.message, true); rerenderKeep(); });
467
+ }});
468
+ head.appendChild(h('div', {class:'row sb'}, [
469
+ h('span', null, [h('b', {text:SRC[p.source] || String(p.source || '').toUpperCase()}), h('span', {class:'dim', text:' fetched ' + rel(p.fetchedAt) + (p.modelCount != null ? ' · ' + p.modelCount + ' public models' : '')})]), refBtn]));
470
+ if (p.error) head.appendChild(h('div', {class:'err small', text:p.error}));
471
+ head.appendChild(h('p', {class:'note', style:'margin:8px 0 0', text:'Only models found in your sessions are listed. AUTO models follow the public price list; Pin price keeps a price for that model forever. Anything that is not a Claude model costs $0 until you set one.'}));
472
+ wrap.appendChild(head);
473
+
474
+ var card = h('div', {class:'card'}, [h('h3', {text:'Models'})]);
475
+ var tb = h('tbody');
476
+ pricingModelRows().forEach(function(m){
477
+ var raw = m.rawModel;
478
+ var inputs = {};
479
+ var tr = h('tr');
480
+ var markCell = h('span', {class:'mark'});
481
+ function cur(){ var o = {}; FIELDS.forEach(function(f){ o[f[0]] = Number(inputs[f[0]].value) || 0; }); return o; }
482
+ function refresh(){
483
+ var c = cur();
484
+ if (priceResets[raw]) { markCell.textContent = '* reset'; return; }
485
+ if (!ratesEqual(c, m.rates)) { priceEdits[raw] = c; markCell.textContent = '* changed'; }
486
+ else { delete priceEdits[raw]; markCell.textContent = ''; }
487
+ setPriceBar();
488
+ }
489
+ function fill(r){ FIELDS.forEach(function(f){ inputs[f[0]].value = r[f[0]]; }); delete priceResets[raw]; refresh(); }
490
+ FIELDS.forEach(function(f){
491
+ var cur0 = (priceEdits[raw] || m.rates)[f[0]];
492
+ inputs[f[0]] = h('input', {type:'number', step:'any', min:'0', class:'pin', value:cur0, title:f[1], oninput:function(){ delete priceResets[raw]; refresh(); }});
493
+ });
494
+ var badge = {override:['PINNED', 1], measured:['AUTO: PUBLIC LIST'], 'approximate-alias':['AUTO: ALIAS'], unpriced:['$0 UNPRICED']}[m.fidelity] || [String(m.fidelity).toUpperCase()];
495
+ var total = 0; for (var k in (m.tokens || {})) total += Number(m.tokens[k]) || 0;
496
+ var hasOverride = S.pricingOverrides && S.pricingOverrides[raw];
497
+ var acts = h('div', {style:'display:flex;gap:4px;flex-wrap:wrap'});
498
+ if (!hasOverride) acts.appendChild(h('button', {class:'sm', title:'keep exactly these rates for this model from now on, even if the public list changes', text:'Pin price', onclick:function(){
499
+ var o = clone(S.pricingOverrides || {}); o[raw] = cur(); delete priceEdits[raw]; delete priceResets[raw];
500
+ save(api('PUT', '/api/pricing', {models: o}), 'pinned ' + raw);
501
+ }}));
502
+ var rfBtn = h('button', {class:'sm', title:'re-fetch the public price list and fill in this model\\'s current price', text:'Refresh', onclick:function(){
503
+ rfBtn.disabled = true; rfBtn.textContent = '...';
504
+ api('POST', '/api/pricing/lookup', {model:raw, refresh:true}).then(function(r){
505
+ rfBtn.disabled = false; rfBtn.textContent = 'Refresh';
506
+ if (r.listError) toast('could not reach the price list, used ' + r.listSource + ' copy', true);
507
+ if (!r.found) return toast('"' + raw + '" is not in the public price list', true);
508
+ if (ratesEqual(cur(), r.rates)) return toast('already matches the public price');
509
+ fill(r.rates); toast('public price filled in - ' + (hasOverride ? 'Save to keep it' : 'Pin it to keep it'));
510
+ }).catch(function(e){ rfBtn.disabled = false; rfBtn.textContent = 'Refresh'; toast(e.message, true); });
511
+ }});
512
+ acts.appendChild(rfBtn);
513
+ if (m.suggested) acts.appendChild(h('button', {class:'sm', text:'Use public $', onclick:function(){ fill(m.suggested); }}));
514
+ acts.appendChild(h('button', {class:'sm', text:'Set $0', onclick:function(){ fill({input:0,output:0,cacheWrite5m:0,cacheWrite1h:0,cacheRead:0}); }}));
515
+ var dropOverride = function(msg){
516
+ var o = clone(S.pricingOverrides || {}); delete o[raw]; delete priceEdits[raw]; delete priceResets[raw];
517
+ save(api('PUT', '/api/pricing', {models: o}), msg);
518
+ };
519
+ if (hasOverride && !m.customOnly) acts.appendChild(h('button', {class:'sm', title:'stop pinning; follow the public list again', text:'Unpin', onclick:function(){ dropOverride('unpinned ' + raw); }}));
520
+ if (m.customOnly) acts.appendChild(h('button', {class:'sm', text:'Remove', onclick:function(){ dropOverride('removed ' + raw); }}));
521
+ tb.appendChild(h('tr', null, [h('td', {colspan:'7', style:'border-bottom:none;padding-bottom:0'}, [
522
+ h('b', {text:raw}),
523
+ ' ', h('span', {class:'badge' + (badge[1] ? ' o' : ''), text:badge[0]}), ' ', markCell,
524
+ h('span', {class:'dim small', text:' ' + m.sessions + ' sessions · ' + fmtTok(total) + ' tokens · ' + money(m.cost) + (m.unreported ? ' · ' + m.unreported + ' requests reported no token usage' : '')})])]));
525
+ var cells = [];
526
+ FIELDS.forEach(function(f){ cells.push(h('td', null, [h('div', {class:'dim small', text:f[1]}), inputs[f[0]]])); });
527
+ cells.push(h('td', {colspan:'2'}, acts));
528
+ tb.appendChild(h('tr', null, cells));
529
+ });
530
+ if (!pricingModelRows().length) tb.appendChild(h('tr', null, h('td', {class:'dim', text:'no models yet — add one below or run a session first'})));
531
+ card.appendChild(h('div', {class:'tblwrap'}, h('table', null, [tb])));
532
+ card.appendChild(h('div', {class:'dim small', style:'margin-top:8px', text:'USD per million tokens'}));
533
+ var addId = h('input', {class:'grow', placeholder:'model id (e.g. claude-sonnet-5-5, qwen3-coder:30b)', value:addDraft, oninput:function(e){ addDraft = e.target.value; }});
534
+ function putModel(id, rates, msg){
535
+ var o = clone(S.pricingOverrides || {});
536
+ if (o[id]) return toast('already listed: ' + id, true);
537
+ o[id] = rates;
538
+ addDraft = ''; addMsg = msg || ''; addCands = [];
539
+ save(api('PUT', '/api/pricing', {models: o}), msg || 'added');
540
+ }
541
+ function doAdd(){
542
+ var id = (addDraft || '').trim().toLowerCase();
543
+ if (!id) return toast('enter a model id', true);
544
+ busy.add = true; addMsg = 'checking the public price list...'; addCands = []; rerenderKeep();
545
+ api('POST', '/api/pricing/lookup', {model:id}).then(function(r){
546
+ busy.add = false;
547
+ if (r.found){
548
+ putModel(id, r.rates, 'found "' + r.id + '" in the public price list' + (r.provider ? ' (' + r.provider + ')' : '') + ' - rates filled in, edit if needed');
549
+ } else if (r.candidates && r.candidates.length){
550
+ addMsg = 'no exact match for "' + id + '". Similar public models:'; addCands = r.candidates; rerenderKeep();
551
+ } else {
552
+ putModel(id, {input:0,output:0,cacheWrite5m:0,cacheWrite1h:0,cacheRead:0}, 'not in the public price list - added at $0, set the rates below');
553
+ }
554
+ }).catch(function(e){ busy.add = false; addMsg = ''; toast(e.message, true); rerenderKeep(); });
555
+ }
556
+ addId.addEventListener('keydown', function(e){ if (e.key === 'Enter') doAdd(); });
557
+ var addBox = h('div', {class:'add-model'}, [
558
+ h('h3', {text:'Add model'}),
559
+ h('p', {class:'note', style:'margin:0 0 8px', text:'Looks the id up in the public price list and fills in the rates when it exists. Otherwise it starts at $0 so you can set your own (gateway, on-prem, local).'}),
560
+ h('div', {class:'row'}, [addId, h('button', {text: busy.add ? 'checking...' : 'Add', disabled:!!busy.add, onclick:doAdd})])]);
561
+ if (addMsg) addBox.appendChild(h('div', {class:'result', text:addMsg}));
562
+ if (addCands.length){
563
+ var cl = h('div', {class:'row'});
564
+ addCands.forEach(function(c){
565
+ cl.appendChild(h('span', {class:'chip', title:'in $' + c.rates.input + ' / out $' + c.rates.output + ' per 1M tokens', text:c.id, onclick:function(){
566
+ putModel(addDraft.trim().toLowerCase() || c.id, c.rates, 'used rates from "' + c.id + '" for "' + (addDraft.trim().toLowerCase() || c.id) + '" - edit if needed');
567
+ }}));
568
+ });
569
+ cl.appendChild(h('button', {class:'sm', text:'add at $0 instead', onclick:function(){ putModel(addDraft.trim().toLowerCase(), {input:0,output:0,cacheWrite5m:0,cacheWrite1h:0,cacheRead:0}, 'added at $0 - set the rates below'); }}));
570
+ addBox.appendChild(cl);
571
+ }
572
+ card.appendChild(addBox);
573
+ wrap.appendChild(card);
574
+ if (Object.keys(priceEdits).length || Object.keys(priceResets).length) wrap.appendChild(priceBar());
575
+ var srcUrl = S.pricingSourceUrl || '';
576
+ if (srcUrl) wrap.appendChild(h('div', {class:'pricing-foot'}, [h('span', {text:'Public price list: '}), h('a', {href:srcUrl, target:'_blank', rel:'noopener', text:srcUrl})]));
577
+ return wrap;
578
+ }
579
+ function priceBar(){ return h('div', {class:'savebar', id:'pricebar'}, [h('span', {class:'dim', text:'unsaved price changes'}), h('span', {class:'row', style:'margin:0'}, [h('button', {text:'Cancel', onclick:function(){ priceEdits = {}; priceResets = {}; rerenderKeep(); toast('changes discarded'); }}), h('button', {text:'Save', onclick:savePricing})])]); }
580
+ function setPriceBar(){
581
+ if (tab !== 'pricing') return;
582
+ var dirty = Object.keys(priceEdits).length || Object.keys(priceResets).length;
583
+ var bar = document.getElementById('pricebar');
584
+ if (dirty && !bar) view.firstChild.appendChild(priceBar());
585
+ else if (!dirty && bar) bar.remove();
586
+ }
587
+ function savePricing(){
588
+ var o = clone(S.pricingOverrides || {});
589
+ Object.keys(priceEdits).forEach(function(k){ o[k] = priceEdits[k]; });
590
+ Object.keys(priceResets).forEach(function(k){ delete o[k]; });
591
+ save(api('PUT', '/api/pricing', {models: o}));
592
+ }
593
+
594
+ function taggingSetupPills(level, ins, tagging, learned){
595
+ var pills = h('div', {class:'setup-pills'});
596
+ pills.appendChild(h('span', {class:'dim small', text:'Claude setup (auto):'}));
597
+ if (level === 'off') {
598
+ pills.appendChild(h('span', {class:'badge dimmed', text:'skill removed'}));
599
+ } else {
600
+ pills.appendChild(h('span', {class:'badge' + (ins.skillInstalled ? ' ok' : ''), text:'/' + (ins.skillName || 'burner-tag') + (ins.skillInstalled ? ' \u2713' : ' \u2717')}));
601
+ var md = ins.claudeMdLevel ? ('CLAUDE.md ' + ins.claudeMdLevel) : 'CLAUDE.md off';
602
+ if (level === 'session' && ins.sessionEndHook) md = 'SessionEnd hook \u2713';
603
+ pills.appendChild(h('span', {class:'badge' + ((ins.claudeMdLevel || ins.sessionEndHook) ? ' ok' : ' dimmed'), text: md}));
604
+ }
605
+ pills.appendChild(h('span', {class:'dim small', style:'margin-left:4px', text:'|'}));
606
+ var L = learned;
607
+ var localN = L && (L.sources && L.sources.local != null ? L.sources.local : L.examples);
608
+ var trainedLbl = 'My trained model';
609
+ if (L && L.hasUserModel) trainedLbl += ' \u2713 · ' + localN + ' tag' + (localN === 1 ? '' : 's');
610
+ else if (L && !L.hasUserModel) trainedLbl += ' (not saved yet)';
611
+ else trainedLbl += ' (not trained)';
612
+ if (L && L.trainedAt && L.hasUserModel) trainedLbl += ' · ' + rel(L.trainedAt);
613
+ pills.appendChild(h('label', {class:'row'}, [
614
+ h('input', {type:'checkbox', checked:!!tagging.learned, onchange:function(e){ save(api('PUT', '/api/config', {tagging:{learned:e.target.checked}}), e.target.checked ? 'learned model on' : 'learned model off'); }}),
615
+ h('span', {class:'badge' + (tagging.learned && L && L.hasUserModel ? ' ok' : ''), text: trainedLbl})]));
616
+ return pills;
617
+ }
618
+
619
+ /* ---------- TAGGING ---------- */
620
+ function rTagging(){
621
+ var wrap = h('div');
622
+ var t = S.config.tagging || {};
623
+ var levels = S.levels || [];
624
+ var ins = S.install || {};
625
+ var L = S.learned;
626
+ var idx = Math.max(0, levels.map(function(l){ return l.id; }).indexOf(t.level));
627
+ var desc = h('div', {class:'lvdesc', text:levels[idx] ? levels[idx].description : ''});
628
+ var setupPills = taggingSetupPills(t.level, ins, t, L);
629
+ var ticks = h('div', {class:'ticks'});
630
+ var slider = h('input', {type:'range', min:'0', max:String(Math.max(0, levels.length - 1)), step:'1', value:String(idx)});
631
+ levels.forEach(function(l, i){ ticks.appendChild(h('span', {class:i === idx ? 'sel' : '', text:l.label, onclick:function(){ slider.value = i; slider.dispatchEvent(new Event('change')); }})); });
632
+ function syncLevelUi(i){
633
+ var lv = levels[i].id;
634
+ desc.textContent = levels[i].description;
635
+ Array.prototype.forEach.call(ticks.children, function(s, j){ s.className = j === i ? 'sel' : ''; });
636
+ sessionCard.style.display = lv === 'session' ? '' : 'none';
637
+ }
638
+ slider.addEventListener('input', function(){ syncLevelUi(Number(slider.value)); });
639
+ slider.addEventListener('change', function(){
640
+ var lv = levels[Number(slider.value)].id;
641
+ save(api('PUT', '/api/config', {tagging:{level:lv}}), 'Claude setup updated');
642
+ });
643
+ var levelCard = h('div', {class:'card'});
644
+ levelCard.appendChild(h('h3', {text:'Tagging level'}));
645
+ levelCard.appendChild(h('div', {class:'slider-wrap'}, [slider, ticks]));
646
+ levelCard.appendChild(desc);
647
+ levelCard.appendChild(setupPills);
648
+ wrap.appendChild(levelCard);
649
+
650
+ var sessionCard = h('div', {class:'card', style: t.level === 'session' ? '' : 'display:none'});
651
+ sessionCard.appendChild(h('h3', {text:'Session-end chat model (optional)'}));
652
+ sessionCard.appendChild(h('p', {class:'note', text:'At Session end level: set an Anthropic model id to tag via a SessionEnd hook, or leave blank to have the main agent tag with /burner-tag before wrapping up.'}));
653
+ var endIn = h('input', {type:'text', placeholder:'e.g. claude-haiku-4-5', value: t.endModel || ''});
654
+ endIn.addEventListener('change', function(){
655
+ save(api('PUT', '/api/config', {tagging:{endModel: endIn.value.trim()}}), 'Claude setup updated');
656
+ });
657
+ var endField = h('div', {class:'field'});
658
+ endField.appendChild(h('label', {text:'Model id'}));
659
+ endField.appendChild(endIn);
660
+ sessionCard.appendChild(endField);
661
+ sessionCard.appendChild(h('p', {class:'dim small', style:'margin-top:8px', text: ins.sessionEndHook ? 'Hook installed' + (ins.endModel ? ' for ' + ins.endModel : '') + ' · needs ANTHROPIC_API_KEY' : 'Saving a model id installs the SessionEnd hook automatically.'}));
662
+ wrap.appendChild(sessionCard);
663
+
664
+ var skillCard = h('div', {class:'card'});
665
+ skillCard.appendChild(h('h3', {text:'Troubleshooting / manual install'}));
666
+ skillCard.appendChild(h('p', {class:'note', text:'Moving the tagging slider or saving session-end model updates ~/.claude for you. Use this only if /burner-tag is missing or hooks look wrong.'}));
667
+ skillCard.appendChild(h('div', {class:'card-section', style:'margin-top:0;padding-top:0;border:none'}, [
668
+ h('h4', {text:'Status'}),
669
+ h('div', {text:'/' + ins.skillName + ' ' + (ins.skillInstalled ? '\u2713' : '\u2717')}),
670
+ ins.skillPath ? h('div', {class:'dim small', text:ins.skillPath}) : null,
671
+ h('div', {class:'dim small', text:'CLAUDE.md: ' + (ins.claudeMdLevel || 'none')})]));
672
+ var installCmd = ins.skillInstallCmdNpm || 'burner install';
673
+ if (ins.devCheckout && ins.skillInstallCmd && ins.skillInstallCmd !== installCmd) {
674
+ installCmd = installCmd + '\\n' + ins.skillInstallCmd;
675
+ }
676
+ var secInstall = h('div', {class:'card-section'});
677
+ secInstall.appendChild(h('h4', {text:'Install'}));
678
+ secInstall.appendChild(copyField('', installCmd));
679
+ secInstall.appendChild(h('button', {class:'sm', text:'Sync Claude setup now', onclick:function(){ save(api('POST', '/api/install/sync', {}), 'synced'); }}));
680
+ secInstall.appendChild(h('p', {class:'note dim small', style:'margin:6px 0 0', text:'npm i -g burner-cc → burner install on PATH'}));
681
+ skillCard.appendChild(secInstall);
682
+ var secPaste = h('div', {class:'card-section'});
683
+ secPaste.appendChild(h('h4', {text:'Paste into Claude / Cursor'}));
684
+ secPaste.appendChild(copyField('', SKILL_SETUP_PROMPT));
685
+ skillCard.appendChild(secPaste);
686
+ wrap.appendChild(skillCard);
687
+
688
+ var c3 = h('div', {class:'card'}, [h('h3', {text:'Train my model'}), h('p', {class:'note', text:'Runs entirely in Node on your machine (no GPU, Python, or extra packages). Reads your tagged sessions and writes ~/.burner/model/intent-nb.json. Works the same on Windows, macOS, and Linux. Enable with the checkbox under the slider; guesses only when there is no tag and no project default, confidence \u2265 60%.'})]);
689
+ var st = h('div', {class:'row'});
690
+ var catIds = (S.config.categories || []).map(function(c){ return c.id; });
691
+ if (L){
692
+ var localN = L.sources && L.sources.local != null ? L.sources.local : L.examples;
693
+ var status = localN + ' tagged session' + (localN === 1 ? '' : 's');
694
+ if (L.trainedAt) status += ' · trained ' + rel(L.trainedAt);
695
+ if (L.builtin) status += ' · built-in prior (weak)';
696
+ else if (L.totalRows && L.totalRows > localN) status += ' · includes older merged data (' + L.totalRows + ' rows) — Reset then Train to use only your tags';
697
+ st.appendChild(h('span', {class:'dim', text:status}));
698
+ var src = L.sources || {};
699
+ var srcBits = Object.keys(src).filter(function(k){ return src[k]; }).map(function(k){ return k + ' ' + src[k]; });
700
+ if (srcBits.length) st.appendChild(h('span', {class:'dim small', text:srcBits.join(' · ')}));
701
+ var chips = h('div', {class:'row', style:'margin-top:6px'});
702
+ Object.keys(L.classes || {}).filter(function(k){ return catIds.indexOf(k) >= 0; }).sort(function(a,b){ return (L.classes[b]||0) - (L.classes[a]||0); }).forEach(function(k){
703
+ var w = L.classes[k];
704
+ var sess = Math.max(1, Math.round(w / 5));
705
+ chips.appendChild(h('span', {class:'chip static', title:'training weight ' + w + ' (each of your tags counts as 5)', text:k + ' \u00b7 ' + sess + ' session' + (sess === 1 ? '' : 's')}));
706
+ });
707
+ c3.appendChild(st);
708
+ if (chips.childNodes.length) {
709
+ c3.appendChild(chips);
710
+ c3.appendChild(h('p', {class:'dim small', style:'margin:4px 0 0', text:'Counts are how many of your tagged sessions trained each category (your tags weigh more than any public bootstrap data).'}));
711
+ }
712
+ } else {
713
+ st.appendChild(h('span', {class:'dim', text:'not trained yet — need 6+ tagged sessions across 2+ categories'}));
714
+ c3.appendChild(st);
715
+ }
716
+ var rowBtns = h('div', {class:'row'});
717
+ rowBtns.appendChild(h('button', {text: busy.train ? 'training…' : 'Train from my tags', disabled:!!busy.train, onclick:function(){
718
+ busy.train = true; rerenderKeep();
719
+ api('POST', '/api/learned/train', {}).then(function(r){
720
+ busy.train = false;
721
+ trainText = r.message || (r.ok ? 'trained on ' + r.examples + ' tagged session' + (r.examples === 1 ? '' : 's') : 'not trained'); trainErr = !r.ok;
722
+ return api('GET', '/api/state').then(function(s2){ applyState(s2); rerenderKeep(); });
723
+ }).catch(function(e){ busy.train = false; toast(e.message, true); rerenderKeep(); });
724
+ }}));
725
+ if (L && L.hasUserModel) rowBtns.appendChild(h('button', {class:'sm', text:'Reset model', onclick:function(){
726
+ if (!confirm('Delete your saved learned model? Untagged sessions will stay untagged until you train again.')) return;
727
+ api('POST', '/api/learned/reset', {}).then(function(){ trainText = 'model cleared'; trainErr = false; return api('GET', '/api/state').then(function(s2){ applyState(s2); rerenderKeep(); }); }).catch(function(e){ toast(e.message, true); });
728
+ }}));
729
+ var secTrain = h('div', {class:'card-section'});
730
+ secTrain.appendChild(h('h4', {text:'Train'}));
731
+ secTrain.appendChild(rowBtns);
732
+ secTrain.appendChild(h('div', {class:'result' + (trainErr ? ' err' : ''), text:trainText}));
733
+ c3.appendChild(secTrain);
734
+ wrap.appendChild(c3);
735
+
736
+ var s = S.stats || {};
737
+ var bs = Object.keys(s.bySource || {}).map(function(k){ return k + ' ' + s.bySource[k]; }).join(' · ');
738
+ wrap.appendChild(h('div', {class:'dim small', style:'text-align:center', text:s.sessions + ' sessions · tagged ' + s.tagged + ' · untagged ' + s.untagged + (bs ? ' [' + bs + ']' : '')}));
739
+ return wrap;
740
+ }
741
+
742
+ window.addEventListener('pageshow', function(e){
743
+ // Back/forward restores a frozen copy: reload fresh state unless there are unsaved edits.
744
+ if (e.persisted && !projDirty && !catDirty && !Object.keys(priceEdits).length && !Object.keys(priceResets).length) api('GET', '/api/state').then(function(st){ applyState(st); allSessions = null; rerenderKeep(); });
745
+ });
746
+ readHash(); render();
747
+ api('GET', '/api/state').then(function(st){ applyState(st); render(); }).catch(function(e){ view.textContent = ''; view.appendChild(h('div', {class:'card err', text:'Failed to load state: ' + e.message})); });
748
+ })();
749
+ </script>
750
+ </body>
751
+ </html>`;
752
+ }