samagotchi 0.3.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +162 -1
  3. data/README.md +29 -2
  4. data/bin/chi +60 -69
  5. data/docs/cli.md +211 -77
  6. data/docs/configuration.md +118 -21
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +89 -7
  10. data/docs/memory.md +40 -0
  11. data/docs/plugins.md +50 -0
  12. data/docs/releasing.md +15 -12
  13. data/docs/sessions.md +20 -18
  14. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  15. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  17. data/lib/samagotchi/bridge.rb +20 -12
  18. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  19. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  22. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
  24. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  25. data/lib/samagotchi/bundles/system/identity.md +5 -0
  26. data/lib/samagotchi/bundles/system/manifest.yml +6 -6
  27. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  28. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  29. data/lib/samagotchi/client.rb +25 -26
  30. data/lib/samagotchi/commands/registry.rb +8 -0
  31. data/lib/samagotchi/config.rb +97 -113
  32. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  33. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  34. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  35. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  36. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  37. data/lib/samagotchi/desktop/macos.rb +59 -8
  38. data/lib/samagotchi/desktop_command.rb +6 -3
  39. data/lib/samagotchi/edit_preview.rb +82 -0
  40. data/lib/samagotchi/engine.rb +236 -443
  41. data/lib/samagotchi/gem_update.rb +89 -0
  42. data/lib/samagotchi/guardrails/approval.rb +26 -4
  43. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  44. data/lib/samagotchi/host_registry.rb +8 -12
  45. data/lib/samagotchi/idle_client.rb +24 -15
  46. data/lib/samagotchi/idle_reminders.rb +2 -2
  47. data/lib/samagotchi/image_store.rb +10 -6
  48. data/lib/samagotchi/kernel_loop.rb +59 -123
  49. data/lib/samagotchi/live_versions.rb +65 -0
  50. data/lib/samagotchi/llm/api_key.rb +41 -0
  51. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  52. data/lib/samagotchi/llm/errors.rb +38 -7
  53. data/lib/samagotchi/llm/http.rb +19 -22
  54. data/lib/samagotchi/llm/openai_chat.rb +22 -26
  55. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  56. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  57. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  58. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  59. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  60. data/lib/samagotchi/model_profile.rb +27 -10
  61. data/lib/samagotchi/note_command.rb +2 -1
  62. data/lib/samagotchi/prompt.rb +4 -2
  63. data/lib/samagotchi/reminder_store.rb +1 -9
  64. data/lib/samagotchi/reply_wait.rb +48 -4
  65. data/lib/samagotchi/self_report.rb +37 -5
  66. data/lib/samagotchi/send_command.rb +190 -17
  67. data/lib/samagotchi/session.rb +4 -2
  68. data/lib/samagotchi/session_commands.rb +38 -8
  69. data/lib/samagotchi/session_manager.rb +19 -53
  70. data/lib/samagotchi/system_prompt.rb +403 -0
  71. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  74. data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
  75. data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
  76. data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
  77. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  78. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  79. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  80. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  81. data/lib/samagotchi/terminal_ui.rb +142 -923
  82. data/lib/samagotchi/text_diff.rb +181 -0
  83. data/lib/samagotchi/thinking.rb +126 -0
  84. data/lib/samagotchi/tool_activity.rb +52 -2
  85. data/lib/samagotchi/tool_runner.rb +37 -1
  86. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  87. data/lib/samagotchi/tools/edit.rb +23 -9
  88. data/lib/samagotchi/tools/execute.rb +3 -3
  89. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  90. data/lib/samagotchi/tools/read.rb +4 -4
  91. data/lib/samagotchi/tools/write.rb +4 -0
  92. data/lib/samagotchi/turn_flow.rb +12 -2
  93. data/lib/samagotchi/update_command.rb +309 -0
  94. data/lib/samagotchi/update_hint.rb +59 -0
  95. data/lib/samagotchi/version.rb +1 -1
  96. data/lib/samagotchi/vision_support.rb +6 -4
  97. data/lib/samagotchi/web/app.rb +173 -38
  98. data/lib/samagotchi/web/lan.rb +99 -0
  99. data/lib/samagotchi/web/message_parts.rb +19 -10
  100. data/lib/samagotchi/web/public/activity.js +10 -0
  101. data/lib/samagotchi/web/public/app.js +135 -78
  102. data/lib/samagotchi/web/public/chat_view.js +8 -1
  103. data/lib/samagotchi/web/public/data.js +2 -0
  104. data/lib/samagotchi/web/public/diff_view.js +58 -0
  105. data/lib/samagotchi/web/public/index.html +185 -18
  106. data/lib/samagotchi/web/public/model_pick.js +136 -0
  107. data/lib/samagotchi/web/public/model_picker.js +224 -0
  108. data/lib/samagotchi/web/public/notify.js +10 -0
  109. data/lib/samagotchi/web/public/question_card.js +3 -1
  110. data/lib/samagotchi/web/public/stage_model.js +110 -0
  111. data/lib/samagotchi/web/public/stage_view.js +580 -0
  112. data/lib/samagotchi/web/public/timing.js +6 -2
  113. data/lib/samagotchi/web/public/turn_events.js +38 -10
  114. data/lib/samagotchi/web/public/turn_model.js +11 -3
  115. data/lib/samagotchi/web/public/turn_view.js +76 -20
  116. data/lib/samagotchi/web/qr.rb +40 -0
  117. data/lib/samagotchi/web/server.rb +101 -11
  118. data/lib/samagotchi/web/token.rb +97 -0
  119. data/lib/samagotchi/worker.rb +5 -4
  120. metadata +38 -3
  121. data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
