samagotchi 0.76.0 → 0.78.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -1
  3. data/docs/cli.md +2 -1
  4. data/docs/configuration.md +10 -6
  5. data/docs/internals/anthropic.md +73 -5
  6. data/docs/internals/engine.md +2 -1
  7. data/docs/internals/prompt-caching.md +3 -1
  8. data/docs/plugins.md +42 -0
  9. data/docs/sessions.md +1 -0
  10. data/lib/samagotchi/answer_command.rb +5 -5
  11. data/lib/samagotchi/bridge.rb +3 -3
  12. data/lib/samagotchi/broadcast/recipients.rb +2 -2
  13. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +12 -10
  14. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  16. data/lib/samagotchi/child_move.rb +2 -2
  17. data/lib/samagotchi/child_reports.rb +3 -3
  18. data/lib/samagotchi/children_status.rb +2 -2
  19. data/lib/samagotchi/engine.rb +21 -12
  20. data/lib/samagotchi/guardrails/parent_approvals.rb +4 -4
  21. data/lib/samagotchi/guardrails/parent_continue.rb +2 -2
  22. data/lib/samagotchi/llm/anthropic_assembly.rb +14 -0
  23. data/lib/samagotchi/llm/anthropic_messages.rb +210 -13
  24. data/lib/samagotchi/llm/anthropic_wire.rb +22 -0
  25. data/lib/samagotchi/llm/chat_loop.rb +49 -2
  26. data/lib/samagotchi/llm/errors.rb +6 -4
  27. data/lib/samagotchi/llm/http.rb +2 -1
  28. data/lib/samagotchi/llm/openai_chat.rb +12 -2
  29. data/lib/samagotchi/memory_bundle/builder.rb +2 -0
  30. data/lib/samagotchi/message_parts.rb +272 -0
  31. data/lib/samagotchi/note_delivery.rb +2 -2
  32. data/lib/samagotchi/parent_report.rb +30 -22
  33. data/lib/samagotchi/pending_question.rb +49 -0
  34. data/lib/samagotchi/plugin/sessions.rb +2 -2
  35. data/lib/samagotchi/question_desk.rb +39 -32
  36. data/lib/samagotchi/relay.rb +8 -9
  37. data/lib/samagotchi/relay_verifier.rb +2 -2
  38. data/lib/samagotchi/relay_watcher.rb +2 -2
  39. data/lib/samagotchi/reply_wait.rb +6 -7
  40. data/lib/samagotchi/row_refs.rb +1 -1
  41. data/lib/samagotchi/session.rb +22 -14
  42. data/lib/samagotchi/session_commands.rb +4 -1
  43. data/lib/samagotchi/session_manager.rb +0 -1
  44. data/lib/samagotchi/session_metrics/totals.rb +4 -3
  45. data/lib/samagotchi/session_metrics.rb +9 -0
  46. data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
  47. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  48. data/lib/samagotchi/terminal_ui/formatting.rb +6 -106
  49. data/lib/samagotchi/terminal_ui/model_segments_text.rb +118 -0
  50. data/lib/samagotchi/terminal_ui.rb +7 -5
  51. data/lib/samagotchi/tool_output_cut.rb +1 -1
  52. data/lib/samagotchi/tool_response.rb +13 -13
  53. data/lib/samagotchi/tool_row_fields.rb +1 -1
  54. data/lib/samagotchi/tool_runner.rb +16 -9
  55. data/lib/samagotchi/tools/delegate.rb +2 -2
  56. data/lib/samagotchi/tools/delegate_relay.rb +7 -7
  57. data/lib/samagotchi/tools/delegate_result.rb +2 -2
  58. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  59. data/lib/samagotchi/tools/list_sessions.rb +2 -2
  60. data/lib/samagotchi/tools/web_fetch.rb +3 -1
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/app.rb +2 -2
  63. data/lib/samagotchi/web/public/app.js +234 -74
  64. data/lib/samagotchi/web/public/composer_fold.js +58 -0
  65. data/lib/samagotchi/web/public/composer_size.js +10 -0
  66. data/lib/samagotchi/web/public/ctx.js +12 -1
  67. data/lib/samagotchi/web/public/index.html +29 -4
  68. data/lib/samagotchi/web/public/markdown_place.js +53 -0
  69. data/lib/samagotchi/web/public/stream_handlers.js +7 -3
  70. data/lib/samagotchi/web/public/toasts.js +69 -0
  71. data/lib/samagotchi/worker.rb +1 -0
  72. metadata +7 -2
  73. data/lib/samagotchi/web/message_parts.rb +0 -274
@@ -45,6 +45,7 @@ import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
45
45
  import { createBubbles, hintEl, outputBubbleTemplate, setBubbleDelivery } from "./bubbles.js";
46
46
  import { createCardsPlace } from "./cards_place.js";
47
47
  import { createStageView, FOLD_MS } from "./stage_view.js";
