samagotchi 0.62.0 → 0.64.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -1
  3. data/README.md +4 -1
  4. data/bin/chi +3 -2
  5. data/docs/architecture.md +12 -2
  6. data/docs/cli.md +20 -9
  7. data/docs/configuration.md +16 -9
  8. data/docs/context.md +13 -3
  9. data/docs/hooks.md +6 -3
  10. data/docs/internals/engine.md +10 -3
  11. data/docs/memory.md +1 -1
  12. data/docs/plugins.md +96 -2
  13. data/docs/releasing.md +1 -1
  14. data/docs/sessions.md +11 -8
  15. data/docs/sub-agent.md +27 -4
  16. data/docs/testing.md +13 -4
  17. data/lib/samagotchi/answer_command.rb +5 -1
  18. data/lib/samagotchi/answer_refs.rb +18 -26
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  20. data/lib/samagotchi/bridge.rb +41 -5
  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/loop-guard/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
  29. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
  31. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  32. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  33. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  34. data/lib/samagotchi/child_reports.rb +7 -2
  35. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  36. data/lib/samagotchi/composer_route.rb +90 -0
  37. data/lib/samagotchi/config.rb +6 -3
  38. data/lib/samagotchi/context_absorber.rb +25 -6
  39. data/lib/samagotchi/continue_offer.rb +17 -3
  40. data/lib/samagotchi/delivery.rb +13 -8
  41. data/lib/samagotchi/engine.rb +2 -0
  42. data/lib/samagotchi/host_config.rb +6 -6
  43. data/lib/samagotchi/host_model.rb +2 -2
  44. data/lib/samagotchi/idle_recap.rb +2 -3
  45. data/lib/samagotchi/image_store.rb +8 -0
  46. data/lib/samagotchi/interrupted_turn.rb +7 -3
  47. data/lib/samagotchi/last_turn.rb +5 -2
  48. data/lib/samagotchi/pending_input_queue.rb +10 -3
  49. data/lib/samagotchi/plugin/attached_context.rb +7 -4
  50. data/lib/samagotchi/plugin/sessions.rb +27 -5
  51. data/lib/samagotchi/recap_setup.rb +3 -3
  52. data/lib/samagotchi/reply_wait.rb +171 -6
  53. data/lib/samagotchi/row_refs.rb +3 -4
  54. data/lib/samagotchi/send_command.rb +63 -19
  55. data/lib/samagotchi/session.rb +12 -0
  56. data/lib/samagotchi/session_commands.rb +1 -1
  57. data/lib/samagotchi/session_inbox.rb +63 -7
  58. data/lib/samagotchi/session_manager.rb +49 -20
  59. data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +73 -35
  61. data/lib/samagotchi/terminal_ui/formatting.rb +3 -19
  62. data/lib/samagotchi/terminal_ui.rb +103 -68
  63. data/lib/samagotchi/tool_activity.rb +5 -5
  64. data/lib/samagotchi/tools/delegate.rb +28 -1
  65. data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
  66. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  67. data/lib/samagotchi/tools/execute.rb +33 -8
  68. data/lib/samagotchi/turn_checkpoint.rb +9 -3
  69. data/lib/samagotchi/version.rb +1 -1
  70. data/lib/samagotchi/web/app.rb +6 -4
  71. data/lib/samagotchi/web/public/activity.js +2 -2
  72. data/lib/samagotchi/web/public/app.js +53 -91
  73. data/lib/samagotchi/web/public/card.js +73 -23
  74. data/lib/samagotchi/web/public/chains.js +6 -4
  75. data/lib/samagotchi/web/public/command_view.js +39 -35
  76. data/lib/samagotchi/web/public/commands.js +8 -8
  77. data/lib/samagotchi/web/public/composer.js +3 -3
  78. data/lib/samagotchi/web/public/copy.js +7 -15
  79. data/lib/samagotchi/web/public/diff_view.js +20 -23
  80. data/lib/samagotchi/web/public/format.js +0 -48
  81. data/lib/samagotchi/web/public/images.js +12 -16
  82. data/lib/samagotchi/web/public/question_cards.js +3 -2
  83. data/lib/samagotchi/web/public/read_view.js +10 -9
  84. data/lib/samagotchi/web/public/scope.js +23 -0
  85. data/lib/samagotchi/web/public/timing.js +2 -8
  86. data/lib/samagotchi/web/public/turn_html.js +119 -59
  87. data/lib/samagotchi/web/public/turn_model.js +13 -3
  88. data/lib/samagotchi/web/public/turn_view.js +73 -116
  89. data/lib/samagotchi/web/public/vendor/directive.js +8 -0
  90. data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
  91. data/lib/samagotchi/worker.rb +53 -35
  92. data/lib/samagotchi/worker_inbound.rb +12 -11
  93. metadata +9 -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, rowTitleRef } 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,31 +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))}"` : "";
