samagotchi 0.53.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -1
  3. data/README.md +1 -1
  4. data/bin/chi +3 -2
  5. data/docs/architecture.md +94 -54
  6. data/docs/configuration.md +30 -2
  7. data/docs/context.md +104 -9
  8. data/docs/internals/background-tasks.md +1 -1
  9. data/docs/internals/bundles.md +73 -0
  10. data/docs/internals/context-telemetry.md +27 -6
  11. data/docs/internals/engine.md +55 -24
  12. data/docs/internals/tool-guardrails.md +8 -6
  13. data/docs/plugins.md +2 -1
  14. data/docs/sessions.md +3 -3
  15. data/docs/testing.md +23 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +8 -6
  17. data/lib/samagotchi/bundle_command.rb +31 -36
  18. data/lib/samagotchi/bundle_needs.rb +4 -5
  19. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
  21. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  22. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  23. data/lib/samagotchi/child_move.rb +6 -4
  24. data/lib/samagotchi/config.rb +2 -1
  25. data/lib/samagotchi/context_command.rb +1 -1
  26. data/lib/samagotchi/context_providers.rb +11 -14
  27. data/lib/samagotchi/context_status.rb +10 -18
  28. data/lib/samagotchi/engine.rb +117 -134
  29. data/lib/samagotchi/extension_load.rb +102 -0
  30. data/lib/samagotchi/file_ref.rb +59 -0
  31. data/lib/samagotchi/guardrail_wiring.rb +16 -16
  32. data/lib/samagotchi/kernel_loop.rb +5 -3
  33. data/lib/samagotchi/llm/chat_loop.rb +5 -4
  34. data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
  35. data/lib/samagotchi/memory_bundle/builder.rb +170 -178
  36. data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
  37. data/lib/samagotchi/memory_bundle/installer.rb +252 -488
  38. data/lib/samagotchi/memory_bundle/listing.rb +6 -6
  39. data/lib/samagotchi/memory_bundle/manifest.rb +4 -2
  40. data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
  41. data/lib/samagotchi/memory_bundle/profile.rb +21 -21
  42. data/lib/samagotchi/memory_bundle/provenance.rb +38 -40
  43. data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
  44. data/lib/samagotchi/memory_bundle/status.rb +22 -24
  45. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
  46. data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
  47. data/lib/samagotchi/plugin/loader.rb +8 -8
  48. data/lib/samagotchi/self_report.rb +2 -2
  49. data/lib/samagotchi/send_command.rb +1 -1
  50. data/lib/samagotchi/session_continue.rb +275 -0
  51. data/lib/samagotchi/session_manager.rb +9 -253
  52. data/lib/samagotchi/session_metrics.rb +3 -1
  53. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  54. data/lib/samagotchi/terminal_ui.rb +12 -2
  55. data/lib/samagotchi/tool_activity.rb +84 -14
  56. data/lib/samagotchi/tool_row_fields.rb +46 -0
  57. data/lib/samagotchi/tool_runner.rb +7 -11
  58. data/lib/samagotchi/version.rb +1 -1
  59. data/lib/samagotchi/web/app.rb +26 -3
  60. data/lib/samagotchi/web/capabilities.rb +13 -9
  61. data/lib/samagotchi/web/editor.rb +38 -0
  62. data/lib/samagotchi/web/message_parts.rb +5 -10
  63. data/lib/samagotchi/web/public/activity.js +35 -18
  64. data/lib/samagotchi/web/public/app.js +240 -1136
  65. data/lib/samagotchi/web/public/commands.js +236 -0
  66. data/lib/samagotchi/web/public/composer.js +101 -0
  67. data/lib/samagotchi/web/public/ctx.js +18 -4
  68. data/lib/samagotchi/web/public/diff_view.js +30 -4
  69. data/lib/samagotchi/web/public/index.html +9 -2
  70. data/lib/samagotchi/web/public/info_bar.js +78 -0
  71. data/lib/samagotchi/web/public/lists_view.js +725 -0
  72. data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
  73. data/lib/samagotchi/web/public/question_card.js +9 -1
  74. data/lib/samagotchi/web/public/question_cards.js +7 -4
  75. data/lib/samagotchi/web/public/refs.js +134 -0
  76. data/lib/samagotchi/web/public/restore.js +81 -0
  77. data/lib/samagotchi/web/public/session_state.js +52 -0
  78. data/lib/samagotchi/web/public/stream_handlers.js +324 -0
  79. data/lib/samagotchi/web/public/thinking_chip.js +6 -7
  80. data/lib/samagotchi/web/public/timing.js +8 -2
  81. data/lib/samagotchi/web/public/turn_events.js +17 -258
  82. data/lib/samagotchi/web/public/turn_html.js +15 -7
  83. data/lib/samagotchi/web/server.rb +4 -4
  84. metadata +17 -2
  85. data/lib/samagotchi/web/public/stable_html.js +0 -18
