samagotchi 0.62.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -1
  3. data/README.md +4 -1
  4. data/bin/chi +3 -2
  5. data/docs/cli.md +4 -1
  6. data/docs/configuration.md +1 -1
  7. data/docs/context.md +13 -3
  8. data/docs/hooks.md +6 -3
  9. data/docs/memory.md +1 -1
  10. data/docs/plugins.md +89 -2
  11. data/docs/releasing.md +1 -1
  12. data/docs/sub-agent.md +6 -0
  13. data/docs/testing.md +7 -4
  14. data/lib/samagotchi/answer_refs.rb +18 -26
  15. data/lib/samagotchi/bridge.rb +23 -1
  16. data/lib/samagotchi/bundles/claude-peer/manifest.yml +22 -0
  17. data/lib/samagotchi/bundles/claude-peer/plugin.rb +406 -0
  18. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +104 -0
  19. data/lib/samagotchi/bundles/claude-peer/scripts/pair +66 -0
  20. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +60 -0
  21. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  22. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
  24. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  26. data/lib/samagotchi/context_absorber.rb +25 -6
  27. data/lib/samagotchi/idle_recap.rb +2 -3
  28. data/lib/samagotchi/image_store.rb +8 -0
  29. data/lib/samagotchi/plugin/attached_context.rb +7 -4
  30. data/lib/samagotchi/recap_setup.rb +3 -3
  31. data/lib/samagotchi/row_refs.rb +3 -4
  32. data/lib/samagotchi/session.rb +12 -0
  33. data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
  34. data/lib/samagotchi/terminal_ui/attached_loop.rb +26 -9
  35. data/lib/samagotchi/terminal_ui.rb +31 -11
  36. data/lib/samagotchi/tools/execute.rb +22 -4
  37. data/lib/samagotchi/version.rb +1 -1
  38. data/lib/samagotchi/web/app.rb +6 -4
  39. data/lib/samagotchi/web/public/activity.js +2 -2
  40. data/lib/samagotchi/web/public/app.js +47 -91
  41. data/lib/samagotchi/web/public/card.js +73 -23
  42. data/lib/samagotchi/web/public/chains.js +6 -4
  43. data/lib/samagotchi/web/public/command_view.js +39 -35
  44. data/lib/samagotchi/web/public/copy.js +7 -15
  45. data/lib/samagotchi/web/public/diff_view.js +20 -23
  46. data/lib/samagotchi/web/public/format.js +0 -48
  47. data/lib/samagotchi/web/public/images.js +12 -16
  48. data/lib/samagotchi/web/public/question_cards.js +3 -2
  49. data/lib/samagotchi/web/public/read_view.js +10 -9
  50. data/lib/samagotchi/web/public/scope.js +23 -0
  51. data/lib/samagotchi/web/public/timing.js +0 -7
  52. data/lib/samagotchi/web/public/turn_html.js +119 -59
  53. data/lib/samagotchi/web/public/turn_view.js +73 -116
  54. data/lib/samagotchi/web/public/vendor/directive.js +8 -0
  55. data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
  56. data/lib/samagotchi/worker.rb +46 -30
  57. data/lib/samagotchi/worker_inbound.rb +12 -11
  58. metadata +8 -1
@@ -10,6 +10,8 @@
10
10
  // every path that fills it (a render, a live turn in either view, the
11
11
  // turn-end markdown) gets them without knowing about them.
12
12
 
13
+ import { html, render } from "./vendor/lit-html.js";
14
+
13
15
  export const COPY_SOURCE_ATTR = "data-copy-source";
14
16
 
15
17
  // +text+ to the clipboard; true when it got there. Without the Clipboard
@@ -73,26 +75,16 @@ export function showsAnswer(bubble, message, normalize) {
73
75
  return normalize(bubble.textContent) === normalize(message.content);
74
76
  }
75
77
 
