samagotchi 0.34.0 → 0.36.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -1
  3. data/README.md +2 -0
  4. data/bin/chi +3 -0
  5. data/docs/broadcast.md +129 -0
  6. data/docs/cli.md +8 -0
  7. data/docs/configuration.md +2 -0
  8. data/docs/context.md +37 -0
  9. data/docs/guardrails.md +14 -4
  10. data/docs/hooks.md +25 -4
  11. data/docs/memory.md +3 -0
  12. data/docs/plugins.md +29 -3
  13. data/docs/sessions.md +6 -6
  14. data/lib/samagotchi/bridge/card_store.rb +1 -1
  15. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  16. data/lib/samagotchi/broadcast/recipients.rb +63 -0
  17. data/lib/samagotchi/broadcast/scope_card.rb +94 -0
  18. data/lib/samagotchi/broadcast/tags.rb +154 -0
  19. data/lib/samagotchi/broadcast_command.rb +228 -0
  20. data/lib/samagotchi/bundles/coordinator/manifest.yml +7 -6
  21. data/lib/samagotchi/bundles/coordinator/plugin.rb +72 -4
  22. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +59 -11
  23. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  24. data/lib/samagotchi/bundles/github-pr/plugin.rb +444 -3
  25. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +22 -0
  26. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  27. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/mcp/plugin.rb +44 -11
  29. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +32 -12
  30. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -3
  31. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  32. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  33. data/lib/samagotchi/bundles/system/memory_guide.md +4 -2
  34. data/lib/samagotchi/bundles/system/self_map.md +4 -0
  35. data/lib/samagotchi/children_status.rb +2 -16
  36. data/lib/samagotchi/config.rb +6 -0
  37. data/lib/samagotchi/engine.rb +29 -5
  38. data/lib/samagotchi/git_head.rb +24 -0
  39. data/lib/samagotchi/guardrails/parent_approvals.rb +6 -0
  40. data/lib/samagotchi/guardrails/rules.rb +15 -4
  41. data/lib/samagotchi/guardrails/targets.rb +9 -2
  42. data/lib/samagotchi/guardrails/verdict.rb +4 -3
  43. data/lib/samagotchi/hooks/registry.rb +11 -5
  44. data/lib/samagotchi/log_subscriber.rb +1 -1
  45. data/lib/samagotchi/memory_bundle/index_updater.rb +6 -3
  46. data/lib/samagotchi/note_command.rb +13 -13
  47. data/lib/samagotchi/note_delivery.rb +42 -0
  48. data/lib/samagotchi/plugin/context.rb +4 -4
  49. data/lib/samagotchi/session_commands.rb +1 -0
  50. data/lib/samagotchi/session_manager.rb +4 -3
  51. data/lib/samagotchi/sessions_command.rb +3 -2
  52. data/lib/samagotchi/system_prompt.rb +1 -0
  53. data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -2
  54. data/lib/samagotchi/terminal_ui/status_row.rb +23 -2
  55. data/lib/samagotchi/terminal_ui.rb +14 -0
  56. data/lib/samagotchi/tool_activity.rb +14 -3
  57. data/lib/samagotchi/tool_declarations.rb +8 -4
  58. data/lib/samagotchi/tools/builtins.rb +1 -1
  59. data/lib/samagotchi/tools/memory.rb +103 -2
  60. data/lib/samagotchi/tools/send_note.rb +5 -11
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/app.rb +3 -0
  63. data/lib/samagotchi/web/capabilities.rb +19 -0
  64. data/lib/samagotchi/web/public/activity.js +26 -6
  65. data/lib/samagotchi/web/public/app.js +515 -288
  66. data/lib/samagotchi/web/public/card.js +12 -3
  67. data/lib/samagotchi/web/public/command_view.js +2 -3
  68. data/lib/samagotchi/web/public/families_view.js +171 -0
  69. data/lib/samagotchi/web/public/format.js +52 -0
  70. data/lib/samagotchi/web/public/index.html +94 -21
  71. data/lib/samagotchi/web/public/live_timing.js +154 -0
  72. data/lib/samagotchi/web/public/live_turn.js +44 -0
  73. data/lib/samagotchi/web/public/sessions_list.js +228 -32
  74. data/lib/samagotchi/web/public/strip.js +5 -2
  75. data/lib/samagotchi/web/public/turn_html.js +107 -0
  76. data/lib/samagotchi/web/public/turn_model.js +5 -4
  77. data/lib/samagotchi/web/public/turn_view.js +43 -129
  78. data/lib/samagotchi/web/session_summary.rb +4 -0
  79. data/lib/samagotchi/worker.rb +166 -307
  80. data/lib/samagotchi/worker_inbound.rb +251 -0
  81. data/lib/samagotchi/worker_wakes.rb +120 -0
  82. metadata +15 -1
