samagotchi 0.51.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -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 +23 -8
  7. data/docs/configuration.md +59 -19
  8. data/docs/memory.md +11 -0
  9. data/docs/plugins.md +2 -1
  10. data/docs/sessions.md +5 -1
  11. data/docs/testing.md +6 -1
  12. data/lib/samagotchi/bootstrap/config_writer.rb +6 -37
  13. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  14. data/lib/samagotchi/bridge.rb +10 -13
  15. data/lib/samagotchi/broadcast/recipients.rb +1 -2
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/children_status.rb +1 -1
  19. data/lib/samagotchi/commands/registry.rb +2 -1
  20. data/lib/samagotchi/config.rb +50 -182
  21. data/lib/samagotchi/config_text_edit.rb +8 -32
  22. data/lib/samagotchi/context_sources.rb +0 -7
  23. data/lib/samagotchi/context_window.rb +2 -2
  24. data/lib/samagotchi/continue_offer.rb +5 -4
  25. data/lib/samagotchi/cut_policy.rb +0 -1
  26. data/lib/samagotchi/delivery.rb +23 -0
  27. data/lib/samagotchi/engine.rb +48 -17
  28. data/lib/samagotchi/engine_peers.rb +1 -0
  29. data/lib/samagotchi/host_config.rb +183 -0
  30. data/lib/samagotchi/host_registry.rb +5 -10
  31. data/lib/samagotchi/last_turn.rb +48 -0
  32. data/lib/samagotchi/live_versions.rb +0 -6
  33. data/lib/samagotchi/llm/native_tool_normalizer.rb +0 -5
  34. data/lib/samagotchi/llm_context_override.rb +3 -22
  35. data/lib/samagotchi/llm_context_strategy.rb +50 -16
  36. data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
  37. data/lib/samagotchi/model_profile.rb +4 -4
  38. data/lib/samagotchi/model_settings.rb +46 -0
  39. data/lib/samagotchi/plugin/context.rb +3 -3
  40. data/lib/samagotchi/plugin/sessions.rb +2 -2
  41. data/lib/samagotchi/queued_command.rb +35 -0
  42. data/lib/samagotchi/reminder_store.rb +0 -27
  43. data/lib/samagotchi/reply_wait.rb +11 -13
  44. data/lib/samagotchi/sampling_settings.rb +4 -4
  45. data/lib/samagotchi/self_report.rb +8 -5
  46. data/lib/samagotchi/send_command.rb +37 -19
  47. data/lib/samagotchi/session.rb +45 -24
  48. data/lib/samagotchi/session_commands.rb +36 -7
  49. data/lib/samagotchi/session_inbox.rb +18 -9
  50. data/lib/samagotchi/session_manager.rb +8 -7
  51. data/lib/samagotchi/session_setup.rb +30 -0
  52. data/lib/samagotchi/steer_cut.rb +9 -2
  53. data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
  54. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
  55. data/lib/samagotchi/terminal_ui/formatting.rb +21 -3
  56. data/lib/samagotchi/terminal_ui.rb +8 -3
  57. data/lib/samagotchi/thinking.rb +67 -7
  58. data/lib/samagotchi/thinking_command.rb +72 -0
  59. data/lib/samagotchi/tools/builtins.rb +7 -2
  60. data/lib/samagotchi/tools/peers.rb +3 -2
  61. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  62. data/lib/samagotchi/version.rb +1 -1
  63. data/lib/samagotchi/vision_support.rb +4 -3
  64. data/lib/samagotchi/web/app.rb +54 -6
  65. data/lib/samagotchi/web/public/app.js +133 -20
  66. data/lib/samagotchi/web/public/data.js +8 -4
  67. data/lib/samagotchi/web/public/format.js +8 -4
  68. data/lib/samagotchi/web/public/index.html +8 -5
  69. data/lib/samagotchi/web/public/kept_commands.js +52 -0
  70. data/lib/samagotchi/web/public/model_pick.js +3 -1
  71. data/lib/samagotchi/web/public/thinking_chip.js +169 -0
  72. data/lib/samagotchi/web/public/timing.js +15 -0
  73. data/lib/samagotchi/web/public/turn_events.js +23 -0
  74. data/lib/samagotchi/web/public/turn_view.js +328 -310
  75. data/lib/samagotchi/web/public/vendor/lit-html.js +8 -0
  76. data/lib/samagotchi/web/session_summary.rb +1 -1
  77. data/lib/samagotchi/worker.rb +43 -42
  78. data/lib/samagotchi/worker_inbound.rb +5 -5
  79. data/lib/samagotchi/worker_wakes.rb +0 -2
  80. data/lib/samagotchi/yaml_lines.rb +72 -0
  81. metadata +11 -1
