samagotchi 0.134.0 → 0.136.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 (114) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -1
  3. data/bin/chi +2 -3
  4. data/docs/architecture.md +6 -3
  5. data/docs/cli.md +43 -38
  6. data/docs/configuration.md +11 -9
  7. data/docs/guardrails.md +53 -9
  8. data/docs/internals/engine.md +6 -3
  9. data/docs/internals/participants.md +1 -1
  10. data/docs/internals/prompt-caching.md +1 -1
  11. data/docs/internals/web-frontend.md +263 -0
  12. data/docs/internals/working-set.md +2 -2
  13. data/docs/sessions.md +5 -3
  14. data/docs/testing.md +36 -6
  15. data/lib/samagotchi/bridge.rb +11 -6
  16. data/lib/samagotchi/bridge_client.rb +8 -5
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/config.rb +0 -2
  19. data/lib/samagotchi/engine.rb +69 -12
  20. data/lib/samagotchi/guardrail_wiring.rb +8 -6
  21. data/lib/samagotchi/guardrails/approval.rb +41 -5
  22. data/lib/samagotchi/guardrails/implicit_writers.rb +19 -4
  23. data/lib/samagotchi/guardrails/pair_boundary.rb +5 -1
  24. data/lib/samagotchi/guardrails/pair_command.rb +387 -78
  25. data/lib/samagotchi/guardrails/parent_approvals.rb +12 -15
  26. data/lib/samagotchi/guardrails/read_only_shell.rb +24 -2
  27. data/lib/samagotchi/guardrails/shared_git.rb +1 -1
  28. data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
  29. data/lib/samagotchi/guardrails/shell_lex.rb +22 -0
  30. data/lib/samagotchi/guardrails_command.rb +13 -10
  31. data/lib/samagotchi/llm/chat_loop.rb +4 -3
  32. data/lib/samagotchi/message_parts.rb +1 -1
  33. data/lib/samagotchi/revise_anchor.rb +33 -0
  34. data/lib/samagotchi/session_inbox.rb +20 -12
  35. data/lib/samagotchi/session_manager.rb +13 -11
  36. data/lib/samagotchi/system_one/approval_question.rb +21 -16
  37. data/lib/samagotchi/system_one/self_protect.rb +13 -15
  38. data/lib/samagotchi/tools/delegate_relay.rb +6 -5
  39. data/lib/samagotchi/tools/delegate_wait.rb +5 -5
  40. data/lib/samagotchi/tools/execute.rb +6 -6
  41. data/lib/samagotchi/tools/output_guardrails.rb +9 -2
  42. data/lib/samagotchi/tools/read.rb +5 -5
  43. data/lib/samagotchi/turn_anchors.rb +49 -0
  44. data/lib/samagotchi/verify_anchor.rb +5 -5
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/app.rb +6 -18
  47. data/lib/samagotchi/web/message_display.rb +33 -1
  48. data/lib/samagotchi/web/participant_access.rb +2 -2
  49. data/lib/samagotchi/web/participant_view.rb +8 -0
  50. data/lib/samagotchi/web/public/activity.js +1 -1
  51. data/lib/samagotchi/web/public/annotations.js +1 -1
  52. data/lib/samagotchi/web/public/answer_fold.js +100 -31
  53. data/lib/samagotchi/web/public/app.js +451 -367
  54. data/lib/samagotchi/web/public/bubbles.js +30 -10
  55. data/lib/samagotchi/web/public/cards_place.js +4 -2
  56. data/lib/samagotchi/web/public/chip_popover.js +2 -2
  57. data/lib/samagotchi/web/public/composer.css +8 -0
  58. data/lib/samagotchi/web/public/context_chips.js +9 -6
  59. data/lib/samagotchi/web/public/data.js +4 -2
  60. data/lib/samagotchi/web/public/dock_reserve.js +13 -15
  61. data/lib/samagotchi/web/public/feature_gates.js +33 -0
  62. data/lib/samagotchi/web/public/history_reveal.js +10 -6
  63. data/lib/samagotchi/web/public/index.html +105 -53
  64. data/lib/samagotchi/web/public/info_bar.js +11 -12
  65. data/lib/samagotchi/web/public/info_bar_model.js +8 -10
  66. data/lib/samagotchi/web/public/latest.js +46 -0
  67. data/lib/samagotchi/web/public/level_chip.js +33 -95
  68. data/lib/samagotchi/web/public/lists_view.js +15 -13
  69. data/lib/samagotchi/web/public/live_timing.js +2 -0
  70. data/lib/samagotchi/web/public/llm_context_chip.js +13 -144
  71. data/lib/samagotchi/web/public/markdown_place.js +1 -1
  72. data/lib/samagotchi/web/public/method_api.js +14 -0
  73. data/lib/samagotchi/web/public/notifications.js +4 -11
  74. data/lib/samagotchi/web/public/participant.js +11 -6
  75. data/lib/samagotchi/web/public/participant_names.js +1 -1
  76. data/lib/samagotchi/web/public/participants_panel.js +4 -2
  77. data/lib/samagotchi/web/public/project_picker.js +18 -22
  78. data/lib/samagotchi/web/public/question_cards.js +2 -2
  79. data/lib/samagotchi/web/public/reasoning_history_chip.js +8 -34
  80. data/lib/samagotchi/web/public/restore.js +2 -0
  81. data/lib/samagotchi/web/public/revise_stack.js +378 -0
  82. data/lib/samagotchi/web/public/scope.js +12 -20
  83. data/lib/samagotchi/web/public/send_turn.js +29 -16
  84. data/lib/samagotchi/web/public/session_actions.js +15 -14
  85. data/lib/samagotchi/web/public/session_fields.js +152 -0
  86. data/lib/samagotchi/web/public/session_payload.js +35 -0
  87. data/lib/samagotchi/web/public/session_state.js +9 -5
  88. data/lib/samagotchi/web/public/session_store.js +191 -0
  89. data/lib/samagotchi/web/public/settings_chip.js +212 -0
  90. data/lib/samagotchi/web/public/settings_panel.js +121 -0
  91. data/lib/samagotchi/web/public/shell_command.js +26 -0
  92. data/lib/samagotchi/web/public/stage_model.js +4 -4
  93. data/lib/samagotchi/web/public/stage_view.js +11 -16
  94. data/lib/samagotchi/web/public/start_settings.js +25 -88
  95. data/lib/samagotchi/web/public/stream_handlers.js +88 -90
  96. data/lib/samagotchi/web/public/task_stop.js +1 -1
  97. data/lib/samagotchi/web/public/thinking_chip.js +7 -45
  98. data/lib/samagotchi/web/public/time_gaps.js +5 -2
  99. data/lib/samagotchi/web/public/timing.js +15 -5
  100. data/lib/samagotchi/web/public/toolkit_chip.js +37 -21
  101. data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +33 -93
  102. data/lib/samagotchi/web/public/turn_events.js +3 -3
  103. data/lib/samagotchi/web/public/turn_html.js +20 -16
  104. data/lib/samagotchi/web/public/turn_model.js +6 -6
  105. data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
  106. data/lib/samagotchi/web/public/working_set_view.js +3 -1
  107. data/lib/samagotchi/web/server.rb +4 -4
  108. data/lib/samagotchi/web/session_control.rb +2 -2
  109. data/lib/samagotchi/web/session_create.rb +1 -1
  110. data/lib/samagotchi/web/session_show.rb +6 -3
  111. data/lib/samagotchi/worker.rb +9 -9
  112. data/lib/samagotchi/worker_inbound.rb +6 -5
  113. metadata +15 -3
  114. data/lib/samagotchi/web/public/hold.js +0 -78
