samagotchi 0.61.0 → 0.63.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +91 -1
  3. data/README.md +4 -1
  4. data/bin/chi +3 -2
  5. data/docs/architecture.md +3 -0
  6. data/docs/cli.md +26 -9
  7. data/docs/configuration.md +33 -20
  8. data/docs/context.md +21 -3
  9. data/docs/guardrails.md +2 -1
  10. data/docs/hooks.md +14 -3
  11. data/docs/internals/engine.md +24 -11
  12. data/docs/internals/prompt-caching.md +6 -1
  13. data/docs/memory.md +1 -1
  14. data/docs/plugins.md +131 -5
  15. data/docs/releasing.md +1 -1
  16. data/docs/sessions.md +4 -4
  17. data/docs/sub-agent.md +6 -0
  18. data/docs/testing.md +10 -4
  19. data/lib/samagotchi/answer_refs.rb +18 -26
  20. data/lib/samagotchi/bridge.rb +23 -1
  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/github-pr/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
  29. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
  31. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
  33. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
  34. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
  35. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  36. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  37. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  38. data/lib/samagotchi/context_absorber.rb +25 -6
  39. data/lib/samagotchi/engine.rb +9 -0
  40. data/lib/samagotchi/engine_peers.rb +1 -0
  41. data/lib/samagotchi/idle_recap.rb +2 -3
  42. data/lib/samagotchi/image_store.rb +8 -0
  43. data/lib/samagotchi/kernel_loop.rb +7 -16
  44. data/lib/samagotchi/llm/chat_loop.rb +23 -15
  45. data/lib/samagotchi/plugin/api.rb +36 -18
  46. data/lib/samagotchi/plugin/attached_context.rb +7 -4
  47. data/lib/samagotchi/reasoning_history.rb +22 -4
  48. data/lib/samagotchi/recap_setup.rb +3 -3
  49. data/lib/samagotchi/row_refs.rb +125 -0
  50. data/lib/samagotchi/session.rb +12 -0
  51. data/lib/samagotchi/session_metrics.rb +24 -2
  52. data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
  53. data/lib/samagotchi/terminal_ui/attached_loop.rb +26 -9
  54. data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
  55. data/lib/samagotchi/terminal_ui.rb +57 -14
  56. data/lib/samagotchi/tool_activity.rb +8 -4
  57. data/lib/samagotchi/tool_response.rb +5 -4
  58. data/lib/samagotchi/tool_row_fields.rb +4 -2
  59. data/lib/samagotchi/tool_runner.rb +16 -3
  60. data/lib/samagotchi/tools/builtins.rb +9 -2
  61. data/lib/samagotchi/tools/execute.rb +37 -7
  62. data/lib/samagotchi/tools/peers.rb +3 -2
  63. data/lib/samagotchi/tools/registry.rb +5 -3
  64. data/lib/samagotchi/version.rb +1 -1
  65. data/lib/samagotchi/web/app.rb +20 -5
  66. data/lib/samagotchi/web/message_parts.rb +17 -9
  67. data/lib/samagotchi/web/public/activity.js +6 -3
  68. data/lib/samagotchi/web/public/app.js +106 -106
  69. data/lib/samagotchi/web/public/card.js +73 -23
  70. data/lib/samagotchi/web/public/chains.js +24 -24
  71. data/lib/samagotchi/web/public/command_view.js +39 -35
  72. data/lib/samagotchi/web/public/context_chips.js +92 -64
  73. data/lib/samagotchi/web/public/copy.js +7 -15
  74. data/lib/samagotchi/web/public/diff_view.js +20 -23
  75. data/lib/samagotchi/web/public/families_view.js +51 -39
  76. data/lib/samagotchi/web/public/format.js +0 -48
  77. data/lib/samagotchi/web/public/images.js +12 -16
  78. data/lib/samagotchi/web/public/index.html +61 -10
  79. data/lib/samagotchi/web/public/keep_focus.js +24 -0
  80. data/lib/samagotchi/web/public/lists_view.js +13 -13
  81. data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
  82. data/lib/samagotchi/web/public/question_card.js +11 -0
  83. data/lib/samagotchi/web/public/question_cards.js +63 -7
  84. data/lib/samagotchi/web/public/read_view.js +10 -9
  85. data/lib/samagotchi/web/public/ref_menu.js +18 -5
  86. data/lib/samagotchi/web/public/refs.js +113 -15
  87. data/lib/samagotchi/web/public/scope.js +23 -0
  88. data/lib/samagotchi/web/public/scroll.js +42 -0
  89. data/lib/samagotchi/web/public/stage_model.js +22 -3
  90. data/lib/samagotchi/web/public/stage_view.js +299 -70
  91. data/lib/samagotchi/web/public/timing.js +1 -8
  92. data/lib/samagotchi/web/public/turn_html.js +122 -61
  93. data/lib/samagotchi/web/public/turn_model.js +49 -0
  94. data/lib/samagotchi/web/public/turn_view.js +167 -116
  95. data/lib/samagotchi/web/public/vendor/directive.js +8 -0
  96. data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
  97. data/lib/samagotchi/worker.rb +49 -32
  98. data/lib/samagotchi/worker_inbound.rb +17 -14
  99. metadata +10 -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
