samagotchi 0.79.0 → 0.81.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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/README.md +7 -5
  4. data/docs/architecture.md +9 -1
  5. data/docs/cli.md +32 -7
  6. data/docs/configuration.md +10 -4
  7. data/docs/guardrails.md +26 -3
  8. data/docs/internals/anthropic.md +3 -1
  9. data/docs/internals/bundles.md +4 -0
  10. data/docs/internals/engine.md +13 -3
  11. data/docs/plugins.md +149 -3
  12. data/docs/releasing.md +2 -2
  13. data/docs/sessions.md +9 -1
  14. data/docs/testing.md +51 -2
  15. data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
  16. data/lib/samagotchi/bootstrap/probe.rb +153 -31
  17. data/lib/samagotchi/bootstrap_command.rb +93 -16
  18. data/lib/samagotchi/bridge.rb +1 -1
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -4
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  21. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/slow-commands/plugin.rb +2 -1
  23. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  25. data/lib/samagotchi/continue_offer.rb +5 -5
  26. data/lib/samagotchi/engine.rb +68 -39
  27. data/lib/samagotchi/guardrails/approval.rb +6 -4
  28. data/lib/samagotchi/guardrails/data_readers.rb +214 -0
  29. data/lib/samagotchi/guardrails/gate.rb +7 -3
  30. data/lib/samagotchi/guardrails/rules.rb +4 -2
  31. data/lib/samagotchi/guardrails/shell_lex.rb +166 -28
  32. data/lib/samagotchi/guardrails/targets.rb +14 -0
  33. data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
  34. data/lib/samagotchi/hooks/registry.rb +44 -3
  35. data/lib/samagotchi/host_config.rb +8 -8
  36. data/lib/samagotchi/host_registry.rb +12 -4
  37. data/lib/samagotchi/idle_client.rb +41 -11
  38. data/lib/samagotchi/idle_recap.rb +6 -1
  39. data/lib/samagotchi/idle_target.rb +12 -4
  40. data/lib/samagotchi/llm/adapter_common.rb +4 -2
  41. data/lib/samagotchi/llm/anthropic_binding_recovery.rb +3 -0
  42. data/lib/samagotchi/llm/anthropic_messages.rb +2 -1
  43. data/lib/samagotchi/llm/http.rb +12 -7
  44. data/lib/samagotchi/llm/usage.rb +9 -3
  45. data/lib/samagotchi/model_price.rb +16 -0
  46. data/lib/samagotchi/plugin/card.rb +44 -0
  47. data/lib/samagotchi/plugin/context.rb +61 -9
  48. data/lib/samagotchi/plugin/http.rb +154 -0
  49. data/lib/samagotchi/plugin/http_transport.rb +77 -0
  50. data/lib/samagotchi/plugin/loader.rb +29 -10
  51. data/lib/samagotchi/plugin/service.rb +24 -1
  52. data/lib/samagotchi/question_answer.rb +29 -0
  53. data/lib/samagotchi/question_desk.rb +20 -19
  54. data/lib/samagotchi/recap_setup.rb +2 -1
  55. data/lib/samagotchi/relay_verifier.rb +1 -1
  56. data/lib/samagotchi/session_metrics/side_totals.rb +110 -0
  57. data/lib/samagotchi/session_metrics.rb +46 -12
  58. data/lib/samagotchi/session_observer.rb +3 -0
  59. data/lib/samagotchi/side_usage.rb +38 -0
  60. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  61. data/lib/samagotchi/terminal_ui/model_segments_text.rb +27 -3
  62. data/lib/samagotchi/testing/boundary.rb +156 -0
  63. data/lib/samagotchi/testing/bundle.rb +349 -0
  64. data/lib/samagotchi/testing/fake_executable.rb +42 -0
  65. data/lib/samagotchi/testing/fake_http.rb +137 -0
  66. data/lib/samagotchi/testing/helpers.rb +56 -0
  67. data/lib/samagotchi/testing/host.rb +76 -0
  68. data/lib/samagotchi/testing/minitest.rb +31 -0
  69. data/lib/samagotchi/testing/recorder.rb +141 -0
  70. data/lib/samagotchi/testing/recording_api.rb +27 -0
  71. data/lib/samagotchi/testing/rspec.rb +31 -0
  72. data/lib/samagotchi/testing/sandbox.rb +165 -0
  73. data/lib/samagotchi/testing/sessions.rb +77 -0
  74. data/lib/samagotchi/testing/verdict.rb +28 -0
  75. data/lib/samagotchi/testing/waiting.rb +36 -0
  76. data/lib/samagotchi/testing.rb +26 -0
  77. data/lib/samagotchi/tools/delegate_relay.rb +7 -6
  78. data/lib/samagotchi/tools/tool_index.rb +3 -0
  79. data/lib/samagotchi/turn_state.rb +28 -1
  80. data/lib/samagotchi/version.rb +1 -1
  81. data/lib/samagotchi/web/app.rb +4 -1
  82. data/lib/samagotchi/web/public/app.js +47 -23
  83. data/lib/samagotchi/web/public/ctx.js +32 -6
  84. data/lib/samagotchi/web/public/images.js +33 -0
  85. data/lib/samagotchi/web/public/index.html +71 -38
  86. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  87. data/lib/samagotchi/web/public/level_chip.js +11 -11
  88. data/lib/samagotchi/web/public/lists_view.js +20 -15
  89. data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
  90. data/lib/samagotchi/web/public/model_picker.js +1 -1
  91. data/lib/samagotchi/web/public/scope.js +3 -3
  92. data/lib/samagotchi/web/public/stream_handlers.js +7 -0
  93. data/lib/samagotchi/web/public/strip.js +35 -9
  94. data/lib/samagotchi/web/public/strip_grip.js +76 -0
  95. data/lib/samagotchi/web/public/thinking_chip.js +5 -5
  96. data/lib/samagotchi/web/public/timing.js +6 -1
  97. data/lib/samagotchi/web/session_summary.rb +2 -0
  98. metadata +24 -1
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.79.0"
4
+ VERSION = "0.81.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1798,7 +1798,10 @@ module Samagotchi
1798
1798
  # model_segments; none with one model, or from an older file).
1799
1799
  model_segments: Array(source["model_segments"]),
1800
1800
  # The memory indexes the session's prompt holds (the ctx tooltip).
