samagotchi 0.59.0 → 0.61.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/README.md +3 -2
  4. data/bin/chi +23 -2
  5. data/docs/architecture.md +11 -6
  6. data/docs/cli.md +9 -3
  7. data/docs/configuration.md +62 -12
  8. data/docs/hooks.md +82 -5
  9. data/docs/internals/engine.md +50 -13
  10. data/docs/internals/gemma4-contract.md +1 -0
  11. data/docs/internals/prompt-caching.md +6 -3
  12. data/docs/memory.md +1 -1
  13. data/docs/plugins.md +57 -4
  14. data/docs/sessions.md +7 -3
  15. data/docs/sub-agent.md +2 -2
  16. data/docs/testing.md +4 -2
  17. data/lib/samagotchi/answer_display.rb +4 -3
  18. data/lib/samagotchi/answer_refs.rb +210 -0
  19. data/lib/samagotchi/bootstrap_command.rb +2 -1
  20. data/lib/samagotchi/bridge/card_store.rb +6 -1
  21. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  22. data/lib/samagotchi/bundles/agents-md/manifest.yml +11 -0
  23. data/lib/samagotchi/bundles/agents-md/plugin.rb +198 -0
  24. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
  26. data/lib/samagotchi/bundles/core/manifest.yml +2 -1
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  28. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  29. data/lib/samagotchi/config.rb +33 -5
  30. data/lib/samagotchi/continue_offer.rb +5 -2
  31. data/lib/samagotchi/engine.rb +101 -23
  32. data/lib/samagotchi/hooks/registry.rb +3 -1
  33. data/lib/samagotchi/interrupted_turn.rb +39 -0
  34. data/lib/samagotchi/iteration_limit.rb +3 -2
  35. data/lib/samagotchi/kernel_loop.rb +40 -12
  36. data/lib/samagotchi/llm/backend.rb +6 -1
  37. data/lib/samagotchi/llm/chat_loop.rb +16 -8
  38. data/lib/samagotchi/llm/model_result.rb +11 -2
  39. data/lib/samagotchi/llm/native_backend.rb +3 -2
  40. data/lib/samagotchi/log_subscriber.rb +8 -4
  41. data/lib/samagotchi/plugin/context.rb +16 -3
  42. data/lib/samagotchi/prompt_files.rb +127 -0
  43. data/lib/samagotchi/ref.rb +141 -0
  44. data/lib/samagotchi/reply_wait.rb +6 -2
  45. data/lib/samagotchi/session.rb +53 -5
  46. data/lib/samagotchi/session_manager.rb +64 -6
  47. data/lib/samagotchi/session_metrics.rb +8 -2
  48. data/lib/samagotchi/session_setup.rb +11 -5
  49. data/lib/samagotchi/system_prompt.rb +71 -15
  50. data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -9
  51. data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
  52. data/lib/samagotchi/terminal_ui.rb +21 -5
  53. data/lib/samagotchi/turn_checkpoint.rb +128 -0
  54. data/lib/samagotchi/turn_flow.rb +22 -3
  55. data/lib/samagotchi/version.rb +1 -1
  56. data/lib/samagotchi/web/app.rb +5 -0
  57. data/lib/samagotchi/web/public/answer_refs.js +217 -0
  58. data/lib/samagotchi/web/public/app.js +15 -18
  59. data/lib/samagotchi/web/public/favicon.svg +1 -0
  60. data/lib/samagotchi/web/public/index.html +6 -3
  61. data/lib/samagotchi/web/public/ref_menu.js +9 -1
  62. data/lib/samagotchi/web/public/ref_scan.js +5 -3
  63. data/lib/samagotchi/web/public/refs.js +129 -15
  64. data/lib/samagotchi/web/public/stream_handlers.js +5 -3
  65. data/lib/samagotchi/web/public/turn_view.js +17 -7
  66. data/lib/samagotchi/worker.rb +169 -21
  67. data/lib/samagotchi/worker_signals.rb +81 -0
  68. metadata +11 -1
