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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +89 -1
- data/README.md +3 -2
- data/bin/chi +23 -2
- data/docs/architecture.md +11 -6
- data/docs/cli.md +9 -3
- data/docs/configuration.md +62 -12
- data/docs/hooks.md +82 -5
- data/docs/internals/engine.md +50 -13
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/internals/prompt-caching.md +6 -3
- data/docs/memory.md +1 -1
- data/docs/plugins.md +57 -4
- data/docs/sessions.md +7 -3
- data/docs/sub-agent.md +2 -2
- data/docs/testing.md +4 -2
- data/lib/samagotchi/answer_display.rb +4 -3
- data/lib/samagotchi/answer_refs.rb +210 -0
- data/lib/samagotchi/bootstrap_command.rb +2 -1
- data/lib/samagotchi/bridge/card_store.rb +6 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
- data/lib/samagotchi/bundles/agents-md/manifest.yml +11 -0
- data/lib/samagotchi/bundles/agents-md/plugin.rb +198 -0
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
- data/lib/samagotchi/bundles/core/manifest.yml +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/config.rb +33 -5
- data/lib/samagotchi/continue_offer.rb +5 -2
- data/lib/samagotchi/engine.rb +101 -23
- data/lib/samagotchi/hooks/registry.rb +3 -1
- data/lib/samagotchi/interrupted_turn.rb +39 -0
- data/lib/samagotchi/iteration_limit.rb +3 -2
- data/lib/samagotchi/kernel_loop.rb +40 -12
- data/lib/samagotchi/llm/backend.rb +6 -1
- data/lib/samagotchi/llm/chat_loop.rb +16 -8
- data/lib/samagotchi/llm/model_result.rb +11 -2
- data/lib/samagotchi/llm/native_backend.rb +3 -2
- data/lib/samagotchi/log_subscriber.rb +8 -4
- data/lib/samagotchi/plugin/context.rb +16 -3
- data/lib/samagotchi/prompt_files.rb +127 -0
- data/lib/samagotchi/ref.rb +141 -0
- data/lib/samagotchi/reply_wait.rb +6 -2
- data/lib/samagotchi/session.rb +53 -5
- data/lib/samagotchi/session_manager.rb +64 -6
- data/lib/samagotchi/session_metrics.rb +8 -2
- data/lib/samagotchi/session_setup.rb +11 -5
- data/lib/samagotchi/system_prompt.rb +71 -15
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -9
- data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
- data/lib/samagotchi/terminal_ui.rb +21 -5
- data/lib/samagotchi/turn_checkpoint.rb +128 -0
- data/lib/samagotchi/turn_flow.rb +22 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +5 -0
- data/lib/samagotchi/web/public/answer_refs.js +217 -0
- data/lib/samagotchi/web/public/app.js +15 -18
- data/lib/samagotchi/web/public/favicon.svg +1 -0
- data/lib/samagotchi/web/public/index.html +6 -3
- data/lib/samagotchi/web/public/ref_menu.js +9 -1
- data/lib/samagotchi/web/public/ref_scan.js +5 -3
- data/lib/samagotchi/web/public/refs.js +129 -15
- data/lib/samagotchi/web/public/stream_handlers.js +5 -3
- data/lib/samagotchi/web/public/turn_view.js +17 -7
- data/lib/samagotchi/worker.rb +169 -21
- data/lib/samagotchi/worker_signals.rb +81 -0
- 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 {
|
|
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
|
|
908
|
-
// turn-end read, an answer_display re-read
|
|
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
|
-
|
|
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
|
|
925
|
-
//
|
|
926
|
-
//
|
|
927
|
-
//
|
|
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:
|
|
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
|
-
|
|
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
|
|
107
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
101
|
-
// href} or null
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
145
|
-
//
|
|
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 (_) {
|