76
- // The attribute that keeps a message's source on its reloaded bubble:
77
- // rendered html and a prompt (its `>` lines render as quotes) need it; a
78
- // plain answer's text is its source.
79
- export function copySourceAttr(message, renderedMarkdown) {
80
- if (!renderedMarkdown && message?.role !== "user") return "";
81
- const text = copySource(message)
82
- .replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
83
- return ` ${COPY_SOURCE_ATTR}="${text}"`;
84
- }
85
-
86
- const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M10.5 3.5v-.5A1.5 1.5 0 0 0 9 1.5H3A1.5 1.5 0 0 0 1.5 3v6A1.5 1.5 0 0 0 3 10.5h.5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>';
78
+ const ICON = html`<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M10.5 3.5v-.5A1.5 1.5 0 0 0 9 1.5H3A1.5 1.5 0 0 0 1.5 3v6A1.5 1.5 0 0 0 3 10.5h.5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>`;
87
79
 
88
80
  const BUBBLES = ".bubble.output:not(.streaming), .bubble.user";
89
81
  const CODE = ".bubble.output pre";
90
82
 
91
- // The button as markup, for a builder that returns HTML (a tool row's
83
+ // The button as a lit template, for a templated part (a tool row's
92
84
  // command block, command_view.js); the same click handler answers it.
