samagotchi 0.54.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/bin/chi +3 -2
  4. data/docs/architecture.md +6 -2
  5. data/docs/cli.md +29 -7
  6. data/docs/configuration.md +113 -8
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/context-telemetry.md +23 -5
  9. data/docs/internals/engine.md +54 -18
  10. data/docs/internals/prompt-caching.md +8 -0
  11. data/docs/sessions.md +2 -2
  12. data/docs/testing.md +23 -3
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -6
  14. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  15. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  16. data/lib/samagotchi/cache_confirm.rb +51 -0
  17. data/lib/samagotchi/client_id.rb +12 -0
  18. data/lib/samagotchi/config.rb +9 -3
  19. data/lib/samagotchi/engine.rb +102 -8
  20. data/lib/samagotchi/engine_peers.rb +1 -0
  21. data/lib/samagotchi/file_ref.rb +59 -0
  22. data/lib/samagotchi/host_config.rb +6 -3
  23. data/lib/samagotchi/host_registry.rb +4 -3
  24. data/lib/samagotchi/kernel_loop.rb +99 -16
  25. data/lib/samagotchi/llm/chat_loop.rb +122 -71
  26. data/lib/samagotchi/llm/openai_chat.rb +2 -1
  27. data/lib/samagotchi/llm/stream_drops.rb +63 -0
  28. data/lib/samagotchi/llm/turn_settings.rb +9 -5
  29. data/lib/samagotchi/model_settings.rb +5 -2
  30. data/lib/samagotchi/reasoning_history.rb +132 -0
  31. data/lib/samagotchi/reasoning_history_command.rb +107 -0
  32. data/lib/samagotchi/self_report.rb +21 -0
  33. data/lib/samagotchi/session.rb +23 -2
  34. data/lib/samagotchi/session_commands.rb +55 -15
  35. data/lib/samagotchi/session_manager.rb +2 -1
  36. data/lib/samagotchi/session_setup.rb +7 -5
  37. data/lib/samagotchi/setting_explained.rb +20 -0
  38. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -1
  39. data/lib/samagotchi/terminal_ui/attached_loop.rb +11 -0
  40. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  41. data/lib/samagotchi/terminal_ui.rb +13 -6
  42. data/lib/samagotchi/thinking.rb +20 -25
  43. data/lib/samagotchi/thinking_command.rb +33 -11
  44. data/lib/samagotchi/tool_row_fields.rb +46 -0
  45. data/lib/samagotchi/tool_runner.rb +7 -11
  46. data/lib/samagotchi/tools/builtins.rb +6 -2
  47. data/lib/samagotchi/tools/peers.rb +4 -2
  48. data/lib/samagotchi/tools/task_runtime.rb +2 -1
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +48 -4
  51. data/lib/samagotchi/web/capabilities.rb +13 -9
  52. data/lib/samagotchi/web/editor.rb +38 -0
  53. data/lib/samagotchi/web/message_parts.rb +5 -10
  54. data/lib/samagotchi/web/public/activity.js +33 -18
  55. data/lib/samagotchi/web/public/app.js +256 -1133
  56. data/lib/samagotchi/web/public/commands.js +269 -0
  57. data/lib/samagotchi/web/public/composer.js +105 -0
  58. data/lib/samagotchi/web/public/diff_view.js +30 -4
  59. data/lib/samagotchi/web/public/index.html +11 -3
  60. data/lib/samagotchi/web/public/info_bar.js +80 -0
  61. data/lib/samagotchi/web/public/level_chip.js +174 -0
  62. data/lib/samagotchi/web/public/lists_view.js +725 -0
  63. data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
  64. data/lib/samagotchi/web/public/model_pick.js +1 -1
  65. data/lib/samagotchi/web/public/question_cards.js +2 -1
  66. data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
  67. data/lib/samagotchi/web/public/refs.js +134 -0
  68. data/lib/samagotchi/web/public/restore.js +81 -0
  69. data/lib/samagotchi/web/public/session_state.js +57 -0
  70. data/lib/samagotchi/web/public/stream_handlers.js +332 -0
  71. data/lib/samagotchi/web/public/thinking_chip.js +38 -144
  72. data/lib/samagotchi/web/public/turn_events.js +17 -258
  73. data/lib/samagotchi/web/public/turn_html.js +5 -1
  74. data/lib/samagotchi/web/server.rb +4 -4
  75. data/lib/samagotchi/worker.rb +17 -5
  76. metadata +18 -2
  77. data/lib/samagotchi/web/public/stable_html.js +0 -18
