samagotchi 0.50.0 → 0.52.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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/README.md +1 -1
  4. data/bin/chi +11 -3
  5. data/docs/architecture.md +1 -1
  6. data/docs/cli.md +40 -11
  7. data/docs/configuration.md +63 -21
  8. data/docs/hooks.md +3 -2
  9. data/docs/internals/engine.md +4 -2
  10. data/docs/memory.md +11 -0
  11. data/docs/plugins.md +2 -1
  12. data/docs/sessions.md +10 -5
  13. data/docs/testing.md +6 -1
  14. data/lib/samagotchi/bootstrap/config_writer.rb +6 -37
  15. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  17. data/lib/samagotchi/bridge.rb +29 -17
  18. data/lib/samagotchi/bridge_client.rb +3 -1
  19. data/lib/samagotchi/broadcast/recipients.rb +1 -2
  20. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  22. data/lib/samagotchi/children_status.rb +1 -1
  23. data/lib/samagotchi/commands/registry.rb +2 -1
  24. data/lib/samagotchi/config.rb +50 -182
  25. data/lib/samagotchi/config_text_edit.rb +8 -32
  26. data/lib/samagotchi/context_sources.rb +0 -7
  27. data/lib/samagotchi/context_window.rb +2 -2
  28. data/lib/samagotchi/continue_offer.rb +5 -4
  29. data/lib/samagotchi/cut_policy.rb +0 -1
  30. data/lib/samagotchi/delivery.rb +56 -0
  31. data/lib/samagotchi/engine.rb +48 -17
  32. data/lib/samagotchi/engine_peers.rb +1 -0
  33. data/lib/samagotchi/host_config.rb +183 -0
  34. data/lib/samagotchi/host_registry.rb +21 -13
  35. data/lib/samagotchi/kernel_loop.rb +28 -2
  36. data/lib/samagotchi/last_turn.rb +48 -0
  37. data/lib/samagotchi/live_versions.rb +0 -6
  38. data/lib/samagotchi/llm/native_tool_normalizer.rb +2 -7
  39. data/lib/samagotchi/llm_context_override.rb +3 -22
  40. data/lib/samagotchi/llm_context_strategy.rb +50 -16
  41. data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
  42. data/lib/samagotchi/model_profile.rb +4 -4
  43. data/lib/samagotchi/model_settings.rb +46 -0
  44. data/lib/samagotchi/plugin/context.rb +3 -3
  45. data/lib/samagotchi/plugin/sessions.rb +2 -2
  46. data/lib/samagotchi/queued_command.rb +35 -0
  47. data/lib/samagotchi/reminder_store.rb +0 -27
  48. data/lib/samagotchi/reply_wait.rb +11 -13
  49. data/lib/samagotchi/sampling_settings.rb +4 -4
  50. data/lib/samagotchi/self_report.rb +8 -5
  51. data/lib/samagotchi/send_command.rb +92 -21
  52. data/lib/samagotchi/session.rb +45 -24
  53. data/lib/samagotchi/session_commands.rb +41 -3
  54. data/lib/samagotchi/session_inbox.rb +36 -7
  55. data/lib/samagotchi/session_manager.rb +21 -10
  56. data/lib/samagotchi/session_metrics.rb +25 -6
  57. data/lib/samagotchi/session_setup.rb +30 -0
  58. data/lib/samagotchi/sessions_command.rb +1 -1
  59. data/lib/samagotchi/steer_cut.rb +21 -10
  60. data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
  61. data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -1
  62. data/lib/samagotchi/terminal_ui/formatting.rb +69 -4
  63. data/lib/samagotchi/terminal_ui.rb +56 -8
  64. data/lib/samagotchi/thinking.rb +67 -7
  65. data/lib/samagotchi/thinking_command.rb +72 -0
  66. data/lib/samagotchi/tool_activity.rb +3 -0
  67. data/lib/samagotchi/tool_runner.rb +2 -0
  68. data/lib/samagotchi/tools/builtin_calls.rb +33 -0
  69. data/lib/samagotchi/tools/builtins.rb +7 -2
  70. data/lib/samagotchi/tools/peers.rb +3 -2
  71. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  72. data/lib/samagotchi/version.rb +1 -1
  73. data/lib/samagotchi/vision_support.rb +4 -3
  74. data/lib/samagotchi/web/app.rb +59 -7
  75. data/lib/samagotchi/web/message_parts.rb +3 -0
  76. data/lib/samagotchi/web/public/activity.js +6 -0
  77. data/lib/samagotchi/web/public/app.js +199 -48
  78. data/lib/samagotchi/web/public/data.js +8 -4
  79. data/lib/samagotchi/web/public/format.js +8 -4
  80. data/lib/samagotchi/web/public/index.html +25 -20
  81. data/lib/samagotchi/web/public/kept_commands.js +52 -0
  82. data/lib/samagotchi/web/public/model_pick.js +3 -1
  83. data/lib/samagotchi/web/public/session_title.js +26 -0
  84. data/lib/samagotchi/web/public/strip.js +4 -6
  85. data/lib/samagotchi/web/public/thinking_chip.js +169 -0
  86. data/lib/samagotchi/web/public/timing.js +15 -0
  87. data/lib/samagotchi/web/public/turn_events.js +26 -2
  88. data/lib/samagotchi/web/public/turn_html.js +3 -1
  89. data/lib/samagotchi/web/public/turn_view.js +328 -310
  90. data/lib/samagotchi/web/public/vendor/lit-html.js +8 -0
  91. data/lib/samagotchi/web/session_summary.rb +1 -1
  92. data/lib/samagotchi/worker.rb +44 -43
  93. data/lib/samagotchi/worker_inbound.rb +10 -7
  94. data/lib/samagotchi/worker_wakes.rb +0 -2
  95. data/lib/samagotchi/yaml_lines.rb +72 -0
  96. data/lib/samagotchi.rb +1 -0
  97. metadata +13 -1
