samagotchi 0.61.0 → 0.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +91 -1
  3. data/README.md +4 -1
  4. data/bin/chi +3 -2
  5. data/docs/architecture.md +3 -0
  6. data/docs/cli.md +26 -9
  7. data/docs/configuration.md +33 -20
  8. data/docs/context.md +21 -3
  9. data/docs/guardrails.md +2 -1
  10. data/docs/hooks.md +14 -3
  11. data/docs/internals/engine.md +24 -11
  12. data/docs/internals/prompt-caching.md +6 -1
  13. data/docs/memory.md +1 -1
  14. data/docs/plugins.md +131 -5
  15. data/docs/releasing.md +1 -1
  16. data/docs/sessions.md +4 -4
  17. data/docs/sub-agent.md +6 -0
  18. data/docs/testing.md +10 -4
  19. data/lib/samagotchi/answer_refs.rb +18 -26
  20. data/lib/samagotchi/bridge.rb +23 -1
  21. data/lib/samagotchi/bundles/claude-peer/manifest.yml +22 -0
  22. data/lib/samagotchi/bundles/claude-peer/plugin.rb +406 -0
  23. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +104 -0
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +66 -0
  25. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +60 -0
  26. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  27. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  28. data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
  29. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
  31. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  32. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
  33. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
  34. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
  35. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  36. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  37. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  38. data/lib/samagotchi/context_absorber.rb +25 -6
  39. data/lib/samagotchi/engine.rb +9 -0
  40. data/lib/samagotchi/engine_peers.rb +1 -0
  41. data/lib/samagotchi/idle_recap.rb +2 -3
  42. data/lib/samagotchi/image_store.rb +8 -0
  43. data/lib/samagotchi/kernel_loop.rb +7 -16
  44. data/lib/samagotchi/llm/chat_loop.rb +23 -15
  45. data/lib/samagotchi/plugin/api.rb +36 -18
  46. data/lib/samagotchi/plugin/attached_context.rb +7 -4
  47. data/lib/samagotchi/reasoning_history.rb +22 -4
  48. data/lib/samagotchi/recap_setup.rb +3 -3
  49. data/lib/samagotchi/row_refs.rb +125 -0
  50. data/lib/samagotchi/session.rb +12 -0
  51. data/lib/samagotchi/session_metrics.rb +24 -2
  52. data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
  53. data/lib/samagotchi/terminal_ui/attached_loop.rb +26 -9
  54. data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
  55. data/lib/samagotchi/terminal_ui.rb +57 -14
  56. data/lib/samagotchi/tool_activity.rb +8 -4
  57. data/lib/samagotchi/tool_response.rb +5 -4
  58. data/lib/samagotchi/tool_row_fields.rb +4 -2
  59. data/lib/samagotchi/tool_runner.rb +16 -3
  60. data/lib/samagotchi/tools/builtins.rb +9 -2
  61. data/lib/samagotchi/tools/execute.rb +37 -7
  62. data/lib/samagotchi/tools/peers.rb +3 -2
  63. data/lib/samagotchi/tools/registry.rb +5 -3
  64. data/lib/samagotchi/version.rb +1 -1
  65. data/lib/samagotchi/web/app.rb +20 -5
  66. data/lib/samagotchi/web/message_parts.rb +17 -9
  67. data/lib/samagotchi/web/public/activity.js +6 -3
  68. data/lib/samagotchi/web/public/app.js +106 -106
  69. data/lib/samagotchi/web/public/card.js +73 -23
  70. data/lib/samagotchi/web/public/chains.js +24 -24
  71. data/lib/samagotchi/web/public/command_view.js +39 -35
  72. data/lib/samagotchi/web/public/context_chips.js +92 -64
  73. data/lib/samagotchi/web/public/copy.js +7 -15
  74. data/lib/samagotchi/web/public/diff_view.js +20 -23
  75. data/lib/samagotchi/web/public/families_view.js +51 -39
  76. data/lib/samagotchi/web/public/format.js +0 -48
  77. data/lib/samagotchi/web/public/images.js +12 -16
  78. data/lib/samagotchi/web/public/index.html +61 -10
  79. data/lib/samagotchi/web/public/keep_focus.js +24 -0
  80. data/lib/samagotchi/web/public/lists_view.js +13 -13
  81. data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
  82. data/lib/samagotchi/web/public/question_card.js +11 -0
  83. data/lib/samagotchi/web/public/question_cards.js +63 -7
  84. data/lib/samagotchi/web/public/read_view.js +10 -9
  85. data/lib/samagotchi/web/public/ref_menu.js +18 -5
  86. data/lib/samagotchi/web/public/refs.js +113 -15
  87. data/lib/samagotchi/web/public/scope.js +23 -0
  88. data/lib/samagotchi/web/public/scroll.js +42 -0
  89. data/lib/samagotchi/web/public/stage_model.js +22 -3
  90. data/lib/samagotchi/web/public/stage_view.js +299 -70
  91. data/lib/samagotchi/web/public/timing.js +1 -8
  92. data/lib/samagotchi/web/public/turn_html.js +122 -61
  93. data/lib/samagotchi/web/public/turn_model.js +49 -0
  94. data/lib/samagotchi/web/public/turn_view.js +167 -116
  95. data/lib/samagotchi/web/public/vendor/directive.js +8 -0
  96. data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
  97. data/lib/samagotchi/worker.rb +49 -32
  98. data/lib/samagotchi/worker_inbound.rb +17 -14
  99. metadata +10 -1
