samagotchi 0.154.0 → 0.155.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 (138) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -1
  3. data/docs/architecture.md +3 -2
  4. data/docs/bundles.md +21 -0
  5. data/docs/cli.md +10 -8
  6. data/docs/guardrails.md +2 -2
  7. data/docs/hooks.md +1 -1
  8. data/docs/internals/anthropic.md +4 -4
  9. data/docs/internals/attached-context.md +8 -6
  10. data/docs/internals/block-renderers.md +8 -1
  11. data/docs/internals/bridge.md +18 -5
  12. data/docs/internals/engine.md +221 -100
  13. data/docs/internals/inline-widgets.md +136 -0
  14. data/docs/internals/objectives.md +8 -6
  15. data/docs/internals/participants.md +5 -5
  16. data/docs/internals/prompt-caching.md +12 -6
  17. data/docs/internals/web-frontend.md +2 -2
  18. data/docs/internals/working-set.md +2 -1
  19. data/docs/plugins.md +2 -0
  20. data/docs/releasing.md +1 -1
  21. data/docs/sessions.md +3 -3
  22. data/docs/testing.md +16 -1
  23. data/lib/samagotchi/answer_text.rb +1 -2
  24. data/lib/samagotchi/block_renderers.rb +5 -0
  25. data/lib/samagotchi/bridge/card_places.rb +56 -7
  26. data/lib/samagotchi/bridge/card_store.rb +24 -9
  27. data/lib/samagotchi/bridge.rb +9 -0
  28. data/lib/samagotchi/bridge_client.rb +9 -5
  29. data/lib/samagotchi/bundle_command/check.rb +1 -1
  30. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  31. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/btw/plugin.rb +1 -1
  33. data/lib/samagotchi/bundles/coordinator/manifest.yml +2 -2
  34. data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
  35. data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
  36. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  37. data/lib/samagotchi/bundles/github-pr/plugin.rb +3 -3
  38. data/lib/samagotchi/bundles/inline-widgets/manifest.yml +11 -0
  39. data/lib/samagotchi/bundles/inline-widgets/plugin.rb +76 -0
  40. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  41. data/lib/samagotchi/bundles/skills/plugin.rb +3 -2
  42. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  43. data/lib/samagotchi/desktop/macos/ChiRunner.swift +1 -1
  44. data/lib/samagotchi/engine/context_judge.rb +19 -125
  45. data/lib/samagotchi/engine/guardrails.rb +4 -2
  46. data/lib/samagotchi/engine/messages.rb +9 -6
  47. data/lib/samagotchi/engine/objectives.rb +22 -90
  48. data/lib/samagotchi/engine/participants.rb +15 -10
  49. data/lib/samagotchi/engine/plugins.rb +3 -1
  50. data/lib/samagotchi/engine/questions.rb +1 -1
  51. data/lib/samagotchi/engine/side_requests.rb +5 -2
  52. data/lib/samagotchi/engine/toolkit.rb +33 -190
  53. data/lib/samagotchi/engine/turn_end.rb +1 -1
  54. data/lib/samagotchi/engine/turn_helpers.rb +16 -39
  55. data/lib/samagotchi/engine/verify.rb +7 -143
  56. data/lib/samagotchi/engine.rb +162 -99
  57. data/lib/samagotchi/engine_peers.rb +1 -1
  58. data/lib/samagotchi/engine_snapshots.rb +9 -8
  59. data/lib/samagotchi/event_fanout.rb +18 -12
  60. data/lib/samagotchi/guardrail_wiring.rb +83 -82
  61. data/lib/samagotchi/guardrails/gate.rb +41 -40
  62. data/lib/samagotchi/guardrails/pair_boundary.rb +2 -1
  63. data/lib/samagotchi/guardrails/review_boundary.rb +2 -1
  64. data/lib/samagotchi/guardrails/targets.rb +6 -4
  65. data/lib/samagotchi/guardrails_command.rb +1 -1
  66. data/lib/samagotchi/idle_recap.rb +3 -1
  67. data/lib/samagotchi/inline_widgets.rb +325 -0
  68. data/lib/samagotchi/kernel_loop.rb +194 -216
  69. data/lib/samagotchi/llm/backend.rb +3 -1
  70. data/lib/samagotchi/llm/chat_loop.rb +66 -42
  71. data/lib/samagotchi/llm/native_backend.rb +3 -2
  72. data/lib/samagotchi/llm_context_forget.rb +5 -3
  73. data/lib/samagotchi/model_binding.rb +3 -3
  74. data/lib/samagotchi/plugin/prompt_sections.rb +7 -0
  75. data/lib/samagotchi/question_desk.rb +1 -1
  76. data/lib/samagotchi/revise_anchor.rb +1 -1
  77. data/lib/samagotchi/session_manager.rb +8 -13
  78. data/lib/samagotchi/session_retention.rb +24 -4
  79. data/lib/samagotchi/steer.rb +12 -1
  80. data/lib/samagotchi/stream_event.rb +22 -0
  81. data/lib/samagotchi/system_prompt.rb +84 -61
  82. data/lib/samagotchi/terminal_ui/attached_view.rb +0 -9
  83. data/lib/samagotchi/terminal_ui/input_support.rb +16 -15
  84. data/lib/samagotchi/terminal_ui/join_replay.rb +16 -12
  85. data/lib/samagotchi/terminal_ui/reline_seam.rb +0 -10
  86. data/lib/samagotchi/terminal_ui/status_row.rb +0 -7
  87. data/lib/samagotchi/testing/boundary.rb +7 -0
  88. data/lib/samagotchi/testing/bundle.rb +7 -5
  89. data/lib/samagotchi/testing/install.rb +3 -2
  90. data/lib/samagotchi/tool_activity.rb +8 -6
  91. data/lib/samagotchi/tool_declarations.rb +4 -4
  92. data/lib/samagotchi/tool_response.rb +4 -3
  93. data/lib/samagotchi/tool_runner.rb +93 -101
  94. data/lib/samagotchi/toolkit_binding.rb +40 -14
  95. data/lib/samagotchi/tools/deferred_tools.rb +17 -21
  96. data/lib/samagotchi/tools/registry.rb +43 -40
  97. data/lib/samagotchi/tools/tool_index.rb +135 -88
  98. data/lib/samagotchi/tools/tool_surface.rb +173 -0
  99. data/lib/samagotchi/turn_context.rb +67 -0
  100. data/lib/samagotchi/turn_flow.rb +7 -2
  101. data/lib/samagotchi/turn_stages/context_judge.rb +170 -0
  102. data/lib/samagotchi/turn_stages/objectives.rb +146 -0
  103. data/lib/samagotchi/turn_stages/toolkit_suggestion.rb +261 -0
  104. data/lib/samagotchi/turn_stages/verify_anchors.rb +169 -0
  105. data/lib/samagotchi/turn_stages/warmup.rb +83 -0
  106. data/lib/samagotchi/turn_state.rb +46 -4
  107. data/lib/samagotchi/version.rb +1 -1
  108. data/lib/samagotchi/web/app.rb +3 -1
  109. data/lib/samagotchi/web/markdown_renderer.rb +39 -14
  110. data/lib/samagotchi/web/message_display.rb +8 -1
  111. data/lib/samagotchi/web/public/app.js +5 -1
  112. data/lib/samagotchi/web/public/block_host.js +74 -199
  113. data/lib/samagotchi/web/public/block_renderers.css +2 -0
  114. data/lib/samagotchi/web/public/block_source.js +7 -4
  115. data/lib/samagotchi/web/public/block_watch.js +256 -0
  116. data/lib/samagotchi/web/public/card_stamps.js +27 -0
  117. data/lib/samagotchi/web/public/cards_place.js +5 -3
  118. data/lib/samagotchi/web/public/composer_fold.js +3 -0
  119. data/lib/samagotchi/web/public/composer_mode.js +1 -1
  120. data/lib/samagotchi/web/public/format.js +3 -2
  121. data/lib/samagotchi/web/public/index.html +28 -9
  122. data/lib/samagotchi/web/public/inline_widgets.js +146 -0
  123. data/lib/samagotchi/web/public/owner_verify.js +1 -1
  124. data/lib/samagotchi/web/public/participant.js +1 -1
  125. data/lib/samagotchi/web/public/revise_stack.js +1 -1
  126. data/lib/samagotchi/web/public/stage_watch.js +6 -2
  127. data/lib/samagotchi/web/public/text_roots.js +1 -1
  128. data/lib/samagotchi/web/public/turn_html.js +8 -4
  129. data/lib/samagotchi/web/public/verify_target.js +1 -1
  130. data/lib/samagotchi/web/public/widget_picks.js +130 -0
  131. data/lib/samagotchi/web/public/widget_walk.js +230 -0
  132. data/lib/samagotchi/web/public/widgets.css +70 -0
  133. data/lib/samagotchi/web/session_access_routes.rb +8 -0
  134. data/lib/samagotchi/web/session_show.rb +4 -0
  135. data/lib/samagotchi/worker.rb +8 -5
  136. data/lib/samagotchi.rb +0 -1
  137. metadata +19 -2
  138. data/lib/samagotchi/pending_input_queue.rb +0 -55
