samagotchi 0.50.0 → 0.51.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -1
  3. data/docs/cli.md +21 -7
  4. data/docs/configuration.md +11 -9
  5. data/docs/hooks.md +3 -2
  6. data/docs/internals/engine.md +4 -2
  7. data/docs/sessions.md +5 -4
  8. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  9. data/lib/samagotchi/bridge.rb +24 -9
  10. data/lib/samagotchi/bridge_client.rb +3 -1
  11. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  12. data/lib/samagotchi/delivery.rb +33 -0
  13. data/lib/samagotchi/host_registry.rb +16 -3
  14. data/lib/samagotchi/kernel_loop.rb +28 -2
  15. data/lib/samagotchi/llm/native_tool_normalizer.rb +2 -2
  16. data/lib/samagotchi/send_command.rb +63 -10
  17. data/lib/samagotchi/session_commands.rb +9 -0
  18. data/lib/samagotchi/session_inbox.rb +27 -7
  19. data/lib/samagotchi/session_manager.rb +13 -3
  20. data/lib/samagotchi/session_metrics.rb +25 -6
  21. data/lib/samagotchi/sessions_command.rb +1 -1
  22. data/lib/samagotchi/steer_cut.rb +12 -8
  23. data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -1
  24. data/lib/samagotchi/terminal_ui/formatting.rb +51 -4
  25. data/lib/samagotchi/terminal_ui.rb +48 -5
  26. data/lib/samagotchi/tool_activity.rb +3 -0
  27. data/lib/samagotchi/tool_runner.rb +2 -0
  28. data/lib/samagotchi/tools/builtin_calls.rb +33 -0
  29. data/lib/samagotchi/version.rb +1 -1
  30. data/lib/samagotchi/web/app.rb +6 -2
  31. data/lib/samagotchi/web/message_parts.rb +3 -0
  32. data/lib/samagotchi/web/public/activity.js +6 -0
  33. data/lib/samagotchi/web/public/app.js +66 -28
  34. data/lib/samagotchi/web/public/index.html +17 -15
  35. data/lib/samagotchi/web/public/session_title.js +26 -0
  36. data/lib/samagotchi/web/public/strip.js +4 -6
  37. data/lib/samagotchi/web/public/turn_events.js +3 -2
  38. data/lib/samagotchi/web/public/turn_html.js +3 -1
  39. data/lib/samagotchi/worker.rb +1 -1
  40. data/lib/samagotchi/worker_inbound.rb +8 -5
  41. data/lib/samagotchi.rb +1 -0
  42. metadata +3 -1
@@ -752,6 +752,14 @@ module Samagotchi
752
752
  def run_input_line(session, input)
753
753
  return if input.empty?
754
754
 
755
+ # /cut TEXT and /queue TEXT are deliveries only while a turn runs; at
756
+ # the prompt both run the text as a normal turn, as their usage says.
757
+ if (choice = steer_choice(input))
758
+ return @surface.commit(STEER_CHOICE_USAGE) if choice.last.empty?
759
+
760
+ input = choice.last
761
+ end
762
+
755
763
  # /model, /models, !rollback, !cmd, /continue (shared with workers);
756
764
  # an anytime command's cards print as it shows them (btw's
757
765
  # "thinking…" before the answer).
@@ -1083,6 +1091,10 @@ module Samagotchi
1083
1091
  # plugin's /btw, /model alone) starts now on its own thread; one that
1084
1092
  # runs after the turn (/model X, !cmd) waits in the queue, in arrival
1085
1093
  # order with the other lines that wait; others go back in the prompt.
1094
+ #
1095
+ # A plain line goes in at the running turn's next step boundary and
1096
+ # never cuts; /cut TEXT asks for a cut, /queue TEXT waits for the turn
1097
+ # to end and runs as a turn of its own.
1086
1098
  # @return [Boolean, :back] whether the turn took it, :back to put it back
1087
1099
  def steer_line(line)
1088
1100
  local = local_command(line) unless line.nil?
@@ -1091,6 +1103,12 @@ module Samagotchi
1091
1103
  return detach_note if local == :detach
1092
1104
  return false if @active_cancel_controller&.cancelled?
1093
1105
 
1106
+ # Before every command check: /cut and /queue are the UIs' own words
1107
+ # for a delivery (they are listed for Tab and /help, never run).
1108
+ if (choice = steer_choice(line))
1109
+ return submit_steer_choice(*choice)
1110
+ end
1111
+
1094
1112
  policy = command_registry.mid_turn(line)
1095
1113
  return start_anytime_command(line) if policy == :anytime
1096
1114
  return command_queued(line) if policy == :queue
@@ -1108,12 +1126,37 @@ module Samagotchi
1108
1126
  # Behind a command that waits for the turn's end, a line waits too.
1109
1127
  return wait_for_turn_end(line) if @command_waits
1110
1128
 
1111
- epoch = @engine.input_epoch
1112
1129
  @pending_input_queue.push(line.strip)
