samagotchi 0.54.0 → 0.55.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/bin/chi +3 -2
  4. data/docs/configuration.md +29 -0
  5. data/docs/internals/context-telemetry.md +23 -5
  6. data/docs/internals/engine.md +40 -18
  7. data/docs/sessions.md +2 -2
  8. data/docs/testing.md +23 -3
  9. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -6
  10. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  11. data/lib/samagotchi/config.rb +2 -0
  12. data/lib/samagotchi/file_ref.rb +59 -0
  13. data/lib/samagotchi/tool_row_fields.rb +46 -0
  14. data/lib/samagotchi/tool_runner.rb +7 -11
  15. data/lib/samagotchi/version.rb +1 -1
  16. data/lib/samagotchi/web/app.rb +25 -2
  17. data/lib/samagotchi/web/capabilities.rb +13 -9
  18. data/lib/samagotchi/web/editor.rb +38 -0
  19. data/lib/samagotchi/web/message_parts.rb +5 -10
  20. data/lib/samagotchi/web/public/activity.js +33 -18
  21. data/lib/samagotchi/web/public/app.js +237 -1133
  22. data/lib/samagotchi/web/public/commands.js +236 -0
  23. data/lib/samagotchi/web/public/composer.js +101 -0
  24. data/lib/samagotchi/web/public/diff_view.js +30 -4
  25. data/lib/samagotchi/web/public/index.html +5 -0
  26. data/lib/samagotchi/web/public/info_bar.js +78 -0
  27. data/lib/samagotchi/web/public/lists_view.js +725 -0
  28. data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
  29. data/lib/samagotchi/web/public/question_cards.js +2 -1
  30. data/lib/samagotchi/web/public/refs.js +134 -0
  31. data/lib/samagotchi/web/public/restore.js +81 -0
  32. data/lib/samagotchi/web/public/session_state.js +52 -0
  33. data/lib/samagotchi/web/public/stream_handlers.js +324 -0
  34. data/lib/samagotchi/web/public/thinking_chip.js +6 -7
  35. data/lib/samagotchi/web/public/turn_events.js +17 -258
  36. data/lib/samagotchi/web/public/turn_html.js +5 -1
  37. data/lib/samagotchi/web/server.rb +4 -4
  38. metadata +11 -2
  39. data/lib/samagotchi/web/public/stable_html.js +0 -18
@@ -12,6 +12,8 @@
12
12
  // choice for the next create only (POST /api/sessions' llm_context), never
13
13
  // remembered: nothing is sent unless the user sets one.
14
14
 