@@ -0,0 +1,217 @@
1
+ // The refs a plugin attached to an answer (the message's display_refs,
2
+ // traceable refs P5b): each {match, kind, target, label?, actions} is
3
+ // paired with the answer's bubble, then bound to what it stands for
4
+ // (refs.js bindRef), so its actions join the ref's menu.
5
+ //
6
+ // A bubble knows its message's refs through setAnswerRefs (a reload's
7
+ // render, a turn-end read's re-render); pairAnswerRefs pairs them once per
8
+ // setAnswerRefs. Three passes over all the refs, strongest evidence
9
+ // first, so a weak text match of one ref never takes another's link:
10
+ // 1. a link whose href is one of the ref's action URLs;
11
+ // 2. a link whose text is the match;
12
+ // 3. the match as a whole token inside one plain text node (not in pre,
13
+ // a link, inline code or another ref), or an inline code that is the
14
+ // match alone.
15
+ // A ref binds every occurrence its first finding pass finds; a ref no
16
+ // pass finds is dropped. A label that differs from the element's text is
17
+ // its hover title (unless the element has one: a markdown link's).
18
+ // The match and label are a plugin's text: they only ever go through
19
+ // textContent, setAttribute and bindRef, never markup or a RegExp.
20
+
21
+ import { COPY_SOURCE_ATTR, copySource, showsAnswer } from "./copy.js";
22
+ import { bindRef, boundRef, httpUrl, refKindOk } from "./refs.js";
23
+
24
+ // bubble → {refs, paired}. A WeakMap: a dropped bubble takes its refs with
25
+ // it; the stage hand-off moves the bubble, so they move along.
26
+ const ANSWER_REFS = new WeakMap();
27
+
28
+ // Text never paired: code blocks, links (passes 1 and 2 are theirs), a ref
29
+ // already made, inline code (only as the match alone).
30
+ const SKIP = "pre, a, [data-ref-kind]";
31
+
32
+ // A token boundary, as the `path:line` scanner's (ref_scan.js REF): not
33
+ // after a path-ish character, not before a word character or a slash.
34
+ const BEFORE = /[\w./@+~-]/;
35
+ const AFTER = /[\w/]/;
36
+
37
+ // +bubble+'s refs are +refs+ (a message's display_refs: entries with a
38
+ // match and a kind bindRef binds); a non-array or one without a usable
39
+ // entry clears them. They pair on the next
40
+ // pairAnswerRefs, even when the same ones were paired before.
41
+ export function setAnswerRefs(bubble, refs) {
42
+ if (!bubble) return;
43
+ const list = (Array.isArray(refs) ? refs : []).filter(
44
+ (ref) => ref && typeof ref === "object" && refKindOk(ref.kind) && typeof ref.match === "string" && ref.match.trim(),
45
+ );
46
+ if (list.length) ANSWER_REFS.set(bubble, { refs: list, paired: false });
47
+ else ANSWER_REFS.delete(bubble);
48
+ }
49
+
50
+ // +bubble+'s refs (setAnswerRefs'), or null.
51
+ export function answerRefsOf(bubble) {
52
+ return ANSWER_REFS.get(bubble)?.refs || null;
53
+ }
54
+
55
+ // The turn's final answer on its bubble (a turn-end read's +messages+):
56
+ // the last assistant message with text, its bubble the last of +bubbles+
57
+ // that shows it (copy.js showsAnswer, with +normalize+). Its rendered html
58
+ // replaces the bubble's when it has one (not with web.markdown off or no
59
+ // renderer: the text stays); its refs are the bubble's either way (a
60
+ // later read without any clears them). The bubble, or null.
61
+ export function applyFinalAnswer(messages, bubbles, normalize) {
62
+ const message = [...(Array.isArray(messages) ? messages : [])].reverse().find(
63
+ (item) => item?.role === "assistant" && (typeof item.html === "string" || String(item.content ?? "").trim()),
64
+ );
65
+ const bubble = message && [...bubbles].reverse().find((item) => showsAnswer(item, message, normalize));
66
+ if (!bubble) return null;
67
+ if (typeof message.html === "string") {
68
+ bubble.classList.add("markdown");
69
+ bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
70
+ bubble.innerHTML = message.html;
71
+ }
72
+ setAnswerRefs(bubble, message.display_refs);
73
+ return bubble;
74
+ }
75
+
76
+ // Pairs +bubble+'s refs with its links and text and binds them (once per
77
+ // setAnswerRefs; again a no-op).
78
+ export function pairAnswerRefs(bubble) {
79
+ const entry = bubble && ANSWER_REFS.get(bubble);
80
+ if (!entry || entry.paired) return;
81
+ entry.paired = true;
82
+ let left = entry.refs;
83
+ left = left.filter((ref) => !pairByHref(bubble, ref));
84
+ left = left.filter((ref) => !pairByLinkText(bubble, ref));
85
+ pairByText(bubble, left);
86
+ }
87
+
88
+ // The links a ref may take: not inside a code block or another ref, not
89
+ // bound by a plugin's ref already (a link the regex scanner adopted has
90
+ // attributes only: it may be bound).
91
+ function freeLinks(bubble) {
92
+ return [...bubble.querySelectorAll("a[href]")].filter((a) => !boundRef(a) && !a.parentElement?.closest("pre, [data-ref-kind]"));
93
+ }
94
+
95
+ // Pass 1: true when +ref+ took a link to one of its action URLs.
96
+ function pairByHref(bubble, ref) {
97
+ const urls = new Set((Array.isArray(ref.actions) ? ref.actions : []).map((action) => httpUrl(action?.url)).filter(Boolean));
98
+ if (!urls.size) return false;
99
+ const links = freeLinks(bubble).filter((a) => urls.has(httpUrl(a.getAttribute("href"))));
100
+ return bindAll(links, ref);
101
+ }
102
+
103
+ // Pass 2: true when +ref+ took a link whose text is its match. The link
104
+ // goes somewhere the ref doesn't name (the model's URL): the ref's actions
105
+ // join its menu, but its label isn't the link's title.
106
+ function pairByLinkText(bubble, ref) {
107
+ const match = ref.match.trim();
108
+ return bindAll(freeLinks(bubble).filter((a) => a.textContent.trim() === match), ref, { label: false });
109
+ }
110
+
111
+ // Pass 3, for all the +refs+ left at once: a ref the path:line scan made
112
+ // (attributes only, no plugin data) whose text is a match is bound in
113
+ // place (the scan ran first on a bubble that wasn't re-rendered: an answer
114
+ // without html); an inline code that is a match alone is wrapped whole;
115
+ // then each text node's hits (refs in list order, a hit overlapping an
116
+ // earlier one dropped) are wrapped in one go.
117
+ function pairByText(bubble, refs) {
118
+ if (!refs.length) return;
119
+ const doc = bubble.ownerDocument;
120
+ const matching = (el) => {
121
+ const text = el.textContent.trim();
122
+ return refs.find((candidate) => candidate.match.trim() === text);
123
+ };
124
+ for (const scanned of [...bubble.querySelectorAll("[data-ref-kind]:not(a)")]) {
125
+ if (boundRef(scanned) || scanned.parentElement?.closest("pre, a, [data-ref-kind]")) continue;
126
+ const ref = matching(scanned);
127
+ if (ref) bindAll([scanned], ref);
128
+ }
129
+ for (const code of [...bubble.querySelectorAll("code")]) {
130
+ if (code.closest(SKIP)) continue;
131
+ const ref = matching(code);
132
+ if (!ref) continue;
133
+ const span = refSpan(doc, ref);
134
+ if (!span) continue;
135
+ code.replaceWith(span);
136
+ span.append(code);
137
+ labelOn(span, ref);
138
+ }
139
+ const walker = doc.createTreeWalker(bubble, 4 /* NodeFilter.SHOW_TEXT */);
140
+ const nodes = [];
141
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
142
+ const skip = node.parentElement?.closest(`${SKIP}, code`);
143
+ if (!skip || !bubble.contains(skip)) nodes.push(node);
144
+ }
145
+ const hits = new Map();
146
+ for (const ref of refs) {
147
+ const match = ref.match.trim();
148
+ for (const node of nodes) {
149
+ for (const [start, end] of tokenHits(node.data, match)) {
150
+ const list = hits.get(node) || [];
151
+ if (list.some(([s, e]) => start < e && end > s)) continue;
152
+ list.push([start, end, ref]);
153
+ hits.set(node, list);
154
+ }
155
+ }
156
+ }
157
+ for (const [node, list] of hits) wrap(node, list.sort((a, b) => a[0] - b[0]));
158
+ }
159
+
160
+ // [start, end] of each whole-token +match+ in +text+ (indexOf: the match
161
+ // is a plugin's text, never a pattern).
162
+ function tokenHits(text, match) {
163
+ const found = [];
164
+ for (let at = text.indexOf(match); at >= 0; at = text.indexOf(match, at + 1)) {
165
+ const end = at + match.length;
166
+ if (at > 0 && BEFORE.test(text[at - 1])) continue;
167
+ if (end < text.length && AFTER.test(text[end])) continue;
168
+ found.push([at, end]);
169
+ at = end - 1;
170
+ }
171
+ return found;
172
+ }
173
+
174
+ function wrap(node, hits) {
175
+ const doc = node.ownerDocument;
176
+ const frag = doc.createDocumentFragment();
177
+ let pos = 0;
178
+ for (const [start, end, ref] of hits) {
179
+ const span = refSpan(doc, ref);
180
+ if (!span) continue;
181
+ if (start > pos) frag.append(node.data.slice(pos, start));
182
+ span.textContent = node.data.slice(start, end);
183
+ labelOn(span, ref);
184
+ frag.append(span);
185
+ pos = end;
186
+ }
187
+ if (!pos) return;
188
+ if (pos < node.data.length) frag.append(node.data.slice(pos));
189
+ node.replaceWith(frag);
190
+ }
191
+
192
+ // A span bound to +ref+, or null when bindRef refuses it (a bad kind).
193
+ function refSpan(doc, ref) {
194
+ const span = doc.createElement("span");
195
+ bindRef(span, ref);
196
+ return boundRef(span) ? span : null;
197
+ }
198
+
199
+ // Binds each of +els+ to +ref+; true when one took it.
200
+ function bindAll(els, ref, { label = true } = {}) {
201
+ let bound = false;
202
+ for (const el of els) {
203
+ bindRef(el, ref);
204
+ if (!boundRef(el)) continue;
205
+ if (label) labelOn(el, ref);
206
+ bound = true;
207
+ }
208
+ return bound;
209
+ }
210
+
211
+ // The ref's label as the element's hover title, when it says more than
212
+ // the element's text and the element has no title of its own.
213
+ function labelOn(el, ref) {
214
+ const label = typeof ref.label === "string" ? ref.label.trim() : "";
215
+ if (!label || label === el.textContent.trim() || el.hasAttribute("title")) return;
216
+ el.setAttribute("title", label);
217
+ }
@@ -46,7 +46,7 @@ import { createTaskStopper } from "./task_stop.js";
46
46
  import { appendQuote } from "./annotations.js";
