samagotchi 0.77.0 → 0.79.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/docs/cli.md +2 -1
  4. data/docs/configuration.md +14 -8
  5. data/docs/internals/anthropic.md +80 -7
  6. data/docs/internals/engine.md +3 -2
  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/adapter_common.rb +4 -2
  15. data/lib/samagotchi/llm/anthropic_assembly.rb +14 -0
  16. data/lib/samagotchi/llm/anthropic_binding_recovery.rb +195 -0
  17. data/lib/samagotchi/llm/anthropic_messages.rb +74 -17
  18. data/lib/samagotchi/llm/anthropic_wire.rb +29 -1
  19. data/lib/samagotchi/llm/chat_loop.rb +61 -5
  20. data/lib/samagotchi/llm/errors.rb +6 -4
  21. data/lib/samagotchi/llm/http.rb +2 -1
  22. data/lib/samagotchi/llm/model_listing.rb +29 -1
  23. data/lib/samagotchi/llm/openai_chat.rb +12 -2
  24. data/lib/samagotchi/llm/refusal_memo.rb +3 -2
  25. data/lib/samagotchi/note_delivery.rb +2 -2
  26. data/lib/samagotchi/plugin/sessions.rb +2 -2
  27. data/lib/samagotchi/question_desk.rb +10 -3
  28. data/lib/samagotchi/reasoning_history.rb +2 -1
  29. data/lib/samagotchi/reply_wait.rb +2 -3
  30. data/lib/samagotchi/session_manager.rb +0 -1
  31. data/lib/samagotchi/session_metrics/totals.rb +4 -3
  32. data/lib/samagotchi/session_metrics.rb +9 -0
  33. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  34. data/lib/samagotchi/terminal_ui.rb +2 -2
  35. data/lib/samagotchi/tools/delegate.rb +2 -2
  36. data/lib/samagotchi/tools/delegate_result.rb +2 -2
  37. data/lib/samagotchi/tools/list_sessions.rb +2 -2
  38. data/lib/samagotchi/version.rb +1 -1
  39. data/lib/samagotchi/web/public/app.js +61 -53
  40. data/lib/samagotchi/web/public/composer_fold.js +58 -0
  41. data/lib/samagotchi/web/public/composer_grip.js +231 -0
  42. data/lib/samagotchi/web/public/composer_size.js +10 -0
  43. data/lib/samagotchi/web/public/index.html +27 -3
  44. data/lib/samagotchi/web/public/model_picker.js +4 -5
  45. data/lib/samagotchi/web/public/stage_view.js +3 -2
  46. data/lib/samagotchi/web/public/storage.js +23 -0
  47. data/lib/samagotchi/web/public/toasts.js +5 -4
  48. data/lib/samagotchi/worker.rb +1 -0
  49. metadata +5 -1
@@ -144,6 +144,9 @@ module Samagotchi
144
144
  # (finish_reason length). `retries` counts the network's only.
145
145
  :cuts,
146
146
  :capped,
147
+ # Replayed thinking blocks a preserved-thinking recovery dropped
148
+ # (:generation_completed's thinking_dropped, ChatLoop).
149
+ :thinking_dropped,
147
150
  :model,
148
151
  # The model the turn ran on (asked host:model, :generation_started's
149
152
  # model_ref; the first generation's), and whether its open generation
@@ -505,6 +508,7 @@ module Samagotchi
505
508
  retries: totals.retries,
506
509
  cuts: totals.cuts,
507
510
  capped: totals.capped,
511
+ thinking_dropped: totals.thinking_dropped,
508
512
  started_at: @started_at,
509
513
  last_activity_at: @last_activity_at,
510
514
  # From the session's first start (an earlier process's too), to now.
@@ -589,6 +593,7 @@ module Samagotchi
589
593
  retries: 0,
590
594
  cuts: 0,
591
595
  capped: 0,
596
+ thinking_dropped: 0,
592
597
  model: nil,
593
598
  model_ref: nil,
594
599
  gen_switch: false,
@@ -679,12 +684,15 @@ module Samagotchi
679
684
  end
