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
@@ -99,7 +99,7 @@ function makeButton(doc, kind) {
99
99
 
100
100
  // Buttons on every bubble and code block in +root+ (and on +root+ itself)
101
101
  // that has none yet.
102
- export function decorate(root) {
102
+ function decorate(root) {
103
103
  const doc = root.ownerDocument;
104
104
  const bubbles = [...root.querySelectorAll(BUBBLES)];
105
105
  if (root.matches(BUBBLES)) bubbles.push(root);
@@ -13,7 +13,7 @@ function num(v) {
13
13
 
14
14
  // What a fill counts against: the window, or the budget when one is set
15
15
  // and smaller; null without a window.
16
- export function countedTokens(windowTokens, budgetTokens = null) {
16
+ function countedTokens(windowTokens, budgetTokens = null) {
17
17
  const window = num(windowTokens);
18
18
  if (!window || window <= 0) return null;
19
19
  const budget = num(budgetTokens);
@@ -21,8 +21,8 @@ import { html, nothing, render } from "./vendor/lit-html.js";
21
21
  export const QUEUE_LABEL = "queue for after the turn";
22
22
  export const CUT_LABEL = "cut in now";
23
23
  // A phone's row (≤ 600 px) is short: its text and chips.
24
- export const QUEUE_SHORT = "queue";
25
- export const CUT_SHORT = "cut in";
24
+ const QUEUE_SHORT = "queue";
25
+ const CUT_SHORT = "cut in";
26
26
  // Alt+Enter to a worker without the delivery feature: not sent (a queue
27
27
  // would steer it), the text kept, this said.
28
28
  export const QUEUE_NEEDS_WORKER = "Not sent: queueing needs a newer worker (it would steer the running turn). "
@@ -144,7 +144,7 @@ export function deliveryLineTemplate(line, { onCut, onQueue } = {}) {
144
144
  }
145
145
 
146
146
  // The idle row: the key hint (none on a phone: no keyboard keys there).
147
- export function idleTemplate() {
147
+ function idleTemplate() {
148
148
  return html`<span class="delivery-where idle"><span class="long">${IDLE_HINT}</span><span class="short"></span></span>`;
149
149
  }
150
150
 
@@ -155,7 +155,7 @@ export function idleTemplate() {
155
155
  // phase, gen, tool, cutAfter, serverGen, canQueue, model)
156
156
  // composerEmpty() nothing typed and no image chips: a link arms
157
157
  // send(delivery) send the composer's line with +delivery+ ("cut", "queue")
158
- export const TICK_MS = 500;
158
+ const TICK_MS = 500;
159
159
 
160
160
  export function createDeliveryControl({ el, read, composerEmpty, send, now = () => Date.now(),
161
161
  setTimer = (fn, ms) => setInterval(fn, ms), clearTimer = (t) => clearInterval(t) }) {
@@ -71,7 +71,7 @@ export function familyRowsTemplate(f, { activeId = null, matchIds = null, archiv
71
71
  }
72
72
 
73
73
  // The widest window index.html lays out as a phone (@media (max-width:600px)).
74
- export const PHONE_MAX_PX = 600;
74
+ const PHONE_MAX_PX = 600;
75
75
 
76
76
  // The strip's family popover: one #familyPop in body (position: fixed, so
77
77
  // the strip's sideways scroll on a phone doesn't clip it), under the chip
@@ -1,7 +1,7 @@
1
1
  import { waitingOn } from "./notify.js";
2
2
 
3
3
  export const PREVIEW_CHARS = 40;
4
- export const FIRST_PREVIEW_CHARS = 80;
4
+ const FIRST_PREVIEW_CHARS = 80;
5
5
 
6
6
  export function escapeHtml(value) {
7
7
  return String(value).replace(
@@ -37,20 +37,24 @@ export function normalize(s) {
37
37
  return String(s || "").replace(/\s+/g, " ").trim();
38
38
  }
39
39
 
40
+ // +text+ in at most +n+ characters: whole when it fits, else cut with "…"
41
+ // as its last (the kept part's trailing spaces dropped).
42
+ export function clip(text, n) {
43
+ const s = String(text ?? "");
44
+ return s.length > n ? `${s.slice(0, n - 1).trimEnd()}\u2026` : s;
45
+ }
46
+
40
47
  export function previewOf(lastPrompt) {
41
48
  const normalized = normalize(lastPrompt);
42
49
  if (!normalized) return "\u2014";
43
- if (normalized.length > PREVIEW_CHARS) {
44
- return `${normalized.slice(0, PREVIEW_CHARS)}\u2026`;
45
- }
46
- return normalized;
50
+ return clip(normalized, PREVIEW_CHARS + 1);
47
51
  }
48
52
 
49
53
  // A session's first message as the info bar's title: whitespace collapsed,
50
54
  // cut at FIRST_PREVIEW_CHARS with an ellipsis ("" for none).
51
55
  export function firstPreview(text) {
52
56
  const n = normalize(text);
53
- return n.length > FIRST_PREVIEW_CHARS ? `${n.slice(0, FIRST_PREVIEW_CHARS)}\u2026` : n;
57
+ return clip(n, FIRST_PREVIEW_CHARS + 1);
54
58
  }
55
59
 
56
60
  export function previewForCard(session) {
@@ -66,7 +70,7 @@ const DELETE_PREVIEW_CHARS = 40;
66
70
  // "stopped if it's still running", never promised.
67
71
  export function deleteConfirmText(session) {
68
72
  const n = normalize(session.first_preview || session.last_prompt || "");
69
- const preview = n.length > DELETE_PREVIEW_CHARS ? `${n.slice(0, DELETE_PREVIEW_CHARS)}\u2026` : n;
73
+ const preview = clip(n, DELETE_PREVIEW_CHARS + 1);
70
74
  const shortId = session.short_id || String(session.id).slice(0, 8);
71
75
  const lines = [
72
76
  `Delete session ${shortId}${preview ? ` \u2014 "${preview}"` : ""}?`,
@@ -67,7 +67,30 @@ export function turnRefs(chips) {
67
67
  }
68
68
 
69
69
  // Chips again for a prompt the worker handed back (its images are stored
70
- // already, so they need no upload).
70
+ // already, so they need no upload: to that session).
71
71
  export function restoredChips(sessionId, images) {
72
- return (images || []).filter((i) => i && i.file).map((ref) => ({ name: ref.name || "image", ref, src: imageUrl(sessionId, ref) }));
72
+ return (images || []).filter((i) => i && i.file)
73
+ .map((ref) => ({ name: ref.name || "image", ref, session: sessionId, src: imageUrl(sessionId, ref) }));
74
+ }
75
+
76
+ // A restored chip has no file of its own: its image, read back from where
77
+ // its session stores it.
78
+ async function fetchChipFile(src, name) {
79
+ const res = await fetch(src);
80
+ if (!res.ok) throw new Error(`Can't read ${name} again (${res.status})`);
81
+ const blob = await res.blob();
82
+ return new File([blob], name, { type: blob.type });
83
+ }
84
+
85
+ // Uploads the chips a turn to +sessionId+ needs: a ref is into one session's
86
+ // image store (chip.session), so a chip uploaded to another one (a failed
87
+ // send there, then a switch) goes up again, to this one. Sets each chip's
88
+ // ref and session in place: a failed send keeps them for another try.
89
+ export async function uploadChips(chips, sessionId, { upload, fetchFile = fetchChipFile }) {
90
+ for (const chip of chips || []) {
91
+ if (chip.ref && chip.session === sessionId) continue;
92
+ const file = chip.file || await fetchFile(chip.src, chip.name);
93
+ chip.ref = await upload(sessionId, file, chip.name);
94
+ chip.session = sessionId;
95
+ }
73
96
  }
@@ -42,7 +42,7 @@ button:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline
42
42
  button.ghost{background:var(--bg-raised);color:var(--text-2)}
43
43
  button:disabled{opacity:.5;cursor:not-allowed}
44
44
  main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:auto;background:transparent;min-height:0}
45
- #topStrip{display:grid;grid-template-columns:repeat(var(--strip-cols,3),minmax(0,440px)) 150px;gap:12px;min-height:88px;align-content:start;transition:opacity 280ms ease, transform 280ms ease}
45
+ #topStrip{display:grid;grid-template-columns:repeat(var(--strip-cols,3),minmax(0,440px)) 150px;gap:12px;min-height:88px;align-content:start}
46
46
  #topStrip:empty{display:grid}
47
47
  #center{flex:1;display:flex;flex-direction:column;min-height:45vh;background:var(--bg-panel);border:1px solid var(--border);border-radius:12px;overflow:hidden}
48
48
  #stage{flex:1;display:flex;flex-direction:column;position:relative;min-height:160px;background:var(--bg);overflow:hidden}
@@ -578,8 +578,7 @@ body.has-session #composer{flex-direction:row;align-items:flex-end;padding:10px}
578
578
  body.has-session #composer textarea{min-height:0}
579
579
  body.has-session #composeHint{display:none}
580
580
  body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
581
- /* The model picker (start page only): reads as a label, "host:model ▾", left of Start; a native select under a drawn caret. */
582
- /* A select is as wide as its widest option: a hidden copy of the chosen name (data-value) sizes the box to that name instead, and the select fills it. */
581
+ /* The model picker (start page only): reads as a label, "host:model ▾", left of Start; a button under a drawn caret that opens a searchable list (model_picker.js). Its name ellipsizes within max-width. */
583
582
  #composer .actions-col .model-pick{order:0;position:relative;display:inline-block;max-width:min(50%,380px);height:28px;min-height:28px;padding:0 22px 0 8px;border-radius:8px;overflow:hidden;background:transparent;color:var(--text-muted);font-size:12px;line-height:28px;text-align:left;text-overflow:ellipsis;transition:background .12s ease,color .12s ease}
584
583
  #composer .actions-col .model-pick:hover,#composer .actions-col .model-pick[aria-expanded="true"]{filter:none;background:color-mix(in srgb,var(--bg-hover) 75%,transparent);color:var(--text-2)}
585
584
  #composer .actions-col .model-pick[hidden],body.has-session #composer .actions-col .model-pick{display:none}
@@ -827,6 +826,12 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
827
826
  .llmctx-field>span{flex:0 0 58px;font-size:12px;color:var(--text-muted)}
828
827
  .llmctx-field select,.llmctx-field input{flex:1;min-width:0;box-sizing:border-box;padding:5px 8px;border-radius:7px;border:1px solid var(--border-strong);background:var(--bg-inset);color:var(--text);font:13px/1.4 inherit}
829
828
  .llmctx-field select:focus,.llmctx-field input:focus{outline:none;border-color:var(--accent-border)}
829
+ /* The settings selects draw their own closed box (each browser's native one differs) with the model picker's drawn caret on the label; the open list stays the OS menu. */
830
+ .llmctx-field select{appearance:none;-webkit-appearance:none;padding-right:26px;cursor:pointer}
831
+ .llmctx-field select option{background:var(--bg-raised);color:var(--text)}
832
+ .llmctx-field input::placeholder{color:var(--text-faint);opacity:1}
833
+ .llmctx-field:has(select){position:relative}
834
+ .llmctx-field:has(select)::after{content:"";position:absolute;right:11px;top:50%;width:5px;height:5px;border-right:1.5px solid var(--text-muted);border-bottom:1.5px solid var(--text-muted);transform:translateY(-70%) rotate(45deg);pointer-events:none}
830
835
  .llmctx-form .llmctx-set{background:var(--accent-bg);color:var(--accent);border-color:var(--accent-border)}
831
836
  .llmctx-form .llmctx-set:disabled{opacity:.6;cursor:default}
832
837
  /* Attached context (context_chips.js): a row of its own under the meta; not the composer's #chips/.chip. */
@@ -1091,7 +1096,10 @@ body.all-view #center, body.all-view #topStrip{display:none}
1091
1096
  .card.skeleton .line.tiny{width:30%;height:8px}
1092
1097
  @keyframes skeleton-pulse{0%,100%{opacity:1}50%{opacity:.55}}
1093
1098
  @keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
1094
- #topStrip:not(.loading) .card, #allList .card{animation:fadeIn .28s ease}
1099
+ /* Only a card new to its list fades in (lists_view.js enteringCards: every draw makes new nodes);
1100
+ --enter-delay carries a fade on across a redraw, --pulse-delay keeps a dot's pulse in phase. */
1101
+ #topStrip:not(.loading) .card.entering, #allList .card.entering{animation:fadeIn .28s ease var(--enter-delay,0s)}
1102
+ @media (prefers-reduced-motion:reduce){.card.entering{animation:none}}
1095
1103
  .card{padding:12px;border-radius:10px;background:var(--bg-panel);border:1px solid var(--border);cursor:pointer;display:flex;flex-direction:column;gap:6px;min-height:72px}
1096
1104
  .card:hover{border-color:var(--border-strong);background:var(--bg-hover)}
1097
1105
  #allList .card{position:relative}
@@ -1116,13 +1124,13 @@ body.all-view #center, body.all-view #topStrip{display:none}
1116
1124
  .card .meta{font-size:11px;color:var(--text-muted);display:flex;gap:6px;align-items:center}
1117
1125
  /* A card's status is a dot (its word is the tooltip); the .status.* pills below are the info bar's. */
1118
1126
  .card .dot,.family-row .dot{display:inline-block;width:8px;height:8px;border-radius:50%;flex-shrink:0;background:var(--text-muted)}
1119
- .card .dot.running,.family-row .dot.running{background:var(--ok);animation:dot-pulse 1.6s ease-in-out infinite}
1127
+ .card .dot.running,.family-row .dot.running{background:var(--ok);animation:dot-pulse 1.6s ease-in-out var(--pulse-delay,0s) infinite}
1120
1128
  .card .dot.idle,.family-row .dot.idle{background:var(--text-muted)}
1121
1129
  .card .dot.stopped,.family-row .dot.stopped{background:transparent;border:1px solid var(--text-faint)}
1122
1130
  .card .dot.completed,.family-row .dot.completed{background:var(--accent)}
1123
1131
  .card .dot.error,.family-row .dot.error{background:var(--danger)}
1124
1132
  @keyframes dot-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--ok) 55%,transparent)}50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--ok) 0%,transparent)}}
1125
- .card .dot.waiting,.family-row .dot.waiting{background:var(--warn);animation:attn-pulse 1.6s ease-in-out infinite}
1133
+ .card .dot.waiting,.family-row .dot.waiting{background:var(--warn);animation:attn-pulse 1.6s ease-in-out var(--pulse-delay,0s) infinite}
1126
1134
  @keyframes attn-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--warn) 60%,transparent)}50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--warn) 0%,transparent)}}
