claude-finops 0.10.0 → 0.11.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/README.md +11 -1
- package/finops/act_now.py +3 -0
- package/package.json +1 -1
- package/web/app.js +214 -32
- package/web/styles.css +29 -0
package/README.md
CHANGED
|
@@ -318,6 +318,11 @@ agents listed under Multi-agent.
|
|
|
318
318
|
server binds to `127.0.0.1` only, but treat the database and any export you
|
|
319
319
|
generate as sensitive.
|
|
320
320
|
|
|
321
|
+
**Hide prompts** in the top bar hides prompt text and session titles as dots,
|
|
322
|
+
for when you share your screen. Session titles fall back to their short id. The
|
|
323
|
+
setting is remembered in that browser. It only changes what the page shows: the
|
|
324
|
+
database and exports still contain the full text.
|
|
325
|
+
|
|
321
326
|
The app makes exactly one outbound request of its own: once a day it asks
|
|
322
327
|
`registry.npmjs.org` what the latest `claude-finops` version is, so it can tell
|
|
323
328
|
you when an upgrade is out (npm has no way to push one at you). It sends nothing
|
|
@@ -367,12 +372,17 @@ files on Windows.
|
|
|
367
372
|
- **Skills & MCP** suggests MCP servers and skills from work you repeat, with the evidence.
|
|
368
373
|
"Add" runs `claude mcp add -s user …`; "Create skill" writes `~/.claude/skills/<name>/SKILL.md`.
|
|
369
374
|
The strongest skill suggestions also appear in **⚡ Act now** on the overview.
|
|
370
|
-
- **Compact** (Running sessions, Act now, session rows) types `/compact` into that session's
|
|
375
|
+
- **Compact** (Running sessions, Recent, Act now, session rows) types `/compact` into that session's
|
|
371
376
|
terminal: tmux, Terminal.app, iTerm2 or the Windows console. Anywhere else it copies the
|
|
372
377
|
command for you to paste. It asks you to click twice.
|
|
373
378
|
- **Hand over / split** (Running sessions) writes a brief of the session to
|
|
374
379
|
`~/.claude/finops-handoffs/` and opens fresh session(s) in new terminals, one per sub-task if
|
|
375
380
|
you list them; it can close the old session (resumable).
|
|
381
|
+
- **☰ Recent** (top bar, or press `R`) opens a side panel on any page: running sessions first,
|
|
382
|
+
with Resume, Compact and Stop (Interrupt), then past sessions newest first. Click a session to
|
|
383
|
+
list its prompts, and click a prompt for its full details. Search matches your prompt text.
|
|
384
|
+
It covers all dates but keeps the agent and project filters. Close and Force kill stay on
|
|
385
|
+
Running sessions.
|
|
376
386
|
- **Interrupt / Close / Force kill** (Running sessions) send the session's process a signal.
|
|
377
387
|
Each asks you to click twice. Interrupt is macOS/Linux only: on Windows there is no way to
|
|
378
388
|
interrupt one console process without hitting the others in its window.
|
package/finops/act_now.py
CHANGED
|
@@ -33,6 +33,8 @@ def build(live, skills, memory, statusline):
|
|
|
33
33
|
"kind": "session", "severity": x["severity"], "pid": x.get("pid"),
|
|
34
34
|
"session_id": x.get("session_id"), "hosts_dashboard": bool(x.get("hosts_dashboard")),
|
|
35
35
|
"title": f"{x.get('name') or 'A session'} is carrying {_k(x['context'])} tokens of context",
|
|
36
|
+
# the part of the title that comes from the user's prompts, for the privacy mask
|
|
37
|
+
"private": x.get("name"),
|
|
36
38
|
"detail": f"{x.get('project') or ''} · every message re-reads all of it. "
|
|
37
39
|
+ ("Hand it over to a fresh session." if x["severity"] == "high"
|
|
38
40
|
else "Compact it at the next break."),
|
|
@@ -52,6 +54,7 @@ def build(live, skills, memory, statusline):
|
|
|
52
54
|
items.append({
|
|
53
55
|
"kind": "memory",
|
|
54
56
|
"title": f"You keep re-typing instructions about: {m['text']}",
|
|
57
|
+
"private": m["text"],
|
|
55
58
|
"detail": f"Seen in {m['sessions']} sessions. Put it in {m['target']} once instead.",
|
|
56
59
|
})
|
|
57
60
|
if statusline is None:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-finops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Local FinOps dashboard for Claude Code: what you used, what it cost, why it cost that much, and what to change. Reads your own transcripts, no API key, no data leaves the machine.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"claude-finops": "bin/claude-finops.js"
|
package/web/app.js
CHANGED
|
@@ -21,6 +21,15 @@ const h = (html) => { const t = document.createElement('template');
|
|
|
21
21
|
const esc = s => String(s ?? '').replace(/[&<>"']/g, c =>
|
|
22
22
|
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
23
23
|
|
|
24
|
+
/* ---------- privacy mask ----------
|
|
25
|
+
For screen-sharing: prompt text and session titles (written from your prompts)
|
|
26
|
+
render as dots. Only the page changes; the warehouse and exports are untouched. */
|
|
27
|
+
const MASK_KEY = 'finops-mask-prompts';
|
|
28
|
+
let MASKED = false;
|
|
29
|
+
try { MASKED = localStorage.getItem(MASK_KEY) === '1'; } catch (_) {}
|
|
30
|
+
const pt = t => MASKED && t ? String(t).replace(/\S/g, '•') : t;
|
|
31
|
+
const stitle = x => (!MASKED && x.title) || shortId(x.session_id);
|
|
32
|
+
|
|
24
33
|
const qs = () => {
|
|
25
34
|
const f = S.filter, p = new URLSearchParams();
|
|
26
35
|
if (f.start) p.set('start', f.start);
|
|
@@ -173,6 +182,8 @@ function shell() {
|
|
|
173
182
|
<input id="gsearch" placeholder="Search prompts, sessions, models, dates…"></div>
|
|
174
183
|
<button class="iconbtn upd" id="upd" hidden></button>
|
|
175
184
|
<button class="iconbtn" id="tour-btn" title="Walk through this dashboard">? Tour</button>
|
|
185
|
+
<button class="iconbtn" id="recent-btn" title="Past sessions and prompts, from any page (R)">☰ Recent</button>
|
|
186
|
+
<button class="iconbtn" id="mask"></button>
|
|
176
187
|
<button class="iconbtn" id="theme" title="Toggle theme">◐</button>
|
|
177
188
|
<button class="iconbtn" id="refresh" title="Reload data">↻</button>
|
|
178
189
|
<button class="act" id="sync" title="Re-read every agent's local data so the dashboard is current">⟳ Sync</button>
|
|
@@ -194,6 +205,14 @@ function shell() {
|
|
|
194
205
|
render();
|
|
195
206
|
};
|
|
196
207
|
$('#refresh').onclick = () => { bust(); render(); };
|
|
208
|
+
maskLabel();
|
|
209
|
+
$('#mask').onclick = () => {
|
|
210
|
+
MASKED = !MASKED;
|
|
211
|
+
try { localStorage.setItem(MASK_KEY, MASKED ? '1' : '0'); } catch (_) {}
|
|
212
|
+
maskLabel(); closeDrawer(); render();
|
|
213
|
+
if (RECENT.open) { RECENT.offset = 0; renderRecent(); }
|
|
214
|
+
};
|
|
215
|
+
$('#recent-btn').onclick = () => toggleRecent();
|
|
197
216
|
updateChip();
|
|
198
217
|
$('#sync').onclick = runSync;
|
|
199
218
|
syncLabel();
|
|
@@ -203,6 +222,15 @@ function shell() {
|
|
|
203
222
|
else if (S.view === 'search') go('overview'); }, 260); };
|
|
204
223
|
}
|
|
205
224
|
|
|
225
|
+
function maskLabel() {
|
|
226
|
+
const b = $('#mask');
|
|
227
|
+
b.textContent = MASKED ? '◌ Show prompts' : '◉ Hide prompts';
|
|
228
|
+
b.title = MASKED ? 'Prompt text is hidden. Click to show it'
|
|
229
|
+
: 'Hide prompt text and session titles, e.g. before sharing your screen';
|
|
230
|
+
b.setAttribute('aria-pressed', MASKED);
|
|
231
|
+
b.classList.toggle('on', MASKED);
|
|
232
|
+
}
|
|
233
|
+
|
|
206
234
|
/* ---------- update notice ----------
|
|
207
235
|
npm cannot push a new release at anyone, so the server asks the registry once
|
|
208
236
|
a day and we surface the answer here. Silent when you are current, when the
|
|
@@ -367,7 +395,13 @@ function drawer(title, bodyHtml, sub) {
|
|
|
367
395
|
document.body.append(scrim, d);
|
|
368
396
|
return d;
|
|
369
397
|
}
|
|
370
|
-
document.addEventListener('keydown', e => {
|
|
398
|
+
document.addEventListener('keydown', e => {
|
|
399
|
+
// Esc closes the top-most layer: a detail drawer first, then the Recent panel.
|
|
400
|
+
if (e.key === 'Escape') { if ($('.drawer')) closeDrawer(); else if (RECENT.open) toggleRecent(false); return; }
|
|
401
|
+
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName) || e.target.isContentEditable;
|
|
402
|
+
if (!typing && !e.metaKey && !e.ctrlKey && !e.altKey && e.key.toLowerCase() === 'r' && !$('.drawer'))
|
|
403
|
+
toggleRecent();
|
|
404
|
+
});
|
|
371
405
|
|
|
372
406
|
async function openPrompt(id) {
|
|
373
407
|
const d = drawer('Prompt detail', '<div class="loading">Loading…</div>');
|
|
@@ -379,11 +413,11 @@ async function openPrompt(id) {
|
|
|
379
413
|
${kpi('Output tokens', fmtInt(p.output_tokens))}
|
|
380
414
|
${kpi('Peak context', fmtInt(p.max_context_tokens))}
|
|
381
415
|
</div>
|
|
382
|
-
${card('Prompt', `<div class="prompt-text">${esc(p.text)}</div>
|
|
416
|
+
${card('Prompt', `<div class="prompt-text">${esc(pt(p.text))}</div>
|
|
383
417
|
<div class="mt" style="margin-top:8px;display:flex;gap:14px;flex-wrap:wrap;font-size:11px;color:var(--muted)">
|
|
384
418
|
<span>${esc(p.ts || '')}</span><span class="pill">${esc(p.category)}</span>
|
|
385
419
|
<span>confidence ${(100*(p.category_confidence||0)).toFixed(0)}%</span>
|
|
386
|
-
${(p.category_evidence||[]).length ? `<span>matched: ${esc(p.category_evidence.join(', '))}</span>` : ''}
|
|
420
|
+
${(p.category_evidence||[]).length ? `<span>matched: ${esc(pt(p.category_evidence.join(', ')))}</span>` : ''}
|
|
387
421
|
<span>${fmtInt(p.char_len)} chars · ${fmtInt(p.word_len)} words</span>
|
|
388
422
|
${p.source ? `<span class="pill">${esc(p.source)}</span>` : ''}
|
|
389
423
|
</div>`, {badge: BADGE.actual})}
|
|
@@ -411,7 +445,7 @@ async function openPrompt(id) {
|
|
|
411
445
|
: '<div class="na">No files touched</div>', {badge: BADGE.actual})}
|
|
412
446
|
</div>
|
|
413
447
|
${card('Context', `<dl class="kv">
|
|
414
|
-
<dt>Session</dt><dd><a data-sess="${esc(p.session_id)}">${esc(p.session_title || shortId(p.session_id))}</a></dd>
|
|
448
|
+
<dt>Session</dt><dd><a data-sess="${esc(p.session_id)}">${esc((!MASKED && p.session_title) || shortId(p.session_id))}</a></dd>
|
|
415
449
|
<dt>Project</dt><dd>${esc(p.project)}</dd>
|
|
416
450
|
<dt>Git branch</dt><dd>${p.git_branch ? esc(p.git_branch) : NA()}</dd>
|
|
417
451
|
<dt>Models</dt><dd>${esc(p.models || '—')}</dd>
|
|
@@ -443,7 +477,7 @@ async function openSession(id) {
|
|
|
443
477
|
hint: 'context tokens actual, cost estimated'})}
|
|
444
478
|
${card('Prompts in this session', table([
|
|
445
479
|
{h: 'Time', f: r => `<span class="mono">${esc((r.ts||'').slice(11,16))}</span>`},
|
|
446
|
-
{h: 'Prompt', trunc: 1, title: r => r.preview, f: r => esc(r.preview)},
|
|
480
|
+
{h: 'Prompt', trunc: 1, title: r => pt(r.preview), f: r => esc(pt(r.preview))},
|
|
447
481
|
{h: 'Category', f: r => `<span class="pill">${esc(r.category)}</span>`},
|
|
448
482
|
{h: 'Tools', num: 1, f: r => fmtInt(r.tool_calls)},
|
|
449
483
|
{h: 'Peak ctx', num: 1, f: r => fmtInt(r.max_context)},
|
|
@@ -462,7 +496,7 @@ async function openSession(id) {
|
|
|
462
496
|
${s.resume ? `<dt>Resume</dt><dd><span class="mono">${esc(s.resume)}</span>
|
|
463
497
|
<button class="btn pb-copy" id="sess-resume" data-copy="${esc(s.resume)}">Copy</button></dd>` : ''}
|
|
464
498
|
<dt>Session ID</dt><dd class="mono">${esc(s.id)}</dd>
|
|
465
|
-
<dt>Title</dt><dd>${s.title ? esc(s.title) : NA()}</dd>
|
|
499
|
+
<dt>Title</dt><dd>${s.title ? esc(pt(s.title)) : NA()}</dd>
|
|
466
500
|
<dt>Project</dt><dd>${esc(s.project)}</dd>
|
|
467
501
|
<dt>Git branch</dt><dd>${s.git_branch ? esc(s.git_branch) : NA()}</dd>
|
|
468
502
|
<dt>CLI version</dt><dd>${s.cli_version ? esc(s.cli_version) : NA()}</dd>
|
|
@@ -650,7 +684,7 @@ VIEWS.overview = async (page) => {
|
|
|
650
684
|
const lp = leaderboards.most_expensive.slice(0, 10);
|
|
651
685
|
$('#topprompts', page).innerHTML = table([
|
|
652
686
|
{h: '#', num: 1, f: (r) => lp.indexOf(r) + 1},
|
|
653
|
-
{h: 'Prompt', trunc: 1, title: r => r.preview, f: r => esc(r.preview)},
|
|
687
|
+
{h: 'Prompt', trunc: 1, title: r => pt(r.preview), f: r => esc(pt(r.preview))},
|
|
654
688
|
{h: 'Category', f: r => `<span class="pill">${esc(r.category)}</span>`},
|
|
655
689
|
{h: 'Tokens', num: 1, f: r => fmtNum(r.ptokens)},
|
|
656
690
|
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.pcost)},
|
|
@@ -660,7 +694,7 @@ VIEWS.overview = async (page) => {
|
|
|
660
694
|
const ls = (await api('sessions', '&limit=10&order=cost')).rows;
|
|
661
695
|
$('#topsessions', page).innerHTML = table([
|
|
662
696
|
{h: 'Session', trunc: 1, title: r => r.session_id,
|
|
663
|
-
f: r => esc(
|
|
697
|
+
f: r => esc(stitle(r))},
|
|
664
698
|
{h: 'Project', f: r => esc(r.project)},
|
|
665
699
|
{h: 'Prompts', num: 1, f: r => fmtInt(r.prompts)},
|
|
666
700
|
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
@@ -711,7 +745,7 @@ function actNowStrip(items) {
|
|
|
711
745
|
<span class="note">one click each · nothing changes until you click</span></div>
|
|
712
746
|
${items.map((it, i) => `<div class="actnow-it" data-i="${i}">
|
|
713
747
|
<span class="ai">${ACT_ICON[it.kind] || '•'}</span>
|
|
714
|
-
<div class="ft"><b>${codeTicks(it.title)}</b><div class="note">${esc(it.detail)}</div></div>
|
|
748
|
+
<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>
|
|
715
749
|
<div class="live-actions">${btn(it)}<span class="live-msg"></span></div></div>`).join('')}</section>`;
|
|
716
750
|
}
|
|
717
751
|
function wireActNow(root, items) {
|
|
@@ -1201,13 +1235,13 @@ VIEWS.sessions = async (page) => {
|
|
|
1201
1235
|
</div>`, {badge: BADGE.estimated, hint: 'click a row to open the session'})}
|
|
1202
1236
|
<div class="grid g2">
|
|
1203
1237
|
${card('Lowest output yield', table([
|
|
1204
|
-
{h: 'Session', trunc: 1, f: r => esc(
|
|
1238
|
+
{h: 'Session', trunc: 1, f: r => esc(stitle(r))},
|
|
1205
1239
|
{h: 'Output share', num: 1, f: r => fmtPct(r.output_ratio * 100, 2)},
|
|
1206
1240
|
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
1207
1241
|
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
1208
1242
|
], eff.low_efficiency_sessions, {onRow: 1}), {badge: BADGE.estimated, hint: 'low efficiency'})}
|
|
1209
1243
|
${card('Highest output yield', table([
|
|
1210
|
-
{h: 'Session', trunc: 1, f: r => esc(
|
|
1244
|
+
{h: 'Session', trunc: 1, f: r => esc(stitle(r))},
|
|
1211
1245
|
{h: 'Output share', num: 1, f: r => fmtPct(r.output_ratio * 100, 2)},
|
|
1212
1246
|
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
1213
1247
|
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
@@ -1219,7 +1253,7 @@ VIEWS.sessions = async (page) => {
|
|
|
1219
1253
|
if (sessMore) sessMore.onclick = () => { sp.offset += sp.limit; bust(); render(); };
|
|
1220
1254
|
$('#st', page).innerHTML = table([
|
|
1221
1255
|
{h: 'Session', trunc: 1, title: r => r.session_id,
|
|
1222
|
-
f: r => `${r.tokens > avgTok * 3 ? '🔴 ' : ''}${esc(
|
|
1256
|
+
f: r => `${r.tokens > avgTok * 3 ? '🔴 ' : ''}${esc(stitle(r))}
|
|
1223
1257
|
<div class="sub mono">${esc(shortId(r.session_id))}</div>`},
|
|
1224
1258
|
{h: '', f: r => rowActs(r, live)},
|
|
1225
1259
|
{h: 'Project', f: r => esc(r.project)},
|
|
@@ -1249,7 +1283,7 @@ VIEWS.sessions = async (page) => {
|
|
|
1249
1283
|
wireTable(effCards[1], eff.high_efficiency_sessions, r => openSession(r.session_id));
|
|
1250
1284
|
addChart(page, 'Top sessions by estimated cost', el => C.barsH(el, {
|
|
1251
1285
|
rows: [...rows].sort((a, b) => b.cost - a.cost).slice(0, 12),
|
|
1252
|
-
label: r => clip(
|
|
1286
|
+
label: r => clip(stitle(r), 42), value: r => r.cost,
|
|
1253
1287
|
sub: r => `<div class="row"><span class="k">Project</span><span class="v">${esc(r.project)}</span></div>
|
|
1254
1288
|
<div class="row"><span class="k">Tokens</span><span class="v">${fmtNum(r.tokens)}</span></div>
|
|
1255
1289
|
<div class="row"><span class="k">Prompts</span><span class="v">${fmtInt(r.prompts)}</span></div>`,
|
|
@@ -1289,12 +1323,12 @@ VIEWS.prompts = async (page) => {
|
|
|
1289
1323
|
if (promptMore) promptMore.onclick = () => { pp.offset += pp.limit; bust(); render(); };
|
|
1290
1324
|
$('#pt', page).innerHTML = table([
|
|
1291
1325
|
{h: 'When', f: r => `<span class="mono">${esc((r.ts || '').slice(0, 16).replace('T', ' '))}</span>`},
|
|
1292
|
-
{h: 'Prompt', trunc: 1, title: r => r.preview, f: r => esc(r.preview)},
|
|
1326
|
+
{h: 'Prompt', trunc: 1, title: r => pt(r.preview), f: r => esc(pt(r.preview))},
|
|
1293
1327
|
{h: 'Category', f: r => `<span class="pill" title="confidence ${(100*(r.category_confidence||0)).toFixed(0)}%${
|
|
1294
1328
|
r.category_evidence?.length ? ' · matched: ' + esc(r.category_evidence.join(', ')) : ''}">${esc(r.category)}</span>`},
|
|
1295
1329
|
{h: 'Project', f: r => esc(r.project)},
|
|
1296
1330
|
{h: 'Session', trunc: 1, title: r => r.session_id,
|
|
1297
|
-
f: r => `<span class="mono sub">${esc(r.session_title || shortId(r.session_id))}</span>`},
|
|
1331
|
+
f: r => `<span class="mono sub">${esc((!MASKED && r.session_title) || shortId(r.session_id))}</span>`},
|
|
1298
1332
|
{h: 'Models', f: r => (r.models || '').split(',').map(m =>
|
|
1299
1333
|
`<span class="swatch" title="${esc(modelName(m))}" style="background:${modelColor(m)}"></span>`).join('')},
|
|
1300
1334
|
{h: 'Chars', num: 1, f: r => fmtInt(r.char_len)},
|
|
@@ -1334,7 +1368,7 @@ VIEWS.rankings = async (page) => {
|
|
|
1334
1368
|
S.rankTab = b.dataset.rt; render(); });
|
|
1335
1369
|
const cols = isSess ? [
|
|
1336
1370
|
{h: '#', num: 1, f: r => rows.indexOf(r) + 1},
|
|
1337
|
-
{h: 'Session', trunc: 1, f: r => esc(
|
|
1371
|
+
{h: 'Session', trunc: 1, f: r => esc(stitle(r))},
|
|
1338
1372
|
{h: '', f: r => rowActs(r, live)},
|
|
1339
1373
|
{h: 'Project', f: r => esc(r.project)},
|
|
1340
1374
|
{h: 'Duration', num: 1, f: r => dur(r.duration_s)},
|
|
@@ -1345,7 +1379,7 @@ VIEWS.rankings = async (page) => {
|
|
|
1345
1379
|
{h: 'Start', f: r => `<span class="mono">${esc((r.started_at || '').slice(0, 16).replace('T', ' '))}</span>`},
|
|
1346
1380
|
] : [
|
|
1347
1381
|
{h: '#', num: 1, f: r => rows.indexOf(r) + 1},
|
|
1348
|
-
{h: 'Prompt', trunc: 1, title: r => r.preview, f: r => esc(r.preview)},
|
|
1382
|
+
{h: 'Prompt', trunc: 1, title: r => pt(r.preview), f: r => esc(pt(r.preview))},
|
|
1349
1383
|
{h: 'Category', f: r => `<span class="pill">${esc(r.category)}</span>`},
|
|
1350
1384
|
{h: 'Models', f: r => (r.models || '').split(',').map(m =>
|
|
1351
1385
|
`<span class="swatch" title="${esc(modelName(m))}" style="background:${modelColor(m)}"></span>`).join('')},
|
|
@@ -1353,7 +1387,7 @@ VIEWS.rankings = async (page) => {
|
|
|
1353
1387
|
{h: 'Output', num: 1, f: r => fmtNum(r.output_tokens)},
|
|
1354
1388
|
{h: 'Tok eff', num: 1, f: r => fmtPct(r.efficiency * 100, 2)},
|
|
1355
1389
|
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.pcost)},
|
|
1356
|
-
{h: 'Session', trunc: 1, f: r => esc(r.session_title || shortId(r.session_id))},
|
|
1390
|
+
{h: 'Session', trunc: 1, f: r => esc((!MASKED && r.session_title) || shortId(r.session_id))},
|
|
1357
1391
|
{h: '', f: r => rowActs(r, live)},
|
|
1358
1392
|
{h: 'Date', f: r => esc(r.day)},
|
|
1359
1393
|
];
|
|
@@ -1361,7 +1395,7 @@ VIEWS.rankings = async (page) => {
|
|
|
1361
1395
|
wireTable($('#rt', page), rows, r => isSess ? openSession(r.session_id) : openPrompt(r.prompt_id));
|
|
1362
1396
|
wireRowActs($('#rt', page));
|
|
1363
1397
|
addChart(page, 'Top 12 prompts by estimated cost', el => C.barsH(el, {
|
|
1364
|
-
rows: (lb.most_expensive || []).slice(0, 12), label: r => clip(r.preview, 46), value: r => r.pcost,
|
|
1398
|
+
rows: (lb.most_expensive || []).slice(0, 12), label: r => clip(pt(r.preview), 46), value: r => r.pcost,
|
|
1365
1399
|
sub: r => `<div class="row"><span class="k">Project</span><span class="v">${esc(r.project)}</span></div>
|
|
1366
1400
|
<div class="row"><span class="k">Tokens</span><span class="v">${fmtNum(r.ptokens)}</span></div>`}),
|
|
1367
1401
|
{badge: BADGE.estimated, after: '.nothing'});
|
|
@@ -1448,13 +1482,13 @@ VIEWS.hygiene = async (page) => {
|
|
|
1448
1482
|
series: [{key: 'ctx', label: 'Context tokens', color: seriesVar(0)}],
|
|
1449
1483
|
xLabel: v => `#${v}`});
|
|
1450
1484
|
$('#hy-traj', page).insertAdjacentHTML('beforeend',
|
|
1451
|
-
`<div class="note" style="margin-top:6px">${esc(
|
|
1485
|
+
`<div class="note" style="margin-top:6px">${esc(stitle(s))} — ${fmtInt(s.requests)} requests,
|
|
1452
1486
|
${fmtUSD(s.cost_usd)}. Crossed ${K(kT)} at request #${s.ever_crossed[kT] ? s.first_cross_idx[kT] + 1 : '—'};
|
|
1453
1487
|
${fmtPct(s.cost_after_pct[kT])} of its spend came after that.</div>`);
|
|
1454
1488
|
};
|
|
1455
1489
|
const rows = hy.sessions_ranked;
|
|
1456
1490
|
$('#hy-sess', page).innerHTML = table([
|
|
1457
|
-
{h: 'Session', trunc: 1, f: r => esc(
|
|
1491
|
+
{h: 'Session', trunc: 1, f: r => esc(stitle(r))},
|
|
1458
1492
|
{h: 'Project', trunc: 1, f: r => esc(r.project || '')},
|
|
1459
1493
|
{h: 'Context', f: r => `<span class="spk" data-i="${rows.indexOf(r)}" style="display:inline-block;width:110px"></span>`},
|
|
1460
1494
|
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
@@ -1599,7 +1633,7 @@ VIEWS.context = async (page) => {
|
|
|
1599
1633
|
{k: 'With caching (your actual usage)', v: ca.cost_with_cache}],
|
|
1600
1634
|
label: r => r.k, value: r => r.v, color: (r, i) => i ? seriesVar(2) : seriesVar(1), height: 60});
|
|
1601
1635
|
$('#hs', page).innerHTML = table([
|
|
1602
|
-
{h: 'Session', trunc: 1, f: r => esc(
|
|
1636
|
+
{h: 'Session', trunc: 1, f: r => esc(stitle(r))},
|
|
1603
1637
|
{h: 'Project', f: r => esc(r.project)},
|
|
1604
1638
|
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
1605
1639
|
{h: 'Avg context', num: 1, f: r => fmtNum(r.avg_context)},
|
|
@@ -1653,14 +1687,14 @@ VIEWS.waste = async (page) => {
|
|
|
1653
1687
|
const f = all.find(x => x.kind === host.dataset.kind);
|
|
1654
1688
|
const isSess = !!f.evidence[0]?.session_id && !f.evidence[0]?.prompt_id;
|
|
1655
1689
|
const cols = isSess ? [
|
|
1656
|
-
{h: 'Session', trunc: 1, f: r => esc(
|
|
1690
|
+
{h: 'Session', trunc: 1, f: r => esc(stitle(r))},
|
|
1657
1691
|
{h: 'Project', f: r => esc(r.project || '—')},
|
|
1658
1692
|
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
1659
1693
|
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens ?? r.reads)},
|
|
1660
1694
|
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
1661
1695
|
{h: 'Est. excess', num: 1, f: r => fmtUSD(r.excess)},
|
|
1662
1696
|
] : [
|
|
1663
|
-
{h: 'Prompt', trunc: 1, title: r => r.preview, f: r => esc(r.preview)},
|
|
1697
|
+
{h: 'Prompt', trunc: 1, title: r => pt(r.preview), f: r => esc(pt(r.preview))},
|
|
1664
1698
|
{h: 'Detail', f: r => r.n ? `repeated ${r.n}×` : r.char_len ? fmtInt(r.char_len) + ' chars'
|
|
1665
1699
|
: r.tools ? fmtInt(r.tools) + ' tool calls'
|
|
1666
1700
|
: r.out_tokens != null ? fmtInt(r.out_tokens) + ' output tokens' : '—'},
|
|
@@ -1719,7 +1753,7 @@ VIEWS.attribution = async (page) => {
|
|
|
1719
1753
|
${b.configured_unused_mcp.length ? `<div class="note">Configured but never called: <b>${esc(b.configured_unused_mcp.join(', '))}</b>. Their tool definitions still load into every session. Remove them with <code>claude mcp remove <name></code>.</div>` : ''}`;
|
|
1720
1754
|
const put = (id, cols, rows, onRow) => { const el = $(id, page); el.innerHTML = table(cols, rows, {onRow: !!onRow}); wireTable(el, rows, onRow); };
|
|
1721
1755
|
put('#at-sess', [
|
|
1722
|
-
{h: 'Session', trunc: 1, title: r => r.session_id, f: r => esc(
|
|
1756
|
+
{h: 'Session', trunc: 1, title: r => r.session_id, f: r => esc(stitle(r))},
|
|
1723
1757
|
{h: 'Project', f: r => esc(r.project)},
|
|
1724
1758
|
{h: 'Main agent', num: 1, f: r => fmtNum(r.main_tokens)},
|
|
1725
1759
|
{h: 'Subagents', num: 1, f: r => r.sub_tokens ? `${fmtNum(r.sub_tokens)} (${r.subagents})` : '—'},
|
|
@@ -1822,7 +1856,7 @@ VIEWS.live = async (page) => {
|
|
|
1822
1856
|
<div class="item sev-${sevOf(x)}" data-i="${i}">
|
|
1823
1857
|
<div class="hd">${x.status === 'busy' ? '<span class="live-dot"></span>' : '<span class="idle-dot"></span>'}
|
|
1824
1858
|
<span class="pill">${esc(agentName(x.agent))}</span>
|
|
1825
|
-
${esc(x.name || shortId(x.session_id))} <span class="note">· ${esc(x.project)}${x.pid ? ` · pid ${x.pid}` : ''}${x.model ? ` · ${esc(x.model)}` : ''}</span>
|
|
1859
|
+
${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>
|
|
1826
1860
|
${x.hosts_dashboard ? '<span class="status high">runs this dashboard</span>' : ''}
|
|
1827
1861
|
<span class="spacer"></span>
|
|
1828
1862
|
<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>
|
|
@@ -1958,7 +1992,7 @@ VIEWS.diagnose = async (page) => {
|
|
|
1958
1992
|
<div id="dx-live"></div>${card('Running now', d.live_sessions.length ? `<div class="stack">${d.live_sessions.map(x => `
|
|
1959
1993
|
<div class="item sev-${x.severity === 'ok' ? 'low' : x.severity}">
|
|
1960
1994
|
<div class="hd">${x.severity === 'high' ? '🔴' : x.severity === 'medium' ? '🟡' : '🟢'}
|
|
1961
|
-
${esc(
|
|
1995
|
+
${esc(stitle(x))} <span class="note">· ${esc(x.project)}</span><span class="spacer"></span>
|
|
1962
1996
|
<span style="font-variant-numeric:tabular-nums">${fmtNum(x.context)} context · ${fmtInt(x.steps)} steps</span></div>
|
|
1963
1997
|
<div class="dt">${esc(x.advice)} <span class="note">Last write ${x.idle_min} min ago; context grew
|
|
1964
1998
|
${fmtNum(x.start_context)} → ${fmtNum(x.context)}.</span></div>${pb(x.playbook)}</div>`).join('')}</div>`
|
|
@@ -1966,7 +2000,7 @@ VIEWS.diagnose = async (page) => {
|
|
|
1966
2000
|
{badge: BADGE.actual, hint: 'Read live from transcripts on each load'})}
|
|
1967
2001
|
<div id="dx-past"></div>${card('Past sessions that carried too much context', `<div class="stack">${d.session_health.map(x => `
|
|
1968
2002
|
<div class="item sev-${x.peak >= 300000 ? 'high' : 'medium'}">
|
|
1969
|
-
<div class="hd"><a href="#" class="sess-link" data-sess="${esc(x.session_id)}">${esc(
|
|
2003
|
+
<div class="hd"><a href="#" class="sess-link" data-sess="${esc(x.session_id)}">${esc(stitle(x))}</a>
|
|
1970
2004
|
<span class="note">· ${esc(x.project)}</span><span class="spacer"></span>
|
|
1971
2005
|
<span style="font-variant-numeric:tabular-nums">peak ${fmtNum(x.peak)} · ${fmtUSD(x.cost)} ·
|
|
1972
2006
|
~${fmtNum(x.tokens_above_100k)} tokens re-read above 100K (not a saving)</span></div>
|
|
@@ -1976,12 +2010,12 @@ VIEWS.diagnose = async (page) => {
|
|
|
1976
2010
|
<div id="dx-mem"></div>${card(`Add to ${md}${isCl ? ' / memory' : ''} (from your past prompts)`, `<div class="stack">${d.memory_suggestions.map(x => `
|
|
1977
2011
|
<div class="item sev-${x.kind === 'security' ? 'high' : x.already_saved ? 'low' : 'medium'}">
|
|
1978
2012
|
<div class="hd">${x.kind === 'security' ? '🔐' : x.kind === 'template' ? '⚙' : x.kind === 'reference' ? '🔗' : '📝'}
|
|
1979
|
-
${esc(x.text.slice(0, 140))}${x.already_saved ? ' <span class="na">already saved</span>' : ''}
|
|
2013
|
+
${esc(pt(x.text.slice(0, 140)))}${x.already_saved ? ' <span class="na">already saved</span>' : ''}
|
|
1980
2014
|
<span class="spacer"></span><span class="note">${fmtInt(x.sessions)} sessions</span></div>
|
|
1981
2015
|
<div class="dt">${esc(x.why)}</div>
|
|
1982
2016
|
<div class="dt"><b>Put it in:</b> ${esc(x.target)}</div>
|
|
1983
2017
|
${(x.examples || []).length ? `<details><summary style="cursor:pointer;font-size:11.5px">Examples</summary>
|
|
1984
|
-
${x.examples.map(e => `<div class="dt note">“${esc(e)}”</div>`).join('')}</details>` : ''}${pb(x.playbook)}</div>`).join('')
|
|
2018
|
+
${x.examples.map(e => `<div class="dt note">“${esc(pt(e))}”</div>`).join('')}</details>` : ''}${pb(x.playbook)}</div>`).join('')
|
|
1985
2019
|
|| '<div class="empty">No repeated instructions found</div>'}</div>`,
|
|
1986
2020
|
{badge: BADGE.recommendation, hint: 'Mined from non-sandbox prompts; secrets masked'})}
|
|
1987
2021
|
${card('Why consumption is high', `<div class="stack">${d.drivers.map(x => `
|
|
@@ -2325,7 +2359,7 @@ VIEWS.search = async (page) => {
|
|
|
2325
2359
|
if (r.prompts.length) {
|
|
2326
2360
|
$('#sp', page).innerHTML = table([
|
|
2327
2361
|
{h: 'When', f: x => `<span class="mono">${esc((x.ts || '').slice(0, 16).replace('T', ' '))}</span>`},
|
|
2328
|
-
{h: 'Prompt', trunc: 1, title: x => x.preview, f: x => esc(x.preview)},
|
|
2362
|
+
{h: 'Prompt', trunc: 1, title: x => pt(x.preview), f: x => esc(pt(x.preview))},
|
|
2329
2363
|
{h: 'Category', f: x => `<span class="pill">${esc(x.category)}</span>`},
|
|
2330
2364
|
{h: 'Tokens', num: 1, f: x => fmtNum(x.billable_tokens)},
|
|
2331
2365
|
{h: 'Est. cost', num: 1, f: x => fmtUSD(x.est_cost_usd)},
|
|
@@ -2334,7 +2368,7 @@ VIEWS.search = async (page) => {
|
|
|
2334
2368
|
}
|
|
2335
2369
|
if (r.sessions.length) {
|
|
2336
2370
|
$('#ss', page).innerHTML = table([
|
|
2337
|
-
{h: 'Session', trunc: 1, f: x => esc(
|
|
2371
|
+
{h: 'Session', trunc: 1, f: x => esc(stitle(x))},
|
|
2338
2372
|
{h: 'Project', f: x => esc(x.project)},
|
|
2339
2373
|
{h: 'Branch', f: x => x.git_branch ? `<span class="mono">${esc(x.git_branch)}</span>` : '—'},
|
|
2340
2374
|
{h: 'Tokens', num: 1, f: x => fmtNum(x.billable_tokens)},
|
|
@@ -2585,6 +2619,154 @@ async function render() {
|
|
|
2585
2619
|
}
|
|
2586
2620
|
}
|
|
2587
2621
|
|
|
2622
|
+
/* ---------- Recent panel ----------
|
|
2623
|
+
Past sessions and their prompts from any page, without leaving it. Resume and
|
|
2624
|
+
stop act on sessions, so the list is grouped by session; running ones sit on
|
|
2625
|
+
top. Close and Force kill stay on Running sessions, one click away from here. */
|
|
2626
|
+
const RECENT = {open: false, offset: 0, limit: 30, q: '', open_ids: new Set()};
|
|
2627
|
+
// Recent means all time: keep agent/project filters, drop the date range and thresholds.
|
|
2628
|
+
const recentQs = () => { const p = new URLSearchParams(qs());
|
|
2629
|
+
['start', 'end', 'min_cost', 'min_tokens'].forEach(k => p.delete(k)); return p.toString(); };
|
|
2630
|
+
const fmtWhen = iso => iso ? `${ago(iso)} · ${esc(iso.slice(0, 16).replace('T', ' '))}` : '';
|
|
2631
|
+
|
|
2632
|
+
function toggleRecent(force) {
|
|
2633
|
+
RECENT.open = force ?? !RECENT.open;
|
|
2634
|
+
document.querySelectorAll('.recent,.recent-scrim').forEach(e => e.remove());
|
|
2635
|
+
$('#recent-btn')?.classList.toggle('on', RECENT.open);
|
|
2636
|
+
if (!RECENT.open) return;
|
|
2637
|
+
const scrim = h('<div class="recent-scrim"></div>');
|
|
2638
|
+
const p = h(`<aside class="recent" aria-label="Recent sessions"><header>
|
|
2639
|
+
<h2>Recent</h2><span class="note">all time · agent & project filters apply</span>
|
|
2640
|
+
<span class="spacer"></span><button class="iconbtn" data-x>Close ✕</button></header>
|
|
2641
|
+
<div class="recent-search"><span class="mag">⌕</span>
|
|
2642
|
+
<input id="recent-q" placeholder="Search your prompts…" value="${esc(RECENT.q)}"></div>
|
|
2643
|
+
<div class="content"><div class="loading">Loading…</div></div></aside>`);
|
|
2644
|
+
scrim.onclick = () => toggleRecent(false);
|
|
2645
|
+
p.querySelector('[data-x]').onclick = () => toggleRecent(false);
|
|
2646
|
+
let t;
|
|
2647
|
+
p.querySelector('#recent-q').oninput = e => { clearTimeout(t);
|
|
2648
|
+
t = setTimeout(() => { RECENT.q = e.target.value.trim(); RECENT.offset = 0; renderRecent(); }, 260); };
|
|
2649
|
+
document.body.append(scrim, p);
|
|
2650
|
+
RECENT.offset = 0;
|
|
2651
|
+
renderRecent();
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
async function renderRecent() {
|
|
2655
|
+
const box = $('.recent .content');
|
|
2656
|
+
if (!box) return;
|
|
2657
|
+
const more = RECENT.offset > 0;
|
|
2658
|
+
const [live, res] = await Promise.all([
|
|
2659
|
+
more || RECENT.q ? null : fetch('/api/live?agents=' + encodeURIComponent(S.filter.agents.join(',')))
|
|
2660
|
+
.then(r => r.json()).catch(() => ({sessions: []})),
|
|
2661
|
+
RECENT.q
|
|
2662
|
+
? fetch(`/api/prompts?${recentQs()}&order=recent&limit=${RECENT.limit}&offset=${RECENT.offset}&q=${encodeURIComponent(RECENT.q)}`).then(r => r.json())
|
|
2663
|
+
: fetch(`/api/sessions?${recentQs()}&order=recent&limit=${RECENT.limit}&offset=${RECENT.offset}`).then(r => r.json())]);
|
|
2664
|
+
if (!$('.recent')) return; // closed while loading
|
|
2665
|
+
const rows = res.rows || [];
|
|
2666
|
+
const liveIds = new Set(((live || {}).sessions || RECENT.live || []).map(x => x.session_id));
|
|
2667
|
+
if (live) RECENT.live = live.sessions || [];
|
|
2668
|
+
const html = RECENT.q ? recentPrompts(rows) : recentSessions(rows, liveIds);
|
|
2669
|
+
const shown = RECENT.offset + rows.length;
|
|
2670
|
+
const moreBtn = shown < (res.total || 0)
|
|
2671
|
+
? `<button class="btn pb-copy recent-more">Load more (${fmtInt(shown)} of ${fmtInt(res.total)})</button>` : '';
|
|
2672
|
+
if (more) { box.querySelector('.recent-more')?.remove(); box.insertAdjacentHTML('beforeend', html + moreBtn); }
|
|
2673
|
+
else box.innerHTML = (RECENT.q ? '' : recentLive(RECENT.live || [])) + html + moreBtn
|
|
2674
|
+
|| '<div class="empty">Nothing found</div>';
|
|
2675
|
+
if (!rows.length && !more && RECENT.q) box.innerHTML = '<div class="empty">No prompt matches</div>';
|
|
2676
|
+
wireRecent(box);
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
const recentLive = list => !list.length ? '' : `<div class="recent-hd">Running now
|
|
2680
|
+
<span class="spacer"></span><a href="#" data-go-live>Close or kill ›</a></div>
|
|
2681
|
+
${list.map((x, i) => `<div class="item recent-it sev-${x.severity === 'high' ? 'high' : x.severity === 'medium' ? 'medium' : 'low'}" data-live="${i}">
|
|
2682
|
+
<div class="hd"><span class="live-dot ${x.status === 'busy' ? 'busy' : ''}" title="${esc(x.status)}"></span>
|
|
2683
|
+
<span class="recent-title">${esc((!MASKED && x.name) || shortId(x.session_id))}</span>
|
|
2684
|
+
<span class="spacer"></span><span class="note">${esc(x.status || '')}</span></div>
|
|
2685
|
+
<div class="mt"><span>${esc(x.project || '')}</span>
|
|
2686
|
+
${x.context ? `<span>${fmtNum(x.context)} context</span>` : ''}
|
|
2687
|
+
${x.est_cost_usd != null ? `<span>${fmtUSD(x.est_cost_usd)}</span>` : ''}</div>
|
|
2688
|
+
<div class="live-actions">
|
|
2689
|
+
${x.resume ? `<button class="act ghost" data-resume="${esc(x.resume)}" title="Copy: ${esc(x.resume)}">⧉ Resume</button>` : ''}
|
|
2690
|
+
${x.signalable && x.agent === 'claude' ? '<button class="act" data-la="compact" title="Types /compact into that session\'s terminal">🗜 Compact</button>' : ''}
|
|
2691
|
+
${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>` : ''}
|
|
2692
|
+
${x.session_id ? `<button class="act ghost" data-open-sess="${esc(x.session_id)}">Details</button>` : ''}
|
|
2693
|
+
<span class="live-msg"></span></div></div>`).join('')}
|
|
2694
|
+
<div class="recent-hd">Past sessions</div>`;
|
|
2695
|
+
|
|
2696
|
+
const recentSessions = (rows, liveIds) => rows.map(r => `
|
|
2697
|
+
<div class="item recent-it clickable${RECENT.open_ids.has(r.session_id) ? ' open' : ''}" data-sess="${esc(r.session_id)}">
|
|
2698
|
+
<div class="hd">${liveIds.has(r.session_id) ? '<span class="live-dot" title="Running now"></span>' : ''}
|
|
2699
|
+
<span class="recent-title">${esc(stitle(r))}</span><span class="spacer"></span>
|
|
2700
|
+
<span class="note">${fmtUSD(r.cost)}</span></div>
|
|
2701
|
+
<div class="mt"><span>${esc(r.project || '')}</span><span>${fmtInt(r.prompts)} prompts</span>
|
|
2702
|
+
<span>${fmtWhen(r.started_at)}</span></div>
|
|
2703
|
+
<div class="live-actions">
|
|
2704
|
+
${r.resume ? `<button class="act ghost" data-resume="${esc(r.resume)}" title="Copy: ${esc(r.resume)}">⧉ Resume</button>` : ''}
|
|
2705
|
+
<button class="act ghost" data-open-sess="${esc(r.session_id)}">Details</button>
|
|
2706
|
+
<span class="spacer"></span><span class="note recent-caret">${RECENT.open_ids.has(r.session_id) ? '▾' : '▸'} prompts</span></div>
|
|
2707
|
+
<div class="recent-prompts"${RECENT.open_ids.has(r.session_id) ? '' : ' hidden'}></div></div>`).join('')
|
|
2708
|
+
|| '<div class="empty">No sessions for these filters</div>';
|
|
2709
|
+
|
|
2710
|
+
const promptLine = x => `<a href="#" class="recent-p" data-prompt="${x.prompt_id}" title="${esc(pt(x.preview))}">
|
|
2711
|
+
<span class="mono sub">${esc((x.ts || '').slice(5, 16).replace('T', ' '))}</span>
|
|
2712
|
+
<span class="recent-pv">${esc(pt(x.preview) || '—')}</span>
|
|
2713
|
+
<span class="note">${fmtUSD(x.pcost ?? x.est_cost_usd)}</span></a>`;
|
|
2714
|
+
|
|
2715
|
+
const recentPrompts = rows => rows.map(x => `<div class="item recent-it">
|
|
2716
|
+
${promptLine(x)}
|
|
2717
|
+
<div class="mt"><span>${esc((!MASKED && x.session_title) || shortId(x.session_id))}</span>
|
|
2718
|
+
<span>${esc(x.project || '')}</span>
|
|
2719
|
+
<a href="#" data-open-sess="${esc(x.session_id)}">Session ›</a></div></div>`).join('');
|
|
2720
|
+
|
|
2721
|
+
async function loadSessionPrompts(host, sid) {
|
|
2722
|
+
host.innerHTML = '<div class="note">Loading…</div>';
|
|
2723
|
+
try {
|
|
2724
|
+
const s = await fetch(`/api/session/${encodeURIComponent(sid)}`).then(r => r.json());
|
|
2725
|
+
const ps = s.prompts || [];
|
|
2726
|
+
host.innerHTML = ps.length ? ps.map(promptLine).join('') : '<div class="note">No prompts recorded</div>';
|
|
2727
|
+
wireRecent(host);
|
|
2728
|
+
} catch (e) { host.innerHTML = `<div class="live-msg err">${esc(e.message)}</div>`; }
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
function wireRecent(root) {
|
|
2732
|
+
wireRowActs(root); // Resume: copy command
|
|
2733
|
+
root.querySelectorAll('[data-open-sess]').forEach(a => a.onclick = e => {
|
|
2734
|
+
e.preventDefault(); e.stopPropagation(); openSession(a.dataset.openSess); });
|
|
2735
|
+
root.querySelectorAll('[data-prompt]').forEach(a => a.onclick = e => {
|
|
2736
|
+
e.preventDefault(); e.stopPropagation(); openPrompt(+a.dataset.prompt); });
|
|
2737
|
+
root.querySelectorAll('[data-go-live]').forEach(a => a.onclick = e => {
|
|
2738
|
+
e.preventDefault(); toggleRecent(false); go('live'); });
|
|
2739
|
+
root.querySelector('.recent-more')?.addEventListener('click', () => {
|
|
2740
|
+
RECENT.offset += RECENT.limit; renderRecent(); });
|
|
2741
|
+
root.querySelectorAll('.item[data-sess]').forEach(it => {
|
|
2742
|
+
const host = it.querySelector('.recent-prompts'), sid = it.dataset.sess;
|
|
2743
|
+
if (!host.hidden && !host.childElementCount) loadSessionPrompts(host, sid);
|
|
2744
|
+
it.onclick = e => {
|
|
2745
|
+
if (e.target.closest('button, a, .recent-prompts')) return;
|
|
2746
|
+
host.hidden = !host.hidden;
|
|
2747
|
+
it.classList.toggle('open', !host.hidden);
|
|
2748
|
+
it.querySelector('.recent-caret').textContent = (host.hidden ? '▸' : '▾') + ' prompts';
|
|
2749
|
+
host.hidden ? RECENT.open_ids.delete(sid) : RECENT.open_ids.add(sid);
|
|
2750
|
+
if (!host.hidden && !host.childElementCount) loadSessionPrompts(host, sid);
|
|
2751
|
+
};
|
|
2752
|
+
});
|
|
2753
|
+
root.querySelectorAll('.item[data-live]').forEach(it => {
|
|
2754
|
+
const x = RECENT.live[+it.dataset.live], msg = it.querySelector('.live-msg');
|
|
2755
|
+
it.querySelectorAll('[data-la]').forEach(b => b.onclick = async e => {
|
|
2756
|
+
e.stopPropagation();
|
|
2757
|
+
if (!armed(b)) return; // two clicks, like Running sessions
|
|
2758
|
+
b.disabled = true;
|
|
2759
|
+
try {
|
|
2760
|
+
const r = await sessionAction(x.pid, b.dataset.la, {agent: x.agent});
|
|
2761
|
+
if (!r.ok && r.copy) { try { await navigator.clipboard.writeText(r.copy); } catch {} }
|
|
2762
|
+
msg.textContent = r.ok ? r.message : `Failed: ${r.error}`;
|
|
2763
|
+
msg.className = 'live-msg ' + (r.ok ? 'ok' : 'err');
|
|
2764
|
+
} catch (err) { msg.textContent = 'Failed: ' + err.message; msg.className = 'live-msg err'; }
|
|
2765
|
+
setTimeout(() => { b.disabled = false; }, 2500);
|
|
2766
|
+
});
|
|
2767
|
+
});
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2588
2770
|
/* ============================ boot ============================ */
|
|
2589
2771
|
(async function boot() {
|
|
2590
2772
|
const saved = localStorage.getItem('finops-theme');
|
package/web/styles.css
CHANGED
|
@@ -457,3 +457,32 @@ table.tbl .sub { font-size:10.5px; color:var(--muted); }
|
|
|
457
457
|
.brand .ver { font-size: 9.5px; font-weight: 600; color: var(--muted);
|
|
458
458
|
border: 1px solid var(--border); border-radius: 99px;
|
|
459
459
|
padding: 1px 5px; margin-left: 5px; letter-spacing: .01em; }
|
|
460
|
+
.iconbtn.on { background: var(--surface-3); color:var(--text); border-color: var(--s1); }
|
|
461
|
+
|
|
462
|
+
/* ---------- Recent panel: sits under the detail drawer so a click opens on top ---------- */
|
|
463
|
+
.recent-scrim { position:fixed; inset:0; background:rgba(11,11,11,.18); z-index:98; }
|
|
464
|
+
.recent { position:fixed; top:0; right:0; bottom:0; width:min(440px, 100vw); background:var(--plane);
|
|
465
|
+
z-index:99; box-shadow:-8px 0 40px rgba(0,0,0,.18); display:flex; flex-direction:column; }
|
|
466
|
+
.recent > header { padding:13px 16px; background:var(--surface); border-bottom:1px solid var(--border);
|
|
467
|
+
display:flex; align-items:center; gap:10px; }
|
|
468
|
+
.recent > header h2 { margin:0; font-size:14.5px; font-weight:640; }
|
|
469
|
+
.recent-search { position:relative; padding:10px 16px 0; }
|
|
470
|
+
.recent-search input { width:100%; box-sizing:border-box; padding:7px 10px 7px 28px; border-radius:8px;
|
|
471
|
+
font-size:12.5px; border:1px solid var(--border); background:var(--surface-2); color:var(--text); }
|
|
472
|
+
.recent-search .mag { position:absolute; left:26px; top:calc(50% + 5px); transform:translateY(-50%);
|
|
473
|
+
color:var(--muted); font-size:12px; pointer-events:none; }
|
|
474
|
+
.recent .content { overflow-y:auto; padding:10px 16px 40px; display:flex; flex-direction:column; gap:8px; }
|
|
475
|
+
.recent-hd { display:flex; align-items:center; font-size:11px; font-weight:640; text-transform:uppercase;
|
|
476
|
+
letter-spacing:.04em; color:var(--muted); margin-top:4px; }
|
|
477
|
+
.recent-hd a { text-transform:none; letter-spacing:0; font-weight:500; }
|
|
478
|
+
.recent-it { display:flex; flex-direction:column; gap:5px; }
|
|
479
|
+
.recent-it .live-actions { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
|
|
480
|
+
.recent-it .act { padding:2px 8px; font-size:11px; }
|
|
481
|
+
.recent-title { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
|
|
482
|
+
.recent-prompts { display:flex; flex-direction:column; border-top:1px solid var(--border); padding-top:4px; }
|
|
483
|
+
.recent-p { display:flex; gap:8px; align-items:baseline; padding:4px 2px; border-radius:5px;
|
|
484
|
+
color:var(--text); text-decoration:none; font-size:12px; }
|
|
485
|
+
.recent-p:hover { background:var(--surface-3); }
|
|
486
|
+
.recent-pv { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
487
|
+
.live-dot { width:8px; height:8px; border-radius:50%; background:var(--axis); flex:none; }
|
|
488
|
+
.live-dot.busy, .recent-it .hd > .live-dot[title="Running now"] { background:var(--s1); }
|