@@ -0,0 +1,332 @@
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,
19
+ // reasoningHistoryControl the page's views
20
+ // sentIds, earlyRestores restore.js's bookkeeping
21
+ // stream: {takeGuess(), restart()} the stream was opened from a
22
+ // guess (takeGuess: whether, and forget it); restart:
23
+ // close the stream and resync
24
+ // composer: {empty(), refill(text), setChips(chips)}
25
+ // getSession(id, opts) data.js
26
+ // resync(), updateInfoBar(), applyPromptOps(data), showSteers(steers),
27
+ // markRecapStale(), setTurnRunning(running), setLiveStatus(status),
28
+ // endTurnView(kind, endText, opts), appendChunk(text), readTurnEnd(),
29
+ // applyFinalMarkdown(messages), refreshTimingFromSession(),
30
+ // makeUserBubble(prompt, opts), addEndBubble(text), commandBubble(id),
31
+ // addCommandBubble(view), addNoteBubble(note), findInTurnRoots(selector),
32
+ // setUsedMemories(names), showRecap(text), showGuardrailWarning(text, label),
33
+ // showHookNotice(data), onInitEvent(type, data), upsertCard(card),
34
+ // refreshCardBodies() the page's hooks
35
+
36
+ import { routeChunk } from "./chunk_router.js";
37
+ import { extractCtxPct, generationTokens } from "./ctx.js";
38
+ import { failedTurnText } from "./format.js";
39
+ import { cancelLineText } from "./timing.js";
40
+ import { keepFollowing } from "./scroll.js";
41
+ import { shownElsewhere } from "./card.js";
42
+ import { restoredChips } from "./images.js";
43
+ import {
44
+ clientLabel, displayWait, emptyRetryLine, initWaitLine, isOwn, llmContextNotice, reminderText, replaysDrawnTurn,
45
+ retryStatusLine, steerCutLine, turnOutput,
46
+ } from "./turn_events.js";
47
+ import { commandView, drawCommandBubble, historyHasCommandBubble } from "./commands.js";
48
+ import { keepEarlyRestore, restoreAction } from "./restore.js";
49
+
50
+ export function createStreamHandlers(deps) {
51
+ const {
52
+ selected, state, clientId, capabilities, historyEl,
53
+ liveTurn, turnDom, shownTexts, questionCards, contextChips, llmContextControl, thinkingControl, reasoningHistoryControl,
54
+ sentIds, earlyRestores, stream, composer, getSession,
55
+ resync, updateInfoBar, applyPromptOps, showSteers, markRecapStale, setTurnRunning, setLiveStatus, endTurnView,
56
+ appendChunk, readTurnEnd, applyFinalMarkdown, refreshTimingFromSession, makeUserBubble, addEndBubble,
57
+ commandBubble, addCommandBubble, addNoteBubble, findInTurnRoots, setUsedMemories, showRecap,
58
+ showGuardrailWarning, showHookNotice, onInitEvent, upsertCard, refreshCardBodies,
59
+ } = deps;
60
+
61
+ return {
62
+ generation_chunk: (data) => {
63
+ // Route the chunk's bytes to the text bubble and the thinking block (see
64
+ // chunk_router.js). Raw `content` is only a fallback for an older server.
65
+ const { text, thinking } = routeChunk(data);
66
+ liveTurn.timing.setNote("");
67
+ turnDom.chunk({ text, thinking, iteration: data.iteration ?? null });
68
+ const current = state();
69
+ const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
70
+ if (pct !== null && !Number.isNaN(pct)) {
71
+ current.ctxPct = pct;
72
+ updateInfoBar();
73
+ }
74
+ },
75
+ generation_started: (data) => {
76
+ if (data?.context_window_tokens) {
77
+ const current = state();
78
+ current.ctxWindow = data.context_window_tokens;
79
+ current.ctxWindowSource = data.context_window_source || null;
80
+ }
81
+ liveTurn.timing.setNote("");
82
+ turnDom.generationStarted(data || {});
83
+ },
84
+ generation_completed: (data) => {
85
+ liveTurn.timing.setNote("");
86
+ const current = state();
87
+ if (data?.served_model) current.served = [data.served_model, data.requested_model, data.served_expected_by || null];
88
+ // The generation's speed and the session's token totals, from the server.
89
+ current.timing.tokens = generationTokens(current.timing.tokens, data);
90
+ if (data?.served_model || data?.tokens) updateInfoBar();
91
+ turnDom.generationCompleted();
92
+ },
93
+ // Prompts sent while a turn ran merge into it at an iteration boundary:
94
+ // input_merged names them (their bubbles turn "steered"), then
95
+ // pending_input_merged carries the merged text, shown only for prompts
96
+ // this tab has no bubble for. Its `answer` (the answer the merge
97
+ // superseded) is the TUI's line: here it streamed as that step's text.
98
+ input_merged: (data) => applyPromptOps(data),
99
+ pending_input_merged: (data) => {
100
+ applyPromptOps(data);
101
+ showSteers(data.steers);
102
+ },
103
+ turn_enqueued: (data) => applyPromptOps(data),
104
+ turn_started: (data) => keepFollowing(historyEl, () => {
105
+ if (stream.takeGuess()) {
106
+ // A replay of a turn the render drew: re-read and stream from there.
107
+ if (replaysDrawnTurn(data, state().timing.turnRecords)) {
108
+ stream.restart();
109
+ return;
110
+ }
111
+ }
112
+ // A finished turn still in the stage goes first: the start op looks
113
+ // for a queued prompt among its rows.
114
+ turnDom.handOff?.({ immediate: true });
115
+ markRecapStale();
116
+ shownTexts.turnStarted();
117
+ const promptBubble = applyPromptOps(data);
118
+ setTurnRunning(true);
119
+ setLiveStatus("running");
120
+ liveTurn.timing.detach();
121
+ turnDom.turnStarted({ ...data, promptBubble });
122
+ liveTurn.timing.start(data.started_at, data.turn_id || null);
123
+ }),
124
+ turn_completed: async (data) => {
125
+ endTurnView("completed", null, { emptyAnswer: data.turn_summary?.empty_answer || null });
126
+ const out = turnOutput(data);
127
+ if (out) appendChunk(out);
128
+ // after_turn hooks are running and may present the answer: its
129
+ // answer_display (display: null when they didn't) comes next. A worker
130
+ // that dies before it never sends one: workerGone releases the wait, and
131
+ // displayWait's timeout ends it whatever happens (4.14).
132
+ const display = data.display_pending ? displayWait() : null;
133
+ if (display) liveTurn.displayPending = display;
134
+ liveTurn.endRead = readTurnEnd();
135
+ await liveTurn.endRead;
136
+ // The answer bubble the turn view held at its box's height pops now
137
+ // that the rendered markdown is in it (or the re-read failed: then the
138
+ // plain text pops), and the display if one was coming, so the links
139
+ // never swap in after the pop. The hold reveals by itself after
140
+ // HOLD_MS whatever comes. The stage has no hold.
141
+ if (display) await display.promise;
142
+ turnDom.answerReady?.();
143
+ },
144
+ // An after_turn hook presented the answer (AnswerDisplay): it comes after
145
+ // turn_completed, so re-read the answer once that read is done.
146
+ answer_display: async (data) => {
147
+ try {
148
+ await liveTurn.endRead;
149
+ if (!selected() || !data.display) return;
150
+ const d = await getSession(selected(), { tail: true, recent: true });
151
+ if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
152
+ } catch (_) {
153
+ } finally {
154
+ liveTurn.releaseDisplay();
155
+ }
156
+ },
157
+ turn_canceled: (data) => {
158
+ endTurnView("canceled", cancelLineText(data.cancellation_reason || "", data.cancelled_by), { stopped: true });
159
+ setLiveStatus("idle");
160
+ refreshTimingFromSession();
161
+ },
162
+ // The turn raised (e.g. the model server stayed unreachable). The worker
163
+ // stays up: it rolls the turn back and hands the prompt back
164
+ // (prompt_restored), and the prompts queued behind it run as usual. A turn
165
+ // that got to tool steps stays as it is (kept_steps; no prompt_restored).
166
+ turn_failed: (data) => {
167
+ const text = failedTurnText(data);
168
+ // Only a rolled-back turn's line waits for its prompt_restored.
169
+ liveTurn.failedText = Number.isInteger(data.kept_steps) ? null : text;
170
+ endTurnView("failed", text);
171
+ setLiveStatus("idle");
172
+ },
173
+ // The rolled-back turn left the session: render it from the server again,
174
+ // then show the prompt once more, marked failed (with why). This tab's own
175
+ // prompt goes back into an empty composer.
176
+ prompt_restored: async (data) => {
177
+ keepEarlyRestore(data, earlyRestores, { myId: clientId, sentIds });
178
+ const { refill, label, images: restoredImages } = restoreAction(data, { myId: clientId, sentIds, composerEmpty: composer.empty() });
179
+ if (refill) composer.refill(refill);
180
+ if (restoredImages?.length) composer.setChips(restoredChips(selected(), restoredImages));
181
+ const failedText = liveTurn.failedText;
182
+ liveTurn.failedText = null;
183
+ await resync();
184
+ // The re-render drew it from the session (failed_turn): drawn again
185
+ // below with this event's label and images.
186
+ const drawn = historyEl.querySelector(".bubble.user[data-failed-turn]");
187
+ if (drawn) {
188
+ if (drawn.nextElementSibling?.classList.contains("cancel")) drawn.nextElementSibling.remove();
189
+ drawn.remove();
190
+ }
191
+ // Not an echo waiting for its turn_enqueued: it keeps its enqueued_id, so
192
+ // a resend's echo isn't matched to it.
193
+ makeUserBubble(data.prompt || "", { state: "failed", label, enqueuedId: data.origin?.enqueued_id || `failed:${Date.now()}`, images: data.images });
194
+ if (failedText) addEndBubble(failedText);
195
+ },
196
+ // A session command ran in the worker (any client's). One that changed
197
+ // the conversation (!rollback, !cmd, a continue answered no) re-reads it.
198
+ command_ran: async (data) => {
199
+ const view = commandView(data, clientId);
200
+ if (view.modelName && view.modelName !== state().model) {
201
+ state().model = view.modelName;
202
+ state().served = null;
203
+ updateInfoBar();
204
+ }
205
+ // /llm-context changed what the next turn runs under.
206
+ if (data.llm_context) {
207
+ state().llmContext = data.llm_context;
208
+ updateInfoBar();
209
+ llmContextControl.refresh();
210
+ }
211
+ // /thinking (or /model) changed the level the next turn runs at.
212
+ if (data.thinking) {
213
+ state().thinking = data.thinking;
214
+ updateInfoBar();
215
+ thinkingControl.refresh();
216
+ }
217
+ // /reasoning-history (or /model) changed it; null: the model now runs
218
+ // on a native host, which ignores it (the chip goes).
219
+ if ("reasoning_history" in data) {
220
+ state().reasoningHistory = data.reasoning_history || null;
221
+ updateInfoBar();
222
+ reasoningHistoryControl.refresh();
223
+ }
224
+ if (view.resync) await resync();
225
+ // A card's action that isn't anytime (the step-limit question's
226
+ // /continue) has no bubble: nothing to remove. An anytime or queued
227
+ // one's bubble was drawn at its command_queued: filled in here. One
228
+ // queued for after the turn ran after it: its bubble moves from among
229
+ // the turn's rows to after its answer and timing line.
230
+ const queued = view.anytime || view.queued ? commandBubble(view.commandId) : null;
231
+ if (view.hidden) queued?.remove();
232
+ else if (queued && view.queued && !view.anytime) {
233
+ queued.remove();
234
+ addCommandBubble(view);
235
+ } else if (queued) drawCommandBubble(queued, view);
236
+ // A command that changed the conversation (!cmd): the redrawn history
237
+ // ends with its command bubble already (the saved "!(…)\n<output>"
238
+ // message renders as one) — no second bubble, or the output shows twice.
239
+ else if (view.resync && historyHasCommandBubble(findInTurnRoots(".bubble.command"), view.line)) { /* drawn by the resync */ }
240
+ else addCommandBubble(view);
241
+ },
242
+ // An anytime command (/help, a plugin's /btw) runs at once, beside a
243
+ // turn: its bubble goes here, so the cards it shows come after it. One
244
+ // queued for after the turn (/model X mid-turn) shows waiting here.
245
+ command_queued: (data) => {
246
+ // A card's action waits for its command_ran: shown only if it answers.
247
+ if ((data.anytime || data.waits) && !data.card && !commandBubble(data.command_id)) addCommandBubble(commandView(data, clientId));
248
+ },
249
+ // Due reminders went into the turn (a reminder turn has no prompt bubble).
250
+ reminder_injected: (data) => addCommandBubble({ label: null, line: reminderText(data), text: "" }),
251
+ // A context note joined the conversation (between turns; no turn runs).
252
+ context_added: (data) => {
253
+ addNoteBubble({ label: data.label, content: data.text || "" });
254
+ // An attached context source's note: its chip changed.
255
+ if (data.context_source) contextChips.refresh();
256
+ },
257
+ continue_offered: (data) => questionCards.renderContinue(data),
258
+ continue_resolved: (data) => questionCards.resolveContinue(data),
259
+ tool_call_started: (data) => turnDom.toolStarted(data),
260
+ tool_call_completed: (data) => turnDom.toolCompleted(data),
261
+ // The engine's list, after every memory read and at a turn's end.
262
+ used_memories_updated: (data) => {
263
+ if (Array.isArray(data.used_memory_names)) setUsedMemories(data.used_memory_names);
264
+ },
265
+ context_status: (data) => {
266
+ const current = state();
267
+ const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
268
+ if (pct !== null) {
269
+ current.ctxPct = pct;
270
+ updateInfoBar();
271
+ }
272
+ },
273
+ question_requested: (data) => {
274
+ const pq = data.pending_question;
275
+ if (pq && pq.id) questionCards.renderQuestion(pq);
276
+ },
277
+ question_answered: (data) => {
278
+ questionCards.resolveQuestion(data.id, { answer: data.answer });
279
+ },
280
+ // A delegate's question relayed to its parent's card, or no longer.
281
+ question_relay: (data) => questionCards.markRelayed(data.id, data.relayed_to),
282
+ question_cancelled: (data) => {
283
+ questionCards.resolveQuestion(data.id, { cancelled: true, reason: data.reason });
284
+ },
285
+ // One collected just as a turn started describes the chat before it.
286
+ recap_ready: (data) => {
287
+ // After the turn it covers, never above it.
288
+ turnDom.handOff?.({ immediate: true });
289
+ if (!liveTurn.running) showRecap(data.recap);
290
+ },
291
+ guardrail_warning: (data) => showGuardrailWarning(data.message, data.label || "guardrails"),
292
+ // A notice during a turn is a row of its current step. One the page
293
+ // shows elsewhere (a hook's links in the rendered answer) isn't drawn,
294
+ // not even in the stage trail; a joined turn's notices come here too.
295
+ hook_notice: (data) => {
296
+ if (shownElsewhere(data, capabilities())) return;
297
+ if (!turnDom.notice(data)) showHookNotice(data);
298
+ },
299
+ // The loop asks again after an empty answer: a row of the empty step
300
+ // (a reload puts it back from the snapshot's cards).
301
+ empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
302
+ steer_cut: (data) => { turnDom.notice({ line: steerCutLine(data), cls: "steer-cut" }); },
303
+ // A batch of LLM context edits the server applied: a ✂ row of the
304
+ // current step, its hover listing the outputs.
305
+ llm_context_edited: (data) => { turnDom.notice(llmContextNotice(data)); },
306
+ // The provider is asked again after an error, or the turn waits for
307
+ // plugins' setup: a note on the live timing line until it streams.
308
+ generation_retrying: (data) => {
309
+ // A dropped stream's step asked again: what it streamed is void.
310
+ if (data?.restarted) turnDom.generationRestarted(data);
311
+ liveTurn.timing.setNote(retryStatusLine(data));
312
+ },
313
+ plugin_init_wait: (data) => liveTurn.timing.setNote(initWaitLine(data)),
314
+ plugin_init_started: (data) => onInitEvent("plugin_init_started", data),
315
+ plugin_init_finished: (data) => onInitEvent("plugin_init_finished", data),
316
+ // A plugin's card, or a new version of one (the same id).
317
+ card: (data) => {
318
+ upsertCard(data);
319
+ if (data.body) refreshCardBodies();
320
+ },
321
+ // The stream's cursor could not be replayed: render from scratch.
322
+ reset: () => resync(),
323
+ // Synthetic (snapshotEvents): a prompt merged into the turn in progress.
324
+ merged_input: (data) => {
325
+ const origin = (data.origins || [])[0] || {};
326
+ const own = isOwn(origin.client_id, clientId);
327
+ makeUserBubble(data.content, {
328
+ state: "steered", own, enqueuedId: origin.enqueued_id || null, label: own ? null : clientLabel(origin.client_id),
329
+ });
330
+ },
331
+ };
332
+ }
@@ -2,20 +2,35 @@
2
2
  // low"; "· session" when the session set its own), and a popover with a
