samagotchi 0.3.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +162 -1
  3. data/README.md +29 -2
  4. data/bin/chi +60 -69
  5. data/docs/cli.md +211 -77
  6. data/docs/configuration.md +118 -21
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +89 -7
  10. data/docs/memory.md +40 -0
  11. data/docs/plugins.md +50 -0
  12. data/docs/releasing.md +15 -12
  13. data/docs/sessions.md +20 -18
  14. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  15. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  17. data/lib/samagotchi/bridge.rb +20 -12
  18. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  19. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  22. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
  24. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  25. data/lib/samagotchi/bundles/system/identity.md +5 -0
  26. data/lib/samagotchi/bundles/system/manifest.yml +6 -6
  27. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  28. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  29. data/lib/samagotchi/client.rb +25 -26
  30. data/lib/samagotchi/commands/registry.rb +8 -0
  31. data/lib/samagotchi/config.rb +97 -113
  32. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  33. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  34. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  35. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  36. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  37. data/lib/samagotchi/desktop/macos.rb +59 -8
  38. data/lib/samagotchi/desktop_command.rb +6 -3
  39. data/lib/samagotchi/edit_preview.rb +82 -0
  40. data/lib/samagotchi/engine.rb +236 -443
  41. data/lib/samagotchi/gem_update.rb +89 -0
  42. data/lib/samagotchi/guardrails/approval.rb +26 -4
  43. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  44. data/lib/samagotchi/host_registry.rb +8 -12
  45. data/lib/samagotchi/idle_client.rb +24 -15
  46. data/lib/samagotchi/idle_reminders.rb +2 -2
  47. data/lib/samagotchi/image_store.rb +10 -6
  48. data/lib/samagotchi/kernel_loop.rb +59 -123
  49. data/lib/samagotchi/live_versions.rb +65 -0
  50. data/lib/samagotchi/llm/api_key.rb +41 -0
  51. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  52. data/lib/samagotchi/llm/errors.rb +38 -7
  53. data/lib/samagotchi/llm/http.rb +19 -22
  54. data/lib/samagotchi/llm/openai_chat.rb +22 -26
  55. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  56. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  57. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  58. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  59. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  60. data/lib/samagotchi/model_profile.rb +27 -10
  61. data/lib/samagotchi/note_command.rb +2 -1
  62. data/lib/samagotchi/prompt.rb +4 -2
  63. data/lib/samagotchi/reminder_store.rb +1 -9
  64. data/lib/samagotchi/reply_wait.rb +48 -4
  65. data/lib/samagotchi/self_report.rb +37 -5
  66. data/lib/samagotchi/send_command.rb +190 -17
  67. data/lib/samagotchi/session.rb +4 -2
  68. data/lib/samagotchi/session_commands.rb +38 -8
  69. data/lib/samagotchi/session_manager.rb +19 -53
  70. data/lib/samagotchi/system_prompt.rb +403 -0
  71. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  74. data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
  75. data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
  76. data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
  77. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  78. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  79. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  80. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  81. data/lib/samagotchi/terminal_ui.rb +142 -923
  82. data/lib/samagotchi/text_diff.rb +181 -0
  83. data/lib/samagotchi/thinking.rb +126 -0
  84. data/lib/samagotchi/tool_activity.rb +52 -2
  85. data/lib/samagotchi/tool_runner.rb +37 -1
  86. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  87. data/lib/samagotchi/tools/edit.rb +23 -9
  88. data/lib/samagotchi/tools/execute.rb +3 -3
  89. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  90. data/lib/samagotchi/tools/read.rb +4 -4
  91. data/lib/samagotchi/tools/write.rb +4 -0
  92. data/lib/samagotchi/turn_flow.rb +12 -2
  93. data/lib/samagotchi/update_command.rb +309 -0
  94. data/lib/samagotchi/update_hint.rb +59 -0
  95. data/lib/samagotchi/version.rb +1 -1
  96. data/lib/samagotchi/vision_support.rb +6 -4
  97. data/lib/samagotchi/web/app.rb +173 -38
  98. data/lib/samagotchi/web/lan.rb +99 -0
  99. data/lib/samagotchi/web/message_parts.rb +19 -10
  100. data/lib/samagotchi/web/public/activity.js +10 -0
  101. data/lib/samagotchi/web/public/app.js +135 -78
  102. data/lib/samagotchi/web/public/chat_view.js +8 -1
  103. data/lib/samagotchi/web/public/data.js +2 -0
  104. data/lib/samagotchi/web/public/diff_view.js +58 -0
  105. data/lib/samagotchi/web/public/index.html +185 -18
  106. data/lib/samagotchi/web/public/model_pick.js +136 -0
  107. data/lib/samagotchi/web/public/model_picker.js +224 -0
  108. data/lib/samagotchi/web/public/notify.js +10 -0
  109. data/lib/samagotchi/web/public/question_card.js +3 -1
  110. data/lib/samagotchi/web/public/stage_model.js +110 -0
  111. data/lib/samagotchi/web/public/stage_view.js +580 -0
  112. data/lib/samagotchi/web/public/timing.js +6 -2
  113. data/lib/samagotchi/web/public/turn_events.js +38 -10
  114. data/lib/samagotchi/web/public/turn_model.js +11 -3
  115. data/lib/samagotchi/web/public/turn_view.js +76 -20
  116. data/lib/samagotchi/web/qr.rb +40 -0
  117. data/lib/samagotchi/web/server.rb +101 -11
  118. data/lib/samagotchi/web/token.rb +97 -0
  119. data/lib/samagotchi/worker.rb +5 -4
  120. metadata +38 -3
  121. data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
