samagotchi 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/README.md +13 -2
  4. data/bin/chi +29 -39
  5. data/docs/cli.md +135 -73
  6. data/docs/configuration.md +15 -20
  7. data/docs/hooks.md +1 -1
  8. data/docs/memory.md +40 -0
  9. data/docs/plugins.md +50 -0
  10. data/docs/releasing.md +9 -6
  11. data/docs/sessions.md +3 -3
  12. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  13. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  15. data/lib/samagotchi/bridge.rb +16 -11
  16. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  17. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  18. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -8
  19. data/lib/samagotchi/bundles/system/identity.md +5 -0
  20. data/lib/samagotchi/bundles/system/manifest.yml +5 -5
  21. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  22. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  23. data/lib/samagotchi/client.rb +16 -20
  24. data/lib/samagotchi/config.rb +40 -100
  25. data/lib/samagotchi/engine.rb +50 -354
  26. data/lib/samagotchi/kernel_loop.rb +33 -44
  27. data/lib/samagotchi/live_versions.rb +7 -1
  28. data/lib/samagotchi/llm/errors.rb +17 -0
  29. data/lib/samagotchi/llm/http.rb +4 -18
  30. data/lib/samagotchi/llm/openai_chat.rb +17 -0
  31. data/lib/samagotchi/model_profile.rb +4 -10
  32. data/lib/samagotchi/note_command.rb +2 -1
  33. data/lib/samagotchi/reply_wait.rb +48 -4
  34. data/lib/samagotchi/self_report.rb +20 -2
  35. data/lib/samagotchi/send_command.rb +84 -6
  36. data/lib/samagotchi/session.rb +4 -2
  37. data/lib/samagotchi/session_manager.rb +18 -37
  38. data/lib/samagotchi/system_prompt.rb +403 -0
  39. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +120 -83
  41. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  42. data/lib/samagotchi/terminal_ui/event_renderer.rb +23 -7
  43. data/lib/samagotchi/terminal_ui/formatting.rb +32 -22
  44. data/lib/samagotchi/terminal_ui/input_support.rb +3 -4
  45. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  46. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  47. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  48. data/lib/samagotchi/terminal_ui.rb +95 -690
  49. data/lib/samagotchi/thinking.rb +11 -0
  50. data/lib/samagotchi/tool_activity.rb +52 -2
  51. data/lib/samagotchi/tool_runner.rb +3 -0
  52. data/lib/samagotchi/tools/execute.rb +3 -3
  53. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  54. data/lib/samagotchi/tools/read.rb +4 -4
  55. data/lib/samagotchi/update_command.rb +2 -1
  56. data/lib/samagotchi/version.rb +1 -1
  57. data/lib/samagotchi/web/app.rb +170 -35
  58. data/lib/samagotchi/web/lan.rb +99 -0
  59. data/lib/samagotchi/web/message_parts.rb +15 -11
  60. data/lib/samagotchi/web/public/activity.js +7 -0
  61. data/lib/samagotchi/web/public/app.js +99 -54
  62. data/lib/samagotchi/web/public/chat_view.js +5 -1
  63. data/lib/samagotchi/web/public/index.html +163 -17
  64. data/lib/samagotchi/web/public/model_pick.js +136 -0
  65. data/lib/samagotchi/web/public/model_picker.js +224 -0
  66. data/lib/samagotchi/web/public/notify.js +10 -0
  67. data/lib/samagotchi/web/public/stage_model.js +110 -0
  68. data/lib/samagotchi/web/public/stage_view.js +580 -0
  69. data/lib/samagotchi/web/public/timing.js +6 -2
  70. data/lib/samagotchi/web/public/turn_events.js +9 -5
  71. data/lib/samagotchi/web/public/turn_model.js +11 -3
  72. data/lib/samagotchi/web/public/turn_view.js +74 -19
  73. data/lib/samagotchi/web/qr.rb +40 -0
  74. data/lib/samagotchi/web/server.rb +101 -11
  75. data/lib/samagotchi/web/token.rb +97 -0
  76. metadata +27 -3
  77. data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
