samagotchi 0.4.0 → 0.5.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 +80 -1
  3. data/README.md +13 -2
  4. data/bin/chi +29 -39
  5. data/docs/cli.md +135 -73
  6. data/docs/configuration.md +15 -20
  7. data/docs/hooks.md +1 -1
  8. data/docs/memory.md +40 -0
  9. data/docs/plugins.md +50 -0
  10. data/docs/releasing.md +9 -6
  11. data/docs/sessions.md +3 -3
  12. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  13. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  15. data/lib/samagotchi/bridge.rb +16 -11
  16. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  17. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  18. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -8
  19. data/lib/samagotchi/bundles/system/identity.md +5 -0
  20. data/lib/samagotchi/bundles/system/manifest.yml +5 -5
  21. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  22. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  23. data/lib/samagotchi/client.rb +16 -20
  24. data/lib/samagotchi/config.rb +40 -100
  25. data/lib/samagotchi/engine.rb +50 -354
  26. data/lib/samagotchi/kernel_loop.rb +33 -44
  27. data/lib/samagotchi/live_versions.rb +7 -1
  28. data/lib/samagotchi/llm/errors.rb +17 -0
  29. data/lib/samagotchi/llm/http.rb +4 -18
  30. data/lib/samagotchi/llm/openai_chat.rb +17 -0
  31. data/lib/samagotchi/model_profile.rb +4 -10
  32. data/lib/samagotchi/note_command.rb +2 -1
  33. data/lib/samagotchi/reply_wait.rb +48 -4
  34. data/lib/samagotchi/self_report.rb +20 -2
  35. data/lib/samagotchi/send_command.rb +84 -6
  36. data/lib/samagotchi/session.rb +4 -2
  37. data/lib/samagotchi/session_manager.rb +18 -37
  38. data/lib/samagotchi/system_prompt.rb +403 -0
  39. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  40. data/lib/samagotchi/terminal_ui/attached_loop.rb +120 -83
  41. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  42. data/lib/samagotchi/terminal_ui/event_renderer.rb +23 -7
  43. data/lib/samagotchi/terminal_ui/formatting.rb +32 -22
  44. data/lib/samagotchi/terminal_ui/input_support.rb +3 -4
  45. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  46. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  47. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  48. data/lib/samagotchi/terminal_ui.rb +95 -690
  49. data/lib/samagotchi/thinking.rb +11 -0
  50. data/lib/samagotchi/tool_activity.rb +52 -2
  51. data/lib/samagotchi/tool_runner.rb +3 -0
  52. data/lib/samagotchi/tools/execute.rb +3 -3
  53. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  54. data/lib/samagotchi/tools/read.rb +4 -4
  55. data/lib/samagotchi/update_command.rb +2 -1
  56. data/lib/samagotchi/version.rb +1 -1
  57. data/lib/samagotchi/web/app.rb +170 -35
  58. data/lib/samagotchi/web/lan.rb +99 -0
  59. data/lib/samagotchi/web/message_parts.rb +15 -11
  60. data/lib/samagotchi/web/public/activity.js +7 -0
  61. data/lib/samagotchi/web/public/app.js +99 -54
  62. data/lib/samagotchi/web/public/chat_view.js +5 -1
  63. data/lib/samagotchi/web/public/index.html +163 -17
  64. data/lib/samagotchi/web/public/model_pick.js +136 -0
  65. data/lib/samagotchi/web/public/model_picker.js +224 -0
  66. data/lib/samagotchi/web/public/notify.js +10 -0
  67. data/lib/samagotchi/web/public/stage_model.js +110 -0
  68. data/lib/samagotchi/web/public/stage_view.js +580 -0
  69. data/lib/samagotchi/web/public/timing.js +6 -2
  70. data/lib/samagotchi/web/public/turn_events.js +9 -5
  71. data/lib/samagotchi/web/public/turn_model.js +11 -3
  72. data/lib/samagotchi/web/public/turn_view.js +74 -19
  73. data/lib/samagotchi/web/qr.rb +40 -0
  74. data/lib/samagotchi/web/server.rb +101 -11
  75. data/lib/samagotchi/web/token.rb +97 -0
  76. metadata +27 -3
  77. data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
@@ -55,15 +55,17 @@ module Samagotchi
55
55
  # @param message [Hash] a saved model message (symbol or string keys)
