samagotchi 0.66.0 → 0.68.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 (151) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/docs/architecture.md +55 -30
  4. data/docs/cli.md +3 -1
  5. data/docs/configuration.md +50 -9
  6. data/docs/context.md +35 -1
  7. data/docs/desktop.md +90 -22
  8. data/docs/guardrails.md +8 -5
  9. data/docs/hooks.md +2 -2
  10. data/docs/internals/anthropic.md +156 -0
  11. data/docs/internals/background-tasks.md +1 -1
  12. data/docs/internals/bundles.md +2 -0
  13. data/docs/internals/engine.md +129 -38
  14. data/docs/internals/gemma4-contract.md +1 -1
  15. data/docs/internals/llm-context-bench.md +5 -4
  16. data/docs/internals/prompt-caching.md +26 -11
  17. data/docs/plugins.md +126 -23
  18. data/docs/sessions.md +6 -6
  19. data/docs/sub-agent.md +4 -1
  20. data/docs/testing.md +18 -4
  21. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  22. data/lib/samagotchi/bootstrap_command.rb +13 -3
  23. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  24. data/lib/samagotchi/bridge.rb +6 -3
  25. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  26. data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
  27. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  28. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  29. data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
  31. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
  33. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  34. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  36. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  37. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  38. data/lib/samagotchi/config.rb +15 -7
  39. data/lib/samagotchi/context_absorber.rb +66 -13
  40. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  41. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  42. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  43. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  44. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  45. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  46. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  47. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  48. data/lib/samagotchi/desktop/macos.rb +84 -13
  49. data/lib/samagotchi/desktop_command.rb +14 -3
  50. data/lib/samagotchi/effective_model.rb +283 -0
  51. data/lib/samagotchi/engine.rb +307 -549
  52. data/lib/samagotchi/engine_peers.rb +3 -0
  53. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  54. data/lib/samagotchi/guardrails/approval.rb +1 -1
  55. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  56. data/lib/samagotchi/guardrails/rules.rb +1 -1
  57. data/lib/samagotchi/guardrails/targets.rb +1 -1
  58. data/lib/samagotchi/hook_routes.rb +151 -0
  59. data/lib/samagotchi/hooks/registry.rb +2 -2
  60. data/lib/samagotchi/host_keys.rb +147 -0
  61. data/lib/samagotchi/level_setting_command.rb +99 -0
  62. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  63. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  64. data/lib/samagotchi/llm/api_key.rb +23 -7
  65. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  66. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  67. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  68. data/lib/samagotchi/llm_context_override.rb +1 -0
  69. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  70. data/lib/samagotchi/peer_sources.rb +58 -0
  71. data/lib/samagotchi/plugin/api.rb +64 -155
  72. data/lib/samagotchi/plugin/loader.rb +4 -2
  73. data/lib/samagotchi/plugin/peer.rb +394 -0
  74. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  75. data/lib/samagotchi/plugin_tasks.rb +2 -2
  76. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  77. data/lib/samagotchi/row_refs.rb +1 -1
  78. data/lib/samagotchi/self_report.rb +5 -3
  79. data/lib/samagotchi/send_command.rb +21 -11
  80. data/lib/samagotchi/session.rb +22 -8
  81. data/lib/samagotchi/session_continue.rb +58 -15
  82. data/lib/samagotchi/session_inbox.rb +0 -13
  83. data/lib/samagotchi/session_setting.rb +86 -0
  84. data/lib/samagotchi/thinking_command.rb +24 -76
  85. data/lib/samagotchi/thinking_notices.rb +169 -0
  86. data/lib/samagotchi/tool_call_parser.rb +1 -1
  87. data/lib/samagotchi/toolkit_binding.rb +138 -0
  88. data/lib/samagotchi/tools/context_read.rb +14 -0
  89. data/lib/samagotchi/tools/execute.rb +12 -12
  90. data/lib/samagotchi/tools/registry.rb +1 -1
  91. data/lib/samagotchi/tools/task_create.rb +6 -6
  92. data/lib/samagotchi/tools/task_get.rb +13 -13
  93. data/lib/samagotchi/tools/task_list.rb +6 -6
  94. data/lib/samagotchi/tools/task_record.rb +52 -0
  95. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  96. data/lib/samagotchi/tools/task_stop.rb +6 -6
  97. data/lib/samagotchi/tools/task_wait.rb +12 -13
  98. data/lib/samagotchi/tools/tool_index.rb +454 -0
  99. data/lib/samagotchi/update_command.rb +9 -6
  100. data/lib/samagotchi/version.rb +1 -1
  101. data/lib/samagotchi/web/app.rb +71 -93
  102. data/lib/samagotchi/web/capabilities.rb +1 -1
  103. data/lib/samagotchi/web/public/activity.js +2 -2
  104. data/lib/samagotchi/web/public/annotate_actions.js +1 -1
  105. data/lib/samagotchi/web/public/app.js +103 -539
  106. data/lib/samagotchi/web/public/bubbles.js +291 -0
  107. data/lib/samagotchi/web/public/cards_place.js +184 -0
  108. data/lib/samagotchi/web/public/chains.js +1 -1
  109. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  110. data/lib/samagotchi/web/public/command_list.js +12 -10
  111. data/lib/samagotchi/web/public/commands.js +2 -2
  112. data/lib/samagotchi/web/public/composer_size.js +2 -2
  113. data/lib/samagotchi/web/public/copy.js +1 -1
  114. data/lib/samagotchi/web/public/ctx.js +1 -1
  115. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  116. data/lib/samagotchi/web/public/families_view.js +1 -1
  117. data/lib/samagotchi/web/public/format.js +11 -7
  118. data/lib/samagotchi/web/public/images.js +25 -2
  119. data/lib/samagotchi/web/public/index.html +15 -7
  120. data/lib/samagotchi/web/public/info_bar.js +3 -3
  121. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  122. data/lib/samagotchi/web/public/init_row.js +21 -10
  123. data/lib/samagotchi/web/public/level_chip.js +29 -80
  124. data/lib/samagotchi/web/public/lists_view.js +64 -9
  125. data/lib/samagotchi/web/public/llm_context_chip.js +38 -107
  126. data/lib/samagotchi/web/public/model_picker.js +26 -22
  127. data/lib/samagotchi/web/public/notify.js +6 -4
  128. data/lib/samagotchi/web/public/question_card.js +1 -1
  129. data/lib/samagotchi/web/public/ref_scan.js +2 -8
  130. data/lib/samagotchi/web/public/refs.js +13 -0
  131. data/lib/samagotchi/web/public/session_state.js +14 -0
  132. data/lib/samagotchi/web/public/session_title.js +2 -3
  133. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  134. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  135. data/lib/samagotchi/web/public/stage_model.js +35 -2
  136. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  137. data/lib/samagotchi/web/public/stage_view.js +56 -335
  138. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  139. data/lib/samagotchi/web/public/strip.js +3 -3
  140. data/lib/samagotchi/web/public/task_stop.js +3 -2
  141. data/lib/samagotchi/web/public/turn_events.js +1 -1
  142. data/lib/samagotchi/web/public/turn_html.js +2 -2
  143. data/lib/samagotchi/web/public/turn_model.js +2 -2
  144. data/lib/samagotchi/web/public/turn_view.js +1 -1
  145. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  146. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  147. data/lib/samagotchi/web/server.rb +0 -4
  148. data/lib/samagotchi/worker.rb +22 -24
  149. data/lib/samagotchi/worker_inbound.rb +36 -4
  150. data/lib/samagotchi/worker_wakes.rb +67 -7
  151. metadata +27 -1
