-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathpi-context-tree-mockup.html
More file actions
743 lines (704 loc) · 43.7 KB
/
Copy pathpi-context-tree-mockup.html
File metadata and controls
743 lines (704 loc) · 43.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>pi-context-tree — Context Panel mockup v0.1</title>
<style>
:root{
--bg:#08090b; --term:#0b0d10; --chrome:#16181d; --line:#23262d;
--txt:#c9ccd1; --dim:#5d6470; --faint:#3a3f49;
--green:#5af78e; --green-dim:#39855c;
--cyan:#5fd7d7; --yellow:#e5c07b; --orange:#ff9e3b;
--red:#f4647d; --blue:#6ab0f3; --mauve:#c792ea;
--selbg:rgba(90,247,142,.13);
--mono:"JetBrains Mono","SF Mono",ui-monospace,Menlo,Consolas,monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--txt);font-family:var(--mono);font-size:13px;
padding:18px 14px 40px;display:flex;flex-direction:column;align-items:center;gap:12px}
/* ---- mockup controls (not part of the TUI) ---- */
.toolbar{width:min(1100px,100%);display:flex;flex-wrap:wrap;gap:6px;align-items:center}
.toolbar .tag{color:var(--dim);font-size:11px;margin-right:6px;letter-spacing:.04em}
.toolbar button{font-family:var(--mono);font-size:11px;background:var(--chrome);color:var(--txt);
border:1px solid var(--line);border-radius:5px;padding:4px 10px;cursor:pointer}
.toolbar button:hover{border-color:var(--dim)}
.toolbar button.on{border-color:var(--green);color:var(--green)}
.toolbar button.reset{margin-left:auto;color:var(--orange)}
.hint{width:min(1100px,100%);color:var(--dim);font-size:11px}
.hint b{color:var(--txt);font-weight:600}
/* ---- terminal window ---- */
.termwin{width:min(1100px,100%);background:var(--term);border:1px solid var(--line);
border-radius:10px;overflow:hidden;box-shadow:0 18px 60px rgba(0,0,0,.55)}
.titlebar{background:var(--chrome);display:flex;align-items:center;gap:8px;padding:8px 12px;
border-bottom:1px solid var(--line)}
.dots{display:flex;gap:6px}
.dot{width:11px;height:11px;border-radius:50%}
.dot:nth-child(1){background:#ff5f57}.dot:nth-child(2){background:#febc2e}.dot:nth-child(3){background:#28c840}
.tabpill{margin:0 auto;padding:2px 14px;border-radius:999px;font-size:11px;color:#fff;
border:1px solid transparent;transition:background .3s}
.tabnote{color:var(--faint);font-size:10px}
.screen{position:relative;height:632px;padding:10px 0 0;display:flex;flex-direction:column;overflow:hidden}
.body{flex:1;overflow-y:auto;padding:2px 16px 8px}
.body::-webkit-scrollbar{width:8px}
.body::-webkit-scrollbar-thumb{background:var(--faint);border-radius:4px}
/* panel header */
.phead{padding:0 16px 6px;display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
.phead .brand{color:var(--mauve);font-weight:600}
.phead .crumb{color:var(--dim)}
.phead .spec{margin-left:auto;color:var(--faint);font-size:10px}
.chip{padding:0 7px;border-radius:4px;border:1px solid var(--faint);color:var(--txt);font-size:11px}
.chip.model{color:var(--cyan);border-color:#27424a}
.gaugeline{display:flex;align-items:center;gap:10px;padding:2px 16px 8px;border-bottom:1px solid var(--line)}
.gtrack{position:relative;width:300px;height:9px;border-radius:5px;background:var(--chrome);overflow:hidden;flex:none}
.gfill{position:absolute;inset:0;width:100%;
background:linear-gradient(90deg,#3fae6a 0%,#7ec96a 12%,#e5c07b 22%,#ff9e3b 34%,#f4647d 55%,#f4647d 100%)}
.gmask{position:absolute;top:0;bottom:0;right:0;background:var(--chrome)}
.gtick{position:absolute;top:-3px;bottom:-3px;width:1px;background:var(--dim);opacity:.8}
.gtick.major{background:var(--txt)}
.glabel{color:var(--dim);font-size:12px}
.glabel b{font-weight:600}
.st-low{color:#7ec96a}.st-healthy{color:var(--green)}.st-filling{color:var(--orange)}.st-red{color:var(--red)}
/* rows */
.row{display:flex;align-items:baseline;gap:0 8px;padding:1px 8px;border-radius:4px;cursor:default;white-space:pre}
.row .g{width:2ch;flex:none;text-align:center}
.row .lbl{flex:1;overflow:hidden;text-overflow:ellipsis}
.row .tok{flex:none;min-width:7ch;text-align:right;color:var(--dim)}
.row .tok.big{color:var(--yellow)}
.row .tok.huge{color:var(--orange);font-weight:600}
.row.sel{background:var(--selbg);outline:1px solid rgba(90,247,142,.35)}
.row.dimrow{opacity:.62}
.guide{color:var(--faint)}
.u{color:var(--cyan)} .a{color:#b8bfc7} .t{color:#9aa3ad} .warnflag{color:var(--orange)}
.dec{color:var(--mauve)} .stub{color:var(--dim);font-style:italic}
.b-open{color:var(--green)} .b-dangling{color:var(--yellow)} .b-squashed{color:var(--blue)} .b-rejected{color:var(--red)}
.leafmark{color:var(--green)}
.fold{color:var(--dim)}
.secthead{color:var(--dim);font-size:11px;letter-spacing:.08em;padding:8px 8px 3px}
.pfoot{border-top:1px solid var(--line);padding:6px 16px;color:var(--dim);font-size:11.5px;display:flex;gap:14px;flex-wrap:wrap}
.pfoot b{color:var(--txt);font-weight:600}
.pfoot .ctx{color:var(--green-dim)}
/* crop */
.mark{width:3ch;flex:none;text-align:center}
.mark.x{color:var(--red);font-weight:700}
.mark.prot{color:var(--faint)}
.agecol{flex:none;width:9ch;text-align:right;color:var(--faint)}
.row.csel{outline:1px solid var(--red);background:rgba(244,100,125,.08)}
.armed{color:var(--orange);font-size:11px;padding:2px 16px}
.reclaim{color:var(--yellow)}
/* selector (pi native style) */
.veil{position:absolute;inset:0;background:rgba(8,9,11,.78);display:flex;align-items:center;justify-content:center;z-index:5}
.selbox{width:min(640px,92%);background:var(--term);border:1px solid var(--line);border-radius:8px;
padding:18px 22px;box-shadow:0 12px 48px rgba(0,0,0,.6)}
.selbox h3{color:var(--cyan);font-size:13px;font-weight:600;margin-bottom:14px}
.opt{display:flex;gap:10px;padding:3px 4px;color:var(--green)}
.opt .arrow{width:2ch;color:var(--txt)}
.opt .odesc{color:var(--dim)}
.opt.cur{background:var(--selbg)}
.opt.disabled{color:var(--faint)}
.opt.disabled .odesc{color:var(--faint)}
.selfoot{margin-top:14px;color:var(--dim);font-size:11.5px}
.selfoot b{color:var(--txt)}
.selbox input[type=text]{width:100%;background:var(--bg);border:1px solid var(--green-dim);border-radius:4px;
color:var(--txt);font-family:var(--mono);font-size:13px;padding:6px 8px;outline:none}
.modelpick{display:flex;gap:8px;margin-top:10px;align-items:center;color:var(--dim);font-size:12px}
.mopt{padding:1px 8px;border:1px solid var(--faint);border-radius:4px;color:var(--dim);cursor:pointer}
.mopt.cur{border-color:var(--cyan);color:var(--cyan)}
/* decision record editor */
.record{width:100%;height:330px;background:#0e1014;border:1px solid var(--green-dim);border-radius:6px;
color:var(--txt);font-family:var(--mono);font-size:12.5px;line-height:1.5;padding:12px 14px;outline:none;resize:none}
.record:focus{border-color:var(--green)}
.rnote{color:var(--dim);font-size:11.5px;padding:6px 2px}
/* decisions view */
.dcard{border:1px solid var(--line);border-radius:6px;padding:10px 14px;margin:8px 8px}
.dcard h4{color:var(--mauve);font-size:13px;font-weight:600}
.dcard .meta{color:var(--dim);font-size:11.5px;margin:2px 0 6px}
.dcard .out{color:var(--txt)}
.dcard .rej{color:var(--red);margin-top:4px}
.dcard .rej i{color:var(--dim);font-style:normal}
/* consumers */
.cbar-wrap{flex:1;display:flex;align-items:center;gap:10px;min-width:0}
.cname{width:24ch;flex:none;overflow:hidden;text-overflow:ellipsis}
.cbar{height:9px;border-radius:4px;background:var(--orange);min-width:2px}
.cmeta{flex:none;color:var(--dim);min-width:18ch;text-align:right}
/* inspector */
.imeta{color:var(--dim);padding:2px 8px}
.imeta b{color:var(--txt)}
.ibody{margin:8px;padding:10px 12px;background:#0e1014;border:1px solid var(--line);border-radius:6px;
color:#9aa3ad;white-space:pre;overflow:auto;max-height:380px;font-size:12px;line-height:1.5}
/* ambient (prompt bar) */
.amb{display:flex;flex-direction:column;height:100%;padding:8px 18px 12px}
.amb .scroll{flex:1;overflow:hidden;display:flex;flex-direction:column;justify-content:flex-end;gap:10px}
.amsg{white-space:pre-wrap;line-height:1.55}
.amsg .role{color:var(--green)}
.promptbox{border:1px solid;border-radius:4px;padding:8px 10px;margin-top:12px}
.cursor{display:inline-block;width:8px;height:15px;vertical-align:text-bottom;background:var(--green);animation:blink 1.1s steps(1) infinite}
@keyframes blink{50%{opacity:0}}
.pbfoot{padding-top:8px;color:var(--dim);line-height:1.6}
.pbfoot .pct{padding:0 3px;border-radius:3px;color:#111}
.pbfoot .right{float:right}
.notify{color:var(--orange);padding:4px 0 0;font-size:12px}
/* toast */
.toast{position:absolute;right:14px;bottom:46px;max-width:60ch;background:var(--chrome);border:1px solid var(--green-dim);
border-radius:6px;padding:8px 12px;color:var(--txt);font-size:12px;z-index:9;animation:tin .15s ease-out}
.toast.err{border-color:var(--red)}
@keyframes tin{from{transform:translateY(6px);opacity:0}}
/* legend */
.gmode{margin-top:10px}
.gmodelbl{color:var(--dim);font-size:11px;margin-bottom:4px}
.gmodepre{font:inherit;white-space:pre;overflow-x:auto}
.legend{width:min(1100px,100%);display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:10px;
color:var(--dim);font-size:11.5px;line-height:1.7}
.legend .box{border:1px solid var(--line);border-radius:8px;padding:10px 14px;background:var(--term)}
.legend h5{color:var(--txt);font-size:11px;letter-spacing:.06em;margin-bottom:4px;font-weight:600}
.legend .k{color:var(--txt)}
</style>
</head>
<body>
<div class="toolbar">
<span class="tag">MOCKUP CONTROLS</span>
<button data-go="tree">tree</button>
<button data-go="crop">crop</button>
<button data-go="merge">merge</button>
<button data-go="decisions">decisions</button>
<button data-go="consumers">consumers</button>
<button data-go="inspect">inspector</button>
<button data-go="forest">forest</button>
<button data-go="ambient">prompt bar (panel closed)</button>
<button class="reset" onclick="location.reload()">⟲ reset state</button>
</div>
<div class="hint">Click the terminal once, then drive it with the keyboard — every core action is ≤2 keystrokes (<b>G5</b>).
Try the loop: <b>u</b> see consumers → <b>c</b> crop the two snapshots (<b>space</b>, <b>space space</b> on the protected one, <b>⏎</b>) → watch the gauge → <b>m</b>,<b>⏎</b> squash the branch → <b>D</b> decisions.</div>
<div class="termwin">
<div class="titlebar">
<div class="dots"><span class="dot"></span><span class="dot"></span><span class="dot"></span></div>
<span class="tabpill" id="tabpill">tabwrangler (fix-flaky-test) (pi)</span>
<span class="tabnote">F5.1 — terminal title, color hashed from branch</span>
</div>
<div class="screen" id="screen" tabindex="0"></div>
</div>
<div class="legend">
<div class="box"><h5>ENTRY GLYPHS (F4.3)</h5>
<span class="u">●</span> user <span class="a">○</span> assistant <span class="t">⚙</span> tool / MCP result
<span class="dec">◆</span> decision record <span class="b-open">⎇</span> branch label <span class="stub">✂</span> crop stub</div>
<div class="box"><h5>BRANCH STATUS (derived, F4.3)</h5>
<span class="b-open">open</span> · <span class="b-dangling">dangling (open, no close marker)</span> ·
<span class="b-squashed">squashed</span> · <span class="b-rejected">rejected / discarded</span></div>
<div class="box"><h5>CONTEXT GAUGE (F5.2)</h5>
bands: <5% low · <span class="st-healthy">5–15% healthy</span> · <span class="st-filling">15–40% filling</span> ·
<span class="st-red">>40% red</span><br>ambient = prompt-border color (matches existing workflow); panel = explicit bar with 5/15/40 ticks</div>
<div class="box"><h5>KEYS (F4.4)</h5>
<span class="k">j/k ↑↓</span> move · <span class="k">⏎</span> jump/fold · <span class="k">b</span> branch · <span class="k">m</span> merge ·
<span class="k">c</span> crop · <span class="k">i</span> inspect · <span class="k">D</span> decisions · <span class="k">u</span> consumers ·
<span class="k">f</span> forest · <span class="k">q</span> close panel<br>
<span style="color:var(--faint)">in pi: /panel or Ctrl+Q (this mockup uses t — browsers reserve Ctrl+T)</span></div>
</div>
<script>
/* ================= data — one coherent scenario ================= */
const WINDOW = 200000;
const PROJECT = 'tabwrangler';
const spine = [
{id:'e01', g:'●', cls:'u', label:'user: build the tab-suspender importer', tok:160, type:'user message'},
{id:'e02', g:'○', cls:'a', label:'assistant: plan — storage layer first, then importer, then suspend pass', tok:540, type:'assistant message'},
{id:'e03', g:'⚙', cls:'t', label:'[read_file: src/storage.ts]', tok:2600, type:'tool_result', tool:'read_file', arg:'src/storage.ts', age:11, latest:true},
{id:'f-storage', fork:true, name:'storage-layer', status:'squashed',
sub:'tournament · 3 branches · closed 2026-06-08', folded:true, kids:[
{g:'✗', cls:'b-rejected', label:'storage-a — rejected: sync quota (100KB) too small'},
{g:'✗', cls:'b-rejected', label:'storage-b — rejected: breaks on MV3 event-page suspension'},
{g:'✓', cls:'b-squashed', label:'storage-c — squashed → decision record below'}]},
{id:'e04', g:'◆', cls:'dec', label:'Decision: storage-layer — storage-c won (2 epitaphs)', tok:410, type:'ctree/decision'},
{id:'e05', g:'⚙', cls:'t', label:'[mcp chrome.snapshot: full tab audit, 6 windows]', tok:19400, type:'tool_result (mcp)',
tool:'chrome.snapshot', arg:'tab-audit', age:9, sha:'a3f8c2d1', body:'snap1'},
{id:'e06', g:'○', cls:'a', label:'assistant: 41 suspendable tabs across 6 windows; importer can batch by window', tok:320, type:'assistant message'},
{id:'f-perf', fork:true, name:'perf-spike', status:'dangling', sub:'open · no close marker · 4d', folded:true, kids:[
{g:'·', cls:'dimrow', label:'13 entries · ~9.8k — branched off and never merged (forest flags this)'}]},
{id:'e07', g:'⚙', cls:'t', label:'[mcp chrome.snapshot: re-audit after suspend pass]', tok:16200, type:'tool_result (mcp)',
tool:'chrome.snapshot', arg:'post-suspend', age:5, sha:'7c91e04f', latest:true, body:'snap2'},
{id:'f-fix', fork:true, name:'fix-flaky-test', status:'open', current:true,
sub:'open · trunk opus-4.8 → branch haiku-4.5'},
];
const branchEntries = [
{id:'b1', g:'●', cls:'u', label:'user: tests flake on CI — suspect tmpdir', tok:85, type:'user message'},
{id:'b2', g:'⚙', cls:'t', label:'[run_tests: ×3 — 2 fail, then 1 pass]', tok:6400, type:'tool_result', tool:'run_tests', arg:'×3', age:1, latest:true},
{id:'b3', g:'○', cls:'a', label:'assistant: root cause — shared tmpdir collision between vitest workers', tok:290, type:'assistant message', leaf:true},
];
const decisionRecords = [
{name:'storage-layer', date:'2026-06-08', model:'haiku-4.5', branch:'9b41e7',
outcome:'chrome.storage.session with a write-through cache; survives MV3 service-worker restarts with zero rehydration cost.',
rejected:[
['storage-a (chrome.storage.sync)','100KB quota too small for tab metadata'],
['storage-b (in-memory + events)','breaks on MV3 event-page suspension']]},
];
const draftRecord = [
'## Decision: fix-flaky-test',
'**Date:** 2026-06-12 · **Model:** haiku-4.5 · **Branch:** a3f2c1',
'**Outcome:** CI flake was a tmpdir collision between parallel vitest workers;',
'fixed with a per-worker tmpdir suffix. Suite green 5/5 consecutive runs.',
'**Why:**',
'- failures only reproduced with workers >= 2',
'- both workers wrote fixtures to /tmp/tw-fixtures',
'- os.tmpdir() + worker id suffix isolates them',
'**Assumptions:** CI runners share one tmpdir root per host; no other suite writes tw-fixtures',
'**Changes:** test/setup.ts — commit 4e2a91c',
'**Gotchas:** cleanup hook must run per-worker, not per-suite',
'**Open questions:** none',
'**Confidence / revisit-if:** high · revisit if flakes reappear at >4 workers'
].join('\n');
const emptyRecord = [
'## Decision: fix-flaky-test',
'**Date:** 2026-06-12 · **Model:** (manual, --no-llm) · **Branch:** a3f2c1',
'**Outcome:** ',
'**Why:**',
'- ',
'**Assumptions:** ',
'**Changes:** ',
'**Gotchas:** ',
'**Open questions:** ',
'**Confidence / revisit-if:** '
].join('\n');
const bodies = {
snap1:['{ "windows": [',
' { "id": 1, "tabs": [',
' { "id": 412, "title": "MV3 service worker lifecycle — Chrome for Developers",',
' "url": "https://developer.chrome.com/docs/extensions/develop/concepts/service-workers",',
' "lastActive": "2026-06-12T09:14:02Z", "memoryKb": 48211, "audible": false },',
' { "id": 413, "title": "chrome.storage API reference",',
' "url": "https://developer.chrome.com/docs/extensions/reference/api/storage",',
' "lastActive": "2026-06-11T17:40:51Z", "memoryKb": 31002, "audible": false },',
' { "id": 418, "title": "vitest worker threads — GitHub issue #4441",',
' "url": "https://github.com/vitest-dev/vitest/issues/4441",',
' "lastActive": "2026-06-12T08:02:13Z", "memoryKb": 92408, "audible": false },',
' …',
'',
' — 1,914 more lines (~18.9k tokens) —'].join('\n'),
snap2:['{ "windows": [',
' { "id": 1, "tabs": [',
' { "id": 412, "suspended": true, "freedKb": 48211 },',
' { "id": 413, "suspended": true, "freedKb": 31002 },',
' { "id": 418, "suspended": false, "reason": "audible" },',
' …',
'',
' — 1,612 more lines (~15.8k tokens) —'].join('\n')
};
/* ================= state ================= */
const S = {
view:'tree', sel:9, csel:0, msel:0,
marks:new Set(), armed:null,
merged:false, mergeMode:null, cropped:false,
branch:'fix-flaky-test', model:'haiku-4.5',
inspectId:'e05', bname:'', bmodel:2, note:'',
folds:{}, recordText:draftRecord
};
spine.forEach(e=>{ if(e.fork) S.folds[e.id]=e.folded!==false; });
S.folds['f-fix']=false;
/* ================= helpers ================= */
const $ = s=>document.querySelector(s);
const esc = s=>s.replace(/&/g,'&').replace(/</g,'<');
function fmt(n){ return n>=1000 ? (n/1000).toFixed(1).replace(/\.0$/,'')+'k' : String(n); }
function tokCls(n){ return n>=10000?'huge':n>=1000?'big':''; }
function hashHue(s){ let h=0; for(const c of s) h=(h*31+c.charCodeAt(0))>>>0; return h%360; }
function ctxEntries(){
const out=[];
for(const e of spine){ if(e.fork) continue; out.push(e); }
if(S.merged){
if(S.mergeMode!=='discard') out.push({id:'e08',g:'◆',cls:'dec',label:'Decision: fix-flaky-test — tmpdir collision fixed',tok:380,type:'ctree/decision'});
} else {
out.push(...branchEntries);
}
return out;
}
function eff(e){ return e.cropped?24:e.tok; }
// mirrors BAND_TOKENS / band() in packages/core/src/estimate.ts (LOCA-bench 2026)
const BAND_TOKENS = {healthy:8000, filling:32000, red:64000};
const COMPACTION_RESERVE = 16384; // pi's default compaction.reserveTokens
function totals(){
const t = ctxEntries().reduce((s,e)=>s+eff(e),0);
const pct = t/WINDOW*100;
// quality band: absolute tokens only — the window size does not enter into it
let state='low';
if(t>=BAND_TOKENS.healthy) state='healthy';
if(t>=BAND_TOKENS.filling) state='filling';
if(t>=BAND_TOKENS.red) state='red';
// container guard: an independent signal, not a band
const compacting = t > WINDOW - COMPACTION_RESERVE;
return {t,pct,state,compacting};
}
// mirrors borderGaugeLine() in packages/tui/src/border-gauge.ts — gauge mode 'border'
function borderGaugeLine(width){
const {t,pct,state,compacting}=totals();
const label = fmt(t)+' / '+fmt(WINDOW)+' \u00b7 '+pct.toFixed(1)+'% '+state+(compacting?' \u00b7 pi compacts soon':'');
const head = '\u2500 '+label+' ';
const runway = width - head.length;
if(runway < 10) return '\u2500'.repeat(width); // too narrow for both: bar alone
const fill = Math.round(pct/100*runway);
return head + '\u2500'.repeat(fill) + '\u00b7'.repeat(runway-fill);
}
// mirrors bandStartPercents(): band boundaries drawn as a share of THIS window
function bandStarts(){
return [BAND_TOKENS.healthy,BAND_TOKENS.filling,BAND_TOKENS.red].map(v=>v/WINDOW*100);
}
const stateWord = {low:'low',healthy:'healthy',filling:'filling',red:'red'};
const stateColor = {low:'#7ec96a',healthy:'#5af78e',filling:'#ff9e3b',red:'#f4647d'};
function gauge(){
const {t,pct,state}=totals();
const ticks=bandStarts().map((p,i)=>'<i class="gtick'+(i<2?' major':'')+'" style="left:'+p+'%"></i>').join('');
return '<div class="gaugeline"><span class="glabel">CONTEXT</span>'+
'<div class="gtrack"><div class="gfill"></div><div class="gmask" style="width:'+(100-Math.min(pct,100))+'%"></div>'+ticks+'</div>'+
'<span class="glabel">~'+fmt(t)+' / '+fmt(WINDOW)+' · <b class="st-'+state+'">'+pct.toFixed(1)+'% '+stateWord[state]+'</b></span>'+
'<span class="glabel" style="margin-left:auto;color:var(--faint)">bands 8k · 32k · 64k tokens (F5.2)</span></div>';
}
function phead(crumb,spec){
const br = S.branch ? '⎇ '+S.branch : 'trunk';
return '<div class="phead"><span class="brand">pi-context-tree</span><span class="crumb">'+crumb+'</span>'+
'<span class="chip">'+PROJECT+'</span><span class="chip" style="color:'+(S.branch?'hsl('+hashHue(S.branch)+',60%,65%)':'var(--blue)')+'">'+br+'</span>'+
'<span class="chip model">'+S.model+'</span><span class="spec">'+spec+'</span></div>'+gauge();
}
function pfoot(items){
return '<div class="pfoot">'+items.map(i=>'<span><b>'+i[0]+'</b> <span>'+i[1]+'</span></span>').join('')+'</div>';
}
function setTab(){
const name = S.branch ? PROJECT+' ('+S.branch+') (pi)' : PROJECT+' (pi)';
const hue = hashHue(S.branch||PROJECT);
const p=$('#tabpill'); p.textContent=name;
p.style.background='hsl('+hue+',42%,34%)'; p.style.borderColor='hsl('+hue+',65%,55%)';
}
let toastT=null;
function toast(msg,err){
document.querySelectorAll('.toast').forEach(t=>t.remove());
const d=document.createElement('div'); d.className='toast'+(err?' err':''); d.textContent=msg;
$('#screen').appendChild(d);
clearTimeout(toastT); toastT=setTimeout(()=>d.remove(),4200);
}
/* ================= tree view ================= */
function treeRows(){
const rows=[];
for(const e of spine){
if(e.fork){
rows.push({kind:'fork',e});
if(e.id==='f-fix'){
if(!S.folds['f-fix']){ branchEntries.forEach((b,i)=>rows.push({kind:'kid',e:b,closed:S.merged,last:i===branchEntries.length-1})); }
if(S.merged && S.mergeMode!=='discard') rows.push({kind:'entry',e:{id:'e08',g:'◆',cls:'dec',label:'Decision: fix-flaky-test — tmpdir collision fixed',tok:380,type:'ctree/decision'}});
} else if(!S.folds[e.id] && e.kids){
e.kids.forEach((k,i)=>rows.push({kind:'kidinfo',e:k,last:i===e.kids.length-1}));
}
} else rows.push({kind:'entry',e});
}
return rows;
}
function forkRow(e,i){
let st=e.status, extra='';
if(e.id==='f-fix'){
if(S.merged) st = S.mergeMode==='discard' ? 'rejected' : 'squashed';
else extra = ' <span class="leafmark">← you are here</span>';
}
const sub = e.id==='f-fix' && S.merged
? (S.mergeMode==='discard'?'discarded just now · note: "'+(S.note||'dead end')+'"':'squashed just now → decision record below')
: e.sub;
const fold = (e.kids||e.id==='f-fix') ? '<span class="fold">'+(S.folds[e.id]?'[+]':'[-]')+'</span>' : '';
return '<div class="row'+(i===S.sel?' sel':'')+'" data-i="'+i+'"><span class="g b-'+st+'">⎇</span>'+
'<span class="lbl"><span class="b-'+st+'">'+e.name+'</span> <span class="guide">·</span> <span style="color:var(--dim)">'+sub+'</span>'+extra+'</span>'+
'<span class="tok">'+fold+'</span></div>';
}
function entryRow(e,i,kid,last,closed){
const stub = e.cropped;
const glyph = stub?'✂':e.g;
const label = stub?'[cropped: '+e.tool+' '+e.arg+', ~'+fmt(e.tok)+', '+e.sha+']':e.label;
const guide = kid ? '<span class="guide">'+(last?' └ ':' ├ ')+'</span>' : '';
const leaf = e.leaf&&!S.merged?' <span class="leafmark">◀ current leaf</span>':'';
const warn = e.tok>=10000&&!stub?' <span class="warnflag">⚠</span>':'';
const cls = stub?'stub':e.cls;
return '<div class="row'+(i===S.sel?' sel':'')+(closed?' dimrow':'')+'" data-i="'+i+'">'+
'<span class="g '+cls+'">'+glyph+'</span><span class="lbl">'+guide+'<span class="'+cls+'">'+esc(label)+'</span>'+leaf+warn+'</span>'+
'<span class="tok '+tokCls(eff(e))+'">'+fmt(eff(e))+'</span></div>';
}
function renderTree(){
const rows=treeRows();
if(S.sel>=rows.length) S.sel=rows.length-1;
let html=phead('tree','F4.3 F4.4 F4.5');
html+='<div class="body"><div class="secthead">SESSION 2026-06-12-a · TRUNK + BRANCHES · est tokens (~chars/4)</div>';
rows.forEach((r,i)=>{
if(r.kind==='fork') html+=forkRow(r.e,i);
else if(r.kind==='kid') html+=entryRow(r.e,i,true,r.last,r.closed);
else if(r.kind==='kidinfo') html+='<div class="row dimrow'+(i===S.sel?' sel':'')+'" data-i="'+i+'"><span class="g"></span><span class="lbl"><span class="guide">'+(r.last?' └ ':' ├ ')+'</span><span class="'+r.e.cls+'">'+r.e.g+' '+esc(r.e.label)+'</span></span><span class="tok"></span></div>';
else html+=entryRow(r.e,i);
});
html+='</div>';
html+=pfoot([['↑↓/jk','move'],['⏎','jump / fold'],['b','branch'],['m','merge'],['c','crop'],['i','inspect'],['D','decisions'],['u','consumers'],['f','forest'],['q','close']]);
return html;
}
/* ================= crop view ================= */
function cropList(){ return ctxEntries().filter(e=>e.tool); }
function renderCrop(){
const list=cropList();
if(S.csel>=list.length) S.csel=list.length-1;
const reclaim=[...S.marks].reduce((s,id)=>{const e=list.find(x=>x.id===id);return s+(e&&!e.cropped?e.tok:0);},0);
let html=phead('crop','F3.1 F3.3');
html+='<div class="body"><div class="secthead">CROP — TOOL / MCP RESULTS ON THIS BRANCH · '+
'<span class="reclaim">reclaim ~'+fmt(reclaim)+' tokens</span> · stubs land on a new branch point, originals untouched</div>';
list.forEach((e,i)=>{
const marked=S.marks.has(e.id);
const prot=e.latest&&!marked;
const mk = e.cropped?'<span class="mark" style="color:var(--faint)">✓</span>'
: marked?'<span class="mark x">✗</span>'
: prot?'<span class="mark prot">⊘</span>':'<span class="mark">·</span>';
const protNote = e.cropped?' <span style="color:var(--faint)">(already cropped)</span>'
: e.latest?' <span style="color:var(--faint)">(latest '+e.tool+' — protected)</span>':'';
html+='<div class="row'+(i===S.csel?' csel':'')+(e.cropped?' dimrow':'')+'" data-c="'+i+'">'+mk+
'<span class="g t">'+(e.cropped?'✂':'⚙')+'</span><span class="lbl"><span class="t">'+esc(e.label)+'</span>'+protNote+'</span>'+
'<span class="agecol">'+e.age+' turns</span><span class="tok '+tokCls(e.tok)+'">'+fmt(e.tok)+'</span></div>';
});
if(S.armed){ const e=list.find(x=>x.id===S.armed);
html+='<div class="armed">⚠ '+esc(e.label)+' is the latest result of '+e.tool+' — press space again to crop it anyway (F3.3)</div>'; }
html+='</div>';
html+=pfoot([['space','mark / unmark'],['a','--auto (≥10k, >2 turns, skip protected)'],['⏎','apply → new branch point'],['esc','cancel']]);
return html;
}
function applyCrop(){
const list=cropList(); let n=0,sum=0;
for(const id of S.marks){ const e=list.find(x=>x.id===id);
if(e&&!e.cropped){ e.cropped=true; n++; sum+=e.tok; } }
if(!n){ toast('nothing marked — space to mark entries',true); return; }
S.marks.clear(); S.armed=null; S.cropped=true; S.view='tree';
toast('✓ cropped '+n+' entries → stubs · ~'+fmt(sum)+' reclaimed · ctree/crop appended, originals recoverable');
}
/* ================= merge flow ================= */
const mergeOpts=[
{k:'squash', label:'squash', desc:'haiku-4.5 drafts a decision record → you confirm/edit → lands at the label'},
{k:'nollm', label:'squash --no-llm', desc:'write the decision record yourself'},
{k:'discard', label:'discard', desc:'return to label, inject nothing, mark branch rejected'},
{k:'tournament', label:'tournament', desc:'one combined node: winner record + sibling epitaphs', disabled:'no open siblings share this label'}
];
function renderMerge(){
let html=renderTree();
let opts='';
mergeOpts.forEach((o,i)=>{
opts+='<div class="opt'+(i===S.msel?' cur':'')+(o.disabled?' disabled':'')+'" data-m="'+i+'">'+
'<span class="arrow">'+(i===S.msel?'→':'')+'</span><span>'+o.label+'</span>'+
'<span class="odesc">— '+(o.disabled?o.disabled:o.desc)+'</span></div>';
});
html+='<div class="veil"><div class="selbox"><h3>Merge branch 'fix-flaky-test' → trunk @ label?</h3>'+opts+
'<div class="selfoot">trunk model <b>opus-4.8</b> restored on merge · branch history kept, append-only (G4) · '+
'<b>↑↓</b> navigate · <b>enter</b> select · <b>esc</b> cancel · <span style="color:var(--faint)">F2 — suppresses pi's summarize-on-leave (F2.5)</span></div></div></div>';
return html;
}
function renderRecord(){
let html=phead('merge · squash · review','F2.2 §6');
html+='<div class="body"><div class="secthead">DECISION RECORD — drafted by '+(S.mergeMode==='nollm'?'you (--no-llm)':'haiku-4.5')+
' · NOTHING LANDS ON TRUNK UNTIL YOU ACCEPT</div>'+
'<div style="padding:0 8px"><textarea class="record" id="rec" spellcheck="false">'+esc(S.recordText)+'</textarea></div>'+
'<div class="rnote" style="padding-left:8px">r = redraft with free-text instructions (mocked) · the record becomes one ◆ node at ⎇ fix-flaky-test; 30 noisy turns stay on the branch</div></div>';
html+=pfoot([['e','edit'],['⏎','accept & merge'],['r','redraft with instructions'],['esc','back']]);
return html;
}
function renderDiscard(){
let html=renderTree();
html+='<div class="veil"><div class="selbox"><h3>Discard branch 'fix-flaky-test'</h3>'+
'<div style="color:var(--dim);margin-bottom:8px">returns to the label, injects nothing; branch marked rejected (history kept). optional one-line note:</div>'+
'<input type="text" id="dnote" placeholder="dead end — tmpdir theory was wrong" value="'+esc(S.note)+'">'+
'<div class="selfoot"><b>enter</b> confirm discard · <b>esc</b> back</div></div></div>';
return html;
}
function applyMerge(mode){
S.merged=true; S.mergeMode=mode==='nollm'?'squash':mode;
S.branch=null; S.model='opus-4.8'; S.folds['f-fix']=true; S.view='tree'; S.sel=9;
if(mode==='discard') toast('✓ discarded fix-flaky-test · back at label on opus-4.8 · nothing injected, history kept');
else toast('✓ squashed fix-flaky-test → 1 decision record on trunk · model restored to opus-4.8');
setTab();
}
/* ================= decisions view ================= */
function renderDecisions(){
let html=phead('decisions','F7');
html+='<div class="body"><div class="secthead">DECISION RECORDS ON TRUNK (newest first)</div>';
const recs=[...decisionRecords];
if(S.merged&&S.mergeMode!=='discard') recs.unshift({name:'fix-flaky-test',date:'2026-06-12',model:'haiku-4.5',branch:'a3f2c1',
outcome:'CI flake was a tmpdir collision between parallel vitest workers; fixed with per-worker tmpdir suffix. Suite green 5/5.',rejected:[]});
for(const r of recs){
html+='<div class="dcard"><h4>◆ '+r.name+'</h4><div class="meta">'+r.date+' · drafted by '+r.model+' · branch '+r.branch+' · human-confirmed ✓</div>'+
'<div class="out">'+esc(r.outcome)+'</div>';
for(const [n,why] of r.rejected) html+='<div class="rej">✗ '+n+' <i>— '+why+'</i></div>';
html+='</div>';
}
html+='<div class="rnote" style="padding-left:14px">epitaphs keep the trunk model from re-proposing rejected approaches (G3)</div></div>';
html+=pfoot([['↑↓','scroll'],['⏎','jump to record on trunk'],['esc','back to tree']]);
return html;
}
/* ================= consumers view ================= */
function renderConsumers(){
const groups={};
for(const e of ctxEntries()){
let k = e.cropped?'✂ crop stubs': e.tool?(e.tool.includes('.')?'mcp '+e.tool:e.tool):
e.cls==='u'?'user messages':e.cls==='a'?'assistant messages':'decision records';
(groups[k]=groups[k]||{tok:0,n:0}); groups[k].tok+=eff(e); groups[k].n++;
}
const arr=Object.entries(groups).sort((a,b)=>b[1].tok-a[1].tok);
const max=arr[0][1].tok, tot=totals().t;
let html=phead('consumers','F4.2');
html+='<div class="body"><div class="secthead">TOKENS BY SOURCE — CURRENT BRANCH CONTEXT · the usual offenders are large MCP results</div>';
for(const [k,v] of arr){
const pc=(v.tok/tot*100);
html+='<div class="row"><span class="cbar-wrap"><span class="cname">'+k+'</span>'+
'<span class="cbar" style="width:'+Math.max(1,v.tok/max*46)+'ch;background:'+(pc>50?'var(--orange)':pc>10?'var(--yellow)':'var(--faint)')+'"></span></span>'+
'<span class="cmeta">'+fmt(v.tok)+' · '+v.n+(v.n>1?' entries':' entry')+' · '+pc.toFixed(0)+'%</span></div>';
}
html+='<div class="rnote" style="padding-left:14px;margin-top:10px">'+(S.cropped?
'after crop: chrome.snapshot collapsed to stub size — gauge back in band':'chrome.snapshot is 77% of context → press c, crop both snapshots, watch this view')+'</div></div>';
html+=pfoot([['c','crop the big ones'],['esc','back to tree']]);
return html;
}
/* ================= inspector ================= */
function renderInspect(){
const all=[...spine.filter(e=>!e.fork),...branchEntries];
const e=all.find(x=>x.id===S.inspectId)||all[4];
const body=e.cropped?'[cropped: '+e.tool+' '+e.arg+', ~'+fmt(e.tok)+', '+e.sha+']\n\noriginal preserved in JSONL — recoverable, never mutated (G4)':
(bodies[e.body]||esc(e.label)+'\n\n(full entry content — every node is inspectable)');
let html=phead('inspect · '+e.id,'F4.2');
html+='<div class="body"><div class="imeta"><b>'+esc(e.label)+'</b></div>'+
'<div class="imeta">id <b>'+e.id+'</b> · type <b>'+e.type+'</b>'+(e.tool?' · tool <b>'+e.tool+'</b>':'')+
' · est <b>~'+fmt(e.tok)+' tokens</b> ('+ (e.tok*4).toLocaleString() +' chars)'+(e.sha?' · sha '+e.sha:'')+'</div>'+
'<div class="ibody">'+body+'</div></div>';
html+=pfoot([['c','crop this entry'],['esc','back']]);
return html;
}
/* ================= forest ================= */
function renderForest(){
let html=phead('forest','F6 · pitree ui');
const band=totals();
html+='<div class="body"><div class="secthead">~/.pi/agent/sessions · 3 PROJECTS · 7 SESSION TREES · <span class="b-rejected">READ-ONLY (v1)</span></div>'+
'<div class="row sel"><span class="g b-dangling">⚠</span><span class="lbl"><b>tabwrangler</b> <span style="color:var(--dim)">· 4 trees · active 2m ago</span></span><span class="tok"></span></div>'+
'<div class="row"><span class="g"></span><span class="lbl"><span class="guide"> └ </span>2026-06-12-a · <span class="st-'+band.state+'">'+band.pct.toFixed(1)+'% '+stateWord[band.state]+'</span> · 2 decisions · <span class="b-dangling">dangling: perf-spike (4d)</span></span><span class="tok">'+fmt(band.t)+'</span></div>'+
'<div class="row"><span class="g" style="color:var(--green)">●</span><span class="lbl"><b>blog-pipeline</b> <span style="color:var(--dim)">· 2 trees · clean · active 1d ago</span></span><span class="tok"><span class="st-healthy">8.2%</span></span></div>'+
'<div class="row"><span class="g" style="color:var(--green)">●</span><span class="lbl"><b>dotfiles</b> <span style="color:var(--dim)">· 1 tree · clean · active 6d ago</span></span><span class="tok"><span class="st-low">2.1%</span></span></div>'+
'<div class="rnote" style="padding-left:14px;margin-top:10px">dangling = open fork, no close marker — the forest view makes branch hygiene visible (M2 metric: dangling → 0)<br>standalone panel never writes; mutation only inside pi (F4.6). 50MB files stream-parse, headers first.</div></div>';
html+=pfoot([['↑↓','move'],['⏎','open tree (read-only)'],['d','dangling only'],['esc','back'],['','`pitree` prints this as plain text; `pitree ui` opens this panel']]);
return html;
}
/* ================= ambient (prompt bar) ================= */
function renderAmbient(){
const {t,pct,state,compacting}=totals();
const bcol=stateColor[state];
const last = S.merged
? (S.mergeMode==='discard'
? 'Back at the label. The dead-end branch stayed in history; nothing was injected.'
: 'Merged. One decision record landed on the trunk — the 30 noisy branch turns stayed behind on the branch.')
: 'Root cause: shared tmpdir collision between vitest workers. Each worker wrote fixtures to /tmp/tw-fixtures; suffixing with the worker id isolates them. Patch ready in test/setup.ts.';
const promptTxt = S.merged?'':'';
let html='<div class="amb"><div class="scroll">'+
'<div class="amsg"><span class="role">assistant:</span> '+last+'</div>'+
(state==='red'?'<div class="notify">⚠ context entered the red band ('+fmt(t)+' tokens) — consider /branch, /merge or /crop (one-time notify, F5.3)</div>':'')+
(compacting?'<div class="notify">⚠ pi will auto-compact soon, replacing source material with a lossy summary — /merge or /crop keeps the source (F5.5)</div>':'')+
'</div>'+
'<div class="promptbox" style="border-color:'+bcol+'"><span style="color:var(--green)">'+promptTxt+'</span><span class="cursor"></span></div>'+
'<div class="gmode"><div class="gmodelbl">/gauge border \u2014 the same reading, painted into the input border (F5.6)</div>'+
'<pre class="gmodepre" style="color:'+bcol+'">'+borderGaugeLine(62)+'</pre></div>'+
'<div class="pbfoot">~/repos/'+PROJECT+' <span style="color:var(--txt)">●</span> '+PROJECT+(S.branch?' <span style="color:hsl('+hashHue(S.branch)+',60%,65%)">('+S.branch+')</span>':' <span style="color:var(--dim)">(trunk)</span>')+
'<br>↑245 ↓1.2k R1.8M W42k $3.86 <span class="pct" style="background:'+bcol+'">'+pct.toFixed(1)+'%</span>/'+fmt(WINDOW)+' (auto)'+
'<span class="right">claude-'+S.model.replace('.','-')+' <span style="color:var(--txt)">●</span> high</span>'+
'<br>MCP: 1/6 servers <span class="right" style="color:var(--faint)">prompt-border = context health (F5.2) · /panel or ⌃Q reopens the panel · /compact shows a philosophy warning (F5.4)</span></div></div>';
return html;
}
/* ================= render loop ================= */
function render(){
const v=S.view;
$('#screen').innerHTML =
v==='tree'?renderTree(): v==='crop'?renderCrop(): v==='merge'?renderMerge():
v==='record'?renderRecord(): v==='discard'?renderDiscard(): v==='decisions'?renderDecisions():
v==='consumers'?renderConsumers(): v==='inspect'?renderInspect(): v==='forest'?renderForest():
renderAmbient();
document.querySelectorAll('.toolbar button[data-go]').forEach(b=>{
const map={merge:['merge','record','discard']};
const on = b.dataset.go===v || (map[b.dataset.go]||[]).includes(v);
b.classList.toggle('on',on);
});
setTab();
if(v==='discard'){ const n=$('#dnote'); n&&n.focus(); }
}
/* ================= input ================= */
function treeRowCount(){ return treeRows().length; }
function selectedTreeEntry(){
const r=treeRows()[S.sel]; return r&&(r.kind==='entry'||r.kind==='kid')?r.e:null;
}
document.addEventListener('keydown',ev=>{
const k=ev.key, tag=(document.activeElement||{}).tagName;
/* typing contexts */
if(tag==='TEXTAREA'){ if(k==='Escape'){document.activeElement.blur(); ev.preventDefault();} return; }
if(tag==='INPUT'){
if(k==='Escape'){ S.view='merge'; render(); ev.preventDefault(); }
if(k==='Enter'){ S.note=$('#dnote').value; applyMerge('discard'); render(); ev.preventDefault(); }
return;
}
const nav=(d,max,prop)=>{ S[prop]=Math.max(0,Math.min(max-1,S[prop]+d)); render(); };
if(S.view==='tree'){
if(k==='j'||k==='ArrowDown') return nav(1,treeRowCount(),'sel');
if(k==='k'||k==='ArrowUp') return nav(-1,treeRowCount(),'sel');
if(k==='Enter'){ const r=treeRows()[S.sel];
if(r&&r.kind==='fork'){ S.folds[r.e.id]=!S.folds[r.e.id]; render(); }
else if(r){ toast('(mock) leaf moved to '+r.e.id+' — context now ends here; resume or branch from this point'); }
return; }
if(k==='b'){ const e=selectedTreeEntry(); toast('(mock) /branch from '+(e?e.id:'leaf')+' — name + model prompt, fork label appended (F1)'); return; }
if(k==='m'){ if(S.merged){ toast('no open branch at or above the current leaf — /branch first (F2.1)',true); }
else { S.view='merge'; S.msel=0; render(); } return; }
if(k==='c'){ S.view='crop'; S.csel=0; render(); return; }
if(k==='i'){ const e=selectedTreeEntry(); if(e&&!e.fork){S.inspectId=e.id;} S.view='inspect'; render(); return; }
if(k==='D'){ S.view='decisions'; render(); return; }
if(k==='u'){ S.view='consumers'; render(); return; }
if(k==='f'){ S.view='forest'; render(); return; }
if(k==='q'||k==='Escape'){ S.view='ambient'; render(); return; }
}
else if(S.view==='crop'){
const list=cropList();
if(k==='j'||k==='ArrowDown') return nav(1,list.length,'csel');
if(k==='k'||k==='ArrowUp') return nav(-1,list.length,'csel');
if(k===' '){ ev.preventDefault(); const e=list[S.csel]; if(!e||e.cropped) return;
if(S.marks.has(e.id)){ S.marks.delete(e.id); S.armed=null; }
else if(e.latest&&S.armed!==e.id){ S.armed=e.id; }
else { S.marks.add(e.id); S.armed=null; }
render(); return; }
if(k==='a'){ let n=0; for(const e of list) if(!e.latest&&!e.cropped&&e.tok>=10000&&e.age>2){S.marks.add(e.id);n++;}
toast('--auto --min-tokens 10k --older-than 2: marked '+n+' (protected skipped) — review before apply (F3.2)'); render(); return; }
if(k==='Enter'){ applyCrop(); render(); return; }
if(k==='Escape'||k==='c'||k==='q'){ S.marks.clear(); S.armed=null; S.view='tree'; render(); return; }
}
else if(S.view==='merge'){
if(k==='j'||k==='ArrowDown'){ S.msel=Math.min(mergeOpts.length-1,S.msel+1); render(); return; }
if(k==='k'||k==='ArrowUp'){ S.msel=Math.max(0,S.msel-1); render(); return; }
if(k==='s'){ S.msel=0; S.recordText=draftRecord; S.view='record'; render(); return; }
if(k==='d'){ S.view='discard'; render(); return; }
if(k==='t'){ toast('tournament needs ≥2 open sibling branches on the same label (F2.4) — see Decisions for a past tournament',true); return; }
if(k==='Enter'){ const o=mergeOpts[S.msel];
if(o.disabled){ toast('tournament needs ≥2 open sibling branches on the same label (F2.4)',true); return; }
if(o.k==='discard'){ S.view='discard'; }
else { S.recordText=o.k==='nollm'?emptyRecord:draftRecord; S.mergeMode=o.k; S.view='record'; }
render(); return; }
if(k==='Escape'||k==='q'){ S.view='tree'; render(); return; }
}
else if(S.view==='record'){
if(k==='e'){ const r=$('#rec'); r&&r.focus(); ev.preventDefault(); return; }
if(k==='r'){ toast('(mock) redraft: free-text instructions sent to haiku-4.5, new draft replaces this one (F2.2)'); return; }
if(k==='Enter'){ S.recordText=$('#rec').value; applyMerge(S.mergeMode||'squash'); render(); return; }
if(k==='Escape'){ S.view='merge'; render(); return; }
}
else if(['decisions','consumers','inspect','forest'].includes(S.view)){
if(S.view==='consumers'&&k==='c'){ S.view='crop'; render(); return; }
if(S.view==='decisions'&&k==='Enter'){ toast('(mock) jumped to the ◆ node on trunk'); return; }
if(S.view==='forest'&&k==='d'){ toast('(mock) filter: dangling only — 1 branch across the forest'); return; }
if(S.view==='inspect'&&k==='c'){ const all=[...spine.filter(e=>!e.fork),...branchEntries];
const e=all.find(x=>x.id===S.inspectId);
if(e&&e.tool&&!e.cropped){ S.marks.add(e.id); S.armed=null; S.view='crop';
S.csel=cropList().findIndex(x=>x.id===e.id); toast('pre-marked from inspector — review and ⏎ to apply'); }
else { toast('only tool/MCP results are croppable (F3.3)',true); return; }
render(); return; }
if(k==='Escape'||k==='q'||k==='t'){ S.view='tree'; render(); return; }
}
else if(S.view==='ambient'){
if(k==='t'||k==='q'||k==='p'){ S.view='tree'; render(); return; }
}
});
document.addEventListener('click',ev=>{
const go=ev.target.closest('[data-go]'); if(go){ S.view=go.dataset.go; if(go.dataset.go==='merge'&&S.merged){S.view='tree';toast('no open branch — already merged (F2.1). ⟲ reset to replay',true);} render(); return; }
const ri=ev.target.closest('[data-i]'); if(ri){ S.sel=+ri.dataset.i; render(); return; }
const rc=ev.target.closest('[data-c]'); if(rc){ S.csel=+rc.dataset.c; render(); return; }
const rm=ev.target.closest('[data-m]'); if(rm){ S.msel=+rm.dataset.m; render(); }
});
render();
</script>
</body>
</html>