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.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -1
  3. data/README.md +4 -1
  4. data/bin/chi +3 -2
  5. data/docs/architecture.md +12 -2
  6. data/docs/cli.md +20 -9
  7. data/docs/configuration.md +16 -9
  8. data/docs/context.md +13 -3
  9. data/docs/hooks.md +6 -3
  10. data/docs/internals/engine.md +10 -3
  11. data/docs/memory.md +1 -1
  12. data/docs/plugins.md +96 -2
  13. data/docs/releasing.md +1 -1
  14. data/docs/sessions.md +11 -8
  15. data/docs/sub-agent.md +27 -4
  16. data/docs/testing.md +13 -4
  17. data/lib/samagotchi/answer_command.rb +5 -1
  18. data/lib/samagotchi/answer_refs.rb +18 -26
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  20. data/lib/samagotchi/bridge.rb +41 -5
  21. data/lib/samagotchi/bundles/claude-peer/manifest.yml +22 -0
  22. data/lib/samagotchi/bundles/claude-peer/plugin.rb +406 -0
  23. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +104 -0
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +66 -0
  25. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +60 -0
  26. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  27. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/loop-guard/plugin.rb +17 -6
  29. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
  31. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  32. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  33. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  34. data/lib/samagotchi/child_reports.rb +7 -2
  35. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  36. data/lib/samagotchi/composer_route.rb +90 -0
  37. data/lib/samagotchi/config.rb +6 -3
  38. data/lib/samagotchi/context_absorber.rb +25 -6
  39. data/lib/samagotchi/continue_offer.rb +17 -3
  40. data/lib/samagotchi/delivery.rb +13 -8
  41. data/lib/samagotchi/engine.rb +2 -0
  42. data/lib/samagotchi/host_config.rb +6 -6
  43. data/lib/samagotchi/host_model.rb +2 -2
  44. data/lib/samagotchi/idle_recap.rb +2 -3
  45. data/lib/samagotchi/image_store.rb +8 -0
  46. data/lib/samagotchi/interrupted_turn.rb +7 -3
  47. data/lib/samagotchi/last_turn.rb +5 -2
  48. data/lib/samagotchi/pending_input_queue.rb +10 -3
  49. data/lib/samagotchi/plugin/attached_context.rb +7 -4
  50. data/lib/samagotchi/plugin/sessions.rb +27 -5
  51. data/lib/samagotchi/recap_setup.rb +3 -3
  52. data/lib/samagotchi/reply_wait.rb +171 -6
  53. data/lib/samagotchi/row_refs.rb +3 -4
  54. data/lib/samagotchi/send_command.rb +63 -19
  55. data/lib/samagotchi/session.rb +12 -0
  56. data/lib/samagotchi/session_commands.rb +1 -1
  57. data/lib/samagotchi/session_inbox.rb +63 -7
  58. data/lib/samagotchi/session_manager.rb +49 -20
  59. data/lib/samagotchi/terminal_ui/attach_launcher.rb +6 -3
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +73 -35
  61. data/lib/samagotchi/terminal_ui/formatting.rb +3 -19
  62. data/lib/samagotchi/terminal_ui.rb +103 -68
  63. data/lib/samagotchi/tool_activity.rb +5 -5
  64. data/lib/samagotchi/tools/delegate.rb +28 -1
  65. data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
  66. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  67. data/lib/samagotchi/tools/execute.rb +33 -8
  68. data/lib/samagotchi/turn_checkpoint.rb +9 -3
  69. data/lib/samagotchi/version.rb +1 -1
  70. data/lib/samagotchi/web/app.rb +6 -4
  71. data/lib/samagotchi/web/public/activity.js +2 -2
  72. data/lib/samagotchi/web/public/app.js +53 -91
  73. data/lib/samagotchi/web/public/card.js +73 -23
  74. data/lib/samagotchi/web/public/chains.js +6 -4
  75. data/lib/samagotchi/web/public/command_view.js +39 -35
  76. data/lib/samagotchi/web/public/commands.js +8 -8
  77. data/lib/samagotchi/web/public/composer.js +3 -3
  78. data/lib/samagotchi/web/public/copy.js +7 -15
  79. data/lib/samagotchi/web/public/diff_view.js +20 -23
  80. data/lib/samagotchi/web/public/format.js +0 -48
  81. data/lib/samagotchi/web/public/images.js +12 -16
  82. data/lib/samagotchi/web/public/question_cards.js +3 -2
  83. data/lib/samagotchi/web/public/read_view.js +10 -9
  84. data/lib/samagotchi/web/public/scope.js +23 -0
  85. data/lib/samagotchi/web/public/timing.js +2 -8
  86. data/lib/samagotchi/web/public/turn_html.js +119 -59
  87. data/lib/samagotchi/web/public/turn_model.js +13 -3
  88. data/lib/samagotchi/web/public/turn_view.js +73 -116
  89. data/lib/samagotchi/web/public/vendor/directive.js +8 -0
  90. data/lib/samagotchi/web/public/vendor/directives/unsafe-html.js +8 -0
  91. data/lib/samagotchi/worker.rb +53 -35
  92. data/lib/samagotchi/worker_inbound.rb +12 -11
  93. 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). Pure HTML builders for the tool