@@ -1,15 +1,17 @@
1
- // The turn's pieces as HTML, one builder for the live page and a reload:
2
- // a tool row, a prompt bubble and the notice of a turn with no answer. The
3
- // live page makes its elements from the same markup a reload writes
4
- // (htmlElement), so the two can't drift apart. Pure but htmlElement: `node --test` covers it.
1
+ // The turn's pieces as lit-html templates, one for the live page and a
2
+ // reload: a tool row, a prompt bubble, a note, a steer, the notice of a
3
+ // turn with no answer. The live page renders the same template a reload
4
+ // does (elementOf, or its own lit root for a tool row), so the two can't
5
+ // drift apart. `node --test` covers it (on the happy-dom shim).
5
6
  import { toolName } from "./activity.js";
6
- import { commandBlockHtml, rowHover } from "./command_view.js";
7
- import { copySourceAttr } from "./copy.js";
8
- import { activityDiffHtml } from "./diff_view.js";
9
- import { escapeHtml, userBodyHtml } from "./format.js";
10
- import { refAttrs } from "./refs.js";
11
- import { readLinesHtml } from "./read_view.js";
12
- import { formatDuration } from "./timing.js";
7
+ import { html, nothing, render } from "./vendor/lit-html.js";
8
+ import { commandBlockTemplate, rowHover } from "./command_view.js";
9
+ import { copySource } from "./copy.js";
10
+ import { activityDiffTemplate } from "./diff_view.js";
11
+ import { steerSender } from "./format.js";
12
+ import { rowTitleRef } from "./refs.js";
13
+ import { readLinesTemplate } from "./read_view.js";
14
+ import { STOPPED_LINE_CLASS, cancelLineText, formatDuration } from "./timing.js";
13
15
  import { emptyAnswerLine } from "./turn_events.js";
14
16
 
15
17
  // A row's status (live, or a saved tool record's on reload): running,
@@ -31,12 +33,14 @@ export function statusParts(status, { noMatch = false } = {}) {
31
33
  return { cls, label, word: cls === "ok" || cls === "running" ? "" : label };
32
34
  }
33
35
 