94
+ const hover = rowHover(row);
87
95
  // A row with a ref (a file tool's file, else a plugin tool's first ref)
88
96
  // carries it on its title, which opens it, and its diff lines open it at
89
97
  // their line (refs.js; bindRowRefs binds a plugin's data to the title).
90
- const ref = shown ? refAttrs(rowTitleRef(row)) : "";
91
- const params = shown ? `<span class="activity-params${ref ? " ref" : ""}"${hover}${ref}>${escapeHtml(shown)}</span>` : "";
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;
92
103
  // The name the model called the tool by when it was an alias's, dim.
93
- 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;
94
105
  const duration = formatDuration(row.duration_ms);
95
106
  const full = row.status === "running" ? "" : String(row.output || "").replace(/^\[[^\]]*\]\s*/, "");
96
107
  const output = full
97
- ? `<div class="activity-output" title="${escapeHtml(full)}">${escapeHtml(full.length > 300 ? `${full.slice(0, 300)}…` : full)}</div>`
98
- : "";
108
+ ? html`<div class="activity-output" title=${full}>${full.length > 300 ? `${full.slice(0, 300)}…` : full}</div>`
109
+ : nothing;
99
110
  const status = statusParts(row.status, { noMatch: !!row.no_match });
100
- return `${statusMarkHtml(status)}<span class="activity-tool">${escapeHtml(toolName(row))}</span>${params}${alias}${statusWordHtml(status)}` +
101
- `${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}` +
102
- `${commandBlockHtml(row.view, row.title)}${llmEditHtml(row)}${output}${output ? readLinesHtml(row) : ""}` +
103
- `${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}`;
104
112
  }
105
113
 
106
114
  // A prompt bubble's states and their badges: queued (waiting for its
