samagotchi 0.67.0 → 0.68.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/docs/architecture.md +6 -3
  4. data/docs/internals/engine.md +58 -9
  5. data/docs/sessions.md +1 -1
  6. data/docs/testing.md +1 -1
  7. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  8. data/lib/samagotchi/effective_model.rb +283 -0
  9. data/lib/samagotchi/engine.rb +211 -358
  10. data/lib/samagotchi/level_setting_command.rb +99 -0
  11. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  12. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  13. data/lib/samagotchi/session.rb +22 -8
  14. data/lib/samagotchi/session_setting.rb +86 -0
  15. data/lib/samagotchi/thinking_command.rb +24 -76
  16. data/lib/samagotchi/thinking_notices.rb +169 -0
  17. data/lib/samagotchi/version.rb +1 -1
  18. data/lib/samagotchi/web/app.rb +55 -84
  19. data/lib/samagotchi/web/public/app.js +74 -505
  20. data/lib/samagotchi/web/public/bubbles.js +291 -0
  21. data/lib/samagotchi/web/public/cards_place.js +184 -0
  22. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  23. data/lib/samagotchi/web/public/command_list.js +12 -10
  24. data/lib/samagotchi/web/public/index.html +15 -7
  25. data/lib/samagotchi/web/public/info_bar.js +3 -3
  26. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  27. data/lib/samagotchi/web/public/init_row.js +21 -10
  28. data/lib/samagotchi/web/public/level_chip.js +29 -80
  29. data/lib/samagotchi/web/public/lists_view.js +64 -9
  30. data/lib/samagotchi/web/public/llm_context_chip.js +36 -105
  31. data/lib/samagotchi/web/public/model_picker.js +26 -22
  32. data/lib/samagotchi/web/public/ref_scan.js +1 -7
  33. data/lib/samagotchi/web/public/refs.js +13 -0
  34. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  35. data/lib/samagotchi/web/public/stage_model.js +33 -0
  36. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  37. data/lib/samagotchi/web/public/stage_view.js +56 -335
  38. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  39. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  40. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  41. data/lib/samagotchi/worker.rb +11 -16
  42. metadata +14 -1
@@ -12,6 +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 { createChipPopover } from "./chip_popover.js";
15
16
  import { html, nothing, render } from "./vendor/lit-html.js";
16
17
 