@@ -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
+ }
@@ -37,13 +37,12 @@ export function chipModel(summary, { kind = SESSION } = {}) {
37
37
  return { text: `think ${summary.level}${own ? ` · ${kind}` : ""}`, title, own };
38
38
  }
39
39
 
40
- // The session bar's chip, as a string: the info bar is still built as one
41
- // (app.js); it becomes a template when the info bar moves to lit.
42
- export function chipHtml(summary, escapeHtml) {
40
+ // The session bar's chip (a lit-html template; info_bar.js), nothing
41
+ // without a summary.
42
+ export function chipTemplate(summary) {
43
43
  const chip = chipModel(summary);
44
- if (!chip) return "";
45
- return `<button type="button" class="model think-chip${chip.own ? " own" : ""}" title="${escapeHtml(chip.title)}" ` +
46
- `data-think-chip aria-haspopup="dialog">${escapeHtml(chip.text)}</button>`;
44
+ if (!chip) return nothing;
45
+ return html`<button type="button" class=${`model think-chip${chip.own ? " own" : ""}`} title=${chip.title} data-think-chip aria-haspopup="dialog">${chip.text}</button>`;
47
46
  }
48
47
 
49
48
  // The /thinking line for a select value ("default" unsets the session's
@@ -81,7 +80,7 @@ export function formTemplate(summary, { running = false, kind = SESSION, choice
81
80
  }
82
81
 
83
82
  // The chip's popover, as createLlmContextControl's: el is where the chip
84
- // is drawn (clicks are delegated: #infoText is redrawn every tick);
83
+ // is drawn (clicks are delegated: #infoText, info_bar.js);
85
84
  // summary(): the open session's thinking; session(): its id; running():
86
85
  // a turn runs; run(line): sends the command. The start page's (kind
87
86
  // NEW_CHAT): summary() is the picked model's, choice() the pending level
@@ -264,14 +264,20 @@ function canceledSteps(g, record, tools) {
264
264
  }
265
265
 
266
266
  // A call's row from its tool record alone (no saved parts): the record's
267
- // called_as too, so "(called as bash)" shows after a reload, as live.
267
+ // called_as too, so "(called as bash)" shows after a reload, as live, and
268
+ // its no_match ("no match" on a grep that found nothing).
268
269
  function recordRows(tools, iteration) {
269
270
  return tools.filter((r) => Number(r.iteration) === iteration).map((r) => ({
270
271
  key: `${iteration}:${r.call_index ?? 0}`, tool: r.tool, status: r.status, duration_ms: r.duration_ms,
271
272
  ...(r.called_as ? { called_as: r.called_as } : {}),
273
+ ...noMatch(r),
272
274
  }));
273
275
  }
274
276
 
277
+ function noMatch(record) {
278
+ return record?.no_match ? { no_match: true } : {};
279
+ }
280
+
275
281
  // A turn whose saved messages carry their parts (the server's ?parts=1):
276
282
  // every assistant message is one step. Its calls take the turn's records by
277
283
  // tool, in call order (each the next record of its tool, consumed with the
@@ -292,7 +298,7 @@ function partsTurn(g, items, record, tools, stepsOnly = false) {
292
298
  const rows = Array.isArray(parts.tools)
293
299
  ? parts.tools.map((call, j) => {
294
300
  const r = takeRecord(pool, call.tool);
295
- return { key: `${iteration}:${j + 1}`, status: r?.status || "ok", duration_ms: r?.duration_ms, ...call };
301
+ return { key: `${iteration}:${j + 1}`, status: r?.status || "ok", duration_ms: r?.duration_ms, ...noMatch(r), ...call };
296
302
  })
297
303
  : [];
298
304
  const thinking = String(parts.thinking || "");