samagotchi 0.62.0 → 0.64.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 +78 -1
- data/README.md +4 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +12 -2
- data/docs/cli.md +20 -9
- data/docs/configuration.md +16 -9
- data/docs/context.md +13 -3
- data/docs/hooks.md +6 -3
- data/docs/internals/engine.md +10 -3
- data/docs/memory.md +1 -1
- data/docs/plugins.md +96 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +11 -8
- data/docs/sub-agent.md +27 -4
- data/docs/testing.md +13 -4
- data/lib/samagotchi/answer_command.rb +5 -1
- data/lib/samagotchi/answer_refs.rb +18 -26
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +41 -5
- 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/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/child_reports.rb +7 -2
- data/lib/samagotchi/cli/parent_wait.rb +9 -2
- data/lib/samagotchi/composer_route.rb +90 -0
- data/lib/samagotchi/config.rb +6 -3
- data/lib/samagotchi/context_absorber.rb +25 -6
- data/lib/samagotchi/continue_offer.rb +17 -3
- data/lib/samagotchi/delivery.rb +13 -8
- data/lib/samagotchi/engine.rb +2 -0
- data/lib/samagotchi/host_config.rb +6 -6
- data/lib/samagotchi/host_model.rb +2 -2
- data/lib/samagotchi/idle_recap.rb +2 -3
- data/lib/samagotchi/image_store.rb +8 -0
- data/lib/samagotchi/interrupted_turn.rb +7 -3
- data/lib/samagotchi/last_turn.rb +5 -2
- data/lib/samagotchi/pending_input_queue.rb +10 -3
- data/lib/samagotchi/plugin/attached_context.rb +7 -4
- data/lib/samagotchi/plugin/sessions.rb +27 -5
- data/lib/samagotchi/recap_setup.rb +3 -3
- data/lib/samagotchi/reply_wait.rb +171 -6
- data/lib/samagotchi/row_refs.rb +3 -4
- data/lib/samagotchi/send_command.rb +63 -19
- data/lib/samagotchi/session.rb +12 -0
- data/lib/samagotchi/session_commands.rb +1 -1
- data/lib/samagotchi/session_inbox.rb +63 -7
- data/lib/samagotchi/session_manager.rb +49 -20
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +73 -35
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -19
- data/lib/samagotchi/terminal_ui.rb +103 -68
- data/lib/samagotchi/tool_activity.rb +5 -5
- data/lib/samagotchi/tools/delegate.rb +28 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
- data/lib/samagotchi/tools/delegate_wait.rb +17 -5
- data/lib/samagotchi/tools/execute.rb +33 -8
- data/lib/samagotchi/turn_checkpoint.rb +9 -3
- 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 +53 -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/commands.js +8 -8
- data/lib/samagotchi/web/public/composer.js +3 -3
- 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 +2 -8
- data/lib/samagotchi/web/public/turn_html.js +119 -59
- data/lib/samagotchi/web/public/turn_model.js +13 -3
- 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 +53 -35
- data/lib/samagotchi/worker_inbound.rb +12 -11
- metadata +9 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// A tool call's full command in a richer UI than the TUI's params line:
|
|
2
2
|
// the server's view (ToolView: an execute/task_create's command uncut up to
|
|
3
|
-
// 8,000 chars, the call's cwd: argument).
|
|
4
|
-
// row (turn_html.js
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
3
|
+
// 8,000 chars, the call's cwd: argument). lit-html templates for the tool
|
|
4
|
+
// row (turn_html.js toolRowTemplate, live and reloaded alike).
|
|
5
|
+
import { html, nothing } from "./vendor/lit-html.js";
|
|
6
|
+
import { copyButtonTemplate } from "./copy.js";
|
|
7
7
|
|
|
8
8
|
// What a row's title hovers: the full command when the call has one,
|
|
9
9
|
// else the params line when the row shows a title in its place.
|
|
@@ -15,7 +15,7 @@ export function rowHover(row) {
|
|
|
15
15
|
// The expanded row's command block: "in <cwd>" when the call gave one (as
|
|
16
16
|
// given: execute's may be relative to the project root), the command as
|
|
17
17
|
// written (whitespace kept, its own scroll), a copy button, and a line
|
|
18
|
-
// saying how much a cut command left out.
|
|
18
|
+
// saying how much a cut command left out. nothing for a call without a view.
|
|
19
19
|
//
|
|
20
20
|
// A view with steps (CommandSteps) shows them in the command's place, its
|
|
21
21
|
// leading cd as one more "in" tag, and a "raw" toggle (a checkbox, CSS
|
|
@@ -28,30 +28,33 @@ export function rowHover(row) {
|
|
|
28
28
|
// +title+ is the row's: the model's description of the command, when it
|
|
29
29
|
// gave one, cut to fit. The block starts with the whole description when
|
|
30
30
|
// the title is cut, never in place of the command.
|
|
31
|
-
export function
|
|
31
|
+
export function commandBlockTemplate(view, title = null) {
|
|
32
32
|
const command = view?.command;
|
|
33
|
-
if (!command) return
|
|
33
|
+
if (!command) return nothing;
|
|
34
34
|
const desc = view.description && view.description !== title
|
|
35
|
-
? `<div class="activity-command-desc">${
|
|
36
|
-
:
|
|
35
|
+
? html`<div class="activity-command-desc">${view.description}</div>`
|
|
36
|
+
: nothing;
|
|
37
37
|
const steps = Array.isArray(view.steps) && view.steps.length ? view.steps : null;
|
|
38
38
|
const dirs = [view.cwd, steps && view.cd].filter(Boolean);
|
|
39
39
|
const where = dirs.length
|
|
40
|
-
? `in ${dirs.map((d) =>
|
|
41
|
-
:
|
|
40
|
+
? html`in ${dirs.map((d, i) => html`${i ? html` <span class="activity-command-sep">›</span> ` : nothing}<span>${d}</span>`)}`
|
|
41
|
+
: null;
|
|
42
42
|
const cut = view.truncated
|
|
43
|
-
? `<div class="activity-command-cut">… (${command.length.toLocaleString("en-US")} of ${Number(view.chars || 0).toLocaleString("en-US")} chars)</div>`
|
|
44
|
-
:
|
|
45
|
-
const raw = `<pre><code>${
|
|
43
|
+
? html`<div class="activity-command-cut">… (${command.length.toLocaleString("en-US")} of ${Number(view.chars || 0).toLocaleString("en-US")} chars)</div>`
|
|
44
|
+
: nothing;
|
|
45
|
+
const raw = html`<pre><code>${command}</code></pre>`;
|
|
46
46
|
if (!steps) {
|
|
47
|
-
const cwd = where ? `<div class="activity-command-cwd">${where}</div>` :
|
|
48
|
-
return `<div class="activity-command code-wrap">${desc}${cwd}${raw}${
|
|
47
|
+
const cwd = where ? html`<div class="activity-command-cwd">${where}</div>` : nothing;
|
|
48
|
+
return html`<div class="activity-command code-wrap">${desc}${cwd}${raw}${copyButtonTemplate("code")}${cut}</div>`;
|
|
49
49
|
}
|
|
50
50
|
// No "in" tag: the toggle alone, floated beside the first step (an
|
|
51
|
-
// empty line for it would sit above the steps).
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
// empty line for it would sit above the steps). lit never binds the
|
|
52
|
+
// checkbox: a user's toggle stays across a redraw.
|
|
53
|
+
const toggle = html`<label class="activity-command-raw" title="Show the command as written"><input type="checkbox">raw</label>`;
|
|
54
|
+
const head = where
|
|
55
|
+
? html`<div class="activity-command-head"><span class="activity-command-cwd">${where}</span>${toggle}</div>`
|
|
56
|
+
: html`<div class="activity-command-head bare">${toggle}</div>`;
|
|
57
|
+
return html`<div class="activity-command code-wrap has-steps">${desc}${head}${stepsTemplate(steps)}${raw}${copyButtonTemplate("code")}${cut}</div>`;
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
// What an op between two steps reads as: an arrow for "then" (&&, ;, a
|
|
@@ -59,26 +62,27 @@ export function commandBlockHtml(view, title = null) {
|
|
|
59
62
|
const OP_GLYPHS = { "&&": "→", ";": "→", "\n": "→", "|": "→", "|&": "→", "||": "else", "&": "&" };
|
|
60
63
|
const OP_NAMES = { "\n": "new line", "&": "after starting the step before in the background" };
|
|
61
64
|
|
|
62
|
-
function
|
|
63
|
-
if (!op) return `<span class="step-op"></span>`;
|
|
65
|
+
function opTemplate(op) {
|
|
66
|
+
if (!op) return html`<span class="step-op"></span>`;
|
|
64
67
|
const glyph = OP_GLYPHS[op] || op;
|
|
65
68
|
const name = OP_NAMES[op] || op;
|
|
66
|
-
return `<span class
|
|
69
|
+
return html`<span class=${`step-op${op === "||" ? " else" : ""}`} title=${name}>${glyph}</span>`;
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
// A step's redirections to or from nowhere (2>&1, 2>/dev/null, </dev/null),
|
|
70
73
|
// dimmed: plumbing, not what the step does.
|
|
71
74
|
const PLUMBING = /(^|\s)(\d*>&\d+|&>\/dev\/null|\d*>>?\s*\/dev\/null|<\s*\/dev\/null)(?=\s|$)/g;
|
|
72
75
|
|
|
73
|
-
export function
|
|
74
|
-
|
|
76
|
+
export function stepTextTemplate(text) {
|
|
77
|
+
const out = [];
|
|
75
78
|
let at = 0;
|
|
76
79
|
for (const m of String(text).matchAll(PLUMBING)) {
|
|
77
80
|
const start = m.index + m[1].length;
|
|
78
|
-
out
|
|
81
|
+
out.push(text.slice(at, start), html`<span class="step-plumbing">${m[2]}</span>`);
|
|
79
82
|
at = start + m[2].length;
|
|
80
83
|
}
|
|
81
|
-
|
|
84
|
+
out.push(String(text).slice(at));
|
|
85
|
+
return out;
|
|
82
86
|
}
|
|
83
87
|
|
|
84
88
|
function heredocLabel({ tag, lines }) {
|
|
@@ -88,25 +92,25 @@ function heredocLabel({ tag, lines }) {
|
|
|
88
92
|
// A step's heredoc chip: the first heredoc's tag and size, and "+N" for
|
|
89
93
|
// the others when the step has more (CommandSteps' heredocs:, as gh api
|
|
90
94
|
// -f body="$(cat <<EOF …)" -f title="$(cat <<EOF …)"), as a title with
|
|
91
|
-
// more steps reads "+N"; the hover lists them all.
|
|
95
|
+
// more steps reads "+N"; the hover lists them all. nothing without one.
|
|
92
96
|
export function heredocChip(step) {
|
|
93
|
-
if (!step?.heredoc) return
|
|
97
|
+
if (!step?.heredoc) return nothing;
|
|
94
98
|
const all = Array.isArray(step.heredocs) && step.heredocs.length > 1 ? step.heredocs : [step.heredoc];
|
|
95
99
|
const more = all.length > 1 ? ` +${all.length - 1}` : "";
|
|
96
100
|
const title = all.length > 1
|
|
97
101
|
? `${all.length} heredocs: ${all.map(heredocLabel).join(", ")}; their text in the raw command`
|
|
98
102
|
: "a heredoc: its text in the raw command";
|
|
99
|
-
return `<span class="step-chip heredoc" title
|
|
103
|
+
return html`<span class="step-chip heredoc" title=${title}>${heredocLabel(all[0])}${more}</span>`;
|
|
100
104
|
}
|
|
101
105
|
|
|
102
106
|
// The steps as a list: a label as a small heading above its step, the op
|
|
103
107
|
// that links a step to the one before, its text, its limit and heredoc
|
|
104
108
|
// as chips (beside the text, below it when the row is narrow).
|
|
105
|
-
export function
|
|
109
|
+
export function stepsTemplate(steps) {
|
|
106
110
|
const items = steps.map((step) => {
|
|
107
|
-
const label = step.label ? `<li class="step-label">${
|
|
108
|
-
const limit = step.limit ? `<span class="step-chip" title="output limited">${
|
|
109
|
-
return `${label}<li class="activity-step">${
|
|
111
|
+
const label = step.label ? html`<li class="step-label">${step.label}</li>` : nothing;
|
|
112
|
+
const limit = step.limit ? html`<span class="step-chip" title="output limited">${step.limit}</span>` : nothing;
|
|
113
|
+
return html`${label}<li class="activity-step">${opTemplate(step.op)}<span class="step-body"><code>${stepTextTemplate(step.text)}</code>${limit}${heredocChip(step)}</span></li>`;
|
|
110
114
|
});
|
|
111
|
-
return `<ol class="activity-steps">${items
|
|
115
|
+
return html`<ol class="activity-steps">${items}</ol>`;
|
|
112
116
|
}
|
|
@@ -23,16 +23,17 @@ export function sessionCommandLine(text, commands) {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
// /cut TEXT and /queue TEXT: the UIs' own words for a delivery (listed
|
|
26
|
-
// for Tab and /help, never run as commands;
|
|
27
|
-
//
|
|
26
|
+
// for Tab and /help, never run as commands; ComposerRoute::STEER_CHOICE
|
|
27
|
+
// (spec/fixtures/composer_routes.json): the word, then any whitespace and the text). During a
|
|
28
28
|
// turn /cut TEXT is the text sent as a cut (POST /turn, delivery "cut"),
|
|
29
|
-
// as chi send --cut does
|
|
30
|
-
//
|
|
31
|
-
//
|
|
29
|
+
// as chi send --cut does, and /queue TEXT the text queued to run as a turn
|
|
30
|
+
// of its own after this one (delivery "queue"; a worker that can't queue
|
|
31
|
+
// refuses it, and the page shows the refusal's detail). With no running
|
|
32
|
+
// turn both are the text as a normal turn. The bare word gets the usage
|
|
33
|
+
// line.
|
|
32
34
|
// @return {{send: string, delivery?: string} | {reply: string} | null}
|
|
33
35
|
// null: not a delivery line
|
|
34
36
|
export const STEER_CHOICE_USAGE = "(/cut and /queue need a message: /cut TEXT, /queue TEXT)";
|
|
35
|
-
export const QUEUE_UNAVAILABLE_WEB = "(/queue isn't available in the web yet; a plain message goes in at the running turn's next step, and /cut TEXT cuts in)";
|
|
36
37
|
const STEER_CHOICE = /^\/(cut|queue)(?:\s+([\s\S]*))?$/;
|
|
37
38
|
|
|
38
39
|
export function steerChoiceRoute(text, { running = false } = {}) {
|
|
@@ -41,8 +42,7 @@ export function steerChoiceRoute(text, { running = false } = {}) {
|
|
|
41
42
|
const message = (match[2] || "").trim();
|
|
42
43
|
if (!message) return { reply: STEER_CHOICE_USAGE };
|
|
43
44
|
if (!running) return { send: message };
|
|
44
|
-
|
|
45
|
-
return { send: message, delivery: "cut" };
|
|
45
|
+
return { send: message, delivery: match[1] };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
// A line that is one word: a slash and a name, no spaces, no second slash
|
|
@@ -7,8 +7,8 @@ import { sessionCommandLine, startPageReplyOrHint, steerChoiceRoute, unknownComm
|
|
|
7
7
|
|
|
8
8
|
// Where a composer line goes, in the order the page reads it:
|
|
9
9
|
// 1. /cut TEXT, /queue TEXT (steerChoiceRoute): during a turn in a session
|
|
10
|
-
// the text as a cut
|
|
11
|
-
// as the line.
|
|
10
|
+
// the text as a cut or queued for after the turn; else the text goes
|
|
11
|
+
// on as the line.
|
|
12
12
|
// 2. On the start page (+session+ false): the page's own reply or a typo
|
|
13
13
|
// hint (startPageReplyOrHint) needs no session; anything else makes one.
|
|
14
14
|
// 3. In a session (routeSessionLine): a session command, the page's own
|
|
@@ -21,7 +21,7 @@ import { sessionCommandLine, startPageReplyOrHint, steerChoiceRoute, unknownComm
|
|
|
21
21
|
// +send+ again in it (routeSessionLine, with its
|
|
22
22
|
// commands)
|
|
23
23
|
// {command} POST /command
|
|
24
|
-
// {send, delivery} POST /turn (+delivery+ "cut" or null)
|
|
24
|
+
// {send, delivery} POST /turn (+delivery+ "cut", "queue" or null)
|
|
25
25
|
export function routeComposerLine(text, { session = true, running = false, commands = [], images = 0 } = {}) {
|
|
26
26
|
const choice = steerChoiceRoute(text, { running: running && session });
|
|
27
27
|
if (choice?.reply) return { reply: choice.reply, line: text };
|
|
@@ -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
|
+
}
|
|
@@ -33,7 +33,8 @@ export function liveDurationText(durationMs) {
|
|
|
33
33
|
|
|
34
34
|
// A cancel's reason as the terminal UIs name it too (Formatting::
|
|
35
35
|
// CANCEL_REASONS); anything else as it is. Both sides: spec/shared/labels_matrix.json.
|
|
36
|
-
const CANCEL_REASONS = { ctrl_c: "Ctrl-C", user: "stopped", hook: "by a hook", worker_stopped: "worker stopped"
|
|
36
|
+
const CANCEL_REASONS = { ctrl_c: "Ctrl-C", user: "stopped", hook: "by a hook", worker_stopped: "worker stopped",
|
|
37
|
+
session_stopped: "session stopped" };
|
|
37
38
|
|
|
38
39
|
// The line a canceled turn ends with: "■ canceled (stopped)", live from
|
|
39
40
|
// turn_canceled's reason and after a re-render from the turn record's
|
|
@@ -49,13 +50,6 @@ export function cancelLineText(reason, by) {
|
|
|
49
50
|
return `${STOP_MARK} canceled` + (reason ? ` (${CANCEL_REASONS[reason] || reason})` : "");
|
|
50
51
|
}
|
|
51
52
|
|
|
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
53
|
export function elapsedSince(startedAt, now = Date.now()) {
|
|
60
54
|
const started = Date.parse(startedAt);
|
|
61
55
|
const current = Number(now);
|