56
56
  # @param responses [Array<Hash>] the tool_response messages after it
57
57
  # @param registry [Tools::Registry] the session's tools
58
- # @return [Hash, nil] { thinking:, tools: [{ tool:, params:, output:,
59
- # output_truncated:, images: }] } with only what it found; nil for nothing
60
- def for_message(message, responses, registry: Tools::Builtins.default)
58
+ # @param cwd [String, nil] the session's working directory (a file
59
+ # tool's title is relative to it)
60
+ # @return [Hash, nil] { thinking:, tools: [{ tool:, params:, title:,
61
+ # output:, output_truncated:, images: }] } with only what it found; nil for nothing
62
+ def for_message(message, responses, registry: Tools::Builtins.default, cwd: nil)
61
63
  content = field(message, :content).to_s
62
64
  calls = field(message, :tool_calls)
63
65
  tools = if calls.is_a?(Array) && !calls.empty?
64
- native_tools(calls, responses, registry)
66
+ native_tools(calls, responses, registry, cwd)
65
67
  else
66
- markup_tools(content, responses, registry)
68
+ markup_tools(content, responses, registry, cwd)
67
69
  end
68
70
  parts = {}
69
71
  thinking = [field(message, :thinking).to_s.strip, thinking_of(content)].reject(&:empty?).join("\n\n")
@@ -83,7 +85,7 @@ module Samagotchi
83
85
  blocks.map(&:strip).reject(&:empty?).join("\n\n")
84
86
  end
85
87
 
86
- def markup_tools(content, responses, registry)
88
+ def markup_tools(content, responses, registry, cwd = nil)
87
89
  calls = []
88
90
  calls.concat(ToolCallParser::Qwen.new(ModelProfile.qwen36).parse(content)) if content.include?("<tool_call>")
89
91
  calls.concat(ToolCallParser::Gemma.new(ModelProfile.gemma4).parse(content)) if content.include?("<|tool_call>")
@@ -96,7 +98,7 @@ module Samagotchi
96
98
  diffs = saved_list(responses, :tool_diffs, calls.length)
97
99
  images = split_images(responses, calls.length)
98
100
  calls.each_with_index.map do |call, i|
99
- tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i])
101
+ tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i], cwd: cwd)
100
102
  end
101
103
  end
102
104
 
@@ -118,7 +120,7 @@ module Samagotchi
118
120
  counts.map { |n| images.shift(n) }
119
121
  end
120
122
 
121
- def native_tools(calls, responses, registry)
123
+ def native_tools(calls, responses, registry, cwd = nil)
122
124
  by_id = responses.to_h { |r| [field(r, :tool_call_id), r] }
123
125
  calls.each_with_index.map do |raw, i|
124
126
  ref = CallRef.new(field(raw, :name).to_s, field(raw, :arguments))
@@ -126,7 +128,7 @@ module Samagotchi
126
128
  response = by_id[field(raw, :id)] || (field(responses[i], :tool_call_id).nil? ? responses[i] : nil)
127
129
  tool_part(call, response && field(response, :content).to_s, registry, field(response, :tool_params),
128
130
  response && field(response, :images), label: field(response, :tool_labels),
129
- diff: field(response, :tool_diffs))
131
+ diff: field(response, :tool_diffs), cwd: cwd)
130
132
  end
131
133
  end
132
134
 
@@ -148,11 +150,13 @@ module Samagotchi
148
150
 
149
151
  # +shown+ is the saved params line, when it is a String.
150
152
  # +diff+ is what an edit/write changed (EditPreview.change), saved as
151
- # tool_diffs.
152
- def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil)
153
+ # tool_diffs. +cwd+: the session's working directory, for the title.
154
+ def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil, cwd: nil)
153
155
  name = call[:name].to_s
154
156
  params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
155
157
  part = { tool: name, params: params.to_s }
158
+ title = ToolActivity.tool_title(name, call, cwd: cwd)
159
+ part[:title] = title if title
156
160
  part[:label] = label if label.is_a?(String) && !label.empty?
157
161
  unless output.nil?
158
162
  part[:output] = output.length > OUTPUT_MAX ? output[0, OUTPUT_MAX] : output
@@ -46,6 +46,7 @@ export function addStarted(model, event = {}) {
46
46
  row.tool = event.tool || row.tool;
47
47
  row.label = event.label || row.label;
48
48
  row.params = event.params || row.params;
49
+ row.title = event.title || row.title;
49
50
  return { row, created: false };
50
51
  }
