samagotchi 0.61.0 → 0.63.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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +91 -1
  3. data/README.md +4 -1
  4. data/bin/chi +3 -2
  5. data/docs/architecture.md +3 -0
  6. data/docs/cli.md +26 -9
  7. data/docs/configuration.md +33 -20
  8. data/docs/context.md +21 -3
  9. data/docs/guardrails.md +2 -1
  10. data/docs/hooks.md +14 -3
  11. data/docs/internals/engine.md +24 -11
  12. data/docs/internals/prompt-caching.md +6 -1
  13. data/docs/memory.md +1 -1
  14. data/docs/plugins.md +131 -5
  15. data/docs/releasing.md +1 -1
  16. data/docs/sessions.md +4 -4
  17. data/docs/sub-agent.md +6 -0
  18. data/docs/testing.md +10 -4
  19. data/lib/samagotchi/answer_refs.rb +18 -26
  20. data/lib/samagotchi/bridge.rb +23 -1
  21. data/lib/samagotchi/bundles/claude-peer/manifest.yml +22 -0
  22. data/lib/samagotchi/bundles/claude-peer/plugin.rb +406 -0
  23. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +104 -0
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +66 -0
  25. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +60 -0
  26. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  27. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
  29. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
  31. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
  33. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
  34. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
  35. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  36. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  37. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  38. data/lib/samagotchi/context_absorber.rb +25 -6
  39. data/lib/samagotchi/engine.rb +9 -0
  40. data/lib/samagotchi/engine_peers.rb +1 -0
  41. data/lib/samagotchi/idle_recap.rb +2 -3
  42. data/lib/samagotchi/image_store.rb +8 -0
  43. data/lib/samagotchi/kernel_loop.rb +7 -16
  44. data/lib/samagotchi/llm/chat_loop.rb +23 -15
  45. data/lib/samagotchi/plugin/api.rb +36 -18
  46. data/lib/samagotchi/plugin/attached_context.rb +7 -4
  47. data/lib/samagotchi/reasoning_history.rb +22 -4
  48. data/lib/samagotchi/recap_setup.rb +3 -3
  49. data/lib/samagotchi/row_refs.rb +125 -0
  50. data/lib/samagotchi/session.rb +12 -0
  51. data/lib/samagotchi/session_metrics.rb +24 -2
  52. data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
  53. data/lib/samagotchi/terminal_ui/attached_loop.rb +26 -9
  54. data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
  55. data/lib/samagotchi/terminal_ui.rb +57 -14
  56. data/lib/samagotchi/tool_activity.rb +8 -4
  57. data/lib/samagotchi/tool_response.rb +5 -4
  58. data/lib/samagotchi/tool_row_fields.rb +4 -2
  59. data/lib/samagotchi/tool_runner.rb +16 -3
  60. data/lib/samagotchi/tools/builtins.rb +9 -2
  61. data/lib/samagotchi/tools/execute.rb +37 -7
  62. data/lib/samagotchi/tools/peers.rb +3 -2
  63. data/lib/samagotchi/tools/registry.rb +5 -3
  64. data/lib/samagotchi/version.rb +1 -1
  65. data/lib/samagotchi/web/app.rb +20 -5
  66. data/lib/samagotchi/web/message_parts.rb +17 -9
  67. data/lib/samagotchi/web/public/activity.js +6 -3
  68. data/lib/samagotchi/web/public/app.js +106 -106
  69. data/lib/samagotchi/web/public/card.js +73 -23
  70. data/lib/samagotchi/web/public/chains.js +24 -24
  71. data/lib/samagotchi/web/public/command_view.js +39 -35
  72. data/lib/samagotchi/web/public/context_chips.js +92 -64
  73. data/lib/samagotchi/web/public/copy.js +7 -15
  74. data/lib/samagotchi/web/public/diff_view.js +20 -23
  75. data/lib/samagotchi/web/public/families_view.js +51 -39
  76. data/lib/samagotchi/web/public/format.js +0 -48
  77. data/lib/samagotchi/web/public/images.js +12 -16
  78. data/lib/samagotchi/web/public/index.html +61 -10
  79. data/lib/samagotchi/web/public/keep_focus.js +24 -0
  80. data/lib/samagotchi/web/public/lists_view.js +13 -13
  81. data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
  82. data/lib/samagotchi/web/public/question_card.js +11 -0
  83. data/lib/samagotchi/web/public/question_cards.js +63 -7
  84. data/lib/samagotchi/web/public/read_view.js +10 -9
  85. data/lib/samagotchi/web/public/ref_menu.js +18 -5
  86. data/lib/samagotchi/web/public/refs.js +113 -15
  87. data/lib/samagotchi/web/public/scope.js +23 -0
  88. data/lib/samagotchi/web/public/scroll.js +42 -0
  89. data/lib/samagotchi/web/public/stage_model.js +22 -3
  90. data/lib/samagotchi/web/public/stage_view.js +299 -70
  91. data/lib/samagotchi/web/public/timing.js +1 -8
  92. data/lib/samagotchi/web/public/turn_html.js +122 -61
  93. data/lib/samagotchi/web/public/turn_model.js +49 -0
  94. data/lib/samagotchi/web/public/turn_view.js +167 -116
  95. data/lib/samagotchi/web/public/vendor/directive.js +8 -0
  96. data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
  97. data/lib/samagotchi/worker.rb +49 -32
  98. data/lib/samagotchi/worker_inbound.rb +17 -14
  99. metadata +10 -1
@@ -1,8 +1,8 @@
1
1
  // Images in the web UI: pasted or dropped into the composer as chips, sent
2
2
  // with a turn as refs (uploaded first), shown as thumbnails in the bubbles
3
3
  // and tool rows, opened full size in a lightbox. No DOM here beyond what the
