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
@@ -0,0 +1,256 @@
1
+ // Finds and watches the fenced blocks a drawer may draw (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. A block is
6
+ // drawn when it comes near its scroller's view (a closed <details> opening is
7
+ // such a coming near), and one that moves to another scroller while it waits
8
+ // (the stage's hand-off to #history) is watched there.
9
+ //
10
+ // What draws is a drawer's: block_host.js's frames for a bundle's renderers,
11
+ // after any core drawers. The DOM is the state, as block_source.js says: a
12
+ // figure right before the <pre> says it is drawn, data-block-failed on the
13
+ // <pre> says it isn't to be tried again.
14
+ //
15
+ // The drawer contract:
16
+ // - claims(lang): true when it draws this language. A Promise of that when it
17
+ // can't say yet (the bundle drawer's list): the next drawer is asked only
18
+ // after it says no. The first that claims draws; a language no drawer claims
19
+ // is left alone.
20
+ // - needsDraw(pre, figure): whether the block is to be watched and drawn.
21
+ // +figure+ is its drawn figure or null: a drawn block comes here too (the
22
+ // frame drawer redraws one in another theme). It may settle the block on the
23
+ // spot (a cache hit placed, a refusal's line) and say false.
24
+ // - draw(pre, { reconsider }): draws it now; a Promise while it runs, during
25
+ // which the block isn't watched again. reconsider(pre) has the watcher look
26
+ // at the block afresh (a draw that has to try again).
27
+ // - sessionChanged() (optional): the open session changed.
28
+ // A draw that throws or rejects fails its block (failBlock, logged); it never
29
+ // stops the other blocks.
30
+ //
31
+ // What a drawer must keep to:
32
+ // - What it draws goes in a <figure class="block-drawn"> placed directly before
33
+ // the <pre> (the <pre> is its nextElementSibling): figureOf, the toggle,
34
+ // rejoin and a Revise clone all read it there. The <pre> stays, hidden.
35
+ // - holdingView needs the turn roots: the drawer is given them by its host.
36
+ // - reconsider(pre) called during a synchronous part of draw starts a new draw
37
+ // nested in this one (with a watch that fires at once, as the specs' does).
38
+ //
39
+ // rejoin, failBlock, holdingView and scrollerOf are this module's exports, not
40
+ // block_source.js's: new exports go in new modules (web-frontend.md).
41
+ //
42
+ // A module of its own: static JS is cached at unversioned URLs, and a new
43
+ // export on an old module would blank the page.
44
+
45
+ import { DRAWN, figureOf, setSourceShown } from "./block_source.js";
46
+ import { copyText } from "./copy.js";
47
+ import { keepFollowing, shouldFollowScroll } from "./scroll.js";
48
+ import { TEXTS } from "./text_roots.js";
49
+
50
+ const BLOCKS = "pre[data-lang]";
51
+ const IO_MARGIN = "600px 0px";
52
+
53
+ // The nearest scroller holding +el+, +root+ at the latest. IntersectionObserver
54
+ // needs it named: with root:null a rootMargin doesn't pass an overflow:auto
55
+ // between, so a block just under #history's fold wouldn't count.
56
+ export function scrollerOf(el, root, win = window) {
57
+ for (let at = el.parentElement; at && at !== root; at = at.parentElement) {
58
+ const { overflowY } = win.getComputedStyle(at);
59
+ if (overflowY === "auto" || overflowY === "scroll") return at;
60
+ }
61
+ return root;
62
+ }
63
+
64
+ const rootOf = (roots, el) => roots.find((root) => root.contains(el)) || roots[0];
65
+
66
+ // +mutate+ with the view held: a viewer at the bottom stays there, and a
67
+ // block above a reader's view keeps what they read where it is. Browsers that
68
+ // anchor scrolling have done it by the second read, and there is nothing left.
69
+ // @param bottomOf () => the inserted thing's bottom, after +mutate+
70
+ export function holdingView(pre, roots, mutate, bottomOf, win = window) {
71
+ const scroller = scrollerOf(pre, rootOf(roots, pre), win);
72
+ if (shouldFollowScroll(scroller)) return keepFollowing(scroller, mutate);
73
+ const before = pre.getBoundingClientRect().bottom;
74
+ if (before > scroller.getBoundingClientRect().top) return mutate();
75
+ const result = mutate();
76
+ const after = bottomOf();
77
+ if (after !== before) scroller.scrollTop += after - before;
78
+ return result;
79
+ }
80
+
81
+ // The <pre> stays as it is, under one muted line: never markup (the words are
82
+ // a drawer's, driven by the model's input), and shown by CSS, so a bubble's
83
+ // textContent, its dedupe and an annotation's text stay what they were (copy.js's
84
+ // rule for its buttons). A click on the line copies it (the watcher's listener).
85
+ export function failBlock(pre, lang, message) {
86
+ if (!pre.isConnected) return;
87
+ pre.dataset.blockFailed = "1";
88
+ const note = pre.ownerDocument.createElement("div");
89
+ note.className = "block-note";
90
+ const first = String(message ?? "").split("\n")[0].trim();
91
+ const words = `Couldn't draw this ${lang} block${first ? `: ${first}` : ""}`;
92
+ note.dataset.text = words;
93
+ note.setAttribute("role", "note");
94
+ note.setAttribute("aria-label", words);
95
+ note.title = "Click to copy";
96
+ pre.after(note);
97
+ }
98
+
99
+ // A copy.js wrap took the <pre> away from a figure placed first: back with it.
100
+ export function rejoin(pre) {
101
+ if (figureOf(pre) || !pre.hidden) return;
102
+ const wrap = pre.parentElement;
103
+ const stray = wrap?.classList.contains("code-wrap") && wrap.previousElementSibling;
104
+ if (stray?.classList?.contains(DRAWN)) wrap.insertBefore(stray, pre);
105
+ }
106
+
107
+ // One IntersectionObserver per scroller. +el+ moved to another scroller (the
108
+ // stage's hand-off to the history) moves with it.
109
+ function ioWatcher(win) {
110
+ const observers = new Map();
111
+ const watched = new WeakMap();
112
+ return (el, scroller, onNear) => {
113
+ let io = observers.get(scroller);
114
+ if (!io) {
115
+ io = new win.IntersectionObserver((entries) => {
116
+ for (const entry of entries) {
117
+ if (!entry.isIntersecting) continue;
118
+ io.unobserve(entry.target);
119
+ const near = watched.get(entry.target);
120
+ watched.delete(entry.target);
121
+ near?.onNear();
122
+ }
123
+ }, { root: scroller, rootMargin: IO_MARGIN });
124
+ observers.set(scroller, io);
125
+ }
126
+ const before = watched.get(el);
127
+ if (before && before.io !== io) before.io.unobserve(el);
128
+ watched.set(el, { io, onNear });
129
+ io.observe(el);
130
+ };
131
+ }
132
+
133
+ // @param roots the turn roots (app.js turnRoots())
134
+ // @param sessionId () => the open session's id: a change tells the drawers
135
+ // @param drawers in order: the first that claims a block's language draws it
136
+ // @param watch (el, scroller, onNear) => calls onNear once el comes near; a seam
137
+ // for the specs
138
+ export function createBlockWatch({ roots, sessionId, drawers, watch = null, copy = copyText, win = window }) {
139
+ const near = watch || ioWatcher(win);
140
+ // Blocks with a draw on its way (not a drawn state: that is the figure): pre =>
141
+ // {target, scroller, start, drawing}. Until it starts, a block whose scroller
142
+ // changed (the stage's hand-off to the history) gets watched there.
143
+ const waiting = new WeakMap();
144
+ let session = sessionId();
145
+
146
+ function noticeSession() {
147
+ const now = sessionId();
148
+ if (now === session) return;
149
+ session = now;
150
+ for (const drawer of drawers) drawer.sessionChanged?.();
151
+ }
152
+
153
+ // The drawer claiming +lang+, to +fn+: at once when each one asked can say.
154
+ // A throw in +fn+ after an asynchronous claim goes to +onError+.
155
+ function pick(lang, fn, onError, from = 0) {
156
+ for (let i = from; i < drawers.length; i++) {
157
+ const drawer = drawers[i];
158
+ const claimed = drawer.claims(lang);
159
+ if (claimed?.then) {
160
+ claimed
161
+ .then((yes) => (yes ? fn(drawer) : pick(lang, fn, onError, i + 1)), () => pick(lang, fn, onError, i + 1))
162
+ .catch(onError);
163
+ return;
164
+ }
165
+ if (claimed) { fn(drawer); return; }
166
+ }
167
+ }
168
+
169
+ function watchFor(entry) {
170
+ const scroller = scrollerOf(entry.target, rootOf(roots, entry.target), win);
171
+ if (scroller === entry.scroller) return;
172
+ entry.scroller = scroller;
173
+ near(entry.target, scroller, entry.start);
174
+ }
175
+
176
+ function reconsider(pre) {
177
+ if (waiting.get(pre)?.drawing) waiting.delete(pre);
178
+ consider(pre);
179
+ }
180
+
181
+ // A drawer that throws or rejects fails its block, with its line; the rest of
182
+ // the observer's batch still draws.
183
+ function broke(pre, e) {
184
+ console.error("block drawer:", e);
185
+ if (!pre.dataset.blockFailed) failBlock(pre, pre.getAttribute("data-lang"), e?.message);
186
+ }
187
+
188
+ function start(entry, pre, drawer) {
189
+ entry.drawing = true;
190
+ const over = () => { if (waiting.get(pre) === entry) waiting.delete(pre); };
191
+ let drawing;
192
+ try {
193
+ drawing = drawer.draw(pre, { reconsider });
194
+ } catch (e) {
195
+ over();
196
+ broke(pre, e);
197
+ return;
198
+ }
199
+ Promise.resolve(drawing).then(over, (e) => { over(); broke(pre, e); });
200
+ }
201
+
202
+ function consider(pre) {
203
+ if (!pre.isConnected || !pre.closest(TEXTS) || pre.dataset.blockFailed) return;
204
+ const pending = waiting.get(pre);
205
+ if (pending) {
206
+ if (!pending.drawing) watchFor(pending);
207
+ return;
208
+ }
209
+ rejoin(pre);
210
+ const lang = pre.getAttribute("data-lang");
211
+ if (!pre.textContent.trim()) return; // an empty fence is left alone
212
+ pick(lang, (drawer) => {
213
+ if (!pre.isConnected || waiting.has(pre) || pre.dataset.blockFailed) return;
214
+ const figure = figureOf(pre);
215
+ if (!drawer.needsDraw(pre, figure)) return;
216
+ // A drawn block's <pre> is hidden, which never intersects: its figure does.
217
+ const entry = { target: figure || pre, scroller: null, drawing: false, start: () => start(entry, pre, drawer) };
218
+ waiting.set(pre, entry);
219
+ watchFor(entry);
220
+ }, (e) => broke(pre, e));
221
+ }
222
+
223
+ function scan(el) {
224
+ if (el.matches?.(BLOCKS)) consider(el);
225
+ for (const pre of el.querySelectorAll?.(BLOCKS) || []) consider(pre);
226
+ }
227
+
228
+ for (const root of roots) {
229
+ new win.MutationObserver((records) => {
230
+ noticeSession();
231
+ for (const record of records) {
232
+ for (const node of record.addedNodes) if (node.nodeType === 1) scan(node);
233
+ }
234
+ }).observe(root, { childList: true, subtree: true });
235
+ scan(root);
236
+
237
+ root.addEventListener("mousedown", (e) => {
238
+ if (e.target.closest?.(".block-toggle")) e.preventDefault();
239
+ });
240
+ // Delegated, so a Revise clone's work too.
241
+ root.addEventListener("click", (e) => {
242
+ const toggle = e.target.closest?.(".block-toggle");
243
+ if (toggle) {
244
+ e.preventDefault();
245
+ e.stopPropagation();
246
+ const figure = toggle.parentElement;
247
+ setSourceShown(figure, figure.nextElementSibling, !figure.classList.contains("source"));
248
+ return;
249
+ }
250
+ const note = e.target.closest?.(".block-note");
251
+ if (note) copy(note.dataset.text);
252
+ });
253
+ }
254
+
255
+ return { scan, reconsider };
256
+ }
@@ -0,0 +1,27 @@
1
+ // Where a snapshot's card or notice goes by its stamp: the id of the turn
2
+ // opening it comes after (+opening_id+, Bridge::CardPlaces#opening_of: a
3
+ // prompt's turn_id, a wake note's, /continue no's reason line's own id). The
4
+ // page finds that opening's bubble (data-turn-id on a prompt, data-opening-id
5
+ // on a wake note or a reason line, turn_html.js) and the entry goes before
6
+ // the next turn's opening, or at the end. No counting, so the page and the
7
+ // worker can't disagree on which bubbles open turns. An entry with no stamp
8
+ // (an older worker's, one before every opening), or whose opening the page
9
+ // doesn't draw, falls back to its count (card.js cardPlace, turns_since).
10
+ // A new module: the static JS is unversioned (docs/internals/web-frontend.md).
11
+ import { cardPlace } from "./card.js";
12
+
13
+ // The opening bubble +id+ names among the turns' openings (oldest first,
14
+ // bubbles.js turnPromptBubbles), or -1.
15
+ export function stampedOpening(userBubbles, id) {
16
+ if (!id) return -1;
17
+ const stamp = String(id);
18
+ return userBubbles.findIndex((el) => el.dataset?.turnId === stamp || el.dataset?.openingId === stamp);
19
+ }
20
+
21
+ // The element +entry+ goes before: the opening after its stamped one (null:
22
+ // the end), else what its count says (null: the end too).
23
+ export function snapshotPlace(userBubbles, entry) {
24
+ const at = stampedOpening(userBubbles, entry?.opening_id);
25
+ if (at < 0) return cardPlace(userBubbles, entry);
26
+ return userBubbles[at + 1] || null;
27
+ }
@@ -8,7 +8,8 @@
8
8
  // above the first turn after them, a finished turn's notice in its step, a