@@ -0,0 +1,44 @@
1
+ // The open session's running turn as the page follows it, in one place:
2
+ // whether a turn runs, its timing line (live_timing.js), the prompt-bubble
3
+ // flags of its merges, and the turn-end reads it waits on. forget() is the
4
+ // one reset (a session left, or its rendering started over).
5
+ import { createLiveTiming } from "./live_timing.js";
6
+
7
+ // +timingDeps+: createLiveTiming's.
8
+ export function createLiveTurn(timingDeps) {
9
+ return {
10
+ running: false,
11
+ timing: createLiveTiming(timingDeps),
12
+ // The last input_merged had an origin with no bubble: its merged text
13
+ // (pending_input_merged) gets one ("report": a delegate report's).
14
+ unmatchedMerge: false,
15
+ // The running turn is one chi ran for delegate reports and its report
16
+ // bubble isn't drawn yet: that bubble starts the turn.
17
+ wakeStart: false,
18
+ // The turn-end re-read (turn_completed): the final answer's rendered
19
+ // markdown, the status and the timing. An answer_display re-read waits
20
+ // for it.
21
+ endRead: Promise.resolve(),
22
+ // The wait of a turn_completed with display_pending for its
23
+ // answer_display (turn_events.js displayWait). The worker may die in
24
+ // between and never send one, so forget() releases it as well as its
25
+ // own timeout: it must never hold the turn's answer forever.
26
+ displayPending: null,
27
+ // The last turn_failed's line, shown again after prompt_restored's resync.
28
+ failedText: null,
29
+
30
+ releaseDisplay() {
31
+ this.displayPending?.finish();
32
+ this.displayPending = null;
33
+ },
34
+
35
+ // Everything that belongs to one turn's following, but `running` (the
36
+ // page's setTurnRunning redraws the composer with it).
37
+ forget() {
38
+ this.timing.drop();
39
+ this.unmatchedMerge = false;
40
+ this.wakeStart = false;
41
+ this.releaseDisplay();
42
+ },
43
+ };
44
+ }
@@ -1,6 +1,7 @@
1
1
  // The page's session list as a projection of the hub's events
2
2
  // (GET /api/events): a pure reducer over the list the page holds.
3
3
  import { waitingOn } from "./notify.js";
4
+ import { delegatesSummary } from "./format.js";
4
5
 
5
6
  // @param list the current list (never mutated)
6
7
  // @param type "snapshot" | "session" | "session_gone"
@@ -46,25 +47,87 @@ export function sortedByUpdated(list) {
46
47
  return list.slice().sort((a, b) => updatedMs(b) - updatedMs(a));
47
48
  }
48
49
 