@@ -83,6 +83,40 @@ export function stripCountText(shown, total) {
83
83
  : `${total - shown} archived`;
84
84
  }
85
85
 
86
+ // How long a new card fades in and a status dot's pulse period: index.html's
87
+ // `.card.entering` fadeIn (.28s) and `.card .dot` dot-pulse / attn-pulse (1.6s).
88
+ export const CARD_FADE_MS = 280;
89
+ export const DOT_PULSE_MS = 1600;
90
+
91
+ // Which of a list's cards fade in. Every draw makes new nodes (draw), and a
92
+ // new node starts its CSS animation again: so only a card the list did not
93
+ // draw last time (+prev+: id → when it was first drawn, from performance.now();
94
+ // none for a list not drawn yet, or emptied: every card is new) gets
95
+ // `entering`, and one drawn again during its own fade keeps it, carried on
96
+ // from where it was (+entering+: id → ms since its first draw, the negative
97
+ // animation-delay) instead of starting over or snapping to full.
98
+ // +ids+: the cards drawn now. +drawn+ is the next draw's +prev+ (only +ids+).
99
+ export function enteringCards(prev, ids, now, fadeMs = CARD_FADE_MS) {
100
+ const drawn = new Map();
101
+ const entering = new Map();
102
+ for (const id of ids) {
103
+ const at = prev?.get(id) ?? now;
104
+ drawn.set(id, at);
105
+ if (now - at < fadeMs) entering.set(id, Math.max(0, now - at));
106
+ }
107
+ return { drawn, entering };
108
+ }
109
+
110
+ // A card's inline animation phases: its fade's delay (+enterMs+ into it) and
111
+ // the status dots' pulse, phase-locked to the clock (+now+, performance.now())
112
+ // so a redrawn dot carries on pulsing where the last one was. "" for neither.
113
+ export function cardAnimStyle({ enterMs = null, now = null } = {}, period = DOT_PULSE_MS) {
114
+ const parts = [];
115
+ if (enterMs) parts.push(`--enter-delay:-${Math.round(enterMs)}ms`);
116
+ if (now !== null) parts.push(`--pulse-delay:-${Math.round(now % period)}ms`);
117
+ return parts.join(";");
118
+ }
119
+
86
120
  // A card: the first message as its title, then status, how long ago, owner
87
121
  // and memories. The id is in the tooltip (and the search) only. A session
88
122
  // that waits on the user (a question, an approval, a card with actions)