3
3
  // level select that runs /thinking in the session's worker (the command's
4
4
  // reply shows in the conversation, like a typed one). The level and where
5
- // it came from are the session's thinking (Thinking::Explained#summary:
5
+ // it came from are the session's thinking (SettingExplained#summary:
6
6
  // {level, source, own}; the worker's snapshot, a command_ran, or the
7
- // server's own reading of the session file).
7
+ // server's own reading of the session file). A config on level_chip.js.
8
8
  //
9
9
  // The start page has one too (kind "new chat"): it shows the picked
10
10
  // model's level (GET /api/models' thinking) and keeps a choice for the
11
11
  // next create only (POST /api/sessions' thinking), never remembered:
12
12
  // nothing is sent unless the user sets one.
13
13
 
14
- import { NEW_CHAT, SESSION, popoverPlace } from "./llm_context_chip.js";
15
- import { html, nothing, render } from "./vendor/lit-html.js";
14
+ import { SESSION } from "./llm_context_chip.js";
15
+ import * as level from "./level_chip.js";
16
+ import { html, nothing } from "./vendor/lit-html.js";
16
17
 
17
18
  export const LEVELS = ["off", "low", "medium", "high"];
18
- const DEFAULT = "default";
19
+
20
+ export const CONFIG = Object.freeze({
21
+ name: "Thinking",
22
+ prefix: "think",
23
+ command: "/thinking",
24
+ levels: LEVELS,
25
+ anchor: "[data-think-chip]",
26
+ formClass: "think-form",
27
+ popoverClass: "think-popover",
28
+ nowClass: "think-now",
29
+ when: (fresh) => (fresh ? "For the new chat's first turn on; not remembered for the next one."
30
+ : "From the next turn's start. On Gemma the whole prompt is read again; a hosted API reads the whole conversation again."),
31
+ });
32
+
33
+ export const { nowText } = level;
19
34
 