@@ -4,19 +4,21 @@
4
4
  //
5
5
  // Everything that changes while the page runs is read through deps at the
6
6
  // event, never captured at creation: the open session (selected()), its
7
- // fields (state(): resetSessionState replaces the object), the page's
7
+ // fields (store.current: app.js forgetSession replaces the object), the page's
8
8
  // capabilities, the composer's text. A session switch mid-stream is handled
9
9
  // as before: the handlers act on whatever is open when the event comes.
10
10
  //
11
11
  // deps:
12
12
  // selected() the open session's id, or null
13
- // state() the open session's fields (session_state.js)
13
+ // store the page's session store (session_store.js): the
14
+ // open session's fields are read as store.current
15
+ // and written with store.update, one patch per handler
16
+ // (or one batch); a write redraws what reads it (the
17
+ // bar, a chip's popover), so no handler calls a redraw
14
18
  // clientId this tab's id (own prompts and commands)
15
19
  // capabilities() the page's capabilities (card.js shownElsewhere)
16
20
  // historyEl the history element (keepFollowing, the failed-turn redraw)
17
- // liveTurn, turnDom, shownTexts, questionCards, contextChips,
18
- // llmContextControl, thinkingControl,
19
- // reasoningHistoryControl the page's views
21
+ // liveTurn, turnDom, shownTexts, questionCards, contextChips the page's views
20
22
  // workingSet the stage view's working set (web.working_set),
21
23
  // null when off
22
24
  // participantsControl the owner's participants panel (participants_panel.js),
@@ -27,8 +29,9 @@
27
29
  // close the stream and resync
28
30
  // composer: {empty(), refill(text), setChips(chips)}
29
31
  // restoreVerify(anchor) a restored Verify turn's claim, armed again for its resend
32
+ // restoreRevise(anchor) a restored Revise turn's draft ({turn_id}), its chip back
30
33
  // getSession(id, opts) data.js
31
- // resync(), updateInfoBar(), applyPromptOps(data), showSteers(steers),
34
+ // resync(), applyPromptOps(data), showSteers(steers),
32
35
  // markRecapStale(), setTurnRunning(running), setLiveStatus(status),
33
36
  // endTurnView(kind, endText, opts), appendChunk(text), readTurnEnd(),
34
37
  // applyFinalMarkdown(messages), refreshTimingFromSession(),
@@ -56,13 +59,14 @@ import { commandView, drawCommandBubble, historyHasCommandBubble } from "./comma
56
59
  import { keepEarlyRestore, restoreAction } from "./restore.js";
57
60
  import { PAIR, setBadgeMode } from "./participant_names.js";
58
61
  import { isParticipantAuthor } from "./participant_model.js";