@@ -1696,7 +1696,8 @@ module Samagotchi
1696
1696
  source = if live
1697
1697
  persisted.merge(live_metrics).merge(
1698
1698
  "started_at" => persisted["started_at"] || live_metrics["started_at"],
1699
- "turn_records" => merge_timing_records(persisted["turn_records"], live_metrics["turn_records"]),
1699
+ "turn_records" => merge_timing_records(without_running_turn(persisted["turn_records"], live_metrics),
1700
+ live_metrics["turn_records"]),
1700
1701
  "tool_records" => merge_timing_records(persisted["tool_records"], live_metrics["tool_records"])
1701
1702
  )
1702
1703
  else
@@ -1777,6 +1778,18 @@ module Samagotchi
1777
1778
  AnalyticsFile.read(@session_class.session_dir(session_id, state_dir: default_state_dir)) || {}
1778
1779
  end
1779
1780
 
1781
+ # The saved turn records without the one the live worker is running:
1782
+ # its turn checkpoint saves that turn's record so far as a killed
1783
+ # turn's (canceled, worker_stopped, SessionMetrics#persist's
1784
+ # running_turn), which is only true once the worker is gone.
1785
+ def without_running_turn(records, live_metrics)
1786
+ active = live_metrics["active_turn"]
1787
+ id = active.is_a?(Hash) ? active["id"] || active[:id] : nil
1788
+ return records if id.nil?
1789
+
1790
+ Array(records).reject { |record| record.is_a?(Hash) && (record["id"] || record[:id]) == id }
1791
+ end
1792
+
1780
1793
  def merge_timing_records(persisted, live)
1781
1794
  (Array(persisted) + Array(live)).each_with_object({}) do |record, records|
1782
1795
  next unless record.is_a?(Hash)
@@ -1910,7 +1923,7 @@ module Samagotchi
1910
1923
  message[:html] = @markdown_renderer.render(rendered) if !rendered.empty? && @markdown_renderer.available?
1911
1924
  # The refs after_turn hooks attached (AnswerRefs), checked again:
1912
1925
  # a stored file is never trusted. Never raises.
1913
- 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)
1914
1927
  message[:display_refs] = refs unless refs.empty?
1915
1928
  end
1916
1929
  message[:parts] = did if did
@@ -1955,8 +1968,10 @@ module Samagotchi
1955
1968
 
1956
1969
  # The last message messages_for_display shows as an answer
1957
1970
  # (AnswerTail), as a list of at most one: only that one is rendered.
1958
- def last_assistant_for_display(msgs, turn_id: nil)
1959
- 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)
1960
1975
  end
1961
1976
 
1962
1977
  def parse_json(str)
@@ -9,6 +9,7 @@ require_relative "../tools/builtins"
9
9
  require_relative "../image_store"
10
10
  require_relative "../llm_context_edit"
11
11
  require_relative "../token_usage"
12
+ require_relative "../row_refs"
12
13
 
13
14
  module Samagotchi
14
15
  module Web
@@ -37,7 +38,8 @@ module Samagotchi
37
38
  # A plugin tool's params are what its live row showed (its preview),
38
39
  # saved with the result as tool_params (native: one per call, chat: one
39
40
  # per result); its label ("chrome: screenshot") likewise as tool_labels,
40
- # the part's label:. The web server has no Engine and runs no plugins, so a
41
+ # the part's label:, and its row refs as tool_refs (RowRefs, checked
42
+ # again here), the part's refs:. The web server has no Engine and runs no plugins, so a
41
43
  # result without them (older sessions) falls back to the given
42
44
  # registry: the built-ins', and a plugin tool's arguments as key="value"
43
45
  # from the parsers' args:, as the live row shows them for a tool
@@ -106,11 +108,12 @@ module Samagotchi
106
108
  shown = saved_list(responses, :tool_params, calls.length)
107
109
  labels = saved_list(responses, :tool_labels, calls.length)