34
- function statusMarkHtml({ cls, label }) {
35
- return `<span class="activity-status ${cls}" role="img" aria-label="${label}" title="${label}"></span>`;
36
+ // role is bound too: lit sets bound attributes after the static ones, and
37
+ // the markup keeps the order a reload wrote before.
38
+ function statusMarkTemplate({ cls, label }) {
39
+ return html`<span class="activity-status ${cls}" role=${"img"} aria-label=${label} title=${label}></span>`;
36
40
  }
37
41
 
38
- function statusWordHtml({ cls, word, wordCls }) {
39
- return word ? `<span class="activity-state ${wordCls || cls}">${word}</span>` : "";
42
+ function statusWordTemplate({ cls, word, wordCls }) {
43
+ return word ? html`<span class="activity-state ${wordCls || cls}">${word}</span>` : nothing;
40
44
  }
41
45
 
42
46
  // A reloaded row's ✂ mark: what an LLM context edit saved on its output
@@ -62,11 +66,11 @@ function compactTokens(n) {
62
66
  return n >= 1000 ? `${(n / 1000).toFixed(1)}k tokens` : `${n} tokens`;
63
67
  }
64
68
 
65
- function llmEditHtml(row) {
69
+ function llmEditTemplate(row) {
66
70
  const line = llmEditLine(row.edit);
67
- if (!line) return "";
71
+ if (!line) return nothing;
68
72
  const hover = `${row.tool_id ? `${row.tool_id}: ` : ""}${line}\nThe model is sent this instead of the output; the session keeps it.`;
69
- return `<div class="activity-edit" title="${escapeHtml(hover)}">${escapeHtml(line)}</div>`;
73
+ return html`<div class="activity-edit" title=${hover}>${line}</div>`;
70
74
  }
71
75
 
72
76
  // A tool row: its status mark, tool, title (else params; the full command,
@@ -76,30 +80,35 @@ function llmEditHtml(row) {
76
80
  // the rest on hover; a read of the file as-is also its numbered lines,
77
81
  // closed: read_view.js), its images (+thumbs+(images) draws them) and an
78
82
  // edit's collapsed diff, and a reloaded row's ✂ mark above its output. A
79
- // part with nothing to say is left out.
80
- export function toolRowHtml(row, { thumbs = () => "" } = {}) {
81
- return `<div class="activity-row" data-key="${escapeHtml(row.key)}">${toolRowInnerHtml(row, { thumbs })}</div>`;
83
+ // part with nothing to say is left out. A reload renders toolRowTemplate;
84
+ // the live view renders toolRowInnerTemplate into its own row element
85
+ // (turn_view.js), again on each event: lit writes only what changed, so a
86
+ // part the user toggled (the raw command, the diff, the read lines) stays.
87
+ // +stop+: the live row's stop-task button (a node), after the title.
88
+ export function toolRowTemplate(row, opts = {}) {
89
+ return html`<div class="activity-row" data-key=${row.key}>${toolRowInnerTemplate(row, opts)}</div>`;
82
90
  }
83
91
 
84
- export function toolRowInnerHtml(row, { thumbs = () => "" } = {}) {
92
+ export function toolRowInnerTemplate(row, { thumbs = () => nothing, stop = nothing } = {}) {
85
93
  const shown = row.title || row.params;
86
- const hover = rowHover(row) ? ` title="${escapeHtml(rowHover(row))}"` : "";
87
- // A row with a ref (a file tool's file) carries it on its title, which
88
- // opens it, and its diff lines open it at their line (refs.js).
89
- const ref = shown ? refAttrs(row.ref) : "";
90
- const params = shown ? `<span class="activity-params${ref ? " ref" : ""}"${hover}${ref}>${escapeHtml(shown)}</span>` : "";
94
+ const hover = rowHover(row);
95
+ // A row with a ref (a file tool's file, else a plugin tool's first ref)
96
+ // carries it on its title, which opens it, and its diff lines open it at
97
+ // their line (refs.js; bindRowRefs binds a plugin's data to the title).
98
+ const ref = shown ? rowTitleRef(row) : null;
99
+ const attr = (value) => (value ? String(value) : nothing);
100
+ const params = shown
101
+ ? html`<span class=${`activity-params${ref?.kind ? " ref" : ""}`} title=${hover || nothing} data-ref-kind=${attr(ref?.kind)} data-ref-path=${attr(ref?.kind && ref.path)} data-ref-line=${attr(ref?.kind && ref.line)} data-ref-end-line=${attr(ref?.kind && ref.end_line)}>${shown}</span>`
102
+ : nothing;
91
103
  // The name the model called the tool by when it was an alias's, dim.
92
- const alias = row.called_as ? `<span class="activity-alias">(called as ${escapeHtml(row.called_as)})</span>` : "";
104
+ const alias = row.called_as ? html`<span class="activity-alias">(called as ${row.called_as})</span>` : nothing;
93
105
  const duration = formatDuration(row.duration_ms);
94
106
  const full = row.status === "running" ? "" : String(row.output || "").replace(/^\[[^\]]*\]\s*/, "");
95
107
  const output = full
96
- ? `<div class="activity-output" title="${escapeHtml(full)}">${escapeHtml(full.length > 300 ? `${full.slice(0, 300)}…` : full)}</div>`
97
- : "";
108
+ ? html`<div class="activity-output" title=${full}>${full.length > 300 ? `${full.slice(0, 300)}…` : full}</div>`
109
+ : nothing;
98
110
  const status = statusParts(row.status, { noMatch: !!row.no_match });
99
- return `${statusMarkHtml(status)}<span class="activity-tool">${escapeHtml(toolName(row))}</span>${params}${alias}${statusWordHtml(status)}` +
100
- `${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}` +
101
- `${commandBlockHtml(row.view, row.title)}${llmEditHtml(row)}${output}${output ? readLinesHtml(row) : ""}` +
102
- `${row.images?.length ? thumbs(row.images) : ""}${row.diff ? activityDiffHtml(row.diff) : ""}`;
111
+ return html`${statusMarkTemplate(status)}<span class="activity-tool">${toolName(row)}</span>${params}${stop}${alias}${statusWordTemplate(status)}${duration ? html`<span class="activity-duration">${duration}</span>` : nothing}${commandBlockTemplate(row.view, row.title)}${llmEditTemplate(row)}${output}${full ? readLinesTemplate(row) : nothing}${row.images?.length ? thumbs(row.images) : nothing}${row.diff ? activityDiffTemplate(row.diff) : nothing}`;
103
112
  }
104
113
 
105
114
  // A prompt bubble's states and their badges: queued (waiting for its
@@ -107,8 +116,8 @@ export function toolRowInnerHtml(row, { thumbs = () => "" } = {}) {
107
116
  // gave it back). Any other state ("started", none) is a plain bubble.
108
117
  export const BADGES = { queued: "queued", steered: "steered", failed: "failed" };
109
118
 
110
- export function stateBadgeHtml(state) {
111
- return BADGES[state] ? `<span class="state-badge">${escapeHtml(BADGES[state])}</span>` : "";
119
+ export function stateBadgeTemplate(state) {
120
+ return BADGES[state] ? html`<span class="state-badge">${BADGES[state]}</span>` : nothing;
112
121
  }
113
122
 
114
123
  // A `!cmd`'s saved user message (the worker appends "!(<command>)\n<output>"
@@ -127,45 +136,97 @@ export function shellCommandMessage(content) {
127
136
  // A `!cmd`'s history bubble: the same markup the live page draws for a
128
137
  // command (app.js's drawCommandBubble): the line, the output in a
129
138
  // pre.command-output.
130
- export function commandBubbleHtml({ line, text }) {
131
- const body = text ? `<pre class="command-output">${escapeHtml(text)}</pre>` : "";
132
- return `<div class="bubble command"><div class="command-line">${escapeHtml(line)}</div>${body}</div>`;
139
+ export function commandBubbleTemplate({ line, text }) {
140
+ const body = text ? html`<pre class="command-output">${text}</pre>` : nothing;
141
+ return html`<div class="bubble command"><div class="command-line">${line}</div>${body}</div>`;
142
+ }
143
+
144
+ // A user message's body: runs of `>` lines become <blockquote>s, the rest
145
+ // stays text. The bubble is pre-wrap, so the blank lines around a quote
146
+ // are dropped (the blockquote's margin spaces it instead).
147
+ export function userBodyTemplate(content) {
148
+ const text = String(content ?? "").replace(/\r\n?/g, "\n");
149
+ const blocks = [];
150
+ for (const line of text.split("\n")) {
151
+ const m = /^ {0,3}> ?(.*)$/.exec(line);
152
+ const kind = m ? "quote" : "text";
153
+ const last = blocks[blocks.length - 1];
154
+ if (last?.kind === kind) last.lines.push(m ? m[1] : line);
155
+ else blocks.push({ kind, lines: [m ? m[1] : line] });
156
+ }
157
+ if (!blocks.some((b) => b.kind === "quote")) return String(content ?? "");
158
+ return blocks.map((b) => {
159
+ if (b.kind === "quote") return html`<blockquote>${b.lines.join("\n")}</blockquote>`;
160
+ const body = b.lines.join("\n").replace(/^\s*\n/, "").replace(/\n\s*$/, "");
161
+ return body.trim() ? body : nothing;
162
+ });
163
+ }
164
+
165
+ // An answer's bubble (+cls+: a report reply's; +at+: its message's index,
166
+ // data-answer-at): an assistant's rendered markdown (the server's html,
167
+ // sanitized there) set as it is, as the live turn end sets it
168
+ // (answer_refs.js applyFinalAnswer), else its text, set whole (no lit
169
+ // markers inside: the bubble's nodes are the live one's). Its markdown
170
+ // source is kept for the copy button when it shows rendered html.
171
+ export function answerBubbleTemplate(message, { cls = "", at = null } = {}) {
172
+ const rendered = message?.role === "assistant" && typeof message.html === "string";
173
+ const user = !rendered && message?.role === "user";
174
+ const klass = `bubble output${cls}${rendered ? " markdown" : ""}`;
175
+ const answerAt = at ?? nothing;
176
+ const source = rendered || user ? copySource(message) : nothing;
177
+ if (rendered) return html`<div class=${klass} data-answer-at=${answerAt} data-copy-source=${source} .innerHTML=${message.html}></div>`;
178
+ if (user) return html`<div class=${klass} data-answer-at=${answerAt} data-copy-source=${source}>${userBodyTemplate(message.content)}</div>`;
179
+ return html`<div class=${klass} data-answer-at=${answerAt} data-copy-source=${source} .textContent=${String(message?.content ?? "")}></div>`;
180
+ }
181
+
182
+ // A context note's bubble (background the user or another session pushed
183
+ // in; the model reads it, nobody asked it anything): who sent it, then its
184
+ // text.
185
+ export function noteBubbleTemplate(note) {
186
+ const from = note?.label ? `note from ${note.label}` : "note";
187
+ return html`<div class="bubble note"><div class="note-line">${from}</div><div class="note-text">${note?.content ?? ""}</div></div>`;
188
+ }
189
+
190
+ // A plugin's steer (a nudge put into the running turn): one line that
191
+ // names who nudged the model, its text collapsed under it. +bubble+: on its
192
+ // own between bubbles (no step holds it), not a row of a step.
193
+ export function steerRowTemplate({ source, text } = {}, { bubble = false } = {}) {
194
+ const sender = steerSender(source);
195
+ const who = sender ? `${sender} nudged the model` : "nudged the model";
196
+ return html`<details class=${`${bubble ? "bubble " : ""}steer-row`}><summary>${who}</summary><div class="steer-text">${text ?? ""}</div></details>`;
133
197
  }
134
198
 
135
199
  // A user's line: who sent it (+label+, a delegate report's or another
136
200
  // client's), its body (`>` lines as quotes) and +thumbs+ (its images'
137
- // markup), its state's badge. +message+: { content, display } (the copy
201
+ // template), its state's badge. +message+: { content, display } (the copy
138
202
  // source keeps the text as written). +step+: the step that read a line
139
203
  // merged mid-turn (a reload's; a resolved question goes before it).
140
204
  // The live page's own: +own+ (this tab sent it), +enqueuedId+ and +key+
141
205
  // (its normalized text: an echo waiting for its id is matched by it).
142
- export function userBubbleHtml(message, { label = null, state = null, step = null, thumbs = "", own = false, enqueuedId = null, key = null } = {}) {
206
+ export function userBubbleTemplate(message, { label = null, state = null, step = null, thumbs = nothing, own = false, enqueuedId = null, key = null } = {}) {
143
207
  const named = BADGES[state] ? state : null;
144
- const attrs = [
145
- `class="bubble user${named ? ` ${named}` : ""}"`,
146
- named ? `data-user-state="${named}"` : "",
147
- step ? `data-step="${Number(step)}"` : "",
148
- ].filter(Boolean).join(" ");
149
- const live = [
150
- key !== null ? ` data-user-content="${escapeHtml(key)}"` : "",
151
- own ? ' data-own="1"' : "",
152
- enqueuedId ? ` data-enqueued-id="${escapeHtml(enqueuedId)}"` : "",
153
- ].join("");
154
- const from = label ? `<span class="origin-label">${escapeHtml(label)}</span>` : "";
155
- return `<div ${attrs}${copySourceAttr({ ...message, role: "user" }, false)}${live}>${from}` +
156
- `<div class="user-message">${userBodyHtml(message?.content)}${thumbs}</div>${stateBadgeHtml(named)}</div>`;
208
+ const from = label ? html`<span class="origin-label">${label}</span>` : nothing;
209
+ return html`<div class=${`bubble user${named ? ` ${named}` : ""}`} data-user-state=${named || nothing} data-step=${step ? Number(step) : nothing} data-copy-source=${copySource({ ...message, role: "user" })} data-user-content=${key !== null ? key : nothing} data-own=${own ? "1" : nothing} data-enqueued-id=${enqueuedId || nothing}>${from}<div class="user-message">${userBodyTemplate(message?.content)}${thumbs}</div>${stateBadgeTemplate(named)}</div>`;
157
210
  }
158
211
 
159
212
  // The notice of a turn that ended with no answer (the server's
160
213
  // empty_answer item, turn_summary.empty_answer live): a muted row like the
161
214
  // loop's retry row, where the answer would be.
162
- export function emptyAnswerHtml(item) {
163
- return `<div class="bubble hook-notice empty-answer">${escapeHtml(emptyAnswerLine(item))}</div>`;
215
+ export function emptyAnswerTemplate(item) {
216
+ return html`<div class="bubble hook-notice empty-answer">${emptyAnswerLine(item)}</div>`;
217
+ }
218
+
219
+ // A re-rendered turn's cancel line (a .bubble.cancel.stopped, as live's
220
+ // addEndBubble), nothing when the turn record is not a canceled one.
221
+ export function cancelLineTemplate(record) {
222
+ if (record?.status !== "canceled") return nothing;
223
+ return html`<div class=${STOPPED_LINE_CLASS}>${cancelLineText(record.cancellation_reason, record.cancelled_by)}</div>`;
164
224
  }
165
225
 
166
- // The element +html+ (one top-level element) describes.
167
- export function htmlElement(html) {
168
- const template = document.createElement("template");
169
- template.innerHTML = html;
170
- return template.content.firstElementChild;
226
+ // +template+'s one top-level element, rendered once (the live page's
227
+ // bubbles: their classes and badge change by hand after this).
228
+ export function elementOf(template) {
229
+ const frag = document.createDocumentFragment();
230
+ render(template, frag);
231
+ return frag.firstElementChild;
171
232
  }
@@ -152,6 +152,20 @@ export function notice(turn, { hook, text, level } = {}) {
152
152
  return { gen };
153
153
  }
154
154
 
155
+ // The whole text of the reply that asked the user (a question or an
156
+ // approval blocks its call): the last gen's text, trimmed, while one of its
157
+ // calls runs and the turn goes on; else "". Only that step's text: a hook's
158
+ // question (asked before its call's row, or between steps) has no running
159
+ // row there, so it gets none rather than another step's (or the answer's)
160
+ // text. The stage shows it while the card waits; the card keeps it once
161
+ // answered.
162
+ export function askingText(turn) {
163
+ if (!turn || turn.ended) return "";
164
+ const gen = turn.gens[turn.gens.length - 1];
165
+ if (!gen?.text || !gen.tools.some((row) => row.status === "running")) return "";
166
+ return gen.text.trim();
167
+ }
168
+
155
169
  // The turn is over (completed, canceled, failed, the worker gone).
156
170
  export function turnEnded(turn) {
157
171
  const closed = closeCurrent(turn);
@@ -215,6 +229,41 @@ export function genLabelParts(gen) {
215
229
  return { head: "thinking", calls: "", repeats: false };
216
230
  }
217
231
 
232
+ // A step that said something (its text is not the turn's answer): a row of
233
+ // its own in a finished block's outline.
234
+ export function isNarrated(gen) {
235
+ return !gen.answer && !!String(gen.text || "").trim();
236
+ }
237
+
238
+ // A finished block's outline (+gens+: its steps in order, as the block holds
239
+ // them): each narrated step a row, each run of the others between (tool-only,
240
+ // thinking-only) one fold ({fold: [indices]}), so every mid-turn text's first
241
+ // line reads without a click and the steps that said nothing take one line.
242
+ // [] when no step said anything: the block stays closed, as before.
243
+ // A last run of one thinking-only step stays a row (no "· 1 step" at the end).
244
+ export function outlineRuns(gens) {
245
+ if (!gens.some(isNarrated)) return [];
246
+ const runs = [];
247
+ gens.forEach((gen, i) => {
248
+ if (isNarrated(gen)) return runs.push({ step: i });
249
+ const last = runs[runs.length - 1];
250
+ if (last?.fold) last.fold.push(i);
251
+ else runs.push({ fold: [i] });
252
+ });
253
+ // A turn doesn't end on a "1 step" fold for a lone thinking-only step
254
+ // (the answer's own thinking): it stays a row, its "thinking" label.
255
+ const last = runs[runs.length - 1];
256
+ const lone = last.fold?.length === 1 && gens[last.fold[0]];
257
+ if (lone && lone.tools.length === 0) runs[runs.length - 1] = { step: last.fold[0] };
258
+ return runs;
259
+ }
260
+
261
+ // A finished turn's steps as its block holds them: the answer's step left it
262
+ // when the answer was all it had (turn_view.js promote).
263
+ export function blockGens(turn) {
264
+ return turn.gens.filter((gen) => !(gen.answer && !gen.thinking && gen.tools.length === 0));
265
+ }
266
+
218
267
  // The block's summary line: "working · 3 steps · 8 tool calls · execute ×7 · …"
219
268
  // while it runs, without the prefix after; the tally (tally.js) from 3 calls.
220
269
  export function blockSummary(turn, { running = false } = {}) {