samagotchi 0.77.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -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 +1 -0
  7. data/docs/internals/prompt-caching.md +3 -1
  8. data/docs/sessions.md +1 -0
  9. data/lib/samagotchi/broadcast/recipients.rb +2 -2
  10. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  11. data/lib/samagotchi/child_move.rb +2 -2
  12. data/lib/samagotchi/child_reports.rb +2 -2
  13. data/lib/samagotchi/children_status.rb +2 -2
  14. data/lib/samagotchi/llm/anthropic_assembly.rb +14 -0
  15. data/lib/samagotchi/llm/anthropic_messages.rb +210 -13
  16. data/lib/samagotchi/llm/anthropic_wire.rb +22 -0
  17. data/lib/samagotchi/llm/chat_loop.rb +49 -2
  18. data/lib/samagotchi/llm/errors.rb +6 -4
  19. data/lib/samagotchi/llm/http.rb +2 -1
  20. data/lib/samagotchi/llm/openai_chat.rb +12 -2
  21. data/lib/samagotchi/note_delivery.rb +2 -2
  22. data/lib/samagotchi/plugin/sessions.rb +2 -2
  23. data/lib/samagotchi/reply_wait.rb +2 -3
  24. data/lib/samagotchi/session_manager.rb +0 -1
  25. data/lib/samagotchi/session_metrics/totals.rb +4 -3
  26. data/lib/samagotchi/session_metrics.rb +9 -0
  27. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  28. data/lib/samagotchi/terminal_ui.rb +2 -2
  29. data/lib/samagotchi/tools/delegate.rb +2 -2
  30. data/lib/samagotchi/tools/delegate_result.rb +2 -2
  31. data/lib/samagotchi/tools/list_sessions.rb +2 -2
  32. data/lib/samagotchi/version.rb +1 -1
  33. data/lib/samagotchi/web/public/app.js +217 -19
  34. data/lib/samagotchi/web/public/composer_fold.js +58 -0
  35. data/lib/samagotchi/web/public/composer_size.js +10 -0
  36. data/lib/samagotchi/web/public/index.html +27 -3
  37. data/lib/samagotchi/worker.rb +1 -0
  38. metadata +2 -1
@@ -56,7 +56,7 @@ import { actionLine, annotationActions, composerWithAction } from "./annotate_ac
56
56
  import { copyText, installCopy } from "./copy.js";
57
57
  import { createQuestionCards } from "./question_cards.js";
58
58
  import { splitSnapshotCards } from "./card.js";
59
- 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";
60
60
  import { createCommandList } from "./command_list.js";
61
61
  import { createPromptHistory } from "./prompt_history.js";
62
62
  import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
@@ -64,6 +64,8 @@ import { scopeDir, scopeTemplate } from "./scope.js";
64
64
  import { html, nothing, render } from "./vendor/lit-html.js";
65
65
  import { easeScrollTo, FOLLOW_MS, shouldFollowScroll } from "./scroll.js";
66
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";
67
69
  import { applySessionEvent, inTree, sortedByUpdated } from "./sessions_list.js";
68
70
  import { createNotifications } from "./notifications.js";
69
71
  import { tabTitle, topBarTitle } from "./session_title.js";