62
+ import { stillOn } from "./latest.js";
59
63
 
60
64
  export function createStreamHandlers(deps) {
61
65
  const {
62
- selected, state, clientId, capabilities, historyEl,
63
- liveTurn, turnDom, shownTexts, questionCards, contextChips, llmContextControl, thinkingControl, reasoningHistoryControl,
66
+ selected, store, clientId, capabilities, historyEl,
67
+ liveTurn, turnDom, shownTexts, questionCards, contextChips,
64
68
  workingSet = null, participantsControl = null, sentIds, earlyRestores, stream, composer, getSession,
65
- resync, updateInfoBar, applyPromptOps, showSteers, markRecapStale, setTurnRunning, setLiveStatus, endTurnView,
69
+ resync, applyPromptOps, showSteers, markRecapStale, setTurnRunning, setLiveStatus, endTurnView,
66
70
  appendChunk, readTurnEnd, applyFinalMarkdown, refreshTimingFromSession, makeUserBubble, addEndBubble,
67
71
  commandBubble, addCommandBubble, addNoteBubble, findInTurnRoots, setUsedMemories, showRecap,
68
72
  showGuardrailWarning, showHookNotice, onInitEvent, upsertCard, refreshCardBodies, syncModelPick,
@@ -85,26 +89,21 @@ export function createStreamHandlers(deps) {
85
89
  const { text, thinking } = routeChunk(data);
86
90
  liveTurn.timing.setNote("");
87
91
  turnDom.chunk({ text, thinking, iteration: data.iteration ?? null, now: data.replayed ? null : Date.now() });
88
- const current = state();
92
+ const current = store.current;
89
93
  const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
90
- if (pct !== null && !Number.isNaN(pct)) {
91
- current.ctxPct = pct;
92
- updateInfoBar();
93
- }
94
+ // A chunk with the same % redraws nothing (the store compares).
95
+ if (pct !== null && !Number.isNaN(pct)) store.update({ current: { ctxPct: pct } });
94
96
  },
95
97
  generation_started: (data) => {
96
98
  // The request after a /model's unresolved window started: whatever it
97
99
  // brings, the ctx tooltip no longer waits for it (ctxBarTip).
98
- const current = state();
99
- let contextChanged = current.ctxUnknownAfterSwitch !== false;
100
- current.ctxUnknownAfterSwitch = false;
100
+ // A changed value redraws the bar (the store), an unchanged one not.
101
+ const patch = { ctxUnknownAfterSwitch: false };
101
102
  if (data?.context_window_tokens) {
102
- const source = data.context_window_source || null;
103
- contextChanged ||= current.ctxWindow !== data.context_window_tokens || current.ctxWindowSource !== source;
104
- current.ctxWindow = data.context_window_tokens;
105
- current.ctxWindowSource = source;
103
+ patch.ctxWindow = data.context_window_tokens;
104
+ patch.ctxWindowSource = data.context_window_source || null;
106
105
  }
107
- if (contextChanged) updateInfoBar();
106
+ store.update({ current: patch });
108
107
  liveTurn.timing.setNote("");
109
108
  // The step's clock (the composer's delivery line): the page's own at a
110
109
  // live start; a replay's comes from the snapshot's generation age,
@@ -114,15 +113,19 @@ export function createStreamHandlers(deps) {
114
113
  },
115
114
  generation_completed: (data) => {
116
115
  liveTurn.timing.setNote("");
117
- const current = state();
118
- if (data?.served_model) current.served = [data.served_model, data.requested_model, data.served_expected_by || null];
119
- // The generation's speed and the session's token totals, from the server.
120
- current.timing.tokens = generationTokens(current.timing.tokens, data);
121
- // The rows per model, the server's whole list (the open one with this
122
- // generation in); an event without them (a replay, an older worker)
123
- // keeps the page's.
124
- if (Array.isArray(data?.model_segments)) current.timing.modelSegments = data.model_segments;
125
- if (data?.served_model || data?.tokens) updateInfoBar();
116
+ const current = store.current;
117
+ // The generation's speed and the session's token totals, from the
118
+ // server; the rows per model, the server's whole list (the open one
119
+ // with this generation in). An event without them (a replay, an older
120
+ // worker) keeps the page's: the timing object stays, no redraw.
121
+ const patch = {};
122
+ const tokens = generationTokens(current.timing.tokens, data);
123
+ const segments = Array.isArray(data?.model_segments) ? data.model_segments : null;
124
+ if (tokens !== current.timing.tokens || segments) {
125
+ patch.timing = { ...current.timing, tokens, ...(segments ? { modelSegments: segments } : {}) };
126
+ }
127
+ if (data?.served_model) patch.served = [data.served_model, data.requested_model, data.served_expected_by || null];
128
+ store.update({ current: patch });
126
129
  turnDom.generationCompleted();
127
130
  },
128
131
  // Prompts sent while a turn ran merge into it at an iteration boundary:
@@ -142,14 +145,14 @@ export function createStreamHandlers(deps) {
142
145
  turn_started: (data) => keepFollowing(historyEl, () => {
143
146
  if (stream.takeGuess()) {
144
147
  // A replay of a turn the render drew: re-read and stream from there.
145
- if (replaysDrawnTurn(data, state().timing.turnRecords)) {
148
+ if (replaysDrawnTurn(data, store.current.timing.turnRecords)) {
146
149
  stream.restart();
147
150
  return;
148
151
  }
149
152
  }
150
153
  // A finished turn still in the stage goes first: the start op looks
151
154
  // for a queued prompt among its rows.
152
- turnDom.handOff?.({ immediate: true });
155
+ turnDom.handOff({ immediate: true });
153
156
  markRecapStale();
154
157
  shownTexts.turnStarted();
155
158
  const promptBubble = applyPromptOps(data);
@@ -171,13 +174,19 @@ export function createStreamHandlers(deps) {
171
174
  if (data.participant_mode) setBadgeMode(promptBubble, data.participant_mode);
172
175
  // The owner's input queues behind it (the composer's delivery line).
173
176
  liveTurn.participantTurn = participant;
174
- setTurnRunning(true);
175
- setLiveStatus("running");
177
+ // One redraw of the composer, the bar and the lists, before
178
+ // keepFollowing's scroll.
179
+ store.batch(() => {
180
+ setTurnRunning(true);
181
+ setLiveStatus("running");
182
+ });
176
183
  liveTurn.timing.detach();
177
184
  turnDom.turnStarted({ ...data, promptBubble });
178
185
  liveTurn.timing.start(data.started_at, data.turn_id || null);
179
186
  }),
180
187
  turn_completed: async (data) => {
188
+ // The turn's id: the event names none, its turn_started did.
189
+ const turnId = data.turn_id || liveTurn.timing.turnId || null;
181
190
  activityEnded();
182
191
  // A participant's turn: their spend and turns in the panel moved.
183
192
  if (data.origin?.author) participantsControl?.reload();
@@ -185,6 +194,13 @@ export function createStreamHandlers(deps) {
185
194
  const out = turnOutput(data);
186
195
  if (out) appendChunk(out);
187
196
  if (data.verdict) deps.placeVerdict?.(data.verdict);
197
+ // A Revise turn that revised: its prompt takes the stamp, as a reload
198
+ // draws it, and the stack folds (revise_stack.js follows the DOM).
199
+ if (data.revises?.turn_id && turnId) {
200
+ for (const prompt of findInTurnRoots(".bubble.user[data-turn-id]")) {
201
+ if (prompt.dataset.turnId === turnId) prompt.dataset.revises = data.revises.turn_id;
202
+ }
203
+ }
188
204
  // after_turn hooks are running and may present the answer: its
189
205
  // answer_display (display: null when they didn't) comes next. A worker
190
206
  // that dies before it never sends one: workerGone releases the wait, and
@@ -193,13 +209,11 @@ export function createStreamHandlers(deps) {
193
209
  if (display) liveTurn.displayPending = display;
194
210
  liveTurn.endRead = readTurnEnd();
195
211
  await liveTurn.endRead;
196
- // The answer bubble the turn view held at its box's height pops now
197
- // that the rendered markdown is in it (or the re-read failed: then the
198
- // plain text pops), and the display if one was coming, so the links
199
- // never swap in after the pop. The hold reveals by itself after
200
- // HOLD_MS whatever comes. The stage has no hold.
212
+ // The stage's answer is settled now that the rendered markdown is in
213
+ // it (or the re-read failed), and the display if one was coming, so
214
+ // the hand-off never moves it before its links swap in.
201
215
  if (display) await display.promise;
202
- turnDom.answerReady?.();
216
+ turnDom.answerReady();
203
217
  // A participant's review turn: Apply under its reply, as a reload shows it.
204
218
  const review = liveTurn.review;
205
219
  liveTurn.review = null;
@@ -212,11 +226,12 @@ export function createStreamHandlers(deps) {
212
226
  // other session's answers alone.
213
227
  answer_display: async (data) => {
214
228
  const id = selected();
229
+ const still = stillOn(selected, id);
215
230
  try {
216
231
  await liveTurn.endRead;
217
- if (!id || id !== selected() || (!data.display && !data.refs)) return;
232
+ if (!id || !still() || (!data.display && !data.refs)) return;
218
233
  const d = await getSession(id, { tail: true, recent: true });
219
- if (id !== selected()) return;
234
+ if (!still()) return;
220
235
  if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
221
236
  } catch (_) {
222
237
  } finally {
@@ -247,9 +262,11 @@ export function createStreamHandlers(deps) {
247
262
  prompt_restored: async (data) => {
248
263
  const id = selected();
249
264
  keepEarlyRestore(data, earlyRestores, { myId: clientId, sentIds });
250
- const { refill, label, images: restoredImages, verify } = restoreAction(data, { myId: clientId, sentIds, composerEmpty: composer.empty() });
265
+ const { refill, label, images: restoredImages, verify, revises } = restoreAction(data, { myId: clientId, sentIds, composerEmpty: composer.empty() });
251
266
  if (refill) composer.refill(refill);
252
267
  if (refill && verify) deps.restoreVerify?.(verify);
268
+ // A Revise turn's draft too: its chip comes back with the text.
269
+ if (refill && revises) deps.restoreRevise?.(revises);
253
270
  if (restoredImages?.length) composer.setChips(restoredChips(selected(), restoredImages));
254
271
  const failedText = liveTurn.failedText;
255
272
  liveTurn.failedText = null;
@@ -276,40 +293,24 @@ export function createStreamHandlers(deps) {
276
293
  // A queued /working-set line is no longer pending (the list's pill).
277
294
  workingSet?.commandRan(data);
278
295
  const view = commandView(data, clientId);
279
- if (view.modelName && view.modelName !== state().model) {
280
- state().model = view.modelName;
281
- state().served = null;
282
- updateInfoBar();
283
- }
284
- // A fresh link's picker shows the model the worker kept, a refused
285
- // /model too (its pick set the button before the answer).
286
- if (view.modelName) syncModelPick?.();
296
+ // What the command changed, in one patch: the bar and the chips'
297
+ // popovers redraw once (the store; the working set's list follows
298
+ // /llm-context: workingSetStrategy).
299
+ const patch = {};
300
+ if (view.modelName && view.modelName !== store.current.model) Object.assign(patch, { model: view.modelName, served: null });
287
301
  // /llm-context changed what the next turn runs under.
288
- if (data.llm_context) {
289
- state().llmContext = data.llm_context;
290
- updateInfoBar();
291
- llmContextControl.refresh();
292
- // The working set's list offers pin only under forget.
293
- workingSet?.strategyChanged();
294
- }
302
+ if (data.llm_context) patch.llmContext = data.llm_context;
295
303
  // /thinking (or /model) changed the level the next turn runs at.
296
- if (data.thinking) {
297
- state().thinking = data.thinking;
298
- updateInfoBar();
299
- thinkingControl.refresh();
300
- }
304
+ if (data.thinking) patch.thinking = data.thinking;
301
305
  // /reasoning-history (or /model) changed it; null: the model now runs
302
306
  // on a native host, which ignores it (the chip goes).
303
- if ("reasoning_history" in data) {
304
- state().reasoningHistory = data.reasoning_history || null;
305
- updateInfoBar();
306
- reasoningHistoryControl.refresh();
307
- }
307
+ if ("reasoning_history" in data) patch.reasoningHistory = data.reasoning_history || null;
308
308
  // /toolkit changed what the next turn declares; null: full (no chip).
309
- if ("toolkit" in data) {
310
- state().toolkit = data.toolkit || null;
311
- updateInfoBar();
312
- }
309
+ if ("toolkit" in data) patch.toolkit = data.toolkit || null;
310
+ store.update({ current: patch });
311
+ // A fresh link's picker shows the model the worker kept, a refused
312
+ // /model too (its pick set the button before the answer): an event (D9).
313
+ if (view.modelName) syncModelPick?.();
313
314
  if (view.resync) {
314
315
  await resync();
315
316
  // Another session was opened during the re-read: the bubble isn't its.
@@ -375,34 +376,33 @@ export function createStreamHandlers(deps) {
375
376
  // ignored with web.working_set off.
376
377
  working_set_updated: (data) => { workingSet?.update({ items: data.items, at: data.at, ...(data.adds ? { adds: data.adds } : {}) }); },
377
378
  context_status: (data) => {
378
- const current = state();
379
+ const current = store.current;
380
+ const patch = {};
379
381
  // After /model (Engine#announce_switched_context!): the new model's
380
382
  // window, and usage null when it didn't resolve (the % clears, "ctx ?").
381
383
  // Only that event carries the key: a null drops the old model's window,
382
384
  // so the chunks before the next window count no % against it.
383
385
  if (data && "context_window_tokens" in data) {
384
- current.ctxWindow = data.context_window_tokens || null;
385
- current.ctxWindowSource = data.context_window_tokens ? data.context_window_source || null : null;
386
+ patch.ctxWindow = data.context_window_tokens || null;
387
+ patch.ctxWindowSource = data.context_window_tokens ? data.context_window_source || null : null;
386
388
  // The ctx tooltip's "until the next request" (ctxBarTip).
387
- current.ctxUnknownAfterSwitch = !data.context_window_tokens;
389
+ patch.ctxUnknownAfterSwitch = !data.context_window_tokens;
388
390
  }
389
391
  if (data && "usage" in data && data.usage === null) {
390
- current.ctxPct = null;
391
- updateInfoBar();
392
+ store.update({ current: { ...patch, ctxPct: null } });
392
393
  return;
393
394
  }
394
- const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
395
- if (pct !== null) {
396
- current.ctxPct = pct;
397
- updateInfoBar();
398
- }
395
+ const ctxWindow = "ctxWindow" in patch ? patch.ctxWindow : current.ctxWindow;
396
+ const pct = extractCtxPct(data, ctxWindow, current.llmContext?.budget_tokens);
397
+ if (pct !== null) patch.ctxPct = pct;
398
+ store.update({ current: patch });
399
399
  },
400
400
  question_requested: (data) => {
401
401
  const pq = data.pending_question;
402
402
  if (!pq || !pq.id) return;
403
403
  // A plugin's refs on the reply that asks: on its step before the card
404
404
  // draws the asking slot.
405
- if (pq.refs) turnDom.questionRefs?.(pq.refs);
405
+ if (pq.refs) turnDom.questionRefs(pq.refs);
406
406
  questionCards.renderQuestion(pq);
407
407
  },
408
408
  question_answered: (data) => {
@@ -417,13 +417,12 @@ export function createStreamHandlers(deps) {
417
417
  // the server's whole side block, for the ctx tooltip's line at once.
418
418
  side_usage: (data) => {
419
419
  if (!data?.side || typeof data.side !== "object") return;
420
- state().timing.side = data.side;
421
- updateInfoBar();
420
+ store.update({ current: { timing: { ...store.current.timing, side: data.side } } });
422
421
  },
423
422
  // One collected just as a turn started describes the chat before it.
424
423
  recap_ready: (data) => {
425
424
  // After the turn it covers, never above it.
426
- turnDom.handOff?.({ immediate: true });
425
+ turnDom.handOff({ immediate: true });
427
426
  if (!liveTurn.running) showRecap(data.recap, 0, data.created_at || null);
428
427
  },
429
428
  guardrail_warning: (data) => showGuardrailWarning(data.message, data.label || "guardrails"),
@@ -433,8 +432,7 @@ export function createStreamHandlers(deps) {
433
432
  hook_notice: (data) => {
434
433
  // A plugin put the session on another toolkit (ctx.use_toolkit).
435
434
  if ("toolkit" in data) {
436
- state().toolkit = data.toolkit || null;
437
- updateInfoBar();
435
+ store.update({ current: { toolkit: data.toolkit || null } });
438
436
  }
439
437
  if (shownElsewhere(data, capabilities())) return;
440
438
  if (!turnDom.notice(data)) showHookNotice(data);
@@ -4,7 +4,7 @@
4
4
  // and the stops in flight. The in-flight set lives here, not in the DOM:
5
5
  // the stage rebuilds its "now" slot, and a redraw must keep a clicked
6
6
  // button disabled. Pure but for the injected calls, so `node --test`
7
- // covers it; turn_view.js and stage_view.js draw the button.
7
+ // covers it; turn_block.js and stage_view.js draw the button.
8
8
  import { stopTask as postStopTask } from "./data.js";
9
9
  import { clip } from "./format.js";
10
10
 
@@ -1,16 +1,9 @@
1
- // The session bar's thinking chip: the level the next turn runs at ("think
2
- // low"; "· session" when the session set its own), and a popover with a
3
- // level select that runs /thinking in the session's worker (the command's
4
- // reply shows in the conversation, like a typed one). The level and where
5
- // it came from are the session's thinking (SettingExplained#summary:
6
- // {level, source, own}; the worker's snapshot, a command_ran, or the
7
- // server's own reading of the session file). A config on level_chip.js.
8
- //
9
- // The start page's new session sets its level in the ⚙ panel instead
10
- // (start_settings.js, over LEVELS).
1
+ // Thinking as a level setting (level_chip.js): the levels the settings
2
+ // panels' Thinking row offers (settings_panel.js; the start page's ⚙ panel,
3
+ // the session bar's, settings_chip.js) and the /thinking line a session's
4
+ // pick runs in its worker.
11
5
 
12
6
  import * as level from "./level_chip.js";
13
- import { html, nothing } from "./vendor/lit-html.js";
14
7
 
15
8
  export const LEVELS = ["off", "low", "medium", "high"];
16
9
 
@@ -19,42 +12,11 @@ export const CONFIG = Object.freeze({
19
12
  prefix: "think",
20
13
  command: "/thinking",
21
14
  levels: LEVELS,
22
- anchor: "[data-think-chip]",
23
- formClass: "think-form",
24
- popoverClass: "think-popover",
25
- nowClass: "think-now",
26
- when: () => "From the next turn's start. On Gemma the whole prompt is read again; a hosted API reads the whole conversation again.",
15
+ cost: "A thinking change: on Gemma the whole prompt is read again; a hosted API reads the whole conversation again.",
27
16
  });
28
17
 
29
- export const { nowText } = level;
30
-
31
- // The chip: its text, tooltip and whether the session set its own; null
32
- // without a summary (an older worker). The default with nothing set
33
- // anywhere shows too ("think default"): the chip is where the level is set.
34
- export function chipModel(summary) {
35
- return level.chipModel(CONFIG, summary);
36
- }
37
-
38
- // The session bar's chip (a lit-html template; info_bar.js), nothing
39
- // without a summary.
40
- export function chipTemplate(summary) {
41
- const chip = chipModel(summary);
42
- if (!chip) return nothing;
43
- return html`<button type="button" class=${`model think-chip${chip.own ? " own" : ""}`} title=${chip.title} data-think-chip aria-haspopup="dialog">${chip.text}</button>`;
44
- }
45
-
46
- // The /thinking line for a select value, with --yes (level_chip.js
47
- // commandLine); null when it is what the form opened with.
18
+ // The /thinking line for a level, with --yes (level_chip.js commandLine);
19
+ // null when it is the session's (+initial+).
48
20
  export function commandLine(choice, initial = null) {
49
21
  return level.commandLine(CONFIG, choice, initial);
50
22
  }
51
-
52
- // The popover's form (level_chip.js formTemplate).
53
- export function formTemplate(summary, opts) {
54
- return level.formTemplate(CONFIG, summary, opts);
55
- }
56
-
57
- // The chip's popover (level_chip.js createLevelControl).
58
- export function createThinkingControl({ id = "thinkingPopover", ...opts }) {
59
- return level.createLevelControl({ cfg: CONFIG, id, ...opts });
60
- }
@@ -28,7 +28,7 @@ export function timeGapEl(text) {
28
28
 
29
29
  // The history items a group (timing.js turnGroups) holds, by index.
30
30
  function groupIndexes(group) {
31
- if (group.kind === "note" || group.kind === "steer") return [group.i];
31
+ if (group.kind === "note" || group.kind === "steer" || group.kind === "command") return [group.i];
32
32
  if (group.kind === "answer") return [group.answer];
33
33
  const steps = group.steps || [];
34
34
  return [
@@ -54,11 +54,14 @@ export function groupTimes(group, items) {
54
54
 
55
55
  // For each group, the divider text before it, or null. A group with no
56
56
  // known time draws none and hides the gap across it (no divider measured
57
- // over a stretch whose times are unknown).
57
+ // over a stretch whose times are unknown). A `!cmd`'s record is saved with
58
+ // no time: it draws none and the gap is measured across it (it ran in that
59
+ // stretch).
58
60
  export function groupGaps(groups, items, now = Date.now()) {
59
61
  let prevEnd = null;
60
62
  return groups.map((group) => {
61
63
  const { start, end } = groupTimes(group, items);
64
+ if (group.kind === "command" && start === null) return null;
62
65
  const text = prevEnd !== null && start !== null ? gapText(prevEnd, start, now) : null;
63
66
  prevEnd = end;
64
67
  return text;
@@ -1,3 +1,5 @@
1
+ import { isShellCommandItem } from "./shell_command.js";
2
+
1
3
  export function formatDuration(milliseconds) {
2
4
  const ms = Number(milliseconds);
3
5
  if (!Number.isFinite(ms) || ms < 0) return "";
@@ -148,8 +150,10 @@ export function turnRecordAt(timing, index) {
148
150
  }
149
151
 
150
152
  // A history item that starts a turn: a prompt, or the note a context wake
151
- // turn starts with (turn_start).
152
- const startsTurn = (item) => item?.role === "user" || (item?.role === "note" && item.turn_start === true);
153
+ // turn starts with (turn_start). A `!cmd`'s record (shell_command.js) is a
154
+ // user message but no prompt: no turn, no record, no timing.
155
+ const startsTurn = (item) =>
156
+ (item?.role === "user" && !isShellCommandItem(item)) || (item?.role === "note" && item.turn_start === true);
153
157
 
154
158
  // For each history item, the turn index its timing shows under, or null. A
155
159
  // turn with tool calls holds several assistant messages; only the last one
@@ -173,7 +177,7 @@ export function timedTurnIndexes(items) {
173
177
  return result;
174
178
  }
175
179
 
176
- // The history as turns, for the turn view's reload: notes on their own, and
180
+ // The history as turns, for the turn block's reload: notes on their own, and
177
181
  // per prompt a turn with its record, its steps (one per iteration: the
178
182
  // turn's tool records grouped by iteration, and the assistant messages but
179
183
  // the last, in order; a text-less iteration leaves no message, so a text
@@ -181,7 +185,9 @@ export function timedTurnIndexes(items) {
181
185
  // assistant message). Assistant messages before any prompt are answers on
182
186
  // their own. A turn that ended with no answer (the server's empty_answer
183
187
  // item) has no answer: its texts are steps, and `emptyAnswer` is that
184
- // item's index. The turn index counts prompts, as timedTurnIndexes does. The
188
+ // item's index. A `!cmd`'s record is a group of its own (`command`, its
189
+ // index): no steps, no record, no turn index. The turn index counts
190
+ // prompts, as timedTurnIndexes does. The
185
191
  // user's lines merged into the running turn (`merged`, the server's
186
192
  // Steer::INPUT_KIND) stay in it: the turn's `merged` list.
187
193
  export function turnGroups(items, timing) {
@@ -191,6 +197,10 @@ export function turnGroups(items, timing) {
191
197
  items.forEach((item, i) => {
192
198
  if (item?.role === "note" && !startsTurn(item)) {
193
199
  groups.push({ kind: "note", i });
200
+ } else if (isShellCommandItem(item)) {
201
+ // A `!cmd`'s record: its bubble alone, between the turns.
202
+ groups.push({ kind: "command", i });
203
+ turn = null;
194
204
  } else if (item?.role === "steer") {
195
205
  if (turn) turn.steers.push(i);
196
206
  else groups.push({ kind: "steer", i });
@@ -267,7 +277,7 @@ function turnGroup(g, items, timing) {
267
277
  }
268
278
 
269
279
  // A canceled turn that ran more than one step or any call has no answer:
270
- // its partial text stays in the block, as it does live (turn_view.js
280
+ // its partial text stays in the block, as it does live (turn_block.js
271
281
  // turnEnded promotes only a completed turn or a lone step with no tools). A
272
282
  // failed turn never answered: its texts are steps.
273
283
  function canceledSteps(g, record, tools) {
@@ -1,20 +1,34 @@
1
- // The session bar's tools chip: the session's toolkit when it isn't full
2
- // ("tools lean"), with the tools it leaves out and how to change them in
3
- // its tooltip. The summary is the session's toolkit ({name, label,
4
- // left_out, reachable, removed, fallback}: Toolkit::Explained#summary; the
5
- // worker's snapshot or a command_ran; the server's own reading of the
6
- // session file has no lists). Left out is reachable (find_tools and
7
- // call_tool reach them) and removed (only /toolkit add brings one back).
8
- // Changing it is /toolkit (no popover yet). A kit with a prompt text has
9
- // it on the summary too (prompt), and the tooltip ends with it.
1
+ // The session bar's 🧰 chip, after ⚙: the session's toolkit. "🧰" alone
2
+ // while the session is on config's default kit (toolkit.default, full
3
+ // unless set; GET /api/sessions/:id's session.toolkit_default), "🧰 lean"
4
+ // on another; the tooltip names the kit, the tools it leaves out and how to
5
+ // change them. No popover: /toolkit changes it. The summary is the
6
+ // session's toolkit ({name, label, left_out, reachable, removed, fallback}:
7
+ // Toolkit::Explained#summary, null on full; the worker's snapshot or a
8
+ // command_ran; the server's own reading of the session file has no lists).
9
+ // Left out is reachable (find_tools and call_tool reach them) and removed
10
+ // (only /toolkit add brings one back). A kit with a prompt text has it on
11
+ // the summary too (prompt), and the tooltip ends with it.
10
12
 
11
13
  import { html, nothing } from "./vendor/lit-html.js";
12
14
 
13
- // The chip's text and tooltip; null without a summary (full).
14
- export function chipModel(summary) {
15
- if (!summary) return null;
15
+ const FULL = "full";
16
+ const CHANGE = "/toolkit lists them; /toolkit NAME, add TOOL or remove TOOL changes them from the next turn.";
17
+
18
+ // The chip's text ("" while on the default kit: 🧰 alone), tooltip and
19
+ // whether it is the default. +summary+: the session's (null: full);
20
+ // +defaultKit+: config's toolkit.default (null: not known, full).
21
+ export function chipModel(summary, defaultKit = null) {
22
+ const fallbackKit = defaultKit || FULL;
23
+ if (!summary) {
24
+ const isDefault = fallbackKit === FULL;
25
+ const title = isDefault ? `Toolkit: ${FULL} (the default), every tool is declared. ${CHANGE}`
26
+ : `Toolkit: ${FULL}, every tool is declared (the default is ${fallbackKit}). ${CHANGE}`;
27
+ return { text: isDefault ? "" : FULL, title, isDefault };
28
+ }
16
29
  const label = summary.label || summary.name;
17
- const parts = [`Toolkit: ${label}, the tools this session declares.`];
30
+ const isDefault = !summary.fallback && label === fallbackKit;
31
+ const parts = [`Toolkit: ${label}${isDefault ? " (the default)" : ""}, the tools this session declares.`];
18
32
  if (summary.fallback) parts.push(`${summary.fallback}: every tool is declared (full) until it resolves.`);
19
33
  if (Array.isArray(summary.groups)) {
20
34
  parts.push(`Source groups: ${summary.groups.length ? summary.groups.join(", ") : "none"}.`);
@@ -27,15 +41,17 @@ export function chipModel(summary) {
27
41
  } else if (Array.isArray(summary.left_out)) {
28
42
  parts.push(summary.left_out.length ? `Not loaded: ${summary.left_out.join(", ")}.` : "Nothing left out.");
29
43
  }
30
- parts.push("/toolkit lists them; /toolkit NAME, add TOOL or remove TOOL changes them from the next turn.");
44
+ parts.push(CHANGE);
31
45
  if (summary.prompt) parts.push(`Prompt: ${summary.prompt}`);
32
- return { text: `tools ${label}${summary.fallback ? " ?" : ""}`, title: parts.join(" ") };
46
+ return { text: isDefault ? "" : `${label}${summary.fallback ? " ?" : ""}`, title: parts.join(" "), isDefault };
33
47
  }
34
48
 
35
- // The session bar's chip (a lit-html template; info_bar.js), nothing on
36
- // full.
37
- export function chipTemplate(summary) {
38
- const chip = chipModel(summary);
39
- if (!chip) return nothing;
40
- return html`<span class="model tools-chip" title=${chip.title}>${chip.text}</span>`;
49
+ // The session bar's chip (a lit-html template; info_bar.js): +toolkit+ is
50
+ // {summary, defaultKit}; nothing without it. 🧰 is drawn as the start
51
+ // page's toolkit icon (the glyph stays the text; index.html's CSS).
52
+ export function chipTemplate(toolkit) {
53
+ if (!toolkit) return nothing;
54
+ const chip = chipModel(toolkit.summary, toolkit.defaultKit);
55
+ return html`<span class=${`model tools-chip${chip.isDefault ? " default" : ""}`} title=${chip.title}><span
56
+ class="chip-icon tc-kit" aria-hidden="true">🧰</span>${chip.text ? ` ${chip.text}` : ""}</span>`;
41
57
  }