samagotchi 0.60.0 → 0.62.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -1
  3. data/docs/architecture.md +10 -4
  4. data/docs/cli.md +22 -8
  5. data/docs/configuration.md +32 -19
  6. data/docs/context.md +8 -0
  7. data/docs/guardrails.md +2 -1
  8. data/docs/hooks.md +80 -1
  9. data/docs/internals/engine.md +57 -15
  10. data/docs/internals/prompt-caching.md +8 -1
  11. data/docs/plugins.md +49 -6
  12. data/docs/sessions.md +7 -3
  13. data/docs/testing.md +4 -1
  14. data/lib/samagotchi/answer_display.rb +4 -3
  15. data/lib/samagotchi/answer_refs.rb +210 -0
  16. data/lib/samagotchi/bridge/card_store.rb +6 -1
  17. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
  19. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  20. data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
  21. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
  25. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  26. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  27. data/lib/samagotchi/engine.rb +47 -9
  28. data/lib/samagotchi/engine_peers.rb +1 -0
  29. data/lib/samagotchi/hooks/registry.rb +3 -1
  30. data/lib/samagotchi/interrupted_turn.rb +39 -0
  31. data/lib/samagotchi/kernel_loop.rb +13 -20
  32. data/lib/samagotchi/llm/chat_loop.rb +28 -17
  33. data/lib/samagotchi/log_subscriber.rb +6 -3
  34. data/lib/samagotchi/plugin/api.rb +36 -18
  35. data/lib/samagotchi/reasoning_history.rb +22 -4
  36. data/lib/samagotchi/ref.rb +25 -8
  37. data/lib/samagotchi/reply_wait.rb +6 -2
  38. data/lib/samagotchi/row_refs.rb +126 -0
  39. data/lib/samagotchi/session.rb +40 -4
  40. data/lib/samagotchi/session_manager.rb +64 -6
  41. data/lib/samagotchi/session_metrics.rb +24 -2
  42. data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
  43. data/lib/samagotchi/terminal_ui.rb +30 -3
  44. data/lib/samagotchi/tool_activity.rb +8 -4
  45. data/lib/samagotchi/tool_response.rb +5 -4
  46. data/lib/samagotchi/tool_row_fields.rb +4 -2
  47. data/lib/samagotchi/tool_runner.rb +16 -3
  48. data/lib/samagotchi/tools/builtins.rb +9 -2
  49. data/lib/samagotchi/tools/execute.rb +15 -3
  50. data/lib/samagotchi/tools/peers.rb +3 -2
  51. data/lib/samagotchi/tools/registry.rb +5 -3
  52. data/lib/samagotchi/turn_checkpoint.rb +128 -0
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +19 -1
  55. data/lib/samagotchi/web/message_parts.rb +17 -9
  56. data/lib/samagotchi/web/public/activity.js +4 -1
  57. data/lib/samagotchi/web/public/answer_refs.js +217 -0
  58. data/lib/samagotchi/web/public/app.js +74 -33
  59. data/lib/samagotchi/web/public/chains.js +18 -20
  60. data/lib/samagotchi/web/public/context_chips.js +92 -64
  61. data/lib/samagotchi/web/public/families_view.js +51 -39
  62. data/lib/samagotchi/web/public/index.html +66 -13
  63. data/lib/samagotchi/web/public/keep_focus.js +24 -0
  64. data/lib/samagotchi/web/public/lists_view.js +13 -13
  65. data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
  66. data/lib/samagotchi/web/public/question_card.js +11 -0
  67. data/lib/samagotchi/web/public/question_cards.js +60 -5
  68. data/lib/samagotchi/web/public/ref_menu.js +24 -3
  69. data/lib/samagotchi/web/public/ref_scan.js +5 -3
  70. data/lib/samagotchi/web/public/refs.js +140 -20
  71. data/lib/samagotchi/web/public/scroll.js +42 -0
  72. data/lib/samagotchi/web/public/stage_model.js +22 -3
  73. data/lib/samagotchi/web/public/stage_view.js +299 -70
  74. data/lib/samagotchi/web/public/stream_handlers.js +5 -3
  75. data/lib/samagotchi/web/public/timing.js +1 -1
  76. data/lib/samagotchi/web/public/turn_html.js +5 -4
  77. data/lib/samagotchi/web/public/turn_model.js +49 -0
  78. data/lib/samagotchi/web/public/turn_view.js +121 -17
  79. data/lib/samagotchi/worker.rb +169 -21
  80. data/lib/samagotchi/worker_inbound.rb +5 -3
  81. data/lib/samagotchi/worker_signals.rb +81 -0
  82. metadata +8 -1
@@ -1,18 +1,21 @@
1
1
  // The stage view of a running turn (web.view: stage): the active turn pinned