1127
1135
  @media (prefers-reduced-motion:reduce){.card .dot.running,.card .dot.waiting,.family-row .dot.running,.family-row .dot.waiting{animation:none}}
1128
1136
  .card .attn,.family-row .attn{font-size:11px;font-weight:600;padding:1px 7px;border-radius:999px;white-space:nowrap;flex-shrink:0;background:var(--warn-bg);color:var(--warn-text);border:1px solid var(--warn-border)}
@@ -1176,8 +1184,8 @@ a{color:var(--accent)}
1176
1184
  </div>
1177
1185
  <div class="actions-col">
1178
1186
  <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>
1179
- <button type="button" id="llmContextNew" class="llmctx-new" aria-haspopup="dialog" hidden></button>
1180
1187
  <button type="button" id="thinkingNew" class="llmctx-new think-new" aria-haspopup="dialog" hidden></button>
1188
+ <button type="button" id="llmContextNew" class="llmctx-new" aria-haspopup="dialog" hidden></button>
1181
1189
  <div id="composeHint">Enter to start · Shift+Enter for a newline</div>
1182
1190
  <button id="cancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Stop turn</button>
1183
1191
  <div class="split">
@@ -1,8 +1,8 @@
1
1
  // The session bar's info line (#infoText): the open session's id, its copy