@@ -3,6 +3,7 @@
3
3
  require "nokogiri"
4
4
  require "uri"
5
5
  require_relative "../block_renderers"
6
+ require_relative "../inline_widgets"
6
7
 
7
8
  module Samagotchi
8
9
  module Web
@@ -25,6 +26,12 @@ module Samagotchi
25
26
  extension: { header_ids: nil }
26
27
  }.freeze
27
28
 
29
+ # A code block as the parse tree has it: its language (nil for an indented
30
+ # block or a bare fence) and its literal, the source between the fences.
31
+ Fence = Data.define(:language, :literal)
32
+ # A JSON string's < > & as \u escapes: the same JSON once parsed.
33
+ JSON_MARKUP = { "<" => "\\u003c", ">" => "\\u003e", "&" => "\\u0026" }.freeze
34
+
28
35
  def initialize(enabled: false)
29
36
  @enabled = enabled
30
37
  end
@@ -61,7 +68,7 @@ module Samagotchi
61
68
  })
62
69
  return nil unless html
63
70
 
64
- sanitize(html, fence_languages(markdown.to_s))
71
+ sanitize(html, fences(markdown.to_s))
65
72
  end
66
73
 
67
74
  private
@@ -81,17 +88,17 @@ module Samagotchi
81
88
  @commonmarker_installed = Gem::Specification.find_all_by_name("commonmarker").any?
