samagotchi 0.53.0 → 0.55.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -1
  3. data/README.md +1 -1
  4. data/bin/chi +3 -2
  5. data/docs/architecture.md +94 -54
  6. data/docs/configuration.md +30 -2
  7. data/docs/context.md +104 -9
  8. data/docs/internals/background-tasks.md +1 -1
  9. data/docs/internals/bundles.md +73 -0
  10. data/docs/internals/context-telemetry.md +27 -6
  11. data/docs/internals/engine.md +55 -24
  12. data/docs/internals/tool-guardrails.md +8 -6
  13. data/docs/plugins.md +2 -1
  14. data/docs/sessions.md +3 -3
  15. data/docs/testing.md +23 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +8 -6
  17. data/lib/samagotchi/bundle_command.rb +31 -36
  18. data/lib/samagotchi/bundle_needs.rb +4 -5
  19. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
  21. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  22. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  23. data/lib/samagotchi/child_move.rb +6 -4
  24. data/lib/samagotchi/config.rb +2 -1
  25. data/lib/samagotchi/context_command.rb +1 -1
  26. data/lib/samagotchi/context_providers.rb +11 -14
  27. data/lib/samagotchi/context_status.rb +10 -18
  28. data/lib/samagotchi/engine.rb +117 -134
  29. data/lib/samagotchi/extension_load.rb +102 -0
  30. data/lib/samagotchi/file_ref.rb +59 -0
  31. data/lib/samagotchi/guardrail_wiring.rb +16 -16
  32. data/lib/samagotchi/kernel_loop.rb +5 -3
  33. data/lib/samagotchi/llm/chat_loop.rb +5 -4
  34. data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
  35. data/lib/samagotchi/memory_bundle/builder.rb +170 -178
  36. data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
  37. data/lib/samagotchi/memory_bundle/installer.rb +252 -488
  38. data/lib/samagotchi/memory_bundle/listing.rb +6 -6
  39. data/lib/samagotchi/memory_bundle/manifest.rb +4 -2
  40. data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
  41. data/lib/samagotchi/memory_bundle/profile.rb +21 -21
  42. data/lib/samagotchi/memory_bundle/provenance.rb +38 -40
  43. data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
  44. data/lib/samagotchi/memory_bundle/status.rb +22 -24
  45. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
  46. data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
  47. data/lib/samagotchi/plugin/loader.rb +8 -8
  48. data/lib/samagotchi/self_report.rb +2 -2
  49. data/lib/samagotchi/send_command.rb +1 -1
  50. data/lib/samagotchi/session_continue.rb +275 -0
  51. data/lib/samagotchi/session_manager.rb +9 -253
  52. data/lib/samagotchi/session_metrics.rb +3 -1
  53. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  54. data/lib/samagotchi/terminal_ui.rb +12 -2
  55. data/lib/samagotchi/tool_activity.rb +84 -14
  56. data/lib/samagotchi/tool_row_fields.rb +46 -0
  57. data/lib/samagotchi/tool_runner.rb +7 -11
  58. data/lib/samagotchi/version.rb +1 -1
  59. data/lib/samagotchi/web/app.rb +26 -3
  60. data/lib/samagotchi/web/capabilities.rb +13 -9
  61. data/lib/samagotchi/web/editor.rb +38 -0
  62. data/lib/samagotchi/web/message_parts.rb +5 -10
  63. data/lib/samagotchi/web/public/activity.js +35 -18
  64. data/lib/samagotchi/web/public/app.js +240 -1136
  65. data/lib/samagotchi/web/public/commands.js +236 -0
  66. data/lib/samagotchi/web/public/composer.js +101 -0
  67. data/lib/samagotchi/web/public/ctx.js +18 -4
  68. data/lib/samagotchi/web/public/diff_view.js +30 -4
  69. data/lib/samagotchi/web/public/index.html +9 -2
  70. data/lib/samagotchi/web/public/info_bar.js +78 -0
  71. data/lib/samagotchi/web/public/lists_view.js +725 -0
  72. data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
  73. data/lib/samagotchi/web/public/question_card.js +9 -1
  74. data/lib/samagotchi/web/public/question_cards.js +7 -4
  75. data/lib/samagotchi/web/public/refs.js +134 -0
  76. data/lib/samagotchi/web/public/restore.js +81 -0
  77. data/lib/samagotchi/web/public/session_state.js +52 -0
  78. data/lib/samagotchi/web/public/stream_handlers.js +324 -0
  79. data/lib/samagotchi/web/public/thinking_chip.js +6 -7
  80. data/lib/samagotchi/web/public/timing.js +8 -2
  81. data/lib/samagotchi/web/public/turn_events.js +17 -258
  82. data/lib/samagotchi/web/public/turn_html.js +15 -7
  83. data/lib/samagotchi/web/server.rb +4 -4
  84. metadata +17 -2
  85. data/lib/samagotchi/web/public/stable_html.js +0 -18