2
2
  // chi --attach button, status, model, chain links, parent and delegates,
3
- // llm ctx, thinking, reasoning history and tools chips, model notes, memories, folder, ctx, speed,
3
+ // thinking, reasoning history, llm ctx and tools chips, model notes, memories, folder, ctx, speed,
4
4
  // session clock and the worker's version badge. A lit-html template over a
5
- // plain model app.js builds (updateInfoBar), so the markup lives here and
5
+ // plain model (info_bar_model.js, applied by app.js's updateInfoBar), so the markup lives here and
6
6
  // runs under node --test (spec/web/public/info_bar.test.js).
7
7
  //
8
8
  // It is redrawn every second while a turn runs (the session clock): lit
@@ -72,7 +72,7 @@ export function infoBarHtml(bar) {
72
72
  if (!bar) return nothing;
73
73
  const attach = `chi --attach ${bar.id}`;
74
74
  const memories = bar.memories || [];
75
- return html`<div class="meta"><span class="id" title=${bar.id}>${String(bar.id).slice(0, 8)}</span><button type="button" class="copy-attach" title=${`Copy "${attach}" to attach a terminal`}>${bar.copied ? "copied ✓" : "copy chi --attach"}</button><span class=${bar.status ? `status ${bar.status}` : "status"}>${bar.status || ""}</span><span class=${`model${bar.model.mismatch ? " served-mismatch" : ""}`} title=${bar.model.title}>${bar.model.text}</span>${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>delegated by ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${llmContextChip(bar.llmContext)}${thinkingChip(bar.thinking)}${reasoningHistoryChip(bar.reasoningHistory)}${toolkitChip(bar.toolkit)}${bar.notes ? html`<span class="model notes-chip" title=${bar.notes.title}>${bar.notes.text}</span>` : nothing}${memories.length ? html`<span class="model mem-chip" title=${memChipTitle(memories)}>${memChipText(memories)}</span>` : nothing}<span class="dir" title=${bar.dir || ""}><bdi>${bar.dir || ""}</bdi></span><span class="model ctx" title=${bar.ctx.tip}>${bar.ctx.text}</span>${bar.speed ? html`<span class="model speed" title=${bar.speed.tip}>${bar.speed.text}</span>` : nothing}${bar.clock ? html`<span class="model session-time">${bar.clock}</span>` : nothing}${workerBadgeTemplate(bar.worker)}</div>`;
75
+ return html`<div class="meta"><span class="id" title=${bar.id}>${String(bar.id).slice(0, 8)}</span><button type="button" class="copy-attach" title=${`Copy "${attach}" to attach a terminal`}>${bar.copied ? "copied ✓" : "copy chi --attach"}</button><span class=${bar.status ? `status ${bar.status}` : "status"}>${bar.status || ""}</span><span class=${`model${bar.model.mismatch ? " served-mismatch" : ""}`} title=${bar.model.title}>${bar.model.text}</span>${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>delegated by ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${thinkingChip(bar.thinking)}${reasoningHistoryChip(bar.reasoningHistory)}${llmContextChip(bar.llmContext)}${toolkitChip(bar.toolkit)}${bar.notes ? html`<span class="model notes-chip" title=${bar.notes.title}>${bar.notes.text}</span>` : nothing}${memories.length ? html`<span class="model mem-chip" title=${memChipTitle(memories)}>${memChipText(memories)}</span>` : nothing}<span class="dir" title=${bar.dir || ""}><bdi>${bar.dir || ""}</bdi></span><span class="model ctx" title=${bar.ctx.tip}>${bar.ctx.text}</span>${bar.speed ? html`<span class="model speed" title=${bar.speed.tip}>${bar.speed.text}</span>` : nothing}${bar.clock ? html`<span class="model session-time">${bar.clock}</span>` : nothing}${workerBadgeTemplate(bar.worker)}</div>`;
76
76
  }