680
685
  end
681
686
 
687
+ # A generation's cut, output cap and dropped thinking blocks, into the turn.
682
688
  def count_cut(event)
683
689
  @mutex.synchronize do
684
690
  next unless @turn
685
691
 
686
692
  @turn.cuts += 1 if event[:stopped_by] && event[:stopped_by].to_s != "steer"
687
693
  @turn.capped += 1 if event[:finish_reason].to_s == "length"
694
+ dropped = event[:thinking_dropped]
695
+ @turn.thinking_dropped += dropped if dropped.is_a?(Integer) && dropped.positive?
688
696
  end
689
697
  end
690
698
 
@@ -872,6 +880,7 @@ module Samagotchi
872
880
  }
873
881
  fields[:cache_write_tokens_sum] = turn.cache_write_sum if turn.cache_write_sum.positive?
874
882
  fields[:reprefill_tokens_sum] = turn.reprefill_sum if turn.reprefill_sum.positive?
883
+ fields[:thinking_dropped] = turn.thinking_dropped if turn.thinking_dropped.positive?
875
884
  fields.merge!(cost: turn.cost_sum, cost_source: "reported") if turn.cost_sum
876
885
  fields[:cost_estimate] = turn.cost_estimate_sum if turn.cost_estimate_sum.positive?
877
886
  fields[:switch_prefill_tokens] = turn.switch_prefill_tokens if turn.switch_prefill_tokens
@@ -600,6 +600,9 @@ module Samagotchi
600
600
  # A looped session: loop-guard's cuts, or thinking that ran to the cap.
601
601
  lines << "thinking cuts: #{snapshot[:cuts].to_i}"
602
602
  lines << "output cap hits: #{snapshot[:capped].to_i}"
603
+ # Claude 5.x: replayed thinking a prefix change made the API drop.
604
+ dropped = snapshot[:thinking_dropped].to_i
605
+ lines << "thinking dropped: #{dropped} blocks (preserved-thinking recovery)" if dropped.positive?
603
606
  context = snapshot[:context] || {}
604
607
  if context[:used_tokens]
605
608
  pct = context_pct_text(context, snapshot[:llm_context])
@@ -42,8 +42,8 @@ require_relative "children_status"
42
42
  require_relative "log"
43
43
 
44
44
  module Samagotchi
45
- # Loaded on first use: session_manager requires this file (its worker
46
- # process loads the whole UI stack through it), a require cycle otherwise.
45
+ # Loaded on first use: this autoload keeps SessionManager and its ~80
46
+ # files unloaded until the delegate/session path needs them.
47
47
  autoload :SessionManager, File.expand_path("session_manager", __dir__)
48
48
 
49
49
  # The in-process terminal front end (`chi --no-shared`, `chi scratch`,
@@ -10,8 +10,8 @@ require_relative "delegate_wait"
10
10
  require_relative "../child_reports"
11
11
 
12
12
  module Samagotchi
13
- # Loaded on first use: session_manager requires terminal_ui, which
14
- # requires KernelLoop and so these tools (a require cycle otherwise).
13
+ # Loaded on first use: this autoload keeps SessionManager and its ~80
14
+ # files unloaded until the delegate/session path needs them.
15
15
  autoload :SessionManager, File.expand_path("../session_manager", __dir__)
16
16
 
17
17
  module Tools
@@ -6,8 +6,8 @@ require_relative "delegate"
6
6
  require_relative "delegate_wait"
7
7
 
8
8
  module Samagotchi
9
- # Loaded on first use: session_manager requires terminal_ui, which
10
- # requires KernelLoop and so these tools (a require cycle otherwise).
9
+ # Loaded on first use: this autoload keeps SessionManager and its ~80
10
+ # files unloaded until the delegate/session path needs them.
11
11
  autoload :SessionManager, File.expand_path("../session_manager", __dir__)
12
12
 
13
13
  module Tools
@@ -6,8 +6,8 @@ require_relative "../session"
6
6
  require_relative "peers"
7
7
 
8
8
  module Samagotchi
