samagotchi 0.66.0 → 0.68.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 (151) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/docs/architecture.md +55 -30
  4. data/docs/cli.md +3 -1
  5. data/docs/configuration.md +50 -9
  6. data/docs/context.md +35 -1
  7. data/docs/desktop.md +90 -22
  8. data/docs/guardrails.md +8 -5
  9. data/docs/hooks.md +2 -2
  10. data/docs/internals/anthropic.md +156 -0
  11. data/docs/internals/background-tasks.md +1 -1
  12. data/docs/internals/bundles.md +2 -0
  13. data/docs/internals/engine.md +129 -38
  14. data/docs/internals/gemma4-contract.md +1 -1
  15. data/docs/internals/llm-context-bench.md +5 -4
  16. data/docs/internals/prompt-caching.md +26 -11
  17. data/docs/plugins.md +126 -23
  18. data/docs/sessions.md +6 -6
  19. data/docs/sub-agent.md +4 -1
  20. data/docs/testing.md +18 -4
  21. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  22. data/lib/samagotchi/bootstrap_command.rb +13 -3
  23. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  24. data/lib/samagotchi/bridge.rb +6 -3
  25. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  26. data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
  27. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  28. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  29. data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
  31. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
  33. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  34. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  36. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  37. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  38. data/lib/samagotchi/config.rb +15 -7
  39. data/lib/samagotchi/context_absorber.rb +66 -13
  40. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  41. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  42. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  43. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  44. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  45. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  46. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  47. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  48. data/lib/samagotchi/desktop/macos.rb +84 -13
  49. data/lib/samagotchi/desktop_command.rb +14 -3
  50. data/lib/samagotchi/effective_model.rb +283 -0
  51. data/lib/samagotchi/engine.rb +307 -549
  52. data/lib/samagotchi/engine_peers.rb +3 -0
  53. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  54. data/lib/samagotchi/guardrails/approval.rb +1 -1
  55. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  56. data/lib/samagotchi/guardrails/rules.rb +1 -1
  57. data/lib/samagotchi/guardrails/targets.rb +1 -1
  58. data/lib/samagotchi/hook_routes.rb +151 -0
  59. data/lib/samagotchi/hooks/registry.rb +2 -2
  60. data/lib/samagotchi/host_keys.rb +147 -0
  61. data/lib/samagotchi/level_setting_command.rb +99 -0
  62. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  63. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  64. data/lib/samagotchi/llm/api_key.rb +23 -7
  65. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  66. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  67. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  68. data/lib/samagotchi/llm_context_override.rb +1 -0
  69. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  70. data/lib/samagotchi/peer_sources.rb +58 -0
  71. data/lib/samagotchi/plugin/api.rb +64 -155
  72. data/lib/samagotchi/plugin/loader.rb +4 -2
  73. data/lib/samagotchi/plugin/peer.rb +394 -0
  74. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  75. data/lib/samagotchi/plugin_tasks.rb +2 -2
  76. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  77. data/lib/samagotchi/row_refs.rb +1 -1
  78. data/lib/samagotchi/self_report.rb +5 -3
  79. data/lib/samagotchi/send_command.rb +21 -11
  80. data/lib/samagotchi/session.rb +22 -8
  81. data/lib/samagotchi/session_continue.rb +58 -15
  82. data/lib/samagotchi/session_inbox.rb +0 -13
  83. data/lib/samagotchi/session_setting.rb +86 -0
  84. data/lib/samagotchi/thinking_command.rb +24 -76
  85. data/lib/samagotchi/thinking_notices.rb +169 -0
  86. data/lib/samagotchi/tool_call_parser.rb +1 -1
  87. data/lib/samagotchi/toolkit_binding.rb +138 -0
  88. data/lib/samagotchi/tools/context_read.rb +14 -0
  89. data/lib/samagotchi/tools/execute.rb +12 -12
  90. data/lib/samagotchi/tools/registry.rb +1 -1
  91. data/lib/samagotchi/tools/task_create.rb +6 -6
  92. data/lib/samagotchi/tools/task_get.rb +13 -13
  93. data/lib/samagotchi/tools/task_list.rb +6 -6
  94. data/lib/samagotchi/tools/task_record.rb +52 -0
  95. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  96. data/lib/samagotchi/tools/task_stop.rb +6 -6
  97. data/lib/samagotchi/tools/task_wait.rb +12 -13
  98. data/lib/samagotchi/tools/tool_index.rb +454 -0
  99. data/lib/samagotchi/update_command.rb +9 -6
  100. data/lib/samagotchi/version.rb +1 -1
  101. data/lib/samagotchi/web/app.rb +71 -93
  102. data/lib/samagotchi/web/capabilities.rb +1 -1
  103. data/lib/samagotchi/web/public/activity.js +2 -2
  104. data/lib/samagotchi/web/public/annotate_actions.js +1 -1
  105. data/lib/samagotchi/web/public/app.js +103 -539
  106. data/lib/samagotchi/web/public/bubbles.js +291 -0
  107. data/lib/samagotchi/web/public/cards_place.js +184 -0
  108. data/lib/samagotchi/web/public/chains.js +1 -1
  109. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  110. data/lib/samagotchi/web/public/command_list.js +12 -10
  111. data/lib/samagotchi/web/public/commands.js +2 -2
  112. data/lib/samagotchi/web/public/composer_size.js +2 -2
  113. data/lib/samagotchi/web/public/copy.js +1 -1
  114. data/lib/samagotchi/web/public/ctx.js +1 -1
  115. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  116. data/lib/samagotchi/web/public/families_view.js +1 -1
  117. data/lib/samagotchi/web/public/format.js +11 -7
  118. data/lib/samagotchi/web/public/images.js +25 -2
  119. data/lib/samagotchi/web/public/index.html +15 -7
  120. data/lib/samagotchi/web/public/info_bar.js +3 -3
  121. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  122. data/lib/samagotchi/web/public/init_row.js +21 -10
  123. data/lib/samagotchi/web/public/level_chip.js +29 -80
  124. data/lib/samagotchi/web/public/lists_view.js +64 -9
  125. data/lib/samagotchi/web/public/llm_context_chip.js +38 -107
  126. data/lib/samagotchi/web/public/model_picker.js +26 -22
  127. data/lib/samagotchi/web/public/notify.js +6 -4
  128. data/lib/samagotchi/web/public/question_card.js +1 -1
  129. data/lib/samagotchi/web/public/ref_scan.js +2 -8
  130. data/lib/samagotchi/web/public/refs.js +13 -0
  131. data/lib/samagotchi/web/public/session_state.js +14 -0
  132. data/lib/samagotchi/web/public/session_title.js +2 -3
  133. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  134. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  135. data/lib/samagotchi/web/public/stage_model.js +35 -2
  136. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  137. data/lib/samagotchi/web/public/stage_view.js +56 -335
  138. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  139. data/lib/samagotchi/web/public/strip.js +3 -3
  140. data/lib/samagotchi/web/public/task_stop.js +3 -2
  141. data/lib/samagotchi/web/public/turn_events.js +1 -1
  142. data/lib/samagotchi/web/public/turn_html.js +2 -2
  143. data/lib/samagotchi/web/public/turn_model.js +2 -2
  144. data/lib/samagotchi/web/public/turn_view.js +1 -1
  145. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  146. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  147. data/lib/samagotchi/web/server.rb +0 -4
  148. data/lib/samagotchi/worker.rb +22 -24
  149. data/lib/samagotchi/worker_inbound.rb +36 -4
  150. data/lib/samagotchi/worker_wakes.rb +67 -7
  151. metadata +27 -1