47
47
  import { parsePresets } from "./annotate_presets.js";
48
48
  import { createAnnotateBar } from "./annotate_bar.js";
49
- import { COPY_SOURCE_ATTR, copySource, copyText, installCopy, showsAnswer } from "./copy.js";
49
+ import { copyText, installCopy } from "./copy.js";
50
50
  import { ctxBarText, ctxBarTip, lastSpeedText, speedText } from "./ctx.js";
51
51
  import { createQuestionCards } from "./question_cards.js";
52
52
  import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, splitSnapshotCards, turnNoticePlace } from "./card.js";
@@ -65,6 +65,7 @@ import {
65
65
  appendAboveLiveTiming, formatDuration, mergeTiming, normalizeTiming, sessionDurationNow, STOPPED_LINE_CLASS,
66
66
  } from "./timing.js";
67
67
  import { createLiveTurn } from "./live_turn.js";
68
+ import { applyFinalAnswer, pairAnswerRefs } from "./answer_refs.js";
68
69
  import { installRefClicks } from "./refs.js";
69
70
  import { linkRefs, touchedFiles } from "./ref_scan.js";
70
71
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
@@ -904,30 +905,26 @@ async function readTurnEnd() {
904
905
  } catch(_){}
905
906
  }
906
907
 
907
- // The turn's answer as its rendered markdown, once it is final (the
908
- // turn-end read, an answer_display re-read); then its refs.
908
+ // The turn's answer as its rendered markdown and with its plugin refs,
909
+ // once it is final (the turn-end read, an answer_display re-read:
910
+ // answer_refs.js applyFinalAnswer); then the refs.
909
911
  function applyFinalMarkdown(messages) {
910
- const message = [...messages].reverse().find(
911
- (item) => item?.role === "assistant" && typeof item.html === "string",
912
- );
913
- const bubble = message && findInTurnRoots(".bubble.output")
914
- .reverse()
915
- .find((item) => showsAnswer(item, message, normalize));
916
- if (bubble) {
917
- bubble.classList.add("markdown");
918
- bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
919
- bubble.innerHTML = message.html;
920
- }
912
+ applyFinalAnswer(messages, findInTurnRoots(".bubble.output"), normalize);
921
913
  linkTextRefs();
922
914
  }
