samagotchi 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/docs/architecture.md +24 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/guardrails.md +3 -2
  6. data/docs/hooks.md +44 -7
  7. data/docs/internals/gemma4-contract.md +1 -0
  8. data/docs/plugins.md +34 -12
  9. data/docs/sessions.md +1 -1
  10. data/lib/samagotchi/bootstrap_command.rb +29 -29
  11. data/lib/samagotchi/bridge/card_store.rb +4 -4
  12. data/lib/samagotchi/bridge/pending_card.rb +2 -2
  13. data/lib/samagotchi/bridge/sse_writer.rb +22 -28
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
  15. data/lib/samagotchi/bridge.rb +59 -112
  16. data/lib/samagotchi/bridge_client.rb +11 -28
  17. data/lib/samagotchi/bundle_command.rb +158 -212
  18. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
  20. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +7 -3
  21. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
  24. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  25. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
  26. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/skills/plugin.rb +4 -6
  28. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
  29. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  31. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  32. data/lib/samagotchi/cli/command.rb +51 -0
  33. data/lib/samagotchi/cli/flags.rb +116 -0
  34. data/lib/samagotchi/config.rb +70 -55
  35. data/lib/samagotchi/config_text_edit.rb +107 -0
  36. data/lib/samagotchi/desktop_command.rb +24 -18
  37. data/lib/samagotchi/edit_preview.rb +2 -2
  38. data/lib/samagotchi/engine.rb +20 -75
  39. data/lib/samagotchi/events.rb +23 -0
  40. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  41. data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
  42. data/lib/samagotchi/hooks/loader.rb +23 -33
  43. data/lib/samagotchi/hooks/registry.rb +41 -0
  44. data/lib/samagotchi/hooks.rb +0 -3
  45. data/lib/samagotchi/host_registry.rb +15 -0
  46. data/lib/samagotchi/idle_reminders.rb +0 -7
  47. data/lib/samagotchi/image_store.rb +22 -0
  48. data/lib/samagotchi/kernel_loop.rb +12 -16
  49. data/lib/samagotchi/live_versions.rb +2 -13
  50. data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
  51. data/lib/samagotchi/memory_bundle/builder.rb +1 -14
  52. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
  53. data/lib/samagotchi/memory_bundle/installer.rb +1 -27
  54. data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
  55. data/lib/samagotchi/memory_bundle/status.rb +1 -14
  56. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
  57. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
  58. data/lib/samagotchi/memory_paths.rb +10 -0
  59. data/lib/samagotchi/note_command.rb +23 -37
  60. data/lib/samagotchi/owner_lock.rb +14 -3
  61. data/lib/samagotchi/plugin/api.rb +11 -17
  62. data/lib/samagotchi/plugin/context.rb +70 -15
  63. data/lib/samagotchi/plugin/loader.rb +2 -3
  64. data/lib/samagotchi/sampling_settings.rb +3 -5
  65. data/lib/samagotchi/self_report.rb +6 -9
  66. data/lib/samagotchi/send_command.rb +33 -52
  67. data/lib/samagotchi/session.rb +88 -92
  68. data/lib/samagotchi/session_archive_command.rb +8 -14
  69. data/lib/samagotchi/session_commands.rb +3 -21
  70. data/lib/samagotchi/session_delete_command.rb +15 -29
  71. data/lib/samagotchi/session_manager.rb +27 -13
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
  74. data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
  75. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  76. data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
  77. data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
  78. data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
  79. data/lib/samagotchi/terminal_ui.rb +156 -234
  80. data/lib/samagotchi/thinking.rb +2 -3
  81. data/lib/samagotchi/thought_stream_splitter.rb +10 -9
  82. data/lib/samagotchi/tool_call_parser.rb +56 -292
  83. data/lib/samagotchi/tools/builtin_calls.rb +146 -0
  84. data/lib/samagotchi/tools/builtins.rb +2 -1
  85. data/lib/samagotchi/tools/edit.rb +21 -29
  86. data/lib/samagotchi/tools/memory.rb +1 -11
  87. data/lib/samagotchi/tools/send_note.rb +1 -1
  88. data/lib/samagotchi/update_command.rb +32 -22
  89. data/lib/samagotchi/version.rb +1 -1
  90. data/lib/samagotchi/vision_support.rb +4 -5
  91. data/lib/samagotchi/web/app.rb +89 -151
  92. data/lib/samagotchi/web/public/activity.js +1 -11
  93. data/lib/samagotchi/web/public/annotate_bar.js +120 -0
  94. data/lib/samagotchi/web/public/app.js +158 -785
  95. data/lib/samagotchi/web/public/chunk_router.js +7 -6
  96. data/lib/samagotchi/web/public/command_list.js +99 -0
  97. data/lib/samagotchi/web/public/data.js +22 -66
  98. data/lib/samagotchi/web/public/format.js +0 -23
  99. data/lib/samagotchi/web/public/index.html +0 -2
  100. data/lib/samagotchi/web/public/model_pick.js +0 -7
  101. data/lib/samagotchi/web/public/notifications.js +169 -0
  102. data/lib/samagotchi/web/public/question_cards.js +352 -0
  103. data/lib/samagotchi/web/public/session_state.js +34 -0
  104. data/lib/samagotchi/web/public/timing.js +1 -1
  105. data/lib/samagotchi/web/public/turn_events.js +11 -2
  106. data/lib/samagotchi/web/public/turn_model.js +0 -14
  107. data/lib/samagotchi/web/session_hub.rb +6 -4
  108. data/lib/samagotchi/web/session_summary.rb +5 -3
  109. data/lib/samagotchi/worker.rb +59 -53
  110. data/lib/samagotchi/worker_sidecar.rb +27 -1
  111. 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, messageBodyHtml, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, firstMessageOf, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
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 { annotateBarLeft, annotationSource, appendQuote, quoteBlock } from "./annotations.js";
27
- import { parsePresets, presetNote } from "./annotate_presets.js";
28
- import { COPY_SOURCE_ATTR, copySource, copySourceAttr, copyText, installCopy, showsAnswer } from "./copy.js";
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 { diffHtml } from "./diff_view.js";
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 { commandMatches, commandRow, moveSelection, pickCommand } from "./command_complete.js";
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 { NOTIFY_CHANNEL, attentionText, createNotifyGate, initialAttentionState, notifyState as bellState, trackAttention } from "./notify.js";
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, continueLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isCommandLine, isOwn, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
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
- let currentFirstPreview = "";
100
- let currentUsedMemories = [];
101
- // The session's --memory and --mute lists (from the session card; shown in
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
- currentOwner = sess.owner || null;
206
+ current.owner = sess.owner || null;
221
207
  const streaming = !!(streamControl && streamControl.live);