@@ -26,14 +26,18 @@ import {
26
26
  chipHtml as llmContextChipHtml, chipModel as llmContextChipModel, choiceWords, createLlmContextControl, NEW_CHAT,
27
27
  withChoice,
28
28
  } from "./llm_context_chip.js";
29
+ import {
30
+ chipHtml as thinkingChipHtml, chipModel as thinkingChipModel, createThinkingControl,
31
+ withChoice as withThinkingChoice,
32
+ } from "./thinking_chip.js";
29
33
  import { renderStable } from "./stable_html.js";
30
34
  import { createShownTexts } from "./shown_texts.js";
31
35
  import { MODEL_KEY } from "./model_pick.js";
32
36
  import { emptySessionState } from "./session_state.js";
33
37
  import { createModelPicker } from "./model_picker.js";
34
38
  import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
35
- import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary, promptNotesChip } from "./format.js";
36
- import { createTurnView, turnHistoryHtml } from "./turn_view.js";
39
+ import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml, ownerBadge, previewForCard, firstPreview, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary, promptNotesChip } from "./format.js";
40
+ import { createTurnView, turnHistoryNodes } from "./turn_view.js";
37
41
  import { BADGES, emptyAnswerHtml, htmlElement, stateBadgeHtml, userBubbleHtml } from "./turn_html.js";
38
42
  import { createStageView } from "./stage_view.js";
39
43
  import { createFamilyPop, familyChipHtml, familyRowsHtml } from "./families_view.js";
@@ -56,13 +60,15 @@ import { shouldFollowScroll, keepFollowing } from "./scroll.js";
56
60
  import { SHORT_WINDOW_QUERY, stripAutoHidden, stripColumns, stripShowParts } from "./strip.js";
57
61
  import { applySessionEvent, batchCounts, batchTargets, batchToastText, families, familyId, familyOpen, familyWaits, filterFamilies, flattenFamilies, heldOrder, inTree, rangeIds, runBatch, listedSessions, sortedByUpdated, stoppedBadge, waitingBadge, waitingFirst, waitingSearchText } from "./sessions_list.js";
58
62
  import { createNotifications } from "./notifications.js";
63
+ import { tabTitle, topBarTitle } from "./session_title.js";
59
64
  import { restartConfirmText, versionNotice, workerBadge } from "./update_notice.js";
65
+ import { anchorInsertIndex, commandAnchor, isShellCommandLine, keepsCommandBubble } from "./kept_commands.js";
60
66
  import {
61
- appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, STOPPED_LINE_CLASS,
67
+ appendAboveLiveTiming, cancelLineText, formatDuration, mergeTiming, normalizeTiming, sessionDurationNow, STOPPED_LINE_CLASS,
62
68
  } from "./timing.js";
63
69
  import { createLiveTurn } from "./live_turn.js";
64
70
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
65
- import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyRetryLine, historyHasCommandBubble, hookNoticeLabel, initWaitLine, isOwn, llmContextHover, llmContextLine, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
71
+ import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyRetryLine, historyHasCommandBubble, hookNoticeLabel, initWaitLine, isOwn, llmContextHover, llmContextLine, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerChoiceRoute, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
66
72
 
67
73
  const $ = (s) => document.querySelector(s);
68
74
  const topStripEl = $("#topStrip");
@@ -71,7 +77,11 @@ const historyEl = $("#history");
71
77
  const emptyEl = $("#empty");
72
78
  const composerEl = $("#composer");
73
79
  const infoBarEl = $("#infoBar");
74
- const firstPeekEl = $("#firstPeek");
80
+ // The open session's title in the top bar, when no strip card in sight
81
+ // shows it; the tab's base ("Chi · <project>"), set by renderScope.
82
+ const sessionTitleEl = $("#sessionTitle");
83
+ let tabBase = null;
84
+ let tabShown = null;
75
85
  const infoTextEl = $("#infoText");
76
86
  // The open session's attached context, in its own row of the session bar
77
87
  // (updateInfoBar redraws #infoText every tick; the chips keep their popover).
@@ -112,6 +122,43 @@ const llmContextNewControl = createLlmContextControl({
112
122
  id: "llmContextNewPopover",
113
123
  anchor: "#llmContextNew",
114
124
  });
