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.
- package/README.md +16 -7
- package/README.zh-CN.md +16 -7
- package/package.json +2 -2
- package/skills/midcompact/SKILL.md +87 -72
- package/skills/midcompact/references/tool-interface.md +83 -0
- package/src/atoms.ts +31 -7
- package/src/content-metrics.ts +185 -0
- package/src/index.ts +487 -196
- package/src/inventory.ts +295 -0
- package/src/messages.ts +56 -1
- package/src/plan.ts +176 -20
- package/src/planning-lock.ts +42 -0
- package/src/projection.ts +43 -1
- package/src/renderers.ts +16 -19
- package/src/review-ui.ts +17 -16
- package/src/review-webui.html +290 -73
- package/src/review-webui.ts +176 -71
- package/src/selection-ui.ts +220 -0
- package/src/selection.ts +95 -0
- package/src/start-ui.ts +57 -0
- package/src/state.ts +39 -2
- package/src/telemetry.ts +48 -18
- package/src/types.ts +141 -2
package/src/review-webui.html
CHANGED
|
@@ -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:
|
|
36
|
+
--r: 8px;
|
|
37
37
|
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
38
|
-
--sans:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 & 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
|
|
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.
|
|
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
|
-
|
|
427
|
-
$('dirty-badge').
|
|
428
|
-
$('
|
|
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
|
|
445
|
-
const
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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.
|
|
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 ·
|
|
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
|
|
499
|
-
state.atoms.forEach((
|
|
500
|
-
const key =
|
|
501
|
-
if (!
|
|
502
|
-
|
|
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) :
|
|
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
|
|
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 ·
|
|
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 ·
|
|
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(
|
|
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
|
-
|
|
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"
|
|
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.
|
|
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>
|
|
594
|
-
<div class="estat"><label>
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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') {
|
|
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>
|