samagotchi 0.62.0 → 0.64.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -1
  3. data/README.md +4 -1
  4. data/bin/chi +3 -2
  5. data/docs/architecture.md +12 -2
  6. data/docs/cli.md +20 -9
  7. data/docs/configuration.md +16 -9
  8. data/docs/context.md +13 -3
  9. data/docs/hooks.md +6 -3
  10. data/docs/internals/engine.md +10 -3
  11. data/docs/memory.md +1 -1
  12. data/docs/plugins.md +96 -2
  13. data/docs/releasing.md +1 -1
  14. data/docs/sessions.md +11 -8
  15. data/docs/sub-agent.md +27 -4
  16. data/docs/testing.md +13 -4
  17. data/lib/samagotchi/answer_command.rb +5 -1
  18. data/lib/samagotchi/answer_refs.rb +18 -26
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  20. data/lib/samagotchi/bridge.rb +41 -5
  21. data/lib/samagotchi/bundles/claude-peer/manifest.yml +22 -0
  22. data/lib/samagotchi/bundles/claude-peer/plugin.rb +406 -0
  23. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +104 -0
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +66 -0
  25. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +60 -0
  26. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  27. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
  29. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
  31. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  32. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  33. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  34. data/lib/samagotchi/child_reports.rb +7 -2
  35. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  36. data/lib/samagotchi/composer_route.rb +90 -0
  37. data/lib/samagotchi/config.rb +6 -3
  38. data/lib/samagotchi/context_absorber.rb +25 -6
  39. data/lib/samagotchi/continue_offer.rb +17 -3
  40. data/lib/samagotchi/delivery.rb +13 -8
  41. data/lib/samagotchi/engine.rb +2 -0
  42. data/lib/samagotchi/host_config.rb +6 -6
  43. data/lib/samagotchi/host_model.rb +2 -2
  44. data/lib/samagotchi/idle_recap.rb +2 -3
  45. data/lib/samagotchi/image_store.rb +8 -0
  46. data/lib/samagotchi/interrupted_turn.rb +7 -3
  47. data/lib/samagotchi/last_turn.rb +5 -2
  48. data/lib/samagotchi/pending_input_queue.rb +10 -3
  49. data/lib/samagotchi/plugin/attached_context.rb +7 -4
  50. data/lib/samagotchi/plugin/sessions.rb +27 -5
  51. data/lib/samagotchi/recap_setup.rb +3 -3
  52. data/lib/samagotchi/reply_wait.rb +171 -6
  53. data/lib/samagotchi/row_refs.rb +3 -4
  54. data/lib/samagotchi/send_command.rb +63 -19
  55. data/lib/samagotchi/session.rb +12 -0
  56. data/lib/samagotchi/session_commands.rb +1 -1
  57. data/lib/samagotchi/session_inbox.rb +63 -7
  58. data/lib/samagotchi/session_manager.rb +49 -20
  59. data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +73 -35
  61. data/lib/samagotchi/terminal_ui/formatting.rb +3 -19
  62. data/lib/samagotchi/terminal_ui.rb +103 -68
  63. data/lib/samagotchi/tool_activity.rb +5 -5
  64. data/lib/samagotchi/tools/delegate.rb +28 -1
  65. data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
  66. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  67. data/lib/samagotchi/tools/execute.rb +33 -8
  68. data/lib/samagotchi/turn_checkpoint.rb +9 -3
  69. data/lib/samagotchi/version.rb +1 -1
  70. data/lib/samagotchi/web/app.rb +6 -4
  71. data/lib/samagotchi/web/public/activity.js +2 -2
  72. data/lib/samagotchi/web/public/app.js +53 -91
  73. data/lib/samagotchi/web/public/card.js +73 -23
  74. data/lib/samagotchi/web/public/chains.js +6 -4
  75. data/lib/samagotchi/web/public/command_view.js +39 -35
  76. data/lib/samagotchi/web/public/commands.js +8 -8
  77. data/lib/samagotchi/web/public/composer.js +3 -3
  78. data/lib/samagotchi/web/public/copy.js +7 -15
  79. data/lib/samagotchi/web/public/diff_view.js +20 -23
  80. data/lib/samagotchi/web/public/format.js +0 -48
  81. data/lib/samagotchi/web/public/images.js +12 -16
  82. data/lib/samagotchi/web/public/question_cards.js +3 -2
  83. data/lib/samagotchi/web/public/read_view.js +10 -9
  84. data/lib/samagotchi/web/public/scope.js +23 -0
  85. data/lib/samagotchi/web/public/timing.js +2 -8
  86. data/lib/samagotchi/web/public/turn_html.js +119 -59
  87. data/lib/samagotchi/web/public/turn_model.js +13 -3
  88. data/lib/samagotchi/web/public/turn_view.js +73 -116
  89. data/lib/samagotchi/web/public/vendor/directive.js +8 -0
  90. data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
  91. data/lib/samagotchi/worker.rb +53 -35
  92. data/lib/samagotchi/worker_inbound.rb +12 -11
  93. metadata +9 -1