@@ -10,6 +10,7 @@ require "rack"
10
10
  require "rack/request"
11
11
 
12
12
  require_relative "../client_id"
13
+ require_relative "../delivery"
13
14
  require_relative "../answer_tail"
14
15
  require_relative "../bridge_client"
15
16
  require_relative "../bridge/bounded_queue"
@@ -23,6 +24,7 @@ require_relative "../host_registry"
23
24
  require_relative "../llm_context_override"
24
25
  require_relative "../model_catalog"
25
26
  require_relative "../model_profile"
27
+ require_relative "../thinking"
26
28
  require_relative "../sampling_settings"
27
29
  require_relative "../served_model"
28
30
  require_relative "../project_scope"
@@ -476,6 +478,7 @@ module Samagotchi
476
478
  summary = SamplingSettings.summary(target, names: names, models: settings)
477
479
  model[:sampling] = summary if summary
478
480
  add_llm_context(model, target, names, settings)
481
+ add_thinking(model, target, names, settings)
479
482
  end
480
483
  rescue StandardError
481
484
  nil
@@ -489,6 +492,14 @@ module Samagotchi
489
492
  nil
490
493
  end
491
494
 
495
+ # The thinking level +model+ starts at, with where it came from (the
496
+ # start page's think chip; Thinking::Explained#summary, no session).
497
+ def add_thinking(model, target, names, settings)
498
+ model[:thinking] = Thinking.explain(target, names: names, models: settings).summary
499
+ rescue StandardError
500
+ nil
501
+ end
502
+
492
503
  # The served model a session's last generation reported (analytics.json,
493
504
  # SessionMetrics#persist), for a session no worker runs: shown only
494
505
  # while the name it answered is still the session's model, as
@@ -525,6 +536,19 @@ module Samagotchi
525
536
  LLMContextStrategy.for_session(session, registry: host_registry)
526
537
  end
527
538
 
539
+ # A session's thinking level with where it came from and its own
540
+ # (Thinking::Explained#summary), as its worker would resolve it; nil
541
+ # when it can't be. Config and the registry only: no network.
542
+ def thinking_for(session)
543
+ registry = host_registry
544
+ name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
545
+ target = registry.resolve(name)
546
+ names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
547
+ Thinking.explain(target, names: names, session: session.thinking).summary
548
+ rescue StandardError
549
+ nil
550
+ end
551
+
528
552
  # Built again when config.yml's hosts: changed (compared as read: the
529
553
  # YAML read is mtime-cached), so the model picker lists a host added
530
554
  # while chi web runs. An injected registry (specs) stays.
@@ -607,6 +631,11 @@ module Samagotchi
607
631
  llm_context, error = request_llm_context(body["llm_context"])
608
632
  return error if error
609
633
 
634
+ # thinking: the session's own thinking level (the start page's think
635
+ # chip; chi --thinking), checked the same way.
636
+ thinking, error = request_thinking(body["thinking"])
637
+ return error if error
638
+
610
639
  folder = dir ? { working_directory: dir } : {}
611
640
  # model: the model the session starts on (the start page's picker,