@@ -95,11 +129,13 @@ export function stripCountText(shown, total) {
95
129
  // member does; with rows (all sessions) an open family lists its members
96
130
  // inline, the strip's opens in a popover; matchIds: a search's matching
97
131
  // members. A family in a session chain (chains.js foldChains) carries its
98
- // `day N` chip, which opens the other links in a popover.
132
+ // `day N` chip, which opens the other links in a popover. entering: a card
133
+ // new to its list (enteringCards: it fades in); anim: its inline animation
134
+ // phases (cardAnimStyle).
99
135
  // +ctx+: title (cardTitle), projectName (the page's scope), activeId (the
100
136
  // open session), chainOpen (its chain's popover is open), parent (the
101
137
  // delegating session, or null).
102
- export function cardTemplate(s, { deletable = false, archivable = false, selectable = false, picked = false, skipReason = null, family = null, open = false, rows = false, matchIds = null } = {}, { title = "", projectName = null, activeId = null, chainOpen = false, parent = null } = {}) {
138
+ export function cardTemplate(s, { deletable = false, archivable = false, selectable = false, picked = false, skipReason = null, family = null, open = false, rows = false, matchIds = null, entering = false, anim = "" } = {}, { title = "", projectName = null, activeId = null, chainOpen = false, parent = null } = {}) {
103
139
  const fam = family && family.members.length ? family : null;
104
140
  const ts = s.updated_at || s.created_at || "";
105
141
  const tip = `${s.id} · updated ${ts} · ${s.status}`;
@@ -128,9 +164,9 @@ export function cardTemplate(s, { deletable = false, archivable = false, selecta
128
164
  // Its delegates and its chain's other links: the card is .contains-active while one is open.
129
165
  const memberIds = [...(fam ? fam.members.map((m) => m.session.id) : []),
130
166
  ...(family?.chain ? family.chain.links.map((x) => x.id).filter((id) => id !== s.id) : [])];
131
- const cls = `card${s.archived ? " archived" : ""}${waiting || familyWaiting ? " waiting" : ""}${selectable && picked ? " picked" : ""}${fam && rows && open ? " family-open" : ""}`;
167
+ const cls = `card${s.archived ? " archived" : ""}${waiting || familyWaiting ? " waiting" : ""}${selectable && picked ? " picked" : ""}${fam && rows && open ? " family-open" : ""}${entering ? " entering" : ""}`;
132
168
  const recap = s.recap ? html`<div class="recap" title=${s.recap}>${s.recap}</div>` : nothing;
133
- return html`<div class=${cls} data-id=${s.id} data-members=${memberIds.length ? memberIds.join(" ") : nothing} title=${`${tip}${memTitle ? ` · ${memTitle}` : ""}`}><div class="preview">${title}</div>${recap}<div class="meta"><span class=${`status dot ${s.status}${waiting ? " waiting" : ""}`} title=${dotWord} aria-label=${dotWord}></span>${waitingT}${stoppedT}${time}${ctxT}${folderT}${parentChipTemplate(s, parent)}${chain}${kids}${ownerBadgeTemplate(s)}${archived}</div>${famRows}${skip}${pick}${selectable ? nothing : cardToolsTemplate(s, { deletable, archivable })}</div>`;
169
+ return html`<div class=${cls} data-id=${s.id} data-members=${memberIds.length ? memberIds.join(" ") : nothing} style=${anim || nothing} title=${`${tip}${memTitle ? ` · ${memTitle}` : ""}`}><div class="preview">${title}</div>${recap}<div class="meta"><span class=${`status dot ${s.status}${waiting ? " waiting" : ""}`} title=${dotWord} aria-label=${dotWord}></span>${waitingT}${stoppedT}${time}${ctxT}${folderT}${parentChipTemplate(s, parent)}${chain}${kids}${ownerBadgeTemplate(s)}${archived}</div>${famRows}${skip}${pick}${selectable ? nothing : cardToolsTemplate(s, { deletable, archivable })}</div>`;
134
170
  }
135
171
 
136
172
  function cardToolsTemplate(s, { deletable, archivable }) {
@@ -157,8 +193,10 @@ function ownerBadgeTemplate(s) {
157
193
  }
158
194
 
159
195
  // The strip's end: the All sessions tile (with its count) and hide ▴.
160
- export function stripTileTemplate(count) {
161
- return html`<div class="strip-side"><button type="button" class="card" id="allTile" title="All sessions, with search">All sessions →<span class="count">${count}</span></button><button type="button" class="strip-toggle" id="stripHide" title="Hide the latest sessions">hide ▴</button></div>`;
196
+ // entering / anim: as a card's (cardTemplate). id and title are bound, as
197
+ // class is, so the attributes keep their order.
198
+ export function stripTileTemplate(count, { entering = false, anim = "" } = {}) {
199
+ return html`<div class="strip-side"><button type="button" class=${entering ? "card entering" : "card"} id=${"allTile"} style=${anim || nothing} title=${"All sessions, with search"}>All sessions →<span class="count">${count}</span></button><button type="button" class="strip-toggle" id="stripHide" title="Hide the latest sessions">hide ▴</button></div>`;
162
200
  }
163
201
 
164
202
  // The hidden strip's pill: "▾ 5 sessions · ● 1 live · ● 1 waiting"
@@ -261,6 +299,18 @@ export function createListsView(deps) {
261
299
  return ordered;
262
300
  }
263
301
 
302
+ // The cards each list actually drew (the strip's after its cut to what
303
+ // fits; not drawnOrder, which select mode reads), id → when first drawn:
304
+ // a card not in it fades in (enteringCards). A list drawn empty forgets.
305
+ const lastDrawn = new Map(); // list element → Map(id → performance.now() at its first draw)
306
+ const STRIP_TILE = "#allTile"; // the All sessions tile's id there (no session id starts with #)
307
+ function enteringFor(el, ids) {
308
+ const now = win.performance?.now() ?? Date.now();
309
+ const { drawn, entering } = enteringCards(lastDrawn.get(el), ids, now);
310
+ lastDrawn.set(el, drawn);
311
+ return (id) => ({ entering: entering.has(id), anim: cardAnimStyle({ enterMs: entering.get(id), now }) });
312
+ }
313
+
264
314
  // The strip's cards: stripOrder, held under the pointer, as many as fit.
265
315
  function stripCards() {
266
316
  return listOrder(topStripEl, stripOrder(shownSessions(), sessions()), familyId).slice(0, stripCols);
@@ -425,6 +475,7 @@ export function createListsView(deps) {
425
475
  const refused = scopeError();
426
476
  if (refused) {
427
477
  draw(topStripEl, html`<div class="hint scope-error">${refused} · <a href=${allScopeHref("", null, win.location.search)}>show every session</a></div>`);
478
+ lastDrawn.delete(topStripEl);
428
479
  familyPop.close();
429
480
  chainPop.close();
430
481
  return;
@@ -433,12 +484,14 @@ export function createListsView(deps) {
433
484
  // Only archived sessions: no cards, but the tile stays, the way to them.
434
485
  if (!all.length) {
435
486
  topStripEl.replaceChildren();
487
+ lastDrawn.delete(topStripEl);
436
488
  familyPop.close();
437
489
  chainPop.close();
438
490
  return;
439
491
  }
440
- const tile = stripTileTemplate(stripCountText(shownSessions().length, all.length));
441
- draw(topStripEl, html`${cards.map((f) => cardFor(f.head, { family: f, open: familyPop.openHead() === f.head.id }))}${tile}`);
492
+ const anim = enteringFor(topStripEl, [...cards.map(familyId), STRIP_TILE]);
493
+ const tile = stripTileTemplate(stripCountText(shownSessions().length, all.length), anim(STRIP_TILE));
494
+ draw(topStripEl, html`${cards.map((f) => cardFor(f.head, { family: f, open: familyPop.openHead() === f.head.id, ...anim(familyId(f)) }))}${tile}`);
442
495
  bindCards(topStripEl, select, {
443
496
  onFamily: (id, _chip, e) => {
444
497
  familyPop.toggle(id);
@@ -490,6 +543,7 @@ export function createListsView(deps) {
490
543
  updateSelectBar();
491
544
  if (!fams.length) {
492
545
  drawnOpen.clear();
546
+ lastDrawn.delete(allListEl);
493
547
  draw(allListEl, html`<div class="hint">${listed.length ? "No session matches." : "No sessions yet."}</div>`);
494
548
  if (chainFrom === allListEl) chainPop.refresh();
495
549
  return;
@@ -501,11 +555,12 @@ export function createListsView(deps) {
501
555
  ? { selectable: true, picked: selecting.picked.has(s.id), skipReason: selecting.skipReasons.get(s.id) }
502
556
  : { deletable: true, archivable: true });
503
557
  drawnOpen.clear();
558
+ const anim = enteringFor(allListEl, fams.map(familyId));
504
559
  draw(allListEl, html`${fams.map((f) => {
505
560
  const matched = !!f.matchIds?.size;
506
561
  const open = familyOpen(f, matched ? searchToggles : familyToggles, { matched });
507
562
  drawnOpen.set(f.head.id, open);
508
- return cardFor(f.head, { ...opts(f.head), family: f, open, rows: true, matchIds: q ? f.matchIds : null });
563
+ return cardFor(f.head, { ...opts(f.head), family: f, open, rows: true, matchIds: q ? f.matchIds : null, ...anim(familyId(f)) });
509
564
  })}`);
510
565
  bindCards(allListEl, (id, e) => {
511
566
  if (selecting.on) {
@@ -12,9 +12,10 @@
12
12
  // choice for the next create only (POST /api/sessions' llm_context), never
13
13
  // remembered: nothing is sent unless the user sets one.
14
14
 
15
+ import { createChipPopover } from "./chip_popover.js";
15
16
  import { html, nothing, render } from "./vendor/lit-html.js";
16
17
 
17
- export const STRATEGIES = [
18
+ const STRATEGIES = [
18
19
  { value: "none", label: "none" },
19
20
  { value: "stale", label: "stale" },
20
21
  { value: "stale,forget", label: "stale + forget (experimental)" },
@@ -144,7 +145,7 @@ export function choiceWords(choice) {
144
145
 
145
146
  // The strategy list, with the session's own value when it isn't one of them
146
147
  // (forget alone, a newer chi's layer).
147
- export function strategyOptions(chosen) {
148
+ function strategyOptions(chosen) {
148
149
  if (chosen === DEFAULT || STRATEGIES.some((s) => s.value === chosen)) return STRATEGIES;
149
150
  return [...STRATEGIES, { value: chosen, label: chosen }];
150
151
  }
@@ -201,113 +202,40 @@ export function renderForm(target, summary, opts = {}) {
201
202
  render(formTemplate(summary, opts), target);
202
203
  }
203
204
 
204
- const EDGE = 16;
205
- const GAP = 8;
206
-
207
- // Where the popover goes for the chip's +rect+: above it, or below when
208
- // the room above is short of its height and below has more; capped to the
209
- // room on that side (it scrolls), never past the window's edges.
210
- // @return {{left, top?, bottom?, maxHeight?}}
211
- export function popoverPlace(rect, { width, height, innerWidth, innerHeight }) {
212
- const left = Math.max(EDGE, Math.min(rect.left, innerWidth - width - EDGE));
213
- const above = rect.top - GAP - EDGE;
214
- const below = innerHeight - rect.bottom - GAP - EDGE;
215
- const down = height > above && below > above;
216
- const room = Math.max(0, down ? below : above);
217
- const place = down ? { left, top: rect.bottom + GAP } : { left, bottom: Math.max(EDGE, innerHeight - rect.top + GAP) };
218
- return height > room ? { ...place, maxHeight: room } : place;
219
- }
220
-
221
- // The chip's popover. el: where the chip is drawn (#infoText, info_bar.js:
222
- // clicks are delegated); summary(): the open session's llm_context;
223
- // session(): the open session's id; running(): a turn runs; run(line):
224
- // sends the command.
205
+ // The chip's popover (chip_popover.js' shell). el: where the chip is drawn
206
+ // (#infoText, info_bar.js: clicks are delegated); summary(): the open
207
+ // session's llm_context; session(): the open session's id; running(): a
208
+ // turn runs; run(line): sends the command.
225
209
  // The start page's (kind NEW_CHAT): summary() is the picked model's,
226
210
  // choice() the pending choice, and choose(next) takes a Set's (null for
227
211
  // "follow the model") instead of a command running. id and anchor (the
228
212
  // chip's selector) keep the two controls apart.
229
213
  export function createLlmContextControl({ el, summary, session = () => null, running = () => false, run,
230
214
  kind = SESSION, choice = () => null, choose = null,
231
- id = "llmContextPopover", anchor: anchorSel = ".llmctx-chip",
215
+ id = "llmContextPopover", anchor = ".llmctx-chip",
232
216
  doc = document, win = window }) {
233
217
  const fresh = kind === NEW_CHAT;
234
- const pop = doc.createElement("div");
235
- pop.id = id;
236
- pop.className = "ctx-popover llmctx-popover";
237
- pop.setAttribute("role", "dialog");
238
- pop.hidden = true;
239
- doc.body.appendChild(pop);
240
- // The values the form opened with, and the session it is for.
218
+ // The values the form opened with.
241
219
  let initial = null;
242
- let openFor = null;
243
220
  // What the open form was drawn from: the summary, the form's options and
244
- // the summary its "Now:" line reads. A closed popover is empty, so each
245
- // open draws a fresh form (unsent edits are gone).
221
+ // the summary its "Now:" line reads.
246
222
  let shown = null;
247
223
  const draw = (changes = {}) => {
248
224
  shown = { ...shown, ...changes };
249
- renderForm(pop, shown.data, { ...shown.opts, now: shown.now, error: shown.error });
225
+ renderForm(shell.pop, shown.data, { ...shown.opts, now: shown.now, error: shown.error });
250
226
  };
251
227
 
252
- function place(anchor) {
253
- const width = Math.min(360, win.innerWidth - 32);
254
- pop.style.width = `${width}px`;
255
- pop.style.maxHeight = "";
256
- const at = popoverPlace(anchor.getBoundingClientRect(), { width, height: pop.offsetHeight,
257
- innerWidth: win.innerWidth, innerHeight: win.innerHeight });
258
- pop.style.left = `${at.left}px`;
259
- pop.style.top = at.top === undefined ? "" : `${at.top}px`;
260
- pop.style.bottom = at.bottom === undefined ? "" : `${at.bottom}px`;
261
- if (at.maxHeight !== undefined) pop.style.maxHeight = `${at.maxHeight}px`;
262
- }
263
-
264
- function open(anchor) {
265
- const data = summary();
266
- if (!data) return;
228
+ // A refresh (a newer summary): an open form keeps what the user typed;
229
+ // only its "Now:" line follows (the same template again: lit writes that
230
+ // line alone).
231
+ function show(data, opening) {
232
+ if (!opening) return draw({ now: data });
267
233
  initial = fresh ? ownValues({ own: choice() }) : ownValues(data);
268
- openFor = session();
269
234
  shown = { data, opts: { running: running(), kind, choice: choice() } };
270
235
  draw({ now: data, error: null });
271
- pop.hidden = false;
272
- place(anchor);
273
- }
274
-
275
- function close() {
276
- pop.hidden = true;
277
- initial = null;
278
- openFor = null;
279
- shown = null;
280
- render(nothing, pop);
281
236
  }
282
237
 
283
- function isOpen() { return !pop.hidden; }
284
-
285
- // A new summary (a command_ran, a re-read): an open form keeps what the
286
- // user typed; only its "Now:" line follows (the same template again: lit
287
- // writes that line alone). Another session closes it.
288
- function refresh() {
289
- if (!isOpen()) return;
290
- const data = summary();
291
- if (!data || session() !== openFor) return close();
292
- draw({ now: data });
293
- }
294
-
295
- el.addEventListener("click", (e) => {
296
- const anchor = e.target.closest(anchorSel);
297
- if (!anchor) return;
298
- e.stopPropagation();
299
- if (isOpen()) close();
300
- else open(anchor);
301
- });
302
- // An edit makes an error the last Set showed stale.
303
- const clearError = () => {
304
- if (shown?.error) draw({ error: null });
305
- };
306
- pop.addEventListener("input", clearError);
307
- pop.addEventListener("change", clearError);
308
- pop.addEventListener("submit", (e) => {
309
- e.preventDefault();
310
- const form = e.target;
238
+ function submit(form) {
311
239
  const values = { strategy: form.elements.strategy.value, apply: form.elements.apply.value,
312
240
  budget: form.elements.budget.value };
313
241
  if (fresh) {
@@ -318,29 +246,32 @@ export function createLlmContextControl({ el, summary, session = () => null, run
318
246
  draw({ error: err.message });
319
247
  return;
320
248
  }
321
- close();
249
+ shell.close();
322
250
  choose(next);
323
251
  return;
324
252
  }
325
253
  const line = commandLine(values, initial);
326
- close();
254
+ shell.close();
327
255
  if (line) run(line);
256
+ }
257
+
258
+ function act(name) {
259
+ if (name !== "reset") return;
260
+ shell.close();
261
+ if (fresh) choose(null);
262
+ else run(commandLine());
263
+ }
264
+
265
+ const shell = createChipPopover({
266
+ id, className: "ctx-popover llmctx-popover", width: 360, el, anchor, summary, session, show,
267
+ onClose: () => { initial = null; shown = null; }, onSubmit: submit, onAct: act, doc, win,
328
268
  });
329
- pop.addEventListener("click", (e) => {
330
- const act = e.target.closest("[data-act]")?.dataset.act;
331
- if (act === "close") close();
332
- else if (act === "reset") {
333
- close();
334
- if (fresh) choose(null);
335
- else run(commandLine());
336
- }
337
- });
338
- doc.addEventListener("click", (e) => {
339
- if (isOpen() && !pop.contains(e.target) && !e.target.closest?.(anchorSel)) close();
340
- });
341
- doc.addEventListener("keydown", (e) => {
342
- if (e.key === "Escape" && isOpen()) close();
343
- });
269
+ // An edit makes an error the last Set showed stale.
270
+ const clearError = () => {
271
+ if (shown?.error) draw({ error: null });
272
+ };
273
+ shell.pop.addEventListener("input", clearError);
274
+ shell.pop.addEventListener("change", clearError);
344
275
 
345
- return { close, refresh, get open() { return isOpen(); } };
276
+ return { close: shell.close, refresh: shell.refresh, get open() { return shell.isOpen(); } };
346
277
  }
@@ -9,26 +9,32 @@
9
9
  // has it. The search input keeps the focus:
10
10
  // ↑/↓ PageUp/PageDown move, ⏎ picks, Esc and Tab close without a change.
11
11
 
12
- import { escapeHtml } from "./format.js";
13
12
  import { moveSelection } from "./command_complete.js";
14
13
  import {
15
14
  MODEL_KEY, RECENT_KEY, groupRows, matchModels, modelRows, pickModel, pushRecent, recentRows, rowNote, rowTitle,
16
15
  } from "./model_pick.js";
16
+ import { html, nothing, render as draw } from "./vendor/lit-html.js";
17
17
 
18
18
  const PAGE = 8;
19
19
  const MAX_HEIGHT = 360;
20
20
  const MIN_ROOM = 200;
21
21
  const GAP = 8;
22
22
 
23
- // The text with the [start, end) ranges in <mark>, escaped slice by slice.
24
- function markedHtml(text, marks) {
25
- let html = "";
23
+ // The text with the [start, end) ranges in <mark> (lit-html parts).
24
+ function markedTemplate(text, marks) {
25
+ const parts = [];
26
26
  let at = 0;
27
27
  for (const [a, b] of marks || []) {
28
- html += `${escapeHtml(text.slice(at, a))}<mark>${escapeHtml(text.slice(a, b))}</mark>`;
28
+ parts.push(text.slice(at, a), html`<mark>${text.slice(a, b)}</mark>`);
29
29
  at = b;
30
30
  }
31
- return html + escapeHtml(text.slice(at));
31
+ parts.push(text.slice(at));
32
+ return parts;
33
+ }
34
+
35
+ // A group's head ("Recent", a host with its notes).
36
+ function headTemplate(label, note = "") {
37
+ return html`<div class="model-group" role="presentation">${label}${note ? html` <span>${note}</span>` : nothing}</div>`;
32
38
  }
33
39
 
34
40
  function readRecent() {
@@ -58,40 +64,38 @@ export function createModelPicker({ button, panel, input, list, composer, center
58
64
  button.classList.toggle("unavailable", rows.some((r) => r.name === name && r.unavailable === true));
59
65
  }
60
66
 
61
- function rowHtml(row, i, { hostMarks = [], idMarks = [], showHost = !row.isDefaultHost } = {}) {
62
- const host = showHost ? `<span class="model-host">${markedHtml(row.host, hostMarks)}</span><span class="model-dot"> · </span>` : "";
67
+ // One option row. The active one is drawn so here and kept so by
68
+ // showActive (the arrows and the pointer move it without a redraw).
69
+ function rowTemplate(row, i, { hostMarks = [], idMarks = [], showHost = !row.isDefaultHost } = {}) {
70
+ const host = showHost
71
+ ? html`<span class="model-host">${markedTemplate(row.host, hostMarks)}</span><span class="model-dot"> · </span>` : nothing;
63
72
  const noteText = rowNote(row, defaultName);
64
- const note = noteText ? `<span class="model-note">${noteText}</span>` : "";
73
+ const note = noteText ? html`<span class="model-note">${noteText}</span>` : nothing;
65
74
  const cls = `model-option${i === active ? " active" : ""}${row.name === chosen ? " chosen" : ""}${row.unavailable ? " unavailable" : ""}`;
66
- const titleText = rowTitle(row);
67
- const title = titleText ? ` title="${escapeHtml(titleText)}"` : "";
68
- return `<div class="${cls}" role="option" id="model-option-${i}" aria-selected="${i === active}" data-index="${i}"${title}>` +
69
- `<span class="model-id">${host}${markedHtml(row.id, idMarks)}</span>${note}</div>`;
75
+ return html`<div class=${cls} role="option" id=${`model-option-${i}`} aria-selected=${String(i === active)} data-index=${i} title=${rowTitle(row) || nothing}><span class="model-id">${host}${markedTemplate(row.id, idMarks)}</span>${note}</div>`;
70
76
  }
71
77
 
72
78
  function render() {
73
79
  const query = input.value.trim();
74
- let html = "";
80
+ const parts = [];
75
81
  shown = [];
76
- const add = (row, opts) => { html += rowHtml(row, shown.length, opts); shown.push(row); };
82
+ const add = (row, opts) => { parts.push(rowTemplate(row, shown.length, opts)); shown.push(row); };
77
83
  if (query) {
78
84
  for (const m of matchModels(rows, query)) add(m.row, { hostMarks: m.hostMarks, idMarks: m.idMarks });
79
- if (!shown.length) html = `<div class="model-empty" role="presentation">Nothing matches</div>`;
85
+ if (!shown.length) parts.push(html`<div class="model-empty" role="presentation">Nothing matches</div>`);
80
86
  } else {
81
87
  const recent = recentRows(rows, readRecent());
82
- const head = (label, note = "") =>
83
- `<div class="model-group" role="presentation">${escapeHtml(label)}${note ? ` <span>${escapeHtml(note)}</span>` : ""}</div>`;
84
88
  if (recent.length) {
85
- html += head("Recent");
89
+ parts.push(headTemplate("Recent"));
86
90
  recent.forEach((row) => add(row));
87
91
  }
88
92
  for (const group of groupRows(rows)) {
89
- html += head(group.host, [group.isDefault ? "default host" : "", group.unavailable ? "host down" : ""].filter(Boolean).join(" · "));
93
+ parts.push(headTemplate(group.host, [group.isDefault ? "default host" : "", group.unavailable ? "host down" : ""].filter(Boolean).join(" · ")));
90
94
  group.rows.forEach((row) => add(row, { showHost: false }));
91
95
  }
92
96
  }
93
97
  if (active >= shown.length) active = 0;
94
- list.innerHTML = html;
98
+ draw(parts, list);
95
99
  showActive();
96
100
  }
97
101
 
@@ -144,7 +148,7 @@ export function createModelPicker({ button, panel, input, list, composer, center
144
148
  function close({ refocus = false } = {}) {
145
149
  if (!isOpen()) return;
146
150
  panel.hidden = true;
147
- list.innerHTML = "";
151
+ draw(nothing, list);
148
152
  shown = [];
149
153
  button.setAttribute("aria-expanded", "false");
150
154
  input.setAttribute("aria-expanded", "false");
@@ -2,6 +2,8 @@
2
2
  // summaries (GET /api/events). The page shows an OS notification and a
3
3
  // title badge for what it returns, while the tab is not in front.
4
4
 
5
+ import { clip } from "./format.js";
6
+
5
7
  // The bell's state. OS notifications need a secure context: on chi web
6
8
  // over http at a LAN address the API is missing or always denied, so the
7
9
  // bell is hidden there (the title badge still counts).
@@ -16,7 +18,7 @@ export function notifyState({ hasNotification, secureContext, permission, wanted
16
18
  // chi ran for a delegate child's report (last_turn origin delegate_report)
17
19
  // or an attached context change (context): nobody started it, so nobody
18
20
  // watched it.
19
- export const DONE_MIN_SECONDS = 10;
21
+ const DONE_MIN_SECONDS = 10;
20
22
  const UNWATCHED_ORIGINS = ["delegate_report", "context"];
21
23
 
22
24
  // The step-limit question (kind "continue": a turn ran out of iterations
@@ -90,7 +92,7 @@ const cardKey = (sessionId, cardId) => `${sessionId}:card:${cardId}`;
90
92
  // @param prev the summary held before (null for a session not seen yet)
91
93
  // @param next the new summary (null when the session is gone)
92
94
  // @return [String] keys, maybe none
93
- export function closedKeys(prev, next) {
95
+ function closedKeys(prev, next) {
94
96
  const keys = [];
95
97
  const q = prev?.pending_question;
96
98
  const nq = next?.pending_question;
@@ -147,7 +149,7 @@ export function trackAttention(state, type, data = {}) {
147
149
  // No question or answer text (it would show on a lock screen).
148
150
  export function attentionText(session, attention) {
149
151
  const name = String(session?.title || session?.first_preview || session?.last_prompt || "chi session").trim();
150
- const title = name.length > 60 ? `${name.slice(0, 60)}…` : name;
152
+ const title = clip(name, 61);
151
153
  const seconds = Math.round(Number(session?.last_turn?.seconds) || 0);
152
154
  const body = {
153
155
  question: "needs an answer",
@@ -171,7 +173,7 @@ export function attentionText(session, attention) {
171
173
  // lowest claim (a tab id) shows it (4.11). Without BroadcastChannel every
172
174
  // tab goes on alone, at once.
173
175
  export const NOTIFY_CHANNEL = "chi-notify";
174
- export const NOTIFY_HOLD_MS = 300;
176
+ const NOTIFY_HOLD_MS = 300;
175
177
  // How long a key seen in front is remembered (its frame may come late).
176
178
  const SEEN_TTL_MS = 10 * 60 * 1000;
177
179
 
@@ -70,7 +70,7 @@ export function approvalView(pq) {
70
70
  const reason = a.reason ? String(a.reason) : "(no reason given)";
71
71
  return {
72
72
  // A plugin tool's label ("chrome: screenshot"), as its row shows it;
73
- // else the tool the call acts as (an MCP tool behind mcp_call).
73
+ // else the tool the call acts as (an MCP tool behind call_tool).
74
74
  tool: a.label || a.acts_as || a.tool || "",
75
75
  // A tool without a command or path (an MCP tool): its compact args.
76
76
  what: command || paths.join("\n") || a.args || "",
@@ -7,7 +7,7 @@
7
7
  // are left alone, inline code only when it is the ref alone, a bare URL's
8
8
  // text too.
9
9
 
10
- import { refAttrs } from "./refs.js";
10
+ import { refSpan } from "./refs.js";
11
11
 
12
12
  // A path (no spaces), a line, an optional end line and an optional column
13
13
  // (kept in the text, not used). LineLinker::REF.
@@ -30,7 +30,7 @@ const SKIP = "pre, a, [data-ref-kind]";
30
30
  // The ref a match names: { path, line, endLine } or null. A path has a `/`
31
31
  // or an extension (`10:30` is a time, `host:8080` a port); line 0 is never
32
32
  // a ref; an end line not after the line is dropped.
33
- export function refOf(groups) {
33
+ function refOf(groups) {
34
34
  const path = groups?.path || "";
35
35
  if (!path.includes("/") && !/\.[A-Za-z0-9]+$/.test(path)) return null;
36
36
  if (groups.from === undefined) return { path, line: null, endLine: null };
@@ -168,12 +168,6 @@ function wrapRefs(node, hits) {
168
168
  node.replaceWith(frag);
169
169
  }
170
170
 
171
- function refSpan(doc, ref) {
172
- const holder = doc.createElement("div");
173
- holder.innerHTML = `<span class="ref"${refAttrs(ref)}></span>`;
174
- return holder.firstChild;
175
- }
176
-
177
171
  // [start, end] of each fenced block (an unclosed one runs to the end).
178
172
  function fencedBlocks(text) {
179
173
  const blocks = [];
@@ -32,6 +32,19 @@ export function refAttrs(ref) {
32
32
  return ` data-ref-kind="${escapeHtml(ref.kind)}"${attr("path", ref.path)}${attr("line", ref.line)}${attr("end-line", ref.end_line)}`;
33
33
  }
34
34
 
35
+ // A span with refAttrs' ref class and data attributes, for a page that
36
+ // builds DOM (ref_scan.js).
37
+ export function refSpan(doc, ref) {
38
+ const span = doc.createElement("span");
39
+ span.className = "ref";
40
+ if (!ref?.kind) return span;
41
+ span.dataset.refKind = ref.kind;
42
+ for (const [name, value] of [["refPath", ref.path], ["refLine", ref.line], ["refEndLine", ref.end_line]]) {
43
+ if (value) span.dataset[name] = String(value);
44
+ }
45
+ return span;
46
+ }
47
+
35
48
  // A kind, as the server's Ref allows it (Ref::KIND).
36
49
  const KIND = /^[a-z][a-z0-9_-]{0,31}$/;
37
50
 
@@ -50,6 +50,15 @@ export function emptySessionState() {
50
50
  reasoningHistory: null,
51
51
  toolkit: null,
52
52
  timing: normalizeTiming(),
53
+ // The event id (or, from an older worker, the event_seq) the session was
54
+ // rendered at (null: no live worker then), where a stream opened later
55
+ // has to start; set by the page's resync, not by the payload.
56
+ streamSeq: null,
57
+ // The stream was opened from 0 (a wake with no cursor to go on) and no
58
+ // new turn has started on it yet (app.js followWokenWorker).
59
+ streamFromGuess: false,
60
+ // When the info bar's "copy chi --attach" copied (its "copied ✓").
61
+ attachCopiedAt: 0,
53
62
  };
54
63
  }
55
64
 
@@ -65,6 +74,11 @@ export function sessionStateFrom(data) {
65
74
  served: s.served_model ? [s.served_model, s.served_model_for, s.served_expected_by || null] : null,
66
75
  dir: s.working_directory,
67
76
  owner: s.owner || null,
77
+ // Its live worker's chi and features (null and [] without one): a
78
+ // switch shows its stale-worker badge, task stop and queue at once,
79
+ // not at the session's next hub event (followSelectedSession).
80
+ workerVersion: s.worker_version || null,
81
+ workerFeatures: Array.isArray(s.worker_features) ? s.worker_features : [],
68
82
  timing: normalizeTiming(data.timing),
69
83
  };
70
84
  // The saved fill, so a reloaded or stopped session shows it before its
@@ -1,4 +1,4 @@
1
- import { normalize } from "./format.js";
1
+ import { clip, normalize } from "./format.js";
2
2
 
3
3
  // Where the open session's title shows: once on screen. Its strip card says
4
4
  // it when the strip is shown and has an `.active` card for it; otherwise
@@ -21,6 +21,5 @@ export function topBarTitle({ title, cardShown, stripHidden, allView }) {
21
21
  export function tabTitle({ title, base }) {
22
22
  const text = normalize(title);
23
23
  if (!text) return base;
24
- const cut = text.length > TAB_TITLE_CHARS ? `${text.slice(0, TAB_TITLE_CHARS)}…` : text;
25
- return `${cut} · ${base}`;
24
+ return `${clip(text, TAB_TITLE_CHARS + 1)} · ${base}`;
26
25
  }
@@ -188,14 +188,6 @@ export function familySummary(f) {
188
188
  return delegatesSummary(f.members.map((m) => m.session));
189
189
  }
190
190
 
191
- // The chip's words: "▸ 3 delegates · 1 live · 1 waiting".
192
- export function familyChipText(sum) {
193
- if (!sum) return "";
194
- const waiting = sum.counts?.waiting || 0;
195
- return [`\u25B8 ${sum.total} ${sum.total === 1 ? "delegate" : "delegates"}`,
196
- sum.live ? `${sum.live} live` : "", waiting ? `${waiting} waiting` : ""].filter(Boolean).join(" \u00b7 ");
197
- }
198
-
199
191
  // Whether a family's list is open: the user's toggle (+toggles+, head id →
200
192
  // bool) wins; else open when a member waits on the user (the head's own
201
193
  // wait is on its card already) or a search matched a member.