9
- # Loaded on first use: session_manager requires terminal_ui, which
10
- # requires KernelLoop and so these tools (a require cycle otherwise).
9
+ # Loaded on first use: this autoload keeps SessionManager and its ~80
10
+ # files unloaded until the delegate/session path needs them.
11
11
  autoload :SessionManager, File.expand_path("../session_manager", __dir__)
12
12
 
13
13
  module Tools
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.77.0"
4
+ VERSION = "0.79.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -56,7 +56,9 @@ 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 { PROMPT_LINE_PX, PROMPT_MIN_PX, promptHeight } from "./composer_size.js";
60
+ import { GRIP_KEY, createComposerGrip } from "./composer_grip.js";
61
+ import { readStored, writeStored } from "./storage.js";
60
62
  import { createCommandList } from "./command_list.js";
61
63
  import { createPromptHistory } from "./prompt_history.js";
62
64
  import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
@@ -64,6 +66,7 @@ import { scopeDir, scopeTemplate } from "./scope.js";
64
66
  import { html, nothing, render } from "./vendor/lit-html.js";
65
67
  import { easeScrollTo, FOLLOW_MS, shouldFollowScroll } from "./scroll.js";
66
68
  import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
69
+ import { waitingOn } from "./notify.js";
67
70
  import { applySessionEvent, inTree, sortedByUpdated } from "./sessions_list.js";
68
71
  import { createNotifications } from "./notifications.js";
69
72
  import { tabTitle, topBarTitle } from "./session_title.js";
