samagotchi 0.54.0 → 0.56.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/bin/chi +3 -2
  4. data/docs/architecture.md +6 -2
  5. data/docs/cli.md +29 -7
  6. data/docs/configuration.md +113 -8
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/context-telemetry.md +23 -5
  9. data/docs/internals/engine.md +54 -18
  10. data/docs/internals/prompt-caching.md +8 -0
  11. data/docs/sessions.md +2 -2
  12. data/docs/testing.md +23 -3
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -6
  14. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  15. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  16. data/lib/samagotchi/cache_confirm.rb +51 -0
  17. data/lib/samagotchi/client_id.rb +12 -0
  18. data/lib/samagotchi/config.rb +9 -3
  19. data/lib/samagotchi/engine.rb +102 -8
  20. data/lib/samagotchi/engine_peers.rb +1 -0
  21. data/lib/samagotchi/file_ref.rb +59 -0
  22. data/lib/samagotchi/host_config.rb +6 -3
  23. data/lib/samagotchi/host_registry.rb +4 -3
  24. data/lib/samagotchi/kernel_loop.rb +99 -16
  25. data/lib/samagotchi/llm/chat_loop.rb +122 -71
  26. data/lib/samagotchi/llm/openai_chat.rb +2 -1
  27. data/lib/samagotchi/llm/stream_drops.rb +63 -0
  28. data/lib/samagotchi/llm/turn_settings.rb +9 -5
  29. data/lib/samagotchi/model_settings.rb +5 -2
  30. data/lib/samagotchi/reasoning_history.rb +132 -0
  31. data/lib/samagotchi/reasoning_history_command.rb +107 -0
  32. data/lib/samagotchi/self_report.rb +21 -0
  33. data/lib/samagotchi/session.rb +23 -2
  34. data/lib/samagotchi/session_commands.rb +55 -15
  35. data/lib/samagotchi/session_manager.rb +2 -1
  36. data/lib/samagotchi/session_setup.rb +7 -5
  37. data/lib/samagotchi/setting_explained.rb +20 -0
  38. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -1
  39. data/lib/samagotchi/terminal_ui/attached_loop.rb +11 -0
  40. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  41. data/lib/samagotchi/terminal_ui.rb +13 -6
  42. data/lib/samagotchi/thinking.rb +20 -25
  43. data/lib/samagotchi/thinking_command.rb +33 -11
  44. data/lib/samagotchi/tool_row_fields.rb +46 -0
  45. data/lib/samagotchi/tool_runner.rb +7 -11
  46. data/lib/samagotchi/tools/builtins.rb +6 -2
  47. data/lib/samagotchi/tools/peers.rb +4 -2
  48. data/lib/samagotchi/tools/task_runtime.rb +2 -1
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +48 -4
  51. data/lib/samagotchi/web/capabilities.rb +13 -9
  52. data/lib/samagotchi/web/editor.rb +38 -0
  53. data/lib/samagotchi/web/message_parts.rb +5 -10
  54. data/lib/samagotchi/web/public/activity.js +33 -18
  55. data/lib/samagotchi/web/public/app.js +256 -1133
  56. data/lib/samagotchi/web/public/commands.js +269 -0
  57. data/lib/samagotchi/web/public/composer.js +105 -0
  58. data/lib/samagotchi/web/public/diff_view.js +30 -4
  59. data/lib/samagotchi/web/public/index.html +11 -3
  60. data/lib/samagotchi/web/public/info_bar.js +80 -0
  61. data/lib/samagotchi/web/public/level_chip.js +174 -0
  62. data/lib/samagotchi/web/public/lists_view.js +725 -0
  63. data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
  64. data/lib/samagotchi/web/public/model_pick.js +1 -1
  65. data/lib/samagotchi/web/public/question_cards.js +2 -1
  66. data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
  67. data/lib/samagotchi/web/public/refs.js +134 -0
  68. data/lib/samagotchi/web/public/restore.js +81 -0
  69. data/lib/samagotchi/web/public/session_state.js +57 -0
  70. data/lib/samagotchi/web/public/stream_handlers.js +332 -0
  71. data/lib/samagotchi/web/public/thinking_chip.js +38 -144
  72. data/lib/samagotchi/web/public/turn_events.js +17 -258
  73. data/lib/samagotchi/web/public/turn_html.js +5 -1
  74. data/lib/samagotchi/web/server.rb +4 -4
  75. data/lib/samagotchi/worker.rb +17 -5
  76. metadata +18 -2
  77. data/lib/samagotchi/web/public/stable_html.js +0 -18