82
89
  end
83
90
 
84
- # Every code block's language, in document order (nil for an indented block
85
- # or a bare fence): the highlighter drops it from the HTML, the parse tree
86
- # has it. [] for a text with no fence: its blocks have no language.
87
- def fence_languages(markdown)
91
+ # Every code block, in document order: the highlighter drops the language
92
+ # from the HTML, the parse tree has it. [] for a text with no fence: its
93
+ # blocks have no language.
94
+ def fences(markdown)
88
95
  return [] unless markdown.include?("```") || markdown.include?("~~~")
89
96
 
90
- languages = []
97
+ fences = []
91
98
  Commonmarker.parse(markdown, options: OPTIONS).walk do |node|
92
- languages << language_of(node.fence_info) if node.type == :code_block
99
+ fences << Fence.new(language: language_of(node.fence_info), literal: node.string_content) if node.type == :code_block
93
100
  end
94
- languages
101
+ fences
95
102
  end
96
103
 
97
104
  def language_of(info)
@@ -100,16 +107,34 @@ module Samagotchi
100
107
  end
101
108
 
102
109
  # After the walk, which drops every attribute it doesn't know. Blocks and
103
- # languages pair by position; a count that differs says the two parses
110
+ # fences pair by position; a count that differs says the two parses
104
111
  # disagree, and then no block gets one (it shows as source).
105
- def mark_languages(fragment, languages)
112
+ def mark_languages(fragment, fences)
106
113
  blocks = fragment.css("pre")
107
- return unless blocks.size == languages.size
114
+ return unless blocks.size == fences.size
115
+
116
+ blocks.zip(fences) do |pre, fence|
117
+ next unless fence.language
118
+
119
+ pre["data-lang"] = fence.language
120
+ mark_widget(pre, fence.literal) if fence.language == InlineWidgets::LANG
121
+ end
122
+ end
108
123
 
109
- blocks.zip(languages) { |pre, language| pre["data-lang"] = language if language }
124
+ # A chi-ui block carries what the page draws or why it doesn't. The JSON
125
+ # is an attribute value (Nokogiri escapes its quotes), and its < > & are
126
+ # \u escapes, so the HTML holds no model markup even as attribute text.
127
+ def mark_widget(pre, literal)
128
+ widget = InlineWidgets.parse(literal)
129
+ if widget.ok?
130
+ pre["data-widget"] = widget.widget_json.gsub(/[<>&]/, JSON_MARKUP)
131
+ else
132
+ pre["data-widget-error"] = widget.error
133
+ end
134
+ pre["data-widget-lenient"] = "" if widget.lenient?
110
135
  end
111
136
 
112
- def sanitize(html, languages)
137
+ def sanitize(html, fences)
113
138
  fragment = Nokogiri::HTML5.fragment(html)
114
139
  fragment.traverse do |node|
115
140
  next unless node.element?
@@ -122,7 +147,7 @@ module Samagotchi
122
147
  sanitize_attributes(node)
123
148
  end
124
149
  end
125
- mark_languages(fragment, languages)
150
+ mark_languages(fragment, fences)
126
151
  fragment.to_html
127
152
  end
128
153
 
@@ -134,6 +134,9 @@ module Samagotchi
134
134
  end
135
135
  next if role == "system"
136
136
  next if role == "tool_response"
137
+ # KernelLoop's recovery line: a row of its turn the page showed as
138
+ # nothing live (no turn opens with it, Steer.recovery?).
139
+ next if Samagotchi::Steer.recovery?(m)
137
140
 
138
141
  stripped = Samagotchi::OutputFormatter.strip_markup(content)
139
142
  did = parts && %w[model assistant].include?(role) ? message_parts(list, index, cwd, marks) : nil
@@ -165,7 +168,11 @@ module Samagotchi
165
168
  # record by this, not by its place.
166
169
  turn_id = m[:turn_id] || m["turn_id"]
167
170
  message[:turn_id] = turn_id.to_s if norm_role == "user" && turn_id