@@ -0,0 +1,179 @@
1
+ // The stage view's cap and frame (stage_view.js): the one place that
2
+ // writes the stage's height, and the stage's own scrolls. The cap: at most
3
+ // ~60% of the page (~80% with the cloud open), and never above the session
4
+ // panel. The frame: held and grow-only while the stage holds a turn
5
+ // (stage_model.js frameTarget), its growth eased. The stage's state comes
6
+ // in as getters (+isHolding+, +isCollapsed+, +isCloudOpen+); what the stage
7
+ // changes of the frame's goes through resetHeld and forgetContent.
8
+ import { frameTarget } from "./stage_model.js";
9
+ import { easeScrollTo } from "./scroll.js";
10
+
11
+ // The stage grows (a card, the long answer, the cloud opening) with a short
12
+ // ease: deliberate, never a snap.
13
+ const GROW_MS = 180;
14
+ const GROW_EASE = "cubic-bezier(.2,.7,.3,1)";
15
+ // The room between the stage and the composer card (index.html's margin-bottom).
16
+ const STAGE_GAP = 10;
17
+
18
+ export function reducedMotion() {
19
+ return matchMedia("(prefers-reduced-motion: reduce)").matches;
20
+ }
21
+
22
+ // +observed+: the elements whose size changes refit the frame (the stage's
23
+ // slots, in the order they are observed).
24
+ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, holdEl, asking, extras, dockEl, centerEl, composerEl, observed, isHolding, isCollapsed, isCloudOpen }) {
25
+ // The frame (fitFrame): the height held while the turn runs and the
26
+ // tallest content seen.
27
+ let held = null;
28
+ let maxContent = 0;
29
+ let growth = null; // the running ease of the frame's growth (an Animation)
30
+ let growthTo = 0; // its end height
31
+
32
+ // The stage's own scrolls, eased (instant with reduced motion).
33
+ function easeStage(top) {
34
+ if (Math.abs(top - scroller.scrollTop) < 1) return;
35
+ easeScrollTo(scroller, top, { reduce: reducedMotion() });
36
+ }
37
+
38
+ // +fn+ once the frame has grown for a change made now: two frames on (the
39
+ // ResizeObserver starts the ease in the frame after the change), then at
40
+ // the ease's end.
41
+ function afterGrowth(fn) {
42
+ requestAnimationFrame(() => requestAnimationFrame(() => {
43
+ if (growth?.playState === "running") growth.finished.then(fn, fn);
44
+ else fn();
45
+ }));
46
+ }
47
+
48
+ // ── the cap: at most ~60% of the page (~80% with the cloud open), and
49
+ // never above the session panel (the dock is absolute: a tall composer or
50
+ // a phone's keyboard would push the stage's top out) ────────────────────
51
+
52
+ // Where the stage's scroll goes so +slot+ (the scroller's child) shows:
53
+ // its end in sight, and the asking text's top too when it and the slot
54
+ // (a waiting card) fit together (the reply reads from its start, the card
55
+ // still in sight). null: nothing to show.
56
+ function revealTop(slot) {
57
+ if (stage.hidden || slot.hidden) return null;
58
+ let top = Math.max(scroller.scrollTop, slot.offsetTop + slot.offsetHeight - scroller.clientHeight);
59
+ if (!asking.hidden && asking.offsetTop < top && slot.offsetTop + slot.offsetHeight - asking.offsetTop <= scroller.clientHeight) top = asking.offsetTop;
60
+ return top;
61
+ }
62
+
63
+ // Scrolls the stage to show +slot+: eased, or at once (+now+: the cap
64
+ // dropped under it).
65
+ function reveal(slot, { now = false } = {}) {
66
+ const top = revealTop(slot);
67
+ if (top === null) return;
68
+ if (now) scroller.scrollTop = top;
69
+ else easeStage(top);
70
+ }
71
+
72
+ function fitCap() {
73
+ if (stage.hidden) return;
74
+ // The composer card and the gap above it (the stage floats over them,
75
+ // out of the dock's flow).
76
+ const rest = dockEl.offsetHeight + STAGE_GAP;
77
+ const room = centerEl.clientHeight - rest - 32;
78
+ const share = window.innerHeight * (isCloudOpen() ? 0.8 : 0.6);
79
+ const cap = Math.max(96, Math.round(Math.min(share, room)));
80
+ const was = capPx();
81
+ if (cap === was) return;
82
+ stage.style.setProperty("--stage-max", `${cap}px`);
83
+ fitFrame();
84
+ // A smaller cap (a phone's keyboard came up) keeps a waiting card in
85
+ // sight: the asking text above it would push it out. A larger one
86
+ // leaves the reader's scroll alone.
87
+ if (cap < was && stage.classList.contains("waiting")) {
88
+ requestAnimationFrame(() => reveal(extras, { now: true }));
89
+ }
90
+ }
91
+ new ResizeObserver(() => fitCap()).observe(centerEl);
92
+ new ResizeObserver(() => fitCap()).observe(composerEl);
93
+ window.addEventListener("resize", fitCap);
94
+
95
+ // ── the frame: the one place that writes the stage's height ────────────
96
+ // Nothing in the stage view jumps. The rules: the stage is out of the
97
+ // dock's flow (index.html), so its height never moves the composer; while
98
+ // it holds a turn its height is held and grow-only (frameTarget), so a
99
+ // content change happens inside it; growth eases (frameTo); the chip is a
100
+ // footer no content can push; the hand-off releases the turn at once
101
+ // (releaseTurn) and empties the stage only after it slid away under the
102
+ // composer (a transform, then finishFold/clearStage).
103
+
104
+ // The stage's height, +px+. +animate+: a growth eases (GROW_MS, Web
105
+ // Animations: the running ease is +growth+), unless motion is reduced or
106
+ // the stage is hidden or collapsed; a shrink (the cap dropped) is at once.
107
+ function frameTo(px, { animate = false } = {}) {
108
+ const from = stage.offsetHeight;
109
+ stage.style.height = `${px}px`;
110
+ if (px === from) return;
111
+ const ease = animate && px > from && !isCollapsed() && !stage.hidden && !document.hidden && !reducedMotion();
112
+ if (growth && (!ease || growth.playState !== "running" || growthTo !== px)) {
113
+ growth.cancel();
114
+ growth = null;
115
+ }
116
+ if (!ease || growth) return;
117
+ growth = stage.animate([{ height: `${from}px` }, { height: `${px}px` }], { duration: GROW_MS, easing: GROW_EASE });
118
+ growthTo = px;
119
+ const run = growth;
120
+ run.finished.then(() => { if (growth === run) growth = null; }, () => {});
121
+ }
122
+
123
+ // Back to the content's own height (under the cap).
124
+ function frameFree() {
125
+ growth?.cancel();
126
+ growth = null;
127
+ stage.style.height = "";
128
+ }
129
+
130
+ function capPx() {
131
+ return parseFloat(stage.style.getPropertyValue("--stage-max")) || Infinity;
132
+ }
133
+
134
+ // The scroller's content height: its last shown slot's end (offsetTop is
135
+ // the scroller's own, whatever its scroll) and its bottom padding.
136
+ function contentHeight() {
137
+ const css = getComputedStyle(scroller);
138
+ let end = parseFloat(css.paddingTop) || 0;
139
+ for (const el of scroller.children) {
140
+ if (el === holdEl || el.hidden || !el.getClientRects().length) continue;
141
+ end = el.offsetTop + el.offsetHeight;
142
+ }
143
+ return end + (parseFloat(css.paddingBottom) || 0);
144
+ }
145
+
146
+ // Holds the frame while the stage holds a turn (stage_model.js
147
+ // frameTarget: grow-only, clamped by the cap; free when collapsed or
148
+ // released). Run from a ResizeObserver on the slots (after layout, before
149
+ // paint: no frame shows the content's own height) and when the cap moves.
150
+ function fitFrame() {
151
+ if (stage.hidden || stage.classList.contains("leaving")) return;
152
+ const content = scroller.hidden ? 0 : contentHeight();
153
+ const holding = isHolding();
154
+ const collapsed = isCollapsed();
155
+ const holds = holding && !collapsed;
156
+ maxContent = holds ? Math.max(maxContent, content) : 0;
157
+ const mark = `${Math.max(0, Math.round(maxContent) - 1)}px`;
158
+ if (holdEl.style.top !== mark) holdEl.style.top = mark;
159
+ const natural = (stage.offsetHeight - stage.clientHeight) + status.offsetHeight + content + (cloud.hidden || collapsed ? 0 : cloud.offsetHeight)
160
+ + queuedSlot.offsetHeight;
161
+ held = frameTarget({ natural, held, cap: capPx(), holding, collapsed });
162
+ if (held === null) frameFree();
163
+ else frameTo(held, { animate: true });
164
+ }
165
+ const frameObserver = new ResizeObserver(() => fitFrame());
166
+ for (const el of observed) frameObserver.observe(el);
167
+
168
+ return {
169
+ fitCap,
170
+ frameFree,
171
+ afterGrowth,
172
+ easeStage,
173
+ reveal,
174
+ // A new turn, or none: no height held yet.
175
+ resetHeld: () => { held = null; },
176
+ // The tallest content seen is forgotten (a new turn, the cloud closed by hand).
177
+ forgetContent: () => { maxContent = 0; },
178
+ };
179
+ }
@@ -15,9 +15,9 @@ const FILE_TOOLS = new Set(["read", "edit", "write"]);
15
15
  const TASK_TOOLS = new Set(["task_wait", "task_get", "task_stop"]);