9
9
  // finished turn's question resolved where it was asked.
10
10
  import { getSession, sendCommand } from "./data.js";
11
- import { cardPlace, drawCard, isCard, isNoticeCard, refreshCardBody, turnNoticePlace } from "./card.js";
11
+ import { drawCard, isCard, isNoticeCard, refreshCardBody, turnNoticePlace } from "./card.js";
12
+ import { snapshotPlace } from "./card_stamps.js";
12
13
  import { elementOf } from "./turn_html.js";
13
14
  import { stepText } from "./turn_block.js";
14
15
  import { llmContextNotice, noticeLine } from "./turn_events.js";
@@ -83,14 +84,15 @@ export function createCardsPlace({
83
84
  bubbles.placeFollowing(el, before);
84
85
  }
85
86
 
86
- // A snapshot's cards and notices, each above the first turn after it; a
87
+ // A snapshot's cards and notices, each above the first turn after it (the
88
+ // one after its stamped opening, card_stamps.js; else by its count); a
87
89
  // turn's own notice where it showed live (placeTurnNotice). +end+: what
88
90
  // closes the history below them (a failed turn's prompt), for those with
89
91
  // no turn after them; one shown after that prompt went back stays below it.
90
92
  function placeSnapshotCards(entries, { before: end = null } = {}) {
91
93
  const users = turnPromptBubbles(historyEl);
92
94
  for (const entry of entries) {
93
- const before = cardPlace(users, entry) || (entry.after_failed ? null : end);
95
+ const before = snapshotPlace(users, entry) || (entry.after_failed ? null : end);
94
96
  if (isCard(entry)) upsertCard(entry, { before });
95
97
  else if (entry.type === "question") placeResolvedQuestion(entry, before);
96
98
  else if (entry.type === "guardrail_warning") bubbles.showGuardrailWarning(entry.message, entry.label || "guardrails", { before });
@@ -53,7 +53,10 @@ export function opensOnKey(e, { active, allView, lightbox }) {
53
53
  return !editable(active);
54
54
  }
55
55
 
56
+ // A field inside a shadow root (a chi-ui widget's note, inline_widgets.js)
57
+ // counts: document.activeElement is its host, so the look goes through.
56
58
  function editable(el) {
59
+ while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement;
57
60
  if (!el) return false;
58
61
  if (el.isContentEditable) return true;
59
62
  const tag = (el.tagName || "").toUpperCase();
@@ -51,7 +51,7 @@ export function composerMode({ selected, owner, running, narrow }) {
51
51
  if (tui) {
52
52
  return {
53
53
  ...base,
54
- placeholder: pick("A terminal chi owns this session · its one-turn run (chi --non-interactive) lets go as it ends", "A terminal chi owns this session"),
54
+ placeholder: pick("A terminal chi owns this session · a one-turn chi --non-interactive run or an older chi's terminal", "A terminal chi owns this session"),
55
55
  hint: "", button: "Send", disabled: true,
56
56
  };
57
57
  }
@@ -97,13 +97,14 @@ export function deleteConfirmText(session) {
97
97
  }
98
98
 
99
99
  // The badge on a session a process holds right now (the list's `owner`):
100
- // a worker (every UI can send), or a terminal chi's one-turn run (--non-interactive).
100
+ // a worker (every UI can send), or a terminal chi: a one-turn --non-interactive
101
+ // run or an older chi's terminal, in OwnerLock::TUI_BUSY's words.
101
102
  export function ownerBadge(owner, id) {
102
103
  if (owner === "worker") {
103
104
  return { kind: "worker", text: "live", title: `A worker runs this session; a terminal joins with: chi --attach ${id}` };
104
105
  }
105
106
  if (owner === "tui") {
106
- return { kind: "tui", text: "terminal", title: "A terminal chi runs one turn in this session (chi --non-interactive) and lets go as it ends" };
107
+ return { kind: "tui", text: "terminal", title: "In use by a one-turn chi --non-interactive run or an older chi's terminal" };
107
108
  }
108
109
  return null;
109
110
  }
@@ -1119,14 +1119,28 @@ button.ws-row:hover:not(:disabled){filter:none}@media(hover:hover){button.ws-row
1119
1119
  #infoBar .meta .bar-group{display:flex;gap:8px;align-items:center;min-width:0;flex:none}
1120
1120
  #infoBar .meta .bar-sep{flex:none;width:1px;height:14px;background:var(--border-strong)}
1121
1121
  /* Above the phone's one scrolling line the meta is one line too: the telemetry takes the room left and its folder gives
1122
- way first (it already loses its start); a chip is capped, cut with "…", their full text in the title. */
1122
+ way first (it already loses its start), then the model chip, down to 10ch; a chip is capped, cut with "…", their full
1123
+ text in the title. The two groups are column grids: the giving chip's track (minmax(…, max-content)) leaves the
1124
+ other chips as the group's min-content, so a group never shrinks under its fixed chips (they once slid under
1125
+ continue →), and the info keeps that min-width: past it the bar's buttons wrap to a line of their own. */
1123
1126
  @media(min-width:601px){
1127
+ #infoBar .info{min-width:auto}
1124
1128
  #infoBar .meta{flex-wrap:nowrap}
1125
- #infoBar .meta .bar-group.telemetry{flex:1 1 0}
1126
- #infoBar .meta .telemetry>*{flex:none}
1127
- #infoBar .meta .telemetry>.dir{flex:0 1 auto;max-width:48ch}
1129
+ #infoBar .meta .bar-group:is(.setup,.telemetry){display:grid;grid-auto-flow:column;grid-auto-columns:max-content}
1130
+ #infoBar .meta .bar-group.setup{flex:0 1 auto;min-width:auto;grid-template-columns:max-content max-content minmax(10ch,max-content)}
1131
+ #infoBar .meta .bar-group.telemetry{flex:1 1 0;min-width:auto;grid-template-columns:minmax(0,max-content)}
1132
+ #infoBar .meta .telemetry>.dir{max-width:48ch}
1128
1133
  #infoBar .meta .model{max-width:40ch;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1129
1134
  }
1135
+ /* Below 900px the buttons already have a line of their own, and the meta's one line can be wider than the bar: its
1136
+ groups wrap instead of being cut, the telemetry first. The setup's basis is 0 (its min-content, the model at 10ch)
1137
+ and its grow wins up to its max-content, so a group moves down only when the min-contents don't fit; the room left
1138
+ is the telemetry's (its folder). The info never asks for more than the bar. */
1139
+ @media(min-width:601px) and (max-width:899px){
1140
+ #infoBar .info{max-width:100%}
1141
+ #infoBar .meta{flex-wrap:wrap;row-gap:4px}
1142
+ #infoBar .meta .bar-group.setup{flex:1000 1 0;max-width:max-content}
1143
+ }
1130
1144
  /* The plain chips (spans, links) and the status take the button chips' 1px border, transparent and outside their tint, so every chip is one height: a chip wrapping onto a row of plain ones doesn't grow it. */
1131
1145
  #infoBar .meta :is(span,a).model,#infoBar .meta .status{border:1px solid transparent;background-clip:padding-box}
1132
1146
  /* The chips a running turn rewrites (the session clock each second, the last generation's speed, ctx): tabular digits in a reserved width, so a tick ("9.5s" → "10s" → "1m 00s") or a new number doesn't widen the line and move the chips after it. The speed and the clock show from the session's start, a muted placeholder until there is a number. */
@@ -1143,6 +1157,9 @@ button.ws-row:hover:not(:disabled){filter:none}@media(hover:hover){button.ws-row
1143
1157
  #infoBar .meta .worker-restart{padding:1px 8px;border-radius:5px;font-size:11px;font-weight:600;background:var(--bg-raised);color:var(--warn-bright);border:1px solid var(--warn-border)}
1144
1158
  @media(hover:hover){#infoBar .meta .worker-restart:hover:not(:disabled){background:var(--warn-border);color:var(--on-warn)}}
1145
1159
  #infoBar .meta .worker-restart:disabled{opacity:.7;cursor:default}
1160
+ /* The bar's action buttons are one flex item: when they don't fit beside the info line they wrap to a line of their
1161
+ own together, never one button alone. */
1162
+ #infoBar .info-actions{display:flex;flex:none;gap:10px;align-items:center}
1146
1163
  #infoBar button{padding:4px 12px;border-radius:8px;font-size:12px;font-weight:600}
1147
1164
  #infoBar #infoStopBtn{background:var(--bg-raised);color:var(--warn-text);border:1px solid var(--border-strong)}
1148
1165
  @media(hover:hover){#infoBar #infoStopBtn:hover{background:var(--warn-bg);border-color:var(--warn-border)}}
@@ -1368,7 +1385,7 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1368
1385
  #infoBar .meta .bar-group{display:contents}
1369
1386
  #infoBar .meta .bar-sep{display:none}
1370
1387
  #infoBar .meta :is(.tools-chip,.notes-chip,.mem-chip,.ws-chip,.chain-link,.parent,.children-chip,.participants-chip,.dir,.ctx,.speed,.session-time,.worker-badge){display:none}
1371
- #infoBar>button:not(#infoMoreBtn){display:none}
1388
+ #infoBar .info-actions{display:none}
1372
1389
  #infoBar .meta .status+.model{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}
1373
1390
  /* ⚙ alone, the start page's dot when the session set its own (its values in the tooltip and accessible name). */
1374
1391
  #infoBar .meta .settings-chip .sc-own{display:none}
@@ -1835,10 +1852,12 @@ a{color:var(--accent)}
1835
1852
  <div id="infoBar" class="composer-card-foot">
1836
1853
  <div class="info" id="infoText"></div>
1837
1854
  <div id="contextChips" aria-label="Attached context" hidden></div>
1838
- <button class="ghost" id="infoContinueBtn" title="Continue: start the next link of this session's chain from its recap (this one is archived)" style="display:none">continue →</button>
1839
- <button class="ghost" id="infoArchiveBtn" title="Archive: hide from the lists, keep for good" style="display:none">archive</button>
1840
- <button class="ghost" id="infoStopBtn" title="Stop session (terminate worker)" style="display:none">stop session</button>
1841
- <button class="ghost" id="infoDeleteBtn" title="Delete session (for good)" style="display:none">delete</button>
1855
+ <div class="info-actions" id="infoActions">
1856
+ <button class="ghost" id="infoContinueBtn" title="Continue: start the next link of this session's chain from its recap (this one is archived)" style="display:none">continue →</button>
1857
+ <button class="ghost" id="infoArchiveBtn" title="Archive: hide from the lists, keep for good" style="display:none">archive</button>
1858
+ <button class="ghost" id="infoStopBtn" title="Stop session (terminate worker)" style="display:none">stop session</button>
1859
+ <button class="ghost" id="infoDeleteBtn" title="Delete session (for good)" style="display:none">delete</button>
1860
+ </div>
1842
1861
  <button type="button" class="ghost" id="infoMoreBtn" data-anchor-stand-in aria-label="More" title="More" aria-haspopup="dialog" aria-expanded="false"><span aria-hidden="true">⋯</span></button>
1843
1862
  </div>
1844
1863
  </div>
@@ -0,0 +1,146 @@
1
+ // The core drawer of chi-ui blocks (docs/internals/inline-widgets.md): a picks
2
+ // or a walk, drawn from the data the server parsed (the <pre>'s data-widget,
3
+ // InlineWidgets on the server), asked before the bundle renderers by
4
+ // block_watch.js (its header has the drawer contract).
5
+ //
6
+ // What it draws: a <figure class="block-drawn"> right before the <pre>, which
7
+ // stays, hidden (the DOM is the state, block_source.js), holding the shared
8
+ // source toggle and a .widget-host whose open shadow root holds the widget and
9
+ // its stylesheet (widgets.css, fetched once into a constructable sheet the
10
+ // widgets adopt, so none draws unstyled and the view held measures the
11
+ // styled widget). The toggle is in the light DOM: a click in the shadow root
12
+ // reaches the page's delegated listeners retargeted at the host. In a shadow
13
+ // root the widget's words are out of the bubble's textContent, which the
14
+ // shown-texts dedupe, copy's fallback and an annotation's quote read, as with a
15
+ // renderer's <img>.
16
+ //
17
+ // cloneNode doesn't copy a shadow root: a Revise clone (revise_switch.js
18
+ // viewOf) arrives with the figure and an empty host, which needsDraw sees and
19
+ // draw fills again (a fresh widget, its listeners its own).
20
+ //
21
+ // A refused block (data-widget-error) stays source under failBlock's muted line;
22
+ // a <pre> with neither attribute (an older server, web.markdown off never has a
23
+ // data-lang) is left alone. The model's words reach the page as text only.
24
+ //
25
+ // A module of its own: static JS is cached at unversioned URLs.
26
+
27
+ import { html, render as litRender } from "./vendor/lit-html.js";
28
+ import { DRAWN, figureOf, setSourceShown } from "./block_source.js";
29
+ import { failBlock, holdingView } from "./block_watch.js";
30
+ import { drawPicks, withDraft } from "./widget_picks.js";
31
+ import { drawWalk } from "./widget_walk.js";
32
+
33
+ export const LANG = "chi-ui";
34
+ export const HOST = "widget-host";
35
+ export const STYLESHEET = "/assets/widgets.css";
36
+ export const TOGGLE_PICTURE = "Show the widget";
37
+
38
+ // widgets.css as a constructable sheet, fetched once per page; a failed fetch
39
+ // draws unstyled and is tried again by the next draw.
40
+ let shared = null;
41
+ export function loadWidgetSheet(win = window) {
42
+ shared ??= win.fetch(STYLESHEET, { headers: { Accept: "text/css" } })
43
+ .then((res) => {
44
+ if (!res.ok) throw new Error(`widgets.css answered ${res.status}`);
45
+ return res.text();
46
+ })
47
+ .catch((e) => {
48
+ console.error("widgets.css:", e);
49
+ shared = null;
50
+ return "";
51
+ })
52
+ .then((css) => {
53
+ const sheet = new win.CSSStyleSheet();
54
+ sheet.replaceSync(css);
55
+ return sheet;
56
+ });
57
+ return shared;
58
+ }
59
+
60
+ // block_host.js's toggle icon, the same button.
61
+ const ICON = html`<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M5.5 4 2 8l3.5 4M10.5 4 14 8l-3.5 4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
62
+
63
+ const DRAW = {
64
+ walk: (data, { doc }) => drawWalk(data, doc),
65
+ picks: (data, { doc, fill }) => drawPicks(data, { doc, fill }),
66
+ };
67
+
68
+ // The widget's data on +pre+.
69
+ // @throws Error it isn't JSON of a known type
70
+ export function widgetData(pre) {
71
+ const data = JSON.parse(pre.getAttribute("data-widget"));
72
+ if (!data || !Object.hasOwn(DRAW, data.type)) throw new Error(`no widget of type ${String(data?.type).slice(0, 40)}`);
73
+ return data;
74
+ }
75
+
76
+ // The host of a drawn figure, or null.
77
+ export function hostOf(figure) {
78
+ return figure?.querySelector(`:scope > .${HOST}`) || null;
79
+ }
80
+
81
+ // @param roots the turn roots, for the view held
82
+ // @param composerText () => the composer's text now
83
+ // @param fillComposer (text) => the composer filled with +text+ (app.js's)
84
+ // @param sheet () => a Promise of the widgets' CSSStyleSheet (a seam for the specs)
85
+ export function createWidgetDrawer({ roots, composerText, fillComposer, doc = document, win = window, sheet = () => loadWidgetSheet(win) }) {
86
+ const fill = (reply) => fillComposer(withDraft(composerText(), reply));
87
+
88
+ function fillHost(host, data, styles) {
89
+ const shadow = host.shadowRoot || host.attachShadow({ mode: "open" });
90
+ shadow.adoptedStyleSheets = [styles];
91
+ shadow.replaceChildren(DRAW[data.type](data, { doc, fill }));
92
+ }
93
+
94
+ function makeFigure(data) {
95
+ const figure = doc.createElement("figure");
96
+ figure.className = `${DRAWN} widget`;
97
+ figure.dataset.widgetType = data.type;
98
+ figure.dataset.togglePicture = TOGGLE_PICTURE;
99
+ const host = doc.createElement("div");
100
+ host.className = HOST;
101
+ const toggle = doc.createElement("button");
102
+ toggle.type = "button";
103
+ toggle.className = "block-toggle";
104
+ litRender(ICON, toggle);
105
+ figure.append(host, toggle);
106
+ return figure;
107
+ }
108
+
109
+ function needsDraw(pre, figure) {
110
+ if (figure) return !hostOf(figure)?.shadowRoot;
111
+ if (pre.hasAttribute("data-widget-error")) {
112
+ failBlock(pre, LANG, pre.getAttribute("data-widget-error"));
113
+ return false;
114
+ }
115
+ return pre.hasAttribute("data-widget");
116
+ }
117
+
118
+ // After the sheet: the data is read first, so a bad block fails at once.
119
+ function draw(pre) {
120
+ const data = widgetData(pre);
121
+ return sheet().then((styles) => place(pre, data, styles));
122
+ }
123
+
124
+ function place(pre, data, styles) {
125
+ if (!pre.isConnected) return;
126
+ const drawn = figureOf(pre);
127
+ if (drawn) {
128
+ let host = hostOf(drawn);
129
+ if (!host) {
130
+ host = doc.createElement("div");
131
+ host.className = HOST;
132
+ drawn.prepend(host);
133
+ }
134
+ if (!host.shadowRoot) fillHost(host, data, styles);
135
+ return;
136
+ }
137
+ const figure = makeFigure(data);
138
+ fillHost(hostOf(figure), data, styles);
139
+ holdingView(pre, roots, () => {
140
+ pre.before(figure);
141
+ setSourceShown(figure, pre, false);
142
+ }, () => figure.getBoundingClientRect().bottom, win);
143
+ }
144
+
145
+ return { claims: (lang) => lang === LANG, needsDraw, draw };
146
+ }
@@ -98,7 +98,7 @@ export function createOwnerVerify({ historyEl, store, api, claim, liveTurn, toas
98
98
  }
99
99
 
100
100
  // Verify's claim: a selection in an ended turn's final answer (the only
101
- // text Engine#verified_anchor takes; verify_target.js), else null and no
101
+ // text TurnStages::VerifyAnchors#verified takes; verify_target.js), else null and no
102
102
  // Verify on the bar.
103
103
  function anchorFor({ source, range }) {
104
104
  if (source?.kind !== "answer" || !range) return null;
@@ -690,7 +690,7 @@ const annotateBar = createAnnotateBar({
690
690
  // Verify's claim: a selection in an ended turn's final reply (the last
691
691
  // reply bubble after the turn's opening, as the owner's page and
692
692
  // placeVerdict count turns: verify_target.js), the only text
693
- // Engine#verified_anchor takes; else null and no Verify on the bar. Not an
693
+ // TurnStages::VerifyAnchors#verified takes; else null and no Verify on the bar. Not an
694
694
  // earlier reply, a line merged mid-turn, a prompt, the running turn or the
695
695
  // Plan.
696
696
  function verifyAnchorFor({ source, range }) {
@@ -1,6 +1,6 @@
1
1
  // Revise stacks (web): a Revise turn rewrites an earlier turn's answer, and
2
2
  // the server stamps its prompt with `revises: {turn_id}` once it completed
3
- // with an answer (Engine#stamp_opening; never derived here). The page draws
3
+ // with an answer (TurnStages::VerifyAnchors#stamp_opening; never derived here). The page draws
4
4
  // each stack v1 ← v2 ← … ← top in place:
5
5
  //
6
6
  // - every superseded turn (its opening, its work, its answer: turn_range.js's
@@ -28,14 +28,18 @@ export function createInUseWatch({ stage, watching, onTick, onDue }) {
28
28
  }
29
29
 
30
30
  function inUse(nowMs) {
31
- const active = document.activeElement;
31
+ // Through shadow roots (a chi-ui widget, inline_widgets.js): the host
32
+ // is document.activeElement, the focused element is inside it.
33
+ let active = document.activeElement;
34
+ while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
32
35
  const sel = window.getSelection();
33
36
  return inUseFrom({
34
37
  pointerIn,
35
38
  lastTouchMs: lastTouch,
36
39
  // A clicked button keeps the focus in Chrome: only keyboard focus and
37
40
  // a field being typed in count.
38
- focusInside: !!active && stage.contains(active) && active.matches(":focus-visible"),
41
+ focusInside: !!active && stage.contains(document.activeElement)
42
+ && (/^(INPUT|TEXTAREA|SELECT)$/.test(active.tagName) || active.matches(":focus-visible")),
39
43
  selectionInside: !!sel && !sel.isCollapsed && stage.contains(sel.anchorNode),
40
44
  nowMs,
41
45
  });
@@ -1,6 +1,6 @@
1
1
  // The texts the page treats as text it wrote out of a model's words: the
2
2
  // answers and the closed steps' texts. Cards, question replies, the asking
3
3
  // slot and delegate reports aren't among them. markdown_place.js links refs on
4
- // these; block_host.js draws in these. A module of its own: static JS is cached
4
+ // these; block_watch.js finds blocks to draw in these. A module of its own: static JS is cached
5
5
  // at unversioned URLs, and a new export on an old module would blank the page.
6
6
  export const TEXTS = ".bubble.output, .gen:not(.live) > .gen-text";