samagotchi 0.5.1 → 0.7.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -1
  3. data/bin/chi +32 -891
  4. data/docs/architecture.md +8 -1
  5. data/docs/cli.md +12 -13
  6. data/docs/configuration.md +24 -1
  7. data/docs/desktop.md +52 -1
  8. data/docs/hooks.md +69 -4
  9. data/docs/plugins.md +98 -3
  10. data/docs/sessions.md +3 -3
  11. data/lib/samagotchi/bridge/card_store.rb +30 -5
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
  13. data/lib/samagotchi/bridge/turn_notice.rb +37 -0
  14. data/lib/samagotchi/bundle_command.rb +652 -0
  15. data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
  16. data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
  18. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  19. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  20. data/lib/samagotchi/cancellation_controller.rb +42 -1
  21. data/lib/samagotchi/config.rb +7 -2
  22. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  23. data/lib/samagotchi/desktop/macos/ChiRunner.swift +27 -68
  24. data/lib/samagotchi/desktop/macos/Images.swift +38 -3
  25. data/lib/samagotchi/desktop/macos/Kitty.swift +346 -0
  26. data/lib/samagotchi/desktop/macos/Panel.swift +248 -55
  27. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +74 -0
  28. data/lib/samagotchi/desktop/macos.rb +23 -8
  29. data/lib/samagotchi/desktop_command.rb +1 -1
  30. data/lib/samagotchi/engine.rb +138 -618
  31. data/lib/samagotchi/guardrail_wiring.rb +238 -0
  32. data/lib/samagotchi/hooks/registry.rb +11 -3
  33. data/lib/samagotchi/hooks/stream_watch.rb +93 -0
  34. data/lib/samagotchi/hooks.rb +3 -0
  35. data/lib/samagotchi/kernel_loop.rb +65 -6
  36. data/lib/samagotchi/llm/chat_loop.rb +73 -11
  37. data/lib/samagotchi/llm/openai_chat.rb +4 -2
  38. data/lib/samagotchi/log_subscriber.rb +10 -4
  39. data/lib/samagotchi/plugin/api.rb +8 -0
  40. data/lib/samagotchi/plugin/context.rb +12 -3
  41. data/lib/samagotchi/plugin_tasks.rb +229 -0
  42. data/lib/samagotchi/question_desk.rb +280 -0
  43. data/lib/samagotchi/recap_setup.rb +128 -0
  44. data/lib/samagotchi/session.rb +9 -1
  45. data/lib/samagotchi/sessions_command.rb +255 -0
  46. data/lib/samagotchi/steer.rb +19 -2
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
  48. data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
  49. data/lib/samagotchi/tool_declarations.rb +2 -2
  50. data/lib/samagotchi/tools/delegate.rb +4 -1
  51. data/lib/samagotchi/turn_note.rb +20 -1
  52. data/lib/samagotchi/update_command.rb +1 -1
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +12 -8
  55. data/lib/samagotchi/web/public/app.js +94 -52
  56. data/lib/samagotchi/web/public/card.js +8 -1
  57. data/lib/samagotchi/web/public/format.js +1 -1
  58. data/lib/samagotchi/web/public/index.html +2 -5
  59. data/lib/samagotchi/web/public/stage_model.js +44 -0
  60. data/lib/samagotchi/web/public/stage_view.js +26 -17
  61. data/lib/samagotchi/web/public/timing.js +9 -3
  62. data/lib/samagotchi/web/public/turn_events.js +18 -5
  63. data/lib/samagotchi/web/public/turn_view.js +68 -16
  64. data/lib/samagotchi/web/server.rb +4 -3
  65. metadata +11 -2
  66. data/lib/samagotchi/web/public/chat_view.js +0 -376
@@ -71,8 +71,8 @@ module Samagotchi
71
71
 
72
72
  # @param bridge_wait_timeout [Float] bounded seconds to wait for a
73
73
  # freshly-spawned worker's bridge before answering POST /api/sessions.
74
- # @param view ["turn", "stage", "chat"] the page's view of a turn
75
- # (web.view); ?view=turn|stage|chat overrides it for one page load
74
+ # @param view ["stage", "turn"] the page's view of a turn (web.view);
75
+ # ?view=stage|turn overrides it for one page load
76
76
  # @param annotate_presets [String, Array] the quick replies next to
77
77
  # Annotate (web.annotate_presets, "|"-separated); "" shows none. The
78
78
  # page parses them (annotate_presets.js).
@@ -87,7 +87,7 @@ module Samagotchi
87
87
  # String, or a Web::Token::Source that follows the file); nil:
88
88
  # loopback only, no token
89
89
  def initialize(manager: nil, session_class: nil, state_dir: nil, public_dir: nil,
90
- bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, view: "turn", hub: nil,
90
+ bridge_wait_timeout: BRIDGE_WAIT_TIMEOUT, markdown: false, view: Config::BY_KEY["web.view"].default, hub: nil,
91
91
  annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
92
92
  events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
93
93
  registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil)
@@ -1282,16 +1282,17 @@ module Samagotchi
1282
1282
  return nil unless saved
1283
1283
 
1284
1284
  messages = Array(session.messages)