4
- // callers pass, so it runs under `node --test`.
5
- import { escapeHtml } from "./format.js";
4
+ // callers pass (the templates are lit-html's), so it runs under `node --test`.
5
+ import { html, nothing } from "./vendor/lit-html.js";
6
6
 
7
7
  // The image files a paste's or drop's DataTransfer carries.
8
8
  export function imageFiles(dataTransfer) {
@@ -40,29 +40,25 @@ export function imageUrl(sessionId, ref) {
40
40
  .join("/")}`;
41
41
  }
42
42
 
43
- // Thumbnails for a bubble or a tool row; "" for none. `src` may be given
44
- // (a blob: URL while the upload is on its way).
45
- export function thumbsHtml(sessionId, images) {
43
+ // Thumbnails for a bubble or a tool row; nothing for none. `src` may be
44
+ // given (a blob: URL while the upload is on its way). A redraw with the
45
+ // same images keeps the <img> elements (no reload).
46
+ export function thumbsTemplate(sessionId, images) {
46
47
  const list = (images || []).filter((i) => i && (i.src || i.file));
47
- if (!list.length) return "";
48
+ if (!list.length) return nothing;
48
49
  const thumbs = list.map((img) => {
49
50
  const src = img.src || imageUrl(sessionId, img);
50
51
  const size = img.width && img.height ? ` ${img.width}×${img.height}` : "";
51
52
  const title = `${img.name || "image"}${size}`;
52
- return `<img class="thumb" src="${escapeHtml(src)}" alt="${escapeHtml(title)}" title="${escapeHtml(title)}" loading="lazy">`;
53
+ return html`<img class="thumb" src=${src} alt=${title} title=${title} loading=${"lazy"}>`;
53
54
  });
54
- return `<div class="thumbs">${thumbs.join("")}</div>`;
55
+ return html`<div class="thumbs">${thumbs}</div>`;
55
56
  }
56
57
 
57
58
  // The composer's chips: a thumbnail, the name and a remove button each.
58
- export function chipsHtml(chips) {
59
- return (chips || [])
60
- .map(
61
- (c, i) =>
62
- `<span class="chip" data-index="${i}"><img src="${escapeHtml(c.src)}" alt=""><span class="chip-name">${escapeHtml(c.name)}</span>` +
63
- `<button type="button" class="chip-remove" data-index="${i}" aria-label="remove ${escapeHtml(c.name)}">×</button></span>`,
64
- )
65
- .join("");
59
+ export function chipsTemplate(chips) {
60
+ return (chips || []).map((c, i) =>
61
+ html`<span class="chip" data-index=${i}><img src=${c.src} alt=""><span class="chip-name">${c.name}</span><button type="button" class="chip-remove" data-index=${i} aria-label=${`remove ${c.name}`}>×</button></span>`);
66
62
  }
67
63
 
68
64
  // The {file, name} refs a turn names.
@@ -283,6 +283,15 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
283
283
  .gen details.thinking summary.thinking-fade{animation:thinking-fade 120ms ease-out}
284
284
  @keyframes thinking-fade{from{opacity:.4}to{opacity:1}}
285
285
  @media (prefers-reduced-motion:reduce){.gen details.thinking summary.thinking-fade{animation:none}}
286
+ /* A finished block's outline (turn_view.js outlineBlock): open on its narrated steps' rows; a run of steps that said
287
+ nothing is one "· N steps" fold, a row like a step's, that shows them. */
288
+ .turn-work.outline{--step-indent:14px}
289
+ .turn-work.outline>.gen.folded:not(.unfolded){display:none}
290
+ .outline-fold{margin-top:8px;padding-top:6px;padding-left:var(--step-indent);border-top:1px solid var(--border);font-size:12px;font-weight:500;color:var(--text-faint);cursor:pointer;user-select:none;border-radius:4px}
291
+ .outline-fold::before{content:"· "}
292
+ .outline-fold.open::before{content:"▾ "}
293
+ .outline-fold:hover{color:var(--text-muted)}
294
+ .turn-work.outline>.gen.folded.unfolded{margin-left:var(--step-indent)}
286
295
  .gen-text{margin-top:6px;white-space:pre-wrap;word-break:break-word;font-size:15px;line-height:1.55}
287
296
  /* The live step's narration box: the complete sentences so far, one per line, three visual lines high (3 × the 1.55 line-height), scrolled to the newest; each new line fades in. */
288
297
  .gen-text.streaming>.narration{max-height:4.65em;overflow:auto;scrollbar-width:thin;white-space:normal}
@@ -310,6 +319,12 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
310
319
  .bubble.question>summary::-webkit-details-marker{display:none}
311
320
  .bubble.question>summary::before{content:"▸ ";color:var(--warn)}
312
321
  .bubble.question[open]>summary::before{content:"▾ "}
322
+ /* An answered card keeps the reply that asked it (question_cards.js): muted under its "Q → A" line, two lines while
323
+ closed (newlines as spaces); open, the summary's copy goes and the body's starts the card, whole, as written and
324
+ selectable. */
325
+ .bubble.question>summary .question-said{max-width:72ch;margin:2px 0 1px 1.05em;font-size:12.5px;font-weight:400;line-height:1.5;color:var(--text-muted);white-space:normal;overflow-wrap:anywhere;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden}
326
+ .bubble.question[open]>summary .question-said{display:none}
327
+ .question-said-full{margin:4px 0 6px;font-size:13px;line-height:1.55;color:var(--text-muted);white-space:pre-wrap;overflow-wrap:anywhere}
313
328
  .bubble.command{border-left:3px solid var(--text-faint);background:var(--bg-panel);font-size:13px}
314
329
  .bubble.note{border-left:3px dotted var(--text-faint);background:transparent;font-size:12px;color:var(--text-muted);opacity:.85}
315
330
  .note-line{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-faint);margin-bottom:2px}
@@ -336,7 +351,8 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
336
351
  .bubble.question.answered{border-left-color:var(--ok)}
337
352
  .bubble.question.cancelled{border-left-color:var(--text-muted);opacity:.75}
338
353
  /* A settled card stops floating: the highlight stays, the drop shadow goes. */
339
- .bubble.question.answered,.bubble.question.cancelled{box-shadow:inset 0 1px 0 var(--glass-highlight)}
354
+ /* Closed, it is a bare line (no card to light): wider with its kept reply, a highlight would read as a stray rule. */
355
+ .bubble.question.answered[open],.bubble.question.cancelled[open]{box-shadow:inset 0 1px 0 var(--glass-highlight)}
340
356
  .question-text{font-size:14px;color:var(--text);margin-bottom:8px;white-space:pre-wrap}
341
357
  .question-options{display:flex;flex-direction:column;gap:4px;margin-bottom:8px}
342
358
  .question-option{display:flex;gap:8px;align-items:center;font-size:13px;color:var(--text-2);padding:6px 8px;border:1px solid var(--border);border-radius:8px;cursor:pointer}
@@ -394,6 +410,8 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
394
410
  .ref-more[hidden]{display:none}
395
411
  /* A ref makes room after itself for its ▾ while it shows (ref_menu.js), and keeps its hover look (its ↗ too) until the ▾ goes: the ▾ never covers what follows it. */
396
412
  .ref[data-ref-more]{padding-right:19px}
413
+ /* …unless the room would push it onto the next line: it ends its line, and the ▾ goes past its end. */
414
+ .ref[data-ref-more="end"]{padding-right:0}
397
415
  .ref-more:hover{color:var(--accent);border-color:var(--accent)}
398
416
  .ref-menu{position:fixed;z-index:61;min-width:160px;max-width:calc(100vw - 16px);padding:4px;border:1px solid var(--border-strong);border-radius:8px;background:var(--bg-panel);box-shadow:var(--toast-shadow);animation:fadeIn .12s ease-out}
399
417
  .ref-menu [role="menuitem"]{display:block;width:100%;padding:6px 10px;border:0;border-radius:5px;background:none;color:var(--text);font:inherit;font-size:13px;text-align:left;cursor:pointer;white-space:nowrap}
@@ -594,7 +612,13 @@ body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
594
612
  body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;border:0;box-shadow:none;-webkit-backdrop-filter:none;backdrop-filter:none;gap:10px}
595
613
  .dock-card{display:flex;flex-direction:column;border-radius:16px;position:relative;transition:border-color .12s ease,box-shadow .12s ease}
596
614
  .dock-card:focus-within{border-color:color-mix(in srgb,var(--accent) 70%,transparent);box-shadow:0 0 0 3px var(--accent-ring),var(--glass-shadow)}
615
+ /* The live slots' heights (headline, tool row, trail); --ts-live-h is their running height with the two 8 px gaps. */
616
+ .turn-stage{--ts-head-h:42px;--ts-now-h:32px;--ts-trail-h:18px;--ts-live-h:calc(var(--ts-head-h) + var(--ts-now-h) + var(--ts-trail-h) + 16px)}
597
617
  .turn-stage{display:flex;flex-direction:column;min-height:0;max-height:var(--stage-max,60vh);border-left:3px solid var(--accent);border-radius:14px;overflow:hidden}
618
+ /* Out of the dock's flow: it floats 10 px above the composer card, so its rise, growth and leaving never move #dock (a
619
+ bottom-pinned box changing height is a layout shift, animated or not). Painted before .dock-card (no z-index): the command
620
+ list (z-index 20 inside .dock-card) opens over its lower edge. The history's bottom reserve counts it (app.js, --dock-h). */
621
+ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-bottom:10px}
598
622
  .turn-stage[hidden]{display:none}
599
623
  .turn-stage.ended{border-left-color:var(--border-strong)}
600
624
  .turn-stage[data-phase="answered"]{border-left-color:var(--ok)}
@@ -603,7 +627,10 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
603
627
  .turn-stage[data-phase="no answer"]{border-left-color:var(--warn)}
604
628
  .turn-stage.enter{animation:ts-rise .35s cubic-bezier(.3,.7,.2,1)}
605
629
  @keyframes ts-rise{from{opacity:0;transform:translateY(14px)}}
606
- .turn-stage.leaving{transition:height .4s cubic-bezier(.3,.7,.2,1),opacity .3s ease;opacity:0}
630
+ /* The hand-off: the stage slides away under the composer still drawn (a transform: nothing moves; stage_view.js handOff),
631
+ then the turn arrives in the history. Meanwhile the history's bottom reserve eases down (app.js). */
632
+ .turn-stage.leaving{transition:transform .42s cubic-bezier(.4,0,.6,1),opacity .42s ease-in;transform:translateY(40px);opacity:0;pointer-events:none}
633
+ #center.reserve-easing #history{transition:padding-bottom .42s cubic-bezier(.4,0,.6,1)}
607
634
  .ts-arrive{animation:ts-arrive .5s cubic-bezier(.3,.7,.2,1)}
608
635
  @keyframes ts-arrive{from{opacity:0;transform:translateY(60px)}}
609
636
  .ts-status{flex:none;display:flex;align-items:center;gap:8px;min-width:0;padding:8px 8px 0 14px;font-size:12px;color:var(--text-muted);font-variant-numeric:tabular-nums;white-space:nowrap}
@@ -640,20 +667,24 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
640
667
  .ts-prompt:not(.full) .user-message blockquote{display:inline;border:0;padding:0;margin:0}
641
668
  /* The live slots: fixed heights (two headline lines, one tool row, one trail line). */
642
669
  .ts-live{display:flex;flex-direction:column;gap:8px}
643
- .ts-headline{height:42px;font-size:14px;line-height:21px;color:var(--narr);overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;cursor:pointer;border-radius:6px}
670
+ .ts-headline{height:var(--ts-head-h);font-size:14px;line-height:21px;color:var(--narr);overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;cursor:pointer;border-radius:6px}
644
671
  .ts-headline:hover{background:var(--bg-hover)}
645
672
  .ts-headline:not(.thinking)::before{content:"\203A";color:var(--accent);font-weight:700;margin-right:7px}
646
673
  .ts-headline.thinking{font-size:13.5px;font-style:italic;color:var(--text-muted)}
647
674
  .ts-think-tag{font-style:normal;font-size:12.5px;color:var(--think-text);margin-right:6px}
648
675
  .ts-headline.fresh{animation:ts-fade .3s ease}
676
+ /* While a question or approval waits: the whole reply that asked it, raw as a closed step's text, in the headline's
677
+ place; capped so the card keeps its room, read from the top (stage_view.js drawAsking). */
678
+ .ts-asking{max-height:calc(var(--stage-max,60vh) * .35);overflow:auto;scrollbar-width:thin;white-space:pre-wrap;overflow-wrap:anywhere;font-size:14px;line-height:1.55;color:var(--narr);border-left:2px solid var(--border-strong);padding-left:10px}
679
+ .turn-stage.asking .ts-headline{display:none}
649
680
  @keyframes ts-fade{from{opacity:.35}}
650
- .ts-now{height:32px;display:flex;align-items:center;gap:8px;min-width:0;padding:0 9px;border-radius:8px;background:var(--accent-tint);font-size:13px}
681
+ .ts-now{height:var(--ts-now-h);display:flex;align-items:center;gap:8px;min-width:0;padding:0 9px;border-radius:8px;background:var(--accent-tint);font-size:13px}
651
682
  .ts-spin{flex:none;width:11px;height:11px;border:2px solid var(--warn);border-right-color:transparent;border-radius:50%;animation:init-spin .8s linear infinite}
652
683
  .ts-idle{flex:none;width:11px;height:11px;border:2px solid var(--border-strong);border-radius:50%}
653
684
  .ts-tool{flex:none;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;font-weight:600;color:var(--accent)}
654
685
  .ts-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--text-2)}
655
686
  .ts-title.quiet{font-family:inherit;font-style:italic;color:var(--text-faint)}
656
- .ts-trail{height:18px;display:flex;gap:10px;overflow:hidden;font-size:12px;line-height:18px;color:var(--text-faint);white-space:nowrap}
687
+ .ts-trail{height:var(--ts-trail-h);display:flex;gap:10px;overflow:hidden;font-size:12px;line-height:18px;color:var(--text-faint);white-space:nowrap}
657
688
  .ts-trail-item{flex:none;max-width:36ch;overflow:hidden;text-overflow:ellipsis}
658
689
  .ts-trail-item:is(.error,.blocked){color:var(--danger-text)}
659
690
  .ts-trail-item.warn{color:var(--warn-text)}
@@ -664,7 +695,14 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
664
695
  .turn-stage.waiting .ts-now,.turn-stage.waiting .ts-trail{display:none}
665
696
  /* Rows that arrive during the turn (cards, queued prompts, commands, notes) and after its end (the end line): capped, own scroll. */
666
697
  .ts-extras,.ts-tail{display:flex;flex-direction:column;gap:8px;max-height:calc(var(--stage-max,60vh) * .4);overflow:auto;scrollbar-width:thin}
667
- .turn-stage.waiting .ts-extras{max-height:calc(var(--stage-max,60vh) * .7)}
698
+ .turn-stage.held-wait .ts-extras{max-height:calc(var(--stage-max,60vh) * .7)}
699
+ /* The card under the live slots stays put from its arrival to the next step: waiting, the slots keep at least their running
700
+ height (the asking text in the headline's place, the tool row's and the trail's room left blank); answered, the height
701
+ they had while waiting (stage_view.js). */
702
+ .turn-stage.waiting .ts-live{min-height:var(--ts-live-h)}
703
+ .turn-stage.held-wait:not(.waiting) .ts-live{min-height:max(var(--ts-live-h),var(--live-held,0px))}
704
+ /* A 1 px mark at the tallest content's end, so the scroll extent never drops while a turn runs (stage_view.js fitFrame). */
705
+ .ts-hold{position:absolute;left:0;top:0;width:1px;height:1px;visibility:hidden;pointer-events:none}
668
706
  .ts-extras>*,.ts-tail>*{flex-shrink:0}
669
707
  .ts-extras>.bubble.user{max-width:none;margin:0;padding:4px 10px;font-size:13px;line-height:1.45;background:transparent;color:var(--text-2)}
670
708
  .ts-extras>.bubble.user>.copy-btn{display:none}
@@ -678,12 +716,14 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
678
716
  .ts-answer>.bubble.output{border-left:0;background:transparent;border:0;padding:0}
679
717
  .ts-answer>.bubble.output>.copy-btn{top:0;right:0}
680
718
  /* The cloud: one chip (steps, calls, a tick per call), the turn view's block behind it. */
681
- .ts-cloud{display:flex;flex-direction:column;gap:6px}
719
+ /* The footer: the chip at the stage's bottom edge (the cloud's steps open above it, the scroller's last slot). */
720
+ .ts-foot{flex:none;padding:0 14px 10px}
721
+ .ts-foot[hidden],.turn-stage.collapsed .ts-foot{display:none}
682
722
  .ts-chip{display:flex;align-items:center;gap:10px;flex-wrap:nowrap;width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:9px;background:transparent;color:var(--text-muted);text-align:left}
683
723
  .ts-chip:hover:not(:disabled){filter:none;background:var(--bg-hover)}
684
724
  .ts-chip-label{font-size:12.5px;font-weight:600;white-space:nowrap;font-variant-numeric:tabular-nums}
685
725
  .ts-chip-label::before{content:"\25B8 ";color:var(--accent)}
686
- .ts-cloud.open .ts-chip-label::before{content:"\25BE "}
726
+ .ts-foot.open .ts-chip-label::before{content:"\25BE "}
687
727
  /* One row whatever the count (the stage's height stays): the newest ticks show, the oldest are cut on the left. */
688
728
  .ts-ticks{display:flex;flex-wrap:nowrap;justify-content:flex-end;overflow:hidden;gap:2px;flex:0 1 auto;min-width:0}
689
729
  .ts-tick{flex:none}
@@ -702,14 +742,18 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
702
742
  .ts-cloud-body[hidden]{display:none}
703
743
  .turn-stage .turn-work{border:0;background:transparent;padding:0;border-radius:0}
704
744
  .turn-stage .turn-work>summary{display:none}
745
+ /* The ended turn's cloud shows every step: no folds there. */
746
+ .turn-stage .outline-fold{display:none}
747
+ .turn-stage .turn-work.outline>.gen.folded{display:block}
705
748
  .turn-stage .ts-cloud-body .gen:first-of-type{margin-top:0;border-top:0}
706
749
  @media(max-width:600px){
707
750
  .ts-status{padding-left:10px}
708
751
  .ts-scroll{padding:8px 10px 10px}
752
+ .ts-foot{padding:0 10px 10px}
709
753
  .ts-defer{display:none}
710
754
  .ts-trail-item{max-width:24ch}
711
755
  }
712
- @media (prefers-reduced-motion:reduce){.turn-stage.enter,.ts-arrive,.ts-headline.fresh,.ts-tick.running,.ts-mark,.turn-work:not(.done) .activity-status.running::before{animation:none}.turn-stage.leaving{transition:none}}
756
+ @media (prefers-reduced-motion:reduce){.turn-stage.enter,.ts-arrive,.ts-headline.fresh,.ts-tick.running,.ts-mark,.turn-work:not(.done) .activity-status.running::before{animation:none}.turn-stage.leaving,#center.reserve-easing #history{transition:none}}
713
757
  #infoBar{display:flex;padding:8px 14px 10px;border-top:1px solid var(--glass-border);background:transparent;border-radius:0 0 16px 16px;color:var(--text-2);gap:10px;align-items:flex-start;flex-wrap:wrap;flex-shrink:0;transition:opacity 280ms ease}
714
758
  #infoBar.visible{display:flex}
715
759
  #infoBar .info{flex:1;min-width:0;font-size:13px;line-height:1.4;word-break:break-word;display:flex;flex-direction:column;gap:2px}
@@ -863,10 +907,17 @@ body.has-session #scope .scope-where{display:none}
863
907
  /* The open session's title, when no strip card in sight shows it (session_title.js): the bar's middle, cut with an ellipsis; the whole first message in its tooltip. */
864
908
  #sessionTitle{flex:1;min-width:0;font-size:13px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
865
909
  #sessionTitle[hidden]{display:none}
866
- /* A phone: the title is its own row under the bar, so "chi + · pill · bell" stay one line. */
910
+ /* A phone: the title is its own row under the bar, so "chi + · pill · bell" stay one line. Nothing on that line wraps to a
911
+ row of its own when the pill's text grows mid-turn ("· 1 waiting"): the scope chip and the pill have a base size of 0 (what
912
+ a wrapping line counts) and grow to their text, the pill first; the scope chip cuts with an ellipsis before the pill does.
913
+ The pill drops its session count while it says live or waiting, so the scope chip keeps its words. */
867
914
  @media(max-width:600px){
868
915
  #topBar{flex-wrap:wrap;row-gap:2px}
869
916
  #sessionTitle{flex-basis:100%;order:10;font-size:12px}
917
+ #scope,#stripShow{flex:1 1 0;min-width:0;max-width:max-content}
918
+ #stripShow{flex-grow:1000;overflow:hidden;text-overflow:ellipsis}
919
+ #scope .scope-name{flex-shrink:1;min-width:0}
920
+ #stripShow:has(.live,.waiting) .pill-count{display:none}
870
921
  }
871
922
  #notifyBtn{margin-left:auto;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;min-height:0;background:transparent;border:1px solid transparent;border-radius:999px;color:var(--text-muted)}
872
923
  #notifyBtn[hidden]{display:none}
@@ -0,0 +1,24 @@
1
+ // lit reuses a list's nodes by position (vendor/lit-html.js has no keyed
2
+ // repeat): after an item is inserted before the focused one, the focused
3
+ // node holds another item. keepFocus draws with +draw+ and puts the focus
4
+ // back on the item it was on, by its +attr+ (data-id, data-name): the same
5
+ // node inside it (by class), the item itself else; blurred when the item is
6
+ // gone, so no other item keeps it. Nothing is done when the focus wasn't
7
+ // inside +root+.
8
+ export function keepFocus(root, attr, draw) {
9
+ const doc = root.ownerDocument;
10
+ const itemOf = (node) => (node && node !== root && root.contains(node) ? node.closest(`[${attr}]`) : null);
11
+ const was = doc.activeElement;
12
+ const item = itemOf(was);
13
+ const key = item && root.contains(item) ? item.getAttribute(attr) : null;
14
+ const inner = item && was !== item ? was.className : null;
15
+ draw();
16
+ if (key === null || itemOf(doc.activeElement)?.getAttribute(attr) === key) return;
17
+ const target = [...root.querySelectorAll(`[${attr}]`)].find((el) => el.getAttribute(attr) === key);
18
+ if (!target) {
19
+ if (root.contains(doc.activeElement)) doc.activeElement.blur?.();
20
+ return;
21
+ }
22
+ const same = inner && [...target.querySelectorAll("*")].find((el) => el.className === inner);
23
+ (same || target).focus();
24
+ }
@@ -24,14 +24,13 @@
24
24
  // setStripHidden(hidden)
25
25
 
26
26
  import { ownerBadge, relativeTime, delegatedBy } from "./format.js";
27
- import { htmlElement } from "./turn_html.js";
28
27
  import { html, nothing, render } from "./vendor/lit-html.js";
29
28
  import { cardCtxText, ctxBarTip } from "./ctx.js";
30
29
  import { allScopeHref, cardFolder } from "./scope.js";
31
30
  import { sessionHash } from "./route.js";
32
31
  import { stripColumns, stripShowParts } from "./strip.js";
33
- import { createFamilyPop, familyChipHtml, familyRowsHtml } from "./families_view.js";
34
- import { allViewChains, chainChipHtml, chainRowsHtml, chainTitle, foldChains } from "./chains.js";
32
+ import { createFamilyPop, familyChipTemplate, familyRowsTemplate } from "./families_view.js";
33
+ import { allViewChains, chainChipTemplate, chainRowsTemplate, chainTitle, foldChains } from "./chains.js";
35
34
  import {
36
35
  batchCounts, batchTargets, batchToastText, families, familyId, familyOpen, familyWaits, filterFamilies, heldOrder,
37
36
  listedSessions, rangeIds, runBatch, stoppedBadge, waitingBadge, waitingFirst, waitingSearchText,
@@ -122,11 +121,10 @@ export function cardTemplate(s, { deletable = false, archivable = false, selecta
122
121
  const pick = selectable ? html`<input type="checkbox" class="card-pick" aria-label=${`Select session ${s.short_id}`} ?checked=${picked}>` : nothing;
123
122
  const skip = skipReason ? html`<div class="skip-reason">skipped: ${skipReason}</div>` : nothing;
124
123
  const familyWaiting = !!family && familyWaits(family);
125
- // The family and chain chips and rows are families_view.js's and
126
- // chains.js's string builders (not lit yet): one element each, as nodes.
127
- const chain = family?.chain ? htmlElement(chainChipHtml(family, { open: chainOpen })) : nothing;
128
- const kids = fam ? htmlElement(familyChipHtml(fam, { open })) : nothing;
129
- const famRows = fam && rows && open ? htmlElement(familyRowsHtml(fam, { activeId, matchIds, archivable: !selectable })) : nothing;
124
+ // The family and chain chips and rows (families_view.js, chains.js).
125
+ const chain = family?.chain ? chainChipTemplate(family, { open: chainOpen }) : nothing;
126
+ const kids = fam ? familyChipTemplate(fam, { open }) : nothing;
127
+ const famRows = fam && rows && open ? familyRowsTemplate(fam, { activeId, matchIds, archivable: !selectable }) : nothing;
130
128
  // Its delegates and its chain's other links: the card is .contains-active while one is open.
131
129
  const memberIds = [...(fam ? fam.members.map((m) => m.session.id) : []),
132
130
  ...(family?.chain ? family.chain.links.map((x) => x.id).filter((id) => id !== s.id) : [])];
@@ -165,10 +163,12 @@ export function stripTileTemplate(count) {
165
163
 
166
164
  // The hidden strip's pill: "▾ 5 sessions · ● 1 live · ● 1 waiting"
167
165
  // (strip.js stripShowParts).
166
+ // The count (and the dot after it) in its own span: a phone's narrow top
167
+ // bar drops it when live or waiting shows (index.html).
168
168
  export function stripPillTemplate({ count, live, waiting }) {
169
- const liveT = live ? html` · <span class="live"><span class="live-dot" aria-hidden="true"></span>${live}</span>` : nothing;
170
- const waitingT = waiting ? html` · <span class="waiting"><span class="waiting-dot" aria-hidden="true"></span>${waiting}</span>` : nothing;
171
- return html`<span class="pill-arrow" aria-hidden="true">\u25BE</span> ${count}${liveT}${waitingT}`;
169
+ const liveT = live ? html`<span class="live"><span class="live-dot" aria-hidden="true"></span>${live}</span>` : nothing;
170
+ const waitingT = waiting ? html`${live ? " · " : ""}<span class="waiting"><span class="waiting-dot" aria-hidden="true"></span>${waiting}</span>` : nothing;
171
+ return html`<span class="pill-arrow" aria-hidden="true">\u25BE</span> <span class="pill-count">${count}${live || waiting ? " · " : ""}</span>${liveT}${waitingT}`;
172
172
  }
173
173
 
174
174
  // Draws +template+ into +el+ as new nodes, as an innerHTML write did: no
@@ -274,7 +274,7 @@ export function createListsView(deps) {
274
274
  win,
275
275
  rowsFor: (id) => {
276
276
  const f = stripFamilies.get(id);
277
- return f && f.members.length ? familyRowsHtml(f, { activeId: selected() }) : null;
277
+ return f && f.members.length ? familyRowsTemplate(f, { activeId: selected() }) : null;
278
278
  },
279
279
  anchorFor: (id) => [...topStripEl.querySelectorAll(".card[data-id] .family-chip")].find((chip) => chip.closest(".card").dataset.id === id) || null,
280
280
  onPick: (id) => {
@@ -302,7 +302,7 @@ export function createListsView(deps) {
302
302
  win,
303
303
  rowsFor: (id) => {
304
304
  const f = chainFamilies.get(id);
305
- return f?.chain ? chainRowsHtml(f, { activeId: selected() }) : null;
305
+ return f?.chain ? chainRowsTemplate(f, { activeId: selected() }) : null;
306
306
  },
307
307
  anchorFor: (id) => (chainFrom ? [...chainFrom.querySelectorAll(".card[data-id] .chain-chip")].find((chip) => chip.closest(".card").dataset.id === id) || null : null),
308
308
  onPick: (id) => {
@@ -12,7 +12,7 @@
12
12
  // choice for the next create only (POST /api/sessions' llm_context), never
13
13
  // remembered: nothing is sent unless the user sets one.
14
14
 
15
- import { html, nothing } from "./vendor/lit-html.js";
15
+ import { html, nothing, render } from "./vendor/lit-html.js";
16
16
 
17
17
  export const STRATEGIES = [
18
18
  { value: "none", label: "none" },
@@ -149,9 +149,7 @@ export function strategyOptions(chosen) {
149
149
  return [...STRATEGIES, { value: chosen, label: chosen }];
150
150
  }
151
151
 
152
- function option(value, label, chosen, escapeHtml) {
153
- return `<option value="${escapeHtml(value)}"${value === chosen ? " selected" : ""}>${escapeHtml(label)}</option>`;
154
- }
152
+ const option = (value, label) => html`<option value=${value}>${label}</option>`;
155
153
 
156
154
  // The popover's form: the values now (each with its source), and the
157
155
  // session's own to change.
@@ -161,30 +159,46 @@ export function nowText(summary) {
161
159
  `budget ${budget} (${summary.budget_where})`;
162
160
  }
163
161
 
164
- // For the new chat (kind NEW_CHAT), +summary+ is the model's and +choice+
165
- // the values the form shows.
166
- export function formHtml(summary, escapeHtml, { running = false, kind = SESSION, choice = null } = {}) {
162
+ // The popover's form (a lit-html template). For the new chat (kind
163
+ // NEW_CHAT), +summary+ is the model's and +choice+ the values the form
164
+ // shows; +now+ is the summary its "Now:" line reads (refresh passes a newer
165
+ // one, the rest stays as the form opened, so lit leaves what the user typed
166
+ // alone); +error+ a failed Set's message. The selects take their value as
167
+ // a property (.value), set after their options: +unset+ draws the options
168
+ // alone, the first pass renderForm makes.
169
+ export function formTemplate(summary, { running = false, kind = SESSION, choice = null, now = summary, error = null,
170
+ unset = false } = {}) {
167
171
  const fresh = kind === NEW_CHAT;
168
172
  const own = fresh ? ownValues({ own: choice }) : ownValues(summary);
169
173
  const follow = (field) => (summary[`${field}_source`] === "session" ? "follow the model" : `follow the model (${summary[field]})`);
170
174
  const when = fresh ? "For the new chat's first turn on; not remembered for the next one."
171
175
  : "From the next turn's start. Turning forget on or off re-reads the whole prompt once.";
172
- return `<form class="llmctx-form"><div class="ctx-pop-head"><strong>LLM context</strong>` +
173
- `<span class="ctx-pop-kind">${fresh ? NEW_CHAT : "this session"}</span>` +
174
- `<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>` +
175
- `<div class="ctx-pop-meta llmctx-now">${escapeHtml(nowText(summary))}</div>` +
176
- `<label class="llmctx-field"><span>Strategy</span><select name="strategy">` +
177
- option(DEFAULT, follow("strategy"), own.strategy, escapeHtml) +
178
- strategyOptions(own.strategy).map((s) => option(s.value, s.label, own.strategy, escapeHtml)).join("") + `</select></label>` +
179
- `<label class="llmctx-field"><span>Apply</span><select name="apply">` +
180
- option(DEFAULT, follow("apply"), own.apply, escapeHtml) +
181
- APPLIES.map((a) => option(a, a, own.apply, escapeHtml)).join("") + `</select></label>` +
182
- `<label class="llmctx-field"><span>Budget</span><input name="budget" type="text" inputmode="numeric" autocomplete="off" ` +
183
- `placeholder="follow the model (64000, 64k or off)" value="${escapeHtml(own.budget)}"></label>` +
184
- `<div class="ctx-pop-error llmctx-error" hidden></div>` +
185
- `<div class="ctx-pop-meta">${when}${running ? " A turn is running: it applies after this turn." : ""}</div>` +
186
- `<div class="ctx-pop-actions"><button type="button" class="ctx-pop-detach" data-act="reset">follow the model</button>` +
187
- `<button type="submit" class="llmctx-set">Set</button></div></form>`;
176
+ const chosen = (value) => (unset ? nothing : value);
177
+ return html`<form class="llmctx-form"><div class="ctx-pop-head"><strong>LLM context</strong
178
+ ><span class="ctx-pop-kind">${fresh ? NEW_CHAT : "this session"}</span
179
+ ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
180
+ ><div class="ctx-pop-meta llmctx-now">${nowText(now)}</div
181
+ ><label class="llmctx-field"><span>Strategy</span><select name="strategy" .value=${chosen(own.strategy)}>${
182
+ option(DEFAULT, follow("strategy"))}${strategyOptions(own.strategy).map((s) => option(s.value, s.label))}</select></label
183
+ ><label class="llmctx-field"><span>Apply</span><select name="apply" .value=${chosen(own.apply)}>${
184
+ option(DEFAULT, follow("apply"))}${APPLIES.map((a) => option(a, a))}</select></label
185
+ ><label class="llmctx-field"><span>Budget</span><input name="budget" type="text" inputmode="numeric" autocomplete="off"
186
+ placeholder="follow the model (64000, 64k or off)" .value=${own.budget}></label
187
+ ><div class="ctx-pop-error llmctx-error" ?hidden=${!error}>${error || ""}</div
188
+ ><div class="ctx-pop-meta">${when}${running ? " A turn is running: it applies after this turn." : ""}</div
189
+ ><div class="ctx-pop-actions"><button type="button" class="ctx-pop-detach" data-act="reset">follow the model</button
190
+ ><button type="submit" class="llmctx-set">Set</button></div></form>`;
191
+ }
192
+
193
+ // Draws the form into +target+ (formTemplate's options). A first draw (no
194
+ // form there yet) makes two passes, the options and then the selects'
195
+ // values: lit sets an element's own bindings before its children, so a
196
+ // select's .value would land before its options exist. A later draw is
197
+ // one pass, and lit writes only what changed since the last (the "Now:"
198
+ // line, the error), never a value the user changed.
199
+ export function renderForm(target, summary, opts = {}) {
200
+ if (!target.firstElementChild) render(formTemplate(summary, { ...opts, unset: true }), target);
201
+ render(formTemplate(summary, opts), target);
188
202
  }
189
203
 
190
204
  const EDGE = 16;
@@ -212,7 +226,7 @@ export function popoverPlace(rect, { width, height, innerWidth, innerHeight }) {
212
226
  // choice() the pending choice, and choose(next) takes a Set's (null for
213
227
  // "follow the model") instead of a command running. id and anchor (the
214
228
  // chip's selector) keep the two controls apart.
215
- export function createLlmContextControl({ el, escapeHtml, summary, session = () => null, running = () => false, run,
229
+ export function createLlmContextControl({ el, summary, session = () => null, running = () => false, run,
216
230
  kind = SESSION, choice = () => null, choose = null,
217
231
  id = "llmContextPopover", anchor: anchorSel = ".llmctx-chip",
218
232
  doc = document, win = window }) {
@@ -226,6 +240,14 @@ export function createLlmContextControl({ el, escapeHtml, summary, session = ()
226
240
  // The values the form opened with, and the session it is for.
227
241
  let initial = null;
228
242
  let openFor = null;
243
+ // What the open form was drawn from: the summary, the form's options and
244
+ // the summary its "Now:" line reads. A closed popover is empty, so each
245
+ // open draws a fresh form (unsent edits are gone).
246
+ let shown = null;
247
+ const draw = (changes = {}) => {
248
+ shown = { ...shown, ...changes };
249
+ renderForm(pop, shown.data, { ...shown.opts, now: shown.now, error: shown.error });
250
+ };
229
251
 
230
252
  function place(anchor) {
231
253
  const width = Math.min(360, win.innerWidth - 32);
@@ -244,7 +266,8 @@ export function createLlmContextControl({ el, escapeHtml, summary, session = ()
244
266
  if (!data) return;
245
267
  initial = fresh ? ownValues({ own: choice() }) : ownValues(data);
246
268
  openFor = session();
247
- pop.innerHTML = formHtml(data, escapeHtml, { running: running(), kind, choice: choice() });
269
+ shown = { data, opts: { running: running(), kind, choice: choice() } };
270
+ draw({ now: data, error: null });
248
271
  pop.hidden = false;
249
272
  place(anchor);
250
273
  }
@@ -253,19 +276,20 @@ export function createLlmContextControl({ el, escapeHtml, summary, session = ()
253
276
  pop.hidden = true;
254
277
  initial = null;
255
278
  openFor = null;
279
+ shown = null;
280
+ render(nothing, pop);
256
281
  }
257
282
 
258
283
  function isOpen() { return !pop.hidden; }
259
284
 
260
285
  // A new summary (a command_ran, a re-read): an open form keeps what the
261
- // user typed; only its "Now:" line and Set's state follow. Another
262
- // session closes it.
286
+ // user typed; only its "Now:" line follows (the same template again: lit
287
+ // writes that line alone). Another session closes it.
263
288
  function refresh() {
264
289
  if (!isOpen()) return;
265
290
  const data = summary();
266
291
  if (!data || session() !== openFor) return close();
267
- const now = pop.querySelector(".llmctx-now");
268
- if (now) now.textContent = nowText(data);
292
+ draw({ now: data });
269
293
  }
270
294
 
271
295
  el.addEventListener("click", (e) => {
@@ -277,8 +301,7 @@ export function createLlmContextControl({ el, escapeHtml, summary, session = ()
277
301
  });
278
302
  // An edit makes an error the last Set showed stale.
279
303
  const clearError = () => {
280
- const error = pop.querySelector(".llmctx-error");
281
- if (error && !error.hidden) { error.hidden = true; error.textContent = ""; }
304
+ if (shown?.error) draw({ error: null });
282
305
  };
283
306
  pop.addEventListener("input", clearError);
284
307
  pop.addEventListener("change", clearError);
@@ -292,9 +315,7 @@ export function createLlmContextControl({ el, escapeHtml, summary, session = ()
292
315
  try {
293
316
  next = mergeChoice(choice(), values, initial);
294
317
  } catch (err) {
295
- const error = pop.querySelector(".llmctx-error");
296
- error.textContent = err.message;
297
- error.hidden = false;
318
+ draw({ error: err.message });
298
319
  return;
299
320
  }
300
321
  close();
@@ -102,6 +102,17 @@ function relayClosedText(relay, reason) {
102
102
  return reason ? `Left open in ${relay.who} (${reason})` : `Left open in ${relay.who}`;
103
103
  }
104
104
 
105
+ // Whether the card keeps the reply that asked it once resolved: the model's
106
+ // own question and an own approval (their call ran in the asking step). Not
107
+ // the step limit (asked between turns), a hook's question (not asked by a
108
+ // call) or a delegate's relayed approval (the parent's text is about its
109
+ // own call, the card's delegate line says what the child asks).
110
+ export function keepsAskingText(pq) {
111
+ if (!pq) return false;
112
+ if (isApproval(pq)) return !relayView(pq);
113
+ return !pq.kind || pq.kind === "question";
114
+ }
115
+
105
116
  // The line a resolved card shows.
106
117
  export function resultText(pq, { answer = null, cancelled = false, reason = "" } = {}) {
107
118
  const approval = isApproval(pq);