@@ -132,7 +134,7 @@ const llmContextNewControl = createLlmContextControl({
132
134
  choose: (next) => {
133
135
  newChatLlmContext = next;
134
136
  renderNewChatLlmContext();
135
- promptEl.focus();
137
+ focusPrompt();
136
138
  },
137
139
  id: "llmContextNewPopover",
138
140
  anchor: "#llmContextNew",
@@ -168,7 +170,7 @@ const thinkingNewControl = createThinkingControl({
168
170
  choose: (next) => {
169
171
  newChatThinking = next;
170
172
  renderNewChatThinking();
171
- promptEl.focus();
173
+ focusPrompt();
172
174
  },
173
175
  id: "thinkingNewPopover",
174
176
  anchor: "#thinkingNew",
@@ -202,6 +204,19 @@ let pageEditorUrl = null;
202
204
  const promptEl = $("#prompt");
203
205
  const actionBtn = $("#actionBtn");
204
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();
205
220
  const modelPickEl = $("#modelPick");
206
221
  const composeHintEl = $("#composeHint");
207
222
 
@@ -739,9 +754,11 @@ function clearSelection() {
739
754
  closeStream();
740
755
  setMarkdownWarning(null);
741
756
  setCapabilities(null, null);
757
+ foldVisit = newFoldVisit();
758
+ applyComposerFolded();
742
759
  updateComposerMode();
743
760
  updateInfoBar();
744
- promptEl.focus();
761
+ focusPrompt();
745
762
  }
746
763
 
747
764
  // A short line on the start page (the open session went away); gone at the
@@ -774,11 +791,14 @@ async function select(id) {
774
791
  closeStream();
775
792
  resetSessionState();
776
793
  resetTurnView();
794
+ foldVisit = newFoldVisit();
795
+ applyComposerFolded();
777
796
  updateInfoBar();
778
797
  contextChips.load(id);
779
798
  updateComposerMode();
780
799
  await resync();
781
- promptEl.focus();
800
+ // A folded composer stays folded: typing on the page opens it.
801
+ focusPrompt({ open: false });
782
802
  }
783
803
 
784
804
  // Clear everything that belongs to one turn's rendering.
@@ -815,6 +835,13 @@ async function resync({ endText = null } = {}) {
815
835
  return;
816
836
  }
817
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
+ }
818
845
  const kept = keptCommandBubbles(historyEl, id);
819
846
  resetTurnView();
820
847
  renderSessionView(data, { kept });
@@ -1089,6 +1116,7 @@ function updateInfoBar() {
1089
1116
  archiveBtn.title = buttons.archive.title;
1090
1117
  }
1091
1118
  if (stopBtn) stopBtn.style.display = buttons.stop.shown ? "" : "none";
1119
+ drawFoldControls();
1092
1120
  // No session: the start page hides the info bar (the storage path is on
1093
1121
  // #/sessions). The session clock ticks every second while a turn runs:
1094
1122
  // lit writes its text alone, the bar's other nodes (a tooltip, a button
@@ -1260,6 +1288,8 @@ const streamHandlers = createStreamHandlers({
1260
1288
  composer: {
1261
1289
  empty: () => !promptEl.value.trim() && !chips.length,
1262
1290
  refill: (text) => {
1291
+ // A failed turn's line back: seen, so the composer opens (this visit).
1292
+ openForVisit();
1263
1293
  promptEl.value = text;
1264
1294
  fitPrompt();
1265
1295
  },
@@ -1427,6 +1457,8 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1427
1457
  // (and images) go back into the composer, without replacing text typed
1428
1458
  // since (4.13). The composer still holds the text just sent: it goes.
1429
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();
1430
1462
  promptEl.value = composerAfterAck({ sent: line, current: promptEl.value, refill: restored?.refill || "" });
1431
1463
  fitPrompt();
1432
1464
  clearChips();
@@ -1463,7 +1495,7 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1463
1495
  showToast(cantText("send", e), null, { sticky: true });
1464
1496
  } finally {
1465
1497
  actionBtn.disabled = current.owner === "tui";
1466
- promptEl.focus();
1498
+ focusPrompt();
1467
1499
  }
1468
1500
  }
1469
1501
 
@@ -1488,7 +1520,8 @@ const composerCommands = createComposerCommands({
1488
1520
  clear: () => clearComposer(),
1489
1521
  done: () => {
1490
1522
  actionBtn.disabled = current.owner === "tui";
1491
- promptEl.focus();
1523
+ // A chip's command (the info bar) leaves a folded composer folded.
1524
+ focusPrompt({ open: false });
1492
1525
  },
1493
1526
  },
1494
1527
  });
@@ -1505,6 +1538,7 @@ function clearComposer() {
1505
1538
  async function handleCancel() {
1506
1539
  if (!selected) return;
1507
1540
  cancelBtn.disabled = true;
1541
+ infoCancelBtn.disabled = true;
1508
1542
  try {
1509
1543
  await cancelTurn(selected);
1510
1544
  } catch (e) {
@@ -1512,6 +1546,7 @@ async function handleCancel() {
1512
1546
  if (e.code !== "not_running") alert(e.message);
1513
1547
  } finally {
1514
1548
  cancelBtn.disabled = false;
1549
+ infoCancelBtn.disabled = false;
1515
1550
  }
1516
1551
  }
1517
1552
 
@@ -1560,7 +1595,7 @@ const modelPicker = createModelPicker({
1560
1595
  renderNewChatLlmContext();
1561
1596
  renderNewChatThinking();
1562
1597
  }
1563
- promptEl.focus();
1598
+ focusPrompt();
1564
1599
  },
1565
1600
  });
1566
1601
  // A pick on a fresh link: /model in its worker (its command bubble and the
@@ -1652,6 +1687,7 @@ let chips = [];
1652
1687
  function renderChips() {
1653
1688
  render(chipsTemplate(chips), chipsEl);
1654
1689
  chipsEl.hidden = !chips.length;
1690
+ drawFoldControls();
1655
1691
  }
1656
1692
 
1657
1693
  function setChips(list) {
@@ -1668,7 +1704,7 @@ function addImageFiles(files) {
1668
1704
  if (!files.length) return false;
1669
1705
  for (const file of files) chips.push({ file, name: chipName(file, chips.length), src: URL.createObjectURL(file) });
1670
1706
  renderChips();
1671
- promptEl.focus();
1707
+ focusPrompt();
1672
1708
  return true;
1673
1709
  }
1674
1710
 
@@ -1697,33 +1733,95 @@ function fitPrompt() {
1697
1733
  promptEl.style.overflowY = scrolls ? "auto" : "hidden";
1698
1734
  }
1699
1735
 
1700
- 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.
1701
1741
  composeGrip.addEventListener("pointerdown", (e) => {
1702
1742
  e.preventDefault();
1703
1743
  composeGrip.setPointerCapture(e.pointerId);
1704
1744
  composeGrip.classList.add("dragging");
1705
- const startY = e.clientY;
1706
- const startH = promptEl.offsetHeight;
1745
+ const tapY = e.clientY;
1746
+ let startY = tapY;
1747
+ let startH = promptEl.offsetHeight;
1748
+ let opened = false;
1707
1749
  const move = (ev) => {
1708
- 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());
1709
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
+ }
1710
1769
  };
1711
- const up = () => {
1770
+ const end = () => {
1712
1771
  composeGrip.classList.remove("dragging");
1713
1772
  composeGrip.removeEventListener("pointermove", move);
1714
1773
  composeGrip.removeEventListener("pointerup", up);
1715
- composeGrip.removeEventListener("pointercancel", up);
1716
- 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();
1717
1802
  };
1718
1803
  composeGrip.addEventListener("pointermove", move);
1719
1804
  composeGrip.addEventListener("pointerup", up);
1720
- composeGrip.addEventListener("pointercancel", up);
1805
+ composeGrip.addEventListener("pointercancel", cancel);
1721
1806
  });
1722
1807
  composeGrip.addEventListener("dblclick", () => {
1808
+ if (selected && isComposerFolded()) return;
1723
1809
  promptFloor = null;
1724
1810
  try { localStorage.removeItem(GRIP_KEY); } catch (_) {}
1725
1811
  fitPrompt();
1726
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
+ });
1727
1825
  promptEl.addEventListener("input", fitPrompt);
1728
1826
  window.addEventListener("resize", fitPrompt);
1729
1827
  fitPrompt();
@@ -1806,6 +1904,104 @@ document.addEventListener("keydown", (e) => {
1806
1904
  if (e.key === "Escape" && !lightboxEl.hidden) lightboxEl.hidden = true;
1807
1905
  });
1808
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
+ });
1809
2005
  // The / autocomplete (command_list.js): the session's commands while the
1810
2006
  // composer holds one "/word".
1811
2007
  const commandList = createCommandList({
@@ -1934,9 +2130,10 @@ async function continueSelected() {
1934
2130
  // continue's 201.
1935
2131
  function fillOpener(created) {
1936
2132
  if (promptEl.value.trim() || chips.length) return;
2133
+ openForVisit();
1937
2134
  promptEl.value = openerText(created);
1938
2135
  fitPrompt();
1939
- promptEl.focus();
2136
+ focusPrompt();
1940
2137
  promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
1941
2138
  }
1942
2139
 
@@ -2039,9 +2236,10 @@ function showToast(text, action = null, options = {}) { toasts.show(text, action
2039
2236
  // session's plugins' pills (chi.annotation, annotate_actions.js), which fill
2040
2237
  // the composer with their command line and the quote, or send it at once.
2041
2238
  function fillComposer(text) {
2239
+ openForVisit();
2042
2240
  promptEl.value = text;
2043
2241
  fitPrompt();
2044
- promptEl.focus();
2242
+ focusPrompt();
2045
2243
  promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
2046
2244
  promptEl.scrollTop = promptEl.scrollHeight;
2047
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
+ }
@@ -590,10 +590,25 @@ body.has-session #composer{flex-wrap:wrap;row-gap:6px}
590
590
  #lightbox[hidden]{display:none}
591
591
  #lightbox img{max-width:96vw;max-height:94vh;border-radius:6px}
592
592
  #composer textarea{flex:1 1 auto;min-height:72px;resize:none;background:transparent;border:0;color:var(--text);padding:2px;border-radius:0;font-size:15px;line-height:1.45}
593
- #composeGrip{position:absolute;left:0;right:0;top:-5px;height:10px;cursor:ns-resize;z-index:2;touch-action:none}
593
+ #composeGrip{position:relative;height:10px;margin:-5px 0;cursor:ns-resize;z-index:2;touch-action:none}
594
594
  #composeGrip::after{content:"";position:absolute;left:50%;top:3px;width:36px;height:4px;margin-left:-18px;border-radius:2px;background:var(--border-strong);opacity:0;transition:opacity .12s ease}
595
- #composer:hover #composeGrip::after,#composeGrip.dragging::after{opacity:1}
595
+ #composeGrip:hover::after,#composeGrip:has(+ #composer:hover)::after,#composeGrip.dragging::after{opacity:1}
596
596
  #composeGrip:hover::after,#composeGrip.dragging::after{background:var(--accent)}
597
+ /* Folded (app.js, composer_fold.js): the grip is the handle on the info bar's top edge, drawn always, wider so it reads as a control;
598
+ draft = the accent, waiting = the attention colour; .will-fold = a drag past the fold threshold (released it hides the box). */
599
+ body.has-session.composer-folded #composeGrip::after{opacity:1;width:48px;margin-left:-24px}
600
+ #composeGrip.draft::after{background:var(--accent)}
601
+ #composeGrip.waiting::after{background:var(--warn)}
602
+ #composeGrip.will-fold::after{opacity:1;background:var(--accent)}
603
+ #composeGrip:focus-visible{outline:none}
604
+ #composeGrip:focus-visible::after{opacity:1;box-shadow:0 0 0 2px var(--accent-ring)}
605
+ /* A phone has no hover: the pill is always there and the hit zone is taller, ~96 px wide and centred (full width would cover the
606
+ info bar's buttons while folded, the stage card's bottom in the stage view); it reaches 7 px below the edge, the info bar's
607
+ border and padding, so it never covers a button of the bar. */
608
+ @media(max-width:600px){
609
+ #composeGrip{width:96px;height:26px;margin:-19px auto -7px}
610
+ #composeGrip::after{top:17px;opacity:1}
611
+ }
597
612
  #composer textarea::placeholder{color:var(--text-faint)}
598
613
  /* Key hint: its line is always reserved, so focusing never shifts the layout. */
599
614
  #composeHint{order:1;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px;line-height:14px;color:var(--text-faint);padding-left:2px;opacity:0;transition:opacity .12s ease;user-select:none}
@@ -851,6 +866,12 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
851
866
  #infoBar #infoArchiveBtn:hover{color:var(--text);background:var(--bg-hover)}
852
867
  #infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
853
868
  #infoBar #infoDeleteBtn:hover{color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
869
+ /* The composer folded away (composer_fold.js, the fold button): the info bar is the page's bottom edge.
870
+ Stop turn comes into the bar while folded and a turn runs. */
871
+ body.has-session.composer-folded #composer,body.has-session.composer-folded #initRow{display:none}
872
+ body.has-session.composer-folded #infoBar{border-top-color:transparent}
873
+ #infoBar #infoCancelBtn{background:var(--bg-raised);color:var(--danger-text);border:1px solid color-mix(in srgb,var(--danger) 55%,transparent)}
874
+ #infoBar #infoCancelBtn:hover:not(:disabled){color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
854
875
  /* The LLM context and thinking chips (llm_context_chip.js, thinking_chip.js): model-like chips that open their popovers; accent when the session set its own. */
855
876
  #infoBar .meta .llmctx-chip,#infoBar .meta .think-chip,#infoBar .meta .rh-chip{padding:2px 6px;border-radius:6px;font-size:11px;font-weight:400;border:1px solid transparent;cursor:pointer;font-family:inherit;white-space:nowrap}
856
877
  #infoBar .meta .llmctx-chip:hover,#infoBar .meta .think-chip:hover,#infoBar .meta .rh-chip:hover{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
@@ -932,6 +953,8 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
932
953
  #infoBar>button::before{content:"";display:block;width:14px;height:14px;background:currentColor;-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat}
933
954
  #infoBar #infoArchiveBtn{--icon:var(--icon-archive)}
934
955
  #infoBar #infoArchiveBtn.archived{--icon:var(--icon-unarchive)}
956
+ #infoBar #infoContinueBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 8h9M8.5 4.5 12 8l-3.5 3.5'/%3E%3C/svg%3E")}
957
+ #infoBar #infoCancelBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='4' width='8' height='8' rx='1.5'/%3E%3C/svg%3E")}
935
958
  #infoBar #infoStopBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 2v5.5M4.6 4.4a5 5 0 1 0 6.8 0'/%3E%3C/svg%3E")}
936
959
  #infoBar #infoDeleteBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.5 4.5h11M6 4.5V2.75h4V4.5M4 4.5l.7 8.6a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9l.7-8.6M6.75 7.25v4.5M9.25 7.25v4.5'/%3E%3C/svg%3E")}
937
960
  }
@@ -1208,13 +1231,13 @@ a{color:var(--accent)}
1208
1231
  <div id="lightbox" hidden><img alt=""></div>
1209
1232
  <div id="dock">
1210
1233
  <div id="initRow" role="status" aria-live="polite" hidden></div>
1234
+ <div id="composeGrip" tabindex="0" aria-label="Message box" title="Drag to make the message box taller (double-click resets)"></div>
1211
1235
  <div id="composer" style="position:relative">
1212
1236
  <div id="commandList" role="listbox" aria-label="Commands" hidden></div>
1213
1237
  <div id="modelPanel" hidden>
1214
1238
  <label class="model-search"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5 14 14" stroke-linecap="round"/></svg><input id="modelSearch" type="text" placeholder="Search models" autocomplete="off" spellcheck="false" role="combobox" aria-label="Search models" aria-autocomplete="list" aria-controls="modelList" aria-expanded="false"></label>
1215
1239
  <div id="modelList" role="listbox" aria-label="Models"></div>
1216
1240
  </div>
1217
- <div id="composeGrip" title="Drag to make the message box taller (double-click resets)"></div>
1218
1241
  <div id="deliveryLine" aria-live="off" hidden></div>
1219
1242
  <div class="compose-col">
1220
1243
  <div id="chips" hidden></div>
@@ -1234,6 +1257,7 @@ a{color:var(--accent)}
1234
1257
  <div id="infoBar">
1235
1258
  <div class="info" id="infoText"></div>
1236
1259
  <div id="contextChips" aria-label="Attached context" hidden></div>
1260
+ <button class="ghost" id="infoCancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Stop turn</button>
1237
1261
  <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>
1238
1262
  <button class="ghost" id="infoArchiveBtn" title="Archive: hide from the lists, keep for good" style="display:none">archive</button>
1239
1263
  <button class="ghost" id="infoStopBtn" title="Stop session (terminate worker)" style="display:none">stop session</button>
@@ -4,6 +4,7 @@ require "fileutils"
4
4
 
5
5
  require_relative "client_id"
6
6
  require_relative "config"
7
+ require_relative "engine"
7
8
  require_relative "events"
8
9
  require_relative "session"
9
10
  require_relative "session_inbox"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.77.0
4
+ version: 0.78.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -594,6 +594,7 @@ files:
594
594
  - lib/samagotchi/web/public/command_view.js
595
595
  - lib/samagotchi/web/public/commands.js
596
596
  - lib/samagotchi/web/public/composer.js
597
+ - lib/samagotchi/web/public/composer_fold.js
597
598
  - lib/samagotchi/web/public/composer_size.js
598
599
  - lib/samagotchi/web/public/context_chips.js
599
600
  - lib/samagotchi/web/public/copy.js