1285
- since = messages.drop(saved[:covered].to_i).count { |m| Samagotchi::Steer.prompt?(m) }
1285
+ since = messages.drop(saved[:covered].to_i).count { |m| Samagotchi::Steer.turn_prompt?(m) }
1286
1286
  { text: saved[:text], turns_since: since }
1287
1287
  rescue StandardError
1288
1288
  nil
1289
1289
  end
1290
1290
 
1291
- # The step (iteration) that answered the steer at +index+: one past
1292
- # the model messages between it and the prompt before it.
1291
+ # The step (iteration) that answered the steer or merged input at
1292
+ # +index+: one past the model messages between it and the turn's
1293
+ # prompt.
1293
1294
  def steer_step(list, index)
1294
- list[0...index].reverse_each.take_while { |m| !Samagotchi::Steer.prompt?(m) }
1295
+ list[0...index].reverse_each.take_while { |m| !Samagotchi::Steer.turn_prompt?(m) }
1295
1296
  .count { |m| %w[model assistant].include?((m[:role] || m["role"]).to_s) } + 1
1296
1297
  end
1297
1298
 
@@ -1340,6 +1341,9 @@ module Samagotchi
1340
1341
  next unless %w[user assistant].include?(norm_role)
1341
1342
 
1342
1343
  message = { role: norm_role, content: stripped }
1344
+ # Merged into the running turn (Steer::INPUT_KIND): part of it,
1345
+ # read by step +step+.
1346
+ message.merge!(merged: true, step: steer_step(list, index)) if norm_role == "user" && Samagotchi::Steer.input?(m)
1343
1347
  images = m[:images] || m["images"]
1344
1348
  message[:images] = Array(images).map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } if norm_role == "user" && images.is_a?(Array) && !images.empty?
1345
1349
  if norm_role == "assistant"
@@ -1454,7 +1458,7 @@ module Samagotchi
1454
1458
 
1455
1459
  VIEWS = Config::BY_KEY["web.view"].enum_values
1456
1460
 
1457
- # The view for this page load: ?view=turn|stage|chat wins, anything
1461
+ # The view for this page load: ?view=stage|turn wins, anything
1458
1462
  # else leaves the config's choice (web.view).
1459
1463
  def view_name(param)
1460
1464
  VIEWS.include?(param) ? param : @view
@@ -21,7 +21,6 @@ import { MODEL_KEY } from "./model_pick.js";
21
21
  import { createModelPicker } from "./model_picker.js";
22
22
  import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