4
- // row (turn_html.js toolRowHtml, live and reloaded alike).
5
- import { copyButtonHtml } from "./copy.js";
6
- import { escapeHtml } from "./format.js";
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. "" for a call without a view.
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 commandBlockHtml(view, title = null) {
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">${escapeHtml(view.description)}</div>`
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) => `<span>${escapeHtml(d)}</span>`).join(" <span class=\"activity-command-sep\">›</span> ")}`
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>${escapeHtml(command)}</code></pre>`;
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}${copyButtonHtml("code")}${cut}</div>`;
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
- const head = (where ? `<div class="activity-command-head"><span class="activity-command-cwd">${where}</span>` : `<div class="activity-command-head bare">`) +
53
- `<label class="activity-command-raw" title="Show the command as written"><input type="checkbox">raw</label></div>`;
54
- return `<div class="activity-command code-wrap has-steps">${desc}${head}${stepsHtml(steps)}${raw}${copyButtonHtml("code")}${cut}</div>`;
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 opHtml(op) {
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="step-op${op === "||" ? " else" : ""}" title="${escapeHtml(name)}">${escapeHtml(glyph)}</span>`;
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 stepTextHtml(text) {
74
- let out = "";
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 += `${escapeHtml(text.slice(at, start))}<span class="step-plumbing">${escapeHtml(m[2])}</span>`;
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
- return out + escapeHtml(String(text).slice(at));
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. "" without one.
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="${escapeHtml(title)}">${escapeHtml(heredocLabel(all[0]))}${more}</span>`;
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 stepsHtml(steps) {
109
+ export function stepsTemplate(steps) {
106
110
  const items = steps.map((step) => {
107
- const label = step.label ? `<li class="step-label">${escapeHtml(step.label)}</li>` : "";
108
- const limit = step.limit ? `<span class="step-chip" title="output limited">${escapeHtml(step.limit)}</span>` : "";
109
- return `${label}<li class="activity-step">${opHtml(step.op)}<span class="step-body"><code>${stepTextHtml(step.text)}</code>${limit}${heredocChip(step)}</span></li>`;
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.join("")}</ol>`;
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; TerminalUI::Formatting's
27
- // STEER_CHOICE: the word, then any whitespace and the text). During a
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; /queue isn't in the web yet (the page says so,
30
- // as the attached TUI does). With no running turn both are the text as a
31
- // normal turn. The bare word gets the usage line.
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
- if (match[1] === "queue") return { reply: QUEUE_UNAVAILABLE_WEB };
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, or the page's own answer; else the text goes on
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
- // The attribute that keeps a message's source on its reloaded bubble:
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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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 markup, for a builder that returns HTML (a tool row's
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 copyButtonHtml(kind) {
85
+ export function copyButtonTemplate(kind) {
94
86
  const title = kind === "code" ? "Copy code" : "Copy";
95
- return `<button type="button" class="copy-btn copy-${kind}" title="${title}" aria-label="${title}">${ICON}</button>`;
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.innerHTML = ICON;
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 HTML, apart from the DOM. One
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 { escapeHtml } from "./format.js";
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 linesHtml(lines, numbers) {
55
- return lines.map((line, i) => {
56
- const at = numbers[i] > 0 ? ` data-line="${numbers[i]}"` : "";
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 {string} HTML; "" for none
63
- export function diffHtml(diff, { fold = DIFF_FOLD_LINES } = {}) {
64
- if (!diff || typeof diff !== "object") return "";
65
- if (diff.error) return `<div class="diff-note warn">this edit would fail: ${escapeHtml(diff.error)}</div>`;
66
- if (diff.skipped) return `<div class="diff-note">diff not shown: ${escapeHtml(diff.skipped)}</div>`;
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>${linesHtml(lines.slice(0, fold), numbers.slice(0, fold))}</code></pre>`;
73
- if (lines.length <= fold) return head + shown;
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
- const rest = `<details class="diff-more"><summary>show all ${lines.length} lines</summary>` +
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
- export function activityDiffHtml(diff) {
82
- if (!diff || typeof diff !== "object" || diff.error || diff.skipped) return "";
83
- return `<details class="activity-diff"><summary>${escapeHtml(diffSummary(diff))}</summary>${diffHtml(diff)}</details>`;
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 { escapeHtml } from "./format.js";
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; "" for none. `src` may be given
44
- // (a blob: URL while the upload is on its way).
45
- export function thumbsHtml(sessionId, images) {
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="${escapeHtml(src)}" alt="${escapeHtml(title)}" title="${escapeHtml(title)}" loading="lazy">`;
53
+ return html`<img class="thumb" src=${src} alt=${title} title=${title} loading=${"lazy"}>`;
53
54
  });
54
- return `<div class="thumbs">${thumbs.join("")}</div>`;
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 chipsHtml(chips) {
59
- return (chips || [])
60
- .map(
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 { diffHtml } from "./diff_view.js";
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
- diff.innerHTML = diffHtml(view.preview);
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, apart from the DOM.
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 { escapeHtml } from "./format.js";
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
- export function readLinesHtml(row) {
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">${escapeHtml(text)}</span>`
74
- : `<span class="read-line" data-line="${line}">${escapeHtml(text) || "<br>"}</span>`).join("");
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);