1113
- # The user's line cuts a generation that has streamed only thinking
1114
- # for long (steer.cut_after), now or once it has: the model starts the
1115
- # step again with it.
1116
- @engine.cut_for_steer(nil, epoch: epoch)
1130
+ @surface.commit(NEXT_STEP_NOTE)
1131
+ true
1132
+ end
1133
+
1134
+ # /cut TEXT and /queue TEXT during a turn, which the REPL would
1135
+ # otherwise read as prompts (SessionCommands registers them for Tab and
1136
+ # help only). A cut asks the engine to cut the generation only thinking
1137
+ # too long; a queue waits for the turn's end (the REPL's own after-turn
1138
+ # list). Empty text gets the usage line. A line with images never steers
1139
+ # (as a plain one doesn't).
1140
+ # @return [Boolean] whether the turn took it
1141
+ def submit_steer_choice(choice, text)
1142
+ if text.empty?
1143
+ @surface.commit(STEER_CHOICE_USAGE)
1144
+ return true
1145
+ end
1146
+ return image_line_waits(text) if ImageInput.extract(text).any?
1147
+
1148
+ # A /queue waits for the turn's end; a /cut behind a command that
1149
+ # already waits there goes after it too.
1150
+ if choice == :queue || @command_waits
1151
+ @surface.commit(COMMAND_QUEUED)
1152
+ return wait_for_turn_end(text)
1153
+ end
1154
+
1155
+ epoch = @engine.input_epoch
1156
+ @pending_input_queue.push(text)
1157
+ # With no session running the cut is :off; the text still waits for
1158
+ # the running turn's next step.
1159
+ @surface.commit(steer_cut_note(@engine.cut_for_steer(nil, epoch: epoch)))
1117
1160
  true
1118
1161
  end
1119
1162
 
@@ -47,6 +47,9 @@ module Samagotchi
47
47
  }
48
48
  title = tool_title(tool_name, call, cwd: cwd)
49
49
  event[:title] = title if title
50
+ # The name the model used when an alias ran a built-in (called_as: "bash"
51
+ # for an execute), for a UI that shows it.
52
+ event[:called_as] = call[:called_as] if call.is_a?(Hash) && call[:called_as]
50
53
  # The model's own words for a command, which the TUI's tool line
51
54
  # shows in place of the cut params.
52
55
  description = command_description(tool_name, call)
@@ -57,6 +57,8 @@ module Samagotchi
57
57
  # The worker runs in its session's working directory.
58
58
  title = ToolActivity.tool_title(call[:name], call, cwd: Dir.pwd)
59
59
  started[:title] = title if title
60
+ # The name the model called it by when that was an alias's (bash).
61
+ started[:called_as] = call[:called_as] if call[:called_as]
60
62
  # The full command for a richer UI (params stays cut at 80).
61
63
  view = ToolView.for(call[:name], call)&.to_h
62
64
  started[:view] = view if view
@@ -56,6 +56,13 @@ module Samagotchi
56
56
  "forget_outputs" => { content: "note", also: %w[note], verbatim: %w[ids keep restore] }
57
57
  }.freeze
58
58
 
59
+ # A name the model uses for a built-in, mapped to that built-in (a model
60
+ # that keeps calling `bash` with execute's arguments; before this such a
61
+ # call died as an unknown tool). It only takes effect when the
62
+ # arguments really are the target's shape; the call then carries
63
+ # called_as: with the model's own spelling.
64
+ ALIASES = { "bash" => "execute" }.freeze
65
+
59
66
  # One built-in's mapping, resolved from its schema and OVERRIDES.
60
67
  Row = Data.define(:name, :content_keys, :path_key, :fields, :aliases, :verbatim, :fallback, :fallback_key,
61
68
  :options) do
@@ -109,6 +116,14 @@ module Samagotchi
109
116
  def build(name, args, raw: nil)
110
117
  name = name.to_s
111
118
  args = args.is_a?(Hash) ? args.transform_keys(&:to_s) : {}
119
+ # An alias runs its target (bash → execute); the row is the target's,
120
+ # and the call keeps the model's own spelling on called_as:.
121
+ # Not the target's shape (a String main argument, only its keys) and
122
+ # the alias doesn't take: the call passes through as an unknown tool.
123
+ if (target = ALIASES[name.downcase])
124
+ return aliasable?(row(target), args) ? aliased(name, target, args, raw) : passthrough(name, args, raw)
125
+ end
126
+
112
127
  row = row(name)
113
128
  return passthrough(name, args, raw) unless row
114
129
 
@@ -120,6 +135,24 @@ module Samagotchi
120
135
  call
121
136
  end
122
137
 
138
+ # An execute-shaped call the model spelled +name+: built as its +target+,
139
+ # tagged with the model's spelling for the activity event and the log.
140
+ def aliased(name, target, args, raw)
141
+ call = build(target, args, raw: raw)
142
+ call[:called_as] = name
143
+ call
144
+ end
145
+
146
+ # The alias gate: a String main argument, and every key one the target's
147
+ # schema knows (its aliases included). More, or differently shaped, the
148
+ # call is the model's own and passes through as an unknown tool.
149
+ def aliasable?(row, args)
150
+ return false unless row
151
+
152
+ row.content_keys.map { |key| value(row, args, key) }.any?(String) &&
153
+ (args.keys - (row.content_keys + [row.path_key].compact + row.fields + row.aliases.keys)).empty?
154
+ end
155
+
123
156
  def content_value(row, args)
