samagotchi 0.51.0 → 0.53.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 +72 -1
- data/README.md +1 -1
- data/bin/chi +11 -3
- data/docs/architecture.md +1 -1
- data/docs/cli.md +23 -8
- data/docs/configuration.md +59 -19
- data/docs/memory.md +11 -0
- data/docs/plugins.md +2 -1
- data/docs/releasing.md +6 -3
- data/docs/sessions.md +5 -1
- data/docs/testing.md +8 -1
- data/lib/samagotchi/analytics_file.rb +35 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +6 -37
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge.rb +10 -13
- data/lib/samagotchi/broadcast/recipients.rb +1 -2
- data/lib/samagotchi/bundle_command.rb +9 -13
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/children_status.rb +1 -1
- data/lib/samagotchi/commands/registry.rb +2 -1
- data/lib/samagotchi/config.rb +50 -182
- data/lib/samagotchi/config_text_edit.rb +8 -32
- data/lib/samagotchi/context_sources.rb +0 -7
- data/lib/samagotchi/context_window.rb +2 -2
- data/lib/samagotchi/continue_offer.rb +5 -4
- data/lib/samagotchi/cut_policy.rb +0 -1
- data/lib/samagotchi/delivery.rb +23 -0
- data/lib/samagotchi/engine.rb +63 -18
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +9 -8
- data/lib/samagotchi/host_config.rb +183 -0
- data/lib/samagotchi/host_registry.rb +18 -10
- data/lib/samagotchi/last_turn.rb +48 -0
- data/lib/samagotchi/live_versions.rb +0 -6
- data/lib/samagotchi/llm/native_tool_normalizer.rb +0 -5
- data/lib/samagotchi/llm_context_override.rb +3 -22
- data/lib/samagotchi/llm_context_strategy.rb +50 -16
- data/lib/samagotchi/memory_bundle/builder.rb +23 -21
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +51 -0
- data/lib/samagotchi/memory_bundle/installer.rb +4 -4
- data/lib/samagotchi/memory_bundle/manifest.rb +6 -28
- data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
- data/lib/samagotchi/memory_bundle/provenance.rb +8 -17
- data/lib/samagotchi/model_profile.rb +4 -4
- data/lib/samagotchi/model_settings.rb +46 -0
- data/lib/samagotchi/plugin/context.rb +3 -3
- data/lib/samagotchi/plugin/sessions.rb +2 -2
- data/lib/samagotchi/queued_command.rb +35 -0
- data/lib/samagotchi/reminder_store.rb +0 -27
- data/lib/samagotchi/reply_wait.rb +11 -13
- data/lib/samagotchi/sampling_settings.rb +4 -4
- data/lib/samagotchi/self_report.rb +8 -5
- data/lib/samagotchi/send_command.rb +40 -46
- data/lib/samagotchi/session.rb +45 -24
- data/lib/samagotchi/session_commands.rb +68 -11
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +44 -11
- data/lib/samagotchi/session_metrics.rb +15 -20
- data/lib/samagotchi/session_setup.rb +30 -0
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/steer_cut.rb +9 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +26 -4
- data/lib/samagotchi/terminal_ui.rb +21 -4
- data/lib/samagotchi/thinking.rb +67 -7
- data/lib/samagotchi/thinking_command.rb +72 -0
- data/lib/samagotchi/tools/builtins.rb +7 -2
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -3
- data/lib/samagotchi/web/app.rb +56 -13
- data/lib/samagotchi/web/public/app.js +131 -20
- data/lib/samagotchi/web/public/command_complete.js +7 -2
- data/lib/samagotchi/web/public/data.js +8 -4
- data/lib/samagotchi/web/public/format.js +8 -4
- data/lib/samagotchi/web/public/index.html +8 -5
- data/lib/samagotchi/web/public/kept_commands.js +52 -0
- data/lib/samagotchi/web/public/model_pick.js +3 -1
- data/lib/samagotchi/web/public/thinking_chip.js +179 -0
- data/lib/samagotchi/web/public/timing.js +15 -0
- data/lib/samagotchi/web/public/turn_events.js +23 -0
- data/lib/samagotchi/web/public/turn_view.js +328 -310
- data/lib/samagotchi/web/public/vendor/lit-html.js +8 -0
- data/lib/samagotchi/web/session_summary.rb +1 -1
- data/lib/samagotchi/worker.rb +43 -42
- data/lib/samagotchi/worker_inbound.rb +5 -5
- data/lib/samagotchi/worker_wakes.rb +0 -2
- data/lib/samagotchi/yaml_lines.rb +72 -0
- metadata +13 -1
|
@@ -35,10 +35,11 @@ export function listSessions(sort, order, { dir, ...opts } = {}) {
|
|
|
35
35
|
// it); either left out means the server's default. preview: that message,
|
|
36
36
|
// which names the session until its turn is saved. llm_context: the
|
|
37
37
|
// session's own LLM context words ({strategy, apply, budget}), when the
|
|
38
|
-
// start page's chip set some
|
|
39
|
-
|
|
38
|
+
// start page's chip set some; thinking: its own level ("low"), when the
|
|
39
|
+
// start page's think chip set one.
|
|
40
|
+
export function createIdleSession({ dir, model, preview, llm_context: llmContext, thinking, ...opts } = {}) {
|
|
40
41
|
const body = { idle: true, ...(dir ? { dir } : {}), ...(model ? { model } : {}), ...(preview ? { preview } : {}),
|
|
41
|
-
...(llmContext ? { llm_context: llmContext } : {}) };
|
|
42
|
+
...(llmContext ? { llm_context: llmContext } : {}), ...(thinking ? { thinking } : {}) };
|
|
42
43
|
return api("/api/sessions", { method: "POST", body: JSON.stringify(body), ...opts });
|
|
43
44
|
}
|
|
44
45
|
|
|
@@ -90,9 +91,12 @@ export function getSession(id, { tail, recent, turnId, timing, parts, cards, ...
|
|
|
90
91
|
// opts.images: refs ({file, name}) to images uploaded into the session.
|
|
91
92
|
// history: false keeps the prompt out of the shared prompt history (an
|
|
92
93
|
// image-only send, whose text is only its [image: …] placeholders).
|
|
93
|
-
|
|
94
|
+
// delivery: what the message does to a running turn ("cut": /cut TEXT);
|
|
95
|
+
// none = the server's default (the next step).
|
|
96
|
+
export function sendTurn(id, prompt, { clientId, images, history = true, delivery = null, ...opts } = {}) {
|
|
94
97
|
const body = clientId ? { prompt, client_id: clientId } : { prompt };
|
|
95
98
|
if (images?.length) body.images = images;
|
|
99
|
+
if (delivery) body.delivery = delivery;
|
|
96
100
|
if (history === false) body.history = false;
|
|
97
101
|
return api(`/api/sessions/${id}/turn`, {
|
|
98
102
|
method: "POST",
|
|
@@ -94,13 +94,17 @@ export function previewOf(lastPrompt) {
|
|
|
94
94
|
return normalized;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// A session's first message as the info bar's title: whitespace collapsed,
|
|
98
|
+
// cut at FIRST_PREVIEW_CHARS with an ellipsis ("" for none).
|
|
99
|
+
export function firstPreview(text) {
|
|
100
|
+
const n = normalize(text);
|
|
101
|
+
return n.length > FIRST_PREVIEW_CHARS ? `${n.slice(0, FIRST_PREVIEW_CHARS)}\u2026` : n;
|
|
102
|
+
}
|
|
103
|
+
|
|
97
104
|
export function previewForCard(session) {
|
|
98
105
|
// Unified card preview: first message (80) else previewOf(last_prompt)
|
|
99
106
|
const raw = session.first_preview || session.last_prompt || "";
|
|
100
|
-
|
|
101
|
-
if (!n) return "\u2014";
|
|
102
|
-
if (n.length > FIRST_PREVIEW_CHARS) return `${n.slice(0, FIRST_PREVIEW_CHARS)}\u2026`;
|
|
103
|
-
return n;
|
|
107
|
+
return firstPreview(raw) || "\u2014";
|
|
104
108
|
}
|
|
105
109
|
|
|
106
110
|
const DELETE_PREVIEW_CHARS = 40;
|
|
@@ -502,8 +502,10 @@ body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
|
|
|
502
502
|
#composer .actions-col .llmctx-new:hover{filter:none;color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
|
|
503
503
|
#composer .actions-col .llmctx-new.own{color:var(--accent);border-color:var(--accent-border)}
|
|
504
504
|
#composer .actions-col .llmctx-new[hidden],body.has-session #composer .actions-col .llmctx-new{display:none}
|
|
505
|
+
/* The new chat's thinking chip, after it: shorter text, so a smaller share of the row. */
|
|
506
|
+
#composer .actions-col .think-new{max-width:min(25%,140px)}
|
|
505
507
|
/* Phones: picker · chip · Start on one row (the key hint is gone there); names cut short rather than wrapping, the chip first. */
|
|
506
|
-
@media(max-width:600px){#composer .actions-col:has(.llmctx-new:not([hidden])){flex-wrap:nowrap}#composer .actions-col .model-pick{flex:0 1 auto;min-width:0}#composer .actions-col .llmctx-new{max-width:45%;flex-shrink:4}}
|
|
508
|
+
@media(max-width:600px){#composer .actions-col:has(.llmctx-new:not([hidden])){flex-wrap:nowrap}#composer .actions-col .model-pick{flex:0 1 auto;min-width:0}#composer .actions-col .llmctx-new{max-width:45%;flex-shrink:4}#composer .actions-col .think-new{max-width:30%}}
|
|
507
509
|
/* The model search: opaque like #commandList (a list over the conversation
|
|
508
510
|
must stay readable), above the composer, or below it (.down) when the
|
|
509
511
|
start page leaves no room above, or over the composer (.over) when
|
|
@@ -694,10 +696,10 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
694
696
|
#infoBar #infoArchiveBtn:hover{color:var(--text);background:var(--bg-hover)}
|
|
695
697
|
#infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
|
|
696
698
|
#infoBar #infoDeleteBtn:hover{color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
|
|
697
|
-
/* The LLM context
|
|
698
|
-
#infoBar .meta .llmctx-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}
|
|
699
|
-
#infoBar .meta .llmctx-chip:hover{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
|
|
700
|
-
#infoBar .meta .llmctx-chip.own{color:var(--accent);border-color:var(--accent-border)}
|
|
699
|
+
/* 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. */
|
|
700
|
+
#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}
|
|
701
|
+
#infoBar .meta .llmctx-chip:hover,#infoBar .meta .think-chip:hover{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
|
|
702
|
+
#infoBar .meta .llmctx-chip.own,#infoBar .meta .think-chip.own{color:var(--accent);border-color:var(--accent-border)}
|
|
701
703
|
.llmctx-form{display:flex;flex-direction:column;gap:8px;margin:0}
|
|
702
704
|
.llmctx-form .ctx-pop-head strong{font-family:inherit}
|
|
703
705
|
.llmctx-field{display:flex;align-items:center;gap:8px}
|
|
@@ -1046,6 +1048,7 @@ a{color:var(--accent)}
|
|
|
1046
1048
|
<div class="actions-col">
|
|
1047
1049
|
<button type="button" id="modelPick" class="model-pick" title="The model the new chat starts on" aria-label="Model for the new chat" aria-haspopup="listbox" aria-expanded="false" hidden><span class="model-pick-name"></span></button>
|
|
1048
1050
|
<button type="button" id="llmContextNew" class="llmctx-new" aria-haspopup="dialog" hidden></button>
|
|
1051
|
+
<button type="button" id="thinkingNew" class="llmctx-new think-new" aria-haspopup="dialog" hidden></button>
|
|
1049
1052
|
<div id="composeHint">Enter to start · Shift+Enter for a newline</div>
|
|
1050
1053
|
<button id="cancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Stop turn</button>
|
|
1051
1054
|
<div class="split">
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Command bubbles (/help, /model, /stats, /llm-context …) live only in the
|
|
2
|
+
// page: the session saves just a `!cmd`'s output (a message, redrawn as its
|
|
3
|
+
// command bubble). A resync (a `!cmd`, a `!rollback`, a dropped stream)
|
|
4
|
+
// redraws the history from the server, so the page keeps its own command
|
|
5
|
+
// bubbles across it: each one's anchor is the prompts and saved shell
|
|
6
|
+
// commands before it, and it goes back after them in the redrawn history.
|
|
7
|
+
// A reload still drops them.
|
|
8
|
+
//
|
|
9
|
+
// +kinds+: the history's children in order, each "user" (a prompt bubble),
|
|
10
|
+
// "shell" (a `!cmd`'s bubble, which the redrawn history has too) or
|
|
11
|
+
// anything else.
|
|
12
|
+
|
|
13
|
+
// @return {{users: number, shells: number}} the anchor of kinds[index]
|
|
14
|
+
export function commandAnchor(kinds, index) {
|
|
15
|
+
const before = kinds.slice(0, index);
|
|
16
|
+
return {
|
|
17
|
+
users: before.filter((k) => k === "user").length,
|
|
18
|
+
shells: before.filter((k) => k === "shell").length,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The index to insert a kept bubble at: before the first prompt or shell
|
|
23
|
+
// command past its anchor (it ran after its turn), else at the end (a
|
|
24
|
+
// history a !rollback made shorter too).
|
|
25
|
+
export function anchorInsertIndex(kinds, { users = 0, shells = 0 } = {}) {
|
|
26
|
+
let seenUsers = 0;
|
|
27
|
+
let seenShells = 0;
|
|
28
|
+
for (let i = 0; i < kinds.length; i += 1) {
|
|
29
|
+
if (kinds[i] === "user") {
|
|
30
|
+
if (seenUsers >= users) return i;
|
|
31
|
+
seenUsers += 1;
|
|
32
|
+
} else if (kinds[i] === "shell") {
|
|
33
|
+
if (seenShells >= shells) return i;
|
|
34
|
+
seenShells += 1;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return kinds.length;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const SHELL_LINE = /^!\s*\S/;
|
|
41
|
+
const ROLLBACK_LINE = /^!rollback(\s|$)/;
|
|
42
|
+
|
|
43
|
+
// Whether a command bubble's line is kept across a resync: a `!cmd`'s is
|
|
44
|
+
// redrawn from its saved message (keeping it too would show it twice).
|
|
45
|
+
export function isShellCommandLine(line) {
|
|
46
|
+
const text = String(line ?? "");
|
|
47
|
+
return SHELL_LINE.test(text) && !ROLLBACK_LINE.test(text);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function keepsCommandBubble(line) {
|
|
51
|
+
return !isShellCommandLine(line);
|
|
52
|
+
}
|
|
@@ -22,7 +22,8 @@ export const RECENT_KEY = "chi_model_recent";
|
|
|
22
22
|
|
|
23
23
|
// The payload's models as rows: {name, host, id, isDefaultHost}, and
|
|
24
24
|
// sampling (the configured request parameters as /model words them) when
|
|
25
|
-
// the model has some,
|
|
25
|
+
// the model has some, thinking (the level it starts at, Thinking::Explained#summary),
|
|
26
|
+
// and llm_context (the LLM context it starts under,
|
|
26
27
|
// LLMContextStrategy::Explained#summary) when the server sent one, and
|
|
27
28
|
// configured when the host declares the id (hosts.<name>.models), and
|
|
28
29
|
// unavailable when that host failed to list its models. The
|
|
@@ -42,6 +43,7 @@ export function modelRows(payload) {
|
|
|
42
43
|
if (m.configured === true) row.configured = true;
|
|
43
44
|
if (m.unavailable === true) row.unavailable = true;
|
|
44
45
|
if (m.llm_context && typeof m.llm_context === "object") row.llm_context = m.llm_context;
|
|
46
|
+
if (m.thinking && typeof m.thinking === "object") row.thinking = m.thinking;
|
|
45
47
|
return row;
|
|
46
48
|
});
|
|
47
49
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// The session bar's thinking chip: the level the next turn runs at ("think
|
|
2
|
+
// low"; "· session" when the session set its own), and a popover with a
|
|
3
|
+
// level select that runs /thinking in the session's worker (the command's
|
|
4
|
+
// reply shows in the conversation, like a typed one). The level and where
|
|
5
|
+
// it came from are the session's thinking (Thinking::Explained#summary:
|
|
6
|
+
// {level, source, own}; the worker's snapshot, a command_ran, or the
|
|
7
|
+
// server's own reading of the session file).
|
|
8
|
+
//
|
|
9
|
+
// The start page has one too (kind "new chat"): it shows the picked
|
|
10
|
+
// model's level (GET /api/models' thinking) and keeps a choice for the
|
|
11
|
+
// next create only (POST /api/sessions' thinking), never remembered:
|
|
12
|
+
// nothing is sent unless the user sets one.
|
|
13
|
+
|
|
14
|
+
import { NEW_CHAT, SESSION, popoverPlace } from "./llm_context_chip.js";
|
|
15
|
+
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
16
|
+
|
|
17
|
+
export const LEVELS = ["off", "low", "medium", "high"];
|
|
18
|
+
const DEFAULT = "default";
|
|
19
|
+
|
|
20
|
+
// The new chat's choice (a level, or null) laid over the model's summary:
|
|
21
|
+
// what the chip shows. The model's own summary without a choice.
|
|
22
|
+
export function withChoice(summary, choice) {
|
|
23
|
+
if (!summary || !choice) return summary;
|
|
24
|
+
return { ...summary, level: choice, source: SESSION, own: choice };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The chip: its text, tooltip and whether the session (or the new chat)
|
|
28
|
+
// set its own; null without a summary (an older worker). The default with
|
|
29
|
+
// nothing set anywhere shows too ("think default"): the chip is where the
|
|
30
|
+
// level is set.
|
|
31
|
+
export function chipModel(summary, { kind = SESSION } = {}) {
|
|
32
|
+
if (!summary || !summary.level) return null;
|
|
33
|
+
const own = !!summary.own;
|
|
34
|
+
const what = kind === NEW_CHAT ? "the new chat" : "this session";
|
|
35
|
+
const from = own ? what : summary.source || "the model's own";
|
|
36
|
+
const title = `Thinking: ${summary.level} (${from}). Click to change it for ${what}.`;
|
|
37
|
+
return { text: `think ${summary.level}${own ? ` · ${kind}` : ""}`, title, own };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The session bar's chip, as a string: the info bar is still built as one
|
|
41
|
+
// (app.js); it becomes a template when the info bar moves to lit.
|
|
42
|
+
export function chipHtml(summary, escapeHtml) {
|
|
43
|
+
const chip = chipModel(summary);
|
|
44
|
+
if (!chip) return "";
|
|
45
|
+
return `<button type="button" class="model think-chip${chip.own ? " own" : ""}" title="${escapeHtml(chip.title)}" ` +
|
|
46
|
+
`data-think-chip aria-haspopup="dialog">${escapeHtml(chip.text)}</button>`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The /thinking line for a select value ("default" unsets the session's
|
|
50
|
+
// own); null when it is what the form opened with.
|
|
51
|
+
export function commandLine(level, initial = null) {
|
|
52
|
+
if (initial !== null && level === initial) return null;
|
|
53
|
+
return `/thinking ${level}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// "Now: low (session)", "Now: default (the model's own)".
|
|
57
|
+
export function nowText(summary) {
|
|
58
|
+
return `Now: ${summary.level} (${summary.source || "the model's own"})`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const option = (value, label, chosen) => html`<option value=${value} ?selected=${value === chosen}>${label}</option>`;
|
|
62
|
+
|
|
63
|
+
// The popover's form (a lit-html template). For the new chat (kind
|
|
64
|
+
// NEW_CHAT), +summary+ is the model's and +choice+ the level the form
|
|
65
|
+
// shows; +now+ is the summary its "Now:" line reads (refresh passes a
|
|
66
|
+
// newer one, the rest stays as the form opened).
|
|
67
|
+
export function formTemplate(summary, { running = false, kind = SESSION, choice = null, now = summary } = {}) {
|
|
68
|
+
const fresh = kind === NEW_CHAT;
|
|
69
|
+
const chosen = (fresh ? choice : summary.own) || DEFAULT;
|
|
70
|
+
const follow = summary.own && !fresh ? "follow the model" : `follow the model (${summary.level})`;
|
|
71
|
+
const when = fresh ? "For the new chat's first turn on; not remembered for the next one."
|
|
72
|
+
: "From the next turn's start. On Gemma the whole prompt is read again; a hosted API may restart its cache.";
|
|
73
|
+
return html`<form class="think-form llmctx-form"><div class="ctx-pop-head"><strong>Thinking</strong
|
|
74
|
+
><span class="ctx-pop-kind">${fresh ? NEW_CHAT : "this session"}</span
|
|
75
|
+
><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
|
|
76
|
+
><div class="ctx-pop-meta think-now">${nowText(now)}</div
|
|
77
|
+
><label class="llmctx-field"><span>Level</span><select name="level">${option(DEFAULT, follow, chosen)}${
|
|
78
|
+
LEVELS.map((level) => option(level, level, chosen))}</select></label
|
|
79
|
+
><div class="ctx-pop-meta">${when}${running ? " A turn is running: it applies after this turn." : ""}</div
|
|
80
|
+
><div class="ctx-pop-actions"><button type="submit" class="llmctx-set">Set</button></div></form>`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The chip's popover, as createLlmContextControl's: el is where the chip
|
|
84
|
+
// is drawn (clicks are delegated: #infoText is redrawn every tick);
|
|
85
|
+
// summary(): the open session's thinking; session(): its id; running():
|
|
86
|
+
// a turn runs; run(line): sends the command. The start page's (kind
|
|
87
|
+
// NEW_CHAT): summary() is the picked model's, choice() the pending level
|
|
88
|
+
// and choose(next) takes a Set's (null for "follow the model"). id and
|
|
89
|
+
// anchor (the chip's selector) keep it apart from the llm ctx control on
|
|
90
|
+
// the same element.
|
|
91
|
+
export function createThinkingControl({ el, summary, session = () => null, running = () => false, run,
|
|
92
|
+
kind = SESSION, choice = () => null, choose = null,
|
|
93
|
+
id = "thinkingPopover", anchor: anchorSel = "[data-think-chip]",
|
|
94
|
+
doc = document, win = window }) {
|
|
95
|
+
const fresh = kind === NEW_CHAT;
|
|
96
|
+
const pop = doc.createElement("div");
|
|
97
|
+
pop.id = id;
|
|
98
|
+
pop.className = "ctx-popover think-popover";
|
|
99
|
+
pop.setAttribute("role", "dialog");
|
|
100
|
+
pop.hidden = true;
|
|
101
|
+
doc.body.appendChild(pop);
|
|
102
|
+
let initial = null;
|
|
103
|
+
let openFor = null;
|
|
104
|
+
// What the open form was drawn from: the model's or session's summary
|
|
105
|
+
// and the form's options. A closed popover is empty, so each open draws
|
|
106
|
+
// a fresh form (an unsent pick is gone).
|
|
107
|
+
let shown = null;
|
|
108
|
+
const draw = (now) => render(formTemplate(shown.data, { ...shown.opts, now }), pop);
|
|
109
|
+
|
|
110
|
+
function place(anchor) {
|
|
111
|
+
const width = Math.min(320, win.innerWidth - 32);
|
|
112
|
+
pop.style.width = `${width}px`;
|
|
113
|
+
pop.style.maxHeight = "";
|
|
114
|
+
const at = popoverPlace(anchor.getBoundingClientRect(), { width, height: pop.offsetHeight,
|
|
115
|
+
innerWidth: win.innerWidth, innerHeight: win.innerHeight });
|
|
116
|
+
pop.style.left = `${at.left}px`;
|
|
117
|
+
pop.style.top = at.top === undefined ? "" : `${at.top}px`;
|
|
118
|
+
pop.style.bottom = at.bottom === undefined ? "" : `${at.bottom}px`;
|
|
119
|
+
if (at.maxHeight !== undefined) pop.style.maxHeight = `${at.maxHeight}px`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function open(anchor) {
|
|
123
|
+
const data = summary();
|
|
124
|
+
if (!data) return;
|
|
125
|
+
initial = (fresh ? choice() : data.own) || DEFAULT;
|
|
126
|
+
openFor = session();
|
|
127
|
+
shown = { data, opts: { running: running(), kind, choice: choice() } };
|
|
128
|
+
draw(data);
|
|
129
|
+
pop.hidden = false;
|
|
130
|
+
place(anchor);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function close() {
|
|
134
|
+
pop.hidden = true;
|
|
135
|
+
initial = null;
|
|
136
|
+
openFor = null;
|
|
137
|
+
shown = null;
|
|
138
|
+
render(nothing, pop);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isOpen() { return !pop.hidden; }
|
|
142
|
+
|
|
143
|
+
// A new summary: an open form keeps the user's pick; only "Now:" follows
|
|
144
|
+
// (the same template again: lit writes that line alone).
|
|
145
|
+
function refresh() {
|
|
146
|
+
if (!isOpen()) return;
|
|
147
|
+
const data = summary();
|
|
148
|
+
if (!data || session() !== openFor) return close();
|
|
149
|
+
draw(data);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
el.addEventListener("click", (e) => {
|
|
153
|
+
const anchor = e.target.closest(anchorSel);
|
|
154
|
+
if (!anchor) return;
|
|
155
|
+
e.stopPropagation();
|
|
156
|
+
if (isOpen()) close();
|
|
157
|
+
else open(anchor);
|
|
158
|
+
});
|
|
159
|
+
pop.addEventListener("submit", (e) => {
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
const level = e.target.elements.level.value;
|
|
162
|
+
const was = initial;
|
|
163
|
+
close();
|
|
164
|
+
if (fresh) return choose(level === DEFAULT ? null : level);
|
|
165
|
+
const line = commandLine(level, was);
|
|
166
|
+
if (line) run(line);
|
|
167
|
+
});
|
|
168
|
+
pop.addEventListener("click", (e) => {
|
|
169
|
+
if (e.target.closest("[data-act]")?.dataset.act === "close") close();
|
|
170
|
+
});
|
|
171
|
+
doc.addEventListener("click", (e) => {
|
|
172
|
+
if (isOpen() && !pop.contains(e.target) && !e.target.closest?.(anchorSel)) close();
|
|
173
|
+
});
|
|
174
|
+
doc.addEventListener("keydown", (e) => {
|
|
175
|
+
if (e.key === "Escape" && isOpen()) close();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return { close, refresh, get open() { return isOpen(); } };
|
|
179
|
+
}
|
|
@@ -63,6 +63,18 @@ export function elapsedSince(startedAt, now = Date.now()) {
|
|
|
63
63
|
return Math.max(0, current - started);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// The info bar's session clock: while a turn runs it counts from the
|
|
67
|
+
// session's start (+timing.startedAt+), or, on a session's first turn (its
|
|
68
|
+
// snapshot came before any turn, so no start), from the running turn's;
|
|
69
|
+
// otherwise the server's session_duration_ms (null when unknown).
|
|
70
|
+
export function sessionDurationNow(timing, running, now = Date.now()) {
|
|
71
|
+
if (running) {
|
|
72
|
+
const since = elapsedSince(timing?.startedAt ?? timing?.activeTurn?.started_at, now);
|
|
73
|
+
if (since !== null) return since;
|
|
74
|
+
}
|
|
75
|
+
return timing?.sessionDurationMs ?? null;
|
|
76
|
+
}
|
|
77
|
+
|
|
66
78
|
export function normalizeTiming(data = {}) {
|
|
67
79
|
return {
|
|
68
80
|
startedAt: typeof data.started_at === "string" ? data.started_at : null,
|
|
@@ -251,9 +263,12 @@ function canceledSteps(g, record, tools) {
|
|
|
251
263
|
return record?.status === "canceled" && (g.texts.length > 1 || tools.length > 0);
|
|
252
264
|
}
|
|
253
265
|
|
|
266
|
+
// A call's row from its tool record alone (no saved parts): the record's
|
|
267
|
+
// called_as too, so "(called as bash)" shows after a reload, as live.
|
|
254
268
|
function recordRows(tools, iteration) {
|
|
255
269
|
return tools.filter((r) => Number(r.iteration) === iteration).map((r) => ({
|
|
256
270
|
key: `${iteration}:${r.call_index ?? 0}`, tool: r.tool, status: r.status, duration_ms: r.duration_ms,
|
|
271
|
+
...(r.called_as ? { called_as: r.called_as } : {}),
|
|
257
272
|
}));
|
|
258
273
|
}
|
|
259
274
|
|
|
@@ -244,6 +244,29 @@ export function sessionCommandLine(text, commands) {
|
|
|
244
244
|
.some((c) => c && typeof c.name === "string" && (line === c.name || line.startsWith(`${c.name} `)));
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
// /cut TEXT and /queue TEXT: the UIs' own words for a delivery (listed
|
|
248
|
+
// for Tab and /help, never run as commands; TerminalUI::Formatting's
|
|
249
|
+
// STEER_CHOICE: the word, then any whitespace and the text). During a
|
|
250
|
+
// turn /cut TEXT is the text sent as a cut (POST /turn, delivery "cut"),
|
|
251
|
+
// as chi send --cut does; /queue isn't in the web yet (the page says so,
|
|
252
|
+
// as the attached TUI does). With no running turn both are the text as a
|
|
253
|
+
// normal turn. The bare word gets the usage line.
|
|
254
|
+
// @return {{send: string, delivery?: string} | {reply: string} | null}
|
|
255
|
+
// null: not a delivery line
|
|
256
|
+
export const STEER_CHOICE_USAGE = "(/cut and /queue need a message: /cut TEXT, /queue TEXT)";
|
|
257
|
+
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)";
|
|
258
|
+
const STEER_CHOICE = /^\/(cut|queue)(?:\s+([\s\S]*))?$/;
|
|
259
|
+
|
|
260
|
+
export function steerChoiceRoute(text, { running = false } = {}) {
|
|
261
|
+
const match = STEER_CHOICE.exec(String(text || "").trim());
|
|
262
|
+
if (!match) return null;
|
|
263
|
+
const message = (match[2] || "").trim();
|
|
264
|
+
if (!message) return { reply: STEER_CHOICE_USAGE };
|
|
265
|
+
if (!running) return { send: message };
|
|
266
|
+
if (match[1] === "queue") return { reply: QUEUE_UNAVAILABLE_WEB };
|
|
267
|
+
return { send: message, delivery: "cut" };
|
|
268
|
+
}
|
|
269
|
+
|
|
247
270
|
// A line that is one word: a slash and a name, no spaces, no second slash
|
|
248
271
|
// (Commands::Registry#unknown_command_word?). `/modle` is one; `/foo bar`
|
|
249
272
|
// and `/usr/bin/env` are not (they are prompts).
|