923
915
 
924
- // The `path:line` refs in the answers' and closed steps' texts that name a
925
- // file the session's rows (and cards) touched, on this page (ref_scan.js).
926
- // Not mid-stream: at a turn's end and after a reload's render. A ref
927
- // already made stays as it is.
916
+ // The refs on the answers' and closed steps' texts, on this page: first
917
+ // each answer's own (a plugin's, answer_refs.js), then the `path:line`
918
+ // refs that name a file the session's rows (and cards) touched
919
+ // (ref_scan.js), which leave a plugin's alone. Not mid-stream: at a
920
+ // turn's end and after a reload's render. A ref already made stays as it
921
+ // is.
928
922
  function linkTextRefs() {
929
923
  const roots = turnRoots();
930
924
  const touched = touchedFiles(roots);
925
+ for (const root of roots) {
926
+ for (const el of root.querySelectorAll(".bubble.output")) pairAnswerRefs(el);
927
+ }
931
928
  if (!touched.size) return;
932
929
  for (const root of roots) {
933
930
  for (const el of root.querySelectorAll(".bubble.output, .gen:not(.live) > .gen-text")) linkRefs(el, touched, current.dir);
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#7aa5ff"/><stop offset="1" stop-color="#8957e5"/></linearGradient></defs><rect x="1" y="1" width="30" height="30" rx="7.5" fill="url(#g)"/><g fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.8 9.6c2.3-1.3 4-.7 5.2 1.4l6 10.1c1.2 2.1 2.9 2.7 5.2 1.4"/><path d="M22.6 9.2 9.4 22.8"/></g></svg>
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <title>Chi</title>
6
+ <link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
6
7
  <meta name="viewport" content="width=device-width,initial-scale=1">
7
8
  <style>
8
9
  :root{color-scheme:dark;--bg:#0b0d11;--bg-panel:#151821;--bg-inset:#0f1115;--bg-raised:#22262f;--bg-hover:#1b1f27;--border:#1f232b;--border-strong:#2d333f;--text:#e6e8eb;--text-bright:#f0f3f6;--text-2:#c9d1d9;--text-3:#9aa4b2;--text-muted:#8b949e;--text-faint:#6e7681;--accent:#7aa5ff;--accent-strong:#1f6feb;--accent-bg:#1b2336;--accent-ring:rgba(122,165,255,.18);--primary:#2a68d1;--ok:#3fb950;--ok-text:#7ee787;--ok-bg:#1a2e1a;--warn:#d29922;--warn-text:#e3b341;--warn-bright:#f2cc60;--warn-bg:#2b2108;--warn-border:#6b5416;--danger:#f85149;--danger-bg:#da3633;--danger-text:#ff7b72;--danger-bg-dim:#3d1f1f;--think:#8957e5;--think-text:#d2a8ff;--accent-border:#223150;--pick-border:#79c0ff;--accent-tint:color-mix(in srgb,var(--accent) 7%,transparent);--narr:color-mix(in srgb,var(--accent) 38%,var(--text-muted));
@@ -381,16 +382,18 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
381
382
  .diff code{display:block;min-width:max-content}
382
383
  .diff-line{display:block;padding:0 10px;white-space:pre;color:var(--text-2)}
383
384
  /* Refs (refs.js): text until hovered or focused, and only with a target for this viewer (refs.js marks it: data-ref-main, the main one). A link ref keeps its link look. */
384
- .ref[data-ref-main]:not(a):is(:hover,:focus-visible){color:var(--accent);text-decoration:underline dotted;text-underline-offset:3px;cursor:pointer}
385
- .ref[data-ref-main="editor"]:not(a):is(:hover,:focus-visible)::after{content:"\2197";margin-left:.3em;color:var(--text-faint);display:inline-block}
385
+ .ref[data-ref-main]:not(a):is(:hover,:focus-visible,[data-ref-more]){color:var(--accent);text-decoration:underline dotted;text-underline-offset:3px;cursor:pointer}
386
+ .ref[data-ref-main="editor"]:not(a):is(:hover,:focus-visible,[data-ref-more])::after{content:"\2197";margin-left:.3em;color:var(--text-faint);display:inline-block}
386
387
  .ref[data-ref-kind]:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:3px}
387
388
  :is(.activity-row,.bubble.approval) .diff-line[data-ref-main]:hover{cursor:pointer;box-shadow:inset 2px 0 var(--accent)}
388
389
  :is(.activity-row,.bubble.approval) .diff-line[data-ref-main]:hover::after{content:"\2197 " attr(data-line);float:right;padding-left:12px;color:var(--text-faint)}
389
390
  .activity-row .read-line[data-ref-main]:hover{cursor:pointer;box-shadow:inset 2px 0 var(--accent)}
390
391
  .activity-row .read-line[data-ref-main]:hover::before{color:var(--accent)}
391
392
  /* A ref's ▾ and its menu (ref_menu.js): one each, placed next to the ref. */
392
- .ref-more{position:fixed;z-index:60;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:1px solid var(--border-strong);border-radius:5px;background:var(--bg-panel);color:var(--text-muted);font-size:12px;line-height:1;cursor:pointer;box-shadow:0 1px 3px var(--shadow-strong)}
393
+ .ref-more{position:fixed;z-index:60;display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;padding:0;border:1px solid var(--border-strong);border-radius:4px;background:var(--bg-panel);color:var(--text-muted);font-size:12px;line-height:1;cursor:pointer;box-shadow:0 1px 3px var(--shadow-strong)}
393
394
  .ref-more[hidden]{display:none}
395
+ /* A ref makes room after itself for its ▾ while it shows (ref_menu.js), and keeps its hover look (its ↗ too) until the ▾ goes: the ▾ never covers what follows it. */
396
+ .ref[data-ref-more]{padding-right:19px}
394
397
  .ref-more:hover{color:var(--accent);border-color:var(--accent)}
395
398
  .ref-menu{position:fixed;z-index:61;min-width:160px;max-width:calc(100vw - 16px);padding:4px;border:1px solid var(--border-strong);border-radius:8px;background:var(--bg-panel);box-shadow:var(--toast-shadow);animation:fadeIn .12s ease-out}
396
399
  .ref-menu [role="menuitem"]{display:block;width:100%;padding:6px 10px;border:0;border-radius:5px;background:none;color:var(--text);font:inherit;font-size:13px;text-align:left;cursor:pointer;white-space:nowrap}
@@ -10,6 +10,9 @@
10
10
  const HIDE_MS = 300;
11
11
  // Kept from the viewport's edges (a phone's width).
12
12
  const EDGE = 8;
13
+ // The ▾ sits in room its ref makes after itself while it shows
14
+ // (data-ref-more, index.html), so it never covers what follows the ref:
15
+ // the next words of an answer, a row's duration.
13
16
 
14
17
  // +doc+'s ▾ and menu. open(el, items, {focus}) shows +items+ ({id, label,
15
18
  // run}) under +el+; showMore(el, onOpen) puts the ▾ after +el+, whose click
@@ -50,18 +53,23 @@ export function createRefMenu(doc = globalThis.document) {
50
53
  function showMore(el, onOpen) {
51
54
  clearTimeout(hideTimer);
52
55
  const button = moreButton();
56
+ if (moreFor && moreFor !== el) delete moreFor.dataset.refMore;
53
57
  moreFor = el;
54
58
  onMore = onOpen;
59
+ el.dataset.refMore = "";
55
60
  button.hidden = false;
56
61
  const rect = lastRect(el);
62
+ const width = button.offsetWidth || 16;
57
63
  const top = rect.top + (rect.height - (button.offsetHeight || rect.height)) / 2;
58
- button.style.left = `${Math.min(rect.right + 2, win.innerWidth - (button.offsetWidth || 16) - 2)}px`;
64
+ const left = rect.right - width - 1;
65
+ button.style.left = `${Math.min(left, win.innerWidth - width - 2)}px`;
59
66
  button.style.top = `${top}px`;
60
67
  }
61
68
 
62
69
  function hideMore() {
63
70
  clearTimeout(hideTimer);
64
71
  if (more) more.hidden = true;
72
+ if (moreFor) delete moreFor.dataset.refMore;
65
73
  moreFor = null;
66
74
  }
67
75
 
@@ -103,13 +103,15 @@ function resolved(ref, touched, cwd) {
103
103
  return path ? { kind: "file", path, line: ref.line, end_line: ref.endLine } : null;
104
104
  }
105
105
 
106
- // The absolute paths of the file refs under +roots+ (the session's rows,
107
- // approval cards): what a text ref may name.
106
+ // The absolute paths of the file refs in the session's rows and approval
107
+ // cards under +roots+: what a text ref may name. Not an answer's own refs
108
+ // (a scanned one repeats a row's; a plugin's file ref names a file the
109
+ // session may not have touched).
108
110
  export function touchedFiles(roots) {
109
111
  const touched = new Set();
110
112
  for (const root of roots) {
111
113
  for (const el of root.querySelectorAll('[data-ref-kind="file"][data-ref-path]')) {
112
- touched.add(el.dataset.refPath);
114
+ if (el.closest(".activity-row, .bubble.approval")) touched.add(el.dataset.refPath);
113
115
  }
114
116
  }
115
117
  return touched;
@@ -6,6 +6,11 @@
6
6
  // an answer or a step's text (ref_scan.js; a link whose text is one too).
7
7
  // The DOM carries data-ref-kind and the target's attributes, never what a
8
8
  // click does: those are the ref's targets, kept per kind in REF_ACTIONS.
9
+ // A plugin's ref (the server's Ref: {kind, target, label?, actions}) is
10
+ // bound to its element by bindRef: its label and its URL actions live in a
11
+ // WeakMap off the DOM, and refTarget reads them first. Its actions join the
12
+ // kind's own (the `actions` entry); a kind the page has no table for runs
13
+ // only those.
9
14
  // A plain click runs the first one available (the main target); a ref
10
15
  // with more shows a ▾ on hover that opens a menu of all of them
11
16
  // (ref_menu.js), and a tap opens it. A ref with a target is a tab stop
@@ -26,6 +31,77 @@ export function refAttrs(ref) {
26
31
  return ` data-ref-kind="${escapeHtml(ref.kind)}"${attr("path", ref.path)}${attr("line", ref.line)}${attr("end-line", ref.end_line)}`;
27
32
  }
28
33
 
34
+ // A kind, as the server's Ref allows it (Ref::KIND).
35
+ const KIND = /^[a-z][a-z0-9_-]{0,31}$/;
36
+
37
+ // +kind+ is one bindRef binds.
38
+ export function refKindOk(kind) {
39
+ return typeof kind === "string" && KIND.test(kind);
40
+ }
41
+
42
+ // A plugin's ref data per element (bindRef): {kind, target, label,
43
+ // actions}. A WeakMap, so a redrawn element takes its data with it.
44
+ const REF_DATA = new WeakMap();
45
+
46
+ // The longest URL the page opens: the server's RefAction::MAX_URL.
47
+ const MAX_URL = 2000;
48
+
49
+ // +url+ as the page opens it when it is an http(s) one with no userinfo
50
+ // and at most MAX_URL characters (trimmed), else null. The server's
51
+ // RefAction.http_url? checks it too; the page is at least as strict on
52
+ // userinfo, and the URL parser refuses an http(s) URL without a host. The
53
+ // page never opens another scheme a plugin gave (javascript:, data:, an
54
+ // editor's), nor a "https://github.com@evil.example/" (one host shown,
55
+ // another opened).
56
+ export function httpUrl(url) {
57
+ if (typeof url !== "string") return null;
58
+ const trimmed = url.trim();
59
+ if (trimmed.length > MAX_URL) return null;
60
+ try {
61
+ const parsed = new URL(trimmed);
62
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
63
+ if (parsed.username || parsed.password) return null;
64
+ return parsed.href;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ // Makes +el+ the ref +ref+ (the server's Ref#to_h): the ref class and the
71
+ // data attributes every ref carries (a file's path and lines, as refAttrs),
72
+ // its label and its actions in the WeakMap. An action without an id, a
73
+ // label or an http(s) URL is dropped; a kind the server's Ref refuses binds
74
+ // nothing. A rebind replaces the target attributes. +el+, or null without one.
75
+ export function bindRef(el, ref) {
76
+ if (!el) return null;
77
+ if (!refKindOk(ref?.kind)) return el;
78
+ const target = ref.target || {};
79
+ el.classList.add("ref");
80
+ el.dataset.refKind = ref.kind;
81
+ for (const [name, value] of [["refPath", target.path], ["refLine", target.line], ["refEndLine", target.end_line]]) {
82
+ if (value) el.dataset[name] = String(value);
83
+ else delete el.dataset[name];
84
+ }
85
+ const actions = (Array.isArray(ref.actions) ? ref.actions : []).flatMap((action) => {
86
+ const url = httpUrl(action?.url);
87
+ return url && action.id && action.label ? [{ id: String(action.id), label: String(action.label), url }] : [];
88
+ });
89
+ REF_DATA.set(el, { kind: ref.kind, target, label: ref.label || null, actions });
90
+ return el;
91
+ }
92
+
93
+ // The plugin ref bound to +el+ (bindRef's data), or null: a row title or
94
+ // a scanned ref (attributes only) has none.
95
+ export function boundRef(el) {
96
+ return (el && REF_DATA.get(el)) || null;
97
+ }
98
+
99
+ // The same URL either way (a link's href against an action's URL).
100
+ function sameUrl(a, b) {
101
+ const url = httpUrl(a);
102
+ return !!url && url === httpUrl(b);
103
+ }
104
+
29
105
  // The URL +template+ ({path}, {line}; web.editor) opens +path+ at +line+
30
106
  // (1 when none) with: each path segment encoded, the slashes kept. "" with
31
107
  // no template or path.
@@ -41,16 +117,38 @@ export function editorUrl(template, path, line) {
41
117
  // editorUrl}, the server's capabilities.editor and editor_url) and
42
118
  // +target+ (refTarget's). A secondary one (copy) joins a ref that has
43
119
  // another, never alone. A line (a diff or read line) has its click only.
120
+ // A link whose text is the ref (github-pr's PR diff): its href stays the
121
+ // plain click's, which the browser follows. Its label is the ref's action
122
+ // with the same URL (the menu shows them once), else the old guess from
123
+ // the URL (an old session's link has no ref data).
124
+ const LINK = Object.freeze({
125
+ id: "link",
126
+ label: (target) => target.actions?.find((action) => sameUrl(action.url, target.href))?.label ||
127
+ (/\/pull\/\d+\/files\b/.test(target.href) ? "PR diff" : "Open link"),
128
+ available: (_viewer, target) => !!target.href,
129
+ run: (target, _viewer, env) => env.openLink(target.href),
130
+ });
131
+
132
+ // The ref's own actions (a plugin's, bindRef), one target each, for every
133
+ // viewer (a URL opens on a phone too): a new tab with no opener and no
134
+ // referrer. One with the link's URL, or an earlier one's, isn't repeated.
135
+ // Not on a diff or read line (its click only), which shares its title's data.
136
+ const URL_ACTIONS = Object.freeze({
137
+ id: "actions",
138
+ expand: (target) => (target.actions || [])
139
+ .filter((action, at, all) => !sameUrl(action.url, target.href) && all.findIndex((other) => sameUrl(other.url, action.url)) === at)
140
+ .map((action) => Object.freeze({
141
+ id: `url:${action.id}`,
142
+ label: () => action.label,
143
+ available: (_viewer, target) => !target.isLine,
144
+ run: (_target, _viewer, env) => env.openLink(action.url),
145
+ hint: () => action.url,
146
+ })),
147
+ });
148
+
44
149
  export const REF_ACTIONS = Object.freeze({
45
150
  file: Object.freeze([
46
- // A link whose text is the ref (github-pr's PR diff): its href stays
47
- // the plain click's, which the browser follows.
48
- Object.freeze({
49
- id: "link",
50
- label: (target) => (/\/pull\/\d+\/files\b/.test(target.href) ? "PR diff" : "Open link"),
51
- available: (_viewer, target) => !!target.href,
52
- run: (target, _viewer, env) => env.openLink(target.href),
53
- }),
151
+ LINK,
54
152
  Object.freeze({
55
153
  id: "editor",
56
154
  label: () => "Open in editor",
@@ -63,6 +161,7 @@ export const REF_ACTIONS = Object.freeze({
63
161
  // The hover hint: it opens the file as it is now.
64
162
  hint: (target) => `Open the current file in your editor${target.line ? ` at line ${target.line}` : ""}`,
65
163
  }),
164
+ URL_ACTIONS,
66
165
  // The row that touched the file, in the turn: not for a row's own
67
166
  // title or lines (they are in the turn already).
68
167
  Object.freeze({
@@ -80,13 +179,18 @@ export const REF_ACTIONS = Object.freeze({
80
179
  run: (target, _viewer, env) => env.copy(target.path),
81
180
  }),
82
181
  ]),
182
+ // Any other kind (a plugin's issue, commit, …): its link, then its actions.
183
+ "*": Object.freeze([LINK, URL_ACTIONS]),
83
184
  });
84
185
 
85
186
  // The targets +viewer+ has for +target+ (refTarget's), in order; none
86
187
  // when only a secondary one is.
87
188
  export function refActions(target, viewer, actions = REF_ACTIONS) {
88
189
  if (!target) return [];
89
- const found = (actions[target.kind] || []).filter((action) => action.available(viewer, target));
190
+ // Own keys only: a kind named like an Object property ("constructor") is
191
+ // any other kind.
192
+ const table = (Object.hasOwn(actions, target.kind) ? actions[target.kind] : actions["*"]) || [];
193
+ const found = table.flatMap((action) => (action.expand ? action.expand(target) : [action])).filter((action) => action.available(viewer, target));
90
194
  return found.some((action) => !action.secondary) ? found : [];
91
195
  }
92
196
 
@@ -97,8 +201,9 @@ const OWNERS = ".activity-row, .bubble.approval";
97
201
  // A line of the owner's file: a diff line, or a read row's numbered line.
98
202
  const LINES = ".diff-line[data-line], .read-line[data-line]";
99
203
 
100
- // What a click on +el+ points at: {kind, path, line, owner, el, isLine,
101
- // href} or null. A row title (or an approval card's path) opens its file
204
+ // What a click on +el+ points at: {kind, path, id, label, actions, line,
205
+ // owner, el, isLine, href} or null (+id+, +label+ and +actions+ a plugin's
206
+ // ref's, bindRef: the WeakMap is read first). A row title (or an approval card's path) opens its file
102
207
  // at the lines the call named (an edit without any at its first changed
103
208
  // line); a diff line (or a hunk header) or a read row's numbered line, in
104
209
  // a row or a card with a ref, opens that file at that line. +el+ is the
@@ -109,7 +214,8 @@ export function refTarget(el) {
109
214
  if (title) {
110
215
  const owner = title.closest(OWNERS);
111
216
  const href = title.matches("a[href]") ? title.getAttribute("href") : null;
112
- return { ...targetOf(title), line: Number(title.dataset.refLine) || firstChange(owner), owner: owner || title, el: title, isLine: false, href };
217
+ const own = targetOf(title);
218
+ return { ...own, line: own.line || firstChange(owner), owner: owner || title, el: title, isLine: false, href };
113
219
  }
114
220
  const diffLine = el?.closest?.(LINES);
115
221
  const owner = diffLine?.closest(OWNERS);
@@ -119,7 +225,13 @@ export function refTarget(el) {
119
225
  }
120
226
 
121
227
  function targetOf(el) {
122
- return { kind: el.dataset.refKind, path: el.dataset.refPath || "" };
228
+ const data = REF_DATA.get(el);
229
+ if (!data) return { kind: el.dataset.refKind, path: el.dataset.refPath || "", line: Number(el.dataset.refLine) || null, id: null, label: null, actions: [] };
230
+ // Bound data is the whole ref: no fallback to the attributes.
231
+ const { target } = data;
232
+ return {
233
+ kind: data.kind, path: target.path || "", line: Number(target.line) || null, id: target.id ?? null, label: data.label, actions: data.actions,
234
+ };
123
235
  }
124
236
 
125
237
  // An edit/write without a line range opens at its first changed line (its
@@ -235,9 +347,11 @@ function defaultOpen(url) {
235
347
  else window.location.href = url;
236
348
  }
237
349
 
238
- // A link ref's href from the menu: a new tab, as the answer's links open.
350
+ // A link ref's href or a ref's URL action: a new tab, as the answer's
351
+ // links open, with no opener and no referrer (the page's LAN URL and
352
+ // session id stay here).
239
353
  function defaultOpenLink(href) {
240
- window.open(href, "_blank", "noopener");
354
+ window.open(href, "_blank", "noopener,noreferrer");
241
355
  }
242
356
 
243
357
  // One delegated listener set on +root+ for every ref on the page.
@@ -141,12 +141,14 @@ export function createStreamHandlers(deps) {
141
141
  if (display) await display.promise;
142
142
  turnDom.answerReady?.();
143
143
  },
144
- // An after_turn hook presented the answer (AnswerDisplay): it comes after
145
- // turn_completed, so re-read the answer once that read is done.
144
+ // An after_turn hook presented the answer (AnswerDisplay) or attached
145
+ // refs to it (AnswerRefs: refs, a count; display null when only refs):
146
+ // it comes after turn_completed, so re-read the answer once that read
147
+ // is done.
146
148
  answer_display: async (data) => {
147
149
  try {
148
150
  await liveTurn.endRead;
149
- if (!selected() || !data.display) return;
151
+ if (!selected() || (!data.display && !data.refs)) return;
150
152
  const d = await getSession(selected(), { tail: true, recent: true });
151
153
  if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
152
154
  } catch (_) {