@@ -0,0 +1,269 @@
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). A change that restarts the prompt cache asks
152
+ // first (CacheConfirm): `confirm` is the line that applies it, and this
153
+ // tab's own bubble (`own`) gets an Apply button that sends it.
154
+ export const QUEUED_TEXT = "queued: runs after this turn";
155
+ export const APPLY_TEXT = "Apply (restarts the cache)";
156
+
157
+ export function commandView(event, myId) {
158
+ const own = isOwn(event.client_id, myId);
159
+ const waiting = event.waits === "turn_end";
160
+ return {
161
+ own,
162
+ label: own ? null : clientLabel(event.client_id),
163
+ line: event.line || "",
164
+ text: event.output || (waiting ? QUEUED_TEXT : ""),
165
+ busy: event.status === "busy",
166
+ failed: event.status === "error" || event.status === "dropped",
167
+ resync: (event.changed || []).includes("messages"),
168
+ modelName: event.model_name || null,
169
+ anytime: event.anytime === true,
170
+ queued: waiting || event.queued === true,
171
+ waiting,
172
+ commandId: event.command_id || null,
173
+ confirm: typeof event.confirm?.line === "string" ? event.confirm.line : null,
174
+ // A card's action (Nudge…): no bubble unless it says something back.
175
+ hidden: event.card === true && !event.output && event.status !== "error",
176
+ };
177
+ }
178
+
179
+ // Whether a command_ran that re-read the conversation (`resync`) needs its
180
+ // own bubble: the redrawn history ends with a command bubble for its line
181
+ // (a `!cmd` appends its "!(<command>)\n<output>" message, which renders as
182
+ // one), so a second bubble would show the output twice. +historyBubbles+:
183
+ // the `.bubble.command` elements in document order; +line+: the event's
184
+ // line. Only the last one counts: it is the redrawn history's end.
185
+ export function historyHasCommandBubble(historyBubbles, line) {
186
+ const last = historyBubbles[historyBubbles.length - 1];
187
+ return !!last && last.querySelector(":scope > .command-line")?.textContent === line;
188
+ }
189
+
190
+ // A command bubble's line and output (again, when its command_ran fills in
191
+ // an anytime command's bubble in place), and this tab's own change's Apply
192
+ // button when it asks first (a delegated click sends its line: app.js).
193
+ // Command bubbles are page-only: after a reload the button is gone, and
194
+ // the change is typed again.
195
+ export function drawCommandBubble(div, { label, line, text, busy = false, failed = false, waiting = false, commandId = null,
196
+ own = false, confirm = null }) {
197
+ div.className = "bubble command" + (busy ? " busy" : "") + (failed ? " failed" : "") + (waiting ? " queued" : "");
198
+ if (commandId) div.dataset.commandId = commandId;
199
+ div.replaceChildren();
200
+ const head = document.createElement("div");
201
+ head.className = "command-line";
202
+ head.textContent = label ? `${label}> ${line}` : line;
203
+ div.appendChild(head);
204
+ if (text) {
205
+ const body = document.createElement("pre");
206
+ body.className = "command-output";
207
+ body.textContent = text;
208
+ div.appendChild(body);
209
+ }
210
+ if (own && confirm) {
211
+ const apply = document.createElement("button");
212
+ apply.type = "button";
213
+ apply.className = "command-confirm";
214
+ apply.dataset.confirmLine = confirm;
215
+ apply.title = confirm;
216
+ apply.textContent = APPLY_TEXT;
217
+ div.appendChild(apply);
218
+ }
219
+ }
220
+
221
+ // A click on a bubble's Apply button: sends its --yes line once (the
222
+ // button is disabled at once), and enables it again when the line wasn't
223
+ // sent (+run+ resolves false or throws), so the user can try again.
224
+ export async function applyConfirm(button, run) {
225
+ if (!button || button.disabled) return;
226
+ button.disabled = true;
227
+ let sent = false;
228
+ try {
229
+ sent = await run(button.dataset.confirmLine);
230
+ } catch {
231
+ sent = false;
232
+ }
233
+ if (!sent) button.disabled = false;
234
+ }
235
+
236
+ // The prompt and command bubbles in the history, in order, by kind
237
+ // (kept_commands.js): a prompt, a `!cmd`'s (saved, so redrawn) or another.
238
+ function historyAnchors(historyEl) {
239
+ const els = [...historyEl.querySelectorAll(".bubble.user, .bubble.command")];
240
+ const kinds = els.map((el) => {
241
+ if (el.classList.contains("user")) return "user";
242
+ const line = el.querySelector(":scope > .command-line")?.textContent;
243
+ return !el.classList.contains("failed") && isShellCommandLine(line) ? "shell" : "other";
244
+ });
245
+ return { els, kinds };
246
+ }
247
+
248
+ // This session's own command bubbles (/help, /model …: drawn by this page,
249
+ // never saved) with their anchors, before a resync redraws the history.
250
+ // In-flight ones (busy, queued) are left out: the snapshot draws them again.
251
+ export function keptCommandBubbles(historyEl, id) {
252
+ const { els, kinds } = historyAnchors(historyEl);
253
+ return els
254
+ .map((el, i) => ({ el, anchor: commandAnchor(kinds, i) }))
255
+ .filter(({ el }) => el.dataset.keptFor === id && !el.classList.contains("busy") && !el.classList.contains("queued"))
256
+ .filter(({ el }) => keepsCommandBubble(el.querySelector(":scope > .command-line")?.textContent));
257
+ }
258
+
259
+ // The kept bubbles back into the redrawn history, each after its turn.
260
+ export function restoreCommandBubbles(historyEl, kept) {
261
+ if (!kept.length) return;
262
+ historyEl.querySelector(".hint")?.remove();
263
+ kept.forEach(({ el, anchor }) => {
264
+ const { els, kinds } = historyAnchors(historyEl);
265
+ const at = anchorInsertIndex(kinds, anchor);
266
+ if (at < els.length) els[at].before(el);
267
+ else historyEl.appendChild(el);
268
+ });
269
+ }
@@ -0,0 +1,105 @@
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
+ // Resolves true once the worker took it, false when it wasn't sent (no
90
+ // session, a refusal or a network error: the failed bubble says why).
91
+ async function run(line) {
92
+ const id = session();
93
+ if (!id) return false;
94
+ try {
95
+ await sendCommand(id, line, { clientId });
96
+ followWokenWorker();
97
+ return true;
98
+ } catch (e) {
99
+ failed(line, e.message);
100
+ return false;
101
+ }
102
+ }
103
+
104
+ return { send, run };
105
+ }
@@ -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
 