@@ -0,0 +1,110 @@
1
+ // The stage view's pure parts (web.view: stage): what the live slots show
2
+ // for a turn (turn_model.js's), when a finished turn hands off into the
3
+ // history, and in what order its pieces go there. No DOM, so `node --test`
4
+ // covers the rules; stage_view.js draws them.
5
+ import { toolName } from "./activity.js";
6
+
7
+ const READ_LIKE = new Set(["read", "memory_read", "web_fetch", "task_get", "task_list", "list_sessions"]);
8
+ const EDITS = new Set(["edit", "write", "memory_write"]);
9
+ const EXECS = new Set(["execute", "task_create", "task_wait", "task_stop", "delegate", "delegate_result"]);
10
+ const FILE_TOOLS = new Set(["read", "edit", "write"]);
11
+ const TRAIL = 3;
12
+ // The hand-off waits this long after the stage was last in use.
13
+ export const QUIET_MS = 1500;
14
+ // A touch, wheel or scroll counts as use this long (phones have no hover).
15
+ export const TOUCH_MS = 4000;
16
+
17
+ // A tool's tick colour: read-like, edit, exec or other.
18
+ export function toolKind(name) {
19
+ if (READ_LIKE.has(name)) return "read";
20
+ if (EDITS.has(name)) return "edit";
21
+ if (EXECS.has(name)) return "exec";
22
+ return "other";
23
+ }
24
+
25
+ function rowTitle(row) {
26
+ return row.title || row.params || "";
27
+ }
28
+
29
+ // While the first step has no thinking and no tool rows the turn is plain:
30
+ // its text streams in the stage's answer slot, as the turn view's
31
+ // chrome-less block streams like a bubble.
32
+ export function isPlain(turn) {
33
+ if (!turn || turn.ended || turn.gens.length > 1) return false;
34
+ const [gen] = turn.gens;
35
+ return !gen || (!gen.thinking && gen.tools.length === 0);
36
+ }
37
+
38
+ const END_PHASES = { completed: "answered", canceled: "canceled" };
39
+
40
+ // What the live slots show for +turn+:
41
+ // phase the status row's word;
42
+ // step the step count (at the end as the block summary counts:
43
+ // an answer-only generation is no step);
44
+ // headline the newest narration sentence (+narration+, the view's sentence
45
+ // feed), else the newest thinking one (+thinking+, its ticker),
46
+ // else "…";
47
+ // tool the running call ({name, title, kind}) or null;
48
+ // trail the last three finished calls, newest last (a file's title
49
+ // without its dir);
50
+ // ticks one per call: {kind, status, step (the gen's index)}.
51
+ // +pending+: a card waits for the user; +ended+: the turn's end kind.
52
+ export function liveSlots(turn, { narration = "", thinking = "", pending = false, ended = null } = {}) {
53
+ const gens = turn?.gens || [];
54
+ const rows = gens.flatMap((gen, step) => gen.tools.map((row) => ({ row, step })));
55
+ const running = [...rows].reverse().find(({ row }) => row.status === "running")?.row || null;
56
+ const current = gens[gens.length - 1];
57
+ let phase;
58
+ if (ended) phase = END_PHASES[ended] || "failed";
59
+ else if (pending) phase = "waiting for you";
60
+ else if (running) phase = "running a tool";
61
+ else if (current?.text && isPlain(turn)) phase = "writing the answer";
62
+ else if (current?.text && !current.textDone) phase = "writing";
63
+ else phase = "thinking";
64
+ let headline = { text: "…", thinking: false };
65
+ if (narration) headline = { text: narration, thinking: false };
66
+ else if (thinking) headline = { text: thinking, thinking: true };
67
+ const trail = rows.filter(({ row }) => row.status !== "running").slice(-TRAIL).map(({ row }) => {
68
+ const title = rowTitle(row);
69
+ return { name: toolName(row), title: FILE_TOOLS.has(row.tool) ? title.split("/").pop() : title, status: row.status };
70
+ });
71
+ return {
72
+ phase,
73
+ step: ended ? gens.filter((g) => !g.answer || g.thinking || g.tools.length).length : gens.length,
74
+ headline,
75
+ tool: running ? { name: toolName(running), title: rowTitle(running), kind: toolKind(running.tool) } : null,
76
+ trail,
77
+ ticks: rows.map(({ row, step }) => ({ kind: toolKind(row.tool), status: row.status, step })),
78
+ };
79
+ }
80
+
81
+ // In use: the pointer over the stage, focus or a selection in it, or a
82
+ // touch/wheel/scroll in it within TOUCH_MS.
83
+ export function inUseFrom({ pointerIn, lastTouchMs, focusInside, selectionInside, nowMs }) {
84
+ return !!(pointerIn || focusInside || selectionInside || (lastTouchMs && nowMs - lastTouchMs < TOUCH_MS));
85
+ }
86
+
87
+ // One tick of the hand-off watcher: due once the turn has ended, nothing
88
+ // is pending (a card, the turn-end read) and the stage has not been in use
89
+ // for QUIET_MS. Returns the next quiet clock with it.
90
+ export function handOffDue({ ended, pending, inUse, quietSinceMs, nowMs }) {
91
+ if (!ended || pending || inUse) return { due: false, quietSinceMs: null };
92
+ const since = quietSinceMs ?? nowMs;
93
+ return { due: nowMs - since >= QUIET_MS, quietSinceMs: since };
94
+ }
95
+
96
+ // A handed-off turn's nodes in the order the turn view leaves them: the
97
+ // prompt bubble (none for a reminder turn), the block, the extras in
98
+ // arrival order, the answer's pieces, the timing line, the end line, the
99
+ // kept cards.
100
+ export function handOffOrder({ prompt = null, block = null, extras = [], answer = [], timing = null, end = null, kept = [] }) {
101
+ return [prompt, block, ...extras, ...answer, timing, end, ...kept].filter(Boolean);
102
+ }
103
+
104
+ // Where a row app.js would append goes: the stage's extras while a turn is
105
+ // live (and not handing off), else the history; the recap always the
106
+ // history (it is idle-only).
107
+ export function placeFor({ live, handingOff, kind }) {
108
+ if (kind === "recap") return "history";
109
+ return live && !handingOff ? "stage" : "history";
110
+ }
@@ -0,0 +1,580 @@
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
3
+ // what happens now in fixed slots (a status row, the prompt, a two-line
4
+ // headline, the running tool, a one-line trail of the last calls), the
5
+ // 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
9
+ // in use, everything moves into the history in the turn view's order (the
10
+ // hand-off), so a handed-off turn reads like the turn view's and like its
11
+ // reload. Slots are patched in place, never rebuilt from HTML: a selection
12
+ // and annotate survive. The rules are pure in stage_model.js.
13
+ import { createTurnView } from "./turn_view.js";
14
+ import { handOffDue, handOffOrder, inUseFrom, isPlain, liveSlots, placeFor } from "./stage_model.js";
15
+ import { splitSentences } from "./sentences.js";
16
+ import { formatDuration } from "./timing.js";
17
+
18
+ const COLLAPSE_KEY = "chi_stage_collapsed";
19
+ const WATCH_MS = 200;
20
+ // The turn-end read (markdown, answer_display) settles within this, or the
21
+ // hand-off stops waiting for it.
22
+ const SETTLE_CAP_MS = 8000;
23
+ const FOLD_MS = 420;
24
+ const FLASH_MS = 4000;
25
+ const PENDING = ".bubble.question:not(.answered):not(.cancelled), .bubble.continue:not(.resolved)";
26
+ const MARKS = { answered: "✓", canceled: "■", failed: "✕" };
27
+
28
+ function node(tag, cls, parent = null) {
29
+ const el = document.createElement(tag);
30
+ if (cls) el.className = cls;
31
+ if (parent) parent.appendChild(el);
32
+ return el;
33
+ }
34
+
35
+ // The step's newest narration sentence: the end of a finished text is a
36
+ // boundary (its last line may have no period), a live one's not yet.
37
+ function headlineOf(text, done) {
38
+ const { sentences, rest } = splitSentences(text, { atEnd: true });
39
+ const fragment = rest.replace(/\s+/g, " ").trim();
40
+ if (done && fragment) return fragment;
41
+ if (sentences.length) return sentences[sentences.length - 1];
42
+ return fragment.length > 160 ? fragment : "";
43
+ }
44
+
45
+ function readCollapsed() {
46
+ try { return localStorage.getItem(COLLAPSE_KEY) === "1"; } catch (_) { return false; }
47
+ }
48
+
49
+ // +historyNearBottom+: the history's own follow check (the hand-off scrolls
50
+ // the history only for a reader at its end). +dockEl+, +centerEl+ and
51
+ // +composerEl+ size the stage's cap.
52
+ export function createStageView({ historyEl, appendToHistory, isNearBottom: historyNearBottom, sawText, sessionId, dockEl, centerEl, composerEl }) {
53
+ // The dock becomes a clear column: the stage's card, then the composer's
54
+ // (its rows moved into .dock-card, which wears the glass and the focus ring).
55
+ document.body.classList.add("stage-view");
56
+ const card = node("div", "dock-card");
57
+ card.append(...dockEl.children);
58
+ dockEl.appendChild(card);
59
+ const stage = node("section", "turn-stage");
60
+ stage.id = "turnStage";
61
+ stage.hidden = true;
62
+ stage.setAttribute("aria-label", "Running turn");
63
+ dockEl.prepend(stage);
64
+
65
+ const status = node("div", "ts-status", stage);
66
+ const mark = node("span", "ts-mark", status);
67
+ const phaseEl = node("span", "ts-phase", status);
68
+ const stepEl = node("span", "ts-step", status);
69
+ const timingSlot = node("span", "ts-timing", status);
70
+ const miniTool = node("span", "ts-mini", status);
71
+ const defer = node("span", "ts-defer", status);
72
+ defer.textContent = "moves up when you leave";
73
+ const fold = node("button", "ts-fold", status);
74
+ fold.type = "button";
75
+
76
+ const scroller = node("div", "ts-scroll", stage);
77
+ const promptSlot = node("div", "ts-prompt", scroller);
78
+ promptSlot.title = "Show the whole prompt";
79
+ const live = node("div", "ts-live", scroller);
80
+ const headline = node("div", "ts-headline", live);
81
+ headline.tabIndex = 0;
82
+ headline.setAttribute("role", "button");
83
+ headline.title = "Show this step's reasoning";
84
+ const now = node("div", "ts-now", live);
85
+ const trail = node("div", "ts-trail", live);
86
+ const extras = node("div", "ts-extras", scroller);
87
+ const answer = node("div", "ts-answer", scroller);
88
+ const tail = node("div", "ts-tail", scroller);
89
+ const cloud = node("div", "ts-cloud", scroller);
90
+ const chip = node("button", "ts-chip", cloud);
91
+ chip.type = "button";
92
+ const chipLabel = node("span", "ts-chip-label", chip);
93
+ const ticks = node("span", "ts-ticks", chip);
94
+ const cloudBody = node("div", "ts-cloud-body", cloud);
95
+ cloudBody.hidden = true;
96
+
97
+ // The turn in the stage, from turnStarted until the hand-off (or a reset).
98
+ let holding = false;
99
+ let endKind = null;
100
+ let settled = true;
101
+ let settleTimer = null;
102
+ let startedAt = 0;
103
+ let endedAt = 0;
104
+ let thinkingLine = null; // { step, text } from the live step's ticker
105
+ let flash = null; // { text, until } a warn notice in the trail
106
+ let collapsed = readCollapsed();
107
+ let cloudOpen = false;
108
+ let rafPending = false;
109
+ let foldTimer = null;
110
+ let watchTimer = null;
111
+ let quietSince = null;
112
+ let pointerIn = false;
113
+ let lastTouch = 0;
114
+ // What each slot shows, so an unchanged slot is left alone.
115
+ const shown = { headline: null, now: null, trail: null };
116
+
117
+ const view = createTurnView({
118
+ historyEl,
119
+ appendToHistory,
120
+ sawText,
121
+ sessionId,
122
+ // The newest step is on top of the cloud: "near the bottom" is near its top.
123
+ isNearBottom: () => cloudBody.scrollTop < 24,
124
+ follow: () => { cloudBody.scrollTop = 0; },
125
+ // The block goes into the cloud; a plain turn's (text, no thinking, no
126
+ // tool, one step) streams in the answer slot until it turns out to be
127
+ // more (refresh moves it).
128
+ blockHost: (el) => cloudBody.appendChild(el),
129
+ placeAnswer: (pieces, { plain, blockEl }) => {
130
+ if (plain) blockEl.remove();
131
+ answer.append(...pieces);
132
+ showAnswerTop();
133
+ },
134
+ useHold: false,
135
+ newestFirst: true,
136
+ onChange: () => scheduleRefresh(),
137
+ onThinkingLine: (text) => {
138
+ const { turn } = view.current();
139
+ thinkingLine = { step: turn ? turn.gens.length : 0, text };
140
+ scheduleRefresh();
141
+ },
142
+ });
143
+
144
+ // ── slots ────────────────────────────────────────────────────────────────
145
+
146
+ function scheduleRefresh() {
147
+ if (rafPending) return;
148
+ rafPending = true;
149
+ requestAnimationFrame(refresh);
150
+ }
151
+
152
+ function pendingCard() {
153
+ return !!(extras.querySelector(PENDING) || tail.querySelector(PENDING));
154
+ }
155
+
156
+ function refresh() {
157
+ rafPending = false;
158
+ const { turn, blockEl } = view.current();
159
+ if (!holding || !turn) return;
160
+ const gen = turn.gens[turn.gens.length - 1];
161
+ // Plain once text streams without thinking or tools; until the first
162
+ // chunk the live slots show (a slow first token leaves the height alone).
163
+ const plain = !endKind && isPlain(turn) && !!gen?.text;
164
+ if (blockEl && !endKind) {
165
+ const host = plain ? answer : cloudBody;
166
+ if (blockEl.parentNode !== host) host.appendChild(blockEl);
167
+ }
168
+ // The turn view closes its block at the end; the cloud shows its steps.
169
+ if (blockEl && blockEl.parentNode === cloudBody) blockEl.open = true;
170
+ const narration = gen?.text ? headlineOf(gen.text, gen.textDone || gen.closed) : "";
171
+ const thinking = thinkingLine && thinkingLine.step === turn.gens.length ? thinkingLine.text : "";
172
+ const pending = pendingCard();
173
+ const slots = liveSlots(turn, { narration, thinking, pending, ended: endKind });
174
+
175
+ stage.classList.toggle("plain", plain);
176
+ stage.classList.toggle("ended", !!endKind);
177
+ stage.classList.toggle("waiting", pending);
178
+ stage.dataset.phase = slots.phase;
179
+ mark.textContent = MARKS[slots.phase] || "";
180
+ phaseEl.textContent = slots.phase;
181
+ stepEl.textContent = endKind ? `${slots.step} step${slots.step === 1 ? "" : "s"}` : `step ${slots.step}`;
182
+
183
+ drawHeadline(slots.headline);
184
+ drawNow(slots.tool, gen);
185
+ drawTrail(slots.trail);
186
+ drawTicks(slots.ticks);
187
+ const calls = slots.ticks.length;
188
+ const elapsed = endKind ? formatDuration(endedAt - startedAt) : "";
189
+ chipLabel.textContent = `${slots.step} step${slots.step === 1 ? "" : "s"} · ${calls} tool call${calls === 1 ? "" : "s"}${elapsed ? ` · ${elapsed}` : ""}`;
190
+ miniTool.textContent = collapsed && slots.tool ? `${slots.tool.name} ${slots.tool.title}` : "";
191
+
192
+ live.hidden = plain || !!endKind;
193
+ cloud.hidden = !blockEl || blockEl.parentNode !== cloudBody;
194
+ promptSlot.hidden = !promptSlot.firstChild;
195
+ extras.hidden = !extras.firstChild;
196
+ answer.hidden = !answer.firstChild;
197
+ tail.hidden = !tail.firstChild;
198
+ }
199
+
200
+ function drawHeadline({ text, thinking }) {
201
+ const key = `${thinking ? "t" : "n"}|${text}`;
202
+ if (shown.headline === key) return;
203
+ shown.headline = key;
204
+ headline.replaceChildren();
205
+ headline.classList.toggle("thinking", thinking);
206
+ if (thinking) {
207
+ const tag = node("span", "ts-think-tag", headline);
208
+ tag.textContent = "thinking";
209
+ }
210
+ headline.appendChild(document.createTextNode(text));
211
+ headline.classList.remove("fresh");
212
+ void headline.offsetWidth;
213
+ headline.classList.add("fresh");
214
+ }
215
+
216
+ function drawNow(tool, gen) {
217
+ const quiet = gen?.text && !gen.textDone ? "writing…" : "thinking…";
218
+ const key = tool ? `run|${tool.name}|${tool.title}` : `quiet|${quiet}`;
219
+ if (shown.now === key) return;
220
+ shown.now = key;
221
+ now.replaceChildren();
222
+ now.classList.toggle("running", !!tool);
223
+ node("span", tool ? "ts-spin" : "ts-idle", now);
224
+ if (tool) {
225
+ const name = node("span", `ts-tool ${tool.kind}`, now);
226
+ name.textContent = tool.name;
227
+ const title = node("span", "ts-title", now);
228
+ title.textContent = tool.title;
229
+ title.title = tool.title;
230
+ } else {
231
+ const word = node("span", "ts-title quiet", now);
232
+ word.textContent = quiet;
233
+ }
234
+ }
235
+
236
+ function drawTrail(items) {
237
+ const flashing = flash && flash.until > Date.now() ? flash.text : "";
238
+ const key = JSON.stringify([flashing, items]);
239
+ if (shown.trail === key) return;
240
+ shown.trail = key;
241
+ trail.replaceChildren();
242
+ if (flashing) node("span", "ts-trail-item warn", trail).textContent = `⚠ ${flashing}`;
243
+ for (const item of items) {
244
+ const failed = item.status === "error";
245
+ const el = node("span", `ts-trail-item${failed ? " error" : ""}`, trail);
246
+ el.textContent = `${failed ? "✕" : "✓"} ${item.name} ${item.title}`.trim();
247
+ }
248
+ if (!flashing && !items.length) node("span", "ts-trail-item none", trail).textContent = "no tool calls yet";
249
+ }
250
+
251
+ function drawTicks(list) {
252
+ list.forEach((tick, i) => {
253
+ let el = ticks.children[i];
254
+ if (!el) {
255
+ el = node("span", "", ticks);
256
+ el.setAttribute("role", "button");
257
+ }
258
+ const cls = `ts-tick ${tick.kind} ${tick.status}`;
259
+ if (el.className !== cls) el.className = cls;
260
+ el.dataset.step = String(tick.step);
261
+ el.title = `step ${tick.step + 1}`;
262
+ });
263
+ while (ticks.children.length > list.length) ticks.lastChild.remove();
264
+ }
265
+
266
+ // The answer reads from its top (no tail pin in the stage).
267
+ function showAnswerTop() {
268
+ requestAnimationFrame(() => {
269
+ if (answer.firstChild) scroller.scrollTop = Math.max(0, answer.offsetTop - 8);
270
+ });
271
+ }
272
+
273
+ // ── collapse, cloud, peek ────────────────────────────────────────────────
274
+
275
+ function applyCollapsed() {
276
+ stage.classList.toggle("collapsed", collapsed);
277
+ scroller.hidden = collapsed;
278
+ fold.textContent = collapsed ? "▴" : "▾";
279
+ fold.setAttribute("aria-label", collapsed ? "Expand the running turn" : "Collapse the running turn to its status");
280
+ fold.setAttribute("aria-expanded", String(!collapsed));
281
+ fitCap();
282
+ scheduleRefresh();
283
+ }
284
+
285
+ function setCollapsed(value) {
286
+ collapsed = value;
287
+ try { localStorage.setItem(COLLAPSE_KEY, value ? "1" : "0"); } catch (_) {}
288
+ applyCollapsed();
289
+ }
290
+
291
+ function setCloud(open) {
292
+ cloudOpen = open;
293
+ cloudBody.hidden = !open;
294
+ cloud.classList.toggle("open", open);
295
+ stage.classList.toggle("cloud-open", open);
296
+ chip.setAttribute("aria-expanded", String(open));
297
+ fitCap();
298
+ }
299
+
300
+ // Open the cloud at step +i+ (a hand toggle, as a click on its row), its
301
+ // reasoning too with +thinking+.
302
+ function openStep(i, { thinking = false } = {}) {
303
+ const step = view.step(i);
304
+ if (!step) return;
305
+ if (collapsed) setCollapsed(false);
306
+ setCloud(true);
307
+ if (!step.el.open) step.summary.click();
308
+ const wrap = step.thinkingWrap;
309
+ if (thinking && wrap && !wrap.open) wrap.querySelector(":scope > summary")?.click();
310
+ requestAnimationFrame(() => step.el.scrollIntoView({ block: "nearest" }));
311
+ }
312
+
313
+ function peek() {
314
+ const { turn } = view.current();
315
+ if (turn?.gens.length) openStep(turn.gens.length - 1, { thinking: true });
316
+ }
317
+
318
+ fold.addEventListener("click", (e) => {
319
+ e.stopPropagation();
320
+ setCollapsed(!collapsed);
321
+ });
322
+ status.addEventListener("click", (e) => {
323
+ if (collapsed && !e.target.closest("button, a")) setCollapsed(false);
324
+ });
325
+ chip.addEventListener("click", (e) => {
326
+ const tick = e.target.closest(".ts-tick");
327
+ if (tick) openStep(Number(tick.dataset.step));
328
+ else setCloud(!cloudOpen);
329
+ });
330
+ headline.addEventListener("click", peek);
331
+ headline.addEventListener("keydown", (e) => {
332
+ if (e.key === "Enter" || e.key === " ") {
333
+ e.preventDefault();
334
+ peek();
335
+ }
336
+ });
337
+ promptSlot.addEventListener("click", (e) => {
338
+ if (e.target.closest("button, a, img")) return;
339
+ if (window.getSelection()?.isCollapsed === false) return;
340
+ promptSlot.classList.toggle("full");
341
+ });
342
+
343
+ // ── the cap: at most ~60% of the page (~80% with the cloud open), and
344
+ // never above the session panel (the dock is absolute: a tall composer or
345
+ // a phone's keyboard would push the stage's top out) ────────────────────
346
+
347
+ function fitCap() {
348
+ if (stage.hidden) return;
349
+ const rest = dockEl.offsetHeight - stage.offsetHeight;
350
+ const room = centerEl.clientHeight - rest - 32;
351
+ const share = window.innerHeight * (cloudOpen ? 0.8 : 0.6);
352
+ stage.style.setProperty("--stage-max", `${Math.max(96, Math.round(Math.min(share, room)))}px`);
353
+ }
354
+ new ResizeObserver(() => fitCap()).observe(centerEl);
355
+ new ResizeObserver(() => fitCap()).observe(composerEl);
356
+ window.addEventListener("resize", fitCap);
357
+
358
+ // ── in use: pointer over it (tracked on the stage itself: :hover goes
359
+ // stale when children change), a touch or wheel in the last 4 s (phones
360
+ // have no hover), keyboard focus or a selection in it ─────────────────
361
+
362
+ stage.addEventListener("pointerenter", (e) => { if (e.pointerType === "mouse") pointerIn = true; });
363
+ stage.addEventListener("pointermove", (e) => { if (e.pointerType === "mouse") pointerIn = true; });
364
+ stage.addEventListener("pointerleave", () => { pointerIn = false; });
365
+ // Scrolling needs the pointer over it, a touch or keyboard focus; the
366
+ // stage's own scrolls (follow, the answer's top) are no use.
367
+ for (const type of ["touchstart", "touchmove", "wheel"]) {
368
+ stage.addEventListener(type, () => { lastTouch = Date.now(); }, { passive: true, capture: true });
369
+ }
370
+
371
+ function inUse(nowMs) {
372
+ const active = document.activeElement;
373
+ const sel = window.getSelection();
374
+ return inUseFrom({
375
+ pointerIn,
376
+ lastTouchMs: lastTouch,
377
+ // A clicked button keeps the focus in Chrome: only keyboard focus and
378
+ // a field being typed in count.
379
+ focusInside: !!active && stage.contains(active) && active.matches(":focus-visible"),
380
+ selectionInside: !!sel && !sel.isCollapsed && stage.contains(sel.anchorNode),
381
+ nowMs,
382
+ });
383
+ }
384
+
385
+ function stopWatch() {
386
+ clearInterval(watchTimer);
387
+ watchTimer = null;
388
+ quietSince = null;
389
+ stage.classList.remove("in-use");
390
+ }
391
+
392
+ function startWatch() {
393
+ stopWatch();
394
+ watchTimer = setInterval(() => {
395
+ if (!holding || !endKind) return stopWatch();
396
+ const nowMs = Date.now();
397
+ const busy = inUse(nowMs);
398
+ stage.classList.toggle("in-use", busy);
399
+ const r = handOffDue({ ended: true, pending: !settled || pendingCard(), inUse: busy, quietSinceMs: quietSince, nowMs });
400
+ quietSince = r.quietSinceMs;
401
+ if (r.due) handOff();
402
+ }, WATCH_MS);
403
+ }
404
+
405
+ // ── show, clear, hand off ────────────────────────────────────────────────
406
+
407
+ function cancelFold() {
408
+ clearTimeout(foldTimer);
409
+ foldTimer = null;
410
+ stage.classList.remove("leaving");
411
+ stage.style.height = "";
412
+ }
413
+
414
+ function show() {
415
+ cancelFold();
416
+ const hidden = stage.hidden;
417
+ stage.hidden = false;
418
+ applyCollapsed();
419
+ if (hidden && !matchMedia("(prefers-reduced-motion: reduce)").matches) {
420
+ stage.classList.remove("enter");
421
+ void stage.offsetWidth;
422
+ stage.classList.add("enter");
423
+ }
424
+ }
425
+
426
+ function emptySlots() {
427
+ for (const slot of [promptSlot, extras, answer, tail, cloudBody, timingSlot, headline, now, trail, ticks]) slot.replaceChildren();
428
+ promptSlot.classList.remove("full");
429
+ shown.headline = shown.now = shown.trail = null;
430
+ thinkingLine = null;
431
+ flash = null;
432
+ setCloud(false);
433
+ stage.classList.remove("plain", "ended", "waiting", "in-use");
434
+ }
435
+
436
+ function release() {
437
+ stopWatch();
438
+ clearTimeout(settleTimer);
439
+ holding = false;
440
+ endKind = null;
441
+ settled = true;
442
+ view.reset();
443
+ emptySlots();
444
+ }
445
+
446
+ // Everything the stage holds, into the history's end in the turn view's
447
+ // order: prompt, block (collapsed, chronological), extras, answer, timing
448
+ // line, then the end line and what came after the end. The stage folds
449
+ // away while the turn fades up into the history (not with +immediate+:
450
+ // another turn takes the stage at once). The history follows only a
451
+ // reader who was at its end.
452
+ function handOff({ immediate = false } = {}) {
453
+ if (!holding || !endKind) return;
454
+ const { blockEl } = view.current();
455
+ view.chronological();
456
+ if (blockEl) blockEl.open = false;
457
+ const wasAtEnd = historyNearBottom();
458
+ const pieces = handOffOrder({
459
+ prompt: promptSlot.firstElementChild,
460
+ block: blockEl && blockEl.parentNode === cloudBody ? blockEl : null,
461
+ extras: [...extras.children],
462
+ answer: [...answer.children],
463
+ timing: timingSlot.firstElementChild,
464
+ kept: [...tail.children],
465
+ });
466
+ const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
467
+ const animate = !immediate && !reduce && !document.hidden && !collapsed;
468
+ if (animate) {
469
+ stage.style.height = `${stage.offsetHeight}px`;
470
+ void stage.offsetHeight;
471
+ }
472
+ historyEl.querySelector(":scope > .hint")?.remove();
473
+ for (const el of pieces) {
474
+ historyEl.appendChild(el);
475
+ if (animate) {
476
+ el.classList.add("ts-arrive");
477
+ el.addEventListener("animationend", () => el.classList.remove("ts-arrive"), { once: true });
478
+ }
479
+ }
480
+ release();
481
+ if (!animate) {
482
+ stage.hidden = true;
483
+ if (wasAtEnd) historyEl.scrollTop = historyEl.scrollHeight;
484
+ return;
485
+ }
486
+ stage.classList.add("leaving");
487
+ stage.style.height = "0px";
488
+ if (wasAtEnd) historyEl.scrollTo({ top: historyEl.scrollHeight, behavior: "smooth" });
489
+ foldTimer = setTimeout(() => {
490
+ cancelFold();
491
+ stage.hidden = true;
492
+ if (wasAtEnd) historyEl.scrollTo({ top: historyEl.scrollHeight, behavior: "smooth" });
493
+ }, FOLD_MS);
494
+ }
495
+
496
+ // ── the view interface (app.js) ──────────────────────────────────────────
497
+
498
+ return {
499
+ turnStarted(data) {
500
+ if (holding) {
501
+ if (endKind) handOff({ immediate: true });
502
+ else release();
503
+ }
504
+ holding = true;
505
+ endKind = null;
506
+ settled = true;
507
+ startedAt = Date.parse(data?.started_at) || Date.now();
508
+ emptySlots();
509
+ // The turn's prompt bubble (app.js found or drew it) sits in the prompt
510
+ // line until the hand-off takes it back first.
511
+ if (data?.promptBubble) promptSlot.appendChild(data.promptBubble);
512
+ view.turnStarted(data || {});
513
+ show();
514
+ refresh();
515
+ },
516
+ generationStarted: (data) => view.generationStarted(data),
517
+ chunk: (data) => view.chunk(data),
518
+ generationCompleted: () => view.generationCompleted(),
519
+ toolStarted: (data) => view.toolStarted(data),
520
+ toolCompleted: (data) => view.toolCompleted(data),
521
+ turnEnded(args) {
522
+ const kept = view.turnEnded(args) || [];
523
+ if (!holding) return kept;
524
+ view.chronological();
525
+ endKind = args.kind;
526
+ endedAt = Date.now();
527
+ // A completed turn's end read (markdown, answer_display) is pending
528
+ // until answerReady; the others have none.
529
+ settled = args.kind !== "completed";
530
+ clearTimeout(settleTimer);
531
+ if (!settled) settleTimer = setTimeout(() => { settled = true; }, SETTLE_CAP_MS);
532
+ refresh();
533
+ startWatch();
534
+ return kept;
535
+ },
536
+ notice(data) {
537
+ const placed = view.notice(data);
538
+ if (placed && data.level === "warn") {
539
+ flash = { text: data.line || data.text || "", until: Date.now() + FLASH_MS };
540
+ setTimeout(scheduleRefresh, FLASH_MS + 50);
541
+ }
542
+ return placed;
543
+ },
544
+ steer: (data) => view.steer(data),
545
+ card: (el) => view.card(el),
546
+ flush: () => view.flush(),
547
+ answerReady() {
548
+ settled = true;
549
+ clearTimeout(settleTimer);
550
+ view.answerReady();
551
+ },
552
+ containsLive: (el) => view.containsLive(el),
553
+ reset() {
554
+ cancelFold();
555
+ release();
556
+ stage.hidden = true;
557
+ },
558
+ // The stage's extras while it holds a turn (a card, a queued prompt, a
559
+ // command, a note, the end line); false: the history takes it.
560
+ place(el) {
561
+ const kind = el.matches?.(".bubble.recap") ? "recap" : "row";
562
+ if (placeFor({ live: holding, handingOff: false, kind }) !== "stage") return false;
563
+ const slot = endKind ? tail : extras;
564
+ const stick = slot.scrollHeight - slot.scrollTop - slot.clientHeight < 24;
565
+ slot.appendChild(el);
566
+ slot.hidden = false;
567
+ if (stick || el.matches?.(PENDING)) slot.scrollTop = slot.scrollHeight;
568
+ scheduleRefresh();
569
+ return true;
570
+ },
571
+ // Where the live timing line goes (the status row), null: the history.
572
+ timingHost: () => (holding ? timingSlot : null),
573
+ // A turn is in the stage (running, or ended and not handed off yet):
574
+ // nothing the turn does touches the history's scroll.
575
+ holds: () => holding,
576
+ handOff,
577
+ root: stage,
578
+ scrollEl: scroller,
579
+ };
580
+ }