49
- // A delegated session (parent_id) right after its parent when both are in
50
- // the list: families keep the place of their newest member, the parent
51
- // leads, its children follow in the list's order. A child whose parent
52
- // is not listed stays where it is.
53
- export function withChildrenAfterParents(list) {
50
+ // The list as families: a delegate (delegate: true, the delegate tool's
51
+ // child) whose parent is listed joins its parent's family, at any depth;
52
+ // the rest head one. A fork (a parent_id, delegate false) is not folded: it
53
+ // heads a family of its own, its ↳ chip naming the parent. A family is
54
+ // {head, members: [{session, depth}]}, the members depth first (each child
55
+ // followed by its own delegates, siblings in the list's order, depth 1 for
56
+ // the head's children). Orphans (a parent not listed) and loners are
57
+ // families with no members. Every session shows once: the members of a
58
+ // parent cycle (A→B→A) no head reaches form a family headed by the cycle's
59
+ // smallest id (an order held by head id never changes with updated_at).
60
+ // @param order "family_ms": by the family's newest member, newest first
61
+ // (the all view, re-sorted on every render); "list": a family takes the
62
+ // place of its first-listed member and nothing re-sorts (the strip, which
63
+ // keeps applySessionEvent's no-jump order)
64
+ export function families(list, { order = "family_ms" } = {}) {
54
65
  const ids = new Set(list.map((s) => s.id));
66
+ const grouped = (s) => !!s.delegate && s.parent_id && ids.has(s.parent_id) && s.parent_id !== s.id;
55
67
  const children = new Map();
56
68
  for (const s of list) {
57
- if (s.parent_id && ids.has(s.parent_id) && s.parent_id !== s.id) {
58
- if (!children.has(s.parent_id)) children.set(s.parent_id, []);
59
- children.get(s.parent_id).push(s);
60
- }
69
+ if (!grouped(s)) continue;
70
+ if (!children.has(s.parent_id)) children.set(s.parent_id, []);
71
+ children.get(s.parent_id).push(s);
61
72
  }
62
- if (!children.size) return list;
63
- const grouped = (s) => s.parent_id && ids.has(s.parent_id) && s.parent_id !== s.id;
64
- const familyMs = (s) => Math.max(updatedMs(s), ...(children.get(s.id) || []).map(updatedMs));
73
+ const kids = (s) => children.get(s.id) || [];
74
+ const reached = new Set();
75
+ const reach = (s) => {
76
+ if (reached.has(s.id)) return;
77
+ reached.add(s.id);
78
+ kids(s).forEach(reach);
79
+ };
65
80
  const heads = list.filter((s) => !grouped(s));
66
- const ordered = heads.slice().sort((a, b) => familyMs(b) - familyMs(a));
67
- return ordered.flatMap((s) => [s, ...(children.get(s.id) || [])]);
81
+ heads.forEach(reach);
82
+ // A session no head reaches hangs off a parent cycle: its smallest id leads.
83
+ const byId = new Map(list.map((s) => [s.id, s]));
84
+ for (const s of list) {
85
+ if (reached.has(s.id)) continue;
86
+ const path = [];
87
+ let at = s;
88
+ while (at && !path.includes(at.id) && !reached.has(at.id)) {
89
+ path.push(at.id);
90
+ at = byId.get(at.parent_id);
91
+ }
92
+ if (at && path.includes(at.id)) {
93
+ const head = byId.get(path.slice(path.indexOf(at.id)).sort()[0]);
94
+ heads.push(head);
95
+ reach(head);
96
+ }
97
+ }
98
+ const index = new Map(list.map((s, i) => [s.id, i]));
99
+ const out = heads.map((head) => {
100
+ const members = [];
101
+ const shown = new Set([head.id]);
102
+ const emit = (s, depth) => kids(s).forEach((c) => {
103
+ if (shown.has(c.id)) return;
104
+ shown.add(c.id);
105
+ members.push({ session: c, depth });
106
+ emit(c, depth + 1);
107
+ });
108
+ emit(head, 1);
109
+ return { head, members };
110
+ });
111
+ const all = (f) => [f.head, ...f.members.map((m) => m.session)];
112
+ if (order === "list") {
113
+ const first = new Map(out.map((f) => [f.head.id, Math.min(...all(f).map((s) => index.get(s.id)))]));
114
+ return out.sort((a, b) => first.get(a.head.id) - first.get(b.head.id));
115
+ }
116
+ const ms = new Map(out.map((f) => [f.head.id, Math.max(...all(f).map(updatedMs))]));
117
+ return out.sort((a, b) => ms.get(b.head.id) - ms.get(a.head.id) || index.get(a.head.id) - index.get(b.head.id));
118
+ }
119
+
120
+ // A family's id: its head's (what an order held by id keeps).
121
+ export const familyId = (f) => f.head.id;
122
+
123
+ // The families' sessions, a head then its members: the all view's order.
124
+ export function flattenFamilies(fams) {
125
+ return fams.flatMap((f) => [f.head, ...f.members.map((m) => m.session)]);
126
+ }
127
+
128
+ // Whether any of a family (its head or a member) waits on the user.
129
+ export function familyWaits(f) {
130
+ return !!waitingOn(f.head) || f.members.some((m) => waitingOn(m.session));
68
131
  }
69
132
 
70
133
  const WAITING_BADGES = {
@@ -76,13 +139,15 @@ const WAITING_BADGES = {
76
139
 
77
140
  // A card's "needs you" badge: what the session waits on the user for
78
141
  // (notify.js waitingOn), or null.
142
+ // @param inFamily shown as a row of its parent's family: a relayed approval
143
+ // says its plain kind there ("in parent" would name the card it is in)
79
144
  // @return null | {kind, text, title}
80
- export function waitingBadge(summary) {
145
+ export function waitingBadge(summary, { inFamily = false } = {}) {
81
146
  const waiting = waitingOn(summary);
82
147
  if (!waiting) return null;
83
148
  // A delegate's approval waiting in its parent's card (the approval relay).
84
149
  const parent = summary.pending_question?.relayed_to;
85
- if (parent && waiting.reason !== "card") {
150
+ if (parent && !inFamily && waiting.reason !== "card") {
86
151
  return { kind: waiting.reason, text: `in parent ${parent}`,
87
152
  title: `Waiting for your ${waiting.reason} in parent session ${parent} (answering here works too)` };
88
153
  }
@@ -114,24 +179,155 @@ export function waitingSearchText(summary) {
114
179
  return parts.join(" ");
115
180
  }
116
181
 
117
- // The sessions that wait on the user (waitingOn) first, the rest after,
118
- // each part in the list's order: an answered one goes back to its place.
119
- // A delegated session moves with its parent when both are listed (a
120
- // family waits when any of it does), so withChildrenAfterParents' groups
121
- // stay together.
122
- export function waitingFirst(list) {
123
- const ids = new Set(list.map((s) => s.id));
124
- const family = (s) => (s.parent_id && ids.has(s.parent_id) ? s.parent_id : s.id);
125
- const waiting = new Set(list.filter((s) => waitingOn(s)).map(family));
126
- if (!waiting.size) return list;
127
- return [...list.filter((s) => waiting.has(family(s))), ...list.filter((s) => !waiting.has(family(s)))];
182
+ // A family's chip numbers: its members at any depth, by state, and how
183
+ // many are live (format.js delegatesSummary, which the info bar's ⑂ chip
184
+ // counts with too). null for a family with no members.
185
+ export function familySummary(f) {
186
+ return delegatesSummary(f.members.map((m) => m.session));
187
+ }
188
+
189
+ // The chip's words: "▸ 3 delegates · 1 live · 1 waiting".
190
+ export function familyChipText(sum) {
191
+ if (!sum) return "";
192
+ const waiting = sum.counts?.waiting || 0;
193
+ return [`\u25B8 ${sum.total} ${sum.total === 1 ? "delegate" : "delegates"}`,
194
+ sum.live ? `${sum.live} live` : "", waiting ? `${waiting} waiting` : ""].filter(Boolean).join(" \u00b7 ");
195
+ }
196
+
197
+ // Whether a family's list is open: the user's toggle (+toggles+, head id →
198
+ // bool) wins; else open when a member waits on the user (the head's own
199
+ // wait is on its card already) or a search matched a member.
200
+ export function familyOpen(f, toggles, { matched = false } = {}) {
201
+ if (toggles?.has(f.head.id)) return !!toggles.get(f.head.id);
202
+ return matched || f.members.some((m) => waitingOn(m.session));
203
+ }
204
+
205
+ // The all view's search over families: a family shows when its head or any
206
+ // member matches (+matches+: session → bool), with all its members (the
207
+ // rows that don't match give the context). matchIds: the members that do.
208
+ export function filterFamilies(fams, matches) {
209
+ const out = [];
210
+ for (const f of fams) {
211
+ const matchIds = new Set(f.members.filter((m) => matches(m.session)).map((m) => m.session.id));
212
+ if (matchIds.size || matches(f.head)) out.push({ ...f, matchIds });
213
+ }
214
+ return out;
215
+ }
216
+
217
+ // The families that wait on the user (familyWaits: any member) first, the
218
+ // rest after, each part in the order given: an answered one goes back to
219
+ // its place. The same array when none waits.
220
+ export function waitingFirst(fams) {
221
+ const waiting = fams.filter(familyWaits);
222
+ if (!waiting.length) return fams;
223
+ return [...waiting, ...fams.filter((f) => !familyWaits(f))];
128
224
  }
129
225
 
130
226
  // The list in an order shown before (+ids+): what the pointer is over
131
- // doesn't move. Sessions new since then go after, in the list's order;
132
- // gone ones drop out.
133
- export function heldOrder(ids, list) {
227
+ // doesn't move. Items new since then go after, in the list's order; gone
228
+ // ones drop out. +idOf+ names an item: a session's id, or familyId.
229
+ export function heldOrder(ids, list, idOf = (s) => s.id) {
134
230
  const at = new Map(ids.map((id, i) => [id, i]));
135
- const known = list.filter((s) => at.has(s.id)).sort((a, b) => at.get(a.id) - at.get(b.id));
136
- return [...known, ...list.filter((s) => !at.has(s.id))];
231
+ const known = list.filter((s) => at.has(idOf(s))).sort((a, b) => at.get(idOf(a)) - at.get(idOf(b)));
232
+ return [...known, ...list.filter((s) => !at.has(idOf(s)))];
233
+ }
234
+
235
+ // Whether +other+ is +id+ or one of its delegates, at any depth, by the
236
+ // list's parent_ids: an archive of +id+ stops +other+'s worker too.
237
+ export function inTree(list, id, other) {
238
+ const byId = new Map(list.map((s) => [s.id, s]));
239
+ const seen = new Set();
240
+ for (let at = other; at && !seen.has(at); at = byId.get(at)?.parent_id) {
241
+ if (at === id) return true;
242
+ seen.add(at);
243
+ }
244
+ return false;
245
+ }
246
+
247
+ // ── Select mode (the all view's bulk archive) ─────────────────────────────
248
+
249
+ // The ids from +fromId+ to +toId+ in a drawn order, both included, in
250
+ // either direction: a Shift-click range. Just [toId] when +fromId+ isn't
251
+ // drawn (no earlier pick, or it went away).
252
+ export function rangeIds(order, fromId, toId) {
253
+ const to = order.indexOf(toId);
254
+ if (to < 0) return [];
255
+ const from = fromId == null ? -1 : order.indexOf(fromId);
256
+ if (from < 0) return [toId];
257
+ return from <= to ? order.slice(from, to + 1) : order.slice(to, from + 1);
258
+ }
259
+
260
+ // What a batch acts on: the picked ids that are shown, in the drawn order,
261
+ // the not-archived ones for an archive, the archived ones for an unarchive.
262
+ // A pick that isn't shown (the search hides it) stays picked, untouched.
263
+ // @param picked Set of ids
264
+ // @param shownIds the drawn order
265
+ // @param sessionsById Map id → session summary
266
+ export function batchTargets(picked, shownIds, archive, sessionsById) {
267
+ return shownIds.filter((id) => {
268
+ const s = sessionsById.get(id);
269
+ return picked.has(id) && s && !!s.archived === !archive;
270
+ });
271
+ }
272
+
273
+ // A refusal's reason as the card shows it: the server's words, without
274
+ // the api() status suffix.
275
+ function refusalText(error) {
276
+ return String(error?.message || "refused").replace(/\s*\(\d{3}\)$/, "");
277
+ }
278
+
279
+ // Runs +act+ over +ids+ one at a time (a live session's stop waits on the
280
+ // server, and a parent's and its delegate's stops would race). An id an
281
+ // earlier answer already covered (a delegate of a parent before it, in its
282
+ // archived / unarchived / discarded list) is not sent again.
283
+ // @param act async (id) → {ok: true, result} | {ok: false, error}
284
+ // @param onProgress (doneCount, total) after each id
285
+ // @return {done, covered, skipped: [{id, code, reason}], gone, affected, flipped}
286
+ // done: the ids a request succeeded on (what an Undo targets);
287
+ // covered: picked ids an earlier answer covered; gone: 404s;
288
+ // affected: every id the answers listed (discarded ones too);
289
+ // flipped: the ids archived / unarchived (not the discarded)
290
+ export async function runBatch(ids, act, onProgress = () => {}) {
291
+ const out = { done: [], covered: [], skipped: [], gone: [], affected: new Set(), flipped: new Set() };
292
+ const targets = ids.slice();
293
+ for (let i = 0; i < targets.length; i++) {
294
+ const id = targets[i];
295
+ if (out.affected.has(id)) {
296
+ out.covered.push(id);
297
+ } else {
298
+ const answer = await act(id);
299
+ if (answer.ok) {
300
+ out.done.push(id);
301
+ const r = answer.result || {};
302
+ for (const list of [r.archived, r.unarchived]) (list || []).forEach((x) => { out.affected.add(x); out.flipped.add(x); });
303
+ (r.discarded || []).forEach((x) => out.affected.add(x));
304
+ out.affected.add(id);
305
+ } else if (answer.error?.status === 404) {
306
+ out.gone.push(id);
307
+ } else {
308
+ out.skipped.push({ id, code: answer.error?.code || null, reason: refusalText(answer.error) });
309
+ }
310
+ }
311
+ onProgress(i + 1, targets.length);
312
+ }
313
+ return out;
314
+ }
315
+
316
+ // The end toast of a batch: "Archived 3 (+2 delegates) · 1 skipped", or
317
+ // "None archived: 2 skipped" when nothing went.
318
+ // @param done the picked ids it archived (requested or covered)
319
+ // @param delegates the others the cascade took along
320
+ export function batchToastText({ done, delegates = 0, skipped = 0, archive }) {
321
+ const verb = archive ? "Archived" : "Unarchived";
322
+ const skippedText = `${skipped} skipped`;
323
+ if (!done) return `None ${verb.toLowerCase()}${skipped ? `: ${skippedText}` : ""}`;
324
+ const more = delegates ? ` (+${delegates} ${delegates === 1 ? "delegate" : "delegates"})` : "";
325
+ return `${verb} ${done}${more}${skipped ? ` · ${skippedText}` : ""}`;
326
+ }
327
+
328
+ // The numbers batchToastText takes, from runBatch's outcome.
329
+ export function batchCounts(outcome, archive) {
330
+ const picked = new Set([...outcome.done, ...outcome.covered]);
331
+ const delegates = [...outcome.flipped].filter((id) => !picked.has(id)).length;
332
+ return { done: picked.size, delegates, skipped: outcome.skipped.length, archive };
137
333
  }
@@ -19,9 +19,12 @@ export function stripColumns(width) {
19
19
  // approval, a card), so a hidden strip still says one needs an answer.
20
20
  // Plain text parts; live and waiting are "" when none. Zero sessions keep
21
21
  // the bare label.
22
- export function stripShowParts(sessions) {
22
+ // @param cards how many cards the list makes (a parent's delegates fold
23
+ // into its card: one), the count's number; live and waiting count every
24
+ // session, a waiting delegate is still one thing waiting on the user
25
+ export function stripShowParts(sessions, { cards = null } = {}) {
23
26
  const list = sessions || [];
24
- const n = list.length;
27
+ const n = cards ?? list.length;
25
28
  const liveN = list.filter((s) => s && s.owner === "worker").length;
26
29
  const waitingN = list.filter((s) => waitingOn(s)).length;
27
30
  return {
@@ -0,0 +1,107 @@
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.
5
+ 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 { formatDuration } from "./timing.js";
11
+ import { emptyAnswerLine } from "./turn_events.js";
12
+
13
+ // A row's status (live, or a saved tool record's on reload): running,
14
+ // error, stopped (a task_wait the user's Stop ended), blocked (a guardrail
15
+ // denied it), else ok ("done").
16
+ const NAMED_STATUSES = ["running", "error", "stopped", "blocked"];
17
+
18
+ // A row's status as its parts: the mark's class (CSS draws the glyph), its
19
+ // label (aria-label and title) and the word an exception shows after the
20
+ // title (empty for done and running).
21
+ export function statusParts(status) {
22
+ const named = NAMED_STATUSES.includes(status);
23
+ const cls = named ? status : "ok";
24
+ const label = named ? status : "done";
25
+ return { cls, label, word: cls === "ok" || cls === "running" ? "" : label };
26
+ }
27
+
28
+ function statusMarkHtml({ cls, label }) {
29
+ return `<span class="activity-status ${cls}" role="img" aria-label="${label}" title="${label}"></span>`;
30
+ }
31
+
32
+ function statusWordHtml({ cls, word }) {
33
+ return word ? `<span class="activity-state ${cls}">${word}</span>` : "";
34
+ }
35
+
36
+ // A tool row: its status mark, tool, title (else params; the full command,
37
+ // else the full params line, on hover), the word an exception shows, the
38
+ // call's duration, the full command (an execute/task_create's view, also
39
+ // while it runs), the output once done (its "[tool]" tag dropped, 300 chars,
40
+ // the rest on hover), its images (+thumbs+(images) draws them) and an
41
+ // edit's collapsed diff. A part with nothing to say is left out.
42
+ export function toolRowHtml(row, { thumbs = () => "" } = {}) {
43
+ return `<div class="activity-row" data-key="${escapeHtml(row.key)}">${toolRowInnerHtml(row, { thumbs })}</div>`;
44
+ }
45
+
46
+ export function toolRowInnerHtml(row, { thumbs = () => "" } = {}) {
47
+ const shown = row.title || row.params;
48
+ const hover = rowHover(row) ? ` title="${escapeHtml(rowHover(row))}"` : "";
49
+ const params = shown ? `<span class="activity-params"${hover}>${escapeHtml(shown)}</span>` : "";
50
+ const duration = formatDuration(row.duration_ms);
51
+ const full = row.status === "running" ? "" : String(row.output || "").replace(/^\[[^\]]*\]\s*/, "");
52
+ const output = full
53
+ ? `<div class="activity-output" title="${escapeHtml(full)}">${escapeHtml(full.length > 300 ? `${full.slice(0, 300)}…` : full)}</div>`
54
+ : "";
55
+ const status = statusParts(row.status);
56
+ return `${statusMarkHtml(status)}<span class="activity-tool">${escapeHtml(toolName(row))}</span>${params}${statusWordHtml(status)}` +
57
+ `${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}` +
58
+ `${commandBlockHtml(row.view, row.title)}${output}` +
59
+ `${row.images?.length ? thumbs(row.images) : ""}${row.diff ? activityDiffHtml(row.diff) : ""}`;
60
+ }
61
+
62
+ // A prompt bubble's states and their badges: queued (waiting for its
63
+ // turn), steered (merged into a running turn), failed (its turn raised and
64
+ // gave it back). Any other state ("started", none) is a plain bubble.
65
+ export const BADGES = { queued: "queued", steered: "steered", failed: "failed" };
66
+
67
+ export function stateBadgeHtml(state) {
68
+ return BADGES[state] ? `<span class="state-badge">${escapeHtml(BADGES[state])}</span>` : "";
69
+ }
70
+
71
+ // A user's line: who sent it (+label+, a delegate report's or another
72
+ // client's), its body (`>` lines as quotes) and +thumbs+ (its images'
73
+ // markup), its state's badge. +message+: { content, display } (the copy
74
+ // source keeps the text as written). +step+: the step that read a line
75
+ // merged mid-turn (a reload's; a resolved question goes before it).
76
+ // The live page's own: +own+ (this tab sent it), +enqueuedId+ and +key+
77
+ // (its normalized text: an echo waiting for its id is matched by it).
78
+ export function userBubbleHtml(message, { label = null, state = null, step = null, thumbs = "", own = false, enqueuedId = null, key = null } = {}) {
79
+ const named = BADGES[state] ? state : null;
80
+ const attrs = [
81
+ `class="bubble user${named ? ` ${named}` : ""}"`,
82
+ named ? `data-user-state="${named}"` : "",
83
+ step ? `data-step="${Number(step)}"` : "",
84
+ ].filter(Boolean).join(" ");
85
+ const live = [
86
+ key !== null ? ` data-user-content="${escapeHtml(key)}"` : "",
87
+ own ? ' data-own="1"' : "",
88
+ enqueuedId ? ` data-enqueued-id="${escapeHtml(enqueuedId)}"` : "",
89
+ ].join("");
90
+ const from = label ? `<span class="origin-label">${escapeHtml(label)}</span>` : "";
91
+ return `<div ${attrs}${copySourceAttr({ ...message, role: "user" }, false)}${live}>${from}` +
92
+ `<div class="user-message">${userBodyHtml(message?.content)}${thumbs}</div>${stateBadgeHtml(named)}</div>`;
93
+ }
94
+
95
+ // The notice of a turn that ended with no answer (the server's
96
+ // empty_answer item, turn_summary.empty_answer live): a muted row like the
97
+ // loop's retry row, where the answer would be.
98
+ export function emptyAnswerHtml(item) {
99
+ return `<div class="bubble hook-notice empty-answer">${escapeHtml(emptyAnswerLine(item))}</div>`;
100
+ }
101
+
102
+ // The element +html+ (one top-level element) describes.
103
+ export function htmlElement(html) {
104
+ const template = document.createElement("template");
105
+ template.innerHTML = html;
106
+ return template.content.firstElementChild;
107
+ }
@@ -117,12 +117,13 @@ function toolEvent(turn, event, add) {
117
117
  return { ...r, row };
118
118
  }
119
119
 
120
- export function toolStarted(turn, event) {
121
- return toolEvent(turn, event, (model, e) => addStarted(model, e).row);
120
+ // +now+: when the page got the event, for the call's duration (activity.js).
121
+ export function toolStarted(turn, event, now = null) {
122
+ return toolEvent(turn, event, (model, e) => addStarted(model, e, now).row);
122
123
  }
123
124
 
124
- export function toolCompleted(turn, event) {
125
- return toolEvent(turn, event, addCompleted);
125
+ export function toolCompleted(turn, event, now = null) {
126
+ return toolEvent(turn, event, (model, e) => addCompleted(model, e, now));
126
127
  }
127
128
 
128
129
  // A hook's line to the user, in the current step; null with no running