samagotchi 0.2.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 (243) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/bin/chi +1140 -0
  6. data/docs/architecture.md +299 -0
  7. data/docs/cli.md +490 -0
  8. data/docs/configuration.md +494 -0
  9. data/docs/desktop.md +97 -0
  10. data/docs/guardrails.md +218 -0
  11. data/docs/hooks.md +309 -0
  12. data/docs/internals/background-tasks.md +26 -0
  13. data/docs/internals/context-telemetry.md +36 -0
  14. data/docs/internals/gemma4-contract.md +23 -0
  15. data/docs/internals/tool-guardrails.md +45 -0
  16. data/docs/memory.md +85 -0
  17. data/docs/plugins.md +819 -0
  18. data/docs/releasing.md +135 -0
  19. data/docs/sessions.md +155 -0
  20. data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
  21. data/lib/samagotchi/bridge/card_store.rb +126 -0
  22. data/lib/samagotchi/bridge/event_id.rb +25 -0
  23. data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
  24. data/lib/samagotchi/bridge/sse_writer.rb +248 -0
  25. data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
  26. data/lib/samagotchi/bridge.rb +993 -0
  27. data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
  28. data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
  29. data/lib/samagotchi/bridge_client.rb +330 -0
  30. data/lib/samagotchi/bundle_needs.rb +97 -0
  31. data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
  32. data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
  34. data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
  35. data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
  36. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
  37. data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
  39. data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
  40. data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
  44. data/lib/samagotchi/bundles/system/delegated.md +10 -0
  45. data/lib/samagotchi/bundles/system/identity.md +7 -0
  46. data/lib/samagotchi/bundles/system/manifest.yml +11 -0
  47. data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
  48. data/lib/samagotchi/bundles/system/self_map.md +55 -0
  49. data/lib/samagotchi/cancellation_controller.rb +78 -0
  50. data/lib/samagotchi/client.rb +429 -0
  51. data/lib/samagotchi/commands/registry.rb +112 -0
  52. data/lib/samagotchi/config.rb +910 -0
  53. data/lib/samagotchi/context_note.rb +77 -0
  54. data/lib/samagotchi/context_quote.rb +21 -0
  55. data/lib/samagotchi/context_usage.rb +66 -0
  56. data/lib/samagotchi/context_window.rb +76 -0
  57. data/lib/samagotchi/debug_log.rb +110 -0
  58. data/lib/samagotchi/desktop/macos/App.swift +102 -0
  59. data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
  60. data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
  61. data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
  62. data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
  63. data/lib/samagotchi/desktop/macos.rb +255 -0
  64. data/lib/samagotchi/desktop.rb +21 -0
  65. data/lib/samagotchi/desktop_command.rb +143 -0
  66. data/lib/samagotchi/engine.rb +2807 -0
  67. data/lib/samagotchi/guardrails/approval.rb +125 -0
  68. data/lib/samagotchi/guardrails/approvals.rb +177 -0
  69. data/lib/samagotchi/guardrails/context.rb +71 -0
  70. data/lib/samagotchi/guardrails/gate.rb +125 -0
  71. data/lib/samagotchi/guardrails/load_failures.rb +46 -0
  72. data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
  73. data/lib/samagotchi/guardrails/rules.rb +199 -0
  74. data/lib/samagotchi/guardrails/targets.rb +119 -0
  75. data/lib/samagotchi/guardrails/verdict.rb +134 -0
  76. data/lib/samagotchi/guardrails.rb +18 -0
  77. data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
  78. data/lib/samagotchi/hooks/loader.rb +162 -0
  79. data/lib/samagotchi/hooks/registry.rb +261 -0
  80. data/lib/samagotchi/hooks.rb +30 -0
  81. data/lib/samagotchi/host_registry.rb +315 -0
  82. data/lib/samagotchi/idle_client.rb +147 -0
  83. data/lib/samagotchi/idle_recap.rb +549 -0
  84. data/lib/samagotchi/idle_reminders.rb +101 -0
  85. data/lib/samagotchi/idle_scheduler.rb +76 -0
  86. data/lib/samagotchi/image_store.rb +393 -0
  87. data/lib/samagotchi/installed_gem.rb +38 -0
  88. data/lib/samagotchi/kernel_loop.rb +1017 -0
  89. data/lib/samagotchi/launch_mode.rb +34 -0
  90. data/lib/samagotchi/llm/backend.rb +28 -0
  91. data/lib/samagotchi/llm/chat_loop.rb +450 -0
  92. data/lib/samagotchi/llm/errors.rb +329 -0
  93. data/lib/samagotchi/llm/http.rb +412 -0
  94. data/lib/samagotchi/llm/model_result.rb +72 -0
  95. data/lib/samagotchi/llm/native_backend.rb +50 -0
  96. data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
  97. data/lib/samagotchi/llm/openai_chat.rb +403 -0
  98. data/lib/samagotchi/llm/usage.rb +79 -0
  99. data/lib/samagotchi/log.rb +200 -0
  100. data/lib/samagotchi/log_line.rb +127 -0
  101. data/lib/samagotchi/log_path.rb +31 -0
  102. data/lib/samagotchi/log_subscriber.rb +163 -0
  103. data/lib/samagotchi/memory_bundle/builder.rb +364 -0
  104. data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
  105. data/lib/samagotchi/memory_bundle/installer.rb +528 -0
  106. data/lib/samagotchi/memory_bundle/listing.rb +72 -0
  107. data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
  108. data/lib/samagotchi/memory_bundle/merger.rb +52 -0
  109. data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
  110. data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
  111. data/lib/samagotchi/memory_bundle/source.rb +153 -0
  112. data/lib/samagotchi/memory_bundle/status.rb +107 -0
  113. data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
  114. data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
  115. data/lib/samagotchi/memory_bundle.rb +17 -0
  116. data/lib/samagotchi/memory_paths.rb +101 -0
  117. data/lib/samagotchi/model_overlay.rb +53 -0
  118. data/lib/samagotchi/model_profile.rb +309 -0
  119. data/lib/samagotchi/muted_memories.rb +66 -0
  120. data/lib/samagotchi/note_command.rb +163 -0
  121. data/lib/samagotchi/output_formatter.rb +100 -0
  122. data/lib/samagotchi/owner_lock.rb +110 -0
  123. data/lib/samagotchi/pending_input_queue.rb +48 -0
  124. data/lib/samagotchi/plugin/api.rb +362 -0
  125. data/lib/samagotchi/plugin/context.rb +193 -0
  126. data/lib/samagotchi/plugin/loader.rb +126 -0
  127. data/lib/samagotchi/plugin/service.rb +117 -0
  128. data/lib/samagotchi/plugin/sessions.rb +150 -0
  129. data/lib/samagotchi/plugin/side_question.rb +60 -0
  130. data/lib/samagotchi/plugin/tool_result.rb +24 -0
  131. data/lib/samagotchi/project_scope.rb +25 -0
  132. data/lib/samagotchi/prompt.rb +119 -0
  133. data/lib/samagotchi/prompt_literal_guard.rb +70 -0
  134. data/lib/samagotchi/recap_store.rb +92 -0
  135. data/lib/samagotchi/reminder_store.rb +165 -0
  136. data/lib/samagotchi/self_report.rb +195 -0
  137. data/lib/samagotchi/send_command.rb +170 -0
  138. data/lib/samagotchi/served_model.rb +32 -0
  139. data/lib/samagotchi/session.rb +508 -0
  140. data/lib/samagotchi/session_commands.rb +527 -0
  141. data/lib/samagotchi/session_delete_command.rb +105 -0
  142. data/lib/samagotchi/session_manager.rb +1049 -0
  143. data/lib/samagotchi/session_metrics.rb +466 -0
  144. data/lib/samagotchi/session_observer.rb +117 -0
  145. data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
  146. data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
  147. data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
  148. data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
  149. data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
  150. data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
  151. data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
  152. data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
  153. data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
  154. data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
  155. data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
  156. data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
  157. data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
  158. data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
  159. data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
  160. data/lib/samagotchi/terminal_ui/screen.rb +316 -0
  161. data/lib/samagotchi/terminal_ui/surface.rb +47 -0
  162. data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
  163. data/lib/samagotchi/terminal_ui.rb +1992 -0
  164. data/lib/samagotchi/thinking_ticker.rb +110 -0
  165. data/lib/samagotchi/thought_stream_splitter.rb +149 -0
  166. data/lib/samagotchi/token_usage.rb +88 -0
  167. data/lib/samagotchi/tool_activity.rb +216 -0
  168. data/lib/samagotchi/tool_call_parser.rb +637 -0
  169. data/lib/samagotchi/tool_declarations.rb +561 -0
  170. data/lib/samagotchi/tool_runner.rb +211 -0
  171. data/lib/samagotchi/tools/args.rb +259 -0
  172. data/lib/samagotchi/tools/ask_user_question.rb +152 -0
  173. data/lib/samagotchi/tools/builtins.rb +122 -0
  174. data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
  175. data/lib/samagotchi/tools/delegate.rb +167 -0
  176. data/lib/samagotchi/tools/delegate_result.rb +53 -0
  177. data/lib/samagotchi/tools/delegate_wait.rb +153 -0
  178. data/lib/samagotchi/tools/edit.rb +155 -0
  179. data/lib/samagotchi/tools/execute.rb +214 -0
  180. data/lib/samagotchi/tools/list_reminders.rb +20 -0
  181. data/lib/samagotchi/tools/list_sessions.rb +74 -0
  182. data/lib/samagotchi/tools/memory.rb +256 -0
  183. data/lib/samagotchi/tools/output_guardrails.rb +93 -0
  184. data/lib/samagotchi/tools/peers.rb +18 -0
  185. data/lib/samagotchi/tools/read.rb +182 -0
  186. data/lib/samagotchi/tools/register_reminder.rb +53 -0
  187. data/lib/samagotchi/tools/registry.rb +60 -0
  188. data/lib/samagotchi/tools/send_note.rb +49 -0
  189. data/lib/samagotchi/tools/task_create.rb +29 -0
  190. data/lib/samagotchi/tools/task_get.rb +39 -0
  191. data/lib/samagotchi/tools/task_list.rb +43 -0
  192. data/lib/samagotchi/tools/task_runtime.rb +311 -0
  193. data/lib/samagotchi/tools/task_stop.rb +29 -0
  194. data/lib/samagotchi/tools/task_wait.rb +104 -0
  195. data/lib/samagotchi/tools/tool_path.rb +18 -0
  196. data/lib/samagotchi/tools/web_fetch.rb +163 -0
  197. data/lib/samagotchi/tools/write.rb +26 -0
  198. data/lib/samagotchi/turn_flow.rb +242 -0
  199. data/lib/samagotchi/turn_note.rb +76 -0
  200. data/lib/samagotchi/turn_tally.rb +101 -0
  201. data/lib/samagotchi/version.rb +7 -0
  202. data/lib/samagotchi/vision_context.rb +132 -0
  203. data/lib/samagotchi/vision_support.rb +109 -0
  204. data/lib/samagotchi/web/app.rb +1349 -0
  205. data/lib/samagotchi/web/markdown_renderer.rb +107 -0
  206. data/lib/samagotchi/web/message_parts.rb +169 -0
  207. data/lib/samagotchi/web/public/activity.js +100 -0
  208. data/lib/samagotchi/web/public/annotations.js +67 -0
  209. data/lib/samagotchi/web/public/app.js +2382 -0
  210. data/lib/samagotchi/web/public/card.js +74 -0
  211. data/lib/samagotchi/web/public/chat_view.js +360 -0
  212. data/lib/samagotchi/web/public/chunk_router.js +25 -0
  213. data/lib/samagotchi/web/public/command_complete.js +39 -0
  214. data/lib/samagotchi/web/public/composer_size.js +19 -0
  215. data/lib/samagotchi/web/public/copy.js +142 -0
  216. data/lib/samagotchi/web/public/ctx.js +35 -0
  217. data/lib/samagotchi/web/public/data.js +256 -0
  218. data/lib/samagotchi/web/public/format.js +232 -0
  219. data/lib/samagotchi/web/public/hold.js +78 -0
  220. data/lib/samagotchi/web/public/images.js +77 -0
  221. data/lib/samagotchi/web/public/index.html +568 -0
  222. data/lib/samagotchi/web/public/init_row.js +60 -0
  223. data/lib/samagotchi/web/public/model_pick.js +23 -0
  224. data/lib/samagotchi/web/public/question_card.js +100 -0
  225. data/lib/samagotchi/web/public/route.js +17 -0
  226. data/lib/samagotchi/web/public/scope.js +36 -0
  227. data/lib/samagotchi/web/public/scroll.js +24 -0
  228. data/lib/samagotchi/web/public/sentences.js +88 -0
  229. data/lib/samagotchi/web/public/sessions_list.js +60 -0
  230. data/lib/samagotchi/web/public/strip.js +25 -0
  231. data/lib/samagotchi/web/public/tally.js +37 -0
  232. data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
  233. data/lib/samagotchi/web/public/timing.js +185 -0
  234. data/lib/samagotchi/web/public/turn_events.js +209 -0
  235. data/lib/samagotchi/web/public/turn_model.js +204 -0
  236. data/lib/samagotchi/web/public/turn_view.js +587 -0
  237. data/lib/samagotchi/web/server.rb +183 -0
  238. data/lib/samagotchi/web/session_hub.rb +329 -0
  239. data/lib/samagotchi/web/session_summary.rb +85 -0
  240. data/lib/samagotchi/worker.rb +635 -0
  241. data/lib/samagotchi/worker_idle_exit.rb +87 -0
  242. data/lib/samagotchi.rb +12 -0
  243. metadata +374 -0