612
641
  # spelled as GET /api/models lists it); blank means the default.
@@ -617,7 +646,8 @@ module Samagotchi
617
646
  # until that turn is saved.
618
647
  preview = idle ? body["preview"].to_s.strip : ""
619
648
  folder[:title] = preview unless preview.empty?
620
- folder[:llm_context] = llm_context if llm_context
649
+ setup = SessionSetup.new(llm_context: llm_context, thinking: thinking)
650
+ folder[:setup] = setup unless setup.empty?
621
651
  begin
622
652
  session = @manager.spawn_session(prompt: idle ? nil : prompt.to_s, state_dir: @state_dir, **folder)
623
653
  rescue ModelProfile::MissingModel => e
@@ -635,8 +665,8 @@ module Samagotchi
635
665
 
636
666
  # POST /api/sessions with continues: <id, prefix or last:id>: the next
637
667
  # link of that session's chain (SessionManager.continue_session), in its
638
- # folder, on its model and with its llm_context, so dir, model and
639
- # llm_context are refused beside it. 409 continued (with next_id, the
668
+ # folder, on its model and with its llm_context and thinking level, so
669
+ # dir, model, llm_context and thinking are refused beside it. 409 continued (with next_id, the
640
670
  # link that continues it already: the page opens that one),
641
671
  # open_children (ids), folder_gone, busy, scratch or owned_by_tui.
642
672
  def handle_continue(body, prompt, idle)
@@ -644,9 +674,9 @@ module Samagotchi
644
674
  unless given.is_a?(String) && !given.strip.empty?
645
675
  return error_response(400, "invalid_continues", "continues is a session id")
646
676
  end
647
- if (fields = %w[dir model model_name llm_context].select { |key| body.key?(key) }).any?
677
+ if (fields = %w[dir model model_name llm_context thinking].select { |key| body.key?(key) }).any?
648
678
  return error_response(400, "invalid_continues",
649
- "continues takes the previous session's folder, model and llm_context; " \
679
+ "continues takes the previous session's folder, model, llm_context and thinking; " \
650
680
  "leave out #{fields.join(", ")}")
651
681
  end
652
682
 
@@ -701,6 +731,20 @@ module Samagotchi
701
731
  [nil, error_response(400, "invalid_llm_context", e.message)]
702
732
  end
703
733
 
734
+ # The create request's "thinking" (a level word as /thinking takes it,
735
+ # "default" for none) as the session's own level: [level, nil] (nil
736
+ # when absent or default), or [nil, a 400 response] for anything else.
737
+ def request_thinking(raw)
738
+ return [nil, nil] if raw.nil?
739
+
740
+ word = raw.is_a?(String) ? raw.strip.downcase : nil
741
+ unless word && Thinking::LEVELS.map(&:to_s).include?(word)
742
+ return [nil, error_response(400, "invalid_thinking", "thinking is one of #{Thinking::LEVELS.join(", ")}")]
743
+ end
744
+
745
+ [Thinking.session_level(word), nil]
746
+ end
747
+
704
748
  def handle_show(req, id)
705
749
  # The page's light re-reads: never the whole conversation.
706
750
  return handle_cards_read(id) if req.params["cards"] == "1"
@@ -750,6 +794,8 @@ module Samagotchi
750
794
  # The LLM context strategy the next turn runs under (the info bar's
751
795
  # chip): the worker's, else worked out here from the session file.
752
796
  session_json = session_json.merge(llm_context: (snapshot && snapshot["llm_context"]) || llm_context_for(session))
797
+ # The thinking level the next turn runs at (the think chip), the same way.
798
+ session_json = session_json.merge(thinking: (snapshot && snapshot["thinking"]) || thinking_for(session))
753
799
  raw_messages = turn_snapshot ? turn_snapshot["messages"] : session.messages
754
800
  timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"], persisted: persisted)
