samagotchi 0.61.0 → 0.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +91 -1
- data/README.md +4 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +3 -0
- data/docs/cli.md +26 -9
- data/docs/configuration.md +33 -20
- data/docs/context.md +21 -3
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +14 -3
- data/docs/internals/engine.md +24 -11
- data/docs/internals/prompt-caching.md +6 -1
- data/docs/memory.md +1 -1
- data/docs/plugins.md +131 -5
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +4 -4
- data/docs/sub-agent.md +6 -0
- data/docs/testing.md +10 -4
- data/lib/samagotchi/answer_refs.rb +18 -26
- data/lib/samagotchi/bridge.rb +23 -1
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +22 -0
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +406 -0
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +104 -0
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +66 -0
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +60 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/context_absorber.rb +25 -6
- data/lib/samagotchi/engine.rb +9 -0
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/idle_recap.rb +2 -3
- data/lib/samagotchi/image_store.rb +8 -0
- data/lib/samagotchi/kernel_loop.rb +7 -16
- data/lib/samagotchi/llm/chat_loop.rb +23 -15
- data/lib/samagotchi/plugin/api.rb +36 -18
- data/lib/samagotchi/plugin/attached_context.rb +7 -4
- data/lib/samagotchi/reasoning_history.rb +22 -4
- data/lib/samagotchi/recap_setup.rb +3 -3
- data/lib/samagotchi/row_refs.rb +125 -0
- data/lib/samagotchi/session.rb +12 -0
- data/lib/samagotchi/session_metrics.rb +24 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +26 -9
- data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +57 -14
- data/lib/samagotchi/tool_activity.rb +8 -4
- data/lib/samagotchi/tool_response.rb +5 -4
- data/lib/samagotchi/tool_row_fields.rb +4 -2
- data/lib/samagotchi/tool_runner.rb +16 -3
- data/lib/samagotchi/tools/builtins.rb +9 -2
- data/lib/samagotchi/tools/execute.rb +37 -7
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/registry.rb +5 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +20 -5
- data/lib/samagotchi/web/message_parts.rb +17 -9
- data/lib/samagotchi/web/public/activity.js +6 -3
- data/lib/samagotchi/web/public/app.js +106 -106
- data/lib/samagotchi/web/public/card.js +73 -23
- data/lib/samagotchi/web/public/chains.js +24 -24
- data/lib/samagotchi/web/public/command_view.js +39 -35
- data/lib/samagotchi/web/public/context_chips.js +92 -64
- data/lib/samagotchi/web/public/copy.js +7 -15
- data/lib/samagotchi/web/public/diff_view.js +20 -23
- data/lib/samagotchi/web/public/families_view.js +51 -39
- data/lib/samagotchi/web/public/format.js +0 -48
- data/lib/samagotchi/web/public/images.js +12 -16
- data/lib/samagotchi/web/public/index.html +61 -10
- data/lib/samagotchi/web/public/keep_focus.js +24 -0
- data/lib/samagotchi/web/public/lists_view.js +13 -13
- data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
- data/lib/samagotchi/web/public/question_card.js +11 -0
- data/lib/samagotchi/web/public/question_cards.js +63 -7
- data/lib/samagotchi/web/public/read_view.js +10 -9
- data/lib/samagotchi/web/public/ref_menu.js +18 -5
- data/lib/samagotchi/web/public/refs.js +113 -15
- data/lib/samagotchi/web/public/scope.js +23 -0
- data/lib/samagotchi/web/public/scroll.js +42 -0
- data/lib/samagotchi/web/public/stage_model.js +22 -3
- data/lib/samagotchi/web/public/stage_view.js +299 -70
- data/lib/samagotchi/web/public/timing.js +1 -8
- data/lib/samagotchi/web/public/turn_html.js +122 -61
- data/lib/samagotchi/web/public/turn_model.js +49 -0
- data/lib/samagotchi/web/public/turn_view.js +167 -116
- data/lib/samagotchi/web/public/vendor/directive.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
- data/lib/samagotchi/worker.rb +49 -32
- data/lib/samagotchi/worker_inbound.rb +17 -14
- metadata +10 -1
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
// card closes on the continue_resolved every client gets.
|
|
14
14
|
|
|
15
15
|
import { dismissQuestion, sendAnswer, sendCommand } from "./data.js";
|
|
16
|
-
import {
|
|
16
|
+
import { diffTemplate } from "./diff_view.js";
|
|
17
|
+
import { render } from "./vendor/lit-html.js";
|
|
17
18
|
import {
|
|
18
|
-
answerErrorText, approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, deniedReason, isContinue,
|
|
19
|
-
relayedToView, resultText, summaryText,
|
|
19
|
+
answerErrorText, approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, deniedReason, isContinue, keepsAskingText,
|
|
20
|
+
nextCardAction, relayedToView, resultText, summaryText,
|
|
20
21
|
} from "./question_card.js";
|
|
21
22
|
import { sessionHash } from "./route.js";
|
|
22
23
|
import { clientLabel, isOwn } from "./turn_events.js";
|
|
@@ -28,13 +29,15 @@ import { continueLine } from "./commands.js";
|
|
|
28
29
|
// @param isNearBottom () => whether the history follows its end
|
|
29
30
|
// @param scrollToEnd () scrolls the history to its end
|
|
30
31
|
// @param removeHintIfEmpty () drops the history's placeholder hint
|
|
32
|
+
// @param askingText () => the running turn's asking reply (turn_model.js
|
|
33
|
+
// askingText via the page's view), kept by a question or own approval card
|
|
31
34
|
// @param api {sendAnswer, dismissQuestion, sendCommand} (data.js)
|
|
32
35
|
// @param doc the page's document
|
|
33
|
-
// @return {renderQuestion(pq), resolveQuestion(id, opts), removeQuestion(),
|
|
36
|
+
// @return {renderQuestion(pq, {said}), resolveQuestion(id, opts), removeQuestion(),
|
|
34
37
|
// questionCard() (the live card or null), pendingQuestion() (or null),
|
|
35
38
|
// renderContinue(offer), resolveContinue(data), forgetContinue()}
|
|
36
39
|
export function createQuestionCards({
|
|
37
|
-
sessionId, clientId, place, isNearBottom, scrollToEnd, removeHintIfEmpty,
|
|
40
|
+
sessionId, clientId, place, isNearBottom, scrollToEnd, removeHintIfEmpty, askingText = () => "",
|
|
38
41
|
api = { sendAnswer, dismissQuestion, sendCommand }, doc = globalThis.document,
|
|
39
42
|
}) {
|
|
40
43
|
// Live ask_user_question card, keyed by pending question id. Re-rendered from
|
|
@@ -43,8 +46,12 @@ export function createQuestionCards({
|
|
|
43
46
|
let pending = null;
|
|
44
47
|
// The continue offer's card while the offer is pending.
|
|
45
48
|
let continueEl = null;
|
|
49
|
+
// card → the reply that asked it, drawn under the summary once resolved.
|
|
50
|
+
const saidOf = new WeakMap();
|
|
46
51
|
|
|
47
|
-
|
|
52
|
+
// +said+: the asking reply when the caller knows it (a reloaded card: its
|
|
53
|
+
// step's text, "" for none); else the running turn's (askingText).
|
|
54
|
+
function renderQuestionCard(pq, { said = null } = {}) {
|
|
48
55
|
const action = nextCardAction({ cardId: questionEl?.dataset.qid ?? null, pendingId: pending?.id ?? null }, pq);
|
|
49
56
|
if (action === "same") return;
|
|
50
57
|
// A resolved card stays where it was; only a pending one is replaced.
|
|
@@ -60,6 +67,8 @@ export function createQuestionCards({
|
|
|
60
67
|
const card = doc.createElement("details");
|
|
61
68
|
card.className = approval ? "bubble question approval" : stepLimit ? "bubble question step-limit" : "bubble question";
|
|
62
69
|
card.dataset.qid = pq.id;
|
|
70
|
+
const asked = keepsAskingText(pq) ? String(said ?? askingText() ?? "").trim() : "";
|
|
71
|
+
if (asked) saidOf.set(card, asked);
|
|
63
72
|
// Open while pending: the question must be actionable.
|
|
64
73
|
card.open = true;
|
|
65
74
|
|
|
@@ -174,11 +183,41 @@ export function createQuestionCards({
|
|
|
174
183
|
});
|
|
175
184
|
card.appendChild(dismiss);
|
|
176
185
|
|
|
186
|
+
// Enter in the response box or on an option submits (never denies or
|
|
187
|
+
// dismisses: that stays a click).
|
|
188
|
+
card.addEventListener("keydown", (e) => {
|
|
189
|
+
if (!isPlainEnter(e) || submit.disabled) return;
|
|
190
|
+
const t = e.target;
|
|
191
|
+
if (t !== freeformInput && !t?.closest?.(".question-option")) return;
|
|
192
|
+
e.preventDefault();
|
|
193
|
+
submit.click();
|
|
194
|
+
});
|
|
195
|
+
|
|
177
196
|
// Read before the card makes the history taller.
|
|
178
197
|
const follow = isNearBottom();
|
|
179
198
|
place(card);
|
|
180
199
|
questionEl = card;
|
|
181
200
|
if (follow) scrollToEnd();
|
|
201
|
+
focusWhenIdle(card, pq.id);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isPlainEnter(e) {
|
|
205
|
+
return e.key === "Enter" && !e.isComposing && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// A new card takes the focus while nobody is typing: the focus is on
|
|
209
|
+
// the page or an empty composer. Checked after the caller is done, so a
|
|
210
|
+
// card drawn already answered (the history replay) never takes it.
|
|
211
|
+
function focusWhenIdle(card, id) {
|
|
212
|
+
queueMicrotask(() => {
|
|
213
|
+
if (questionEl !== card || String(pending?.id) !== String(id)) return;
|
|
214
|
+
const prompt = doc.getElementById?.("prompt");
|
|
215
|
+
const active = doc.activeElement;
|
|
216
|
+
const idle = !active || active === doc.body || (active === prompt && !prompt.value);
|
|
217
|
+
if (!idle || (prompt && prompt.value)) return;
|
|
218
|
+
const target = card.querySelector(".question-option input") || card.querySelector(".question-freeform");
|
|
219
|
+
target?.focus();
|
|
220
|
+
});
|
|
182
221
|
}
|
|
183
222
|
|
|
184
223
|
// The step-limit question's actions: a button per option (Continue,
|
|
@@ -263,7 +302,7 @@ export function createQuestionCards({
|
|
|
263
302
|
if (view.preview) {
|
|
264
303
|
const diff = doc.createElement("div");
|
|
265
304
|
diff.className = "approval-diff";
|
|
266
|
-
|
|
305
|
+
render(diffTemplate(view.preview), diff);
|
|
267
306
|
box.appendChild(diff);
|
|
268
307
|
}
|
|
269
308
|
for (const [cls, label, text] of [["approval-where", "in", view.where], ["approval-why", "why", view.why]]) {
|
|
@@ -358,6 +397,23 @@ export function createQuestionCards({
|
|
|
358
397
|
// unless the user opened or closed it by hand.
|
|
359
398
|
const summary = questionEl.querySelector("summary");
|
|
360
399
|
if (summary) summary.textContent = summaryText(pending, { answer, cancelled, reason });
|
|
400
|
+
// The reply that asked, kept with the card: what made the question
|
|
401
|
+
// answerable stays with it in the history. Muted under the "Q → A" line
|
|
402
|
+
// in two lines while the card is closed (a copy screen readers skip: not
|
|
403
|
+
// the toggle's label), and whole, selectable, at the top of the body
|
|
404
|
+
// when it is open (a drag in the summary would toggle the card).
|
|
405
|
+
const asked = saidOf.get(questionEl);
|
|
406
|
+
if (summary && asked) {
|
|
407
|
+
const line = doc.createElement("div");
|
|
408
|
+
line.className = "question-said";
|
|
409
|
+
line.setAttribute("aria-hidden", "true");
|
|
410
|
+
line.textContent = asked;
|
|
411
|
+
summary.appendChild(line);
|
|
412
|
+
const full = doc.createElement("div");
|
|
413
|
+
full.className = "question-said-full";
|
|
414
|
+
full.textContent = asked;
|
|
415
|
+
questionEl.insertBefore(full, summary.nextSibling);
|
|
416
|
+
}
|
|
361
417
|
// 4.02: a reopened card's summary already carries the result (the
|
|
362
418
|
// snapshot re-renders it resolved), and the body line repeats it. It
|
|
363
419
|
// stays for a hand-toggled card, whose expanded body the user looks at,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// A read row's output as the file's lines, numbered
|
|
1
|
+
// A read row's output as the file's lines, numbered (a lit-html template).
|
|
2
2
|
// Only when the output is the file text as-is: each numbered line is a
|
|
3
3
|
// ref's line (refs.js opens the row's file there). The row's preview
|
|
4
4
|
// (turn_html.js) strips any "[tag] " and cuts at 300 chars; numbering
|
|
5
5
|
// needs the text exactly as read, so it is drawn on its own, closed.
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { html, nothing } from "./vendor/lit-html.js";
|
|
8
8
|
|
|
9
9
|
// What follows one of these isn't the file's next line: a ToolRunner cap
|
|
10
10
|
// ("[cut: …]"), a head/tail preview's gap ("[... omitted …]") or the note
|
|
@@ -58,19 +58,20 @@ export function readLines(row) {
|
|
|
58
58
|
return out;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
// A read row's numbered lines behind a closed toggle ("lines 40–60");
|
|
62
|
-
// when its output isn't the file as-is.
|
|
63
|
-
|
|
61
|
+
// A read row's numbered lines behind a closed toggle ("lines 40–60");
|
|
62
|
+
// nothing when its output isn't the file as-is. lit never binds its open:
|
|
63
|
+
// a user's toggle stays across a redraw.
|
|
64
|
+
export function readLinesTemplate(row) {
|
|
64
65
|
const lines = readLines(row);
|
|
65
66
|
const numbered = lines?.filter((l) => l.line !== null) || [];
|
|
66
|
-
if (!numbered.length) return
|
|
67
|
+
if (!numbered.length) return nothing;
|
|
67
68
|
const first = numbered[0].line;
|
|
68
69
|
const last = numbered.at(-1).line;
|
|
69
70
|
const label = first === last ? `line ${first}` : `lines ${first}–${last}`;
|
|
70
71
|
// A blank line of the file holds no text, only a <br>: an empty block
|
|
71
72
|
// drops out of a copied selection, a space would be copied as one.
|
|
72
73
|
const spans = lines.map(({ text, line }) => line === null
|
|
73
|
-
? `<span class="read-line read-meta">${
|
|
74
|
-
: `<span class="read-line" data-line
|
|
75
|
-
return `<details class="activity-read"><summary>${label}</summary><pre class="read-lines"><code>${spans}</code></pre></details>`;
|
|
74
|
+
? html`<span class="read-line read-meta">${text}</span>`
|
|
75
|
+
: html`<span class="read-line" data-line=${line}>${text || html`<br>`}</span>`);
|
|
76
|
+
return html`<details class="activity-read"><summary>${label}</summary><pre class="read-lines"><code>${spans}</code></pre></details>`;
|
|
76
77
|
}
|
|
@@ -12,10 +12,12 @@ const HIDE_MS = 300;
|
|
|
12
12
|
const EDGE = 8;
|
|
13
13
|
// The ▾ sits in room its ref makes after itself while it shows
|
|
14
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.
|
|
15
|
+
// the next words of an answer, a row's duration. A ref that room would
|
|
16
|
+
// push onto the next line (it ends its line: nothing follows it there)
|
|
17
|
+
// makes none (data-ref-more="end"), and the ▾ goes just past its end.
|
|
16
18
|
|
|
17
19
|
// +doc+'s ▾ and menu. open(el, items, {focus}) shows +items+ ({id, label,
|
|
18
|
-
// run}) under +el+; showMore(el, onOpen) puts the ▾ after +el+, whose click
|
|
20
|
+
// hint?, run}; the hint is the item's hover: a URL action's URL) under +el+; showMore(el, onOpen) puts the ▾ after +el+, whose click
|
|
19
21
|
// calls +onOpen+; hideMoreSoon(el) takes it away unless the pointer reaches
|
|
20
22
|
// it; close({refocus}) closes the menu.
|
|
21
23
|
export function createRefMenu(doc = globalThis.document) {
|
|
@@ -56,12 +58,17 @@ export function createRefMenu(doc = globalThis.document) {
|
|
|
56
58
|
if (moreFor && moreFor !== el) delete moreFor.dataset.refMore;
|
|
57
59
|
moreFor = el;
|
|
58
60
|
onMore = onOpen;
|
|
59
|
-
el.dataset.refMore
|
|
61
|
+
const before = el.dataset.refMore === undefined ? lastRect(el) : null;
|
|
62
|
+
if (el.dataset.refMore === undefined) el.dataset.refMore = "";
|
|
63
|
+
let rect = lastRect(el);
|
|
64
|
+
if (before && wrapped(before, rect)) {
|
|
65
|
+
el.dataset.refMore = "end";
|
|
66
|
+
rect = lastRect(el);
|
|
67
|
+
}
|
|
60
68
|
button.hidden = false;
|
|
61
|
-
const rect = lastRect(el);
|
|
62
69
|
const width = button.offsetWidth || 16;
|
|
63
70
|
const top = rect.top + (rect.height - (button.offsetHeight || rect.height)) / 2;
|
|
64
|
-
const left = rect.right - width - 1;
|
|
71
|
+
const left = el.dataset.refMore === "end" ? rect.right + 2 : rect.right - width - 1;
|
|
65
72
|
button.style.left = `${Math.min(left, win.innerWidth - width - 2)}px`;
|
|
66
73
|
button.style.top = `${top}px`;
|
|
67
74
|
}
|
|
@@ -96,6 +103,7 @@ export function createRefMenu(doc = globalThis.document) {
|
|
|
96
103
|
button.tabIndex = -1;
|
|
97
104
|
button.dataset.target = item.id;
|
|
98
105
|
button.textContent = item.label;
|
|
106
|
+
if (item.hint) button.title = item.hint;
|
|
99
107
|
button.addEventListener("click", (e) => {
|
|
100
108
|
e.preventDefault();
|
|
101
109
|
e.stopPropagation();
|
|
@@ -190,6 +198,11 @@ export function createRefMenu(doc = globalThis.document) {
|
|
|
190
198
|
|
|
191
199
|
// The ref's last line box (a ref wrapped over two lines ends on the
|
|
192
200
|
// second); its whole box when it has none.
|
|
201
|
+
// The room moved the ref's last box onto another line.
|
|
202
|
+
function wrapped(before, after) {
|
|
203
|
+
return Math.abs(after.top - before.top) > before.height / 2;
|
|
204
|
+
}
|
|
205
|
+
|
|
193
206
|
function lastRect(el) {
|
|
194
207
|
const rects = el.getClientRects?.() || [];
|
|
195
208
|
return rects.length ? rects[rects.length - 1] : el.getBoundingClientRect();
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// bound to its element by bindRef: its label and its URL actions live in a
|
|
11
11
|
// WeakMap off the DOM, and refTarget reads them first. Its actions join the
|
|
12
12
|
// kind's own (the `actions` entry); a kind the page has no table for runs
|
|
13
|
-
// only those.
|
|
13
|
+
// only those. A plugin tool's row (its +refs+, bindRowRefs) puts its first
|
|
14
|
+
// ref on the title and the others in the title's menu (the `more` entry).
|
|
14
15
|
// A plain click runs the first one available (the main target); a ref
|
|
15
16
|
// with more shows a ▾ on hover that opens a menu of all of them
|
|
16
17
|
// (ref_menu.js), and a tap opens it. A ref with a target is a tab stop
|
|
@@ -74,20 +75,61 @@ export function httpUrl(url) {
|
|
|
74
75
|
// nothing. A rebind replaces the target attributes. +el+, or null without one.
|
|
75
76
|
export function bindRef(el, ref) {
|
|
76
77
|
if (!el) return null;
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
const clean = cleanRef(ref);
|
|
79
|
+
if (!clean) return el;
|
|
80
|
+
const { target } = clean;
|
|
79
81
|
el.classList.add("ref");
|
|
80
|
-
el.dataset.refKind =
|
|
82
|
+
el.dataset.refKind = clean.kind;
|
|
81
83
|
for (const [name, value] of [["refPath", target.path], ["refLine", target.line], ["refEndLine", target.end_line]]) {
|
|
82
84
|
if (value) el.dataset[name] = String(value);
|
|
83
85
|
else delete el.dataset[name];
|
|
84
86
|
}
|
|
87
|
+
REF_DATA.set(el, { ...clean, more: [] });
|
|
88
|
+
return el;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// +ref+ (the server's Ref#to_h) as the page keeps it: {kind, target,
|
|
92
|
+
// label, actions}, its actions only those with an id, a label and an
|
|
93
|
+
// http(s) URL; null for a kind the server's Ref refuses.
|
|
94
|
+
function cleanRef(ref) {
|
|
95
|
+
if (!refKindOk(ref?.kind)) return null;
|
|
96
|
+
const target = ref.target && typeof ref.target === "object" ? ref.target : {};
|
|
85
97
|
const actions = (Array.isArray(ref.actions) ? ref.actions : []).flatMap((action) => {
|
|
86
98
|
const url = httpUrl(action?.url);
|
|
87
99
|
return url && action.id && action.label ? [{ id: String(action.id), label: String(action.label), url }] : [];
|
|
88
100
|
});
|
|
89
|
-
|
|
90
|
-
|
|
101
|
+
return { kind: ref.kind, target, label: ref.label ? String(ref.label) : null, actions };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// A tool row's refs: a plugin tool's (the events' and the reloaded part's
|
|
105
|
+
// +refs+, the server's RowRefs: Ref#to_h each) that bindRef binds, after
|
|
106
|
+
// the row's own (a file tool's +ref+, as a Ref) when it has one.
|
|
107
|
+
function rowRefsOf(row) {
|
|
108
|
+
const own = row?.ref?.kind ? [{ kind: row.ref.kind, target: { path: row.ref.path, line: row.ref.line, end_line: row.ref.end_line } }] : [];
|
|
109
|
+
return [...own, ...(Array.isArray(row?.refs) ? row.refs : [])].map(cleanRef).filter(Boolean);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The ref a tool row's title carries in its markup (turn_html.js, refAttrs'
|
|
113
|
+
// shape: {kind, path?, line?, end_line?}): the row's own, else its first
|
|
114
|
+
// plugin ref; null for none.
|
|
115
|
+
export function rowTitleRef(row) {
|
|
116
|
+
const [first] = rowRefsOf(row);
|
|
117
|
+
return first ? { kind: first.kind, path: first.target.path, line: first.target.line, end_line: first.target.end_line } : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Binds a tool row's refs to its title (the element turn_html.js drew from
|
|
121
|
+
// +row+, live or reloaded; again after each redraw): the first as bindRef
|
|
122
|
+
// does, the others the title's menu offers (D8). Only for a row with a
|
|
123
|
+
// plugin ref: a file tool's row needs no binding (its attributes are its
|
|
124
|
+
// ref). The title, or null.
|
|
125
|
+
export function bindRowRefs(rowEl, row) {
|
|
126
|
+
const title = rowEl?.querySelector?.(":scope > .activity-params[data-ref-kind]");
|
|
127
|
+
if (!title || !Array.isArray(row?.refs) || !row.refs.length) return null;
|
|
128
|
+
const [first, ...more] = rowRefsOf(row);
|
|
129
|
+
if (!first) return null;
|
|
130
|
+
bindRef(title, first);
|
|
131
|
+
REF_DATA.get(title).more = more;
|
|
132
|
+
return title;
|
|
91
133
|
}
|
|
92
134
|
|
|
93
135
|
// The plugin ref bound to +el+ (bindRef's data), or null: a row title or
|
|
@@ -146,6 +188,51 @@ const URL_ACTIONS = Object.freeze({
|
|
|
146
188
|
})),
|
|
147
189
|
});
|
|
148
190
|
|
|
191
|
+
// A short name for a ref in a menu of several: its label, else its id,
|
|
192
|
+
// else its file's name (and line).
|
|
193
|
+
function refName(ref) {
|
|
194
|
+
if (ref.label) return ref.label;
|
|
195
|
+
if (ref.target.id != null) return String(ref.target.id);
|
|
196
|
+
const name = String(ref.target.path || "").split("/").pop();
|
|
197
|
+
return ref.target.line ? `${name}:${ref.target.line}` : name;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// A row's other refs (bindRowRefs: all but the first), after the title's
|
|
201
|
+
// own targets: each one's actions, named "<ref> · <action>", for every
|
|
202
|
+
// viewer, and a file's editor (where the editor opens). A URL the menu
|
|
203
|
+
// already has (the link's, an action's, an earlier one's) isn't repeated.
|
|
204
|
+
const MORE_REFS = Object.freeze({
|
|
205
|
+
id: "more",
|
|
206
|
+
expand(target) {
|
|
207
|
+
const seen = new Set([target.href, ...(target.actions || []).map((action) => action.url)].map(httpUrl).filter(Boolean));
|
|
208
|
+
return (target.more || []).flatMap((ref, at) => {
|
|
209
|
+
const name = refName(ref);
|
|
210
|
+
const items = [];
|
|
211
|
+
if (ref.kind === "file" && ref.target.path) {
|
|
212
|
+
items.push(Object.freeze({
|
|
213
|
+
id: `more:${at}:editor`,
|
|
214
|
+
label: () => `${name} · Open in editor`,
|
|
215
|
+
available: (viewer, target) => !target.isLine && viewer?.editor === true && !!viewer.editorUrl,
|
|
216
|
+
run: (_target, viewer, env) => env.open(editorUrl(viewer.editorUrl, ref.target.path, ref.target.line)),
|
|
217
|
+
hint: () => `Open the current file in your editor${ref.target.line ? ` at line ${ref.target.line}` : ""}`,
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
for (const action of ref.actions) {
|
|
221
|
+
if (seen.has(action.url)) continue;
|
|
222
|
+
seen.add(action.url);
|
|
223
|
+
items.push(Object.freeze({
|
|
224
|
+
id: `more:${at}:${action.id}`,
|
|
225
|
+
label: () => `${name} · ${action.label}`,
|
|
226
|
+
available: (_viewer, target) => !target.isLine,
|
|
227
|
+
run: (_target, _viewer, env) => env.openLink(action.url),
|
|
228
|
+
hint: () => action.url,
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
return items;
|
|
232
|
+
});
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
|
|
149
236
|
export const REF_ACTIONS = Object.freeze({
|
|
150
237
|
file: Object.freeze([
|
|
151
238
|
LINK,
|
|
@@ -171,6 +258,7 @@ export const REF_ACTIONS = Object.freeze({
|
|
|
171
258
|
run: (target, _viewer, env) => env.show(target),
|
|
172
259
|
hint: () => "Show the row that touched it in the turn",
|
|
173
260
|
}),
|
|
261
|
+
MORE_REFS,
|
|
174
262
|
Object.freeze({
|
|
175
263
|
id: "copy",
|
|
176
264
|
label: () => "Copy path",
|
|
@@ -179,8 +267,9 @@ export const REF_ACTIONS = Object.freeze({
|
|
|
179
267
|
run: (target, _viewer, env) => env.copy(target.path),
|
|
180
268
|
}),
|
|
181
269
|
]),
|
|
182
|
-
// Any other kind (a plugin's issue, commit, …): its link, then its
|
|
183
|
-
|
|
270
|
+
// Any other kind (a plugin's issue, commit, …): its link, then its
|
|
271
|
+
// actions, then a row's other refs.
|
|
272
|
+
"*": Object.freeze([LINK, URL_ACTIONS, MORE_REFS]),
|
|
184
273
|
});
|
|
185
274
|
|
|
186
275
|
// The targets +viewer+ has for +target+ (refTarget's), in order; none
|
|
@@ -201,8 +290,9 @@ const OWNERS = ".activity-row, .bubble.approval";
|
|
|
201
290
|
// A line of the owner's file: a diff line, or a read row's numbered line.
|
|
202
291
|
const LINES = ".diff-line[data-line], .read-line[data-line]";
|
|
203
292
|
|
|
204
|
-
// What a click on +el+ points at: {kind, path, id, label, actions,
|
|
205
|
-
// owner, el, isLine, href} or null (+id+, +label
|
|
293
|
+
// What a click on +el+ points at: {kind, path, id, label, actions, more,
|
|
294
|
+
// line, owner, el, isLine, href} or null (+id+, +label+, +actions+ and
|
|
295
|
+
// +more+ (a row's other refs, bindRowRefs) a plugin's
|
|
206
296
|
// ref's, bindRef: the WeakMap is read first). A row title (or an approval card's path) opens its file
|
|
207
297
|
// at the lines the call named (an edit without any at its first changed
|
|
208
298
|
// line); a diff line (or a hunk header) or a read row's numbered line, in
|
|
@@ -226,11 +316,12 @@ export function refTarget(el) {
|
|
|
226
316
|
|
|
227
317
|
function targetOf(el) {
|
|
228
318
|
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: [] };
|
|
319
|
+
if (!data) return { kind: el.dataset.refKind, path: el.dataset.refPath || "", line: Number(el.dataset.refLine) || null, id: null, label: null, actions: [], more: [] };
|
|
230
320
|
// Bound data is the whole ref: no fallback to the attributes.
|
|
231
321
|
const { target } = data;
|
|
232
322
|
return {
|
|
233
323
|
kind: data.kind, path: target.path || "", line: Number(target.line) || null, id: target.id ?? null, label: data.label, actions: data.actions,
|
|
324
|
+
more: data.more,
|
|
234
325
|
};
|
|
235
326
|
}
|
|
236
327
|
|
|
@@ -303,19 +394,24 @@ function lineIn(row, line) {
|
|
|
303
394
|
// (the turn's steps, its step), its diff (or the read that shows the
|
|
304
395
|
// line), scrolls to the line (else the row) and flashes both. A view that
|
|
305
396
|
// hides its rows its own way (the stage's cloud) opens on the row's
|
|
306
|
-
// ref:reveal event, which bubbles
|
|
397
|
+
// ref:reveal event, which bubbles; a view that grows for it first sets the
|
|
398
|
+
// event's detail.until (a promise), and the scroll waits for it. The row,
|
|
399
|
+
// or null when there is none.
|
|
307
400
|
export function showInTurn(target, { behavior = smoothScroll() } = {}) {
|
|
308
401
|
const row = turnRowFor(target);
|
|
309
402
|
if (!row) return null;
|
|
310
403
|
const view = row.ownerDocument.defaultView || globalThis;
|
|
311
|
-
|
|
404
|
+
const reveal = new view.CustomEvent("ref:reveal", { bubbles: true, detail: {} });
|
|
405
|
+
row.dispatchEvent(reveal);
|
|
312
406
|
const line = target.line ? lineIn(row, target.line) : null;
|
|
313
407
|
const fold = line?.closest("details.activity-diff, details.activity-read") || row.querySelector("details.activity-diff");
|
|
314
408
|
for (let details = (line || fold || row).closest("details"); details; details = details.parentElement?.closest("details")) {
|
|
315
409
|
details.open = true;
|
|
316
410
|
}
|
|
317
411
|
if (fold) fold.open = true;
|
|
318
|
-
(line || row).scrollIntoView?.({ block: "center", behavior });
|
|
412
|
+
const scroll = () => (line || row).scrollIntoView?.({ block: "center", behavior });
|
|
413
|
+
if (reveal.detail?.until) reveal.detail.until.then(scroll);
|
|
414
|
+
else scroll();
|
|
319
415
|
for (const el of [row, line].filter(Boolean)) flash(el);
|
|
320
416
|
return row;
|
|
321
417
|
}
|
|
@@ -365,7 +461,9 @@ export function installRefClicks(root, viewer, {
|
|
|
365
461
|
} = {}) {
|
|
366
462
|
const env = { open, openLink, show, copy };
|
|
367
463
|
const run = (action, target) => action.run(target, viewer(), env);
|
|
368
|
-
const items = (actions, target) => actions.map((action) => ({
|
|
464
|
+
const items = (actions, target) => actions.map((action) => ({
|
|
465
|
+
id: action.id, label: action.label(target), hint: action.hint?.(target) || "", run: () => run(action, target),
|
|
466
|
+
}));
|
|
369
467
|
const openMenu = (target, actions, options) => menu.open(target.el, items(actions, target), options);
|
|
370
468
|
// The last pointer's type: a tap opens the menu, a mouse click runs the
|
|
371
469
|
// main target.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The page's scope: ?dir=<folder> shows that folder's project (`chi web` in
|
|
2
2
|
// a repo opens it so); no dir shows every session. The hash routes
|
|
3
3
|
// (#/s/<id>, #/sessions) work in either.
|
|
4
|
+
import { html, nothing } from "./vendor/lit-html.js";
|
|
4
5
|
|
|
5
6
|
// The ?dir= folder in a location.search string, or null.
|
|
6
7
|
export function scopeDir(search) {
|
|
@@ -43,3 +44,25 @@ export function cardFolder(workingDirectory, projectName) {
|
|
|
43
44
|
if (!folder) return "";
|
|
44
45
|
return folder === projectName ? "" : folder;
|
|
45
46
|
}
|
|
47
|
+
|
|
48
|
+
// The header's scope chip: "<project> · all" in a project view (all links
|
|
49
|
+
// to the same place without ?dir); "all projects" otherwise, with a link
|
|
50
|
+
// back to the project it came from and where a new chat starts on the
|
|
51
|
+
// start page. +scope+: { dir, projectName, projectDir, serverDir, backDir,
|
|
52
|
+
// backName }; +error+: why the ?dir= folder can't be shown; +where+:
|
|
53
|
+
// () => { hash, search } (the page's location, read again at a click: the
|
|
54
|
+
// links follow an open session or the all-sessions view).
|
|
55
|
+
export function scopeTemplate(scope, { error = null, where = () => ({ hash: "", search: "" }) } = {}) {
|
|
56
|
+
const at = where();
|
|
57
|
+
const allHref = () => allScopeHref(where().hash, scope.dir, where().search);
|
|
58
|
+
const all = html`<a class="scope-all" href=${allScopeHref(at.hash, scope.dir, at.search)} title=${"Sessions of every project"} @click=${(e) => { e.currentTarget.href = allHref(); }}>all</a>`;
|
|
59
|
+
if (error) return html`<span class="scope-name error" title=${error}>folder not found</span>${all}`;
|
|
60
|
+
if (scope.projectName) return html`<span class="scope-name" title=${`Sessions of ${scope.projectDir}`}>${scope.projectName}</span>${all}`;
|
|
61
|
+
const dir = scope.dir || scope.serverDir;
|
|
62
|
+
const backHref = () => projectScopeHref(scope.backDir, where().hash, where().search);
|
|
63
|
+
const back = scope.backName
|
|
64
|
+
? html`<a class="scope-all scope-back" href=${projectScopeHref(scope.backDir, at.hash, at.search)} title=${`Sessions of ${scope.backName} only`} @click=${(e) => { e.currentTarget.href = backHref(); }}>← ${scope.backName}</a>`
|
|
65
|
+
: nothing;
|
|
66
|
+
const newChat = dir ? html`<span class="scope-where" title=${`New chats start in ${dir}`}>new chat in<span class="path"><bdi>${dir}</bdi></span></span>` : nothing;
|
|
67
|
+
return html`<span class="scope-name" title="Sessions of every project">all projects</span>${back}${newChat}`;
|
|
68
|
+
}
|
|
@@ -22,3 +22,45 @@ export function keepFollowing(el, mutate, thresholdPx = SCROLL_FOLLOW_PX) {
|
|
|
22
22
|
if (follow) el.scrollTop = el.scrollHeight;
|
|
23
23
|
return result;
|
|
24
24
|
}
|
|
25
|
+
|
|
26
|
+
// A follow that eases instead of jumping: +el+'s scrollTop goes to +top+
|
|
27
|
+
// over +ms+ (ease-out), +el+ marked data-following meanwhile (a layout
|
|
28
|
+
// watcher reads that as a follow, not a jump). +top+ may be a function,
|
|
29
|
+
// read each frame (a target that moves on: the history's end while its
|
|
30
|
+
// bottom reserve eases). With +reduce+ (prefers-reduced-motion) it jumps.
|
|
31
|
+
// A newer call on the same element takes over. Resolves when it is there.
|
|
32
|
+
export const FOLLOW_MS = 180;
|
|
33
|
+
const easing = new WeakMap();
|
|
34
|
+
export function easeScrollTo(el, top, { ms = FOLLOW_MS, reduce = false, raf = (fn) => requestAnimationFrame(fn), now = () => performance.now() } = {}) {
|
|
35
|
+
const target = () => (typeof top === "function" ? top() : top);
|
|
36
|
+
easing.get(el)?.stop();
|
|
37
|
+
if (reduce || ms <= 0) {
|
|
38
|
+
el.scrollTop = target();
|
|
39
|
+
return Promise.resolve();
|
|
40
|
+
}
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
const from = el.scrollTop;
|
|
43
|
+
const start = now();
|
|
44
|
+
let done = false;
|
|
45
|
+
const run = { stop: () => finish() };
|
|
46
|
+
function finish() {
|
|
47
|
+
if (done) return;
|
|
48
|
+
done = true;
|
|
49
|
+
if (easing.get(el) === run) {
|
|
50
|
+
easing.delete(el);
|
|
51
|
+
delete el.dataset.following;
|
|
52
|
+
}
|
|
53
|
+
resolve();
|
|
54
|
+
}
|
|
55
|
+
easing.set(el, run);
|
|
56
|
+
el.dataset.following = "";
|
|
57
|
+
const step = () => {
|
|
58
|
+
if (done) return;
|
|
59
|
+
const t = Math.min(1, (now() - start) / ms);
|
|
60
|
+
el.scrollTop = from + (target() - from) * (1 - (1 - t) ** 3);
|
|
61
|
+
if (t < 1) raf(step);
|
|
62
|
+
else finish();
|
|
63
|
+
};
|
|
64
|
+
raf(step);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -6,6 +6,7 @@ import { toolName } from "./activity.js";
|
|
|
6
6
|
import { splitSentences } from "./sentences.js";
|
|
7
7
|
import { steerSender } from "./format.js";
|
|
8
8
|
import { hookNoticeLabel } from "./turn_events.js";
|
|
9
|
+
import { askingText } from "./turn_model.js";
|
|
9
10
|
|
|
10
11
|
const READ_LIKE = new Set(["read", "memory_read", "web_fetch", "task_get", "task_list", "list_sessions"]);
|
|
11
12
|
const EDITS = new Set(["edit", "write", "memory_write"]);
|
|
@@ -150,11 +151,15 @@ const NO_ANSWER = "no answer";
|
|
|
150
151
|
// ended (nothing happens now);
|
|
151
152
|
// trail the last three finished calls, newest last (a file's title
|
|
152
153
|
// without its dir; command as for tool);
|
|
153
|
-
// ticks one per call: {kind, status, step (the gen's index)}
|
|
154
|
-
//
|
|
154
|
+
// ticks one per call: {kind, status, step (the gen's index)};
|
|
155
|
+
// asking the whole text of the reply whose call a card blocks
|
|
156
|
+
// (turn_model.js askingText), "" when none is blocked.
|
|
157
|
+
// +pending+: a card waits for the user; +blocked+: a card that blocks the
|
|
158
|
+
// turn (a question, an approval; not an action card) waits; +ended+: the
|
|
159
|
+
// turn's end kind;
|
|
155
160
|
// +emptyAnswer+: a completed turn ended with no answer
|
|
156
161
|
// (turn_summary.empty_answer), so it reads "no answer", not "answered".
|
|
157
|
-
export function liveSlots(turn, { narration = "", thinking = "", pending = false, ended = null, emptyAnswer = false } = {}) {
|
|
162
|
+
export function liveSlots(turn, { narration = "", thinking = "", pending = false, blocked = false, ended = null, emptyAnswer = false } = {}) {
|
|
158
163
|
const gens = turn?.gens || [];
|
|
159
164
|
const rows = gens.flatMap((gen, step) => gen.tools.map((row) => ({ row, step })));
|
|
160
165
|
const running = [...rows].reverse().find(({ row }) => row.status === "running")?.row || null;
|
|
@@ -182,9 +187,23 @@ export function liveSlots(turn, { narration = "", thinking = "", pending = false
|
|
|
182
187
|
tool: running ? withTask(withCommand({ name: toolName(running), title: rowTitle(running), kind: toolKind(running.tool) }, running), running) : null,
|
|
183
188
|
trail,
|
|
184
189
|
ticks: rows.map(({ row, step }) => ({ kind: toolKind(row.tool), status: row.status, step })),
|
|
190
|
+
asking: blocked && !ended ? askingText(turn) : "",
|
|
185
191
|
};
|
|
186
192
|
}
|
|
187
193
|
|
|
194
|
+
// The stage's frame height (px) while it holds a turn: grow-only, so a
|
|
195
|
+
// content change inside it (the plain flip, an answered card, a short
|
|
196
|
+
// answer) never moves its top edge. +natural+: its content's own height;
|
|
197
|
+
// +held+: the height it holds (null: none yet); +cap+: --stage-max. A lower
|
|
198
|
+
// cap (a phone's keyboard) clamps it at once and the clamp is the new held
|
|
199
|
+
// height: a cap that rises again doesn't bring the old one back, only new
|
|
200
|
+
// content grows it. null: no frame (not holding a turn, or collapsed to
|
|
201
|
+
// its status row): the content's own height.
|
|
202
|
+
export function frameTarget({ natural, held, cap, holding, collapsed }) {
|
|
203
|
+
if (!holding || collapsed) return null;
|
|
204
|
+
return Math.min(Math.max(natural, held ?? 0), cap);
|
|
205
|
+
}
|
|
206
|
+
|
|
188
207
|
// In use: the pointer over the stage, focus or a selection in it, or a
|
|
189
208
|
// touch/wheel/scroll in it within TOUCH_MS.
|
|
190
209
|
export function inUseFrom({ pointerIn, lastTouchMs, focusInside, selectionInside, nowMs }) {
|