@@ -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
+ }
@@ -20,11 +20,15 @@ export function turnTimingText(number, durationMs, { running = false, canceled =
20
20
  return `${turn}${running ? " running" : ""} · ${formatDuration(durationMs)}${canceled ? " · canceled" : ""}`;
21
21
  }
22
22
 
23
- // The line a canceled turn ends with: "✕ canceled (user)", live from
23
+ // A cancel's reason as the terminal UIs name it too (Formatting::
24
+ // CANCEL_REASONS); anything else as it is.
25
+ const CANCEL_REASONS = { ctrl_c: "Ctrl-C", user: "stopped", hook: "by a hook" };
26
+
27
+ // The line a canceled turn ends with: "✕ canceled (stopped)", live from
24
28
  // turn_canceled's reason and after a re-render from the turn record's
25
29
  // cancellation_reason (records saved before it existed: no reason).
26
30
  export function cancelLineText(reason) {
27
- return "\u2715 canceled" + (reason ? ` (${reason})` : "");
31
+ return "\u2715 canceled" + (reason ? ` (${CANCEL_REASONS[reason] || reason})` : "");
28
32
  }
29
33
 
30
34
  // A re-rendered turn's cancel line (a .bubble.cancel), or "" when the turn
@@ -45,10 +45,14 @@ export function reminderText(event) {
45
45
  }
46
46
 
47
47
  // Who a hook's notice is from: the bundle's name for a bundle hook
48
- // ("known_names.rb (bundle known-names)"), else "hook".
48
+ // ("known_names.rb (bundle known-names)"), chi's own notice by its word
49
+ // (thinking), else "hook".
49
50
  export function hookNoticeLabel(hook) {
50
- const m = /\(bundle (.+)\)$/.exec(String(hook || ""));
51
- return m ? m[1] : "hook";
51
+ const name = String(hook || "");
52
+ const m = /\(bundle (.+)\)$/.exec(name);
53
+ if (m) return m[1];
54
+ // chi's own notices name themselves with a bare word (thinking).
55
+ return /^[a-z][a-z0-9-]*$/.test(name) ? name : "hook";
52
56
  }
53
57
 
54
58
  // The row a step gets when the loop asks again after an empty answer.
@@ -56,17 +60,38 @@ export function emptyRetryLine({ attempt, of } = {}) {
56
60
  return `↻ empty answer, asking again (${attempt}/${of})`;
57
61
  }
58
62
 
63
+ // The live turn's status while the provider is asked again after an error
64
+ // (generation_retrying): "↻ retrying (503) in 4 s, 2/5" (retry 2 of 5).
65
+ export function retryStatusLine({ attempt, max_retries, next_delay, status, error_class } = {}) {
66
+ const why = status || String(error_class || "").split("::").pop();
67
+ const delay = Number(next_delay);
68
+ const when = Number.isFinite(delay) ? ` in ${delay < 1 ? delay.toFixed(1) : Math.round(delay)} s` : "";
69
+ const count = attempt ? `, ${attempt}${max_retries ? `/${max_retries}` : ""}` : "";
70
+ return `↻ retrying${why ? ` (${why})` : ""}${when}${count}`;
71
+ }
72
+
73
+ // The live turn's status while it waits for plugins' slow setup before its
74
+ // first request (plugin_init_wait).
75
+ export function initWaitLine({ tasks } = {}) {
76
+ const labels = (tasks || []).map((t) => [t?.bundle, t?.label].filter(Boolean).join(": ")).filter(Boolean);
77
+ return `waiting for ${labels.length ? labels.join(" · ") : "plugins"}…`;
78
+ }
79
+
59
80
  // A composer line for the worker's command route rather than a prompt.
60
81
  export function isCommandLine(text) {
61
82
  return /^[/!]/.test(String(text || "").trim());
62
83
  }
63
84
 
64
- // The terminal's own commands that have a button in the web: the reply
65
- // the page shows itself (they never go to the worker, which doesn't know
66
- // them), or null.
85
+ // The terminal's own commands (a button in the web, or none yet): the
86
+ // reply the page shows itself (they never go to the worker, which doesn't
87
+ // know them), or null.
67
88
  const WEB_LOCAL_REPLIES = {
68
89
  "/archive": "/archive: use the archive button in the session bar",
90
+ "/detach": "/detach: a terminal's command; close the tab to leave, the worker keeps running",
69
91
  "/exit": "/exit: close the tab to leave; stop in the session bar stops the worker",
92
+ "/quit": "/quit: close the tab to leave; stop in the session bar stops the worker",
93
+ "/recap": "/recap: not in the web yet; a recap shows here by itself when you come back to an idle session, and /recap in a terminal (chi --attach) makes one now",
94
+ "/stats": "/stats: not in the web yet; each turn shows its time and the context use, and /stats in a terminal (chi --attach) has the totals",
70
95
  };
71
96
 
72
97
  export function webLocalReply(text) {
@@ -211,12 +236,15 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], started
211
236
  closeText();
212
237
  const call = { iteration: part.iteration, call_index: part.call_index, tool: part.tool };
213
238
  if (part.label) call.label = part.label;
214
- events.push({ type: "tool_call_started", ...call, params: part.params });
239
+ const title = part.title ? { title: part.title } : {};
240
+ events.push({ type: "tool_call_started", ...call, params: part.params, ...title });
215
241
  if (part.status !== "running") {
216
- events.push(withImages({
242
+ const completed = {
217
243
  type: "tool_call_completed", ...call, output: part.output, output_truncated: !!part.output_truncated,
218
- activity: { status: part.status, params: part.params },
219
- }, part.images));
244
+ activity: { status: part.status, params: part.params, ...title },
245
+ };
246
+ if (part.diff) completed.diff = part.diff;
247
+ events.push(withImages(completed, part.images));
220
248
  }
221
249
  break;
222
250
  }