108
110
  diffs = saved_list(responses, :tool_diffs, calls.length)
111
+ refs = saved_list(responses, :tool_refs, calls.length)
109
112
  ids = saved_list(responses, :tool_ids, calls.length)
110
113
  images = split_images(responses, calls.length)
111
114
  calls.each_with_index.map do |call, i|
112
- tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i], cwd: cwd,
113
- tool_id: ids[i], marks: marks)
115
+ tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i], refs: refs[i],
116
+ cwd: cwd, tool_id: ids[i], marks: marks)
114
117
  end
115
118
  end
116
119
 
@@ -144,7 +147,8 @@ module Samagotchi
144
147
  response = by_id[field(raw, :id).to_s] || (responses[i] && !unique.call(ids[i]) ? responses[i] : nil)
145
148
  tool_part(call, response && field(response, :content).to_s, registry, field(response, :tool_params),
146
149
  response && field(response, :images), label: field(response, :tool_labels),
147
- diff: field(response, :tool_diffs), cwd: cwd,
150
+ diff: field(response, :tool_diffs),
151
+ refs: field(response, :tool_refs), cwd: cwd,
148
152
  tool_id: Array(field(response, :tool_ids)).first, marks: marks)
149
153
  end
150
154
  end
@@ -224,10 +228,12 @@ module Samagotchi
224
228
 
225
229
  # +shown+ is the saved params line, when it is a String.
226
230
  # +diff+ is what an edit/write changed (EditPreview.change), saved as
227
- # tool_diffs. +cwd+: the session's working directory, for the title.
231
+ # tool_diffs. +refs+: a plugin tool's row refs, saved as tool_refs
232
+ # (RowRefs.for_display checks them again against +cwd+). +cwd+: the
233
+ # session's working directory, for the title.
228
234
  # +tool_id+: its output's ToolIds id, with its ✂ mark in +marks+.
229
- def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil, cwd: nil, tool_id: nil,
230
- marks: {})
235
+ def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil, refs: nil, cwd: nil,
236
+ tool_id: nil, marks: {})
231
237
  name = call[:name].to_s
232
238
  params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
233
239
  part = { tool: name, params: params.to_s }
@@ -240,9 +246,11 @@ module Samagotchi
240
246
  part[:output] = output.length > OUTPUT_MAX ? output[0, OUTPUT_MAX] : output
241
247
  part[:output_truncated] = true if output.length > OUTPUT_MAX
242
248
  end
243
- refs = Array(images).select { |ref| ref.is_a?(Hash) }
244
- part[:images] = refs.map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } unless refs.empty?
249
+ shots = Array(images).select { |image| image.is_a?(Hash) }
250
+ part[:images] = shots.map { |image| ImageStore.symbolize(image).slice(:file, :name, :width, :height) } unless shots.empty?
245
251
  part[:diff] = diff if diff.is_a?(Hash)
252
+ row_refs = RowRefs.for_display(refs, cwd: cwd)
253
+ part[:refs] = row_refs unless row_refs.empty?
246
254
  if tool_id
247
255
  part[:tool_id] = tool_id.to_s
248
256
  mark = mark_of(tool_id.to_s, marks, output)
