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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +6 -2
- data/docs/cli.md +29 -7
- data/docs/configuration.md +113 -8
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/context-telemetry.md +23 -5
- data/docs/internals/engine.md +54 -18
- data/docs/internals/prompt-caching.md +8 -0
- data/docs/sessions.md +2 -2
- data/docs/testing.md +23 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +6 -6
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cache_confirm.rb +51 -0
- data/lib/samagotchi/client_id.rb +12 -0
- data/lib/samagotchi/config.rb +9 -3
- data/lib/samagotchi/engine.rb +102 -8
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/file_ref.rb +59 -0
- data/lib/samagotchi/host_config.rb +6 -3
- data/lib/samagotchi/host_registry.rb +4 -3
- data/lib/samagotchi/kernel_loop.rb +99 -16
- data/lib/samagotchi/llm/chat_loop.rb +122 -71
- data/lib/samagotchi/llm/openai_chat.rb +2 -1
- data/lib/samagotchi/llm/stream_drops.rb +63 -0
- data/lib/samagotchi/llm/turn_settings.rb +9 -5
- data/lib/samagotchi/model_settings.rb +5 -2
- data/lib/samagotchi/reasoning_history.rb +132 -0
- data/lib/samagotchi/reasoning_history_command.rb +107 -0
- data/lib/samagotchi/self_report.rb +21 -0
- data/lib/samagotchi/session.rb +23 -2
- data/lib/samagotchi/session_commands.rb +55 -15
- data/lib/samagotchi/session_manager.rb +2 -1
- data/lib/samagotchi/session_setup.rb +7 -5
- data/lib/samagotchi/setting_explained.rb +20 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +11 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
- data/lib/samagotchi/terminal_ui.rb +13 -6
- data/lib/samagotchi/thinking.rb +20 -25
- data/lib/samagotchi/thinking_command.rb +33 -11
- data/lib/samagotchi/tool_row_fields.rb +46 -0
- data/lib/samagotchi/tool_runner.rb +7 -11
- data/lib/samagotchi/tools/builtins.rb +6 -2
- data/lib/samagotchi/tools/peers.rb +4 -2
- data/lib/samagotchi/tools/task_runtime.rb +2 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +48 -4
- data/lib/samagotchi/web/capabilities.rb +13 -9
- data/lib/samagotchi/web/editor.rb +38 -0
- data/lib/samagotchi/web/message_parts.rb +5 -10
- data/lib/samagotchi/web/public/activity.js +33 -18
- data/lib/samagotchi/web/public/app.js +256 -1133
- data/lib/samagotchi/web/public/commands.js +269 -0
- data/lib/samagotchi/web/public/composer.js +105 -0
- data/lib/samagotchi/web/public/diff_view.js +30 -4
- data/lib/samagotchi/web/public/index.html +11 -3
- data/lib/samagotchi/web/public/info_bar.js +80 -0
- data/lib/samagotchi/web/public/level_chip.js +174 -0
- data/lib/samagotchi/web/public/lists_view.js +725 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
- data/lib/samagotchi/web/public/model_pick.js +1 -1
- data/lib/samagotchi/web/public/question_cards.js +2 -1
- data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
- data/lib/samagotchi/web/public/refs.js +134 -0
- data/lib/samagotchi/web/public/restore.js +81 -0
- data/lib/samagotchi/web/public/session_state.js +57 -0
- data/lib/samagotchi/web/public/stream_handlers.js +332 -0
- data/lib/samagotchi/web/public/thinking_chip.js +38 -144
- data/lib/samagotchi/web/public/turn_events.js +17 -258
- data/lib/samagotchi/web/public/turn_html.js +5 -1
- data/lib/samagotchi/web/server.rb +4 -4
- data/lib/samagotchi/worker.rb +17 -5
- metadata +18 -2
- 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
|
-
|
|
33
|
-
|
|
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
|
|
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
|
+
}
|