samagotchi 0.64.0 → 0.66.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 +88 -1
- data/README.md +2 -1
- data/bin/chi +51 -2
- data/docs/architecture.md +20 -3
- data/docs/cli.md +36 -2
- data/docs/configuration.md +177 -8
- data/docs/context.md +4 -1
- data/docs/internals/engine.md +19 -4
- data/docs/internals/prompt-caching.md +10 -2
- data/docs/memory.md +1 -1
- data/docs/plugins.md +163 -2
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +13 -2
- data/docs/sub-agent.md +3 -1
- data/lib/samagotchi/bridge.rb +11 -1
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +18 -1
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +21 -0
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +464 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +131 -0
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +68 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
- data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/delegated.md +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/cache_confirm.rb +9 -6
- data/lib/samagotchi/client.rb +4 -2
- data/lib/samagotchi/commands/registry.rb +25 -8
- data/lib/samagotchi/config.rb +13 -5
- data/lib/samagotchi/engine.rb +151 -11
- data/lib/samagotchi/generation_phase.rb +17 -0
- data/lib/samagotchi/host_config.rb +27 -6
- data/lib/samagotchi/host_registry.rb +34 -11
- data/lib/samagotchi/idle_client.rb +62 -25
- data/lib/samagotchi/idle_recap.rb +5 -2
- data/lib/samagotchi/idle_target.rb +30 -7
- data/lib/samagotchi/kernel_loop.rb +9 -1
- data/lib/samagotchi/llm/adapters.rb +41 -0
- data/lib/samagotchi/llm/anthropic_assembly.rb +212 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
- data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
- data/lib/samagotchi/llm/api_key.rb +26 -9
- data/lib/samagotchi/llm/chat_loop.rb +113 -14
- data/lib/samagotchi/llm/dialect.rb +57 -0
- data/lib/samagotchi/llm/errors.rb +81 -12
- data/lib/samagotchi/llm/model_listing.rb +111 -0
- data/lib/samagotchi/llm/model_result.rb +9 -2
- data/lib/samagotchi/llm/openai_chat.rb +46 -93
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/plugin/api.rb +80 -7
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/loader.rb +3 -2
- data/lib/samagotchi/reasoning_history.rb +14 -4
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/self_report.rb +18 -28
- data/lib/samagotchi/send_command.rb +23 -7
- data/lib/samagotchi/session.rb +21 -3
- data/lib/samagotchi/session_commands.rb +16 -1
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/session_metrics.rb +6 -11
- data/lib/samagotchi/session_setup.rb +14 -7
- data/lib/samagotchi/steer_cut.rb +4 -0
- data/lib/samagotchi/system_prompt.rb +16 -1
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
- data/lib/samagotchi/terminal_ui/attached_loop.rb +8 -2
- data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +4 -3
- data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
- data/lib/samagotchi/terminal_ui.rb +18 -2
- data/lib/samagotchi/tool_runner.rb +13 -1
- data/lib/samagotchi/toolkit.rb +246 -0
- data/lib/samagotchi/toolkit_command.rb +117 -0
- data/lib/samagotchi/tools/registry.rb +43 -6
- data/lib/samagotchi/turn_note.rb +22 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +14 -1
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/annotate_actions.js +109 -0
- data/lib/samagotchi/web/public/annotate_bar.js +84 -2
- data/lib/samagotchi/web/public/app.js +111 -20
- data/lib/samagotchi/web/public/commands.js +4 -2
- data/lib/samagotchi/web/public/delivery_line.js +228 -0
- data/lib/samagotchi/web/public/index.html +38 -0
- data/lib/samagotchi/web/public/info_bar.js +5 -3
- data/lib/samagotchi/web/public/live_turn.js +8 -0
- data/lib/samagotchi/web/public/session_state.js +3 -0
- data/lib/samagotchi/web/public/stage_model.js +27 -7
- data/lib/samagotchi/web/public/stage_view.js +32 -10
- data/lib/samagotchi/web/public/stream_handlers.js +21 -2
- data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
- data/lib/samagotchi/web/public/turn_events.js +22 -8
- data/lib/samagotchi/web/public/turn_html.js +20 -4
- data/lib/samagotchi/web/public/turn_model.js +16 -9
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- data/lib/samagotchi/worker.rb +2 -0
- metadata +17 -1
|
@@ -7,10 +7,15 @@
|
|
|
7
7
|
// bubble rewrites its text every frame, which would collapse it by the time
|
|
8
8
|
// a button is clicked.
|
|
9
9
|
//
|
|
10
|
+
// A bundle's pills (chi.annotation) come after the presets: each prepares
|
|
11
|
+
// its command line with the selection (annotate_actions.js), at most 3
|
|
12
|
+
// inline, the rest behind "⋯".
|
|
13
|
+
//
|
|
10
14
|
// The quoting rules are annotations.js'; this is the bar.
|
|
11
15
|
|
|
12
16
|
import { annotateBarLeft, annotationSource, quoteBlock } from "./annotations.js";
|
|
13
17
|
import { presetNote } from "./annotate_presets.js";
|
|
18
|
+
import { actionEnabled, actionTitle, sameActions, selectionFields, splitInline } from "./annotate_actions.js";
|
|
14
19
|
|
|
15
20
|
// @param presets the preset texts (annotate_presets.js parsePresets)
|
|
16
21
|
// @param canAnnotate () => whether the open session takes annotations (one
|
|
@@ -21,11 +26,18 @@ import { presetNote } from "./annotate_presets.js";
|
|
|
21
26
|
// until its step closes)
|
|
22
27
|
// @param onQuote (text) runs with the quote (and a preset's note) to append
|
|
23
28
|
// to the composer; the selection is cleared and the bar hidden first
|
|
29
|
+
// @param actions () => the open session's annotation actions
|
|
30
|
+
// (annotate_actions.js annotationActions), read on every selection
|
|
31
|
+
// @param running () => whether a turn runs (a send pill may wait for it)
|
|
32
|
+
// @param onAction (action, fields) runs with a clicked pill's action and the
|
|
33
|
+
// selection's {quote, text, source}; the selection is cleared and the bar
|
|
34
|
+
// hidden first
|
|
24
35
|
// @param doc, win the page's document and window
|
|
25
36
|
// @return {schedule(), hide(), el}; schedule() re-reads the selection on the
|
|
26
37
|
// next frame (call it on scroll)
|
|
27
38
|
export function createAnnotateBar({
|
|
28
|
-
presets, canAnnotate, roots, isLive, onQuote,
|
|
39
|
+
presets, canAnnotate, roots, isLive, onQuote, actions = () => [], running = () => false, onAction = () => {},
|
|
40
|
+
doc = globalThis.document, win = globalThis.window,
|
|
29
41
|
}) {
|
|
30
42
|
const bar = doc.createElement("div");
|
|
31
43
|
bar.id = "annotateBar";
|
|
@@ -46,11 +58,63 @@ export function createAnnotateBar({
|
|
|
46
58
|
btn.title = text;
|
|
47
59
|
bar.appendChild(btn);
|
|
48
60
|
});
|
|
61
|
+
// The plugin pills: a separator, the inline ones, then ⋯ and the list
|
|
62
|
+
// it opens. Redrawn only when the session's actions change.
|
|
63
|
+
const group = doc.createElement("span");
|
|
64
|
+
group.className = "annotate-actions";
|
|
65
|
+
group.hidden = true;
|
|
66
|
+
bar.appendChild(group);
|
|
49
67
|
doc.body.appendChild(bar);
|
|
50
68
|
let pendingQuote = null;
|
|
69
|
+
let pendingFields = null;
|
|
70
|
+
let drawn = [];
|
|
71
|
+
let moreList = null;
|
|
72
|
+
|
|
73
|
+
function drawActions(list) {
|
|
74
|
+
drawn = list;
|
|
75
|
+
moreList = null;
|
|
76
|
+
group.hidden = !list.length;
|
|
77
|
+
if (!list.length) return group.replaceChildren();
|
|
78
|
+
const sep = doc.createElement("span");
|
|
79
|
+
sep.className = "annotate-sep";
|
|
80
|
+
sep.setAttribute("aria-hidden", "true");
|
|
81
|
+
const pill = (action, i) => {
|
|
82
|
+
const btn = button(action.label, "annotate-action");
|
|
83
|
+
btn.dataset.action = String(i);
|
|
84
|
+
if (action.send) btn.dataset.sends = "";
|
|
85
|
+
return btn;
|
|
86
|
+
};
|
|
87
|
+
const { inline, more } = splitInline(list);
|
|
88
|
+
const nodes = [sep, ...inline.map(pill)];
|
|
89
|
+
if (more.length) {
|
|
90
|
+
const toggle = button("⋯", "annotate-more");
|
|
91
|
+
toggle.title = "More actions";
|
|
92
|
+
toggle.setAttribute("aria-label", "More actions");
|
|
93
|
+
moreList = doc.createElement("div");
|
|
94
|
+
moreList.className = "annotate-more-list";
|
|
95
|
+
moreList.hidden = true;
|
|
96
|
+
more.forEach((action, j) => moreList.appendChild(pill(action, inline.length + j)));
|
|
97
|
+
nodes.push(toggle, moreList);
|
|
98
|
+
}
|
|
99
|
+
group.replaceChildren(...nodes);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Each pill's state for whether a turn runs (a send pill may wait).
|
|
103
|
+
function markActions() {
|
|
104
|
+
const now = running();
|
|
105
|
+
const all = [...group.children, ...(moreList ? moreList.children : [])];
|
|
106
|
+
for (const btn of all) {
|
|
107
|
+
if (btn.dataset?.action === undefined) continue;
|
|
108
|
+
const action = drawn[Number(btn.dataset.action)];
|
|
109
|
+
btn.disabled = !actionEnabled(action, now);
|
|
110
|
+
btn.title = actionTitle(action, now);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
51
113
|
|
|
52
114
|
function hide() {
|
|
53
115
|
pendingQuote = null;
|
|
116
|
+
pendingFields = null;
|
|
117
|
+
if (moreList) moreList.hidden = true;
|
|
54
118
|
bar.hidden = true;
|
|
55
119
|
}
|
|
56
120
|
|
|
@@ -67,9 +131,14 @@ export function createAnnotateBar({
|
|
|
67
131
|
// Clip to where the selection starts: one tool row, one bubble.
|
|
68
132
|
const clipped = range.cloneRange();
|
|
69
133
|
if (!source.root.contains(range.endContainer)) clipped.setEnd(source.root, source.root.childNodes.length);
|
|
70
|
-
const
|
|
134
|
+
const raw = clipped.toString();
|
|
135
|
+
const block = quoteBlock(raw, source);
|
|
71
136
|
if (!block) return hide();
|
|
72
137
|
pendingQuote = block;
|
|
138
|
+
pendingFields = selectionFields(raw, source);
|
|
139
|
+
const list = actions() || [];
|
|
140
|
+
if (!sameActions(list, drawn)) drawActions(list);
|
|
141
|
+
markActions();
|
|
73
142
|
const rects = clipped.getClientRects();
|
|
74
143
|
const rect = rects.length ? rects[rects.length - 1] : clipped.getBoundingClientRect();
|
|
75
144
|
bar.hidden = false;
|
|
@@ -109,6 +178,19 @@ export function createAnnotateBar({
|
|
|
109
178
|
bar.addEventListener("click", (e) => {
|
|
110
179
|
const btn = e.target.closest("button");
|
|
111
180
|
if (!btn || !pendingQuote) return;
|
|
181
|
+
if (btn.className === "annotate-more") {
|
|
182
|
+
moreList.hidden = !moreList.hidden;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (btn.dataset.action !== undefined) {
|
|
186
|
+
const action = drawn[Number(btn.dataset.action)];
|
|
187
|
+
if (!action || btn.disabled) return;
|
|
188
|
+
const fields = pendingFields;
|
|
189
|
+
win.getSelection()?.removeAllRanges();
|
|
190
|
+
hide();
|
|
191
|
+
onAction(action, fields);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
112
194
|
const preset = btn.dataset.index === undefined ? null : presets[Number(btn.dataset.index)];
|
|
113
195
|
const text = preset ? presetNote(pendingQuote, preset) : pendingQuote;
|
|
114
196
|
win.getSelection()?.removeAllRanges();
|
|
@@ -46,6 +46,7 @@ import { createTaskStopper } from "./task_stop.js";
|
|
|
46
46
|
import { appendQuote } from "./annotations.js";
|
|
47
47
|
import { parsePresets } from "./annotate_presets.js";
|
|
48
48
|
import { createAnnotateBar } from "./annotate_bar.js";
|
|
49
|
+
import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
|
|
49
50
|
import { copyText, installCopy } from "./copy.js";
|
|
50
51
|
import { ctxBarText, ctxBarTip, lastSpeedText, speedText } from "./ctx.js";
|
|
51
52
|
import { createQuestionCards } from "./question_cards.js";
|
|
@@ -76,6 +77,10 @@ import { createComposerCommands, routeComposerLine, routeSessionLine } from "./c
|
|
|
76
77
|
import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js";
|
|
77
78
|
import { createStreamHandlers } from "./stream_handlers.js";
|
|
78
79
|
import { createListsView } from "./lists_view.js";
|
|
80
|
+
import { createDeliveryControl, QUEUE_NEEDS_WORKER } from "./delivery_line.js";
|
|
81
|
+
import { liveSlots } from "./stage_model.js";
|
|
82
|
+
import { currentGen } from "./turn_model.js";
|
|
83
|
+
import { toolName } from "./activity.js";
|
|
79
84
|
|
|
80
85
|
const $ = (s) => document.querySelector(s);
|
|
81
86
|
const topStripEl = $("#topStrip");
|
|
@@ -251,6 +256,28 @@ const liveTurn = createLiveTurn({
|
|
|
251
256
|
onTick: () => updateInfoBar(),
|
|
252
257
|
});
|
|
253
258
|
|
|
259
|
+
// The composer's delivery line while the open session's turn runs
|
|
260
|
+
// (delivery_line.js): where a message typed now goes in, and the cut and
|
|
261
|
+
// queue links.
|
|
262
|
+
const deliveryControl = createDeliveryControl({
|
|
263
|
+
el: $("#deliveryLine"),
|
|
264
|
+
read: () => {
|
|
265
|
+
const turn = turnDom.current?.().turn || null;
|
|
266
|
+
const running = turn ? turn.gens.flatMap((g) => g.tools).reverse().find((r) => r.status === "running") : null;
|
|
267
|
+
return {
|
|
268
|
+
phase: turnDom.phase?.() || liveSlots(turn).phase,
|
|
269
|
+
gen: turn ? currentGen(turn) : null,
|
|
270
|
+
tool: running ? { name: toolName(running), startedAt: running.startedAt ?? null } : null,
|
|
271
|
+
cutAfter: liveTurn.cutAfter,
|
|
272
|
+
serverGen: liveTurn.serverGen,
|
|
273
|
+
canQueue: current.workerFeatures.includes("delivery"),
|
|
274
|
+
model: current.model,
|
|
275
|
+
};
|
|
276
|
+
},
|
|
277
|
+
composerEmpty: () => !promptEl.value.trim() && !chips.length,
|
|
278
|
+
send: (delivery) => handleSendTurn({ delivery }),
|
|
279
|
+
});
|
|
280
|
+
|
|
254
281
|
// This tab's id in the session's events: tells its own prompts from the
|
|
255
282
|
// ones other clients (an attached TUI, another tab, reminders) sent.
|
|
256
283
|
const clientId = newClientId();
|
|
@@ -552,8 +579,9 @@ function updateComposerMode() {
|
|
|
552
579
|
}
|
|
553
580
|
const running = liveTurn.running && !!selected;
|
|
554
581
|
cancelBtn.style.display = running ? "" : "none";
|
|
582
|
+
syncDeliveryLine();
|
|
555
583
|
if (running) {
|
|
556
|
-
setPlaceholder("Running… a message
|
|
584
|
+
setPlaceholder("Running… a message goes in at the next step · Enter to send · Alt+Enter queues", "Steer the turn…");
|
|
557
585
|
composeHintEl.textContent = SEND_HINT;
|
|
558
586
|
actionBtn.textContent = "Send";
|
|
559
587
|
emptyEl.classList.add("hidden");
|
|
@@ -574,7 +602,8 @@ function updateComposerMode() {
|
|
|
574
602
|
actionBtn.disabled = true;
|
|
575
603
|
emptyEl.classList.add("hidden");
|
|
576
604
|
} else {
|
|
577
|
-
|
|
605
|
+
// The keys are in the delivery line's row above.
|
|
606
|
+
setPlaceholder("Message chi…", "Message chi…");
|
|
578
607
|
composeHintEl.textContent = SEND_HINT;
|
|
579
608
|
actionBtn.textContent = "Send";
|
|
580
609
|
actionBtn.disabled = false;
|
|
@@ -582,6 +611,14 @@ function updateComposerMode() {
|
|
|
582
611
|
}
|
|
583
612
|
}
|
|
584
613
|
|
|
614
|
+
// The delivery line's row in a session (delivery_line.js): the key hint
|
|
615
|
+
// while idle, where a message goes in while the turn runs.
|
|
616
|
+
function syncDeliveryLine() {
|
|
617
|
+
// A terminal owns the session: the composer is off, so is its row.
|
|
618
|
+
if (!selected || current.owner === "tui") deliveryControl.show("off");
|
|
619
|
+
else deliveryControl.show(liveTurn.running && current.owner !== "tui" ? "running" : "idle");
|
|
620
|
+
}
|
|
621
|
+
|
|
585
622
|
function setTurnRunning(running) {
|
|
586
623
|
liveTurn.running = !!running;
|
|
587
624
|
updateComposerMode();
|
|
@@ -790,6 +827,9 @@ function renderSessionView(data, { kept = [] } = {}) {
|
|
|
790
827
|
});
|
|
791
828
|
// Marked replayed: a call's duration isn't timed from the replay's clock.
|
|
792
829
|
replay.forEach((event) => streamHandlers[event.type]?.({ ...event, replayed: true }));
|
|
830
|
+
// A join mid-generation: the worker's word on it clocks the delivery line.
|
|
831
|
+
const gen = data.current_turn?.generation;
|
|
832
|
+
liveTurn.serverGen = gen ? { ...gen, at: Date.now() } : null;
|
|
793
833
|
if (!data.current_turn) {
|
|
794
834
|
setTurnRunning(current.status === "running");
|
|
795
835
|
if (liveTurn.running && current.timing.activeTurn?.started_at) liveTurn.timing.start(current.timing.activeTurn.started_at, activeTurnId);
|
|
@@ -997,11 +1037,13 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
|
|
|
997
1037
|
// prompts are echoed at send (data-own) and tagged by their turn_enqueued.
|
|
998
1038
|
|
|
999
1039
|
// The bubble a reload draws (turn_html.js), with this page's own fields.
|
|
1000
|
-
|
|
1040
|
+
// +delivery+: how it went into a running turn (next_step, cut, queue): its
|
|
1041
|
+
// queued badge says what it waits for.
|
|
1042
|
+
function makeUserBubble(prompt, { state = null, label = null, enqueuedId = null, own = false, images = null, delivery = null } = {}) {
|
|
1001
1043
|
const hint = historyEl.querySelector(".hint");
|
|
1002
1044
|
if (hint) hint.remove();
|
|
1003
1045
|
const div = elementOf(userBubbleTemplate({ content: prompt }, {
|
|
1004
|
-
label, state, own, enqueuedId, key: normalize(prompt), thumbs: thumbsTemplate(selected, images),
|
|
1046
|
+
label, state, own, enqueuedId, delivery, key: normalize(prompt), thumbs: thumbsTemplate(selected, images),
|
|
1005
1047
|
}));
|
|
1006
1048
|
const follow = isNearBottom();
|
|
1007
1049
|
appendToHistory(div);
|
|
@@ -1018,7 +1060,17 @@ function setBubbleState(bubble, state) {
|
|
|
1018
1060
|
}
|
|
1019
1061
|
bubble.classList.add(state);
|
|
1020
1062
|
bubble.dataset.userState = state;
|
|
1021
|
-
bubble.append(elementOf(stateBadgeTemplate(state)));
|
|
1063
|
+
bubble.append(elementOf(stateBadgeTemplate(state, bubble.dataset.delivery || null)));
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// A bubble's delivery changed (the ack's cut: off goes in at the next
|
|
1067
|
+
// step; a queue to a turn that ended meanwhile is just the next turn): its
|
|
1068
|
+
// badge follows.
|
|
1069
|
+
function setBubbleDelivery(bubble, delivery) {
|
|
1070
|
+
if ((bubble.dataset.delivery || null) === (delivery || null)) return;
|
|
1071
|
+
if (delivery) bubble.dataset.delivery = delivery;
|
|
1072
|
+
else delete bubble.dataset.delivery;
|
|
1073
|
+
if (bubble.dataset.userState) setBubbleState(bubble, bubble.dataset.userState);
|
|
1022
1074
|
}
|
|
1023
1075
|
|
|
1024
1076
|
function bubbleById(enqueuedId) {
|
|
@@ -1047,15 +1099,18 @@ function applyPromptOps(event) {
|
|
|
1047
1099
|
if (op.op === "report") {
|
|
1048
1100
|
liveTurn.unmatchedMerge = "report";
|
|
1049
1101
|
} else if (op.op === "add") {
|
|
1050
|
-
const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images });
|
|
1102
|
+
const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery });
|
|
1051
1103
|
if (op.state === "started") started = bubble;
|
|
1052
1104
|
if (event.type === "pending_input_merged") liveTurn.wakeStart = false;
|
|
1053
1105
|
} else if (op.op === "tag") {
|
|
1054
1106
|
if (bubbleById(op.enqueuedId)) continue;
|
|
1055
1107
|
const echo = untaggedOwnBubble(op.prompt);
|
|
1056
|
-
if (echo)
|
|
1108
|
+
if (echo) {
|
|
1109
|
+
echo.dataset.enqueuedId = op.enqueuedId;
|
|
1110
|
+
if (op.delivery) setBubbleDelivery(echo, op.delivery);
|
|
1111
|
+
}
|
|
1057
1112
|
// Re-rendered from a snapshot: the echo is gone, the prompt still queued.
|
|
1058
|
-
else makeUserBubble(op.prompt, { state: "queued", own: true, enqueuedId: op.enqueuedId, images: op.images });
|
|
1113
|
+
else makeUserBubble(op.prompt, { state: "queued", own: true, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery });
|
|
1059
1114
|
} else if (op.op === "start") {
|
|
1060
1115
|
const bubble = bubbleById(op.enqueuedId) || untaggedOwnBubble(op.prompt);
|
|
1061
1116
|
// A resync (a new worker woken by this page's send) re-rendered the
|
|
@@ -1422,6 +1477,8 @@ function updateInfoBar() {
|
|
|
1422
1477
|
llmContext: current.llmContext,
|
|
1423
1478
|
thinking: current.thinking,
|
|
1424
1479
|
reasoningHistory: current.reasoningHistory,
|
|
1480
|
+
// The tools chip (toolkit_chip.js): null on full.
|
|
1481
|
+
toolkit: current.toolkit,
|
|
1425
1482
|
// The model notes the session's prompt carried.
|
|
1426
1483
|
notes: promptNotesChip(current.promptNotes),
|
|
1427
1484
|
memories: current.usedMemories,
|
|
@@ -1683,10 +1740,14 @@ function handleCreate() {
|
|
|
1683
1740
|
return handleSendTurn();
|
|
1684
1741
|
}
|
|
1685
1742
|
|
|
1686
|
-
|
|
1743
|
+
// +delivery+: the composer's choice for a running turn (Alt+Enter: queue;
|
|
1744
|
+
// a delivery-line link: cut or queue); else the armed one; a typed /cut or
|
|
1745
|
+
// /queue wins over both.
|
|
1746
|
+
async function handleSendTurn({ delivery: chosen = null } = {}) {
|
|
1687
1747
|
if (current.owner === "tui") return;
|
|
1688
1748
|
let prompt = turnText(promptEl.value, chips);
|
|
1689
1749
|
if (!prompt) return;
|
|
1750
|
+
const armed = deliveryControl.take();
|
|
1690
1751
|
// An image-only send's text is its placeholders: not for ↑.
|
|
1691
1752
|
const typed = promptEl.value.trim() !== "";
|
|
1692
1753
|
// The composer holds the line as typed until the ack (composerAfterAck).
|
|
@@ -1727,14 +1788,18 @@ async function handleSendTurn() {
|
|
|
1727
1788
|
}
|
|
1728
1789
|
if (route.command) return composerCommands.send(route.command);
|
|
1729
1790
|
prompt = route.send;
|
|
1730
|
-
const
|
|
1791
|
+
const running = liveTurn.running && !!selected;
|
|
1792
|
+
const delivery = route.delivery || (running ? chosen || armed : null);
|
|
1731
1793
|
const hint = historyEl.querySelector(".hint");
|
|
1732
1794
|
if (hint) hint.remove();
|
|
1733
1795
|
// Sending moves a finished turn out of the stage at once (else the echo
|
|
1734
1796
|
// would land among its rows); into a running one it is a queued row there.
|
|
1735
1797
|
turnDom.handOff?.({ immediate: true });
|
|
1736
1798
|
const sending = chips.slice();
|
|
1737
|
-
|
|
1799
|
+
// Into a running turn the echo says what it waits for (its delivery); a
|
|
1800
|
+
// line with images always waits for the turn's end (the worker queues it).
|
|
1801
|
+
const shownDelivery = running ? (sending.length ? "queue" : delivery || "next_step") : null;
|
|
1802
|
+
const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending, delivery: shownDelivery });
|
|
1738
1803
|
if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1739
1804
|
if (!current.firstPreview || current.firstPreview==="—") {
|
|
1740
1805
|
current.firstPreview = firstPreview(prompt);
|
|
@@ -1756,6 +1821,11 @@ async function handleSendTurn() {
|
|
|
1756
1821
|
return;
|
|
1757
1822
|
}
|
|
1758
1823
|
if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
|
|
1824
|
+
// A cut the worker didn't take (no thinking-only step: cut off) goes in
|
|
1825
|
+
// at the next step; a queue whose turn ended meanwhile is the next turn.
|
|
1826
|
+
if (ack?.cut === "off" || (ack?.delivery === "next_step" && echo.dataset.delivery === "queue")) {
|
|
1827
|
+
setBubbleDelivery(echo, ack.cut === "off" ? "next_step" : null);
|
|
1828
|
+
}
|
|
1759
1829
|
refreshHistory();
|
|
1760
1830
|
// A turn that failed fast was restored before this ack: its prompt
|
|
1761
1831
|
// (and images) go back into the composer, without replacing text typed
|
|
@@ -2118,10 +2188,20 @@ function historyKey(e) {
|
|
|
2118
2188
|
promptEl.addEventListener("keydown", (e) => {
|
|
2119
2189
|
if (commandList.handleKey(e)) return;
|
|
2120
2190
|
if (historyKey(e)) return;
|
|
2121
|
-
|
|
2191
|
+
// Esc lets go of a choice armed on the delivery line.
|
|
2192
|
+
if (e.key === "Escape" && deliveryControl.disarm()) {
|
|
2193
|
+
e.preventDefault();
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
// An IME's Enter picks the composition, it doesn't send.
|
|
2197
|
+
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
|
|
2122
2198
|
e.preventDefault();
|
|
2123
2199
|
if (!selected) handleCreate();
|
|
2124
|
-
|
|
2200
|
+
// Alt/Option+Enter: queued for after the running turn (D6), only to a
|
|
2201
|
+
// worker that queues (else it would steer): the text stays, a note says why.
|
|
2202
|
+
else if (e.altKey && liveTurn.running && !current.workerFeatures.includes("delivery")) {
|
|
2203
|
+
addCommandBubble({ label: null, line: promptEl.value.trim(), text: QUEUE_NEEDS_WORKER, failed: true });
|
|
2204
|
+
} else handleSendTurn(e.altKey ? { delivery: "queue" } : {});
|
|
2125
2205
|
}
|
|
2126
2206
|
});
|
|
2127
2207
|
$("#infoStopBtn").addEventListener("click", async () => {
|
|
@@ -2302,18 +2382,29 @@ function showToast(text, action = null, { sticky = false } = {}) {
|
|
|
2302
2382
|
}
|
|
2303
2383
|
|
|
2304
2384
|
// Annotate (annotate_bar.js): a selection in the conversation offers
|
|
2305
|
-
// "Annotate" and the presets, which append a quote to the composer
|
|
2385
|
+
// "Annotate" and the presets, which append a quote to the composer, and the
|
|
2386
|
+
// session's plugins' pills (chi.annotation, annotate_actions.js), which fill
|
|
2387
|
+
// the composer with their command line and the quote, or send it at once.
|
|
2388
|
+
function fillComposer(text) {
|
|
2389
|
+
promptEl.value = text;
|
|
2390
|
+
fitPrompt();
|
|
2391
|
+
promptEl.focus();
|
|
2392
|
+
promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
|
|
2393
|
+
promptEl.scrollTop = promptEl.scrollHeight;
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2306
2396
|
const annotateBar = createAnnotateBar({
|
|
2307
2397
|
presets: parsePresets(document.body.dataset.annotatePresets),
|
|
2308
2398
|
canAnnotate: () => !!selected && current.owner !== "tui",
|
|
2309
2399
|
roots: turnRoots,
|
|
2310
2400
|
isLive: (root) => liveTurn.running && turnDom.containsLive(root),
|
|
2311
|
-
onQuote: (text) =>
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2401
|
+
onQuote: (text) => fillComposer(appendQuote(promptEl.value, text)),
|
|
2402
|
+
actions: () => annotationActions(current.commands),
|
|
2403
|
+
running: () => liveTurn.running,
|
|
2404
|
+
onAction: (action, fields) => {
|
|
2405
|
+
const line = actionLine(action, fields);
|
|
2406
|
+
if (action.send) runSessionCommand(line);
|
|
2407
|
+
else fillComposer(composerWithAction(promptEl.value, action, line));
|
|
2317
2408
|
},
|
|
2318
2409
|
});
|
|
2319
2410
|
historyEl.addEventListener("scroll", annotateBar.schedule);
|
|
@@ -10,7 +10,8 @@ import { anchorInsertIndex, commandAnchor, isShellCommandLine, keepsCommandBubbl
|
|
|
10
10
|
// Whether a composer line in an open session is one of its commands
|
|
11
11
|
// (+commands+: the session GET's listing), as the terminals decide
|
|
12
12
|
// (Commands::Registry#lookup): a "!" line with something after the "!", or
|
|
13
|
-
// a listed name alone or followed by
|
|
13
|
+
// a listed name alone or followed by whitespace (a newline too: an
|
|
14
|
+
// annotation action's line puts the quote under the name); a terminal's own (/stats,
|
|
14
15
|
// /exit) too, which the page answers itself (webLocalReply). An unknown
|
|
15
16
|
// "/word" (a path, a typo) goes to the model as a prompt, as in a terminal.
|
|
16
17
|
export function sessionCommandLine(text, commands) {
|
|
@@ -19,7 +20,8 @@ export function sessionCommandLine(text, commands) {
|
|
|
19
20
|
if (!line.startsWith("/")) return false;
|
|
20
21
|
if (webLocalReply(line)) return true;
|
|
21
22
|
return (Array.isArray(commands) ? commands : [])
|
|
22
|
-
.some((c) => c && typeof c.name === "string"
|
|
23
|
+
.some((c) => c && typeof c.name === "string"
|
|
24
|
+
&& (line === c.name || (line.startsWith(c.name) && /\s/.test(line[c.name.length]))));
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
// /cut TEXT and /queue TEXT: the UIs' own words for a delivery (listed
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// The composer's delivery line (web, steer delivery P4w): a row above the
|
|
2
|
+
// text field in a session, always there so the composer never changes
|
|
3
|
+
// height (the stage above it would jump). Idle it holds the key hint; while
|
|
4
|
+
// the open session's turn runs it says where a message typed now goes in,
|
|
5
|
+
// and offers the other two deliveries as links:
|
|
6
|
+
//
|
|
7
|
+
// ↳ goes in after this thinking step · 2:31 · 31k chars cut in now · queue for after the turn
|
|
8
|
+
// ↳ goes in after this tool call (execute · 0:12) queue for after the turn
|
|
9
|
+
//
|
|
10
|
+
// Send (Enter, the button) is next_step; "queue for after the turn" (or
|
|
11
|
+
// Alt/Option+Enter) runs the message as a turn of its own after this one;
|
|
12
|
+
// "cut in now" (a click only: it throws the thinking away) cuts a
|
|
13
|
+
// thinking-only generation (the worker's SteerCut rule: once it has
|
|
14
|
+
// thought for the model's cut_after seconds). A link clicked with an empty
|
|
15
|
+
// composer arms its choice for the next Enter; Esc or a second click
|
|
16
|
+
// disarms it.
|
|
17
|
+
//
|
|
18
|
+
// deliveryLine is pure (node tests); deliveryLineTemplate is its lit view.
|
|
19
|
+
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
20
|
+
|
|
21
|
+
export const QUEUE_LABEL = "queue for after the turn";
|
|
22
|
+
export const CUT_LABEL = "cut in now";
|
|
23
|
+
// A phone's row (≤ 600 px) is short: its text and chips.
|
|
24
|
+
export const QUEUE_SHORT = "queue";
|
|
25
|
+
export const CUT_SHORT = "cut in";
|
|
26
|
+
// Alt+Enter to a worker without the delivery feature: not sent (a queue
|
|
27
|
+
// would steer it), the text kept, this said.
|
|
28
|
+
export const QUEUE_NEEDS_WORKER = "Not sent: queueing needs a newer worker (it would steer the running turn). "
|
|
29
|
+
+ "Restart it (chi sessions restart ID, or the update badge), then send again.";
|
|
30
|
+
// The idle row: the keys (the placeholder's hint before the row existed).
|
|
31
|
+
export const IDLE_HINT = "Enter to send · Shift+Enter newline · ↑ history";
|
|
32
|
+
|
|
33
|
+
// m:ss (h:mm:ss past an hour) of +seconds+; "" when unknown.
|
|
34
|
+
export function clockText(seconds) {
|
|
35
|
+
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return "";
|
|
36
|
+
const total = Math.floor(seconds);
|
|
37
|
+
const h = Math.floor(total / 3600);
|
|
38
|
+
const m = Math.floor((total % 3600) / 60);
|
|
39
|
+
const s = String(total % 60).padStart(2, "0");
|
|
40
|
+
return h ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// "812 chars", "31k chars"; "" for none.
|
|
44
|
+
export function charsText(count) {
|
|
45
|
+
if (!count) return "";
|
|
46
|
+
return count < 1000 ? `${count} chars` : `${Math.round(count / 1000)}k chars`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Seconds the current generation has run: from the page's own clock (its
|
|
50
|
+
// first thinking delta, thinkingAt, as the worker's cut counts; else its
|
|
51
|
+
// startedAt, set at generation_started), else from the worker's snapshot
|
|
52
|
+
// (+serverGen+: {thinking_only, age, at}; a page that joined mid-generation
|
|
53
|
+
// has no start of its own: age as of +at+, the page's clock when the
|
|
54
|
+
// snapshot came); null when neither.
|
|
55
|
+
export function genAge(gen, serverGen, now) {
|
|
56
|
+
const since = gen?.thinkingAt ?? gen?.startedAt;
|
|
57
|
+
if (since != null) return Math.max(0, (now - since) / 1000);
|
|
58
|
+
if (serverGen?.age != null && serverGen.at != null) return serverGen.age + Math.max(0, (now - serverGen.at) / 1000);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Whether the generation has streamed only thinking: thinking, no visible
|
|
63
|
+
// text, no tool call (the worker's GenerationPhase, freshness aside: a
|
|
64
|
+
// stale cut is the worker's to refuse or hold, its ack says which). The
|
|
65
|
+
// worker's own word (+serverGen+) wins: it also sees tool-call chunks.
|
|
66
|
+
function thinkingOnly(gen, serverGen) {
|
|
67
|
+
if (!gen?.thinking || gen.text?.trim() || gen.tools?.length) return false;
|
|
68
|
+
return serverGen?.thinking_only !== false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// What the line shows for the running turn:
|
|
72
|
+
// phase stage_model.js liveSlots(turn).phase
|
|
73
|
+
// gen the current gen (turn_model.js: thinking, text, tools, startedAt)
|
|
74
|
+
// tool the running call: {name, startedAt} (activity.js row), or null
|
|
75
|
+
// cutAfter the run's cut wait in seconds (turn_started / current_turn
|
|
76
|
+
// cut_after): 0 is cutting off, null unknown (an older worker)
|
|
77
|
+
// serverGen the snapshot's current_turn.generation with `at`, or null
|
|
78
|
+
// now the page's clock (ms)
|
|
79
|
+
// armed "cut" | "queue" | null: the choice the next Enter sends
|
|
80
|
+
// canQueue the worker takes delivery: queue (its "delivery" feature)
|
|
81
|
+
// model the session's model, for the cut-off reason
|
|
82
|
+
// → {text, short, cut: {enabled, shown, reason, label, short}, queue:
|
|
83
|
+
// {enabled, shown, label, short}, armed}: short is the phone's text;
|
|
84
|
+
// armed is what is still armed (a cut no longer possible disarms). With
|
|
85
|
+
// cutting off the cut link goes (the text says why); without the
|
|
86
|
+
// feature the queue link goes.
|
|
87
|
+
export function deliveryLine({ phase = "thinking", gen = null, tool = null, cutAfter = null, serverGen = null, now = Date.now(),
|
|
88
|
+
armed = null, canQueue = true, model = "" } = {}) {
|
|
89
|
+
const cutOff = cutAfter === 0 || (cutAfter != null && cutAfter < 0);
|
|
90
|
+
const thinking = phase === "thinking" && thinkingOnly(gen, serverGen);
|
|
91
|
+
const age = genAge(gen, serverGen, now);
|
|
92
|
+
let where;
|
|
93
|
+
let short;
|
|
94
|
+
let reason = "";
|
|
95
|
+
if (phase === "running a tool") {
|
|
96
|
+
const clock = clockText(tool?.startedAt != null ? (now - tool.startedAt) / 1000 : null);
|
|
97
|
+
where = `goes in after this tool call (${[tool?.name || "tool", clock].filter(Boolean).join(" · ")})`;
|
|
98
|
+
short = ["after this tool call", clock].filter(Boolean).join(" · ");
|
|
99
|
+
reason = "a tool is running: only a thinking step can be cut";
|
|
100
|
+
} else if (phase === "waiting for you") {
|
|
101
|
+
where = "goes in once the card above is answered";
|
|
102
|
+
short = "after the card's answer";
|
|
103
|
+
reason = "the turn waits for you";
|
|
104
|
+
} else if (phase === "writing" || phase === "writing the answer") {
|
|
105
|
+
where = "goes in after this answer; the turn may go on";
|
|
106
|
+
short = "after this answer";
|
|
107
|
+
reason = "the model is writing: only a thinking step can be cut";
|
|
108
|
+
} else if (thinking) {
|
|
109
|
+
where = ["goes in after this thinking step", clockText(age), charsText(gen.thinking.length)].filter(Boolean).join(" · ");
|
|
110
|
+
short = ["after this thinking", clockText(age)].filter(Boolean).join(" · ");
|
|
111
|
+
} else {
|
|
112
|
+
where = "goes in after this step";
|
|
113
|
+
short = "after this step";
|
|
114
|
+
reason = "nothing to cut until the model thinks";
|
|
115
|
+
}
|
|
116
|
+
if (cutOff) reason = `cutting is off for ${model || "this model"}`;
|
|
117
|
+
const cutEnabled = !cutOff && thinking;
|
|
118
|
+
let wait = "";
|
|
119
|
+
if (cutEnabled && cutAfter > 0 && age != null && age < cutAfter) wait = `cuts at about ${clockText(cutAfter)} if you choose ${CUT_LABEL}`;
|
|
120
|
+
const effective = (armed === "cut" && cutEnabled) || (armed === "queue" && canQueue) ? armed : null;
|
|
121
|
+
const parts = [cutOff ? reason : "", where, wait].filter(Boolean);
|
|
122
|
+
// A phone's short row leaves it out: its ticked chip says it.
|
|
123
|
+
if (effective) parts.push("Enter sends");
|
|
124
|
+
const tick = (label, which) => (effective === which ? `${label} \u2713` : label);
|
|
125
|
+
return {
|
|
126
|
+
text: `\u21B3 ${parts.join(" · ")}`,
|
|
127
|
+
short: `\u21B3 ${short}`,
|
|
128
|
+
cut: { enabled: cutEnabled, shown: !cutOff, reason: cutEnabled ? "" : reason, label: tick(CUT_LABEL, "cut"), short: tick(CUT_SHORT, "cut") },
|
|
129
|
+
queue: { enabled: canQueue, shown: canQueue, label: tick(QUEUE_LABEL, "queue"), short: tick(QUEUE_SHORT, "queue") },
|
|
130
|
+
armed: effective,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The line's view: +line+ is deliveryLine's; +onCut+ / +onQueue+ the
|
|
135
|
+
// links' clicks. A disabled cut stays in place, its reason on hover.
|
|
136
|
+
export function deliveryLineTemplate(line, { onCut, onQueue } = {}) {
|
|
137
|
+
const link = (which, part, onClick) => html`<button type="button" class="delivery-link ${which}${line.armed === which ? " armed" : ""}"
|
|
138
|
+
?disabled=${!part.enabled} title=${part.reason || nothing} aria-label=${part.label} aria-pressed=${line.armed === which ? "true" : "false"}
|
|
139
|
+
@mousedown=${(e) => e.preventDefault()} @click=${onClick}><span class="long">${part.label}</span><span class="short">${part.short}</span></button>`;
|
|
140
|
+
const links = [line.cut.shown ? link("cut", line.cut, onCut) : null, line.queue.shown ? link("queue", line.queue, onQueue) : null]
|
|
141
|
+
.filter(Boolean);
|
|
142
|
+
return html`<span class="delivery-where" title=${line.text}><span class="long">${line.text}</span><span class="short">${line.short}</span></span><span class="delivery-links">${links.map((l, i) =>
|
|
143
|
+
(i ? html`<span class="delivery-sep" aria-hidden="true">·</span>${l}` : l))}</span>`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The idle row: the key hint (none on a phone: no keyboard keys there).
|
|
147
|
+
export function idleTemplate() {
|
|
148
|
+
return html`<span class="delivery-where idle"><span class="long">${IDLE_HINT}</span><span class="short"></span></span>`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The row on the page: the key hint while idle, the line while the turn
|
|
152
|
+
// runs (its clock ticking every TICK_MS), nothing outside a session; the
|
|
153
|
+
// armed choice is kept here. No page globals: the page passes its hooks.
|
|
154
|
+
// read() deliveryLine's inputs but now and armed (the turn's
|
|
155
|
+
// phase, gen, tool, cutAfter, serverGen, canQueue, model)
|
|
156
|
+
// composerEmpty() nothing typed and no image chips: a link arms
|
|
157
|
+
// send(delivery) send the composer's line with +delivery+ ("cut", "queue")
|
|
158
|
+
export const TICK_MS = 500;
|
|
159
|
+
|
|
160
|
+
export function createDeliveryControl({ el, read, composerEmpty, send, now = () => Date.now(),
|
|
161
|
+
setTimer = (fn, ms) => setInterval(fn, ms), clearTimer = (t) => clearInterval(t) }) {
|
|
162
|
+
// "off" (no session: the row is gone), "idle" or "running".
|
|
163
|
+
let mode = "off";
|
|
164
|
+
let armed = null;
|
|
165
|
+
let timer = null;
|
|
166
|
+
let line = null;
|
|
167
|
+
|
|
168
|
+
const click = (which) => {
|
|
169
|
+
if (mode !== "running" || !line?.[which]?.enabled) return;
|
|
170
|
+
if (!composerEmpty()) {
|
|
171
|
+
armed = null;
|
|
172
|
+
send(which);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
armed = armed === which ? null : which;
|
|
176
|
+
refresh();
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
function refresh() {
|
|
180
|
+
if (mode === "off") return;
|
|
181
|
+
if (mode === "idle") {
|
|
182
|
+
render(idleTemplate(), el);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
line = deliveryLine({ ...read(), now: now(), armed });
|
|
186
|
+
// A choice no longer possible (a tool started) disarms.
|
|
187
|
+
armed = line.armed;
|
|
188
|
+
render(deliveryLineTemplate(line, { onCut: () => click("cut"), onQueue: () => click("queue") }), el);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
// +next+: "off", "idle" or "running".
|
|
193
|
+
show(next) {
|
|
194
|
+
if (next !== mode) {
|
|
195
|
+
mode = next;
|
|
196
|
+
el.hidden = mode === "off";
|
|
197
|
+
el.classList.toggle("running", mode === "running");
|
|
198
|
+
if (mode === "running") timer ??= setTimer(refresh, TICK_MS);
|
|
199
|
+
else {
|
|
200
|
+
clearTimer(timer);
|
|
201
|
+
timer = null;
|
|
202
|
+
armed = null;
|
|
203
|
+
line = null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
refresh();
|
|
207
|
+
},
|
|
208
|
+
refresh,
|
|
209
|
+
click,
|
|
210
|
+
armed: () => armed,
|
|
211
|
+
// The armed choice for this send, and disarmed.
|
|
212
|
+
take() {
|
|
213
|
+
const was = armed;
|
|
214
|
+
if (was) {
|
|
215
|
+
armed = null;
|
|
216
|
+
refresh();
|
|
217
|
+
}
|
|
218
|
+
return was;
|
|
219
|
+
},
|
|
220
|
+
disarm() {
|
|
221
|
+
if (!armed) return false;
|
|
222
|
+
armed = null;
|
|
223
|
+
refresh();
|
|
224
|
+
return true;
|
|
225
|
+
},
|
|
226
|
+
line: () => line,
|
|
227
|
+
};
|
|
228
|
+
}
|