@@ -45,8 +45,11 @@ export function newActivity() {
45
45
  // ref: what the row traces to (the server's FileRef: {kind: "file",
46
46
  // path, line?, end_line?}, the file a file tool touched), which
47
47
  // refs.js makes the row's title and diff lines open.
48
+ // refs: a plugin tool's refs (the server's RowRefs: [{kind, target,
49
+ // label?, actions}]): the first on the row's title, the others in its
50
+ // menu (refs.js bindRowRefs).
48
51
  export const ACTIVITY_FIELDS = Object.freeze(["title", "called_as"]);
49
- export const EVENT_FIELDS = Object.freeze(["view", "ref"]);
52
+ export const EVENT_FIELDS = Object.freeze(["view", "ref", "refs"]);
50
53
  const ROW_FIELDS = Object.freeze([...ACTIVITY_FIELDS, ...EVENT_FIELDS]);
51
54
 
52
55
  // The +fields+ of +source+ that are set, as an object (for a spread).
@@ -137,10 +140,10 @@ export function addCompleted(model, event = {}, now = null) {
137
140
  // A grep that found nothing (status ok): the row says "no match".
138
141
  if (event.activity?.no_match) row.no_match = true;
139
142
  // The images the tool returned (a reload in the turn view draws them from
140
- // the saved parts, turn_html.js toolRowHtml).
143
+ // the saved parts, turn_html.js toolRowTemplate).
141
144
  if (event.images?.length) row.images = event.images;
142
145
  // What an edit/write changed in its file (a reload draws it from the
143
- // saved parts, turn_html.js toolRowHtml).
146
+ // saved parts, turn_html.js toolRowTemplate).
144
147
  if (event.diff) row.diff = event.diff;
145
148
  const duration = callDuration(row, event, now);
146
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";
41
- import { createTurnView, turnHistoryNodes } from "./turn_view.js";
42
- import { BADGES, emptyAnswerHtml, htmlElement, stateBadgeHtml, userBubbleHtml } from "./turn_html.js";
43
- import { createStageView } from "./stage_view.js";
44
- import { CONTINUE_OPENER, isOpener, chainDate, chainNeighbours, continueRefusal, continuedText, openChildrenHtml } from "./chains.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
+ import { createTurnView, stepText, turnHistoryNodes } from "./turn_view.js";
42
+ import { BADGES, elementOf, emptyAnswerTemplate, noteBubbleTemplate, stateBadgeTemplate, steerRowTemplate, userBubbleTemplate } from "./turn_html.js";
43
+ import { createStageView, FOLD_MS } from "./stage_view.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,13 +49,14 @@ 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";
58
- import { shouldFollowScroll, keepFollowing } from "./scroll.js";
57
+ import { scopeDir, scopeTemplate } from "./scope.js";
58
+ import { html, nothing, render } from "./vendor/lit-html.js";
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";
61
62
  import { createNotifications } from "./notifications.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,
@@ -264,6 +262,7 @@ const questionCards = createQuestionCards({
264
262
  isNearBottom: () => isNearBottom(),
265
263
  scrollToEnd: () => { historyEl.scrollTop = historyEl.scrollHeight; },
266
264
  removeHintIfEmpty: () => removeHintIfEmpty(),
265
+ askingText: () => turnDom.askingText(),
267
266
  });
268
267
  // The event id (or, from an older worker, the event_seq) the selected session
269
268
  // was rendered at (null: no live worker then), where a stream opened later
@@ -392,29 +391,11 @@ function followSelectedSession(sess) {
392
391
  // new chat starts on the start page.
393
392
  function renderScope() {
394
393
  const el = $("#scope");
395
- const allLink = `<a class="scope-all" href="${escapeHtml(allScopeHref(location.hash, scope.dir, location.search))}" title="Sessions of every project">all</a>`;
396
- if (scopeError) {
397
- el.innerHTML = `<span class="scope-name error" title="${escapeHtml(scopeError)}">folder not found</span>${allLink}`;
398
- } else if (scope.projectName) {
399
- el.innerHTML = `<span class="scope-name" title="Sessions of ${escapeHtml(scope.projectDir)}">${escapeHtml(scope.projectName)}</span>${allLink}`;
400
- } else {
401
- const where = scope.dir || scope.serverDir;
402
- const backLink = scope.backName
403
- ? `<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>`
404
- : "";
405
- 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>` : ""}`;
406
- }
394
+ render(scopeTemplate(scope, { error: scopeError, where: () => ({ hash: location.hash, search: location.search }) }), el);
407
395
  el.hidden = false;
408
396
  // The tab: "<session title> · Chi · <project>" (updateSessionTitle).
409
397
  tabBase = scope.projectName ? `Chi · ${scope.projectName}` : "Chi";
410
398
  updateSessionTitle();
411
- // The links follow the hash (an open session, the all-sessions view).
412
- el.querySelector(".scope-all:not(.scope-back)")?.addEventListener("click", (e) => {
413
- e.currentTarget.href = allScopeHref(location.hash, scope.dir, location.search);
414
- });
415
- el.querySelector(".scope-back")?.addEventListener("click", (e) => {
416
- e.currentTarget.href = projectScopeHref(scope.backDir, location.hash, location.search);
417
- });
418
399
  }
419
400
 
420
401
  // The session lists (lists_view.js): the strip, all sessions, their cards,
@@ -584,7 +565,7 @@ function updateComposerMode() {
584
565
  actionBtn.textContent = "Start";
585
566
  actionBtn.disabled = false;
586
567
  emptyEl.classList.remove("hidden");
587
- historyEl.innerHTML = "";
568
+ historyEl.replaceChildren();
588
569
  updateInfoBar();
589
570
  } else if (current.owner === "tui") {
590
571
  setPlaceholder("A terminal chi owns this session · run it with chi --shared to send from here", "A terminal chi owns this session");
@@ -698,7 +679,7 @@ async function select(id) {
698
679
  selected = id;
699
680
  setSessionHash(id);
700
681
  lists.paintActive();
701
- historyEl.innerHTML = `<div class="hint">Loading…</div>`;
682
+ historyEl.replaceChildren(hintEl("Loading…"));
702
683
  emptyEl.classList.add("hidden");
703
684
  closeStream();
704
685
  resetSessionState();
@@ -740,7 +721,7 @@ async function resync({ endText = null } = {}) {
740
721
  data = await getSession(id, { parts: true });
741
722
  } catch (e) {
742
723
  if (e.status === 404) return leaveGoneSession(id, "not_found");
743
- if (id === selected) historyEl.innerHTML = `<div class="hint">Error: ${escapeHtml(e.message)}</div>`;
724
+ if (id === selected) historyEl.replaceChildren(hintEl(`Error: ${e.message}`));
744
725
  return;
745
726
  }
746
727
  if (id !== selected) return;
@@ -832,16 +813,30 @@ function renderSessionView(data, { kept = [] } = {}) {
832
813
  updateComposerMode();
833
814
  }
834
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
+
835
825
  function renderHistory(items) {
836
826
  if (!items.length) {
837
- historyEl.innerHTML = `<div class="hint">No output yet. Send a message.</div>`;
827
+ historyEl.replaceChildren(hintEl("No output yet. Send a message."));
838
828
  return;
839
829
  }
840
830
  if (typeof items[0] === "object" && items[0].role) {
841
- historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsHtml(selected, images) }));
831
+ historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsTemplate(selected, images) }));
842
832
  linkTextRefs();
843
833
  } else {
844
- 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
+ }));
845
840
  }