16
16
  const TRAIL = 3;
17
17
  // The hand-off waits this long after the stage was last in use.
18
- export const QUIET_MS = 1500;
18
+ const QUIET_MS = 1500;
19
19
  // A touch, wheel or scroll counts as use this long (phones have no hover).
20
- export const TOUCH_MS = 4000;
20
+ const TOUCH_MS = 4000;
21
21
 
22
22
  // One headline sentence as plain text: the markdown a streaming answer
23
23
  // carries (headings, quotes, bullets, table pipes, emphasis, code ticks,
@@ -124,6 +124,39 @@ export function trailFlashText(flash, mark) {
124
124
  return { text, hover: text };
125
125
  }
126
126
 
127
+ // The status row's mark for an ended turn's phase (liveSlots), none while
128
+ // it runs.
129
+ export const MARKS = { answered: "✓", "no answer": "∅", canceled: "■", failed: "✕" };
130
+ // The mark of a flash in the trail: a warn notice, a hook's info notice,
131
+ // an applied LLM context edit (✂), a steer-cut row and a plugin's nudge (↪).
132
+ export const FLASH_MARKS = { warn: "⚠", info: "ℹ", edit: "✂", steer: "↪" };
133
+
134
+ // "1 step", "3 steps".
135
+ export function stepsText(n) {
136
+ return `${n} step${n === 1 ? "" : "s"}`;
137
+ }
138
+
139
+ // The chip's label: the steps, the calls, and the turn's time when the
140
+ // status row's timing line isn't there to say it (+elapsed+, "" for none).
141
+ export function chipLabelText({ steps, calls, elapsed = "" }) {
142
+ return `${stepsText(steps)} · ${calls} tool call${calls === 1 ? "" : "s"}${elapsed ? ` · ${elapsed}` : ""}`;
143
+ }
144
+
145
+ // The status row's step: running, the step it is on; ended, the count,
146
+ // unless the chip says it (+chipHidden+: no chip shows).
147
+ export function stepSlotText({ step, ended, chipHidden }) {
148
+ return !ended ? `step ${step}` : chipHidden ? stepsText(step) : "";
149
+ }
150
+
151
+ // The collapsed stage's mini tool in the status row: the running call's
152
+ // name and title, its full command as the hover; "" for both otherwise.
153
+ export function miniToolText({ collapsed, tool }) {
154
+ return {
155
+ text: collapsed && tool ? `${tool.name} ${tool.title}` : "",
156
+ title: (collapsed && tool?.command) || "",
157
+ };
158
+ }
159
+
127
160
  // While the first step has no thinking and no tool rows the turn is plain:
128
161
  // its text streams in the stage's answer slot, as the turn view's
129
162
  // chrome-less block streams like a bubble.
@@ -0,0 +1,130 @@
1
+ // The stage view's live slots (stage_view.js): the headline, the asking
2
+ // text, the "now" row, the trail and the chip's ticks, each drawn from
3
+ // liveSlots (stage_model.js) and patched only when what it shows changes
4
+ // (+shown+), so an unchanged slot keeps its nodes: a selection in it
5
+ // survives, and its animations never restart (the ticks' blink; the
6
+ // headline's fade restarts on purpose at a new text).
7
+ import { FLASH_MARKS, trailFlashText, trailItemText, trailTone } from "./stage_model.js";
8
+ import { taskCommand, taskStopButton } from "./task_stop.js";
9
+
10
+ // The stage's one element helper (stage_view.js builds its skeleton with it).
11
+ export function node(tag, cls, parent = null) {
12
+ const el = document.createElement(tag);
13
+ if (cls) el.className = cls;
14
+ if (parent) parent.appendChild(el);
15
+ return el;
16
+ }
17
+
18
+ // +headline+, +asking+, +now+, +trail+, +ticks+: the slots' elements.
19
+ // +taskStop+: the page's stopper (task_stop.js), for the stop-task button
20
+ // on a running task_wait; +gens()+ the running turn's gens (the button's
21
+ // command, read at its click); +onChange+ redraws (the stage's
22
+ // scheduleRefresh).
23
+ export function createStageSlots({ headline, asking, now, trail, ticks, taskStop = null, gens, onChange }) {
24
+ // What each slot shows, so an unchanged slot is left alone.
25
+ const shown = { headline: null, now: null, trail: null, asking: null };
26
+
27
+ function drawHeadline({ text, thinking }) {
28
+ const key = `${thinking ? "t" : "n"}|${text}`;
29
+ if (shown.headline === key) return;
30
+ shown.headline = key;
31
+ headline.replaceChildren();
32
+ headline.classList.toggle("thinking", thinking);
33
+ if (thinking) {
34
+ const tag = node("span", "ts-think-tag", headline);
35
+ tag.textContent = "thinking";
36
+ }
37
+ headline.appendChild(document.createTextNode(text));
38
+ headline.classList.remove("fresh");
39
+ void headline.offsetWidth;
40
+ headline.classList.add("fresh");
41
+ }
42
+
43
+ // Patched only when the text changes (a late chunk to the closed gen
44
+ // rewrites the same text): a selection in it survives. A new text reads
45
+ // from its top.
46
+ function drawAsking(text) {
47
+ asking.hidden = !text;
48
+ if (shown.asking === text) return;
49
+ shown.asking = text;
50
+ asking.textContent = text;
51
+ asking.scrollTop = 0;
52
+ }
53
+
54
+ function drawNow(tool, quiet) {
55
+ // A task_wait's stop button, as the row's (task_stop.js): in the key
56
+ // with its in-flight state, so a redraw keeps a clicked one disabled.
57
+ const stopId = tool?.taskId && taskStop?.target({ tool: "task_wait", status: "running", view: { task_id: tool.taskId } });
58
+ const stopKey = stopId ? `|stop:${tool.key}:${taskStop.busy(stopId)}` : "";
59
+ const key = tool ? `run|${tool.name}|${tool.title}|${tool.command || ""}${stopKey}` : `quiet|${quiet}`;
60
+ if (shown.now === key) return;
61
+ shown.now = key;
62
+ now.replaceChildren();
63
+ now.classList.toggle("running", !!tool);
64
+ node("span", tool ? "ts-spin" : "ts-idle", now);
65
+ if (tool) {
66
+ const name = node("span", `ts-tool ${tool.kind}`, now);
67
+ name.textContent = tool.name;
68
+ const title = node("span", "ts-title", now);
69
+ title.textContent = tool.title;
70
+ // The full command when the call has one (the title is its first step +N).
71
+ title.title = tool.command || tool.title;
72
+ if (stopId) {
73
+ const command = () => taskCommand(gens(), stopId);
74
+ now.appendChild(taskStopButton(taskStop, stopId, { command, onChange }));
75
+ }
76
+ } else if (quiet) {
77
+ const word = node("span", "ts-title quiet", now);
78
+ word.textContent = quiet;
79
+ }
80
+ }
81
+
82
+ // +flash+: the trail's flash ({ kind, text, until }), shown ahead of the
83
+ // calls until its time is up.
84
+ function drawTrail(items, flash) {
85
+ const flashing = flash && flash.until > Date.now() ? flash : null;
86
+ const key = JSON.stringify([flashing?.kind, flashing?.text, items]);
87
+ if (shown.trail === key) return;
88
+ shown.trail = key;
89
+ trail.replaceChildren();
90
+ if (flashing) {
91
+ const flash = trailFlashText(flashing, FLASH_MARKS[flashing.kind]);
92
+ const el = node("span", `ts-trail-item ${flashing.kind}`, trail);
93
+ el.textContent = flash.text;
94
+ el.title = flash.hover;
95
+ }
96
+ for (const item of items) {
97
+ const tone = trailTone(item.status);
98
+ const el = node("span", `ts-trail-item${tone ? ` ${tone}` : ""}`, trail);
99
+ const { text, hover } = trailItemText(item);
100
+ el.textContent = text;
101
+ el.title = hover;
102
+ }
103
+ if (!flashing && !items.length) node("span", "ts-trail-item none", trail).textContent = "no tool calls yet";
104
+ }
105
+
106
+ function drawTicks(list) {
107
+ list.forEach((tick, i) => {
108
+ let el = ticks.children[i];
109
+ if (!el) {
110
+ el = node("span", "", ticks);
111
+ el.setAttribute("role", "button");
112
+ }
113
+ const cls = `ts-tick ${tick.kind} ${tick.status}`;
114
+ if (el.className !== cls) el.className = cls;
115
+ el.dataset.step = String(tick.step);
116
+ el.title = `step ${tick.step + 1}`;
117
+ });
118
+ while (ticks.children.length > list.length) ticks.lastChild.remove();
119
+ }
120
+
121
+ // A new turn (or none): the slots empty, the asking text hidden, the next
122
+ // draw of each slot a fresh one.
123
+ function reset() {
124
+ for (const slot of [headline, asking, now, trail, ticks]) slot.replaceChildren();
125
+ asking.hidden = true;
126
+ shown.headline = shown.now = shown.trail = shown.asking = null;
127
+ }
128
+
129
+ return { drawHeadline, drawAsking, drawNow, drawTrail, drawTicks, reset };
130
+ }