samagotchi 0.9.0 → 0.10.1
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 +68 -1
- data/docs/architecture.md +24 -9
- data/docs/cli.md +4 -3
- data/docs/guardrails.md +5 -3
- data/docs/hooks.md +44 -7
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/plugins.md +36 -13
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bootstrap_command.rb +29 -29
- data/lib/samagotchi/bridge/card_store.rb +4 -4
- data/lib/samagotchi/bridge/pending_card.rb +2 -2
- data/lib/samagotchi/bridge/sse_writer.rb +22 -28
- data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
- data/lib/samagotchi/bridge.rb +59 -112
- data/lib/samagotchi/bridge_client.rb +11 -28
- data/lib/samagotchi/bundle_command.rb +158 -212
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +13 -4
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
- data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
- data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +18 -9
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cli/command.rb +51 -0
- data/lib/samagotchi/cli/flags.rb +116 -0
- data/lib/samagotchi/config.rb +70 -55
- data/lib/samagotchi/config_text_edit.rb +107 -0
- data/lib/samagotchi/desktop_command.rb +24 -18
- data/lib/samagotchi/edit_preview.rb +2 -2
- data/lib/samagotchi/engine.rb +20 -75
- data/lib/samagotchi/events.rb +23 -0
- data/lib/samagotchi/guardrail_wiring.rb +2 -5
- data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
- data/lib/samagotchi/hooks/loader.rb +23 -33
- data/lib/samagotchi/hooks/registry.rb +41 -0
- data/lib/samagotchi/hooks.rb +0 -3
- data/lib/samagotchi/host_registry.rb +15 -0
- data/lib/samagotchi/idle_reminders.rb +0 -7
- data/lib/samagotchi/image_store.rb +22 -0
- data/lib/samagotchi/kernel_loop.rb +12 -16
- data/lib/samagotchi/live_versions.rb +2 -13
- data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
- data/lib/samagotchi/memory_bundle/builder.rb +1 -14
- data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
- data/lib/samagotchi/memory_bundle/installer.rb +1 -27
- data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
- data/lib/samagotchi/memory_bundle/status.rb +1 -14
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
- data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
- data/lib/samagotchi/memory_paths.rb +10 -0
- data/lib/samagotchi/note_command.rb +23 -37
- data/lib/samagotchi/owner_lock.rb +14 -3
- data/lib/samagotchi/plugin/api.rb +11 -17
- data/lib/samagotchi/plugin/context.rb +70 -15
- data/lib/samagotchi/plugin/loader.rb +2 -3
- data/lib/samagotchi/sampling_settings.rb +3 -5
- data/lib/samagotchi/self_report.rb +6 -9
- data/lib/samagotchi/send_command.rb +33 -52
- data/lib/samagotchi/session.rb +88 -92
- data/lib/samagotchi/session_archive_command.rb +8 -14
- data/lib/samagotchi/session_commands.rb +3 -21
- data/lib/samagotchi/session_delete_command.rb +15 -29
- data/lib/samagotchi/session_manager.rb +27 -13
- data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
- data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
- data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
- data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
- data/lib/samagotchi/terminal_ui.rb +156 -234
- data/lib/samagotchi/thinking.rb +2 -3
- data/lib/samagotchi/thought_stream_splitter.rb +10 -9
- data/lib/samagotchi/tool_call_parser.rb +56 -292
- data/lib/samagotchi/tools/builtin_calls.rb +146 -0
- data/lib/samagotchi/tools/builtins.rb +2 -1
- data/lib/samagotchi/tools/edit.rb +21 -29
- data/lib/samagotchi/tools/memory.rb +1 -11
- data/lib/samagotchi/tools/send_note.rb +1 -1
- data/lib/samagotchi/update_command.rb +32 -22
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -5
- data/lib/samagotchi/web/app.rb +89 -151
- data/lib/samagotchi/web/public/activity.js +1 -11
- data/lib/samagotchi/web/public/annotate_bar.js +120 -0
- data/lib/samagotchi/web/public/app.js +158 -785
- data/lib/samagotchi/web/public/chunk_router.js +7 -6
- data/lib/samagotchi/web/public/command_list.js +99 -0
- data/lib/samagotchi/web/public/data.js +22 -66
- data/lib/samagotchi/web/public/format.js +0 -23
- data/lib/samagotchi/web/public/index.html +0 -2
- data/lib/samagotchi/web/public/model_pick.js +0 -7
- data/lib/samagotchi/web/public/notifications.js +169 -0
- data/lib/samagotchi/web/public/question_cards.js +352 -0
- data/lib/samagotchi/web/public/session_state.js +34 -0
- data/lib/samagotchi/web/public/timing.js +1 -1
- data/lib/samagotchi/web/public/turn_events.js +11 -2
- data/lib/samagotchi/web/public/turn_model.js +0 -14
- data/lib/samagotchi/web/session_hub.rb +6 -4
- data/lib/samagotchi/web/session_summary.rb +5 -3
- data/lib/samagotchi/worker.rb +59 -53
- data/lib/samagotchi/worker_sidecar.rb +27 -1
- metadata +12 -1
|
@@ -8,8 +8,6 @@ import {
|
|
|
8
8
|
deleteSession,
|
|
9
9
|
archiveSession,
|
|
10
10
|
unarchiveSession,
|
|
11
|
-
sendAnswer,
|
|
12
|
-
dismissQuestion,
|
|
13
11
|
sendCommand,
|
|
14
12
|
openStream,
|
|
15
13
|
openEvents,
|
|
@@ -18,32 +16,33 @@ import {
|
|
|
18
16
|
listModels,
|
|
19
17
|
} from "./data.js";
|
|
20
18
|
import { MODEL_KEY } from "./model_pick.js";
|
|
19
|
+
import { emptySessionState } from "./session_state.js";
|
|
21
20
|
import { createModelPicker } from "./model_picker.js";
|
|
22
21
|
import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
|
|
23
|
-
import { escapeHtml,
|
|
22
|
+
import { escapeHtml, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
|
|
24
23
|
import { createTurnView, turnHistoryHtml } from "./turn_view.js";
|
|
25
24
|
import { createStageView } from "./stage_view.js";
|
|
26
|
-
import {
|
|
27
|
-
import { parsePresets
|
|
28
|
-
import {
|
|
25
|
+
import { appendQuote } from "./annotations.js";
|
|
26
|
+
import { parsePresets } from "./annotate_presets.js";
|
|
27
|
+
import { createAnnotateBar } from "./annotate_bar.js";
|
|
28
|
+
import { COPY_SOURCE_ATTR, copySource, copyText, installCopy, showsAnswer } from "./copy.js";
|
|
29
29
|
import { routeChunk } from "./chunk_router.js";
|
|
30
30
|
import { extractCtxPct, savedCtxPct, cardCtxText } from "./ctx.js";
|
|
31
|
-
import {
|
|
32
|
-
import { approvalAllowed, approvalView, isApproval, nextCardAction, resultText, summaryText } from "./question_card.js";
|
|
31
|
+
import { createQuestionCards } from "./question_cards.js";
|
|
33
32
|
import { cardClass, cardInnerHtml, cardPlace, isCard, splitSnapshotCards, turnNoticePlace } from "./card.js";
|
|
34
33
|
import { gripFloor, promptHeight } from "./composer_size.js";
|
|
35
|
-
import {
|
|
34
|
+
import { createCommandList } from "./command_list.js";
|
|
36
35
|
import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
|
|
37
36
|
import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
|
|
38
37
|
import { shouldFollowScroll, keepFollowing } from "./scroll.js";
|
|
39
38
|
import { stripColumns, stripShowParts } from "./strip.js";
|
|
40
39
|
import { applySessionEvent, listedSessions, sortedByUpdated, withChildrenAfterParents } from "./sessions_list.js";
|
|
41
|
-
import {
|
|
40
|
+
import { createNotifications } from "./notifications.js";
|
|
42
41
|
import {
|
|
43
42
|
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
|
|
44
43
|
} from "./timing.js";
|
|
45
44
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
46
|
-
import { clientLabel, commandView,
|
|
45
|
+
import { clientLabel, commandView, emptyRetryLine, hookNoticeLabel, initWaitLine, isCommandLine, isOwn, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
47
46
|
|
|
48
47
|
const $ = (s) => document.querySelector(s);
|
|
49
48
|
const topStripEl = $("#topStrip");
|
|
@@ -96,25 +95,9 @@ function turnRoots() {
|
|
|
96
95
|
function findInTurnRoots(selector) {
|
|
97
96
|
return turnRoots().flatMap((root) => [...root.querySelectorAll(selector)]);
|
|
98
97
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
// the info bar's tooltip).
|
|
103
|
-
let currentPreloadedMemories = [];
|
|
104
|
-
let currentMutedMemories = [];
|
|
105
|
-
let currentParentId = null;
|
|
106
|
-
let currentCtxPct = null;
|
|
107
|
-
// Window the kernel resolved for the running generation (:generation_started).
|
|
108
|
-
let currentCtxWindow = null;
|
|
109
|
-
let currentStatus = "";
|
|
110
|
-
let currentModel = "";
|
|
111
|
-
// What the worker's server said it served for a model name: [served, asked].
|
|
112
|
-
let currentServed = null;
|
|
113
|
-
let currentDir = "";
|
|
114
|
-
// Who holds the selected session: "worker" (shared), "tui" (a plain terminal
|
|
115
|
-
// chi; sending would be refused) or null.
|
|
116
|
-
let currentOwner = null;
|
|
117
|
-
let currentTiming = normalizeTiming();
|
|
98
|
+
// The open session's fields (session_state.js); resetSessionState() starts
|
|
99
|
+
// them over.
|
|
100
|
+
let current = emptySessionState();
|
|
118
101
|
let turnRunning = false;
|
|
119
102
|
let liveTurnTimingEl = null;
|
|
120
103
|
// What the running turn waits on, after its live timing line: a provider
|
|
@@ -122,16 +105,19 @@ let liveTurnTimingEl = null;
|
|
|
122
105
|
// it streams again.
|
|
123
106
|
let liveTurnNote = "";
|
|
124
107
|
let timingInterval = null;
|
|
125
|
-
// Live ask_user_question card, keyed by pending question id. Re-rendered from
|
|
126
|
-
// pending_question on session select so a reload mid-question restores it.
|
|
127
|
-
let questionCardEl = null;
|
|
128
|
-
let pendingQuestion = null;
|
|
129
|
-
// The continue offer's card while the offer is pending.
|
|
130
|
-
let continueCardEl = null;
|
|
131
108
|
|
|
132
109
|
// This tab's id in the session's events: tells its own prompts from the
|
|
133
110
|
// ones other clients (an attached TUI, another tab, reminders) sent.
|
|
134
111
|
const clientId = newClientId();
|
|
112
|
+
// The question and continue cards in the history (question_cards.js).
|
|
113
|
+
const questionCards = createQuestionCards({
|
|
114
|
+
sessionId: () => selected,
|
|
115
|
+
clientId,
|
|
116
|
+
place: (card) => appendToHistory(card),
|
|
117
|
+
isNearBottom: () => isNearBottom(),
|
|
118
|
+
scrollToEnd: () => { historyEl.scrollTop = historyEl.scrollHeight; },
|
|
119
|
+
removeHintIfEmpty: () => removeHintIfEmpty(),
|
|
120
|
+
});
|
|
135
121
|
// The event id (or, from an older worker, the event_seq) the selected session
|
|
136
122
|
// was rendered at (null: no live worker then), where a stream opened later
|
|
137
123
|
// has to start.
|
|
@@ -182,20 +168,20 @@ async function refresh() {
|
|
|
182
168
|
function openSessionEvents() {
|
|
183
169
|
eventsControl = openEvents({
|
|
184
170
|
snapshot: (data) => {
|
|
185
|
-
noticeSessions("snapshot", data);
|
|
171
|
+
notifications.noticeSessions("snapshot", data);
|
|
186
172
|
allSessions = applySessionEvent(allSessions, "snapshot", data);
|
|
187
173
|
render();
|
|
188
174
|
const sess = allSessions.find((s) => s.id === selected);
|
|
189
175
|
if (sess) followSelectedSession(sess);
|
|
190
176
|
},
|
|
191
177
|
session: (data) => {
|
|
192
|
-
noticeSessions("session", data);
|
|
178
|
+
notifications.noticeSessions("session", data);
|
|
193
179
|
allSessions = applySessionEvent(allSessions, "session", data);
|
|
194
180
|
render();
|
|
195
181
|
if (data.session && data.session.id === selected) followSelectedSession(data.session);
|
|
196
182
|
},
|
|
197
183
|
session_gone: (data) => {
|
|
198
|
-
noticeSessions("session_gone", data);
|
|
184
|
+
notifications.noticeSessions("session_gone", data);
|
|
199
185
|
const next = applySessionEvent(allSessions, "session_gone", data);
|
|
200
186
|
if (next !== allSessions) {
|
|
201
187
|
allSessions = next;
|
|
@@ -217,9 +203,9 @@ function openSessionEvents() {
|
|
|
217
203
|
// (a turn another client sent woke one, or a replacement after a kill)
|
|
218
204
|
// gets the stream attached through one resync.
|
|
219
205
|
function followSelectedSession(sess) {
|
|
220
|
-
|
|
206
|
+
current.owner = sess.owner || null;
|
|
221
207
|
const streaming = !!(streamControl && streamControl.live);
|
|
222
|
-
if (!streaming)
|
|
208
|
+
if (!streaming) current.status = sess.status;
|
|
223
209
|
updateInfoBar();
|
|
224
210
|
if (!sess.bridge_up || streaming) return;
|
|
225
211
|
if (resyncsInFlight > 0) resyncAgain = true;
|
|
@@ -244,8 +230,7 @@ function renderScope() {
|
|
|
244
230
|
el.innerHTML = `<span class="scope-name" title="Sessions of every project">all projects</span>${backLink}${where ? `<span class="scope-where" title="New chats start in ${escapeHtml(where)}">new chat in<span class="path"><bdi>${escapeHtml(where)}</bdi></span></span>` : ""}`;
|
|
245
231
|
}
|
|
246
232
|
el.hidden = false;
|
|
247
|
-
|
|
248
|
-
setTitle();
|
|
233
|
+
notifications.setBaseTitle(scope.projectName ? `Chi · ${scope.projectName}` : "Chi");
|
|
249
234
|
// The links follow the hash (an open session, the all-sessions view).
|
|
250
235
|
el.querySelector(".scope-all:not(.scope-back)")?.addEventListener("click", (e) => {
|
|
251
236
|
e.currentTarget.href = allScopeHref(location.hash, scope.dir);
|
|
@@ -512,7 +497,7 @@ function updateComposerMode() {
|
|
|
512
497
|
emptyEl.classList.remove("hidden");
|
|
513
498
|
historyEl.innerHTML = "";
|
|
514
499
|
updateInfoBar();
|
|
515
|
-
} else if (
|
|
500
|
+
} else if (current.owner === "tui") {
|
|
516
501
|
setPlaceholder("A terminal chi owns this session and doesn't share it — run it with chi --shared to send from here", "A terminal chi owns this session");
|
|
517
502
|
composeHintEl.textContent = "";
|
|
518
503
|
actionBtn.textContent = "Send";
|
|
@@ -536,7 +521,7 @@ function setTurnRunning(running) {
|
|
|
536
521
|
// Session status is turn state (idle/running): follow the live stream in the
|
|
537
522
|
// info bar and the session's card without a refetch.
|
|
538
523
|
function setLiveStatus(status) {
|
|
539
|
-
|
|
524
|
+
current.status = status;
|
|
540
525
|
const i = allSessions.findIndex((s) => s.id === selected);
|
|
541
526
|
if (i >= 0) {
|
|
542
527
|
// Its card gets the new dot and time; it keeps its place until the next
|
|
@@ -572,24 +557,22 @@ function onInitEvent(type, data) {
|
|
|
572
557
|
}, DONE_MS);
|
|
573
558
|
}
|
|
574
559
|
|
|
560
|
+
// Forget what the info bar, composer and command list knew about the open
|
|
561
|
+
// session; select() and clearSelection() both start from here, so a field
|
|
562
|
+
// the previous session set (its "delegated by" parent, its commands) never
|
|
563
|
+
// shows for the next one.
|
|
564
|
+
function resetSessionState() {
|
|
565
|
+
current = emptySessionState();
|
|
566
|
+
commandList.close();
|
|
567
|
+
}
|
|
568
|
+
|
|
575
569
|
function clearSelection() {
|
|
576
570
|
selected = null;
|
|
577
571
|
seedInitTasks(initTasks, []);
|
|
578
572
|
renderInitRow();
|
|
579
573
|
setSessionHash(null);
|
|
580
574
|
document.querySelectorAll(".card").forEach((el) => el.classList.remove("active"));
|
|
581
|
-
|
|
582
|
-
currentUsedMemories = [];
|
|
583
|
-
currentPreloadedMemories = [];
|
|
584
|
-
currentMutedMemories = [];
|
|
585
|
-
currentCtxPct = null;
|
|
586
|
-
currentCtxWindow = null;
|
|
587
|
-
currentStatus = "";
|
|
588
|
-
currentModel = "";
|
|
589
|
-
currentServed = null;
|
|
590
|
-
currentDir = "";
|
|
591
|
-
currentOwner = null;
|
|
592
|
-
currentTiming = normalizeTiming();
|
|
575
|
+
resetSessionState();
|
|
593
576
|
clearTimingInterval();
|
|
594
577
|
liveTurnTimingEl = null;
|
|
595
578
|
setTurnRunning(false);
|
|
@@ -631,20 +614,7 @@ async function select(id) {
|
|
|
631
614
|
historyEl.innerHTML = `<div class="hint">Loading…</div>`;
|
|
632
615
|
emptyEl.classList.add("hidden");
|
|
633
616
|
closeStream();
|
|
634
|
-
|
|
635
|
-
currentUsedMemories = [];
|
|
636
|
-
currentPreloadedMemories = [];
|
|
637
|
-
currentMutedMemories = [];
|
|
638
|
-
currentCtxPct = null;
|
|
639
|
-
currentCtxWindow = null;
|
|
640
|
-
currentStatus = "";
|
|
641
|
-
currentModel = "";
|
|
642
|
-
currentServed = null;
|
|
643
|
-
currentDir = "";
|
|
644
|
-
currentOwner = null;
|
|
645
|
-
currentCommands = [];
|
|
646
|
-
closeCommandList();
|
|
647
|
-
currentTiming = normalizeTiming();
|
|
617
|
+
resetSessionState();
|
|
648
618
|
resetTurnView();
|
|
649
619
|
updateFirstPeek();
|
|
650
620
|
updateInfoBar();
|
|
@@ -658,8 +628,8 @@ function resetTurnView() {
|
|
|
658
628
|
closeStream();
|
|
659
629
|
clearTimingInterval();
|
|
660
630
|
liveTurnTimingEl = null;
|
|
661
|
-
|
|
662
|
-
|
|
631
|
+
questionCards.removeQuestion();
|
|
632
|
+
questionCards.forgetContinue();
|
|
663
633
|
unmatchedMerge = false;
|
|
664
634
|
setTurnRunning(false);
|
|
665
635
|
}
|
|
@@ -709,32 +679,32 @@ async function resync({ endText = null } = {}) {
|
|
|
709
679
|
function renderSessionView(data) {
|
|
710
680
|
const s = data.session;
|
|
711
681
|
setMarkdownWarning(data.markdown_warning);
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
682
|
+
current.status = s.status;
|
|
683
|
+
current.model = s.model_name;
|
|
684
|
+
current.served = s.served_model ? [s.served_model, s.served_model_for] : null;
|
|
685
|
+
current.dir = s.working_directory;
|
|
686
|
+
current.owner = s.owner || null;
|
|
687
|
+
current.timing = normalizeTiming(data.timing);
|
|
718
688
|
// The saved fill, so a reloaded or stopped session shows it before its
|
|
719
689
|
// next turn (a running turn's replay below streams over it).
|
|
720
690
|
const savedPct = savedCtxPct(data.timing?.context);
|
|
721
|
-
if (savedPct !== null)
|
|
722
|
-
if (data.timing?.context?.window_tokens)
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
691
|
+
if (savedPct !== null) current.ctxPct = savedPct;
|
|
692
|
+
if (data.timing?.context?.window_tokens) current.ctxWindow = data.timing.context.window_tokens;
|
|
693
|
+
current.firstPreview = s.first_preview || previewForCard(s);
|
|
694
|
+
current.usedMemories = s.used_memory_names || [];
|
|
695
|
+
current.preloadedMemories = s.preloaded_memory_names || [];
|
|
696
|
+
current.mutedMemories = s.muted_memory_names || [];
|
|
697
|
+
current.parentId = s.parent_id || null;
|
|
698
|
+
current.commands = Array.isArray(data.commands) ? data.commands : [];
|
|
729
699
|
if (data.messages && data.messages.length) {
|
|
730
700
|
const first = data.messages.find((m) => m.role === "user");
|
|
731
|
-
if (first && first.content)
|
|
701
|
+
if (first && first.content) current.firstPreview = normalize(first.content).slice(0,80) + (normalize(first.content).length>80?"…":"");
|
|
732
702
|
renderHistory(data.messages);
|
|
733
703
|
} else {
|
|
734
704
|
renderHistory(data.history || []);
|
|
735
|
-
if (!
|
|
705
|
+
if (!current.firstPreview && data.history && data.history.length) {
|
|
736
706
|
const txt = normalize(String(data.history[0]||"")).slice(0,80);
|
|
737
|
-
if(txt)
|
|
707
|
+
if(txt) current.firstPreview = txt;
|
|
738
708
|
}
|
|
739
709
|
}
|
|
740
710
|
// A failed turn's prompt went back to the user: shown after the
|
|
@@ -762,22 +732,22 @@ function renderSessionView(data) {
|
|
|
762
732
|
const replay = snapshotEvents({
|
|
763
733
|
current_turn: data.current_turn,
|
|
764
734
|
queued: data.queued,
|
|
765
|
-
started_at:
|
|
735
|
+
started_at: current.timing.activeTurn?.started_at,
|
|
766
736
|
});
|
|
767
737
|
replay.forEach((event) => streamHandlers[event.type]?.(event));
|
|
768
738
|
if (!data.current_turn) {
|
|
769
|
-
setTurnRunning(
|
|
770
|
-
if (turnRunning &&
|
|
739
|
+
setTurnRunning(current.status === "running");
|
|
740
|
+
if (turnRunning && current.timing.activeTurn?.started_at) startLiveTurnTiming(current.timing.activeTurn.started_at);
|
|
771
741
|
}
|
|
772
742
|
turnDom.flush();
|
|
773
743
|
snapshotCards.current.forEach((card) => upsertCard(card));
|
|
774
744
|
snapshotCards.during.forEach((card) => upsertCard(card));
|
|
775
745
|
// A question the worker is blocked on (also after a reload).
|
|
776
746
|
if (data.pending_question && data.pending_question.id && data.pending_question.status === "pending") {
|
|
777
|
-
|
|
747
|
+
questionCards.renderQuestion(data.pending_question);
|
|
778
748
|
}
|
|
779
749
|
// A turn ran out of iterations and waits for a yes or no.
|
|
780
|
-
if (data.continue_offer)
|
|
750
|
+
if (data.continue_offer) questionCards.renderContinue(data.continue_offer);
|
|
781
751
|
updateInfoBar();
|
|
782
752
|
updateFirstPeek();
|
|
783
753
|
updateComposerMode();
|
|
@@ -789,7 +759,7 @@ function renderHistory(items) {
|
|
|
789
759
|
return;
|
|
790
760
|
}
|
|
791
761
|
if (typeof items[0] === "object" && items[0].role) {
|
|
792
|
-
historyEl.innerHTML = turnHistoryHtml(items,
|
|
762
|
+
historyEl.innerHTML = turnHistoryHtml(items, current.timing, { thumbs: (images) => thumbsHtml(selected, images) });
|
|
793
763
|
} else {
|
|
794
764
|
historyEl.innerHTML = items.map((c) => `<div class="bubble output">${escapeHtml(c)}</div>`).join("");
|
|
795
765
|
}
|
|
@@ -818,17 +788,17 @@ async function readTurnEnd() {
|
|
|
818
788
|
try {
|
|
819
789
|
const d = await getSession(selected, { tail: true });
|
|
820
790
|
if (d.session) {
|
|
821
|
-
if (Array.isArray(d.session.used_memory_names))
|
|
822
|
-
if (d.session.status)
|
|
791
|
+
if (Array.isArray(d.session.used_memory_names)) current.usedMemories = d.session.used_memory_names;
|
|
792
|
+
if (d.session.status) current.status = d.session.status;
|
|
823
793
|
applyTiming(d.timing);
|
|
824
|
-
finishLiveTurnTiming(
|
|
794
|
+
finishLiveTurnTiming(current.timing.turnRecords[current.timing.turnRecords.length - 1]);
|
|
825
795
|
setMarkdownWarning(d.markdown_warning);
|
|
826
796
|
if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
|
|
827
797
|
updateInfoBar();
|
|
828
798
|
const sess = allSessions.find(s=>s.id===selected);
|
|
829
799
|
if (sess) {
|
|
830
|
-
sess.used_memory_names =
|
|
831
|
-
sess.status =
|
|
800
|
+
sess.used_memory_names = current.usedMemories.slice();
|
|
801
|
+
sess.status = current.status;
|
|
832
802
|
}
|
|
833
803
|
render();
|
|
834
804
|
document.querySelectorAll(".card").forEach(el=>el.classList.toggle("active", el.dataset.id===selected));
|
|
@@ -909,7 +879,7 @@ function endTurnView(kind, endText = null) {
|
|
|
909
879
|
if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
910
880
|
}
|
|
911
881
|
// The question dies with its turn (the worker stopped waiting).
|
|
912
|
-
if (pendingQuestion)
|
|
882
|
+
if (questionCards.pendingQuestion()) questionCards.resolveQuestion(questionCards.pendingQuestion().id, { cancelled: true, reason: "turn ended" });
|
|
913
883
|
}
|
|
914
884
|
|
|
915
885
|
// ── Prompt bubbles ─────────────────────────────────────────────────────────
|
|
@@ -1213,10 +1183,10 @@ function turnAnswerEl(turnEls) {
|
|
|
1213
1183
|
function placeResolvedQuestion(entry, before) {
|
|
1214
1184
|
const pq = entry.pending_question;
|
|
1215
1185
|
if (!pq?.id || !(entry.answer || entry.cancelled)) return;
|
|
1216
|
-
|
|
1217
|
-
const card =
|
|
1186
|
+
questionCards.renderQuestion(pq);
|
|
1187
|
+
const card = questionCards.questionCard();
|
|
1218
1188
|
if (!card || card.dataset.qid !== String(pq.id)) return;
|
|
1219
|
-
|
|
1189
|
+
questionCards.resolveQuestion(pq.id, { answer: entry.answer || null, cancelled: !!entry.cancelled, reason: entry.reason || "" });
|
|
1220
1190
|
const turnEls = reloadedTurnEls(before);
|
|
1221
1191
|
const asked = Number(entry.iteration) || 1;
|
|
1222
1192
|
const later = turnEls.find((el) => el.matches(".bubble.user.steered") && Number(el.dataset.step) > asked);
|
|
@@ -1295,24 +1265,24 @@ function updateInfoBar() {
|
|
|
1295
1265
|
if (stopBtn) stopBtn.style.display = "none";
|
|
1296
1266
|
return;
|
|
1297
1267
|
}
|
|
1298
|
-
if (stopBtn) stopBtn.style.display = canStopSession({ owner:
|
|
1268
|
+
if (stopBtn) stopBtn.style.display = canStopSession({ owner: current.owner, streaming: !!streamControl }) ? "" : "none";
|
|
1299
1269
|
const idShort = selected.slice(0,8);
|
|
1300
|
-
const memText =
|
|
1301
|
-
const ctxText =
|
|
1270
|
+
const memText = current.usedMemories.length ? current.usedMemories.join(", ") : "—";
|
|
1271
|
+
const ctxText = current.ctxPct !== null ? `ctx ${Math.round(current.ctxPct)}%` : "";
|
|
1302
1272
|
const sessionDuration = currentSessionDuration();
|
|
1303
1273
|
const timingText = sessionDuration === null ? "" : `session ${formatDuration(sessionDuration)}`;
|
|
1304
|
-
const statusClass =
|
|
1305
|
-
const label = modelLabel(
|
|
1274
|
+
const statusClass = current.status ? `status ${escapeHtml(current.status)}` : "status";
|
|
1275
|
+
const label = modelLabel(current.model, ...(current.served || []));
|
|
1306
1276
|
// A delegated session names its parent, as a link to it.
|
|
1307
|
-
const parentChip = delegatedBy(
|
|
1308
|
-
const parentHtml = parentChip ? `<a class="model parent" href="${escapeHtml(sessionHash(
|
|
1277
|
+
const parentChip = delegatedBy(current.parentId, allSessions.find((x) => x.id === current.parentId) || null);
|
|
1278
|
+
const parentHtml = parentChip ? `<a class="model parent" href="${escapeHtml(sessionHash(current.parentId))}" title="${escapeHtml(parentChip.title)}">delegated by ${escapeHtml(String(current.parentId).slice(0, 8))}</a>` : "";
|
|
1309
1279
|
const attachCmd = `chi --attach ${selected}`;
|
|
1310
1280
|
const copied = Date.now() - attachCopiedAt < 2000;
|
|
1311
1281
|
const copyHtml = `<button type="button" class="copy-attach" title="Copy "${escapeHtml(attachCmd)}" to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
|
|
1312
|
-
const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(
|
|
1313
|
-
const previewHtml = `<div class="preview-line"><span class="preview">${escapeHtml(
|
|
1282
|
+
const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}<span class="dir" title="${escapeHtml(current.dir||"")}">${escapeHtml((current.dir||"").slice(0,48))}</span>${ctxText ? `<span class="model">${escapeHtml(ctxText)}</span>` : ""}${timingText ? `<span class="model">${escapeHtml(timingText)}</span>` : ""}</div>`;
|
|
1283
|
+
const previewHtml = `<div class="preview-line"><span class="preview">${escapeHtml(current.firstPreview || "—")}</span> · <span class="mem" title="${escapeHtml(current.usedMemories.join(", "))}">mem: ${escapeHtml(memText)}</span></div>`;
|
|
1314
1284
|
infoTextEl.innerHTML = metaHtml + previewHtml;
|
|
1315
|
-
infoTextEl.title = memoriesTooltip(
|
|
1285
|
+
infoTextEl.title = memoriesTooltip(current.usedMemories, current.preloadedMemories, current.mutedMemories);
|
|
1316
1286
|
}
|
|
1317
1287
|
|
|
1318
1288
|
// `memories: a, b · preloaded: c · muted: d`: the used memories always,
|
|
@@ -1325,10 +1295,10 @@ function memoriesTooltip(used, preloaded, muted) {
|
|
|
1325
1295
|
}
|
|
1326
1296
|
|
|
1327
1297
|
function currentSessionDuration() {
|
|
1328
|
-
if (turnRunning &&
|
|
1329
|
-
return elapsedSince(
|
|
1298
|
+
if (turnRunning && current.timing.startedAt) {
|
|
1299
|
+
return elapsedSince(current.timing.startedAt);
|
|
1330
1300
|
}
|
|
1331
|
-
return
|
|
1301
|
+
return current.timing.sessionDurationMs;
|
|
1332
1302
|
}
|
|
1333
1303
|
|
|
1334
1304
|
function clearTimingInterval() {
|
|
@@ -1342,9 +1312,9 @@ let liveTurnNumber = null;
|
|
|
1342
1312
|
|
|
1343
1313
|
function startLiveTurnTiming(startedAt = new Date().toISOString()) {
|
|
1344
1314
|
clearTimingInterval();
|
|
1345
|
-
|
|
1315
|
+
current.timing.activeTurn = { started_at: startedAt };
|
|
1346
1316
|
// The turn records hold the finished turns, so this one comes next.
|
|
1347
|
-
liveTurnNumber =
|
|
1317
|
+
liveTurnNumber = current.timing.turnRecords.length + 1;
|
|
1348
1318
|
liveTurnNote = "";
|
|
1349
1319
|
liveTurnTimingEl = document.createElement("div");
|
|
1350
1320
|
liveTurnTimingEl.className = "turn-timing live";
|
|
@@ -1362,7 +1332,7 @@ function appendToHistory(el) {
|
|
|
1362
1332
|
}
|
|
1363
1333
|
|
|
1364
1334
|
function updateLiveTiming() {
|
|
1365
|
-
const duration = elapsedSince(
|
|
1335
|
+
const duration = elapsedSince(current.timing.activeTurn?.started_at);
|
|
1366
1336
|
if (liveTurnTimingEl && duration !== null) {
|
|
1367
1337
|
liveTurnTimingEl.textContent = turnTimingText(liveTurnNumber, duration, { running: true }) + (liveTurnNote ? ` · ${liveTurnNote}` : "");
|
|
1368
1338
|
}
|
|
@@ -1373,7 +1343,7 @@ function updateLiveTiming() {
|
|
|
1373
1343
|
function setLiveTurnNote(text) {
|
|
1374
1344
|
if (liveTurnNote === text) return;
|
|
1375
1345
|
liveTurnNote = text;
|
|
1376
|
-
if (
|
|
1346
|
+
if (current.timing.activeTurn) updateLiveTiming();
|
|
1377
1347
|
}
|
|
1378
1348
|
|
|
1379
1349
|
// A canceled turn's line says so (+canceled+, or its record's status), as
|
|
@@ -1381,29 +1351,29 @@ function setLiveTurnNote(text) {
|
|
|
1381
1351
|
function finishLiveTurnTiming(record = null, { canceled = false } = {}) {
|
|
1382
1352
|
const duration = Number.isFinite(Number(record?.duration_ms))
|
|
1383
1353
|
? Number(record.duration_ms)
|
|
1384
|
-
: elapsedSince(
|
|
1354
|
+
: elapsedSince(current.timing.activeTurn?.started_at);
|
|
1385
1355
|
if (liveTurnTimingEl && duration !== null) {
|
|
1386
1356
|
liveTurnNote = "";
|
|
1387
1357
|
liveTurnTimingEl.classList.remove("live");
|
|
1388
|
-
const at = record ?
|
|
1358
|
+
const at = record ? current.timing.turnRecords.indexOf(record) : -1;
|
|
1389
1359
|
liveTurnTimingEl.textContent = turnTimingText(at >= 0 ? at + 1 : liveTurnNumber, duration,
|
|
1390
1360
|
{ canceled: canceled || record?.status === "canceled" });
|
|
1391
1361
|
}
|
|
1392
|
-
|
|
1362
|
+
current.timing.activeTurn = null;
|
|
1393
1363
|
clearTimingInterval();
|
|
1394
1364
|
updateInfoBar();
|
|
1395
1365
|
}
|
|
1396
1366
|
|
|
1397
1367
|
function applyTiming(data) {
|
|
1398
|
-
|
|
1399
|
-
if (turnRunning &&
|
|
1400
|
-
startLiveTurnTiming(
|
|
1368
|
+
current.timing = normalizeTiming(data);
|
|
1369
|
+
if (turnRunning && current.timing.activeTurn?.started_at) {
|
|
1370
|
+
startLiveTurnTiming(current.timing.activeTurn.started_at);
|
|
1401
1371
|
}
|
|
1402
1372
|
}
|
|
1403
1373
|
|
|
1404
1374
|
function updateFirstPeek() {
|
|
1405
|
-
if (
|
|
1406
|
-
firstPeekEl.textContent =
|
|
1375
|
+
if (current.ctxPct !== null && current.ctxPct > 20 && current.firstPreview) {
|
|
1376
|
+
firstPeekEl.textContent = current.firstPreview;
|
|
1407
1377
|
firstPeekEl.classList.add("visible");
|
|
1408
1378
|
} else {
|
|
1409
1379
|
firstPeekEl.textContent = "";
|
|
@@ -1412,11 +1382,11 @@ function updateFirstPeek() {
|
|
|
1412
1382
|
}
|
|
1413
1383
|
|
|
1414
1384
|
function setUsedMemories(names) {
|
|
1415
|
-
|
|
1385
|
+
current.usedMemories = names.slice();
|
|
1416
1386
|
updateInfoBar();
|
|
1417
1387
|
const sess = allSessions.find(s=>s.id===selected);
|
|
1418
1388
|
if (sess) {
|
|
1419
|
-
sess.used_memory_names =
|
|
1389
|
+
sess.used_memory_names = current.usedMemories.slice();
|
|
1420
1390
|
render();
|
|
1421
1391
|
document.querySelectorAll(".card").forEach(el=>el.classList.toggle("active", el.dataset.id===selected));
|
|
1422
1392
|
}
|
|
@@ -1455,7 +1425,7 @@ function workerGone(id, { opened = true } = {}) {
|
|
|
1455
1425
|
endTurnView("gone", endText);
|
|
1456
1426
|
if (!stopped) setLiveStatus("idle");
|
|
1457
1427
|
}
|
|
1458
|
-
if (
|
|
1428
|
+
if (current.owner === "worker") current.owner = null;
|
|
1459
1429
|
// Its card says "live" (the list's owner, or setLiveStatus's) until the
|
|
1460
1430
|
// hub's event says otherwise: the worker is gone now, as far as this
|
|
1461
1431
|
// tab can tell.
|
|
@@ -1476,28 +1446,27 @@ function workerGone(id, { opened = true } = {}) {
|
|
|
1476
1446
|
// them too.
|
|
1477
1447
|
const streamHandlers = {
|
|
1478
1448
|
generation_chunk: (data) => {
|
|
1479
|
-
//
|
|
1480
|
-
//
|
|
1481
|
-
// for pre-Phase-2 / non-enriched servers.
|
|
1449
|
+
// Route the chunk's bytes to the text bubble and the thinking block (see
|
|
1450
|
+
// chunk_router.js). Raw `content` is only a fallback for an older server.
|
|
1482
1451
|
const { text, thinking } = routeChunk(data);
|
|
1483
1452
|
setLiveTurnNote("");
|
|
1484
1453
|
turnDom.chunk({ text, thinking, iteration: data.iteration ?? null });
|
|
1485
|
-
const pct = extractCtxPct(data,
|
|
1454
|
+
const pct = extractCtxPct(data, current.ctxWindow);
|
|
1486
1455
|
if (pct !== null && !Number.isNaN(pct)) {
|
|
1487
|
-
|
|
1456
|
+
current.ctxPct = pct;
|
|
1488
1457
|
updateInfoBar();
|
|
1489
1458
|
updateFirstPeek();
|
|
1490
1459
|
}
|
|
1491
1460
|
},
|
|
1492
1461
|
generation_started: (data) => {
|
|
1493
|
-
if (data?.context_window_tokens)
|
|
1462
|
+
if (data?.context_window_tokens) current.ctxWindow = data.context_window_tokens;
|
|
1494
1463
|
setLiveTurnNote("");
|
|
1495
1464
|
turnDom.generationStarted(data || {});
|
|
1496
1465
|
},
|
|
1497
1466
|
generation_completed: (data) => {
|
|
1498
1467
|
setLiveTurnNote("");
|
|
1499
1468
|
if (data?.served_model) {
|
|
1500
|
-
|
|
1469
|
+
current.served = [data.served_model, data.requested_model];
|
|
1501
1470
|
updateInfoBar();
|
|
1502
1471
|
}
|
|
1503
1472
|
turnDom.generationCompleted();
|
|
@@ -1599,9 +1568,9 @@ const streamHandlers = {
|
|
|
1599
1568
|
// the conversation (!rollback, !cmd, a continue answered no) re-reads it.
|
|
1600
1569
|
command_ran: async (data) => {
|
|
1601
1570
|
const view = commandView(data, clientId);
|
|
1602
|
-
if (view.modelName && view.modelName !==
|
|
1603
|
-
|
|
1604
|
-
|
|
1571
|
+
if (view.modelName && view.modelName !== current.model) {
|
|
1572
|
+
current.model = view.modelName;
|
|
1573
|
+
current.served = null;
|
|
1605
1574
|
updateInfoBar();
|
|
1606
1575
|
}
|
|
1607
1576
|
if (view.resync) await resync();
|
|
@@ -1620,8 +1589,8 @@ const streamHandlers = {
|
|
|
1620
1589
|
reminder_injected: (data) => addCommandBubble({ label: null, line: reminderText(data), text: "" }),
|
|
1621
1590
|
// A context note joined the conversation (between turns; no turn runs).
|
|
1622
1591
|
context_added: (data) => addNoteBubble({ label: data.label, content: data.text || "" }),
|
|
1623
|
-
continue_offered: (data) =>
|
|
1624
|
-
continue_resolved: (data) =>
|
|
1592
|
+
continue_offered: (data) => questionCards.renderContinue(data),
|
|
1593
|
+
continue_resolved: (data) => questionCards.resolveContinue(data),
|
|
1625
1594
|
tool_call_started: (data) => turnDom.toolStarted(data),
|
|
1626
1595
|
tool_call_completed: (data) => turnDom.toolCompleted(data),
|
|
1627
1596
|
// The engine's list, after every memory read and at a turn's end.
|
|
@@ -1629,22 +1598,22 @@ const streamHandlers = {
|
|
|
1629
1598
|
if (Array.isArray(data.used_memory_names)) setUsedMemories(data.used_memory_names);
|
|
1630
1599
|
},
|
|
1631
1600
|
context_status: (data) => {
|
|
1632
|
-
const pct = extractCtxPct(data,
|
|
1601
|
+
const pct = extractCtxPct(data, current.ctxWindow);
|
|
1633
1602
|
if (pct !== null) {
|
|
1634
|
-
|
|
1603
|
+
current.ctxPct = pct;
|
|
1635
1604
|
updateInfoBar();
|
|
1636
1605
|
updateFirstPeek();
|
|
1637
1606
|
}
|
|
1638
1607
|
},
|
|
1639
1608
|
question_requested: (data) => {
|
|
1640
|
-
const pq = data.pending_question
|
|
1641
|
-
if (pq && pq.id)
|
|
1609
|
+
const pq = data.pending_question;
|
|
1610
|
+
if (pq && pq.id) questionCards.renderQuestion(pq);
|
|
1642
1611
|
},
|
|
1643
1612
|
question_answered: (data) => {
|
|
1644
|
-
|
|
1613
|
+
questionCards.resolveQuestion(data.id, { answer: data.answer });
|
|
1645
1614
|
},
|
|
1646
1615
|
question_cancelled: (data) => {
|
|
1647
|
-
|
|
1616
|
+
questionCards.resolveQuestion(data.id, { cancelled: true, reason: data.reason });
|
|
1648
1617
|
},
|
|
1649
1618
|
// One collected just as a turn started describes the chat before it.
|
|
1650
1619
|
recap_ready: (data) => {
|
|
@@ -1685,236 +1654,13 @@ async function refreshTimingFromSession() {
|
|
|
1685
1654
|
if (!selected) return;
|
|
1686
1655
|
try {
|
|
1687
1656
|
const data = await getSession(selected, { tail: true });
|
|
1688
|
-
if (data.session?.status)
|
|
1657
|
+
if (data.session?.status) current.status = data.session.status;
|
|
1689
1658
|
applyTiming(data.timing);
|
|
1690
|
-
finishLiveTurnTiming(
|
|
1659
|
+
finishLiveTurnTiming(current.timing.turnRecords[current.timing.turnRecords.length - 1]);
|
|
1691
1660
|
} catch (_) {}
|
|
1692
1661
|
}
|
|
1693
1662
|
|
|
1694
1663
|
|
|
1695
|
-
// ── ask_user_question card ─────────────────────────────────────────────────
|
|
1696
|
-
// Inline card in the message history: rendered on question_requested (or from
|
|
1697
|
-
// pending_question on session select for reload recovery), resolved/disabled
|
|
1698
|
-
// on question_answered / question_cancelled so SSE replay and answers from
|
|
1699
|
-
// another client converge to the same state.
|
|
1700
|
-
|
|
1701
|
-
function renderQuestionCard(pq) {
|
|
1702
|
-
const action = nextCardAction({ cardId: questionCardEl?.dataset.qid ?? null, pendingId: pendingQuestion?.id ?? null }, pq);
|
|
1703
|
-
if (action === "same") return;
|
|
1704
|
-
// A resolved card stays where it was; only a pending one is replaced.
|
|
1705
|
-
if (action === "keep") questionCardEl = null;
|
|
1706
|
-
else removeQuestionCard();
|
|
1707
|
-
pendingQuestion = pq;
|
|
1708
|
-
removeHintIfEmpty();
|
|
1709
|
-
|
|
1710
|
-
const approval = approvalView(pq);
|
|
1711
|
-
const card = document.createElement("details");
|
|
1712
|
-
card.className = approval ? "bubble question approval" : "bubble question";
|
|
1713
|
-
card.dataset.qid = pq.id;
|
|
1714
|
-
// Open while pending: the question must be actionable.
|
|
1715
|
-
card.open = true;
|
|
1716
|
-
|
|
1717
|
-
// The summary is the card's one line: the question (or its header) while
|
|
1718
|
-
// pending, the result once resolved. It replaces the old .question-header.
|
|
1719
|
-
const summary = document.createElement("summary");
|
|
1720
|
-
summary.textContent = summaryText(pq);
|
|
1721
|
-
card.appendChild(summary);
|
|
1722
|
-
// A hand toggle: the resolve's auto-collapse then leaves it as the user
|
|
1723
|
-
// left it (rememberToggle-style, as turn_view.js does).
|
|
1724
|
-
summary.addEventListener("click", () => { card.dataset.toggled = "1"; });
|
|
1725
|
-
|
|
1726
|
-
if (approval) {
|
|
1727
|
-
card.appendChild(renderApprovalDetails(approval));
|
|
1728
|
-
} else {
|
|
1729
|
-
const q = document.createElement("div");
|
|
1730
|
-
q.className = "question-text";
|
|
1731
|
-
q.textContent = pq.question || "";
|
|
1732
|
-
card.appendChild(q);
|
|
1733
|
-
}
|
|
1734
|
-
|
|
1735
|
-
const options = Array.isArray(pq.options) ? pq.options : [];
|
|
1736
|
-
if (options.length) {
|
|
1737
|
-
const list = document.createElement("div");
|
|
1738
|
-
list.className = "question-options";
|
|
1739
|
-
const inputType = pq.multi_select ? "checkbox" : "radio";
|
|
1740
|
-
options.forEach((label, i) => {
|
|
1741
|
-
const row = document.createElement("label");
|
|
1742
|
-
row.className = "question-option";
|
|
1743
|
-
const input = document.createElement("input");
|
|
1744
|
-
input.type = inputType;
|
|
1745
|
-
input.name = `q-${pq.id}`;
|
|
1746
|
-
input.value = String(label);
|
|
1747
|
-
input.dataset.index = String(i);
|
|
1748
|
-
const text = document.createElement("span");
|
|
1749
|
-
text.textContent = String(label);
|
|
1750
|
-
row.appendChild(input);
|
|
1751
|
-
row.appendChild(text);
|
|
1752
|
-
list.appendChild(row);
|
|
1753
|
-
});
|
|
1754
|
-
card.appendChild(list);
|
|
1755
|
-
}
|
|
1756
|
-
|
|
1757
|
-
let freeformInput = null;
|
|
1758
|
-
if (pq.allow_freeform) {
|
|
1759
|
-
freeformInput = document.createElement("input");
|
|
1760
|
-
freeformInput.type = "text";
|
|
1761
|
-
freeformInput.className = "question-freeform";
|
|
1762
|
-
freeformInput.placeholder = approval ? "Reason for the model (with Deny, or alone to deny)…" : "Other…";
|
|
1763
|
-
card.appendChild(freeformInput);
|
|
1764
|
-
}
|
|
1765
|
-
|
|
1766
|
-
const err = document.createElement("div");
|
|
1767
|
-
err.className = "question-error hidden";
|
|
1768
|
-
card.appendChild(err);
|
|
1769
|
-
|
|
1770
|
-
const submit = document.createElement("button");
|
|
1771
|
-
submit.className = "question-submit";
|
|
1772
|
-
submit.textContent = "Submit";
|
|
1773
|
-
submit.addEventListener("click", () => {
|
|
1774
|
-
const chosen = Array.from(card.querySelectorAll(".question-option input:checked")).map((i) => i.value);
|
|
1775
|
-
const freeform = freeformInput ? freeformInput.value.trim() : "";
|
|
1776
|
-
if (!chosen.length && !freeform) {
|
|
1777
|
-
err.textContent = "Pick an option or enter a response.";
|
|
1778
|
-
err.classList.remove("hidden");
|
|
1779
|
-
return;
|
|
1780
|
-
}
|
|
1781
|
-
err.classList.add("hidden");
|
|
1782
|
-
submit.disabled = true;
|
|
1783
|
-
card.classList.add("submitting");
|
|
1784
|
-
sendAnswer(selected, {
|
|
1785
|
-
id: pq.id,
|
|
1786
|
-
selected: chosen,
|
|
1787
|
-
...(freeform ? { freeform } : {}),
|
|
1788
|
-
}).catch((e) => {
|
|
1789
|
-
submit.disabled = false;
|
|
1790
|
-
card.classList.remove("submitting");
|
|
1791
|
-
err.textContent = /503|not_live/.test(e.message)
|
|
1792
|
-
? "Session is not running — restart it to answer."
|
|
1793
|
-
: e.message;
|
|
1794
|
-
err.classList.remove("hidden");
|
|
1795
|
-
});
|
|
1796
|
-
});
|
|
1797
|
-
card.appendChild(submit);
|
|
1798
|
-
|
|
1799
|
-
// Leaves the question unanswered, like an empty answer in the terminal.
|
|
1800
|
-
// The card closes on the question_cancelled every client gets.
|
|
1801
|
-
// For an approval, dismissing denies the call.
|
|
1802
|
-
const dismiss = document.createElement("button");
|
|
1803
|
-
dismiss.className = "question-dismiss ghost";
|
|
1804
|
-
dismiss.textContent = approval ? "Deny" : "Dismiss";
|
|
1805
|
-
dismiss.addEventListener("click", () => {
|
|
1806
|
-
err.classList.add("hidden");
|
|
1807
|
-
submit.disabled = true;
|
|
1808
|
-
dismiss.disabled = true;
|
|
1809
|
-
dismissQuestion(selected, pq.id).catch((e) => {
|
|
1810
|
-
submit.disabled = false;
|
|
1811
|
-
dismiss.disabled = false;
|
|
1812
|
-
err.textContent = /503|not_live/.test(e.message)
|
|
1813
|
-
? "Session is not running — restart it to answer."
|
|
1814
|
-
: e.message;
|
|
1815
|
-
err.classList.remove("hidden");
|
|
1816
|
-
});
|
|
1817
|
-
});
|
|
1818
|
-
card.appendChild(dismiss);
|
|
1819
|
-
|
|
1820
|
-
appendToHistory(card);
|
|
1821
|
-
questionCardEl = card;
|
|
1822
|
-
if (isNearBottom()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1823
|
-
}
|
|
1824
|
-
|
|
1825
|
-
// The tool, the command (or paths, or args) as code, where and why; no
|
|
1826
|
-
// code box when there is nothing to show.
|
|
1827
|
-
function renderApprovalDetails(view) {
|
|
1828
|
-
const box = document.createElement("div");
|
|
1829
|
-
box.className = "approval-details";
|
|
1830
|
-
const tool = document.createElement("div");
|
|
1831
|
-
tool.className = "approval-tool";
|
|
1832
|
-
tool.textContent = view.tool;
|
|
1833
|
-
box.appendChild(tool);
|
|
1834
|
-
if (view.what) {
|
|
1835
|
-
const what = document.createElement("pre");
|
|
1836
|
-
what.className = view.isCommand ? "approval-what command" : "approval-what paths";
|
|
1837
|
-
const code = document.createElement("code");
|
|
1838
|
-
code.textContent = view.what;
|
|
1839
|
-
what.appendChild(code);
|
|
1840
|
-
box.appendChild(what);
|
|
1841
|
-
}
|
|
1842
|
-
// An edit/write: the diff it would make, under the path.
|
|
1843
|
-
if (view.preview) {
|
|
1844
|
-
const diff = document.createElement("div");
|
|
1845
|
-
diff.className = "approval-diff";
|
|
1846
|
-
diff.innerHTML = diffHtml(view.preview);
|
|
1847
|
-
box.appendChild(diff);
|
|
1848
|
-
}
|
|
1849
|
-
for (const [cls, label, text] of [["approval-where", "in", view.where], ["approval-why", "why", view.why]]) {
|
|
1850
|
-
if (!text) continue;
|
|
1851
|
-
const row = document.createElement("div");
|
|
1852
|
-
row.className = cls;
|
|
1853
|
-
const b = document.createElement("span");
|
|
1854
|
-
b.className = "approval-label";
|
|
1855
|
-
b.textContent = `${label} `;
|
|
1856
|
-
row.appendChild(b);
|
|
1857
|
-
row.appendChild(document.createTextNode(text));
|
|
1858
|
-
box.appendChild(row);
|
|
1859
|
-
}
|
|
1860
|
-
return box;
|
|
1861
|
-
}
|
|
1862
|
-
|
|
1863
|
-
function resolveQuestionCard(id, { answer = null, cancelled = false, reason = "" } = {}) {
|
|
1864
|
-
if (!questionCardEl) return;
|
|
1865
|
-
if (id && pendingQuestion && String(pendingQuestion.id) !== String(id)) return;
|
|
1866
|
-
// A double-delivered question_answered / question_cancelled (SSE replay
|
|
1867
|
-
// after a reconnect) is a no-op: the card is already resolved.
|
|
1868
|
-
if (questionCardEl.classList.contains("answered") || questionCardEl.classList.contains("cancelled")
|
|
1869
|
-
|| questionCardEl.querySelector(".question-result")) return;
|
|
1870
|
-
|
|
1871
|
-
const result = resultText(pendingQuestion, { answer, cancelled, reason });
|
|
1872
|
-
const note = document.createElement("div");
|
|
1873
|
-
note.className = "question-result";
|
|
1874
|
-
note.textContent = result;
|
|
1875
|
-
if (cancelled) {
|
|
1876
|
-
questionCardEl.classList.add("cancelled");
|
|
1877
|
-
} else {
|
|
1878
|
-
questionCardEl.classList.add("answered");
|
|
1879
|
-
if (approvalAllowed(pendingQuestion, answer) === false) questionCardEl.classList.add("denied");
|
|
1880
|
-
markQuestionSelection(answer && Array.isArray(answer.selected) ? answer.selected : []);
|
|
1881
|
-
}
|
|
1882
|
-
questionCardEl.appendChild(note);
|
|
1883
|
-
questionCardEl.classList.remove("submitting");
|
|
1884
|
-
disableQuestionInputs();
|
|
1885
|
-
// The summary becomes the result; the card collapses to that one line
|
|
1886
|
-
// unless the user opened or closed it by hand.
|
|
1887
|
-
const summary = questionCardEl.querySelector("summary");
|
|
1888
|
-
if (summary) summary.textContent = summaryText(pendingQuestion, { answer, cancelled, reason });
|
|
1889
|
-
if (!questionCardEl.dataset.toggled) questionCardEl.open = false;
|
|
1890
|
-
pendingQuestion = null;
|
|
1891
|
-
}
|
|
1892
|
-
|
|
1893
|
-
function markQuestionSelection(sel) {
|
|
1894
|
-
if (!questionCardEl || !sel.length) return;
|
|
1895
|
-
questionCardEl.querySelectorAll(".question-option input").forEach((input) => {
|
|
1896
|
-
if (sel.includes(input.value)) {
|
|
1897
|
-
input.checked = true;
|
|
1898
|
-
input.closest(".question-option").classList.add("selected");
|
|
1899
|
-
}
|
|
1900
|
-
});
|
|
1901
|
-
}
|
|
1902
|
-
|
|
1903
|
-
function disableQuestionInputs() {
|
|
1904
|
-
if (!questionCardEl) return;
|
|
1905
|
-
questionCardEl.querySelectorAll("input, button").forEach((el) => {
|
|
1906
|
-
el.disabled = true;
|
|
1907
|
-
});
|
|
1908
|
-
}
|
|
1909
|
-
|
|
1910
|
-
function removeQuestionCard() {
|
|
1911
|
-
if (questionCardEl) {
|
|
1912
|
-
questionCardEl.remove();
|
|
1913
|
-
questionCardEl = null;
|
|
1914
|
-
}
|
|
1915
|
-
pendingQuestion = null;
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
1664
|
function removeHintIfEmpty() {
|
|
1919
1665
|
const hint = historyEl.querySelector(".hint");
|
|
1920
1666
|
if (!hint) return;
|
|
@@ -1961,7 +1707,7 @@ async function handleCreate() {
|
|
|
1961
1707
|
|
|
1962
1708
|
async function handleSendTurn() {
|
|
1963
1709
|
if (!selected && !chips.length && isCommandLine(promptEl.value.trim())) return handleCreate();
|
|
1964
|
-
if (
|
|
1710
|
+
if (current.owner === "tui") return;
|
|
1965
1711
|
const prompt = turnText(promptEl.value, chips);
|
|
1966
1712
|
if (!prompt) return;
|
|
1967
1713
|
if (!chips.length && isCommandLine(prompt)) return handleSendCommand(prompt);
|
|
@@ -1986,8 +1732,8 @@ async function handleSendTurn() {
|
|
|
1986
1732
|
const sending = chips.slice();
|
|
1987
1733
|
const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending });
|
|
1988
1734
|
if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1989
|
-
if (!
|
|
1990
|
-
|
|
1735
|
+
if (!current.firstPreview || current.firstPreview==="—") {
|
|
1736
|
+
current.firstPreview = normalize(prompt).slice(0,80) + (normalize(prompt).length>80?"…":"");
|
|
1991
1737
|
updateInfoBar();
|
|
1992
1738
|
updateFirstPeek();
|
|
1993
1739
|
}
|
|
@@ -2023,13 +1769,13 @@ async function handleSendTurn() {
|
|
|
2023
1769
|
return;
|
|
2024
1770
|
}
|
|
2025
1771
|
if (/owned_by_tui|409/.test(e.message)) {
|
|
2026
|
-
|
|
1772
|
+
current.owner = "tui";
|
|
2027
1773
|
updateComposerMode();
|
|
2028
1774
|
return;
|
|
2029
1775
|
}
|
|
2030
1776
|
alert(e.message);
|
|
2031
1777
|
} finally {
|
|
2032
|
-
actionBtn.disabled =
|
|
1778
|
+
actionBtn.disabled = current.owner === "tui";
|
|
2033
1779
|
promptEl.focus();
|
|
2034
1780
|
}
|
|
2035
1781
|
}
|
|
@@ -2053,13 +1799,13 @@ async function handleSendCommand(line) {
|
|
|
2053
1799
|
if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
|
|
2054
1800
|
} catch (e) {
|
|
2055
1801
|
if (/owned_by_tui|409/.test(e.message)) {
|
|
2056
|
-
|
|
1802
|
+
current.owner = "tui";
|
|
2057
1803
|
updateComposerMode();
|
|
2058
1804
|
return;
|
|
2059
1805
|
}
|
|
2060
1806
|
addCommandBubble({ label: null, line, text: e.message, failed: true });
|
|
2061
1807
|
} finally {
|
|
2062
|
-
actionBtn.disabled =
|
|
1808
|
+
actionBtn.disabled = current.owner === "tui";
|
|
2063
1809
|
promptEl.focus();
|
|
2064
1810
|
}
|
|
2065
1811
|
}
|
|
@@ -2106,88 +1852,6 @@ function addNoteBubble(note) {
|
|
|
2106
1852
|
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
2107
1853
|
}
|
|
2108
1854
|
|
|
2109
|
-
// ── continue card ──────────────────────────────────────────────────────────
|
|
2110
|
-
// A turn ran out of iterations: continue it, drop it, or drop it saying why
|
|
2111
|
-
// (the terminal's continue prompt). Answers go as /continue commands; the
|
|
2112
|
-
// card closes on the continue_resolved every client gets.
|
|
2113
|
-
|
|
2114
|
-
const CONTINUE_DECISIONS = {
|
|
2115
|
-
resume: "Continued",
|
|
2116
|
-
abort: "Not continued",
|
|
2117
|
-
abort_with_reason: "Not continued (reason noted)",
|
|
2118
|
-
dropped: "Dropped: a new prompt came",
|
|
2119
|
-
};
|
|
2120
|
-
|
|
2121
|
-
function renderContinueCard(offer) {
|
|
2122
|
-
if (continueCardEl?.isConnected) return;
|
|
2123
|
-
removeHintIfEmpty();
|
|
2124
|
-
const card = document.createElement("div");
|
|
2125
|
-
card.className = "bubble continue";
|
|
2126
|
-
const text = document.createElement("div");
|
|
2127
|
-
text.className = "continue-text";
|
|
2128
|
-
text.textContent = "The turn ran out of iterations. Continue it?";
|
|
2129
|
-
card.appendChild(text);
|
|
2130
|
-
const task = offer?.context?.original_prompt;
|
|
2131
|
-
if (task) {
|
|
2132
|
-
const ctx = document.createElement("div");
|
|
2133
|
-
ctx.className = "continue-context";
|
|
2134
|
-
ctx.textContent = task;
|
|
2135
|
-
card.appendChild(ctx);
|
|
2136
|
-
}
|
|
2137
|
-
const err = document.createElement("div");
|
|
2138
|
-
err.className = "question-error hidden";
|
|
2139
|
-
const answer = (line) => {
|
|
2140
|
-
err.classList.add("hidden");
|
|
2141
|
-
card.querySelectorAll("button, input").forEach((el) => { el.disabled = true; });
|
|
2142
|
-
sendCommand(selected, line, { clientId }).catch((e) => {
|
|
2143
|
-
card.querySelectorAll("button, input").forEach((el) => { el.disabled = false; });
|
|
2144
|
-
err.textContent = e.message;
|
|
2145
|
-
err.classList.remove("hidden");
|
|
2146
|
-
});
|
|
2147
|
-
};
|
|
2148
|
-
const yes = document.createElement("button");
|
|
2149
|
-
yes.className = "continue-yes";
|
|
2150
|
-
yes.textContent = "Yes";
|
|
2151
|
-
yes.addEventListener("click", () => answer(continueLine("yes")));
|
|
2152
|
-
const no = document.createElement("button");
|
|
2153
|
-
no.className = "continue-no ghost";
|
|
2154
|
-
no.textContent = "No";
|
|
2155
|
-
no.addEventListener("click", () => answer(continueLine("no")));
|
|
2156
|
-
const reason = document.createElement("input");
|
|
2157
|
-
reason.type = "text";
|
|
2158
|
-
reason.className = "continue-reason";
|
|
2159
|
-
reason.placeholder = "Why not? (the model reads it)";
|
|
2160
|
-
const noBecause = document.createElement("button");
|
|
2161
|
-
noBecause.className = "continue-no-reason ghost";
|
|
2162
|
-
noBecause.textContent = "No, because…";
|
|
2163
|
-
noBecause.addEventListener("click", () => {
|
|
2164
|
-
if (!reason.value.trim()) return reason.focus();
|
|
2165
|
-
answer(continueLine("no", reason.value));
|
|
2166
|
-
});
|
|
2167
|
-
const row = document.createElement("div");
|
|
2168
|
-
row.className = "continue-actions";
|
|
2169
|
-
[yes, no, reason, noBecause].forEach((el) => row.appendChild(el));
|
|
2170
|
-
card.appendChild(row);
|
|
2171
|
-
card.appendChild(err);
|
|
2172
|
-
const follow = isNearBottom();
|
|
2173
|
-
appendToHistory(card);
|
|
2174
|
-
continueCardEl = card;
|
|
2175
|
-
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
2176
|
-
}
|
|
2177
|
-
|
|
2178
|
-
function resolveContinueCard(data) {
|
|
2179
|
-
const card = continueCardEl;
|
|
2180
|
-
continueCardEl = null;
|
|
2181
|
-
if (!card) return;
|
|
2182
|
-
card.classList.add("resolved");
|
|
2183
|
-
card.querySelectorAll("button, input").forEach((el) => { el.disabled = true; });
|
|
2184
|
-
const note = document.createElement("div");
|
|
2185
|
-
note.className = "question-result";
|
|
2186
|
-
const who = isOwn(data.client_id, clientId) ? "" : ` (${clientLabel(data.client_id) || "another client"})`;
|
|
2187
|
-
note.textContent = (CONTINUE_DECISIONS[data.decision] || data.decision || "Answered") + who;
|
|
2188
|
-
card.appendChild(note);
|
|
2189
|
-
}
|
|
2190
|
-
|
|
2191
1855
|
async function handleCancel() {
|
|
2192
1856
|
if (!selected) return;
|
|
2193
1857
|
cancelBtn.disabled = true;
|
|
@@ -2370,97 +2034,17 @@ document.addEventListener("keydown", (e) => {
|
|
|
2370
2034
|
if (e.key === "Escape" && !lightboxEl.hidden) lightboxEl.hidden = true;
|
|
2371
2035
|
});
|
|
2372
2036
|
cancelBtn.addEventListener("click", () => handleCancel());
|
|
2373
|
-
//
|
|
2374
|
-
//
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
let commandIndex = 0;
|
|
2381
|
-
const commandListEl = $("#commandList");
|
|
2382
|
-
|
|
2383
|
-
function commandListOpen() {
|
|
2384
|
-
return !commandListEl.hidden && commandShown.length > 0;
|
|
2385
|
-
}
|
|
2386
|
-
|
|
2387
|
-
function updateCommandList() {
|
|
2388
|
-
const shown = selected && currentOwner !== "tui" ? commandMatches(currentCommands, promptEl.value) : [];
|
|
2389
|
-
if (!shown.length) return closeCommandList();
|
|
2390
|
-
const keep = commandShown[commandIndex]?.name;
|
|
2391
|
-
commandShown = shown;
|
|
2392
|
-
commandIndex = Math.max(0, shown.findIndex((c) => c.name === keep));
|
|
2393
|
-
renderCommandList();
|
|
2394
|
-
}
|
|
2395
|
-
|
|
2396
|
-
function renderCommandList() {
|
|
2397
|
-
commandListEl.innerHTML = commandShown.map((c, i) => {
|
|
2398
|
-
const row = commandRow(c);
|
|
2399
|
-
const note = row.note ? `<span class="command-note">${escapeHtml(row.note)}</span>` : "";
|
|
2400
|
-
return `<div class="command-option${i === commandIndex ? " active" : ""}" role="option" id="command-option-${i}" ` +
|
|
2401
|
-
`aria-selected="${i === commandIndex}" data-index="${i}"><span class="command-name">${escapeHtml(row.name)}</span>` +
|
|
2402
|
-
`<span class="command-desc">${escapeHtml(row.description)}</span>${note}</div>`;
|
|
2403
|
-
}).join("");
|
|
2404
|
-
commandListEl.hidden = false;
|
|
2405
|
-
promptEl.setAttribute("aria-expanded", "true");
|
|
2406
|
-
promptEl.setAttribute("aria-activedescendant", `command-option-${commandIndex}`);
|
|
2407
|
-
commandListEl.querySelector(".command-option.active")?.scrollIntoView({ block: "nearest" });
|
|
2408
|
-
}
|
|
2409
|
-
|
|
2410
|
-
function closeCommandList() {
|
|
2411
|
-
commandShown = [];
|
|
2412
|
-
commandIndex = 0;
|
|
2413
|
-
commandListEl.hidden = true;
|
|
2414
|
-
commandListEl.innerHTML = "";
|
|
2415
|
-
promptEl.setAttribute("aria-expanded", "false");
|
|
2416
|
-
promptEl.removeAttribute("aria-activedescendant");
|
|
2417
|
-
}
|
|
2418
|
-
|
|
2419
|
-
// @return whether the pick took the key (false: ⏎ on a full name sends it)
|
|
2420
|
-
function acceptCommand(index, { enter = false } = {}) {
|
|
2421
|
-
const command = commandShown[index];
|
|
2422
|
-
if (!command) return false;
|
|
2423
|
-
const pick = pickCommand(command.name, promptEl.value, { enter });
|
|
2424
|
-
closeCommandList();
|
|
2425
|
-
if (pick.send) return false;
|
|
2426
|
-
promptEl.value = pick.text;
|
|
2427
|
-
promptEl.setSelectionRange(pick.text.length, pick.text.length);
|
|
2428
|
-
fitPrompt();
|
|
2429
|
-
promptEl.focus();
|
|
2430
|
-
return true;
|
|
2431
|
-
}
|
|
2432
|
-
|
|
2433
|
-
// @return whether the open list took the key
|
|
2434
|
-
function handleCommandListKey(e) {
|
|
2435
|
-
if (!commandListOpen() || e.isComposing) return false;
|
|
2436
|
-
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
2437
|
-
commandIndex = moveSelection(commandIndex, commandShown.length, e.key === "ArrowDown" ? 1 : -1);
|
|
2438
|
-
renderCommandList();
|
|
2439
|
-
} else if (e.key === "Tab" && !e.shiftKey) {
|
|
2440
|
-
acceptCommand(commandIndex);
|
|
2441
|
-
} else if (e.key === "Enter" && !e.shiftKey) {
|
|
2442
|
-
if (!acceptCommand(commandIndex, { enter: true })) return false;
|
|
2443
|
-
} else if (e.key === "Escape") {
|
|
2444
|
-
closeCommandList();
|
|
2445
|
-
e.stopPropagation();
|
|
2446
|
-
} else {
|
|
2447
|
-
return false;
|
|
2448
|
-
}
|
|
2449
|
-
e.preventDefault();
|
|
2450
|
-
return true;
|
|
2451
|
-
}
|
|
2452
|
-
|
|
2453
|
-
promptEl.addEventListener("input", updateCommandList);
|
|
2454
|
-
promptEl.addEventListener("blur", closeCommandList);
|
|
2455
|
-
// Keep the focus in the composer: the pick happens on click.
|
|
2456
|
-
commandListEl.addEventListener("pointerdown", (e) => e.preventDefault());
|
|
2457
|
-
commandListEl.addEventListener("click", (e) => {
|
|
2458
|
-
const option = e.target.closest(".command-option");
|
|
2459
|
-
if (option) acceptCommand(Number(option.dataset.index));
|
|
2037
|
+
// The / autocomplete (command_list.js): the session's commands while the
|
|
2038
|
+
// composer holds one "/word".
|
|
2039
|
+
const commandList = createCommandList({
|
|
2040
|
+
list: $("#commandList"),
|
|
2041
|
+
prompt: promptEl,
|
|
2042
|
+
commands: () => (selected && current.owner !== "tui" ? current.commands : []),
|
|
2043
|
+
afterPick: () => fitPrompt(),
|
|
2460
2044
|
});
|
|
2461
2045
|
|
|
2462
2046
|
promptEl.addEventListener("keydown", (e) => {
|
|
2463
|
-
if (
|
|
2047
|
+
if (commandList.handleKey(e)) return;
|
|
2464
2048
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
2465
2049
|
e.preventDefault();
|
|
2466
2050
|
if (!selected) handleCreate();
|
|
@@ -2477,7 +2061,7 @@ $("#infoStopBtn").addEventListener("click", async () => {
|
|
|
2477
2061
|
if (id === selected) {
|
|
2478
2062
|
// The worker is gone: don't wait for its stream to notice.
|
|
2479
2063
|
streamControl?.close();
|
|
2480
|
-
|
|
2064
|
+
current.status = "stopped";
|
|
2481
2065
|
workerGone(id);
|
|
2482
2066
|
}
|
|
2483
2067
|
} finally {
|
|
@@ -2523,7 +2107,7 @@ async function setArchived(id, archive, { undo = true } = {}) {
|
|
|
2523
2107
|
if (archive && id === selected && (result.stopped || []).includes(id)) {
|
|
2524
2108
|
// Its worker is gone: don't wait for its stream to notice.
|
|
2525
2109
|
streamControl?.close();
|
|
2526
|
-
|
|
2110
|
+
current.status = "stopped";
|
|
2527
2111
|
workerGone(id);
|
|
2528
2112
|
}
|
|
2529
2113
|
} catch (e) {
|
|
@@ -2556,239 +2140,28 @@ $("#includeArchived").addEventListener("change", () => {
|
|
|
2556
2140
|
if (allViewOpen()) renderAll();
|
|
2557
2141
|
});
|
|
2558
2142
|
|
|
2559
|
-
//
|
|
2560
|
-
//
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
annotateBar.setAttribute("aria-label", "Annotate");
|
|
2574
|
-
function annotateButton(text, cls) {
|
|
2575
|
-
const btn = document.createElement("button");
|
|
2576
|
-
btn.type = "button";
|
|
2577
|
-
btn.className = cls;
|
|
2578
|
-
btn.textContent = text;
|
|
2579
|
-
return btn;
|
|
2580
|
-
}
|
|
2581
|
-
annotateBar.appendChild(annotateButton("Annotate", "annotate-main"));
|
|
2582
|
-
annotatePresets.forEach((text, i) => {
|
|
2583
|
-
const btn = annotateButton(text, "annotate-preset");
|
|
2584
|
-
btn.dataset.index = String(i);
|
|
2585
|
-
btn.title = text;
|
|
2586
|
-
annotateBar.appendChild(btn);
|
|
2143
|
+
// Annotate (annotate_bar.js): a selection in the conversation offers
|
|
2144
|
+
// "Annotate" and the presets, which append a quote to the composer.
|
|
2145
|
+
const annotateBar = createAnnotateBar({
|
|
2146
|
+
presets: parsePresets(document.body.dataset.annotatePresets),
|
|
2147
|
+
canAnnotate: () => !!selected && current.owner !== "tui",
|
|
2148
|
+
roots: turnRoots,
|
|
2149
|
+
isLive: (root) => turnRunning && turnDom.containsLive(root),
|
|
2150
|
+
onQuote: (text) => {
|
|
2151
|
+
promptEl.value = appendQuote(promptEl.value, text);
|
|
2152
|
+
fitPrompt();
|
|
2153
|
+
promptEl.focus();
|
|
2154
|
+
promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
|
|
2155
|
+
promptEl.scrollTop = promptEl.scrollHeight;
|
|
2156
|
+
},
|
|
2587
2157
|
});
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
function hideAnnotate() {
|
|
2592
|
-
pendingQuote = null;
|
|
2593
|
-
annotateBar.hidden = true;
|
|
2594
|
-
}
|
|
2595
|
-
|
|
2596
|
-
function updateAnnotate() {
|
|
2597
|
-
const sel = window.getSelection();
|
|
2598
|
-
if (!selected || currentOwner === "tui" || !sel || sel.isCollapsed || !sel.rangeCount) return hideAnnotate();
|
|
2599
|
-
const range = sel.getRangeAt(0);
|
|
2600
|
-
if (!turnRoots().some((root) => root.contains(range.startContainer))) return hideAnnotate();
|
|
2601
|
-
const source = annotationSource(range.startContainer);
|
|
2602
|
-
// A running turn's narration box shows complete sentences and its thinking
|
|
2603
|
-
// is appended by delta; a selection in either is refused while the step is
|
|
2604
|
-
// live — the quote lands once it closes (then on the full text).
|
|
2605
|
-
if (!source || (turnRunning && turnDom.containsLive(source.root))) return hideAnnotate();
|
|
2606
|
-
// Clip to where the selection starts: one tool row, one bubble.
|
|
2607
|
-
const clipped = range.cloneRange();
|
|
2608
|
-
if (!source.root.contains(range.endContainer)) clipped.setEnd(source.root, source.root.childNodes.length);
|
|
2609
|
-
const block = quoteBlock(clipped.toString(), source);
|
|
2610
|
-
if (!block) return hideAnnotate();
|
|
2611
|
-
pendingQuote = block;
|
|
2612
|
-
const rects = clipped.getClientRects();
|
|
2613
|
-
const rect = rects.length ? rects[rects.length - 1] : clipped.getBoundingClientRect();
|
|
2614
|
-
annotateBar.hidden = false;
|
|
2615
|
-
// Measured at the left edge: a fixed element's shrink-to-fit width is
|
|
2616
|
-
// capped by what's right of it, so at the last (right) spot it would
|
|
2617
|
-
// measure wrapped.
|
|
2618
|
-
annotateBar.style.left = "8px";
|
|
2619
|
-
const w = annotateBar.offsetWidth;
|
|
2620
|
-
const h = annotateBar.offsetHeight;
|
|
2621
|
-
const maxTop = window.innerHeight - h - 8;
|
|
2622
|
-
const top = Math.min(rect.bottom + 6, maxTop);
|
|
2623
|
-
annotateBar.style.top = `${Math.max(top, 8)}px`;
|
|
2624
|
-
annotateBar.style.left = `${annotateBarLeft(rect, w, window.innerWidth)}px`;
|
|
2625
|
-
// Not over a copy button (this bubble's, one inside it, the next
|
|
2626
|
-
// bubble's).
|
|
2627
|
-
const box = annotateBar.getBoundingClientRect();
|
|
2628
|
-
const bubble = source.root.closest(".bubble");
|
|
2629
|
-
const near = [bubble, bubble?.nextElementSibling].filter(Boolean);
|
|
2630
|
-
for (const btn of near.flatMap((el) => [...el.querySelectorAll(".copy-btn")])) {
|
|
2631
|
-
const r = btn.getBoundingClientRect();
|
|
2632
|
-
if (r.bottom < box.top || r.top > box.bottom || r.right < box.left || r.left > box.right) continue;
|
|
2633
|
-
annotateBar.style.top = `${Math.max(Math.min(r.bottom + 4, maxTop), 8)}px`;
|
|
2634
|
-
break;
|
|
2635
|
-
}
|
|
2636
|
-
}
|
|
2637
|
-
|
|
2638
|
-
let annotateRaf = false;
|
|
2639
|
-
function scheduleAnnotate() {
|
|
2640
|
-
if (annotateRaf) return;
|
|
2641
|
-
annotateRaf = true;
|
|
2642
|
-
requestAnimationFrame(() => { annotateRaf = false; updateAnnotate(); });
|
|
2643
|
-
}
|
|
2644
|
-
document.addEventListener("selectionchange", scheduleAnnotate);
|
|
2645
|
-
historyEl.addEventListener("scroll", scheduleAnnotate);
|
|
2646
|
-
turnDom.scrollEl?.addEventListener("scroll", scheduleAnnotate, true);
|
|
2647
|
-
window.addEventListener("resize", scheduleAnnotate);
|
|
2158
|
+
historyEl.addEventListener("scroll", annotateBar.schedule);
|
|
2159
|
+
turnDom.scrollEl?.addEventListener("scroll", annotateBar.schedule, true);
|
|
2648
2160
|
turnRoots().forEach((root) => installCopy(root));
|
|
2649
|
-
// Keep the selection when a button is pressed.
|
|
2650
|
-
annotateBar.addEventListener("mousedown", (e) => e.preventDefault());
|
|
2651
|
-
annotateBar.addEventListener("click", (e) => {
|
|
2652
|
-
const btn = e.target.closest("button");
|
|
2653
|
-
if (!btn || !pendingQuote) return;
|
|
2654
|
-
const preset = btn.dataset.index === undefined ? null : annotatePresets[Number(btn.dataset.index)];
|
|
2655
|
-
promptEl.value = appendQuote(promptEl.value, preset ? presetNote(pendingQuote, preset) : pendingQuote);
|
|
2656
|
-
fitPrompt();
|
|
2657
|
-
window.getSelection()?.removeAllRanges();
|
|
2658
|
-
hideAnnotate();
|
|
2659
|
-
promptEl.focus();
|
|
2660
|
-
promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
|
|
2661
|
-
promptEl.scrollTop = promptEl.scrollHeight;
|
|
2662
|
-
});
|
|
2663
|
-
|
|
2664
|
-
// ── Notifications (notify.js): when a session needs the user (a question,
|
|
2665
|
-
// an approval, a failed turn, a long turn done) and this tab is not in
|
|
2666
|
-
// front, an OS notification (the bell turns them on) and a count in the
|
|
2667
|
-
// title (always). One tag per event, so several tabs show one, and none
|
|
2668
|
-
// while another chi tab is in front with it (notify.js, Several tabs).
|
|
2669
|
-
const NOTIFY_KEY = "chi_notify";
|
|
2670
|
-
const notifyBtn = $("#notifyBtn");
|
|
2671
|
-
let attention = initialAttentionState();
|
|
2672
|
-
const badgeKeys = new Set();
|
|
2673
|
-
let baseTitle = "Chi";
|
|
2674
|
-
|
|
2675
|
-
function setTitle() {
|
|
2676
|
-
document.title = badgeKeys.size ? `(${badgeKeys.size}) ${baseTitle}` : baseTitle;
|
|
2677
|
-
}
|
|
2678
|
-
|
|
2679
|
-
// The user is looking at this tab: visible and focused (a window behind
|
|
2680
|
-
// the terminal is visible but not focused).
|
|
2681
|
-
function tabInFront() {
|
|
2682
|
-
return document.visibilityState === "visible" && document.hasFocus();
|
|
2683
|
-
}
|
|
2684
|
-
|
|
2685
|
-
function notifyWanted() {
|
|
2686
|
-
try { return localStorage.getItem(NOTIFY_KEY) === "1"; } catch (_) { return false; }
|
|
2687
|
-
}
|
|
2688
|
-
|
|
2689
|
-
// "on" | "off" | "denied" | "unsupported" (notify.js)
|
|
2690
|
-
function notifyState() {
|
|
2691
|
-
const hasNotification = typeof globalThis.Notification === "function";
|
|
2692
|
-
return bellState({
|
|
2693
|
-
hasNotification,
|
|
2694
|
-
secureContext: globalThis.isSecureContext !== false,
|
|
2695
|
-
permission: hasNotification ? Notification.permission : "default",
|
|
2696
|
-
wanted: notifyWanted(),
|
|
2697
|
-
});
|
|
2698
|
-
}
|
|
2699
|
-
|
|
2700
|
-
const NOTIFY_TITLES = {
|
|
2701
|
-
on: "Notifications on: a session that needs you while this tab is in the background. Click to turn off",
|
|
2702
|
-
off: "Notify me when a session needs me while this tab is in the background",
|
|
2703
|
-
denied: "Notifications are blocked for this site in the browser's settings",
|
|
2704
|
-
};
|
|
2705
2161
|
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
notifyBtn.dataset.state = state;
|
|
2710
|
-
notifyBtn.title = NOTIFY_TITLES[state] || "";
|
|
2711
|
-
notifyBtn.setAttribute("aria-pressed", state === "on" ? "true" : "false");
|
|
2712
|
-
}
|
|
2713
|
-
|
|
2714
|
-
async function toggleNotify() {
|
|
2715
|
-
const state = notifyState();
|
|
2716
|
-
if (state === "unsupported" || state === "denied") return;
|
|
2717
|
-
let wanted = state !== "on";
|
|
2718
|
-
if (wanted && Notification.permission !== "granted") {
|
|
2719
|
-
wanted = (await Notification.requestPermission()) === "granted";
|
|
2720
|
-
}
|
|
2721
|
-
try { localStorage.setItem(NOTIFY_KEY, wanted ? "1" : "0"); } catch (_) {}
|
|
2722
|
-
renderNotifyBtn();
|
|
2723
|
-
}
|
|
2724
|
-
|
|
2725
|
-
function showNotification(session, a) {
|
|
2726
|
-
if (notifyState() !== "on") return;
|
|
2727
|
-
const { title, body } = attentionText(session, a);
|
|
2728
|
-
try {
|
|
2729
|
-
const n = new Notification(title, { body, tag: a.key });
|
|
2730
|
-
n.onclick = () => {
|
|
2731
|
-
window.focus();
|
|
2732
|
-
location.hash = sessionHash(a.sessionId);
|
|
2733
|
-
n.close();
|
|
2734
|
-
};
|
|
2735
|
-
} catch (_) {}
|
|
2736
|
-
}
|
|
2737
|
-
|
|
2738
|
-
// Another chi tab in front has these (notify.js, Several tabs).
|
|
2739
|
-
function seenElsewhere(keys) {
|
|
2740
|
-
const size = badgeKeys.size;
|
|
2741
|
-
for (const key of keys) badgeKeys.delete(key);
|
|
2742
|
-
if (badgeKeys.size !== size) setTitle();
|
|
2743
|
-
}
|
|
2744
|
-
let notifyChannel = null;
|
|
2745
|
-
try {
|
|
2746
|
-
if (typeof globalThis.BroadcastChannel === "function") notifyChannel = new BroadcastChannel(NOTIFY_CHANNEL);
|
|
2747
|
-
} catch (_) {}
|
|
2748
|
-
const notifyGate = createNotifyGate({ channel: notifyChannel, onSeen: seenElsewhere });
|
|
2749
|
-
|
|
2750
|
-
// Every hub frame goes through here before the list takes it.
|
|
2751
|
-
function noticeSessions(type, data) {
|
|
2752
|
-
const next = trackAttention(attention, type, data);
|
|
2753
|
-
attention = next.state;
|
|
2754
|
-
const size = badgeKeys.size;
|
|
2755
|
-
for (const key of next.closed) badgeKeys.delete(key);
|
|
2756
|
-
notifyGate.drop(next.closed);
|
|
2757
|
-
if (next.attentions.length > 0 && tabInFront()) {
|
|
2758
|
-
notifyGate.seenInFront(next.attentions.map((a) => a.key));
|
|
2759
|
-
} else {
|
|
2760
|
-
for (const a of next.attentions) {
|
|
2761
|
-
notifyGate.offer(a, () => {
|
|
2762
|
-
if (tabInFront()) return;
|
|
2763
|
-
badgeKeys.add(a.key);
|
|
2764
|
-
setTitle();
|
|
2765
|
-
showNotification(attention.byId[a.sessionId], a);
|
|
2766
|
-
});
|
|
2767
|
-
}
|
|
2768
|
-
}
|
|
2769
|
-
if (badgeKeys.size !== size) setTitle();
|
|
2770
|
-
}
|
|
2771
|
-
|
|
2772
|
-
// The user is here: the badge goes, and the other tabs drop what it had.
|
|
2773
|
-
function clearBadge() {
|
|
2774
|
-
if (badgeKeys.size === 0) return;
|
|
2775
|
-
notifyGate.seenInFront([...badgeKeys]);
|
|
2776
|
-
badgeKeys.clear();
|
|
2777
|
-
setTitle();
|
|
2778
|
-
}
|
|
2779
|
-
|
|
2780
|
-
// The user is back: the page turned visible (Safari makes it visible
|
|
2781
|
-
// before it has focus, and a tab switch inside a focused window sends no
|
|
2782
|
-
// focus event), the window got focus, or they touched the page.
|
|
2783
|
-
function backInFront() {
|
|
2784
|
-
if (document.visibilityState === "visible") clearBadge();
|
|
2785
|
-
}
|
|
2786
|
-
document.addEventListener("visibilitychange", backInFront);
|
|
2787
|
-
window.addEventListener("focus", backInFront);
|
|
2788
|
-
document.addEventListener("pointerdown", clearBadge, true);
|
|
2789
|
-
document.addEventListener("keydown", clearBadge, true);
|
|
2790
|
-
notifyBtn.addEventListener("click", toggleNotify);
|
|
2791
|
-
renderNotifyBtn();
|
|
2162
|
+
// Notifications (notifications.js): an OS notification and a title count
|
|
2163
|
+
// when a session needs the user while this tab is not in front.
|
|
2164
|
+
const notifications = createNotifications({ button: $("#notifyBtn") });
|
|
2792
2165
|
|
|
2793
2166
|
// The latest-sessions strip can be hidden for more chat room; the choice
|
|
2794
2167
|
// stays per browser (it replaced zen mode, so a zen user starts hidden).
|