846
841
  historyEl.scrollTop = historyEl.scrollHeight;
847
842
  }
@@ -983,7 +978,7 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
983
978
  if (kind !== "gone") liveTurn.timing.ended += 1;
984
979
  // A completed turn with no answer: its notice where the answer goes
985
980
  // (the turn view places it), else after the turn; before the timing.
986
- const endNote = emptyAnswer ? htmlElement(emptyAnswerHtml(emptyAnswer)) : null;
981
+ const endNote = emptyAnswer ? elementOf(emptyAnswerTemplate(emptyAnswer)) : null;
987
982
  const kept = turnDom.turnEnded({ kind, endNote, emptyAnswer: !!emptyAnswer }) || [];
988
983
  if (endNote && !endNote.isConnected) appendToHistory(endNote);
989
984
  liveTurn.timing.finish({ canceled: kind === "canceled" });
@@ -1005,8 +1000,8 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
1005
1000
  function makeUserBubble(prompt, { state = null, label = null, enqueuedId = null, own = false, images = null } = {}) {
1006
1001
  const hint = historyEl.querySelector(".hint");
1007
1002
  if (hint) hint.remove();
1008
- const div = htmlElement(userBubbleHtml({ content: prompt }, {
1009
- 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),
1010
1005
  }));
1011
1006
  const follow = isNearBottom();
1012
1007
  appendToHistory(div);
@@ -1023,7 +1018,7 @@ function setBubbleState(bubble, state) {
1023
1018
  }
1024
1019
  bubble.classList.add(state);
1025
1020
  bubble.dataset.userState = state;
1026
- bubble.insertAdjacentHTML("beforeend", stateBadgeHtml(state));
1021
+ bubble.append(elementOf(stateBadgeTemplate(state)));
1027
1022
  }
1028
1023
 
1029
1024
  function bubbleById(enqueuedId) {
@@ -1101,6 +1096,7 @@ function showRecap(text, turnsSince = 0) {
1101
1096
  details.appendChild(body);
1102
1097
  // A stale one sits where it was written, above the turns it doesn't cover.
1103
1098
  // Never in the stage: a recap is between turns.
1099
+ turnDom.settleFold?.();
1104
1100
  const before = recapPlace(turnPromptBubbles(), turnsSince);
1105
1101
  keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurn.timing.el)));
1106
1102
  }
@@ -1137,10 +1133,9 @@ function showSteers(steers) {
1137
1133
  for (const steer of steers || []) {
1138
1134
  if (turnDom.steer(steer)) continue;
1139
1135
  removeHintIfEmpty();
1140
- const holder = document.createElement("div");
1141
- holder.innerHTML = steerRowHtml(steer, { bubble: true });
1136
+ const row = elementOf(steerRowTemplate(steer, { bubble: true }));
1142
1137
  const follow = isNearBottom();
1143
- appendToHistory(holder.firstChild);
1138
+ appendToHistory(row);
1144
1139
  if (follow) historyEl.scrollTop = historyEl.scrollHeight;
1145
1140
  }
1146
1141
  }