51
52
  row = {
@@ -55,6 +56,10 @@ export function addStarted(model, event = {}) {
55
56
  tool: event.tool || "tool",
56
57
  label: event.label || null,
57
58
  params: event.params || "",
59
+ // What the call did in a few words (the server's tool title: a
60
+ // project-relative path, a command without its "cd … &&"); rows show it
61
+ // in place of the params when there is one.
62
+ title: event.title || null,
58
63
  status: ROW_STATUS.RUNNING,
59
64
  output: "",
60
65
  output_truncated: false,
@@ -78,6 +83,7 @@ export function addCompleted(model, event = {}) {
78
83
  tool: event.tool,
79
84
  label: event.label,
80
85
  params: event.activity?.params || "",
86
+ title: event.activity?.title,
81
87
  });
82
88
  row = synthesized.row;
83
89
  }
@@ -85,6 +91,7 @@ export function addCompleted(model, event = {}) {
85
91
  row.output = event.output || "";
86
92
  row.output_truncated = !!event.output_truncated;
87
93
  if (event.activity?.params) row.params = event.activity.params;
94
+ if (event.activity?.title) row.title = event.activity.title;
88
95
  // The images the tool returned (a reload in the turn view draws them from
89
96
  // the saved parts, turn_view.js reloadRowHtml).
90
97
  if (event.images?.length) row.images = event.images;
@@ -17,11 +17,13 @@ import {
17
17
  uploadImage,
18
18
  listModels,
19
19
  } from "./data.js";
20
- import { MODEL_KEY, modelNames, pickModel } from "./model_pick.js";
20
+ import { MODEL_KEY } from "./model_pick.js";
21
+ import { createModelPicker } from "./model_picker.js";
21
22
  import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
22
23
  import { escapeHtml, messageBodyHtml, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, firstMessageOf, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
23
24
  import { chatHistoryHtml, createChatView } from "./chat_view.js";
24
25
  import { createTurnView, turnHistoryHtml } from "./turn_view.js";
26
+ import { createStageView } from "./stage_view.js";
25
27
  import { annotateBarLeft, annotationSource, appendQuote, quoteBlock } from "./annotations.js";
26
28
  import { parsePresets, presetNote } from "./annotate_presets.js";
27
29
  import { COPY_SOURCE_ATTR, copySource, copySourceAttr, copyText, installCopy, showsAnswer } from "./copy.js";
@@ -37,7 +39,7 @@ import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js
37
39
  import { shouldFollowScroll, keepFollowing } from "./scroll.js";
38
40
  import { stripColumns, stripShowParts } from "./strip.js";
39
41
  import { applySessionEvent, listedSessions, sortedByUpdated, withChildrenAfterParents } from "./sessions_list.js";
40
- import { NOTIFY_CHANNEL, attentionText, createNotifyGate, initialAttentionState, trackAttention } from "./notify.js";
42
+ import { NOTIFY_CHANNEL, attentionText, createNotifyGate, initialAttentionState, notifyState as bellState, trackAttention } from "./notify.js";
41
43
  import {
42
44
  appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
43
45
  } from "./timing.js";
@@ -58,7 +60,6 @@ const promptEl = $("#prompt");
58
60
  const actionBtn = $("#actionBtn");
59
61
  const cancelBtn = $("#cancelBtn");
60
62
  const modelPickEl = $("#modelPick");
61
- const modelSelectEl = $("#modelSelect");
62
63
  const composeHintEl = $("#composeHint");
63
64
 
64
65
  let selected = null;
@@ -72,18 +73,32 @@ let resyncsInFlight = 0;
72
73
  let resyncAgain = false;
73
74
  let allSessions = [];
74
75
  let seenContent = new Set();
75
- // The per-turn view (web.turn_view, or ?view=turn|chat): one block per turn
76
- // with its steps (turn_view.js), instead of a row of bubbles (chat_view.js).
77
- const turnView = document.body.dataset.turnView === "1";
76
+ // The page's view of a turn (web.view, or ?view=turn|stage|chat): one block
77
+ // per turn with its steps (turn_view.js), the running turn pinned above the
78
+ // composer (the stage), or a row of bubbles (chat_view.js).
79
+ const view = ["turn", "stage", "chat"].includes(document.body.dataset.view) ? document.body.dataset.view : "turn";
80
+ // The turn and stage views draw a turn as steps (a reload asks for parts).
81
+ const turnView = view !== "chat";
78
82
  // The running turn's DOM: the streaming bubbles, the thinking block and the
79
- // activity panel, or the turn block.
80
- const turnDom = (turnView ? createTurnView : createChatView)({
83
+ // activity panel, the turn block, or the stage (the turn block in its cloud).
84
+ const viewArgs = {
81
85
  historyEl,
82
86
  appendToHistory: (el) => appendToHistory(el),
83
87
  isNearBottom: () => isNearBottom(),
84
88
  sawText: (k) => seenContent.add(k),
85
89
  sessionId: () => selected,
86
- });
90
+ };
91
+ const turnDom = view === "stage"
92
+ ? createStageView({ ...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl })
93
+ : (turnView ? createTurnView : createChatView)(viewArgs);
94
+ // Where a running turn's rows can be: the history, and the stage while it
95
+ // holds a turn (lookups by id, copy, annotate, card actions).
96
+ function turnRoots() {
97
+ return turnDom.root ? [historyEl, turnDom.root] : [historyEl];
98
+ }
99
+ function findInTurnRoots(selector) {
100
+ return turnRoots().flatMap((root) => [...root.querySelectorAll(selector)]);
101
+ }
87
102
  let currentFirstPreview = "";
88
103
  let currentUsedMemories = [];
89
104
  // The session's --memory and --mute lists (from the session card; shown in
@@ -482,6 +497,7 @@ const SEND_HINT = "Enter to send · Shift+Enter for a newline";
482
497
  // its next step (steering). Cancel is its own button then.
483
498
  function updateComposerMode() {
484
499
  document.body.classList.toggle("has-session", !!selected);
500
+ if (selected) modelPicker.close();
485
501
  const running = turnRunning && !!selected;
486
502
  cancelBtn.style.display = running ? "" : "none";
487
503
  if (running) {
@@ -829,7 +845,7 @@ function applyFinalMarkdown(messages) {
829
845
  );
830
846
  if (!message) return;
831
847
 
832
- const bubble = [...historyEl.querySelectorAll(".bubble.output")]
848
+ const bubble = findInTurnRoots(".bubble.output")
833
849
  .reverse()
834
850
  .find((item) => showsAnswer(item, message, normalize));
835
851
  if (!bubble) return;
@@ -841,7 +857,7 @@ function applyFinalMarkdown(messages) {
841
857
 
842
858
  function seedSeenFromDom() {
843
859
  seenContent = new Set();
844
- historyEl.querySelectorAll(".bubble").forEach((b) => {
860
+ findInTurnRoots(".bubble").forEach((b) => {
845
861
  const k = normalize(b.textContent);
846
862
  if (k) seenContent.add(k);
847
863
  });
@@ -850,8 +866,13 @@ function seedSeenFromDom() {
850
866
  // Sticky-follow scroll: only auto-scroll to the bottom while the user is
851
867
  // already near it; never yank them back down mid-read. Capture the decision
852
868
  // BEFORE any DOM mutation that grows the content (new chunk / row / thinking
853
- // delta), so the gap measured is the one the user was sitting at.
869
+ // delta), so the gap measured is the one the user was sitting at. While the
870
+ // stage holds a turn its rows go there: nothing it does moves the history.
854
871
  function isNearBottom() {
872
+ return !turnDom.holds?.() && atHistoryEnd();
873
+ }
874
+
875
+ function atHistoryEnd() {
855
876
  const { scrollHeight, scrollTop, clientHeight } = historyEl;
856
877
  return shouldFollowScroll({ scrollHeight, scrollTop, clientHeight });
857
878
  }
@@ -875,7 +896,7 @@ function addEndBubble(text) {
875
896
  div.className = "bubble cancel";
876
897
  div.textContent = text;
877
898
  appendToHistory(div);
878
- historyEl.scrollTop = historyEl.scrollHeight;
899
+ if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
879
900
  }
880
901
 
881
902
  // Everything a turn's end (completed, canceled, failed) closes: then its
@@ -888,7 +909,7 @@ function endTurnView(kind, endText = null) {
888
909
  if (endText) addEndBubble(endText);
889
910
  if (kept.length) {
890
911
  kept.forEach((el) => appendToHistory(el));
891
- historyEl.scrollTop = historyEl.scrollHeight;
912
+ if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
892
913
  }
893
914
  // The question dies with its turn (the worker stopped waiting).
894
915
  if (pendingQuestion) resolveQuestionCard(pendingQuestion.id, { cancelled: true, reason: "turn ended" });
@@ -944,25 +965,29 @@ function setBubbleState(bubble, state) {
944
965
 
945
966
  function bubbleById(enqueuedId) {
946
967
  if (!enqueuedId) return null;
947
- return [...historyEl.querySelectorAll(".bubble.user[data-enqueued-id]")].find(
968
+ return findInTurnRoots(".bubble.user[data-enqueued-id]").find(
948
969
  (el) => el.dataset.enqueuedId === enqueuedId,
949
970
  ) || null;
950
971
  }
951
972
 
952
973
  // This tab's oldest untagged echo, preferring one with the same text.
953
974
  function untaggedOwnBubble(prompt) {
954
- const echoes = [...historyEl.querySelectorAll(".bubble.user[data-own]:not([data-enqueued-id])")];
975
+ const echoes = findInTurnRoots(".bubble.user[data-own]:not([data-enqueued-id])");
955
976
  const text = normalize(prompt || "");
956
977
  return echoes.find((el) => el.dataset.userContent === text) || echoes[0] || null;
957
978
  }
958
979
 
980
+ // Returns the bubble of the turn a turn_started starts (the stage takes it
981
+ // into its prompt line), else null.
959
982
  function applyPromptOps(event) {
960
983
  const ops = promptOps(event, { myId: clientId, known: (id) => !!bubbleById(id), unmatchedMerge });
961
984
  if (event.type === "input_merged") unmatchedMerge = false;
962
985
  if (event.type === "pending_input_merged") unmatchedMerge = false;
986
+ let started = null;
963
987
  for (const op of ops) {
964
988
  if (op.op === "add") {
965
- makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images });
989
+ const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images });
990
+ if (op.state === "started") started = bubble;
966
991
  } else if (op.op === "tag") {
967
992
  if (bubbleById(op.enqueuedId)) continue;
968
993
  const echo = untaggedOwnBubble(op.prompt);
@@ -973,10 +998,11 @@ function applyPromptOps(event) {
973
998
  const bubble = bubbleById(op.enqueuedId) || untaggedOwnBubble(op.prompt);
974
999
  // A resync (a new worker woken by this page's send) re-rendered the
975
1000
  // history and took the echo with it: draw the prompt again.
976
- if (!bubble) makeUserBubble(op.prompt, { own: true, enqueuedId: op.enqueuedId, images: op.images });
1001
+ if (!bubble) started = makeUserBubble(op.prompt, { own: true, enqueuedId: op.enqueuedId, images: op.images });
977
1002
  else {
978
1003
  if (op.enqueuedId) bubble.dataset.enqueuedId = op.enqueuedId;
979
1004
  setBubbleState(bubble, null);
1005
+ started = bubble;
980
1006
  }
981
1007
  } else if (op.op === "steer") {
982
1008
  const bubble = bubbleById(op.enqueuedId);
@@ -984,6 +1010,7 @@ function applyPromptOps(event) {
984
1010
  else unmatchedMerge = true;
985
1011
  }
986
1012
  }
1013
+ return started;
987
1014
  }
988
1015
 
989
1016
  // ── Idle recap ─────────────────────────────────────────────────────────────
@@ -1006,8 +1033,9 @@ function showRecap(text, turnsSince = 0) {
1006
1033
  details.appendChild(summary);
1007
1034
  details.appendChild(body);
1008
1035
  // A stale one sits where it was written, above the turns it doesn't cover.
1036
+ // Never in the stage: a recap is between turns.
1009
1037
  const before = recapPlace([...historyEl.querySelectorAll(":scope > .bubble.user")], turnsSince);
1010
- keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendToHistory(details)));
1038
+ keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurnTimingEl)));
1011
1039
  }
1012
1040
 
1013
1041
  // A turn started: the recap describes the chat before it.
@@ -1067,7 +1095,7 @@ function showHookNotice(data, { before = null } = {}) {
1067
1095
 
1068
1096
  function cardElement(id) {
1069
1097
  if (!id) return null;
1070
- return [...historyEl.querySelectorAll(".plugin-card")].find((el) => el.dataset.cardId === String(id)) || null;
1098
+ return findInTurnRoots(".plugin-card").find((el) => el.dataset.cardId === String(id)) || null;
1071
1099
  }
1072
1100
 
1073
1101
  function drawCard(el, card) {
@@ -1175,7 +1203,7 @@ function refreshCardBodies() {
1175
1203
  }, 150);
1176
1204
  }
1177
1205
 
1178
- historyEl.addEventListener("click", (e) => {
1206
+ function onCardAction(e) {
1179
1207
  const button = e.target.closest(".plugin-card .card-action");
1180
1208
  if (!button || !selected) return;
1181
1209
  const card = button.closest(".plugin-card");
@@ -1192,7 +1220,8 @@ historyEl.addEventListener("click", (e) => {
1192
1220
  err.classList.remove("hidden");
1193
1221
  })
1194
1222
  .finally(() => { button.disabled = false; });
1195
- });
1223
+ }
1224
+ turnRoots().forEach((root) => root.addEventListener("click", onCardAction));
1196
1225
 
1197
1226
  function removeRecap() {
1198
1227
  historyEl.querySelectorAll(".bubble.recap").forEach((el) => el.remove());
@@ -1280,12 +1309,16 @@ function startLiveTurnTiming(startedAt = new Date().toISOString()) {
1280
1309
  liveTurnNote = "";
1281
1310
  liveTurnTimingEl = document.createElement("div");
1282
1311
  liveTurnTimingEl.className = "turn-timing live";
1283
- historyEl.appendChild(liveTurnTimingEl);
1312
+ // The stage's status row while it holds the turn: the line moves into the
1313
+ // history with it.
1314
+ (turnDom.timingHost?.() || historyEl).appendChild(liveTurnTimingEl);
1284
1315
  updateLiveTiming();
1285
1316
  timingInterval = setInterval(updateLiveTiming, 1000);
1286
1317
  }
1287
1318
 
1319
+ // The stage takes a running turn's rows (its extras); else the history.
1288
1320
  function appendToHistory(el) {
1321
+ if (turnDom.place?.(el)) return;
1289
1322
  appendAboveLiveTiming(historyEl, el, liveTurnTimingEl);
1290
1323
  }
1291
1324
 
@@ -1438,13 +1471,16 @@ const streamHandlers = {
1438
1471
  },
1439
1472
  turn_enqueued: (data) => applyPromptOps(data),
1440
1473
  turn_started: (data) => keepFollowing(historyEl, () => {
1474
+ // A finished turn still in the stage goes first: the start op looks
1475
+ // for a queued prompt among its rows.
1476
+ turnDom.handOff?.({ immediate: true });
1441
1477
  markRecapStale();
1442
- applyPromptOps(data);
1478
+ const promptBubble = applyPromptOps(data);
1443
1479
  setTurnRunning(true);
1444
1480
  setLiveStatus("running");
1445
1481
  clearTimingInterval();
1446
1482
  liveTurnTimingEl = null;
1447
- turnDom.turnStarted(data);
1483
+ turnDom.turnStarted({ ...data, promptBubble });
1448
1484
  startLiveTurnTiming(data.started_at);
1449
1485
  }),
1450
1486
  turn_completed: async (data) => {
@@ -1569,7 +1605,11 @@ const streamHandlers = {
1569
1605
  resolveQuestionCard(data.id, { cancelled: true, reason: data.reason });
1570
1606
  },
1571
1607
  // One collected just as a turn started describes the chat before it.
1572
- recap_ready: (data) => { if (!turnRunning) showRecap(data.recap); },
1608
+ recap_ready: (data) => {
1609
+ // After the turn it covers, never above it.
1610
+ turnDom.handOff?.({ immediate: true });
1611
+ if (!turnRunning) showRecap(data.recap);
1612
+ },
1573
1613
  guardrail_warning: (data) => showGuardrailWarning(data.message, data.label || "guardrails"),
1574
1614
  // In the turn view, a notice during a turn is a row of its current step.
1575
1615
  hook_notice: (data) => { if (!turnDom.notice(data)) showHookNotice(data); },
@@ -1898,9 +1938,12 @@ async function handleSendTurn() {
1898
1938
  }
1899
1939
  const hint = historyEl.querySelector(".hint");
1900
1940
  if (hint) hint.remove();
1941
+ // Sending moves a finished turn out of the stage at once (else the echo
1942
+ // would land among its rows); into a running one it is a queued row there.
1943
+ turnDom.handOff?.({ immediate: true });
1901
1944
  const sending = chips.slice();
1902
1945
  const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending });
1903
- historyEl.scrollTop = historyEl.scrollHeight;
1946
+ if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
1904
1947
  if (!currentFirstPreview || currentFirstPreview==="—") {
1905
1948
  currentFirstPreview = normalize(prompt).slice(0,80) + (normalize(prompt).length>80?"…":"");
1906
1949
  updateInfoBar();
@@ -2008,7 +2051,7 @@ function drawCommandBubble(div, { label, line, text, busy = false, failed = fals
2008
2051
 
2009
2052
  function commandBubble(commandId) {
2010
2053
  if (!commandId) return null;
2011
- return [...historyEl.querySelectorAll(".bubble.command")].find((el) => el.dataset.commandId === String(commandId)) || null;
2054
+ return findInTurnRoots(".bubble.command").find((el) => el.dataset.commandId === String(commandId)) || null;
2012
2055
  }
2013
2056
 
2014
2057
  function addNoteBubble(note) {
@@ -2131,7 +2174,17 @@ $("#newBtn").addEventListener("click", () => {
2131
2174
  // preselected while it is still offered (else the server's default). Hidden
2132
2175
  // until the list is in, and in a session (its model is in the info bar). A
2133
2176
  // host that failed is the picker's tooltip; a route that fails leaves the
2134
- // picker hidden and the server's default in charge.
2177
+ // picker hidden and the server's default in charge. The search list itself
2178
+ // is model_picker.js'; a pick sends the focus to the message box.
2179
+ const modelPicker = createModelPicker({
2180
+ button: modelPickEl,
2181
+ panel: $("#modelPanel"),
2182
+ input: $("#modelSearch"),
2183
+ list: $("#modelList"),
2184
+ composer: $("#composer"),
2185
+ center: $("#center"),
2186
+ onPick: () => promptEl.focus(),
2187
+ });
2135
2188
  async function loadModels() {
2136
2189
  let payload;
2137
2190
  try {
@@ -2139,26 +2192,16 @@ async function loadModels() {
2139
2192
  } catch (_) {
2140
2193
  return;
2141
2194
  }
2142
- const names = modelNames(payload);
2143
- if (!names.length) return;
2144
2195
  let stored = null;
2145
2196
  try { stored = localStorage.getItem(MODEL_KEY); } catch (_) {}
2146
- const chosen = pickModel(names, payload.default, stored || modelSelectEl.value);
2147
- modelSelectEl.innerHTML = names
2148
- .map((n) => `<option value="${escapeHtml(n)}"${n === chosen ? " selected" : ""}>${escapeHtml(n)}</option>`)
2149
- .join("");
2197
+ if (!modelPicker.setModels(payload, stored)) return;
2150
2198
  modelPickEl.title = payload.warning ? `The model the new chat starts on · ${payload.warning}` : "The model the new chat starts on";
2151
- modelPickEl.dataset.value = chosen; // sizes the select to the chosen name (CSS)
2152
2199
  modelPickEl.hidden = false;
2153
2200
  }
2154
- modelSelectEl.addEventListener("change", () => {
2155
- modelPickEl.dataset.value = modelSelectEl.value;
2156
- try { localStorage.setItem(MODEL_KEY, modelSelectEl.value); } catch (_) {}
2157
- });
2158
2201
  // The picker's choice for a create request; nothing (the server's default)
2159
2202
  // while the list is not in.
2160
2203
  function chosenModel() {
2161
- return modelPickEl.hidden ? undefined : modelSelectEl.value || undefined;
2204
+ return modelPickEl.hidden ? undefined : modelPicker.chosen() || undefined;
2162
2205
  }
2163
2206
  actionBtn.addEventListener("click", () => {
2164
2207
  if (!selected) handleCreate();
@@ -2250,7 +2293,8 @@ fitPrompt();
2250
2293
  const dockEl = $("#dock");
2251
2294
  const centerEl = $("#center");
2252
2295
  new ResizeObserver(() => {
2253
- const follow = isNearBottom();
2296
+ // The stage's height too: a reader at the end stays there.
2297
+ const follow = atHistoryEnd();
2254
2298
  centerEl.style.setProperty("--dock-h", `${dockEl.offsetHeight}px`);
2255
2299
  if (follow) historyEl.scrollTop = historyEl.scrollHeight;
2256
2300
  }).observe(dockEl);
@@ -2273,12 +2317,12 @@ composerEl.addEventListener("drop", (e) => {
2273
2317
  e.preventDefault();
2274
2318
  addImageFiles(files);
2275
2319
  });
2276
- historyEl.addEventListener("click", (e) => {
2320
+ turnRoots().forEach((root) => root.addEventListener("click", (e) => {
2277
2321
  const img = e.target.closest("img.thumb");
2278
2322
  if (!img) return;
2279
2323
  lightboxEl.querySelector("img").src = img.src;
2280
2324
  lightboxEl.hidden = false;
2281
- });
2325
+ }));
2282
2326
  lightboxEl.addEventListener("click", () => { lightboxEl.hidden = true; });
2283
2327
  document.addEventListener("keydown", (e) => {
2284
2328
  if (e.key === "Escape" && !lightboxEl.hidden) lightboxEl.hidden = true;
@@ -2511,7 +2555,7 @@ function updateAnnotate() {
2511
2555
  const sel = window.getSelection();
2512
2556
  if (!selected || currentOwner === "tui" || !sel || sel.isCollapsed || !sel.rangeCount) return hideAnnotate();
2513
2557
  const range = sel.getRangeAt(0);
2514
- if (!historyEl.contains(range.startContainer)) return hideAnnotate();
2558
+ if (!turnRoots().some((root) => root.contains(range.startContainer))) return hideAnnotate();
2515
2559
  const source = annotationSource(range.startContainer);
2516
2560
  // A running turn's narration box shows complete sentences and its thinking
2517
2561
  // is appended by delta; a selection in either is refused while the step is
@@ -2557,8 +2601,9 @@ function scheduleAnnotate() {
2557
2601
  }
2558
2602
  document.addEventListener("selectionchange", scheduleAnnotate);
2559
2603
  historyEl.addEventListener("scroll", scheduleAnnotate);
2604
+ turnDom.scrollEl?.addEventListener("scroll", scheduleAnnotate, true);
2560
2605
  window.addEventListener("resize", scheduleAnnotate);
2561
- installCopy(historyEl);
2606
+ turnRoots().forEach((root) => installCopy(root));
2562
2607
  // Keep the selection when a button is pressed.
2563
2608
  annotateBar.addEventListener("mousedown", (e) => e.preventDefault());
2564
2609
  annotateBar.addEventListener("click", (e) => {
@@ -2595,19 +2640,19 @@ function tabInFront() {
2595
2640
  return document.visibilityState === "visible" && document.hasFocus();
2596
2641
  }
2597
2642
 
2598
- function notifySupported() {
2599
- return typeof globalThis.Notification === "function";
2600
- }
2601
-
2602
2643
  function notifyWanted() {
2603
2644
  try { return localStorage.getItem(NOTIFY_KEY) === "1"; } catch (_) { return false; }
2604
2645
  }
2605
2646
 
2606
- // "on" | "off" | "denied" | "unsupported"
2647
+ // "on" | "off" | "denied" | "unsupported" (notify.js)
2607
2648
  function notifyState() {
2608
- if (!notifySupported()) return "unsupported";
2609
- if (Notification.permission === "denied") return "denied";
2610
- return notifyWanted() && Notification.permission === "granted" ? "on" : "off";
2649
+ const hasNotification = typeof globalThis.Notification === "function";
2650
+ return bellState({
2651
+ hasNotification,
2652
+ secureContext: globalThis.isSecureContext !== false,
2653
+ permission: hasNotification ? Notification.permission : "default",
2654
+ wanted: notifyWanted(),
2655
+ });
2611
2656
  }
2612
2657
 
2613
2658
  const NOTIFY_TITLES = {
@@ -349,7 +349,11 @@ export function fillActivityRow(el, row, sessionId) {
349
349
  const toolEl = el.querySelector(".activity-tool");
350
350
  if (toolEl) toolEl.textContent = toolName(row);
351
351
  const paramsEl = el.querySelector(".activity-params");
352
- if (paramsEl) paramsEl.textContent = row.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
+ }
353
357
  const outEl = el.querySelector(".activity-output");
354
358
  if (outEl) {
355
359
  if (row.status === "running") {