2
- // above the composer, as the top section of the dock card. It shows only
2
+ // above the composer, its own card floating over the dock's (out of the
3
+ // dock's flow: its height never moves the composer). It shows only
3
4
  // what happens now in fixed slots (a status row, the prompt, a two-line
4
5
  // headline, the running tool, a one-line trail of the last calls), the
5
6
  // cards and rows that arrive meanwhile in their own capped scroller, the
6
- // answer, and a chip with one tick per tool call: the "cloud", the turn
7
- // view's block (turn_view.js) reused as is, expands in place, newest step
8
- // first while the turn runs. When the turn has ended and the stage is not
7
+ // answer, and a chip with one tick per tool call, the stage's footer (no
8
+ // content above it can push it away): the "cloud", the turn view's block
9
+ // (turn_view.js) reused as is, opens above it as the scroller's last slot,
10
+ // newest step first while the turn runs. When the turn has ended and the stage is not
9
11
  // in use, everything moves into the history in the turn view's order (the
10
12
  // hand-off), so a handed-off turn reads like the turn view's and like its
11
13
  // reload. Slots are patched in place, never rebuilt from HTML: a selection
12
14
  // and annotate survive. The rules are pure in stage_model.js.
13
15
  import { createTurnView } from "./turn_view.js";
14
- import { flashOf, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor, trailFlashText, trailItemText, trailTone } from "./stage_model.js";
16
+ import { flashOf, frameTarget, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor, trailFlashText, trailItemText, trailTone } from "./stage_model.js";
15
17
  import { formatDuration } from "./timing.js";
18
+ import { easeScrollTo } from "./scroll.js";
16
19
  import { taskCommand, taskStopButton } from "./task_stop.js";
17
20
 
18
21
  const COLLAPSE_KEY = "chi_stage_collapsed";
@@ -20,8 +23,19 @@ const WATCH_MS = 200;
20
23
  // The turn-end read (markdown, answer_display) settles within this, or the
21
24
  // hand-off stops waiting for it.
22
25
  const SETTLE_CAP_MS = 8000;
23
- const FOLD_MS = 420;
26
+ export const FOLD_MS = 420;
27
+ // The stage grows (a card, the long answer, the cloud opening) with a short
28
+ // ease: deliberate, never a snap.
29
+ const GROW_MS = 180;
30
+ const GROW_EASE = "cubic-bezier(.2,.7,.3,1)";
31
+ // The room between the stage and the composer card (index.html's margin-bottom).
32
+ const STAGE_GAP = 10;
24
33
  const FLASH_MS = 4000;
34
+ // The answer reads from its top: at the end it is scrolled to unless this
35
+ // much of it (about three lines) shows already.
36
+ const ANSWER_SEEN = 72;
37
+ // A short answer cut by less than this at its foot isn't scrolled to.
38
+ const ANSWER_CUT = 12;
25
39
  const PENDING = ".bubble.question:not(.answered):not(.cancelled), .bubble.continue:not(.resolved)";
26
40
  // A running turn's card with actions (check-in's Nudge / Keep going / Stop)
27
41
  // asks the user too, but the turn goes on: it waits only while the turn runs
@@ -39,6 +53,10 @@ function node(tag, cls, parent = null) {
39
53
  return el;
40
54
  }
41
55
 
56
+ function reducedMotion() {
57
+ return matchMedia("(prefers-reduced-motion: reduce)").matches;
58
+ }
59
+
42
60
  function readCollapsed() {
43
61
  try { return localStorage.getItem(COLLAPSE_KEY) === "1"; } catch (_) { return false; }
44
62
  }
@@ -80,18 +98,32 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
80
98
  headline.tabIndex = 0;
81
99
  headline.setAttribute("role", "button");
82
100
  headline.title = "Show this step's reasoning";
101
+ // While a question or approval waits: the whole reply that asked it, in
102
+ // the headline's place (the card says what to answer, this says why).
103
+ const asking = node("div", "ts-asking", live);
104
+ asking.hidden = true;
83
105
  const now = node("div", "ts-now", live);
84
106
  const trail = node("div", "ts-trail", live);
85
107
  const extras = node("div", "ts-extras", scroller);
86
108
  const answer = node("div", "ts-answer", scroller);
87
109
  const tail = node("div", "ts-tail", scroller);
88
- const cloud = node("div", "ts-cloud", scroller);
110
+ // The cloud's steps, the scroller's last slot, right above the chip.
111
+ const cloudBody = node("div", "ts-cloud-body", scroller);
112
+ cloudBody.hidden = true;
113
+ // A 1 px mark at the tallest content's end (absolute: out of the flow),
114
+ // so the scroll extent never drops while the turn runs: content that
115
+ // shrinks never lowers a scrolled reader's scrollTop (the browser would
116
+ // clamp it, moving everything in sight), even in the layout before
117
+ // fitFrame runs.
118
+ const holdEl = node("div", "ts-hold", scroller);
119
+ holdEl.setAttribute("aria-hidden", "true");
120
+ // The footer: the chip, pinned to the stage's bottom edge as the status
121
+ // row is to its top.
122
+ const cloud = node("div", "ts-foot", stage);
89
123
  const chip = node("button", "ts-chip", cloud);