@@ -921,7 +921,7 @@ module Samagotchi
921
921
  state = nil
922
922
  session_json = { id: id, status: displayed_status(session), used_memory_names: Array(session.used_memory_names),
923
923
  prompt_notes: session.prompt_notes.map(&:to_h) }
924
- messages = last_assistant_for_display(session.messages, turn_id: turn_id)
924
+ messages = last_assistant_for_display(session.messages, turn_id: turn_id, cwd: session.working_directory)
925
925
  end
926
926
  live_metrics = state && state["metrics"]
927
927
  # Without recent=1: a tab opened before this release (it replaces its
@@ -1923,7 +1923,7 @@ module Samagotchi
1923
1923
  message[:html] = @markdown_renderer.render(rendered) if !rendered.empty? && @markdown_renderer.available?
1924
1924
  # The refs after_turn hooks attached (AnswerRefs), checked again:
1925
1925
  # a stored file is never trusted. Never raises.
1926
- refs = Samagotchi::AnswerRefs.for_display(m[:display_refs] || m["display_refs"])
1926
+ refs = Samagotchi::AnswerRefs.for_display(m[:display_refs] || m["display_refs"], cwd: cwd)
1927
1927
  message[:display_refs] = refs unless refs.empty?
1928
1928
  end
1929
1929
  message[:parts] = did if did
@@ -1968,8 +1968,10 @@ module Samagotchi
1968
1968
 
1969
1969
  # The last message messages_for_display shows as an answer
1970
1970
  # (AnswerTail), as a list of at most one: only that one is rendered.
1971
- def last_assistant_for_display(msgs, turn_id: nil)
1972
- messages_for_display([AnswerTail.find(msgs, turn_id: turn_id)].compact)
1971
+ # +cwd+: the session's folder, for its file refs (nil from a live
1972
+ # worker's tail: its refs were held to the folder when they were added).
1973
+ def last_assistant_for_display(msgs, turn_id: nil, cwd: nil)
1974
+ messages_for_display([AnswerTail.find(msgs, turn_id: turn_id)].compact, cwd: cwd)
1973
1975
  end
1974
1976
 
1975
1977
  def parse_json(str)
@@ -140,10 +140,10 @@ export function addCompleted(model, event = {}, now = null) {
140
140
  // A grep that found nothing (status ok): the row says "no match".
141
141
  if (event.activity?.no_match) row.no_match = true;
142
142
  // The images the tool returned (a reload in the turn view draws them from
143
- // the saved parts, turn_html.js toolRowHtml).
143
+ // the saved parts, turn_html.js toolRowTemplate).
144
144
  if (event.images?.length) row.images = event.images;
145
145
  // What an edit/write changed in its file (a reload draws it from the
146
- // saved parts, turn_html.js toolRowHtml).
146
+ // saved parts, turn_html.js toolRowTemplate).
147
147
  if (event.diff) row.diff = event.diff;
148
148
  const duration = callDuration(row, event, now);
149
149
  if (duration !== null) row.duration_ms = duration;
@@ -36,12 +36,12 @@ import { createShownTexts } from "./shown_texts.js";
36
36
  import { MODEL_KEY } from "./model_pick.js";
37
37
  import { emptySessionState, sessionStateFrom } from "./session_state.js";
38
38
  import { createModelPicker } from "./model_picker.js";
39
- import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
40
- import { escapeHtml, noteHtml, normalize, steerRowHtml, firstPreview, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary, promptNotesChip } from "./format.js";
39
+ import { chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText } from "./images.js";
40
+ import { normalize, firstPreview, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary, promptNotesChip } from "./format.js";
41
41
  import { createTurnView, stepText, turnHistoryNodes } from "./turn_view.js";
42
- import { BADGES, emptyAnswerHtml, htmlElement, stateBadgeHtml, userBubbleHtml } from "./turn_html.js";
42
+ import { BADGES, elementOf, emptyAnswerTemplate, noteBubbleTemplate, stateBadgeTemplate, steerRowTemplate, userBubbleTemplate } from "./turn_html.js";
43
43
  import { createStageView, FOLD_MS } from "./stage_view.js";
44
- import { CONTINUE_OPENER, isOpener, chainDate, chainNeighbours, continueRefusal, continuedText, openChildrenHtml } from "./chains.js";
44
+ import { CONTINUE_OPENER, isOpener, chainDate, chainNeighbours, continueRefusal, continuedText, openChildrenTemplate } from "./chains.js";
45
45
  import { createTaskStopper } from "./task_stop.js";
46
46
  import { appendQuote } from "./annotations.js";
47
47
  import { parsePresets } from "./annotate_presets.js";
@@ -49,12 +49,13 @@ import { createAnnotateBar } from "./annotate_bar.js";
49
49
  import { copyText, installCopy } from "./copy.js";
50
50
  import { ctxBarText, ctxBarTip, lastSpeedText, speedText } from "./ctx.js";
51
51
  import { createQuestionCards } from "./question_cards.js";
52
- import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, splitSnapshotCards, turnNoticePlace } from "./card.js";
52
+ import { cardPlace, drawCard, isCard, isNoticeCard, refreshCardBody, splitSnapshotCards, turnNoticePlace } from "./card.js";
53
53
  import { PROMPT_LINE_PX, PROMPT_MIN_PX, gripFloor, promptHeight } from "./composer_size.js";