@@ -307,6 +307,9 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
307
307
  .bubble.command.queued .command-output{color:var(--text-faint)}
308
308
  .command-line{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-3)}
309
309
  .command-output{margin:6px 0 0;white-space:pre-wrap;font-size:12px;color:var(--text-2)}
310
+ .bubble.command .command-confirm{margin-top:8px;padding:3px 10px;border-radius:6px;font:inherit;font-size:12px;cursor:pointer;background:var(--accent-bg);color:var(--accent);border:1px solid var(--accent-border)}
311
+ .bubble.command .command-confirm:hover:not(:disabled){border-color:var(--accent)}
312
+ .bubble.command .command-confirm:disabled{opacity:.55;cursor:default}
310
313
  .bubble.continue{border-left:3px solid var(--accent);background:var(--bg-panel);padding:10px 12px}
311
314
  .bubble.continue.resolved{border-left-color:var(--text-muted);opacity:.8}
312
315
  .continue-text{font-size:14px;color:var(--text);margin-bottom:6px}
@@ -365,6 +368,11 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
365
368
  .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}
366
369
  .diff code{display:block;min-width:max-content}
367
370
  .diff-line{display:block;padding:0 10px;white-space:pre;color:var(--text-2)}
371
+ /* Refs (refs.js): text until hovered, and only for a kind this viewer has an action for (body.refs-file). */
372
+ .refs-file .ref[data-ref-kind="file"]:hover{color:var(--accent);text-decoration:underline dotted;text-underline-offset:3px;cursor:pointer}
373
+ .refs-file .ref[data-ref-kind="file"]:hover::after{content:"\2197";margin-left:.3em;color:var(--text-faint);display:inline-block}
374
+ .refs-file .activity-row:has(.ref[data-ref-kind="file"]) .diff-line[data-line]:hover{cursor:pointer;box-shadow:inset 2px 0 var(--accent)}
375
+ .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)}
368
376
  .diff-add{background:var(--diff-add-bg);color:var(--diff-add-text)}
369
377
  .diff-del{background:var(--diff-del-bg);color:var(--diff-del-text)}
370
378
  .diff-hunk{color:var(--text-faint)}
@@ -699,9 +707,9 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
699
707
  #infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
700
708
  #infoBar #infoDeleteBtn:hover{color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
701
709
  /* The LLM context and thinking chips (llm_context_chip.js, thinking_chip.js): model-like chips that open their popovers; accent when the session set its own. */
702
- #infoBar .meta .llmctx-chip,#infoBar .meta .think-chip{padding:2px 6px;border-radius:6px;font-size:11px;font-weight:400;border:1px solid transparent;cursor:pointer;font-family:inherit;white-space:nowrap}
703
- #infoBar .meta .llmctx-chip:hover,#infoBar .meta .think-chip:hover{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
704
- #infoBar .meta .llmctx-chip.own,#infoBar .meta .think-chip.own{color:var(--accent);border-color:var(--accent-border)}
710
+ #infoBar .meta .llmctx-chip,#infoBar .meta .think-chip,#infoBar .meta .rh-chip{padding:2px 6px;border-radius:6px;font-size:11px;font-weight:400;border:1px solid transparent;cursor:pointer;font-family:inherit;white-space:nowrap}
711
+ #infoBar .meta .llmctx-chip:hover,#infoBar .meta .think-chip:hover,#infoBar .meta .rh-chip:hover{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
712
+ #infoBar .meta .llmctx-chip.own,#infoBar .meta .think-chip.own,#infoBar .meta .rh-chip.own{color:var(--accent);border-color:var(--accent-border)}
705
713
  .llmctx-form{display:flex;flex-direction:column;gap:8px;margin:0}