90
124
  chip.type = "button";
91
125
  const chipLabel = node("span", "ts-chip-label", chip);
92
126
  const ticks = node("span", "ts-ticks", chip);
93
- const cloudBody = node("div", "ts-cloud-body", cloud);
94
- cloudBody.hidden = true;
95
127
 
96
128
  // The turn in the stage, from turnStarted until the hand-off (or a reset).
97
129
  let holding = false;
@@ -114,8 +146,14 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
114
146
  let quietSince = null;
115
147
  let pointerIn = false;
116
148
  let lastTouch = 0;
149
+ // The frame (fitFrame): the height held while the turn runs and the
150
+ // tallest content seen.
151
+ let held = null;
152
+ let maxContent = 0;
153
+ let growth = null; // the running ease of the frame's growth (an Animation)
154
+ let growthTo = 0; // its end height
117
155
  // What each slot shows, so an unchanged slot is left alone.
118
- const shown = { headline: null, now: null, trail: null };
156
+ const shown = { headline: null, now: null, trail: null, asking: null };
119
157
 
120
158
  const view = createTurnView({
121
159
  historyEl,
@@ -161,8 +199,9 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
161
199
  return !endKind && !!extras.querySelector(ASKS);
162
200
  }
163
201
  // A card answered or redrawn in place (its buttons gone) changes the
164
- // status without a turn event.
165
- new MutationObserver(() => scheduleRefresh()).observe(extras, { childList: true, subtree: true });
202
+ // status without a turn event: at once (a microtask), so the slots swap in
203
+ // the card's own task, never a frame apart.
204
+ new MutationObserver(() => refresh()).observe(extras, { childList: true, subtree: true });
166
205
 
167
206
  function refresh() {
168
207
  rafPending = false;
@@ -183,17 +222,25 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
183
222
  // A question blocks the turn: its card takes the live slots' room. An
184
223
  // asking card's turn goes on: the status waits, the slots stay.
185
224
  const blocked = pendingCard();
186
- const slots = liveSlots(turn, { narration, thinking, pending: blocked || asksCard(), ended: endKind, emptyAnswer });
187
-
225
+ const slots = liveSlots(turn, { narration, thinking, pending: blocked || asksCard(), blocked, ended: endKind, emptyAnswer });
226
+
227
+ // The live slots keep their height while the turn waits and until its
228
+ // next step (held-wait, set with waiting): the card under them never
229
+ // moves at the answer. The waiting height is measured before the swap.
230
+ const wasWaiting = stage.classList.contains("waiting");
231
+ if (blocked && !wasWaiting) stage.classList.add("held-wait");
232
+ if (!blocked && wasWaiting) stage.style.setProperty("--live-held", `${live.offsetHeight}px`);
188
233
  stage.classList.toggle("plain", plain);
189
234
  stage.classList.toggle("ended", !!endKind);
190
235
  stage.classList.toggle("waiting", blocked);
236
+ stage.classList.toggle("asking", !!slots.asking);
191
237
  stage.dataset.phase = slots.phase;
192
238
  mark.textContent = MARKS[slots.phase] || "";
193
239
  phaseEl.textContent = slots.phase;
194
240
  const steps = `${slots.step} step${slots.step === 1 ? "" : "s"}`;
195
241
 
196
242
  drawHeadline(slots.headline);
243
+ drawAsking(slots.asking);
197
244
  drawNow(slots.tool, slots.quiet);
198
245
  drawTrail(slots.trail);
199
246
  drawTicks(slots.ticks);
@@ -207,8 +254,11 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
207
254
  miniTool.textContent = collapsed && slots.tool ? `${slots.tool.name} ${slots.tool.title}` : "";
208
255
  miniTool.title = (collapsed && slots.tool?.command) || "";
209
256
 
210
- live.hidden = plain || !!endKind;
257
+ // An ended turn's live slots go, unless something under them would move
258
+ // (a card or a row in the extras): then they stay as they were last.
259
+ live.hidden = plain || (!!endKind && !extras.firstChild);
211
260
  cloud.hidden = !blockEl || blockEl.parentNode !== cloudBody;
261
+ cloudBody.hidden = !cloudOpen || cloud.hidden;
212
262
  // Running: the step it is on. Ended: the count, unless the chip says it.
213
263
  stepEl.textContent = !endKind ? `step ${slots.step}` : cloud.hidden ? steps : "";
214
264
  promptSlot.hidden = !promptSlot.firstChild;
@@ -233,6 +283,17 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
233
283
  headline.classList.add("fresh");
234
284
  }
235
285
 
286
+ // Patched only when the text changes (a late chunk to the closed gen
287
+ // rewrites the same text): a selection in it survives. A new text reads
288
+ // from its top.
289
+ function drawAsking(text) {
290
+ asking.hidden = !text;
291
+ if (shown.asking === text) return;
292
+ shown.asking = text;
293
+ asking.textContent = text;
294
+ asking.scrollTop = 0;
295
+ }
296
+
236
297
  function drawNow(tool, quiet) {
237
298
  // A task_wait's stop button, as the row's (task_stop.js): in the key
238
299
  // with its in-flight state, so a redraw keeps a clicked one disabled.
@@ -307,13 +368,34 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
307
368
  while (ticks.children.length > list.length) ticks.lastChild.remove();
308
369
  }
309
370
 
310
- // The answer reads from its top (no tail pin in the stage).
371
+ // The answer reads from its top (no tail pin in the stage): scrolled to
372
+ // (eased, once the stage has grown for it) when its top is out of sight
373
+ // or only its first line shows.
311
374
  function showAnswerTop() {
312
- requestAnimationFrame(() => {
313
- if (answer.firstChild) scroller.scrollTop = Math.max(0, answer.offsetTop - 8);
375
+ afterGrowth(() => {
376
+ if (!answer.firstChild || answer.hidden || stage.hidden) return;
377
+ const top = answer.offsetTop;
378
+ const seen = scroller.scrollTop + scroller.clientHeight - top;
379
+ if (top < scroller.scrollTop || seen < Math.min(answer.offsetHeight - ANSWER_CUT, ANSWER_SEEN)) easeStage(Math.max(0, top - 8));
314
380
  });
315
381
  }
316
382
 
383
+ // The stage's own scrolls, eased (instant with reduced motion).
384
+ function easeStage(top) {
385
+ if (Math.abs(top - scroller.scrollTop) < 1) return;
386
+ easeScrollTo(scroller, top, { reduce: reducedMotion() });
387
+ }
388
+
389
+ // +fn+ once the frame has grown for a change made now: two frames on (the
390
+ // ResizeObserver starts the ease in the frame after the change), then at
391
+ // the ease's end.
392
+ function afterGrowth(fn) {
393
+ requestAnimationFrame(() => requestAnimationFrame(() => {
394
+ if (growth?.playState === "running") growth.finished.then(fn, fn);
395
+ else fn();
396
+ }));
397
+ }
398
+
317
399
  // ── collapse, cloud, peek ────────────────────────────────────────────────
318
400
 
319
401
  function applyCollapsed() {
@@ -335,11 +417,16 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
335
417
 
336
418
  function setCloud(open) {
337
419
  cloudOpen = open;
338
- cloudBody.hidden = !open;
420
+ cloudBody.hidden = !open || cloud.hidden;
421
+ // Closed by hand: its room isn't held at the scroller's end.
422
+ if (!open) maxContent = 0;
339
423
  cloud.classList.toggle("open", open);
340
424
  stage.classList.toggle("cloud-open", open);
341
425
  chip.setAttribute("aria-expanded", String(open));
342
426
  fitCap();
427
+ // The steps open right above the chip: in sight (the answer and the
428
+ // cards above them scroll up), once the stage has grown for them.
429
+ if (open) afterGrowth(() => reveal(cloudBody));
343
430
  }
344
431
 
345
432
  // Open the cloud at step +i+ (a hand toggle, as a click on its row), its
@@ -377,14 +464,20 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
377
464
  // stage and the cloud to it, as a hand toggle would.
378
465
  // A collapsed stage opens for this turn only: the kept choice
379
466
  // (chi_stage_collapsed) is left alone and comes back at the release.
467
+ // The row's scroll into view (refs.js) waits for the stage's growth.
468
+ // A row of a turn sliding away is no longer the stage's: it goes into the
469
+ // history first (finishFold), and refs.js finds it there.
380
470
  stage.addEventListener("ref:reveal", (e) => {
381
471
  if (!cloudBody.contains(e.target)) return;
472
+ if (folding) return finishFold();
473
+ if (!holding) return;
382
474
  if (collapsed) {
383
475
  collapsed = false;
384
476
  revealed = true;
385
477
  applyCollapsed();
386
478
  }
387
479
  setCloud(true);
480
+ if (e.detail) e.detail.until = new Promise((resolve) => afterGrowth(resolve));
388
481
  });
389
482
  headline.addEventListener("keydown", (e) => {
390
483
  if (e.key === "Enter" || e.key === " ") {
@@ -402,24 +495,119 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
402
495
  // never above the session panel (the dock is absolute: a tall composer or
403
496
  // a phone's keyboard would push the stage's top out) ────────────────────
404
497
 
405
- // Scroll the stage so +slot+'s end shows (it is the scroller's child).
406
- function revealSlot(slot) {
407
- if (stage.hidden || slot.hidden) return;
408
- const end = slot.offsetTop + slot.offsetHeight - scroller.clientHeight;
409
- if (end > scroller.scrollTop) scroller.scrollTop = end;
498
+ // Where the stage's scroll goes so +slot+ (the scroller's child) shows:
499
+ // its end in sight, and the asking text's top too when it and the slot
500
+ // (a waiting card) fit together (the reply reads from its start, the card
501
+ // still in sight). null: nothing to show.
502
+ function revealTop(slot) {
503
+ if (stage.hidden || slot.hidden) return null;
504
+ let top = Math.max(scroller.scrollTop, slot.offsetTop + slot.offsetHeight - scroller.clientHeight);
505
+ if (!asking.hidden && asking.offsetTop < top && slot.offsetTop + slot.offsetHeight - asking.offsetTop <= scroller.clientHeight) top = asking.offsetTop;
506
+ return top;
507
+ }
508
+
509
+ // Scrolls the stage to show +slot+: eased, or at once (+now+: the cap
510
+ // dropped under it).
511
+ function reveal(slot, { now = false } = {}) {
512
+ const top = revealTop(slot);
513
+ if (top === null) return;
514
+ if (now) scroller.scrollTop = top;
515
+ else easeStage(top);
410
516
  }
411
517
 
412
518
  function fitCap() {
413
519
  if (stage.hidden) return;
414
- const rest = dockEl.offsetHeight - stage.offsetHeight;
520
+ // The composer card and the gap above it (the stage floats over them,
521
+ // out of the dock's flow).
522
+ const rest = dockEl.offsetHeight + STAGE_GAP;
415
523
  const room = centerEl.clientHeight - rest - 32;
416
524
  const share = window.innerHeight * (cloudOpen ? 0.8 : 0.6);
417
- stage.style.setProperty("--stage-max", `${Math.max(96, Math.round(Math.min(share, room)))}px`);
525
+ const cap = Math.max(96, Math.round(Math.min(share, room)));
526
+ const was = capPx();
527
+ if (cap === was) return;
528
+ stage.style.setProperty("--stage-max", `${cap}px`);
529
+ fitFrame();
530
+ // A smaller cap (a phone's keyboard came up) keeps a waiting card in
531
+ // sight: the asking text above it would push it out. A larger one
532
+ // leaves the reader's scroll alone.
533
+ if (cap < was && stage.classList.contains("waiting")) {
534
+ requestAnimationFrame(() => reveal(extras, { now: true }));
535
+ }
418
536
  }
419
537
  new ResizeObserver(() => fitCap()).observe(centerEl);
420
538
  new ResizeObserver(() => fitCap()).observe(composerEl);
421
539
  window.addEventListener("resize", fitCap);
422
540
 
541
+ // ── the frame: the one place that writes the stage's height ────────────
542
+ // Nothing in the stage view jumps. The rules: the stage is out of the
543
+ // dock's flow (index.html), so its height never moves the composer; while
544
+ // it holds a turn its height is held and grow-only (frameTarget), so a
545
+ // content change happens inside it; growth eases (frameTo); the chip is a
546
+ // footer no content can push; the hand-off releases the turn at once
547
+ // (releaseTurn) and empties the stage only after it slid away under the
548
+ // composer (a transform, then finishFold/clearStage).
549
+
550
+ // The stage's height, +px+. +animate+: a growth eases (GROW_MS, Web
551
+ // Animations: the running ease is +growth+), unless motion is reduced or
552
+ // the stage is hidden or collapsed; a shrink (the cap dropped) is at once.
553
+ function frameTo(px, { animate = false } = {}) {
554
+ const from = stage.offsetHeight;
555
+ stage.style.height = `${px}px`;
556
+ if (px === from) return;
557
+ const ease = animate && px > from && !collapsed && !stage.hidden && !document.hidden && !reducedMotion();
558
+ if (growth && (!ease || growth.playState !== "running" || growthTo !== px)) {
559
+ growth.cancel();
560
+ growth = null;
561
+ }
562
+ if (!ease || growth) return;
563
+ growth = stage.animate([{ height: `${from}px` }, { height: `${px}px` }], { duration: GROW_MS, easing: GROW_EASE });
564
+ growthTo = px;
565
+ const run = growth;
566
+ run.finished.then(() => { if (growth === run) growth = null; }, () => {});
567
+ }
568
+
569
+ // Back to the content's own height (under the cap).
570
+ function frameFree() {
571
+ growth?.cancel();
572
+ growth = null;
573
+ stage.style.height = "";
574
+ }
575
+
576
+ function capPx() {
577
+ return parseFloat(stage.style.getPropertyValue("--stage-max")) || Infinity;
578
+ }
579
+
580
+ // The scroller's content height: its last shown slot's end (offsetTop is
581
+ // the scroller's own, whatever its scroll) and its bottom padding.
582
+ function contentHeight() {
583
+ const css = getComputedStyle(scroller);
584
+ let end = parseFloat(css.paddingTop) || 0;
585
+ for (const el of scroller.children) {
586
+ if (el === holdEl || el.hidden || !el.getClientRects().length) continue;
587
+ end = el.offsetTop + el.offsetHeight;
588
+ }
589
+ return end + (parseFloat(css.paddingBottom) || 0);
590
+ }
591
+
592
+ // Holds the frame while the stage holds a turn (stage_model.js
593
+ // frameTarget: grow-only, clamped by the cap; free when collapsed or
594
+ // released). Run from a ResizeObserver on the slots (after layout, before
595
+ // paint: no frame shows the content's own height) and when the cap moves.
596
+ function fitFrame() {
597
+ if (stage.hidden || stage.classList.contains("leaving")) return;
598
+ const content = scroller.hidden ? 0 : contentHeight();
599
+ const holds = holding && !collapsed;
600
+ maxContent = holds ? Math.max(maxContent, content) : 0;
601
+ const mark = `${Math.max(0, Math.round(maxContent) - 1)}px`;
602
+ if (holdEl.style.top !== mark) holdEl.style.top = mark;
603
+ const natural = (stage.offsetHeight - stage.clientHeight) + status.offsetHeight + content + (cloud.hidden || collapsed ? 0 : cloud.offsetHeight);
604
+ held = frameTarget({ natural, held, cap: capPx(), holding, collapsed });
605
+ if (held === null) frameFree();
606
+ else frameTo(held, { animate: true });
607
+ }
608
+ const frameObserver = new ResizeObserver(() => fitFrame());
609
+ for (const el of [status, promptSlot, live, extras, answer, tail, cloudBody, cloud]) frameObserver.observe(el);
610
+
423
611
  // ── in use: pointer over it (tracked on the stage itself: :hover goes
424
612
  // stale when children change), a touch or wheel in the last 4 s (phones
425
613
  // have no hover), keyboard focus or a selection in it ─────────────────
@@ -469,11 +657,11 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
469
657
 
470
658
  // ── show, clear, hand off ────────────────────────────────────────────────
471
659
 
660
+ // A fold cut short (the stage shows again): the slide's class goes.
472
661
  function cancelFold() {
473
662
  clearTimeout(foldTimer);
474
663
  foldTimer = null;
475
664
  stage.classList.remove("leaving");
476
- stage.style.height = "";
477
665
  }
478
666
 
479
667
  function show() {
@@ -481,7 +669,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
481
669
  const hidden = stage.hidden;
482
670
  stage.hidden = false;
483
671
  applyCollapsed();
484
- if (hidden && !matchMedia("(prefers-reduced-motion: reduce)").matches) {
672
+ if (hidden && !reducedMotion()) {
485
673
  stage.classList.remove("enter");
486
674
  void stage.offsetWidth;
487
675
  stage.classList.add("enter");
@@ -489,24 +677,38 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
489
677
  }
490
678
 
491
679
  function emptySlots() {
492
- for (const slot of [promptSlot, extras, answer, tail, cloudBody, timingSlot, headline, now, trail, ticks]) slot.replaceChildren();
680
+ for (const slot of [promptSlot, extras, answer, tail, cloudBody, timingSlot, headline, asking, now, trail, ticks]) slot.replaceChildren();
493
681
  promptSlot.classList.remove("full");
494
- shown.headline = shown.now = shown.trail = null;
682
+ asking.hidden = true;
683
+ shown.headline = shown.now = shown.trail = shown.asking = null;
495
684
  thinkingLine = null;
496
685
  flash = null;
497
686
  setCloud(false);
498
- stage.classList.remove("plain", "ended", "waiting", "in-use");
687
+ stage.classList.remove("plain", "ended", "waiting", "held-wait", "asking", "in-use");
688
+ stage.style.removeProperty("--live-held");
499
689
  }
500
690
 
501
- function release() {
691
+ // The turn leaves the stage's care (the hand-off's start, a reset, a new
692
+ // turn): from here new rows, the next timing line and the history's
693
+ // scroll are the history's (place, timingHost, holds). The slots keep
694
+ // what they show until clearStage.
695
+ function releaseTurn() {
502
696
  stopWatch();
503
697
  clearTimeout(settleTimer);
504
698
  holding = false;
699
+ held = null;
505
700
  endKind = null;
506
701
  emptyAnswer = false;
507
702
  settled = true;
508
703
  view.reset();
704
+ }
705
+
706
+ // The stage empty and hidden, its frame free.
707
+ function clearStage() {
708
+ cancelFold();
509
709
  emptySlots();
710
+ stage.hidden = true;
711
+ frameFree();
510
712
  if (revealed) {
511
713
  revealed = false;
512
714
  collapsed = readCollapsed();
@@ -514,65 +716,76 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
514
716
  }
515
717
  }
516
718
 
517
- // Everything the stage holds, into the history's end in the turn view's
518
- // order: prompt, block (collapsed, chronological), extras, answer, timing
519
- // line, then the end line and what came after the end. The stage folds
520
- // away while the turn fades up into the history (not with +immediate+:
521
- // another turn takes the stage at once). The history follows only a
522
- // reader who was at its end.
719
+ function release() {
720
+ releaseTurn();
721
+ clearStage();
722
+ }
723
+
724
+ // The hand-off: everything the stage holds goes into the history's end in
725
+ // the turn view's order (prompt, block (chronological; its outline open,
726
+ // else collapsed), extras, answer, timing line, then the end line and
727
+ // what came after the end). The turn is released at once; the stage
728
+ // slides away under the composer still drawn (a transform: nothing on the
729
+ // page moves; app.js eases the history's bottom reserve meanwhile), then
730
+ // the pieces arrive in the history and it follows a reader who was at its
731
+ // end. Not with +immediate+ (another turn takes the stage at once), a
732
+ // reduced motion, a hidden page or a collapsed stage: all at once.
733
+ let folding = null; // { pieces, block, wasAtEnd, animate } while it slides
523
734
  function handOff({ immediate = false } = {}) {
735
+ if (folding && immediate) finishFold();
524
736
  if (!holding || !endKind) return;
525
737
  const { blockEl } = view.current();
526
738
  view.chronological();
527
- if (blockEl) blockEl.open = false;
528
- const wasAtEnd = historyNearBottom();
739
+ const block = blockEl && blockEl.parentNode === cloudBody ? blockEl : null;
529
740
  const pieces = handOffOrder({
530
741
  prompt: promptSlot.firstElementChild,
531
- block: blockEl && blockEl.parentNode === cloudBody ? blockEl : null,
742
+ block,
532
743
  extras: [...extras.children],
533
744
  answer: [...answer.children],
534
745
  timing: timingSlot.firstElementChild,
535
746
  kept: [...tail.children],
536
747
  });
537
- const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
538
- const animate = !immediate && !reduce && !document.hidden && !collapsed;
539
- if (animate) {
540
- stage.style.height = `${stage.offsetHeight}px`;
541
- void stage.offsetHeight;
542
- }
748
+ const animate = !immediate && !reducedMotion() && !document.hidden && !collapsed;
749
+ folding = { pieces, block: blockEl, wasAtEnd: historyNearBottom(), animate };
750
+ releaseTurn();
751
+ if (!animate) return finishFold();
752
+ stage.classList.add("leaving");
753
+ foldTimer = setTimeout(finishFold, FOLD_MS);
754
+ }
755
+
756
+ // The fold's end (or a cut-short fold): the original pieces (their ref
757
+ // bindings, selection and late updates with them) move into the history.
758
+ function finishFold() {
759
+ const fold = folding;
760
+ if (!fold) return;
761
+ folding = null;
762
+ // Closed, unless it is an outline (turn_view.js outlineBlock): the
763
+ // history reads its narrated steps without a click.
764
+ if (fold.block) fold.block.open = fold.block.classList.contains("outline");
543
765
  historyEl.querySelector(":scope > .hint")?.remove();
544
- for (const el of pieces) {
766
+ for (const el of fold.pieces) {
545
767
  historyEl.appendChild(el);
546
- if (animate) {
768
+ if (fold.animate) {
547
769
  el.classList.add("ts-arrive");
548
770
  el.addEventListener("animationend", () => el.classList.remove("ts-arrive"), { once: true });
549
771
  }
550
772
  }
551
- release();
552
- if (!animate) {
553
- stage.hidden = true;
554
- if (wasAtEnd) historyEl.scrollTop = historyEl.scrollHeight;
555
- return;
556
- }
557
- stage.classList.add("leaving");
558
- stage.style.height = "0px";
559
- if (wasAtEnd) historyEl.scrollTo({ top: historyEl.scrollHeight, behavior: "smooth" });
560
- foldTimer = setTimeout(() => {
561
- cancelFold();
562
- stage.hidden = true;
563
- if (wasAtEnd) historyEl.scrollTo({ top: historyEl.scrollHeight, behavior: "smooth" });
564
- }, FOLD_MS);
773
+ clearStage();
774
+ if (fold.wasAtEnd) easeScrollTo(historyEl, () => historyEl.scrollHeight - historyEl.clientHeight, { reduce: !fold.animate || reducedMotion() });
565
775
  }
566
776
 
567
777
  // ── the view interface (app.js) ──────────────────────────────────────────
568
778
 
569
779
  return {
570
780
  turnStarted(data) {
781
+ if (folding) finishFold();
571
782
  if (holding) {
572
783
  if (endKind) handOff({ immediate: true });
573
784
  else release();
574
785
  }
575
786
  holding = true;
787
+ held = null;
788
+ maxContent = 0;
576
789
  endKind = null;
577
790
  emptyAnswer = false;
578
791
  settled = true;
@@ -585,7 +798,12 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
585
798
  show();
586
799
  refresh();
587
800
  },
588
- generationStarted: (data) => view.generationStarted(data),
801
+ generationStarted(data) {
802
+ // The next step: the live slots' waiting height is let go.
803
+ stage.classList.remove("held-wait");
804
+ stage.style.removeProperty("--live-held");
805
+ view.generationStarted(data);
806
+ },
589
807
  chunk: (data) => view.chunk(data),
590
808
  generationRestarted: (data) => view.generationRestarted(data),
591
809
  generationCompleted: () => view.generationCompleted(),
@@ -634,14 +852,17 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
634
852
  view.answerReady();
635
853
  },
636
854
  containsLive: (el) => view.containsLive(el),
855
+ askingText: () => view.askingText(),
637
856
  reset() {
638
- cancelFold();
857
+ if (folding) finishFold();
639
858
  release();
640
- stage.hidden = true;
641
859
  },
642
860
  // The stage's extras while it holds a turn (a card, a queued prompt, a
643
861
  // command, a note, the end line); false: the history takes it.
644
862
  place(el) {
863
+ // A turn still sliding away goes into the history first: what comes
864
+ // now comes after it.
865
+ if (folding) finishFold();
645
866
  const kind = el.matches?.(".bubble.recap") ? "recap" : "row";
646
867
  if (placeFor({ live: holding, handingOff: false, kind }) !== "stage") return false;
647
868
  const slot = endKind ? tail : extras;
@@ -650,15 +871,23 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
650
871
  slot.hidden = false;
651
872
  const waits = el.matches?.(PENDING) || el.querySelector?.(ASKS);
652
873
  if (stick || waits) slot.scrollTop = slot.scrollHeight;
653
- scheduleRefresh();
874
+ // A waiting card and the asking text (refresh) land in one task.
875
+ if (waits) refresh();
876
+ else scheduleRefresh();
654
877
  // A card that waits for the user is brought into the stage's view: in
655
878
  // a short window the prompt and headline fill it and the card sat
656
- // below, out of sight (after the refresh, which hides the live slots).
657
- if (waits) requestAnimationFrame(() => requestAnimationFrame(() => revealSlot(slot)));
879
+ // below, out of sight (once the stage has grown for it).
880
+ if (waits) afterGrowth(() => reveal(slot));
658
881
  return true;
659
882
  },
660
883
  // Where the live timing line goes (the status row), null: the history.
661
- timingHost: () => (holding ? timingSlot : null),
884
+ timingHost: () => {
885
+ if (folding) finishFold();
886
+ return holding ? timingSlot : null;
887
+ },
888
+ // Before anything else goes into the history (app.js): a turn still
889
+ // sliding away gets there first.
890
+ settleFold: () => { if (folding) finishFold(); },
662
891
  // A turn is in the stage (running, or ended and not handed off yet):
663
892
  // nothing the turn does touches the history's scroll.
664
893
  holds: () => holding,
@@ -141,12 +141,14 @@ export function createStreamHandlers(deps) {
141
141
  if (display) await display.promise;
142
142
  turnDom.answerReady?.();
143
143
  },
144
- // An after_turn hook presented the answer (AnswerDisplay): it comes after
145
- // turn_completed, so re-read the answer once that read is done.
144
+ // An after_turn hook presented the answer (AnswerDisplay) or attached
145
+ // refs to it (AnswerRefs: refs, a count; display null when only refs):
146
+ // it comes after turn_completed, so re-read the answer once that read
147
+ // is done.
146
148
  answer_display: async (data) => {
147
149
  try {
148
150
  await liveTurn.endRead;
149
- if (!selected() || !data.display) return;
151
+ if (!selected() || (!data.display && !data.refs)) return;
150
152
  const d = await getSession(selected(), { tail: true, recent: true });
151
153
  if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
152
154
  } catch (_) {
@@ -33,7 +33,7 @@ export function liveDurationText(durationMs) {
33
33
 
34
34
  // A cancel's reason as the terminal UIs name it too (Formatting::
35
35
  // CANCEL_REASONS); anything else as it is. Both sides: spec/shared/labels_matrix.json.
36
- const CANCEL_REASONS = { ctrl_c: "Ctrl-C", user: "stopped", hook: "by a hook" };
36
+ const CANCEL_REASONS = { ctrl_c: "Ctrl-C", user: "stopped", hook: "by a hook", worker_stopped: "worker stopped" };
37
37
 
38
38
  // The line a canceled turn ends with: "■ canceled (stopped)", live from
39
39
  // turn_canceled's reason and after a re-render from the turn record's