@@ -0,0 +1,2382 @@
1
+ import {
2
+ listSessions,
3
+ createSession,
4
+ getSession,
5
+ sendTurn,
6
+ cancelTurn,
7
+ stopSession,
8
+ deleteSession,
9
+ sendAnswer,
10
+ dismissQuestion,
11
+ sendCommand,
12
+ openStream,
13
+ openEvents,
14
+ createIdleSession,
15
+ uploadImage,
16
+ listModels,
17
+ } from "./data.js";
18
+ import { MODEL_KEY, modelNames, pickModel } from "./model_pick.js";
19
+ import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
20
+ import { escapeHtml, messageBodyHtml, noteHtml, normalize, userBodyHtml, ownerBadge, previewForCard, relativeTime, firstMessageOf, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
21
+ import { chatHistoryHtml, createChatView } from "./chat_view.js";
22
+ import { createTurnView, turnHistoryHtml } from "./turn_view.js";
23
+ import { annotationSource, appendQuote, quoteBlock } from "./annotations.js";
24
+ import { COPY_SOURCE_ATTR, copySourceAttr, copyText, installCopy } from "./copy.js";
25
+ import { routeChunk } from "./chunk_router.js";
26
+ import { extractCtxPct } from "./ctx.js";
27
+ import { approvalAllowed, approvalView, isApproval, nextCardAction, resultText, summaryText } from "./question_card.js";
28
+ import { cardClass, cardInnerHtml, cardPlace, splitSnapshotCards, turnNoticePlace } from "./card.js";
29
+ import { gripFloor, promptHeight } from "./composer_size.js";
30
+ import { commandMatches, commandRow, moveSelection, pickCommand } from "./command_complete.js";
31
+ import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
32
+ import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
33
+ import { shouldFollowScroll, keepFollowing } from "./scroll.js";
34
+ import { stripColumns, stripShowParts } from "./strip.js";
35
+ import { applySessionEvent, sortedByUpdated, withChildrenAfterParents } from "./sessions_list.js";
36
+ import {
37
+ appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
38
+ } from "./timing.js";
39
+ import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
40
+ import { clientLabel, commandView, continueLine, hookNoticeLabel, isCommandLine, isOwn, newClientId, promptOps, reminderText, restoreAction, snapshotEvents, turnOutput, workerGoneText } from "./turn_events.js";
41
+
42
+ const $ = (s) => document.querySelector(s);
43
+ const topStripEl = $("#topStrip");
44
+ const allListEl = $("#allList");
45
+ const historyEl = $("#history");
46
+ const emptyEl = $("#empty");
47
+ const composerEl = $("#composer");
48
+ const infoBarEl = $("#infoBar");
49
+ const firstPeekEl = $("#firstPeek");
50
+ const infoTextEl = $("#infoText");
51
+ const markdownWarningEl = $("#markdownWarning");
52
+ const promptEl = $("#prompt");
53
+ const actionBtn = $("#actionBtn");
54
+ const cancelBtn = $("#cancelBtn");
55
+ const modelPickEl = $("#modelPick");
56
+ const modelSelectEl = $("#modelSelect");
57
+ const composeHintEl = $("#composeHint");
58
+
59
+ let selected = null;
60
+ let streamControl = null;
61
+ // The session list's stream (GET /api/events); null while the page falls
62
+ // back to fetching (a chi web without a hub).
63
+ let eventsControl = null;
64
+ // resync() calls in flight, and whether a worker came up for the selected
65
+ // session while one ran (its bridge_up event): the tail runs once more.
66
+ let resyncsInFlight = 0;
67
+ let resyncAgain = false;
68
+ let allSessions = [];
69
+ let seenContent = new Set();
70
+ // The per-turn view (web.turn_view, or ?view=turn|chat): one block per turn
71
+ // with its steps (turn_view.js), instead of a row of bubbles (chat_view.js).
72
+ const turnView = document.body.dataset.turnView === "1";
73
+ // The running turn's DOM: the streaming bubbles, the thinking block and the
74
+ // activity panel, or the turn block.
75
+ const turnDom = (turnView ? createTurnView : createChatView)({
76
+ historyEl,
77
+ appendToHistory: (el) => appendToHistory(el),
78
+ isNearBottom: () => isNearBottom(),
79
+ sawText: (k) => seenContent.add(k),
80
+ sessionId: () => selected,
81
+ });
82
+ let currentFirstPreview = "";
83
+ let currentUsedMemories = [];
84
+ // The session's --memory and --mute lists (from the session card; shown in
85
+ // the info bar's tooltip).
86
+ let currentPreloadedMemories = [];
87
+ let currentMutedMemories = [];
88
+ let currentParentId = null;
89
+ let currentCtxPct = null;
90
+ // Window the kernel resolved for the running generation (:generation_started).
91
+ let currentCtxWindow = null;
92
+ let currentStatus = "";
93
+ let currentModel = "";
94
+ // What the worker's server said it served for a model name: [served, asked].
95
+ let currentServed = null;
96
+ let currentDir = "";
97
+ // Who holds the selected session: "worker" (shared), "tui" (a plain terminal
98
+ // chi; sending would be refused) or null.
99
+ let currentOwner = null;
100
+ let currentTiming = normalizeTiming();
101
+ let turnRunning = false;
102
+ let liveTurnTimingEl = null;
103
+ let timingInterval = null;
104
+ // Live ask_user_question card, keyed by pending question id. Re-rendered from
105
+ // pending_question on session select so a reload mid-question restores it.
106
+ let questionCardEl = null;
107
+ let pendingQuestion = null;
108
+ // The continue offer's card while the offer is pending.
109
+ let continueCardEl = null;
110
+
111
+ // This tab's id in the session's events: tells its own prompts from the
112
+ // ones other clients (an attached TUI, another tab, reminders) sent.
113
+ const clientId = newClientId();
114
+ // The event id (or, from an older worker, the event_seq) the selected session
115
+ // was rendered at (null: no live worker then), where a stream opened later
116
+ // has to start.
117
+ let selectSeq = null;
118
+ // The last input_merged had an origin with no bubble: its merged text
119
+ // (pending_input_merged) gets one.
120
+ let unmatchedMerge = false;
121
+ // The enqueued_ids this page sent (from the /turn acks): only these come back
122
+ // into the composer when their turn fails.
123
+ const sentIds = new Set();
124
+ // The last turn_failed's line, shown again after prompt_restored's resync.
125
+ let lastFailedText = null;
126
+
127
+ // The page's scope (scope.js): ?dir= is the folder a project view came
128
+ // from (new chats start there); the server names its project, if any.
129
+ const scope = {
130
+ dir: scopeDir(location.search),
131
+ projectName: document.body.dataset.projectName || null,
132
+ projectDir: document.body.dataset.projectDir || "",
133
+ serverDir: document.body.dataset.serverDir || "",
134
+ // The all view's way back to a project view (?from=, or chi web's own).
135
+ backName: document.body.dataset.backName || null,
136
+ backDir: document.body.dataset.backDir || "",
137
+ };
138
+ // The server refused ?dir (a folder deleted since the link was made).
139
+ let scopeError = null;
140
+
141
+ // The list by fetching: the fallback when there is no events stream (a
142
+ // chi web without a hub, or ?dir the server refused: the 400 is read here).
143
+ async function refresh() {
144
+ try {
145
+ allSessions = await listSessions("updated_at", "desc", { dir: scope.dir });
146
+ } catch (e) {
147
+ if (e.status !== 400 || !scope.dir) throw e;
148
+ scopeError = e.message;
149
+ allSessions = [];
150
+ renderScope();
151
+ }
152
+ render();
153
+ }
154
+
155
+ // The list as a projection of the hub's events: a snapshot on every
156
+ // (re)connect, then upserts and removals. The selected session's owner and
157
+ // worker follow its events (a new worker's bridge_up reconnects the stream).
158
+ function openSessionEvents() {
159
+ eventsControl = openEvents({
160
+ snapshot: (data) => {
161
+ allSessions = applySessionEvent(allSessions, "snapshot", data);
162
+ render();
163
+ const sess = allSessions.find((s) => s.id === selected);
164
+ if (sess) followSelectedSession(sess);
165
+ },
166
+ session: (data) => {
167
+ allSessions = applySessionEvent(allSessions, "session", data);
168
+ render();
169
+ if (data.session && data.session.id === selected) followSelectedSession(data.session);
170
+ },
171
+ session_gone: (data) => {
172
+ const next = applySessionEvent(allSessions, "session_gone", data);
173
+ if (next !== allSessions) {
174
+ allSessions = next;
175
+ render();
176
+ }
177
+ leaveGoneSession(data.id, "deleted");
178
+ },
179
+ }, {
180
+ dir: scope.dir,
181
+ onError: () => {
182
+ eventsControl = null;
183
+ refresh();
184
+ },
185
+ });
186
+ }
187
+
188
+ // The selected session's card changed: its owner and, without a live
189
+ // stream, its status show in the info bar; a worker whose bridge is up
190
+ // (a turn another client sent woke one, or a replacement after a kill)
191
+ // gets the stream attached through one resync.
192
+ function followSelectedSession(sess) {
193
+ currentOwner = sess.owner || null;
194
+ const streaming = !!(streamControl && streamControl.live);
195
+ if (!streaming) currentStatus = sess.status;
196
+ updateInfoBar();
197
+ if (!sess.bridge_up || streaming) return;
198
+ if (resyncsInFlight > 0) resyncAgain = true;
199
+ else resync();
200
+ }
201
+
202
+ // The header's scope chip: "<project> · all" in a project view (all links
203
+ // to the same place without ?dir); "all projects" otherwise, with where a
204
+ // new chat starts on the start page.
205
+ function renderScope() {
206
+ const el = $("#scope");
207
+ const allLink = `<a class="scope-all" href="${escapeHtml(allScopeHref(location.hash, scope.dir))}" title="Sessions of every project">all</a>`;
208
+ if (scopeError) {
209
+ el.innerHTML = `<span class="scope-name error" title="${escapeHtml(scopeError)}">folder not found</span>${allLink}`;
210
+ } else if (scope.projectName) {
211
+ el.innerHTML = `<span class="scope-name" title="Sessions of ${escapeHtml(scope.projectDir)}">${escapeHtml(scope.projectName)}</span>${allLink}`;
212
+ } else {
213
+ const where = scope.dir || scope.serverDir;
214
+ const backLink = scope.backName
215
+ ? `<a class="scope-all scope-back" href="${escapeHtml(projectScopeHref(scope.backDir, location.hash))}" title="Sessions of ${escapeHtml(scope.backName)} only">← ${escapeHtml(scope.backName)}</a>`
216
+ : "";
217
+ 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>` : ""}`;
218
+ }
219
+ el.hidden = false;
220
+ document.title = scope.projectName ? `Chi · ${scope.projectName}` : "Chi";
221
+ // The links follow the hash (an open session, the all-sessions view).
222
+ el.querySelector(".scope-all:not(.scope-back)")?.addEventListener("click", (e) => {
223
+ e.currentTarget.href = allScopeHref(location.hash, scope.dir);
224
+ });
225
+ el.querySelector(".scope-back")?.addEventListener("click", (e) => {
226
+ e.currentTarget.href = projectScopeHref(scope.backDir, location.hash);
227
+ });
228
+ }
229
+
230
+ function filteredSessions() {
231
+ const q = $("#filter").value.trim().toLowerCase();
232
+ if (!q) return allSessions;
233
+ return allSessions.filter(
234
+ (s) =>
235
+ previewForCard(s).toLowerCase().includes(q) ||
236
+ (s.first_preview || "").toLowerCase().includes(q) ||
237
+ s.short_id.toLowerCase().includes(q) ||
238
+ s.id.toLowerCase().includes(q) ||
239
+ s.status.toLowerCase().includes(q),
240
+ );
241
+ }
242
+
243
+ // A card: the first message as its title, then status, how long ago, owner
244
+ // and memories. The id is in the tooltip (and the search) only.
245
+ // deletable: the all-sessions view's cards get a delete button.
246
+ function cardHtml(s, { deletable = false } = {}) {
247
+ const ts = s.updated_at || s.created_at || "";
248
+ const tip = `${s.id} · updated ${ts} · ${s.status}`;
249
+ const preview = escapeHtml(previewForCard(s));
250
+ const memTitle = s.used_memory_names && s.used_memory_names.length ? `mem: ${s.used_memory_names.join(", ")}` : "";
251
+ const ago = relativeTime(ts);
252
+ const timeHtml = ago ? `<span class="time" title="${escapeHtml(new Date(ts).toLocaleString())}">${escapeHtml(ago)}</span>` : "";
253
+ const folder = cardFolder(s.working_directory, scope.projectName);
254
+ const folderHtml = folder ? `<span class="folder" title="${escapeHtml(s.working_directory)}">${escapeHtml(folder)}</span>` : "";
255
+ return `<div class="card" data-id="${s.id}" title="${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}" title="${escapeHtml(s.status)}" aria-label="${escapeHtml(s.status)}"></span>${timeHtml}${folderHtml}${parentChipHtml(s)}${ownerBadgeHtml(s)}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${escapeHtml(s.short_id)}">\u2715</button>` : ""}</div>`;
256
+ }
257
+
258
+ // A delegated session's `↳ <parent>` chip; the parent's preview is the tooltip.
259
+ function parentChipHtml(s) {
260
+ const chip = delegatedBy(s.parent_id, allSessions.find((x) => x.id === s.parent_id) || null);
261
+ return chip ? `<span class="parent" title="${escapeHtml(chip.title)}">${escapeHtml(chip.text)}</span>` : "";
262
+ }
263
+
264
+ function ownerBadgeHtml(s) {
265
+ const badge = ownerBadge(s.owner, s.id);
266
+ return badge ? `<span class="owner ${badge.kind}" title="${escapeHtml(badge.title)}">${escapeHtml(badge.text)}</span>` : "";
267
+ }
268
+
269
+ // The strip's card count follows the window: more cards on wide screens
270
+ // (each capped at 440 px) instead of 3 stretched ones. main's 16 px padding
271
+ // sits on both sides; measuring main works while the strip is hidden too.
272
+ let stripCols = 3;
273
+ function fitStripColumns() {
274
+ stripCols = stripColumns(topStripEl.parentElement.clientWidth - 32);
275
+ topStripEl.style.setProperty("--strip-cols", stripCols);
276
+ }
277
+
278
+ function render() {
279
+ fitStripColumns();
280
+ renderTop(allSessions.slice(0, stripCols));
281
+ if (allViewOpen()) renderAll();
282
+ }
283
+
284
+ let stripResizeTimer = null;
285
+ window.addEventListener("resize", () => {
286
+ clearTimeout(stripResizeTimer);
287
+ stripResizeTimer = setTimeout(() => {
288
+ const before = stripCols;
289
+ fitStripColumns();
290
+ if (stripCols !== before && allSessions.length) renderTop(allSessions.slice(0, stripCols));
291
+ }, 150);
292
+ });
293
+
294
+ function bindCards(container, onPick) {
295
+ container.querySelectorAll(".card[data-id]").forEach((el) => {
296
+ el.addEventListener("click", (e) => {
297
+ const del = e.target.closest(".card-del");
298
+ if (del) {
299
+ e.stopPropagation();
300
+ confirmDelete(del.dataset.del);
301
+ return;
302
+ }
303
+ onPick(el.dataset.id);
304
+ });
305
+ if (selected && el.dataset.id === selected) el.classList.add("active");
306
+ });
307
+ }
308
+
309
+ // The latest sessions (as many as fit), then the way to all of them.
310
+ function renderTop(sessions) {
311
+ topStripEl.classList.remove("loading");
312
+ renderStripShow();
313
+ if (scopeError) {
314
+ topStripEl.innerHTML = `<div class="hint scope-error">${escapeHtml(scopeError)} · <a href="${escapeHtml(allScopeHref(""))}">show every session</a></div>`;
315
+ return;
316
+ }
317
+ if (!sessions.length) {
318
+ topStripEl.innerHTML = "";
319
+ return;
320
+ }
321
+ const count = `${allSessions.length} ${allSessions.length === 1 ? "session" : "sessions"}`;
322
+ const tile = `<div class="strip-side"><button type="button" class="card" id="allTile" title="All sessions, with search">All sessions →<span class="count">${count}</span></button><button type="button" class="strip-toggle" id="stripHide" title="Hide the latest sessions">hide ▴</button></div>`;
323
+ topStripEl.innerHTML = sessions.map((s) => cardHtml(s)).join("") + tile;
324
+ bindCards(topStripEl, select);
325
+ $("#allTile").addEventListener("click", openAllView);
326
+ $("#stripHide").addEventListener("click", () => setStripHidden(true));
327
+ }
328
+
329
+ // The hidden strip's pill: "▾ 5 sessions · ● 1 live", following the list.
330
+ function renderStripShow() {
331
+ const { count, live } = stripShowParts(allSessions);
332
+ const liveHtml = live ? ` · <span class="live"><span class="live-dot" aria-hidden="true"></span>${escapeHtml(live)}</span>` : "";
333
+ $("#stripShow").innerHTML = `<span class="pill-arrow" aria-hidden="true">\u25BE</span> ${escapeHtml(count)}${liveHtml}`;
334
+ }
335
+
336
+ // A delegated session sits right after its parent: applied on every
337
+ // render, since a hub snapshot hands the list back in the server's order.
338
+ function renderAll() {
339
+ const sessions = withChildrenAfterParents(filteredSessions());
340
+ $("#count").textContent = sessions.length === allSessions.length
341
+ ? `${allSessions.length}`
342
+ : `${sessions.length} / ${allSessions.length}`;
343
+ if (!sessions.length) {
344
+ allListEl.innerHTML = `<div class="hint">${allSessions.length ? "No session matches." : "No sessions yet."}</div>`;
345
+ return;
346
+ }
347
+ allListEl.innerHTML = sessions.map((s) => cardHtml(s, { deletable: true })).join("");
348
+ bindCards(allListEl, (id) => { leaveAllView(sessionHash(id)); select(id); });
349
+ }
350
+
351
+ // Delete a session (the info bar's button, an all-sessions card's ✕) after
352
+ // a confirm; the list drops it without a reload. The server stops a live
353
+ // worker first and refuses a session a terminal chi has open.
354
+ async function confirmDelete(id) {
355
+ const sess = allSessions.find((s) => s.id === id) || { id };
356
+ if (sess.owner === "tui") {
357
+ alert(`Session ${id.slice(0, 8)} is open in a terminal chi; close it there first.`);
358
+ return;
359
+ }
360
+ if (!confirm(deleteConfirmText(sess))) return;
361
+ try {
362
+ await deleteSession(id);
363
+ } catch (e) {
364
+ alert(`Could not delete session ${id.slice(0, 8)}: ${e.message}`);
365
+ if (!eventsControl) refresh();
366
+ return;
367
+ }
368
+ allSessions = allSessions.filter((s) => s.id !== id);
369
+ if (selected === id) clearSelection();
370
+ render();
371
+ }
372
+
373
+ // All sessions: its own view at #/sessions, so the browser's back button
374
+ // leaves it. Opened from the page, it closes with history.back().
375
+ let allViewPushed = false;
376
+ function allViewOpen() {
377
+ return location.hash === ALL_SESSIONS_HASH;
378
+ }
379
+ function openAllView() {
380
+ if (allViewOpen()) return;
381
+ allViewPushed = true;
382
+ location.hash = ALL_SESSIONS_HASH;
383
+ }
384
+ function closeAllView() {
385
+ if (!allViewOpen()) return;
386
+ if (allViewPushed) {
387
+ history.back();
388
+ } else {
389
+ leaveAllView(sessionHash(selected));
390
+ }
391
+ }
392
+ // Leave all sessions for a place of our own (a picked session, a new chat):
393
+ // replace its history entry, since history.back() would come back later
394
+ // with the session the view was opened from.
395
+ function leaveAllView(hash) {
396
+ history.replaceState(null, "", location.pathname + location.search + hash);
397
+ allViewPushed = false;
398
+ applyView();
399
+ }
400
+ // The open session in the URL, so a reload or a copied link opens it again.
401
+ // Switching sessions replaces the entry; only all sessions pushes one.
402
+ function setSessionHash(id) {
403
+ if (allViewOpen()) return;
404
+ const hash = sessionHash(id);
405
+ if (location.hash === hash || (!hash && !location.hash)) return;
406
+ history.replaceState(null, "", location.pathname + location.search + hash);
407
+ }
408
+ // A #/s/<id> typed or pasted into the address bar, or reached by back.
409
+ function selectFromHash() {
410
+ const id = sessionIdFromHash(location.hash);
411
+ if (id && id !== selected) select(id);
412
+ }
413
+ function applyView() {
414
+ const open = allViewOpen();
415
+ if (!open) {
416
+ allViewPushed = false;
417
+ topStripEl.scrollLeft = 0; // a phone's strip scrolls sideways to the tile
418
+ }
419
+ document.body.classList.toggle("all-view", open);
420
+ if (open) {
421
+ // The list is live; only its order can lag (upserts keep cards in
422
+ // place), so the all view opens in the server's order.
423
+ allSessions = sortedByUpdated(allSessions);
424
+ renderAll();
425
+ if (!eventsControl) refresh();
426
+ $("#filter").select();
427
+ }
428
+ }
429
+ window.addEventListener("hashchange", () => {
430
+ applyView();
431
+ selectFromHash();
432
+ });
433
+ $("#allBack").addEventListener("click", closeAllView);
434
+
435
+ // A phone's composer fits a few words and has no Shift+Enter: the short
436
+ // placeholder there.
437
+ const narrowScreen = window.matchMedia("(max-width: 600px)");
438
+ narrowScreen.addEventListener("change", () => updateComposerMode());
439
+ function setPlaceholder(long, short) {
440
+ promptEl.placeholder = narrowScreen.matches ? short : long;
441
+ }
442
+
443
+ // The key hint under the composer (shown while it has focus).
444
+ const SEND_HINT = "Enter to send · Shift+Enter for a newline";
445
+
446
+ // While a turn runs, Send still sends: the prompt merges into the turn at
447
+ // its next step (steering). Cancel is its own button then.
448
+ function updateComposerMode() {
449
+ document.body.classList.toggle("has-session", !!selected);
450
+ const running = turnRunning && !!selected;
451
+ cancelBtn.style.display = running ? "" : "none";
452
+ if (running) {
453
+ setPlaceholder("Running… a message now steers this turn (Enter to send)", "Running… a message steers it");
454
+ composeHintEl.textContent = SEND_HINT;
455
+ actionBtn.textContent = "Send";
456
+ emptyEl.classList.add("hidden");
457
+ return;
458
+ }
459
+ if (!selected) {
460
+ setPlaceholder("Ask chi anything…", "Ask chi anything…");
461
+ composeHintEl.textContent = "Enter to start · Shift+Enter for a newline";
462
+ actionBtn.textContent = "Start";
463
+ actionBtn.disabled = false;
464
+ emptyEl.classList.remove("hidden");
465
+ historyEl.innerHTML = "";
466
+ updateInfoBar();
467
+ } else if (currentOwner === "tui") {
468
+ 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");
469
+ composeHintEl.textContent = "";
470
+ actionBtn.textContent = "Send";
471
+ actionBtn.disabled = true;
472
+ emptyEl.classList.add("hidden");
473
+ } else {
474
+ setPlaceholder("Message chi…", "Message chi…");
475
+ composeHintEl.textContent = SEND_HINT;
476
+ actionBtn.textContent = "Send";
477
+ actionBtn.disabled = false;
478
+ emptyEl.classList.add("hidden");
479
+ }
480
+ }
481
+
482
+ function setTurnRunning(running) {
483
+ turnRunning = !!running;
484
+ updateComposerMode();
485
+ updateInfoBar();
486
+ }
487
+
488
+ // Session status is turn state (idle/running): follow the live stream in the
489
+ // info bar and the session's card without a refetch.
490
+ function setLiveStatus(status) {
491
+ currentStatus = status;
492
+ const i = allSessions.findIndex((s) => s.id === selected);
493
+ if (i >= 0) {
494
+ // Its card gets the new dot and time; it keeps its place until the next
495
+ // snapshot or the all view re-sorts (no card jumping under the pointer;
496
+ // a session event replaces it in place too, see sessions_list.js).
497
+ allSessions[i] = withLiveStatus(allSessions[i], status);
498
+ render();
499
+ }
500
+ updateInfoBar();
501
+ }
502
+
503
+ // Plugins' slow setup still running in the open session's worker (chi.init),
504
+ // over the composer; a done line fades (init_row.js).
505
+ const initRowEl = $("#initRow");
506
+ const initTasks = newInitTasks();
507
+ function renderInitRow() {
508
+ initRowEl.innerHTML = initRowHtml(initTasks);
509
+ initRowEl.hidden = initTasks.size === 0;
510
+ }
511
+ function onInitEvent(type, data) {
512
+ const done = applyInitEvent(initTasks, type, data);
513
+ renderInitRow();
514
+ if (!done) return;
515
+ const session = selected;
516
+ setTimeout(() => {
517
+ if (session !== selected) return;
518
+ initRowEl.querySelector(`.init-task.done[data-id="${CSS.escape(done)}"]`)?.classList.add("fading");
519
+ setTimeout(() => {
520
+ if (session !== selected) return;
521
+ dropDone(initTasks, done);
522
+ renderInitRow();
523
+ }, 400);
524
+ }, DONE_MS);
525
+ }
526
+
527
+ function clearSelection() {
528
+ selected = null;
529
+ seedInitTasks(initTasks, []);
530
+ renderInitRow();
531
+ setSessionHash(null);
532
+ document.querySelectorAll(".card").forEach((el) => el.classList.remove("active"));
533
+ currentFirstPreview = "";
534
+ currentUsedMemories = [];
535
+ currentPreloadedMemories = [];
536
+ currentMutedMemories = [];
537
+ currentCtxPct = null;
538
+ currentCtxWindow = null;
539
+ currentStatus = "";
540
+ currentModel = "";
541
+ currentServed = null;
542
+ currentDir = "";
543
+ currentOwner = null;
544
+ currentTiming = normalizeTiming();
545
+ clearTimingInterval();
546
+ liveTurnTimingEl = null;
547
+ setTurnRunning(false);
548
+ closeStream();
549
+ setMarkdownWarning(null);
550
+ updateComposerMode();
551
+ updateInfoBar();
552
+ updateFirstPeek();
553
+ promptEl.focus();
554
+ }
555
+
556
+ // A short line on the start page (the open session went away); gone at the
557
+ // next session or keystroke.
558
+ const noticeEl = $("#notice");
559
+ function showNotice(text) {
560
+ noticeEl.textContent = text;
561
+ noticeEl.hidden = false;
562
+ }
563
+ function clearNotice() {
564
+ noticeEl.hidden = true;
565
+ noticeEl.textContent = "";
566
+ }
567
+ promptEl.addEventListener("input", clearNotice);
568
+
569
+ // The open session no longer exists: back to the start page, saying why.
570
+ function leaveGoneSession(id, why) {
571
+ if (selected !== id) return;
572
+ clearSelection();
573
+ showNotice(goneSessionNotice(id, why));
574
+ }
575
+
576
+ async function select(id) {
577
+ clearNotice();
578
+ selected = id;
579
+ setSessionHash(id);
580
+ document.querySelectorAll(".card").forEach((el) =>
581
+ el.classList.toggle("active", el.dataset.id === id),
582
+ );
583
+ historyEl.innerHTML = `<div class="hint">Loading…</div>`;
584
+ emptyEl.classList.add("hidden");
585
+ closeStream();
586
+ currentFirstPreview = "";
587
+ currentUsedMemories = [];
588
+ currentPreloadedMemories = [];
589
+ currentMutedMemories = [];
590
+ currentCtxPct = null;
591
+ currentCtxWindow = null;
592
+ currentStatus = "";
593
+ currentModel = "";
594
+ currentServed = null;
595
+ currentDir = "";
596
+ currentOwner = null;
597
+ currentCommands = [];
598
+ closeCommandList();
599
+ currentTiming = normalizeTiming();
600
+ resetTurnView();
601
+ updateFirstPeek();
602
+ updateInfoBar();
603
+ updateComposerMode();
604
+ await resync();
605
+ promptEl.focus();
606
+ }
607
+
608
+ // Clear everything that belongs to one turn's rendering.
609
+ function resetTurnView() {
610
+ closeStream();
611
+ clearTimingInterval();
612
+ liveTurnTimingEl = null;
613
+ removeQuestionCard();
614
+ continueCardEl = null;
615
+ unmatchedMerge = false;
616
+ setTurnRunning(false);
617
+ }
618
+
619
+ // (Re)render the selected session from the server and stream on from the
620
+ // event_seq that render covers. Used on select, when the stream resets
621
+ // (its cursor could not be replayed) and when it dropped (workerGone).
622
+ // Without a live bridge there is nothing to stream: the session's next
623
+ // event with bridge_up (a worker another client woke) resyncs again
624
+ // (followSelectedSession); a bridge_up that arrived while this ran is
625
+ // taken by the tail. +endText+: what workerGone said about the turn the
626
+ // worker left unfinished; shown again after the re-render when no worker
627
+ // took over.
628
+ async function resync({ endText = null } = {}) {
629
+ const id = selected;
630
+ if (!id) return;
631
+ resyncsInFlight += 1;
632
+ let data;
633
+ try {
634
+ try {
635
+ data = await getSession(id, { parts: turnView });
636
+ } catch (e) {
637
+ if (e.status === 404) return leaveGoneSession(id, "not_found");
638
+ if (id === selected) historyEl.innerHTML = `<div class="hint">Error: ${escapeHtml(e.message)}</div>`;
639
+ return;
640
+ }
641
+ if (id !== selected) return;
642
+ resetTurnView();
643
+ renderSessionView(data);
644
+ // Preview is read-only: a worker is woken only by POST /turn, so attach
645
+ // only when a live bridge exists.
646
+ selectSeq = typeof data.last_event_seq === "number" ? (data.last_event_id || data.last_event_seq) : null;
647
+ if (selectSeq !== null) startStream(id, selectSeq);
648
+ else if (endText) addEndBubble(endText);
649
+ } finally {
650
+ resyncsInFlight -= 1;
651
+ if (resyncsInFlight === 0 && resyncAgain) {
652
+ resyncAgain = false;
653
+ if (id === selected && !(streamControl && streamControl.live)) resync();
654
+ }
655
+ }
656
+ }
657
+
658
+ // A session view: its messages, then the turn in progress and the queued
659
+ // prompts (a live worker's snapshot) replayed through the stream handlers,
660
+ // then a pending question.
661
+ function renderSessionView(data) {
662
+ const s = data.session;
663
+ setMarkdownWarning(data.markdown_warning);
664
+ currentStatus = s.status;
665
+ currentModel = s.model_name;
666
+ currentServed = s.served_model ? [s.served_model, s.served_model_for] : null;
667
+ currentDir = s.working_directory;
668
+ currentOwner = s.owner || null;
669
+ currentTiming = normalizeTiming(data.timing);
670
+ currentFirstPreview = s.first_preview || previewForCard(s);
671
+ currentUsedMemories = s.used_memory_names || [];
672
+ currentPreloadedMemories = s.preloaded_memory_names || [];
673
+ currentMutedMemories = s.muted_memory_names || [];
674
+ currentParentId = s.parent_id || null;
675
+ currentCommands = Array.isArray(data.commands) ? data.commands : [];
676
+ if (data.messages && data.messages.length) {
677
+ const first = data.messages.find((m) => m.role === "user");
678
+ if (first && first.content) currentFirstPreview = normalize(first.content).slice(0,80) + (normalize(first.content).length>80?"…":"");
679
+ renderHistory(data.messages);
680
+ } else {
681
+ renderHistory(data.history || []);
682
+ if (!currentFirstPreview && data.history && data.history.length) {
683
+ const txt = normalize(String(data.history[0]||"")).slice(0,80);
684
+ if(txt) currentFirstPreview = txt;
685
+ }
686
+ }
687
+ seedSeenFromDom();
688
+ seedInitTasks(initTasks, data.init_tasks);
689
+ renderInitRow();
690
+ // A guardrail file that failed to load, announced before this page joined.
691
+ showGuardrailWarning(data.guardrail_warning);
692
+ showGuardrailWarning(data.plugin_warning, "plugins");
693
+ // The recap saved with the session (also a stopped one's), else a live
694
+ // worker's from before saved_recap existed.
695
+ if (data.saved_recap) showRecap(data.saved_recap.text, data.saved_recap.turns_since);
696
+ else showRecap(data.recap);
697
+ // A live worker's last cards and between-turns notices, where they
698
+ // arrived; the running turn's go into its step once it is drawn.
699
+ const snapshotCards = splitSnapshotCards(data.cards);
700
+ placeSnapshotCards(snapshotCards.placed);
701
+ const replay = snapshotEvents({
702
+ current_turn: data.current_turn,
703
+ queued: data.queued,
704
+ started_at: currentTiming.activeTurn?.started_at,
705
+ });
706
+ replay.forEach((event) => streamHandlers[event.type]?.(event));
707
+ if (!data.current_turn) {
708
+ setTurnRunning(currentStatus === "running");
709
+ if (turnRunning && currentTiming.activeTurn?.started_at) startLiveTurnTiming(currentTiming.activeTurn.started_at);
710
+ }
711
+ turnDom.flush();
712
+ snapshotCards.current.forEach((card) => upsertCard(card));
713
+ snapshotCards.during.forEach((card) => upsertCard(card));
714
+ // A question the worker is blocked on (also after a reload).
715
+ if (data.pending_question && data.pending_question.id && data.pending_question.status === "pending") {
716
+ renderQuestionCard(data.pending_question);
717
+ }
718
+ // A turn ran out of iterations and waits for a yes or no.
719
+ if (data.continue_offer) renderContinueCard(data.continue_offer);
720
+ updateInfoBar();
721
+ updateFirstPeek();
722
+ updateComposerMode();
723
+ }
724
+
725
+ function renderHistory(items) {
726
+ if (!items.length) {
727
+ historyEl.innerHTML = `<div class="hint">No output yet. Send a message.</div>`;
728
+ return;
729
+ }
730
+ if (typeof items[0] === "object" && items[0].role && turnView) {
731
+ historyEl.innerHTML = turnHistoryHtml(items, currentTiming, { thumbs: (images) => thumbsHtml(selected, images) });
732
+ } else if (typeof items[0] === "object" && items[0].role) {
733
+ historyEl.innerHTML = chatHistoryHtml(items, currentTiming, { thumbs: (images) => thumbsHtml(selected, images) });
734
+ } else {
735
+ historyEl.innerHTML = items.map((c) => `<div class="bubble output">${escapeHtml(c)}</div>`).join("");
736
+ }
737
+ historyEl.scrollTop = historyEl.scrollHeight;
738
+ }
739
+
740
+ function setMarkdownWarning(warning) {
741
+ if (!warning) {
742
+ markdownWarningEl.hidden = true;
743
+ markdownWarningEl.textContent = "";
744
+ return;
745
+ }
746
+ markdownWarningEl.textContent = warning;
747
+ markdownWarningEl.hidden = false;
748
+ }
749
+
750
+ function applyFinalMarkdown(messages) {
751
+ const message = [...messages].reverse().find(
752
+ (item) => item?.role === "assistant" && typeof item.html === "string",
753
+ );
754
+ if (!message) return;
755
+
756
+ const bubble = [...historyEl.querySelectorAll(".bubble.output")]
757
+ .reverse()
758
+ .find((item) => normalize(item.textContent) === normalize(message.content));
759
+ if (!bubble) return;
760
+
761
+ bubble.classList.add("markdown");
762
+ bubble.setAttribute(COPY_SOURCE_ATTR, message.content);
763
+ bubble.innerHTML = message.html;
764
+ }
765
+
766
+ function seedSeenFromDom() {
767
+ seenContent = new Set();
768
+ historyEl.querySelectorAll(".bubble").forEach((b) => {
769
+ const k = normalize(b.textContent);
770
+ if (k) seenContent.add(k);
771
+ });
772
+ }
773
+
774
+ // Sticky-follow scroll: only auto-scroll to the bottom while the user is
775
+ // already near it; never yank them back down mid-read. Capture the decision
776
+ // BEFORE any DOM mutation that grows the content (new chunk / row / thinking
777
+ // delta), so the gap measured is the one the user was sitting at.
778
+ function isNearBottom() {
779
+ const { scrollHeight, scrollTop, clientHeight } = historyEl;
780
+ return shouldFollowScroll({ scrollHeight, scrollTop, clientHeight });
781
+ }
782
+
783
+ function appendChunk(text) {
784
+ if (!text) return;
785
+ const k = normalize(text);
786
+ if (!k || seenContent.has(k)) return;
787
+ const follow = isNearBottom();
788
+ const hint = historyEl.querySelector(".hint");
789
+ if (hint) hint.remove();
790
+ const div = document.createElement("div");
791
+ div.className = "bubble output";
792
+ div.textContent = text;
793
+ appendToHistory(div);
794
+ if (follow) historyEl.scrollTop = historyEl.scrollHeight;
795
+ }
796
+
797
+ function addEndBubble(text) {
798
+ const div = document.createElement("div");
799
+ div.className = "bubble cancel";
800
+ div.textContent = text;
801
+ appendToHistory(div);
802
+ historyEl.scrollTop = historyEl.scrollHeight;
803
+ }
804
+
805
+ // Everything a turn's end (completed, canceled, failed) closes: then its
806
+ // end line (+endText+, the cancel or failed line), then the step cards that
807
+ // left the collapsed block (card.js leavesBlock), where a reload puts them.
808
+ function endTurnView(kind, endText = null) {
809
+ setTurnRunning(false);
810
+ const kept = turnDom.turnEnded({ kind }) || [];
811
+ finishLiveTurnTiming();
812
+ if (endText) addEndBubble(endText);
813
+ if (kept.length) {
814
+ kept.forEach((el) => appendToHistory(el));
815
+ historyEl.scrollTop = historyEl.scrollHeight;
816
+ }
817
+ // The question dies with its turn (the worker stopped waiting).
818
+ if (pendingQuestion) resolveQuestionCard(pendingQuestion.id, { cancelled: true, reason: "turn ended" });
819
+ }
820
+
821
+ // ── Prompt bubbles ─────────────────────────────────────────────────────────
822
+ // Every client's prompts, keyed by enqueued_id: queued (badge) until their
823
+ // turn starts, or steered when merged into a running turn. This tab's own
824
+ // prompts are echoed at send (data-own) and tagged by their turn_enqueued.
825
+
826
+ const BADGES = { queued: "queued", steered: "steered", failed: "failed" };
827
+
828
+ function makeUserBubble(prompt, { state = null, label = null, enqueuedId = null, own = false, images = null } = {}) {
829
+ const hint = historyEl.querySelector(".hint");
830
+ if (hint) hint.remove();
831
+ const div = document.createElement("div");
832
+ div.className = "bubble user";
833
+ div.dataset.userContent = normalize(prompt);
834
+ div.setAttribute(COPY_SOURCE_ATTR, prompt);
835
+ if (own) div.dataset.own = "1";
836
+ if (enqueuedId) div.dataset.enqueuedId = enqueuedId;
837
+ if (label) {
838
+ const from = document.createElement("span");
839
+ from.className = "origin-label";
840
+ from.textContent = label;
841
+ div.appendChild(from);
842
+ }
843
+ const message = document.createElement("div");
844
+ message.className = "user-message";
845
+ message.innerHTML = userBodyHtml(prompt) + thumbsHtml(selected, images);
846
+ div.appendChild(message);
847
+ setBubbleState(div, state);
848
+ const follow = isNearBottom();
849
+ appendToHistory(div);
850
+ if (follow) historyEl.scrollTop = historyEl.scrollHeight;
851
+ return div;
852
+ }
853
+
854
+ function setBubbleState(bubble, state) {
855
+ bubble.classList.remove("queued", "steered", "failed");
856
+ bubble.querySelector(".state-badge")?.remove();
857
+ if (!BADGES[state]) {
858
+ delete bubble.dataset.userState;
859
+ return;
860
+ }
861
+ bubble.classList.add(state);
862
+ bubble.dataset.userState = state;
863
+ const badge = document.createElement("span");
864
+ badge.className = "state-badge";
865
+ badge.textContent = BADGES[state];
866
+ bubble.appendChild(badge);
867
+ }
868
+
869
+ function bubbleById(enqueuedId) {
870
+ if (!enqueuedId) return null;
871
+ return [...historyEl.querySelectorAll(".bubble.user[data-enqueued-id]")].find(
872
+ (el) => el.dataset.enqueuedId === enqueuedId,
873
+ ) || null;
874
+ }
875
+
876
+ // This tab's oldest untagged echo, preferring one with the same text.
877
+ function untaggedOwnBubble(prompt) {
878
+ const echoes = [...historyEl.querySelectorAll(".bubble.user[data-own]:not([data-enqueued-id])")];
879
+ const text = normalize(prompt || "");
880
+ return echoes.find((el) => el.dataset.userContent === text) || echoes[0] || null;
881
+ }
882
+
883
+ function applyPromptOps(event) {
884
+ const ops = promptOps(event, { myId: clientId, known: (id) => !!bubbleById(id), unmatchedMerge });
885
+ if (event.type === "input_merged") unmatchedMerge = false;
886
+ if (event.type === "pending_input_merged") unmatchedMerge = false;
887
+ for (const op of ops) {
888
+ if (op.op === "add") {
889
+ makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images });
890
+ } else if (op.op === "tag") {
891
+ if (bubbleById(op.enqueuedId)) continue;
892
+ const echo = untaggedOwnBubble(op.prompt);
893
+ if (echo) echo.dataset.enqueuedId = op.enqueuedId;
894
+ // Re-rendered from a snapshot: the echo is gone, the prompt still queued.
895
+ else makeUserBubble(op.prompt, { state: "queued", own: true, enqueuedId: op.enqueuedId, images: op.images });
896
+ } else if (op.op === "start") {
897
+ const bubble = bubbleById(op.enqueuedId) || untaggedOwnBubble(op.prompt);
898
+ // A resync (a new worker woken by this page's send) re-rendered the
899
+ // history and took the echo with it: draw the prompt again.
900
+ if (!bubble) makeUserBubble(op.prompt, { own: true, enqueuedId: op.enqueuedId, images: op.images });
901
+ else {
902
+ if (op.enqueuedId) bubble.dataset.enqueuedId = op.enqueuedId;
903
+ setBubbleState(bubble, null);
904
+ }
905
+ } else if (op.op === "steer") {
906
+ const bubble = bubbleById(op.enqueuedId);
907
+ if (bubble) setBubbleState(bubble, "steered");
908
+ else unmatchedMerge = true;
909
+ }
910
+ }
911
+ }
912
+
913
+ // ── Idle recap ─────────────────────────────────────────────────────────────
914
+ // A summary the worker writes after a quiet stretch or as it leaves: shown at
915
+ // the end of the history. A turn starting leaves it in place, marked
916
+ // earlier; only a new recap replaces it.
917
+
918
+ function showRecap(text, turnsSince = 0) {
919
+ if (!text) return;
920
+ removeRecap();
921
+ const details = document.createElement("details");
922
+ details.className = "bubble recap";
923
+ if (turnsSince > 0) details.classList.add("stale");
924
+ details.open = true;
925
+ const summary = document.createElement("summary");
926
+ summary.textContent = recapLabel(turnsSince);
927
+ const body = document.createElement("div");
928
+ body.className = "recap-body";
929
+ body.textContent = text;
930
+ details.appendChild(summary);
931
+ details.appendChild(body);
932
+ // A stale one sits where it was written, above the turns it doesn't cover.
933
+ const before = recapPlace([...historyEl.querySelectorAll(":scope > .bubble.user")], turnsSince);
934
+ keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendToHistory(details)));
935
+ }
936
+
937
+ // A turn started: the recap describes the chat before it.
938
+ function markRecapStale() {
939
+ historyEl.querySelectorAll(".bubble.recap:not(.stale)").forEach((el) => {
940
+ el.classList.add("stale");
941
+ const summary = el.querySelector("summary");
942
+ if (summary) summary.textContent = recapLabel(0, true);
943
+ });
944
+ }
945
+
946
+ // A guardrail hook or rule file that failed to load (once per worker), or
947
+ // a bundle's plugin (+label+ "plugins").
948
+ function showGuardrailWarning(text, label = "guardrails") {
949
+ if (!text) return;
950
+ const el = document.createElement("div");
951
+ el.className = "bubble guardrail-warning";
952
+ el.textContent = `${label}: ${text}`;
953
+ const follow = isNearBottom();
954
+ appendToHistory(el);
955
+ if (follow) historyEl.scrollTop = historyEl.scrollHeight;
956
+ }
957
+
958
+ // A hook's line to the user (event[:notify]): "<bundle>: text", in the
959
+ // warning colour for level warn.
960
+ // +before+: the element it goes above (a snapshot's notice), else the end.
961
+ function showHookNotice(data, { before = null } = {}) {
962
+ removeHintIfEmpty();
963
+ const el = document.createElement("div");
964
+ el.className = "bubble hook-notice" + (data.level === "warn" ? " warn" : "");
965
+ el.textContent = `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
966
+ const follow = isNearBottom();
967
+ if (before) historyEl.insertBefore(el, before);
968
+ else appendToHistory(el);
969
+ if (follow) historyEl.scrollTop = historyEl.scrollHeight;
970
+ }
971
+
972
+ // ── plugin cards ───────────────────────────────────────────────────────────
973
+ // A plugin's card (docs/plugins.md, Cards): a glass card between turns, or
974
+ // a row of the running turn's step. The same id updates the card in place.
975
+ // An action button runs its command line in the session (sendCommand), as
976
+ // if typed.
977
+
978
+ function cardElement(id) {
979
+ if (!id) return null;
980
+ return [...historyEl.querySelectorAll(".plugin-card")].find((el) => el.dataset.cardId === String(id)) || null;
981
+ }
982
+
983
+ function drawCard(el, card) {
984
+ el.className = cardClass(card);
985
+ el.dataset.cardId = card.id;
986
+ if (card.body_html === undefined && el.dataset.body === String(card.body ?? "") && el.querySelector(".card-body")) {
987
+ // The same body again from the stream: keep the rendered one.
988
+ const body = el.querySelector(".card-body").innerHTML;
989
+ el.innerHTML = cardInnerHtml({ ...card, body_html: body });
990
+ } else {
991
+ el.innerHTML = cardInnerHtml(card);
992
+ }
993
+ el.dataset.body = String(card.body ?? "");
994
+ }
995
+
996
+ // Show +card+: update the one with its id where it is, else add it (a
997
+ // running turn's card as a row of its step; else before +before+ or at the
998
+ // end of the history).
999
+ function upsertCard(card, { before = null } = {}) {
1000
+ if (!card || !card.id) return;
1001
+ const existing = cardElement(card.id);
1002
+ if (existing) {
1003
+ drawCard(existing, card);
1004
+ existing.classList.add("card-updated");
1005
+ setTimeout(() => existing.classList.remove("card-updated"), 900);
1006
+ return;
1007
+ }
1008
+ removeHintIfEmpty();
1009
+ const el = document.createElement("div");
1010
+ drawCard(el, card);
1011
+ if (card.in_turn && !before && turnDom.card(el)) return;
1012
+ const follow = isNearBottom();
1013
+ if (before) historyEl.insertBefore(el, before);
1014
+ else appendToHistory(el);
1015
+ if (follow) historyEl.scrollTop = historyEl.scrollHeight;
1016
+ }
1017
+
1018
+ // A snapshot's cards and notices, each above the first turn after it; a
1019
+ // turn's own notice where it showed live (placeTurnNotice).
1020
+ function placeSnapshotCards(entries) {
1021
+ const users = [...historyEl.querySelectorAll(":scope > .bubble.user")];
1022
+ for (const entry of entries) {
1023
+ const before = cardPlace(users, entry);
1024
+ if (entry.type === "hook_notice" && entry.in_turn) placeTurnNotice(entry, before);
1025
+ else if (entry.type === "hook_notice") showHookNotice(entry, { before });
1026
+ else upsertCard(entry, { before });
1027
+ }
1028
+ }
1029
+
1030
+ // A finished turn's hook notice (+before+: the next turn's prompt, or null
1031
+ // for the last turn): in the turn view a row of its step, above the row of
1032
+ // the call it came before, as live; else (the chat view, a plain answer, a
1033
+ // step the history doesn't have) a bubble before the turn's answer, or
1034
+ // before its timing line when it has none.
1035
+ function placeTurnNotice(entry, before) {
1036
+ const turnEls = [];
1037
+ for (let el = before ? before.previousElementSibling : historyEl.lastElementChild; el; el = el.previousElementSibling) {
1038
+ turnEls.unshift(el);
1039
+ if (el.matches(".bubble.user")) break;
1040
+ }
1041
+ const place = turnNoticePlace(entry);
1042
+ const block = turnEls.find((el) => el.matches(".turn-work"));
1043
+ const step = place && block ? block.querySelectorAll(":scope > .gen")[place.step] : null;
1044
+ if (!step) {
1045
+ // The chat view's canceled turn has no answer: its text bubbles are
1046
+ // partial narration the notice came after.
1047
+ const canceled = !turnView && turnEls.some((el) => el.matches(".bubble.cancel"));
1048
+ const answer = canceled ? null : turnEls.filter((el) => el.matches(".bubble.output")).pop();
1049
+ const timing = turnEls.find((el) => el.matches(".turn-timing"));
1050
+ showHookNotice(entry, { before: answer || timing || before });
1051
+ return;
1052
+ }
1053
+ const el = document.createElement("div");
1054
+ el.className = "hook-notice" + (entry.level === "warn" ? " warn" : "");
1055
+ el.textContent = `${hookNoticeLabel(entry.hook)}: ${entry.text || ""}`;
1056
+ let body = step.querySelector(":scope > .activity-body");
1057
+ if (!body) {
1058
+ body = document.createElement("div");
1059
+ body.className = "activity-body";
1060
+ step.appendChild(body);
1061
+ }
1062
+ const row = [...body.children].find((r) => r.dataset.key === place.rowKey);
1063
+ body.insertBefore(el, row || null);
1064
+ }
1065
+
1066
+ // A live card's body comes as text; the worker's snapshot (through the
1067
+ // server) has it rendered. One re-read for a burst of cards.
1068
+ let cardBodiesTimer = null;
1069
+ function refreshCardBodies() {
1070
+ clearTimeout(cardBodiesTimer);
1071
+ const id = selected;
1072
+ cardBodiesTimer = setTimeout(async () => {
1073
+ try {
1074
+ const data = await getSession(id, { cards: true });
1075
+ if (id !== selected) return;
1076
+ for (const card of data.cards || []) {
1077
+ const el = card.type === "card" && cardElement(card.id);
1078
+ const body = el && el.querySelector(".card-body");
1079
+ if (body && typeof card.body_html === "string" && el.dataset.body === String(card.body ?? "")) body.innerHTML = card.body_html;
1080
+ }
1081
+ } catch (_) {}
1082
+ }, 150);
1083
+ }
1084
+
1085
+ historyEl.addEventListener("click", (e) => {
1086
+ const button = e.target.closest(".plugin-card .card-action");
1087
+ if (!button || !selected) return;
1088
+ const card = button.closest(".plugin-card");
1089
+ const err = card.querySelector(".card-error");
1090
+ err.classList.add("hidden");
1091
+ button.disabled = true;
1092
+ sendCommand(selected, button.dataset.command, { clientId })
1093
+ .then(() => {
1094
+ // The command woke the worker: follow its stream if none is open.
1095
+ if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
1096
+ })
1097
+ .catch((error) => {
1098
+ err.textContent = error.message;
1099
+ err.classList.remove("hidden");
1100
+ })
1101
+ .finally(() => { button.disabled = false; });
1102
+ });
1103
+
1104
+ function removeRecap() {
1105
+ historyEl.querySelectorAll(".bubble.recap").forEach((el) => el.remove());
1106
+ }
1107
+
1108
+ // The info bar's "copy chi --attach": the full id, which the page shows
1109
+ // only short; "copied ✓" for 2 s.
1110
+ let attachCopiedAt = 0;
1111
+ infoTextEl.addEventListener("click", async (e) => {
1112
+ if (!e.target.closest(".copy-attach") || !selected) return;
1113
+ if (!(await copyText(`chi --attach ${selected}`))) return;
1114
+ attachCopiedAt = Date.now();
1115
+ updateInfoBar();
1116
+ setTimeout(updateInfoBar, 2000);
1117
+ });
1118
+
1119
+ function updateInfoBar() {
1120
+ const stopBtn = document.getElementById("infoStopBtn");
1121
+ const deleteBtn = document.getElementById("infoDeleteBtn");
1122
+ if (deleteBtn) deleteBtn.style.display = selected ? "" : "none";
1123
+ if (!selected) {
1124
+ // The start page hides the info bar (the storage path is on #/sessions).
1125
+ infoTextEl.innerHTML = "";
1126
+ infoTextEl.title = "";
1127
+ if (stopBtn) stopBtn.style.display = "none";
1128
+ return;
1129
+ }
1130
+ if (stopBtn) stopBtn.style.display = canStopSession({ owner: currentOwner, streaming: !!streamControl }) ? "" : "none";
1131
+ const idShort = selected.slice(0,8);
1132
+ const memText = currentUsedMemories.length ? currentUsedMemories.join(", ") : "—";
1133
+ const ctxText = currentCtxPct !== null ? `ctx ${Math.round(currentCtxPct)}%` : "";
1134
+ const sessionDuration = currentSessionDuration();
1135
+ const timingText = sessionDuration === null ? "" : `session ${formatDuration(sessionDuration)}`;
1136
+ const statusClass = currentStatus ? `status ${escapeHtml(currentStatus)}` : "status";
1137
+ const label = modelLabel(currentModel, ...(currentServed || []));
1138
+ // A delegated session names its parent, as a link to it.
1139
+ const parentChip = delegatedBy(currentParentId, allSessions.find((x) => x.id === currentParentId) || null);
1140
+ const parentHtml = parentChip ? `<a class="model parent" href="${escapeHtml(sessionHash(currentParentId))}" title="${escapeHtml(parentChip.title)}">delegated by ${escapeHtml(String(currentParentId).slice(0, 8))}</a>` : "";
1141
+ const attachCmd = `chi --attach ${selected}`;
1142
+ const copied = Date.now() - attachCopiedAt < 2000;
1143
+ const copyHtml = `<button type="button" class="copy-attach" title="Copy &quot;${escapeHtml(attachCmd)}&quot; to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
1144
+ 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>`;
1145
+ const previewHtml = `<div class="preview-line"><span class="preview">${escapeHtml(currentFirstPreview || "—")}</span> · <span class="mem" title="${escapeHtml(currentUsedMemories.join(", "))}">mem: ${escapeHtml(memText)}</span></div>`;
1146
+ infoTextEl.innerHTML = metaHtml + previewHtml;
1147
+ infoTextEl.title = memoriesTooltip(currentUsedMemories, currentPreloadedMemories, currentMutedMemories);
1148
+ }
1149
+
1150
+ // `memories: a, b · preloaded: c · muted: d`: the used memories always,
1151
+ // the session's --memory and --mute lists only when there are any.
1152
+ function memoriesTooltip(used, preloaded, muted) {
1153
+ const parts = [`memories: ${(used || []).join(", ") || "—"}`];
1154
+ if (preloaded && preloaded.length) parts.push(`preloaded: ${preloaded.join(", ")}`);
1155
+ if (muted && muted.length) parts.push(`muted: ${muted.join(", ")}`);
1156
+ return parts.join(" · ");
1157
+ }
1158
+
1159
+ function currentSessionDuration() {
1160
+ if (turnRunning && currentTiming.startedAt) {
1161
+ return elapsedSince(currentTiming.startedAt);
1162
+ }
1163
+ return currentTiming.sessionDurationMs;
1164
+ }
1165
+
1166
+ function clearTimingInterval() {
1167
+ if (timingInterval !== null) {
1168
+ clearInterval(timingInterval);
1169
+ timingInterval = null;
1170
+ }
1171
+ }
1172
+
1173
+ let liveTurnNumber = null;
1174
+
1175
+ function startLiveTurnTiming(startedAt = new Date().toISOString()) {
1176
+ clearTimingInterval();
1177
+ currentTiming.activeTurn = { started_at: startedAt };
1178
+ // The turn records hold the finished turns, so this one comes next.
1179
+ liveTurnNumber = currentTiming.turnRecords.length + 1;
1180
+ liveTurnTimingEl = document.createElement("div");
1181
+ liveTurnTimingEl.className = "turn-timing live";
1182
+ historyEl.appendChild(liveTurnTimingEl);
1183
+ updateLiveTiming();
1184
+ timingInterval = setInterval(updateLiveTiming, 1000);
1185
+ }
1186
+
1187
+ function appendToHistory(el) {
1188
+ appendAboveLiveTiming(historyEl, el, liveTurnTimingEl);
1189
+ }
1190
+
1191
+ function updateLiveTiming() {
1192
+ const duration = elapsedSince(currentTiming.activeTurn?.started_at);
1193
+ if (liveTurnTimingEl && duration !== null) {
1194
+ liveTurnTimingEl.textContent = turnTimingText(liveTurnNumber, duration, { running: true });
1195
+ }
1196
+ updateInfoBar();
1197
+ }
1198
+
1199
+ function finishLiveTurnTiming(record = null) {
1200
+ const duration = Number.isFinite(Number(record?.duration_ms))
1201
+ ? Number(record.duration_ms)
1202
+ : elapsedSince(currentTiming.activeTurn?.started_at);
1203
+ if (liveTurnTimingEl && duration !== null) {
1204
+ liveTurnTimingEl.classList.remove("live");
1205
+ const at = record ? currentTiming.turnRecords.indexOf(record) : -1;
1206
+ liveTurnTimingEl.textContent = turnTimingText(at >= 0 ? at + 1 : liveTurnNumber, duration);
1207
+ }
1208
+ currentTiming.activeTurn = null;
1209
+ clearTimingInterval();
1210
+ updateInfoBar();
1211
+ }
1212
+
1213
+ function applyTiming(data) {
1214
+ currentTiming = normalizeTiming(data);
1215
+ if (turnRunning && currentTiming.activeTurn?.started_at) {
1216
+ startLiveTurnTiming(currentTiming.activeTurn.started_at);
1217
+ }
1218
+ }
1219
+
1220
+ function updateFirstPeek() {
1221
+ if (currentCtxPct !== null && currentCtxPct > 20 && currentFirstPreview) {
1222
+ firstPeekEl.textContent = currentFirstPreview;
1223
+ firstPeekEl.classList.add("visible");
1224
+ } else {
1225
+ firstPeekEl.textContent = "";
1226
+ firstPeekEl.classList.remove("visible");
1227
+ }
1228
+ }
1229
+
1230
+ function addUsedMemory(name) {
1231
+ const v = String(name||"").trim();
1232
+ if (!v || currentUsedMemories.includes(v)) return;
1233
+ currentUsedMemories.push(v);
1234
+ updateInfoBar();
1235
+ const sess = allSessions.find(s=>s.id===selected);
1236
+ if (sess) {
1237
+ sess.used_memory_names = sess.used_memory_names || [];
1238
+ if (!sess.used_memory_names.includes(v)) sess.used_memory_names.push(v);
1239
+ render();
1240
+ document.querySelectorAll(".card").forEach(el=>el.classList.toggle("active", el.dataset.id===selected));
1241
+ }
1242
+ }
1243
+
1244
+ function startStream(id, fromSeq) {
1245
+ if (streamControl) {
1246
+ streamControl.close();
1247
+ streamControl = null;
1248
+ }
1249
+ streamControl = openStream(id, fromSeq, streamHandlers, {
1250
+ // The stream dropped or never opened: the worker left (idle exit, stop,
1251
+ // crash) or is starting. Its cursor means nothing to the next worker
1252
+ // (event_seq starts over), so re-read the session once one is live.
1253
+ onStreamClosed: () => workerGone(id),
1254
+ onStreamError: () => workerGone(id, { opened: false }),
1255
+ });
1256
+ updateInfoBar();
1257
+ }
1258
+
1259
+ // The session this tab is stopping: its stream may close before the stop
1260
+ // request returns.
1261
+ let stoppingId = null;
1262
+
1263
+ // +opened+ false: the stream never delivered a frame (the proxy answered
1264
+ // 503, or the bridge refused it), so a retry waits a moment rather than
1265
+ // spinning through resync → stream → error.
1266
+ const STREAM_RETRY_MS = 3000;
1267
+ function workerGone(id, { opened = true } = {}) {
1268
+ streamControl = null;
1269
+ if (id !== selected) return;
1270
+ // A turn that was running is over: its worker can't finish it.
1271
+ const stopped = stoppingId === id;
1272
+ const endText = workerGoneText({ turnRunning, stopped });
1273
+ if (endText) {
1274
+ endTurnView("gone", endText);
1275
+ if (!stopped) setLiveStatus("idle");
1276
+ }
1277
+ if (currentOwner === "worker") currentOwner = null;
1278
+ // Its card says "live" (the list's owner, or setLiveStatus's) until the
1279
+ // hub's event says otherwise: the worker is gone now, as far as this
1280
+ // tab can tell.
1281
+ const i = allSessions.findIndex((s) => s.id === id);
1282
+ if (i >= 0 && allSessions[i].owner === "worker") {
1283
+ allSessions[i] = withoutWorker(allSessions[i]);
1284
+ render();
1285
+ }
1286
+ updateInfoBar();
1287
+ // Once: a worker still alive with a bridge (the stream dropped, not the
1288
+ // worker: chi web restarted) reconnects right here. Otherwise the next
1289
+ // worker's bridge_up event brings the stream back (followSelectedSession).
1290
+ if (opened) resync({ endText });
1291
+ else setTimeout(() => { if (id === selected && !(streamControl && streamControl.live)) resync({ endText }); }, STREAM_RETRY_MS);
1292
+ }
1293
+
1294
+ // The stream's event handlers; renderSessionView replays a snapshot through
1295
+ // them too.
1296
+ const streamHandlers = {
1297
+ generation_chunk: (data) => {
1298
+ // Phase 2: route the chunk's bytes to the text bubble and the thinking
1299
+ // block (see chunk_router.js). Raw `content` is only used as a fallback
1300
+ // for pre-Phase-2 / non-enriched servers.
1301
+ const { text, thinking } = routeChunk(data);
1302
+ turnDom.chunk({ text, thinking, iteration: data.iteration ?? null });
1303
+ const pct = extractCtxPct(data, currentCtxWindow);
1304
+ if (pct !== null && !Number.isNaN(pct)) {
1305
+ currentCtxPct = pct;
1306
+ updateInfoBar();
1307
+ updateFirstPeek();
1308
+ }
1309
+ },
1310
+ generation_started: (data) => {
1311
+ if (data?.context_window_tokens) currentCtxWindow = data.context_window_tokens;
1312
+ turnDom.generationStarted(data || {});
1313
+ },
1314
+ generation_completed: (data) => {
1315
+ if (data?.served_model) {
1316
+ currentServed = [data.served_model, data.requested_model];
1317
+ updateInfoBar();
1318
+ }
1319
+ turnDom.generationCompleted();
1320
+ },
1321
+ // Prompts sent while a turn ran merge into it at an iteration boundary:
1322
+ // input_merged names them (their bubbles turn "steered"), then
1323
+ // pending_input_merged carries the merged text, shown only for prompts
1324
+ // this tab has no bubble for.
1325
+ input_merged: (data) => applyPromptOps(data),
1326
+ pending_input_merged: (data) => applyPromptOps(data),
1327
+ turn_enqueued: (data) => applyPromptOps(data),
1328
+ turn_started: (data) => keepFollowing(historyEl, () => {
1329
+ markRecapStale();
1330
+ applyPromptOps(data);
1331
+ setTurnRunning(true);
1332
+ setLiveStatus("running");
1333
+ clearTimingInterval();
1334
+ liveTurnTimingEl = null;
1335
+ turnDom.turnStarted(data);
1336
+ startLiveTurnTiming(data.started_at);
1337
+ }),
1338
+ turn_completed: async (data) => {
1339
+ endTurnView("completed");
1340
+ const out = turnOutput(data);
1341
+ if (out) appendChunk(out);
1342
+ if (selected) {
1343
+ try {
1344
+ const d = await getSession(selected, { tail: true });
1345
+ if (d.session) {
1346
+ if (Array.isArray(d.session.used_memory_names)) currentUsedMemories = d.session.used_memory_names;
1347
+ if (d.session.status) currentStatus = d.session.status;
1348
+ applyTiming(d.timing);
1349
+ finishLiveTurnTiming(currentTiming.turnRecords[currentTiming.turnRecords.length - 1]);
1350
+ setMarkdownWarning(d.markdown_warning);
1351
+ if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
1352
+ updateInfoBar();
1353
+ const sess = allSessions.find(s=>s.id===selected);
1354
+ if (sess) {
1355
+ sess.used_memory_names = currentUsedMemories.slice();
1356
+ sess.status = currentStatus;
1357
+ }
1358
+ render();
1359
+ document.querySelectorAll(".card").forEach(el=>el.classList.toggle("active", el.dataset.id===selected));
1360
+ }
1361
+ } catch(_){}
1362
+ }
1363
+ // The answer bubble the turn view held at its box's height pops now
1364
+ // that the rendered markdown is in it (or the re-read failed: then the
1365
+ // plain text pops). The chat view has no hold.
1366
+ turnDom.answerReady?.();
1367
+ },
1368
+ turn_canceled: (data) => {
1369
+ endTurnView("canceled", cancelLineText(data.cancellation_reason || ""));
1370
+ setLiveStatus("idle");
1371
+ refreshTimingFromSession();
1372
+ },
1373
+ // The turn raised (e.g. the model server stayed unreachable). The worker
1374
+ // stays up: it rolls the turn back and hands the prompt back
1375
+ // (prompt_restored), and the prompts queued behind it run as usual.
1376
+ turn_failed: (data) => {
1377
+ lastFailedText = failedTurnText(data);
1378
+ endTurnView("failed", lastFailedText);
1379
+ setLiveStatus("idle");
1380
+ },
1381
+ // The rolled-back turn left the session: render it from the server again,
1382
+ // then show the prompt once more, marked failed (with why). This tab's own
1383
+ // prompt goes back into an empty composer.
1384
+ prompt_restored: async (data) => {
1385
+ const { refill, label, images: restoredImages } = restoreAction(data, { myId: clientId, sentIds, composerEmpty: !promptEl.value.trim() && !chips.length });
1386
+ if (refill) {
1387
+ promptEl.value = refill;
1388
+ fitPrompt();
1389
+ }
1390
+ if (restoredImages?.length) setChips(restoredChips(selected, restoredImages));
1391
+ const failedText = lastFailedText;
1392
+ lastFailedText = null;
1393
+ await resync();
1394
+ // Not an echo waiting for its turn_enqueued: it keeps its enqueued_id, so
1395
+ // a resend's echo isn't matched to it.
1396
+ makeUserBubble(data.prompt || "", { state: "failed", label, enqueuedId: data.origin?.enqueued_id || `failed:${Date.now()}`, images: data.images });
1397
+ if (failedText) addEndBubble(failedText);
1398
+ },
1399
+ // A session command ran in the worker (any client's). One that changed
1400
+ // the conversation (!rollback, !cmd, a continue answered no) re-reads it.
1401
+ command_ran: async (data) => {
1402
+ const view = commandView(data, clientId);
1403
+ if (view.modelName && view.modelName !== currentModel) {
1404
+ currentModel = view.modelName;
1405
+ currentServed = null;
1406
+ updateInfoBar();
1407
+ }
1408
+ if (view.resync) await resync();
1409
+ const queued = view.anytime && commandBubble(view.commandId);
1410
+ if (queued) drawCommandBubble(queued, view);
1411
+ else addCommandBubble(view);
1412
+ },
1413
+ // An anytime command (/help, a plugin's /btw) runs at once, beside a
1414
+ // turn: its bubble goes here, so the cards it shows come after it.
1415
+ command_queued: (data) => {
1416
+ if (data.anytime && !commandBubble(data.command_id)) addCommandBubble({ ...commandView(data, clientId), text: "" });
1417
+ },
1418
+ // Due reminders went into the turn (a reminder turn has no prompt bubble).
1419
+ reminder_injected: (data) => addCommandBubble({ label: null, line: reminderText(data), text: "" }),
1420
+ // A context note joined the conversation (between turns; no turn runs).
1421
+ context_added: (data) => addNoteBubble({ label: data.label, content: data.text || "" }),
1422
+ continue_offered: (data) => renderContinueCard(data),
1423
+ continue_resolved: (data) => resolveContinueCard(data),
1424
+ tool_call_started: (data) => {
1425
+ turnDom.toolStarted(data);
1426
+ const call = data.call || {};
1427
+ let name = null;
1428
+ if (call.name === "memory_read" && call.content) {
1429
+ name = String(call.content).split(",")[0].trim();
1430
+ if (name) name = name.replace(/\.md$/,"").split("/").pop();
1431
+ } else if (call.name === "read" && call.content && /memories\/.+\.md/.test(call.content)) {
1432
+ name = String(call.content).split("/").pop().replace(/\.md$/,"");
1433
+ }
1434
+ if (name) addUsedMemory(name);
1435
+ },
1436
+ tool_call_completed: (data) => turnDom.toolCompleted(data),
1437
+ used_memories_updated: (data) => {
1438
+ if (Array.isArray(data.used_memory_names)) {
1439
+ currentUsedMemories = data.used_memory_names.slice();
1440
+ updateInfoBar();
1441
+ }
1442
+ },
1443
+ context_status: (data) => {
1444
+ const pct = extractCtxPct(data, currentCtxWindow);
1445
+ if (pct !== null) {
1446
+ currentCtxPct = pct;
1447
+ updateInfoBar();
1448
+ updateFirstPeek();
1449
+ }
1450
+ },
1451
+ question_requested: (data) => {
1452
+ const pq = data.pending_question || data.pendingQuestion;
1453
+ if (pq && pq.id) renderQuestionCard(pq);
1454
+ },
1455
+ question_answered: (data) => {
1456
+ resolveQuestionCard(data.id, { answer: data.answer });
1457
+ },
1458
+ question_cancelled: (data) => {
1459
+ resolveQuestionCard(data.id, { cancelled: true, reason: data.reason });
1460
+ },
1461
+ // One collected just as a turn started describes the chat before it.
1462
+ recap_ready: (data) => { if (!turnRunning) showRecap(data.recap); },
1463
+ guardrail_warning: (data) => showGuardrailWarning(data.message, data.label || "guardrails"),
1464
+ // In the turn view, a notice during a turn is a row of its current step.
1465
+ hook_notice: (data) => { if (!turnDom.notice(data)) showHookNotice(data); },
1466
+ plugin_init_started: (data) => onInitEvent("plugin_init_started", data),
1467
+ plugin_init_finished: (data) => onInitEvent("plugin_init_finished", data),
1468
+ // A plugin's card, or a new version of one (the same id).
1469
+ card: (data) => {
1470
+ upsertCard(data);
1471
+ if (data.body) refreshCardBodies();
1472
+ },
1473
+ // The stream's cursor could not be replayed: render from scratch.
1474
+ reset: () => resync(),
1475
+ // Synthetic (snapshotEvents): a prompt merged into the turn in progress.
1476
+ merged_input: (data) => {
1477
+ const origin = (data.origins || [])[0] || {};
1478
+ const own = isOwn(origin.client_id, clientId);
1479
+ makeUserBubble(data.content, {
1480
+ state: "steered", own, enqueuedId: origin.enqueued_id || null, label: own ? null : clientLabel(origin.client_id),
1481
+ });
1482
+ },
1483
+ };
1484
+
1485
+ async function refreshTimingFromSession() {
1486
+ if (!selected) return;
1487
+ try {
1488
+ const data = await getSession(selected, { tail: true });
1489
+ if (data.session?.status) currentStatus = data.session.status;
1490
+ applyTiming(data.timing);
1491
+ finishLiveTurnTiming(currentTiming.turnRecords[currentTiming.turnRecords.length - 1]);
1492
+ } catch (_) {}
1493
+ }
1494
+
1495
+
1496
+ // ── ask_user_question card ─────────────────────────────────────────────────
1497
+ // Inline card in the message history: rendered on question_requested (or from
1498
+ // pending_question on session select for reload recovery), resolved/disabled
1499
+ // on question_answered / question_cancelled so SSE replay and answers from
1500
+ // another client converge to the same state.
1501
+
1502
+ function renderQuestionCard(pq) {
1503
+ const action = nextCardAction({ cardId: questionCardEl?.dataset.qid ?? null, pendingId: pendingQuestion?.id ?? null }, pq);
1504
+ if (action === "same") return;
1505
+ // A resolved card stays where it was; only a pending one is replaced.
1506
+ if (action === "keep") questionCardEl = null;
1507
+ else removeQuestionCard();
1508
+ pendingQuestion = pq;
1509
+ removeHintIfEmpty();
1510
+
1511
+ const approval = approvalView(pq);
1512
+ const card = document.createElement("details");
1513
+ card.className = approval ? "bubble question approval" : "bubble question";
1514
+ card.dataset.qid = pq.id;
1515
+ // Open while pending: the question must be actionable.
1516
+ card.open = true;
1517
+
1518
+ // The summary is the card's one line: the question (or its header) while
1519
+ // pending, the result once resolved. It replaces the old .question-header.
1520
+ const summary = document.createElement("summary");
1521
+ summary.textContent = summaryText(pq);
1522
+ card.appendChild(summary);
1523
+ // A hand toggle: the resolve's auto-collapse then leaves it as the user
1524
+ // left it (rememberToggle-style, as turn_view.js does).
1525
+ summary.addEventListener("click", () => { card.dataset.toggled = "1"; });
1526
+
1527
+ if (approval) {
1528
+ card.appendChild(renderApprovalDetails(approval));
1529
+ } else {
1530
+ const q = document.createElement("div");
1531
+ q.className = "question-text";
1532
+ q.textContent = pq.question || "";
1533
+ card.appendChild(q);
1534
+ }
1535
+
1536
+ const options = Array.isArray(pq.options) ? pq.options : [];
1537
+ if (options.length) {
1538
+ const list = document.createElement("div");
1539
+ list.className = "question-options";
1540
+ const inputType = pq.multi_select ? "checkbox" : "radio";
1541
+ options.forEach((label, i) => {
1542
+ const row = document.createElement("label");
1543
+ row.className = "question-option";
1544
+ const input = document.createElement("input");
1545
+ input.type = inputType;
1546
+ input.name = `q-${pq.id}`;
1547
+ input.value = String(label);
1548
+ input.dataset.index = String(i);
1549
+ const text = document.createElement("span");
1550
+ text.textContent = String(label);
1551
+ row.appendChild(input);
1552
+ row.appendChild(text);
1553
+ list.appendChild(row);
1554
+ });
1555
+ card.appendChild(list);
1556
+ }
1557
+
1558
+ let freeformInput = null;
1559
+ if (pq.allow_freeform) {
1560
+ freeformInput = document.createElement("input");
1561
+ freeformInput.type = "text";
1562
+ freeformInput.className = "question-freeform";
1563
+ freeformInput.placeholder = approval ? "Reason for the model (with Deny, or alone to deny)…" : "Other…";
1564
+ card.appendChild(freeformInput);
1565
+ }
1566
+
1567
+ const err = document.createElement("div");
1568
+ err.className = "question-error hidden";
1569
+ card.appendChild(err);
1570
+
1571
+ const submit = document.createElement("button");
1572
+ submit.className = "question-submit";
1573
+ submit.textContent = "Submit";
1574
+ submit.addEventListener("click", () => {
1575
+ const chosen = Array.from(card.querySelectorAll(".question-option input:checked")).map((i) => i.value);
1576
+ const freeform = freeformInput ? freeformInput.value.trim() : "";
1577
+ if (!chosen.length && !freeform) {
1578
+ err.textContent = "Pick an option or enter a response.";
1579
+ err.classList.remove("hidden");
1580
+ return;
1581
+ }
1582
+ err.classList.add("hidden");
1583
+ submit.disabled = true;
1584
+ card.classList.add("submitting");
1585
+ sendAnswer(selected, {
1586
+ id: pq.id,
1587
+ selected: chosen,
1588
+ ...(freeform ? { freeform } : {}),
1589
+ }).catch((e) => {
1590
+ submit.disabled = false;
1591
+ card.classList.remove("submitting");
1592
+ err.textContent = /503|not_live/.test(e.message)
1593
+ ? "Session is not running — restart it to answer."
1594
+ : e.message;
1595
+ err.classList.remove("hidden");
1596
+ });
1597
+ });
1598
+ card.appendChild(submit);
1599
+
1600
+ // Leaves the question unanswered, like an empty answer in the terminal.
1601
+ // The card closes on the question_cancelled every client gets.
1602
+ // For an approval, dismissing denies the call.
1603
+ const dismiss = document.createElement("button");
1604
+ dismiss.className = "question-dismiss ghost";
1605
+ dismiss.textContent = approval ? "Deny" : "Dismiss";
1606
+ dismiss.addEventListener("click", () => {
1607
+ err.classList.add("hidden");
1608
+ submit.disabled = true;
1609
+ dismiss.disabled = true;
1610
+ dismissQuestion(selected, pq.id).catch((e) => {
1611
+ submit.disabled = false;
1612
+ dismiss.disabled = false;
1613
+ err.textContent = /503|not_live/.test(e.message)
1614
+ ? "Session is not running — restart it to answer."
1615
+ : e.message;
1616
+ err.classList.remove("hidden");
1617
+ });
1618
+ });
1619
+ card.appendChild(dismiss);
1620
+
1621
+ appendToHistory(card);
1622
+ questionCardEl = card;
1623
+ if (isNearBottom()) historyEl.scrollTop = historyEl.scrollHeight;
1624
+ }
1625
+
1626
+ // The tool, the command (or paths) as code, where and why.
1627
+ function renderApprovalDetails(view) {
1628
+ const box = document.createElement("div");
1629
+ box.className = "approval-details";
1630
+ const tool = document.createElement("div");
1631
+ tool.className = "approval-tool";
1632
+ tool.textContent = view.tool;
1633
+ box.appendChild(tool);
1634
+ const what = document.createElement("pre");
1635
+ what.className = view.isCommand ? "approval-what command" : "approval-what paths";
1636
+ const code = document.createElement("code");
1637
+ code.textContent = view.what;
1638
+ what.appendChild(code);
1639
+ box.appendChild(what);
1640
+ for (const [cls, label, text] of [["approval-where", "in", view.where], ["approval-why", "why", view.why]]) {
1641
+ if (!text) continue;
1642
+ const row = document.createElement("div");
1643
+ row.className = cls;
1644
+ const b = document.createElement("span");
1645
+ b.className = "approval-label";
1646
+ b.textContent = `${label} `;
1647
+ row.appendChild(b);
1648
+ row.appendChild(document.createTextNode(text));
1649
+ box.appendChild(row);
1650
+ }
1651
+ return box;
1652
+ }
1653
+
1654
+ function resolveQuestionCard(id, { answer = null, cancelled = false, reason = "" } = {}) {
1655
+ if (!questionCardEl) return;
1656
+ if (id && pendingQuestion && String(pendingQuestion.id) !== String(id)) return;
1657
+ // A double-delivered question_answered / question_cancelled (SSE replay
1658
+ // after a reconnect) is a no-op: the card is already resolved.
1659
+ if (questionCardEl.classList.contains("answered") || questionCardEl.classList.contains("cancelled")
1660
+ || questionCardEl.querySelector(".question-result")) return;
1661
+
1662
+ const result = resultText(pendingQuestion, { answer, cancelled, reason });
1663
+ const note = document.createElement("div");
1664
+ note.className = "question-result";
1665
+ note.textContent = result;
1666
+ if (cancelled) {
1667
+ questionCardEl.classList.add("cancelled");
1668
+ } else {
1669
+ questionCardEl.classList.add("answered");
1670
+ if (approvalAllowed(pendingQuestion, answer) === false) questionCardEl.classList.add("denied");
1671
+ markQuestionSelection(answer && Array.isArray(answer.selected) ? answer.selected : []);
1672
+ }
1673
+ questionCardEl.appendChild(note);
1674
+ questionCardEl.classList.remove("submitting");
1675
+ disableQuestionInputs();
1676
+ // The summary becomes the result; the card collapses to that one line
1677
+ // unless the user opened or closed it by hand.
1678
+ const summary = questionCardEl.querySelector("summary");
1679
+ if (summary) summary.textContent = summaryText(pendingQuestion, { answer, cancelled, reason });
1680
+ if (!questionCardEl.dataset.toggled) questionCardEl.open = false;
1681
+ pendingQuestion = null;
1682
+ }
1683
+
1684
+ function markQuestionSelection(sel) {
1685
+ if (!questionCardEl || !sel.length) return;
1686
+ questionCardEl.querySelectorAll(".question-option input").forEach((input) => {
1687
+ if (sel.includes(input.value)) {
1688
+ input.checked = true;
1689
+ input.closest(".question-option").classList.add("selected");
1690
+ }
1691
+ });
1692
+ }
1693
+
1694
+ function disableQuestionInputs() {
1695
+ if (!questionCardEl) return;
1696
+ questionCardEl.querySelectorAll("input, button").forEach((el) => {
1697
+ el.disabled = true;
1698
+ });
1699
+ }
1700
+
1701
+ function removeQuestionCard() {
1702
+ if (questionCardEl) {
1703
+ questionCardEl.remove();
1704
+ questionCardEl = null;
1705
+ }
1706
+ pendingQuestion = null;
1707
+ }
1708
+
1709
+ function removeHintIfEmpty() {
1710
+ const hint = historyEl.querySelector(".hint");
1711
+ if (!hint) return;
1712
+ // Remove the placeholder when the card will be the first content in history.
1713
+ if (!historyEl.querySelector(".bubble")) hint.remove();
1714
+ }
1715
+ function closeStream() {
1716
+ turnDom.reset();
1717
+ if (streamControl) {
1718
+ streamControl.close();
1719
+ streamControl = null;
1720
+ }
1721
+ }
1722
+
1723
+ // A session this tab just created: onto the list now, as the events would
1724
+ // put it (they follow, with its owner and bridge_up).
1725
+ function addCreatedSession(created) {
1726
+ const { bridge_port: _port, ...session } = created;
1727
+ allSessions = applySessionEvent(allSessions, "session", { session });
1728
+ render();
1729
+ }
1730
+
1731
+ async function handleCreate() {
1732
+ // A first message with images: an idle session, then the turn into it.
1733
+ if (chips.length) return handleSendTurn();
1734
+ const prompt = promptEl.value.trim();
1735
+ if (!prompt) return;
1736
+ actionBtn.disabled = true;
1737
+ try {
1738
+ const s = await createSession(prompt, { dir: scope.dir, model: chosenModel() });
1739
+ addCreatedSession(s);
1740
+ select(s.id);
1741
+ promptEl.value = "";
1742
+ fitPrompt();
1743
+ } catch (e) {
1744
+ alert(e.message);
1745
+ } finally {
1746
+ actionBtn.disabled = false;
1747
+ }
1748
+ }
1749
+
1750
+ async function handleSendTurn() {
1751
+ if (!selected && !chips.length) return handleCreate();
1752
+ if (currentOwner === "tui") return;
1753
+ const prompt = turnText(promptEl.value, chips);
1754
+ if (!prompt) return;
1755
+ if (!chips.length && isCommandLine(prompt)) return handleSendCommand(prompt);
1756
+ if (!selected) {
1757
+ actionBtn.disabled = true;
1758
+ try {
1759
+ const s = await createIdleSession({ dir: scope.dir, model: chosenModel() });
1760
+ addCreatedSession(s);
1761
+ await select(s.id);
1762
+ } catch (e) {
1763
+ alert(e.message);
1764
+ return;
1765
+ } finally {
1766
+ actionBtn.disabled = false;
1767
+ }
1768
+ }
1769
+ const hint = historyEl.querySelector(".hint");
1770
+ if (hint) hint.remove();
1771
+ const sending = chips.slice();
1772
+ const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending });
1773
+ historyEl.scrollTop = historyEl.scrollHeight;
1774
+ if (!currentFirstPreview || currentFirstPreview==="—") {
1775
+ currentFirstPreview = normalize(prompt).slice(0,80) + (normalize(prompt).length>80?"…":"");
1776
+ updateInfoBar();
1777
+ updateFirstPeek();
1778
+ }
1779
+ actionBtn.disabled = true;
1780
+ try {
1781
+ for (const chip of sending) {
1782
+ if (!chip.ref) chip.ref = await uploadImage(selected, chip.file, chip.name);
1783
+ }
1784
+ const ack = await sendTurn(selected, prompt, { clientId, images: turnRefs(sending) });
1785
+ promptEl.value = "";
1786
+ fitPrompt();
1787
+ clearChips();
1788
+ // Its turn_enqueued usually tagged the echo already; the ack covers a
1789
+ // worker that wasn't live (no turn_enqueued then).
1790
+ if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
1791
+ if (ack?.enqueued_id && !echo.dataset.enqueuedId && !bubbleById(ack.enqueued_id)) {
1792
+ echo.dataset.enqueuedId = ack.enqueued_id;
1793
+ }
1794
+ // A live stream carries this turn already; restarting it would drop the
1795
+ // events in between. Without one, POST /turn just woke a worker: its
1796
+ // events start after the seq the session was rendered at (0 for a new
1797
+ // worker process).
1798
+ if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
1799
+ } catch (e) {
1800
+ echo.remove();
1801
+ // The chips stay (uploaded ones keep their ref) for another try.
1802
+ if (/images_unsupported|bad_image|too_large|restart it/.test(e.message)) {
1803
+ alert(e.message);
1804
+ return;
1805
+ }
1806
+ if (/owned_by_tui|409/.test(e.message)) {
1807
+ currentOwner = "tui";
1808
+ updateComposerMode();
1809
+ return;
1810
+ }
1811
+ alert(e.message);
1812
+ } finally {
1813
+ actionBtn.disabled = currentOwner === "tui";
1814
+ promptEl.focus();
1815
+ }
1816
+ }
1817
+
1818
+ // /model, !ls, /continue no … run in the session's worker; every client
1819
+ // shows the command_ran, this one too.
1820
+ async function handleSendCommand(line) {
1821
+ actionBtn.disabled = true;
1822
+ try {
1823
+ await sendCommand(selected, line, { clientId });
1824
+ promptEl.value = "";
1825
+ fitPrompt();
1826
+ // The command woke the worker: follow its stream if none is open.
1827
+ if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
1828
+ } catch (e) {
1829
+ if (/owned_by_tui|409/.test(e.message)) {
1830
+ currentOwner = "tui";
1831
+ updateComposerMode();
1832
+ return;
1833
+ }
1834
+ addCommandBubble({ label: null, line, text: e.message, failed: true });
1835
+ } finally {
1836
+ actionBtn.disabled = currentOwner === "tui";
1837
+ promptEl.focus();
1838
+ }
1839
+ }
1840
+
1841
+ function addCommandBubble(view) {
1842
+ removeHintIfEmpty();
1843
+ const div = document.createElement("div");
1844
+ drawCommandBubble(div, view);
1845
+ const follow = isNearBottom();
1846
+ appendToHistory(div);
1847
+ if (follow) historyEl.scrollTop = historyEl.scrollHeight;
1848
+ }
1849
+
1850
+ // A command bubble's line and output (again, when its command_ran fills in
1851
+ // an anytime command's bubble in place).
1852
+ function drawCommandBubble(div, { label, line, text, busy = false, failed = false, commandId = null }) {
1853
+ div.className = "bubble command" + (busy ? " busy" : "") + (failed ? " failed" : "");
1854
+ if (commandId) div.dataset.commandId = commandId;
1855
+ div.replaceChildren();
1856
+ const head = document.createElement("div");
1857
+ head.className = "command-line";
1858
+ head.textContent = label ? `${label}> ${line}` : line;
1859
+ div.appendChild(head);
1860
+ if (text) {
1861
+ const body = document.createElement("pre");
1862
+ body.className = "command-output";
1863
+ body.textContent = text;
1864
+ div.appendChild(body);
1865
+ }
1866
+ }
1867
+
1868
+ function commandBubble(commandId) {
1869
+ if (!commandId) return null;
1870
+ return [...historyEl.querySelectorAll(".bubble.command")].find((el) => el.dataset.commandId === String(commandId)) || null;
1871
+ }
1872
+
1873
+ function addNoteBubble(note) {
1874
+ removeHintIfEmpty();
1875
+ const div = document.createElement("div");
1876
+ div.className = "bubble note";
1877
+ div.innerHTML = noteHtml(note);
1878
+ const follow = isNearBottom();
1879
+ appendToHistory(div);
1880
+ if (follow) historyEl.scrollTop = historyEl.scrollHeight;
1881
+ }
1882
+
1883
+ // ── continue card ──────────────────────────────────────────────────────────
1884
+ // A turn ran out of iterations: continue it, drop it, or drop it saying why
1885
+ // (the terminal's continue prompt). Answers go as /continue commands; the
1886
+ // card closes on the continue_resolved every client gets.
1887
+
1888
+ const CONTINUE_DECISIONS = {
1889
+ resume: "Continued",
1890
+ abort: "Not continued",
1891
+ abort_with_reason: "Not continued (reason noted)",
1892
+ dropped: "Dropped: a new prompt came",
1893
+ };
1894
+
1895
+ function renderContinueCard(offer) {
1896
+ if (continueCardEl?.isConnected) return;
1897
+ removeHintIfEmpty();
1898
+ const card = document.createElement("div");
1899
+ card.className = "bubble continue";
1900
+ const text = document.createElement("div");
1901
+ text.className = "continue-text";
1902
+ text.textContent = "The turn ran out of iterations. Continue it?";
1903
+ card.appendChild(text);
1904
+ const task = offer?.context?.original_prompt;
1905
+ if (task) {
1906
+ const ctx = document.createElement("div");
1907
+ ctx.className = "continue-context";
1908
+ ctx.textContent = task;
1909
+ card.appendChild(ctx);
1910
+ }
1911
+ const err = document.createElement("div");
1912
+ err.className = "question-error hidden";
1913
+ const answer = (line) => {
1914
+ err.classList.add("hidden");
1915
+ card.querySelectorAll("button, input").forEach((el) => { el.disabled = true; });
1916
+ sendCommand(selected, line, { clientId }).catch((e) => {
1917
+ card.querySelectorAll("button, input").forEach((el) => { el.disabled = false; });
1918
+ err.textContent = e.message;
1919
+ err.classList.remove("hidden");
1920
+ });
1921
+ };
1922
+ const yes = document.createElement("button");
1923
+ yes.className = "continue-yes";
1924
+ yes.textContent = "Yes";
1925
+ yes.addEventListener("click", () => answer(continueLine("yes")));
1926
+ const no = document.createElement("button");
1927
+ no.className = "continue-no ghost";
1928
+ no.textContent = "No";
1929
+ no.addEventListener("click", () => answer(continueLine("no")));
1930
+ const reason = document.createElement("input");
1931
+ reason.type = "text";
1932
+ reason.className = "continue-reason";
1933
+ reason.placeholder = "Why not? (the model reads it)";
1934
+ const noBecause = document.createElement("button");
1935
+ noBecause.className = "continue-no-reason ghost";
1936
+ noBecause.textContent = "No, because…";
1937
+ noBecause.addEventListener("click", () => {
1938
+ if (!reason.value.trim()) return reason.focus();
1939
+ answer(continueLine("no", reason.value));
1940
+ });
1941
+ const row = document.createElement("div");
1942
+ row.className = "continue-actions";
1943
+ [yes, no, reason, noBecause].forEach((el) => row.appendChild(el));
1944
+ card.appendChild(row);
1945
+ card.appendChild(err);
1946
+ const follow = isNearBottom();
1947
+ appendToHistory(card);
1948
+ continueCardEl = card;
1949
+ if (follow) historyEl.scrollTop = historyEl.scrollHeight;
1950
+ }
1951
+
1952
+ function resolveContinueCard(data) {
1953
+ const card = continueCardEl;
1954
+ continueCardEl = null;
1955
+ if (!card) return;
1956
+ card.classList.add("resolved");
1957
+ card.querySelectorAll("button, input").forEach((el) => { el.disabled = true; });
1958
+ const note = document.createElement("div");
1959
+ note.className = "question-result";
1960
+ const who = isOwn(data.client_id, clientId) ? "" : ` (${clientLabel(data.client_id) || "another client"})`;
1961
+ note.textContent = (CONTINUE_DECISIONS[data.decision] || data.decision || "Answered") + who;
1962
+ card.appendChild(note);
1963
+ }
1964
+
1965
+ async function handleCancel() {
1966
+ if (!selected) return;
1967
+ cancelBtn.disabled = true;
1968
+ try {
1969
+ await cancelTurn(selected);
1970
+ } catch (e) {
1971
+ // The turn ended meanwhile: nothing left to cancel.
1972
+ if (!/not_running|409/.test(e.message)) alert(e.message);
1973
+ } finally {
1974
+ cancelBtn.disabled = false;
1975
+ }
1976
+ }
1977
+
1978
+ // The chi logo: back to the empty start, where the composer makes a new
1979
+ // session (from all sessions too).
1980
+ // The typed draft stays in the composer: paste in a session, then + new chat
1981
+ // starts from it (with the model picker). clearSelection leaves #prompt alone.
1982
+ $("#newBtn").addEventListener("click", () => {
1983
+ if (allViewOpen()) leaveAllView("");
1984
+ clearSelection();
1985
+ loadModels();
1986
+ });
1987
+
1988
+ // ── The start page's model picker ──────────────────────────────────────────
1989
+ // The hosts' models from GET /api/models, the choice this browser made last
1990
+ // preselected while it is still offered (else the server's default). Hidden
1991
+ // until the list is in, and in a session (its model is in the info bar). A
1992
+ // host that failed is the picker's tooltip; a route that fails leaves the
1993
+ // picker hidden and the server's default in charge.
1994
+ async function loadModels() {
1995
+ let payload;
1996
+ try {
1997
+ payload = await listModels();
1998
+ } catch (_) {
1999
+ return;
2000
+ }
2001
+ const names = modelNames(payload);
2002
+ if (!names.length) return;
2003
+ let stored = null;
2004
+ try { stored = localStorage.getItem(MODEL_KEY); } catch (_) {}
2005
+ const chosen = pickModel(names, payload.default, stored || modelSelectEl.value);
2006
+ modelSelectEl.innerHTML = names
2007
+ .map((n) => `<option value="${escapeHtml(n)}"${n === chosen ? " selected" : ""}>${escapeHtml(n)}</option>`)
2008
+ .join("");
2009
+ modelPickEl.title = payload.warning ? `The model the new chat starts on · ${payload.warning}` : "The model the new chat starts on";
2010
+ modelPickEl.dataset.value = chosen; // sizes the select to the chosen name (CSS)
2011
+ modelPickEl.hidden = false;
2012
+ }
2013
+ modelSelectEl.addEventListener("change", () => {
2014
+ modelPickEl.dataset.value = modelSelectEl.value;
2015
+ try { localStorage.setItem(MODEL_KEY, modelSelectEl.value); } catch (_) {}
2016
+ });
2017
+ // The picker's choice for a create request; nothing (the server's default)
2018
+ // while the list is not in.
2019
+ function chosenModel() {
2020
+ return modelPickEl.hidden ? undefined : modelSelectEl.value || undefined;
2021
+ }
2022
+ actionBtn.addEventListener("click", () => {
2023
+ if (!selected) handleCreate();
2024
+ else handleSendTurn();
2025
+ });
2026
+
2027
+ // ── Images: paste or drop into the composer, chips until sent ─────────────
2028
+ const chipsEl = $("#chips");
2029
+ const lightboxEl = $("#lightbox");
2030
+ let chips = [];
2031
+
2032
+ function renderChips() {
2033
+ chipsEl.innerHTML = chipsHtml(chips);
2034
+ chipsEl.hidden = !chips.length;
2035
+ }
2036
+
2037
+ function setChips(list) {
2038
+ for (const c of chips) if (c.file && c.src) URL.revokeObjectURL(c.src);
2039
+ chips = list;
2040
+ renderChips();
2041
+ }
2042
+
2043
+ function clearChips() {
2044
+ setChips([]);
2045
+ }
2046
+
2047
+ function addImageFiles(files) {
2048
+ if (!files.length) return false;
2049
+ for (const file of files) chips.push({ file, name: chipName(file, chips.length), src: URL.createObjectURL(file) });
2050
+ renderChips();
2051
+ promptEl.focus();
2052
+ return true;
2053
+ }
2054
+
2055
+ chipsEl.addEventListener("click", (e) => {
2056
+ const btn = e.target.closest(".chip-remove");
2057
+ if (!btn) return;
2058
+ const [chip] = chips.splice(Number(btn.dataset.index), 1);
2059
+ if (chip?.file && chip.src) URL.revokeObjectURL(chip.src);
2060
+ renderChips();
2061
+ });
2062
+ // ── Composer height: grows with the text; the top-edge grip sets a floor ───
2063
+ const GRIP_KEY = "chi_composer_floor";
2064
+ let promptFloor = null;
2065
+ try { promptFloor = Number(localStorage.getItem(GRIP_KEY)) || null; } catch (_) {}
2066
+
2067
+ function fitPrompt() {
2068
+ const borders = promptEl.offsetHeight - promptEl.clientHeight;
2069
+ promptEl.style.height = "0px";
2070
+ const { height, scrolls } = promptHeight({ contentPx: promptEl.scrollHeight + borders, floorPx: promptFloor, viewportPx: window.innerHeight });
2071
+ promptEl.style.height = `${height}px`;
2072
+ promptEl.style.overflowY = scrolls ? "auto" : "hidden";
2073
+ }
2074
+
2075
+ const composeGrip = $("#composeGrip");
2076
+ composeGrip.addEventListener("pointerdown", (e) => {
2077
+ e.preventDefault();
2078
+ composeGrip.setPointerCapture(e.pointerId);
2079
+ composeGrip.classList.add("dragging");
2080
+ const startY = e.clientY;
2081
+ const startH = promptEl.offsetHeight;
2082
+ const move = (ev) => {
2083
+ promptFloor = gripFloor(startH + (startY - ev.clientY), window.innerHeight);
2084
+ fitPrompt();
2085
+ };
2086
+ const up = () => {
2087
+ composeGrip.classList.remove("dragging");
2088
+ composeGrip.removeEventListener("pointermove", move);
2089
+ composeGrip.removeEventListener("pointerup", up);
2090
+ composeGrip.removeEventListener("pointercancel", up);
2091
+ try { localStorage.setItem(GRIP_KEY, String(promptFloor || "")); } catch (_) {}
2092
+ };
2093
+ composeGrip.addEventListener("pointermove", move);
2094
+ composeGrip.addEventListener("pointerup", up);
2095
+ composeGrip.addEventListener("pointercancel", up);
2096
+ });
2097
+ composeGrip.addEventListener("dblclick", () => {
2098
+ promptFloor = null;
2099
+ try { localStorage.removeItem(GRIP_KEY); } catch (_) {}
2100
+ fitPrompt();
2101
+ });
2102
+ promptEl.addEventListener("input", fitPrompt);
2103
+ window.addEventListener("resize", fitPrompt);
2104
+ fitPrompt();
2105
+
2106
+ // The card floats over the history: keep the history's bottom padding at the
2107
+ // card's height, and stay pinned to the end if the reader was there, since the
2108
+ // card grows (text, chips, Cancel, a wrapping footer) under a reader at the end.
2109
+ const dockEl = $("#dock");
2110
+ const centerEl = $("#center");
2111
+ new ResizeObserver(() => {
2112
+ const follow = isNearBottom();
2113
+ centerEl.style.setProperty("--dock-h", `${dockEl.offsetHeight}px`);
2114
+ if (follow) historyEl.scrollTop = historyEl.scrollHeight;
2115
+ }).observe(dockEl);
2116
+
2117
+ promptEl.addEventListener("paste", (e) => {
2118
+ const files = imageFiles(e.clipboardData);
2119
+ // Text pasted along with an image still lands in the textarea.
2120
+ if (addImageFiles(files) && !e.clipboardData.getData("text/plain")) e.preventDefault();
2121
+ });
2122
+ composerEl.addEventListener("dragover", (e) => {
2123
+ if (![...(e.dataTransfer?.types || [])].includes("Files")) return;
2124
+ e.preventDefault();
2125
+ composerEl.classList.add("dropping");
2126
+ });
2127
+ composerEl.addEventListener("dragleave", () => composerEl.classList.remove("dropping"));
2128
+ composerEl.addEventListener("drop", (e) => {
2129
+ composerEl.classList.remove("dropping");
2130
+ const files = imageFiles(e.dataTransfer);
2131
+ if (!files.length) return;
2132
+ e.preventDefault();
2133
+ addImageFiles(files);
2134
+ });
2135
+ historyEl.addEventListener("click", (e) => {
2136
+ const img = e.target.closest("img.thumb");
2137
+ if (!img) return;
2138
+ lightboxEl.querySelector("img").src = img.src;
2139
+ lightboxEl.hidden = false;
2140
+ });
2141
+ lightboxEl.addEventListener("click", () => { lightboxEl.hidden = true; });
2142
+ document.addEventListener("keydown", (e) => {
2143
+ if (e.key === "Escape" && !lightboxEl.hidden) lightboxEl.hidden = true;
2144
+ });
2145
+ cancelBtn.addEventListener("click", () => handleCancel());
2146
+ // ── / autocomplete ─────────────────────────────────────────────────────────
2147
+ // While the composer holds one "/word", a small glass list above it offers
2148
+ // the session's commands (its worker's, plugins' too): arrows move, ⏎/Tab
2149
+ // pick, Esc closes, a tap picks. A closed list takes no keys.
2150
+
2151
+ let currentCommands = [];
2152
+ let commandShown = [];
2153
+ let commandIndex = 0;
2154
+ const commandListEl = $("#commandList");
2155
+
2156
+ function commandListOpen() {
2157
+ return !commandListEl.hidden && commandShown.length > 0;
2158
+ }
2159
+
2160
+ function updateCommandList() {
2161
+ const shown = selected && currentOwner !== "tui" ? commandMatches(currentCommands, promptEl.value) : [];
2162
+ if (!shown.length) return closeCommandList();
2163
+ const keep = commandShown[commandIndex]?.name;
2164
+ commandShown = shown;
2165
+ commandIndex = Math.max(0, shown.findIndex((c) => c.name === keep));
2166
+ renderCommandList();
2167
+ }
2168
+
2169
+ function renderCommandList() {
2170
+ commandListEl.innerHTML = commandShown.map((c, i) => {
2171
+ const row = commandRow(c);
2172
+ const note = row.note ? `<span class="command-note">${escapeHtml(row.note)}</span>` : "";
2173
+ return `<div class="command-option${i === commandIndex ? " active" : ""}" role="option" id="command-option-${i}" ` +
2174
+ `aria-selected="${i === commandIndex}" data-index="${i}"><span class="command-name">${escapeHtml(row.name)}</span>` +
2175
+ `<span class="command-desc">${escapeHtml(row.description)}</span>${note}</div>`;
2176
+ }).join("");
2177
+ commandListEl.hidden = false;
2178
+ promptEl.setAttribute("aria-expanded", "true");
2179
+ promptEl.setAttribute("aria-activedescendant", `command-option-${commandIndex}`);
2180
+ commandListEl.querySelector(".command-option.active")?.scrollIntoView({ block: "nearest" });
2181
+ }
2182
+
2183
+ function closeCommandList() {
2184
+ commandShown = [];
2185
+ commandIndex = 0;
2186
+ commandListEl.hidden = true;
2187
+ commandListEl.innerHTML = "";
2188
+ promptEl.setAttribute("aria-expanded", "false");
2189
+ promptEl.removeAttribute("aria-activedescendant");
2190
+ }
2191
+
2192
+ // @return whether the pick took the key (false: ⏎ on a full name sends it)
2193
+ function acceptCommand(index, { enter = false } = {}) {
2194
+ const command = commandShown[index];
2195
+ if (!command) return false;
2196
+ const pick = pickCommand(command.name, promptEl.value, { enter });
2197
+ closeCommandList();
2198
+ if (pick.send) return false;
2199
+ promptEl.value = pick.text;
2200
+ promptEl.setSelectionRange(pick.text.length, pick.text.length);
2201
+ fitPrompt();
2202
+ promptEl.focus();
2203
+ return true;
2204
+ }
2205
+
2206
+ // @return whether the open list took the key
2207
+ function handleCommandListKey(e) {
2208
+ if (!commandListOpen() || e.isComposing) return false;
2209
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
2210
+ commandIndex = moveSelection(commandIndex, commandShown.length, e.key === "ArrowDown" ? 1 : -1);
2211
+ renderCommandList();
2212
+ } else if (e.key === "Tab" && !e.shiftKey) {
2213
+ acceptCommand(commandIndex);
2214
+ } else if (e.key === "Enter" && !e.shiftKey) {
2215
+ if (!acceptCommand(commandIndex, { enter: true })) return false;
2216
+ } else if (e.key === "Escape") {
2217
+ closeCommandList();
2218
+ e.stopPropagation();
2219
+ } else {
2220
+ return false;
2221
+ }
2222
+ e.preventDefault();
2223
+ return true;
2224
+ }
2225
+
2226
+ promptEl.addEventListener("input", updateCommandList);
2227
+ promptEl.addEventListener("blur", closeCommandList);
2228
+ // Keep the focus in the composer: the pick happens on click.
2229
+ commandListEl.addEventListener("pointerdown", (e) => e.preventDefault());
2230
+ commandListEl.addEventListener("click", (e) => {
2231
+ const option = e.target.closest(".command-option");
2232
+ if (option) acceptCommand(Number(option.dataset.index));
2233
+ });
2234
+
2235
+ promptEl.addEventListener("keydown", (e) => {
2236
+ if (handleCommandListKey(e)) return;
2237
+ if (e.key === "Enter" && !e.shiftKey) {
2238
+ e.preventDefault();
2239
+ if (!selected) handleCreate();
2240
+ else handleSendTurn();
2241
+ }
2242
+ });
2243
+ $("#infoStopBtn").addEventListener("click", async () => {
2244
+ if (!selected) return;
2245
+ if (!confirm(`Stop session ${selected}?`)) return;
2246
+ const id = selected;
2247
+ stoppingId = id;
2248
+ try {
2249
+ await stopSession(id);
2250
+ if (id === selected) {
2251
+ // The worker is gone: don't wait for its stream to notice.
2252
+ streamControl?.close();
2253
+ currentStatus = "stopped";
2254
+ workerGone(id);
2255
+ }
2256
+ } finally {
2257
+ stoppingId = null;
2258
+ }
2259
+ // The list hears of the stop from the hub (the stop route rescans it).
2260
+ if (!eventsControl) refresh();
2261
+ });
2262
+ $("#infoDeleteBtn").addEventListener("click", () => {
2263
+ if (selected) confirmDelete(selected);
2264
+ });
2265
+
2266
+ // ── Annotate ───────────────────────────────────────────────────────────────
2267
+ // Selecting text in an answer, thinking, a tool row or an own message shows
2268
+ // "Annotate"; it appends the quote (with where it came from) to the
2269
+ // composer. The button lives outside #history, which is re-rendered whole.
2270
+ // The selection is read when it changes: a live bubble rewrites its text
2271
+ // every frame, which would collapse it by the time the button is clicked.
2272
+
2273
+ const annotateBtn = document.createElement("button");
2274
+ annotateBtn.id = "annotateBtn";
2275
+ annotateBtn.type = "button";
2276
+ annotateBtn.textContent = "Annotate";
2277
+ annotateBtn.hidden = true;
2278
+ document.body.appendChild(annotateBtn);
2279
+ let pendingQuote = null;
2280
+
2281
+ function hideAnnotate() {
2282
+ pendingQuote = null;
2283
+ annotateBtn.hidden = true;
2284
+ }
2285
+
2286
+ function updateAnnotate() {
2287
+ const sel = window.getSelection();
2288
+ if (!selected || currentOwner === "tui" || !sel || sel.isCollapsed || !sel.rangeCount) return hideAnnotate();
2289
+ const range = sel.getRangeAt(0);
2290
+ if (!historyEl.contains(range.startContainer)) return hideAnnotate();
2291
+ const source = annotationSource(range.startContainer);
2292
+ // A running turn's narration box shows complete sentences and its thinking
2293
+ // is appended by delta; a selection in either is refused while the step is
2294
+ // live — the quote lands once it closes (then on the full text).
2295
+ if (!source || (turnRunning && turnDom.containsLive(source.root))) return hideAnnotate();
2296
+ // Clip to where the selection starts: one tool row, one bubble.
2297
+ const clipped = range.cloneRange();
2298
+ if (!source.root.contains(range.endContainer)) clipped.setEnd(source.root, source.root.childNodes.length);
2299
+ const block = quoteBlock(clipped.toString(), source);
2300
+ if (!block) return hideAnnotate();
2301
+ pendingQuote = block;
2302
+ const rects = clipped.getClientRects();
2303
+ const rect = rects.length ? rects[rects.length - 1] : clipped.getBoundingClientRect();
2304
+ annotateBtn.hidden = false;
2305
+ const top = Math.min(rect.bottom + 6, window.innerHeight - annotateBtn.offsetHeight - 8);
2306
+ const left = Math.min(Math.max(rect.right - annotateBtn.offsetWidth, 8), window.innerWidth - annotateBtn.offsetWidth - 8);
2307
+ annotateBtn.style.top = `${Math.max(top, 8)}px`;
2308
+ annotateBtn.style.left = `${left}px`;
2309
+ // Not over a copy button (this bubble's, one inside it, the next
2310
+ // bubble's).
2311
+ const box = annotateBtn.getBoundingClientRect();
2312
+ const bubble = source.root.closest(".bubble");
2313
+ const near = [bubble, bubble?.nextElementSibling].filter(Boolean);
2314
+ for (const btn of near.flatMap((el) => [...el.querySelectorAll(".copy-btn")])) {
2315
+ const r = btn.getBoundingClientRect();
2316
+ if (r.bottom < box.top || r.top > box.bottom || r.right < box.left || r.left > box.right) continue;
2317
+ annotateBtn.style.top = `${r.bottom + 4}px`;
2318
+ break;
2319
+ }
2320
+ }
2321
+
2322
+ let annotateRaf = false;
2323
+ function scheduleAnnotate() {
2324
+ if (annotateRaf) return;
2325
+ annotateRaf = true;
2326
+ requestAnimationFrame(() => { annotateRaf = false; updateAnnotate(); });
2327
+ }
2328
+ document.addEventListener("selectionchange", scheduleAnnotate);
2329
+ historyEl.addEventListener("scroll", scheduleAnnotate);
2330
+ window.addEventListener("resize", scheduleAnnotate);
2331
+ installCopy(historyEl);
2332
+ // Keep the selection when the button is pressed.
2333
+ annotateBtn.addEventListener("mousedown", (e) => e.preventDefault());
2334
+ annotateBtn.addEventListener("click", () => {
2335
+ if (!pendingQuote) return;
2336
+ promptEl.value = appendQuote(promptEl.value, pendingQuote);
2337
+ fitPrompt();
2338
+ window.getSelection()?.removeAllRanges();
2339
+ hideAnnotate();
2340
+ promptEl.focus();
2341
+ promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
2342
+ promptEl.scrollTop = promptEl.scrollHeight;
2343
+ });
2344
+
2345
+ // The latest-sessions strip can be hidden for more chat room; the choice
2346
+ // stays per browser (it replaced zen mode, so a zen user starts hidden).
2347
+ function stripHiddenSaved() {
2348
+ try {
2349
+ const saved = localStorage.getItem("chi_strip_hidden");
2350
+ return saved === null ? localStorage.getItem("chi_zen") === "1" : saved === "1";
2351
+ } catch (_) {
2352
+ return false;
2353
+ }
2354
+ }
2355
+ function setStripHidden(hidden) {
2356
+ try { localStorage.setItem("chi_strip_hidden", hidden ? "1" : "0"); } catch (_) {}
2357
+ document.body.classList.toggle("strip-hidden", hidden);
2358
+ }
2359
+ setStripHidden(stripHiddenSaved());
2360
+ $("#stripShow").addEventListener("click", () => setStripHidden(false));
2361
+
2362
+ $("#filter").addEventListener("input", renderAll);
2363
+ // Esc leaves the all-sessions view.
2364
+ document.addEventListener("keydown", (e) => {
2365
+ if (e.key === "Escape" && allViewOpen()) closeAllView();
2366
+ });
2367
+ applyView();
2368
+
2369
+ // Where sessions live: under the all-sessions search (the server fills it in).
2370
+ if (document.body.dataset.sessionsDir) {
2371
+ $("#allPath code").textContent = document.body.dataset.sessionsDir;
2372
+ $("#allPath").hidden = false;
2373
+ }
2374
+
2375
+ renderScope();
2376
+ updateInfoBar();
2377
+ updateComposerMode();
2378
+ loadModels();
2379
+ // The list: the hub's stream when the browser has EventSource, else fetched.
2380
+ if (typeof globalThis.EventSource === "function") openSessionEvents();
2381
+ else refresh();
2382
+ selectFromHash();