samagotchi 0.49.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -1
  3. data/bin/chi +2 -2
  4. data/docs/cli.md +33 -8
  5. data/docs/configuration.md +11 -9
  6. data/docs/hooks.md +12 -3
  7. data/docs/internals/engine.md +4 -2
  8. data/docs/sessions.md +8 -4
  9. data/docs/testing.md +2 -1
  10. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  11. data/lib/samagotchi/bridge.rb +24 -9
  12. data/lib/samagotchi/bridge_client.rb +3 -1
  13. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/loop-guard/plugin.rb +13 -1
  15. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  16. data/lib/samagotchi/delivery.rb +33 -0
  17. data/lib/samagotchi/engine.rb +1 -1
  18. data/lib/samagotchi/hooks/stream_watch.rb +9 -2
  19. data/lib/samagotchi/host_registry.rb +17 -4
  20. data/lib/samagotchi/kernel_loop.rb +28 -2
  21. data/lib/samagotchi/llm/native_tool_normalizer.rb +2 -2
  22. data/lib/samagotchi/send_command.rb +124 -10
  23. data/lib/samagotchi/session_commands.rb +10 -2
  24. data/lib/samagotchi/session_inbox.rb +27 -7
  25. data/lib/samagotchi/session_manager.rb +13 -3
  26. data/lib/samagotchi/session_metrics.rb +32 -6
  27. data/lib/samagotchi/sessions_command.rb +84 -7
  28. data/lib/samagotchi/steer_cut.rb +12 -8
  29. data/lib/samagotchi/terminal_ui/attached_loop.rb +50 -3
  30. data/lib/samagotchi/terminal_ui/event_renderer.rb +4 -2
  31. data/lib/samagotchi/terminal_ui/formatting.rb +68 -6
  32. data/lib/samagotchi/terminal_ui.rb +48 -5
  33. data/lib/samagotchi/tool_activity.rb +3 -0
  34. data/lib/samagotchi/tool_runner.rb +2 -0
  35. data/lib/samagotchi/tools/builtin_calls.rb +33 -0
  36. data/lib/samagotchi/version.rb +1 -1
  37. data/lib/samagotchi/web/app.rb +6 -2
  38. data/lib/samagotchi/web/message_parts.rb +3 -0
  39. data/lib/samagotchi/web/public/activity.js +6 -0
  40. data/lib/samagotchi/web/public/app.js +71 -29
  41. data/lib/samagotchi/web/public/index.html +17 -15
  42. data/lib/samagotchi/web/public/live_timing.js +32 -5
  43. data/lib/samagotchi/web/public/session_title.js +26 -0
  44. data/lib/samagotchi/web/public/stage_model.js +15 -0
  45. data/lib/samagotchi/web/public/stage_view.js +10 -4
  46. data/lib/samagotchi/web/public/strip.js +4 -6
  47. data/lib/samagotchi/web/public/timing.js +11 -0
  48. data/lib/samagotchi/web/public/turn_events.js +14 -2
  49. data/lib/samagotchi/web/public/turn_html.js +24 -1
  50. data/lib/samagotchi/web/public/turn_view.js +9 -3
  51. data/lib/samagotchi/web/session_hub.rb +11 -3
  52. data/lib/samagotchi/worker.rb +1 -1
  53. data/lib/samagotchi/worker_inbound.rb +8 -5
  54. data/lib/samagotchi.rb +1 -0
  55. metadata +3 -1
@@ -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,13 +56,14 @@ 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,
62
63
  } from "./timing.js";
63
64
  import { createLiveTurn } from "./live_turn.js";
64
65
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
65
- import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyRetryLine, 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";
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";
66
67
 
67
68
  const $ = (s) => document.querySelector(s);
68
69
  const topStripEl = $("#topStrip");
@@ -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) => {
@@ -2274,6 +2293,10 @@ const streamHandlers = {
2274
2293
  queued.remove();
2275
2294
  addCommandBubble(view);
2276
2295
  } else if (queued) drawCommandBubble(queued, view);
2296
+ // A command that changed the conversation (!cmd): the redrawn history
2297
+ // ends with its command bubble already (the saved "!(…)\n<output>"
2298
+ // message renders as one) — no second bubble, or the output shows twice.
2299
+ else if (view.resync && historyHasCommandBubble(findInTurnRoots(".bubble.command"), view.line)) { /* drawn by the resync */ }
2277
2300
  else addCommandBubble(view);
2278
2301
  },
2279
2302
  // An anytime command (/help, a plugin's /btw) runs at once, beside a
@@ -2304,7 +2327,6 @@ const streamHandlers = {
2304
2327
  if (pct !== null) {
2305
2328
  current.ctxPct = pct;
2306
2329
  updateInfoBar();
2307
- updateFirstPeek();
2308
2330
  }
2309
2331
  },