@@ -0,0 +1,236 @@
1
+ // Session commands in the web: which composer lines are commands, the
2
+ // page's own replies, how a command_ran shows (pure, no DOM), and the
3
+ // command bubbles this page draws and keeps across a resync (DOM, plain
4
+ // nodes: no lit, so the modules that import this one need no DOM to load).
5
+ // Split out of turn_events.js and app.js.
6
+
7
+ import { clientLabel, isOwn } from "./turn_events.js";
8
+ import { anchorInsertIndex, commandAnchor, isShellCommandLine, keepsCommandBubble } from "./kept_commands.js";
9
+
10
+ // Whether a composer line in an open session is one of its commands
11
+ // (+commands+: the session GET's listing), as the terminals decide
12
+ // (Commands::Registry#lookup): a "!" line with something after the "!", or
13
+ // a listed name alone or followed by a space; a terminal's own (/stats,
14
+ // /exit) too, which the page answers itself (webLocalReply). An unknown
15
+ // "/word" (a path, a typo) goes to the model as a prompt, as in a terminal.
16
+ export function sessionCommandLine(text, commands) {
17
+ const line = String(text || "").trim();
18
+ if (/^!\s*\S/.test(line)) return true;
19
+ if (!line.startsWith("/")) return false;
20
+ if (webLocalReply(line)) return true;
21
+ return (Array.isArray(commands) ? commands : [])
22
+ .some((c) => c && typeof c.name === "string" && (line === c.name || line.startsWith(`${c.name} `)));
23
+ }
24
+
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
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.
32
+ // @return {{send: string, delivery?: string} | {reply: string} | null}
33
+ // null: not a delivery line
34
+ 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
+ const STEER_CHOICE = /^\/(cut|queue)(?:\s+([\s\S]*))?$/;
37
+
38
+ export function steerChoiceRoute(text, { running = false } = {}) {
39
+ const match = STEER_CHOICE.exec(String(text || "").trim());
40
+ if (!match) return null;
41
+ const message = (match[2] || "").trim();
42
+ if (!message) return { reply: STEER_CHOICE_USAGE };
43
+ if (!running) return { send: message };
44
+ if (match[1] === "queue") return { reply: QUEUE_UNAVAILABLE_WEB };
45
+ return { send: message, delivery: "cut" };
46
+ }
47
+
48
+ // A line that is one word: a slash and a name, no spaces, no second slash
49
+ // (Commands::Registry#unknown_command_word?). `/modle` is one; `/foo bar`
50
+ // and `/usr/bin/env` are not (they are prompts).
51
+ const UNKNOWN_COMMAND_WORD = /^\/[A-Za-z][A-Za-z0-9_-]*$/;
52
+
53
+ // The hint for a command word no command answers (a typo like `/modle`),
54
+ // or null when the line is not one: `Unknown command /modle. Did you mean
55
+ // /model? /help lists the commands.` The "Did you mean" part needs a close
56
+ // name among the session's commands and the page's own (webLocalReply).
57
+ export function unknownCommandHint(text, commands) {
58
+ const line = String(text || "").trim();
59
+ if (!UNKNOWN_COMMAND_WORD.test(line)) return null;
60
+ if (sessionCommandLine(line, commands)) return null;
61
+ const names = (Array.isArray(commands) ? commands : [])
62
+ .map((c) => (c && typeof c.name === "string" ? c.name : null))
63
+ .filter(Boolean)
64
+ .concat(Object.keys(WEB_LOCAL_REPLIES));
65
+ const close = closestName(line, names);
66
+ return `Unknown command ${line}. ${close ? `Did you mean ${close}? ` : ""}/help lists the commands.`;
67
+ }
68
+
69
+ // The closest name within an optimal string alignment distance of 2, or
70
+ // null. Ties go to the first in the list.
71
+ function closestName(line, names) {
72
+ let best = null;
73
+ let bestDistance = 3;
74
+ for (const name of names) {
75
+ const distance = osaDistance(line, name);
76
+ if (distance < bestDistance) {
77
+ best = name;
78
+ bestDistance = distance;
79
+ }
80
+ }
81
+ return best;
82
+ }
83
+
84
+ // Optimal string alignment distance: Levenshtein, with an adjacent
85
+ // transposition counting as 1 (so `/modle` is 1 from `/model`, as Ruby's
86
+ // DidYouMean reads it).
87
+ function osaDistance(a, b) {
88
+ const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
89
+ for (let j = 0; j <= b.length; j += 1) rows[0][j] = j;
90
+ for (let i = 1; i <= a.length; i += 1) {
91
+ for (let j = 1; j <= b.length; j += 1) {
92
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
93
+ rows[i][j] = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost);
94
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
95
+ rows[i][j] = Math.min(rows[i][j], rows[i - 2][j - 2] + 1);
96
+ }
97
+ }
98
+ }
99
+ return rows[a.length][b.length];
100
+ }
101
+
102
+ // The terminal's own commands (a button in the web, or none yet): the
103
+ // reply the page shows itself (they never go to the worker, which doesn't
104
+ // know them), or null.
105
+ const WEB_LOCAL_REPLIES = {
106
+ "/archive": "/archive: use the archive button in the session bar",
107
+ "/detach": "/detach: a terminal's command; close the tab to leave, the worker keeps running",
108
+ "/exit": "/exit: close the tab to leave; stop in the session bar stops the worker",
109
+ "/quit": "/quit: close the tab to leave; stop in the session bar stops the worker",
110
+ "/recap": "/recap: not in the web yet; a recap shows here by itself when you come back to an idle session, and /recap in a terminal (chi --attach) makes one now",
111
+ "/stats": "/stats: not in the web yet; each turn shows its time, the session bar the context use and speed (hover ctx for tokens and cost), and /stats in a terminal (chi --attach) has the rest",
112
+ };
113
+
114
+ export function webLocalReply(text) {
115
+ const name = String(text || "").trim().split(/\s+/)[0].toLowerCase();
116
+ return WEB_LOCAL_REPLIES[name] || null;
117
+ }
118
+
119
+ // The start page's first message when the page answers it itself
120
+ // (webLocalReply: /stats, /exit …): shown there, with no session made just
121
+ // to hold the reply. With images it is a message, as in a session.
122
+ export function startPageReply(text, { images = 0 } = {}) {
123
+ return images ? null : webLocalReply(text);
124
+ }
125
+
126
+ // The start page's reply to a first message that needs no session at all
127
+ // (2.29): its own command's reply (startPageReply), or the hint for a
128
+ // command word no command answers (a typo like /modle) — the hint the page
129
+ // shows in a session too, as a command bubble. Decided before the session
130
+ // is created, so a first /stats or /modle leaves none behind. A null here
131
+ // means the message goes to a session (a prompt, or anything with images).
132
+ export function startPageReplyOrHint(text, { commands = [], images = 0 } = {}) {
133
+ if (images) return null;
134
+ return startPageReply(text) || unknownCommandHint(text, commands);
135
+ }
136
+
137
+ // The continue card's answers, as the terminal's continue prompt reads them.
138
+ export function continueLine(choice, reason = "") {
139
+ const why = String(reason || "").trim();
140
+ if (choice === "no" && why) return `/continue no, ${why}`;
141
+ return `/continue ${choice}`;
142
+ }
143
+
144
+ // How to show a command_ran: `label` names another client (null for this
145
+ // tab's own), `resync` says the conversation changed (!rollback, !cmd, a
146
+ // continue answered no) and must be read again. An anytime command's
147
+ // (`anytime`, D8) bubble is drawn at its command_queued, before the cards
148
+ // it shows; its command_ran fills that bubble in (`commandId`). So is a
149
+ // command queued for after the running turn (`queued`): its command_queued
150
+ // (waits: "turn_end") draws it `waiting`, saying so, until it ran or was
151
+ // dropped (styled as failed).
152
+ export const QUEUED_TEXT = "queued: runs after this turn";
153
+
154
+ export function commandView(event, myId) {
155
+ const own = isOwn(event.client_id, myId);
156
+ const waiting = event.waits === "turn_end";
157
+ return {
158
+ label: own ? null : clientLabel(event.client_id),
159
+ line: event.line || "",
160
+ text: event.output || (waiting ? QUEUED_TEXT : ""),
161
+ busy: event.status === "busy",
162
+ failed: event.status === "error" || event.status === "dropped",
163
+ resync: (event.changed || []).includes("messages"),
164
+ modelName: event.model_name || null,
165
+ anytime: event.anytime === true,
166
+ queued: waiting || event.queued === true,
167
+ waiting,
168
+ commandId: event.command_id || null,
169
+ // A card's action (Nudge…): no bubble unless it says something back.
170
+ hidden: event.card === true && !event.output && event.status !== "error",
171
+ };
172
+ }
173
+
174
+ // Whether a command_ran that re-read the conversation (`resync`) needs its
175
+ // own bubble: the redrawn history ends with a command bubble for its line
176
+ // (a `!cmd` appends its "!(<command>)\n<output>" message, which renders as
177
+ // one), so a second bubble would show the output twice. +historyBubbles+:
178
+ // the `.bubble.command` elements in document order; +line+: the event's
179
+ // line. Only the last one counts: it is the redrawn history's end.
180
+ export function historyHasCommandBubble(historyBubbles, line) {
181
+ const last = historyBubbles[historyBubbles.length - 1];
182
+ return !!last && last.querySelector(":scope > .command-line")?.textContent === line;
183
+ }
184
+
185
+ // A command bubble's line and output (again, when its command_ran fills in
186
+ // an anytime command's bubble in place).
187
+ export function drawCommandBubble(div, { label, line, text, busy = false, failed = false, waiting = false, commandId = null }) {
188
+ div.className = "bubble command" + (busy ? " busy" : "") + (failed ? " failed" : "") + (waiting ? " queued" : "");
189
+ if (commandId) div.dataset.commandId = commandId;
190
+ div.replaceChildren();
191
+ const head = document.createElement("div");
192
+ head.className = "command-line";
193
+ head.textContent = label ? `${label}> ${line}` : line;
194
+ div.appendChild(head);
195
+ if (text) {
196
+ const body = document.createElement("pre");
197
+ body.className = "command-output";
198
+ body.textContent = text;
199
+ div.appendChild(body);
200
+ }
201
+ }
202
+
203
+ // The prompt and command bubbles in the history, in order, by kind
204
+ // (kept_commands.js): a prompt, a `!cmd`'s (saved, so redrawn) or another.
205
+ function historyAnchors(historyEl) {
206
+ const els = [...historyEl.querySelectorAll(".bubble.user, .bubble.command")];
207
+ const kinds = els.map((el) => {
208
+ if (el.classList.contains("user")) return "user";
209
+ const line = el.querySelector(":scope > .command-line")?.textContent;
210
+ return !el.classList.contains("failed") && isShellCommandLine(line) ? "shell" : "other";
211
+ });
212
+ return { els, kinds };
213
+ }
214
+
215
+ // This session's own command bubbles (/help, /model …: drawn by this page,
216
+ // never saved) with their anchors, before a resync redraws the history.
217
+ // In-flight ones (busy, queued) are left out: the snapshot draws them again.
218
+ export function keptCommandBubbles(historyEl, id) {
219
+ const { els, kinds } = historyAnchors(historyEl);
220
+ return els
221
+ .map((el, i) => ({ el, anchor: commandAnchor(kinds, i) }))
222
+ .filter(({ el }) => el.dataset.keptFor === id && !el.classList.contains("busy") && !el.classList.contains("queued"))
223
+ .filter(({ el }) => keepsCommandBubble(el.querySelector(":scope > .command-line")?.textContent));
224
+ }
225
+
226
+ // The kept bubbles back into the redrawn history, each after its turn.
227
+ export function restoreCommandBubbles(historyEl, kept) {
228
+ if (!kept.length) return;
229
+ historyEl.querySelector(".hint")?.remove();
230
+ kept.forEach(({ el, anchor }) => {
231
+ const { els, kinds } = historyAnchors(historyEl);
232
+ const at = anchorInsertIndex(kinds, anchor);
233
+ if (at < els.length) els[at].before(el);
234
+ else historyEl.appendChild(el);
235
+ });
236
+ }
@@ -0,0 +1,101 @@
1
+ // The composer's line: where a typed line goes (routeComposerLine, pure),
2
+ // and a session command sent from it or from a control such as the llm ctx
3
+ // chip (createComposerCommands: no DOM, the page passes its hooks). Split
4
+ // out of app.js; plain JS, no markup.
5
+
6
+ import { sessionCommandLine, startPageReplyOrHint, steerChoiceRoute, unknownCommandHint, webLocalReply } from "./commands.js";
7
+
8
+ // Where a composer line goes, in the order the page reads it:
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.
12
+ // 2. On the start page (+session+ false): the page's own reply or a typo
13
+ // hint (startPageReplyOrHint) needs no session; anything else makes one.
14
+ // 3. In a session (routeSessionLine): a session command, the page's own
15
+ // reply to a terminal's command, a typo hint, else a turn.
16
+ // +images+ is the count of image chips: a line with images is a message.
17
+ // @return one of
18
+ // {reply, line} the page answers itself (a bubble in a session,
19
+ // a notice on the start page); nothing is sent
20
+ // {create: true, send, delivery} start page: make a session, then route
21
+ // +send+ again in it (routeSessionLine, with its
22
+ // commands)
23
+ // {command} POST /command
24
+ // {send, delivery} POST /turn (+delivery+ "cut" or null)
25
+ export function routeComposerLine(text, { session = true, running = false, commands = [], images = 0 } = {}) {
26
+ const choice = steerChoiceRoute(text, { running: running && session });
27
+ if (choice?.reply) return { reply: choice.reply, line: text };
28
+ const prompt = choice ? choice.send : text;
29
+ const delivery = choice?.delivery || null;
30
+ if (!session) {
31
+ const local = startPageReplyOrHint(prompt, { commands, images });
32
+ if (local) return { reply: local, line: prompt };
33
+ return { create: true, send: prompt, delivery };
34
+ }
35
+ return routeSessionLine(prompt, { commands, images, delivery });
36
+ }
37
+
38
+ // A line in a session, after its /cut or /queue was read: a session command
39
+ // by the session's list (+commands+), or the page's own reply to one of a
40
+ // terminal's; an unknown /word is a prompt, as in a terminal, but a typo of
41
+ // a command (/modle) gets the hint, not a turn.
42
+ export function routeSessionLine(prompt, { commands = [], images = 0, delivery = null } = {}) {
43
+ if (!images && sessionCommandLine(prompt, commands)) {
44
+ const local = webLocalReply(prompt);
45
+ return local ? { reply: local, line: prompt } : { command: prompt };
46
+ }
47
+ if (!images) {
48
+ const typo = unknownCommandHint(prompt, commands);
49
+ if (typo) return { reply: typo, line: prompt };
50
+ }
51
+ return { send: prompt, delivery };
52
+ }
53
+
54
+ // /model, !ls, /continue no … run in the session's worker; every client
55
+ // shows the command_ran, this one too.
56
+ // deps:
57
+ // session() the open session's id, or null
58
+ // clientId this tab's id (the command_ran's origin)
59
+ // sendCommand(id, line, {clientId}) POST /command (data.js)
60
+ // followWokenWorker() the command woke the worker: follow its stream
61
+ // refreshHistory()
62
+ // failed(line, text) a failed command's bubble
63
+ // ownedByTui() a plain terminal holds the session
64
+ // composer: {busy(), clear(), done()} the composer's send button and text
65
+ export function createComposerCommands(deps) {
66
+ const { session, clientId, sendCommand, followWokenWorker, refreshHistory, failed, ownedByTui, composer } = deps;
67
+
68
+ // A line typed in the composer (routeComposerLine's {command}).
69
+ async function send(line) {
70
+ composer.busy();
71
+ try {
72
+ await sendCommand(session(), line, { clientId });
73
+ composer.clear();
74
+ refreshHistory();
75
+ followWokenWorker();
76
+ } catch (e) {
77
+ if (e.code === "owned_by_tui") {
78
+ ownedByTui();
79
+ return;
80
+ }
81
+ failed(line, e.message);
82
+ } finally {
83
+ composer.done();
84
+ }
85
+ }
86
+
87
+ // A session command from a control (the llm ctx chip), not the composer:
88
+ // its command_ran shows its line and reply, as a typed one's would.
89
+ async function run(line) {
90
+ const id = session();
91
+ if (!id) return;
92
+ try {
93
+ await sendCommand(id, line, { clientId });
94
+ followWokenWorker();
95
+ } catch (e) {
96
+ failed(line, e.message);
97
+ }
98
+ }
99
+
100
+ return { send, run };
101
+ }
@@ -62,9 +62,11 @@ export function savedCtxPct(context, budgetTokens = null) {
62
62
  return (used / against) * 100;
63
63
  }