125
+ // The open session's thinking chip, beside the llm ctx one: /thinking runs
126
+ // in the session's worker.
127
+ const thinkingControl = createThinkingControl({
128
+ el: infoTextEl,
129
+ escapeHtml,
130
+ summary: () => (selected ? current.thinking : null),
131
+ session: () => selected,
132
+ running: () => liveTurn.running,
133
+ run: (line) => runSessionCommand(line),
134
+ });
135
+ // The start page's thinking chip: the picked model's level, and a level
136
+ // for the next new chat only (sent with its create, then dropped).
137
+ const thinkingNewEl = $("#thinkingNew");
138
+ let newChatThinking = null;
139
+ const newChatThinkingSummary = () => (modelPickEl.hidden ? null : modelPicker.chosenRow()?.thinking || null);
140
+ const thinkingNewControl = createThinkingControl({
141
+ el: thinkingNewEl,
142
+ escapeHtml,
143
+ summary: newChatThinkingSummary,
144
+ kind: NEW_CHAT,
145
+ choice: () => newChatThinking,
146
+ choose: (next) => {
147
+ newChatThinking = next;
148
+ renderNewChatThinking();
149
+ promptEl.focus();
150
+ },
151
+ id: "thinkingNewPopover",
152
+ anchor: "#thinkingNew",
153
+ });
154
+ function renderNewChatThinking() {
155
+ const chip = thinkingChipModel(withThinkingChoice(newChatThinkingSummary(), newChatThinking), { kind: NEW_CHAT });
156
+ thinkingNewEl.hidden = !chip;
157
+ if (!chip) return thinkingNewControl.close();
158
+ thinkingNewEl.textContent = chip.text;
159
+ thinkingNewEl.title = chip.title;
160
+ thinkingNewEl.classList.toggle("own", chip.own);
161
+ }
115
162
  // Hidden without the model's values (the list not in, an older server).
