samagotchi 0.5.1 → 0.7.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -1
  3. data/bin/chi +32 -891
  4. data/docs/architecture.md +8 -1
  5. data/docs/cli.md +12 -13
  6. data/docs/configuration.md +24 -1
  7. data/docs/desktop.md +52 -1
  8. data/docs/hooks.md +69 -4
  9. data/docs/plugins.md +98 -3
  10. data/docs/sessions.md +3 -3
  11. data/lib/samagotchi/bridge/card_store.rb +30 -5
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
  13. data/lib/samagotchi/bridge/turn_notice.rb +37 -0
  14. data/lib/samagotchi/bundle_command.rb +652 -0
  15. data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
  16. data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
  18. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  19. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  20. data/lib/samagotchi/cancellation_controller.rb +42 -1
  21. data/lib/samagotchi/config.rb +7 -2
  22. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  23. data/lib/samagotchi/desktop/macos/ChiRunner.swift +27 -68
  24. data/lib/samagotchi/desktop/macos/Images.swift +38 -3
  25. data/lib/samagotchi/desktop/macos/Kitty.swift +346 -0
  26. data/lib/samagotchi/desktop/macos/Panel.swift +248 -55
  27. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +74 -0
  28. data/lib/samagotchi/desktop/macos.rb +23 -8
  29. data/lib/samagotchi/desktop_command.rb +1 -1
  30. data/lib/samagotchi/engine.rb +138 -618
  31. data/lib/samagotchi/guardrail_wiring.rb +238 -0
  32. data/lib/samagotchi/hooks/registry.rb +11 -3
  33. data/lib/samagotchi/hooks/stream_watch.rb +93 -0
  34. data/lib/samagotchi/hooks.rb +3 -0
  35. data/lib/samagotchi/kernel_loop.rb +65 -6
  36. data/lib/samagotchi/llm/chat_loop.rb +73 -11
  37. data/lib/samagotchi/llm/openai_chat.rb +4 -2
  38. data/lib/samagotchi/log_subscriber.rb +10 -4
  39. data/lib/samagotchi/plugin/api.rb +8 -0
  40. data/lib/samagotchi/plugin/context.rb +12 -3
  41. data/lib/samagotchi/plugin_tasks.rb +229 -0
  42. data/lib/samagotchi/question_desk.rb +280 -0
  43. data/lib/samagotchi/recap_setup.rb +128 -0
  44. data/lib/samagotchi/session.rb +9 -1
  45. data/lib/samagotchi/sessions_command.rb +255 -0
  46. data/lib/samagotchi/steer.rb +19 -2
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
  48. data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
  49. data/lib/samagotchi/tool_declarations.rb +2 -2
  50. data/lib/samagotchi/tools/delegate.rb +4 -1
  51. data/lib/samagotchi/turn_note.rb +20 -1
  52. data/lib/samagotchi/update_command.rb +1 -1
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +12 -8
  55. data/lib/samagotchi/web/public/app.js +94 -52
  56. data/lib/samagotchi/web/public/card.js +8 -1
  57. data/lib/samagotchi/web/public/format.js +1 -1
  58. data/lib/samagotchi/web/public/index.html +2 -5
  59. data/lib/samagotchi/web/public/stage_model.js +44 -0
  60. data/lib/samagotchi/web/public/stage_view.js +26 -17
  61. data/lib/samagotchi/web/public/timing.js +9 -3
  62. data/lib/samagotchi/web/public/turn_events.js +18 -5
  63. data/lib/samagotchi/web/public/turn_view.js +68 -16
  64. data/lib/samagotchi/web/server.rb +4 -3
  65. metadata +11 -2
  66. data/lib/samagotchi/web/public/chat_view.js +0 -376
@@ -55,9 +55,18 @@ export function hookNoticeLabel(hook) {
55
55
  return /^[a-z][a-z0-9-]*$/.test(name) ? name : "hook";
56
56
  }
57
57
 