77
77
 
78
78
  // Draws +bar+ into +el+ (#infoText), with the memories in its tooltip.
@@ -0,0 +1,110 @@
1
+ // What the session bar shows for the open session, as plain data: the info
2
+ // line's model (info_bar.js renders it) and its buttons' states (continue,
3
+ // delete, archive, stop), from the page's session state (session_state.js),
4
+ // its list and its in-flight actions. Pure: app.js's updateInfoBar applies
5
+ // it, every second while a turn runs (the session clock).
6
+ import { canStopSession, childrenSummary, delegatedBy, modelLabel, promptNotesChip } from "./format.js";
7
+ import { chainDate, chainNeighbours } from "./chains.js";
8
+ import { ctxBarText, ctxBarTip, lastSpeedText, speedText } from "./ctx.js";
9
+ import { formatDuration, sessionDurationNow } from "./timing.js";
10
+ import { workerBadge } from "./update_notice.js";
11
+
12
+ // The open session's worker runs an older chi than the newest installed:
13
+ // a badge, with Restart when the worker can (else the stop command).
14
+ // @param id the open session's id (null: none)
15
+ // @param state the open session's fields (session_state.js)
16
+ // @param versions {served, installed}: the chi serving the page, the newest
17
+ export function workerBadgeFor(id, state, versions) {
18
+ if (!id || state.owner !== "worker") return null;
19
+ return workerBadge({ workerVersion: state.workerVersion, features: state.workerFeatures,
20
+ installed: versions.installed, served: versions.served, sessionId: id });
21
+ }
22
+
23
+ // A chain's link (chains.js chainNeighbours) as the info bar's ← / →
24
+ // shows it (info_bar.js chainLinkTemplate), or null. +cardTitle+: the
25
+ // lists' title for a session (a chain's opener reads its first link's).
26
+ export function chainLinkView(link, cardTitle) {
27
+ if (!link) return null;
28
+ return { id: link.id, date: chainDate(link.session?.created_at), preview: link.session ? cardTitle(link.session) : "",
29
+ archived: !!link.session?.archived };
30
+ }
31
+
32
+ // The bar's buttons: continue (not on a link continued already, which has
33
+ // its → link, nor a terminal's session; "continuing…" while one runs),
34
+ // delete, archive (or unarchive), stop (while a worker runs the session).
35
+ export function infoButtons({ id, state, chain, archived, continuing, streaming }) {
36
+ const busy = !!id && continuing;
37
+ return {
38
+ continue: { shown: !!id && !chain.next && state.owner !== "tui", disabled: busy, text: busy ? "continuing…" : "continue →" },
39
+ delete: { shown: !!id },
40
+ archive: {
41
+ shown: !!id,
42
+ archived,
43
+ text: archived ? "unarchive" : "archive",
44
+ title: archived ? "Unarchive: back to the lists" : "Archive: hide from the lists, keep for good",
45
+ },
46
+ stop: { shown: !!id && canStopSession({ owner: state.owner, streaming }) },
47
+ };
48
+ }
49
+
50
+ // The info line's model (info_bar.js renderInfoBar's +bar+).
51
+ // @param id the open session's id
52
+ // @param state the open session's fields (session_state.js)
53
+ // @param sessions the page's list (allSessions: archived ones too)
54
+ // @param chain chainNeighbours' {prev, next} for +id+
55
+ // @param cardTitle (session) => the lists' title for it
56
+ // @param badge workerBadgeFor's, or null
57
+ // @param restarting whether this session's Restart is under way
58
+ // @param running whether its turn runs (the session clock ticks)
59
+ // @param now the clock (ms)
60
+ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, restarting, running, now }) {
61
+ const tokens = state.timing.tokens;
62
+ const ctxTip = ctxBarTip(tokens, state.timing.memoryIndex,
63
+ { tokens: state.ctxWindow, source: state.ctxWindowSource, pct: state.ctxPct,
64
+ budget: state.llmContext?.budget_tokens });
65
+ const speedTextNow = lastSpeedText(tokens);
66
+ const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
67
+ const sessionDuration = sessionDurationNow(state.timing, running);
68
+ // A delegated session names its parent, as a link to it.
69
+ const parentChip = delegatedBy(state.parentId, sessions.find((x) => x.id === state.parentId) || null);
70
+ return {
71
+ id,
72
+ copied: now - state.attachCopiedAt < 2000,
73
+ status: state.status,
74
+ model: modelLabel(state.model, ...(state.served || [])),
75
+ // A link of a session chain: ← the previous link, → the next one (each
76
+ // only while the list holds it).
77
+ chain: { prev: chainLinkView(chain.prev, cardTitle), next: chainLinkView(chain.next, cardTitle) },
78
+ parent: parentChip ? { id: state.parentId, title: parentChip.title } : null,
79
+ // A parent names its delegates; the link opens all sessions.
80
+ kids: childrenSummary({ id }, sessions),
81
+ llmContext: state.llmContext,
82
+ thinking: state.thinking,
83
+ reasoningHistory: state.reasoningHistory,
84
+ // The tools chip (toolkit_chip.js): null on full.
85
+ toolkit: state.toolkit,
86
+ // The model notes the session's prompt carried.
87
+ notes: promptNotesChip(state.promptNotes),
88
+ memories: state.usedMemories,
89
+ preloaded: state.preloadedMemories,
90
+ muted: state.mutedMemories,
91
+ dir: state.dir,
92
+ ctx: { text: ctxBarText(state.ctxPct, state.ctxWindowSource), tip: ctxTip },
93
+ speed: speedTextNow ? { text: speedTextNow, tip: `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}` } : null,
94
+ clock: sessionDuration === null ? "" : `session ${formatDuration(sessionDuration)}`,
95
+ worker: badge ? { badge, busy: restarting } : null,
96
+ };
97
+ }
98
+
99
+ // The whole bar for the open session +id+ (null: the start page, no line).
100
+ // @param sessions the page's list; +continuing+, +restarting+: Sets of ids
101
+ // @return {buttons, bar}
102
+ export function sessionBar({ id, state, sessions, versions, cardTitle, continuing, restarting, streaming, running, now }) {
103
+ const chain = id ? chainNeighbours(sessions, id, { continues: state.continues }) : { prev: null, next: null };
104
+ const archived = !!sessions.find((s) => s.id === id)?.archived;
105
+ const buttons = infoButtons({ id, state, chain, archived, continuing: continuing.has(id), streaming });
106
+ if (!id) return { buttons, bar: null };
107
+ const badge = workerBadgeFor(id, state, versions);
108
+ const bar = infoBarModel({ id, state, sessions, chain, cardTitle, badge, restarting: restarting.has(id), running, now });
109
+ return { buttons, bar };
110
+ }
@@ -2,9 +2,10 @@
2
2
  // download) as a small row above the composer: a spinner line per running