48
+ import { createMarkdownPlace } from "./markdown_place.js";
48
49
  import { reducedMotion, STAGE_GAP } from "./stage_frame.js";
49
50
  import { chainNeighbours, continueRefusal, continuedText, freshLink, openChildrenTemplate, openerText, openTitle } from "./chains.js";
50
51
  import { createTaskStopper } from "./task_stop.js";
@@ -55,7 +56,7 @@ import { actionLine, annotationActions, composerWithAction } from "./annotate_ac
55
56
  import { copyText, installCopy } from "./copy.js";
56
57
  import { createQuestionCards } from "./question_cards.js";
57
58
  import { splitSnapshotCards } from "./card.js";
58
- import { PROMPT_LINE_PX, PROMPT_MIN_PX, gripFloor, promptHeight } from "./composer_size.js";
59
+ import { GRIP_OPEN_PX, PROMPT_LINE_PX, PROMPT_MIN_PX, gripFloor, gripFolds, promptHeight } from "./composer_size.js";
59
60
  import { createCommandList } from "./command_list.js";
60
61
  import { createPromptHistory } from "./prompt_history.js";
61
62
  import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
@@ -63,6 +64,8 @@ import { scopeDir, scopeTemplate } from "./scope.js";
63
64
  import { html, nothing, render } from "./vendor/lit-html.js";
64
65
  import { easeScrollTo, FOLLOW_MS, shouldFollowScroll } from "./scroll.js";
65
66
  import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
67
+ import { COMPOSER_FOLD_KEY, composerFolded, foldHandle, opensOnKey } from "./composer_fold.js";
68
+ import { waitingOn } from "./notify.js";
66
69
  import { applySessionEvent, inTree, sortedByUpdated } from "./sessions_list.js";
67
70
  import { createNotifications } from "./notifications.js";
68
71
  import { tabTitle, topBarTitle } from "./session_title.js";
@@ -86,6 +89,7 @@ import { liveSlots } from "./stage_model.js";
86
89
  import { currentGen } from "./turn_model.js";
87
90
  import { toolName } from "./activity.js";
88
91
  import { toastBottom } from "./toast_place.js";
92
+ import { createToasts } from "./toasts.js";
89
93
 
90
94
  const $ = (s) => document.querySelector(s);
91
95
  const topStripEl = $("#topStrip");