23
23
  import { escapeHtml, messageBodyHtml, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, firstMessageOf, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
24
- import { chatHistoryHtml, createChatView } from "./chat_view.js";
25
24
  import { createTurnView, turnHistoryHtml } from "./turn_view.js";
26
25
  import { createStageView } from "./stage_view.js";
27
26
  import { annotateBarLeft, annotationSource, appendQuote, quoteBlock } from "./annotations.js";
@@ -31,7 +30,7 @@ import { routeChunk } from "./chunk_router.js";
31
30
  import { extractCtxPct, savedCtxPct, cardCtxText } from "./ctx.js";
32
31
  import { diffHtml } from "./diff_view.js";
33
32
  import { approvalAllowed, approvalView, isApproval, nextCardAction, resultText, summaryText } from "./question_card.js";
34
- import { cardClass, cardInnerHtml, cardPlace, splitSnapshotCards, turnNoticePlace } from "./card.js";
33
+ import { cardClass, cardInnerHtml, cardPlace, isCard, splitSnapshotCards, turnNoticePlace } from "./card.js";
35
34
  import { gripFloor, promptHeight } from "./composer_size.js";
36
35
  import { commandMatches, commandRow, moveSelection, pickCommand } from "./command_complete.js";
37
36
  import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
@@ -44,7 +43,7 @@ import {
44
43
  appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
45
44
  } from "./timing.js";
46
45
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
47
- import { clientLabel, commandView, continueLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isCommandLine, isOwn, keepEarlyRestore, newClientId, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
46
+ import { clientLabel, commandView, continueLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isCommandLine, isOwn, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
48
47
 
49
48
  const $ = (s) => document.querySelector(s);
50
49
  const topStripEl = $("#topStrip");
@@ -73,14 +72,12 @@ let resyncsInFlight = 0;
73
72
  let resyncAgain = false;
74
73
  let allSessions = [];
75
74
  let seenContent = new Set();
76
- // The page's view of a turn (web.view, or ?view=turn|stage|chat): one block
77
- // per turn with its steps (turn_view.js), the running turn pinned above the
78
- // composer (the stage), or a row of bubbles (chat_view.js).
79
- const view = ["turn", "stage", "chat"].includes(document.body.dataset.view) ? document.body.dataset.view : "turn";
80
- // The turn and stage views draw a turn as steps (a reload asks for parts).
81
- const turnView = view !== "chat";
82
- // The running turn's DOM: the streaming bubbles, the thinking block and the
83
- // activity panel, the turn block, or the stage (the turn block in its cloud).
75
+ // The page's view of a turn (web.view, or ?view=stage|turn): the running
76
+ // turn pinned above the composer (the stage, stage_view.js), or one block
77
+ // per turn with its steps in the history (turn_view.js).
78
+ const view = ["stage", "turn"].includes(document.body.dataset.view) ? document.body.dataset.view : "stage";
79
+ // The running turn's DOM: the turn block, or the stage (the turn block in
80
+ // its cloud).
84
81
  const viewArgs = {
85
82
  historyEl,
86
83
  appendToHistory: (el) => appendToHistory(el),
@@ -90,7 +87,7 @@ const viewArgs = {
90
87
  };
91
88
  const turnDom = view === "stage"
92
89
  ? createStageView({ ...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl })
93
- : (turnView ? createTurnView : createChatView)(viewArgs);
90
+ : createTurnView(viewArgs);
94
91
  // Where a running turn's rows can be: the history, and the stage while it
95
92
  // holds a turn (lookups by id, copy, annotate, card actions).
96
93
  function turnRoots() {
@@ -683,7 +680,7 @@ async function resync({ endText = null } = {}) {
683
680
  let data;
684
681
  try {
685
682
  try {
686
- data = await getSession(id, { parts: turnView });
683
+ data = await getSession(id, { parts: true });
687
684
  } catch (e) {
688
685
  if (e.status === 404) return leaveGoneSession(id, "not_found");
689
686
  if (id === selected) historyEl.innerHTML = `<div class="hint">Error: ${escapeHtml(e.message)}</div>`;
@@ -749,9 +746,11 @@ function renderSessionView(data) {
749
746
  seedSeenFromDom();
750
747
  seedInitTasks(initTasks, data.init_tasks);
751
748
  renderInitRow();
752
- // A guardrail file that failed to load, announced before this page joined.
753
- showGuardrailWarning(data.guardrail_warning);
754
- showGuardrailWarning(data.plugin_warning, "plugins");
749
+ // A guardrail file that failed to load, announced before this page
750
+ // joined: where it came (the snapshot's cards), else at the end.
751
+ const loadWarned = (label) => (data.cards || []).some((c) => c?.type === "guardrail_warning" && (c.label || "guardrails") === label);
752
+ if (!loadWarned("guardrails")) showGuardrailWarning(data.guardrail_warning);
753
+ if (!loadWarned("plugins")) showGuardrailWarning(data.plugin_warning, "plugins");
755
754
  // The recap saved with the session (also a stopped one's), else a live
756
755
  // worker's from before saved_recap existed.
757
756
  if (data.saved_recap) showRecap(data.saved_recap.text, data.saved_recap.turns_since);
@@ -789,10 +788,8 @@ function renderHistory(items) {
789
788
  historyEl.innerHTML = `<div class="hint">No output yet. Send a message.</div>`;
790
789
  return;
791
790
  }
792
- if (typeof items[0] === "object" && items[0].role && turnView) {
791
+ if (typeof items[0] === "object" && items[0].role) {
793
792
  historyEl.innerHTML = turnHistoryHtml(items, currentTiming, { thumbs: (images) => thumbsHtml(selected, images) });
794
- } else if (typeof items[0] === "object" && items[0].role) {
795
- historyEl.innerHTML = chatHistoryHtml(items, currentTiming, { thumbs: (images) => thumbsHtml(selected, images) });
796
793
  } else {
797
794
  historyEl.innerHTML = items.map((c) => `<div class="bubble output">${escapeHtml(c)}</div>`).join("");
798
795
  }
@@ -905,7 +902,7 @@ function addEndBubble(text) {
905
902
  function endTurnView(kind, endText = null) {
906
903
  setTurnRunning(false);
907
904
  const kept = turnDom.turnEnded({ kind }) || [];
908
- finishLiveTurnTiming();
905
+ finishLiveTurnTiming(null, { canceled: kind === "canceled" });
909
906
  if (endText) addEndBubble(endText);
910
907
  if (kept.length) {
911
908
  kept.forEach((el) => appendToHistory(el));
@@ -1034,7 +1031,7 @@ function showRecap(text, turnsSince = 0) {
1034
1031
  details.appendChild(body);
1035
1032
  // A stale one sits where it was written, above the turns it doesn't cover.
1036
1033
  // Never in the stage: a recap is between turns.
1037
- const before = recapPlace([...historyEl.querySelectorAll(":scope > .bubble.user")], turnsSince);
1034
+ const before = recapPlace(turnPromptBubbles(), turnsSince);
1038
1035
  keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurnTimingEl)));
1039
1036
  }
1040
1037
 
@@ -1049,21 +1046,23 @@ function markRecapStale() {
1049
1046
 
1050
1047
  // A guardrail hook or rule file that failed to load (once per worker), or
1051
1048
  // a bundle's plugin (+label+ "plugins").
1052
- function showGuardrailWarning(text, label = "guardrails") {
1049
+ // +before+: the element it goes above (a snapshot's), else the end.
1050
+ function showGuardrailWarning(text, label = "guardrails", { before = null } = {}) {
1053
1051
  if (!text) return;
1054
1052
  const el = document.createElement("div");
1055
1053
  el.className = "bubble guardrail-warning";
1056
1054
  el.textContent = `${label}: ${text}`;
1057
1055
  const follow = isNearBottom();
1058
- appendToHistory(el);
1056
+ if (before) historyEl.insertBefore(el, before);
1057
+ else appendToHistory(el);
1059
1058
  if (follow) historyEl.scrollTop = historyEl.scrollHeight;
1060
1059
  }
1061
1060
 
1062
1061
  // A hook's line to the user (event[:notify]): "<bundle>: text", in the
1063
1062
  // warning colour for level warn.
1064
1063
  // +before+: the element it goes above (a snapshot's notice), else the end.
1065
- // A plugin's steers (pending_input_merged's steers): in the turn view a row
1066
- // of the running turn's step, else a bubble.
1064
+ // A plugin's steers (pending_input_merged's steers): a row of the running
1065
+ // turn's step, else (no turn runs) a bubble.
1067
1066
  function showSteers(steers) {
1068
1067
  for (const steer of steers || []) {
1069
1068
  if (turnDom.steer(steer)) continue;
@@ -1080,7 +1079,7 @@ function showHookNotice(data, { before = null } = {}) {
1080
1079
  removeHintIfEmpty();
1081
1080
  const el = document.createElement("div");
1082
1081
  el.className = "bubble hook-notice" + (data.level === "warn" ? " warn" : "");
1083
- el.textContent = `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
1082
+ el.textContent = data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
1084
1083
  const follow = isNearBottom();
1085
1084
  if (before) historyEl.insertBefore(el, before);
1086
1085
  else appendToHistory(el);
@@ -1101,6 +1100,9 @@ function cardElement(id) {
1101
1100
  function drawCard(el, card) {
1102
1101
  el.className = cardClass(card);
1103
1102
  el.dataset.cardId = card.id;
1103
+ // A running turn's own card (the stage's status waits on one that asks).
1104
+ if (card.in_turn) el.dataset.inTurn = "1";
1105
+ else delete el.dataset.inTurn;
1104
1106
  if (card.body_html === undefined && el.dataset.body === String(card.body ?? "") && el.querySelector(".card-body")) {
1105
1107
  // The same body again from the stream: keep the rendered one.
1106
1108
  const body = el.querySelector(".card-body").innerHTML;
@@ -1139,41 +1141,35 @@ function upsertCard(card, { before = null } = {}) {
1139
1141
  // A snapshot's cards and notices, each above the first turn after it; a
1140
1142
  // turn's own notice where it showed live (placeTurnNotice).
1141
1143
  function placeSnapshotCards(entries) {
1142
- const users = [...historyEl.querySelectorAll(":scope > .bubble.user")];
1144
+ const users = turnPromptBubbles();
1143
1145
  for (const entry of entries) {
1144
1146
  const before = cardPlace(users, entry);
1145
- if (entry.type === "hook_notice" && entry.in_turn) placeTurnNotice(entry, before);
1147
+ if (isCard(entry)) upsertCard(entry, { before });
1148
+ else if (entry.type === "question") placeResolvedQuestion(entry, before);
1149
+ else if (entry.type === "guardrail_warning") showGuardrailWarning(entry.message, entry.label || "guardrails", { before });
1150
+ else if (entry.in_turn) placeTurnNotice(entry, before);
1146
1151
  else if (entry.type === "hook_notice") showHookNotice(entry, { before });
1147
- else upsertCard(entry, { before });
1148
1152
  }
1149
1153
  }
1150
1154
 
1151
- // A finished turn's hook notice (+before+: the next turn's prompt, or null
1152
- // for the last turn): in the turn view a row of its step, above the row of
1153
- // the call it came before, as live; else (the chat view, a plain answer, a
1154
- // step the history doesn't have) a bubble before the turn's answer, or
1155
- // before its timing line when it has none.
1155
+ // A finished turn's row, a hook's notice or the loop's asking again
1156
+ // (+before+: the next turn's prompt, or null for the last turn): a row of
1157
+ // its step, above the row of the call it came before, as live; else (a
1158
+ // plain answer, a step the history doesn't have) a bubble before the
1159
+ // turn's answer, or before its timing line when it has none.
1156
1160
  function placeTurnNotice(entry, before) {
1157
- const turnEls = [];
1158
- for (let el = before ? before.previousElementSibling : historyEl.lastElementChild; el; el = el.previousElementSibling) {
1159
- turnEls.unshift(el);
1160
- if (el.matches(".bubble.user")) break;
1161
- }
1161
+ const line = noticeLine(entry);
1162
+ const turnEls = reloadedTurnEls(before);
1162
1163
  const place = turnNoticePlace(entry);
1163
1164
  const block = turnEls.find((el) => el.matches(".turn-work"));
1164
1165
  const step = place && block ? block.querySelectorAll(":scope > .gen")[place.step] : null;
1165
1166
  if (!step) {
1166
- // The chat view's canceled turn has no answer: its text bubbles are
1167
- // partial narration the notice came after.
1168
- const canceled = !turnView && turnEls.some((el) => el.matches(".bubble.cancel"));
1169
- const answer = canceled ? null : turnEls.filter((el) => el.matches(".bubble.output")).pop();
1170
- const timing = turnEls.find((el) => el.matches(".turn-timing"));
1171
- showHookNotice(entry, { before: answer || timing || before });
1167
+ showHookNotice({ ...entry, line }, { before: turnAnswerEl(turnEls) || before });
1172
1168
  return;
1173
1169
  }
1174
1170
  const el = document.createElement("div");
1175
1171
  el.className = "hook-notice" + (entry.level === "warn" ? " warn" : "");
1176
- el.textContent = `${hookNoticeLabel(entry.hook)}: ${entry.text || ""}`;
1172
+ el.textContent = line;
1177
1173
  let body = step.querySelector(":scope > .activity-body");
1178
1174
  if (!body) {
1179
1175
  body = document.createElement("div");
@@ -1184,6 +1180,49 @@ function placeTurnNotice(entry, before) {
1184
1180
  body.insertBefore(el, row || null);
1185
1181
  }
1186
1182
 
1183
+ // A prompt that started a turn: not a line steered into a running one.
1184
+ const TURN_PROMPT = ".bubble.user:not(.steered)";
1185
+
1186
+ // The history's turn prompts, oldest first (what turns_since counts).
1187
+ function turnPromptBubbles() {
1188
+ return [...historyEl.querySelectorAll(`:scope > ${TURN_PROMPT}`)];
1189
+ }
1190
+
1191
+ // The elements of the reloaded turn that ends before +before+ (the next
1192
+ // turn's prompt, or null for the last turn), its prompt first.
1193
+ function reloadedTurnEls(before) {
1194
+ const turnEls = [];
1195
+ for (let el = before ? before.previousElementSibling : historyEl.lastElementChild; el; el = el.previousElementSibling) {
1196
+ turnEls.unshift(el);
1197
+ if (el.matches(TURN_PROMPT)) break;
1198
+ }
1199
+ return turnEls;
1200
+ }
1201
+
1202
+ // What goes after a reloaded turn's own rows and cards: its answer, else
1203
+ // its timing line; null when it has neither.
1204
+ function turnAnswerEl(turnEls) {
1205
+ return turnEls.filter((el) => el.matches(".bubble.output")).pop() || turnEls.find((el) => el.matches(".turn-timing")) || null;
1206
+ }
1207
+
1208
+ // A finished turn's question (the snapshot's cards keep it with its
1209
+ // answer): the card as it was left live, resolved, before the lines the
1210
+ // user sent into the turn after it (a steered bubble's data-step: the step
1211
+ // that read it) and the turn's answer. A pending one is the running turn's
1212
+ // (pending_question draws it).
1213
+ function placeResolvedQuestion(entry, before) {
1214
+ const pq = entry.pending_question;
1215
+ if (!pq?.id || !(entry.answer || entry.cancelled)) return;
1216
+ renderQuestionCard(pq);
1217
+ const card = questionCardEl;
1218
+ if (!card || card.dataset.qid !== String(pq.id)) return;
1219
+ resolveQuestionCard(pq.id, { answer: entry.answer || null, cancelled: !!entry.cancelled, reason: entry.reason || "" });
1220
+ const turnEls = reloadedTurnEls(before);
1221
+ const asked = Number(entry.iteration) || 1;
1222
+ const later = turnEls.find((el) => el.matches(".bubble.user.steered") && Number(el.dataset.step) > asked);
1223
+ historyEl.insertBefore(card, later || turnAnswerEl(turnEls) || before);
1224
+ }
1225
+
1187
1226
  // A live card's body comes as text; the worker's snapshot (through the
1188
1227
  // server) has it rendered. One re-read for a burst of cards.
1189
1228
  let cardBodiesTimer = null;
@@ -1337,7 +1376,9 @@ function setLiveTurnNote(text) {
1337
1376
  if (currentTiming.activeTurn) updateLiveTiming();
1338
1377
  }
1339
1378
 
1340
- function finishLiveTurnTiming(record = null) {
1379
+ // A canceled turn's line says so (+canceled+, or its record's status), as
1380
+ // a reload's does.
1381
+ function finishLiveTurnTiming(record = null, { canceled = false } = {}) {
1341
1382
  const duration = Number.isFinite(Number(record?.duration_ms))
1342
1383
  ? Number(record.duration_ms)
1343
1384
  : elapsedSince(currentTiming.activeTurn?.started_at);
@@ -1345,7 +1386,8 @@ function finishLiveTurnTiming(record = null) {
1345
1386
  liveTurnNote = "";
1346
1387
  liveTurnTimingEl.classList.remove("live");
1347
1388
  const at = record ? currentTiming.turnRecords.indexOf(record) : -1;
1348
- liveTurnTimingEl.textContent = turnTimingText(at >= 0 ? at + 1 : liveTurnNumber, duration);
1389
+ liveTurnTimingEl.textContent = turnTimingText(at >= 0 ? at + 1 : liveTurnNumber, duration,
1390
+ { canceled: canceled || record?.status === "canceled" });
1349
1391
  }
1350
1392
  currentTiming.activeTurn = null;
1351
1393
  clearTimingInterval();
@@ -1496,7 +1538,7 @@ const streamHandlers = {
1496
1538
  // that the rendered markdown is in it (or the re-read failed: then the
1497
1539
  // plain text pops), and the display if one was coming, so the links
1498
1540
  // never swap in after the pop. The hold reveals by itself after
1499
- // HOLD_MS whatever comes. The chat view has no hold.
1541
+ // HOLD_MS whatever comes. The stage has no hold.
1500
1542
  if (display) await display;
1501
1543
  turnDom.answerReady?.();
1502
1544
  },
@@ -1611,10 +1653,10 @@ const streamHandlers = {
1611
1653
  if (!turnRunning) showRecap(data.recap);
1612
1654
  },
1613
1655
  guardrail_warning: (data) => showGuardrailWarning(data.message, data.label || "guardrails"),
1614
- // In the turn view, a notice during a turn is a row of its current step.
1656
+ // A notice during a turn is a row of its current step.
1615
1657
  hook_notice: (data) => { if (!turnDom.notice(data)) showHookNotice(data); },
1616
- // The loop asks again after an empty answer: a row of the empty step (the
1617
- // chat view shows nothing; after a reload neither does the turn view).
1658
+ // The loop asks again after an empty answer: a row of the empty step
1659
+ // (a reload puts it back from the snapshot's cards).
1618
1660
  empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
1619
1661
  // The provider is asked again after an error, or the turn waits for
1620
1662
  // plugins' setup: a note on the live timing line until it streams.
@@ -53,15 +53,22 @@ export function cardPlace(userBubbles, card) {
53
53
  // (a row of its current step once the turn is drawn), and the ones that
54
54
  // came during the running turn but aren't its own (an anytime command's,
55
55
  // such as /btw: after the running turn once it is drawn, as they came).
56
+ // The running turn's own rows (a hook's notice, a retry) are left out:
57
+ // they come with the turn's parts (snapshotEvents).
56
58
  export function splitSnapshotCards(cards) {
57
59
  const list = Array.isArray(cards) ? cards : [];
58
60
  return {
59
61
  placed: list.filter((c) => !c.current && !c.during),
60
- current: list.filter((c) => c.current),
62
+ current: list.filter((c) => c.current && isCard(c)),
61
63
  during: list.filter((c) => !c.current && c.during),
62
64
  };
63
65
  }
64
66
 
67
+ // An entry of the snapshot's cards that is a card (not a notice or a row).
68
+ export function isCard(entry) {
69
+ return (entry?.type ?? "card") === "card";
70
+ }
71
+
65
72
  // Where a snapshot's turn notice (a hook's notice during a turn, in_turn)
66
73
  // goes in its reloaded turn's block: the step of its iteration (the
67
74
  // iteration-th .gen, 0-based index here) and the key of the tool row it
@@ -25,7 +25,7 @@ export function noteHtml(note) {
25
25
 
26
26
  // A plugin's steer (a nudge put into the running turn): one line that
27
27
  // names who nudged the model, its text collapsed under it. +bubble+: on its
28
- // own between bubbles (the chat view), not a row of a step.
28
+ // own between bubbles (no step holds it), not a row of a step.
29
29
  export function steerRowHtml({ source, text } = {}, { bubble = false } = {}) {
30
30
  const who = source ? `${source} nudged the model` : "nudged the model";
31
31
  return `<details class="${bubble ? "bubble " : ""}steer-row"><summary>${escapeHtml(who)}</summary>` +
@@ -104,11 +104,6 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
104
104
  .bubble.recap summary{cursor:pointer;color:var(--think-text);font-size:12px}
105
105
  .bubble.recap .recap-body{white-space:pre-wrap;margin-top:6px}
106
106
  .bubble.recap.stale{opacity:.7;border-left-style:dashed}
107
- .bubble.activity{border-left:3px solid var(--accent);background:var(--bg-panel);padding:8px 12px}
108
- .bubble.activity>summary{cursor:pointer;color:var(--text-muted);font-size:13px;font-weight:600;user-select:none;list-style:none}
109
- .bubble.activity>summary::-webkit-details-marker{display:none}
110
- .bubble.activity>summary::before{content:"▸ ";color:var(--accent)}
111
- .bubble.activity[open]>summary::before{content:"▾ "}
112
107
  .activity-body{margin-top:6px}
113
108
  .activity-row{display:flex;gap:8px;align-items:baseline;font-size:13px;color:var(--text-2);padding:5px 2px;border-top:1px solid var(--border)}
114
109
  .activity-row:first-child{border-top:none}
@@ -561,6 +556,8 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
561
556
  #composeHint{display:none}
562
557
  #infoBar{flex-wrap:nowrap;align-items:center;gap:6px;padding:6px 10px 8px}
563
558
  #infoBar .preview-line{display:none}
559
+ /* No terminal on a phone to attach from. */
560
+ #infoBar .meta .copy-attach{display:none}
564
561
  #infoBar .meta{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none;white-space:nowrap}
565
562
  #infoBar .meta::-webkit-scrollbar{display:none}
566
563
  #infoBar .meta>*{flex-shrink:0}
@@ -3,6 +3,7 @@
3
3
  // history, and in what order its pieces go there. No DOM, so `node --test`
4
4
  // covers the rules; stage_view.js draws them.
5
5
  import { toolName } from "./activity.js";
6
+ import { splitSentences } from "./sentences.js";
6
7
 
7
8
  const READ_LIKE = new Set(["read", "memory_read", "web_fetch", "task_get", "task_list", "list_sessions"]);
8
9
  const EDITS = new Set(["edit", "write", "memory_write"]);
@@ -14,6 +15,49 @@ export const QUIET_MS = 1500;
14
15
  // A touch, wheel or scroll counts as use this long (phones have no hover).
15
16
  export const TOUCH_MS = 4000;
16
17
 
18
+ // One headline sentence as plain text: the markdown a streaming answer
19
+ // carries (headings, quotes, bullets, table pipes, emphasis, code ticks,
20
+ // links) goes, and a line that is only syntax (a code fence, a table's
21
+ // separator row, a rule) is empty. A small pass, no renderer: a live line
22
+ // only needs to read well, and an unclosed "**" mid-stream goes too.
23
+ export function plainHeadline(line) {
24
+ let s = String(line ?? "").trim();
25
+ if (/^(```|~~~)/.test(s)) return "";
26
+ if (/^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?$/.test(s)) return "";
27
+ if (/^([-*_])(\s*\1){2,}$/.test(s)) return "";
28
+ s = s.replace(/^(>\s*)+/, "").replace(/^#{1,6}\s+/, "").replace(/^[-*+]\s+(\[[ xX]\]\s+)?/, "");
29
+ // A table row: a pipe at either end (a pipe mid-prose, "a || b", stays).
30
+ if (/^\||\|$/.test(s)) {
31
+ s = s.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim()).filter(Boolean).join(" · ");
32
+ }
33
+ // Code spans keep their text as is; an unclosed tick just goes.
34
+ const code = [];
35
+ s = s.replace(/`+([^`]+)`+/g, (_, c) => `\u0000${code.push(c) - 1}\u0000`);
36
+ s = s
37
+ .replace(/!\[([^\]]*)\]\([^)]*\)?/g, "$1")
38
+ .replace(/\[([^\]]*)\]\([^)]*\)?/g, "$1")
39
+ .replace(/<(https?:\/\/[^>\s]+)>/g, "$1")
40
+ .replace(/`+/g, "")
41
+ .replace(/(^|[^\w])__(?=\S)(.*?\S)__(?=$|[^\w])/g, "$1$2")
42
+ .replace(/(\*\*|~~)/g, "")
43
+ .replace(/(^|[^\w*])\*(?=\S)([^*]*?\S)\*(?=$|[^\w*])/g, "$1$2")
44
+ .replace(/(^|[^\w])_(?=\S)([^_]*?\S)_(?=$|[^\w])/g, "$1$2")
45
+ .replace(/\u0000(\d+)\u0000/g, (_, i) => code[Number(i)]);
46
+ return s.replace(/\s+/g, " ").trim();
47
+ }
48
+
49
+ // The step's newest narration sentence, as plain text: the end of a
50
+ // finished text is a boundary (its last line may have no period), a live
51
+ // one's not yet. A sentence that is only markdown syntax is skipped.
52
+ export function headlineOf(text, done) {
53
+ const { sentences, rest } = splitSentences(text, { atEnd: true });
54
+ const fragment = plainHeadline(rest);
55
+ if (done && fragment) return fragment;
56
+ const plain = sentences.map(plainHeadline).filter(Boolean);
57
+ if (plain.length) return plain[plain.length - 1];
58
+ return fragment.length > 160 ? fragment : "";
59
+ }
60
+
17
61
  // A tool's tick colour: read-like, edit, exec or other.
18
62
  export function toolKind(name) {
19
63
  if (READ_LIKE.has(name)) return "read";
@@ -11,8 +11,7 @@
11
11
  // reload. Slots are patched in place, never rebuilt from HTML: a selection
12
12
  // and annotate survive. The rules are pure in stage_model.js.
13
13
  import { createTurnView } from "./turn_view.js";
14
- import { handOffDue, handOffOrder, inUseFrom, isPlain, liveSlots, placeFor } from "./stage_model.js";
15
- import { splitSentences } from "./sentences.js";
14
+ import { handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor } from "./stage_model.js";
16
15
  import { formatDuration } from "./timing.js";
17
16
 
18
17
  const COLLAPSE_KEY = "chi_stage_collapsed";
@@ -23,6 +22,10 @@ const SETTLE_CAP_MS = 8000;
23
22
  const FOLD_MS = 420;
24
23
  const FLASH_MS = 4000;
25
24
  const PENDING = ".bubble.question:not(.answered):not(.cancelled), .bubble.continue:not(.resolved)";
25
+ // A running turn's card with actions (check-in's Nudge / Keep going / Stop)
26
+ // asks the user too, but the turn goes on: it waits only while the turn runs
27
+ // and never holds the hand-off (a turn cut short can leave it open).
28
+ const ASKS = ".plugin-card[data-in-turn] .card-action";
26
29
  const MARKS = { answered: "✓", canceled: "■", failed: "✕" };
27
30
 
28
31
  function node(tag, cls, parent = null) {
@@ -32,16 +35,6 @@ function node(tag, cls, parent = null) {
32
35
  return el;
33
36
  }
34
37
 
35
- // The step's newest narration sentence: the end of a finished text is a
36
- // boundary (its last line may have no period), a live one's not yet.
37
- function headlineOf(text, done) {
38
- const { sentences, rest } = splitSentences(text, { atEnd: true });
39
- const fragment = rest.replace(/\s+/g, " ").trim();
40
- if (done && fragment) return fragment;
41
- if (sentences.length) return sentences[sentences.length - 1];
42
- return fragment.length > 160 ? fragment : "";
43
- }
44
-
45
38
  function readCollapsed() {
46
39
  try { return localStorage.getItem(COLLAPSE_KEY) === "1"; } catch (_) { return false; }
47
40
  }
@@ -153,6 +146,13 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
153
146
  return !!(extras.querySelector(PENDING) || tail.querySelector(PENDING));
154
147
  }
155
148
 
149
+ function asksCard() {
150
+ return !endKind && !!extras.querySelector(ASKS);
151
+ }
152
+ // A card answered or redrawn in place (its buttons gone) changes the
153
+ // status without a turn event.
154
+ new MutationObserver(() => scheduleRefresh()).observe(extras, { childList: true, subtree: true });
155
+
156
156
  function refresh() {
157
157
  rafPending = false;
158
158
  const { turn, blockEl } = view.current();
@@ -169,12 +169,14 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
169
169
  if (blockEl && blockEl.parentNode === cloudBody) blockEl.open = true;
170
170
  const narration = gen?.text ? headlineOf(gen.text, gen.textDone || gen.closed) : "";
171
171
  const thinking = thinkingLine && thinkingLine.step === turn.gens.length ? thinkingLine.text : "";
172
- const pending = pendingCard();
173
- const slots = liveSlots(turn, { narration, thinking, pending, ended: endKind });
172
+ // A question blocks the turn: its card takes the live slots' room. An
173
+ // asking card's turn goes on: the status waits, the slots stay.
174
+ const blocked = pendingCard();
175
+ const slots = liveSlots(turn, { narration, thinking, pending: blocked || asksCard(), ended: endKind });
174
176
 
175
177
  stage.classList.toggle("plain", plain);
176
178
  stage.classList.toggle("ended", !!endKind);
177
- stage.classList.toggle("waiting", pending);
179
+ stage.classList.toggle("waiting", blocked);
178
180
  stage.dataset.phase = slots.phase;
179
181
  mark.textContent = MARKS[slots.phase] || "";
180
182
  phaseEl.textContent = slots.phase;
@@ -521,6 +523,11 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
521
523
  turnEnded(args) {
522
524
  const kept = view.turnEnded(args) || [];
523
525
  if (!holding) return kept;
526
+ // The warn cards the extras kept in sight leave the turn with the
527
+ // block's (card.js leavesBlock): after the end line, as a reload has them.
528
+ const warn = [...extras.querySelectorAll(":scope > .plugin-card.warn")];
529
+ warn.forEach((el) => el.remove());
530
+ kept.push(...warn);
524
531
  view.chronological();
525
532
  endKind = args.kind;
526
533
  endedAt = Date.now();
@@ -542,7 +549,9 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
542
549
  return placed;
543
550
  },
544
551
  steer: (data) => view.steer(data),
545
- card: (el) => view.card(el),
552
+ // A warn card (a plugin's warning) stays in sight: false puts it in the
553
+ // extras (place), not in its step inside the closed cloud.
554
+ card: (el) => !el.classList.contains("warn") && view.card(el),
546
555
  flush: () => view.flush(),
547
556
  answerReady() {
548
557
  settled = true;
@@ -564,7 +573,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
564
573
  const stick = slot.scrollHeight - slot.scrollTop - slot.clientHeight < 24;
565
574
  slot.appendChild(el);
566
575
  slot.hidden = false;
567
- if (stick || el.matches?.(PENDING)) slot.scrollTop = slot.scrollHeight;
576
+ if (stick || el.matches?.(PENDING) || el.querySelector?.(ASKS)) slot.scrollTop = slot.scrollHeight;
568
577
  scheduleRefresh();
569
578
  return true;
570
579
  },
@@ -88,7 +88,9 @@ export function timedTurnIndexes(items) {
88
88
  // the last, in order; a text-less iteration leaves no message, so a text
89
89
  // can sit one step early: inspection, not truth) and its answer (the last
90
90
  // assistant message). Assistant messages before any prompt are answers on
91
- // their own. The turn index counts prompts, as timedTurnIndexes does.
91
+ // their own. The turn index counts prompts, as timedTurnIndexes does. The
92
+ // user's lines merged into the running turn (`merged`, the server's
93
+ // Steer::INPUT_KIND) stay in it: the turn's `merged` list.
92
94
  export function turnGroups(items, timing) {
93
95
  const groups = [];
94
96
  let turnIndex = -1;
@@ -99,9 +101,11 @@ export function turnGroups(items, timing) {
99
101
  } else if (item?.role === "steer") {
100
102
  if (turn) turn.steers.push(i);
101
103
  else groups.push({ kind: "steer", i });
104
+ } else if (item?.role === "user" && item.merged && turn) {
105
+ turn.merged.push(i);
102
106
  } else if (item?.role === "user") {
103
107
  turnIndex += 1;
104
- turn = { kind: "turn", turnIndex, user: i, texts: [], steers: [] };
108
+ turn = { kind: "turn", turnIndex, user: i, texts: [], steers: [], merged: [] };
105
109
  groups.push(turn);
106
110
  } else if (item?.role === "assistant") {
107
111
  if (turn) turn.texts.push(i);
@@ -110,7 +114,9 @@ export function turnGroups(items, timing) {
110
114
  });
111
115
  return groups.map((g) => {
112
116
  if (g.kind !== "turn") return g;
113
- return withSteers(turnGroup(g, items, timing), g.steers, items);
117
+ const group = withSteers(turnGroup(g, items, timing), g.steers, items);
118
+ // A turn with no merged lines keeps its shape.
119
+ return g.merged.length ? { ...group, merged: g.merged } : group;
114
120
  });
115
121
  }
116
122