@@ -108,8 +116,8 @@ export function toolRowInnerHtml(row, { thumbs = () => "" } = {}) {
108
116
  // gave it back). Any other state ("started", none) is a plain bubble.
109
117
  export const BADGES = { queued: "queued", steered: "steered", failed: "failed" };
110
118
 
111
- export function stateBadgeHtml(state) {
112
- 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;
113
121
  }
114
122
 
115
123
  // A `!cmd`'s saved user message (the worker appends "!(<command>)\n<output>"
@@ -128,45 +136,97 @@ export function shellCommandMessage(content) {
128
136
  // A `!cmd`'s history bubble: the same markup the live page draws for a
129
137
  // command (app.js's drawCommandBubble): the line, the output in a
130
138
  // pre.command-output.
131
- export function commandBubbleHtml({ line, text }) {
132
- const body = text ? `<pre class="command-output">${escapeHtml(text)}</pre>` : "";
133
- 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>`;
134
197
  }
135
198
 
136
199
  // A user's line: who sent it (+label+, a delegate report's or another
137
200
  // client's), its body (`>` lines as quotes) and +thumbs+ (its images'
138
- // markup), its state's badge. +message+: { content, display } (the copy
201
+ // template), its state's badge. +message+: { content, display } (the copy
139
202
  // source keeps the text as written). +step+: the step that read a line
140
203
  // merged mid-turn (a reload's; a resolved question goes before it).
141
204
  // The live page's own: +own+ (this tab sent it), +enqueuedId+ and +key+
142
205
  // (its normalized text: an echo waiting for its id is matched by it).
143
- 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 } = {}) {
144
207
  const named = BADGES[state] ? state : null;
145
- const attrs = [
146
- `class="bubble user${named ? ` ${named}` : ""}"`,
147
- named ? `data-user-state="${named}"` : "",
148
- step ? `data-step="${Number(step)}"` : "",
149
- ].filter(Boolean).join(" ");
150
- const live = [
151
- key !== null ? ` data-user-content="${escapeHtml(key)}"` : "",
152
- own ? ' data-own="1"' : "",
153
- enqueuedId ? ` data-enqueued-id="${escapeHtml(enqueuedId)}"` : "",
154
- ].join("");
155
- const from = label ? `<span class="origin-label">${escapeHtml(label)}</span>` : "";
156
- return `<div ${attrs}${copySourceAttr({ ...message, role: "user" }, false)}${live}>${from}` +
157
- `<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>`;
158
210
  }
159
211
 
160
212
  // The notice of a turn that ended with no answer (the server's
161
213
  // empty_answer item, turn_summary.empty_answer live): a muted row like the
162
214
  // loop's retry row, where the answer would be.
163
- export function emptyAnswerHtml(item) {
164
- 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>`;
165
224
  }
166
225
 
167
- // The element +html+ (one top-level element) describes.
168
- export function htmlElement(html) {
169
- const template = document.createElement("template");
170
- template.innerHTML = html;
171
- 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;
172
232
  }
@@ -222,13 +222,23 @@ export function genLabelParts(gen) {
222
222
  if (line) return { head: cut(line), calls, repeats: true };
223
223
  if (first?.title) return { head: cut(`${toolName(first)} ${first.title}`), calls, repeats: true };
224
224
  if (gen.tools.length) {
225
- const names = [...new Set(gen.tools.map(toolName))];
226
- const head = `working with ${names.slice(0, LABEL_TOOLS).join(", ")}${names.length > LABEL_TOOLS ? ", …" : ""}`;
227
- return { head, calls, repeats: false };
225
+ // A plugin label that is a verb ("echoing", the default "calling
226
+ // tool") is the head itself when every call has one; among other
227
+ // tools it gives way to its tool's name ("working with read, echo").
228
+ const verbs = gen.tools.every(isVerbLabel);
229
+ const names = [...new Set(gen.tools.map((r) => (verbs || !isVerbLabel(r) ? toolName(r) : r.tool || "tool")))];
230
+ const list = `${names.slice(0, LABEL_TOOLS).join(", ")}${names.length > LABEL_TOOLS ? ", …" : ""}`;
231
+ return { head: verbs ? list : `working with ${list}`, calls, repeats: false };
228
232
  }
229
233
  return { head: "thinking", calls: "", repeats: false };
230
234
  }
231
235
 
236
+ // A plugin tool's label that reads as a verb (its first word ends in
237
+ // "ing": "echoing", "calling tool"), not as a name ("chrome: screenshot").
238
+ function isVerbLabel(row) {
239
+ return /^[a-z]+ing\b/i.test(String(row?.label || ""));
240
+ }
241
+
232
242
  // A step that said something (its text is not the turn's answer): a row of
233
243
  // its own in a finished block's outline.
234
244
  export function isNarrated(gen) {
@@ -24,13 +24,15 @@ import { html, nothing, render } from "./vendor/lit-html.js";
24
24
  import { setAnswerRefs } from "./answer_refs.js";
25
25
  import { bindRowRefs } from "./refs.js";
26
26
  import { leavesBlock } from "./card.js";
27
- import { copySourceAttr } from "./copy.js";
28
- import { escapeHtml, failedTurnText, messageBodyHtml, noteHtml, normalize, sourceLabel, steerRowHtml } from "./format.js";
29
- import { thumbsHtml } from "./images.js";
27
+ import { failedTurnText, normalize, sourceLabel } from "./format.js";
28
+ import { thumbsTemplate } from "./images.js";
30
29
  import { shouldFollowScroll } from "./scroll.js";
31
30
  import { hookNoticeLabel } from "./turn_events.js";
32
- import { commandBubbleHtml, emptyAnswerHtml, htmlElement, shellCommandMessage, toolRowHtml, toolRowInnerHtml, userBubbleHtml } from "./turn_html.js";
33
- import { cancelLineHtml, formatDuration, turnGroups, turnTimingText } from "./timing.js";
31
+ import {
32
+ answerBubbleTemplate, cancelLineTemplate, commandBubbleTemplate, elementOf, emptyAnswerTemplate, noteBubbleTemplate, shellCommandMessage,
33
+ steerRowTemplate, toolRowInnerTemplate, toolRowTemplate, userBubbleTemplate,
34
+ } from "./turn_html.js";
35
+ import { formatDuration, turnGroups, turnTimingText } from "./timing.js";
34
36
  import { createSentenceFeed } from "./sentences.js";
35
37
  import { createTicker } from "./thinking_ticker.js";
36
38
  import * as model from "./turn_model.js";
@@ -110,13 +112,6 @@ function nodesOf(template) {
110
112
  return frag;
111
113
  }
112
114
 
113
- // +markup+ (any HTML string) as nodes.
114
- function rawNodes(markup) {
115
- const t = document.createElement("template");
116
- t.innerHTML = markup;
117
- return t.content;
118
- }
119
-
120
115
  // ---- A finished block's outline ----
121
116
 
122
117
  // A finished block as its outline (turn_model.js outlineRuns of +gens+, its
@@ -170,43 +165,16 @@ export function outlineBlock(blockEl, gens) {
170
165
  return true;
171
166
  }
172
167
 
173
- // Reloaded tool rows (turn_html.js toolRowHtml) as nodes, each row's
168
+ // Reloaded tool rows (turn_html.js toolRowTemplate) as nodes, each row's
174
169
  // plugin refs bound to its title (refs.js bindRowRefs).
175
170
  function rowNodes(rows, thumbs) {
176
- const nodes = rawNodes(rows.map((r) => toolRowHtml(r, { thumbs })).join(""));
171
+ const nodes = nodesOf(rows.map((r) => toolRowTemplate(r, { thumbs })));
177
172
  [...nodes.children].forEach((el, i) => bindRowRefs(el, rows[i]));
178
173
  return nodes;
179
174
  }
180
175
 
181
176
  // ---- The live view ----
182
177
 
183
- // The parts of a drawn tool row a user may have changed (the command's
184
- // raw toggle, an opened diff) or that never change once there (the
185
- // thumbs): a redraw keeps them as they are.
186
- const KEPT_PARTS = ["activity-command", "activity-diff", "activity-read", "thumbs"];
187
-
188
- const childWith = (el, cls) => [...el.children].find((c) => c.classList.contains(cls));
189
-
190
- // A live tool row drawn again from its activity row (status, title,
191
- // output): the reloaded row's markup (toolRowInnerHtml), the kept parts
192
- // and the stop-task button carried over. +html+ is that markup.
193
- function redrawRow(el, markup) {
194
- const fresh = htmlElement(`<div>${markup}</div>`);
195
- for (const cls of KEPT_PARTS) {
196
- const old = childWith(el, cls);
197
- const drawn = childWith(fresh, cls);
198
- if (old && drawn) drawn.replaceWith(old);
199
- }
200
- const stop = childWith(el, "task-stop");
201
- el.replaceChildren(...fresh.childNodes);
202
- if (stop) rowAnchor(el).after(stop);
203
- }
204
-
205
- // Where a row's stop-task button goes: after its title (else its tool).
206
- function rowAnchor(el) {
207
- return childWith(el, "activity-params") || childWith(el, "activity-tool");
208
- }
209
-
210
178
  // The container seams (the stage view composes this view, stage_view.js):
211
179
  // blockHost(el) puts the turn's block in the page (default: the history);
212
180
  // follow() keeps the live part in view (default: the history's
@@ -485,10 +453,11 @@ export function createTurnView({
485
453
  onChange();
486
454
  }
487
455
 
488
- // A tool row is the reloaded row's markup (turn_html.js toolRowHtml),
489
- // drawn again when an event changed it.
490
- const rowHtml = new WeakMap();
491
- const rowThumbs = (images) => thumbsHtml(sessionId(), images);
456
+ // A live tool row is its own element, the reloaded row's template
457
+ // (turn_html.js toolRowInnerTemplate) rendered into it on each event.
458
+ const rowThumbs = (images) => thumbsTemplate(sessionId(), images);
459
+ // A row element → its stop-task button (syncTaskStop).
460
+ const rowStops = new WeakMap();
492
461
 
493
462
  // A row, notice or card into the step's activity; a step that wrote no
494
463
  // narration before it drops the "…".
@@ -504,43 +473,48 @@ export function createTurnView({
504
473
  apply(r);
505
474
  const entry = genEls.get(r.gen);
506
475
  let el = entry.rows.get(r.row.key);
507
- const inner = toolRowInnerHtml(r.row, { thumbs: rowThumbs });
508
476
  if (!el) {
509
- el = htmlElement(toolRowHtml(r.row, { thumbs: rowThumbs }));
510
- bindRowRefs(el, r.row);
511
- rowHtml.set(el, inner);
477
+ el = document.createElement("div");
478
+ el.className = "activity-row";
479
+ el.dataset.key = r.row.key;
512
480
  entry.rows.set(r.row.key, el);
481
+ drawRow(el, r.row);
513
482
  addItem(entry, el);
514
483
  } else {
515
484
  if (!r.gen.text) entry.placeholder = false;
516
- if (rowHtml.get(el) !== inner) {
517
- redrawRow(el, inner);
518
- bindRowRefs(el, r.row);
519
- }
520
- rowHtml.set(el, inner);
485
+ drawRow(el, r.row);
521
486
  // A late result in a closed step: its label and failed mark.
522
487
  draw(entry);
523
488
  }
524
- syncTaskStop(el, r.row);
525
489
  updateBlockSummary();
526
490
  onChange();
527
491
  }
528
492
 
529
- // The stop-task button after a running task_wait's params; gone once the
530
- // row completes.
493
+ // The row rendered again (lit writes only what changed: a toggled raw
494
+ // command, an opened diff or read and the thumbs stay), its stop-task
495
+ // button in its slot, its plugin refs bound to its title (refs.js).
496
+ function drawRow(el, row) {
497
+ render(toolRowInnerTemplate(row, { thumbs: rowThumbs, stop: syncTaskStop(el, row) }), el);
498
+ bindRowRefs(el, row);
499
+ }
500
+
501
+ // The stop-task button of a running task_wait's row (after its title),
502
+ // nothing once the row completes.
531
503
  function syncTaskStop(el, row) {
532
504
  const taskId = taskStop?.target(row);
533
- const btn = el.querySelector(":scope > .task-stop");
505
+ let btn = rowStops.get(el);
534
506
  if (!taskId) {
535
- btn?.remove();
536
- return;
507
+ rowStops.delete(el);
508
+ return nothing;
537
509
  }
538
510
  if (btn) {
539
511
  btn.disabled = taskStop.busy(taskId);
540
- return;
512
+ return btn;
541
513
  }
542
514
  const command = () => taskCommand(turn?.gens, taskId);
543
- rowAnchor(el).after(taskStopButton(taskStop, taskId, { command }));
515
+ btn = taskStopButton(taskStop, taskId, { command });
516
+ rowStops.set(el, btn);
517
+ return btn;
544
518
  }
545
519
 
546
520
  // A hook's line (hook_notice) as a row of the current step, above the row
@@ -560,7 +534,7 @@ export function createTurnView({
560
534
  // that ends first puts it in its last step). False with no running turn.
561
535
  function steer(data) {
562
536
  if (!turn || turn.ended) return false;
563
- pendingSteers.push(htmlElement(steerRowHtml(data)));
537
+ pendingSteers.push(elementOf(steerRowTemplate(data)));
564
538
  return true;
565
539
  }
566
540
 
@@ -829,26 +803,12 @@ export function stepText(block, iteration) {
829
803
  // A re-rendered failed turn's line (a .bubble.cancel, as live's
830
804
  // addEndBubble), in turn_failed's words (failedTurnText) from what the turn
831
805
  // record kept of it (SessionMetrics' failure: summary, message,
832
- // error_class, kept_steps); "" for any other record, or a failed one saved
833
- // before records kept that.
834
- function failedLineHtml(record) {
806
+ // error_class, kept_steps); nothing for any other record, or a failed one
807
+ // saved before records kept that.
808
+ function failedLineTemplate(record) {
835
809
  const failure = record?.status === "failed" ? record.failure : null;
836
- if (!failure || typeof failure !== "object") return "";
837
- return `<div class="bubble cancel">${escapeHtml(failedTurnText(failure))}</div>`;
838
- }
839
-
840
- // Parts (HTML strings and templates, in order) as one fragment.
841
- function fragmentOf(parts) {
842
- const out = document.createDocumentFragment();
843
- let markup = "";
844
- for (const part of parts) {
845
- if (typeof part === "string") { markup += part; continue; }
846
- if (markup) out.append(rawNodes(markup));
847
- markup = "";
848
- out.append(nodesOf(part));
849
- }
850
- if (markup) out.append(rawNodes(markup));
851
- return out;
810
+ if (!failure || typeof failure !== "object") return nothing;
811
+ return html`<div class="bubble cancel">${failedTurnText(failure)}</div>`;
852
812
  }
853
813
 
854
814
  // A reloaded history in the turn view (renderHistory's object path), as
@@ -857,53 +817,51 @@ function fragmentOf(parts) {
857
817
  // the thinking, params and output, from the saved messages; status and
858
818
  // duration from the timing's tool records), then the answer and the turn's
859
819
  // timing line under it.
860
- // +thumbs(images)+ draws a prompt's or a tool row's image thumbs.
820
+ // +thumbs(images)+ draws a prompt's or a tool row's image thumbs (a
821
+ // template, images.js thumbsTemplate).
861
822
  export function turnHistoryNodes(items, timing, { thumbs }) {
862
- const timingHtml = (group) => {
823
+ const timingTemplate = (group) => {
863
824
  const record = group.record;
864
825
  // Numbered by its place in the turn records, as live (a failed turn
865
826
  // whose prompt went back still counts).
866
827
  const at = (timing?.turnRecords || []).indexOf(record);
867
828
  return record && formatDuration(record.duration_ms)
868
- ? `<div class="turn-timing">${escapeHtml(turnTimingText(at >= 0 ? at + 1 : group.turnIndex + 1, record.duration_ms, { canceled: record.status === "canceled" }))}</div>`
869
- : "";
829
+ ? html`<div class="turn-timing">${turnTimingText(at >= 0 ? at + 1 : group.turnIndex + 1, record.duration_ms, { canceled: record.status === "canceled" })}</div>`
830
+ : nothing;
870
831
  };
871
- // The timing line after the bubble, not in it: where the live view
872
- // leaves it at the turn's end. +at+: the message's index, for its refs
832
+ // An answer's bubble; +at+, the message's index, is for its refs
873
833
  // (data-answer-at, gone before the nodes are returned).
874
- const answerHtml = (at, timing) => {
875
- const m = items[at];
876
- const { body, renderedMarkdown } = messageBodyHtml(m);
877
- return `<div class="bubble output${renderedMarkdown ? " markdown" : ""}" data-answer-at="${at}"${copySourceAttr(m, renderedMarkdown)}>${body}</div>${timing}`;
878
- };
834
+ const answerBubble = (at, cls = "") => answerBubbleTemplate(items[at], { cls, at });
835
+ // The timing line after the bubble, not in it: where the live view
836
+ // leaves it at the turn's end.
837
+ const answerTemplate = (at, timing) => html`${answerBubble(at)}${timing}`;
879
838
  // A user's line, labelled when chi brought it in (a delegate report). A
880
839
  // `!cmd`'s saved message (the worker's "!(<command>)\n<output>") renders
881
840
  // as its command bubble, as live does — not as a user bubble.
882
- const userHtml = (m, opts = {}) => {
841
+ const userTemplate = (m, opts = {}) => {
883
842
  const shell = shellCommandMessage(m?.content);
884
- if (shell) return commandBubbleHtml(shell);
885
- return userBubbleHtml(m, { label: sourceLabel(m?.source), thumbs: thumbs(m.images), ...opts });
843
+ if (shell) return commandBubbleTemplate(shell);
844
+ return userBubbleTemplate(m, { label: sourceLabel(m?.source), thumbs: thumbs(m.images), ...opts });
886
845
  };
887
846
  // The first text after a delegate report merged mid-turn, unless it is
888
847
  // the turn's answer (shown anyway).
889
- const reportReplyHtml = (i, group) => {
890
- if (items[i]?.source !== "delegate_report") return "";
848
+ const reportReplyTemplate = (i, group) => {
849
+ if (items[i]?.source !== "delegate_report") return nothing;
891
850
  const j = items.findIndex((m, k) => k > i && m?.role === "assistant" && String(m.content ?? "").trim());
892
- if (j < 0 || j === group.answer || items.slice(i + 1, j).some((m) => m?.role === "user" && !m.merged)) return "";
893
- const { body, renderedMarkdown } = messageBodyHtml(items[j]);
894
- return `<div class="bubble output report-reply${renderedMarkdown ? " markdown" : ""}" data-answer-at="${j}"${copySourceAttr(items[j], renderedMarkdown)}>${body}</div>`;
851
+ if (j < 0 || j === group.answer || items.slice(i + 1, j).some((m) => m?.role === "user" && !m.merged)) return nothing;
852
+ return answerBubble(j, " report-reply");
895
853
  };
896
- const steerHtml = (i, bubble) => steerRowHtml({ source: items[i].source, text: items[i].content }, { bubble });
854
+ const steerTemplate = (i, bubble) => steerRowTemplate({ source: items[i].source, text: items[i].content }, { bubble });
897
855
  const parts = [];
898
856
  // Each block's steps, in order: its outline once the nodes exist.
899
857
  const outlines = [];
900
858
  for (const group of turnGroups(items, timing)) {
901
- if (group.kind === "note") { parts.push(`<div class="bubble note">${noteHtml(items[group.i])}</div>`); continue; }
902
- if (group.kind === "steer") { parts.push(steerHtml(group.i, true)); continue; }
903
- if (group.kind === "answer") { parts.push(answerHtml(group.answer, "")); continue; }
859
+ if (group.kind === "note") { parts.push(noteBubbleTemplate(items[group.i])); continue; }
860
+ if (group.kind === "steer") { parts.push(steerTemplate(group.i, true)); continue; }
861
+ if (group.kind === "answer") { parts.push(answerTemplate(group.answer, nothing)); continue; }
904
862
  const prompt = items[group.user];
905
863
  // A context wake turn starts with its note.
906
- parts.push(prompt.role === "note" ? `<div class="bubble note">${noteHtml(prompt)}</div>` : userHtml(prompt));
864
+ parts.push(prompt.role === "note" ? noteBubbleTemplate(prompt) : userTemplate(prompt));
907
865
  const [only] = group.steps;
908
866
  const ended = group.answer !== null || group.emptyAnswer != null;
909
867
  const textless = only && (only.i === null || !String(items[only.i].content ?? ""));
@@ -917,7 +875,7 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
917
875
  const gens = group.steps.map((step) => ({ text: step.i === null ? "" : String(items[step.i].content ?? ""), thinking: step.thinking || "", tools: step.tools }));
918
876
  const steps = gens.map((gen, k) => stepTemplate(gen, {
919
877
  // The steers the step answered first, where the live rows went.
920
- steers: (group.steps[k].steers || []).map((i) => rawNodes(steerHtml(i, false))),
878
+ steers: (group.steps[k].steers || []).map((i) => steerTemplate(i, false)),
921
879
  items: gen.tools.length ? [rowNodes(gen.tools, thumbs)] : [],
922
880
  }));
923
881
  parts.push(html`<details class="turn-work done"><summary>${model.blockSummary({ gens, ended: true })}</summary>${steps}</details>`);
@@ -926,28 +884,27 @@ export function turnHistoryNodes(items, timing, { thumbs }) {
926
884
  // Steers with no step to sit in, or in a turn that shows no block: on
927
885
  // their own after the prompt.
928
886
  const loose = noBlock ? [...group.steps.flatMap((step) => step.steers || []), ...(group.steers || [])] : group.steers || [];
929
- parts.push(loose.map((i) => steerHtml(i, true)).join(""));
887
+ parts.push(loose.map((i) => steerTemplate(i, true)));
930
888
  // The user's lines merged into the turn: the live "steered" bubbles,
931
889
  // after its steps and before its answer. A delegate report's is
932
890
  // followed by the text that answered it when that isn't the answer: a
933
891
  // small model tells its user about a report before its next tool call,
934
892
  // and that step's text would stay folded in the block.
935
893
  parts.push((group.merged || []).map((i) =>
936
- userHtml(items[i], { state: "steered", step: items[i].step }) +
937
- reportReplyHtml(i, group)).join(""));
894
+ html`${userTemplate(items[i], { state: "steered", step: items[i].step })}${reportReplyTemplate(i, group)}`));
938
895
  // A turn that ended with no answer: chi's muted notice where the answer
939
896
  // would be, before the timing, as live.
940
- if (group.emptyAnswer != null) parts.push(`${emptyAnswerHtml(items[group.emptyAnswer])}${timingHtml(group)}`);
897
+ if (group.emptyAnswer != null) parts.push(html`${emptyAnswerTemplate(items[group.emptyAnswer])}${timingTemplate(group)}`);
941
898
  // A turn with no answer (canceled, or running: no record yet) shows its
942
899
  // timing after its prompt and steps, where the live view leaves it.
943
- else if (group.answer === null) parts.push(timingHtml(group));
944
- else parts.push(answerHtml(group.answer, timingHtml(group)));
900
+ else if (group.answer === null) parts.push(timingTemplate(group));
901
+ else parts.push(answerTemplate(group.answer, timingTemplate(group)));
945
902
  // A canceled turn ends with its cancel line, a failed one whose steps
946
903
  // stayed with its failure line, as the live view left them.
947
- parts.push(cancelLineHtml(group.record, escapeHtml));
948
- parts.push(failedLineHtml(group.record));
904
+ parts.push(cancelLineTemplate(group.record));
905
+ parts.push(failedLineTemplate(group.record));
949
906
  }
950
- const out = fragmentOf(parts);
907
+ const out = nodesOf(parts);
951
908
  // A finished turn's block as the live end leaves it: its outline, open.
952
909
  [...out.children].filter((el) => el.matches(".turn-work")).forEach((block, i) => outlineBlock(block, outlines[i] || []));
953
910
  // Each answer bubble knows its message's refs (answer_refs.js; the
@@ -0,0 +1,8 @@
1
+ // lit-html 3.3.3 (npm tarball lit-html-3.3.3.tgz, directive.js: the minified production
2
+ // build, unmodified but for this line and the dropped sourceMappingURL). BSD-3-Clause.
3
+ /**
4
+ * @license
5
+ * Copyright 2017 Google LLC
6
+ * SPDX-License-Identifier: BSD-3-Clause
7
+ */
8
+ const t={ATTRIBUTE:1,CHILD:2,PROPERTY:3,BOOLEAN_ATTRIBUTE:4,EVENT:5,ELEMENT:6},e=t=>(...e)=>({_$litDirective$:t,values:e});class i{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,e,i){this._$Ct=t,this._$AM=e,this._$Ci=i}_$AS(t,e){return this.update(t,e)}update(t,e){return this.render(...e)}}export{i as Directive,t as PartType,e as directive};
@@ -0,0 +1,8 @@
1
+ // lit-html 3.3.3 (npm tarball lit-html-3.3.3.tgz, directives/unsafe-html.js: the minified
2
+ // production build, unmodified but for this line and the dropped sourceMappingURL). BSD-3-Clause.
3
+ import{nothing as t,noChange as i}from"../lit-html.js";import{directive as r,Directive as s,PartType as n}from"../directive.js";
4
+ /**
5
+ * @license
6
+ * Copyright 2017 Google LLC
7
+ * SPDX-License-Identifier: BSD-3-Clause
8
+ */class e extends s{constructor(i){if(super(i),this.it=t,i.type!==n.CHILD)throw Error(this.constructor.directiveName+"() can only be used in child bindings")}render(r){if(r===t||null==r)return this._t=void 0,this.it=r;if(r===i)return r;if("string"!=typeof r)throw Error(this.constructor.directiveName+"() called with a non-string value");if(r===this.it)return this._t;this.it=r;const s=[r];return s.raw=s,this._t={_$litType$:this.constructor.resultType,strings:s,values:[]}}}e.directiveName="unsafeHTML",e.resultType=1;const o=r(e);export{e as UnsafeHTMLDirective,o as unsafeHTML};