168
- # The turn it revised (a Revise stack, Engine#stamp_opening), and
171
+ # An opening no turn ran (TurnFlow#abort_continue!'s reason): its own
172
+ # id, which a card's stamp names (Bridge::CardPlaces#opening_of).
173
+ opening_id = m[:opening_id] || m["opening_id"]
174
+ message[:opening_id] = opening_id.to_s if norm_role == "user" && opening_id && !turn_id && !message[:merged]
175
+ # The turn it revised (a Revise stack, TurnStages::VerifyAnchors#stamp_opening), and
169
176
  # whether it was a Verify turn (the page offers no Revise on its
170
177
  # answer).
171
178
  if norm_role == "user" && turn_id && !message[:merged]
@@ -71,6 +71,7 @@ import { createAnnotateBar } from "./annotate_bar.js";
71
71
  import { VERIFY_PRESET, VERIFY_UNAVAILABLE, verifyWorkerUnavailable, withVerify } from "./verify_preset.js";
72
72
  import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
73
73
  import { createBlockHost } from "./block_host.js";
74
+ import { createWidgetDrawer } from "./inline_widgets.js";
74
75
  import { copyText, installCopy } from "./copy.js";
75
76
  import { installAnswerFold, sweepAnswerFolds } from "./answer_fold.js";
76
77
  import { installDetailsMotion } from "./details_motion.js";
@@ -1856,7 +1857,10 @@ historyEl.addEventListener("click", (e) => {
1856
1857
  });
1857
1858
  turnDom.scrollEl.addEventListener("scroll", annotateBar.schedule, true);
1858
1859
  turnRoots().forEach((root) => installCopy(root));
1859
- createBlockHost({ roots: turnRoots(), sessionId: () => store.selected });
1860
+ // chi-ui blocks (inline_widgets.js) are a core drawer, ahead of the bundles'
1861
+ // renderers; a picks' Fill reply only fills the composer.
1862
+ const widgets = createWidgetDrawer({ roots: turnRoots(), composerText: () => promptEl.value, fillComposer });
1863
+ createBlockHost({ roots: turnRoots(), sessionId: () => store.selected, drawers: [widgets] });
1860
1864
  // A steps block, a thinking block and the recap open and close by hand
1861
1865
  // with their height animated (details_motion.js), in the history and on
1862
1866
  // the stage.
@@ -1,8 +1,6 @@
1
- // Draws a bundle's fenced blocks in the page (docs/internals/block-renderers.md).
2
- // One observer per turn root, filtered to TEXTS, as copy.js and answer_fold.js:
3
- // every path that fills a root (a reload, a session switch, a turn's end, an
4
- // answer_display, Revise's clones, a step's swap) arrives the same way, and
5
- // cards, the asking slot and question replies are never touched.
1
+ // Draws a bundle's fenced blocks in the page (docs/internals/block-renderers.md),
2
+ // in a hidden frame per language. Finding and watching the blocks is
3
+ // block_watch.js's; this is its frame drawer, after any core drawers.
6
4
  //
7
5
  // The DOM is the state: a <figure class="block-drawn"> right before the <pre>
8
6
  // (block_source.js) says it is drawn, so a Revise clone needs nothing, and
@@ -16,16 +14,13 @@
16
14
  import { html, render as litRender } from "./vendor/lit-html.js";
17
15
  import { createBlockFrame, FRAME_TIMEOUT_MS } from "./block_frame.js";
18
16
  import { DRAWN, figureOf, setSourceShown } from "./block_source.js";
17
+ import { createBlockWatch, failBlock, holdingView } from "./block_watch.js";
19
18
  import { copyText } from "./copy.js";
20
- import { keepFollowing, shouldFollowScroll } from "./scroll.js";
21
- import { TEXTS } from "./text_roots.js";
22
19
 
23
- const BLOCKS = "pre[data-lang]";
24
20
  // MessageDisplay::MAX_RENDER_BYTES's number: a source over it stays source.
25
21
  export const MAX_SOURCE_BYTES = 64 * 1024;
26
22
  export const CACHE_SIZE = 200;
27
23
  export const MARK_PREFIX = "chi_block_rendering:";
28
- const IO_MARGIN = "600px 0px";
29
24
  const MAX_RETRIES = 5;
30
25
  // The biggest picture a page shows, whatever the renderer: a model-written
31
26
  // directive can ask Mermaid for an SVG tens of millions of px tall, which breaks
@@ -126,43 +121,6 @@ export function currentTheme(doc = document, win = window) {
126
121
  };
127
122
  }
128
123
 
