samagotchi 0.62.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 +33 -1
- data/README.md +4 -1
- data/bin/chi +3 -2
- data/docs/cli.md +4 -1
- data/docs/configuration.md +1 -1
- data/docs/context.md +13 -3
- data/docs/hooks.md +6 -3
- data/docs/memory.md +1 -1
- data/docs/plugins.md +89 -2
- data/docs/releasing.md +1 -1
- data/docs/sub-agent.md +6 -0
- data/docs/testing.md +7 -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/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
- 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/idle_recap.rb +2 -3
- data/lib/samagotchi/image_store.rb +8 -0
- data/lib/samagotchi/plugin/attached_context.rb +7 -4
- data/lib/samagotchi/recap_setup.rb +3 -3
- data/lib/samagotchi/row_refs.rb +3 -4
- data/lib/samagotchi/session.rb +12 -0
- 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.rb +31 -11
- data/lib/samagotchi/tools/execute.rb +22 -4
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +6 -4
- data/lib/samagotchi/web/public/activity.js +2 -2
- data/lib/samagotchi/web/public/app.js +47 -91
- data/lib/samagotchi/web/public/card.js +73 -23
- data/lib/samagotchi/web/public/chains.js +6 -4
- data/lib/samagotchi/web/public/command_view.js +39 -35
- data/lib/samagotchi/web/public/copy.js +7 -15
- data/lib/samagotchi/web/public/diff_view.js +20 -23
- data/lib/samagotchi/web/public/format.js +0 -48
- data/lib/samagotchi/web/public/images.js +12 -16
- data/lib/samagotchi/web/public/question_cards.js +3 -2
- data/lib/samagotchi/web/public/read_view.js +10 -9
- data/lib/samagotchi/web/public/scope.js +23 -0
- data/lib/samagotchi/web/public/timing.js +0 -7
- data/lib/samagotchi/web/public/turn_html.js +119 -59
- data/lib/samagotchi/web/public/turn_view.js +73 -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 +46 -30
- data/lib/samagotchi/worker_inbound.rb +12 -11
- metadata +8 -1
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
// every path that fills it (a render, a live turn in either view, the
|
|
11
11
|
// turn-end markdown) gets them without knowing about them.
|
|
12
12
|
|
|
13
|
+
import { html, render } from "./vendor/lit-html.js";
|
|
14
|
+
|
|
13
15
|
export const COPY_SOURCE_ATTR = "data-copy-source";
|
|
14
16
|
|
|
15
17
|
// +text+ to the clipboard; true when it got there. Without the Clipboard
|
|
@@ -73,26 +75,16 @@ export function showsAnswer(bubble, message, normalize) {
|
|
|
73
75
|
return normalize(bubble.textContent) === normalize(message.content);
|
|
74
76
|
}
|
|
75
77
|
|
|
76
|
-
|
|
77
|
-
// rendered html and a prompt (its `>` lines render as quotes) need it; a
|
|
78
|
-
// plain answer's text is its source.
|
|
79
|
-
export function copySourceAttr(message, renderedMarkdown) {
|
|
80
|
-
if (!renderedMarkdown && message?.role !== "user") return "";
|
|
81
|
-
const text = copySource(message)
|
|
82
|
-
.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
83
|
-
return ` ${COPY_SOURCE_ATTR}="${text}"`;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M10.5 3.5v-.5A1.5 1.5 0 0 0 9 1.5H3A1.5 1.5 0 0 0 1.5 3v6A1.5 1.5 0 0 0 3 10.5h.5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>';
|
|
78
|
+
const ICON = html`<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M10.5 3.5v-.5A1.5 1.5 0 0 0 9 1.5H3A1.5 1.5 0 0 0 1.5 3v6A1.5 1.5 0 0 0 3 10.5h.5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>`;
|
|
87
79
|
|
|
88
80
|
const BUBBLES = ".bubble.output:not(.streaming), .bubble.user";
|
|
89
81
|
const CODE = ".bubble.output pre";
|
|
90
82
|
|
|
91
|
-
// The button as
|
|
83
|
+
// The button as a lit template, for a templated part (a tool row's
|
|
92
84
|
// command block, command_view.js); the same click handler answers it.
|
|
93
|
-
export function
|
|
85
|
+
export function copyButtonTemplate(kind) {
|
|
94
86
|
const title = kind === "code" ? "Copy code" : "Copy";
|
|
95
|
-
return `<button type="button" class
|
|
87
|
+
return html`<button type="button" class=${`copy-btn copy-${kind}`} title=${title} aria-label=${title}>${ICON}</button>`;
|
|
96
88
|
}
|
|
97
89
|
|
|
98
90
|
function makeButton(doc, kind) {
|
|
@@ -101,7 +93,7 @@ function makeButton(doc, kind) {
|
|
|
101
93
|
btn.className = `copy-btn copy-${kind}`;
|
|
102
94
|
btn.title = kind === "code" ? "Copy code" : "Copy";
|
|
103
95
|
btn.setAttribute("aria-label", btn.title);
|
|
104
|
-
btn
|
|
96
|
+
render(ICON, btn);
|
|
105
97
|
return btn;
|
|
106
98
|
}
|
|
107
99
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// What chi's edit/write tools change, as
|
|
1
|
+
// What chi's edit/write tools change, as lit-html templates. One
|
|
2
2
|
// renderer for both places: the approval card (the dry run, which can also
|
|
3
3
|
// say the edit would fail, or that the file isn't shown) and a tool row's
|
|
4
4
|
// collapsed "diff +3 −1" (what the call really changed). A diff is the
|
|
5
5
|
// server's {text, added, removed, truncated, new_file} (EditPreview).
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { html, nothing } from "./vendor/lit-html.js";
|
|
8
8
|
|
|
9
9
|
// More lines than this fold behind a "show all N lines" toggle.
|
|
10
10
|
export const DIFF_FOLD_LINES = 20;
|
|
@@ -51,34 +51,31 @@ export function diffLineNumbers(lines) {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
// A line with a number carries it as data-line (refs.js opens it there).
|
|
54
|
-
function
|
|
55
|
-
return lines.map((line, i) =>
|
|
56
|
-
|
|
57
|
-
return `<span class="diff-line diff-${diffLineClass(line)}"${at}>${escapeHtml(line) || " "}</span>`;
|
|
58
|
-
}).join("");
|
|
54
|
+
function linesTemplate(lines, numbers) {
|
|
55
|
+
return lines.map((line, i) =>
|
|
56
|
+
html`<span class=${`diff-line diff-${diffLineClass(line)}`} data-line=${numbers[i] > 0 ? numbers[i] : nothing}>${line || " "}</span>`);
|
|
59
57
|
}
|
|
60
58
|
|
|
61
59
|
// @param diff an EditPreview: a diff, or {error} / {skipped}
|
|
62
|
-
// @return
|
|
63
|
-
export function
|
|
64
|
-
if (!diff || typeof diff !== "object") return
|
|
65
|
-
if (diff.error) return `<div class="diff-note warn">this edit would fail: ${
|
|
66
|
-
if (diff.skipped) return `<div class="diff-note">diff not shown: ${
|
|
60
|
+
// @return a lit template; nothing for none
|
|
61
|
+
export function diffTemplate(diff, { fold = DIFF_FOLD_LINES } = {}) {
|
|
62
|
+
if (!diff || typeof diff !== "object") return nothing;
|
|
63
|
+
if (diff.error) return html`<div class="diff-note warn">this edit would fail: ${diff.error}</div>`;
|
|
64
|
+
if (diff.skipped) return html`<div class="diff-note">diff not shown: ${diff.skipped}</div>`;
|
|
67
65
|
const text = String(diff.text ?? "");
|
|
68
|
-
if (!text) return `<div class="diff-note">no change</div>`;
|
|
66
|
+
if (!text) return html`<div class="diff-note">no change</div>`;
|
|
69
67
|
const lines = text.split("\n");
|
|
70
|
-
const head = diff.new_file ? `<div class="diff-note">new file</div>` :
|
|
68
|
+
const head = diff.new_file ? html`<div class="diff-note">new file</div>` : nothing;
|
|
71
69
|
const numbers = diffLineNumbers(lines);
|
|
72
|
-
const shown = `<pre class="diff"><code>${
|
|
73
|
-
if (lines.length <= fold) return head
|
|
70
|
+
const shown = html`<pre class="diff"><code>${linesTemplate(lines.slice(0, fold), numbers.slice(0, fold))}</code></pre>`;
|
|
71
|
+
if (lines.length <= fold) return html`${head}${shown}`;
|
|
74
72
|
// The rest opens under the first lines; the toggle hides once open (CSS).
|
|
75
|
-
|
|
76
|
-
`<pre class="diff"><code>${linesHtml(lines.slice(fold), numbers.slice(fold))}</code></pre></details>`;
|
|
77
|
-
return head + shown + rest;
|
|
73
|
+
return html`${head}${shown}<details class="diff-more"><summary>show all ${lines.length} lines</summary><pre class="diff"><code>${linesTemplate(lines.slice(fold), numbers.slice(fold))}</code></pre></details>`;
|
|
78
74
|
}
|
|
79
75
|
|
|
80
|
-
// A tool row's collapsed diff (closed by default
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
76
|
+
// A tool row's collapsed diff (closed by default: lit never binds its
|
|
77
|
+
// open, so a user's toggle stays across a redraw).
|
|
78
|
+
export function activityDiffTemplate(diff) {
|
|
79
|
+
if (!diff || typeof diff !== "object" || diff.error || diff.skipped) return nothing;
|
|
80
|
+
return html`<details class="activity-diff"><summary>${diffSummary(diff)}</summary>${diffTemplate(diff)}</details>`;
|
|
84
81
|
}
|
|
@@ -10,21 +10,6 @@ export function escapeHtml(value) {
|
|
|
10
10
|
);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export function messageBodyHtml(message) {
|
|
14
|
-
if (message?.role === "assistant" && typeof message.html === "string") {
|
|
15
|
-
return { body: message.html, renderedMarkdown: true };
|
|
16
|
-
}
|
|
17
|
-
if (message?.role === "user") return { body: userBodyHtml(message.content), renderedMarkdown: false };
|
|
18
|
-
return { body: escapeHtml(message?.content), renderedMarkdown: false };
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// A context note (background the user or another session pushed in; the
|
|
22
|
-
// model reads it, nobody asked it anything): who sent it, then its text.
|
|
23
|
-
export function noteHtml(note) {
|
|
24
|
-
const from = note?.label ? `note from ${note.label}` : "note";
|
|
25
|
-
return `<div class="note-line">${escapeHtml(from)}</div><div class="note-text">${escapeHtml(note?.content ?? "")}</div>`;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
13
|
// Who a steer names as its sender: a plugin's own label, chi's sender ids
|
|
29
14
|
// in words (the TUI's Formatting#steer_sender agrees;
|
|
30
15
|
// spec/shared/labels_matrix.json). "": no one named.
|
|
@@ -41,39 +26,6 @@ export function sourceLabel(source) {
|
|
|
41
26
|
return source === "delegate_report" ? steerSender(source) : null;
|
|
42
27
|
}
|
|
43
28
|
|
|
44
|
-
// A plugin's steer (a nudge put into the running turn): one line that
|
|
45
|
-
// names who nudged the model, its text collapsed under it. +bubble+: on its
|
|
46
|
-
// own between bubbles (no step holds it), not a row of a step.
|
|
47
|
-
export function steerRowHtml({ source, text } = {}, { bubble = false } = {}) {
|
|
48
|
-
const sender = steerSender(source);
|
|
49
|
-
const who = sender ? `${sender} nudged the model` : "nudged the model";
|
|
50
|
-
return `<details class="${bubble ? "bubble " : ""}steer-row"><summary>${escapeHtml(who)}</summary>` +
|
|
51
|
-
`<div class="steer-text">${escapeHtml(text ?? "")}</div></details>`;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// A user message's body: runs of `>` lines become <blockquote>s, the rest
|
|
55
|
-
// stays escaped text. The bubble is pre-wrap, so the blank lines around a
|
|
56
|
-
// quote are dropped (the blockquote's margin spaces it instead).
|
|
57
|
-
export function userBodyHtml(content) {
|
|
58
|
-
const text = String(content ?? "").replace(/\r\n?/g, "\n");
|
|
59
|
-
const blocks = [];
|
|
60
|
-
for (const line of text.split("\n")) {
|
|
61
|
-
const m = /^ {0,3}> ?(.*)$/.exec(line);
|
|
62
|
-
const kind = m ? "quote" : "text";
|
|
63
|
-
const last = blocks[blocks.length - 1];
|
|
64
|
-
if (last?.kind === kind) last.lines.push(m ? m[1] : line);
|
|
65
|
-
else blocks.push({ kind, lines: [m ? m[1] : line] });
|
|
66
|
-
}
|
|
67
|
-
if (!blocks.some((b) => b.kind === "quote")) return escapeHtml(content ?? "");
|
|
68
|
-
return blocks
|
|
69
|
-
.map((b) => {
|
|
70
|
-
if (b.kind === "quote") return `<blockquote>${escapeHtml(b.lines.join("\n"))}</blockquote>`;
|
|
71
|
-
const body = b.lines.join("\n").replace(/^\s*\n/, "").replace(/\n\s*$/, "");
|
|
72
|
-
return body.trim() ? escapeHtml(body) : "";
|
|
73
|
-
})
|
|
74
|
-
.join("");
|
|
75
|
-
}
|
|
76
|
-
|
|
77
29
|
// The piece to append to a streamed body that holds +sofar+: while the body
|
|
78
30
|
// is empty its leading whitespace is dropped (a model's thinking starts with
|
|
79
31
|
// the "\n" after <think>, which would render as an empty first line).
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Images in the web UI: pasted or dropped into the composer as chips, sent
|
|
2
2
|
// with a turn as refs (uploaded first), shown as thumbnails in the bubbles
|
|
3
3
|
// and tool rows, opened full size in a lightbox. No DOM here beyond what the
|
|
4
|
-
// callers pass, so it runs under `node --test`.
|
|
5
|
-
import {
|
|
4
|
+
// callers pass (the templates are lit-html's), so it runs under `node --test`.
|
|
5
|
+
import { html, nothing } from "./vendor/lit-html.js";
|
|
6
6
|
|
|
7
7
|
// The image files a paste's or drop's DataTransfer carries.
|
|
8
8
|
export function imageFiles(dataTransfer) {
|
|
@@ -40,29 +40,25 @@ export function imageUrl(sessionId, ref) {
|
|
|
40
40
|
.join("/")}`;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
// Thumbnails for a bubble or a tool row;
|
|
44
|
-
// (a blob: URL while the upload is on its way).
|
|
45
|
-
|
|
43
|
+
// Thumbnails for a bubble or a tool row; nothing for none. `src` may be
|
|
44
|
+
// given (a blob: URL while the upload is on its way). A redraw with the
|
|
45
|
+
// same images keeps the <img> elements (no reload).
|
|
46
|
+
export function thumbsTemplate(sessionId, images) {
|
|
46
47
|
const list = (images || []).filter((i) => i && (i.src || i.file));
|
|
47
|
-
if (!list.length) return
|
|
48
|
+
if (!list.length) return nothing;
|
|
48
49
|
const thumbs = list.map((img) => {
|
|
49
50
|
const src = img.src || imageUrl(sessionId, img);
|
|
50
51
|
const size = img.width && img.height ? ` ${img.width}×${img.height}` : "";
|
|
51
52
|
const title = `${img.name || "image"}${size}`;
|
|
52
|
-
return `<img class="thumb" src
|
|
53
|
+
return html`<img class="thumb" src=${src} alt=${title} title=${title} loading=${"lazy"}>`;
|
|
53
54
|
});
|
|
54
|
-
return `<div class="thumbs">${thumbs
|
|
55
|
+
return html`<div class="thumbs">${thumbs}</div>`;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
// The composer's chips: a thumbnail, the name and a remove button each.
|
|
58
|
-
export function
|
|
59
|
-
return (chips || [])
|
|
60
|
-
.
|
|
61
|
-
(c, i) =>
|
|
62
|
-
`<span class="chip" data-index="${i}"><img src="${escapeHtml(c.src)}" alt=""><span class="chip-name">${escapeHtml(c.name)}</span>` +
|
|
63
|
-
`<button type="button" class="chip-remove" data-index="${i}" aria-label="remove ${escapeHtml(c.name)}">×</button></span>`,
|
|
64
|
-
)
|
|
65
|
-
.join("");
|
|
59
|
+
export function chipsTemplate(chips) {
|
|
60
|
+
return (chips || []).map((c, i) =>
|
|
61
|
+
html`<span class="chip" data-index=${i}><img src=${c.src} alt=""><span class="chip-name">${c.name}</span><button type="button" class="chip-remove" data-index=${i} aria-label=${`remove ${c.name}`}>×</button></span>`);
|
|
66
62
|
}
|
|
67
63
|
|
|
68
64
|
// The {file, name} refs a turn names.
|
|
@@ -13,7 +13,8 @@
|
|
|
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
19
|
answerErrorText, approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, deniedReason, isContinue, keepsAskingText,
|
|
19
20
|
nextCardAction, relayedToView, resultText, summaryText,
|
|
@@ -301,7 +302,7 @@ export function createQuestionCards({
|
|
|
301
302
|
if (view.preview) {
|
|
302
303
|
const diff = doc.createElement("div");
|
|
303
304
|
diff.className = "approval-diff";
|
|
304
|
-
|
|
305
|
+
render(diffTemplate(view.preview), diff);
|
|
305
306
|
box.appendChild(diff);
|
|
306
307
|
}
|
|
307
308
|
for (const [cls, label, text] of [["approval-where", "in", view.where], ["approval-why", "why", view.why]]) {
|
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -49,13 +49,6 @@ export function cancelLineText(reason, by) {
|
|
|
49
49
|
return `${STOP_MARK} canceled` + (reason ? ` (${CANCEL_REASONS[reason] || reason})` : "");
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
// A re-rendered turn's cancel line (a .bubble.cancel.stopped), or "" when
|
|
53
|
-
// the turn record is not a canceled one.
|
|
54
|
-
export function cancelLineHtml(record, escape) {
|
|
55
|
-
if (record?.status !== "canceled") return "";
|
|
56
|
-
return `<div class="${STOPPED_LINE_CLASS}">${escape(cancelLineText(record.cancellation_reason, record.cancelled_by))}</div>`;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
52
|
export function elapsedSince(startedAt, now = Date.now()) {
|
|
60
53
|
const started = Date.parse(startedAt);
|
|
61
54
|
const current = Number(now);
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
// The turn's pieces as
|
|
2
|
-
// a tool row, a prompt bubble
|
|
3
|
-
//
|
|
4
|
-
// (
|
|
1
|
+
// The turn's pieces as lit-html templates, one for the live page and a
|
|
2
|
+
// reload: a tool row, a prompt bubble, a note, a steer, the notice of a
|
|
3
|
+
// turn with no answer. The live page renders the same template a reload
|
|
4
|
+
// does (elementOf, or its own lit root for a tool row), so the two can't
|
|
5
|
+
// drift apart. `node --test` covers it (on the happy-dom shim).
|
|
5
6
|
import { toolName } from "./activity.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
7
|
+
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
8
|
+
import { commandBlockTemplate, rowHover } from "./command_view.js";
|
|
9
|
+
import { copySource } from "./copy.js";
|
|
10
|
+
import { activityDiffTemplate } from "./diff_view.js";
|
|
11
|
+
import { steerSender } from "./format.js";
|
|
12
|
+
import { rowTitleRef } from "./refs.js";
|
|
13
|
+
import { readLinesTemplate } from "./read_view.js";
|
|
14
|
+
import { STOPPED_LINE_CLASS, cancelLineText, formatDuration } from "./timing.js";
|
|
13
15
|
import { emptyAnswerLine } from "./turn_events.js";
|
|
14
16
|
|
|
15
17
|
// A row's status (live, or a saved tool record's on reload): running,
|
|
@@ -31,12 +33,14 @@ export function statusParts(status, { noMatch = false } = {}) {
|
|
|
31
33
|
return { cls, label, word: cls === "ok" || cls === "running" ? "" : label };
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
// role is bound too: lit sets bound attributes after the static ones, and
|
|
37
|
+
// the markup keeps the order a reload wrote before.
|
|
38
|
+
function statusMarkTemplate({ cls, label }) {
|
|
39
|
+
return html`<span class="activity-status ${cls}" role=${"img"} aria-label=${label} title=${label}></span>`;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
|
-
function
|
|
39
|
-
return word ? `<span class="activity-state ${wordCls || cls}">${word}</span>` :
|
|
42
|
+
function statusWordTemplate({ cls, word, wordCls }) {
|
|
43
|
+
return word ? html`<span class="activity-state ${wordCls || cls}">${word}</span>` : nothing;
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
// A reloaded row's ✂ mark: what an LLM context edit saved on its output
|
|
@@ -62,11 +66,11 @@ function compactTokens(n) {
|
|
|
62
66
|
return n >= 1000 ? `${(n / 1000).toFixed(1)}k tokens` : `${n} tokens`;
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
function
|
|
69
|
+
function llmEditTemplate(row) {
|
|
66
70
|
const line = llmEditLine(row.edit);
|
|
67
|
-
if (!line) return
|
|
71
|
+
if (!line) return nothing;
|
|
68
72
|
const hover = `${row.tool_id ? `${row.tool_id}: ` : ""}${line}\nThe model is sent this instead of the output; the session keeps it.`;
|
|
69
|
-
return `<div class="activity-edit" title
|
|
73
|
+
return html`<div class="activity-edit" title=${hover}>${line}</div>`;
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
// A tool row: its status mark, tool, title (else params; the full command,
|
|
@@ -76,31 +80,35 @@ function llmEditHtml(row) {
|
|
|
76
80
|
// the rest on hover; a read of the file as-is also its numbered lines,
|
|
77
81
|
// closed: read_view.js), its images (+thumbs+(images) draws them) and an
|
|
78
82
|
// edit's collapsed diff, and a reloaded row's ✂ mark above its output. A
|
|
79
|
-
// part with nothing to say is left out.
|
|
80
|
-
|
|
81
|
-
|
|
83
|
+
// part with nothing to say is left out. A reload renders toolRowTemplate;
|
|
84
|
+
// the live view renders toolRowInnerTemplate into its own row element
|
|
85
|
+
// (turn_view.js), again on each event: lit writes only what changed, so a
|
|
86
|
+
// part the user toggled (the raw command, the diff, the read lines) stays.
|
|
87
|
+
// +stop+: the live row's stop-task button (a node), after the title.
|
|
88
|
+
export function toolRowTemplate(row, opts = {}) {
|
|
89
|
+
return html`<div class="activity-row" data-key=${row.key}>${toolRowInnerTemplate(row, opts)}</div>`;
|
|
82
90
|
}
|
|
83
91
|
|
|
84
|
-
export function
|
|
92
|
+
export function toolRowInnerTemplate(row, { thumbs = () => nothing, stop = nothing } = {}) {
|
|
85
93
|
const shown = row.title || row.params;
|
|
86
|
-
const hover = rowHover(row)
|
|
94
|
+
const hover = rowHover(row);
|
|
87
95
|
// A row with a ref (a file tool's file, else a plugin tool's first ref)
|
|
88
96
|
// carries it on its title, which opens it, and its diff lines open it at
|
|
89
97
|
// their line (refs.js; bindRowRefs binds a plugin's data to the title).
|
|
90
|
-
const ref = shown ?
|
|
91
|
-
const
|
|
98
|
+
const ref = shown ? rowTitleRef(row) : null;
|
|
99
|
+
const attr = (value) => (value ? String(value) : nothing);
|
|
100
|
+
const params = shown
|
|
101
|
+
? html`<span class=${`activity-params${ref?.kind ? " ref" : ""}`} title=${hover || nothing} data-ref-kind=${attr(ref?.kind)} data-ref-path=${attr(ref?.kind && ref.path)} data-ref-line=${attr(ref?.kind && ref.line)} data-ref-end-line=${attr(ref?.kind && ref.end_line)}>${shown}</span>`
|
|
102
|
+
: nothing;
|
|
92
103
|
// The name the model called the tool by when it was an alias's, dim.
|
|
93
|
-
const alias = row.called_as ? `<span class="activity-alias">(called as ${
|
|
104
|
+
const alias = row.called_as ? html`<span class="activity-alias">(called as ${row.called_as})</span>` : nothing;
|
|
94
105
|
const duration = formatDuration(row.duration_ms);
|
|
95
106
|
const full = row.status === "running" ? "" : String(row.output || "").replace(/^\[[^\]]*\]\s*/, "");
|
|
96
107
|
const output = full
|
|
97
|
-
? `<div class="activity-output" title
|
|
98
|
-
:
|
|
108
|
+
? html`<div class="activity-output" title=${full}>${full.length > 300 ? `${full.slice(0, 300)}…` : full}</div>`
|
|
109
|
+
: nothing;
|
|
99
110
|
const status = statusParts(row.status, { noMatch: !!row.no_match });
|
|
100
|
-
return `${
|
|
101
|
-
`${duration ? `<span class="activity-duration">${escapeHtml(duration)}</span>` : ""}` +
|
|
102
|
-
`${commandBlockHtml(row.view, row.title)}${llmEditHtml(row)}${output}${output ? readLinesHtml(row) : ""}` +
|
|
103
|
-
`${row.images?.length ? thumbs(row.images) : ""}${row.diff ? activityDiffHtml(row.diff) : ""}`;
|
|
111
|
+
return html`${statusMarkTemplate(status)}<span class="activity-tool">${toolName(row)}</span>${params}${stop}${alias}${statusWordTemplate(status)}${duration ? html`<span class="activity-duration">${duration}</span>` : nothing}${commandBlockTemplate(row.view, row.title)}${llmEditTemplate(row)}${output}${full ? readLinesTemplate(row) : nothing}${row.images?.length ? thumbs(row.images) : nothing}${row.diff ? activityDiffTemplate(row.diff) : nothing}`;
|
|
104
112
|
}
|
|
105
113
|
|
|
106
114
|
// A prompt bubble's states and their badges: queued (waiting for its
|
|
@@ -108,8 +116,8 @@ export function toolRowInnerHtml(row, { thumbs = () => "" } = {}) {
|
|
|
108
116
|
// gave it back). Any other state ("started", none) is a plain bubble.
|
|
109
117
|
export const BADGES = { queued: "queued", steered: "steered", failed: "failed" };
|
|
110
118
|
|
|
111
|
-
export function
|
|
112
|
-
return BADGES[state] ? `<span class="state-badge">${
|
|
119
|
+
export function stateBadgeTemplate(state) {
|
|
120
|
+
return BADGES[state] ? html`<span class="state-badge">${BADGES[state]}</span>` : nothing;
|
|
113
121
|
}
|
|
114
122
|
|
|
115
123
|
// A `!cmd`'s saved user message (the worker appends "!(<command>)\n<output>"
|
|
@@ -128,45 +136,97 @@ export function shellCommandMessage(content) {
|
|
|
128
136
|
// A `!cmd`'s history bubble: the same markup the live page draws for a
|
|
129
137
|
// command (app.js's drawCommandBubble): the line, the output in a
|
|
130
138
|
// pre.command-output.
|
|
131
|
-
export function
|
|
132
|
-
const body = text ? `<pre class="command-output">${
|
|
133
|
-
return `<div class="bubble command"><div class="command-line">${
|
|
139
|
+
export function commandBubbleTemplate({ line, text }) {
|
|
140
|
+
const body = text ? html`<pre class="command-output">${text}</pre>` : nothing;
|
|
141
|
+
return html`<div class="bubble command"><div class="command-line">${line}</div>${body}</div>`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// A user message's body: runs of `>` lines become <blockquote>s, the rest
|
|
145
|
+
// stays text. The bubble is pre-wrap, so the blank lines around a quote
|
|
146
|
+
// are dropped (the blockquote's margin spaces it instead).
|
|
147
|
+
export function userBodyTemplate(content) {
|
|
148
|
+
const text = String(content ?? "").replace(/\r\n?/g, "\n");
|
|
149
|
+
const blocks = [];
|
|
150
|
+
for (const line of text.split("\n")) {
|
|
151
|
+
const m = /^ {0,3}> ?(.*)$/.exec(line);
|
|
152
|
+
const kind = m ? "quote" : "text";
|
|
153
|
+
const last = blocks[blocks.length - 1];
|
|
154
|
+
if (last?.kind === kind) last.lines.push(m ? m[1] : line);
|
|
155
|
+
else blocks.push({ kind, lines: [m ? m[1] : line] });
|
|
156
|
+
}
|
|
157
|
+
if (!blocks.some((b) => b.kind === "quote")) return String(content ?? "");
|
|
158
|
+
return blocks.map((b) => {
|
|
159
|
+
if (b.kind === "quote") return html`<blockquote>${b.lines.join("\n")}</blockquote>`;
|
|
160
|
+
const body = b.lines.join("\n").replace(/^\s*\n/, "").replace(/\n\s*$/, "");
|
|
161
|
+
return body.trim() ? body : nothing;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// An answer's bubble (+cls+: a report reply's; +at+: its message's index,
|
|
166
|
+
// data-answer-at): an assistant's rendered markdown (the server's html,
|
|
167
|
+
// sanitized there) set as it is, as the live turn end sets it
|
|
168
|
+
// (answer_refs.js applyFinalAnswer), else its text, set whole (no lit
|
|
169
|
+
// markers inside: the bubble's nodes are the live one's). Its markdown
|
|
170
|
+
// source is kept for the copy button when it shows rendered html.
|
|
171
|
+
export function answerBubbleTemplate(message, { cls = "", at = null } = {}) {
|
|
172
|
+
const rendered = message?.role === "assistant" && typeof message.html === "string";
|
|
173
|
+
const user = !rendered && message?.role === "user";
|
|
174
|
+
const klass = `bubble output${cls}${rendered ? " markdown" : ""}`;
|
|
175
|
+
const answerAt = at ?? nothing;
|
|
176
|
+
const source = rendered || user ? copySource(message) : nothing;
|
|
177
|
+
if (rendered) return html`<div class=${klass} data-answer-at=${answerAt} data-copy-source=${source} .innerHTML=${message.html}></div>`;
|
|
178
|
+
if (user) return html`<div class=${klass} data-answer-at=${answerAt} data-copy-source=${source}>${userBodyTemplate(message.content)}</div>`;
|
|
179
|
+
return html`<div class=${klass} data-answer-at=${answerAt} data-copy-source=${source} .textContent=${String(message?.content ?? "")}></div>`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// A context note's bubble (background the user or another session pushed
|
|
183
|
+
// in; the model reads it, nobody asked it anything): who sent it, then its
|
|
184
|
+
// text.
|
|
185
|
+
export function noteBubbleTemplate(note) {
|
|
186
|
+
const from = note?.label ? `note from ${note.label}` : "note";
|
|
187
|
+
return html`<div class="bubble note"><div class="note-line">${from}</div><div class="note-text">${note?.content ?? ""}</div></div>`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// A plugin's steer (a nudge put into the running turn): one line that
|
|
191
|
+
// names who nudged the model, its text collapsed under it. +bubble+: on its
|
|
192
|
+
// own between bubbles (no step holds it), not a row of a step.
|
|
193
|
+
export function steerRowTemplate({ source, text } = {}, { bubble = false } = {}) {
|
|
194
|
+
const sender = steerSender(source);
|
|
195
|
+
const who = sender ? `${sender} nudged the model` : "nudged the model";
|
|
196
|
+
return html`<details class=${`${bubble ? "bubble " : ""}steer-row`}><summary>${who}</summary><div class="steer-text">${text ?? ""}</div></details>`;
|
|
134
197
|
}
|
|
135
198
|
|
|
136
199
|
// A user's line: who sent it (+label+, a delegate report's or another
|
|
137
200
|
// client's), its body (`>` lines as quotes) and +thumbs+ (its images'
|
|
138
|
-
//
|
|
201
|
+
// template), its state's badge. +message+: { content, display } (the copy
|
|
139
202
|
// source keeps the text as written). +step+: the step that read a line
|
|
140
203
|
// merged mid-turn (a reload's; a resolved question goes before it).
|
|
141
204
|
// The live page's own: +own+ (this tab sent it), +enqueuedId+ and +key+
|
|
142
205
|
// (its normalized text: an echo waiting for its id is matched by it).
|
|
143
|
-
export function
|
|
206
|
+
export function userBubbleTemplate(message, { label = null, state = null, step = null, thumbs = nothing, own = false, enqueuedId = null, key = null } = {}) {
|
|
144
207
|
const named = BADGES[state] ? state : null;
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
named ? `data-user-state="${named}"` : "",
|
|
148
|
-
step ? `data-step="${Number(step)}"` : "",
|
|
149
|
-
].filter(Boolean).join(" ");
|
|
150
|
-
const live = [
|
|
151
|
-
key !== null ? ` data-user-content="${escapeHtml(key)}"` : "",
|
|
152
|
-
own ? ' data-own="1"' : "",
|
|
153
|
-
enqueuedId ? ` data-enqueued-id="${escapeHtml(enqueuedId)}"` : "",
|
|
154
|
-
].join("");
|
|
155
|
-
const from = label ? `<span class="origin-label">${escapeHtml(label)}</span>` : "";
|
|
156
|
-
return `<div ${attrs}${copySourceAttr({ ...message, role: "user" }, false)}${live}>${from}` +
|
|
157
|
-
`<div class="user-message">${userBodyHtml(message?.content)}${thumbs}</div>${stateBadgeHtml(named)}</div>`;
|
|
208
|
+
const from = label ? html`<span class="origin-label">${label}</span>` : nothing;
|
|
209
|
+
return html`<div class=${`bubble user${named ? ` ${named}` : ""}`} data-user-state=${named || nothing} data-step=${step ? Number(step) : nothing} data-copy-source=${copySource({ ...message, role: "user" })} data-user-content=${key !== null ? key : nothing} data-own=${own ? "1" : nothing} data-enqueued-id=${enqueuedId || nothing}>${from}<div class="user-message">${userBodyTemplate(message?.content)}${thumbs}</div>${stateBadgeTemplate(named)}</div>`;
|
|
158
210
|
}
|
|
159
211
|
|
|
160
212
|
// The notice of a turn that ended with no answer (the server's
|
|
161
213
|
// empty_answer item, turn_summary.empty_answer live): a muted row like the
|
|
162
214
|
// loop's retry row, where the answer would be.
|
|
163
|
-
export function
|
|
164
|
-
return `<div class="bubble hook-notice empty-answer">${
|
|
215
|
+
export function emptyAnswerTemplate(item) {
|
|
216
|
+
return html`<div class="bubble hook-notice empty-answer">${emptyAnswerLine(item)}</div>`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// A re-rendered turn's cancel line (a .bubble.cancel.stopped, as live's
|
|
220
|
+
// addEndBubble), nothing when the turn record is not a canceled one.
|
|
221
|
+
export function cancelLineTemplate(record) {
|
|
222
|
+
if (record?.status !== "canceled") return nothing;
|
|
223
|
+
return html`<div class=${STOPPED_LINE_CLASS}>${cancelLineText(record.cancellation_reason, record.cancelled_by)}</div>`;
|
|
165
224
|
}
|
|
166
225
|
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
226
|
+
// +template+'s one top-level element, rendered once (the live page's
|
|
227
|
+
// bubbles: their classes and badge change by hand after this).
|
|
228
|
+
export function elementOf(template) {
|
|
229
|
+
const frag = document.createDocumentFragment();
|
|
230
|
+
render(template, frag);
|
|
231
|
+
return frag.firstElementChild;
|
|
172
232
|
}
|