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
@@ -7,6 +7,9 @@
7
7
  // provider can attach one: GET …/context's can_add_url) opens a form that
8
8
  // attaches a URL (a GitHub PR); the web never adds a command.
9
9
 
10
+ import { html, nothing, render } from "./vendor/lit-html.js";
11
+ import { keepFocus } from "./keep_focus.js";
12
+
10
13
  // "now", "3m", "2h", "4d" since an ISO time; "" for none.
11
14
  export function ageText(iso, now = Date.now()) {
12
15
  const at = Date.parse(iso || "");
@@ -38,33 +41,40 @@ export function chipModel(rows, now = Date.now()) {
38
41
  });
39
42
  }
40
43
 
41
- // addUrl: end with the "+ URL" chip.
42
- export function chipsHtml(chips, escapeHtml, { addUrl = false } = {}) {
43
- return chips.map((chip) => `<button type="button" class="ctx-chip ${chip.state}" data-name="${escapeHtml(chip.name)}" ` +
44
- `title="${escapeHtml(chip.title)}" aria-haspopup="dialog">` +
45
- `${chip.state === "unread" ? '<span class="ctx-dot" aria-hidden="true"></span>' : ""}${escapeHtml(chip.label)}</button>`).join("") +
46
- (addUrl ? '<button type="button" class="ctx-chip ctx-add" data-act="add" title="Attach a URL (a GitHub PR) the agent can read and hears about when it changes" aria-haspopup="dialog">+ URL</button>' : "");
44
+ const ADD_TITLE = "Attach a URL (a GitHub PR) the agent can read and hears about when it changes";
45
+
46
+ // The chips (a lit-html template; attributes after a bound one are bound
47
+ // too, so they keep their order). addUrl: end with the "+ URL" chip;
48
+ // open: the chip whose popover is open (a source's name), addOpen: the
49
+ // "+ URL" form is.
50
+ export function chipsTemplate(chips, { addUrl = false, open = null, addOpen = false } = {}) {
51
+ return html`${chips.map((chip) => html`<button type="button" class=${`ctx-chip ${chip.state}${chip.name === open ? " open" : ""}`}
52
+ data-name=${chip.name} title=${chip.title} aria-haspopup=${"dialog"}>${
53
+ chip.state === "unread" ? html`<span class="ctx-dot" aria-hidden="true"></span>` : nothing}${chip.label}</button>`)}${
54
+ addUrl ? html`<button type="button" class=${`ctx-chip ctx-add${addOpen ? " open" : ""}`} data-act=${"add"} title=${ADD_TITLE}
55
+ aria-haspopup=${"dialog"}>+ URL</button>` : nothing}`;
47
56
  }
48
57
 
49
- // The "+ URL" popover: the URL, an optional why, Attach; +url+ and +error+
50
- // after a try that failed.
51
- export function addFormHtml(escapeHtml, { url = "", error = null } = {}) {
52
- return `<form class="ctx-add-form"><div class="ctx-pop-head"><strong>Attach a URL</strong>` +
53
- `<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>` +
54
- `<div class="ctx-pop-meta">A link an installed bundle knows (github-pr: a pull request). The agent gets a note when it changes.</div>` +
55
- `<input class="ctx-add-input" name="url" type="url" inputmode="url" placeholder="https://github.com/owner/repo/pull/123" value="${escapeHtml(url)}" required autocomplete="off">` +
56
- `<input class="ctx-add-input" name="why" type="text" placeholder="why (optional)" maxlength="300" autocomplete="off">` +
57
- (error ? `<div class="ctx-pop-error" role="alert">${escapeHtml(error)}</div>` : "") +
58
- `<div class="ctx-pop-actions"><button type="submit" class="ctx-add-submit">Attach</button></div></form>`;
58
+ // The "+ URL" popover: the URL, an optional why, Attach; +error+ after a
59
+ // try that failed, +busy+ while one runs.
60
+ export function addFormTemplate({ error = null, busy = false } = {}) {
61
+ return html`<form class="ctx-add-form"><div class="ctx-pop-head"><strong>Attach a URL</strong
62
+ ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
63
+ ><div class="ctx-pop-meta">A link an installed bundle knows (github-pr: a pull request). The agent gets a note when it changes.</div
64
+ ><input class="ctx-add-input" name="url" type="url" inputmode="url" placeholder="https://github.com/owner/repo/pull/123" required autocomplete="off"
65
+ ><input class="ctx-add-input" name="why" type="text" placeholder="why (optional)" maxlength="300" autocomplete="off"
66
+ >${error ? html`<div class="ctx-pop-error" role="alert">${error}</div>` : nothing
67
+ }<div class="ctx-pop-actions"><button type="submit" class="ctx-add-submit" ?disabled=${busy}>Attach</button></div></form>`;
59
68
  }
60
69
 
61
- // The popover's inside for one row (GET …/context's).
62
- export function popoverHtml(row, escapeHtml, now = Date.now()) {
70
+ // The popover's inside for one row (GET …/context's); +text+ the row's
71
+ // text once "view text" read it (null before).
72
+ export function popoverTemplate(row, { text = null, now = Date.now() } = {}) {
63
73
  const kind = row.kind === "cmd" ? `runs every ${row.every_seconds ? `${row.every_seconds}s` : "few minutes"}` : "pushed";
64
74
  const url = hintUrl(row.hint);
65
75
  const hint = url
66
- ? `<a class="ctx-pop-hint" href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(url)}</a>`
67
- : (row.hint ? `<div class="ctx-pop-hint">${escapeHtml(row.hint)}</div>` : "");
76
+ ? html`<a class="ctx-pop-hint" href=${url} target=${"_blank"} rel=${"noopener noreferrer"}>${url}</a>`
77
+ : (row.hint ? html`<div class="ctx-pop-hint">${row.hint}</div>` : nothing);
68
78
  const age = ageText(row.fetched_at, now);
69
79
  const meta = [age ? (age === "now" ? "fetched just now" : `fetched ${age} ago`) : "no text yet",
70
80
  row.unread ? "the agent hasn't read this change" : (row.has_text ? "the agent has read it" : "")]
@@ -73,29 +83,32 @@ export function popoverHtml(row, escapeHtml, now = Date.now()) {
73
83
  const detachTitle = row.scope === "project"
74
84
  ? "The project's source: this session stops seeing it (chi context unmute brings it back)"
75
85
  : "Remove it from this session";
76
- return `<div class="ctx-pop-head"><strong>${escapeHtml(row.name)}</strong>` +
77
- `<span class="ctx-pop-kind">${escapeHtml(row.scope)} · ${escapeHtml(kind)}</span>` +
78
- `<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>` +
79
- (row.why ? `<div class="ctx-pop-why">${escapeHtml(row.why)}</div>` : "") +
80
- hint +
81
- (row.summary ? `<div class="ctx-pop-summary">${escapeHtml(row.summary)}</div>` : "") +
82
- `<div class="ctx-pop-meta">${escapeHtml(meta)}</div>` +
83
- (row.error ? `<div class="ctx-pop-error">last refresh failed: ${escapeHtml(row.error)}</div>` : "") +
84
- `<pre class="ctx-pop-text" hidden></pre>` +
85
- `<div class="ctx-pop-actions">` +
86
- (row.has_text ? `<button type="button" data-act="view">view text</button>` : "") +
87
- `<button type="button" class="ctx-pop-detach" data-act="detach" title="${escapeHtml(detachTitle)}">${detach}</button></div>`;
86
+ return html`<div class="ctx-pop-head"><strong>${row.name}</strong
87
+ ><span class="ctx-pop-kind">${row.scope} · ${kind}</span
88
+ ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
89
+ >${row.why ? html`<div class="ctx-pop-why">${row.why}</div>` : nothing}${hint}${
90
+ row.summary ? html`<div class="ctx-pop-summary">${row.summary}</div>` : nothing
91
+ }<div class="ctx-pop-meta">${meta}</div
92
+ >${row.error ? html`<div class="ctx-pop-error">last refresh failed: ${row.error}</div>` : nothing
93
+ }<pre class="ctx-pop-text" ?hidden=${text === null}>${text ?? ""}</pre
94
+ ><div class="ctx-pop-actions">${row.has_text && text === null ? html`<button type="button" data-act="view">view text</button>` : nothing
95
+ }<button type="button" class="ctx-pop-detach" data-act="detach" title=${detachTitle}>${detach}</button></div>`;
88
96
  }
89
97
 
90
98
  // The chips for the session bar. el: #contextChips; api: {list(id),
91
- // show(id, name), detach(id, name), add(id, {url, why})}; escapeHtml;
92
- // toast(text).
93
- export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc = document, win = window }) {
99
+ // show(id, name), detach(id, name), add(id, {url, why})}; toast(text).
100
+ // Chips, popover and form are lit templates: a redraw (a refresh, the
101
+ // minute's tick) keeps their nodes, so focus and typed text stay.
102
+ export function createContextChips({ el, api, toast = () => {}, doc = document, win = window }) {
94
103
  let sessionId = null;
95
104
  let rows = [];
96
105
  let canAdd = false;
97
106
  let openName = null;
98
107
  let loadSeq = 0;
108
+ // The open row's text once read (view text), and the "+ URL" form's
109
+ // state ({error, busy}).
110
+ let text = null;
111
+ let addState = {};
99
112
  const pop = doc.createElement("div");
100
113
  pop.id = "contextPopover";
101
114
  pop.className = "ctx-popover";
@@ -106,17 +119,20 @@ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc
106
119
  // openName ADD: the "+ URL" form is open.
107
120
  const ADD = "\u0000add";
108
121
 
109
- function render() {
122
+ function drawChips() {
110
123
  const chips = chipModel(rows);
111
- el.innerHTML = chipsHtml(chips, escapeHtml, { addUrl: canAdd });
124
+ // A source inserted before the focused chip: the focus stays on its source.
125
+ keepFocus(el, "data-name", () => render(chipsTemplate(chips, { addUrl: canAdd, open: openName === ADD ? null : openName,
126
+ addOpen: openName === ADD }), el));
112
127
  el.hidden = chips.length === 0 && !canAdd;
113
128
  // The "+ URL" chip alone sits inline, not on a row of its own.
114
129
  el.classList.toggle("only-add", chips.length === 0 && canAdd);
115
- if (openName === ADD) {
116
- if (canAdd) el.querySelector(".ctx-add")?.classList.add("open");
117
- else close();
118
- } else if (openName && !chips.some((chip) => chip.name === openName)) close();
119
- else if (openName) el.querySelector(`.ctx-chip[data-name="${openName}"]`)?.classList.add("open");
130
+ }
131
+
132
+ // The chips, and the open popover closed when what it is for has gone.
133
+ function draw() {
134
+ if (openName === ADD ? !canAdd : openName && !chipModel(rows).some((chip) => chip.name === openName)) close();
135
+ else drawChips();
120
136
  }
121
137
 
122
138
  async function refresh() {
@@ -134,10 +150,11 @@ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc
134
150
  rows = [];
135
151
  canAdd = false;
136
152
  }
137
- render();
138
- if (openName && openName !== ADD) {
153
+ draw();
154
+ // An open popover follows its row, but not while it shows the text.
155
+ if (openName && openName !== ADD && text === null) {
139
156
  const row = rows.find((r) => r.name === openName);
140
- if (row && pop.querySelector(".ctx-pop-text")?.hidden !== false) fill(row);
157
+ if (row) fill(row);
141
158
  }
142
159
  }
143
160
 
@@ -146,7 +163,6 @@ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc
146
163
  rows = [];
147
164
  canAdd = false;
148
165
  close();
149
- render();
150
166
  return refresh();
151
167
  }
152
168
 
@@ -155,11 +171,10 @@ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc
155
171
  rows = [];
156
172
  canAdd = false;
157
173
  close();
158
- render();
159
174
  }
160
175
 
161
176
  function fill(row) {
162
- pop.innerHTML = popoverHtml(row, escapeHtml);
177
+ render(popoverTemplate(row, { text }), pop);
163
178
  }
164
179
 
165
180
  // Above the chip, inside the window with a 16px gutter.
@@ -174,19 +189,30 @@ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc
174
189
  function open(name, chip) {
175
190
  const row = rows.find((r) => r.name === name);
176
191
  if (!row) return;
192
+ // Another popover's nodes go, so this one starts fresh.
193
+ render(nothing, pop);
177
194
  openName = name;
195
+ text = null;
178
196
  fill(row);
179
197
  pop.hidden = false;
180
198
  place(chip);
181
- el.querySelectorAll(".ctx-chip").forEach((c) => c.classList.toggle("open", c.dataset.name === name));
199
+ drawChips();
182
200
  }
183
201
 
184
- function openAdd(chip, state = {}) {
202
+ const drawAdd = (changes = {}) => {
203
+ addState = { ...addState, ...changes };
204
+ render(addFormTemplate(addState), pop);
205
+ };
206
+
207
+ function openAdd(chip) {
208
+ render(nothing, pop);
185
209
  openName = ADD;
186
- pop.innerHTML = addFormHtml(escapeHtml, state);
210
+ text = null;
211
+ addState = {};
212
+ drawAdd();
187
213
  pop.hidden = false;
188
214
  place(chip);
189
- el.querySelectorAll(".ctx-chip").forEach((c) => c.classList.toggle("open", c === chip));
215
+ drawChips();
190
216
  pop.querySelector('input[name="url"]')?.focus();
191
217
  }
192
218
 
@@ -195,8 +221,7 @@ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc
195
221
  const url = form.elements.url.value.trim();
196
222
  const why = form.elements.why.value.trim();
197
223
  if (!id || !url) return;
198
- const button = form.querySelector(".ctx-add-submit");
199
- if (button) button.disabled = true;
224
+ drawAdd({ busy: true });
200
225
  try {
201
226
  const data = await api.add(id, { url, why: why || undefined });
202
227
  if (id !== sessionId) return;
@@ -205,32 +230,35 @@ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc
205
230
  toast(`Attached ${data.name}: the agent gets a note before your next message`);
206
231
  } catch (e) {
207
232
  if (id !== sessionId || openName !== ADD) return;
208
- const chip = el.querySelector(".ctx-add");
209
- if (chip) openAdd(chip, { url, error: String(e.message).replace(/ \(\d+\)$/, "") });
233
+ // The same form again: what the user typed stays; the URL gets focus.
234
+ drawAdd({ busy: false, error: String(e.message).replace(/ \(\d+\)$/, "") });
235
+ pop.querySelector('input[name="url"]')?.focus();
210
236
  }
211
237
  }
212
238
 
213
239
  function close() {
214
240
  openName = null;
241
+ text = null;
242
+ addState = {};
215
243
  pop.hidden = true;
216
- el.querySelectorAll(".ctx-chip.open").forEach((c) => c.classList.remove("open"));
244
+ render(nothing, pop);
245
+ drawChips();
217
246
  }
218
247
 
219
248
  async function viewText() {
220
249
  const name = openName;
221
- const pre = pop.querySelector(".ctx-pop-text");
222
- if (!name || !pre) return;
250
+ if (!name || name === ADD) return;
223
251
  try {
224
252
  const data = await api.show(sessionId, name);
225
253
  if (name !== openName) return;
226
- pre.textContent = data.text || "";
227
- pre.hidden = false;
228
- pop.querySelector('[data-act="view"]')?.remove();
254
+ const row = rows.find((r) => r.name === name);
255
+ if (!row) return;
256
+ text = data.text || "";
257
+ fill(row);
229
258
  } catch (e) {
230
259
  toast(`Can't read ${name}: ${String(e.message).replace(/ \(\d+\)$/, "")}`);
231
260
  }
232
261
  }
233
-
234
262
  async function detach() {
235
263
  const row = rows.find((r) => r.name === openName);
236
264
  if (!row) return;
@@ -279,7 +307,7 @@ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc
279
307
  });
280
308
  win.addEventListener("focus", () => refresh());
281
309
  // The ages move on by themselves.
282
- win.setInterval(() => { if (rows.length) render(); }, 60_000);
310
+ win.setInterval(() => { if (rows.length) drawChips(); }, 60_000);
283
311
 
284
312
  return { load, clear, refresh, close, get rows() { return rows; } };
285
313
  }
@@ -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
  }