1801
- memory_index: source["memory_index"]
1801
+ memory_index: source["memory_index"],
1802
+ # The side requests (recap, /btw, ctx.ask_model), apart from the
1803
+ # tokens: the ctx tooltip's own line (nil without any).
1804
+ side: source["side"]
1802
1805
  }
1803
1806
  end
1804
1807
 
@@ -24,7 +24,7 @@ import {
24
24
  } from "./data.js";
25
25
  import { createContextChips } from "./context_chips.js";
26
26
  import {
27
- chipModel as llmContextChipModel, choiceWords, createLlmContextControl, NEW_CHAT,
27
+ chipModel as llmContextChipModel, choiceWords, createLlmContextControl, NEW_SESSION,
28
28
  withChoice,
29
29
  } from "./llm_context_chip.js";
30
30
  import {
@@ -38,7 +38,7 @@ import { createShownTexts } from "./shown_texts.js";
38
38
  import { MODEL_KEY } from "./model_pick.js";
39
39
  import { emptySessionState, sessionStateFrom } from "./session_state.js";
40
40
  import { createModelPicker } from "./model_picker.js";
41
- import { chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
41
+ import { admitImages, chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
42
42
  import { normalize, firstPreview, failedTurnText, deleteConfirmText, goneSessionNotice, cantText, withLiveStatus, withoutWorker } from "./format.js";
43
43
  import { createTurnView, turnHistoryNodes } from "./turn_view.js";
44
44
  import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
@@ -66,6 +66,7 @@ import { scopeDir, scopeTemplate } from "./scope.js";
66
66
  import { html, nothing, render } from "./vendor/lit-html.js";
67
67
  import { easeScrollTo, FOLLOW_MS, shouldFollowScroll } from "./scroll.js";
68
68
  import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
69
+ import { createStripGrip } from "./strip_grip.js";
69
70
  import { waitingOn } from "./notify.js";
70
71
  import { applySessionEvent, inTree, sortedByUpdated } from "./sessions_list.js";
71
72
  import { createNotifications } from "./notifications.js";
@@ -121,7 +122,7 @@ const llmContextControl = createLlmContextControl({
121
122
  run: (line) => runSessionCommand(line),
122
123
  });
123
124
  // The start page's LLM context chip, beside the model picker: the picked
124
- // model's values, and a choice for the next new chat only (sent with its
125
+ // model's values, and a choice for the next new session only (sent with its
125
126
  // create, then dropped; never remembered: a reload or opening a session
126
127
  // drops it too, a failed create keeps it).
127
128
  const llmContextNewEl = $("#llmContextNew");
@@ -130,7 +131,7 @@ const newChatLlmSummary = () => (selected || modelPickEl.hidden ? null : modelPi
130
131
  const llmContextNewControl = createLlmContextControl({
131
132
  el: llmContextNewEl,
132
133
  summary: newChatLlmSummary,
133
- kind: NEW_CHAT,
134
+ kind: NEW_SESSION,
134
135
  choice: () => newChatLlmContext,
135
136
  choose: (next) => {
136
137
  newChatLlmContext = next;
@@ -159,14 +160,14 @@ const reasoningHistoryControl = createReasoningHistoryControl({
159
160
  run: (line) => runSessionCommand(line),
160
161
  });
161
162
  // The start page's thinking chip: the picked model's level, and a level
162
- // for the next new chat only (sent with its create, then dropped).
163
+ // for the next new session only (sent with its create, then dropped).
163
164
  const thinkingNewEl = $("#thinkingNew");
164
165
  let newChatThinking = null;
165
166
  const newChatThinkingSummary = () => (selected || modelPickEl.hidden ? null : modelPicker.chosenRow()?.thinking || null);
166
167
  const thinkingNewControl = createThinkingControl({
167
168
  el: thinkingNewEl,
168
169
  summary: newChatThinkingSummary,
169
- kind: NEW_CHAT,
170
+ kind: NEW_SESSION,
170
171
  choice: () => newChatThinking,
171
172
  choose: (next) => {
172
173
  newChatThinking = next;
@@ -177,7 +178,7 @@ const thinkingNewControl = createThinkingControl({
177
178
  anchor: "#thinkingNew",
178
179
  });
179
180
  function renderNewChatThinking() {
180
- const chip = thinkingChipModel(withThinkingChoice(newChatThinkingSummary(), newChatThinking), { kind: NEW_CHAT });
181
+ const chip = thinkingChipModel(withThinkingChoice(newChatThinkingSummary(), newChatThinking), { kind: NEW_SESSION });
181
182
  thinkingNewEl.hidden = !chip;
182
183
  if (!chip) return thinkingNewControl.close();
183
184
  thinkingNewEl.textContent = chip.text;
@@ -186,7 +187,7 @@ function renderNewChatThinking() {
186
187
  }
187
188
  // Hidden without the model's values (the list not in, an older server).
188
189
  function renderNewChatLlmContext() {
189
- const chip = llmContextChipModel(withChoice(newChatLlmSummary(), newChatLlmContext), { kind: NEW_CHAT });
190
+ const chip = llmContextChipModel(withChoice(newChatLlmSummary(), newChatLlmContext), { kind: NEW_SESSION });
190
191
  llmContextNewEl.hidden = !chip;
191
192
  if (!chip) return llmContextNewControl.close();
192
193
  llmContextNewEl.textContent = chip.text;
@@ -205,6 +206,8 @@ let pageEditorUrl = null;
205
206
  const promptEl = $("#prompt");
206
207
  const actionBtn = $("#actionBtn");
207
208
  const cancelBtn = $("#cancelBtn");
209
+ const attachBtn = $("#attachBtn");
210
+ const attachInput = $("#attachInput");
208
211
  const infoCancelBtn = $("#infoCancelBtn");
209
212
  const modelPickEl = $("#modelPick");
210
213
  const composeHintEl = $("#composeHint");
@@ -357,7 +360,7 @@ const sentIds = new Set();
357
360
  const earlyRestores = new Map();
358
361
 
359
362
  // The page's scope (scope.js): ?dir= is the folder a project view came
360
- // from (new chats start there); the server names its project, if any.
363
+ // from (new sessions start there); the server names its project, if any.
361
364
  const scope = {
362
365
  dir: scopeDir(location.search),
363
366
  projectName: document.body.dataset.projectName || null,
@@ -467,7 +470,7 @@ function followSelectedSession(sess) {
467
470
 
468
471
  // The header's scope chip: "<project> · all" in a project view (all links
469
472
  // to the same place without ?dir); "all projects" otherwise, with where a
470
- // new chat starts on the start page.
473
+ // new session starts on the start page.
471
474
  function renderScope() {
472
475
  const el = $("#scope");
473
476
  render(scopeTemplate(scope, { error: scopeError, where: () => ({ hash: location.hash, search: location.search }) }), el);
@@ -500,7 +503,6 @@ const lists = createListsView({
500
503
  allViewOpen: () => allViewOpen(),
501
504
  openAllView: () => openAllView(),
502
505
  leaveAllView: (hash) => leaveAllView(hash),
503
- setStripHidden: (hidden) => setStripHidden(hidden),
504
506
  });
505
507
 
506
508
  // Delete a session (the info bar's button, an all-sessions card's ✕) after
@@ -544,7 +546,7 @@ function closeAllView() {
544
546
  leaveAllView(sessionHash(selected));
545
547
  }
546
548
  }
547
- // Leave all sessions for a place of our own (a picked session, a new chat):
549
+ // Leave all sessions for a place of our own (a picked session, a new session):
548
550
  // replace its history entry, since history.back() would come back later
549
551
  // with the session the view was opened from.
550
552
  function leaveAllView(hash) {
@@ -620,7 +622,7 @@ function updateComposerMode() {
620
622
  syncModelPick();
621
623
  if (selected) {
622
624
  llmContextNewControl.close();
623
- // The start page's choice was for a new chat: opening a session drops it.
625
+ // The start page's choice was for a new session: opening a session drops it.
624
626
  if (newChatLlmContext) {
625
627
  newChatLlmContext = null;
626
628
  renderNewChatLlmContext();
@@ -633,6 +635,9 @@ function updateComposerMode() {
633
635
  }
634
636
  const running = liveTurn.running && !!selected;
635
637
  cancelBtn.style.display = running ? "" : "none";
638
+ // No images for a session a terminal owns (it takes no message from here);
639
+ // on a phone none while a turn runs either: Stop turn needs the row's room.
640
+ attachBtn.hidden = (!!selected && current.owner === "tui") || (running && narrowScreen.matches);
636
641
  syncDeliveryLine();
637
642
  if (running) {
638
643
  setPlaceholder("Running… a message goes in at the next step · Enter to send · Alt+Enter queues", "Steer the turn…");
@@ -1535,7 +1540,7 @@ async function handleCancel() {
1535
1540
 
1536
1541
  // The chi logo: back to the empty start, where the composer makes a new
1537
1542
  // session (from all sessions too).
1538
- // The typed draft stays in the composer: paste in a session, then + new chat
1543
+ // The typed draft stays in the composer: paste in a session, then + new session
1539
1544
  // starts from it (with the model picker). clearSelection leaves #prompt alone.
1540
1545
  $("#newBtn").addEventListener("click", () => {
1541
1546
  if (allViewOpen()) leaveAllView("");
@@ -1553,7 +1558,7 @@ $("#newBtn").addEventListener("click", () => {
1553
1558
  // tooltip; a route that fails leaves the picker hidden and the server's
1554
1559
  // default in charge. The search list itself is model_picker.js'; a pick
1555
1560
  // sends the focus to the message box.
1556
- const START_PICK_TITLE = "The model the new chat starts on";
1561
+ const START_PICK_TITLE = "The model the new session starts on";
1557
1562
  let startPickTitle = START_PICK_TITLE;
1558
1563
  // The fresh link the picker shows (null: the start page, or a session it
1559
1564
  // is hidden in), and a link pick's /model still in flight (a send waits).
@@ -1624,7 +1629,7 @@ function syncModelPick() {
1624
1629
  modelPickEl.setAttribute("aria-label", "Model this link starts on");
1625
1630
  } else {
1626
1631
  modelPickEl.title = startPickTitle;
1627
- modelPickEl.setAttribute("aria-label", "Model for the new chat");
1632
+ modelPickEl.setAttribute("aria-label", "Model for the new session");
1628
1633
  }
1629
1634
  }
1630
1635
  // Leaving a session: the start page's remembered model again (no fetch).
@@ -1680,9 +1685,14 @@ function clearChips() {
1680
1685
  setChips([]);
1681
1686
  }
1682
1687
 
1688
+ // A paste's, a drop's or the image button's files: the images the message
1689
+ // may still take become chips (images.js admitImages); a toast says why the
1690
+ // rest stayed out. Whether any went in.
1683
1691
  function addImageFiles(files) {
1684
- if (!files.length) return false;
1685
- for (const file of files) chips.push({ file, name: chipName(file, chips.length), src: URL.createObjectURL(file) });
1692
+ const { take, refused } = admitImages(files, chips.length);
1693
+ if (refused) showToast(refused);
1694
+ if (!take.length) return false;
1695
+ for (const file of take) chips.push({ file, name: chipName(file, chips.length), src: URL.createObjectURL(file) });
1686
1696
  renderChips();
1687
1697
  focusPrompt();
1688
1698
  return true;
@@ -1695,6 +1705,13 @@ chipsEl.addEventListener("click", (e) => {
1695
1705
  if (chip?.file && chip.src) URL.revokeObjectURL(chip.src);
1696
1706
  renderChips();
1697
1707
  });
1708
+ // The image button: the file picker's images join the chips. Its value is
1709
+ // cleared after each pick, so the same file can be picked again.
1710
+ attachBtn.addEventListener("click", () => attachInput.click());
1711
+ attachInput.addEventListener("change", () => {
1712
+ addImageFiles([...attachInput.files]);
1713
+ attachInput.value = "";
1714
+ });
1698
1715
  // ── Composer height: grows with the text; the top-edge grip sets a floor ───
1699
1716
  let promptFloor = Number(readStored(GRIP_KEY)) || null;
1700
1717
 
@@ -2093,15 +2110,24 @@ function stripHiddenSaved() {
2093
2110
  return saved === null ? readStored("chi_zen") === "1" : saved === "1";
2094
2111
  }
2095
2112
  // A short window hides the strip by itself, so the stage keeps room for a
2096
- // card that waits (its question and choices); the sessions pill brings it
2097
- // back until the window is tall again. Not saved: the saved choice is the
2098
- // user's own hide ▴ / show.
2113
+ // card that waits (its question and choices); the grip brings it back
2114
+ // until the window is tall again. Not saved: the saved choice is the
2115
+ // user's own hide or show.
2099
2116
  const shortWindow = window.matchMedia(SHORT_WINDOW_QUERY);
2100
2117
  let stripShownWhileShort = false;
2118
+ // The strip's grip (strip_grip.js): the handle on the edge between the strip
2119
+ // and the chat. It is given the hidden state and the toggle; it speaks its own
2120
+ // title and aria-expanded, refreshed below after every change.
2121
+ const stripGripRefresh = createStripGrip({
2122
+ gripEl: $("#stripGrip"),
2123
+ isHidden: () => document.body.classList.contains("strip-hidden"),
2124
+ setHidden: (hidden) => setStripHidden(hidden),
2125
+ });
2101
2126
  function applyStripHidden() {
2102
2127
  document.body.classList.toggle("strip-hidden", stripAutoHidden({
2103
2128
  saved: stripHiddenSaved(), short: shortWindow.matches, shownWhileShort: stripShownWhileShort,
2104
2129
  }));
2130
+ stripGripRefresh();
2105
2131
  updateSessionTitle();
2106
2132
  }
2107
2133
  function setStripHidden(hidden) {
@@ -2115,8 +2141,6 @@ shortWindow.addEventListener("change", () => {
2115
2141
  if (!shortWindow.matches) stripShownWhileShort = false;
2116
2142
  applyStripHidden();
2117
2143
  });
2118
- $("#stripShow").addEventListener("click", () => setStripHidden(false));
2119
- $("#stripHideTop").addEventListener("click", () => setStripHidden(true));
2120
2144
 
2121
2145
  // Esc leaves select mode first, then the all-sessions view.
2122
2146
  document.addEventListener("keydown", (e) => {
@@ -142,14 +142,19 @@ export function ctxBarText(pct, windowSource = null) {
142
142
  // bar's; a /model whose new window didn't resolve, no request since:
143
143
  // Engine#announce_switched_context! sent usage null) adds "until the next
144
144
  // request", which asks for it again.
145
- export function ctxBarTip(tokens, memoryIndex, window, segments = null) {
145
+ // +side+ (the timing's side block) adds the side requests' line, in the
146
+ // "No request in this session yet" answer too (a /btw before any turn).
147
+ export function ctxBarTip(tokens, memoryIndex, window, segments = null, side = null) {
146
148
  if (num(window?.pct) === null) {
147
- if (!num(tokens?.prompt_sum) && !num(tokens?.completion_sum)) return "No request in this session yet";
149
+ if (!num(tokens?.prompt_sum) && !num(tokens?.completion_sum)) {
150
+ const sideText = sideRequestsText(side);
151
+ return `No request in this session yet${sideText ? `\nside requests: ${sideText}` : ""}`;
152
+ }
148
153
  const why = window?.afterSwitch ? "Context window unknown until the next request" : "Context window unknown";
149
- return tokensTipText(tokens, why, memoryIndex, null, segments);
154
+ return tokensTipText(tokens, why, memoryIndex, null, segments, side);
150
155
  }
151
156
 
152
- return tokensTipText(tokens, undefined, memoryIndex, window, segments);
157
+ return tokensTipText(tokens, undefined, memoryIndex, window, segments, side);
153
158
  }
154
159
 
155
160
  // The ctx tooltip's window line ({tokens:, source:, pct:, budget:} the
@@ -198,6 +203,24 @@ export function modelSegmentCostText(row) {
198
203
  return [costText(row?.cost_sum), costText(row?.cost_estimate_sum, { estimate: true })].filter(Boolean).join(" + ");
199
204
  }
200
205
 
206
+ // "3 (recap 2, btw 1) · 14.2k in / 380 out · $0.0012 + ~$0.0009": the
207
+ // session's side requests (the timing's side block: the idle recap, /btw and
208
+ // other ctx.ask_model calls, apart from its turns' totals), the kinds in
209
+ // first-seen order (one kind: its name alone), then the model rows' tokens
210
+ // and cost words on the block; "" without any (the line is hidden). As
211
+ // Formatting#side_requests_text (spec/shared/labels_matrix.json side_requests).
212
+ export function sideRequestsText(side) {
213
+ if (!side || typeof side !== "object") return "";
214
+ const requests = num(side.requests) || 0;
215
+ if (requests <= 0) return "";
216
+ const kinds = side.by_kind && typeof side.by_kind === "object" ? Object.entries(side.by_kind) : [];
217
+ const names = kinds.length === 1
218
+ ? kinds[0][0]
219
+ : kinds.map(([kind, row]) => `${kind} ${Math.round(num(row?.requests) || 0)}`).join(", ");
220
+ const head = names ? `${Math.round(requests)} (${names})` : String(Math.round(requests));
221
+ return [head, modelSegmentTokensText(side), modelSegmentCostText(side)].filter(Boolean).join(" · ");
222
+ }
223
+
201
224
  // "switch prefill 3.0k uncached prompt ≈$0.0060": what the switch to the
202
225
  // row's model prefilled (its cost an estimate from the configured price,
203
226
  // left out without one); "" for none.
@@ -246,14 +269,16 @@ function modelSegmentLines(segments) {
246
269
  // totals. A delegated session keeps its own metrics, so these are this
247
270
  // session's only.
248
271
  export function tokensTipText(tokens, title = "Context used after the last turn", memoryIndex = null, window = null,
249
- segments = null) {
272
+ segments = null, side = null) {
250
273
  const memory = memoryIndexText(memoryIndex);
251
274
  const memoryLine = memory ? `\nmemory index: ${memory}` : "";
275
+ const sideText = sideRequestsText(side);
276
+ const sideLine = sideText ? `side requests: ${sideText}` : "";
252
277
  const windowText = ctxWindowText(window);
253
278
  if (windowText) title = `${title}\n${windowText}`;
254
279
  const prompt = num(tokens?.prompt_sum) || 0;
255
280
  const completion = num(tokens?.completion_sum) || 0;
256
- if (!prompt && !completion) return `${title}${memoryLine}`;
281
+ if (!prompt && !completion) return `${title}${sideLine ? `\n${sideLine}` : ""}${memoryLine}`;
257
282
  const cached = num(tokens.cached_sum) || 0;
258
283
  const reasoning = num(tokens.reasoning_sum) || 0;
259
284
  const parts = [`in ${count(prompt)}`];
@@ -262,6 +287,7 @@ export function tokensTipText(tokens, title = "Context used after the last turn"
262
287
  const lines = [title, ...modelSegmentLines(segments), `tokens: ${parts.join(" · ")}`];
263
288
  const cost = costLine(tokens);
264
289
  if (cost) lines.push(cost);
290
+ if (sideLine) lines.push(sideLine);
265
291
  lines.push("this session only, all requests");
266
292
  return `${lines.join("\n")}${memoryLine}`;
267
293
  }
@@ -15,6 +15,39 @@ export function imageFiles(dataTransfer) {
15
15
  return files.filter((f) => /^image\//.test(f?.type || ""));
16
16
  }
17
17
 
18
+ // The server's caps, mirrored so the composer refuses at once what a send
19
+ // would (spec/web/images_spec.rb pins both): ImageStore::MAX_TURN_REFS
20
+ // images a message, Web::App::MAX_IMAGE_UPLOAD_BYTES a file.
21
+ export const MAX_TURN_IMAGES = 20;
22
+ export const MAX_IMAGE_MB = 20;
23
+ const MAX_IMAGE_BYTES = MAX_IMAGE_MB * 1024 * 1024;
24
+
25
+ function filesText(n) {
26
+ return n === 1 ? "1 file" : `${n} files`;
27
+ }
28
+
29
+ // What the composer takes of +files+ (a paste's, a drop's or the image
30
+ // button's picks) while it holds +held+ chips: images only, none over
31
+ // MAX_IMAGE_MB, and no more than MAX_TURN_IMAGES in all. +refused+ says
32
+ // why the rest stayed out, for a toast ("" when none did).
33
+ export function admitImages(files, held = 0) {
34
+ const take = [];
35
+ let notImage = 0;
36
+ let tooBig = 0;
37
+ let overCap = 0;
38
+ for (const file of files || []) {
39
+ if (!/^image\//.test(file?.type || "")) notImage += 1;
40
+ else if ((file.size || 0) > MAX_IMAGE_BYTES) tooBig += 1;
41
+ else if (held + take.length >= MAX_TURN_IMAGES) overCap += 1;
42
+ else take.push(file);
43
+ }
44
+ const why = [];
45
+ if (overCap) why.push(`${filesText(overCap)} not added: at most ${MAX_TURN_IMAGES} images a message`);
46
+ if (tooBig) why.push(`${filesText(tooBig)} not added: over ${MAX_IMAGE_MB} MB`);
47
+ if (notImage) why.push(`${filesText(notImage)} not added: not an image`);
48
+ return { take, refused: why.join(" · ") };
49
+ }
50
+
18
51
  // A pasted screenshot is often called "image.png": number it so the chips
19
52
  // (and what the model reads) tell them apart.
20
53
  export function chipName(file, index) {
@@ -17,7 +17,8 @@
17
17
  --diff-add-bg:rgba(26,127,55,.12);--diff-add-text:#116329;--diff-del-bg:rgba(207,34,46,.10);--diff-del-text:#82071e;
18
18
  --code-text:#1f2328;--code-keyword:#cf222e;--code-string:#0a3069;--code-comment:#57606a;--code-constant:#0550ae;--code-func:#8250df;--code-type:#953800;
19
19
  --card-hover-border:rgba(31,35,40,.18);--on-solid:#fff;--on-warn:#1f2328;--text-hover:#0d1117;--badge-bg:rgba(31,35,40,.06);--badge-ring:rgba(31,35,40,.15);--shadow-strong:rgba(31,35,40,.18);--backdrop:rgba(13,17,23,.85);--toast-shadow:0 6px 24px rgba(31,35,40,.14)}}
20
- /* Icon masks shared by the phone info bar and the all view's cards (the stroke only shapes the mask; the icon takes currentColor). */
20
+ /* Icon masks shared by the phone info bar, the all view's cards and the composer's image button (the stroke only shapes the mask; the icon takes currentColor). */
21
+ :root{--icon-image: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='2' y='2.75' width='12' height='10.5' rx='1.5'/%3E%3Ccircle cx='5.75' cy='6.25' r='1.1'/%3E%3Cpath d='m2.5 12.5 3.75-3.75 2.5 2.5 1.75-1.75 3 3'/%3E%3C/svg%3E")}
21
22
  :root{--icon-archive: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='2' y='2.75' width='12' height='3.5' rx='1'/%3E%3Cpath d='M3.25 6.25v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1v-6M6.5 9h3'/%3E%3C/svg%3E");--icon-unarchive: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='2' y='2.75' width='12' height='3.5' rx='1'/%3E%3Cpath d='M3.25 6.25v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1v-6M8 11.5V8.25M6.5 9.75 8 8.25l1.5 1.5'/%3E%3C/svg%3E")}
22
23
  *{box-sizing:border-box;margin:0;padding:0}
23
24
  body{font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:radial-gradient(1100px 700px at 8% 0%,color-mix(in srgb,var(--accent) 13%,transparent),transparent 70%),radial-gradient(900px 700px at 100% 100%,color-mix(in srgb,var(--think) 9%,transparent),transparent 70%),var(--bg);background-attachment:fixed;color:var(--text);height:100vh;display:flex;flex-direction:column}
@@ -590,18 +591,26 @@ body.has-session #composer{flex-wrap:wrap;row-gap:6px}
590
591
  #lightbox[hidden]{display:none}
591
592
  #lightbox img{max-width:96vw;max-height:94vh;border-radius:6px}
592
593
  #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:relative;height:10px;margin:-5px 0;cursor:ns-resize;z-index:2;touch-action:none}
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
- #composeGrip:hover::after,#composeGrip:has(+ #composer:hover)::after,#composeGrip.dragging::after{opacity:1}
596
- #composeGrip:hover::after,#composeGrip.dragging::after{background:var(--accent)}
594
+ /* An edge grip: the thin handle on an edge between two panes (the composer's,
595
+ the latest-sessions strip's). The pill is the handle's look, a 36×4 px bar
596
+ centred on the edge, drawn on hover, while dragging or focused; the id
597
+ rules hold each grip's own box: height and margins (they depend on the flex
598
+ gap around it: none for the composer's, main's 16 px for the strip's) and
599
+ its own states. */
600
+ .edge-grip{position:relative;z-index:2;cursor:ns-resize;touch-action:none}
601
+ .edge-grip::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}
602
+ .edge-grip:hover::after,.edge-grip.dragging::after{opacity:1}
603
+ .edge-grip:hover::after,.edge-grip.dragging::after{background:var(--accent)}
604
+ .edge-grip:focus-visible{outline:none}
605
+ .edge-grip:focus-visible::after{opacity:1;box-shadow:0 0 0 2px var(--accent-ring)}
606
+ #composeGrip{height:10px;margin:-5px 0}
607
+ #composeGrip:has(+ #composer:hover)::after{opacity:1}
597
608
  /* Folded (composer_grip.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
609
  draft = the accent, waiting = the attention colour; .will-fold = a drag past the fold threshold (released it hides the box). */
599
610
  body.has-session.composer-folded #composeGrip::after{opacity:1;width:48px;margin-left:-24px}
600
611
  #composeGrip.draft::after{background:var(--accent)}
601
612
  #composeGrip.waiting::after{background:var(--warn)}
602
613
  #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
614
  /* 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
615
  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
616
  border and padding, so it never covers a button of the bar. */
@@ -619,6 +628,12 @@ body.has-session.composer-folded #composeGrip::after{opacity:1;width:48px;margin
619
628
  #composer .actions-col button{white-space:nowrap;font-size:12px;padding:0 12px;height:28px;min-height:28px;line-height:28px}
620
629
  #composer .actions-col .split{order:3;display:flex;gap:4px;margin-left:auto}
621
630
  #composer #actionBtn{min-width:64px;font-weight:600}
631
+ /* The image button (app.js: a click opens #attachInput, the hidden file picker; a phone's offers the camera and the photo library):
632
+ an icon just left of Start / Send, quiet like the model picker; hidden in a session a terminal owns, and on a phone while a turn runs. */
633
+ #composer .actions-col .attach-btn{order:2;flex:none;width:28px;padding:0;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--text-muted)}
634
+ #composer .actions-col .attach-btn::before{content:"";display:block;width:16px;height:16px;margin:auto;background:currentColor;-webkit-mask:var(--icon-image) center/contain no-repeat;mask:var(--icon-image) center/contain no-repeat}
635
+ #composer .actions-col .attach-btn:hover{filter:none;color:var(--text);background:color-mix(in srgb,var(--bg-hover) 75%,transparent)}
636
+ #composer .actions-col .attach-btn[hidden]{display:none}
622
637
  /* In a session the composer is one row: the text field (one line, growing with its text; fitPrompt sets its height)
623
638
  and Cancel · Send at its bottom right. The key hint is in the placeholder there, so its row goes. */
624
639
  body.has-session #composer{flex-direction:row;align-items:flex-end;padding:10px}
@@ -636,12 +651,12 @@ body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
636
651
  /* A fresh link's (body.fresh-link): the session composer is one row and .actions-col is content-sized there, so a px cap keeps the text field's room and Send in view. :not([hidden]) keeps a failed /api/models' hide (this selector outranks the [hidden] one). */
637
652
  body.has-session.fresh-link #composer .actions-col .model-pick:not([hidden]){display:inline-block;flex:0 1 auto;min-width:0;max-width:200px}
638
653
  @media(max-width:600px){body.has-session.fresh-link #composer .actions-col .model-pick:not([hidden]){max-width:100px}}
639
- /* The new chat's LLM context chip, after the picker: as quiet as it; accent once a choice is set (the info bar chip's .own). */
654
+ /* The new session's LLM context chip, after the picker: as quiet as it; accent once a choice is set (the info bar chip's .own). */
640
655
  #composer .actions-col .llmctx-new{order:0;flex:0 1 auto;min-width:0;max-width:min(40%,260px);height:24px;min-height:24px;line-height:22px;padding:0 6px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--text-muted);font-size:11px;font-weight:400;overflow:hidden;text-overflow:ellipsis}
641
656
  #composer .actions-col .llmctx-new:hover{filter:none;color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
642
657
  #composer .actions-col .llmctx-new.own{color:var(--accent);border-color:var(--accent-border)}
643
658
  #composer .actions-col .llmctx-new[hidden],body.has-session #composer .actions-col .llmctx-new{display:none}
644
- /* The new chat's thinking chip, after it: shorter text, so a smaller share of the row. */
659
+ /* The new session's thinking chip, after it: shorter text, so a smaller share of the row. */
645
660
  #composer .actions-col .think-new{max-width:min(25%,140px)}
646
661
  /* Phones (the key hint is gone there): the chips keep their whole text and the row wraps, Start staying right (its margin-left:auto) on the last line; only a model name wider than the row ellipsizes. */
647
662
  @media(max-width:600px){#composer .actions-col .model-pick{flex:0 1 auto;min-width:0;max-width:100%}#composer .actions-col .llmctx-new,#composer .actions-col .think-new{flex:0 0 auto;max-width:100%}}
@@ -975,8 +990,7 @@ body.has-session #notice{display:none}
975
990
  #newBtn .brand-new{font-size:12px;color:var(--text-muted);display:none}
976
991
  body.has-session #newBtn .brand-new{display:inline}
977
992
  #newBtn:hover .brand-new{color:var(--text-2)}
978
- .strip-side{display:flex;flex-direction:column;gap:6px;min-width:0}
979
- /* The scope chip: "<project> · all", or "all projects" and where a new chat starts (start page only). */
993
+ /* The scope chip: "<project> · all", or "all projects" and where a new session starts (start page only). */
980
994
  #scope{display:inline-flex;align-items:center;gap:8px;min-width:0;font-size:12px;color:var(--text-muted)}
981
995
  #scope[hidden]{display:none}
982
996
  #scope .scope-name{flex-shrink:0;padding:2px 9px;border:1px solid var(--border-strong);border-radius:999px;color:var(--text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:40vw}
@@ -989,7 +1003,7 @@ body.has-session #newBtn .brand-new{display:inline}
989
1003
  body.has-session #scope .scope-where{display:none}
990
1004
  /* A phone has no room for both; the hidden strip's pill (count, live) wins. */
991
1005
  @media(max-width:600px){body.strip-hidden #scope .scope-where{display:none}}
992
- /* A phone's top bar stays one row: "chi +" (the rest of "+ new chat" goes),
1006
+ /* A phone's top bar stays one row: "chi +" (the rest of "+ new session" goes),
993
1007
  tighter gaps, nothing wraps, and the "← project" link cuts with an
994
1008
  ellipsis before the pill does. */
995
1009
  @media(max-width:600px){
@@ -998,11 +1012,9 @@ body.has-session #scope .scope-where{display:none}
998
1012
  #newBtn .brand-new-word{display:none}
999
1013
  #newBtn .brand-new,#scope .scope-back{white-space:nowrap}
1000
1014
  #scope .scope-back{min-width:0;overflow:hidden;text-overflow:ellipsis}
1001
- #stripShow{padding:3px 8px}
1015
+ #stripStatus{padding:3px 8px}
1002
1016
  #newBtn .brand-new{font-size:16px}
1003
1017
  }
1004
- /* The ▾ glyph draws tiny at the pill's 11 px: at 20 px it is about as tall
1005
- as the pill text's capitals (phone and desktop alike). */
1006
1018
  /* The bell (notifications): quiet at the top bar's right end; filled in the accent when on, struck through when the browser blocks it. */
1007
1019
  /* The open session's title, when no strip card in sight shows it (session_title.js): the bar's middle, cut with an ellipsis; the whole first message in its tooltip. */
1008
1020
  #sessionTitle{flex:1;min-width:0;font-size:13px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -1014,10 +1026,10 @@ body.has-session #scope .scope-where{display:none}
1014
1026
  @media(max-width:600px){
1015
1027
  #topBar{flex-wrap:wrap;row-gap:2px}
1016
1028
  #sessionTitle{flex-basis:100%;order:10;font-size:12px}
1017
- #scope,#stripShow{flex:1 1 0;min-width:0;max-width:max-content}
1018
- #stripShow{flex-grow:1000;overflow:hidden;text-overflow:ellipsis}
1029
+ #scope,#stripStatus{flex:1 1 0;min-width:0;max-width:max-content}
1030
+ #stripStatus{flex-grow:1000;overflow:hidden;text-overflow:ellipsis}
1019
1031
  #scope .scope-name{flex-shrink:1;min-width:0}
1020
- #stripShow:has(.live,.waiting) .pill-count{display:none}
1032
+ #stripStatus:has(.live,.waiting) .pill-count{display:none}
1021
1033
  }
1022
1034
  #notifyBtn{margin-left:auto;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;min-height:0;background:transparent;border:1px solid transparent;border-radius:999px;color:var(--text-muted)}
1023
1035
  #notifyBtn[hidden]{display:none}
@@ -1028,7 +1040,6 @@ body.has-session #scope .scope-where{display:none}
1028
1040
  #notifyBtn[data-state="on"] .bell{fill:currentColor;fill-opacity:.22}
1029
1041
  #notifyBtn[data-state="denied"]{color:var(--text-faint)}
1030
1042
  #notifyBtn[data-state="denied"] .slash{display:inline}
1031
- #stripShow .pill-arrow{font-size:20px;line-height:0;vertical-align:-3px}
1032
1043
  .card .folder{font-family:ui-monospace,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;color:var(--text-3)}
1033
1044
  .card .parent{font-family:ui-monospace,monospace;white-space:nowrap;color:var(--text-3)}
1034
1045
  #infoBar .meta a.parent{text-decoration:none}
@@ -1098,22 +1109,42 @@ body.no-glass :is(#familyPop,#chainPop){background:var(--bg-panel);-webkit-backd
1098
1109
  .scope-error{grid-column:1/-1;font-size:13px;color:var(--warn-text)}
1099
1110
  .strip-toggle{font-size:11px;padding:3px 10px;background:transparent;color:var(--text-muted);border:1px solid var(--border);border-radius:999px}
1100
1111
  .strip-toggle:hover{color:var(--text-2);border-color:var(--border-strong)}
1101
- #stripShow{display:none;white-space:nowrap;flex-shrink:0}
1102
- #stripShow .live{color:var(--ok-text);white-space:nowrap}
1103
- #stripShow .live-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--ok);margin-right:4px;vertical-align:1px}
1104
- #stripShow .waiting{color:var(--warn-text);white-space:nowrap}
1105
- #stripShow .waiting-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--warn);margin-right:4px;vertical-align:1px}
1106
- #stripHide{align-self:flex-end;border:0;padding:0 2px;border-radius:4px}
1107
- #stripHide:hover{text-decoration:underline;color:var(--text-2)}
1108
- /* A narrow window's strip scrolls sideways, which puts its own "hide ▴" out of sight: the top bar has it instead, where the pill comes back, with a finger-sized target. */
1109
- #stripHideTop{display:none;white-space:nowrap;flex-shrink:0}
1112
+ #stripStatus{display:none;white-space:nowrap;flex-shrink:0}
1113
+ #stripStatus .live{color:var(--ok-text);white-space:nowrap}
1114
+ #stripStatus .live-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--ok);margin-right:4px;vertical-align:1px}
1115
+ #stripStatus .waiting{color:var(--warn-text);white-space:nowrap}
1116
+ #stripStatus .waiting-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--warn);margin-right:4px;vertical-align:1px}
1117
+ /* A narrow window's strip scrolls sideways, and the grip's own target there is thin: the status pill gets a finger-sized one. */
1110
1118
  @media(max-width:900px){
1111
- body:not(.strip-hidden):not(.all-view) #stripHideTop{display:block}
1112
- #stripHide{display:none}
1113
- #stripHideTop,#stripShow{min-height:30px;padding:4px 12px;font-size:12px}
1119
+ #stripStatus{min-height:30px;padding:4px 12px;font-size:12px}
1120
+ }
1121
+ body.strip-hidden #stripStatus{display:block}
1122
+ body.strip-hidden #topStrip, body.all-view #stripStatus{display:none}
1123
+ /* The strip's grip (strip_grip.js, app.js): the handle on the edge between the
1124
+ strip and the chat, the composer's twin (the shared .edge-grip look, its own
1125
+ 10 px box). Its box is centred on #center's top edge: main's 16 px gap stands
1126
+ on both sides because the box's height and margins add up to -26 px, so the
1127
+ distances stay what they were without it (strip → chat 16 px; hidden, bar →
1128
+ chat 10 px). While the strip shows, the pill is drawn on a hover of the strip
1129
+ or of the grip itself, as the composer's is of its box; hidden, it is the
1130
+ always-on handle that brings the strip back (48 px, as the folded composer's,
1131
+ since it is a control then and not only a hint). */
1132
+ #stripGrip{height:10px;margin:-5px 0 -21px}
1133
+ #topStrip:hover + #stripGrip::after{opacity:1}
1134
+ #stripGrip.will-toggle::after{opacity:1;background:var(--accent)}
1135
+ body.strip-hidden #stripGrip::after{opacity:1;width:48px;margin-left:-24px}
1136
+ body.all-view #stripGrip{display:none}
1137
+ /* A phone has no hover: the pill is always drawn, and the hit zone is 28 px
1138
+ tall, ~96 px wide and centred on the edge (main's gap plus the box: the
1139
+ margins add up to -(28+16)) — the mirror of the composer's phone grip: it
1140
+ reaches 7 px over its own side (below the cards; it never covers one) and
1141
+ 21 px into the transcript, with the pill on #center's top edge. The grip sits
1142
+ under the strip, outside its sideways scroller, so a swipe on the cards stays
1143
+ a scroll (touch-action:none is the grip's box, not the strip's). */
1144
+ @media(max-width:600px){
1145
+ #stripGrip{width:96px;height:28px;margin:-7px auto -37px}
1146
+ #stripGrip::after{top:5px;opacity:1}
1114
1147
  }
1115
- body.strip-hidden #stripShow{display:block}
1116
- body.strip-hidden #topStrip, body.all-view #stripShow{display:none}
1117
1148
  #allTile{flex:1;flex-direction:column;justify-content:center;align-items:flex-start;text-align:left;color:var(--text-2);font-size:13px;font-weight:600;width:100%}
1118
1149
  #allTile .count{font-size:11px;font-weight:400;color:var(--text-muted)}
1119
1150
  #allView{display:none;flex:1;min-height:0;flex-direction:column;gap:12px}
@@ -1213,14 +1244,14 @@ a{color:var(--accent)}
1213
1244
  <body>
1214
1245
  <main>
1215
1246
  <div id="topBar">
1216
- <button type="button" id="newBtn" class="brand" title="New chat"><span class="brand-name">chi</span><span class="brand-new">+<span class="brand-new-word"> new chat</span></span></button>
1247
+ <button type="button" id="newBtn" class="brand" title="New session"><span class="brand-name">chi</span><span class="brand-new">+<span class="brand-new-word"> new session</span></span></button>
1217
1248
  <div id="scope" hidden></div>
1218
- <button type="button" class="strip-toggle" id="stripShow" title="Show the latest sessions"><span class="pill-arrow" aria-hidden="true">▾</span> sessions</button>
1219
- <button type="button" class="strip-toggle" id="stripHideTop" title="Hide the latest sessions"><span class="pill-arrow" aria-hidden="true">▴</span> hide</button>
1249
+ <button type="button" class="strip-toggle" id="stripStatus" title="All sessions, with search"><span class="pill-count">sessions</span></button>
1220
1250
  <span id="sessionTitle" hidden></span>
1221
1251
  <button type="button" id="notifyBtn" aria-label="Notifications" aria-pressed="false" hidden><svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true"><path class="bell" d="M8 1.75a4 4 0 0 0-4 4v2.6L2.75 10.6v.9h10.5v-.9L12 8.35v-2.6a4 4 0 0 0-4-4Z"/><path class="clapper" d="M6.5 13a1.5 1.5 0 0 0 3 0"/><path class="slash" d="M2.5 2.5l11 11"/></svg></button>
1222
1252
  </div>
1223
1253
  <section id="topStrip" aria-label="Latest sessions" class="loading"><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div></section>
1254
+ <div id="stripGrip" class="edge-grip" tabindex="0" role="button" aria-controls="topStrip" aria-label="Latest sessions" title="Drag up or click to hide the latest sessions"></div>
1224
1255
  <section id="center">
1225
1256
  <div id="stage">
1226
1257
  <div id="markdownWarning" class="markdown-warning" hidden></div>
@@ -1231,7 +1262,7 @@ a{color:var(--accent)}
1231
1262
  <div id="lightbox" hidden><img alt=""></div>
1232
1263
  <div id="dock">
1233
1264
  <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>
1265
+ <div id="composeGrip" class="edge-grip" tabindex="0" aria-label="Message box" title="Drag to make the message box taller (double-click resets)"></div>
1235
1266
  <div id="composer" style="position:relative">
1236
1267
  <div id="commandList" role="listbox" aria-label="Commands" hidden></div>
1237
1268
  <div id="modelPanel" hidden>
@@ -1244,11 +1275,13 @@ a{color:var(--accent)}
1244
1275
  <textarea id="prompt" placeholder="Ask chi anything…" aria-controls="commandList" aria-autocomplete="list" aria-expanded="false"></textarea>
1245
1276
  </div>
1246
1277
  <div class="actions-col">
1247
- <button type="button" id="modelPick" class="model-pick" title="The model the new chat starts on" aria-label="Model for the new chat" aria-haspopup="listbox" aria-expanded="false" hidden><span class="model-pick-name"></span></button>
1278
+ <button type="button" id="modelPick" class="model-pick" title="The model the new session starts on" aria-label="Model for the new session" aria-haspopup="listbox" aria-expanded="false" hidden><span class="model-pick-name"></span></button>
1248
1279
  <button type="button" id="thinkingNew" class="llmctx-new think-new" aria-haspopup="dialog" hidden></button>
1249
1280
  <button type="button" id="llmContextNew" class="llmctx-new" aria-haspopup="dialog" hidden></button>
1250
1281
  <div id="composeHint">Enter to start · Shift+Enter for a newline</div>
1251
1282
  <button id="cancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Stop turn</button>
1283
+ <button type="button" id="attachBtn" class="attach-btn" title="Add images (or paste or drop them here)" aria-label="Add images" hidden></button>
1284
+ <input type="file" id="attachInput" accept="image/*" multiple hidden>
1252
1285
  <div class="split">
1253
1286
  <button id="actionBtn">Start</button>
1254
1287
  </div>
@@ -68,7 +68,7 @@ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, res
68
68
  const ctxTip = ctxBarTip(tokens, state.timing.memoryIndex,
69
69
  { tokens: state.ctxWindow, source: state.ctxWindowSource, pct: state.ctxPct,
70
70
  budget: state.llmContext?.budget_tokens, afterSwitch: state.ctxUnknownAfterSwitch },
71
- state.timing.modelSegments);
71
+ state.timing.modelSegments, state.timing.side);
72
72
  const speedTextNow = lastSpeedText(tokens);
73
73
  const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
74
74
  const sessionDuration = sessionDurationNow(state.timing, running);