pi-midcompact 0.4.0 → 0.5.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.
@@ -33,9 +33,9 @@
33
33
  --mark: rgba(224,175,104,.28);
34
34
  --shadow: 0 10px 34px rgba(0,0,0,.45);
35
35
  --ring: rgba(122,162,247,.45);
36
- --r: 10px;
36
+ --r: 8px;
37
37
  --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
38
- --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", sans-serif;
38
+ --sans: "HarmonyOS Sans SC", "HarmonyOS Sans", "Noto Sans CJK SC", "Noto Sans SC", "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", system-ui, -apple-system, "Segoe UI", sans-serif;
39
39
  }
40
40
  [data-theme="light"] {
41
41
  --bg: #f3f5fa;
@@ -77,7 +77,7 @@
77
77
  padding: 12px 18px 14px; flex: 0 0 auto;
78
78
  }
79
79
  .bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
80
- .brand { font-size: 15px; font-weight: 650; letter-spacing: -.01em; margin: 0; }
80
+ .brand { font-size: 15px; font-weight: 650; letter-spacing: 0; margin: 0; }
81
81
  .brand b { color: var(--accent); font-weight: 750; }
82
82
  .chip {
83
83
  background: var(--panel-2); border: 1px solid var(--line); color: var(--fg-dim);
@@ -104,7 +104,7 @@
104
104
  .btn-sm { padding: 4px 9px; font-size: 12px; border-radius: 7px; }
105
105
  .btn-icon { padding: 6px 9px; }
106
106
 
107
- .metrics { display: grid; grid-template-columns: 2.1fr 1fr 1fr; gap: 10px; margin-top: 12px; }
107
+ .metrics { display: grid; grid-template-columns: 2fr repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 12px; }
108
108
  .metric {
109
109
  background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r); padding: 9px 12px 10px;
110
110
  }
@@ -167,7 +167,7 @@
167
167
  .rlist { padding: 8px; display: flex; flex-direction: column; gap: 6px; }
168
168
  .ritem {
169
169
  width: 100%; text-align: left; background: var(--panel-2); border: 1px solid var(--line);
170
- border-left: 3px solid var(--line); border-radius: 9px; padding: 8px 10px; cursor: pointer;
170
+ border-left: 3px solid var(--line); border-radius: 8px; padding: 8px 10px; cursor: pointer;
171
171
  display: grid; gap: 3px; transition: background .13s, border-color .13s;
172
172
  }
173
173
  .ritem:hover { background: var(--elev); }
@@ -189,7 +189,7 @@
189
189
  .group.keepg { background: transparent; border-color: var(--line-soft); }
190
190
  .ghead {
191
191
  position: sticky; top: 0; z-index: 2; width: 100%; text-align: left; cursor: pointer;
192
- display: grid; grid-template-columns: 18px auto 1fr auto; align-items: center; gap: 8px;
192
+ display: grid; grid-template-columns: 34px auto minmax(0, 1fr) auto; align-items: center; gap: 8px;
193
193
  padding: 8px 10px; background: var(--panel-2); border: 0; border-bottom: 1px solid var(--line-soft);
194
194
  }
195
195
  .ghead:hover { background: var(--elev); }
@@ -225,6 +225,14 @@
225
225
  background: var(--elev); color: var(--fg-dim); border: 1px solid var(--line-soft);
226
226
  }
227
227
  .tag.lock { color: var(--warn); }
228
+ .tag.tool { font-weight: 700; color: var(--accent); border-color: var(--accent); }
229
+ .tag.tool.read { color: var(--range); border-color: var(--range); }
230
+ .tag.tool.write { color: var(--keep); border-color: var(--keep); }
231
+ .tag.tool.edit { color: var(--accent-2); border-color: var(--accent-2); }
232
+ .tag.tool.bash { color: var(--warn); border-color: var(--warn); }
233
+ .atom-actions { display: flex; gap: 6px; margin-top: 8px; }
234
+ .atom.selected-atom { background: color-mix(in srgb, var(--range) 10%, transparent); border-color: color-mix(in srgb, var(--range) 35%, transparent); }
235
+ .atom.kept-atom { background: color-mix(in srgb, var(--keep) 8%, transparent); }
228
236
  mark { background: var(--mark); color: inherit; border-radius: 3px; padding: 0 1px; }
229
237
 
230
238
  /* ---------- editor ---------- */
@@ -260,7 +268,7 @@
260
268
  .toast {
261
269
  position: fixed; bottom: 18px; left: 50%; transform: translate(-50%, 12px);
262
270
  background: var(--panel-2); border: 1px solid var(--line); color: var(--fg);
263
- padding: 9px 14px; border-radius: 9px; box-shadow: var(--shadow); font-size: 12.5px;
271
+ padding: 9px 14px; border-radius: 8px; box-shadow: var(--shadow); font-size: 12.5px;
264
272
  opacity: 0; transition: opacity .18s, transform .18s; pointer-events: none; z-index: 50; max-width: 70vw;
265
273
  }
266
274
  .toast.show { opacity: 1; transform: translate(-50%, 0); }
@@ -271,10 +279,12 @@
271
279
  }
272
280
  .help.show { display: grid; }
273
281
  .help-card {
274
- background: var(--panel); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow);
282
+ background: var(--panel); border: 1px solid var(--line); border-radius: 8px; box-shadow: var(--shadow);
275
283
  padding: 18px 20px; min-width: 300px; max-width: 420px;