@@ -1,23 +1,44 @@
1
1
  // A parent's card with its delegates folded in (docs/sessions.md, the web):
2
- // the chip that opens the family's list, and the list's rows. Pure string
3
- // builders; app.js places them (inline in all sessions, a popover over the
4
- // strip) and binds the clicks.
5
- import { escapeHtml, ownerBadge, previewForCard, relativeTime } from "./format.js";
2
+ // the chip that opens the family's list, and the list's rows, as lit-html
3
+ // templates; lists_view.js places them (inline in all sessions, a popover
4
+ // over the strip) and binds the clicks. Attributes after a bound one are
5
+ // bound too, so they keep their order in the markup (lit sets bound ones
6
+ // after the static ones).
7
+ import { ownerBadge, previewForCard, relativeTime } from "./format.js";
6
8
  import { familySummary, stoppedBadge, waitingBadge } from "./sessions_list.js";
9
+ import { html, nothing, render } from "./vendor/lit-html.js";
10
+ import { keepFocus } from "./keep_focus.js";
7
11
 
8
12
  // The chip in a parent's meta row: "▸ 3 delegates · ● 1 live · 1 waiting"
9
13
  // (a waiting one in the attention colour), its tooltip the delegates by
10
- // state. "" for a family with no members.
14
+ // state. nothing for a family with no members.
11
15
  // @param open whether the list is open (aria-expanded; the arrow turns)