@@ -130,7 +134,7 @@ const llmContextNewControl = createLlmContextControl({
130
134
  choose: (next) => {
131
135
  newChatLlmContext = next;
132
136
  renderNewChatLlmContext();
133
- promptEl.focus();
137
+ focusPrompt();
134
138
  },
135
139
  id: "llmContextNewPopover",
136
140
  anchor: "#llmContextNew",
@@ -166,7 +170,7 @@ const thinkingNewControl = createThinkingControl({
166
170
  choose: (next) => {
167
171
  newChatThinking = next;
168
172
  renderNewChatThinking();
169
- promptEl.focus();
173
+ focusPrompt();
170
174
  },
171
175
  id: "thinkingNewPopover",
172
176
  anchor: "#thinkingNew",
@@ -200,6 +204,19 @@ let pageEditorUrl = null;
200
204
  const promptEl = $("#prompt");
201
205
  const actionBtn = $("#actionBtn");
202
206
  const cancelBtn = $("#cancelBtn");
207
+ // The composer's fold (composer_fold.js): the grip, which is the handle
208
+ // while folded, and the info bar's Stop turn while folded (the composer's
209
+ // #cancelBtn is folded away then). +gripWillFold+: the drag in progress is
210
+ // past the fold threshold.
211
+ const composeGrip = $("#composeGrip");
212
+ const gripStartTitle = composeGrip.title;
213
+ let gripWillFold = false;
214
+ const infoCancelBtn = $("#infoCancelBtn");
215
+ // This visit's state (a visit is one select() call): opened by the page
216
+ // (a focus, a restored line), archived (decided once, from the visit's
217
+ // first getSession answer).
218
+ const newFoldVisit = () => ({ open: false, archived: false, decided: false });
219
+ let foldVisit = newFoldVisit();
203
220
  const modelPickEl = $("#modelPick");
204
221
  const composeHintEl = $("#composeHint");
205
222
 
@@ -231,26 +248,25 @@ const viewArgs = {
231
248
  // the route (its sidecar features).
232
249
  taskStop: createTaskStopper({ sessionId: () => selected, features: () => current.workerFeatures }),
233
250
  };
251
+ const markdownPlace = createMarkdownPlace({
252
+ capabilities: () => pageCapabilities,
253
+ renderText,
254
+ roots: () => turnRoots(),
255
+ touchedFiles,
256
+ currentDir: () => current.dir,
257
+ linkRefs,
258
+ applyFinalAnswer,
259
+ findInTurnRoots: (selector) => findInTurnRoots(selector),
260
+ normalize,
261
+ pairAnswerRefs,
262
+ });
263
+ const { renderMarkdown, linkRefsIn, applyFinalMarkdown, linkTextRefs } = markdownPlace;
234
264
  const turnDom = view === "stage"
235
265
  ? createStageView({
236
266
  ...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl,
237
267
  renderMarkdown, linkRefs: linkRefsIn,
238
268
  })
239
269
  : createTurnView(viewArgs);
240
- // A text the page already shows as its markdown HTML (POST /api/render,
241
- // sanitised there), or null: markdown off here (no request), or a failed
242
- // read (the raw text stays).
243
- function renderMarkdown(text) {
244
- if (pageCapabilities.display !== true) return Promise.resolve(null);
245
- return renderText(text)
246
- .then((d) => (typeof d?.html === "string" && d.html ? d.html : null))
247
- .catch(() => null);
248
- }
249
- // The `path:line` refs in one rendered text (the stage's asking slot, a
250
- // kept reply), as linkTextRefs links the answers'.
251
- function linkRefsIn(el) {
252
- linkRefs(el, touchedFiles(turnRoots()), current.dir);
253
- }
254
270
  // Where a running turn's rows can be: the history, and the stage while it
255
271
  // holds a turn (lookups by id, copy, annotate, card actions).
256
272
  function turnRoots() {
@@ -738,9 +754,11 @@ function clearSelection() {
738
754
  closeStream();
739
755
  setMarkdownWarning(null);
740
756
  setCapabilities(null, null);
757
+ foldVisit = newFoldVisit();
758
+ applyComposerFolded();
741
759
  updateComposerMode();
742
760
  updateInfoBar();
743
- promptEl.focus();
761
+ focusPrompt();
744
762
  }
745
763
 
746
764
  // A short line on the start page (the open session went away); gone at the
@@ -773,11 +791,14 @@ async function select(id) {
773
791
  closeStream();
774
792
  resetSessionState();
775
793
  resetTurnView();
794
+ foldVisit = newFoldVisit();
795
+ applyComposerFolded();
776
796
  updateInfoBar();
777
797
  contextChips.load(id);
778
798
  updateComposerMode();
779
799
  await resync();
780
- promptEl.focus();
800
+ // A folded composer stays folded: typing on the page opens it.
801
+ focusPrompt({ open: false });
781
802
  }
782
803
 
783
804
  // Clear everything that belongs to one turn's rendering.
@@ -814,6 +835,13 @@ async function resync({ endText = null } = {}) {
814
835
  return;
815
836
  }
816
837
  if (id !== selected) return;
838
+ // An archived session opens folded: decided once per visit, from its
839
+ // first answer (the list may not hold it yet, or ever under ?dir).
840
+ if (!foldVisit.decided) {
841
+ foldVisit.decided = true;
842
+ foldVisit.archived = !!data.session?.archived;
843
+ applyComposerFolded();
844
+ }
817
845
  const kept = keptCommandBubbles(historyEl, id);
818
846
  resetTurnView();
819
847
  renderSessionView(data, { kept });
@@ -978,32 +1006,6 @@ async function readTurnEnd() {
978
1006
  } catch(_){}
979
1007
  }
980
1008
 
981
- // The turn's answer as its rendered markdown and with its plugin refs,
982
- // once it is final (the turn-end read, an answer_display re-read:
983
- // answer_refs.js applyFinalAnswer); then the refs.
984
- function applyFinalMarkdown(messages) {
985
- applyFinalAnswer(messages, findInTurnRoots(".bubble.output"), normalize);
986
- linkTextRefs();
987
- }
988
-
989
- // The refs on the answers' and closed steps' texts, on this page: first
990
- // each answer's own (a plugin's, answer_refs.js), then the `path:line`
991
- // refs that name a file the session's rows (and cards) touched
992
- // (ref_scan.js), which leave a plugin's alone. Not mid-stream: at a
993
- // turn's end and after a reload's render. A ref already made stays as it
994
- // is.
995
- function linkTextRefs() {
996
- const roots = turnRoots();
997
- const touched = touchedFiles(roots);
998
- for (const root of roots) {
999
- for (const el of root.querySelectorAll(".bubble.output")) pairAnswerRefs(el);
1000
- }
1001
- if (!touched.size) return;
1002
- for (const root of roots) {
1003
- for (const el of root.querySelectorAll(".bubble.output, .gen:not(.live) > .gen-text")) linkRefs(el, touched, current.dir);
1004
- }
1005
- }
1006
-
1007
1009
  function seedSeenFromDom() {
1008
1010
  shownTexts.seed(findInTurnRoots(".bubble").map((b) => normalize(b.textContent)));
1009
1011
  }
@@ -1114,6 +1116,7 @@ function updateInfoBar() {
1114
1116
  archiveBtn.title = buttons.archive.title;
1115
1117
  }
1116
1118
  if (stopBtn) stopBtn.style.display = buttons.stop.shown ? "" : "none";
1119
+ drawFoldControls();
1117
1120
  // No session: the start page hides the info bar (the storage path is on
1118
1121
  // #/sessions). The session clock ticks every second while a turn runs:
1119
1122
  // lit writes its text alone, the bar's other nodes (a tooltip, a button
@@ -1285,6 +1288,8 @@ const streamHandlers = createStreamHandlers({
1285
1288
  composer: {
1286
1289
  empty: () => !promptEl.value.trim() && !chips.length,
1287
1290
  refill: (text) => {
1291
+ // A failed turn's line back: seen, so the composer opens (this visit).
1292
+ openForVisit();
1288
1293
  promptEl.value = text;
1289
1294
  fitPrompt();
1290
1295
  },
@@ -1452,6 +1457,8 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1452
1457
  // (and images) go back into the composer, without replacing text typed
1453
1458
  // since (4.13). The composer still holds the text just sent: it goes.
1454
1459
  const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
1460
+ // A restored line must be seen: the composer opens for this visit.
1461
+ if (restored?.refill && sentTo === selected) openForVisit();
1455
1462
  promptEl.value = composerAfterAck({ sent: line, current: promptEl.value, refill: restored?.refill || "" });
1456
1463
  fitPrompt();
1457
1464
  clearChips();
@@ -1488,7 +1495,7 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1488
1495
  showToast(cantText("send", e), null, { sticky: true });
1489
1496
  } finally {
1490
1497
  actionBtn.disabled = current.owner === "tui";
1491
- promptEl.focus();
1498
+ focusPrompt();
1492
1499
  }
1493
1500
  }
1494
1501
 
@@ -1513,7 +1520,8 @@ const composerCommands = createComposerCommands({
1513
1520
  clear: () => clearComposer(),
1514
1521
  done: () => {
1515
1522
  actionBtn.disabled = current.owner === "tui";
1516
- promptEl.focus();
1523
+ // A chip's command (the info bar) leaves a folded composer folded.
1524
+ focusPrompt({ open: false });
1517
1525
  },
1518
1526
  },
1519
1527
  });
@@ -1530,6 +1538,7 @@ function clearComposer() {
1530
1538
  async function handleCancel() {
1531
1539
  if (!selected) return;
1532
1540
  cancelBtn.disabled = true;
1541
+ infoCancelBtn.disabled = true;
1533
1542
  try {
1534
1543
  await cancelTurn(selected);
1535
1544
  } catch (e) {
@@ -1537,6 +1546,7 @@ async function handleCancel() {
1537
1546
  if (e.code !== "not_running") alert(e.message);
1538
1547
  } finally {
1539
1548
  cancelBtn.disabled = false;
1549
+ infoCancelBtn.disabled = false;
1540
1550
  }
1541
1551
  }
1542
1552
 
@@ -1585,7 +1595,7 @@ const modelPicker = createModelPicker({
1585
1595
  renderNewChatLlmContext();
1586
1596
  renderNewChatThinking();
1587
1597
  }
1588
- promptEl.focus();
1598
+ focusPrompt();
1589
1599
  },
1590
1600
  });
1591
1601
  // A pick on a fresh link: /model in its worker (its command bubble and the
@@ -1677,6 +1687,7 @@ let chips = [];
1677
1687
  function renderChips() {
1678
1688
  render(chipsTemplate(chips), chipsEl);
1679
1689
  chipsEl.hidden = !chips.length;
1690
+ drawFoldControls();
1680
1691
  }
1681
1692
 
1682
1693
  function setChips(list) {
@@ -1693,7 +1704,7 @@ function addImageFiles(files) {
1693
1704
  if (!files.length) return false;
1694
1705
  for (const file of files) chips.push({ file, name: chipName(file, chips.length), src: URL.createObjectURL(file) });
1695
1706
  renderChips();
1696
- promptEl.focus();
1707
+ focusPrompt();
1697
1708
  return true;
1698
1709
  }
1699
1710
 
@@ -1722,33 +1733,95 @@ function fitPrompt() {
1722
1733
  promptEl.style.overflowY = scrolls ? "auto" : "hidden";
1723
1734
  }
1724
1735
 
1725
- const composeGrip = $("#composeGrip");
1736
+ // A drag down past the box folds the composer on release (a session only: the
1737
+ // start page's grip only resizes); from folded, a pull up opens it and the box
1738
+ // follows the pointer in the same gesture, a tap opens it and focuses the box.
1739
+ // No click listener: a click still fires after pointerdown's preventDefault
1740
+ // and would toggle twice.
1726
1741
  composeGrip.addEventListener("pointerdown", (e) => {
1727
1742
  e.preventDefault();
1728
1743
  composeGrip.setPointerCapture(e.pointerId);
1729
1744
  composeGrip.classList.add("dragging");
1730
- const startY = e.clientY;
1731
- const startH = promptEl.offsetHeight;
1745
+ const tapY = e.clientY;
1746
+ let startY = tapY;
1747
+ let startH = promptEl.offsetHeight;
1748
+ let opened = false;
1732
1749
  const move = (ev) => {
1733
- promptFloor = gripFloor(startH + (startY - ev.clientY), window.innerHeight, promptMinPx());
1750
+ if (selected && isComposerFolded()) {
1751
+ if (startY - ev.clientY < GRIP_OPEN_PX) return;
1752
+ setComposerFolded(false);
1753
+ opened = true;
1754
+ // Re-based after the unfold (its fitPrompt sized the box: the minimum
1755
+ // when empty, the text's height with a draft).
1756
+ startH = promptEl.offsetHeight;
1757
+ startY = ev.clientY;
1758
+ return;
1759
+ }
1760
+ const requested = startH + (startY - ev.clientY);
1761
+ promptFloor = gripFloor(requested, window.innerHeight, promptMinPx());
1734
1762
  fitPrompt();
1763
+ const willFold = !!selected && gripFolds({ requestedPx: requested, shownPx: promptEl.offsetHeight });
1764
+ if (willFold !== gripWillFold) {
1765
+ gripWillFold = willFold;
1766
+ composeGrip.classList.toggle("will-fold", willFold);
1767
+ drawFoldControls();
1768
+ }
1735
1769
  };
1736
- const up = () => {
1770
+ const end = () => {
1737
1771
  composeGrip.classList.remove("dragging");
1738
1772
  composeGrip.removeEventListener("pointermove", move);
1739
1773
  composeGrip.removeEventListener("pointerup", up);
1740
- composeGrip.removeEventListener("pointercancel", up);
1741
- try { localStorage.setItem(GRIP_KEY, String(promptFloor || "")); } catch (_) {}
1774
+ composeGrip.removeEventListener("pointercancel", cancel);
1775
+ const folds = gripWillFold;
1776
+ gripWillFold = false;
1777
+ composeGrip.classList.remove("will-fold");
1778
+ return folds;
1779
+ };
1780
+ const up = (ev) => {
1781
+ const folds = end();
1782
+ if (folds) {
1783
+ // The next opening starts at the minimum (or the text's height).
1784
+ promptFloor = null;
1785
+ try { localStorage.removeItem(GRIP_KEY); } catch (_) {}
1786
+ setComposerFolded(true);
1787
+ } else if (selected && isComposerFolded()) {
1788
+ if (Math.abs(ev.clientY - tapY) < GRIP_OPEN_PX) {
1789
+ setComposerFolded(false);
1790
+ promptEl.focus();
1791
+ }
1792
+ } else {
1793
+ try { localStorage.setItem(GRIP_KEY, String(promptFloor || "")); } catch (_) {}
1794
+ // A pull that opened the box leaves the focus in it, as a tap does.
1795
+ if (opened) promptEl.focus();
1796
+ }
1797
+ drawFoldControls();
1798
+ };
1799
+ const cancel = () => {
1800
+ end();
1801
+ drawFoldControls();
1742
1802
  };
1743
1803
  composeGrip.addEventListener("pointermove", move);
1744
1804
  composeGrip.addEventListener("pointerup", up);
1745
- composeGrip.addEventListener("pointercancel", up);
1805
+ composeGrip.addEventListener("pointercancel", cancel);
1746
1806
  });
1747
1807
  composeGrip.addEventListener("dblclick", () => {
1808
+ if (selected && isComposerFolded()) return;
1748
1809
  promptFloor = null;
1749
1810
  try { localStorage.removeItem(GRIP_KEY); } catch (_) {}
1750
1811
  fitPrompt();
1751
1812
  });
1813
+ // Enter or Space on the focused handle folds, or opens and focuses the box
1814
+ // (a session only; preventDefault: Space would scroll the page).
1815
+ composeGrip.addEventListener("keydown", (e) => {
1816
+ if (!selected || (e.key !== "Enter" && e.key !== " ")) return;
1817
+ e.preventDefault();
1818
+ if (isComposerFolded()) {
1819
+ setComposerFolded(false);
1820
+ promptEl.focus();
1821
+ } else {
1822
+ setComposerFolded(true);
1823
+ }
1824
+ });
1752
1825
  promptEl.addEventListener("input", fitPrompt);
1753
1826
  window.addEventListener("resize", fitPrompt);
1754
1827
  fitPrompt();
@@ -1831,6 +1904,104 @@ document.addEventListener("keydown", (e) => {
1831
1904
  if (e.key === "Escape" && !lightboxEl.hidden) lightboxEl.hidden = true;
1832
1905
  });
1833
1906
  cancelBtn.addEventListener("click", () => handleCancel());
1907
+ infoCancelBtn.addEventListener("click", () => handleCancel());
1908
+
1909
+ // ── Composer fold (composer_fold.js) ───────────────────────────────────────
1910
+ // Dragging the composer's grip down folds the session's composer away; the
1911
+ // grip, now the handle on the info bar's top edge, pulled up or clicked, or
1912
+ // typing on the page, brings it back. The user's own choice is saved per
1913
+ // browser; an archived session opens folded for its visit, and expanding
1914
+ // there saves nothing. The page's own focus or a restored line opens it
1915
+ // for the visit only. The start page never folds.
1916
+ function composerFoldSaved() {
1917
+ try {
1918
+ return localStorage.getItem(COMPOSER_FOLD_KEY) === "1";
1919
+ } catch (_) {
1920
+ return false;
1921
+ }
1922
+ }
1923
+ function isComposerFolded() {
1924
+ return !!selected && composerFolded({ saved: composerFoldSaved(), archivedVisit: foldVisit.archived, visitOpen: foldVisit.open });
1925
+ }
1926
+ function applyComposerFolded() {
1927
+ const folded = isComposerFolded();
1928
+ const was = document.body.classList.contains("composer-folded");
1929
+ document.body.classList.toggle("composer-folded", folded);
1930
+ // Its height was measured while hidden.
1931
+ if (was && !folded) fitPrompt();
1932
+ drawFoldControls();
1933
+ }
1934
+ // The user's own fold or unfold (the grip, typing): saved, unless this
1935
+ // is an archived visit (both are for the visit then).
1936
+ function setComposerFolded(folded) {
1937
+ if (foldVisit.archived) {
1938
+ foldVisit.open = !folded;
1939
+ } else {
1940
+ try { localStorage.setItem(COMPOSER_FOLD_KEY, folded ? "1" : "0"); } catch (_) {}
1941
+ foldVisit.open = false;
1942
+ }
1943
+ applyComposerFolded();
1944
+ }
1945
+ // The page opens it for this visit: nothing saved.
1946
+ function openForVisit() {
1947
+ if (!isComposerFolded()) return;
1948
+ foldVisit.open = true;
1949
+ applyComposerFolded();
1950
+ }
1951
+ // Every focus of the message box: in a session it opens a folded composer
1952
+ // for the visit first (+open+ false: only when it shows already).
1953
+ function focusPrompt({ open = true } = {}) {
1954
+ if (selected && isComposerFolded()) {
1955
+ if (!open) return;
1956
+ openForVisit();
1957
+ }
1958
+ promptEl.focus();
1959
+ }
1960
+ // The grip as the fold's handle (its title, marks, aria-expanded) and Stop turn
1961
+ // while folded. The start page's grip only resizes: no role, no fold state.
1962
+ function drawFoldControls() {
1963
+ const folded = isComposerFolded();
1964
+ infoCancelBtn.style.display = folded && liveTurn.running ? "" : "none";
1965
+ if (!selected) {
1966
+ composeGrip.removeAttribute("role");
1967
+ composeGrip.removeAttribute("aria-expanded");
1968
+ composeGrip.title = gripStartTitle;
1969
+ for (const c of ["folded", "draft", "waiting"]) composeGrip.classList.remove(c);
1970
+ return;
1971
+ }
1972
+ const id = selected;
1973
+ const waiting = folded && !!(waitingOn(allSessions.find((s) => s.id === id)) || questionCards.pendingQuestion());
1974
+ const h = foldHandle({ folded, draft: !!(promptEl.value.trim() || chips.length), waiting });
1975
+ composeGrip.title = gripWillFold ? "Release to hide the message box" : h.title;
1976
+ composeGrip.setAttribute("role", "button");
1977
+ composeGrip.setAttribute("aria-expanded", folded ? "false" : "true");
1978
+ composeGrip.classList.toggle("folded", h.folded);
1979
+ composeGrip.classList.toggle("draft", h.draft);
1980
+ composeGrip.classList.toggle("waiting", h.waiting);
1981
+ }
1982
+ promptEl.addEventListener("input", () => drawFoldControls());
1983
+ // Typing on the page opens a folded composer: the key lands in the box
1984
+ // (the class goes before the focus, so the browser types it there).
1985
+ document.addEventListener("keydown", (e) => {
1986
+ if (!isComposerFolded()) return;
1987
+ if (!opensOnKey(e, { active: document.activeElement, allView: allViewOpen(), lightbox: !lightboxEl.hidden })) return;
1988
+ setComposerFolded(false);
1989
+ promptEl.focus();
1990
+ });
1991
+ // A file dropped on the folded info bar: open (for the visit), then add it.
1992
+ const infoBarEl = $("#infoBar");
1993
+ infoBarEl.addEventListener("dragover", (e) => {
1994
+ if (!isComposerFolded() || ![...(e.dataTransfer?.types || [])].includes("Files")) return;
1995
+ e.preventDefault();
1996
+ });
1997
+ infoBarEl.addEventListener("drop", (e) => {
1998
+ if (!isComposerFolded()) return;
1999
+ const files = imageFiles(e.dataTransfer);
2000
+ if (!files.length) return;
2001
+ e.preventDefault();
2002
+ openForVisit();
2003
+ addImageFiles(files);
2004
+ });
1834
2005
  // The / autocomplete (command_list.js): the session's commands while the
1835
2006
  // composer holds one "/word".
1836
2007
  const commandList = createCommandList({
@@ -1959,9 +2130,10 @@ async function continueSelected() {
1959
2130
  // continue's 201.
1960
2131
  function fillOpener(created) {
1961
2132
  if (promptEl.value.trim() || chips.length) return;
2133
+ openForVisit();
1962
2134
  promptEl.value = openerText(created);
1963
2135
  fitPrompt();
1964
- promptEl.focus();
2136
+ focusPrompt();
1965
2137
  promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
1966
2138
  }
1967
2139
 
@@ -2053,33 +2225,21 @@ function placeToast() {
2053
2225
  else root.removeProperty("--toast-bottom");
2054
2226
  }
2055
2227
  }
2228
+ const toasts = createToasts({ el: $("#toast"), onChange: placeToast });
2056
2229
  // The model panel opening, closing or changing height (its list filters).
2057
2230
  new ResizeObserver(() => placeToast()).observe(document.getElementById("modelPanel"));
2058
2231
 
2059
- // sticky: stays until its action or its ✕ (else gone after 6 s). +text+:
2060
- // words, or a template (chains.js openChildrenTemplate: a link in it, a
2061
- // delegate a continue waits for, opens that session and hides the toast).
2062
- let toastTimer = null;
2063
- function showToast(text, action = null, { sticky = false } = {}) {
2064
- const el = $("#toast");
2065
- const hide = () => { el.hidden = true; };
2066
- render(html`<span @click=${(e) => { if (e.target.closest?.("a[href]")) hide(); }}>${text}</span>${action
2067
- ? html`<button type="button" class="toast-action" @click=${() => { hide(); action.run(); }}>${action.label}</button>`
2068
- : nothing}${sticky ? html`<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss" @click=${hide}>✕</button>` : nothing}`, el);
2069
- el.hidden = false;
2070
- placeToast();
2071
- clearTimeout(toastTimer);
2072
- if (!sticky) toastTimer = setTimeout(hide, 6000);
2073
- }
2232
+ function showToast(text, action = null, options = {}) { toasts.show(text, action, options); }
2074
2233
 
2075
2234
  // Annotate (annotate_bar.js): a selection in the conversation offers
2076
2235
  // "Annotate" and the presets, which append a quote to the composer, and the
2077
2236
  // session's plugins' pills (chi.annotation, annotate_actions.js), which fill
2078
2237
  // the composer with their command line and the quote, or send it at once.
2079
2238
  function fillComposer(text) {
2239
+ openForVisit();
2080
2240
  promptEl.value = text;
2081
2241
  fitPrompt();
2082
- promptEl.focus();
2242
+ focusPrompt();
2083
2243
  promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
2084
2244
  promptEl.scrollTop = promptEl.scrollHeight;
2085
2245
  }
@@ -0,0 +1,58 @@
1
+ // The session's composer can fold away for room to read (the info bar under
2
+ // it stays and becomes the page's bottom edge). The user's own fold or
3
+ // unfold is saved per browser (chi_composer_collapsed); an archived
4
+ // session opens folded for that visit; the page expanding it by itself (a
5
+ // focus, a restored line, a quote) opens it for the visit only. A visit is
6
+ // one select() call.
7
+
8
+ export const COMPOSER_FOLD_KEY = "chi_composer_collapsed";
9
+
10
+ // Whether the composer is folded: the saved choice or an archived visit,
11
+ // unless this visit opened it.
12
+ export function composerFolded({ saved, archivedVisit, visitOpen }) {
13
+ if (visitOpen) return false;
14
+ return !!saved || !!archivedVisit;
15
+ }
16
+
17
+ // The composer's grip while it is the fold's handle. Expanded it only says
18
+ // how to resize and hide; folded it is drawn as the handle on the info
19
+ // bar's top edge, pulled up or clicked to open, and marked: `draft` (the
20
+ // box holds text or images: folding never clears them: the accent),
21
+ // `waiting` (the attention colour: the session waits on the user, and its
22
+ // card may not take the focus while a draft is there). The title carries
23
+ // the words.
24
+ // @return {title, folded, draft, waiting}
25
+ export function foldHandle({ folded, draft, waiting }) {
26
+ if (!folded) {
27
+ return { title: "Drag to make the message box taller, down to hide it (double-click resets)", folded: false, draft: false, waiting: false };
28
+ }
29
+ const marked = !!draft;
30
+ let title = "Pull up or click to write";
31
+ if (marked) title += " · a draft is in it";
32
+ if (waiting) title += " · the session waits on you";
33
+ return { title, folded: true, draft: marked, waiting: !!waiting };
34
+ }
35
+
36
+ // Whether a key pressed on the page (the composer folded) opens it and
37
+ // lands in the message box: a printable character, not Space (it scrolls,
38
+ // and the turn marker and the stage headline use it), no Ctrl/Meta/Alt,
39
+ // not taken already, not mid-composition, and not while the focus is in a
40
+ // field of its own (an input, textarea, select or contenteditable), the
41
+ // all-sessions view or the lightbox is open. An IME's first key arrives as
42
+ // "Process": it doesn't open it.
43
+ // +active+: document.activeElement.
44
+ export function opensOnKey(e, { active, allView, lightbox }) {
45
+ if (!e || e.defaultPrevented || e.isComposing) return false;
46
+ if (e.ctrlKey || e.metaKey || e.altKey) return false;
47
+ const key = typeof e.key === "string" ? e.key : "";
48
+ if ([...key].length !== 1 || key === " ") return false;
49
+ if (allView || lightbox) return false;
50
+ return !editable(active);
51
+ }
52
+
53
+ function editable(el) {
54
+ if (!el) return false;
55
+ if (el.isContentEditable) return true;
56
+ const tag = (el.tagName || "").toUpperCase();
57
+ return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
58
+ }
@@ -4,6 +4,8 @@ export const PROMPT_MIN_PX = 72; // about three lines of the 15 px field: the st
4
4
  export const PROMPT_LINE_PX = 26; // one line (15 px × 1.45 + 4 padding): in a session
5
5
  const PROMPT_CAP_SHARE = 0.35; // of the window's height, for growing with text
6
6
  const PROMPT_GRIP_SHARE = 0.5; // of the window's height, for the grip
7
+ export const GRIP_FOLD_PX = 32; // a drag down this far past what the box shows folds the composer (on release)
8
+ export const GRIP_OPEN_PX = 8; // a drag up this far from the folded handle opens it; less is a tap
7
9
 
8
10
  // contentPx: the text's height (scrollHeight plus borders); floorPx: the
9
11
  // grip's height, or null; minPx: the empty box's height.
@@ -18,3 +20,11 @@ export function promptHeight({ contentPx, floorPx = null, viewportPx, minPx = PR
18
20
  export function gripFloor(px, viewportPx, minPx = PROMPT_MIN_PX) {
19
21
  return Math.round(Math.min(Math.max(px, minPx), viewportPx * PROMPT_GRIP_SHARE));
20
22
  }
23
+
24
+ // Whether a grip drag asks to fold the composer: it wants the box at least
25
+ // GRIP_FOLD_PX less than the box actually shows (+shownPx+, after the box
26
+ // fitted itself: with text in it the content sets the height, so measuring
27
+ // against the floor would fold a tall box on a short pull).
28
+ export function gripFolds({ requestedPx, shownPx }) {
29
+ return shownPx - requestedPx >= GRIP_FOLD_PX;
30
+ }
@@ -208,6 +208,17 @@ export function modelSegmentPrefillText(row) {
208
208
  return `switch prefill ${countK(tokens)} uncached prompt${cost ? ` ≈${cost}` : ""}`;
209
209
  }
210
210
 
211
+ // A ref past +max+ characters with its middle cut to one "…":
212
+ // the tooltip is a native title, which grows as wide as its longest line.
213
+ // The host's start and the model's end stay; the model chip has the full ref.
214
+ const TIP_REF_MAX = 40;
215
+ export function shortRef(text, max = TIP_REF_MAX) {
216
+ const chars = [...text];
217
+ if (chars.length <= max) return text;
218
+ const head = Math.floor((max - 1) / 3);
219
+ return `${chars.slice(0, head).join("")}…${chars.slice(chars.length - (max - 1 - head)).join("")}`;
220
+ }
221
+
211
222
  // The tooltip's rows per model, with ≥ 2 (one model: none): "model ·
212
223
  // turns · in / out · cost", a switch's prefill under its row.
213
224
  function modelSegmentLines(segments) {
@@ -215,7 +226,7 @@ function modelSegmentLines(segments) {
215
226
  const lines = ["per model:"];
216
227
  for (const row of segments) {
217
228
  const turns = num(row.turns) || 0;
218
- const parts = [modelSegmentLabel(row), `${turns} ${turns === 1 ? "turn" : "turns"}`, modelSegmentTokensText(row)];
229
+ const parts = [shortRef(modelSegmentLabel(row)), `${turns} ${turns === 1 ? "turn" : "turns"}`, modelSegmentTokensText(row)];
219
230
  const cost = modelSegmentCostText(row);
220
231
  if (cost) parts.push(cost);
221
232
  lines.push(` ${parts.join(" · ")}`);