64
64
 
65
- // A session card's quiet ctx: "12%", or "" when the list row has none.
65
+ // A session card's quiet ctx: "12%", "ctx ?" when the list row has none (the
66
+ // chip is always shown; "ctx ?" never means 0 — no request in this session
67
+ // yet; a lone "?" beside the folder would read as "? project").
66
68
  export function cardCtxText(pct) {
67
- return num(pct) === null ? "" : `${Math.round(pct)}%`;
69
+ return num(pct) === null ? "ctx ?" : `${Math.round(pct)}%`;
68
70
  }
69
71
 
70
72
  // A decode speed: "87 tok/s", "1.9k tok/s"; "~64 tok/s" for an estimate (no
@@ -123,12 +125,24 @@ export function memoryIndexText(memoryIndex) {
123
125
 
124
126
  // The info bar's ctx: "ctx 12%", "ctx ~12%" when the window is chi's
125
127
  // default (no server, model list or setting gave one, so the percent is of
126
- // a guess); "" without a pct.
128
+ // a guess); "ctx ?" when there is no percent yet (no request in this session
129
+ // — the chip is always shown; "?" never means 0).
127
130
  export function ctxBarText(pct, windowSource = null) {
128
- if (num(pct) === null) return "";
131
+ if (num(pct) === null) return "ctx ?";
129
132
  return `ctx ${windowSource === "default" ? "~" : ""}${Math.round(pct)}%`;
130
133
  }
131
134
 
135
+ // The info bar's / the card's ctx tooltip: the window line under the title
136
+ // when there's a number (tokensTipText), or why there's none — "No request
137
+ // in this session yet" — when the page has no percent (before any request;
138
+ // the page isn't told context.status is off, and the window always resolves,
139
+ // so "?" never means 0).
140
+ export function ctxBarTip(tokens, memoryIndex, window) {
141
+ if (num(window?.pct) === null) return "No request in this session yet";
142
+
143
+ return tokensTipText(tokens, undefined, memoryIndex, window);
144
+ }
145
+
132
146
  // The ctx tooltip's window line ({tokens:, source:, pct:, budget:} the
133
147
  // session's window, where it came from, the meter's value and the LLM
134
148
  // context budget): "context: ~41.0k of 128.0k tokens (server)", as /stats
@@ -29,8 +29,33 @@ export function diffLineClass(line) {
29
29
  return "ctx";
30
30
  }
31
31
 
32
- function linesHtml(lines) {
33
- return lines.map((line) => `<span class="diff-line diff-${diffLineClass(line)}">${escapeHtml(line) || " "}</span>`).join("");
32
+ // Each line's line number in the file after the change (the new side),
33
+ // from the hunk headers ("@@ -a,b +c,d @@"): a hunk header its hunk's
34
+ // start, a context or added line its own, a removed line the line it was
35
+ // removed before; null outside a hunk and for a meta line ("\ No newline
36
+ // at end of file", "… N more lines"). An emptied file's hunk (+0,0) gives
37
+ // 0, which no line carries.
38
+ export function diffLineNumbers(lines) {
39
+ let next = null;
40
+ return lines.map((line) => {
41
+ const kind = diffLineClass(line);
42
+ if (kind === "hunk") {
43
+ const m = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(line);
44
+ next = m ? Number(m[1]) : null;
45
+ return next;
46
+ }
47
+ if (next === null || kind === "meta") return null;
48
+ if (kind === "del") return next;
49
+ return next++;
50
+ });
51
+ }
52
+
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("");
34
59
  }
35
60
 
36
61
  // @param diff an EditPreview: a diff, or {error} / {skipped}
@@ -43,11 +68,12 @@ export function diffHtml(diff, { fold = DIFF_FOLD_LINES } = {}) {
43
68
  if (!text) return `<div class="diff-note">no change</div>`;
44
69
  const lines = text.split("\n");
45
70
  const head = diff.new_file ? `<div class="diff-note">new file</div>` : "";
46
- const shown = `<pre class="diff"><code>${linesHtml(lines.slice(0, fold))}</code></pre>`;
71
+ const numbers = diffLineNumbers(lines);
72
+ const shown = `<pre class="diff"><code>${linesHtml(lines.slice(0, fold), numbers.slice(0, fold))}</code></pre>`;
47
73
  if (lines.length <= fold) return head + shown;
48
74
  // The rest opens under the first lines; the toggle hides once open (CSS).
49
75
  const rest = `<details class="diff-more"><summary>show all ${lines.length} lines</summary>` +
50
- `<pre class="diff"><code>${linesHtml(lines.slice(fold))}</code></pre></details>`;
76
+ `<pre class="diff"><code>${linesHtml(lines.slice(fold), numbers.slice(fold))}</code></pre></details>`;
51
77
  return head + shown + rest;
52
78
  }
53
79
 
@@ -136,7 +136,8 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
136
136
  /* A row's status is a mark in a 14 px slot, drawn from its class (no text: a copy or an annotate quote gets
137
137
  no glyph; the word is its aria-label and title). The glyphs the stage already uses: done ✓, error and
138
138
  blocked ✕, stopped ■, running its border spinner (still once the turn is over: a dead worker's row).
139
- An exception also shows its word after the title (.activity-state). */
139
+ An exception also shows its word after the title (.activity-state); a done grep that found nothing a muted
140
+ "no match" (.no-match). */
140
141
  .activity-status{width:14px;text-align:center;font-size:12px;font-weight:700}
141
142
  .activity-status.ok::before{content:"\2713";opacity:.85}
142
143
  .activity-status.error::before,.activity-status.blocked::before{content:"\2715"}
@@ -149,6 +150,7 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
149
150
  :is(.activity-status,.activity-state).error{color:var(--danger)}
150
151
  :is(.activity-status,.activity-state).stopped{color:var(--warn-text);opacity:.8}
151
152
  :is(.activity-status,.activity-state).blocked{color:var(--danger-text)}
153
+ .activity-state.no-match{color:var(--text-muted);font-weight:500}
152
154
  .activity-tool{color:var(--accent);font-weight:600}
153
155
  /* The stop-task button on a running task_wait (task_stop.js): a small ghost
154
156
  button in the danger colours, at the row's end and in the stage's now slot. */
@@ -328,7 +330,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
328
330
  .question-freeform{width:100%;box-sizing:border-box;background:var(--bg-inset);border:1px solid var(--border);color:var(--text);padding:8px 10px;border-radius:8px;font-size:13px;margin-bottom:8px}
329
331
  .question-error{color:var(--danger);font-size:12px;margin-bottom:8px}
330
332
  .question-error.hidden{display:none}
331
- .question-result{margin-top:6px;font-size:12px;color:var(--ok);font-weight:600}
333
+ .question-result{margin-top:6px;font-size:12px;color:var(--ok);font-weight:600;white-space:pre-wrap;overflow-wrap:anywhere}
332
334
  /* 4.02: a resolved card whose summary already carries the result hides the
333
335
  body line; a hand-toggled one keeps it (its expanded body shows it). */
334
336
  .question-result.hidden{display:none}
@@ -363,6 +365,11 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
363
365
  .diff{margin:0;padding:4px 0;background:var(--bg-inset);border:1px solid var(--border);border-radius:6px;font-size:12px;line-height:1.45;overflow:auto;max-height:420px}
364
366
  .diff code{display:block;min-width:max-content}
365
367
  .diff-line{display:block;padding:0 10px;white-space:pre;color:var(--text-2)}
368
+ /* Refs (refs.js): text until hovered, and only for a kind this viewer has an action for (body.refs-file). */
369
+ .refs-file .ref[data-ref-kind="file"]:hover{color:var(--accent);text-decoration:underline dotted;text-underline-offset:3px;cursor:pointer}
370
+ .refs-file .ref[data-ref-kind="file"]:hover::after{content:"\2197";margin-left:.3em;color:var(--text-faint);display:inline-block}
371
+ .refs-file .activity-row:has(.ref[data-ref-kind="file"]) .diff-line[data-line]:hover{cursor:pointer;box-shadow:inset 2px 0 var(--accent)}
372
+ .refs-file .activity-row:has(.ref[data-ref-kind="file"]) .diff-line[data-line]:hover::after{content:"\2197 " attr(data-line);float:right;padding-left:12px;color:var(--text-faint)}
366
373
  .diff-add{background:var(--diff-add-bg);color:var(--diff-add-text)}
367
374
  .diff-del{background:var(--diff-del-bg);color:var(--diff-del-text)}
368
375
  .diff-hunk{color:var(--text-faint)}
@@ -0,0 +1,78 @@
1
+ // The session bar's info line (#infoText): the open session's id, its copy
2
+ // chi --attach button, status, model, chain links, parent and delegates,
3
+ // llm ctx and thinking chips, model notes, memories, folder, ctx, speed,
4
+ // session clock and the worker's version badge. A lit-html template over a
5
+ // plain model app.js builds (updateInfoBar), so the markup lives here and
6
+ // runs under node --test (spec/web/public/info_bar.test.js).
7
+ //
8
+ // It is redrawn every second while a turn runs (the session clock): lit
9
+ // writes only what changed, so a shown tooltip stays up, a press on one of
10
+ // its buttons isn't lost to a rewrite, and the chips' popovers keep their
11
+ // anchor (clicks on it are delegated, app.js and the chip controls).
12
+
13
+ import { html, nothing, render } from "./vendor/lit-html.js";
14
+ import { memChipText, memChipTitle } from "./format.js";
15
+ import { ALL_SESSIONS_HASH, sessionHash } from "./route.js";
16
+ import { chipTemplate as llmContextChip } from "./llm_context_chip.js";
17
+ import { chipTemplate as thinkingChip } from "./thinking_chip.js";
18
+
19
+ export const PREV = "←";
20
+ export const NEXT = "→";
21
+
22
+ // A chain's ← / → link (chains.js chainNeighbours): its date (else its short
23
+ // id), its preview and id in the tooltip. +link+: {id, date, preview,
24
+ // archived}.
25
+ export function chainLinkTemplate(link, arrow, what) {
26
+ const short = String(link.id).slice(0, 8);
27
+ const title = `${what}: ${short}${link.preview ? ` · ${link.preview}` : ""}${link.archived ? " (archived)" : ""}`;
28
+ const text = arrow === PREV ? `${arrow} ${link.date || short}` : `${link.date || short} ${arrow}`;
29
+ return html`<a class="model chain-link" href=${sessionHash(link.id)} title=${title}>${text}</a>`;
30
+ }
31
+
32
+ // `memories: a, b · preloaded: c · muted: d`: the used memories always,
33
+ // the session's --memory and --mute lists only when there are any.
34
+ export function memoriesTooltip(used, preloaded, muted) {
35
+ const parts = [`memories: ${(used || []).join(", ") || "—"}`];
36
+ if (preloaded && preloaded.length) parts.push(`preloaded: ${preloaded.join(", ")}`);
37
+ if (muted && muted.length) parts.push(`muted: ${muted.join(", ")}`);
38
+ return parts.join(" · ");
39
+ }
40
+
41
+ // The open session's worker runs an older chi than the newest installed
42
+ // (update_notice.js workerBadge): its badge, with Restart when it can.
43
+ function workerBadgeTemplate(worker) {
44
+ if (!worker?.badge) return nothing;
45
+ const { badge, busy } = worker;
46
+ const button = badge.restart
47
+ ? html`<button type="button" class="worker-restart" ?disabled=${busy}>${busy ? "restarting…" : "Restart"}</button>`
48
+ : nothing;
49
+ return html`<span class="worker-badge" title=${badge.title}>${badge.text}${button}</span>`;
50
+ }
51
+
52
+ function kidsTemplate(kids) {
53
+ if (!kids) return nothing;
54
+ const waiting = kids.waitingText ? html` · <span class="kids-waiting">${kids.waitingText}</span>` : nothing;
55
+ return html`<a class=${`model children-chip${kids.waitingText ? " waiting" : ""}`} href=${ALL_SESSIONS_HASH} title=${`${kids.title} (all sessions)`}>${kids.text}${waiting}</a>`;
56
+ }
57
+
58
+ // The info line for +bar+, nothing without a session (the start page hides
59
+ // the bar). +bar+:
60
+ // id, copied (the copy button just copied), status, model ({text, title,
61
+ // mismatch}: format.js modelLabel), chain ({prev, next}: chainLinkTemplate's
62
+ // links or null), parent ({id, title} or null), kids (format.js
63
+ // childrenSummary), llmContext and thinking (the chips' summaries), notes
64
+ // ({text, title} or null), memories (the used names), dir, ctx ({text,
65
+ // tip}), speed ({text, tip} or null), clock (the session time's text, ""
66
+ // for none), worker ({badge, busy} or null).
67
+ export function infoBarHtml(bar) {
68
+ if (!bar) return nothing;
69
+ const attach = `chi --attach ${bar.id}`;
70
+ const memories = bar.memories || [];
71
+ return html`<div class="meta"><span class="id" title=${bar.id}>${String(bar.id).slice(0, 8)}</span><button type="button" class="copy-attach" title=${`Copy "${attach}" to attach a terminal`}>${bar.copied ? "copied ✓" : "copy chi --attach"}</button><span class=${bar.status ? `status ${bar.status}` : "status"}>${bar.status || ""}</span><span class=${`model${bar.model.mismatch ? " served-mismatch" : ""}`} title=${bar.model.title}>${bar.model.text}</span>${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>delegated by ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${llmContextChip(bar.llmContext)}${thinkingChip(bar.thinking)}${bar.notes ? html`<span class="model notes-chip" title=${bar.notes.title}>${bar.notes.text}</span>` : nothing}${memories.length ? html`<span class="model mem-chip" title=${memChipTitle(memories)}>${memChipText(memories)}</span>` : nothing}<span class="dir" title=${bar.dir || ""}><bdi>${bar.dir || ""}</bdi></span><span class="model ctx" title=${bar.ctx.tip}>${bar.ctx.text}</span>${bar.speed ? html`<span class="model speed" title=${bar.speed.tip}>${bar.speed.text}</span>` : nothing}${bar.clock ? html`<span class="model session-time">${bar.clock}</span>` : nothing}${workerBadgeTemplate(bar.worker)}</div>`;
72
+ }
73
+
74
+ // Draws +bar+ into +el+ (#infoText), with the memories in its tooltip.
75
+ export function renderInfoBar(el, bar) {
76
+ render(infoBarHtml(bar), el);
77
+ el.title = bar ? memoriesTooltip(bar.memories, bar.preloaded, bar.muted) : "";
78
+ }