12
- export function familyChipHtml(f, { open = false } = {}) {
16
+ export function familyChipTemplate(f, { open = false } = {}) {
13
17
  const sum = familySummary(f);
14
- if (!sum) return "";
18
+ if (!sum) return nothing;
15
19
  const waiting = sum.counts.waiting;
16
- const liveHtml = sum.live ? `<span class="fc-live"> · <span class="live-dot" aria-hidden="true"></span>${sum.live} live</span>` : "";
17
- const waitingHtml = waiting ? ` · <span class="fc-waiting">${waiting} waiting</span>` : "";
18
- return `<button type="button" class="family-chip${waiting ? " waiting" : ""}" aria-expanded="${open ? "true" : "false"}" title="${escapeHtml(sum.title)}"><span class="fc-arrow" aria-hidden="true">▸</span>${sum.total} ${sum.total === 1 ? "delegate" : "delegates"}${liveHtml}${waitingHtml}</button>`;
20
+ const live = sum.live ? html`<span class="fc-live"> · <span class="live-dot" aria-hidden="true"></span>${sum.live} live</span>` : nothing;
21
+ const waitingT = waiting ? html` · <span class="fc-waiting">${waiting} waiting</span>` : nothing;
22
+ return html`<button type="button" class=${`family-chip${waiting ? " waiting" : ""}`} aria-expanded=${open ? "true" : "false"}
23
+ title=${sum.title}><span class="fc-arrow" aria-hidden="true">▸</span>${sum.total} ${sum.total === 1 ? "delegate" : "delegates"}${
24
+ live}${waitingT}</button>`;
19
25
  }
20
26
 
27
+ // A row's status dot (a family's or a chain's): the waiting title over the
28
+ // status when the session waits on the user.
29
+ export function rowDotTemplate(s, waiting) {
30
+ const word = (waiting ? waiting.title : s.status) || "";
31
+ return html`<span class=${`status dot ${s.status || ""}${waiting ? " waiting" : ""}`} title=${word} aria-label=${word}></span>`;
32
+ }
33
+
34
+ // A row's waiting or stopped badge, and the archived one.
35
+ export function rowBadgesTemplate(waiting, stopped) {
36
+ return html`${waiting ? html`<span class=${`attn attn-${waiting.kind}`} title=${waiting.title}>${waiting.text}</span>` : nothing}${
37
+ stopped ? html`<span class="stopped-badge" title=${stopped.title}>${stopped.text}</span>` : nothing}`;
38
+ }
39
+
40
+ export const archivedBadge = html`<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>`;
41
+
21
42
  // The family's list: one row per member, depth first, indented one step
22
43
  // per depth (--depth): status dot, preview, time, the waiting / stopped
23
44
  // badge, the owner badge, archived. A row's click opens that session.
@@ -25,7 +46,7 @@ export function familyChipHtml(f, { open = false } = {}) {
25
46
  // @param matchIds a search's matching members (.match; the rest .dim), or
26
47
  // null without a search
27
48
  // @param archivable an archive button per row (all sessions, not in select mode)
28
- export function familyRowsHtml(f, { activeId = null, matchIds = null, archivable = false, now = Date.now() } = {}) {
49
+ export function familyRowsTemplate(f, { activeId = null, matchIds = null, archivable = false, now = Date.now() } = {}) {
29
50
  const rows = f.members.map(({ session: s, depth }) => {
30
51
  const cls = ["family-row"];
31
52
  if (activeId && s.id === activeId) cls.push("active");
@@ -36,23 +57,17 @@ export function familyRowsHtml(f, { activeId = null, matchIds = null, archivable
36
57
  const stopped = waiting ? null : stoppedBadge(s);
37
58
  const owner = ownerBadge(s.owner, s.id);
38
59
  const ago = relativeTime(ts, now);
39
- const dotWord = waiting ? waiting.title : s.status;
40
- const parts = [
41
- `<span class="status dot ${escapeHtml(s.status || "")}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord || "")}" aria-label="${escapeHtml(dotWord || "")}"></span>`,
42
- `<span class="fr-preview">${escapeHtml(previewForCard(s))}</span>`,
43
- waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "",
44
- stopped ? `<span class="stopped-badge" title="${escapeHtml(stopped.title)}">${escapeHtml(stopped.text)}</span>` : "",
45
- ago ? `<span class="time">${escapeHtml(ago)}</span>` : "",
46
- owner ? `<span class="owner ${owner.kind}" title="${escapeHtml(owner.title)}">${escapeHtml(owner.text)}</span>` : "",
47
- s.archived ? `<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : "",
48
- ].join("");
49
- const short = escapeHtml(s.short_id || String(s.id).slice(0, 8));
50
- const arch = !archivable ? "" : s.archived
51
- ? `<button type="button" class="card-arch" data-arch="${escapeHtml(s.id)}" title="Unarchive: back to the lists" aria-label="Unarchive session ${short}"></button>`
52
- : `<button type="button" class="card-arch" data-arch="${escapeHtml(s.id)}" title="Archive: hide from the lists, keep for good" aria-label="Archive session ${short}"></button>`;
53
- return `<div class="${cls.join(" ")}" data-id="${escapeHtml(s.id)}" style="--depth:${depth}" role="listitem"><button type="button" class="fr-open" title="${escapeHtml(`${s.id} · ${s.status || ""}`)}">${parts}</button>${arch}</div>`;
60
+ const short = s.short_id || String(s.id).slice(0, 8);
61
+ const arch = !archivable ? nothing : s.archived
62
+ ? html`<button type="button" class="card-arch" data-arch=${s.id} title=${"Unarchive: back to the lists"} aria-label=${`Unarchive session ${short}`}></button>`
63
+ : html`<button type="button" class="card-arch" data-arch=${s.id} title=${"Archive: hide from the lists, keep for good"} aria-label=${`Archive session ${short}`}></button>`;
64
+ return html`<div class=${cls.join(" ")} data-id=${s.id} style=${`--depth:${depth}`} role=${"listitem"}><button type="button" class="fr-open"
65
+ title=${`${s.id} · ${s.status || ""}`}>${rowDotTemplate(s, waiting)}<span class="fr-preview">${previewForCard(s)}</span>${
66
+ rowBadgesTemplate(waiting, stopped)}${ago ? html`<span class="time">${ago}</span>` : nothing}${
67
+ owner ? html`<span class=${`owner ${owner.kind}`} title=${owner.title}>${owner.text}</span>` : nothing}${
68
+ s.archived ? archivedBadge : nothing}</button>${arch}</div>`;
54
69
  });
55
- return `<div class="family-rows" role="list">${rows.join("")}</div>`;
70
+ return html`<div class="family-rows" role="list">${rows}</div>`;
56
71
  }
57
72
 
58
73
  // The widest window index.html lays out as a phone (@media (max-width:600px)).
@@ -61,11 +76,13 @@ export const PHONE_MAX_PX = 600;
61
76
  // The strip's family popover: one #familyPop in body (position: fixed, so
62
77
  // the strip's sideways scroll on a phone doesn't clip it), under the chip
63
78
  // it was opened from and as wide as that card (300 px at least, inside the
64
- // window; on a phone, the window less 16 px gutters). The strip draws again on every hub event: refresh() then finds
65
- // the new chip for the same head, rewrites the rows only when they changed,
79
+ // window; on a phone, the window less 16 px gutters). The strip draws again
80
+ // on every hub event: refresh() then finds the new chip for the same head,
81
+ // draws the rows again (lit writes only what changed: a row keeps its
82
+ // nodes, reused by position, so keepFocus keeps the focus on its session)
66
83
  // and closes when the family is gone.
67
- // @param rowsFor (headId) → the rows' html, or null when the family has no
68
- // members (any more)
84
+ // @param rowsFor (headId) → the rows' template, or null when the family
85
+ // has no members (any more)
69
86
  // @param anchorFor (headId) → its chip in the strip, or null
70
87
  // @param onPick (id) a row's click
71
88
  // @param onClose () after it closed (the strip's held order catches up)
@@ -73,7 +90,6 @@ export const PHONE_MAX_PX = 600;
73
90
  export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor, onPick, onClose = () => {} }) {
74
91
  let el = null;
75
92
  let headId = null;
76
- let html = "";
77
93
 
78
94
  const element = () => {
79
95
  if (el) return el;
@@ -110,10 +126,8 @@ export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor
110
126
  close();
111
127
  return false;
112
128
  }
113
- if (rows !== html) {
114
- el.innerHTML = rows;
115
- html = rows;
116
- }
129
+ // A row inserted before the focused one: the focus stays on its session.
130
+ keepFocus(el, "data-id", () => render(rows, el));
117
131
  anchor.setAttribute("aria-expanded", "true");
118
132
  place(anchor);
119
133
  return true;
@@ -123,7 +137,6 @@ export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor
123
137
  if (headId && headId !== id) close();
124
138
  headId = id;
125
139
  element().hidden = false;
126
- html = "";
127
140
  draw();
128
141
  }
129
142
 
@@ -131,9 +144,8 @@ export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor
131
144
  if (!headId) return;
132
145
  anchorFor(headId)?.setAttribute("aria-expanded", "false");
133
146
  headId = null;
134
- html = "";
135
147
  el.hidden = true;
136
- el.innerHTML = "";
148
+ render(nothing, el);
137
149
  onClose();
138
150
  }
139
151
 
@@ -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).