20
35
  // The new chat's choice (a level, or null) laid over the model's summary:
21
36
  // what the chip shows. The model's own summary without a choice.
@@ -28,152 +43,31 @@ export function withChoice(summary, choice) {
28
43
  // set its own; null without a summary (an older worker). The default with
29
44
  // nothing set anywhere shows too ("think default"): the chip is where the
30
45
  // level is set.
31
- export function chipModel(summary, { kind = SESSION } = {}) {
32
- if (!summary || !summary.level) return null;
33
- const own = !!summary.own;
34
- const what = kind === NEW_CHAT ? "the new chat" : "this session";
35
- const from = own ? what : summary.source || "the model's own";
36
- const title = `Thinking: ${summary.level} (${from}). Click to change it for ${what}.`;
37
- return { text: `think ${summary.level}${own ? ` · ${kind}` : ""}`, title, own };
46
+ export function chipModel(summary, opts) {
47
+ return level.chipModel(CONFIG, summary, opts);
38
48
  }
39
49
 
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) {
50
+ // The session bar's chip (a lit-html template; info_bar.js), nothing
51
+ // without a summary.
52
+ export function chipTemplate(summary) {
43
53
  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>`;
47
- }
48
-
49
- // The /thinking line for a select value ("default" unsets the session's
50
- // own); null when it is what the form opened with.
51
- export function commandLine(level, initial = null) {
52
- if (initial !== null && level === initial) return null;
53
- return `/thinking ${level}`;
54
+ if (!chip) return nothing;
55
+ return html`<button type="button" class=${`model think-chip${chip.own ? " own" : ""}`} title=${chip.title} data-think-chip aria-haspopup="dialog">${chip.text}</button>`;
54
56
  }
55
57
 
56
- // "Now: low (session)", "Now: default (the model's own)".
57
- export function nowText(summary) {
58
- return `Now: ${summary.level} (${summary.source || "the model's own"})`;
58
+ // The /thinking line for a select value, with --yes (level_chip.js
59
+ // commandLine); null when it is what the form opened with.
60
+ export function commandLine(choice, initial = null) {
61
+ return level.commandLine(CONFIG, choice, initial);
59
62
  }
60
63
 
61
- const option = (value, label, chosen) => html`<option value=${value} ?selected=${value === chosen}>${label}</option>`;
62
-
63
- // The popover's form (a lit-html template). For the new chat (kind
64
- // NEW_CHAT), +summary+ is the model's and +choice+ the level the form
65
- // shows; +now+ is the summary its "Now:" line reads (refresh passes a
66
- // newer one, the rest stays as the form opened).
67
- export function formTemplate(summary, { running = false, kind = SESSION, choice = null, now = summary } = {}) {
68
- const fresh = kind === NEW_CHAT;
69
- const chosen = (fresh ? choice : summary.own) || DEFAULT;
70
- const follow = summary.own && !fresh ? "follow the model" : `follow the model (${summary.level})`;
71
- const when = fresh ? "For the new chat's first turn on; not remembered for the next one."
72
- : "From the next turn's start. On Gemma the whole prompt is read again; a hosted API may restart its cache.";
73
- return html`<form class="think-form llmctx-form"><div class="ctx-pop-head"><strong>Thinking</strong
74
- ><span class="ctx-pop-kind">${fresh ? NEW_CHAT : "this session"}</span
75
- ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
76
- ><div class="ctx-pop-meta think-now">${nowText(now)}</div
77
- ><label class="llmctx-field"><span>Level</span><select name="level">${option(DEFAULT, follow, chosen)}${
78
- LEVELS.map((level) => option(level, level, chosen))}</select></label
79
- ><div class="ctx-pop-meta">${when}${running ? " A turn is running: it applies after this turn." : ""}</div
80
- ><div class="ctx-pop-actions"><button type="submit" class="llmctx-set">Set</button></div></form>`;
64
+ // The popover's form (level_chip.js formTemplate).
65
+ export function formTemplate(summary, opts) {
66
+ return level.formTemplate(CONFIG, summary, opts);
81
67
  }
82
68
 
83
- // The chip's popover, as createLlmContextControl's: el is where the chip
84
- // is drawn (clicks are delegated: #infoText is redrawn every tick);
85
- // summary(): the open session's thinking; session(): its id; running():
86
- // a turn runs; run(line): sends the command. The start page's (kind
87
- // NEW_CHAT): summary() is the picked model's, choice() the pending level
88
- // and choose(next) takes a Set's (null for "follow the model"). id and
89
- // anchor (the chip's selector) keep it apart from the llm ctx control on
90
- // the same element.
91
- export function createThinkingControl({ el, summary, session = () => null, running = () => false, run,
92
- kind = SESSION, choice = () => null, choose = null,
93
- id = "thinkingPopover", anchor: anchorSel = "[data-think-chip]",
94
- doc = document, win = window }) {
95
- const fresh = kind === NEW_CHAT;
96
- const pop = doc.createElement("div");
97
- pop.id = id;
98
- pop.className = "ctx-popover think-popover";
99
- pop.setAttribute("role", "dialog");
100
- pop.hidden = true;
101
- doc.body.appendChild(pop);
102
- let initial = null;
103
- let openFor = null;
104
- // What the open form was drawn from: the model's or session's summary
105
- // and the form's options. A closed popover is empty, so each open draws
106
- // a fresh form (an unsent pick is gone).
107
- let shown = null;
108
- const draw = (now) => render(formTemplate(shown.data, { ...shown.opts, now }), pop);
109
-
110
- function place(anchor) {
111
- const width = Math.min(320, win.innerWidth - 32);
112
- pop.style.width = `${width}px`;
113
- pop.style.maxHeight = "";
114
- const at = popoverPlace(anchor.getBoundingClientRect(), { width, height: pop.offsetHeight,
115
- innerWidth: win.innerWidth, innerHeight: win.innerHeight });
116
- pop.style.left = `${at.left}px`;
117
- pop.style.top = at.top === undefined ? "" : `${at.top}px`;
118
- pop.style.bottom = at.bottom === undefined ? "" : `${at.bottom}px`;
119
- if (at.maxHeight !== undefined) pop.style.maxHeight = `${at.maxHeight}px`;
120
- }
121
-
122
- function open(anchor) {
123
- const data = summary();
124
- if (!data) return;
125
- initial = (fresh ? choice() : data.own) || DEFAULT;
126
- openFor = session();
127
- shown = { data, opts: { running: running(), kind, choice: choice() } };
128
- draw(data);
129
- pop.hidden = false;
130
- place(anchor);
131
- }
132
-
133
- function close() {
134
- pop.hidden = true;
135
- initial = null;
136
- openFor = null;
137
- shown = null;
138
- render(nothing, pop);
139
- }
140
-
141
- function isOpen() { return !pop.hidden; }
142
-
143
- // A new summary: an open form keeps the user's pick; only "Now:" follows
144
- // (the same template again: lit writes that line alone).
145
- function refresh() {
146
- if (!isOpen()) return;
147
- const data = summary();
148
- if (!data || session() !== openFor) return close();
149
- draw(data);
150
- }
151
-
152
- el.addEventListener("click", (e) => {
153
- const anchor = e.target.closest(anchorSel);
154
- if (!anchor) return;
155
- e.stopPropagation();
156
- if (isOpen()) close();
157
- else open(anchor);
158
- });
159
- pop.addEventListener("submit", (e) => {
160
- e.preventDefault();
161
- const level = e.target.elements.level.value;
162
- const was = initial;
163
- close();
164
- if (fresh) return choose(level === DEFAULT ? null : level);
165
- const line = commandLine(level, was);
166
- if (line) run(line);
167
- });
168
- pop.addEventListener("click", (e) => {
169
- if (e.target.closest("[data-act]")?.dataset.act === "close") close();
170
- });
171
- doc.addEventListener("click", (e) => {
172
- if (isOpen() && !pop.contains(e.target) && !e.target.closest?.(anchorSel)) close();
173
- });
174
- doc.addEventListener("keydown", (e) => {
175
- if (e.key === "Escape" && isOpen()) close();
176
- });
177
-
178
- return { close, refresh, get open() { return isOpen(); } };
69
+ // The chip's popover (level_chip.js createLevelControl); the start page's
70
+ // passes kind NEW_CHAT, choice and choose.
71
+ export function createThinkingControl({ id = "thinkingPopover", ...opts }) {
72
+ return level.createLevelControl({ cfg: CONFIG, id, ...opts });
179
73
  }