706
714
  .llmctx-form .ctx-pop-head strong{font-family:inherit}
707
715
  .llmctx-field{display:flex;align-items:center;gap:8px}
@@ -0,0 +1,80 @@
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, thinking and reasoning history 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
+ import { chipTemplate as reasoningHistoryChip } from "./reasoning_history_chip.js";
19
+
20
+ export const PREV = "←";
21
+ export const NEXT = "→";
22
+
23
+ // A chain's ← / → link (chains.js chainNeighbours): its date (else its short
24
+ // id), its preview and id in the tooltip. +link+: {id, date, preview,
25
+ // archived}.
26
+ export function chainLinkTemplate(link, arrow, what) {
27
+ const short = String(link.id).slice(0, 8);
28
+ const title = `${what}: ${short}${link.preview ? ` · ${link.preview}` : ""}${link.archived ? " (archived)" : ""}`;
29
+ const text = arrow === PREV ? `${arrow} ${link.date || short}` : `${link.date || short} ${arrow}`;
30
+ return html`<a class="model chain-link" href=${sessionHash(link.id)} title=${title}>${text}</a>`;
31
+ }
32
+
33
+ // `memories: a, b · preloaded: c · muted: d`: the used memories always,
34
+ // the session's --memory and --mute lists only when there are any.
35
+ export function memoriesTooltip(used, preloaded, muted) {
36
+ const parts = [`memories: ${(used || []).join(", ") || "—"}`];
37
+ if (preloaded && preloaded.length) parts.push(`preloaded: ${preloaded.join(", ")}`);
38
+ if (muted && muted.length) parts.push(`muted: ${muted.join(", ")}`);
39
+ return parts.join(" · ");
40
+ }
41
+
42
+ // The open session's worker runs an older chi than the newest installed
43
+ // (update_notice.js workerBadge): its badge, with Restart when it can.
44
+ function workerBadgeTemplate(worker) {
45
+ if (!worker?.badge) return nothing;
46
+ const { badge, busy } = worker;
47
+ const button = badge.restart
48
+ ? html`<button type="button" class="worker-restart" ?disabled=${busy}>${busy ? "restarting…" : "Restart"}</button>`
49
+ : nothing;
50
+ return html`<span class="worker-badge" title=${badge.title}>${badge.text}${button}</span>`;
51
+ }
52
+
53
+ function kidsTemplate(kids) {
54
+ if (!kids) return nothing;
55
+ const waiting = kids.waitingText ? html` · <span class="kids-waiting">${kids.waitingText}</span>` : nothing;
56
+ return html`<a class=${`model children-chip${kids.waitingText ? " waiting" : ""}`} href=${ALL_SESSIONS_HASH} title=${`${kids.title} (all sessions)`}>${kids.text}${waiting}</a>`;
57
+ }
58
+
59
+ // The info line for +bar+, nothing without a session (the start page hides
60
+ // the bar). +bar+:
61
+ // id, copied (the copy button just copied), status, model ({text, title,
62
+ // mismatch}: format.js modelLabel), chain ({prev, next}: chainLinkTemplate's
63
+ // links or null), parent ({id, title} or null), kids (format.js
64
+ // childrenSummary), llmContext, thinking and reasoningHistory (the chips'
65
+ // summaries; reasoningHistory null on a native host: no chip), notes
66
+ // ({text, title} or null), memories (the used names), dir, ctx ({text,
67
+ // tip}), speed ({text, tip} or null), clock (the session time's text, ""
68
+ // for none), worker ({badge, busy} or null).
69
+ export function infoBarHtml(bar) {
70
+ if (!bar) return nothing;
71
+ const attach = `chi --attach ${bar.id}`;
72
+ const memories = bar.memories || [];
73
+ 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)}${reasoningHistoryChip(bar.reasoningHistory)}${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>`;
74
+ }
75
+
76
+ // Draws +bar+ into +el+ (#infoText), with the memories in its tooltip.
77
+ export function renderInfoBar(el, bar) {
78
+ render(infoBarHtml(bar), el);
79
+ el.title = bar ? memoriesTooltip(bar.memories, bar.preloaded, bar.muted) : "";
80
+ }