54
54
  import { createCommandList } from "./command_list.js";
55
55
  import { createPromptHistory } from "./prompt_history.js";
56
56
  import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
57
- import { allScopeHref, projectScopeHref, scopeDir } from "./scope.js";
57
+ import { scopeDir, scopeTemplate } from "./scope.js";
58
+ import { html, nothing, render } from "./vendor/lit-html.js";
58
59
  import { easeScrollTo, FOLLOW_MS, shouldFollowScroll, keepFollowing } from "./scroll.js";
59
60
  import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
60
61
  import { applySessionEvent, flattenFamilies, inTree, sortedByUpdated } from "./sessions_list.js";
@@ -94,14 +95,12 @@ const infoTextEl = $("#infoText");
94
95
  const contextChips = createContextChips({
95
96
  el: $("#contextChips"),
96
97
  api: { list: listContext, show: getContext, detach: detachContext, add: addContext },
97
- escapeHtml,
98
98
  toast: (text) => showToast(text),
99
99
  });
100
100
  // The open session's LLM context chip in the info bar, and its popover:
101
101
  // /llm-context runs in the session's worker.
102
102
  const llmContextControl = createLlmContextControl({
103
103
  el: infoTextEl,
104
- escapeHtml,
105
104
  summary: () => (selected ? current.llmContext : null),
106
105
  session: () => selected,
107
106
  running: () => liveTurn.running,
@@ -116,7 +115,6 @@ let newChatLlmContext = null;
116
115
  const newChatLlmSummary = () => (modelPickEl.hidden ? null : modelPicker.chosenRow()?.llm_context || null);
117
116
  const llmContextNewControl = createLlmContextControl({
118
117
  el: llmContextNewEl,
119
- escapeHtml,
120
118
  summary: newChatLlmSummary,
121
119
  kind: NEW_CHAT,
122
120
  choice: () => newChatLlmContext,
@@ -393,29 +391,11 @@ function followSelectedSession(sess) {
393
391
  // new chat starts on the start page.
394
392
  function renderScope() {
395
393
  const el = $("#scope");
396
- const allLink = `<a class="scope-all" href="${escapeHtml(allScopeHref(location.hash, scope.dir, location.search))}" title="Sessions of every project">all</a>`;
397
- if (scopeError) {
398
- el.innerHTML = `<span class="scope-name error" title="${escapeHtml(scopeError)}">folder not found</span>${allLink}`;
399
- } else if (scope.projectName) {
400
- el.innerHTML = `<span class="scope-name" title="Sessions of ${escapeHtml(scope.projectDir)}">${escapeHtml(scope.projectName)}</span>${allLink}`;
401
- } else {
402
- const where = scope.dir || scope.serverDir;
403
- const backLink = scope.backName
404
- ? `<a class="scope-all scope-back" href="${escapeHtml(projectScopeHref(scope.backDir, location.hash, location.search))}" title="Sessions of ${escapeHtml(scope.backName)} only">← ${escapeHtml(scope.backName)}</a>`
405
- : "";
406
- el.innerHTML = `<span class="scope-name" title="Sessions of every project">all projects</span>${backLink}${where ? `<span class="scope-where" title="New chats start in ${escapeHtml(where)}">new chat in<span class="path"><bdi>${escapeHtml(where)}</bdi></span></span>` : ""}`;
407
- }
394
+ render(scopeTemplate(scope, { error: scopeError, where: () => ({ hash: location.hash, search: location.search }) }), el);
408
395
  el.hidden = false;
409
396
  // The tab: "<session title> · Chi · <project>" (updateSessionTitle).
410
397
  tabBase = scope.projectName ? `Chi · ${scope.projectName}` : "Chi";
411
398
  updateSessionTitle();
412
- // The links follow the hash (an open session, the all-sessions view).
413
- el.querySelector(".scope-all:not(.scope-back)")?.addEventListener("click", (e) => {
414
- e.currentTarget.href = allScopeHref(location.hash, scope.dir, location.search);
415
- });
416
- el.querySelector(".scope-back")?.addEventListener("click", (e) => {
417
- e.currentTarget.href = projectScopeHref(scope.backDir, location.hash, location.search);
418
- });
419
399
  }
420
400
 
421
401
  // The session lists (lists_view.js): the strip, all sessions, their cards,
@@ -585,7 +565,7 @@ function updateComposerMode() {
585
565
  actionBtn.textContent = "Start";
586
566
  actionBtn.disabled = false;
587
567
  emptyEl.classList.remove("hidden");
588
- historyEl.innerHTML = "";
568
+ historyEl.replaceChildren();
589
569
  updateInfoBar();
590
570
  } else if (current.owner === "tui") {
591
571
  setPlaceholder("A terminal chi owns this session · run it with chi --shared to send from here", "A terminal chi owns this session");
@@ -699,7 +679,7 @@ async function select(id) {
699
679
  selected = id;
700
680
  setSessionHash(id);
701
681
  lists.paintActive();
702
- historyEl.innerHTML = `<div class="hint">Loading…</div>`;
682
+ historyEl.replaceChildren(hintEl("Loading…"));
703
683
  emptyEl.classList.add("hidden");
704
684
  closeStream();
705
685
  resetSessionState();
@@ -741,7 +721,7 @@ async function resync({ endText = null } = {}) {
741
721
  data = await getSession(id, { parts: true });
742
722
  } catch (e) {
743
723
  if (e.status === 404) return leaveGoneSession(id, "not_found");
744
- if (id === selected) historyEl.innerHTML = `<div class="hint">Error: ${escapeHtml(e.message)}</div>`;
724
+ if (id === selected) historyEl.replaceChildren(hintEl(`Error: ${e.message}`));
745
725
  return;
746
726
  }
747
727
  if (id !== selected) return;
@@ -833,16 +813,30 @@ function renderSessionView(data, { kept = [] } = {}) {
833
813
  updateComposerMode();
834
814
  }
835
815
 
816
+ // The history's one line while it has nothing else (loading, an error,
817
+ // no output yet).
818
+ function hintEl(text) {
819
+ const el = document.createElement("div");
820
+ el.className = "hint";
821
+ el.textContent = text;
822
+ return el;
823
+ }
824
+
836
825
  function renderHistory(items) {
837
826
  if (!items.length) {
838
- historyEl.innerHTML = `<div class="hint">No output yet. Send a message.</div>`;
827
+ historyEl.replaceChildren(hintEl("No output yet. Send a message."));
839
828
  return;
840
829
  }
841
830
  if (typeof items[0] === "object" && items[0].role) {
842
- historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsHtml(selected, images) }));
831
+ historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsTemplate(selected, images) }));
843
832
  linkTextRefs();
844
833
  } else {
845
- historyEl.innerHTML = items.map((c) => `<div class="bubble output">${escapeHtml(c)}</div>`).join("");
834
+ historyEl.replaceChildren(...items.map((c) => {
835
+ const el = document.createElement("div");
836
+ el.className = "bubble output";
837
+ el.textContent = String(c);
838
+ return el;
839
+ }));
846
840
  }
847
841
  historyEl.scrollTop = historyEl.scrollHeight;
848
842
  }
@@ -984,7 +978,7 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
984
978
  if (kind !== "gone") liveTurn.timing.ended += 1;
985
979
  // A completed turn with no answer: its notice where the answer goes
986
980
  // (the turn view places it), else after the turn; before the timing.
987
- const endNote = emptyAnswer ? htmlElement(emptyAnswerHtml(emptyAnswer)) : null;
981
+ const endNote = emptyAnswer ? elementOf(emptyAnswerTemplate(emptyAnswer)) : null;
988
982
  const kept = turnDom.turnEnded({ kind, endNote, emptyAnswer: !!emptyAnswer }) || [];
989
983
  if (endNote && !endNote.isConnected) appendToHistory(endNote);
990
984
  liveTurn.timing.finish({ canceled: kind === "canceled" });
@@ -1006,8 +1000,8 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
1006
1000
  function makeUserBubble(prompt, { state = null, label = null, enqueuedId = null, own = false, images = null } = {}) {
1007
1001
  const hint = historyEl.querySelector(".hint");
1008
1002
  if (hint) hint.remove();
1009
- const div = htmlElement(userBubbleHtml({ content: prompt }, {
1010
- label, state, own, enqueuedId, key: normalize(prompt), thumbs: thumbsHtml(selected, images),
1003
+ const div = elementOf(userBubbleTemplate({ content: prompt }, {
1004
+ label, state, own, enqueuedId, key: normalize(prompt), thumbs: thumbsTemplate(selected, images),
1011
1005
  }));
1012
1006
  const follow = isNearBottom();
1013
1007
  appendToHistory(div);
@@ -1024,7 +1018,7 @@ function setBubbleState(bubble, state) {
1024
1018
  }
1025
1019
  bubble.classList.add(state);
1026
1020
  bubble.dataset.userState = state;
1027
- bubble.insertAdjacentHTML("beforeend", stateBadgeHtml(state));
1021
+ bubble.append(elementOf(stateBadgeTemplate(state)));
1028
1022
  }
1029
1023
 
1030
1024
  function bubbleById(enqueuedId) {
@@ -1139,10 +1133,9 @@ function showSteers(steers) {
1139
1133
  for (const steer of steers || []) {
1140
1134
  if (turnDom.steer(steer)) continue;
1141
1135
  removeHintIfEmpty();
1142
- const holder = document.createElement("div");
1143
- holder.innerHTML = steerRowHtml(steer, { bubble: true });
1136
+ const row = elementOf(steerRowTemplate(steer, { bubble: true }));
1144
1137
  const follow = isNearBottom();
1145
- appendToHistory(holder.firstChild);
1138
+ appendToHistory(row);
1146
1139
  if (follow) historyEl.scrollTop = historyEl.scrollHeight;
1147
1140
  }
1148
1141
  }
@@ -1170,36 +1163,6 @@ function cardElement(id) {
1170
1163
  return findInTurnRoots(".plugin-card").find((el) => el.dataset.cardId === String(id)) || null;
1171
1164
  }
1172
1165
 
1173
- // A notice is a <details> (a one-line row that opens), a card a <div>: a
1174
- // card that turns into the other kind (btw's "thinking…" → its answer) is
1175
- // drawn on a new element in its place. Returns the element drawn.
1176
- function drawCard(el, card) {
1177
- const notice = isNoticeCard(card);
1178
- if (el.tagName !== (notice ? "DETAILS" : "DIV")) {
1179
- const fresh = document.createElement(notice ? "details" : "div");
1180
- el.replaceWith(fresh);
1181
- el = fresh;
1182
- }
1183
- el.className = cardClass(card);
1184
- el.dataset.cardId = card.id;
1185
- // A running turn's own card (the stage's status waits on one that asks).
1186
- if (card.in_turn) el.dataset.inTurn = "1";
1187
- else delete el.dataset.inTurn;
1188
- if (notice) {
1189
- const open = el.open;
1190
- el.innerHTML = noticeInnerHtml(card);
1191
- el.open = open;
1192
- } else if (card.body_html === undefined && el.dataset.body === String(card.body ?? "") && el.querySelector(".card-body")) {
1193
- // The same body again from the stream: keep the rendered one.
1194
- const body = el.querySelector(".card-body").innerHTML;
1195
- el.innerHTML = cardInnerHtml({ ...card, body_html: body });
1196
- } else {
1197
- el.innerHTML = cardInnerHtml(card);
1198
- }
1199
- el.dataset.body = String(card.body ?? "");
1200
- return el;
1201
- }
1202
-
1203
1166
  // Show +card+: update the one with its id where it is, else add it (a
1204
1167
  // running turn's card as a row of its step; else before +before+ or at the
1205
1168
  // end of the history). A running turn's card with actions asks the user
@@ -1326,8 +1289,7 @@ function refreshCardBodies() {
1326
1289
  if (id !== selected) return;
1327
1290
  for (const card of data.cards || []) {
1328
1291
  const el = card.type === "card" && cardElement(card.id);
1329
- const body = el && el.querySelector(".card-body");
1330
- if (body && typeof card.body_html === "string" && el.dataset.body === String(card.body ?? "")) body.innerHTML = card.body_html;
1292
+ if (el) refreshCardBody(el, card);
1331
1293
  }
1332
1294
  } catch (_) {}
1333
1295
  }, 150);
@@ -1779,6 +1741,7 @@ async function handleSendTurn() {
1779
1741
  updateInfoBar();
1780
1742
  }
1781
1743
  actionBtn.disabled = true;
1744
+ const sentTo = selected;
1782
1745
  try {
1783
1746
  for (const chip of sending) {
1784
1747
  if (!chip.ref) chip.ref = await uploadImage(selected, chip.file, chip.name);
@@ -1825,6 +1788,11 @@ async function handleSendTurn() {
1825
1788
  updateComposerMode();
1826
1789
  return;
1827
1790
  }
1791
+ // A stale tab sent into a session deleted meanwhile: the start page
1792
+ // says so, and the line stays in the composer.
1793
+ if (e.code === "not_found") return leaveGoneSession(sentTo, "not_found");
1794
+ // A /queue to a worker that can't queue (it would steer): its words, the text kept.
1795
+ if (e.code === "delivery_unavailable") return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
1828
1796
  alert(e.message);
1829
1797
  } finally {
1830
1798
  actionBtn.disabled = current.owner === "tui";
@@ -1882,9 +1850,7 @@ function commandBubble(commandId) {
1882
1850
 
1883
1851
  function addNoteBubble(note) {
1884
1852
  removeHintIfEmpty();
1885
- const div = document.createElement("div");
1886
- div.className = "bubble note";
1887
- div.innerHTML = noteHtml(note);
1853
+ const div = elementOf(noteBubbleTemplate(note));
1888
1854
  const follow = isNearBottom();
1889
1855
  appendToHistory(div);
1890
1856
  if (follow) historyEl.scrollTop = historyEl.scrollHeight;
@@ -1964,7 +1930,7 @@ const lightboxEl = $("#lightbox");
1964
1930
  let chips = [];
1965
1931
 
1966
1932
  function renderChips() {
1967
- chipsEl.innerHTML = chipsHtml(chips);
1933
+ render(chipsTemplate(chips), chipsEl);
1968
1934
  chipsEl.hidden = !chips.length;
1969
1935
  }
1970
1936
 
@@ -2222,7 +2188,7 @@ async function continueSelected() {
2222
2188
  if (selected === id) await select(refusal.id);
2223
2189
  showToast(`${short} is continued already: ${selected === refusal.id ? "opened" : "see"} ${refusal.id.slice(0, 8)}`);
2224
2190
  } else if (refusal.kind === "children") {
2225
- showToast(openChildrenHtml(id, refusal.children, sessionHash), null, { sticky: true, html: true });
2191
+ showToast(openChildrenTemplate(id, refusal.children, sessionHash), null, { sticky: true });
2226
2192
  } else {
2227
2193
  showToast(`Can't continue ${short}: ${refusal.text}`, null, { sticky: true });
2228
2194
  }
@@ -2319,24 +2285,20 @@ function placeToast() {
2319
2285
  }
2320
2286
  }
2321
2287
 
2322
- // sticky: stays until its action or its ✕ (else gone after 6 s).
2288
+ // sticky: stays until its action or its ✕ (else gone after 6 s). +text+:
2289
+ // words, or a template (chains.js openChildrenTemplate: a link in it, a
2290
+ // delegate a continue waits for, opens that session and hides the toast).
2323
2291
  let toastTimer = null;
2324
- // html: +text+ is markup this page built (its parts escaped), not words.
2325
- function showToast(text, action = null, { sticky = false, html = false } = {}) {
2292
+ function showToast(text, action = null, { sticky = false } = {}) {
2326
2293
  const el = $("#toast");
2327
- el.innerHTML = `<span>${html ? text : escapeHtml(text)}</span>${action ? `<button type="button" class="toast-action">${escapeHtml(action.label)}</button>` : ""}` +
2328
- (sticky ? `<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss">✕</button>` : "");
2294
+ const hide = () => { el.hidden = true; };
2295
+ render(html`<span @click=${(e) => { if (e.target.closest?.("a[href]")) hide(); }}>${text}</span>${action
2296
+ ? html`<button type="button" class="toast-action" @click=${() => { hide(); action.run(); }}>${action.label}</button>`
2297
+ : nothing}${sticky ? html`<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss" @click=${hide}>✕</button>` : nothing}`, el);
2329
2298
  placeToast();
2330
2299
  el.hidden = false;
2331
- el.querySelector(".toast-action")?.addEventListener("click", () => {
2332
- el.hidden = true;
2333
- action.run();
2334
- });
2335
- el.querySelector(".toast-close")?.addEventListener("click", () => { el.hidden = true; });
2336
- // A link in it (a delegate a continue waits for) opens that session.
2337
- el.querySelectorAll("a[href]").forEach((a) => a.addEventListener("click", () => { el.hidden = true; }));
2338
2300
  clearTimeout(toastTimer);
2339
- if (!sticky) toastTimer = setTimeout(() => { el.hidden = true; }, 6000);
2301
+ if (!sticky) toastTimer = setTimeout(hide, 6000);
2340
2302
  }
2341
2303
 
2342
2304
  // Annotate (annotate_bar.js): a selection in the conversation offers
@@ -1,7 +1,10 @@
1
1
  // A plugin's card (docs/plugins.md, Cards): a title, a body and actions,
2
2
  // each action a command line the session runs. app.js places and updates
3
- // the element; this module draws it and says where it goes.
4
- import { escapeHtml, recapPlace } from "./format.js";
3
+ // the element; this module draws it (lit-html templates) and says where it
4
+ // goes.
5
+ import { html, nothing, render } from "./vendor/lit-html.js";
6
+ import { unsafeHTML } from "./vendor/directives/unsafe-html.js";
7
+ import { recapPlace } from "./format.js";
5
8
 
6
9
  // The card's class: a warn card wears the warning colour; a notice is a
7
10
  // one-line row.
@@ -31,13 +34,13 @@ export function noticeCardLine(card) {
31
34
  return title && body ? `${title}: ${body}` : title || body;
32
35
  }
33
36
 
34
- // A notice's markup inside its <details>: the line as the summary, and
35
- // where it came from when the row is opened (the line wraps then).
36
- export function noticeInnerHtml(card) {
37
+ // A notice inside its <details>: the line as the summary, and where it
38
+ // came from when the row is opened (the line wraps then).
39
+ export function noticeTemplate(card) {
37
40
  const title = String(card?.title ?? "").trim();
38
41
  const source = String(card?.source ?? "").trim();
39
- const from = source && source !== title ? `<div class="notice-from">from <span class="card-source">${escapeHtml(source)}</span></div>` : "";
40
- return `<summary>${escapeHtml(noticeCardLine(card))}</summary>${from}`;
42
+ const from = source && source !== title ? html`<div class="notice-from">from <span class="card-source">${source}</span></div>` : nothing;
43
+ return html`<summary>${noticeCardLine(card)}</summary>${from}`;
41
44
  }
42
45
 
43
46
  // Whether a card shown as a row of a step leaves the turn's block when the
@@ -51,27 +54,74 @@ export function leavesBlock({ warn = false, kind = "completed" } = {}) {
51
54
  }
52
55
 
53
56
  // The body: the server's body_html (rendered markdown, sanitized; or the
54
- // escaped text in a <pre>), else the plain text in a <pre> (a live event
55
- // carries no html; the page's ?cards=1 re-read brings it).
56
- export function cardBodyHtml(card) {
57
- if (typeof card?.body_html === "string") return card.body_html;
57
+ // escaped text in a <pre>) as it is, else the plain text in a <pre> (a
58
+ // live event carries no html; the page's ?cards=1 re-read brings it);
59
+ // null for none. The same body_html again keeps its nodes (unsafeHTML
60
+ // renders a string it rendered last time as it is).
61
+ export function cardBodyTemplate(card) {
62
+ if (typeof card?.body_html === "string") return card.body_html ? unsafeHTML(card.body_html) : null;
58
63
  const body = String(card?.body ?? "");
59
- return body.trim() ? `<pre>${escapeHtml(body)}</pre>` : "";
64
+ return body.trim() ? html`<pre>${body}</pre>` : null;
60
65
  }
61
66
 
62
- // The card's inner markup: its head (title, source), body and one button
63
- // per action (the command as its tooltip and data).
64
- export function cardInnerHtml(card) {
65
- const source = card.source ? `<span class="card-source">${escapeHtml(card.source)}</span>` : "";
66
- const head = `<div class="card-head"><span class="card-title">${escapeHtml(card.title || "")}</span>${source}</div>`;
67
- const bodyHtml = cardBodyHtml(card);
68
- const body = bodyHtml ? `<div class="card-body">${bodyHtml}</div>` : "";
67
+ // The card: its head (title, source), body and one button per action (the
68
+ // command as its tooltip and data), then the line a failed action writes.
69
+ export function cardTemplate(card) {
70
+ const source = card.source ? html`<span class="card-source">${card.source}</span>` : nothing;
71
+ const body = cardBodyTemplate(card);
69
72
  const actions = (card.actions || []).filter((a) => a && a.command);
70
73
  const buttons = actions.length
71
- ? `<div class="card-actions">${actions.map((a) =>
72
- `<button type="button" class="card-action ghost" data-command="${escapeHtml(a.command)}" title="${escapeHtml(a.command)}">${escapeHtml(a.label || a.command)}</button>`).join("")}</div>`
73
- : "";
74
- return `${head}${body}${buttons}<div class="card-error hidden"></div>`;
74
+ ? html`<div class="card-actions">${actions.map((a) =>
75
+ html`<button type="button" class="card-action ghost" data-command=${a.command} title=${a.command}>${a.label || a.command}</button>`)}</div>`
76
+ : nothing;
77
+ return html`<div class="card-head"><span class="card-title">${card.title || ""}</span>${source}</div>${body ? html`<div class="card-body">${body}</div>` : nothing}${buttons}<div class="card-error hidden"></div>`;
78
+ }
79
+
80
+ // A card element → the card it shows, as drawCard drew it.
81
+ const DRAWN = new WeakMap();
82
+
83
+ // +card+ drawn on +el+ (a notice on a <details>, a card on a <div>; one
84
+ // that turns into the other kind, btw's "thinking…" → its answer, is
85
+ // drawn on a new element in its place). Returns the element drawn. A
86
+ // redraw writes only what changed: an open notice stays open, a body
87
+ // that didn't change keeps its nodes (a selection in it), and the same
88
+ // body again from the stream (text, no html) keeps the rendered one. A
89
+ // failed action's line goes, as the card is new.
90
+ export function drawCard(el, card) {
91
+ const notice = isNoticeCard(card);
92
+ if (el.tagName !== (notice ? "DETAILS" : "DIV")) {
93
+ const fresh = el.ownerDocument.createElement(notice ? "details" : "div");
94
+ el.replaceWith(fresh);
95
+ el = fresh;
96
+ }
97
+ el.className = cardClass(card);
98
+ el.dataset.cardId = card.id;
99
+ // A running turn's own card (the stage's status waits on one that asks).
100
+ if (card.in_turn) el.dataset.inTurn = "1";
101
+ else delete el.dataset.inTurn;
102
+ const drawn = DRAWN.get(el);
103
+ const sameBody = card.body_html === undefined && drawn && String(drawn.body ?? "") === String(card.body ?? "");
104
+ paint(el, sameBody ? { ...card, body_html: drawn.body_html } : card);
105
+ const error = el.querySelector(":scope > .card-error");
106
+ if (error) {
107
+ error.textContent = "";
108
+ error.classList.add("hidden");
109
+ }
110
+ return el;
111
+ }
112
+
113
+ function paint(el, card) {
114
+ render(isNoticeCard(card) ? noticeTemplate(card) : cardTemplate(card), el);
115
+ DRAWN.set(el, card);
116
+ }
117
+
118
+ // The rendered body a re-read brought (the snapshot's +card+ with its
119
+ // body_html) onto a card drawn with the same body text; a no-op for any
120
+ // other.
121
+ export function refreshCardBody(el, card) {
122
+ const drawn = DRAWN.get(el);
123
+ if (!drawn || isNoticeCard(drawn) || typeof card?.body_html !== "string" || String(drawn.body ?? "") !== String(card.body ?? "")) return;
124
+ paint(el, { ...drawn, body_html: card.body_html });
75
125
  }
76
126
 
77
127
  // Where a snapshot's card or between-turns notice goes: before the user
@@ -6,7 +6,7 @@
6
6
  // `day N` chip, its earlier links in a popover); the session page links the
7
7
  // previous and the next link. Pure: lists_view.js places the chip and rows
8
8
  // (lit-html templates) and app.js the toast's markup, and they bind them.
9
- import { escapeHtml, normalize, previewForCard, relativeTime } from "./format.js";
9
+ import { normalize, previewForCard, relativeTime } from "./format.js";
10
10
  import { stoppedBadge, waitingBadge } from "./sessions_list.js";
11
11
  import { waitingOn } from "./notify.js";
12
12
  import { archivedBadge, rowBadgesTemplate, rowDotTemplate } from "./families_view.js";
@@ -226,10 +226,12 @@ export function continueRefusal(error, sessions = []) {
226
226
  // continue can't move to the next link (an older chi's worker, a chi
227
227
  // REPL), each a link to its session with what to do about it.
228
228
  // @param hrefFor (id) → its page's href
229
- export function openChildrenHtml(prevId, children, hrefFor) {
229
+ export function openChildrenTemplate(prevId, children, hrefFor) {
230
230
  const n = children.length;
231
- const items = children.map((c) => `<li><a href="${escapeHtml(hrefFor(c.id))}" class="cr-child">${escapeHtml(c.short)}</a>${c.text ? ` ${escapeHtml(c.text)}` : ""}</li>`).join("");
232
- return `<div class="continue-refused"><div>Can't continue ${escapeHtml(String(prevId).slice(0, 8))}: ${n} open ${n === 1 ? "delegate" : "delegates"} can't move to the next link. Do what ${n === 1 ? "it says" : "each says"}, or wait for ${n === 1 ? "it" : "them"}, then continue.</div><ul>${items}</ul></div>`;
231
+ const items = children.map((c) => html`<li><a href=${hrefFor(c.id)} class=${"cr-child"}>${c.short}</a>${c.text ? ` ${c.text}` : nothing}</li>`);
232
+ const line = `Can't continue ${String(prevId).slice(0, 8)}: ${n} open ${n === 1 ? "delegate" : "delegates"} can't move to the next link. ` +
233
+ `Do what ${n === 1 ? "it says" : "each says"}, or wait for ${n === 1 ? "it" : "them"}, then continue.`;
234
+ return html`<div class="continue-refused"><div>${line}</div><ul>${items}</ul></div>`;
233
235
  }
234
236
 
235
237
  // The toast after a continue: the new link, the previous one archived (when