276
284
  }
277
285
  .help-card h3 { margin: 0 0 12px; font-size: 13px; }
286
+ .help-card p { margin: 0; color: var(--fg-dim); }
287
+ .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
278
288
  .help-card dl { display: grid; grid-template-columns: auto 1fr; gap: 8px 14px; margin: 0; font-size: 12px; }
279
289
  .help-card dt { text-align: right; }
280
290
  .help-card dd { margin: 0; color: var(--fg-dim); }
@@ -298,16 +308,22 @@
298
308
  .atom { grid-template-columns: 70px 1fr auto; }
299
309
  .atom .abody { grid-column: 1 / -1; }
300
310
  }
311
+ @media (max-width: 600px) {
312
+ .ghead { grid-template-columns: 34px auto minmax(0, 1fr); }
313
+ .ghead .gstat { grid-column: 2 / -1; white-space: normal; }
314
+ .atom > div { grid-column: 1 / -1; }
315
+ }
301
316
  </style>
302
317
  </head>
303
318
  <body>
304
319
  <header>
305
320
  <div class="bar">
306
- <h1 class="brand"><b>Midcompact</b> Review</h1>
321
+ <h1 class="brand"><b>Midcompact</b> <span id="mode-title">Review</span></h1>
307
322
  <span class="chip" id="draft-badge"></span>
308
323
  <span class="chip dirty" id="dirty-badge" hidden></span>
309
324
  <div class="spacer"></div>
310
325
  <button class="btn btn-sm" id="save-all" hidden>Save all</button>
326
+ <button class="btn btn-sm btn-primary" id="save-selection" hidden>Save selection</button>
311
327
  <button class="btn btn-sm btn-icon" id="refresh" title="Reload state (r)" aria-label="Reload state">⟳</button>
312
328
  <button class="btn btn-sm btn-icon" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme"></button>
313
329
  <button class="btn btn-sm btn-icon" id="help-btn" title="Shortcuts (?)" aria-label="Shortcuts">?</button>
@@ -352,7 +368,7 @@
352
368
  <div class="help" id="help" role="dialog" aria-modal="true" aria-label="Keyboard shortcuts">
353
369
  <div class="help-card">
354
370
  <h3>Keyboard shortcuts</h3>
355
- <dl>
371
+ <dl id="help-list">
356
372
  <dt><kbd>j</kbd> / <kbd>k</kbd></dt><dd>Next / previous range</dd>
357
373
  <dt><kbd>/</kbd></dt><dd>Focus filter</dd>
358
374
  <dt><kbd>e</kbd></dt><dd>Focus summary editor</dd>
@@ -363,6 +379,17 @@
363
379
  </dl>
364
380
  </div>
365
381
  </div>
382
+ <div class="help" id="close-dialog" role="dialog" aria-modal="true" aria-label="Unsaved changes">
383
+ <div class="help-card">
384
+ <h3>Unsaved changes</h3>
385
+ <p>Save the current DraftPlan before closing this workbench?</p>
386
+ <div class="dialog-actions">
387
+ <button class="btn" id="close-stay">Stay</button>
388
+ <button class="btn btn-danger" id="close-discard">Discard</button>
389
+ <button class="btn btn-primary" id="close-save">Save &amp; close</button>
390
+ </div>
391
+ </div>
392
+ </div>
366
393
 
367
394
  <script id="state" type="application/json"><!--MIDCOMPACT_STATE--></script>
368
395
  <script type="module">
@@ -370,10 +397,15 @@ const EMPTY = { atoms: [], draft: { revision: 0, ranges: [] }, telemetry: {} };
370
397
  let state = (() => {
371
398
  try {
372
399
  const s = JSON.parse(document.getElementById('state').textContent);
373
- return { atoms: s.atoms ?? [], draft: { revision: s.draft?.revision ?? 0, ranges: s.draft?.ranges ?? [] }, telemetry: s.telemetry ?? {} };
400
+ return { view: s.view ?? 'review', atoms: s.atoms ?? [], draft: { revision: s.draft?.revision ?? 0, ranges: s.draft?.ranges ?? [] }, telemetry: s.telemetry ?? {} };
374
401
  } catch { return EMPTY; }
375
402
  })();
376
403
 
404
+ // The server owns the keepalive writes. If this page disappears, the stream
405
+ // closes and the terminal-side UI lock is released without treating inactivity
406
+ // in an open tab as disconnection.
407
+ const liveness = new EventSource('/api/liveness');
408
+
377
409
  let selectedId = state.draft.ranges[0]?.id ?? null;
378
410
  let query = '';
379
411
  let policy = 'all';
@@ -382,7 +414,7 @@ const collapsed = new Set(); // group keys currently collapsed
382
414
  const openAtoms = new Set(); // atom refs expanded to full preview
383
415
  let lastScrolled = null;
384
416
  let removeArmed = null;
385
- let closeArmed = false;
417
+ let selectionDirty = false;
386
418
 
387
419
  state.draft.ranges.forEach(r => { if (r.id !== selectedId) collapsed.add(r.id); });
388
420
 
@@ -393,8 +425,17 @@ const fmt = n => n == null || Number.isNaN(n) ? '—'
393
425
  : Math.abs(n) >= 1000 ? (n / 1000).toFixed(Math.abs(n) % 1000 ? 1 : 0) + 'k' : String(Math.round(n));