3
3
  // task ("mcp · Starting MCP server chrome (first run, saving its tools)…"),
4
4
  // a "✓ chrome ready, 3 tools" line when one is done, which fades. A failed
5
- // one leaves the row: its warn card says why. No DOM here, so node --test
6
- // covers it; app.js draws the row.
7
- import { escapeHtml as esc } from "./format.js";
5
+ // one leaves the row: its warn card says why. The row is a lit-html
6
+ // template (initRowTemplate); app.js renders it into #initRow.
7
+ import { repeat } from "./vendor/directives/repeat.js";
8
+ import { html } from "./vendor/lit-html.js";
8
9
 
9
10
  // How long a done line stays before it fades out.
10
11
  export const DONE_MS = 2500;
@@ -42,19 +43,29 @@ export function applyInitEvent(tasks, type, data) {
42
43
  return data.id;
43
44
  }
44
45
 
46
+ // A done line's fade starts (DONE_MS after it was done): the line draws
47
+ // faded until dropDone takes it.
48
+ export function fadeDone(tasks, id) {
49
+ const task = tasks.get(id);
50
+ if (task?.status === "done") task.fading = true;
51
+ return tasks;
52
+ }
53
+
45
54
  // Drop a done line (its fade ended); a task started again under the id stays.
46
55
  export function dropDone(tasks, id) {
47
56
  if (tasks.get(id)?.status === "done") tasks.delete(id);
48
57
  return tasks;
49
58
  }