222
- if (!streaming) currentStatus = sess.status;
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
- baseTitle = scope.projectName ? `Chi · ${scope.projectName}` : "Chi";
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 (currentOwner === "tui") {
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
- currentStatus = status;
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
- currentFirstPreview = "";
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
- currentFirstPreview = "";
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
- removeQuestionCard();
662
- continueCardEl = null;
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
- currentStatus = s.status;
713
- currentModel = s.model_name;
714
- currentServed = s.served_model ? [s.served_model, s.served_model_for] : null;
715
- currentDir = s.working_directory;
716
- currentOwner = s.owner || null;
717
- currentTiming = normalizeTiming(data.timing);
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) currentCtxPct = savedPct;
722
- if (data.timing?.context?.window_tokens) currentCtxWindow = data.timing.context.window_tokens;
723
- currentFirstPreview = s.first_preview || previewForCard(s);
724
- currentUsedMemories = s.used_memory_names || [];
725
- currentPreloadedMemories = s.preloaded_memory_names || [];
726
- currentMutedMemories = s.muted_memory_names || [];
727
- currentParentId = s.parent_id || null;
728
- currentCommands = Array.isArray(data.commands) ? data.commands : [];
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) currentFirstPreview = normalize(first.content).slice(0,80) + (normalize(first.content).length>80?"…":"");
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 (!currentFirstPreview && data.history && data.history.length) {
705
+ if (!current.firstPreview && data.history && data.history.length) {
736
706
  const txt = normalize(String(data.history[0]||"")).slice(0,80);
737
- if(txt) currentFirstPreview = 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: currentTiming.activeTurn?.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(currentStatus === "running");
770
- if (turnRunning && currentTiming.activeTurn?.started_at) startLiveTurnTiming(currentTiming.activeTurn.started_at);
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
- renderQuestionCard(data.pending_question);
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) renderContinueCard(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, currentTiming, { thumbs: (images) => thumbsHtml(selected, images) });
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)) currentUsedMemories = d.session.used_memory_names;
822
- if (d.session.status) currentStatus = 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(currentTiming.turnRecords[currentTiming.turnRecords.length - 1]);
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 = currentUsedMemories.slice();
831
- sess.status = currentStatus;
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) resolveQuestionCard(pendingQuestion.id, { cancelled: true, reason: "turn ended" });
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
- renderQuestionCard(pq);
1217
- const card = questionCardEl;
1186
+ questionCards.renderQuestion(pq);
1187
+ const card = questionCards.questionCard();
1218
1188
  if (!card || card.dataset.qid !== String(pq.id)) return;