@@ -17,16 +17,19 @@ 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";
28
30
  import { routeChunk } from "./chunk_router.js";
29
31
  import { extractCtxPct, savedCtxPct, cardCtxText } from "./ctx.js";
32
+ import { diffHtml } from "./diff_view.js";
30
33
  import { approvalAllowed, approvalView, isApproval, nextCardAction, resultText, summaryText } from "./question_card.js";
31
34
  import { cardClass, cardInnerHtml, cardPlace, splitSnapshotCards, turnNoticePlace } from "./card.js";
32
35
  import { gripFloor, promptHeight } from "./composer_size.js";
@@ -36,12 +39,12 @@ import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js
36
39
  import { shouldFollowScroll, keepFollowing } from "./scroll.js";
37
40
  import { stripColumns, stripShowParts } from "./strip.js";
38
41
  import { applySessionEvent, listedSessions, sortedByUpdated, withChildrenAfterParents } from "./sessions_list.js";
39
- import { NOTIFY_CHANNEL, attentionText, createNotifyGate, initialAttentionState, trackAttention } from "./notify.js";
42
+ import { NOTIFY_CHANNEL, attentionText, createNotifyGate, initialAttentionState, notifyState as bellState, trackAttention } from "./notify.js";
40
43
  import {
41
44
  appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
42
45
  } from "./timing.js";