129
- // The nearest scroller holding +el+, +root+ at the latest. IntersectionObserver
130
- // needs it named: with root:null a rootMargin doesn't pass an overflow:auto
131
- // between, so a block just under #history's fold wouldn't count.
132
- function scrollerOf(el, root, win) {
133
- for (let at = el.parentElement; at && at !== root; at = at.parentElement) {
134
- const { overflowY } = win.getComputedStyle(at);
135
- if (overflowY === "auto" || overflowY === "scroll") return at;
136
- }
137
- return root;
138
- }
139
-
140
- // One IntersectionObserver per scroller. +el+ moved to another scroller (the
141
- // stage's hand-off to the history) moves with it.
142
- function ioWatcher(win) {
143
- const observers = new Map();
144
- const watched = new WeakMap();
145
- return (el, scroller, onNear) => {
146
- let io = observers.get(scroller);
147
- if (!io) {
148
- io = new win.IntersectionObserver((entries) => {
149
- for (const entry of entries) {
150
- if (!entry.isIntersecting) continue;
151
- io.unobserve(entry.target);
152
- const near = watched.get(entry.target);
153
- watched.delete(entry.target);
154
- near?.onNear();
155
- }
156
- }, { root: scroller, rootMargin: IO_MARGIN });
157
- observers.set(scroller, io);
158
- }
159
- const before = watched.get(el);
160
- if (before && before.io !== io) before.io.unobserve(el);
161
- watched.set(el, { io, onNear });
162
- io.observe(el);
163
- };
164
- }
165
-
166
124
  function guardedStorage(get) {
167
125
  const use = (fn, fallback) => { try { return fn(get()); } catch (_) { return fallback; } };
168
126
  return {
@@ -172,26 +130,22 @@ function guardedStorage(get) {
172
130
  };
173
131
  }
174
132
 
175
- // @param roots the turn roots (app.js turnRoots())
176
- // @param sessionId () => the open session's id: the frames go on a change, to
177
- // limit what they hold (docs/internals/block-renderers.md, residual risk)
178
- // The rest are seams for the specs.
179
- export function createBlockHost({
180
- roots, sessionId, listRenderers = fetchRendererList, fetchFile = fetchRendererFile, makeFrame = createBlockFrame,
133
+
134
+ // The drawer of a bundle's renderers (block_watch.js has the contract): one
135
+ // frame per language, waiting for /api/block_renderers to say who draws what.
136
+ // @param roots the turn roots, for the view held and the zoom's listeners
137
+ // The rest are createBlockHost's.
138
+ export function createFrameDrawer({
139
+ roots, listRenderers = fetchRendererList, fetchFile = fetchRendererFile, makeFrame = createBlockFrame,
181
140
  decodeImage = async (url) => { const img = new win.Image(); img.src = url; await img.decode(); return { width: img.naturalWidth, height: img.naturalHeight }; },
182
- decodeTimeoutMs = DECODE_TIMEOUT_MS, theme = currentTheme, copy = copyText,
183
- watch = null, doc = document, win = window, storage = guardedStorage(() => globalThis.sessionStorage),
141
+ decodeTimeoutMs = DECODE_TIMEOUT_MS, theme = currentTheme,
142
+ doc = document, win = window, storage = guardedStorage(() => globalThis.sessionStorage),
184
143
  }) {
185
- const near = watch || ioWatcher(win);
186
144
  const cache = new Map();
187
145
  const frames = new Map();
188
146
  // A language whose renderer didn't load, and why: until the session changes,
189
147
  // its blocks don't fetch it again one by one.
190
148
  const broken = new Map();
191
- // Blocks with a draw on its way (not a drawn state: that is the figure): pre =>
192
- // {target, scroller, start, drawing}. Until it starts, a block whose scroller
193
- // changed (the stage's hand-off to the history) gets watched there.
194
- const waiting = new WeakMap();
195
149
  // The marks of the renders running now: mark => when it started. Such a mark isn't
196
150
  // a leftover from an earlier load.
197
151
  const inFlight = new Map();
@@ -199,17 +153,19 @@ export function createBlockHost({
199
153
  // render has its blocks wait for that one, and each draws into whichever is connected.
200
154
  const coming = new Map();
201
155
  const retries = new WeakMap();
156
+ // What needsDraw checked, so draw draws that: pre => {lang, source, renderer}.
157
+ const checked = new WeakMap();
202
158
  let list = null;
203
159
  let listLoad = null;
204
- let session = sessionId();
205
160
 
206
- function withRenderers(fn) {
207
- if (list) { fn(list); return; }
161
+ // The list once loaded; till then a promise of it, asked for once per page load.
162
+ function renderers() {
163
+ if (list) return list;
208
164
  listLoad ??= listRenderers().then(
209
- (renderers) => { list = new Map((renderers || []).map((r) => [r.lang, r])); return list; },
165
+ (got) => { list = new Map((got || []).map((r) => [r.lang, r])); return list; },
210
166
  () => { list = new Map(); return list; }, // a failed list: no drawing
211
167
  );
212
- listLoad.then(fn);
168
+ return listLoad;
213
169
  }
214
170
 
215
171
  function dropFrames() {
@@ -218,13 +174,6 @@ export function createBlockHost({
218
174
  broken.clear();
219
175
  }
220
176
 
221
- function noticeSession() {
222
- const now = sessionId();
223
- if (now === session) return;
224
- session = now;
225
- dropFrames();
226
- }
227
-
228
177
  function frameFor(lang, renderer) {
229
178
  let frame = frames.get(lang);
230
179
  if (frame && !frame.dead()) return frame;
@@ -242,24 +191,7 @@ export function createBlockHost({
242
191
 
243
192
  const cacheKey = (lang, themeName, source) => `${lang}\u0000${themeName}\u0000${source}`;
244
193
  const markKey = (lang, source) => `${MARK_PREFIX}${lang}:${sourceHash(source)}`;
245
-
246
- function rootOf(el) {
247
- return roots.find((root) => root.contains(el)) || roots[0];
248
- }
249
-
250
- // +mutate+ with the view held: a viewer at the bottom stays there, and a
251
- // block above a reader's view keeps what they read where it is. Browsers that
252
- // anchor scrolling have done it by the second read, and there is nothing left.
253
- function holdingView(pre, mutate, bottomOf) {
254
- const scroller = scrollerOf(pre, rootOf(pre), win);
255
- if (shouldFollowScroll(scroller)) return keepFollowing(scroller, mutate);
256
- const before = pre.getBoundingClientRect().bottom;
257
- if (before > scroller.getBoundingClientRect().top) return mutate();
258
- const result = mutate();
259
- const after = bottomOf();
260
- if (after !== before) scroller.scrollTop += after - before;
261
- return result;
262
- }
194
+ const langOf = (pre) => pre.getAttribute("data-lang");
263
195
 
264
196
  function setMark(mark) {
265
197
  storage.set(mark);
@@ -347,28 +279,10 @@ export function createBlockHost({
347
279
  return;
348
280
  }
349
281
  const figure = makeFigure(picture, themeName, lang);
350
- holdingView(pre, () => {
282
+ holdingView(pre, roots, () => {
351
283
  pre.before(figure);
352
284
  setSourceShown(figure, pre, false);
353
- }, () => figure.getBoundingClientRect().bottom);
354
- }
355
-
356
- // The <pre> stays as it is, under one muted line: never markup (the words are
357
- // a renderer's, driven by the model's input), and shown by CSS, so a bubble's
358
- // textContent, its dedupe and an annotation's text stay what they were (copy.js's
359
- // rule for its buttons).
360
- function fail(pre, lang, message) {
361
- if (!pre.isConnected) return;
362
- pre.dataset.blockFailed = "1";
363
- const note = doc.createElement("div");
364
- note.className = "block-note";
365
- const first = String(message ?? "").split("\n")[0].trim();
366
- const words = `Couldn't draw this ${lang} block${first ? `: ${first}` : ""}`;
367
- note.dataset.text = words;
368
- note.setAttribute("role", "note");
369
- note.setAttribute("aria-label", words);
370
- note.title = "Click to copy";
371
- pre.after(note);
285
+ }, () => figure.getBoundingClientRect().bottom, win);
372
286
  }
373
287
 
374
288
  function pictureFor(key, lang, source, renderer, now) {
@@ -407,21 +321,37 @@ export function createBlockHost({
407
321
  }
408
322
  }
409
323
 
410
- async function draw(pre, lang, source, renderer) {
324
+ // A claimed language has a renderer: the list is loaded by then.
325
+ function needsDraw(pre, figure) {
326
+ const lang = langOf(pre);
327
+ const source = pre.textContent;
328
+ const now = theme(doc, win);
329
+ if (figure && figure.dataset.theme === now.name) return false;
330
+ const hit = cache.get(cacheKey(lang, now.name, source));
331
+ if (hit) { place(pre, hit, now.name, lang); return false; } // no flash: this frame
332
+ if (!figure) {
333
+ if (new TextEncoder().encode(source).length > MAX_SOURCE_BYTES) { failBlock(pre, lang, "it is over 64 KB"); return false; }
334
+ const mark = markKey(lang, source);
335
+ if (storage.has(mark) && !inFlight.has(mark)) { failBlock(pre, lang, "it hung or took too long the last time"); return false; }
336
+ }
337
+ checked.set(pre, { lang, source, renderer: list.get(lang) });
338
+ return true;
339
+ }
340
+
341
+ async function draw(pre, { reconsider }) {
342
+ const { lang, source, renderer } = checked.get(pre);
411
343
  const now = theme(doc, win);
412
344
  const key = cacheKey(lang, now.name, source);
413
345
  const hit = cache.get(key);
414
- if (hit) { waiting.delete(pre); place(pre, hit, now.name, lang); return; }
415
- if (broken.has(lang)) { waiting.delete(pre); fail(pre, lang, broken.get(lang)); return; }
346
+ if (hit) { place(pre, hit, now.name, lang); return; }
347
+ if (broken.has(lang)) { failBlock(pre, lang, broken.get(lang)); return; }
416
348
  let retry = false;
417
349
  try {
418
350
  place(pre, await pictureFor(key, lang, source, renderer, now), now.name, lang);
419
351
  } catch (e) {
420
352
  // A redraw that failed keeps the picture it has.
421
- if (!e?.dropped && !figureOf(pre)) fail(pre, lang, e?.message);
353
+ if (!e?.dropped && !figureOf(pre)) failBlock(pre, lang, e?.message);
422
354
  retry = !!e?.dropped;
423
- } finally {
424
- waiting.delete(pre);
425
355
  }
426
356
  // Queued behind a frame that died (another block's timeout): a new frame's
427
357
  // turn. A session switch's frames go with their blocks, which aren't connected.
@@ -430,100 +360,45 @@ export function createBlockHost({
430
360
  if (!retry) return;
431
361
  const tries = (retries.get(pre) || 0) + 1;
432
362
  retries.set(pre, tries);
433
- if (tries <= MAX_RETRIES) consider(pre);
434
- else fail(pre, lang, "its renderer keeps stopping");
435
- }
436
-
437
- // A copy.js wrap took the <pre> away from a figure placed first: back with it.
438
- function rejoin(pre) {
439
- if (figureOf(pre) || !pre.hidden) return;
440
- const wrap = pre.parentElement;
441
- const stray = wrap?.classList.contains("code-wrap") && wrap.previousElementSibling;
442
- if (stray?.classList?.contains(DRAWN)) wrap.insertBefore(stray, pre);
443
- }
444
-
445
- function watchFor(entry) {
446
- const scroller = scrollerOf(entry.target, rootOf(entry.target), win);
447
- if (scroller === entry.scroller) return;
448
- entry.scroller = scroller;
449
- near(entry.target, scroller, entry.start);
450
- }
451
-
452
- function consider(pre) {
453
- if (!pre.isConnected || !pre.closest(TEXTS) || pre.dataset.blockFailed) return;
454
- const pending = waiting.get(pre);
455
- if (pending) {
456
- if (!pending.drawing) watchFor(pending);
457
- return;
458
- }
459
- rejoin(pre);
460
- const lang = pre.getAttribute("data-lang");
461
- const source = pre.textContent;
462
- if (!source.trim()) return; // an empty fence is left alone
463
- withRenderers((renderers) => {
464
- const renderer = renderers.get(lang);
465
- if (!renderer || !pre.isConnected || waiting.has(pre) || pre.dataset.blockFailed) return;
466
- const now = theme(doc, win);
467
- const figure = figureOf(pre);
468
- if (figure && figure.dataset.theme === now.name) return;
469
- const hit = cache.get(cacheKey(lang, now.name, source));
470
- if (hit) { place(pre, hit, now.name, lang); return; } // no flash: this frame
471
- if (!figure) {
472
- if (new TextEncoder().encode(source).length > MAX_SOURCE_BYTES) { fail(pre, lang, "it is over 64 KB"); return; }
473
- const mark = markKey(lang, source);
474
- if (storage.has(mark) && !inFlight.has(mark)) { fail(pre, lang, "it hung or took too long the last time"); return; }
475
- }
476
- // A drawn block's <pre> is hidden, which never intersects: its figure does.
477
- const entry = {
478
- target: figure || pre,
479
- scroller: null,
480
- drawing: false,
481
- start: () => { entry.drawing = true; draw(pre, lang, source, renderer); },
482
- };
483
- waiting.set(pre, entry);
484
- watchFor(entry);
485
- });
486
- }
487
-
488
- function scan(el) {
489
- if (el.matches?.(BLOCKS)) consider(el);
490
- for (const pre of el.querySelectorAll?.(BLOCKS) || []) consider(pre);
363
+ if (tries <= MAX_RETRIES) reconsider(pre);
364
+ else failBlock(pre, lang, "its renderer keeps stopping");
491
365
  }
492
366
 
493
367
  for (const root of roots) {
494
- new win.MutationObserver((records) => {
495
- noticeSession();
496
- for (const record of records) {
497
- for (const node of record.addedNodes) if (node.nodeType === 1) scan(node);
498
- }
499
- }).observe(root, { childList: true, subtree: true });
500
- scan(root);
501
-
502
368
  root.addEventListener("mousedown", (e) => {
503
- if (e.target.closest?.(".block-toggle, .block-zoom")) e.preventDefault();
369
+ if (e.target.closest?.(".block-zoom")) e.preventDefault();
504
370
  });
505
371
  // Delegated, so a Revise clone's work too.
506
372
  root.addEventListener("click", (e) => {
507
- const toggle = e.target.closest?.(".block-toggle");
508
- if (toggle) {
509
- e.preventDefault();
510
- e.stopPropagation();
511
- const figure = toggle.parentElement;
512
- setSourceShown(figure, figure.nextElementSibling, !figure.classList.contains("source"));
513
- return;
514
- }
515
- const note = e.target.closest?.(".block-note");
516
- if (note) { copy(note.dataset.text); return; }
517
- if (e.target.closest?.(".block-zoom, .block-img")) {
518
- const figure = e.target.closest(`.${DRAWN}`);
519
- setZoomed(figure, !figure.classList.contains("natural"));
520
- }
373
+ if (!e.target.closest?.(".block-zoom, .block-img")) return;
374
+ const figure = e.target.closest(`.${DRAWN}`);
375
+ setZoomed(figure, !figure.classList.contains("natural"));
521
376
  });
522
377
  }
523
378
 
379
+ return {
380
+ claims(lang) {
381
+ const known = renderers();
382
+ return known.then ? known.then((got) => got.has(lang)) : known.has(lang);
383
+ },
384
+ needsDraw,
385
+ draw,
386
+ sessionChanged: dropFrames,
387
+ dropFrames,
388
+ };
389
+ }
390
+
391
+ // @param roots the turn roots (app.js turnRoots())
392
+ // @param sessionId () => the open session's id: the frames go on a change, to
393
+ // limit what they hold (docs/internals/block-renderers.md, residual risk)
394
+ // @param drawers core drawers, asked before the bundle renderers
395
+ // The rest are seams for the specs.
396
+ export function createBlockHost({ roots, sessionId, drawers = [], copy = copyText, watch = null, win = window, ...frameOpts }) {
397
+ const frames = createFrameDrawer({ roots, win, ...frameOpts });
398
+ const watcher = createBlockWatch({ roots, sessionId, drawers: [...drawers, frames], watch, copy, win });
399
+ // A system theme change redraws through the figure (needsDraw's theme check).
524
400
  win.matchMedia?.("(prefers-color-scheme: dark)").addEventListener?.("change", () => {
525
- for (const root of roots) scan(root);
401
+ for (const root of roots) watcher.scan(root);
526
402
  });
527
-
528
- return { scan, dropFrames };
403
+ return { scan: watcher.scan, dropFrames: frames.dropFrames };
529
404
  }
@@ -8,6 +8,8 @@
8
8
  .block-img{display:block;max-width:100%;max-height:50000px;object-fit:contain;height:auto;margin:0 auto;cursor:zoom-in}
9
9
  .block-drawn.natural .block-img{max-width:50000px;margin:0;cursor:zoom-out}
10
10
  .block-drawn.source .block-scroll{display:none}
11
+ /* A chi-ui widget (inline_widgets.js): its host holds the shadow root, whose own sheet is widgets.css; the source hides it as the picture. */
12
+ .block-drawn.source>.widget-host{display:none}
11
13
  .block-toggle,.block-zoom{position:absolute;top:5px;right:5px;z-index:1;display:inline-flex;align-items:center;justify-content:center;height:24px;min-width:24px;padding:0 5px;border-radius:6px;background:var(--bg-raised);border:1px solid var(--border-strong);color:var(--text-3);opacity:0;transition:opacity .12s}
12
14
  .code-wrap>.block-drawn>.block-toggle{right:34px}
13
15
  .block-zoom{right:34px}
@@ -1,4 +1,4 @@
1
- // A drawn block's two faces (block_host.js): the picture, or its source. The
1
+ // A drawn block's two faces (block_watch.js, block_host.js): the picture, or its source. The
2
2
  // source <pre> stays in the DOM right after the figure, `hidden` while the
3
3
  // picture shows, so whatever reads text (a claim's walk, copy) sees what it
4
4
  // always did. The DOM is the state: a Revise clone carries both with it.
@@ -18,15 +18,18 @@ export function figureOf(pre) {
18
18
  return before?.classList?.contains(DRAWN) ? before : null;
19
19
  }
20
20
 
21
- // The source shown (and the picture hidden), or the other way round.
21
+ // The source shown (and the picture hidden), or the other way round. A
22
+ // figure that isn't a picture names its own face (data-toggle-picture: a
23
+ // chi-ui widget's "Show the widget").
22
24
  export function setSourceShown(figure, pre, shown) {
23
25
  pre.hidden = !shown;
24
26
  figure.classList.toggle("source", shown);
25
27
  const toggle = figure.querySelector(":scope > .block-toggle");
26
28
  if (!toggle) return;
27
29
  toggle.setAttribute("aria-pressed", shown ? "true" : "false");
28
- toggle.setAttribute("aria-label", shown ? HIDE : SHOW);
29
- toggle.title = shown ? HIDE : SHOW;
30
+ const hide = figure.dataset.togglePicture || HIDE;
31
+ toggle.setAttribute("aria-label", shown ? hide : SHOW);
32
+ toggle.title = shown ? hide : SHOW;
30
33
  }
31
34
 
32
35
  // A jump that lands in a drawn block's source shows it (history_reveal.js).