15
+ import { html, nothing } from "./vendor/lit-html.js";
16
+
15
17
  export const STRATEGIES = [
16
18
  { value: "none", label: "none" },
17
19
  { value: "stale", label: "stale" },
@@ -70,11 +72,12 @@ export function chipModel(summary, { kind = SESSION } = {}) {
70
72
  return { text: `llm ctx ${summary.strategy}${own ? ` · ${kind}` : ""}`, title, own };
71
73
  }
72
74
 
73
- export function chipHtml(summary, escapeHtml) {
75
+ // The session bar's chip (a lit-html template; info_bar.js), nothing
76
+ // without a summary.
77
+ export function chipTemplate(summary) {
74
78
  const chip = chipModel(summary);
75
- if (!chip) return "";
76
- return `<button type="button" class="model llmctx-chip${chip.own ? " own" : ""}" title="${escapeHtml(chip.title)}" ` +
77
- `aria-haspopup="dialog">${escapeHtml(chip.text)}</button>`;
79
+ if (!chip) return nothing;
80
+ return html`<button type="button" class=${`model llmctx-chip${chip.own ? " own" : ""}`} title=${chip.title} aria-haspopup="dialog">${chip.text}</button>`;
78
81
  }
79
82
 
80
83
  // The /llm-context line for the form's values. With +initial+ (the values
@@ -201,8 +204,8 @@ export function popoverPlace(rect, { width, height, innerWidth, innerHeight }) {
201
204
  return height > room ? { ...place, maxHeight: room } : place;
202
205
  }
203
206
 
204
- // The chip's popover. el: where the chip is drawn (#infoText, redrawn every
205
- // tick: clicks are delegated); summary(): the open session's llm_context;
207
+ // The chip's popover. el: where the chip is drawn (#infoText, info_bar.js:
208
+ // clicks are delegated); summary(): the open session's llm_context;
206
209
  // session(): the open session's id; running(): a turn runs; run(line):
207
210
  // sends the command.
208
211
  // The start page's (kind NEW_CHAT): summary() is the picked model's,
@@ -19,7 +19,8 @@ import {
19
19
  relayedToView, resultText, summaryText,
20
20
  } from "./question_card.js";
21
21
  import { sessionHash } from "./route.js";
22
- import { clientLabel, continueLine, isOwn } from "./turn_events.js";
22
+ import { clientLabel, isOwn } from "./turn_events.js";
23
+ import { continueLine } from "./commands.js";
23
24
 
24
25
  // @param sessionId () => the open session's id (answers go there)
25
26
  // @param clientId this tab's id in the session's events
@@ -0,0 +1,134 @@
1
+ // Refs: quiet links from what a turn touched to the thing itself. A ref is
2
+ // a kind and a target: today a file tool's row title (the server's FileRef,
3
+ // {kind: "file", path, line?, end_line?}) and that row's diff lines (the
4
+ // same file at a line). The DOM carries data-ref-kind and the target's
5
+ // attributes, never what a click does: that is an action, kept per kind in
6
+ // REF_ACTIONS, and a click runs the first one available to this viewer. A
7
+ // ref with none (a phone: no editor) stays plain text; the page marks the
8
+ // kinds that have one on its body (refs-<kind>), which the hover styling
9
+ // keys on.
10
+
11
+ import { escapeHtml } from "./format.js";
12
+
13
+ // The data attributes a ref's element carries.
14
+ export function refAttrs(ref) {
15
+ if (!ref?.kind) return "";
16
+ const attr = (name, value) => (value ? ` data-ref-${name}="${escapeHtml(String(value))}"` : "");
17
+ return ` data-ref-kind="${escapeHtml(ref.kind)}"${attr("path", ref.path)}${attr("line", ref.line)}${attr("end-line", ref.end_line)}`;
18
+ }
19
+
20
+ // The URL +template+ ({path}, {line}; web.editor) opens +path+ at +line+
21
+ // (1 when none) with: each path segment encoded, the slashes kept. "" with
22
+ // no template or path.
23
+ export function editorUrl(template, path, line) {
24
+ if (!template || !path) return "";
25
+ const encoded = String(path).split("/").map(encodeURIComponent).join("/");
26
+ return template.replaceAll("{path}", encoded).replaceAll("{line}", String(line || 1));
27
+ }
28
+
29
+ // What a ref can do, per kind, in order: the first available one is a
30
+ // click's. +viewer+ is what this page's viewer can do ({editor, editorUrl}:
31
+ // the server's capabilities.editor and editor_url).
32
+ export const REF_ACTIONS = Object.freeze({
33
+ file: Object.freeze([
34
+ Object.freeze({
35
+ id: "editor",
36
+ available: (viewer) => viewer?.editor === true && !!viewer.editorUrl,
37
+ url: (target, viewer) => editorUrl(viewer.editorUrl, target.path, target.line),
38
+ // The hover hint: it opens the file as it is now.
39
+ hint: (target) => `Open the current file in your editor${target.line ? ` at line ${target.line}` : ""}`,
40
+ }),
41
+ ]),
42
+ });
43
+
44
+ // The first action +viewer+ has for a ref of +kind+, else null.
45
+ export function refAction(kind, viewer, actions = REF_ACTIONS) {
46
+ return (actions[kind] || []).find((action) => action.available(viewer)) || null;
47
+ }
48
+
49
+ // The kinds that have an action for +viewer+ (the page's refs-<kind> classes).
50
+ export function availableKinds(viewer, actions = REF_ACTIONS) {
51
+ return Object.keys(actions).filter((kind) => refAction(kind, viewer, actions));
52
+ }
53
+
54
+ // What a click on +el+ points at: {kind, path, line, owner} or null. A row
55
+ // title opens its file at the lines the call named (an edit without any at
56
+ // its first changed line); a diff line (or a hunk header) in a row with a
57
+ // ref opens the row's file at that line. A diff line outside a row (an
58
+ // approval card's dry run) is none.
59
+ export function refTarget(el) {
60
+ const title = el?.closest?.("[data-ref-kind]");
61
+ if (title) {
62
+ const owner = title.closest(".activity-row");
63
+ return { ...targetOf(title), line: Number(title.dataset.refLine) || firstChange(owner), owner: owner || title };
64
+ }
65
+ const diffLine = el?.closest?.(".diff-line[data-line]");
66
+ const owner = diffLine?.closest(".activity-row");
67
+ const ref = owner?.querySelector("[data-ref-kind]");
68
+ if (!ref) return null;
69
+ return { ...targetOf(ref), line: Number(diffLine.dataset.line), owner };
70
+ }
71
+
72
+ function targetOf(el) {
73
+ return { kind: el.dataset.refKind, path: el.dataset.refPath || "" };
74
+ }
75
+
76
+ // An edit/write without a line range opens at its first changed line (its
77
+ // diff knows it, the call doesn't); null without a diff.
78
+ function firstChange(row) {
79
+ const line = row?.querySelector(".diff-line.diff-add[data-line], .diff-line.diff-del[data-line]");
80
+ return line ? Number(line.dataset.line) : null;
81
+ }
82
+
83
+ // A drag that selected text in the clicked ref's row isn't a click on the
84
+ // ref. A selection elsewhere on the page (or a collapsed one) doesn't count.
85
+ function selectingIn(owner, selection) {
86
+ if (!selection || selection.isCollapsed || !owner) return false;
87
+ return [selection.anchorNode, selection.focusNode].some((node) => node && owner.contains(node));
88
+ }
89
+
90
+ function defaultOpen(url) {
91
+ // The e2e seam (spec/e2e): a spec defines it before the page loads.
92
+ if (typeof window.__chiRefOpen === "function") window.__chiRefOpen(url);
93
+ else window.location.href = url;
94
+ }
95
+
96
+ // One delegated listener on +root+ for every ref on the page. +viewer+()
97
+ // is read per click and hover, so a capabilities change (another session,
98
+ // a reload) is seen; +open+(url) opens an action's URL.
99
+ export function installRefClicks(root, viewer, { open = defaultOpen, selection = () => window.getSelection?.() } = {}) {
100
+ root.addEventListener("click", (e) => {
101
+ if (e.button !== 0 || e.defaultPrevented) return;
102
+ const target = refTarget(e.target);
103
+ if (!target) return;
104
+ const action = refAction(target.kind, viewer());
105
+ if (!action) return;
106
+ if (selectingIn(target.owner, selection())) return;
107
+ const url = action.url(target, viewer());
108
+ if (!url) return;
109
+ e.preventDefault();
110
+ e.stopPropagation();
111
+ open(url);
112
+ });
113
+ // The hover hint, on the ref's own element, worked out on each hover
114
+ // (the viewer can change): its own title (a row title's hover) first,
115
+ // then the action's hint when there is one.
116
+ root.addEventListener("mouseover", (e) => {
117
+ const target = refTarget(e.target);
118
+ if (!target) return;
119
+ const el = e.target.closest("[data-ref-kind]") || e.target.closest(".diff-line[data-line]");
120
+ if (!el) return;
121
+ if (el.dataset.refTitle === undefined) el.dataset.refTitle = el.getAttribute("title") || "";
122
+ const own = el.dataset.refTitle;
123
+ const action = refAction(target.kind, viewer());
124
+ const title = [own, action ? action.hint(target) : ""].filter(Boolean).join("\n");
125
+ if (title) el.setAttribute("title", title);
126
+ else el.removeAttribute("title");
127
+ });
128
+ }
129
+
130
+ // The body's classes for +viewer+: refs-<kind> for each kind with an action.
131
+ export function setRefKinds(body, viewer, actions = REF_ACTIONS) {
132
+ const kinds = availableKinds(viewer, actions);
133
+ for (const kind of Object.keys(actions)) body.classList.toggle(`refs-${kind}`, kinds.includes(kind));
134
+ }
@@ -0,0 +1,81 @@
1
+ // Pure helpers for a failed turn's prompt handed back (prompt_restored):
2
+ // what goes back into this tab's composer, and when (a restore that comes
3
+ // before its /turn ack waits for it). Split out of turn_events.js.
4
+
5
+ import { clientLabel, isOwn, withImages } from "./turn_events.js";
6
+
7
+ // What a prompt_restored (the worker rolled a failed turn back and handed its
8
+ // prompt back) does in this tab: `refill` is the text for an empty composer,
9
+ // only for a prompt this page sent (`sentIds`, its acks' enqueued_ids), so a
10
+ // replay after a reload never refills stale text; `own`/`label` mark the
11
+ // failed bubble shown again after the resync.
12
+ export function restoreAction(event, { myId, sentIds, composerEmpty }) {
13
+ const origin = event?.origin || {};
14
+ const own = isOwn(origin.client_id, myId);
15
+ const sent = own && !!origin.enqueued_id && sentIds.has(origin.enqueued_id);
16
+ // The images go back as chips with the text.
17
+ return withImages({
18
+ refill: sent && composerEmpty ? event.prompt : null,
19
+ own,
20
+ label: own ? null : clientLabel(origin.client_id),
21
+ }, sent && composerEmpty ? event.images : null);
22
+ }
23
+
24
+ // A turn that fails fast can have its prompt_restored come over the stream
25
+ // before the /turn ack names its enqueued_id: `early` (a Map) keeps such a
26
+ // restore of this tab's own prompt by that id, and the ack takes it back
27
+ // (restoreOnAck), so the prompt goes into the composer the ack would clear.
28
+ export function keepEarlyRestore(event, early, { myId, sentIds }) {
29
+ const origin = event?.origin || {};
30
+ const id = origin.enqueued_id;
31
+ if (!isOwn(origin.client_id, myId) || !id || sentIds.has(id)) return false;
32
+ early.set(id, event);
33
+ return true;
34
+ }
35
+
36
+ // The ack's enqueued_id (already in sentIds) was restored early: what the
37
+ // composer gets back (as restoreAction for an empty composer), else null.
38
+ export function restoreOnAck(enqueuedId, early, { myId, sentIds }) {
39
+ const event = enqueuedId && early.get(enqueuedId);
40
+ if (!event) return null;
41
+ early.delete(enqueuedId);
42
+ return restoreAction(event, { myId, sentIds, composerEmpty: true });
43
+ }
44
+
45
+ // The /turn this kept entry was for failed: no ack comes, so nothing would
46
+ // take it back. The whole map goes: this tab sends one turn at a time, so
47
+ // every entry waits for an ack that is now never coming (an older one is
48
+ // left over from a send that failed the same way).
49
+ export function dropEarlyRestores(early) {
50
+ const count = early.size;
51
+ early.clear();
52
+ return count;
53
+ }
54
+
55
+ // The composer's text with a restored prompt put back into it: the restored
56
+ // text alone when nothing is typed, else the typed text with the restored
57
+ // prompt under it (the user typed between the restore and the ack; their
58
+ // text must not be replaced).
59
+ export function restoreInto(refill, current) {
60
+ const restored = String(refill ?? "");
61
+ const typed = String(current ?? "");
62
+ if (!restored) return typed;
63
+ if (!typed.trim()) return restored;
64
+ return `${typed.replace(/\n+$/, "")}\n${restored}`;
65
+ }
66
+
67
+ // What the composer holds once a /turn ack lands (4.13): the composer still
68
+ // carries the text that was just sent, so that text goes (it is the history's
69
+ // now) — but text typed since the send stays, and a prompt restored before
70
+ // this ack (restoreOnAck's refill) is put back without replacing it. The
71
+ // sent text is trimmed (turnText), so the composer's is too before they are
72
+ // compared: a trailing newline or space must not keep the message; and an
73
+ // image-only send (placeholders sent) leaves nothing worth keeping.
74
+ // @param sent the text the turn was sent with
75
+ // @param current the composer's text as the ack lands
76
+ // @param refill the prompt an early restore handed back, if any
77
+ export function composerAfterAck({ sent = "", current = "", refill = "" } = {}) {
78
+ const typed = String(current ?? "").trim();
79
+ const kept = !typed || typed === String(sent ?? "").trim() ? "" : current;
80
+ return restoreInto(refill, kept);
81
+ }
@@ -4,6 +4,8 @@
4
4
  // the previous session set, its "delegated by" parent, its commands, never
5
5
  // shows for the next one).
6
6
 
7
+ import { savedCtxPct } from "./ctx.js";
8
+ import { firstPreview, previewForCard } from "./format.js";
7
9
  import { normalizeTiming } from "./timing.js";
8
10
 
9
11
  export function emptySessionState() {
@@ -46,3 +48,53 @@ export function emptySessionState() {
46
48
  timing: normalizeTiming(),
47
49
  };
48
50
  }
51
+
52
+ // The fields a session's payload (GET /sessions/:id) sets, read from it
53
+ // alone. ctxPct is left out when the payload has no saved fill, ctxWindow
54
+ // and ctxWindowSource when it has no window: the page keeps what it had
55
+ // (a running turn's replay streams over it).
56
+ export function sessionStateFrom(data) {
57
+ const s = data.session;
58
+ const state = {
59
+ status: s.status,
60
+ model: s.model_name,
61
+ served: s.served_model ? [s.served_model, s.served_model_for, s.served_expected_by || null] : null,
62
+ dir: s.working_directory,
63
+ owner: s.owner || null,
64
+ timing: normalizeTiming(data.timing),
65
+ };
66
+ // The saved fill, so a reloaded or stopped session shows it before its
67
+ // next turn.
68
+ const savedPct = savedCtxPct(data.timing?.context, s.llm_context?.budget_tokens);
69
+ if (savedPct !== null) state.ctxPct = savedPct;
70
+ if (data.timing?.context?.window_tokens) {
71
+ state.ctxWindow = data.timing.context.window_tokens;
72
+ state.ctxWindowSource = data.timing.context.window_source || null;
73
+ }
74
+ state.firstPreview = sessionFirstPreview(data);
75
+ state.usedMemories = s.used_memory_names || [];
76
+ state.preloadedMemories = s.preloaded_memory_names || [];
77
+ state.mutedMemories = s.muted_memory_names || [];
78
+ state.promptNotes = Array.isArray(s.prompt_notes) ? s.prompt_notes : [];
79
+ state.llmContext = s.llm_context || null;
80
+ state.thinking = s.thinking || null;
81
+ state.parentId = s.parent_id || null;
82
+ state.continues = s.continues || null;
83
+ state.commands = Array.isArray(data.commands) ? data.commands : [];
84
+ return state;
85
+ }
86
+
87
+ // The info bar's title: the first user message when the payload has
88
+ // messages, else the card's preview, else the first prompt of its history.
89
+ function sessionFirstPreview(data) {
90
+ const s = data.session;
91
+ let preview = s.first_preview || previewForCard(s);
92
+ if (data.messages && data.messages.length) {
93
+ const first = data.messages.find((m) => m.role === "user");
94
+ if (first && first.content) preview = firstPreview(first.content);
95
+ } else if (!preview && data.history && data.history.length) {
96
+ const txt = firstPreview(data.history[0]);
97
+ if (txt) preview = txt;
98
+ }
99
+ return preview;
100
+ }
@@ -0,0 +1,324 @@
1
+ // The open session's stream event handlers (GET /stream's typed events, by
2
+ // type); renderSessionView replays a snapshot through them too. Split out
3
+ // of app.js; no markup of its own: the page passes its views and hooks.
4
+ //
5
+ // Everything that changes while the page runs is read through deps at the
6
+ // event, never captured at creation: the open session (selected()), its
7
+ // fields (state(): resetSessionState replaces the object), the page's
8
+ // capabilities, the composer's text. A session switch mid-stream is handled
9
+ // as before: the handlers act on whatever is open when the event comes.
10
+ //
11
+ // deps:
12
+ // selected() the open session's id, or null
13
+ // state() the open session's fields (session_state.js)
14
+ // clientId this tab's id (own prompts and commands)
15
+ // capabilities() the page's capabilities (card.js shownElsewhere)
16
+ // historyEl the history element (keepFollowing, the failed-turn redraw)
17
+ // liveTurn, turnDom, shownTexts, questionCards, contextChips,
18
+ // llmContextControl, thinkingControl the page's views
19
+ // sentIds, earlyRestores restore.js's bookkeeping
20
+ // stream: {takeGuess(), restart()} the stream was opened from a
21
+ // guess (takeGuess: whether, and forget it); restart:
22
+ // close the stream and resync
23
+ // composer: {empty(), refill(text), setChips(chips)}
24
+ // getSession(id, opts) data.js
25
+ // resync(), updateInfoBar(), applyPromptOps(data), showSteers(steers),
26
+ // markRecapStale(), setTurnRunning(running), setLiveStatus(status),
27
+ // endTurnView(kind, endText, opts), appendChunk(text), readTurnEnd(),
28
+ // applyFinalMarkdown(messages), refreshTimingFromSession(),
29
+ // makeUserBubble(prompt, opts), addEndBubble(text), commandBubble(id),
30
+ // addCommandBubble(view), addNoteBubble(note), findInTurnRoots(selector),
31
+ // setUsedMemories(names), showRecap(text), showGuardrailWarning(text, label),
32
+ // showHookNotice(data), onInitEvent(type, data), upsertCard(card),
33
+ // refreshCardBodies() the page's hooks
34
+
35
+ import { routeChunk } from "./chunk_router.js";
36
+ import { extractCtxPct, generationTokens } from "./ctx.js";
37
+ import { failedTurnText } from "./format.js";
38
+ import { cancelLineText } from "./timing.js";
39
+ import { keepFollowing } from "./scroll.js";
40
+ import { shownElsewhere } from "./card.js";
41
+ import { restoredChips } from "./images.js";
42
+ import {
43
+ clientLabel, displayWait, emptyRetryLine, initWaitLine, isOwn, llmContextNotice, reminderText, replaysDrawnTurn,
44
+ retryStatusLine, steerCutLine, turnOutput,
45
+ } from "./turn_events.js";
46
+ import { commandView, drawCommandBubble, historyHasCommandBubble } from "./commands.js";
47
+ import { keepEarlyRestore, restoreAction } from "./restore.js";
48
+
49
+ export function createStreamHandlers(deps) {
50
+ const {
51
+ selected, state, clientId, capabilities, historyEl,
52
+ liveTurn, turnDom, shownTexts, questionCards, contextChips, llmContextControl, thinkingControl,
53
+ sentIds, earlyRestores, stream, composer, getSession,
54
+ resync, updateInfoBar, applyPromptOps, showSteers, markRecapStale, setTurnRunning, setLiveStatus, endTurnView,
55
+ appendChunk, readTurnEnd, applyFinalMarkdown, refreshTimingFromSession, makeUserBubble, addEndBubble,
56
+ commandBubble, addCommandBubble, addNoteBubble, findInTurnRoots, setUsedMemories, showRecap,
57
+ showGuardrailWarning, showHookNotice, onInitEvent, upsertCard, refreshCardBodies,
58
+ } = deps;
59
+
60
+ return {
61
+ generation_chunk: (data) => {
62
+ // Route the chunk's bytes to the text bubble and the thinking block (see
63
+ // chunk_router.js). Raw `content` is only a fallback for an older server.
64
+ const { text, thinking } = routeChunk(data);
65
+ liveTurn.timing.setNote("");
66
+ turnDom.chunk({ text, thinking, iteration: data.iteration ?? null });
67
+ const current = state();
68
+ const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
69
+ if (pct !== null && !Number.isNaN(pct)) {
70
+ current.ctxPct = pct;
71
+ updateInfoBar();
72
+ }
73
+ },
74
+ generation_started: (data) => {
75
+ if (data?.context_window_tokens) {
76
+ const current = state();
77
+ current.ctxWindow = data.context_window_tokens;
78
+ current.ctxWindowSource = data.context_window_source || null;
79
+ }
80
+ liveTurn.timing.setNote("");
81
+ turnDom.generationStarted(data || {});
82
+ },
83
+ generation_completed: (data) => {
84
+ liveTurn.timing.setNote("");
85
+ const current = state();
86
+ if (data?.served_model) current.served = [data.served_model, data.requested_model, data.served_expected_by || null];
87
+ // The generation's speed and the session's token totals, from the server.
88
+ current.timing.tokens = generationTokens(current.timing.tokens, data);
89
+ if (data?.served_model || data?.tokens) updateInfoBar();
90
+ turnDom.generationCompleted();
91
+ },
92
+ // Prompts sent while a turn ran merge into it at an iteration boundary:
93
+ // input_merged names them (their bubbles turn "steered"), then
94
+ // pending_input_merged carries the merged text, shown only for prompts
95
+ // this tab has no bubble for. Its `answer` (the answer the merge
96
+ // superseded) is the TUI's line: here it streamed as that step's text.
97
+ input_merged: (data) => applyPromptOps(data),
98
+ pending_input_merged: (data) => {
99
+ applyPromptOps(data);
100
+ showSteers(data.steers);
101
+ },
102
+ turn_enqueued: (data) => applyPromptOps(data),
103
+ turn_started: (data) => keepFollowing(historyEl, () => {
104
+ if (stream.takeGuess()) {
105
+ // A replay of a turn the render drew: re-read and stream from there.
106
+ if (replaysDrawnTurn(data, state().timing.turnRecords)) {
107
+ stream.restart();
108
+ return;
109
+ }
110
+ }
111
+ // A finished turn still in the stage goes first: the start op looks
112
+ // for a queued prompt among its rows.
113
+ turnDom.handOff?.({ immediate: true });
114
+ markRecapStale();
115
+ shownTexts.turnStarted();
116
+ const promptBubble = applyPromptOps(data);
117
+ setTurnRunning(true);
118
+ setLiveStatus("running");
119
+ liveTurn.timing.detach();
120
+ turnDom.turnStarted({ ...data, promptBubble });
121
+ liveTurn.timing.start(data.started_at, data.turn_id || null);
122
+ }),
123
+ turn_completed: async (data) => {
124
+ endTurnView("completed", null, { emptyAnswer: data.turn_summary?.empty_answer || null });
125
+ const out = turnOutput(data);
126
+ if (out) appendChunk(out);
127
+ // after_turn hooks are running and may present the answer: its
128
+ // answer_display (display: null when they didn't) comes next. A worker
129
+ // that dies before it never sends one: workerGone releases the wait, and
130
+ // displayWait's timeout ends it whatever happens (4.14).
131
+ const display = data.display_pending ? displayWait() : null;
132
+ if (display) liveTurn.displayPending = display;
133
+ liveTurn.endRead = readTurnEnd();
134
+ await liveTurn.endRead;
135
+ // The answer bubble the turn view held at its box's height pops now
136
+ // that the rendered markdown is in it (or the re-read failed: then the
137
+ // plain text pops), and the display if one was coming, so the links
138
+ // never swap in after the pop. The hold reveals by itself after
139
+ // HOLD_MS whatever comes. The stage has no hold.
140
+ if (display) await display.promise;
141
+ turnDom.answerReady?.();
142
+ },
143
+ // An after_turn hook presented the answer (AnswerDisplay): it comes after
144
+ // turn_completed, so re-read the answer once that read is done.
145
+ answer_display: async (data) => {
146
+ try {
147
+ await liveTurn.endRead;
148
+ if (!selected() || !data.display) return;
149
+ const d = await getSession(selected(), { tail: true, recent: true });
150
+ if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
151
+ } catch (_) {
152
+ } finally {
153
+ liveTurn.releaseDisplay();
154
+ }
155
+ },
156
+ turn_canceled: (data) => {
157
+ endTurnView("canceled", cancelLineText(data.cancellation_reason || "", data.cancelled_by), { stopped: true });
158
+ setLiveStatus("idle");
159
+ refreshTimingFromSession();
160
+ },
161
+ // The turn raised (e.g. the model server stayed unreachable). The worker
162
+ // stays up: it rolls the turn back and hands the prompt back
163
+ // (prompt_restored), and the prompts queued behind it run as usual. A turn
164
+ // that got to tool steps stays as it is (kept_steps; no prompt_restored).
165
+ turn_failed: (data) => {
166
+ const text = failedTurnText(data);
167
+ // Only a rolled-back turn's line waits for its prompt_restored.
168
+ liveTurn.failedText = Number.isInteger(data.kept_steps) ? null : text;
169
+ endTurnView("failed", text);
170
+ setLiveStatus("idle");
171
+ },
172
+ // The rolled-back turn left the session: render it from the server again,
173
+ // then show the prompt once more, marked failed (with why). This tab's own
174
+ // prompt goes back into an empty composer.
175
+ prompt_restored: async (data) => {
176
+ keepEarlyRestore(data, earlyRestores, { myId: clientId, sentIds });
177
+ const { refill, label, images: restoredImages } = restoreAction(data, { myId: clientId, sentIds, composerEmpty: composer.empty() });
178
+ if (refill) composer.refill(refill);
179
+ if (restoredImages?.length) composer.setChips(restoredChips(selected(), restoredImages));
180
+ const failedText = liveTurn.failedText;
181
+ liveTurn.failedText = null;
182
+ await resync();
183
+ // The re-render drew it from the session (failed_turn): drawn again
184
+ // below with this event's label and images.
185
+ const drawn = historyEl.querySelector(".bubble.user[data-failed-turn]");
186
+ if (drawn) {
187
+ if (drawn.nextElementSibling?.classList.contains("cancel")) drawn.nextElementSibling.remove();
188
+ drawn.remove();
189
+ }
190
+ // Not an echo waiting for its turn_enqueued: it keeps its enqueued_id, so
191
+ // a resend's echo isn't matched to it.
192
+ makeUserBubble(data.prompt || "", { state: "failed", label, enqueuedId: data.origin?.enqueued_id || `failed:${Date.now()}`, images: data.images });
193
+ if (failedText) addEndBubble(failedText);
194
+ },
195
+ // A session command ran in the worker (any client's). One that changed
196
+ // the conversation (!rollback, !cmd, a continue answered no) re-reads it.
197
+ command_ran: async (data) => {
198
+ const view = commandView(data, clientId);
199
+ if (view.modelName && view.modelName !== state().model) {
200
+ state().model = view.modelName;
201
+ state().served = null;
202
+ updateInfoBar();
203
+ }
204
+ // /llm-context changed what the next turn runs under.
205
+ if (data.llm_context) {
206
+ state().llmContext = data.llm_context;
207
+ updateInfoBar();
208
+ llmContextControl.refresh();
209
+ }
210
+ // /thinking (or /model) changed the level the next turn runs at.
211
+ if (data.thinking) {
212
+ state().thinking = data.thinking;
213
+ updateInfoBar();
214
+ thinkingControl.refresh();
215
+ }
216
+ if (view.resync) await resync();
217
+ // A card's action that isn't anytime (the step-limit question's
218
+ // /continue) has no bubble: nothing to remove. An anytime or queued
219
+ // one's bubble was drawn at its command_queued: filled in here. One
220
+ // queued for after the turn ran after it: its bubble moves from among
221
+ // the turn's rows to after its answer and timing line.
222
+ const queued = view.anytime || view.queued ? commandBubble(view.commandId) : null;
223
+ if (view.hidden) queued?.remove();
224
+ else if (queued && view.queued && !view.anytime) {
225
+ queued.remove();
226
+ addCommandBubble(view);
227
+ } else if (queued) drawCommandBubble(queued, view);
228
+ // A command that changed the conversation (!cmd): the redrawn history
229
+ // ends with its command bubble already (the saved "!(…)\n<output>"
230
+ // message renders as one) — no second bubble, or the output shows twice.
231
+ else if (view.resync && historyHasCommandBubble(findInTurnRoots(".bubble.command"), view.line)) { /* drawn by the resync */ }
232
+ else addCommandBubble(view);
233
+ },
234
+ // An anytime command (/help, a plugin's /btw) runs at once, beside a
235
+ // turn: its bubble goes here, so the cards it shows come after it. One
236
+ // queued for after the turn (/model X mid-turn) shows waiting here.
237
+ command_queued: (data) => {
238
+ // A card's action waits for its command_ran: shown only if it answers.
239
+ if ((data.anytime || data.waits) && !data.card && !commandBubble(data.command_id)) addCommandBubble(commandView(data, clientId));
240
+ },
241
+ // Due reminders went into the turn (a reminder turn has no prompt bubble).
242
+ reminder_injected: (data) => addCommandBubble({ label: null, line: reminderText(data), text: "" }),
243
+ // A context note joined the conversation (between turns; no turn runs).
244
+ context_added: (data) => {
245
+ addNoteBubble({ label: data.label, content: data.text || "" });
246
+ // An attached context source's note: its chip changed.
247
+ if (data.context_source) contextChips.refresh();
248
+ },
249
+ continue_offered: (data) => questionCards.renderContinue(data),
250
+ continue_resolved: (data) => questionCards.resolveContinue(data),
251
+ tool_call_started: (data) => turnDom.toolStarted(data),
252
+ tool_call_completed: (data) => turnDom.toolCompleted(data),
253
+ // The engine's list, after every memory read and at a turn's end.
254
+ used_memories_updated: (data) => {
255
+ if (Array.isArray(data.used_memory_names)) setUsedMemories(data.used_memory_names);
256
+ },
257
+ context_status: (data) => {
258
+ const current = state();
259
+ const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
260
+ if (pct !== null) {
261
+ current.ctxPct = pct;
262
+ updateInfoBar();
263
+ }
264
+ },
265
+ question_requested: (data) => {
266
+ const pq = data.pending_question;
267
+ if (pq && pq.id) questionCards.renderQuestion(pq);
268
+ },
269
+ question_answered: (data) => {
270
+ questionCards.resolveQuestion(data.id, { answer: data.answer });
271
+ },
272
+ // A delegate's question relayed to its parent's card, or no longer.
273
+ question_relay: (data) => questionCards.markRelayed(data.id, data.relayed_to),
274
+ question_cancelled: (data) => {
275
+ questionCards.resolveQuestion(data.id, { cancelled: true, reason: data.reason });
276
+ },
277
+ // One collected just as a turn started describes the chat before it.
278
+ recap_ready: (data) => {
279
+ // After the turn it covers, never above it.
280
+ turnDom.handOff?.({ immediate: true });
281
+ if (!liveTurn.running) showRecap(data.recap);
282
+ },
283
+ guardrail_warning: (data) => showGuardrailWarning(data.message, data.label || "guardrails"),
284
+ // A notice during a turn is a row of its current step. One the page
285
+ // shows elsewhere (a hook's links in the rendered answer) isn't drawn,
286
+ // not even in the stage trail; a joined turn's notices come here too.
287
+ hook_notice: (data) => {
288
+ if (shownElsewhere(data, capabilities())) return;
289
+ if (!turnDom.notice(data)) showHookNotice(data);
290
+ },
291
+ // The loop asks again after an empty answer: a row of the empty step
292
+ // (a reload puts it back from the snapshot's cards).
293
+ empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
294
+ steer_cut: (data) => { turnDom.notice({ line: steerCutLine(data), cls: "steer-cut" }); },
295
+ // A batch of LLM context edits the server applied: a ✂ row of the
296
+ // current step, its hover listing the outputs.
297
+ llm_context_edited: (data) => { turnDom.notice(llmContextNotice(data)); },
298
+ // The provider is asked again after an error, or the turn waits for
299
+ // plugins' setup: a note on the live timing line until it streams.
300
+ generation_retrying: (data) => {
301
+ // A dropped stream's step asked again: what it streamed is void.
302
+ if (data?.restarted) turnDom.generationRestarted(data);
303
+ liveTurn.timing.setNote(retryStatusLine(data));
304
+ },
305
+ plugin_init_wait: (data) => liveTurn.timing.setNote(initWaitLine(data)),
306
+ plugin_init_started: (data) => onInitEvent("plugin_init_started", data),
307
+ plugin_init_finished: (data) => onInitEvent("plugin_init_finished", data),
308
+ // A plugin's card, or a new version of one (the same id).
309
+ card: (data) => {
310
+ upsertCard(data);
311
+ if (data.body) refreshCardBodies();
312
+ },
313
+ // The stream's cursor could not be replayed: render from scratch.
314
+ reset: () => resync(),
315
+ // Synthetic (snapshotEvents): a prompt merged into the turn in progress.
316
+ merged_input: (data) => {
317
+ const origin = (data.origins || [])[0] || {};
318
+ const own = isOwn(origin.client_id, clientId);
319
+ makeUserBubble(data.content, {
320
+ state: "steered", own, enqueuedId: origin.enqueued_id || null, label: own ? null : clientLabel(origin.client_id),
321
+ });
322
+ },
323
+ };
324
+ }