93
- export function copyButtonHtml(kind) {
85
+ export function copyButtonTemplate(kind) {
94
86
  const title = kind === "code" ? "Copy code" : "Copy";
95
- return `<button type="button" class="copy-btn copy-${kind}" title="${title}" aria-label="${title}">${ICON}</button>`;
87
+ return html`<button type="button" class=${`copy-btn copy-${kind}`} title=${title} aria-label=${title}>${ICON}</button>`;
96
88
  }
97
89
 
98
90
  function makeButton(doc, kind) {
@@ -101,7 +93,7 @@ function makeButton(doc, kind) {
101
93
  btn.className = `copy-btn copy-${kind}`;
102
94
  btn.title = kind === "code" ? "Copy code" : "Copy";
103
95
  btn.setAttribute("aria-label", btn.title);
104
- btn.innerHTML = ICON;
96
+ render(ICON, btn);
105
97
  return btn;
106
98
  }
107
99
 
@@ -1,10 +1,10 @@
1
- // What chi's edit/write tools change, as HTML, apart from the DOM. One
1
+ // What chi's edit/write tools change, as lit-html templates. One
2
2
  // renderer for both places: the approval card (the dry run, which can also
3
3
  // say the edit would fail, or that the file isn't shown) and a tool row's
4
4
  // collapsed "diff +3 −1" (what the call really changed). A diff is the
5
5
  // server's {text, added, removed, truncated, new_file} (EditPreview).
6
6
 
7
- import { escapeHtml } from "./format.js";
7
+ import { html, nothing } from "./vendor/lit-html.js";
8
8
 
9
9
  // More lines than this fold behind a "show all N lines" toggle.
10
10
  export const DIFF_FOLD_LINES = 20;
@@ -51,34 +51,31 @@ export function diffLineNumbers(lines) {
51
51
  }
52
52
 
53
53
  // A line with a number carries it as data-line (refs.js opens it there).
54
- function linesHtml(lines, numbers) {
55
- return lines.map((line, i) => {
56
- const at = numbers[i] > 0 ? ` data-line="${numbers[i]}"` : "";
57
- return `<span class="diff-line diff-${diffLineClass(line)}"${at}>${escapeHtml(line) || " "}</span>`;
58
- }).join("");
54
+ function linesTemplate(lines, numbers) {
55
+ return lines.map((line, i) =>
56
+ html`<span class=${`diff-line diff-${diffLineClass(line)}`} data-line=${numbers[i] > 0 ? numbers[i] : nothing}>${line || " "}</span>`);
59
57
  }
60
58
 
61
59
  // @param diff an EditPreview: a diff, or {error} / {skipped}
62
- // @return {string} HTML; "" for none
63
- export function diffHtml(diff, { fold = DIFF_FOLD_LINES } = {}) {
64
- if (!diff || typeof diff !== "object") return "";
65
- if (diff.error) return `<div class="diff-note warn">this edit would fail: ${escapeHtml(diff.error)}</div>`;
66
- if (diff.skipped) return `<div class="diff-note">diff not shown: ${escapeHtml(diff.skipped)}</div>`;
60
+ // @return a lit template; nothing for none
61
+ export function diffTemplate(diff, { fold = DIFF_FOLD_LINES } = {}) {
62
+ if (!diff || typeof diff !== "object") return nothing;
63
+ if (diff.error) return html`<div class="diff-note warn">this edit would fail: ${diff.error}</div>`;
64
+ if (diff.skipped) return html`<div class="diff-note">diff not shown: ${diff.skipped}</div>`;
67
65
  const text = String(diff.text ?? "");
68
- if (!text) return `<div class="diff-note">no change</div>`;
66
+ if (!text) return html`<div class="diff-note">no change</div>`;
69
67
  const lines = text.split("\n");
70
- const head = diff.new_file ? `<div class="diff-note">new file</div>` : "";
68
+ const head = diff.new_file ? html`<div class="diff-note">new file</div>` : nothing;
71
69
  const numbers = diffLineNumbers(lines);
72
- const shown = `<pre class="diff"><code>${linesHtml(lines.slice(0, fold), numbers.slice(0, fold))}</code></pre>`;
73
- if (lines.length <= fold) return head + shown;
70
+ const shown = html`<pre class="diff"><code>${linesTemplate(lines.slice(0, fold), numbers.slice(0, fold))}</code></pre>`;
71
+ if (lines.length <= fold) return html`${head}${shown}`;
74
72
  // The rest opens under the first lines; the toggle hides once open (CSS).
75
- const rest = `<details class="diff-more"><summary>show all ${lines.length} lines</summary>` +
76
- `<pre class="diff"><code>${linesHtml(lines.slice(fold), numbers.slice(fold))}</code></pre></details>`;
77
- return head + shown + rest;
73
+ return html`${head}${shown}<details class="diff-more"><summary>show all ${lines.length} lines</summary><pre class="diff"><code>${linesTemplate(lines.slice(fold), numbers.slice(fold))}</code></pre></details>`;
78
74
  }
79
75
 
80
- // A tool row's collapsed diff (closed by default).
81
- export function activityDiffHtml(diff) {
82
- if (!diff || typeof diff !== "object" || diff.error || diff.skipped) return "";
83
- return `<details class="activity-diff"><summary>${escapeHtml(diffSummary(diff))}</summary>${diffHtml(diff)}</details>`;
76
+ // A tool row's collapsed diff (closed by default: lit never binds its
77
+ // open, so a user's toggle stays across a redraw).
78
+ export function activityDiffTemplate(diff) {
79
+ if (!diff || typeof diff !== "object" || diff.error || diff.skipped) return nothing;
80
+ return html`<details class="activity-diff"><summary>${diffSummary(diff)}</summary>${diffTemplate(diff)}</details>`;
84
81
  }
@@ -10,21 +10,6 @@ export function escapeHtml(value) {
10
10
  );
11
11
  }
12
12
 
13
- export function messageBodyHtml(message) {
14
- if (message?.role === "assistant" && typeof message.html === "string") {
15
- return { body: message.html, renderedMarkdown: true };
16
- }
17
- if (message?.role === "user") return { body: userBodyHtml(message.content), renderedMarkdown: false };
18
- return { body: escapeHtml(message?.content), renderedMarkdown: false };
19
- }
20
-
21
- // A context note (background the user or another session pushed in; the
22
- // model reads it, nobody asked it anything): who sent it, then its text.
23
- export function noteHtml(note) {
24
- const from = note?.label ? `note from ${note.label}` : "note";
25
- return `<div class="note-line">${escapeHtml(from)}</div><div class="note-text">${escapeHtml(note?.content ?? "")}</div>`;
26
- }
27
-
28
13
  // Who a steer names as its sender: a plugin's own label, chi's sender ids
29
14
  // in words (the TUI's Formatting#steer_sender agrees;
30
15
  // spec/shared/labels_matrix.json). "": no one named.
@@ -41,39 +26,6 @@ export function sourceLabel(source) {
41
26
  return source === "delegate_report" ? steerSender(source) : null;
42
27
  }
43
28
 
44
- // A plugin's steer (a nudge put into the running turn): one line that
45
- // names who nudged the model, its text collapsed under it. +bubble+: on its
46
- // own between bubbles (no step holds it), not a row of a step.
47
- export function steerRowHtml({ source, text } = {}, { bubble = false } = {}) {
48
- const sender = steerSender(source);
49
- const who = sender ? `${sender} nudged the model` : "nudged the model";
50
- return `<details class="${bubble ? "bubble " : ""}steer-row"><summary>${escapeHtml(who)}</summary>` +
51
- `<div class="steer-text">${escapeHtml(text ?? "")}</div></details>`;
52
- }
53
-
54
- // A user message's body: runs of `>` lines become <blockquote>s, the rest
55
- // stays escaped text. The bubble is pre-wrap, so the blank lines around a
56
- // quote are dropped (the blockquote's margin spaces it instead).
57
- export function userBodyHtml(content) {
58
- const text = String(content ?? "").replace(/\r\n?/g, "\n");
59
- const blocks = [];
60
- for (const line of text.split("\n")) {
61
- const m = /^ {0,3}> ?(.*)$/.exec(line);
62
- const kind = m ? "quote" : "text";
63
- const last = blocks[blocks.length - 1];
64
- if (last?.kind === kind) last.lines.push(m ? m[1] : line);
65
- else blocks.push({ kind, lines: [m ? m[1] : line] });
66
- }
67
- if (!blocks.some((b) => b.kind === "quote")) return escapeHtml(content ?? "");
68
- return blocks
69
- .map((b) => {
70
- if (b.kind === "quote") return `<blockquote>${escapeHtml(b.lines.join("\n"))}</blockquote>`;
71
- const body = b.lines.join("\n").replace(/^\s*\n/, "").replace(/\n\s*$/, "");
72
- return body.trim() ? escapeHtml(body) : "";
73
- })
74
- .join("");
75
- }
76
-
77
29
  // The piece to append to a streamed body that holds +sofar+: while the body
78
30
  // is empty its leading whitespace is dropped (a model's thinking starts with
79
31
  // the "\n" after <think>, which would render as an empty first line).
@@ -1,8 +1,8 @@
1
1
  // Images in the web UI: pasted or dropped into the composer as chips, sent
2
2
  // with a turn as refs (uploaded first), shown as thumbnails in the bubbles
3
3
  // and tool rows, opened full size in a lightbox. No DOM here beyond what the
4
- // callers pass, so it runs under `node --test`.
5
- import { escapeHtml } from "./format.js";
4
+ // callers pass (the templates are lit-html's), so it runs under `node --test`.
5
+ import { html, nothing } from "./vendor/lit-html.js";
6
6
 
7
7
  // The image files a paste's or drop's DataTransfer carries.
8
8
  export function imageFiles(dataTransfer) {
@@ -40,29 +40,25 @@ export function imageUrl(sessionId, ref) {
40
40
  .join("/")}`;
41
41
  }
42
42
 
43
- // Thumbnails for a bubble or a tool row; "" for none. `src` may be given
44
- // (a blob: URL while the upload is on its way).
45
- export function thumbsHtml(sessionId, images) {
43
+ // Thumbnails for a bubble or a tool row; nothing for none. `src` may be
44
+ // given (a blob: URL while the upload is on its way). A redraw with the
45
+ // same images keeps the <img> elements (no reload).
46
+ export function thumbsTemplate(sessionId, images) {
46
47
  const list = (images || []).filter((i) => i && (i.src || i.file));
47
- if (!list.length) return "";
48
+ if (!list.length) return nothing;
48
49
  const thumbs = list.map((img) => {
49
50
  const src = img.src || imageUrl(sessionId, img);
50
51
  const size = img.width && img.height ? ` ${img.width}×${img.height}` : "";
51
52
  const title = `${img.name || "image"}${size}`;
52
- return `<img class="thumb" src="${escapeHtml(src)}" alt="${escapeHtml(title)}" title="${escapeHtml(title)}" loading="lazy">`;
53
+ return html`<img class="thumb" src=${src} alt=${title} title=${title} loading=${"lazy"}>`;
53
54
  });
54
- return `<div class="thumbs">${thumbs.join("")}</div>`;
55
+ return html`<div class="thumbs">${thumbs}</div>`;
55
56
  }
56
57
 
57
58
  // The composer's chips: a thumbnail, the name and a remove button each.
58
- export function chipsHtml(chips) {
59
- return (chips || [])
60
- .map(
61
- (c, i) =>
62
- `<span class="chip" data-index="${i}"><img src="${escapeHtml(c.src)}" alt=""><span class="chip-name">${escapeHtml(c.name)}</span>` +
63
- `<button type="button" class="chip-remove" data-index="${i}" aria-label="remove ${escapeHtml(c.name)}">×</button></span>`,
64
- )
65
- .join("");
59
+ export function chipsTemplate(chips) {
60
+ return (chips || []).map((c, i) =>
61
+ html`<span class="chip" data-index=${i}><img src=${c.src} alt=""><span class="chip-name">${c.name}</span><button type="button" class="chip-remove" data-index=${i} aria-label=${`remove ${c.name}`}>×</button></span>`);
66
62
  }
67
63
 
68
64
  // The {file, name} refs a turn names.
@@ -13,7 +13,8 @@
13
13
  // card closes on the continue_resolved every client gets.
14
14
 
15
15
  import { dismissQuestion, sendAnswer, sendCommand } from "./data.js";
16
- import { diffHtml } from "./diff_view.js";
16
+ import { diffTemplate } from "./diff_view.js";
17
+ import { render } from "./vendor/lit-html.js";
17
18
  import {
18
19
  answerErrorText, approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, deniedReason, isContinue, keepsAskingText,
19
20
  nextCardAction, relayedToView, resultText, summaryText,
@@ -301,7 +302,7 @@ export function createQuestionCards({
301
302
  if (view.preview) {
302
303
  const diff = doc.createElement("div");
303
304
  diff.className = "approval-diff";
304
- diff.innerHTML = diffHtml(view.preview);
305
+ render(diffTemplate(view.preview), diff);
305
306
  box.appendChild(diff);
306
307
  }
307
308
  for (const [cls, label, text] of [["approval-where", "in", view.where], ["approval-why", "why", view.why]]) {
@@ -1,10 +1,10 @@
1
- // A read row's output as the file's lines, numbered, apart from the DOM.
1
+ // A read row's output as the file's lines, numbered (a lit-html template).
2
2
  // Only when the output is the file text as-is: each numbered line is a
3
3
  // ref's line (refs.js opens the row's file there). The row's preview
4
4
  // (turn_html.js) strips any "[tag] " and cuts at 300 chars; numbering
5
5
  // needs the text exactly as read, so it is drawn on its own, closed.
6
6
 
7
- import { escapeHtml } from "./format.js";
7
+ import { html, nothing } from "./vendor/lit-html.js";
8
8
 
9
9
  // What follows one of these isn't the file's next line: a ToolRunner cap
10
10
  // ("[cut: …]"), a head/tail preview's gap ("[... omitted …]") or the note
@@ -58,19 +58,20 @@ export function readLines(row) {
58
58
  return out;
59
59
  }
60
60
 
61
- // A read row's numbered lines behind a closed toggle ("lines 40–60"); ""
62
- // when its output isn't the file as-is.
63
- export function readLinesHtml(row) {
61
+ // A read row's numbered lines behind a closed toggle ("lines 40–60");
62
+ // nothing when its output isn't the file as-is. lit never binds its open:
63
+ // a user's toggle stays across a redraw.
64
+ export function readLinesTemplate(row) {
64
65
  const lines = readLines(row);
65
66
  const numbered = lines?.filter((l) => l.line !== null) || [];
66
- if (!numbered.length) return "";
67
+ if (!numbered.length) return nothing;
67
68
  const first = numbered[0].line;
68
69
  const last = numbered.at(-1).line;
69
70
  const label = first === last ? `line ${first}` : `lines ${first}–${last}`;
70
71
  // A blank line of the file holds no text, only a <br>: an empty block
71
72
  // drops out of a copied selection, a space would be copied as one.
72
73
  const spans = lines.map(({ text, line }) => line === null
73
- ? `<span class="read-line read-meta">${escapeHtml(text)}</span>`
74
- : `<span class="read-line" data-line="${line}">${escapeHtml(text) || "<br>"}</span>`).join("");
75
- return `<details class="activity-read"><summary>${label}</summary><pre class="read-lines"><code>${spans}</code></pre></details>`;
74
+ ? html`<span class="read-line read-meta">${text}</span>`
75
+ : html`<span class="read-line" data-line=${line}>${text || html`<br>`}</span>`);
76
+ return html`<details class="activity-read"><summary>${label}</summary><pre class="read-lines"><code>${spans}</code></pre></details>`;
76
77
  }
@@ -1,6 +1,7 @@
1
1
  // The page's scope: ?dir=<folder> shows that folder's project (`chi web` in
2
2
  // a repo opens it so); no dir shows every session. The hash routes
3
3
  // (#/s/<id>, #/sessions) work in either.
4
+ import { html, nothing } from "./vendor/lit-html.js";
4
5
 
5
6
  // The ?dir= folder in a location.search string, or null.
6
7
  export function scopeDir(search) {
@@ -43,3 +44,25 @@ export function cardFolder(workingDirectory, projectName) {
43
44
  if (!folder) return "";
44
45
  return folder === projectName ? "" : folder;
45
46
  }
47
+
48
+ // The header's scope chip: "<project> · all" in a project view (all links
49
+ // to the same place without ?dir); "all projects" otherwise, with a link
50
+ // back to the project it came from and where a new chat starts on the
51
+ // start page. +scope+: { dir, projectName, projectDir, serverDir, backDir,
52
+ // backName }; +error+: why the ?dir= folder can't be shown; +where+:
53
+ // () => { hash, search } (the page's location, read again at a click: the
54
+ // links follow an open session or the all-sessions view).
55
+ export function scopeTemplate(scope, { error = null, where = () => ({ hash: "", search: "" }) } = {}) {
56
+ const at = where();
57
+ const allHref = () => allScopeHref(where().hash, scope.dir, where().search);
58
+ const all = html`<a class="scope-all" href=${allScopeHref(at.hash, scope.dir, at.search)} title=${"Sessions of every project"} @click=${(e) => { e.currentTarget.href = allHref(); }}>all</a>`;
59
+ if (error) return html`<span class="scope-name error" title=${error}>folder not found</span>${all}`;
60
+ if (scope.projectName) return html`<span class="scope-name" title=${`Sessions of ${scope.projectDir}`}>${scope.projectName}</span>${all}`;
61
+ const dir = scope.dir || scope.serverDir;
62
+ const backHref = () => projectScopeHref(scope.backDir, where().hash, where().search);
63
+ const back = scope.backName
64
+ ? html`<a class="scope-all scope-back" href=${projectScopeHref(scope.backDir, at.hash, at.search)} title=${`Sessions of ${scope.backName} only`} @click=${(e) => { e.currentTarget.href = backHref(); }}>← ${scope.backName}</a>`
65
+ : nothing;
66
+ const newChat = dir ? html`<span class="scope-where" title=${`New chats start in ${dir}`}>new chat in<span class="path"><bdi>${dir}</bdi></span></span>` : nothing;
67
+ return html`<span class="scope-name" title="Sessions of every project">all projects</span>${back}${newChat}`;
68
+ }
@@ -49,13 +49,6 @@ export function cancelLineText(reason, by) {
49
49
  return `${STOP_MARK} canceled` + (reason ? ` (${CANCEL_REASONS[reason] || reason})` : "");
50
50
  }
51
51
 
52
- // A re-rendered turn's cancel line (a .bubble.cancel.stopped), or "" when
53
- // the turn record is not a canceled one.
54
- export function cancelLineHtml(record, escape) {
55
- if (record?.status !== "canceled") return "";
56
- return `<div class="${STOPPED_LINE_CLASS}">${escape(cancelLineText(record.cancellation_reason, record.cancelled_by))}</div>`;
57
- }
58
-
59
52
  export function elapsedSince(startedAt, now = Date.now()) {
60
53
  const started = Date.parse(startedAt);
61
54
  const current = Number(now);
@@ -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
  }