50
59
 
51
- // @returns the row's inner HTML ("" with nothing to show)
52
- export function initRowHtml(tasks) {
53
- return [...tasks.entries()].map(([id, t]) => {
54
- const bundle = `<span class="init-bundle">${esc(t.bundle)}</span>`;
60
+ // @returns the row's lines (a lit-html template; none with nothing to show),
61
+ // keyed by the task's id: a line keeps its node while the lines around it
62
+ // come and go (a faded line's node is never reused for the next one).
63
+ export function initRowTemplate(tasks) {
64
+ return repeat(tasks.entries(), ([id]) => id, ([id, t]) => {
65
+ const bundle = html`<span class="init-bundle">${t.bundle}</span>`;
55
66
  if (t.status === "done") {
56
- return `<div class="init-task done" data-id="${esc(id)}"><span class="init-mark" aria-hidden="true">✓</span>${bundle}<span class="init-text">${esc(t.summary || `${t.label}: done`)}</span></div>`;
67
+ return html`<div class=${`init-task done${t.fading ? " fading" : ""}`} data-id=${id}><span class="init-mark" aria-hidden="true">✓</span>${bundle}<span class="init-text">${t.summary || `${t.label}: done`}</span></div>`;
57
68
  }
58
- return `<div class="init-task running" data-id="${esc(id)}"><span class="init-spin" aria-hidden="true"></span>${bundle}<span class="init-text">${esc(t.label)}…</span></div>`;
59
- }).join("");
69
+ return html`<div class="init-task running" data-id=${id}><span class="init-spin" aria-hidden="true"></span>${bundle}<span class="init-text">${t.label}…</span></div>`;
70
+ });
60
71
  }
@@ -23,8 +23,9 @@
23
23
  // (thinking_chip.js keeps its choice): the form shows the model's level and
24
24
  // takes a choice for the next create.
25
25
 
26
- import { NEW_CHAT, SESSION, popoverPlace } from "./llm_context_chip.js";
27
- import { html, nothing, render } from "./vendor/lit-html.js";
26
+ import { createChipPopover } from "./chip_popover.js";
27
+ import { NEW_CHAT, SESSION } from "./llm_context_chip.js";
28
+ import { html, render } from "./vendor/lit-html.js";
28
29
 
29
30
  export const DEFAULT = "default";
30
31
 
@@ -74,101 +75,49 @@ export function formTemplate(cfg, summary, { running = false, kind = SESSION, ch
74
75
  ><div class="ctx-pop-actions"><button type="submit" class="llmctx-set">Set</button></div></form>`;
75
76
  }
76
77
 
77
- // The chip's popover, as createLlmContextControl's: el is where the chip
78
- // is drawn (clicks are delegated: #infoText, info_bar.js);
79
- // summary(): the open session's summary; session(): its id; running(): a
80
- // turn runs; run(line): sends the command. The start page's (kind
81
- // NEW_CHAT): summary() is the picked model's, choice() the pending level
82
- // and choose(next) takes a Set's (null for "follow the model"). id and
83
- // anchor (the chip's selector, by default its data attribute) keep it
78
+ // The chip's popover (chip_popover.js' shell), as createLlmContextControl's:
79
+ // el is where the chip is drawn (clicks are delegated: #infoText,
80
+ // info_bar.js); summary(): the open session's summary; session(): its id;
81
+ // running(): a turn runs; run(line): sends the command. The start page's
82
+ // (kind NEW_CHAT): summary() is the picked model's, choice() the pending
83
+ // level and choose(next) takes a Set's (null for "follow the model"). id
84
+ // and anchor (the chip's selector, by default its data attribute) keep it
84
85
  // apart from the other controls on the same element (the anchor defaults
85
86
  // to the config's).
86
87
  export function createLevelControl({ cfg, el, summary, session = () => null, running = () => false, run,
87
88
  kind = SESSION, choice = () => null, choose = null,
88
- id, anchor: anchorSel = cfg.anchor,
89
+ id, anchor = cfg.anchor,
89
90
  doc = document, win = window }) {
90
91
  const fresh = kind === NEW_CHAT;
91
- const pop = doc.createElement("div");
92
- pop.id = id;
93
- pop.className = `ctx-popover ${cfg.popoverClass}`;
94
- pop.setAttribute("role", "dialog");
95
- pop.hidden = true;
96
- doc.body.appendChild(pop);
97
92
  let initial = null;
98
- let openFor = null;
99
93
  // What the open form was drawn from: the model's or session's summary
100
- // and the form's options. A closed popover is empty, so each open draws
101
- // a fresh form (an unsent pick is gone).
94
+ // and the form's options.
102
95
  let shown = null;
103
- const draw = (now) => render(formTemplate(cfg, shown.data, { ...shown.opts, now }), pop);
96
+ const draw = (now) => render(formTemplate(cfg, shown.data, { ...shown.opts, now }), shell.pop);
104
97
 
105
- function place(anchor) {
106
- const width = Math.min(320, win.innerWidth - 32);
107
- pop.style.width = `${width}px`;
108
- pop.style.maxHeight = "";
109
- const at = popoverPlace(anchor.getBoundingClientRect(), { width, height: pop.offsetHeight,
110
- innerWidth: win.innerWidth, innerHeight: win.innerHeight });
111
- pop.style.left = `${at.left}px`;
112
- pop.style.top = at.top === undefined ? "" : `${at.top}px`;
113
- pop.style.bottom = at.bottom === undefined ? "" : `${at.bottom}px`;
114
- if (at.maxHeight !== undefined) pop.style.maxHeight = `${at.maxHeight}px`;
115
- }
116
-
117
- function open(anchor) {
118
- const data = summary();
119
- if (!data) return;
120
- initial = (fresh ? choice() : data.own) || DEFAULT;
121
- openFor = session();
122
- shown = { data, opts: { running: running(), kind, choice: choice() } };
123
- draw(data);
124
- pop.hidden = false;
125
- place(anchor);
126
- }
127
-
128
- function close() {
129
- pop.hidden = true;
130
- initial = null;
131
- openFor = null;
132
- shown = null;
133
- render(nothing, pop);
134
- }
135
-
136
- function isOpen() { return !pop.hidden; }
137
-
138
- // A new summary: an open form keeps the user's pick; only "Now:" follows
139
- // (the same template again: lit writes that line alone).
140
- function refresh() {
141
- if (!isOpen()) return;
142
- const data = summary();
143
- if (!data || session() !== openFor) return close();
98
+ // A refresh (a newer summary): an open form keeps the user's pick; only
99
+ // "Now:" follows (the same template again: lit writes that line alone).
100
+ function show(data, opening) {
101
+ if (opening) {
102
+ initial = (fresh ? choice() : data.own) || DEFAULT;
103
+ shown = { data, opts: { running: running(), kind, choice: choice() } };
104
+ }
144
105
  draw(data);
145
106
  }
146
107
 
147
- el.addEventListener("click", (e) => {
148
- const anchor = e.target.closest(anchorSel);
149
- if (!anchor) return;
150
- e.stopPropagation();
151
- if (isOpen()) close();
152
- else open(anchor);
153
- });
154
- pop.addEventListener("submit", (e) => {
155
- e.preventDefault();
156
- const level = e.target.elements.level.value;
108
+ function submit(form) {
109
+ const level = form.elements.level.value;
157
110
  const was = initial;
158
- close();
111
+ shell.close();
159
112
  if (fresh) return choose(level === DEFAULT ? null : level);
160
113
  const line = commandLine(cfg, level, was);
161
114
  if (line) run(line);
162
- });
163
- pop.addEventListener("click", (e) => {
164
- if (e.target.closest("[data-act]")?.dataset.act === "close") close();
165
- });
166
- doc.addEventListener("click", (e) => {
167
- if (isOpen() && !pop.contains(e.target) && !e.target.closest?.(anchorSel)) close();
168
- });
169
- doc.addEventListener("keydown", (e) => {
170
- if (e.key === "Escape" && isOpen()) close();
115
+ }
116
+
117
+ const shell = createChipPopover({
118
+ id, className: `ctx-popover ${cfg.popoverClass}`, width: 320, el, anchor, summary, session, show,
119
+ onClose: () => { initial = null; shown = null; }, onSubmit: submit, doc, win,
171
120
  });
172
121
 
173
- return { close, refresh, get open() { return isOpen(); } };
122
+ return { close: shell.close, refresh: shell.refresh, get open() { return shell.isOpen(); } };
174
123
  }