17
18
  const STRATEGIES = [
@@ -201,113 +202,40 @@ export function renderForm(target, summary, opts = {}) {
201
202
  render(formTemplate(summary, opts), target);
202
203
  }
203
204
 
204
- const EDGE = 16;
205
- const GAP = 8;
206
-
207
- // Where the popover goes for the chip's +rect+: above it, or below when
208
- // the room above is short of its height and below has more; capped to the
209
- // room on that side (it scrolls), never past the window's edges.
210
- // @return {{left, top?, bottom?, maxHeight?}}
211
- export function popoverPlace(rect, { width, height, innerWidth, innerHeight }) {
212
- const left = Math.max(EDGE, Math.min(rect.left, innerWidth - width - EDGE));
213
- const above = rect.top - GAP - EDGE;
214
- const below = innerHeight - rect.bottom - GAP - EDGE;
215
- const down = height > above && below > above;
216
- const room = Math.max(0, down ? below : above);
217
- const place = down ? { left, top: rect.bottom + GAP } : { left, bottom: Math.max(EDGE, innerHeight - rect.top + GAP) };
218
- return height > room ? { ...place, maxHeight: room } : place;
219
- }
220
-
221
- // The chip's popover. el: where the chip is drawn (#infoText, info_bar.js:
222
- // clicks are delegated); summary(): the open session's llm_context;
223
- // session(): the open session's id; running(): a turn runs; run(line):
224
- // sends the command.
205
+ // The chip's popover (chip_popover.js' shell). el: where the chip is drawn
206
+ // (#infoText, info_bar.js: clicks are delegated); summary(): the open
207
+ // session's llm_context; session(): the open session's id; running(): a
208
+ // turn runs; run(line): sends the command.
225
209
  // The start page's (kind NEW_CHAT): summary() is the picked model's,
226
210
  // choice() the pending choice, and choose(next) takes a Set's (null for
227
211
  // "follow the model") instead of a command running. id and anchor (the
228
212
  // chip's selector) keep the two controls apart.
229
213
  export function createLlmContextControl({ el, summary, session = () => null, running = () => false, run,
230
214
  kind = SESSION, choice = () => null, choose = null,
231
- id = "llmContextPopover", anchor: anchorSel = ".llmctx-chip",
215
+ id = "llmContextPopover", anchor = ".llmctx-chip",
232
216
  doc = document, win = window }) {
233
217
  const fresh = kind === NEW_CHAT;
234
- const pop = doc.createElement("div");
235
- pop.id = id;
236
- pop.className = "ctx-popover llmctx-popover";
237
- pop.setAttribute("role", "dialog");
238
- pop.hidden = true;
239
- doc.body.appendChild(pop);
240
- // The values the form opened with, and the session it is for.
218
+ // The values the form opened with.
241
219
  let initial = null;
242
- let openFor = null;
243
220
  // 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).
221
+ // the summary its "Now:" line reads.
246
222
  let shown = null;
247
223
  const draw = (changes = {}) => {
248
224
  shown = { ...shown, ...changes };
249
- renderForm(pop, shown.data, { ...shown.opts, now: shown.now, error: shown.error });
225
+ renderForm(shell.pop, shown.data, { ...shown.opts, now: shown.now, error: shown.error });
250
226
  };
251
227
 
252
- function place(anchor) {
253
- const width = Math.min(360, win.innerWidth - 32);
254
- pop.style.width = `${width}px`;
255
- pop.style.maxHeight = "";
256
- const at = popoverPlace(anchor.getBoundingClientRect(), { width, height: pop.offsetHeight,
257
- innerWidth: win.innerWidth, innerHeight: win.innerHeight });
258
- pop.style.left = `${at.left}px`;
259
- pop.style.top = at.top === undefined ? "" : `${at.top}px`;
260
- pop.style.bottom = at.bottom === undefined ? "" : `${at.bottom}px`;
261
- if (at.maxHeight !== undefined) pop.style.maxHeight = `${at.maxHeight}px`;
262
- }
263
-
264
- function open(anchor) {
265
- const data = summary();
266
- if (!data) return;
228
+ // A refresh (a newer summary): an open form keeps what the user typed;
229
+ // only its "Now:" line follows (the same template again: lit writes that
230
+ // line alone).
231
+ function show(data, opening) {
232
+ if (!opening) return draw({ now: data });
267
233
  initial = fresh ? ownValues({ own: choice() }) : ownValues(data);
268
- openFor = session();
269
234
  shown = { data, opts: { running: running(), kind, choice: choice() } };
270
235
  draw({ now: data, error: null });
271
- pop.hidden = false;
272
- place(anchor);
273
- }
274
-
275
- function close() {
276
- pop.hidden = true;
277
- initial = null;
278
- openFor = null;
279
- shown = null;
280
- render(nothing, pop);
281
236
  }
282
237
 
283
- function isOpen() { return !pop.hidden; }
284
-
285
- // A new summary (a command_ran, a re-read): an open form keeps what the
286
- // user typed; only its "Now:" line follows (the same template again: lit
287
- // writes that line alone). Another session closes it.
288
- function refresh() {
289
- if (!isOpen()) return;
290
- const data = summary();
291
- if (!data || session() !== openFor) return close();
292
- draw({ now: data });
293
- }
294
-
295
- el.addEventListener("click", (e) => {
296
- const anchor = e.target.closest(anchorSel);
297
- if (!anchor) return;
298
- e.stopPropagation();
299
- if (isOpen()) close();
300
- else open(anchor);
301
- });
302
- // An edit makes an error the last Set showed stale.
303
- const clearError = () => {
304
- if (shown?.error) draw({ error: null });
305
- };
306
- pop.addEventListener("input", clearError);
307
- pop.addEventListener("change", clearError);
308
- pop.addEventListener("submit", (e) => {
309
- e.preventDefault();
310
- const form = e.target;
238
+ function submit(form) {
311
239
  const values = { strategy: form.elements.strategy.value, apply: form.elements.apply.value,
312
240
  budget: form.elements.budget.value };
313
241
  if (fresh) {
@@ -318,29 +246,32 @@ export function createLlmContextControl({ el, summary, session = () => null, run
318
246
  draw({ error: err.message });
319
247
  return;
320
248
  }
321
- close();
249
+ shell.close();
322
250
  choose(next);
323
251
  return;
324
252
  }
325
253
  const line = commandLine(values, initial);
326
- close();
254
+ shell.close();
327
255
  if (line) run(line);
256
+ }
257
+
258
+ function act(name) {
259
+ if (name !== "reset") return;
260
+ shell.close();
261
+ if (fresh) choose(null);
262
+ else run(commandLine());
263
+ }
264
+
265
+ const shell = createChipPopover({
266
+ id, className: "ctx-popover llmctx-popover", width: 360, el, anchor, summary, session, show,
267
+ onClose: () => { initial = null; shown = null; }, onSubmit: submit, onAct: act, doc, win,
328
268
  });
329
- pop.addEventListener("click", (e) => {
330
- const act = e.target.closest("[data-act]")?.dataset.act;
331
- if (act === "close") close();
332
- else if (act === "reset") {
333
- close();
334
- if (fresh) choose(null);
335
- else run(commandLine());
336
- }
337
- });
338
- doc.addEventListener("click", (e) => {
339
- if (isOpen() && !pop.contains(e.target) && !e.target.closest?.(anchorSel)) close();
340
- });
341
- doc.addEventListener("keydown", (e) => {
342
- if (e.key === "Escape" && isOpen()) close();
343
- });
269
+ // An edit makes an error the last Set showed stale.
270
+ const clearError = () => {
271
+ if (shown?.error) draw({ error: null });
272
+ };
273
+ shell.pop.addEventListener("input", clearError);
274
+ shell.pop.addEventListener("change", clearError);
344
275
 
345
- return { close, refresh, get open() { return isOpen(); } };
276
+ return { close: shell.close, refresh: shell.refresh, get open() { return shell.isOpen(); } };
346
277
  }
@@ -9,26 +9,32 @@
9
9
  // has it. The search input keeps the focus:
10
10
  // ↑/↓ PageUp/PageDown move, ⏎ picks, Esc and Tab close without a change.
11
11
 
12
- import { escapeHtml } from "./format.js";
13
12
  import { moveSelection } from "./command_complete.js";
14
13
  import {
15
14
  MODEL_KEY, RECENT_KEY, groupRows, matchModels, modelRows, pickModel, pushRecent, recentRows, rowNote, rowTitle,
16
15
  } from "./model_pick.js";
16
+ import { html, nothing, render as draw } from "./vendor/lit-html.js";
17
17
 
18
18
  const PAGE = 8;
19
19
  const MAX_HEIGHT = 360;
20
20
  const MIN_ROOM = 200;
21
21
  const GAP = 8;
22
22
 
23
- // The text with the [start, end) ranges in <mark>, escaped slice by slice.
24
- function markedHtml(text, marks) {
25
- let html = "";
23
+ // The text with the [start, end) ranges in <mark> (lit-html parts).
24
+ function markedTemplate(text, marks) {
25
+ const parts = [];
26
26
  let at = 0;
27
27
  for (const [a, b] of marks || []) {
28
- html += `${escapeHtml(text.slice(at, a))}<mark>${escapeHtml(text.slice(a, b))}</mark>`;
28
+ parts.push(text.slice(at, a), html`<mark>${text.slice(a, b)}</mark>`);
29
29
  at = b;
30
30
  }
31
- return html + escapeHtml(text.slice(at));
31
+ parts.push(text.slice(at));
32
+ return parts;
33
+ }
34
+
35
+ // A group's head ("Recent", a host with its notes).
36
+ function headTemplate(label, note = "") {
37
+ return html`<div class="model-group" role="presentation">${label}${note ? html` <span>${note}</span>` : nothing}</div>`;
32
38
  }
33
39
 
34
40
  function readRecent() {
@@ -58,40 +64,38 @@ export function createModelPicker({ button, panel, input, list, composer, center
58
64
  button.classList.toggle("unavailable", rows.some((r) => r.name === name && r.unavailable === true));
59
65
  }
60
66
 
61
- function rowHtml(row, i, { hostMarks = [], idMarks = [], showHost = !row.isDefaultHost } = {}) {
62
- const host = showHost ? `<span class="model-host">${markedHtml(row.host, hostMarks)}</span><span class="model-dot"> · </span>` : "";
67
+ // One option row. The active one is drawn so here and kept so by
68
+ // showActive (the arrows and the pointer move it without a redraw).
69
+ function rowTemplate(row, i, { hostMarks = [], idMarks = [], showHost = !row.isDefaultHost } = {}) {
70
+ const host = showHost
71
+ ? html`<span class="model-host">${markedTemplate(row.host, hostMarks)}</span><span class="model-dot"> · </span>` : nothing;
63
72
  const noteText = rowNote(row, defaultName);
64
- const note = noteText ? `<span class="model-note">${noteText}</span>` : "";
73
+ const note = noteText ? html`<span class="model-note">${noteText}</span>` : nothing;
65
74
  const cls = `model-option${i === active ? " active" : ""}${row.name === chosen ? " chosen" : ""}${row.unavailable ? " unavailable" : ""}`;
66
- const titleText = rowTitle(row);
67
- const title = titleText ? ` title="${escapeHtml(titleText)}"` : "";
68
- return `<div class="${cls}" role="option" id="model-option-${i}" aria-selected="${i === active}" data-index="${i}"${title}>` +
69
- `<span class="model-id">${host}${markedHtml(row.id, idMarks)}</span>${note}</div>`;
75
+ return html`<div class=${cls} role="option" id=${`model-option-${i}`} aria-selected=${String(i === active)} data-index=${i} title=${rowTitle(row) || nothing}><span class="model-id">${host}${markedTemplate(row.id, idMarks)}</span>${note}</div>`;
70
76
  }
71
77
 
72
78
  function render() {
73
79
  const query = input.value.trim();
74
- let html = "";
80
+ const parts = [];
75
81
  shown = [];
76
- const add = (row, opts) => { html += rowHtml(row, shown.length, opts); shown.push(row); };
82
+ const add = (row, opts) => { parts.push(rowTemplate(row, shown.length, opts)); shown.push(row); };
77
83
  if (query) {
78
84
  for (const m of matchModels(rows, query)) add(m.row, { hostMarks: m.hostMarks, idMarks: m.idMarks });
79
- if (!shown.length) html = `<div class="model-empty" role="presentation">Nothing matches</div>`;
85
+ if (!shown.length) parts.push(html`<div class="model-empty" role="presentation">Nothing matches</div>`);
80
86
  } else {
81
87
  const recent = recentRows(rows, readRecent());
82
- const head = (label, note = "") =>
83
- `<div class="model-group" role="presentation">${escapeHtml(label)}${note ? ` <span>${escapeHtml(note)}</span>` : ""}</div>`;
84
88
  if (recent.length) {
85
- html += head("Recent");
89
+ parts.push(headTemplate("Recent"));
86
90
  recent.forEach((row) => add(row));
87
91
  }
88
92
  for (const group of groupRows(rows)) {
89
- html += head(group.host, [group.isDefault ? "default host" : "", group.unavailable ? "host down" : ""].filter(Boolean).join(" · "));
93
+ parts.push(headTemplate(group.host, [group.isDefault ? "default host" : "", group.unavailable ? "host down" : ""].filter(Boolean).join(" · ")));
90
94
  group.rows.forEach((row) => add(row, { showHost: false }));
91
95
  }
92
96
  }
93
97
  if (active >= shown.length) active = 0;
94
- list.innerHTML = html;
98
+ draw(parts, list);
95
99
  showActive();
96
100
  }
97
101
 
@@ -144,7 +148,7 @@ export function createModelPicker({ button, panel, input, list, composer, center
144
148
  function close({ refocus = false } = {}) {
145
149
  if (!isOpen()) return;
146
150
  panel.hidden = true;
147
- list.innerHTML = "";
151
+ draw(nothing, list);
148
152
  shown = [];
149
153
  button.setAttribute("aria-expanded", "false");
150
154
  input.setAttribute("aria-expanded", "false");
@@ -7,7 +7,7 @@
7
7
  // are left alone, inline code only when it is the ref alone, a bare URL's
8
8
  // text too.
9
9
 
10
- import { refAttrs } from "./refs.js";
10
+ import { refSpan } from "./refs.js";
11
11
 
12
12
  // A path (no spaces), a line, an optional end line and an optional column
13
13
  // (kept in the text, not used). LineLinker::REF.
@@ -168,12 +168,6 @@ function wrapRefs(node, hits) {
168
168
  node.replaceWith(frag);
169
169
  }
170
170
 
171
- function refSpan(doc, ref) {
172
- const holder = doc.createElement("div");
173
- holder.innerHTML = `<span class="ref"${refAttrs(ref)}></span>`;
174
- return holder.firstChild;
175
- }
176
-
177
171
  // [start, end] of each fenced block (an unclosed one runs to the end).
178
172
  function fencedBlocks(text) {
179
173
  const blocks = [];
@@ -32,6 +32,19 @@ export function refAttrs(ref) {
32
32
  return ` data-ref-kind="${escapeHtml(ref.kind)}"${attr("path", ref.path)}${attr("line", ref.line)}${attr("end-line", ref.end_line)}`;
33
33
  }
34
34
 
35
+ // A span with refAttrs' ref class and data attributes, for a page that
36
+ // builds DOM (ref_scan.js).
37
+ export function refSpan(doc, ref) {
38
+ const span = doc.createElement("span");
39
+ span.className = "ref";
40
+ if (!ref?.kind) return span;
41
+ span.dataset.refKind = ref.kind;
42
+ for (const [name, value] of [["refPath", ref.path], ["refLine", ref.line], ["refEndLine", ref.end_line]]) {
43
+ if (value) span.dataset[name] = String(value);
44
+ }
45
+ return span;
46
+ }
47
+
35
48
  // A kind, as the server's Ref allows it (Ref::KIND).
36
49
  const KIND = /^[a-z][a-z0-9_-]{0,31}$/;
37
50
 
@@ -0,0 +1,179 @@
1
+ // The stage view's cap and frame (stage_view.js): the one place that
2
+ // writes the stage's height, and the stage's own scrolls. The cap: at most
3
+ // ~60% of the page (~80% with the cloud open), and never above the session
4
+ // panel. The frame: held and grow-only while the stage holds a turn
5
+ // (stage_model.js frameTarget), its growth eased. The stage's state comes
6
+ // in as getters (+isHolding+, +isCollapsed+, +isCloudOpen+); what the stage
7
+ // changes of the frame's goes through resetHeld and forgetContent.
8
+ import { frameTarget } from "./stage_model.js";
9
+ import { easeScrollTo } from "./scroll.js";
10
+
11
+ // The stage grows (a card, the long answer, the cloud opening) with a short
12
+ // ease: deliberate, never a snap.
13
+ const GROW_MS = 180;
14
+ const GROW_EASE = "cubic-bezier(.2,.7,.3,1)";
15
+ // The room between the stage and the composer card (index.html's margin-bottom).
16
+ const STAGE_GAP = 10;
17
+
18
+ export function reducedMotion() {
19
+ return matchMedia("(prefers-reduced-motion: reduce)").matches;
20
+ }
21
+
22
+ // +observed+: the elements whose size changes refit the frame (the stage's
23
+ // slots, in the order they are observed).
24
+ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, holdEl, asking, extras, dockEl, centerEl, composerEl, observed, isHolding, isCollapsed, isCloudOpen }) {
25
+ // The frame (fitFrame): the height held while the turn runs and the
26
+ // tallest content seen.
27
+ let held = null;
28
+ let maxContent = 0;
29
+ let growth = null; // the running ease of the frame's growth (an Animation)
30
+ let growthTo = 0; // its end height
31
+
32
+ // The stage's own scrolls, eased (instant with reduced motion).
33
+ function easeStage(top) {
34
+ if (Math.abs(top - scroller.scrollTop) < 1) return;
35
+ easeScrollTo(scroller, top, { reduce: reducedMotion() });
36
+ }
37
+
38
+ // +fn+ once the frame has grown for a change made now: two frames on (the
39
+ // ResizeObserver starts the ease in the frame after the change), then at
40
+ // the ease's end.
41
+ function afterGrowth(fn) {
42
+ requestAnimationFrame(() => requestAnimationFrame(() => {
43
+ if (growth?.playState === "running") growth.finished.then(fn, fn);
44
+ else fn();
45
+ }));
46
+ }
47
+
48
+ // ── the cap: at most ~60% of the page (~80% with the cloud open), and
49
+ // never above the session panel (the dock is absolute: a tall composer or
50
+ // a phone's keyboard would push the stage's top out) ────────────────────
51
+
52
+ // Where the stage's scroll goes so +slot+ (the scroller's child) shows:
53
+ // its end in sight, and the asking text's top too when it and the slot
54
+ // (a waiting card) fit together (the reply reads from its start, the card
55
+ // still in sight). null: nothing to show.
56
+ function revealTop(slot) {
57
+ if (stage.hidden || slot.hidden) return null;
58
+ let top = Math.max(scroller.scrollTop, slot.offsetTop + slot.offsetHeight - scroller.clientHeight);
59
+ if (!asking.hidden && asking.offsetTop < top && slot.offsetTop + slot.offsetHeight - asking.offsetTop <= scroller.clientHeight) top = asking.offsetTop;
60
+ return top;
61
+ }
62
+
63
+ // Scrolls the stage to show +slot+: eased, or at once (+now+: the cap
64
+ // dropped under it).
65
+ function reveal(slot, { now = false } = {}) {
66
+ const top = revealTop(slot);
67
+ if (top === null) return;
68
+ if (now) scroller.scrollTop = top;
69
+ else easeStage(top);
70
+ }
71
+
72
+ function fitCap() {
73
+ if (stage.hidden) return;
74
+ // The composer card and the gap above it (the stage floats over them,
75
+ // out of the dock's flow).
76
+ const rest = dockEl.offsetHeight + STAGE_GAP;
77
+ const room = centerEl.clientHeight - rest - 32;
78
+ const share = window.innerHeight * (isCloudOpen() ? 0.8 : 0.6);
79
+ const cap = Math.max(96, Math.round(Math.min(share, room)));
80
+ const was = capPx();
81
+ if (cap === was) return;
82
+ stage.style.setProperty("--stage-max", `${cap}px`);
83
+ fitFrame();
84
+ // A smaller cap (a phone's keyboard came up) keeps a waiting card in
85
+ // sight: the asking text above it would push it out. A larger one
86
+ // leaves the reader's scroll alone.
87
+ if (cap < was && stage.classList.contains("waiting")) {
88
+ requestAnimationFrame(() => reveal(extras, { now: true }));
89
+ }
90
+ }
91
+ new ResizeObserver(() => fitCap()).observe(centerEl);
92
+ new ResizeObserver(() => fitCap()).observe(composerEl);
93
+ window.addEventListener("resize", fitCap);
94
+
95
+ // ── the frame: the one place that writes the stage's height ────────────
96
+ // Nothing in the stage view jumps. The rules: the stage is out of the
97
+ // dock's flow (index.html), so its height never moves the composer; while
98
+ // it holds a turn its height is held and grow-only (frameTarget), so a
99
+ // content change happens inside it; growth eases (frameTo); the chip is a
100
+ // footer no content can push; the hand-off releases the turn at once
101
+ // (releaseTurn) and empties the stage only after it slid away under the
102
+ // composer (a transform, then finishFold/clearStage).
103
+
104
+ // The stage's height, +px+. +animate+: a growth eases (GROW_MS, Web
105
+ // Animations: the running ease is +growth+), unless motion is reduced or
106
+ // the stage is hidden or collapsed; a shrink (the cap dropped) is at once.
107
+ function frameTo(px, { animate = false } = {}) {
108
+ const from = stage.offsetHeight;
109
+ stage.style.height = `${px}px`;
110
+ if (px === from) return;
111
+ const ease = animate && px > from && !isCollapsed() && !stage.hidden && !document.hidden && !reducedMotion();
112
+ if (growth && (!ease || growth.playState !== "running" || growthTo !== px)) {
113
+ growth.cancel();
114
+ growth = null;
115
+ }
116
+ if (!ease || growth) return;
117
+ growth = stage.animate([{ height: `${from}px` }, { height: `${px}px` }], { duration: GROW_MS, easing: GROW_EASE });
118
+ growthTo = px;
119
+ const run = growth;
120
+ run.finished.then(() => { if (growth === run) growth = null; }, () => {});
121
+ }
122
+
123
+ // Back to the content's own height (under the cap).
124
+ function frameFree() {
125
+ growth?.cancel();
126
+ growth = null;
127
+ stage.style.height = "";
128
+ }
129
+
130
+ function capPx() {
131
+ return parseFloat(stage.style.getPropertyValue("--stage-max")) || Infinity;
132
+ }
133
+
134
+ // The scroller's content height: its last shown slot's end (offsetTop is
135
+ // the scroller's own, whatever its scroll) and its bottom padding.
136
+ function contentHeight() {
137
+ const css = getComputedStyle(scroller);
138
+ let end = parseFloat(css.paddingTop) || 0;
139
+ for (const el of scroller.children) {
140
+ if (el === holdEl || el.hidden || !el.getClientRects().length) continue;
141
+ end = el.offsetTop + el.offsetHeight;
142
+ }
143
+ return end + (parseFloat(css.paddingBottom) || 0);
144
+ }
145
+
146
+ // Holds the frame while the stage holds a turn (stage_model.js
147
+ // frameTarget: grow-only, clamped by the cap; free when collapsed or
148
+ // released). Run from a ResizeObserver on the slots (after layout, before
149
+ // paint: no frame shows the content's own height) and when the cap moves.
150
+ function fitFrame() {
151
+ if (stage.hidden || stage.classList.contains("leaving")) return;
152
+ const content = scroller.hidden ? 0 : contentHeight();
153
+ const holding = isHolding();
154
+ const collapsed = isCollapsed();
155
+ const holds = holding && !collapsed;
156
+ maxContent = holds ? Math.max(maxContent, content) : 0;
157
+ const mark = `${Math.max(0, Math.round(maxContent) - 1)}px`;
158
+ if (holdEl.style.top !== mark) holdEl.style.top = mark;
159
+ const natural = (stage.offsetHeight - stage.clientHeight) + status.offsetHeight + content + (cloud.hidden || collapsed ? 0 : cloud.offsetHeight)
160
+ + queuedSlot.offsetHeight;
161
+ held = frameTarget({ natural, held, cap: capPx(), holding, collapsed });
162
+ if (held === null) frameFree();
163
+ else frameTo(held, { animate: true });
164
+ }
165
+ const frameObserver = new ResizeObserver(() => fitFrame());
166
+ for (const el of observed) frameObserver.observe(el);
167
+
168
+ return {
169
+ fitCap,
170
+ frameFree,
171
+ afterGrowth,
172
+ easeStage,
173
+ reveal,
174
+ // A new turn, or none: no height held yet.
175
+ resetHeld: () => { held = null; },
176
+ // The tallest content seen is forgotten (a new turn, the cloud closed by hand).
177
+ forgetContent: () => { maxContent = 0; },
178
+ };
179
+ }
@@ -124,6 +124,39 @@ export function trailFlashText(flash, mark) {
124
124
  return { text, hover: text };
125
125
  }
126
126
 
127
+ // The status row's mark for an ended turn's phase (liveSlots), none while
128
+ // it runs.
129
+ export const MARKS = { answered: "✓", "no answer": "∅", canceled: "■", failed: "✕" };
130
+ // The mark of a flash in the trail: a warn notice, a hook's info notice,
131
+ // an applied LLM context edit (✂), a steer-cut row and a plugin's nudge (↪).
132
+ export const FLASH_MARKS = { warn: "⚠", info: "ℹ", edit: "✂", steer: "↪" };
133
+
134
+ // "1 step", "3 steps".
135
+ export function stepsText(n) {
136
+ return `${n} step${n === 1 ? "" : "s"}`;
137
+ }
138
+
139
+ // The chip's label: the steps, the calls, and the turn's time when the
140
+ // status row's timing line isn't there to say it (+elapsed+, "" for none).
141
+ export function chipLabelText({ steps, calls, elapsed = "" }) {
142
+ return `${stepsText(steps)} · ${calls} tool call${calls === 1 ? "" : "s"}${elapsed ? ` · ${elapsed}` : ""}`;
143
+ }
144
+
145
+ // The status row's step: running, the step it is on; ended, the count,
146
+ // unless the chip says it (+chipHidden+: no chip shows).
147
+ export function stepSlotText({ step, ended, chipHidden }) {
148
+ return !ended ? `step ${step}` : chipHidden ? stepsText(step) : "";
149
+ }
150
+
151
+ // The collapsed stage's mini tool in the status row: the running call's
152
+ // name and title, its full command as the hover; "" for both otherwise.
153
+ export function miniToolText({ collapsed, tool }) {
154
+ return {
155
+ text: collapsed && tool ? `${tool.name} ${tool.title}` : "",
156
+ title: (collapsed && tool?.command) || "",
157
+ };
158
+ }
159
+
127
160
  // While the first step has no thinking and no tool rows the turn is plain:
128
161
  // its text streams in the stage's answer slot, as the turn view's
129
162
  // chrome-less block streams like a bubble.