@@ -132,7 +135,7 @@ const llmContextNewControl = createLlmContextControl({
132
135
  choose: (next) => {
133
136
  newChatLlmContext = next;
134
137
  renderNewChatLlmContext();
135
- promptEl.focus();
138
+ focusPrompt();
136
139
  },
137
140
  id: "llmContextNewPopover",
138
141
  anchor: "#llmContextNew",
@@ -168,7 +171,7 @@ const thinkingNewControl = createThinkingControl({
168
171
  choose: (next) => {
169
172
  newChatThinking = next;
170
173
  renderNewChatThinking();
171
- promptEl.focus();
174
+ focusPrompt();
172
175
  },
173
176
  id: "thinkingNewPopover",
174
177
  anchor: "#thinkingNew",
@@ -202,6 +205,7 @@ let pageEditorUrl = null;
202
205
  const promptEl = $("#prompt");
203
206
  const actionBtn = $("#actionBtn");
204
207
  const cancelBtn = $("#cancelBtn");
208
+ const infoCancelBtn = $("#infoCancelBtn");
205
209
  const modelPickEl = $("#modelPick");
206
210
  const composeHintEl = $("#composeHint");
207
211
 
@@ -739,9 +743,10 @@ function clearSelection() {
739
743
  closeStream();
740
744
  setMarkdownWarning(null);
741
745
  setCapabilities(null, null);
746
+ composerGrip.newVisit();
742
747
  updateComposerMode();
743
748
  updateInfoBar();
744
- promptEl.focus();
749
+ focusPrompt();
745
750
  }
746
751
 
747
752
  // A short line on the start page (the open session went away); gone at the
@@ -774,11 +779,13 @@ async function select(id) {
774
779
  closeStream();
775
780
  resetSessionState();
776
781
  resetTurnView();
782
+ composerGrip.newVisit();
777
783
  updateInfoBar();
778
784
  contextChips.load(id);
779
785
  updateComposerMode();
780
786
  await resync();
781
- promptEl.focus();
787
+ // A folded composer stays folded: typing on the page opens it.
788
+ focusPrompt({ open: false });
782
789
  }
783
790
 
784
791
  // Clear everything that belongs to one turn's rendering.
@@ -815,6 +822,9 @@ async function resync({ endText = null } = {}) {
815
822
  return;
816
823
  }
817
824
  if (id !== selected) return;
825
+ // An archived session opens folded: decided once per visit, from its
826
+ // first answer (the list may not hold it yet, or ever under ?dir).
827
+ composerGrip.decideVisit(data.session?.archived);
818
828
  const kept = keptCommandBubbles(historyEl, id);
819
829
  resetTurnView();
820
830
  renderSessionView(data, { kept });
@@ -1089,6 +1099,7 @@ function updateInfoBar() {
1089
1099
  archiveBtn.title = buttons.archive.title;
1090
1100
  }
1091
1101
  if (stopBtn) stopBtn.style.display = buttons.stop.shown ? "" : "none";
1102
+ drawFoldControls();
1092
1103
  // No session: the start page hides the info bar (the storage path is on
1093
1104
  // #/sessions). The session clock ticks every second while a turn runs:
1094
1105
  // lit writes its text alone, the bar's other nodes (a tooltip, a button
@@ -1260,6 +1271,8 @@ const streamHandlers = createStreamHandlers({
1260
1271
  composer: {
1261
1272
  empty: () => !promptEl.value.trim() && !chips.length,
1262
1273
  refill: (text) => {
1274
+ // A failed turn's line back: seen, so the composer opens (this visit).
1275
+ openForVisit();
1263
1276
  promptEl.value = text;
1264
1277
  fitPrompt();
1265
1278
  },
@@ -1427,6 +1440,8 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1427
1440
  // (and images) go back into the composer, without replacing text typed
1428
1441
  // since (4.13). The composer still holds the text just sent: it goes.
1429
1442
  const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
1443
+ // A restored line must be seen: the composer opens for this visit.
1444
+ if (restored?.refill && sentTo === selected) openForVisit();
1430
1445
  promptEl.value = composerAfterAck({ sent: line, current: promptEl.value, refill: restored?.refill || "" });
1431
1446
  fitPrompt();
1432
1447
  clearChips();
@@ -1463,7 +1478,7 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1463
1478
  showToast(cantText("send", e), null, { sticky: true });
1464
1479
  } finally {
1465
1480
  actionBtn.disabled = current.owner === "tui";
1466
- promptEl.focus();
1481
+ focusPrompt();
1467
1482
  }
1468
1483
  }
1469
1484
 
@@ -1488,7 +1503,8 @@ const composerCommands = createComposerCommands({
1488
1503
  clear: () => clearComposer(),
1489
1504
  done: () => {
1490
1505
  actionBtn.disabled = current.owner === "tui";
1491
- promptEl.focus();
1506
+ // A chip's command (the info bar) leaves a folded composer folded.
1507
+ focusPrompt({ open: false });
1492
1508
  },
1493
1509
  },
1494
1510
  });
@@ -1505,6 +1521,7 @@ function clearComposer() {
1505
1521
  async function handleCancel() {
1506
1522
  if (!selected) return;
1507
1523
  cancelBtn.disabled = true;
1524
+ infoCancelBtn.disabled = true;
1508
1525
  try {
1509
1526
  await cancelTurn(selected);
1510
1527
  } catch (e) {
@@ -1512,6 +1529,7 @@ async function handleCancel() {
1512
1529
  if (e.code !== "not_running") alert(e.message);
1513
1530
  } finally {
1514
1531
  cancelBtn.disabled = false;
1532
+ infoCancelBtn.disabled = false;
1515
1533
  }
1516
1534
  }
1517
1535
 
@@ -1560,7 +1578,7 @@ const modelPicker = createModelPicker({
1560
1578
  renderNewChatLlmContext();
1561
1579
  renderNewChatThinking();
1562
1580
  }
1563
- promptEl.focus();
1581
+ focusPrompt();
1564
1582
  },
1565
1583
  });
1566
1584
  // A pick on a fresh link: /model in its worker (its command bubble and the
@@ -1611,9 +1629,7 @@ function syncModelPick() {
1611
1629
  }
1612
1630
  // Leaving a session: the start page's remembered model again (no fetch).
1613
1631
  function restoreStartPick() {
1614
- let stored = null;
1615
- try { stored = localStorage.getItem(MODEL_KEY); } catch (_) {}
1616
- modelPicker.restore(stored);
1632
+ modelPicker.restore(readStored(MODEL_KEY));
1617
1633
  renderNewChatLlmContext();
1618
1634
  renderNewChatThinking();
1619
1635
  }
@@ -1624,8 +1640,7 @@ async function loadModels() {
1624
1640
  } catch (_) {
1625
1641
  return;
1626
1642
  }
1627
- let stored = null;
1628
- try { stored = localStorage.getItem(MODEL_KEY); } catch (_) {}
1643
+ const stored = readStored(MODEL_KEY);
1629
1644
  if (!modelPicker.setModels(payload, stored)) return;
1630
1645
  startPickTitle = payload.warning ? `${START_PICK_TITLE} · ${payload.warning}` : START_PICK_TITLE;
1631
1646
  modelPickEl.hidden = false;
@@ -1652,6 +1667,7 @@ let chips = [];
1652
1667
  function renderChips() {
1653
1668
  render(chipsTemplate(chips), chipsEl);
1654
1669
  chipsEl.hidden = !chips.length;
1670
+ drawFoldControls();
1655
1671
  }
1656
1672
 
1657
1673
  function setChips(list) {
@@ -1668,7 +1684,7 @@ function addImageFiles(files) {
1668
1684
  if (!files.length) return false;
1669
1685
  for (const file of files) chips.push({ file, name: chipName(file, chips.length), src: URL.createObjectURL(file) });
1670
1686
  renderChips();
1671
- promptEl.focus();
1687
+ focusPrompt();
1672
1688
  return true;
1673
1689
  }
1674
1690
 
@@ -1680,9 +1696,7 @@ chipsEl.addEventListener("click", (e) => {
1680
1696
  renderChips();
1681
1697
  });
1682
1698
  // ── Composer height: grows with the text; the top-edge grip sets a floor ───
1683
- const GRIP_KEY = "chi_composer_floor";
1684
- let promptFloor = null;
1685
- try { promptFloor = Number(localStorage.getItem(GRIP_KEY)) || null; } catch (_) {}
1699
+ let promptFloor = Number(readStored(GRIP_KEY)) || null;
1686
1700
 
1687
1701
  // The empty box: one line in a session, three on the start page.
1688
1702
  function promptMinPx() {
@@ -1697,33 +1711,6 @@ function fitPrompt() {
1697
1711
  promptEl.style.overflowY = scrolls ? "auto" : "hidden";
1698
1712
  }
1699
1713
 
1700
- const composeGrip = $("#composeGrip");
1701
- composeGrip.addEventListener("pointerdown", (e) => {
1702
- e.preventDefault();
1703
- composeGrip.setPointerCapture(e.pointerId);
1704
- composeGrip.classList.add("dragging");
1705
- const startY = e.clientY;
1706
- const startH = promptEl.offsetHeight;
1707
- const move = (ev) => {
1708
- promptFloor = gripFloor(startH + (startY - ev.clientY), window.innerHeight, promptMinPx());
1709
- fitPrompt();
1710
- };
1711
- const up = () => {
1712
- composeGrip.classList.remove("dragging");
1713
- composeGrip.removeEventListener("pointermove", move);
1714
- composeGrip.removeEventListener("pointerup", up);
1715
- composeGrip.removeEventListener("pointercancel", up);
1716
- try { localStorage.setItem(GRIP_KEY, String(promptFloor || "")); } catch (_) {}
1717
- };
1718
- composeGrip.addEventListener("pointermove", move);
1719
- composeGrip.addEventListener("pointerup", up);
1720
- composeGrip.addEventListener("pointercancel", up);
1721
- });
1722
- composeGrip.addEventListener("dblclick", () => {
1723
- promptFloor = null;
1724
- try { localStorage.removeItem(GRIP_KEY); } catch (_) {}
1725
- fitPrompt();
1726
- });
1727
1714
  promptEl.addEventListener("input", fitPrompt);
1728
1715
  window.addEventListener("resize", fitPrompt);
1729
1716
  fitPrompt();
@@ -1806,6 +1793,29 @@ document.addEventListener("keydown", (e) => {
1806
1793
  if (e.key === "Escape" && !lightboxEl.hidden) lightboxEl.hidden = true;
1807
1794
  });
1808
1795
  cancelBtn.addEventListener("click", () => handleCancel());
1796
+ infoCancelBtn.addEventListener("click", () => handleCancel());
1797
+
1798
+ // The composer's grip and fold (composer_grip.js).
1799
+ const composerGrip = createComposerGrip({
1800
+ composeGrip: $("#composeGrip"),
1801
+ promptEl,
1802
+ infoCancelBtn,
1803
+ infoBarEl: $("#infoBar"),
1804
+ session: () => selected,
1805
+ fitPrompt,
1806
+ promptMinPx,
1807
+ floor: () => promptFloor,
1808
+ setFloor: (px) => { promptFloor = px; },
1809
+ draft: () => !!(promptEl.value.trim() || chips.length),
1810
+ waiting: () => !!(waitingOn(allSessions.find((s) => s.id === selected)) || questionCards.pendingQuestion()),
1811
+ turnRunning: () => liveTurn.running,
1812
+ allViewOpen,
1813
+ lightboxOpen: () => !lightboxEl.hidden,
1814
+ imageFiles,
1815
+ addImageFiles,
1816
+ });
1817
+ // Bound here, not hoisted: no top-level code above this line may call them.
1818
+ const { openForVisit, focusPrompt, drawFoldControls } = composerGrip;
1809
1819
  // The / autocomplete (command_list.js): the session's commands while the
1810
1820
  // composer holds one "/word".
1811
1821
  const commandList = createCommandList({
@@ -1934,9 +1944,10 @@ async function continueSelected() {
1934
1944
  // continue's 201.
1935
1945
  function fillOpener(created) {
1936
1946
  if (promptEl.value.trim() || chips.length) return;
1947
+ openForVisit();
1937
1948
  promptEl.value = openerText(created);
1938
1949
  fitPrompt();
1939
- promptEl.focus();
1950
+ focusPrompt();
1940
1951
  promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
1941
1952
  }
1942
1953
 
@@ -2039,9 +2050,10 @@ function showToast(text, action = null, options = {}) { toasts.show(text, action
2039
2050
  // session's plugins' pills (chi.annotation, annotate_actions.js), which fill
2040
2051
  // the composer with their command line and the quote, or send it at once.
2041
2052
  function fillComposer(text) {
2053
+ openForVisit();
2042
2054
  promptEl.value = text;
2043
2055
  fitPrompt();
2044
- promptEl.focus();
2056
+ focusPrompt();
2045
2057
  promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
2046
2058
  promptEl.scrollTop = promptEl.scrollHeight;
2047
2059
  }
@@ -2077,12 +2089,8 @@ const notifications = createNotifications({ button: $("#notifyBtn") });
2077
2089
  // The latest-sessions strip can be hidden for more chat room; the choice
2078
2090
  // stays per browser (it replaced zen mode, so a zen user starts hidden).
2079
2091
  function stripHiddenSaved() {
2080
- try {
2081
- const saved = localStorage.getItem("chi_strip_hidden");
2082
- return saved === null ? localStorage.getItem("chi_zen") === "1" : saved === "1";
2083
- } catch (_) {
2084
- return false;
2085
- }
2092
+ const saved = readStored("chi_strip_hidden");
2093
+ return saved === null ? readStored("chi_zen") === "1" : saved === "1";
2086
2094
  }
2087
2095
  // A short window hides the strip by itself, so the stage keeps room for a
2088
2096
  // card that waits (its question and choices); the sessions pill brings it
@@ -2098,7 +2106,7 @@ function applyStripHidden() {
2098
2106
  }
2099
2107
  function setStripHidden(hidden) {
2100
2108
  if (hidden) lists.familyPop.close();
2101
- try { localStorage.setItem("chi_strip_hidden", hidden ? "1" : "0"); } catch (_) {}
2109
+ writeStored("chi_strip_hidden", hidden ? "1" : "0");
2102
2110
  stripShownWhileShort = !hidden && shortWindow.matches;
2103
2111
  applyStripHidden();
2104
2112
  }
@@ -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
+ }