2310
2332
  question_requested: (data) => {
@@ -2465,7 +2487,6 @@ async function handleSendTurn() {
2465
2487
  if (!current.firstPreview || current.firstPreview==="—") {
2466
2488
  current.firstPreview = normalize(prompt).slice(0,80) + (normalize(prompt).length>80?"…":"");
2467
2489
  updateInfoBar();
2468
- updateFirstPeek();
2469
2490
  }
2470
2491
  actionBtn.disabled = true;
2471
2492
  try {
@@ -2767,7 +2788,9 @@ new ResizeObserver(() => {
2767
2788
  const follow = atHistoryEnd();
2768
2789
  centerEl.style.setProperty("--dock-h", `${dockEl.offsetHeight}px`);
2769
2790
  if (follow) historyEl.scrollTop = historyEl.scrollHeight;
2791
+ placeToast();
2770
2792
  }).observe(dockEl);
2793
+ window.addEventListener("resize", () => placeToast());
2771
2794
 
2772
2795
  promptEl.addEventListener("paste", (e) => {
2773
2796
  const files = imageFiles(e.clipboardData);
@@ -2977,6 +3000,23 @@ async function setArchived(id, archive, { undo = true } = {}) {
2977
3000
  showToast(`${what} ${short}`, undo ? { label: "Undo", run: () => setArchived(id, !archive, { undo: false }) } : null);
2978
3001
  }
2979
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
+
2980
3020
  // sticky: stays until its action or its ✕ (else gone after 6 s).
2981
3021
  let toastTimer = null;
2982
3022
  // html: +text+ is markup this page built (its parts escaped), not words.
@@ -2984,6 +3024,7 @@ function showToast(text, action = null, { sticky = false, html = false } = {}) {
2984
3024
  const el = $("#toast");
2985
3025
  el.innerHTML = `<span>${html ? text : escapeHtml(text)}</span>${action ? `<button type="button" class="toast-action">${escapeHtml(action.label)}</button>` : ""}` +
2986
3026
  (sticky ? `<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss">✕</button>` : "");
3027
+ placeToast();
2987
3028
  el.hidden = false;
2988
3029
  el.querySelector(".toast-action")?.addEventListener("click", () => {
2989
3030
  el.hidden = true;
@@ -3043,6 +3084,7 @@ function applyStripHidden() {
3043
3084
  document.body.classList.toggle("strip-hidden", stripAutoHidden({
3044
3085
  saved: stripHiddenSaved(), short: shortWindow.matches, shownWhileShort: stripShownWhileShort,
3045
3086
  }));
3087
+ updateSessionTitle();
3046
3088
  }
3047
3089
  function setStripHidden(hidden) {
3048
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>
@@ -4,15 +4,24 @@
4
4
  // on (a provider retry, plugins' setup) and how many turns this page saw
5
5
  // end. The DOM, the clock and the interval come in, so `node --test` drives
6
6
  // it with a fake element.
7
- import { elapsedSince, turnTimingText } from "./timing.js";
7
+ import { elapsedSince, liveDurationText, turnTimingText } from "./timing.js";
8
8
 
9
9
  // The live line's text, or null when the turn's start is unknown. +inStage+:
10
10
  // the stage's status row already says the turn runs, so the line drops its
11
- // own "running" there.
11
+ // own "running" there. The duration is the live one (never "0ms": under a
12
+ // second it shows tenths).
12
13
  export function liveLineText({ number, startedAt, note = "", inStage = false }, now = Date.now()) {
13
14
  const duration = elapsedSince(startedAt, now);
14
15
  if (duration === null) return null;
15
- return turnTimingText(number, duration, { running: !inStage }) + (note ? ` · ${note}` : "");
16
+ const turn = Number.isInteger(number) && number > 0 ? `turn ${number}` : "turn";
17
+ return `${turn}${!inStage ? " running" : ""} · ${liveDurationText(duration)}${note ? ` · ${note}` : ""}`;
18
+ }
19
+
20
+ // The live ticker's interval at +elapsedMs+ into the turn: 200 ms during the
21
+ // first ten seconds (the live text counts up from the first second), then
22
+ // the one-second tick.
23
+ export function liveTickMs(elapsedMs) {
24
+ return Number(elapsedMs) < 10000 ? 200 : 1000;
16
25
  }
17
26
 
18
27
  // The record a turn-end read finishes its line with: the one with the
@@ -52,18 +61,36 @@ export function createLiveTiming({
52
61
  // the next one, so a turn queued behind another reads its own number while
53
62
  // the other's re-read is still out.
54
63
  let ended = 0;
64
+ // The ticker's current interval, so a tick that crossed the 10 s boundary
65
+ // re-arms it at the slower one.
66
+ let tickerMs = null;
55
67
 
56
68
  function stopTicker() {
57
69
  if (ticker !== null) {
58
70
  cancel(ticker);
59
71
  ticker = null;
72
+ tickerMs = null;
60
73
  }
61
74
  }
62
75
 
76
+ function armTicker(ms) {
77
+ stopTicker();
78
+ ticker = every(tick, ms);
79
+ tickerMs = ms;
80
+ }
81
+
63
82
  function tick() {
64
- const text = liveLineText({ number, startedAt: timing().activeTurn?.started_at, note, inStage }, now());
83
+ const startedAt = timing().activeTurn?.started_at;
84
+ const text = liveLineText({ number, startedAt, note, inStage }, now());
65
85
  if (el && text !== null) el.textContent = text;
66
86
  onTick();
87
+ // The interval follows the elapsed time: 200 ms while the line counts
88
+ // up from the first second, one a second once it is on whole seconds.
89
+ if (ticker !== null) {
90
+ const elapsed = elapsedSince(startedAt, now());
91
+ const ms = elapsed === null ? tickerMs : liveTickMs(elapsed);
92
+ if (ms !== tickerMs) armTicker(ms);
93
+ }
67
94
  }
68
95
 
69
96
  return {
@@ -87,7 +114,7 @@ export function createLiveTiming({
87
114
  el.className = "turn-timing live";
88
115
  inStage = !!place(el);
89
116
  tick();
90
- ticker = every(tick, 1000);
117
+ armTicker(liveTickMs(0));
91
118
  },
92
119
 
93
120
  tick,
@@ -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
+ }
@@ -108,6 +108,21 @@ function withTask(slot, row) {
108
108
  return row.tool === "task_wait" && row.view?.task_id ? { ...slot, taskId: row.view.task_id, key: row.key } : slot;
109
109
  }
110
110
 
111
+ // A trail item's text and its hover: the text the (CSS-truncated) item shows,
112
+ // and the full text it hovers. A call hovers its full command when it has
113
+ // one, else its whole "<mark> <name> <title>" text; a flash (a hook notice)
114
+ // hovers its whole "<mark> <text>" line.
115
+ export function trailItemText(item) {
116
+ const text = `${trailMark(item.status)} ${item.name}${item.title ? ` ${item.title}` : ""}`;
117
+ return { text, hover: item.command || text };
118
+ }
119
+
120
+ // A trail flash's text and hover: the whole "<mark> <text>" line.
121
+ export function trailFlashText(flash, mark) {
122
+ const text = `${mark} ${flash.text}`;
123
+ return { text, hover: text };
124
+ }
125
+
111
126
  // While the first step has no thinking and no tool rows the turn is plain:
112
127
  // its text streams in the stage's answer slot, as the turn view's
113
128
  // chrome-less block streams like a bubble.
@@ -11,7 +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 { flashOf, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor, trailMark, trailTone } from "./stage_model.js";
14
+ import { flashOf, handOffDue, handOffOrder, headlineOf, inUseFrom, isPlain, liveSlots, placeFor, trailFlashText, trailItemText, trailTone } from "./stage_model.js";
15
15
  import { formatDuration } from "./timing.js";
16
16
  import { taskCommand, taskStopButton } from "./task_stop.js";
17
17
 
@@ -265,12 +265,18 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
265
265
  if (shown.trail === key) return;
266
266
  shown.trail = key;
267
267
  trail.replaceChildren();
268
- if (flashing) node("span", `ts-trail-item ${flashing.kind}`, trail).textContent = `${FLASH_MARKS[flashing.kind]} ${flashing.text}`;
268
+ if (flashing) {
269
+ const flash = trailFlashText(flashing, FLASH_MARKS[flashing.kind]);
270
+ const el = node("span", `ts-trail-item ${flashing.kind}`, trail);
271
+ el.textContent = flash.text;
272
+ el.title = flash.hover;
273
+ }
269
274
  for (const item of items) {
270
275
  const tone = trailTone(item.status);
271
276
  const el = node("span", `ts-trail-item${tone ? ` ${tone}` : ""}`, trail);
272
- el.textContent = `${trailMark(item.status)} ${item.name} ${item.title}`.trim();
273
- if (item.command) el.title = item.command;
277
+ const { text, hover } = trailItemText(item);
278
+ el.textContent = text;
279
+ el.title = hover;
274
280
  }
275
281
  if (!flashing && !items.length) node("span", "ts-trail-item none", trail).textContent = "no tool calls yet";
276
282
  }
@@ -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 {
@@ -20,6 +20,17 @@ export function turnTimingText(number, durationMs, { running = false, canceled =
20
20
  return `${turn}${running ? " running" : ""} · ${formatDuration(durationMs)}${canceled ? " · canceled" : ""}`;
21
21
  }
22
22
 
23
+ // The LIVE line's duration: never milliseconds — under a second it shows
24
+ // tenths ("0.0s", "0.2s"), then what formatDuration prints from 1 s on
25
+ // (its 0.1 s precision holds to 10 s). A finished turn's line keeps
26
+ // formatDuration exactly as it is.
27
+ export function liveDurationText(durationMs) {
28
+ const ms = Number(durationMs);
29
+ if (!Number.isFinite(ms) || ms < 0) return "";
30
+ if (ms < 1000) return `${(ms / 1000).toFixed(1)}s`;
31
+ return formatDuration(ms);
32
+ }
33
+
23
34
  // A cancel's reason as the terminal UIs name it too (Formatting::
24
35
  // CANCEL_REASONS); anything else as it is. Both sides: spec/shared/labels_matrix.json.
25
36
  const CANCEL_REASONS = { ctrl_c: "Ctrl-C", user: "stopped", hook: "by a hook" };