755
801
  json_response(200, {
@@ -1066,7 +1112,9 @@ module Samagotchi
1066
1112
  return error_response(400, "bad_images", images) if images.is_a?(String)
1067
1113
 
1068
1114
  delivery = body["delivery"] || body[:delivery]
1069
- return error_response(400, "bad_delivery", "delivery must be a string") unless delivery.nil? || delivery.is_a?(String)
1115
+ if (refused = Delivery.refusal(delivery))
1116
+ return error_response(400, refused[:error], refused[:detail])
1117
+ end
1070
1118
 
1071
1119
  result = SessionManager.deliver_turn(id, prompt: prompt.to_s, client_id: client_id, images: images,
1072
1120
  delivery: delivery, state_dir: @state_dir, manager: @manager,
@@ -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";
@@ -58,12 +62,13 @@ import { applySessionEvent, batchCounts, batchTargets, batchToastText, families,
58
62
  import { createNotifications } from "./notifications.js";
59
63
  import { tabTitle, topBarTitle } from "./session_title.js";
60
64
  import { restartConfirmText, versionNotice, workerBadge } from "./update_notice.js";
65
+ import { anchorInsertIndex, commandAnchor, isShellCommandLine, keepsCommandBubble } from "./kept_commands.js";
61
66
  import {
62
- appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, STOPPED_LINE_CLASS,
67
+ appendAboveLiveTiming, cancelLineText, formatDuration, mergeTiming, normalizeTiming, sessionDurationNow, STOPPED_LINE_CLASS,
63
68
  } from "./timing.js";
64
69
  import { createLiveTurn } from "./live_turn.js";
65
70
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
66
- 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";
67
72
 
68
73
  const $ = (s) => document.querySelector(s);
69
74
  const topStripEl = $("#topStrip");
@@ -117,6 +122,43 @@ const llmContextNewControl = createLlmContextControl({
117
122
  id: "llmContextNewPopover",
118
123
  anchor: "#llmContextNew",
119
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
+ }
120
162
  // Hidden without the model's values (the list not in, an older server).
121
163
  function renderNewChatLlmContext() {
122
164
  const chip = llmContextChipModel(withChoice(newChatLlmSummary(), newChatLlmContext), { kind: NEW_CHAT });
@@ -1057,6 +1099,11 @@ function updateComposerMode() {
1057
1099
  newChatLlmContext = null;
1058
1100
  renderNewChatLlmContext();
1059
1101
  }
1102
+ thinkingNewControl.close();
1103
+ if (newChatThinking) {
1104
+ newChatThinking = null;
1105
+ renderNewChatThinking();
1106
+ }
1060
1107
  }
1061
1108
  const running = liveTurn.running && !!selected;
1062
1109
  cancelBtn.style.display = running ? "" : "none";
@@ -1233,8 +1280,9 @@ async function resync({ endText = null } = {}) {
1233
1280
  return;
1234
1281
  }
1235
1282
  if (id !== selected) return;
1283
+ const kept = keptCommandBubbles(id);
1236
1284
  resetTurnView();
1237
- renderSessionView(data);
1285
+ renderSessionView(data, { kept });
1238
1286
  // Preview is read-only: a worker is woken only by POST /turn, so attach
1239
1287
  // only when a live bridge exists.
1240
1288
  selectSeq = typeof data.last_event_seq === "number" ? (data.last_event_id || data.last_event_seq) : null;
@@ -1249,10 +1297,45 @@ async function resync({ endText = null } = {}) {
1249
1297
  }
1250
1298
  }
1251
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
+
1252
1335
  // A session view: its messages, then the turn in progress and the queued
1253
1336
  // prompts (a live worker's snapshot) replayed through the stream handlers,
1254
1337
  // then a pending question.
1255
- function renderSessionView(data) {
1338
+ function renderSessionView(data, { kept = [] } = {}) {
1256
1339
  const s = data.session;
1257
1340
  setMarkdownWarning(data.markdown_warning);
1258
1341
  setCapabilities(data.capabilities);
@@ -1279,20 +1362,23 @@ function renderSessionView(data) {
1279
1362
  current.promptNotes = Array.isArray(s.prompt_notes) ? s.prompt_notes : [];
1280
1363
  current.llmContext = s.llm_context || null;
1281
1364
  llmContextControl.refresh();
1365
+ current.thinking = s.thinking || null;
1366
+ thinkingControl.refresh();
1282
1367
  current.parentId = s.parent_id || null;
1283
1368
  current.continues = s.continues || null;
1284
1369
  current.commands = Array.isArray(data.commands) ? data.commands : [];
1285
1370
  if (data.messages && data.messages.length) {
1286
1371
  const first = data.messages.find((m) => m.role === "user");
1287
- 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);
1288
1373
  renderHistory(data.messages);
1289
1374
  } else {
1290
1375
  renderHistory(data.history || []);
1291
1376
  if (!current.firstPreview && data.history && data.history.length) {
1292
- const txt = normalize(String(data.history[0]||"")).slice(0,80);
1293
- if(txt) current.firstPreview = txt;
1377
+ const txt = firstPreview(data.history[0]);
1378
+ if (txt) current.firstPreview = txt;
1294
1379
  }
1295
1380
  }
1381
+ restoreCommandBubbles(kept);
1296
1382
  // A failed turn's prompt went back to the user: shown after the
1297
1383
  // conversation, marked failed, as it was live (prompt_restored).
1298
1384
  if (data.failed_turn) {
@@ -1352,7 +1438,7 @@ function renderHistory(items) {
1352
1438
  return;
1353
1439
  }
1354
1440
  if (typeof items[0] === "object" && items[0].role) {
1355
- historyEl.innerHTML = turnHistoryHtml(items, current.timing, { thumbs: (images) => thumbsHtml(selected, images) });
1441
+ historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsHtml(selected, images) }));
1356
1442
  } else {
1357
1443
  historyEl.innerHTML = items.map((c) => `<div class="bubble output">${escapeHtml(c)}</div>`).join("");
1358
1444
  }
@@ -1965,7 +2051,7 @@ function updateInfoBar() {
1965
2051
  const attachCmd = `chi --attach ${selected}`;
1966
2052
  const copied = Date.now() - attachCopiedAt < 2000;
1967
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>`;
1968
- 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>`;
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>`;
1969
2055
  // The session clock ticks every second while a turn runs: only its text
1970
2056
  // changes, the bar's other nodes (a tooltip, a button mid-press) stay.
1971
2057
  renderStable(infoTextEl, metaHtml, { ".session-time": timingText });
@@ -2020,10 +2106,7 @@ function memoriesTooltip(used, preloaded, muted) {
2020
2106
  }
2021
2107
 
2022
2108
  function currentSessionDuration() {
2023
- if (liveTurn.running && current.timing.startedAt) {
2024
- return elapsedSince(current.timing.startedAt);
2025
- }
2026
- return current.timing.sessionDurationMs;
2109
+ return sessionDurationNow(current.timing, liveTurn.running);
2027
2110
  }
2028
2111
 
2029
2112
  // The stage takes a running turn's rows (its extras); else the history.
@@ -2281,6 +2364,12 @@ const streamHandlers = {
2281
2364
  updateInfoBar();
2282
2365
  llmContextControl.refresh();
2283
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
+ }
2284
2373
  if (view.resync) await resync();
2285
2374
  // A card's action that isn't anytime (the step-limit question's
2286
2375
  // /continue) has no bubble: nothing to remove. An anytime or queued
@@ -2433,10 +2522,27 @@ function handleCreate() {
2433
2522
 
2434
2523
  async function handleSendTurn() {
2435
2524
  if (current.owner === "tui") return;
2436
- const prompt = turnText(promptEl.value, chips);
2525
+ let prompt = turnText(promptEl.value, chips);
2437
2526
  if (!prompt) return;
2438
2527
  // An image-only send's text is its placeholders: not for ↑.
2439
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
+ }
2440
2546
  if (!selected) {
2441
2547
  // The start page's own reply (a web-local command) or the hint for a
2442
2548
  // typo: neither needs a session, so none is made for it (2.29).
@@ -2450,10 +2556,13 @@ async function handleSendTurn() {
2450
2556
  actionBtn.disabled = true;
2451
2557
  try {
2452
2558
  const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: prompt,
2453
- llm_context: choiceWords(newChatLlmContext) });
2559
+ llm_context: choiceWords(newChatLlmContext),
2560
+ thinking: newChatThinking || undefined });
2454
2561
  // The choice was for this chat only.
2455
2562
  newChatLlmContext = null;
2456
2563
  renderNewChatLlmContext();
2564
+ newChatThinking = null;
2565
+ renderNewChatThinking();
2457
2566
  addCreatedSession(s);
2458
2567
  await select(s.id);
2459
2568
  } catch (e) {
@@ -2485,7 +2594,7 @@ async function handleSendTurn() {
2485
2594
  const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending });
2486
2595
  if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
2487
2596
  if (!current.firstPreview || current.firstPreview==="—") {
2488
- current.firstPreview = normalize(prompt).slice(0,80) + (normalize(prompt).length>80?"…":"");
2597
+ current.firstPreview = firstPreview(prompt);
2489
2598
  updateInfoBar();
2490
2599
  }
2491
2600
  actionBtn.disabled = true;
@@ -2493,7 +2602,7 @@ async function handleSendTurn() {
2493
2602
  for (const chip of sending) {
2494
2603
  if (!chip.ref) chip.ref = await uploadImage(selected, chip.file, chip.name);
2495
2604
  }
2496
- 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 });
2497
2606
  if (ack?.command_id) {
2498
2607
  // The worker knew it as a command (its list was newer than ours):
2499
2608
  // its command_ran shows it, not a prompt bubble.
@@ -2509,7 +2618,7 @@ async function handleSendTurn() {
2509
2618
  // (and images) go back into the composer, without replacing text typed
2510
2619
  // since (4.13). The composer still holds the text just sent: it goes.
2511
2620
  const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
2512
- promptEl.value = composerAfterAck({ sent: prompt, current: promptEl.value, refill: restored?.refill || "" });
2621
+ promptEl.value = composerAfterAck({ sent: line, current: promptEl.value, refill: restored?.refill || "" });
2513
2622
  fitPrompt();
2514
2623
  clearChips();
2515
2624
  if (restored?.images?.length) setChips(restoredChips(selected, restored.images));
@@ -2589,6 +2698,8 @@ async function runSessionCommand(line) {
2589
2698
  function addCommandBubble(view) {
2590
2699
  removeHintIfEmpty();
2591
2700
  const div = document.createElement("div");
2701
+ // Page-only: kept across a resync of this session (keptCommandBubbles).
2702
+ if (selected) div.dataset.keptFor = selected;
2592
2703
  drawCommandBubble(div, view);
2593
2704
  const follow = isNearBottom();
2594
2705
  appendToHistory(div);
@@ -2667,6 +2778,7 @@ const modelPicker = createModelPicker({
2667
2778
  center: $("#center"),
2668
2779
  onPick: () => {
2669
2780
  renderNewChatLlmContext();
2781
+ renderNewChatThinking();
2670
2782
  promptEl.focus();
2671
2783
  },
2672
2784
  });
@@ -2683,6 +2795,7 @@ async function loadModels() {
2683
2795
  modelPickEl.title = payload.warning ? `The model the new chat starts on · ${payload.warning}` : "The model the new chat starts on";
2684
2796
  modelPickEl.hidden = false;
2685
2797
  renderNewChatLlmContext();
2798
+ renderNewChatThinking();
2686
2799
  }
2687
2800
  // The picker's choice for a create request; nothing (the server's default)
2688
2801
  // while the list is not in.
@@ -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;
@@ -502,8 +502,10 @@ body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
502
502
  #composer .actions-col .llmctx-new:hover{filter:none;color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
503
503
  #composer .actions-col .llmctx-new.own{color:var(--accent);border-color:var(--accent-border)}
504
504
  #composer .actions-col .llmctx-new[hidden],body.has-session #composer .actions-col .llmctx-new{display:none}
505
+ /* The new chat's thinking chip, after it: shorter text, so a smaller share of the row. */
506
+ #composer .actions-col .think-new{max-width:min(25%,140px)}
505
507
  /* Phones: picker · chip · Start on one row (the key hint is gone there); names cut short rather than wrapping, the chip first. */
506
- @media(max-width:600px){#composer .actions-col:has(.llmctx-new:not([hidden])){flex-wrap:nowrap}#composer .actions-col .model-pick{flex:0 1 auto;min-width:0}#composer .actions-col .llmctx-new{max-width:45%;flex-shrink:4}}
508
+ @media(max-width:600px){#composer .actions-col:has(.llmctx-new:not([hidden])){flex-wrap:nowrap}#composer .actions-col .model-pick{flex:0 1 auto;min-width:0}#composer .actions-col .llmctx-new{max-width:45%;flex-shrink:4}#composer .actions-col .think-new{max-width:30%}}
507
509
  /* The model search: opaque like #commandList (a list over the conversation
508
510
  must stay readable), above the composer, or below it (.down) when the
509
511
  start page leaves no room above, or over the composer (.over) when
@@ -694,10 +696,10 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
694
696
  #infoBar #infoArchiveBtn:hover{color:var(--text);background:var(--bg-hover)}
695
697
  #infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
696
698
  #infoBar #infoDeleteBtn:hover{color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
697
- /* The LLM context chip (llm_context_chip.js): a model-like chip that opens the strategy popover; accent when the session set its own. */
698
- #infoBar .meta .llmctx-chip{padding:2px 6px;border-radius:6px;font-size:11px;font-weight:400;border:1px solid transparent;cursor:pointer;font-family:inherit;white-space:nowrap}
699
- #infoBar .meta .llmctx-chip:hover{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
700
- #infoBar .meta .llmctx-chip.own{color:var(--accent);border-color:var(--accent-border)}
699
+ /* The LLM context and thinking chips (llm_context_chip.js, thinking_chip.js): model-like chips that open their popovers; accent when the session set its own. */
700
+ #infoBar .meta .llmctx-chip,#infoBar .meta .think-chip{padding:2px 6px;border-radius:6px;font-size:11px;font-weight:400;border:1px solid transparent;cursor:pointer;font-family:inherit;white-space:nowrap}
701
+ #infoBar .meta .llmctx-chip:hover,#infoBar .meta .think-chip:hover{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
702
+ #infoBar .meta .llmctx-chip.own,#infoBar .meta .think-chip.own{color:var(--accent);border-color:var(--accent-border)}
701
703
  .llmctx-form{display:flex;flex-direction:column;gap:8px;margin:0}
702
704
  .llmctx-form .ctx-pop-head strong{font-family:inherit}
703
705
  .llmctx-field{display:flex;align-items:center;gap:8px}
@@ -1046,6 +1048,7 @@ a{color:var(--accent)}
1046
1048
  <div class="actions-col">
1047
1049
  <button type="button" id="modelPick" class="model-pick" title="The model the new chat starts on" aria-label="Model for the new chat" aria-haspopup="listbox" aria-expanded="false" hidden><span class="model-pick-name"></span></button>
1048
1050
  <button type="button" id="llmContextNew" class="llmctx-new" aria-haspopup="dialog" hidden></button>
1051
+ <button type="button" id="thinkingNew" class="llmctx-new think-new" aria-haspopup="dialog" hidden></button>
1049
1052
  <div id="composeHint">Enter to start · Shift+Enter for a newline</div>
1050
1053
  <button id="cancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Stop turn</button>
1051
1054
  <div class="split">
@@ -0,0 +1,52 @@
1
+ // Command bubbles (/help, /model, /stats, /llm-context …) live only in the
2
+ // page: the session saves just a `!cmd`'s output (a message, redrawn as its
3
+ // command bubble). A resync (a `!cmd`, a `!rollback`, a dropped stream)
4
+ // redraws the history from the server, so the page keeps its own command
5
+ // bubbles across it: each one's anchor is the prompts and saved shell
6
+ // commands before it, and it goes back after them in the redrawn history.
7
+ // A reload still drops them.
8
+ //
9
+ // +kinds+: the history's children in order, each "user" (a prompt bubble),
10
+ // "shell" (a `!cmd`'s bubble, which the redrawn history has too) or
11
+ // anything else.
12
+
13
+ // @return {{users: number, shells: number}} the anchor of kinds[index]
14
+ export function commandAnchor(kinds, index) {
15
+ const before = kinds.slice(0, index);
16
+ return {
17
+ users: before.filter((k) => k === "user").length,
18
+ shells: before.filter((k) => k === "shell").length,
19
+ };
20
+ }
21
+
22
+ // The index to insert a kept bubble at: before the first prompt or shell
23
+ // command past its anchor (it ran after its turn), else at the end (a
24
+ // history a !rollback made shorter too).
25
+ export function anchorInsertIndex(kinds, { users = 0, shells = 0 } = {}) {
26
+ let seenUsers = 0;
27
+ let seenShells = 0;
28
+ for (let i = 0; i < kinds.length; i += 1) {
29
+ if (kinds[i] === "user") {
30
+ if (seenUsers >= users) return i;
31
+ seenUsers += 1;
32
+ } else if (kinds[i] === "shell") {
33
+ if (seenShells >= shells) return i;
34
+ seenShells += 1;
35
+ }
36
+ }
37
+ return kinds.length;
38
+ }
39
+
40
+ const SHELL_LINE = /^!\s*\S/;
41
+ const ROLLBACK_LINE = /^!rollback(\s|$)/;
42
+
43
+ // Whether a command bubble's line is kept across a resync: a `!cmd`'s is
44
+ // redrawn from its saved message (keeping it too would show it twice).
45
+ export function isShellCommandLine(line) {
46
+ const text = String(line ?? "");
47
+ return SHELL_LINE.test(text) && !ROLLBACK_LINE.test(text);
48
+ }
49
+
50
+ export function keepsCommandBubble(line) {
51
+ return !isShellCommandLine(line);
52
+ }
@@ -22,7 +22,8 @@ export const RECENT_KEY = "chi_model_recent";
22
22
 
23
23
  // The payload's models as rows: {name, host, id, isDefaultHost}, and
24
24
  // sampling (the configured request parameters as /model words them) when
25
- // the model has some, and llm_context (the LLM context it starts under,
25
+ // the model has some, thinking (the level it starts at, Thinking::Explained#summary),
26
+ // and llm_context (the LLM context it starts under,
26
27
  // LLMContextStrategy::Explained#summary) when the server sent one, and
27
28
  // configured when the host declares the id (hosts.<name>.models), and
28
29
  // unavailable when that host failed to list its models. The
@@ -42,6 +43,7 @@ export function modelRows(payload) {
42
43
  if (m.configured === true) row.configured = true;
43
44
  if (m.unavailable === true) row.unavailable = true;
44
45
  if (m.llm_context && typeof m.llm_context === "object") row.llm_context = m.llm_context;
46
+ if (m.thinking && typeof m.thinking === "object") row.thinking = m.thinking;
45
47
  return row;
46
48
  });
47
49
  }