43
46
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
44
- import { clientLabel, commandView, continueLine, emptyRetryLine, hookNoticeLabel, isCommandLine, isOwn, keepEarlyRestore, newClientId, promptOps, reminderText, restoreAction, restoreOnAck, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
47
+ import { clientLabel, commandView, continueLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isCommandLine, isOwn, keepEarlyRestore, newClientId, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
45
48
 
46
49
  const $ = (s) => document.querySelector(s);
47
50
  const topStripEl = $("#topStrip");
@@ -57,7 +60,6 @@ const promptEl = $("#prompt");
57
60
  const actionBtn = $("#actionBtn");
58
61
  const cancelBtn = $("#cancelBtn");
59
62
  const modelPickEl = $("#modelPick");
60
- const modelSelectEl = $("#modelSelect");
61
63
  const composeHintEl = $("#composeHint");
62
64
 
63
65
  let selected = null;
@@ -71,18 +73,32 @@ let resyncsInFlight = 0;
71
73
  let resyncAgain = false;
72
74
  let allSessions = [];
73
75
  let seenContent = new Set();
74
- // The per-turn view (web.turn_view, or ?view=turn|chat): one block per turn
75
- // with its steps (turn_view.js), instead of a row of bubbles (chat_view.js).
76
- 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";
77
82
  // The running turn's DOM: the streaming bubbles, the thinking block and the
78
- // activity panel, or the turn block.
79
- const turnDom = (turnView ? createTurnView : createChatView)({
83
+ // activity panel, the turn block, or the stage (the turn block in its cloud).
84
+ const viewArgs = {
80
85
  historyEl,
81
86
  appendToHistory: (el) => appendToHistory(el),
82
87
  isNearBottom: () => isNearBottom(),
83
88
  sawText: (k) => seenContent.add(k),
84
89
  sessionId: () => selected,
85
- });
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
+ }
86
102
  let currentFirstPreview = "";
87
103
  let currentUsedMemories = [];
88
104
  // The session's --memory and --mute lists (from the session card; shown in
@@ -104,6 +120,10 @@ let currentOwner = null;
104
120
  let currentTiming = normalizeTiming();
105
121
  let turnRunning = false;
106
122
  let liveTurnTimingEl = null;
123
+ // What the running turn waits on, after its live timing line: a provider
124
+ // retry (generation_retrying) or plugins' setup (plugin_init_wait); "" when
125
+ // it streams again.
126
+ let liveTurnNote = "";
107
127
  let timingInterval = null;
108
128
  // Live ask_user_question card, keyed by pending question id. Re-rendered from
109
129
  // pending_question on session select so a reload mid-question restores it.
@@ -477,6 +497,7 @@ const SEND_HINT = "Enter to send · Shift+Enter for a newline";
477
497
  // its next step (steering). Cancel is its own button then.
478
498
  function updateComposerMode() {
479
499
  document.body.classList.toggle("has-session", !!selected);
500
+ if (selected) modelPicker.close();
480
501
  const running = turnRunning && !!selected;
481
502
  cancelBtn.style.display = running ? "" : "none";
482
503
  if (running) {
@@ -824,7 +845,7 @@ function applyFinalMarkdown(messages) {
824
845
  );
825
846
  if (!message) return;
826
847
 
827
- const bubble = [...historyEl.querySelectorAll(".bubble.output")]
848
+ const bubble = findInTurnRoots(".bubble.output")
828
849
  .reverse()
829
850
  .find((item) => showsAnswer(item, message, normalize));
830
851
  if (!bubble) return;
@@ -836,7 +857,7 @@ function applyFinalMarkdown(messages) {
836
857
 
837
858
  function seedSeenFromDom() {
838
859
  seenContent = new Set();
839
- historyEl.querySelectorAll(".bubble").forEach((b) => {
860
+ findInTurnRoots(".bubble").forEach((b) => {
840
861
  const k = normalize(b.textContent);
841
862
  if (k) seenContent.add(k);
842
863
  });
@@ -845,8 +866,13 @@ function seedSeenFromDom() {
845
866
  // Sticky-follow scroll: only auto-scroll to the bottom while the user is
846
867
  // already near it; never yank them back down mid-read. Capture the decision
847
868
  // BEFORE any DOM mutation that grows the content (new chunk / row / thinking
848
- // 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.
849
871
  function isNearBottom() {
872
+ return !turnDom.holds?.() && atHistoryEnd();
873
+ }
874
+
875
+ function atHistoryEnd() {
850
876
  const { scrollHeight, scrollTop, clientHeight } = historyEl;
851
877
  return shouldFollowScroll({ scrollHeight, scrollTop, clientHeight });
852
878
  }
@@ -870,7 +896,7 @@ function addEndBubble(text) {
870
896
  div.className = "bubble cancel";
871
897
  div.textContent = text;
872
898
  appendToHistory(div);
873
- historyEl.scrollTop = historyEl.scrollHeight;
899
+ if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
874
900
  }
875
901
 
876
902
  // Everything a turn's end (completed, canceled, failed) closes: then its
@@ -883,7 +909,7 @@ function endTurnView(kind, endText = null) {
883
909
  if (endText) addEndBubble(endText);
884
910
  if (kept.length) {
885
911
  kept.forEach((el) => appendToHistory(el));
886
- historyEl.scrollTop = historyEl.scrollHeight;
912
+ if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
887
913
  }
888
914
  // The question dies with its turn (the worker stopped waiting).
889
915
  if (pendingQuestion) resolveQuestionCard(pendingQuestion.id, { cancelled: true, reason: "turn ended" });
@@ -939,25 +965,29 @@ function setBubbleState(bubble, state) {
939
965
 
940
966
  function bubbleById(enqueuedId) {
941
967
  if (!enqueuedId) return null;
942
- return [...historyEl.querySelectorAll(".bubble.user[data-enqueued-id]")].find(
968
+ return findInTurnRoots(".bubble.user[data-enqueued-id]").find(
943
969
  (el) => el.dataset.enqueuedId === enqueuedId,
944
970
  ) || null;
945
971
  }
946
972
 
947
973
  // This tab's oldest untagged echo, preferring one with the same text.
948
974
  function untaggedOwnBubble(prompt) {
949
- const echoes = [...historyEl.querySelectorAll(".bubble.user[data-own]:not([data-enqueued-id])")];
975
+ const echoes = findInTurnRoots(".bubble.user[data-own]:not([data-enqueued-id])");
950
976
  const text = normalize(prompt || "");
951
977
  return echoes.find((el) => el.dataset.userContent === text) || echoes[0] || null;
952
978
  }
953
979
 
980
+ // Returns the bubble of the turn a turn_started starts (the stage takes it
981
+ // into its prompt line), else null.
954
982
  function applyPromptOps(event) {
955
983
  const ops = promptOps(event, { myId: clientId, known: (id) => !!bubbleById(id), unmatchedMerge });
956
984
  if (event.type === "input_merged") unmatchedMerge = false;
957
985
  if (event.type === "pending_input_merged") unmatchedMerge = false;
986
+ let started = null;
958
987
  for (const op of ops) {
959
988
  if (op.op === "add") {
960
- 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;
961
991
  } else if (op.op === "tag") {
962
992
  if (bubbleById(op.enqueuedId)) continue;
963
993
  const echo = untaggedOwnBubble(op.prompt);
@@ -968,10 +998,11 @@ function applyPromptOps(event) {
968
998
  const bubble = bubbleById(op.enqueuedId) || untaggedOwnBubble(op.prompt);
969
999
  // A resync (a new worker woken by this page's send) re-rendered the
970
1000
  // history and took the echo with it: draw the prompt again.
971
- 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 });
972
1002
  else {
973
1003
  if (op.enqueuedId) bubble.dataset.enqueuedId = op.enqueuedId;
974
1004
  setBubbleState(bubble, null);
1005
+ started = bubble;
975
1006
  }
976
1007
  } else if (op.op === "steer") {
977
1008
  const bubble = bubbleById(op.enqueuedId);
@@ -979,6 +1010,7 @@ function applyPromptOps(event) {
979
1010
  else unmatchedMerge = true;
980
1011
  }
981
1012
  }
1013
+ return started;
982
1014
  }
983
1015
 
984
1016
  // ── Idle recap ─────────────────────────────────────────────────────────────
@@ -1001,8 +1033,9 @@ function showRecap(text, turnsSince = 0) {
1001
1033
  details.appendChild(summary);
1002
1034
  details.appendChild(body);
1003
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.
1004
1037
  const before = recapPlace([...historyEl.querySelectorAll(":scope > .bubble.user")], turnsSince);
1005
- keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendToHistory(details)));
1038
+ keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurnTimingEl)));
1006
1039
  }
1007
1040
 
1008
1041
  // A turn started: the recap describes the chat before it.
@@ -1062,7 +1095,7 @@ function showHookNotice(data, { before = null } = {}) {
1062
1095
 
1063
1096
  function cardElement(id) {
1064
1097
  if (!id) return null;
1065
- 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;
1066
1099
  }
1067
1100
 
1068
1101
  function drawCard(el, card) {
@@ -1170,7 +1203,7 @@ function refreshCardBodies() {
1170
1203
  }, 150);
1171
1204
  }
1172
1205
 
1173
- historyEl.addEventListener("click", (e) => {
1206
+ function onCardAction(e) {
1174
1207
  const button = e.target.closest(".plugin-card .card-action");
1175
1208
  if (!button || !selected) return;
1176
1209
  const card = button.closest(".plugin-card");
@@ -1187,7 +1220,8 @@ historyEl.addEventListener("click", (e) => {
1187
1220
  err.classList.remove("hidden");
1188
1221
  })
1189
1222
  .finally(() => { button.disabled = false; });
1190
- });
1223
+ }
1224
+ turnRoots().forEach((root) => root.addEventListener("click", onCardAction));
1191
1225
 
1192
1226
  function removeRecap() {
1193
1227
  historyEl.querySelectorAll(".bubble.recap").forEach((el) => el.remove());
@@ -1272,30 +1306,43 @@ function startLiveTurnTiming(startedAt = new Date().toISOString()) {
1272
1306
  currentTiming.activeTurn = { started_at: startedAt };
1273
1307
  // The turn records hold the finished turns, so this one comes next.
1274
1308
  liveTurnNumber = currentTiming.turnRecords.length + 1;
1309
+ liveTurnNote = "";
1275
1310
  liveTurnTimingEl = document.createElement("div");
1276
1311
  liveTurnTimingEl.className = "turn-timing live";
1277
- 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);
1278
1315
  updateLiveTiming();
1279
1316
  timingInterval = setInterval(updateLiveTiming, 1000);
1280
1317
  }
1281
1318
 
1319
+ // The stage takes a running turn's rows (its extras); else the history.
1282
1320
  function appendToHistory(el) {
1321
+ if (turnDom.place?.(el)) return;
1283
1322
  appendAboveLiveTiming(historyEl, el, liveTurnTimingEl);
1284
1323
  }
1285
1324
 
1286
1325
  function updateLiveTiming() {
1287
1326
  const duration = elapsedSince(currentTiming.activeTurn?.started_at);
1288
1327
  if (liveTurnTimingEl && duration !== null) {
1289
- liveTurnTimingEl.textContent = turnTimingText(liveTurnNumber, duration, { running: true });
1328
+ liveTurnTimingEl.textContent = turnTimingText(liveTurnNumber, duration, { running: true }) + (liveTurnNote ? ` · ${liveTurnNote}` : "");
1290
1329
  }
1291
1330
  updateInfoBar();
1292
1331
  }
1293
1332
 
1333
+ // Set (or clear, "") what the running turn waits on.
1334
+ function setLiveTurnNote(text) {
1335
+ if (liveTurnNote === text) return;
1336
+ liveTurnNote = text;
1337
+ if (currentTiming.activeTurn) updateLiveTiming();
1338
+ }
1339
+
1294
1340
  function finishLiveTurnTiming(record = null) {
1295
1341
  const duration = Number.isFinite(Number(record?.duration_ms))
1296
1342
  ? Number(record.duration_ms)
1297
1343
  : elapsedSince(currentTiming.activeTurn?.started_at);
1298
1344
  if (liveTurnTimingEl && duration !== null) {
1345
+ liveTurnNote = "";
1299
1346
  liveTurnTimingEl.classList.remove("live");
1300
1347
  const at = record ? currentTiming.turnRecords.indexOf(record) : -1;
1301
1348
  liveTurnTimingEl.textContent = turnTimingText(at >= 0 ? at + 1 : liveTurnNumber, duration);
@@ -1322,15 +1369,12 @@ function updateFirstPeek() {
1322
1369
  }
1323
1370
  }
1324
1371
 
1325
- function addUsedMemory(name) {
1326
- const v = String(name||"").trim();
1327
- if (!v || currentUsedMemories.includes(v)) return;
1328
- currentUsedMemories.push(v);
1372
+ function setUsedMemories(names) {
1373
+ currentUsedMemories = names.slice();
1329
1374
  updateInfoBar();
1330
1375
  const sess = allSessions.find(s=>s.id===selected);
1331
1376
  if (sess) {
1332
- sess.used_memory_names = sess.used_memory_names || [];
1333
- if (!sess.used_memory_names.includes(v)) sess.used_memory_names.push(v);
1377
+ sess.used_memory_names = currentUsedMemories.slice();
1334
1378
  render();
1335
1379
  document.querySelectorAll(".card").forEach(el=>el.classList.toggle("active", el.dataset.id===selected));
1336
1380
  }
@@ -1394,6 +1438,7 @@ const streamHandlers = {
1394
1438
  // block (see chunk_router.js). Raw `content` is only used as a fallback
1395
1439
  // for pre-Phase-2 / non-enriched servers.
1396
1440
  const { text, thinking } = routeChunk(data);
1441
+ setLiveTurnNote("");
1397
1442
  turnDom.chunk({ text, thinking, iteration: data.iteration ?? null });
1398
1443
  const pct = extractCtxPct(data, currentCtxWindow);
1399
1444
  if (pct !== null && !Number.isNaN(pct)) {
@@ -1404,9 +1449,11 @@ const streamHandlers = {
1404
1449
  },
1405
1450
  generation_started: (data) => {
1406
1451
  if (data?.context_window_tokens) currentCtxWindow = data.context_window_tokens;
1452
+ setLiveTurnNote("");
1407
1453
  turnDom.generationStarted(data || {});
1408
1454
  },
1409
1455
  generation_completed: (data) => {
1456
+ setLiveTurnNote("");
1410
1457
  if (data?.served_model) {
1411
1458
  currentServed = [data.served_model, data.requested_model];
1412
1459
  updateInfoBar();
@@ -1424,13 +1471,16 @@ const streamHandlers = {
1424
1471
  },
1425
1472
  turn_enqueued: (data) => applyPromptOps(data),
1426
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 });
1427
1477
  markRecapStale();
1428
- applyPromptOps(data);
1478
+ const promptBubble = applyPromptOps(data);
1429
1479
  setTurnRunning(true);
1430
1480
  setLiveStatus("running");
1431
1481
  clearTimingInterval();
1432
1482
  liveTurnTimingEl = null;
1433
- turnDom.turnStarted(data);
1483
+ turnDom.turnStarted({ ...data, promptBubble });
1434
1484
  startLiveTurnTiming(data.started_at);
1435
1485
  }),
1436
1486
  turn_completed: async (data) => {
@@ -1530,24 +1580,11 @@ const streamHandlers = {
1530
1580
  context_added: (data) => addNoteBubble({ label: data.label, content: data.text || "" }),
1531
1581
  continue_offered: (data) => renderContinueCard(data),
1532
1582
  continue_resolved: (data) => resolveContinueCard(data),
1533
- tool_call_started: (data) => {
1534
- turnDom.toolStarted(data);
1535
- const call = data.call || {};
1536
- let name = null;
1537
- if (call.name === "memory_read" && call.content) {
1538
- name = String(call.content).split(",")[0].trim();
1539
- if (name) name = name.replace(/\.md$/,"").split("/").pop();
1540
- } else if (call.name === "read" && call.content && /memories\/.+\.md/.test(call.content)) {
1541
- name = String(call.content).split("/").pop().replace(/\.md$/,"");
1542
- }
1543
- if (name) addUsedMemory(name);
1544
- },
1583
+ tool_call_started: (data) => turnDom.toolStarted(data),
1545
1584
  tool_call_completed: (data) => turnDom.toolCompleted(data),
1585
+ // The engine's list, after every memory read and at a turn's end.
1546
1586
  used_memories_updated: (data) => {
1547
- if (Array.isArray(data.used_memory_names)) {
1548
- currentUsedMemories = data.used_memory_names.slice();
1549
- updateInfoBar();
1550
- }
1587
+ if (Array.isArray(data.used_memory_names)) setUsedMemories(data.used_memory_names);
1551
1588
  },
1552
1589
  context_status: (data) => {
1553
1590
  const pct = extractCtxPct(data, currentCtxWindow);
@@ -1568,13 +1605,21 @@ const streamHandlers = {
1568
1605
  resolveQuestionCard(data.id, { cancelled: true, reason: data.reason });
1569
1606
  },
1570
1607
  // One collected just as a turn started describes the chat before it.
1571
- 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
+ },
1572
1613
  guardrail_warning: (data) => showGuardrailWarning(data.message, data.label || "guardrails"),
1573
1614
  // In the turn view, a notice during a turn is a row of its current step.
1574
1615
  hook_notice: (data) => { if (!turnDom.notice(data)) showHookNotice(data); },
1575
1616
  // The loop asks again after an empty answer: a row of the empty step (the
1576
1617
  // chat view shows nothing; after a reload neither does the turn view).
1577
1618
  empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
1619
+ // The provider is asked again after an error, or the turn waits for
1620
+ // plugins' setup: a note on the live timing line until it streams.
1621
+ generation_retrying: (data) => setLiveTurnNote(retryStatusLine(data)),
1622
+ plugin_init_wait: (data) => setLiveTurnNote(initWaitLine(data)),
1578
1623
  plugin_init_started: (data) => onInitEvent("plugin_init_started", data),
1579
1624
  plugin_init_finished: (data) => onInitEvent("plugin_init_finished", data),
1580
1625
  // A plugin's card, or a new version of one (the same id).
@@ -1752,6 +1797,13 @@ function renderApprovalDetails(view) {
1752
1797
  what.appendChild(code);
1753
1798
  box.appendChild(what);
1754
1799
  }
1800
+ // An edit/write: the diff it would make, under the path.
1801
+ if (view.preview) {
1802
+ const diff = document.createElement("div");
1803
+ diff.className = "approval-diff";
1804
+ diff.innerHTML = diffHtml(view.preview);
1805
+ box.appendChild(diff);
1806
+ }
1755
1807
  for (const [cls, label, text] of [["approval-where", "in", view.where], ["approval-why", "why", view.why]]) {
1756
1808
  if (!text) continue;
1757
1809
  const row = document.createElement("div");
@@ -1886,9 +1938,12 @@ async function handleSendTurn() {
1886
1938
  }
1887
1939
  const hint = historyEl.querySelector(".hint");
1888
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 });
1889
1944
  const sending = chips.slice();
1890
1945
  const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending });
1891
- historyEl.scrollTop = historyEl.scrollHeight;
1946
+ if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
1892
1947
  if (!currentFirstPreview || currentFirstPreview==="—") {
1893
1948
  currentFirstPreview = normalize(prompt).slice(0,80) + (normalize(prompt).length>80?"…":"");
1894
1949
  updateInfoBar();
@@ -1996,7 +2051,7 @@ function drawCommandBubble(div, { label, line, text, busy = false, failed = fals
1996
2051
 
1997
2052
  function commandBubble(commandId) {
1998
2053
  if (!commandId) return null;
1999
- 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;
2000
2055
  }
2001
2056
 
2002
2057
  function addNoteBubble(note) {
@@ -2119,7 +2174,17 @@ $("#newBtn").addEventListener("click", () => {
2119
2174
  // preselected while it is still offered (else the server's default). Hidden
2120
2175
  // until the list is in, and in a session (its model is in the info bar). A
2121
2176
  // host that failed is the picker's tooltip; a route that fails leaves the
2122
- // 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
+ });
2123
2188
  async function loadModels() {
2124
2189
  let payload;
2125
2190
  try {
@@ -2127,26 +2192,16 @@ async function loadModels() {
2127
2192
  } catch (_) {
2128
2193
  return;
2129
2194
  }
2130
- const names = modelNames(payload);
2131
- if (!names.length) return;
2132
2195
  let stored = null;
2133
2196
  try { stored = localStorage.getItem(MODEL_KEY); } catch (_) {}
2134
- const chosen = pickModel(names, payload.default, stored || modelSelectEl.value);
2135
- modelSelectEl.innerHTML = names
2136
- .map((n) => `<option value="${escapeHtml(n)}"${n === chosen ? " selected" : ""}>${escapeHtml(n)}</option>`)
2137
- .join("");
2197
+ if (!modelPicker.setModels(payload, stored)) return;
2138
2198
  modelPickEl.title = payload.warning ? `The model the new chat starts on · ${payload.warning}` : "The model the new chat starts on";
2139
- modelPickEl.dataset.value = chosen; // sizes the select to the chosen name (CSS)
2140
2199
  modelPickEl.hidden = false;
2141
2200
  }
2142
- modelSelectEl.addEventListener("change", () => {
2143
- modelPickEl.dataset.value = modelSelectEl.value;
2144
- try { localStorage.setItem(MODEL_KEY, modelSelectEl.value); } catch (_) {}
2145
- });
2146
2201
  // The picker's choice for a create request; nothing (the server's default)
2147
2202
  // while the list is not in.
2148
2203
  function chosenModel() {
2149
- return modelPickEl.hidden ? undefined : modelSelectEl.value || undefined;
2204
+ return modelPickEl.hidden ? undefined : modelPicker.chosen() || undefined;
2150
2205
  }
2151
2206
  actionBtn.addEventListener("click", () => {
2152
2207
  if (!selected) handleCreate();
@@ -2238,7 +2293,8 @@ fitPrompt();
2238
2293
  const dockEl = $("#dock");
2239
2294
  const centerEl = $("#center");
2240
2295
  new ResizeObserver(() => {
2241
- const follow = isNearBottom();
2296
+ // The stage's height too: a reader at the end stays there.
2297
+ const follow = atHistoryEnd();
2242
2298
  centerEl.style.setProperty("--dock-h", `${dockEl.offsetHeight}px`);
2243
2299
  if (follow) historyEl.scrollTop = historyEl.scrollHeight;
2244
2300
  }).observe(dockEl);
@@ -2261,12 +2317,12 @@ composerEl.addEventListener("drop", (e) => {
2261
2317
  e.preventDefault();
2262
2318
  addImageFiles(files);
2263
2319
  });
2264
- historyEl.addEventListener("click", (e) => {
2320
+ turnRoots().forEach((root) => root.addEventListener("click", (e) => {
2265
2321
  const img = e.target.closest("img.thumb");
2266
2322
  if (!img) return;
2267
2323
  lightboxEl.querySelector("img").src = img.src;
2268
2324
  lightboxEl.hidden = false;
2269
- });
2325
+ }));
2270
2326
  lightboxEl.addEventListener("click", () => { lightboxEl.hidden = true; });
2271
2327
  document.addEventListener("keydown", (e) => {
2272
2328
  if (e.key === "Escape" && !lightboxEl.hidden) lightboxEl.hidden = true;
@@ -2499,7 +2555,7 @@ function updateAnnotate() {
2499
2555
  const sel = window.getSelection();
2500
2556
  if (!selected || currentOwner === "tui" || !sel || sel.isCollapsed || !sel.rangeCount) return hideAnnotate();
2501
2557
  const range = sel.getRangeAt(0);
2502
- if (!historyEl.contains(range.startContainer)) return hideAnnotate();
2558
+ if (!turnRoots().some((root) => root.contains(range.startContainer))) return hideAnnotate();
2503
2559
  const source = annotationSource(range.startContainer);
2504
2560
  // A running turn's narration box shows complete sentences and its thinking
2505
2561
  // is appended by delta; a selection in either is refused while the step is
@@ -2545,8 +2601,9 @@ function scheduleAnnotate() {
2545
2601
  }
2546
2602
  document.addEventListener("selectionchange", scheduleAnnotate);
2547
2603
  historyEl.addEventListener("scroll", scheduleAnnotate);
2604
+ turnDom.scrollEl?.addEventListener("scroll", scheduleAnnotate, true);
2548
2605
  window.addEventListener("resize", scheduleAnnotate);
2549
- installCopy(historyEl);
2606
+ turnRoots().forEach((root) => installCopy(root));
2550
2607
  // Keep the selection when a button is pressed.
2551
2608
  annotateBar.addEventListener("mousedown", (e) => e.preventDefault());
2552
2609
  annotateBar.addEventListener("click", (e) => {
@@ -2583,19 +2640,19 @@ function tabInFront() {
2583
2640
  return document.visibilityState === "visible" && document.hasFocus();
2584
2641
  }
2585
2642
 
2586
- function notifySupported() {
2587
- return typeof globalThis.Notification === "function";
2588
- }
2589
-
2590
2643
  function notifyWanted() {
2591
2644
  try { return localStorage.getItem(NOTIFY_KEY) === "1"; } catch (_) { return false; }
2592
2645
  }
2593
2646
 
2594
- // "on" | "off" | "denied" | "unsupported"
2647
+ // "on" | "off" | "denied" | "unsupported" (notify.js)
2595
2648
  function notifyState() {
2596
- if (!notifySupported()) return "unsupported";
2597
- if (Notification.permission === "denied") return "denied";
2598
- 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
+ });
2599
2656
  }
2600
2657
 
2601
2658
  const NOTIFY_TITLES = {
@@ -11,6 +11,7 @@
11
11
  import { newActivity, addStarted, addCompleted, finalizeActivity, activityCount, toolName } from "./activity.js";
12
12
  import { copySourceAttr } from "./copy.js";
13
13
  import { escapeHtml, leadTrimmed, messageBodyHtml, noteHtml, normalize, steerRowHtml } from "./format.js";
14
+ import { activityDiffHtml } from "./diff_view.js";
14
15
  import { thumbsHtml } from "./images.js";
15
16
  import { shouldFollowScroll } from "./scroll.js";
16
17
  import { tallyText } from "./tally.js";
@@ -348,7 +349,11 @@ export function fillActivityRow(el, row, sessionId) {
348
349
  const toolEl = el.querySelector(".activity-tool");
349
350
  if (toolEl) toolEl.textContent = toolName(row);
350
351
  const paramsEl = el.querySelector(".activity-params");
351
- 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
+ }
352
357
  const outEl = el.querySelector(".activity-output");
353
358
  if (outEl) {
354
359
  if (row.status === "running") {
@@ -366,4 +371,6 @@ export function fillActivityRow(el, row, sessionId) {
366
371
  // The images a tool returned (the chat view's reloaded history has no tool
367
372
  // rows; the turn view's has, turn_view.js reloadRowHtml).
368
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));
369
376
  }
@@ -29,6 +29,8 @@ const DEFAULT_EVENT_TYPES = Object.freeze([
29
29
  "guardrail_warning",
30
30
  "hook_notice",
31
31
  "empty_answer_retry",
32
+ "generation_retrying",
33
+ "plugin_init_wait",
32
34
  "card",
33
35
  "plugin_init_started",
34
36
  "plugin_init_finished",
@@ -0,0 +1,58 @@
1
+ // What chi's edit/write tools change, as HTML, apart from the DOM. One
2
+ // renderer for both places: the approval card (the dry run, which can also
3
+ // say the edit would fail, or that the file isn't shown) and a tool row's
4
+ // collapsed "diff +3 −1" (what the call really changed). A diff is the
5
+ // server's {text, added, removed, truncated, new_file} (EditPreview).
6
+
7
+ import { escapeHtml } from "./format.js";
8
+
9
+ // More lines than this fold behind a "show all N lines" toggle.
10
+ export const DIFF_FOLD_LINES = 20;
11
+
12
+ const MINUS = "−";
13
+
14
+ // "+3 −1"
15
+ export function diffCounts(diff) {
16
+ return `+${Number(diff?.added) || 0} ${MINUS}${Number(diff?.removed) || 0}`;
17
+ }
18
+
19
+ // A row's summary line: "diff +3 −1", "new file +12".
20
+ export function diffSummary(diff) {
21
+ return diff?.new_file ? `new file +${Number(diff.added) || 0}` : `diff ${diffCounts(diff)}`;
22
+ }
23
+
24
+ export function diffLineClass(line) {
25
+ if (line.startsWith("@@")) return "hunk";
26
+ if (line.startsWith("+")) return "add";
27
+ if (line.startsWith("-")) return "del";
28
+ if (line.startsWith("\\") || line.startsWith("… ")) return "meta";
29
+ return "ctx";
30
+ }
31
+
32
+ function linesHtml(lines) {
33
+ return lines.map((line) => `<span class="diff-line diff-${diffLineClass(line)}">${escapeHtml(line) || " "}</span>`).join("");
34
+ }
35
+
36
+ // @param diff an EditPreview: a diff, or {error} / {skipped}
37
+ // @return {string} HTML; "" for none
38
+ export function diffHtml(diff, { fold = DIFF_FOLD_LINES } = {}) {
39
+ if (!diff || typeof diff !== "object") return "";
40
+ if (diff.error) return `<div class="diff-note warn">this edit would fail: ${escapeHtml(diff.error)}</div>`;
41
+ if (diff.skipped) return `<div class="diff-note">diff not shown: ${escapeHtml(diff.skipped)}</div>`;
42
+ const text = String(diff.text ?? "");
43
+ if (!text) return `<div class="diff-note">no change</div>`;
44
+ const lines = text.split("\n");
45
+ const head = diff.new_file ? `<div class="diff-note">new file</div>` : "";
46
+ const shown = `<pre class="diff"><code>${linesHtml(lines.slice(0, fold))}</code></pre>`;
47
+ if (lines.length <= fold) return head + shown;
48
+ // The rest opens under the first lines; the toggle hides once open (CSS).
49
+ const rest = `<details class="diff-more"><summary>show all ${lines.length} lines</summary>` +
50
+ `<pre class="diff"><code>${linesHtml(lines.slice(fold))}</code></pre></details>`;
51
+ return head + shown + rest;
52
+ }
53
+
54
+ // A tool row's collapsed diff (closed by default).
55
+ export function activityDiffHtml(diff) {
56
+ if (!diff || typeof diff !== "object" || diff.error || diff.skipped) return "";
57
+ return `<details class="activity-diff"><summary>${escapeHtml(diffSummary(diff))}</summary>${diffHtml(diff)}</details>`;
58
+ }