124
157
  found = row.content_keys.lazy.map { |key| value(row, args, key) }.find { |v| !v.nil? }
125
158
  found.nil? ? "" : found.to_s
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.50.0"
4
+ VERSION = "0.51.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1065,8 +1065,12 @@ module Samagotchi
1065
1065
  images = turn_images(id, body["images"])
1066
1066
  return error_response(400, "bad_images", images) if images.is_a?(String)
1067
1067
 
1068
- result = SessionManager.deliver_turn(id, prompt: prompt.to_s, client_id: client_id, images: images, state_dir: @state_dir,
1069
- manager: @manager, bridge: -> { live_bridge_client(id) })
1068
+ delivery = body["delivery"] || body[:delivery]
1069
+ return error_response(400, "bad_delivery", "delivery must be a string") unless delivery.nil? || delivery.is_a?(String)
1070
+
1071
+ result = SessionManager.deliver_turn(id, prompt: prompt.to_s, client_id: client_id, images: images,
1072
+ delivery: delivery, state_dir: @state_dir, manager: @manager,
1073
+ bridge: -> { live_bridge_client(id) })
1070
1074
  case result[:status]
1071
1075
  when :accepted
1072
1076
  record_history(body, prompt)
@@ -233,6 +233,9 @@ module Samagotchi
233
233
  part = { tool: name, params: params.to_s }
234
234
  title = ToolActivity.tool_title(name, call, cwd: cwd)
235
235
  part[:title] = title if title
236
+ # The name the model called it by when that was an alias's (the
237
+ # saved call is rebuilt by BuiltinCalls, which sets it again).
238
+ part[:called_as] = call[:called_as] if call[:called_as]
236
239
  # Built from the saved call (never saved itself), so older sessions
237
240
  # get it too.
238
241
  view = ToolView.for(name, call)&.to_h