@@ -1168,36 +1163,6 @@ function cardElement(id) {
1168
1163
  return findInTurnRoots(".plugin-card").find((el) => el.dataset.cardId === String(id)) || null;
1169
1164
  }
1170
1165
 
1171
- // A notice is a <details> (a one-line row that opens), a card a <div>: a
1172
- // card that turns into the other kind (btw's "thinking…" → its answer) is
1173
- // drawn on a new element in its place. Returns the element drawn.
1174
- function drawCard(el, card) {
1175
- const notice = isNoticeCard(card);
1176
- if (el.tagName !== (notice ? "DETAILS" : "DIV")) {
1177
- const fresh = document.createElement(notice ? "details" : "div");
1178
- el.replaceWith(fresh);
1179
- el = fresh;
1180
- }
1181
- el.className = cardClass(card);
1182
- el.dataset.cardId = card.id;
1183
- // A running turn's own card (the stage's status waits on one that asks).
1184
- if (card.in_turn) el.dataset.inTurn = "1";
1185
- else delete el.dataset.inTurn;
1186
- if (notice) {
1187
- const open = el.open;
1188
- el.innerHTML = noticeInnerHtml(card);
1189
- el.open = open;
1190
- } else if (card.body_html === undefined && el.dataset.body === String(card.body ?? "") && el.querySelector(".card-body")) {
1191
- // The same body again from the stream: keep the rendered one.
1192
- const body = el.querySelector(".card-body").innerHTML;
1193
- el.innerHTML = cardInnerHtml({ ...card, body_html: body });
1194
- } else {
1195
- el.innerHTML = cardInnerHtml(card);
1196
- }
1197
- el.dataset.body = String(card.body ?? "");
1198
- return el;
1199
- }
1200
-
1201
1166
  // Show +card+: update the one with its id where it is, else add it (a
1202
1167
  // running turn's card as a row of its step; else before +before+ or at the
1203
1168
  // end of the history). A running turn's card with actions asks the user
@@ -1299,12 +1264,15 @@ function turnAnswerEl(turnEls) {
1299
1264
  function placeResolvedQuestion(entry, before) {
1300
1265
  const pq = entry.pending_question;
1301
1266
  if (!pq?.id || !(entry.answer || entry.cancelled)) return;
1302
- questionCards.renderQuestion(pq);
1267
+ const turnEls = reloadedTurnEls(before);
1268
+ const asked = Number(entry.iteration) || 1;
1269
+ // The reply that asked: its step's text in the reloaded block (the card
1270
+ // keeps it, as live), never the running turn's.
1271
+ const said = stepText(turnEls.find((el) => el.matches(".turn-work")), asked);
1272
+ questionCards.renderQuestion(pq, { said });
1303
1273
  const card = questionCards.questionCard();
1304
1274
  if (!card || card.dataset.qid !== String(pq.id)) return;
1305
1275
  questionCards.resolveQuestion(pq.id, { answer: entry.answer || null, cancelled: !!entry.cancelled, reason: entry.reason || "" });
1306
- const turnEls = reloadedTurnEls(before);
1307
- const asked = Number(entry.iteration) || 1;
1308
1276
  const later = turnEls.find((el) => el.matches(".bubble.user.steered") && Number(el.dataset.step) > asked);
1309
1277
  historyEl.insertBefore(card, later || turnAnswerEl(turnEls) || before);
1310
1278
  }
@@ -1321,8 +1289,7 @@ function refreshCardBodies() {
1321
1289
  if (id !== selected) return;
1322
1290
  for (const card of data.cards || []) {
1323
1291
  const el = card.type === "card" && cardElement(card.id);
1324
- const body = el && el.querySelector(".card-body");
1325
- if (body && typeof card.body_html === "string" && el.dataset.body === String(card.body ?? "")) body.innerHTML = card.body_html;
1292
+ if (el) refreshCardBody(el, card);
1326
1293
  }
1327
1294
  } catch (_) {}
1328
1295
  }, 150);