58
- // The row a step gets when the loop asks again after an empty answer.
59
- export function emptyRetryLine({ attempt, of } = {}) {
60
- return `↻ empty answer, asking again (${attempt}/${of})`;
58
+ // The row a step gets when the loop asks again after an empty answer, or
59
+ // after a plugin cut the generation (stopped_by: the bundle).
60
+ export function emptyRetryLine({ attempt, of, stopped_by: stoppedBy } = {}) {
61
+ const what = stoppedBy ? `cut by ${stoppedBy}` : "empty answer";
62
+ return `↻ ${what}, asking again (${attempt}/${of})`;
63
+ }
64
+
65
+ // The text of a turn's row from a snapshot entry (CardStore): a hook's
66
+ // notice as "<label>: text", the loop's asking-again row as its line.
67
+ export function noticeLine(entry = {}) {
68
+ if (entry.type === "empty_answer_retry") return emptyRetryLine(entry);
69
+ return `${hookNoticeLabel(entry.hook)}: ${entry.text || ""}`;
61
70
  }
62
71
 
63
72
  // The live turn's status while the provider is asked again after an error
@@ -211,6 +220,7 @@ export function promptOps(event, { myId, known, unmatchedMerge = false }) {
211
220
  // parts in order), then the prompts queued behind it. app.js feeds these to
212
221
  // its stream handlers, so a join renders exactly like watching live.
213
222
  // `merged_input` is the one synthetic type: a prompt merged into the turn.
223
+ // A `notice` part carries its event as it came (TurnNotice).
214
224
  export function snapshotEvents({ current_turn: turn = null, queued = [], started_at = undefined } = {}) {
215
225
  const events = [];
216
226
  if (turn) {
@@ -256,9 +266,12 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], started
256
266
  closeText();
257
267
  events.push({ type: "reminder_injected", reminders: part.reminders || [] });
258
268
  break;
259
- case "hook_notice":
269
+ // One of the turn's rows (a hook's notice, an empty-answer retry, a
270
+ // question and its answer): the event itself, which the live
271
+ // handler draws where it drew it live.
272
+ case "notice":
260
273
  closeText();
261
- events.push({ type: "hook_notice", hook: part.hook, text: part.text, level: part.level });
274
+ if (part.event?.type) events.push({ ...part.event });
262
275
  break;
263
276
  // A plugin's steer: a steer-only merge, as live.
264
277
  case "steer":
@@ -1,20 +1,20 @@
1
- // The turn view of a running turn (web.turn_view): one <details
2
- // class="turn-work"> per turn holding one <details class="gen"> per
3
- // generation, the current one live (open, at the bottom), the earlier ones
4
- // collapsed to one line each. At the turn's end the last generation's
5
- // narration moves out of the block as the answer bubble, so markdown,
6
- // annotate and the turn output dedupe work as in the chat view. The same
7
- // interface as chat_view.js; the grouping rules live in turn_model.js.
1
+ // The turn view of a running turn (web.view: turn, and the stage's block):
2
+ // one <details class="turn-work"> per turn holding one <details
3
+ // class="gen"> per generation, the current one live (open, at the bottom),
4
+ // the earlier ones collapsed to one line each. At the turn's end the last
5
+ // generation's narration moves out of the block as the answer bubble, so
6
+ // markdown, annotate and the turn output dedupe work on a plain bubble.
7
+ // The grouping rules live in turn_model.js.
8
8
  //
9
9
  // A block that holds one gen with no thinking and no tool rows hides its
10
10
  // chrome (CSS :has), so a plain answer streams like today's bubble, and is
11
11
  // removed at the end: the answer bubble takes its place.
12
12
  import { toolName } from "./activity.js";
13
13
  import { leavesBlock } from "./card.js";
14
- import { activityStatusClass, activityStatusLabel, fillActivityRow } from "./chat_view.js";
15
14
  import { copySourceAttr } from "./copy.js";
16
15
  import { activityDiffHtml } from "./diff_view.js";
17
16
  import { escapeHtml, messageBodyHtml, noteHtml, normalize, steerRowHtml } from "./format.js";
17
+ import { thumbsHtml } from "./images.js";
18
18
  import { shouldFollowScroll } from "./scroll.js";
19
19
  import { hookNoticeLabel } from "./turn_events.js";
20
20
  import { cancelLineHtml, formatDuration, turnGroups, turnTimingText } from "./timing.js";
@@ -23,6 +23,55 @@ import { createTicker } from "./thinking_ticker.js";
23
23
  import * as model from "./turn_model.js";
24
24
  import { createHold } from "./hold.js";
25
25
 
26
+ // A row's status (live, or a saved tool record's on reload): running,
27
+ // error, stopped (a task_wait the user's Stop ended), else ok ("done").
28
+ function activityStatusClass(status) {
29
+ if (status === "running") return "running";
30
+ if (status === "error") return "error";
31
+ if (status === "stopped") return "stopped";
32
+ return "ok";
33
+ }
34
+
35
+ function activityStatusLabel(status) {
36
+ if (status === "running") return "running";
37
+ if (status === "error") return "error";
38
+ if (status === "stopped") return "stopped";
39
+ return "done";
40
+ }
41
+
42
+ // A live tool row filled from its activity row (status, title, output).
43
+ function fillActivityRow(el, row, sessionId) {
44
+ const statusEl = el.querySelector(".activity-status");
45
+ statusEl.className = `activity-status ${activityStatusClass(row.status)}`;
46
+ statusEl.textContent = activityStatusLabel(row.status);
47
+ const toolEl = el.querySelector(".activity-tool");
48
+ if (toolEl) toolEl.textContent = toolName(row);
49
+ const paramsEl = el.querySelector(".activity-params");
50
+ if (paramsEl) {
51
+ // The title in place of the params; the full params line on hover.
52
+ paramsEl.textContent = row.title || row.params || "";
53
+ paramsEl.title = row.title && row.params ? row.params : "";
54
+ }
55
+ const outEl = el.querySelector(".activity-output");
56
+ if (outEl) {
57
+ if (row.status === "running") {
58
+ outEl.style.display = "none";
59
+ outEl.textContent = "";
60
+ outEl.title = "";
61
+ } else {
62
+ // dispatch prefixes output with "[tool]"; strip that noise for display.
63
+ const full = (row.output || "").replace(/^\[[^\]]*\]\s*/, "");
64
+ outEl.title = full;
65
+ outEl.textContent = full.length > 300 ? `${full.slice(0, 300)}…` : full;
66
+ outEl.style.display = full ? "" : "none";
67
+ }
68
+ }
69
+ // The images a tool returned (a reloaded row has them too, reloadRowHtml).
70
+ if (row.images?.length && !el.querySelector(".thumbs")) el.insertAdjacentHTML("beforeend", thumbsHtml(sessionId, row.images));
71
+ // An edit/write's change, collapsed: "diff +3 −1".
72
+ if (row.diff && !el.querySelector(".activity-diff")) el.insertAdjacentHTML("beforeend", activityDiffHtml(row.diff));
73
+ }
74
+
26
75
  // The container seams (the stage view composes this view, stage_view.js):
27
76
  // blockHost(el) puts the turn's block in the page (default: the history);
28
77
  // follow() keeps the live part in view (default: the history's
@@ -102,7 +151,7 @@ export function createTurnView({
102
151
  const text = document.createElement("div");
103
152
  text.className = "gen-text streaming";
104
153
  // A step after the first shows "…" until its text comes (the first one
105
- // only for a prompt, as the chat view's bubble).
154
+ // only for a prompt).
106
155
  if (turn.gens.length > 1) text.textContent = "…";
107
156
  el.appendChild(text);
108
157
  const entry = { el, summary, thinking: null, text, activity: null, rows: new Map(), toggled: false,
@@ -308,7 +357,7 @@ export function createTurnView({
308
357
  }
309
358
  entry.summary.textContent = model.genLabel(gen);
310
359
  if (!entry.toggled) entry.el.open = false;
311
- // The chat view puts every generation's text in seenContent.
360
+ // Every generation's text goes into seenContent (the turn output dedupe).
312
361
  const k = normalize(gen.text);
313
362
  if (k) sawText(k);
314
363
  }
@@ -449,8 +498,8 @@ export function createTurnView({
449
498
  cards.forEach((el) => { if (!el.classList.contains("plugin-card")) el.classList.add("bubble"); });
450
499
  if (leaving) entry.el.remove();
451
500
  if (plain) {
452
- // A plain answer: no block, the bubble where it was (as the chat
453
- // view); its thinking, if any, as the chat view's collapsed block.
501
+ // A plain answer: no block, the bubble where it was; its thinking, if
502
+ // any, as a collapsed thinking block above it.
454
503
  const pieces = [];
455
504
  if (gen.thinking) {
456
505
  if (!entry.thinkingWrap) thinkingEl(gen);
@@ -485,7 +534,7 @@ export function createTurnView({
485
534
  const last = model.currentGen(turn);
486
535
  // A completed turn's answer is its last generation's text; a one-step
487
536
  // turn without tools leaves the block in any case (canceled, it is the
488
- // partial text bubble the chat view leaves).
537
+ // partial text bubble).
489
538
  const promoting = last && (kind === "completed" || (turn.gens.length === 1 && last.tools.length === 0));
490
539
  // The box's height, measured while it is still in the DOM and the step
491
540
  // still open: the apply below swaps it for the full text and collapses
@@ -613,8 +662,7 @@ function defaultPlaceAnswer(appendToHistory) {
613
662
  };
614
663
  }
615
664
 
616
- // A reloaded tool row, the live row's markup (chat_view.js
617
- // fillActivityRow): status, tool, params, output (its "[tool]" tag dropped,
665
+ // A reloaded tool row, the live row's markup (fillActivityRow): status, tool, params, output (its "[tool]" tag dropped,
618
666
  // 300 chars, the rest on hover), plus the call's duration from its record.
619
667
  // Params, output and the call's images come from the saved messages'
620
668
  // parts; without them the row has the record's name, status and duration
@@ -663,7 +711,7 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
663
711
  const noBlock = !group.steps.length ||
664
712
  (group.steps.length === 1 && group.answer !== null && only.i === null && !only.tools.length && only.thinking);
665
713
  if (group.steps.length === 1 && group.answer !== null && only.i === null && !only.tools.length && only.thinking) {
666
- // A plain answer with thinking: the chat view's collapsed block, as live.
714
+ // A plain answer with thinking: the collapsed thinking block, as live.
667
715
  parts.push(`<details class="bubble thinking"><summary>thinking</summary><div class="thinking-body">${escapeHtml(only.thinking)}</div></details>`);
668
716
  } else if (group.steps.length) {
669
717
  const gens = group.steps.map((step) => ({ text: step.i === null ? "" : String(items[step.i].content ?? ""), thinking: step.thinking || "", tools: step.tools }));
@@ -684,6 +732,10 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
684
732
  // their own after the prompt.
685
733
  const loose = noBlock ? [...group.steps.flatMap((step) => step.steers || []), ...(group.steers || [])] : group.steers || [];
686
734
  parts.push(loose.map((i) => steerHtml(i, true)).join(""));
735
+ // The user's lines merged into the turn: the live "steered" bubbles,
736
+ // after its steps and before its answer.
737
+ parts.push((group.merged || []).map((i) =>
738
+ `<div class="bubble user steered" data-user-state="steered"${items[i].step ? ` data-step="${Number(items[i].step)}"` : ""}${copySourceAttr(items[i], false)}><div class="user-message">${messageBodyHtml(items[i]).body}${thumbs(items[i].images)}</div><span class="state-badge">steered</span></div>`).join(""));
687
739
  // A turn with no answer (canceled, or running: no record yet) shows its
688
740
  // timing after its prompt and steps, where the live view leaves it.
689
741
  if (group.answer === null) parts.push(timingHtml(group));
@@ -59,8 +59,9 @@ module Samagotchi
59
59
  # @param new_token [Boolean] replace the LAN access token first
60
60
  # (--new-token): a running server takes the new one at once
61
61
  # @return [Integer] the exit status
62
- def self.launch(port: nil, host: nil, scope: "project", dir: Dir.pwd, open_browser: false, markdown: false, view: "turn",
63
- annotate_presets: Config::BY_KEY["web.annotate_presets"].default, new_token: false)
62
+ def self.launch(port: nil, host: nil, scope: "project", dir: Dir.pwd, open_browser: false, markdown: false,
63
+ view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
64
+ new_token: false)
64
65
  rotate_token if new_token
65
66
  setting = host_setting(host)
66
67
  lan = Lan.wanted?(setting) ? Lan.choose(setting) : nil
@@ -108,7 +109,7 @@ module Samagotchi
108
109
  # @param token_path [String] the LAN access token's file
109
110
  # @return [Boolean] false when the port was taken (said so on stderr)
110
111
  def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
111
- view: "turn", annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil, lan: nil,
112
+ view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil, lan: nil,
112
113
  token_path: Token.path)
113
114
  port = resolve_port(port)
114
115
  host = resolve_host(host)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -150,9 +150,11 @@ files:
150
150
  - lib/samagotchi/bridge/ring_buffer.rb
151
151
  - lib/samagotchi/bridge/sse_writer.rb
152
152
  - lib/samagotchi/bridge/turn_accumulator.rb
153
+ - lib/samagotchi/bridge/turn_notice.rb
153
154
  - lib/samagotchi/bridge_client.rb
154
155
  - lib/samagotchi/bridge_client/event_stream.rb
155
156
  - lib/samagotchi/bridge_client/sse_parser.rb
157
+ - lib/samagotchi/bundle_command.rb
156
158
  - lib/samagotchi/bundle_needs.rb
157
159
  - lib/samagotchi/bundles/btw/manifest.yml
158
160
  - lib/samagotchi/bundles/btw/plugin.rb
@@ -195,12 +197,15 @@ files:
195
197
  - lib/samagotchi/desktop/macos/Hotkey.swift
196
198
  - lib/samagotchi/desktop/macos/Images.swift
197
199
  - lib/samagotchi/desktop/macos/Info.plist.erb
200
+ - lib/samagotchi/desktop/macos/Kitty.swift
198
201
  - lib/samagotchi/desktop/macos/Panel.swift
202
+ - lib/samagotchi/desktop/macos/ProcessRunner.swift
199
203
  - lib/samagotchi/desktop_command.rb
200
204
  - lib/samagotchi/edit_preview.rb
201
205
  - lib/samagotchi/empty_answer_retry.rb
202
206
  - lib/samagotchi/engine.rb
203
207
  - lib/samagotchi/gem_update.rb
208
+ - lib/samagotchi/guardrail_wiring.rb
204
209
  - lib/samagotchi/guardrails.rb
205
210
  - lib/samagotchi/guardrails/approval.rb
206
211
  - lib/samagotchi/guardrails/approvals.rb
@@ -216,6 +221,7 @@ files:
216
221
  - lib/samagotchi/hooks/bundle_loader.rb
217
222
  - lib/samagotchi/hooks/loader.rb
218
223
  - lib/samagotchi/hooks/registry.rb
224
+ - lib/samagotchi/hooks/stream_watch.rb
219
225
  - lib/samagotchi/host_registry.rb
220
226
  - lib/samagotchi/idle_client.rb
221
227
  - lib/samagotchi/idle_recap.rb
@@ -269,9 +275,12 @@ files:
269
275
  - lib/samagotchi/plugin/sessions.rb
270
276
  - lib/samagotchi/plugin/side_question.rb
271
277
  - lib/samagotchi/plugin/tool_result.rb
278
+ - lib/samagotchi/plugin_tasks.rb
272
279
  - lib/samagotchi/project_scope.rb
273
280
  - lib/samagotchi/prompt.rb
274
281
  - lib/samagotchi/prompt_literal_guard.rb
282
+ - lib/samagotchi/question_desk.rb
283
+ - lib/samagotchi/recap_setup.rb
275
284
  - lib/samagotchi/recap_store.rb
276
285
  - lib/samagotchi/reminder_store.rb
277
286
  - lib/samagotchi/reply_wait.rb
@@ -286,6 +295,7 @@ files:
286
295
  - lib/samagotchi/session_manager.rb
287
296
  - lib/samagotchi/session_metrics.rb
288
297
  - lib/samagotchi/session_observer.rb
298
+ - lib/samagotchi/sessions_command.rb
289
299
  - lib/samagotchi/steer.rb
290
300
  - lib/samagotchi/system_prompt.rb
291
301
  - lib/samagotchi/terminal_ui.rb
@@ -360,7 +370,6 @@ files:
360
370
  - lib/samagotchi/web/public/annotations.js
361
371
  - lib/samagotchi/web/public/app.js
362
372
  - lib/samagotchi/web/public/card.js
363
- - lib/samagotchi/web/public/chat_view.js
364
373
  - lib/samagotchi/web/public/chunk_router.js
365
374
  - lib/samagotchi/web/public/command_complete.js
366
375
  - lib/samagotchi/web/public/composer_size.js
@@ -1,376 +0,0 @@
1
- // The chat view of a running turn: one streaming answer bubble per
2
- // generation, one thinking block per turn (above the first bubble) and one
3
- // activity panel per turn (at the first tool call). This module owns that
4
- // per-turn DOM; app.js's stream handlers call it and keep the rest (prompt
5
- // bubbles, cards, the timing line, the session state). turn_view.js offers
6
- // the same interface with a different placement; app.js picks one at boot.
7
- //
8
- // deps: historyEl, appendToHistory(el) (above the live timing line),
9
- // isNearBottom(), sawText(normalizedText) (the turn output dedupe),
10
- // sessionId() (image thumbs of a tool row).
11
- import { newActivity, addStarted, addCompleted, finalizeActivity, activityCount, toolName } from "./activity.js";
12
- import { copySourceAttr } from "./copy.js";
13
- import { escapeHtml, leadTrimmed, messageBodyHtml, noteHtml, normalize, steerRowHtml } from "./format.js";
14
- import { activityDiffHtml } from "./diff_view.js";
15
- import { thumbsHtml } from "./images.js";
16
- import { shouldFollowScroll } from "./scroll.js";
17
- import { tallyText } from "./tally.js";
18
- import { cancelLineHtml, formatDuration, timedTurnIndexes, turnRecordAt, turnTimingText } from "./timing.js";
19
-
20
- // A row's status (live, or a saved tool record's on reload): running,
21
- // error, stopped (a task_wait the user's Stop ended), else ok ("done").
22
- export function activityStatusClass(status) {
23
- if (status === "running") return "running";
24
- if (status === "error") return "error";
25
- if (status === "stopped") return "stopped";
26
- return "ok";
27
- }
28
-
29
- export function activityStatusLabel(status) {
30
- if (status === "running") return "running";
31
- if (status === "error") return "error";
32
- if (status === "stopped") return "stopped";
33
- return "done";
34
- }
35
-
36
- export function createChatView({ historyEl, appendToHistory, isNearBottom, sawText, sessionId }) {
37
- let streamingBubble = null;
38
- let streamingBuffer = "";
39
- let rafPending = false;
40
- let activityModel = null;
41
- let activityEl = null;
42
- let activityElBody = null;
43
- const activityRowEls = new Map();
44
- // One live "thinking" block per turn (Claude-like); streams :generation_chunk
45
- // `thinking` deltas and auto-collapses on turn_completed.
46
- let thinkingEl = null;
47
- let thinkingBodyEl = null;
48
- let thinkingBuffer = "";
49
-
50
- function removeHint() {
51
- const hint = historyEl.querySelector(".hint");
52
- if (hint) hint.remove();
53
- }
54
-
55
- function ensureStreamingBubble() {
56
- if (streamingBubble) return streamingBubble;
57
- removeHint();
58
- streamingBubble = document.createElement("div");
59
- streamingBubble.className = "bubble output streaming";
60
- streamingBubble.textContent = "";
61
- appendToHistory(streamingBubble);
62
- return streamingBubble;
63
- }
64
-
65
- function flush() {
66
- rafPending = false;
67
- // Runs every animation frame while streaming — must not yank a user who
68
- // scrolled up to read; only follow when they were already near the bottom.
69
- const follow = isNearBottom();
70
- // An empty buffer keeps the "…" placeholder.
71
- if (streamingBubble && streamingBuffer) {
72
- streamingBubble.textContent = streamingBuffer;
73
- }
74
- if (thinkingBodyEl) {
75
- // The capped thinking box follows its own tail the same way, unless the
76
- // user scrolled up inside it.
77
- const followThinking = shouldFollowScroll(thinkingBodyEl, 24);
78
- thinkingBodyEl.textContent = thinkingBuffer;
79
- if (followThinking) thinkingBodyEl.scrollTop = thinkingBodyEl.scrollHeight;
80
- }
81
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
82
- }
83
-
84
- function scheduleFlush() {
85
- if (rafPending) return;
86
- rafPending = true;
87
- requestAnimationFrame(flush);
88
- }
89
-
90
- function appendToken(content) {
91
- if (!content) return;
92
- // Leading whitespace right after a closed think/tool_call block (or at the
93
- // start of the stream) would otherwise render as an empty first line in
94
- // the pre-wrap bubble. Trim it only at the start of a bubble so mid-bubble
95
- // paragraph spacing is preserved.
96
- if (streamingBuffer === "") content = content.replace(/^[ \t\r\n]+/, "");
97
- if (!content) return;
98
- streamingBuffer += content;
99
- ensureStreamingBubble();
100
- scheduleFlush();
101
- }
102
-
103
- // ── Per-turn live thinking block ─────────────────────────────────────────
104
- // Routed from :generation_chunk `thinking`. One <details> per turn; accumulates
105
- // across the turn's generations and collapses on turn completion.
106
-
107
- function ensureThinkingEl() {
108
- if (thinkingEl) return thinkingEl;
109
- removeHint();
110
- const details = document.createElement("details");
111
- details.className = "bubble thinking";
112
- details.open = true;
113
- const summary = document.createElement("summary");
114
- summary.textContent = "thinking";
115
- details.appendChild(summary);
116
- const body = document.createElement("div");
117
- body.className = "thinking-body";
118
- details.appendChild(body);
119
- thinkingEl = details;
120
- thinkingBodyEl = body;
121
- // Prefer Claude-like ordering: thinking above the streaming answer bubble.
122
- if (streamingBubble && streamingBubble.parentNode === historyEl) {
123
- historyEl.insertBefore(details, streamingBubble);
124
- } else {
125
- appendToHistory(details);
126
- }
127
- return details;
128
- }
129
-
130
- function appendThinking(content) {
131
- // The box starts at the first visible character, not the "\n" after <think>.
132
- const piece = leadTrimmed(thinkingBuffer, content);
133
- if (!piece) return;
134
- thinkingBuffer += piece;
135
- ensureThinkingEl();
136
- scheduleFlush();
137
- }
138
-
139
- function finalizeThinkingEl() {
140
- if (thinkingBodyEl) thinkingBodyEl.textContent = thinkingBuffer.replace(/[ \t\r\n]+$/, "");
141
- if (thinkingEl) thinkingEl.open = false;
142
- thinkingBuffer = "";
143
- }
144
-
145
- function resetThinkingState() {
146
- thinkingEl = null;
147
- thinkingBodyEl = null;
148
- thinkingBuffer = "";
149
- }
150
-
151
- function finalizeStreaming() {
152
- if (streamingBubble) {
153
- const k = normalize(streamingBuffer);
154
- // The text lands in the bubble on the next animation frame; a generation
155
- // can end before one runs (a burst of frames, a hidden tab), so write it
156
- // here too or the bubble keeps its "…" placeholder.
157
- if (k) {
158
- streamingBubble.textContent = streamingBuffer;
159
- sawText(k);
160
- }
161
- else streamingBubble.remove(); // drop text-less bubbles (thinking/tool-only generations, "…" placeholder)
162
- streamingBubble.classList.remove("streaming");
163
- streamingBubble = null;
164
- }
165
- streamingBuffer = "";
166
- rafPending = false;
167
- }
168
-
169
- // ── Per-turn tool/memory activity panel ──────────────────────────────────
170
- // One collapsible <details> per turn; each :tool_call_started/completed upserts
171
- // a row (keyed by iteration:call_index) that transitions running -> ok/error.
172
- // Memory reads/writes flow through the same tool_call events, so they show here too.
173
-
174
- function ensureActivityEl() {
175
- if (activityEl) return activityEl;
176
- removeHint();
177
- const details = document.createElement("details");
178
- details.className = "bubble activity";
179
- details.open = true;
180
- const summary = document.createElement("summary");
181
- summary.textContent = "activity";
182
- details.appendChild(summary);
183
- const body = document.createElement("div");
184
- body.className = "activity-body";
185
- details.appendChild(body);
186
- activityEl = details;
187
- activityElBody = body;
188
- appendToHistory(details);
189
- return details;
190
- }
191
-
192
- function renderActivityRow(row) {
193
- const el = document.createElement("div");
194
- el.className = "activity-row";
195
- el.dataset.key = row.key;
196
- const statusEl = document.createElement("span");
197
- statusEl.className = "activity-status";
198
- const toolEl = document.createElement("span");
199
- toolEl.className = "activity-tool";
200
- toolEl.textContent = toolName(row);
201
- const paramsEl = document.createElement("span");
202
- paramsEl.className = "activity-params";
203
- const outEl = document.createElement("div");
204
- outEl.className = "activity-output";
205
- outEl.style.display = "none";
206
- el.appendChild(statusEl);
207
- el.appendChild(toolEl);
208
- el.appendChild(paramsEl);
209
- el.appendChild(outEl);
210
- activityRowEls.set(row.key, el);
211
- const follow = isNearBottom();
212
- (activityElBody || ensureActivityEl().querySelector(".activity-body")).appendChild(el);
213
- updateActivityRowEl(row);
214
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
215
- }
216
-
217
- function updateActivityRowEl(row) {
218
- const el = activityRowEls.get(row.key);
219
- if (!el) return;
220
- fillActivityRow(el, row, sessionId());
221
- }
222
-
223
- function updateActivitySummary() {
224
- if (!activityEl) return;
225
- const summary = activityEl.querySelector("summary");
226
- if (!summary) return;
227
- // From the 3rd call: "activity · 12 tool calls (2 failed) · execute ×7 · …" (the rows show the last call).
228
- const tally = tallyText(activityModel?.rows, { last: false });
229
- summary.textContent = tally ? `activity · ${tally}` : `activity (${activityCount(activityModel)})`;
230
- }
231
-
232
- function finalizeActivityEl() {
233
- if (activityModel) finalizeActivity(activityModel);
234
- if (activityEl) activityEl.open = false;
235
- }
236
-
237
- function resetActivityState() {
238
- activityModel = null;
239
- activityEl = null;
240
- activityElBody = null;
241
- activityRowEls.clear();
242
- }
243
-
244
- function toolEvent(data, add) {
245
- const model = activityModel || (activityModel = newActivity());
246
- ensureActivityEl();
247
- const row = add(model, data);
248
- if (!activityRowEls.has(row.key)) renderActivityRow(row);
249
- else updateActivityRowEl(row);
250
- updateActivitySummary();
251
- }
252
-
253
- return {
254
- turnStarted(data) {
255
- resetActivityState();
256
- resetThinkingState();
257
- streamingBuffer = "";
258
- ensureStreamingBubble();
259
- if (data.prompt) streamingBubble.textContent = "…";
260
- },
261
- generationStarted() {
262
- // A generation left open (a join mid-way) ends where the next begins.
263
- finalizeStreaming();
264
- ensureStreamingBubble();
265
- },
266
- chunk({ text, thinking }) {
267
- if (text) appendToken(text);
268
- if (thinking) appendThinking(thinking);
269
- },
270
- generationCompleted() {
271
- finalizeStreaming();
272
- },
273
- toolStarted(data) {
274
- toolEvent(data, (model, event) => addStarted(model, event).row);
275
- },
276
- toolCompleted(data) {
277
- toolEvent(data, addCompleted);
278
- },
279
- // Everything a turn's end (completed, canceled, failed) closes.
280
- turnEnded() {
281
- finalizeStreaming();
282
- finalizeActivityEl();
283
- finalizeThinkingEl();
284
- },
285
- // A hook's notice stays the page's bubble in this view.
286
- notice() {
287
- return false;
288
- },
289
- // So is a plugin's steer.
290
- steer() {
291
- return false;
292
- },
293
- // So is a card.
294
- card() {
295
- return false;
296
- },
297
- flush,
298
- // The element is rewritten every frame while the turn runs (annotate
299
- // skips it).
300
- containsLive(el) {
301
- return !!thinkingEl?.contains(el);
302
- },
303
- reset() {
304
- streamingBubble = null;
305
- streamingBuffer = "";
306
- rafPending = false;
307
- resetActivityState();
308
- resetThinkingState();
309
- },
310
- };
311
- }
312
-
313
- // One tool row's status, name, params, output and image thumbs from its
314
- // activity row (activity.js). Shared with the turn view.
315
- // A reloaded history in the chat view (renderHistory's object path): one
316
- // bubble per message; each turn's timing line under its last bubble, then
317
- // its cancel line, where the live view leaves them.
318
- // +thumbs(images)+ draws a prompt's image thumbs.
319
- export function chatHistoryHtml(items, timing, { thumbs }) {
320
- const timedTurns = timedTurnIndexes(items);
321
- return items
322
- .map((m, i) => {
323
- if (m.role === "note") return `<div class="bubble note">${noteHtml(m)}</div>`;
324
- if (m.role === "steer") return steerRowHtml({ source: m.source, text: m.content }, { bubble: true });
325
- const turnIndex = timedTurns[i];
326
- const cls = m.role === "user" ? "user" : "output";
327
- const { body, renderedMarkdown } = messageBodyHtml(m);
328
- const markdown = renderedMarkdown ? " markdown" : "";
329
- const record = turnIndex === null ? null : turnRecordAt(timing, turnIndex);
330
- const timingHtml = record && formatDuration(record.duration_ms)
331
- ? `<div class="turn-timing">${escapeHtml(turnTimingText(turnIndex + 1, record.duration_ms, { canceled: record.status === "canceled" }))}</div>`
332
- : "";
333
- // A canceled turn ends with its cancel line, as the live view left it.
334
- const cancelHtml = record ? cancelLineHtml(record, escapeHtml) : "";
335
- // A user bubble is a flex row (label, text, badge): its text sits in
336
- // one block so quotes stack instead of lining up side by side.
337
- const inner = m.role === "user" ? `<div class="user-message">${body}${thumbs(m.images)}</div>` : body;
338
- // The timing line goes after the bubble, not in it: under the answer,
339
- // or under the prompt of a turn canceled before any answer.
340
- return `<div class="bubble ${cls}${markdown}"${copySourceAttr(m, renderedMarkdown)}>${inner}</div>${timingHtml}${cancelHtml}`;
341
- })
342
- .join("");
343
- }
344
-
345
- export function fillActivityRow(el, row, sessionId) {
346
- const statusEl = el.querySelector(".activity-status");
347
- statusEl.className = `activity-status ${activityStatusClass(row.status)}`;
348
- statusEl.textContent = activityStatusLabel(row.status);
349
- const toolEl = el.querySelector(".activity-tool");
350
- if (toolEl) toolEl.textContent = toolName(row);
351
- const paramsEl = el.querySelector(".activity-params");
352
- if (paramsEl) {
353
- // The title in place of the params; the full params line on hover.
354
- paramsEl.textContent = row.title || row.params || "";
355
- paramsEl.title = row.title && row.params ? row.params : "";
356
- }
357
- const outEl = el.querySelector(".activity-output");
358
- if (outEl) {
359
- if (row.status === "running") {
360
- outEl.style.display = "none";
361
- outEl.textContent = "";
362
- outEl.title = "";
363
- } else {
364
- // dispatch prefixes output with "[tool]"; strip that noise for display.
365
- const full = (row.output || "").replace(/^\[[^\]]*\]\s*/, "");
366
- outEl.title = full;
367
- outEl.textContent = full.length > 300 ? `${full.slice(0, 300)}…` : full;
368
- outEl.style.display = full ? "" : "none";
369
- }
370
- }
371
- // The images a tool returned (the chat view's reloaded history has no tool
372
- // rows; the turn view's has, turn_view.js reloadRowHtml).
373
- if (row.images?.length && !el.querySelector(".thumbs")) el.insertAdjacentHTML("beforeend", thumbsHtml(sessionId, row.images));
374
- // An edit/write's change, collapsed: "diff +3 −1".
375
- if (row.diff && !el.querySelector(".activity-diff")) el.insertAdjacentHTML("beforeend", activityDiffHtml(row.diff));
376
- }