394
426
  const pct = n => n == null || Number.isNaN(n) ? '—' : n.toFixed(n % 1 ? 1 : 0) + '%';
395
427
  const clamp = n => Math.max(0, Math.min(100, n));
428
+ const isSelection = () => state.view === 'selection';
429
+ const atomByRef = ref => state.atoms.find(a => a.ref === ref);
430
+ const rangeAtoms = r => {
431
+ const start = state.atoms.findIndex(a => a.ref === r.startRef);
432
+ const end = state.atoms.findIndex(a => a.ref === r.endRef);
433
+ return start >= 0 && end >= start ? state.atoms.slice(start, end + 1) : [];
434
+ };
435
+ let selectionRefs = new Set(state.draft.ranges.flatMap(r => rangeAtoms(r).map(a => a.ref)));
436
+ let keepRefs = new Set();
396
437
  const rangeById = id => state.draft.ranges.find(r => r.id === id);
397
- const saveOf = r => Math.max(0, (r.originalApproxTokens ?? 0) - (r.compressedApproxTokens ?? 0));
438
+ const saveOf = r => Math.max(0, (r.originalContentChars ?? 0) - (r.replacementContentChars ?? 0));
398
439
 
399
440
  function hl(text, q) {
400
441
  const out = esc(text);
@@ -423,9 +464,17 @@ function render() {
423
464
  const n = state.draft.ranges.length;
424
465
  $('draft-badge').textContent = `draft v${state.draft.revision} · ${n} range${n === 1 ? '' : 's'}`;
425
466
  const d = dirtyIds();
426
- $('dirty-badge').hidden = d.length === 0;
427
- $('dirty-badge').textContent = `${d.length} unsaved`;
428
- $('save-all').hidden = d.length < 2;
467
+ const dirtyCount = d.length + (selectionDirty ? 1 : 0);
468
+ $('dirty-badge').hidden = dirtyCount === 0;
469
+ $('dirty-badge').textContent = `${dirtyCount} unsaved`;
470
+ $('mode-title').textContent = isSelection() ? 'Selection' : 'Review';
471
+ document.title = `Midcompact ${isSelection() ? 'Selection' : 'Review'}`;
472
+ document.querySelector('.seg').hidden = isSelection();
473
+ if (isSelection()) {
474
+ $('help-list').innerHTML = '<dt><kbd>/</kbd></dt><dd>Focus filter</dd><dt><kbd>Click</kbd></dt><dd>Add atom or KEEP</dd><dt><kbd>Save</kbd></dt><dd>Persist DraftPlan</dd><dt><kbd>Esc</kbd></dt><dd>Close dialog</dd>';
475
+ }
476
+ $('save-selection').hidden = !isSelection();
477
+ $('save-all').hidden = isSelection() || d.length < 2;
429
478
  $('save-all').textContent = `Save all (${d.length})`;
430
479
  renderMetrics();
431
480
  renderRanges();
@@ -441,35 +490,60 @@ const cssEsc = s => (window.CSS && CSS.escape) ? CSS.escape(s) : String(s).repla
441
490
 
442
491
  function renderMetrics() {
443
492
  const t = state.telemetry ?? {};
444
- const cw = t.contextWindow;
445
- const aw = cw && t.anchorTokens != null ? clamp(t.anchorTokens / cw * 100) : 0;
446
- const pw = cw && t.projectedTokens != null ? clamp(t.projectedTokens / cw * 100) : 0;
447
- const totalSave = state.draft.ranges.reduce((s, r) => s + saveOf(r), 0);
448
- const atomsCovered = state.draft.ranges.reduce((s, r) => s + (r.atomCount ?? 0), 0);
493
+ const usage = t.anchorUsage;
494
+ const usageLabel = usage?.tokens == null ? 'unavailable' : `${fmt(usage.tokens)} / ${fmt(usage.contextWindow)} (${pct(usage.percent)})`;
495
+ const selected = isSelection()
496
+ ? state.atoms.filter(a => selectionRefs.has(a.ref) && a.compressible && !keepRefs.has(a.ref))
497
+ : state.atoms.filter(a => a.owningRangeId);
498
+ const chars = selected.reduce((sum, a) => sum + (a.contentChars ?? 0), 0);
499
+ const totalChars = state.atoms.reduce((sum, a) => sum + (a.contentChars ?? 0), 0);
500
+ const selectedShare = totalChars > 0 ? clamp((chars / totalChars) * 100) : 0;
501
+ const images = selected.reduce((sum, a) => sum + (a.imageCount ?? 0), 0);
502
+ const pending = state.draft.ranges.filter(r => !r.summary.trim()).length;
503
+ const usagePercent = usage?.percent == null ? 0 : clamp(usage.percent);
504
+ const contentMetric = isSelection()
505
+ ? `<div class="metric"><label>Selected content chars</label><div class="val">${fmt(chars)} / ${fmt(totalChars)} <small>${pct(selectedShare)} of anchor</small></div><div class="track"><i class="proj" style="width:${selectedShare}%"></i></div><div class="legend"><span>up to <b>${pct(selectedShare)}</b> fewer anchor chars · actual is lower after summaries</span></div></div>`
506
+ : `<div class="metric"><label>Content chars</label><div class="val">${fmt(chars)}</div><div class="legend"><span>${selected.length} selected atoms</span></div></div>`;
449
507
  $('metrics').innerHTML = `
450
- <div class="metric">
451
- <label>context window</label>
452
- <div class="val">${fmt(t.projectedTokens)} <small>projected of ${fmt(cw)}</small></div>
453
- <div class="track"><i class="anchor" style="width:${aw}%"></i><i class="proj" style="width:${pw}%"></i></div>
454
- <div class="legend">
455
- <span><i class="dotm" style="background:color-mix(in srgb,var(--warn) 55%,transparent)"></i>anchor <b>${fmt(t.anchorTokens)}</b> (${pct(t.anchorPercent)})</span>
456
- <span><i class="dotm" style="background:var(--accent)"></i>projected <b>${fmt(t.projectedTokens)}</b> (${pct(t.projectedPercent)})</span>
457
- </div>
458
- </div>
459
- <div class="metric">
460
- <label>estimated save</label>
461
- <div class="val" style="color:var(--keep)">−${fmt(t.estimatedSavedTokens ?? totalSave)}</div>
462
- <div class="legend"><span>~${fmt(t.selectedOriginalApproxTokens)} → ~${fmt(t.selectedCompressedApproxTokens)}</span></div>
463
- </div>
464
- <div class="metric">
465
- <label>coverage</label>
466
- <div class="val">${atomsCovered} <small>/ ${state.atoms.length} atoms</small></div>
467
- <div class="legend"><span>across ${state.draft.ranges.length} range(s) · −${fmt(totalSave)} tok</span></div>
468
- </div>`;
508
+ <div class="metric"><label>Pi reported anchor</label><div class="val">${usageLabel}</div><div class="track"><i class="anchor" style="width:${usagePercent}%"></i></div><div class="legend"><span>provider usage fact · no local token estimate</span></div></div>
509
+ <div class="metric"><label>Draft ranges</label><div class="val">${state.draft.ranges.length} <small>${pending ? `${pending} pending` : 'ready for review'}</small></div><div class="legend"><span>revision <b>${state.draft.revision}</b></span></div></div>
510
+ ${contentMetric}
511
+ <div class="metric"><label>Images</label><div class="val">${images}</div><div class="legend"><span>payload facts only</span></div></div>`;
512
+ }
513
+
514
+ function proposedRanges() {
515
+ const runs = [];
516
+ for (const atom of state.atoms) {
517
+ if (!selectionRefs.has(atom.ref) || keepRefs.has(atom.ref) || !atom.compressible) continue;
518
+ const previous = runs.at(-1);
519
+ if (previous && previous.endIndex + 1 === atom.index) {
520
+ previous.endRef = atom.ref;
521
+ previous.endIndex = atom.index;
522
+ previous.atoms.push(atom);
523
+ } else runs.push({ startRef: atom.ref, endRef: atom.ref, endIndex: atom.index, atoms: [atom] });
524
+ }
525
+ return runs;
469
526
  }
470
527
 
471
528
  function renderRanges() {
472
529
  const list = $('range-list');
530
+ if (isSelection()) {
531
+ const ranges = proposedRanges();
532
+ if (!ranges.length) {
533
+ list.innerHTML = '<div class="empty">No atoms selected.<br>Add a group or atom from the timeline.</div>';
534
+ return;
535
+ }
536
+ list.innerHTML = ranges.map((range, index) => {
537
+ const chars = range.atoms.reduce((sum, atom) => sum + (atom.contentChars ?? 0), 0);
538
+ const images = range.atoms.reduce((sum, atom) => sum + (atom.imageCount ?? 0), 0);
539
+ return `<div class="ritem" role="option">
540
+ <div class="r1"><span class="rid">range ${index + 1}</span><span class="refs">${esc(range.startRef)}→${esc(range.endRef)}</span><span class="spacer"></span><button class="btn btn-sm btn-icon" data-remove-proposed="${index}" title="Remove range" aria-label="Remove range">×</button></div>
541
+ <div class="rsum">Pending summary</div>
542
+ <div class="meta">${range.atoms.length} atoms · ${fmt(chars)} chars · ${images} images</div>
543
+ </div>`;
544
+ }).join('');
545
+ return;
546
+ }
473
547
  const rs = state.draft.ranges.filter(r => !query || rangeText(r).includes(query));
474
548
  if (!state.draft.ranges.length) {
475
549
  list.innerHTML = '<div class="empty">No compression ranges in this draft.</div>';
@@ -478,7 +552,7 @@ function renderRanges() {
478
552
  if (!rs.length) { list.innerHTML = '<div class="empty">No range matches the filter.</div>'; return; }
479
553
  list.innerHTML = rs.map(r => {
480
554
  const d = draftOf(r);
481
- const ratio = r.originalApproxTokens ? Math.round((1 - r.compressedApproxTokens / r.originalApproxTokens) * 100) : 0;
555
+ const ratio = r.originalContentChars ? Math.round((1 - (r.replacementContentChars ?? 0) / r.originalContentChars) * 100) : 0;
482
556
  return `<div class="ritem" role="option" tabindex="0" data-range="${esc(r.id)}" aria-selected="${r.id === selectedId}">
483
557
  <div class="r1">
484
558
  <span class="rid">${hl(r.id, query)}</span>
@@ -488,18 +562,21 @@ function renderRanges() {
488
562
  </div>
489
563
  ${d.topic ? `<div class="meta" style="color:var(--accent-2)">${hl(d.topic, query)}</div>` : ''}
490
564
  <div class="rsum">${hl(d.summary, query)}</div>
491
- <div class="meta">${r.atomCount} atoms · ~${fmt(r.originalApproxTokens)}→~${fmt(r.compressedApproxTokens)} (−${ratio}%)</div>
565
+ <div class="meta">${r.atomCount} atoms · ${fmt(r.originalContentChars)}→${fmt(r.replacementContentChars)} chars · ${ratio}% fewer chars</div>
492
566
  </div>`;
493
567
  }).join('');
494
568
  }
495
569
 
496
570
  function buildGroups() {
497
571
  const groups = [];
498
- let cur = null;
499
- state.atoms.forEach((a, i) => {
500
- const key = a.owningRangeId ?? null;
501
- if (!cur || cur.key !== key) { cur = { key, idx: i, atoms: [] }; groups.push(cur); }
502
- cur.atoms.push(a);
572
+ let current = null;
573
+ state.atoms.forEach((atom, index) => {
574
+ const key = isSelection() ? atom.groupRef : (atom.owningRangeId ?? null);
575
+ if (!current || current.key !== key) {
576
+ current = { key, idx: index, label: atom.groupLabel, atoms: [] };
577
+ groups.push(current);
578
+ }
579
+ current.atoms.push(atom);
503
580
  });
504
581
  return groups;
505
582
  }
@@ -510,26 +587,40 @@ function renderTimeline() {
510
587
 
511
588
  let shown = 0;
512
589
  const html = buildGroups().map(g => {
513
- if (policy === 'range' && !g.key) return '';
514
- if (policy === 'keep' && g.key) return '';
515
- const r = g.key ? rangeById(g.key) : null;
590
+ if (!isSelection() && policy === 'range' && !g.key) return '';
591
+ if (!isSelection() && policy === 'keep' && g.key) return '';
592
+ const r = !isSelection() && g.key ? rangeById(g.key) : null;
516
593
  const gkey = g.key ?? ('keep-' + g.idx);
517
- const groupMatch = r ? rangeText(r).includes(query) : false;
594
+ const groupMatch = r ? rangeText(r).includes(query) : `${g.label ?? ''} ${gkey}`.toLowerCase().includes(query);
518
595
  const atoms = query && !groupMatch ? g.atoms.filter(a => atomText(a).includes(query)) : g.atoms;
519
596
  if (query && !groupMatch && !atoms.length) return '';
520
597
  shown += atoms.length;
521
598
 
522
- const tok = g.atoms.reduce((s, a) => s + (a.approxTokens ?? 0), 0);
599
+ const chars = g.atoms.reduce((sum, atom) => sum + (atom.contentChars ?? 0), 0);
600
+ const images = g.atoms.reduce((sum, atom) => sum + (atom.imageCount ?? 0), 0);
523
601
  const open = !collapsed.has(gkey) || (query && !groupMatch);
524
602
  const body = atoms.map(a => atomRow(a)).join('');
525
603
 
604
+ if (isSelection()) {
605
+ const allSelected = g.atoms.every(atom => selectionRefs.has(atom.ref));
606
+ return `<section class="group ${open ? 'open' : ''} ${allSelected ? 'sel' : ''}" data-group-key="${esc(gkey)}">
607
+ <div class="ghead" aria-expanded="${open}">
608
+ <button class="btn btn-sm btn-icon" data-toggle="${esc(gkey)}" title="Expand group">▶</button>
609
+ <span class="gid">${esc(gkey)}</span>
610
+ <span class="gsum">${esc(g.label ?? '')}</span>
611
+ <span class="gstat">${g.atoms.length} atoms · ${fmt(chars)} chars · ${images} img <button class="btn btn-sm" data-select-group="${esc(gkey)}">${allSelected ? 'Clear group' : 'Add group'}</button></span>
612
+ </div>
613
+ <div class="gbody">${body}</div>
614
+ </section>`;
615
+ }
616
+
526
617
  if (!r) {
527
618
  return `<section class="group keepg ${open ? 'open' : ''}" data-group-key="${esc(gkey)}">
528
619
  <button class="ghead" data-toggle="${esc(gkey)}" aria-expanded="${open}">
529
620
  <span class="chev">▶</span>
530
621
  <span class="gid">KEEP</span>
531
622
  <span class="gsum">${esc(g.atoms[0].ref)} → ${esc(g.atoms[g.atoms.length - 1].ref)} verbatim</span>
532
- <span class="gstat">${g.atoms.length} atoms · ~${fmt(tok)}</span>
623
+ <span class="gstat">${g.atoms.length} atoms · ${fmt(chars)} chars · ${images} img</span>
533
624
  </button>
534
625
  <div class="gbody">${body}</div>
535
626
  </section>`;
@@ -540,7 +631,7 @@ function renderTimeline() {
540
631
  <span class="chev">▶</span>
541
632
  <span class="gid">${esc(r.id)}</span>
542
633
  <span class="gsum">${hl(d.topic || d.summary, query)}</span>
543
- <span class="gstat">${r.atomCount} atoms · ~${fmt(r.originalApproxTokens)}→~${fmt(r.compressedApproxTokens)} <em>−${fmt(saveOf(r))}</em></span>
634
+ <span class="gstat">${r.atomCount} atoms · ${fmt(r.originalContentChars)}→${fmt(r.replacementContentChars)} chars <em>−${fmt(saveOf(r))}</em></span>
544
635
  </button>
545
636
  <div class="gbody">${body}</div>
546
637
  </section>`;
@@ -550,28 +641,57 @@ function renderTimeline() {
550
641
  $('tl-stat').textContent = query || policy !== 'all' ? `${shown}/${state.atoms.length} atoms` : `${state.atoms.length} atoms`;
551
642
  }
552
643
 
644
+ function toolTag(name) {
645
+ const tone = ['read', 'write', 'edit', 'bash'].includes(String(name).toLowerCase()) ? ` ${String(name).toLowerCase()}` : '';
646
+ return `<span class="tag tool${tone}">${esc(name)}</span>`;
647
+ }
648
+
553
649
  function atomRow(a) {
554
650
  const tags = [
555
- ...(a.toolNames ?? []).slice(0, 3).map(t => `<span class="tag">${esc(t)}</span>`),
651
+ ...(a.toolNames ?? []).slice(0, 3).map(toolTag),
556
652
  ...(a.roles ?? []).slice(0, 2).map(t => `<span class="tag">${esc(t)}</span>`),
557
653
  a.compressible === false ? '<span class="tag lock">not compressible</span>' : '',
558
654
  a.protocolClosed === false ? '<span class="tag lock">open protocol</span>' : '',
559
655
  a.compressedBlockId ? `<span class="tag">blk ${esc(a.compressedBlockId)}</span>` : '',
560
656
  ].filter(Boolean).join('');
561
657
  const open = openAtoms.has(a.ref);
562
- return `<div class="atom ${open ? 'open' : ''}" data-atom="${esc(a.ref)}">
658
+ const selected = selectionRefs.has(a.ref);
659
+ const kept = keepRefs.has(a.ref) || !a.compressible;
660
+ const actions = isSelection() ? `<div class="atom-actions">
661
+ <button class="btn btn-sm" data-toggle-atom="${esc(a.ref)}" ${!a.compressible ? 'disabled' : ''}>${selected ? 'Remove' : 'Add'}</button>
662
+ <button class="btn btn-sm" data-keep-atom="${esc(a.ref)}" ${!a.compressible ? 'disabled' : ''}>${kept ? 'KEEP' : 'Keep'}</button>
663
+ </div>` : '';
664
+ return `<div class="atom ${open ? 'open' : ''} ${selected ? 'selected-atom' : ''} ${kept ? 'kept-atom' : ''}" data-atom="${esc(a.ref)}">
563
665
  <span class="aref">${hl(a.ref, query)}</span>
564
666
  <span class="akind">${hl(a.kind ?? '', query)}</span>
565
- <span class="atok">~${fmt(a.approxTokens)}</span>
667
+ <span class="atok">${fmt(a.contentChars)} chars<br>${a.imageCount ?? 0} img</span>
566
668
  <div>
567
669
  <div class="abody">${hl(a.preview ?? '', query)}</div>
568
670
  ${tags ? `<div class="tags">${tags}</div>` : ''}
671
+ ${actions}
569
672
  </div>
570
673
  </div>`;
571
674
  }
572
675
 
573
676
  function renderEditor() {
574
677
  const panel = $('editor');
678
+ if (isSelection()) {
679
+ const selectedAtoms = state.atoms.filter(atom => selectionRefs.has(atom.ref));
680
+ const chars = selectedAtoms.reduce((sum, atom) => sum + (atom.contentChars ?? 0), 0);
681
+ const images = selectedAtoms.reduce((sum, atom) => sum + (atom.imageCount ?? 0), 0);
682
+ panel.innerHTML = `<div class="epad">
683
+ <div class="etitle"><span class="eid">Selection</span><span class="erefs">shared DraftPlan</span></div>
684
+ <div class="estats">
685
+ <div class="estat"><label>atoms</label><span>${selectedAtoms.length}</span></div>
686
+ <div class="estat"><label>chars</label><span>${fmt(chars)}</span></div>
687
+ <div class="estat"><label>images</label><span>${images}</span></div>
688
+ </div>
689
+ <div class="hint">Add a whole user group or individual atoms. KEEP creates a hole; protected atoms stay verbatim. Save writes normalized, non-overlapping ranges to the same DraftPlan used by Agent.</div>
690
+ <button class="btn btn-primary" id="selection-save-side">Save selection</button>
691
+ <button class="btn" id="selection-clear">Clear selection</button>
692
+ </div>`;
693
+ return;
694
+ }
575
695
  const r = selectedId ? rangeById(selectedId) : null;
576
696
  if (!r) {
577
697
  panel.innerHTML = `<div class="empty">${state.draft.ranges.length
@@ -581,7 +701,7 @@ function renderEditor() {
581
701
  }
582
702
  const d = draftOf(r);
583
703
  const dirty = isDirty(r);
584
- const ratio = r.originalApproxTokens ? Math.round((1 - r.compressedApproxTokens / r.originalApproxTokens) * 100) : 0;
704
+ const ratio = r.originalContentChars ? Math.round((1 - (r.replacementContentChars ?? 0) / r.originalContentChars) * 100) : 0;
585
705
  panel.innerHTML = `<div class="epad">
586
706
  <div>
587
707
  <div class="etitle">
@@ -590,8 +710,8 @@ function renderEditor() {
590
710
  </div>
591
711
  <div class="estats">
592
712
  <div class="estat"><label>atoms</label><span>${r.atomCount}</span></div>
593
- <div class="estat"><label>tokens</label><span>~${fmt(r.originalApproxTokens)}→~${fmt(r.compressedApproxTokens)}</span></div>
594
- <div class="estat"><label>save</label><span style="color:var(--keep)">−${fmt(saveOf(r))} (${ratio}%)</span></div>
713
+ <div class="estat"><label>chars</label><span>${fmt(r.originalContentChars)}→${fmt(r.replacementContentChars)}</span></div>
714
+ <div class="estat"><label>images</label><span>${r.originalImageCount ?? 0}</span></div>
595
715
  </div>
596
716
  </div>
597
717
  <div class="field" id="f-topic">
@@ -619,7 +739,7 @@ function updateCount() {
619
739
  const ta = $('summary');
620
740
  if (!ta) return;
621
741
  const len = ta.value.length;
622
- $('sum-count').textContent = `${len} chars · ~${Math.ceil(len / 4)} tok`;
742
+ $('sum-count').textContent = `${len} chars`;
623
743
  }
624
744
 
625
745
  /* ---------- state mutation ---------- */
@@ -643,6 +763,55 @@ async function post(path, body) {
643
763
  return res.json();
644
764
  }
645
765
 
766
+ async function saveSelection() {
767
+ const indices = state.atoms.map((atom, index) => selectionRefs.has(atom.ref) ? index : -1).filter(index => index >= 0);
768
+ const spans = [];
769
+ for (const index of indices) {
770
+ const last = spans.at(-1);
771
+ if (last && last.endIndex + 1 === index) {
772
+ last.endRef = state.atoms[index].ref;
773
+ last.endIndex = index;
774
+ } else spans.push({ startRef: state.atoms[index].ref, endRef: state.atoms[index].ref, endIndex: index });
775
+ }
776
+ try {
777
+ state = await post('/api/selection', { spans: spans.map(({ startRef, endRef }) => ({ startRef, endRef })), keepRefs: [...keepRefs] });
778
+ selectionDirty = false;
779
+ selectedId = state.draft.ranges[0]?.id ?? null;
780
+ render();
781
+ toast('DraftPlan saved. Tell the Agent to continue when ready.', 'ok');
782
+ return true;
783
+ } catch (err) { toast(String(err.message ?? err), 'err'); return false; }
784
+ }
785
+
786
+ function toggleSelectionAtom(ref) {
787
+ const atom = atomByRef(ref);
788
+ if (!atom || !atom.compressible) return;
789
+ if (selectionRefs.has(ref)) { selectionRefs.delete(ref); keepRefs.delete(ref); }
790
+ else selectionRefs.add(ref);
791
+ selectionDirty = true;
792
+ render();
793
+ }
794
+
795
+ function toggleKeepAtom(ref) {
796
+ const atom = atomByRef(ref);
797
+ if (!atom || !atom.compressible) return;
798
+ selectionRefs.add(ref);
799
+ if (keepRefs.has(ref)) keepRefs.delete(ref); else keepRefs.add(ref);
800
+ selectionDirty = true;
801
+ render();
802
+ }
803
+
804
+ function toggleSelectionGroup(groupRef) {
805
+ const atoms = state.atoms.filter(atom => atom.groupRef === groupRef);
806
+ const allSelected = atoms.every(atom => selectionRefs.has(atom.ref));
807
+ for (const atom of atoms) {
808
+ if (allSelected) { selectionRefs.delete(atom.ref); keepRefs.delete(atom.ref); }
809
+ else selectionRefs.add(atom.ref);
810
+ }
811
+ selectionDirty = true;
812
+ render();
813
+ }
814
+
646
815
  async function saveRange(id) {
647
816
  const r = rangeById(id);
648
817
  const e = edits.get(id);
@@ -666,6 +835,9 @@ async function refresh() {
666
835
  const res = await fetch('/api/state');
667
836
  if (!res.ok) throw new Error('state fetch failed');
668
837
  state = await res.json();
838
+ selectionRefs = new Set(state.draft.ranges.flatMap(range => rangeAtoms(range).map(atom => atom.ref)));
839
+ keepRefs.clear();
840
+ selectionDirty = false;
669
841
  if (!rangeById(selectedId)) selectedId = state.draft.ranges[0]?.id ?? null;
670
842
  render();
671
843
  toast('Reloaded', 'ok');
@@ -674,6 +846,14 @@ async function refresh() {
674
846
 
675
847
  /* ---------- events ---------- */
676
848
  $('range-list').addEventListener('click', e => {
849
+ const remove = e.target.closest('[data-remove-proposed]');
850
+ if (remove) {
851
+ const range = proposedRanges()[Number(remove.dataset.removeProposed)];
852
+ range?.atoms.forEach(atom => { selectionRefs.delete(atom.ref); keepRefs.delete(atom.ref); });
853
+ selectionDirty = true;
854
+ render();
855
+ return;
856
+ }
677
857
  const item = e.target.closest('[data-range]');
678
858
  if (item) select(item.dataset.range);
679
859
  });
@@ -683,6 +863,12 @@ $('range-list').addEventListener('keydown', e => {
683
863
  });
684
864
 
685
865
  $('timeline').addEventListener('click', e => {
866
+ const atomToggle = e.target.closest('[data-toggle-atom]');
867
+ if (atomToggle) { toggleSelectionAtom(atomToggle.dataset.toggleAtom); return; }
868
+ const keepToggle = e.target.closest('[data-keep-atom]');
869
+ if (keepToggle) { toggleKeepAtom(keepToggle.dataset.keepAtom); return; }
870
+ const groupToggle = e.target.closest('[data-select-group]');
871
+ if (groupToggle) { toggleSelectionGroup(groupToggle.dataset.selectGroup); return; }
686
872
  const atom = e.target.closest('[data-atom]');
687
873
  if (atom && e.target.closest('.abody')) {
688
874
  const ref = atom.dataset.atom;
@@ -741,7 +927,12 @@ $('editor').addEventListener('input', e => {
741
927
  if (e.target.id === 'topic') renderTimeline();
742
928
  });
743
929
 
930
+ $('save-selection').addEventListener('click', saveSelection);
744
931
  $('editor').addEventListener('click', async e => {
932
+ if (e.target.closest('#selection-save-side')) { await saveSelection(); return; }
933
+ if (e.target.closest('#selection-clear')) {
934
+ selectionRefs.clear(); keepRefs.clear(); selectionDirty = true; render(); return;
935
+ }
745
936
  const r = rangeById(selectedId);
746
937
  if (!r) return;
747
938
  if (e.target.closest('#save-btn')) { await saveCurrent(); return; }
@@ -774,19 +965,37 @@ $('save-all').addEventListener('click', async () => {
774
965
 
775
966
  $('refresh').addEventListener('click', refresh);
776
967
 
777
- $('close').addEventListener('click', async () => {
778
- const d = dirtyIds();
779
- if (d.length && !closeArmed) {
780
- closeArmed = true;
781
- $('close').textContent = `Discard ${d.length} unsaved & close`;
782
- setTimeout(() => { closeArmed = false; $('close').textContent = 'Close & return'; }, 4000);
783
- toast('Unsaved edits — click again to discard and close', 'err');
784
- return;
785
- }
968
+ async function closeWorkbench() {
786
969
  try {
787
970
  await post('/api/close', {});
788
- document.body.innerHTML = '<div class="closed">Review closed.<br>Return to your terminal.</div>';
971
+ document.body.innerHTML = `<div class="closed">${isSelection() ? 'Selection' : 'Review'} closed.<br>Return to your terminal.</div>`;
789
972
  } catch (err) { toast(String(err.message ?? err), 'err'); }
973
+ }
974
+
975
+ $('close').addEventListener('click', () => {
976
+ if (dirtyIds().length || selectionDirty) {
977
+ $('close-dialog').classList.add('show');
978
+ return;
979
+ }
980
+ closeWorkbench();
981
+ });
982
+ $('close-stay').addEventListener('click', () => $('close-dialog').classList.remove('show'));
983
+ $('close-discard').addEventListener('click', () => {
984
+ edits.clear();
985
+ selectionDirty = false;
986
+ $('close-dialog').classList.remove('show');
987
+ closeWorkbench();
988
+ });
989
+ $('close-save').addEventListener('click', async () => {
990
+ let saved = true;
991
+ if (isSelection()) saved = await saveSelection();
992
+ else {
993
+ try { for (const id of dirtyIds()) saved = (await saveRange(id)) && saved; }
994
+ catch (err) { toast(String(err.message ?? err), 'err'); saved = false; }
995
+ }
996
+ if (!saved) return;
997
+ $('close-dialog').classList.remove('show');
998
+ await closeWorkbench();
790
999
  });
791
1000
 
792
1001
  /* theme */
@@ -815,7 +1024,12 @@ function moveSel(delta) {
815
1024
  document.addEventListener('keydown', e => {
816
1025
  const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName);
817
1026
  if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); saveCurrent(); return; }
818
- if (e.key === 'Escape') { help.classList.remove('show'); if (typing) e.target.blur(); return; }
1027
+ if (e.key === 'Escape') {
1028
+ help.classList.remove('show');
1029
+ $('close-dialog').classList.remove('show');
1030
+ if (typing) e.target.blur();
1031
+ return;
1032
+ }
819
1033
  if (typing) return;
820
1034
  if (e.key === '/') { e.preventDefault(); $('q').focus(); return; }
821
1035
  if (e.key === '?') { help.classList.toggle('show'); return; }
@@ -838,7 +1052,10 @@ function toast(msg, kind) {
838
1052
  toastTimer = setTimeout(() => t.classList.remove('show'), kind === 'err' ? 3200 : 1800);
839
1053
  }
840
1054
 
841
- window.addEventListener('beforeunload', e => { if (dirtyIds().length) { e.preventDefault(); e.returnValue = ''; } });
1055
+ window.addEventListener('beforeunload', e => { if (dirtyIds().length || selectionDirty) { e.preventDefault(); e.returnValue = ''; } });
1056
+ window.addEventListener('pagehide', () => {
1057
+ if (!dirtyIds().length && !selectionDirty) navigator.sendBeacon('/api/close', new Blob(['{}'], { type: 'application/json' }));
1058
+ });
842
1059
 
843
1060
  render();
844
1061
  </script>