@@ -1509,6 +1476,7 @@ function currentSessionDuration() {
1509
1476
 
1510
1477
  // The stage takes a running turn's rows (its extras); else the history.
1511
1478
  function appendToHistory(el) {
1479
+ // (The stage's place first moves a turn still sliding away into the history.)
1512
1480
  if (turnDom.place?.(el)) return;
1513
1481
  appendAboveLiveTiming(historyEl, el, liveTurn.timing.el);
1514
1482
  }
@@ -1876,9 +1844,7 @@ function commandBubble(commandId) {
1876
1844
 
1877
1845
  function addNoteBubble(note) {
1878
1846
  removeHintIfEmpty();
1879
- const div = document.createElement("div");
1880
- div.className = "bubble note";
1881
- div.innerHTML = noteHtml(note);
1847
+ const div = elementOf(noteBubbleTemplate(note));
1882
1848
  const follow = isNearBottom();
1883
1849
  appendToHistory(div);
1884
1850
  if (follow) historyEl.scrollTop = historyEl.scrollHeight;
@@ -1958,7 +1924,7 @@ const lightboxEl = $("#lightbox");
1958
1924
  let chips = [];
1959
1925
 
1960
1926
  function renderChips() {
1961
- chipsEl.innerHTML = chipsHtml(chips);
1927
+ render(chipsTemplate(chips), chipsEl);
1962
1928
  chipsEl.hidden = !chips.length;
1963
1929
  }
1964
1930
 
@@ -2039,15 +2005,51 @@ fitPrompt();
2039
2005
  // The card floats over the history: keep the history's bottom padding at the
2040
2006
  // card's height, and stay pinned to the end if the reader was there, since the
2041
2007
  // card grows (text, chips, Cancel, a wrapping footer) under a reader at the end.
2008
+ // In the stage view the stage floats above the card, out of the dock's flow
2009
+ // (stage_view.js): the reserve counts it too, with its 10 px gap, while it
2010
+ // shows.
2042
2011
  const dockEl = $("#dock");
2043
2012
  const centerEl = $("#center");
2044
- new ResizeObserver(() => {
2045
- // The stage's height too: a reader at the end stays there.
2046
- const follow = atHistoryEnd();
2047
- centerEl.style.setProperty("--dock-h", `${dockEl.offsetHeight}px`);
2048
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
2013
+ // A leaving stage (the hand-off's slide) no longer counts: the reserve eases
2014
+ // down over the slide (index.html, #center.reserve-easing).
2015
+ const stageEl = view === "stage" ? turnDom.root : null;
2016
+ const STAGE_GAP = 10;
2017
+ function dockReserve() {
2018
+ const shown = stageEl && !stageEl.hidden && !stageEl.classList.contains("leaving");
2019
+ return dockEl.offsetHeight + (shown ? stageEl.offsetHeight + STAGE_GAP : 0);
2020
+ }
2021
+ const historyEnd = () => historyEl.scrollHeight - historyEl.clientHeight;
2022
+ // A reader at the end stays there when the reserve changes: at once for the
2023
+ // composer card, eased for the stage (+fromStage+: it appeared, grew or
2024
+ // leaves), so the history glides rather than jumps. An eased follow running
2025
+ // counts as at the end.
2026
+ function updateReserve({ fromStage = false } = {}) {
2027
+ const follow = atHistoryEnd() || historyEl.dataset.following !== undefined;
2028
+ const leaving = !!stageEl?.classList.contains("leaving");
2029
+ centerEl.classList.toggle("reserve-easing", leaving);
2030
+ const reserve = `${dockReserve()}px`;
2031
+ const changed = centerEl.style.getPropertyValue("--dock-h") !== reserve;
2032
+ if (changed) centerEl.style.setProperty("--dock-h", reserve);
2033
+ if (changed && follow) {
2034
+ if (fromStage) easeScrollTo(historyEl, historyEnd, { ms: leaving ? FOLD_MS : FOLLOW_MS, reduce: matchMedia("(prefers-reduced-motion: reduce)").matches });
2035
+ else if (historyEl.dataset.following === undefined) historyEl.scrollTop = historyEl.scrollHeight;
2036
+ }
2049
2037
  placeToast();
2050
- }).observe(dockEl);
2038
+ }
2039
+ const dockObserver = new ResizeObserver((entries) => updateReserve({ fromStage: entries.some((e) => e.target === stageEl) }));
2040
+ dockObserver.observe(dockEl);
2041
+ if (stageEl) {
2042
+ dockObserver.observe(stageEl);
2043
+ // Only showing, hiding and leaving change the reserve (not the stage's
2044
+ // other classes: waiting, in-use…).
2045
+ let stageShown = "";
2046
+ new MutationObserver(() => {
2047
+ const shown = `${stageEl.hidden}|${stageEl.classList.contains("leaving")}`;
2048
+ if (shown === stageShown) return;
2049
+ stageShown = shown;
2050
+ updateReserve({ fromStage: true });
2051
+ }).observe(stageEl, { attributes: true, attributeFilter: ["class", "hidden"] });
2052
+ }
2051
2053
  window.addEventListener("resize", () => placeToast());
2052
2054
 
2053
2055
  promptEl.addEventListener("paste", (e) => {
@@ -2180,7 +2182,7 @@ async function continueSelected() {
2180
2182
  if (selected === id) await select(refusal.id);
2181
2183
  showToast(`${short} is continued already: ${selected === refusal.id ? "opened" : "see"} ${refusal.id.slice(0, 8)}`);
2182
2184
  } else if (refusal.kind === "children") {
2183
- showToast(openChildrenHtml(id, refusal.children, sessionHash), null, { sticky: true, html: true });
2185
+ showToast(openChildrenTemplate(id, refusal.children, sessionHash), null, { sticky: true });
2184
2186
  } else {
2185
2187
  showToast(`Can't continue ${short}: ${refusal.text}`, null, { sticky: true });
2186
2188
  }
@@ -2260,14 +2262,16 @@ async function setArchived(id, archive, { undo = true } = {}) {
2260
2262
 
2261
2263
  // The toast sits above the session's composer card (measured: it grows with
2262
2264
  // a wrapping footer or a taller box), so it never covers the card's
2263
- // controls; with no card in sight (the start page, all sessions) the CSS's
2264
- // 24 px from the bottom.
2265
+ // controls, and above the stage while it shows; with no card in sight (the
2266
+ // start page, all sessions) the CSS's 24 px from the bottom.
2265
2267
  function placeToast() {
2266
2268
  const gap = 12;
2267
2269
  const dock = document.getElementById("dock");
2268
2270
  const r = dock.getBoundingClientRect();
2269
2271
  const over = document.body.classList.contains("has-session") && r.height > 0;
2270
- const bottom = over ? `${Math.round(window.innerHeight - r.top + gap)}px` : "";
2272
+ const stage = document.getElementById("turnStage");
2273
+ const top = stage && !stage.hidden ? Math.min(r.top, stage.getBoundingClientRect().top) : r.top;
2274
+ const bottom = over ? `${Math.round(window.innerHeight - top + gap)}px` : "";
2271
2275
  const root = document.documentElement.style;
2272
2276
  if (root.getPropertyValue("--toast-bottom") !== bottom) {
2273
2277
  if (bottom) root.setProperty("--toast-bottom", bottom);
@@ -2275,24 +2279,20 @@ function placeToast() {
2275
2279
  }
2276
2280
  }
2277
2281
 
2278
- // sticky: stays until its action or its ✕ (else gone after 6 s).
2282
+ // sticky: stays until its action or its ✕ (else gone after 6 s). +text+:
2283
+ // words, or a template (chains.js openChildrenTemplate: a link in it, a
2284
+ // delegate a continue waits for, opens that session and hides the toast).
2279
2285
  let toastTimer = null;
2280
- // html: +text+ is markup this page built (its parts escaped), not words.
2281
- function showToast(text, action = null, { sticky = false, html = false } = {}) {
2286
+ function showToast(text, action = null, { sticky = false } = {}) {
2282
2287
  const el = $("#toast");
2283
- el.innerHTML = `<span>${html ? text : escapeHtml(text)}</span>${action ? `<button type="button" class="toast-action">${escapeHtml(action.label)}</button>` : ""}` +
2284
- (sticky ? `<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss">✕</button>` : "");
2288
+ const hide = () => { el.hidden = true; };
2289
+ render(html`<span @click=${(e) => { if (e.target.closest?.("a[href]")) hide(); }}>${text}</span>${action
2290
+ ? html`<button type="button" class="toast-action" @click=${() => { hide(); action.run(); }}>${action.label}</button>`
2291
+ : nothing}${sticky ? html`<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss" @click=${hide}>✕</button>` : nothing}`, el);
2285
2292
  placeToast();
2286
2293
  el.hidden = false;
2287
- el.querySelector(".toast-action")?.addEventListener("click", () => {
2288
- el.hidden = true;
2289
- action.run();
2290
- });
2291
- el.querySelector(".toast-close")?.addEventListener("click", () => { el.hidden = true; });
2292
- // A link in it (a delegate a continue waits for) opens that session.
2293
- el.querySelectorAll("a[href]").forEach((a) => a.addEventListener("click", () => { el.hidden = true; }));
2294
2294
  clearTimeout(toastTimer);
2295
- if (!sticky) toastTimer = setTimeout(() => { el.hidden = true; }, 6000);
2295
+ if (!sticky) toastTimer = setTimeout(hide, 6000);
2296
2296
  }
2297
2297
 
2298
2298
  // Annotate (annotate_bar.js): a selection in the conversation offers