116
163
  function renderNewChatLlmContext() {
117
164
  const chip = llmContextChipModel(withChoice(newChatLlmSummary(), newChatLlmContext), { kind: NEW_CHAT });
@@ -342,7 +389,9 @@ function renderScope() {
342
389
  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>` : ""}`;
343
390
  }
344
391
  el.hidden = false;
345
- notifications.setBaseTitle(scope.projectName ? `Chi · ${scope.projectName}` : "Chi");
392
+ // The tab: "<session title> · Chi · <project>" (updateSessionTitle).
393
+ tabBase = scope.projectName ? `Chi · ${scope.projectName}` : "Chi";
394
+ updateSessionTitle();
346
395
  // The links follow the hash (an open session, the all-sessions view).
347
396
  el.querySelector(".scope-all:not(.scope-back)")?.addEventListener("click", (e) => {
348
397
  e.currentTarget.href = allScopeHref(location.hash, scope.dir, location.search);
@@ -642,6 +691,8 @@ function paintActive() {
642
691
  document.querySelectorAll(".family-row[data-id]").forEach((el) => {
643
692
  el.classList.toggle("active", !!selected && el.dataset.id === selected);
644
693
  });
694
+ // The open session's card may have come into the strip or left it.
695
+ updateSessionTitle();
645
696
  }
646
697
 
647
698
  // The latest sessions (as many as fit, a family one card), then the way
@@ -693,11 +744,11 @@ function noteChainFamilies(fams) {
693
744
  }
694
745
 
695
746
  // The hidden strip's pill: "▾ 5 sessions · ● 1 live · ● 1 waiting",
696
- // following the list: its cards (a family is one), the live and waiting
697
- // sessions (a waiting delegate too).
747
+ // following the list: its sessions, the number the All sessions tile
748
+ // shows, and the live and waiting ones (a waiting delegate too).
698
749
  function renderStripShow() {
699
750
  const shown = shownSessions();
700
- const { count, live, waiting } = stripShowParts(shown, { cards: foldChains(families(shown), allSessions).length });
751
+ const { count, live, waiting } = stripShowParts(shown);
701
752
  const liveHtml = live ? ` · <span class="live"><span class="live-dot" aria-hidden="true"></span>${escapeHtml(live)}</span>` : "";
702
753
  const waitingHtml = waiting ? ` · <span class="waiting"><span class="waiting-dot" aria-hidden="true"></span>${escapeHtml(waiting)}</span>` : "";
703
754
  $("#stripShow").innerHTML = `<span class="pill-arrow" aria-hidden="true">\u25BE</span> ${escapeHtml(count)}${liveHtml}${waitingHtml}`;
@@ -999,6 +1050,7 @@ function applyView() {
999
1050
  topStripEl.scrollLeft = 0; // a phone's strip scrolls sideways to the tile
1000
1051
  }
1001
1052
  document.body.classList.toggle("all-view", open);
1053
+ updateSessionTitle();
1002
1054
  // Its chip was in the list just hidden (the strip, or all sessions).
1003
1055
  chainPop.close();
1004
1056
  if (open) {
@@ -1037,6 +1089,7 @@ function updateComposerMode() {
1037
1089
  if (document.body.classList.toggle("has-session", !!selected) !== hadSession) {
1038
1090
  hadSession = !hadSession;
1039
1091
  fitPrompt();
1092
+ placeToast();
1040
1093
  }
1041
1094
  if (selected) {
1042
1095
  modelPicker.close();
@@ -1046,6 +1099,11 @@ function updateComposerMode() {
1046
1099
  newChatLlmContext = null;
1047
1100
  renderNewChatLlmContext();
1048
1101
  }
1102
+ thinkingNewControl.close();
1103
+ if (newChatThinking) {
1104
+ newChatThinking = null;
1105
+ renderNewChatThinking();
1106
+ }
1049
1107
  }
1050
1108
  const running = liveTurn.running && !!selected;
1051
1109
  cancelBtn.style.display = running ? "" : "none";
@@ -1148,7 +1206,6 @@ function clearSelection() {
1148
1206
  setCapabilities(null);
1149
1207
  updateComposerMode();
1150
1208
  updateInfoBar();
1151
- updateFirstPeek();
1152
1209
  promptEl.focus();
1153
1210
  }
1154
1211
 
@@ -1182,7 +1239,6 @@ async function select(id) {
1182
1239
  closeStream();
1183
1240
  resetSessionState();
1184
1241
  resetTurnView();
1185
- updateFirstPeek();
1186
1242
  updateInfoBar();
1187
1243
  contextChips.load(id);
1188
1244
  updateComposerMode();
@@ -1224,8 +1280,9 @@ async function resync({ endText = null } = {}) {
1224
1280
  return;
1225
1281
  }
1226
1282
  if (id !== selected) return;
1283
+ const kept = keptCommandBubbles(id);
1227
1284
  resetTurnView();
1228
- renderSessionView(data);
1285
+ renderSessionView(data, { kept });
1229
1286
  // Preview is read-only: a worker is woken only by POST /turn, so attach
1230
1287
  // only when a live bridge exists.
1231
1288
  selectSeq = typeof data.last_event_seq === "number" ? (data.last_event_id || data.last_event_seq) : null;
@@ -1240,10 +1297,45 @@ async function resync({ endText = null } = {}) {
1240
1297
  }
1241
1298
  }
1242
1299
 
1300
+ // The prompt and command bubbles in the history, in order, by kind
1301
+ // (kept_commands.js): a prompt, a `!cmd`'s (saved, so redrawn) or another.
1302
+ function historyAnchors() {
1303
+ const els = [...historyEl.querySelectorAll(".bubble.user, .bubble.command")];
1304
+ const kinds = els.map((el) => {
1305
+ if (el.classList.contains("user")) return "user";
1306
+ const line = el.querySelector(":scope > .command-line")?.textContent;
1307
+ return !el.classList.contains("failed") && isShellCommandLine(line) ? "shell" : "other";
1308
+ });
1309
+ return { els, kinds };
1310
+ }
1311
+
1312
+ // This session's own command bubbles (/help, /model …: drawn by this page,
1313
+ // never saved) with their anchors, before a resync redraws the history.
1314
+ // In-flight ones (busy, queued) are left out: the snapshot draws them again.
1315
+ function keptCommandBubbles(id) {
1316
+ const { els, kinds } = historyAnchors();
1317
+ return els
1318
+ .map((el, i) => ({ el, anchor: commandAnchor(kinds, i) }))
1319
+ .filter(({ el }) => el.dataset.keptFor === id && !el.classList.contains("busy") && !el.classList.contains("queued"))
1320
+ .filter(({ el }) => keepsCommandBubble(el.querySelector(":scope > .command-line")?.textContent));
1321
+ }
1322
+
1323
+ // The kept bubbles back into the redrawn history, each after its turn.
1324
+ function restoreCommandBubbles(kept) {
1325
+ if (!kept.length) return;
1326
+ historyEl.querySelector(".hint")?.remove();
1327
+ kept.forEach(({ el, anchor }) => {
1328
+ const { els, kinds } = historyAnchors();
1329
+ const at = anchorInsertIndex(kinds, anchor);
1330
+ if (at < els.length) els[at].before(el);
1331
+ else historyEl.appendChild(el);
1332
+ });
1333
+ }
1334
+
1243
1335
  // A session view: its messages, then the turn in progress and the queued
1244
1336
  // prompts (a live worker's snapshot) replayed through the stream handlers,
1245
1337
  // then a pending question.
1246
- function renderSessionView(data) {
1338
+ function renderSessionView(data, { kept = [] } = {}) {
1247
1339
  const s = data.session;
1248
1340
  setMarkdownWarning(data.markdown_warning);
1249
1341
  setCapabilities(data.capabilities);
@@ -1270,20 +1362,23 @@ function renderSessionView(data) {
1270
1362
  current.promptNotes = Array.isArray(s.prompt_notes) ? s.prompt_notes : [];
1271
1363
  current.llmContext = s.llm_context || null;
1272
1364
  llmContextControl.refresh();
1365
+ current.thinking = s.thinking || null;
1366
+ thinkingControl.refresh();
1273
1367
  current.parentId = s.parent_id || null;
1274
1368
  current.continues = s.continues || null;
1275
1369
  current.commands = Array.isArray(data.commands) ? data.commands : [];
1276
1370
  if (data.messages && data.messages.length) {
1277
1371
  const first = data.messages.find((m) => m.role === "user");
1278
- if (first && first.content) current.firstPreview = normalize(first.content).slice(0,80) + (normalize(first.content).length>80?"…":"");
1372
+ if (first && first.content) current.firstPreview = firstPreview(first.content);
1279
1373
  renderHistory(data.messages);
1280
1374
  } else {
1281
1375
  renderHistory(data.history || []);
1282
1376
  if (!current.firstPreview && data.history && data.history.length) {
1283
- const txt = normalize(String(data.history[0]||"")).slice(0,80);
1284
- if(txt) current.firstPreview = txt;
1377
+ const txt = firstPreview(data.history[0]);
1378
+ if (txt) current.firstPreview = txt;
1285
1379
  }
1286
1380
  }
1381
+ restoreCommandBubbles(kept);
1287
1382
  // A failed turn's prompt went back to the user: shown after the
1288
1383
  // conversation, marked failed, as it was live (prompt_restored).
1289
1384
  if (data.failed_turn) {
@@ -1334,7 +1429,6 @@ function renderSessionView(data) {
1334
1429
  // A turn ran out of iterations and waits for a yes or no.
1335
1430
  if (data.continue_offer) questionCards.renderContinue(data.continue_offer);
1336
1431
  updateInfoBar();
1337
- updateFirstPeek();
1338
1432
  updateComposerMode();
1339
1433
  }
1340
1434
 
@@ -1344,7 +1438,7 @@ function renderHistory(items) {
1344
1438
  return;
1345
1439
  }
1346
1440
  if (typeof items[0] === "object" && items[0].role) {
1347
- historyEl.innerHTML = turnHistoryHtml(items, current.timing, { thumbs: (images) => thumbsHtml(selected, images) });
1441
+ historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsHtml(selected, images) }));
1348
1442
  } else {
1349
1443
  historyEl.innerHTML = items.map((c) => `<div class="bubble output">${escapeHtml(c)}</div>`).join("");
1350
1444
  }
@@ -1922,11 +2016,11 @@ function updateInfoBar() {
1922
2016
  renderStable(infoTextEl, "");
1923
2017
  infoTextEl.title = "";
1924
2018
  if (stopBtn) stopBtn.style.display = "none";
2019
+ updateSessionTitle();
1925
2020
  return;
1926
2021
  }
1927
2022
  if (stopBtn) stopBtn.style.display = canStopSession({ owner: current.owner, streaming: !!streamControl }) ? "" : "none";
1928
2023
  const idShort = selected.slice(0,8);
1929
- const memText = current.usedMemories.length ? current.usedMemories.join(", ") : "—";
1930
2024
  const ctxText = ctxBarText(current.ctxPct, current.ctxWindowSource);
1931
2025
  const tokens = current.timing.tokens;
1932
2026
  const ctxTip = tokensTipText(tokens, undefined, current.timing.memoryIndex,
@@ -1957,16 +2051,38 @@ function updateInfoBar() {
1957
2051
  const attachCmd = `chi --attach ${selected}`;
1958
2052
  const copied = Date.now() - attachCopiedAt < 2000;
1959
2053
  const copyHtml = `<button type="button" class="copy-attach" title="Copy &quot;${escapeHtml(attachCmd)}&quot; to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
1960
- const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${chainHtml}${parentHtml}${kidsHtml}${llmContextChipHtml(current.llmContext, escapeHtml)}${notesHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model session-time"></span>` : ""}${workerBadgeHtml()}</div>`;
1961
- // The title line follows the card's: a chain link opened with the opener
1962
- // reads its chain's first link's title (cardTitle).
1963
- const shown = sessionsById().get(selected);
1964
- const titleText = shown && isOpener(current.firstPreview) ? cardTitle(shown) : current.firstPreview;
1965
- const previewHtml = `<div class="preview-line"><span class="preview">${escapeHtml(titleText || "—")}</span> · <span class="mem" title="${escapeHtml(current.usedMemories.join(", "))}">mem: ${escapeHtml(memText)}</span></div>`;
2054
+ const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${chainHtml}${parentHtml}${kidsHtml}${llmContextChipHtml(current.llmContext, escapeHtml)}${thinkingChipHtml(current.thinking, escapeHtml)}${notesHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model session-time"></span>` : ""}${workerBadgeHtml()}</div>`;
1966
2055
  // The session clock ticks every second while a turn runs: only its text
1967
2056
  // changes, the bar's other nodes (a tooltip, a button mid-press) stay.
1968
- renderStable(infoTextEl, metaHtml + previewHtml, { ".session-time": timingText });
2057
+ renderStable(infoTextEl, metaHtml, { ".session-time": timingText });
1969
2058
  infoTextEl.title = memoriesTooltip(current.usedMemories, current.preloadedMemories, current.mutedMemories);
2059
+ updateSessionTitle();
2060
+ }
2061
+
2062
+ // The open session's title, once on screen (session_title.js): its strip
2063
+ // card says it when the strip is shown and has one; else the top bar. The
2064
+ // browser tab names it too. It follows the card's: a chain link opened with
2065
+ // the opener reads its chain's first link's title (cardTitle).
2066
+ function updateSessionTitle() {
2067
+ const shown = selected ? sessionsById().get(selected) : null;
2068
+ const first = current.firstPreview || "";
2069
+ const raw = !selected ? "" : (shown && isOpener(first) ? cardTitle(shown) : first);
2070
+ const title = raw === "\u2014" ? "" : raw; // "—": no first message yet
2071
+ const text = topBarTitle({
2072
+ title,
2073
+ cardShown: !!topStripEl.querySelector(".card.active"),
2074
+ stripHidden: document.body.classList.contains("strip-hidden"),
2075
+ allView: allViewOpen(),
2076
+ });
2077
+ if (sessionTitleEl.textContent !== text) sessionTitleEl.textContent = text;
2078
+ if (sessionTitleEl.hidden !== !text) sessionTitleEl.hidden = !text;
2079
+ const tip = text ? title : "";
2080
+ if (sessionTitleEl.title !== tip) sessionTitleEl.title = tip;
2081
+ const tab = tabBase === null ? null : tabTitle({ title, base: tabBase });
2082
+ if (tab !== null && tab !== tabShown) {
2083
+ tabShown = tab;
2084
+ notifications.setBaseTitle(tab);
2085
+ }
1970
2086
  }
1971
2087
 
1972
2088
  // The info bar's ← / → link to a chain's link (chains.js chainNeighbours):
@@ -1990,10 +2106,7 @@ function memoriesTooltip(used, preloaded, muted) {
1990
2106
  }
1991
2107
 
1992
2108
  function currentSessionDuration() {
1993
- if (liveTurn.running && current.timing.startedAt) {
1994
- return elapsedSince(current.timing.startedAt);
1995
- }
1996
- return current.timing.sessionDurationMs;
2109
+ return sessionDurationNow(current.timing, liveTurn.running);
1997
2110
  }
1998
2111
 
1999
2112
  // The stage takes a running turn's rows (its extras); else the history.
@@ -2024,16 +2137,6 @@ async function mergeTurnTiming(id, data) {
2024
2137
  return true;
2025
2138
  }
2026
2139
 
2027
- function updateFirstPeek() {
2028
- if (current.ctxPct !== null && current.ctxPct > 20 && current.firstPreview) {
2029
- firstPeekEl.textContent = current.firstPreview;
2030
- firstPeekEl.classList.add("visible");
2031
- } else {
2032
- firstPeekEl.textContent = "";
2033
- firstPeekEl.classList.remove("visible");
2034
- }
2035
- }
2036
-
2037
2140
  function setUsedMemories(names) {
2038
2141
  current.usedMemories = names.slice();
2039
2142
  updateInfoBar();
@@ -2119,7 +2222,6 @@ const streamHandlers = {
2119
2222
  if (pct !== null && !Number.isNaN(pct)) {
2120
2223
  current.ctxPct = pct;
2121
2224
  updateInfoBar();
2122
- updateFirstPeek();
2123
2225
  }
2124
2226
  },
2125
2227
  generation_started: (data) => {
@@ -2262,6 +2364,12 @@ const streamHandlers = {
2262
2364
  updateInfoBar();
2263
2365
  llmContextControl.refresh();
2264
2366
  }
2367
+ // /thinking (or /model) changed the level the next turn runs at.
2368
+ if (data.thinking) {
2369
+ current.thinking = data.thinking;
2370
+ updateInfoBar();
2371
+ thinkingControl.refresh();
2372
+ }
2265
2373
  if (view.resync) await resync();
2266
2374
  // A card's action that isn't anytime (the step-limit question's
2267
2375
  // /continue) has no bubble: nothing to remove. An anytime or queued
@@ -2308,7 +2416,6 @@ const streamHandlers = {
2308
2416
  if (pct !== null) {
2309
2417
  current.ctxPct = pct;
2310
2418
  updateInfoBar();
2311
- updateFirstPeek();
2312
2419
  }
2313
2420
  },
2314
2421
  question_requested: (data) => {
@@ -2415,10 +2522,27 @@ function handleCreate() {
2415
2522
 
2416
2523
  async function handleSendTurn() {
2417
2524
  if (current.owner === "tui") return;
2418
- const prompt = turnText(promptEl.value, chips);
2525
+ let prompt = turnText(promptEl.value, chips);
2419
2526
  if (!prompt) return;
2420
2527
  // An image-only send's text is its placeholders: not for ↑.
2421
2528
  const typed = promptEl.value.trim() !== "";
2529
+ // /cut TEXT and /queue TEXT (the terminals' words for a delivery): the
2530
+ // text as a cut, or the page's own answer (/queue, the bare word).
2531
+ let delivery = null;
2532
+ const choice = steerChoiceRoute(prompt, { running: liveTurn.running && !!selected });
2533
+ if (choice?.reply) {
2534
+ if (selected) addCommandBubble({ label: null, line: prompt, text: choice.reply });
2535
+ else showNotice(choice.reply);
2536
+ promptEl.value = "";
2537
+ fitPrompt();
2538
+ return;
2539
+ }
2540
+ // The composer holds the line as typed until the ack (composerAfterAck).
2541
+ const line = prompt;
2542
+ if (choice) {
2543
+ prompt = choice.send;
2544
+ delivery = choice.delivery || null;
2545
+ }
2422
2546
  if (!selected) {
2423
2547
  // The start page's own reply (a web-local command) or the hint for a
2424
2548
  // typo: neither needs a session, so none is made for it (2.29).
@@ -2432,10 +2556,13 @@ async function handleSendTurn() {
2432
2556
  actionBtn.disabled = true;
2433
2557
  try {
2434
2558
  const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: prompt,
2435
- llm_context: choiceWords(newChatLlmContext) });
2559
+ llm_context: choiceWords(newChatLlmContext),
2560
+ thinking: newChatThinking || undefined });
2436
2561
  // The choice was for this chat only.
2437
2562
  newChatLlmContext = null;
2438
2563
  renderNewChatLlmContext();
2564
+ newChatThinking = null;
2565
+ renderNewChatThinking();
2439
2566
  addCreatedSession(s);
2440
2567
  await select(s.id);
2441
2568
  } catch (e) {
@@ -2467,16 +2594,15 @@ async function handleSendTurn() {
2467
2594
  const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending });
2468
2595
  if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
2469
2596
  if (!current.firstPreview || current.firstPreview==="—") {
2470
- current.firstPreview = normalize(prompt).slice(0,80) + (normalize(prompt).length>80?"…":"");
2597
+ current.firstPreview = firstPreview(prompt);
2471
2598
  updateInfoBar();
2472
- updateFirstPeek();
2473
2599
  }
2474
2600
  actionBtn.disabled = true;
2475
2601
  try {
2476
2602
  for (const chip of sending) {
2477
2603
  if (!chip.ref) chip.ref = await uploadImage(selected, chip.file, chip.name);
2478
2604
  }
2479
- const ack = await sendTurn(selected, prompt, { clientId, images: turnRefs(sending), history: typed });
2605
+ const ack = await sendTurn(selected, prompt, { clientId, images: turnRefs(sending), history: typed, delivery });
2480
2606
  if (ack?.command_id) {
2481
2607
  // The worker knew it as a command (its list was newer than ours):
2482
2608
  // its command_ran shows it, not a prompt bubble.
@@ -2492,7 +2618,7 @@ async function handleSendTurn() {
2492
2618
  // (and images) go back into the composer, without replacing text typed
2493
2619
  // since (4.13). The composer still holds the text just sent: it goes.
2494
2620
  const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
2495
- promptEl.value = composerAfterAck({ sent: prompt, current: promptEl.value, refill: restored?.refill || "" });
2621
+ promptEl.value = composerAfterAck({ sent: line, current: promptEl.value, refill: restored?.refill || "" });
2496
2622
  fitPrompt();
2497
2623
  clearChips();
2498
2624
  if (restored?.images?.length) setChips(restoredChips(selected, restored.images));
@@ -2572,6 +2698,8 @@ async function runSessionCommand(line) {
2572
2698
  function addCommandBubble(view) {
2573
2699
  removeHintIfEmpty();
2574
2700
  const div = document.createElement("div");
2701
+ // Page-only: kept across a resync of this session (keptCommandBubbles).
2702
+ if (selected) div.dataset.keptFor = selected;
2575
2703
  drawCommandBubble(div, view);
2576
2704
  const follow = isNearBottom();
2577
2705
  appendToHistory(div);
@@ -2650,6 +2778,7 @@ const modelPicker = createModelPicker({
2650
2778
  center: $("#center"),
2651
2779
  onPick: () => {
2652
2780
  renderNewChatLlmContext();
2781
+ renderNewChatThinking();
2653
2782
  promptEl.focus();
2654
2783
  },
2655
2784
  });
@@ -2666,6 +2795,7 @@ async function loadModels() {
2666
2795
  modelPickEl.title = payload.warning ? `The model the new chat starts on · ${payload.warning}` : "The model the new chat starts on";
2667
2796
  modelPickEl.hidden = false;
2668
2797
  renderNewChatLlmContext();
2798
+ renderNewChatThinking();
2669
2799
  }
2670
2800
  // The picker's choice for a create request; nothing (the server's default)
2671
2801
  // while the list is not in.
@@ -2771,7 +2901,9 @@ new ResizeObserver(() => {
2771
2901
  const follow = atHistoryEnd();
2772
2902
  centerEl.style.setProperty("--dock-h", `${dockEl.offsetHeight}px`);
2773
2903
  if (follow) historyEl.scrollTop = historyEl.scrollHeight;
2904
+ placeToast();
2774
2905
  }).observe(dockEl);
2906
+ window.addEventListener("resize", () => placeToast());
2775
2907
 
2776
2908
  promptEl.addEventListener("paste", (e) => {
2777
2909
  const files = imageFiles(e.clipboardData);
@@ -2981,6 +3113,23 @@ async function setArchived(id, archive, { undo = true } = {}) {
2981
3113
  showToast(`${what} ${short}`, undo ? { label: "Undo", run: () => setArchived(id, !archive, { undo: false }) } : null);
2982
3114
  }
2983
3115
 
3116
+ // The toast sits above the session's composer card (measured: it grows with
3117
+ // a wrapping footer or a taller box), so it never covers the card's
3118
+ // controls; with no card in sight (the start page, all sessions) the CSS's
3119
+ // 24 px from the bottom.
3120
+ function placeToast() {
3121
+ const gap = 12;
3122
+ const dock = document.getElementById("dock");
3123
+ const r = dock.getBoundingClientRect();
3124
+ const over = document.body.classList.contains("has-session") && r.height > 0;
3125
+ const bottom = over ? `${Math.round(window.innerHeight - r.top + gap)}px` : "";
3126
+ const root = document.documentElement.style;
3127
+ if (root.getPropertyValue("--toast-bottom") !== bottom) {
3128
+ if (bottom) root.setProperty("--toast-bottom", bottom);
3129
+ else root.removeProperty("--toast-bottom");
3130
+ }
3131
+ }
3132
+
2984
3133
  // sticky: stays until its action or its ✕ (else gone after 6 s).
2985
3134
  let toastTimer = null;
2986
3135
  // html: +text+ is markup this page built (its parts escaped), not words.
@@ -2988,6 +3137,7 @@ function showToast(text, action = null, { sticky = false, html = false } = {}) {
2988
3137
  const el = $("#toast");
2989
3138
  el.innerHTML = `<span>${html ? text : escapeHtml(text)}</span>${action ? `<button type="button" class="toast-action">${escapeHtml(action.label)}</button>` : ""}` +
2990
3139
  (sticky ? `<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss">✕</button>` : "");
3140
+ placeToast();
2991
3141
  el.hidden = false;
2992
3142
  el.querySelector(".toast-action")?.addEventListener("click", () => {
2993
3143
  el.hidden = true;
@@ -3047,6 +3197,7 @@ function applyStripHidden() {
3047
3197
  document.body.classList.toggle("strip-hidden", stripAutoHidden({
3048
3198
  saved: stripHiddenSaved(), short: shortWindow.matches, shownWhileShort: stripShownWhileShort,
3049
3199
  }));
3200
+ updateSessionTitle();
3050
3201
  }
3051
3202
  function setStripHidden(hidden) {
3052
3203
  if (hidden) familyPop.close();
@@ -35,10 +35,11 @@ export function listSessions(sort, order, { dir, ...opts } = {}) {
35
35
  // it); either left out means the server's default. preview: that message,
36
36
  // which names the session until its turn is saved. llm_context: the
37
37
  // session's own LLM context words ({strategy, apply, budget}), when the
38
- // start page's chip set some.
39
- export function createIdleSession({ dir, model, preview, llm_context: llmContext, ...opts } = {}) {
38
+ // start page's chip set some; thinking: its own level ("low"), when the
39
+ // start page's think chip set one.
40
+ export function createIdleSession({ dir, model, preview, llm_context: llmContext, thinking, ...opts } = {}) {
40
41
  const body = { idle: true, ...(dir ? { dir } : {}), ...(model ? { model } : {}), ...(preview ? { preview } : {}),
41
- ...(llmContext ? { llm_context: llmContext } : {}) };
42
+ ...(llmContext ? { llm_context: llmContext } : {}), ...(thinking ? { thinking } : {}) };
42
43
  return api("/api/sessions", { method: "POST", body: JSON.stringify(body), ...opts });
43
44
  }
44
45
 
@@ -90,9 +91,12 @@ export function getSession(id, { tail, recent, turnId, timing, parts, cards, ...
90
91
  // opts.images: refs ({file, name}) to images uploaded into the session.
91
92
  // history: false keeps the prompt out of the shared prompt history (an
92
93
  // image-only send, whose text is only its [image: …] placeholders).
93
- export function sendTurn(id, prompt, { clientId, images, history = true, ...opts } = {}) {
94
+ // delivery: what the message does to a running turn ("cut": /cut TEXT);
95
+ // none = the server's default (the next step).
96
+ export function sendTurn(id, prompt, { clientId, images, history = true, delivery = null, ...opts } = {}) {
94
97
  const body = clientId ? { prompt, client_id: clientId } : { prompt };
95
98
  if (images?.length) body.images = images;
99
+ if (delivery) body.delivery = delivery;
96
100
  if (history === false) body.history = false;
97
101
  return api(`/api/sessions/${id}/turn`, {
98
102
  method: "POST",
@@ -94,13 +94,17 @@ export function previewOf(lastPrompt) {
94
94
  return normalized;
95
95
  }
96
96
 
97
+ // A session's first message as the info bar's title: whitespace collapsed,
98
+ // cut at FIRST_PREVIEW_CHARS with an ellipsis ("" for none).
99
+ export function firstPreview(text) {
100
+ const n = normalize(text);
101
+ return n.length > FIRST_PREVIEW_CHARS ? `${n.slice(0, FIRST_PREVIEW_CHARS)}\u2026` : n;
102
+ }
103
+
97
104
  export function previewForCard(session) {
98
105
  // Unified card preview: first message (80) else previewOf(last_prompt)
99
106
  const raw = session.first_preview || session.last_prompt || "";
100
- const n = normalize(raw);
101
- if (!n) return "\u2014";
102
- if (n.length > FIRST_PREVIEW_CHARS) return `${n.slice(0, FIRST_PREVIEW_CHARS)}\u2026`;
103
- return n;
107
+ return firstPreview(raw) || "\u2014";
104
108
  }
105
109
 
106
110
  const DELETE_PREVIEW_CHARS = 40;