@@ -53,6 +53,7 @@ export function addStarted(model, event = {}, now = null) {
53
53
  row.label = event.label || row.label;
54
54
  row.params = event.params || row.params;
55
55
  row.title = event.title || row.title;
56
+ row.called_as = event.called_as || row.called_as;
56
57
  row.view = event.view || row.view;
57
58
  markStart(row, event, now);
58
59
  return { row, created: false };
@@ -68,6 +69,9 @@ export function addStarted(model, event = {}, now = null) {
68
69
  // project-relative path, a command without its "cd … &&"); rows show it
69
70
  // in place of the params when there is one.
70
71
  title: event.title || null,
72
+ // The name the model called the tool by when it was an alias's ("bash"
73
+ // run as execute); the row says so.
74
+ called_as: event.called_as || null,
71
75
  // The call's full arguments for the expanded row and the hovers (the
72
76
  // server's ToolView: an execute/task_create's command, its cwd).
73
77
  view: event.view || null,
@@ -104,6 +108,7 @@ export function addCompleted(model, event = {}, now = null) {
104
108
  label: event.label,
105
109
  params: event.activity?.params || "",
106
110
  title: event.activity?.title,
111
+ called_as: event.activity?.called_as,
107
112
  view: event.view,
108
113
  });
109
114
  row = synthesized.row;
@@ -113,6 +118,7 @@ export function addCompleted(model, event = {}, now = null) {
113
118
  row.output_truncated = !!event.output_truncated;
114
119
  if (event.activity?.params) row.params = event.activity.params;
115
120
  if (event.activity?.title) row.title = event.activity.title;
121
+ if (event.activity?.called_as) row.called_as = event.activity.called_as;
116
122
  // The images the tool returned (a reload in the turn view draws them from
117
123
  // the saved parts, turn_html.js toolRowHtml).
118
124
  if (event.images?.length) row.images = event.images;
@@ -56,6 +56,7 @@ import { shouldFollowScroll, keepFollowing } from "./scroll.js";
56
56
  import { SHORT_WINDOW_QUERY, stripAutoHidden, stripColumns, stripShowParts } from "./strip.js";
57
57
  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
58
  import { createNotifications } from "./notifications.js";
59
+ import { tabTitle, topBarTitle } from "./session_title.js";
59
60
  import { restartConfirmText, versionNotice, workerBadge } from "./update_notice.js";
60
61
  import {
61
62
  appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, STOPPED_LINE_CLASS,
@@ -71,7 +72,11 @@ const historyEl = $("#history");
71
72
  const emptyEl = $("#empty");
72
73
  const composerEl = $("#composer");
73
74
  const infoBarEl = $("#infoBar");
74
- const firstPeekEl = $("#firstPeek");
75
+ // The open session's title in the top bar, when no strip card in sight
76
+ // shows it; the tab's base ("Chi · <project>"), set by renderScope.
77
+ const sessionTitleEl = $("#sessionTitle");
78
+ let tabBase = null;
79
+ let tabShown = null;
75
80
  const infoTextEl = $("#infoText");
76
81
  // The open session's attached context, in its own row of the session bar
77
82
  // (updateInfoBar redraws #infoText every tick; the chips keep their popover).
@@ -342,7 +347,9 @@ function renderScope() {
342
347
  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
348
  }
344
349
  el.hidden = false;
345
- notifications.setBaseTitle(scope.projectName ? `Chi · ${scope.projectName}` : "Chi");
350
+ // The tab: "<session title> · Chi · <project>" (updateSessionTitle).
351
+ tabBase = scope.projectName ? `Chi · ${scope.projectName}` : "Chi";
352
+ updateSessionTitle();
346
353
  // The links follow the hash (an open session, the all-sessions view).
347
354
  el.querySelector(".scope-all:not(.scope-back)")?.addEventListener("click", (e) => {
348
355
  e.currentTarget.href = allScopeHref(location.hash, scope.dir, location.search);
@@ -642,6 +649,8 @@ function paintActive() {
642
649
  document.querySelectorAll(".family-row[data-id]").forEach((el) => {
643
650
  el.classList.toggle("active", !!selected && el.dataset.id === selected);
644
651
  });
652
+ // The open session's card may have come into the strip or left it.
653
+ updateSessionTitle();
645
654
  }
646
655
 
647
656
  // The latest sessions (as many as fit, a family one card), then the way
@@ -693,11 +702,11 @@ function noteChainFamilies(fams) {
693
702
  }
694
703
 
695
704
  // 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).
705
+ // following the list: its sessions, the number the All sessions tile
706
+ // shows, and the live and waiting ones (a waiting delegate too).
698
707
  function renderStripShow() {
699
708
  const shown = shownSessions();
700
- const { count, live, waiting } = stripShowParts(shown, { cards: foldChains(families(shown), allSessions).length });
709
+ const { count, live, waiting } = stripShowParts(shown);
701
710
  const liveHtml = live ? ` · <span class="live"><span class="live-dot" aria-hidden="true"></span>${escapeHtml(live)}</span>` : "";
702
711
  const waitingHtml = waiting ? ` · <span class="waiting"><span class="waiting-dot" aria-hidden="true"></span>${escapeHtml(waiting)}</span>` : "";
703
712
  $("#stripShow").innerHTML = `<span class="pill-arrow" aria-hidden="true">\u25BE</span> ${escapeHtml(count)}${liveHtml}${waitingHtml}`;
@@ -999,6 +1008,7 @@ function applyView() {
999
1008
  topStripEl.scrollLeft = 0; // a phone's strip scrolls sideways to the tile
1000
1009
  }
1001
1010
  document.body.classList.toggle("all-view", open);
1011
+ updateSessionTitle();
1002
1012
  // Its chip was in the list just hidden (the strip, or all sessions).
1003
1013
  chainPop.close();
1004
1014
  if (open) {
@@ -1037,6 +1047,7 @@ function updateComposerMode() {
1037
1047
  if (document.body.classList.toggle("has-session", !!selected) !== hadSession) {
1038
1048
  hadSession = !hadSession;
1039
1049
  fitPrompt();
1050
+ placeToast();
1040
1051
  }
1041
1052
  if (selected) {
1042
1053
  modelPicker.close();
@@ -1148,7 +1159,6 @@ function clearSelection() {
1148
1159
  setCapabilities(null);
1149
1160
  updateComposerMode();
1150
1161
  updateInfoBar();
1151
- updateFirstPeek();
1152
1162
  promptEl.focus();
1153
1163
  }
1154
1164
 
@@ -1182,7 +1192,6 @@ async function select(id) {
1182
1192
  closeStream();
1183
1193
  resetSessionState();
1184
1194
  resetTurnView();
1185
- updateFirstPeek();
1186
1195
  updateInfoBar();
1187
1196
  contextChips.load(id);
1188
1197
  updateComposerMode();
@@ -1334,7 +1343,6 @@ function renderSessionView(data) {
1334
1343
  // A turn ran out of iterations and waits for a yes or no.
1335
1344
  if (data.continue_offer) questionCards.renderContinue(data.continue_offer);
1336
1345
  updateInfoBar();
1337
- updateFirstPeek();
1338
1346
  updateComposerMode();
1339
1347
  }
1340
1348
 
@@ -1922,11 +1930,11 @@ function updateInfoBar() {
1922
1930
  renderStable(infoTextEl, "");
1923
1931
  infoTextEl.title = "";
1924
1932
  if (stopBtn) stopBtn.style.display = "none";
1933
+ updateSessionTitle();
1925
1934
  return;
1926
1935
  }
1927
1936
  if (stopBtn) stopBtn.style.display = canStopSession({ owner: current.owner, streaming: !!streamControl }) ? "" : "none";
1928
1937
  const idShort = selected.slice(0,8);
1929
- const memText = current.usedMemories.length ? current.usedMemories.join(", ") : "—";
1930
1938
  const ctxText = ctxBarText(current.ctxPct, current.ctxWindowSource);
1931
1939
  const tokens = current.timing.tokens;
1932
1940
  const ctxTip = tokensTipText(tokens, undefined, current.timing.memoryIndex,
@@ -1958,15 +1966,37 @@ function updateInfoBar() {
1958
1966
  const copied = Date.now() - attachCopiedAt < 2000;
1959
1967
  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
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>`;
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>`;
1966
1969
  // The session clock ticks every second while a turn runs: only its text
1967
1970
  // changes, the bar's other nodes (a tooltip, a button mid-press) stay.
1968
- renderStable(infoTextEl, metaHtml + previewHtml, { ".session-time": timingText });
1971
+ renderStable(infoTextEl, metaHtml, { ".session-time": timingText });
1969
1972
  infoTextEl.title = memoriesTooltip(current.usedMemories, current.preloadedMemories, current.mutedMemories);
1973
+ updateSessionTitle();
1974
+ }
1975
+
1976
+ // The open session's title, once on screen (session_title.js): its strip
1977
+ // card says it when the strip is shown and has one; else the top bar. The
1978
+ // browser tab names it too. It follows the card's: a chain link opened with
1979
+ // the opener reads its chain's first link's title (cardTitle).
1980
+ function updateSessionTitle() {
1981
+ const shown = selected ? sessionsById().get(selected) : null;
1982
+ const first = current.firstPreview || "";
1983
+ const raw = !selected ? "" : (shown && isOpener(first) ? cardTitle(shown) : first);
1984
+ const title = raw === "\u2014" ? "" : raw; // "—": no first message yet
1985
+ const text = topBarTitle({
1986
+ title,
1987
+ cardShown: !!topStripEl.querySelector(".card.active"),
1988
+ stripHidden: document.body.classList.contains("strip-hidden"),
1989
+ allView: allViewOpen(),
1990
+ });
1991
+ if (sessionTitleEl.textContent !== text) sessionTitleEl.textContent = text;
1992
+ if (sessionTitleEl.hidden !== !text) sessionTitleEl.hidden = !text;
1993
+ const tip = text ? title : "";
1994
+ if (sessionTitleEl.title !== tip) sessionTitleEl.title = tip;
1995
+ const tab = tabBase === null ? null : tabTitle({ title, base: tabBase });
1996
+ if (tab !== null && tab !== tabShown) {
1997
+ tabShown = tab;
1998
+ notifications.setBaseTitle(tab);
1999
+ }
1970
2000
  }
1971
2001
 
1972
2002
  // The info bar's ← / → link to a chain's link (chains.js chainNeighbours):
@@ -2024,16 +2054,6 @@ async function mergeTurnTiming(id, data) {
2024
2054
  return true;
2025
2055
  }
2026
2056
 
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
2057
  function setUsedMemories(names) {
2038
2058
  current.usedMemories = names.slice();
2039
2059
  updateInfoBar();
@@ -2119,7 +2139,6 @@ const streamHandlers = {
2119
2139
  if (pct !== null && !Number.isNaN(pct)) {
2120
2140
  current.ctxPct = pct;
2121
2141
  updateInfoBar();
2122
- updateFirstPeek();
2123
2142
  }
2124
2143
  },
2125
2144
  generation_started: (data) => {
@@ -2308,7 +2327,6 @@ const streamHandlers = {
2308
2327
  if (pct !== null) {
2309
2328
  current.ctxPct = pct;
2310
2329
  updateInfoBar();
2311
- updateFirstPeek();
2312
2330
  }
2313
2331
  },
2314
2332
  question_requested: (data) => {
@@ -2469,7 +2487,6 @@ async function handleSendTurn() {
2469
2487
  if (!current.firstPreview || current.firstPreview==="—") {
2470
2488
  current.firstPreview = normalize(prompt).slice(0,80) + (normalize(prompt).length>80?"…":"");
2471
2489
  updateInfoBar();
2472
- updateFirstPeek();
2473
2490
  }
2474
2491
  actionBtn.disabled = true;
2475
2492
  try {
@@ -2771,7 +2788,9 @@ new ResizeObserver(() => {
2771
2788
  const follow = atHistoryEnd();
2772
2789
  centerEl.style.setProperty("--dock-h", `${dockEl.offsetHeight}px`);
2773
2790
  if (follow) historyEl.scrollTop = historyEl.scrollHeight;
2791
+ placeToast();
2774
2792
  }).observe(dockEl);
2793
+ window.addEventListener("resize", () => placeToast());
2775
2794
 
2776
2795
  promptEl.addEventListener("paste", (e) => {
2777
2796
  const files = imageFiles(e.clipboardData);
@@ -2981,6 +3000,23 @@ async function setArchived(id, archive, { undo = true } = {}) {
2981
3000
  showToast(`${what} ${short}`, undo ? { label: "Undo", run: () => setArchived(id, !archive, { undo: false }) } : null);
2982
3001
  }
2983
3002
 
3003
+ // The toast sits above the session's composer card (measured: it grows with
3004
+ // a wrapping footer or a taller box), so it never covers the card's
3005
+ // controls; with no card in sight (the start page, all sessions) the CSS's
3006
+ // 24 px from the bottom.
3007
+ function placeToast() {
3008
+ const gap = 12;
3009
+ const dock = document.getElementById("dock");
3010
+ const r = dock.getBoundingClientRect();
3011
+ const over = document.body.classList.contains("has-session") && r.height > 0;
3012
+ const bottom = over ? `${Math.round(window.innerHeight - r.top + gap)}px` : "";
3013
+ const root = document.documentElement.style;
3014
+ if (root.getPropertyValue("--toast-bottom") !== bottom) {
3015
+ if (bottom) root.setProperty("--toast-bottom", bottom);
3016
+ else root.removeProperty("--toast-bottom");
3017
+ }
3018
+ }
3019
+
2984
3020
  // sticky: stays until its action or its ✕ (else gone after 6 s).
2985
3021
  let toastTimer = null;
2986
3022
  // html: +text+ is markup this page built (its parts escaped), not words.
@@ -2988,6 +3024,7 @@ function showToast(text, action = null, { sticky = false, html = false } = {}) {
2988
3024
  const el = $("#toast");
2989
3025
  el.innerHTML = `<span>${html ? text : escapeHtml(text)}</span>${action ? `<button type="button" class="toast-action">${escapeHtml(action.label)}</button>` : ""}` +
2990
3026
  (sticky ? `<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss">✕</button>` : "");
3027
+ placeToast();
2991
3028
  el.hidden = false;
2992
3029
  el.querySelector(".toast-action")?.addEventListener("click", () => {
2993
3030
  el.hidden = true;
@@ -3047,6 +3084,7 @@ function applyStripHidden() {
3047
3084
  document.body.classList.toggle("strip-hidden", stripAutoHidden({
3048
3085
  saved: stripHiddenSaved(), short: shortWindow.matches, shownWhileShort: stripShownWhileShort,
3049
3086
  }));
3087
+ updateSessionTitle();
3050
3088
  }
3051
3089
  function setStripHidden(hidden) {
3052
3090
  if (hidden) familyPop.close();
@@ -45,8 +45,6 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
45
45
  #topStrip:empty{display:grid}
46
46
  #center{flex:1;display:flex;flex-direction:column;min-height:45vh;background:var(--bg-panel);border:1px solid var(--border);border-radius:12px;overflow:hidden}
47
47
  #stage{flex:1;display:flex;flex-direction:column;position:relative;min-height:160px;background:var(--bg);overflow:hidden}
48
- #firstPeek{display:none;padding:8px 12px;background:var(--accent-bg);color:var(--text);border-bottom:1px solid var(--border);font-size:13px;line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
49
- #firstPeek.visible{display:block}
50
48
  #composer #cancelBtn{order:2;background:var(--danger-bg)}
51
49
  #history{flex:1;overflow:auto;padding:16px max(16px,calc((100% - 960px)/2));display:flex;flex-direction:column;gap:12px;background:var(--bg);min-height:160px}
52
50
  /* No child shrinks to fit: a held (overflow:hidden) answer bubble would, clipped under the dock with nothing to scroll. */
@@ -158,6 +156,7 @@ button.task-stop{flex:none;margin-left:auto;padding:1px 8px;border:1px solid col
158
156
  button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:none}
159
157
  .ts-now button.task-stop{align-self:center}
160
158
  .activity-params{color:var(--text-muted);overflow-wrap:anywhere}
159
+ .activity-alias{color:var(--text-faint);white-space:nowrap}
161
160
  .activity-duration{color:var(--text-faint);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
162
161
  .activity-output{margin:4px 0 2px var(--row-gutter);font-size:12px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word;background:var(--bg);border-radius:6px;padding:6px 8px;max-height:160px;overflow:auto}
163
162
  /* A row's status and tool name never wrap (a phone broke them letter by
@@ -681,8 +680,6 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
681
680
  #infoBar .meta .dir{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);max-width:48ch;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;direction:rtl;text-align:left}
682
681
  /* The footer sits on the card's glass: its chips are tinted, not solid, so the glass reads through them. */
683
682
  #infoBar .meta .model{font-size:11px;padding:2px 6px;border-radius:6px;background:color-mix(in srgb,var(--bg-hover) 55%,transparent);color:var(--text-3)}
684
- /* The memory chip ("mem 2", when the session used some): phones only, where the "mem: …" line is hidden. */
685
- #infoBar .meta .mem-chip{display:none}
686
683
  #infoBar .meta .model.served-mismatch{background:var(--warn-bg);color:var(--warn-bright);border:1px solid var(--warn-border)}
687
684
  /* The open session's worker runs an older chi: its version, and Restart when it can. */
688
685
  #infoBar .meta .worker-badge{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:2px 2px 2px 6px;border-radius:6px;background:var(--warn-bg);color:var(--warn-bright);border:1px solid var(--warn-border);white-space:nowrap}
@@ -690,9 +687,6 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
690
687
  #infoBar .meta .worker-restart{padding:1px 8px;border-radius:5px;font-size:11px;font-weight:600;background:var(--bg-raised);color:var(--warn-bright);border:1px solid var(--warn-border)}
691
688
  #infoBar .meta .worker-restart:hover:not(:disabled){background:var(--warn-border);color:var(--on-warn)}
692
689
  #infoBar .meta .worker-restart:disabled{opacity:.7;cursor:default}
693
- #infoBar .preview-line{font-size:13px;word-break:break-word;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:1;line-clamp:1;overflow:hidden;line-height:1.4;max-height:1.4em}
694
- #infoBar .preview-line .preview, #infoBar .preview-line .mem{word-break:break-word}
695
- #infoBar .mem{font-size:12px;color:var(--text-muted)}
696
690
  #infoBar button{padding:4px 12px;border-radius:8px;font-size:12px;font-weight:600}
697
691
  #infoBar #infoStopBtn{background:var(--bg-raised);color:var(--warn-text);border:1px solid var(--border-strong)}
698
692
  #infoBar #infoStopBtn:hover{background:var(--warn-bg);border-color:var(--warn-border)}
@@ -744,12 +738,11 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
744
738
  .ctx-pop-actions button:hover{color:var(--text-hover)}
745
739
  .ctx-pop-actions .ctx-pop-detach{color:var(--text-muted);background:transparent}
746
740
  .ctx-pop-actions .ctx-pop-detach:hover{color:var(--danger-text);border-color:color-mix(in srgb,var(--danger) 55%,transparent)}
747
- /* Phones: the footer is one line; its meta scrolls sideways (all of it stays reachable) and the first-message line, the strip card's title, goes. */
741
+ /* Phones: the footer is one line; its meta scrolls sideways (all of it stays reachable). */
748
742
  @media(max-width:600px){
749
743
  /* The key hint: cut off at this width, and phones have no Shift+Enter. */
750
744
  #composeHint{display:none}
751
745
  #infoBar{flex-wrap:nowrap;align-items:center;gap:6px;padding:6px 10px 8px}
752
- #infoBar .preview-line{display:none}
753
746
  /* No terminal on a phone to attach from. */
754
747
  #infoBar .meta .copy-attach{display:none}
755
748
  #infoBar .meta{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none;white-space:nowrap}
@@ -757,8 +750,8 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
757
750
  #infoBar .meta>*{flex-shrink:0}
758
751
  #infoBar .meta .id{order:-2}
759
752
  #infoBar .meta .status{order:-1}
760
- /* After the status (same order, later in the DOM): the meta scrolls, and only its start is in sight. */
761
- #infoBar .meta .mem-chip{display:inline;order:-1}
753
+ /* The memory chip right after the status (same order, later in the DOM): the meta scrolls, and only its start is in sight. */
754
+ #infoBar .meta .mem-chip{order:-1}
762
755
  /* A parent's children chip too: its waiting part is what to see. */
763
756
  #infoBar .meta .children-chip{order:-1}
764
757
  /* A chain's ← → links too: the way to the other days. */
@@ -825,6 +818,14 @@ body.has-session #scope .scope-where{display:none}
825
818
  /* The ▾ glyph draws tiny at the pill's 11 px: at 20 px it is about as tall
826
819
  as the pill text's capitals (phone and desktop alike). */
827
820
  /* The bell (notifications): quiet at the top bar's right end; filled in the accent when on, struck through when the browser blocks it. */
821
+ /* The open session's title, when no strip card in sight shows it (session_title.js): the bar's middle, cut with an ellipsis; the whole first message in its tooltip. */
822
+ #sessionTitle{flex:1;min-width:0;font-size:13px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
823
+ #sessionTitle[hidden]{display:none}
824
+ /* A phone: the title is its own row under the bar, so "chi + · pill · bell" stay one line. */
825
+ @media(max-width:600px){
826
+ #topBar{flex-wrap:wrap;row-gap:2px}
827
+ #sessionTitle{flex-basis:100%;order:10;font-size:12px}
828
+ }
828
829
  #notifyBtn{margin-left:auto;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;min-height:0;background:transparent;border:1px solid transparent;border-radius:999px;color:var(--text-muted)}
829
830
  #notifyBtn[hidden]{display:none}
830
831
  #notifyBtn:hover{color:var(--text-2);border-color:var(--border)}
@@ -947,7 +948,8 @@ body.all-view #center, body.all-view #topStrip{display:none}
947
948
  .card.archived{opacity:.6}
948
949
  .card.archived:hover{opacity:.85}
949
950
  .card .archived-badge,.family-row .archived-badge{font-size:10px;padding:1px 6px;border-radius:6px;border:1px solid var(--border-strong);color:var(--text-muted)}
950
- #toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);width:max-content;max-width:calc(100vw - 32px);box-sizing:border-box;z-index:50;display:flex;gap:12px;align-items:center;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:var(--toast-shadow);font-size:13px}
951
+ /* bottom: above the session's composer card when one shows (--toast-bottom, app.js placeToast). */
952
+ #toast{position:fixed;left:50%;bottom:var(--toast-bottom,24px);transform:translateX(-50%);width:max-content;max-width:calc(100vw - 32px);box-sizing:border-box;z-index:50;display:flex;gap:12px;align-items:center;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:var(--toast-shadow);font-size:13px}
951
953
  #toast[hidden]{display:none}
952
954
  #toast .toast-action{background:transparent;border:0;color:var(--accent);font-weight:600;cursor:pointer;padding:2px 4px}
953
955
  #toast .toast-close{background:transparent;border:0;color:var(--text-muted);cursor:pointer;padding:2px 4px;margin-left:-6px}
@@ -1016,11 +1018,11 @@ a{color:var(--accent)}
1016
1018
  <div id="scope" hidden></div>
1017
1019
  <button type="button" class="strip-toggle" id="stripShow" title="Show the latest sessions"><span class="pill-arrow" aria-hidden="true">▾</span> sessions</button>
1018
1020
  <button type="button" class="strip-toggle" id="stripHideTop" title="Hide the latest sessions"><span class="pill-arrow" aria-hidden="true">▴</span> hide</button>
1021
+ <span id="sessionTitle" hidden></span>
1019
1022
  <button type="button" id="notifyBtn" aria-label="Notifications" aria-pressed="false" hidden><svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true"><path class="bell" d="M8 1.75a4 4 0 0 0-4 4v2.6L2.75 10.6v.9h10.5v-.9L12 8.35v-2.6a4 4 0 0 0-4-4Z"/><path class="clapper" d="M6.5 13a1.5 1.5 0 0 0 3 0"/><path class="slash" d="M2.5 2.5l11 11"/></svg></button>
1020
1023
  </div>
1021
1024
  <section id="topStrip" aria-label="Latest sessions" class="loading"><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div></section>
1022
1025
  <section id="center">
1023
- <div id="firstPeek"></div>
1024
1026
  <div id="stage">
1025
1027
  <div id="markdownWarning" class="markdown-warning" hidden></div>
1026
1028
  <div id="history"></div>
@@ -1045,7 +1047,7 @@ a{color:var(--accent)}
1045
1047
  <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>
1046
1048
  <button type="button" id="llmContextNew" class="llmctx-new" aria-haspopup="dialog" hidden></button>
1047
1049
  <div id="composeHint">Enter to start · Shift+Enter for a newline</div>
1048
- <button id="cancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Cancel</button>
1050
+ <button id="cancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Stop turn</button>
1049
1051
  <div class="split">
1050
1052
  <button id="actionBtn">Start</button>
1051
1053
  </div>
@@ -1056,7 +1058,7 @@ a{color:var(--accent)}
1056
1058
  <div id="contextChips" aria-label="Attached context" hidden></div>
1057
1059
  <button class="ghost" id="infoContinueBtn" title="Continue: start the next link of this session's chain from its recap (this one is archived)" style="display:none">continue →</button>
1058
1060
  <button class="ghost" id="infoArchiveBtn" title="Archive: hide from the lists, keep for good" style="display:none">archive</button>
1059
- <button class="ghost" id="infoStopBtn" title="Stop session (terminate worker)" style="display:none">stop</button>
1061
+ <button class="ghost" id="infoStopBtn" title="Stop session (terminate worker)" style="display:none">stop session</button>
1060
1062
  <button class="ghost" id="infoDeleteBtn" title="Delete session (for good)" style="display:none">delete</button>
1061
1063
  </div>
1062
1064
  </div>
@@ -0,0 +1,26 @@
1
+ import { normalize } from "./format.js";
2
+
3
+ // Where the open session's title shows: once on screen. Its strip card says
4
+ // it when the strip is shown and has an `.active` card for it; otherwise
5
+ // (strip hidden, an older session without a card, a delegate folded into its
6
+ // parent's card) the top bar does. The All sessions view shows none.
7
+ // Plain rules, no DOM; app.js wires them (updateSessionTitle).
8
+
9
+ const TAB_TITLE_CHARS = 40;
10
+
11
+ // The top bar's text, or "" when it shows no title.
12
+ export function topBarTitle({ title, cardShown, stripHidden, allView }) {
13
+ const text = normalize(title);
14
+ if (!text || allView) return "";
15
+ if (cardShown && !stripHidden) return "";
16
+ return text;
17
+ }
18
+
19
+ // The browser tab: "<title, cut at 40> · <base>", the base alone without a
20
+ // session title (base = "Chi · <project>" or "Chi").
21
+ export function tabTitle({ title, base }) {
22
+ const text = normalize(title);
23
+ if (!text) return base;
24
+ const cut = text.length > TAB_TITLE_CHARS ? `${text.slice(0, TAB_TITLE_CHARS)}…` : text;
25
+ return `${cut} · ${base}`;
26
+ }
@@ -18,13 +18,11 @@ export function stripColumns(width) {
18
18
  // And how many wait on the user (notify.js waitingOn: a question, an
19
19
  // approval, a card), so a hidden strip still says one needs an answer.
20
20
  // Plain text parts; live and waiting are "" when none. Zero sessions keep
21
- // the bare label.
22
- // @param cards how many cards the list makes (a parent's delegates fold
23
- // into its card: one), the count's number; live and waiting count every
24
- // session, a waiting delegate is still one thing waiting on the user
25
- export function stripShowParts(sessions, { cards = null } = {}) {
21
+ // the bare label. The count is sessions (a folded delegate counts), the
22
+ // number the "All sessions" tile shows.
23
+ export function stripShowParts(sessions) {
26
24
  const list = sessions || [];
27
- const n = cards ?? list.length;
25
+ const n = list.length;
28
26
  const liveN = list.filter((s) => s && s.owner === "worker").length;
29
27
  const waitingN = list.filter((s) => waitingOn(s)).length;
30
28
  return {