1219
- resolveQuestionCard(pq.id, { answer: entry.answer || null, cancelled: !!entry.cancelled, reason: entry.reason || "" });
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: currentOwner, streaming: !!streamControl }) ? "" : "none";
1268
+ if (stopBtn) stopBtn.style.display = canStopSession({ owner: current.owner, streaming: !!streamControl }) ? "" : "none";
1299
1269
  const idShort = selected.slice(0,8);
1300
- const memText = currentUsedMemories.length ? currentUsedMemories.join(", ") : "—";
1301
- const ctxText = currentCtxPct !== null ? `ctx ${Math.round(currentCtxPct)}%` : "";
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 = currentStatus ? `status ${escapeHtml(currentStatus)}` : "status";
1305
- const label = modelLabel(currentModel, ...(currentServed || []));
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(currentParentId, allSessions.find((x) => x.id === currentParentId) || null);
1308
- const parentHtml = parentChip ? `<a class="model parent" href="${escapeHtml(sessionHash(currentParentId))}" title="${escapeHtml(parentChip.title)}">delegated by ${escapeHtml(String(currentParentId).slice(0, 8))}</a>` : "";
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 &quot;${escapeHtml(attachCmd)}&quot; 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(currentStatus||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}<span class="dir" title="${escapeHtml(currentDir||"")}">${escapeHtml((currentDir||"").slice(0,48))}</span>${ctxText ? `<span class="model">${escapeHtml(ctxText)}</span>` : ""}${timingText ? `<span class="model">${escapeHtml(timingText)}</span>` : ""}</div>`;
1313
- const previewHtml = `<div class="preview-line"><span class="preview">${escapeHtml(currentFirstPreview || "—")}</span> · <span class="mem" title="${escapeHtml(currentUsedMemories.join(", "))}">mem: ${escapeHtml(memText)}</span></div>`;
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(currentUsedMemories, currentPreloadedMemories, currentMutedMemories);
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 && currentTiming.startedAt) {
1329
- return elapsedSince(currentTiming.startedAt);
1298
+ if (turnRunning && current.timing.startedAt) {
1299
+ return elapsedSince(current.timing.startedAt);
1330
1300
  }
1331
- return currentTiming.sessionDurationMs;
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
- currentTiming.activeTurn = { started_at: startedAt };
1315
+ current.timing.activeTurn = { started_at: startedAt };
1346
1316
  // The turn records hold the finished turns, so this one comes next.
1347
- liveTurnNumber = currentTiming.turnRecords.length + 1;
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(currentTiming.activeTurn?.started_at);
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 (currentTiming.activeTurn) updateLiveTiming();
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(currentTiming.activeTurn?.started_at);
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 ? currentTiming.turnRecords.indexOf(record) : -1;
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
- currentTiming.activeTurn = null;
1362
+ current.timing.activeTurn = null;
1393
1363
  clearTimingInterval();
1394
1364
  updateInfoBar();
1395
1365
  }
1396
1366
 
1397
1367
  function applyTiming(data) {
1398
- currentTiming = normalizeTiming(data);
1399
- if (turnRunning && currentTiming.activeTurn?.started_at) {
1400
- startLiveTurnTiming(currentTiming.activeTurn.started_at);
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 (currentCtxPct !== null && currentCtxPct > 20 && currentFirstPreview) {
1406
- firstPeekEl.textContent = currentFirstPreview;
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
- currentUsedMemories = names.slice();
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 = currentUsedMemories.slice();
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 (currentOwner === "worker") currentOwner = null;
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
- // Phase 2: route the chunk's bytes to the text bubble and the thinking
1480
- // block (see chunk_router.js). Raw `content` is only used as a fallback
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, currentCtxWindow);
1454
+ const pct = extractCtxPct(data, current.ctxWindow);
1486
1455
  if (pct !== null && !Number.isNaN(pct)) {
1487
- currentCtxPct = pct;
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) currentCtxWindow = 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
- currentServed = [data.served_model, data.requested_model];
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 !== currentModel) {
1603
- currentModel = view.modelName;
1604
- currentServed = null;
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) => renderContinueCard(data),
1624
- continue_resolved: (data) => resolveContinueCard(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, currentCtxWindow);
1601
+ const pct = extractCtxPct(data, current.ctxWindow);
1633
1602
  if (pct !== null) {
1634
- currentCtxPct = pct;
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 || data.pendingQuestion;
1641
- if (pq && pq.id) renderQuestionCard(pq);
1609
+ const pq = data.pending_question;
1610
+ if (pq && pq.id) questionCards.renderQuestion(pq);
1642
1611
  },
1643
1612
  question_answered: (data) => {
1644
- resolveQuestionCard(data.id, { answer: data.answer });
1613
+ questionCards.resolveQuestion(data.id, { answer: data.answer });
1645
1614
  },
1646
1615
  question_cancelled: (data) => {
1647
- resolveQuestionCard(data.id, { cancelled: true, reason: data.reason });
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) currentStatus = data.session.status;
1657
+ if (data.session?.status) current.status = data.session.status;
1689
1658
  applyTiming(data.timing);
1690
- finishLiveTurnTiming(currentTiming.turnRecords[currentTiming.turnRecords.length - 1]);
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 (currentOwner === "tui") return;
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 (!currentFirstPreview || currentFirstPreview==="—") {
1990
- currentFirstPreview = normalize(prompt).slice(0,80) + (normalize(prompt).length>80?"…":"");
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
- currentOwner = "tui";
1772
+ current.owner = "tui";
2027
1773
  updateComposerMode();
2028
1774
  return;
2029
1775
  }
2030
1776
  alert(e.message);
2031
1777
  } finally {
2032
- actionBtn.disabled = currentOwner === "tui";
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
- currentOwner = "tui";
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 = currentOwner === "tui";
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
- // ── / autocomplete ─────────────────────────────────────────────────────────
2374
- // While the composer holds one "/word", a small glass list above it offers
2375
- // the session's commands (its worker's, plugins' too): arrows move, ⏎/Tab
2376
- // pick, Esc closes, a tap picks. A closed list takes no keys.
2377
-
2378
- let currentCommands = [];
2379
- let commandShown = [];
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 (handleCommandListKey(e)) return;
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
- currentStatus = "stopped";
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
- currentStatus = "stopped";
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
- // ── Annotate ───────────────────────────────────────────────────────────────
2560
- // Selecting text in an answer, thinking, a tool row or an own message shows
2561
- // "Annotate" and the presets (web.annotate_presets); Annotate appends the
2562
- // quote (with where it came from) to the composer, a preset the quote with
2563
- // its text as the note. Nothing is sent. The bar lives outside #history,
2564
- // which is re-rendered whole. The selection is read when it changes: a live
2565
- // bubble rewrites its text every frame, which would collapse it by the time
2566
- // a button is clicked.
2567
-
2568
- const annotatePresets = parsePresets(document.body.dataset.annotatePresets);
2569
- const annotateBar = document.createElement("div");
2570
- annotateBar.id = "annotateBar";
2571
- annotateBar.hidden = true;
2572
- annotateBar.setAttribute("role", "toolbar");
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
- document.body.appendChild(annotateBar);
2589
- let pendingQuote = null;
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
- function renderNotifyBtn() {
2707
- const state = notifyState();
2708
- notifyBtn.hidden = state === "unsupported";
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).