samagotchi 0.55.0 → 0.57.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +65 -1
  3. data/README.md +1 -1
  4. data/docs/architecture.md +6 -2
  5. data/docs/cli.md +30 -8
  6. data/docs/configuration.md +84 -8
  7. data/docs/hooks.md +2 -2
  8. data/docs/internals/background-tasks.md +1 -1
  9. data/docs/internals/engine.md +14 -0
  10. data/docs/internals/prompt-caching.md +8 -0
  11. data/docs/memory.md +1 -1
  12. data/docs/plugins.md +63 -0
  13. data/docs/sessions.md +1 -1
  14. data/lib/samagotchi/bootstrap/config_writer.rb +2 -0
  15. data/lib/samagotchi/bootstrap/probe.rb +3 -0
  16. data/lib/samagotchi/bootstrap_command.rb +2 -0
  17. data/lib/samagotchi/broadcast_command.rb +2 -0
  18. data/lib/samagotchi/bundles/core/manifest.yml +2 -1
  19. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/mcp/plugin.rb +15 -1
  21. data/lib/samagotchi/bundles/slow-commands/manifest.yml +11 -0
  22. data/lib/samagotchi/bundles/slow-commands/plugin.rb +320 -0
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  25. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  26. data/lib/samagotchi/cache_confirm.rb +51 -0
  27. data/lib/samagotchi/client_id.rb +12 -0
  28. data/lib/samagotchi/config.rb +7 -3
  29. data/lib/samagotchi/context_command.rb +2 -0
  30. data/lib/samagotchi/desktop/macos.rb +3 -0
  31. data/lib/samagotchi/engine.rb +102 -8
  32. data/lib/samagotchi/engine_peers.rb +1 -0
  33. data/lib/samagotchi/gem_update.rb +3 -0
  34. data/lib/samagotchi/guardrails/shell_lex.rb +8 -0
  35. data/lib/samagotchi/guardrails/shell_paths.rb +3 -0
  36. data/lib/samagotchi/host_config.rb +6 -3
  37. data/lib/samagotchi/host_registry.rb +19 -3
  38. data/lib/samagotchi/installed_versions.rb +3 -0
  39. data/lib/samagotchi/kernel_loop.rb +99 -16
  40. data/lib/samagotchi/llm/chat_loop.rb +122 -71
  41. data/lib/samagotchi/llm/openai_chat.rb +2 -1
  42. data/lib/samagotchi/llm/stream_drops.rb +63 -0
  43. data/lib/samagotchi/llm/turn_settings.rb +9 -5
  44. data/lib/samagotchi/model_settings.rb +5 -2
  45. data/lib/samagotchi/plugin/context.rb +5 -0
  46. data/lib/samagotchi/reasoning_history.rb +132 -0
  47. data/lib/samagotchi/reasoning_history_command.rb +107 -0
  48. data/lib/samagotchi/redacted_inspect.rb +28 -0
  49. data/lib/samagotchi/self_report.rb +21 -0
  50. data/lib/samagotchi/session.rb +23 -2
  51. data/lib/samagotchi/session_commands.rb +55 -15
  52. data/lib/samagotchi/session_manager.rb +2 -1
  53. data/lib/samagotchi/session_setup.rb +7 -5
  54. data/lib/samagotchi/setting_explained.rb +20 -0
  55. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -1
  56. data/lib/samagotchi/terminal_ui/attached_loop.rb +23 -5
  57. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  58. data/lib/samagotchi/terminal_ui.rb +13 -6
  59. data/lib/samagotchi/thinking.rb +20 -25
  60. data/lib/samagotchi/thinking_command.rb +33 -11
  61. data/lib/samagotchi/tool_runner.rb +14 -3
  62. data/lib/samagotchi/tools/builtins.rb +6 -2
  63. data/lib/samagotchi/tools/execute.rb +3 -2
  64. data/lib/samagotchi/tools/peers.rb +4 -2
  65. data/lib/samagotchi/tools/task_runtime.rb +2 -1
  66. data/lib/samagotchi/version.rb +1 -1
  67. data/lib/samagotchi/web/app.rb +29 -6
  68. data/lib/samagotchi/web/public/app.js +44 -9
  69. data/lib/samagotchi/web/public/commands.js +36 -3
  70. data/lib/samagotchi/web/public/composer.js +5 -1
  71. data/lib/samagotchi/web/public/index.html +22 -5
  72. data/lib/samagotchi/web/public/info_bar.js +5 -3
  73. data/lib/samagotchi/web/public/level_chip.js +174 -0
  74. data/lib/samagotchi/web/public/model_pick.js +1 -1
  75. data/lib/samagotchi/web/public/question_card.js +5 -3
  76. data/lib/samagotchi/web/public/question_cards.js +21 -1
  77. data/lib/samagotchi/web/public/read_view.js +74 -0
  78. data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
  79. data/lib/samagotchi/web/public/ref_scan.js +206 -0
  80. data/lib/samagotchi/web/public/refs.js +19 -10
  81. data/lib/samagotchi/web/public/session_state.js +5 -0
  82. data/lib/samagotchi/web/public/stream_handlers.js +10 -2
  83. data/lib/samagotchi/web/public/thinking_chip.js +33 -138
  84. data/lib/samagotchi/web/public/turn_html.js +4 -2
  85. data/lib/samagotchi/web/public/turn_view.js +5 -3
  86. data/lib/samagotchi/web/token.rb +3 -0
  87. data/lib/samagotchi/worker.rb +17 -5
  88. metadata +13 -1
@@ -178,6 +178,17 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
178
178
  .activity-diff>summary{cursor:pointer;font-size:12px;color:var(--text-muted);width:max-content;font-variant-numeric:tabular-nums}
179
179
  .activity-diff>summary:hover{color:var(--text-2)}
180
180
  .activity-diff[open]>summary{margin-bottom:4px}
181
+ /* A read of the file as-is: its numbered lines, closed under the preview (read_view.js); open, they stand for it. */
182
+ .activity-read{flex:1 1 100%;min-width:0;margin:2px 0 2px var(--row-gutter)}
183
+ .activity-read>summary{cursor:pointer;font-size:12px;color:var(--text-muted);width:max-content;font-variant-numeric:tabular-nums}
184
+ .activity-read>summary:hover{color:var(--text-2)}
185
+ .activity-read[open]>summary{margin-bottom:4px}
186
+ .activity-row:has(>.activity-read[open])>.activity-output{display:none}
187
+ .read-lines{margin:0;padding:4px 0;background:var(--bg-inset);border:1px solid var(--border);border-radius:6px;font-size:12px;line-height:1.45;overflow:auto;max-height:420px}
188
+ .read-lines code{display:block;min-width:max-content}
189
+ .read-line{display:block;padding:0 10px 0 0;white-space:pre;color:var(--text-2)}
190
+ .read-line::before{content:attr(data-line);display:inline-block;width:5ch;margin-right:10px;padding-right:6px;text-align:right;color:var(--text-faint);user-select:none;font-variant-numeric:tabular-nums}
191
+ .read-meta{color:var(--text-faint)}
181
192
  /* An execute/task_create's full command, above its output and lined up with it: as written, its own scroll. */
182
193
  .activity-row>.activity-command{flex:1 1 100%;min-width:0;margin:4px 0 2px var(--row-gutter)}
183
194
  .activity-command pre{margin:0;max-height:160px;overflow:auto;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:1.45;color:var(--text-2);background:var(--bg-inset);border:1px solid var(--border);border-radius:6px;padding:6px 34px 6px 8px}
@@ -215,6 +226,7 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
215
226
  .activity-params{flex:1 1 0}
216
227
  .activity-output{margin-left:0}
217
228
  .activity-diff{margin-left:0}
229
+ .activity-read{margin-left:0}
218
230
  .activity-edit{margin-left:0}
219
231
  .activity-row>.activity-command{margin-left:0}
220
232
  }
@@ -307,6 +319,9 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
307
319
  .bubble.command.queued .command-output{color:var(--text-faint)}
308
320
  .command-line{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-3)}
309
321
  .command-output{margin:6px 0 0;white-space:pre-wrap;font-size:12px;color:var(--text-2)}
322
+ .bubble.command .command-confirm{margin-top:8px;padding:3px 10px;border-radius:6px;font:inherit;font-size:12px;cursor:pointer;background:var(--accent-bg);color:var(--accent);border:1px solid var(--accent-border)}
323
+ .bubble.command .command-confirm:hover:not(:disabled){border-color:var(--accent)}
324
+ .bubble.command .command-confirm:disabled{opacity:.55;cursor:default}
310
325
  .bubble.continue{border-left:3px solid var(--accent);background:var(--bg-panel);padding:10px 12px}
311
326
  .bubble.continue.resolved{border-left-color:var(--text-muted);opacity:.8}
312
327
  .continue-text{font-size:14px;color:var(--text);margin-bottom:6px}
@@ -368,8 +383,10 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
368
383
  /* Refs (refs.js): text until hovered, and only for a kind this viewer has an action for (body.refs-file). */
369
384
  .refs-file .ref[data-ref-kind="file"]:hover{color:var(--accent);text-decoration:underline dotted;text-underline-offset:3px;cursor:pointer}
370
385
  .refs-file .ref[data-ref-kind="file"]:hover::after{content:"\2197";margin-left:.3em;color:var(--text-faint);display:inline-block}
371
- .refs-file .activity-row:has(.ref[data-ref-kind="file"]) .diff-line[data-line]:hover{cursor:pointer;box-shadow:inset 2px 0 var(--accent)}
372
- .refs-file .activity-row:has(.ref[data-ref-kind="file"]) .diff-line[data-line]:hover::after{content:"\2197 " attr(data-line);float:right;padding-left:12px;color:var(--text-faint)}
386
+ .refs-file :is(.activity-row,.bubble.approval):has(.ref[data-ref-kind="file"]) .diff-line[data-line]:hover{cursor:pointer;box-shadow:inset 2px 0 var(--accent)}
387
+ .refs-file :is(.activity-row,.bubble.approval):has(.ref[data-ref-kind="file"]) .diff-line[data-line]:hover::after{content:"\2197 " attr(data-line);float:right;padding-left:12px;color:var(--text-faint)}
388
+ .refs-file .activity-row:has(.ref[data-ref-kind="file"]) .read-line[data-line]:hover{cursor:pointer;box-shadow:inset 2px 0 var(--accent)}
389
+ .refs-file .activity-row:has(.ref[data-ref-kind="file"]) .read-line[data-line]:hover::before{color:var(--accent)}
373
390
  .diff-add{background:var(--diff-add-bg);color:var(--diff-add-text)}
374
391
  .diff-del{background:var(--diff-del-bg);color:var(--diff-del-text)}
375
392
  .diff-hunk{color:var(--text-faint)}
@@ -704,9 +721,9 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
704
721
  #infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
705
722
  #infoBar #infoDeleteBtn:hover{color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
706
723
  /* The LLM context and thinking chips (llm_context_chip.js, thinking_chip.js): model-like chips that open their popovers; accent when the session set its own. */
707
- #infoBar .meta .llmctx-chip,#infoBar .meta .think-chip{padding:2px 6px;border-radius:6px;font-size:11px;font-weight:400;border:1px solid transparent;cursor:pointer;font-family:inherit;white-space:nowrap}
708
- #infoBar .meta .llmctx-chip:hover,#infoBar .meta .think-chip:hover{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
709
- #infoBar .meta .llmctx-chip.own,#infoBar .meta .think-chip.own{color:var(--accent);border-color:var(--accent-border)}
724
+ #infoBar .meta .llmctx-chip,#infoBar .meta .think-chip,#infoBar .meta .rh-chip{padding:2px 6px;border-radius:6px;font-size:11px;font-weight:400;border:1px solid transparent;cursor:pointer;font-family:inherit;white-space:nowrap}
725
+ #infoBar .meta .llmctx-chip:hover,#infoBar .meta .think-chip:hover,#infoBar .meta .rh-chip:hover{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
726
+ #infoBar .meta .llmctx-chip.own,#infoBar .meta .think-chip.own,#infoBar .meta .rh-chip.own{color:var(--accent);border-color:var(--accent-border)}
710
727
  .llmctx-form{display:flex;flex-direction:column;gap:8px;margin:0}
711
728
  .llmctx-form .ctx-pop-head strong{font-family:inherit}
712
729
  .llmctx-field{display:flex;align-items:center;gap:8px}
@@ -1,6 +1,6 @@
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 and thinking chips, model notes, memories, folder, ctx, speed,
3
+ // llm ctx, thinking and reasoning history chips, model notes, memories, folder, ctx, speed,
4
4
  // session clock and the worker's version badge. A lit-html template over a
5
5
  // plain model app.js builds (updateInfoBar), so the markup lives here and
6
6
  // runs under node --test (spec/web/public/info_bar.test.js).
@@ -15,6 +15,7 @@ import { memChipText, memChipTitle } from "./format.js";
15
15
  import { ALL_SESSIONS_HASH, sessionHash } from "./route.js";
16
16
  import { chipTemplate as llmContextChip } from "./llm_context_chip.js";
17
17
  import { chipTemplate as thinkingChip } from "./thinking_chip.js";
18
+ import { chipTemplate as reasoningHistoryChip } from "./reasoning_history_chip.js";
18
19
 
19
20
  export const PREV = "←";
20
21
  export const NEXT = "→";
@@ -60,7 +61,8 @@ function kidsTemplate(kids) {
60
61
  // id, copied (the copy button just copied), status, model ({text, title,
61
62
  // mismatch}: format.js modelLabel), chain ({prev, next}: chainLinkTemplate's
62
63
  // links or null), parent ({id, title} or null), kids (format.js
63
- // childrenSummary), llmContext and thinking (the chips' summaries), notes
64
+ // childrenSummary), llmContext, thinking and reasoningHistory (the chips'
65
+ // summaries; reasoningHistory null on a native host: no chip), notes
64
66
  // ({text, title} or null), memories (the used names), dir, ctx ({text,
65
67
  // tip}), speed ({text, tip} or null), clock (the session time's text, ""
66
68
  // for none), worker ({badge, busy} or null).
@@ -68,7 +70,7 @@ export function infoBarHtml(bar) {
68
70
  if (!bar) return nothing;
69
71
  const attach = `chi --attach ${bar.id}`;
70
72
  const memories = bar.memories || [];
71
- 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)}${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>`;
73
+ 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)}${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>`;
72
74
  }
73
75
 
74
76
  // Draws +bar+ into +el+ (#infoText), with the memories in its tooltip.
@@ -0,0 +1,174 @@
1
+ // A level chip in the session bar and its popover: the level the next turn
2
+ // runs at ("think low", "reasoning all"; "· session" when the session set
3
+ // its own) and a form with a level select that runs the setting's command
4
+ // in the session's worker (the reply shows in the conversation, like a
5
+ // typed one). The summary is the setting's SettingExplained#summary
6
+ // ({level, source, own}: the worker's snapshot, a command_ran, or the
7
+ // server's own reading of the session file).
8
+ //
9
+ // A config names the setting (thinking_chip.js, reasoning_history_chip.js):
10
+ // name the popover's heading ("Thinking")
11
+ // prefix the chip's word ("think")
12
+ // command the command ("/thinking")
13
+ // levels the settable levels (the select's options after "follow")
14
+ // anchor the chip's selector (its data attribute: each config's
15
+ // chipTemplate draws it; clicks are delegated on the same
16
+ // element as the other chips')
17
+ // formClass, popoverClass, nowClass the form's, popover's and "Now:"
18
+ // line's own classes
19
+ // when(fresh) the form's note on when it applies (fresh: the start
20
+ // page's new chat, thinking only)
21
+ //
22
+ // The start page's new chat (kind NEW_CHAT) has a thinking chip only
23
+ // (thinking_chip.js keeps its choice): the form shows the model's level and
24
+ // takes a choice for the next create.
25
+
26
+ import { NEW_CHAT, SESSION, popoverPlace } from "./llm_context_chip.js";
27
+ import { html, nothing, render } from "./vendor/lit-html.js";
28
+
29
+ export const DEFAULT = "default";
30
+
31
+ // The chip: its text, tooltip and whether the session (or the new chat)
32
+ // set its own; null without a summary (an older worker, or a host the
33
+ // setting means nothing on).
34
+ export function chipModel(cfg, summary, { kind = SESSION } = {}) {
35
+ if (!summary || !summary.level) return null;
36
+ const own = !!summary.own;
37
+ const what = kind === NEW_CHAT ? "the new chat" : "this session";
38
+ const from = own ? what : summary.source || "the model's own";
39
+ const title = `${cfg.name}: ${summary.level} (${from}). Click to change it for ${what}.`;
40
+ return { text: `${cfg.prefix} ${summary.level}${own ? ` · ${kind}` : ""}`, title, own };
41
+ }
42
+
43
+ // The command line for a select value ("default" unsets the session's own),
44
+ // with --yes: picking a level in the form is the choice, and the form
45
+ // names the cost (CacheConfirm asks nothing then); null when it is what
46
+ // the form opened with.
47
+ export function commandLine(cfg, level, initial = null) {
48
+ if (initial !== null && level === initial) return null;
49
+ return `${cfg.command} ${level} --yes`;
50
+ }
51
+
52
+ // "Now: low (session)", "Now: default (the model's own)".
53
+ export function nowText(summary) {
54
+ return `Now: ${summary.level} (${summary.source || "the model's own"})`;
55
+ }
56
+
57
+ const option = (value, label, chosen) => html`<option value=${value} ?selected=${value === chosen}>${label}</option>`;
58
+
59
+ // The popover's form (a lit-html template). For the new chat (kind
60
+ // NEW_CHAT), +summary+ is the model's and +choice+ the level the form
61
+ // shows; +now+ is the summary its "Now:" line reads (refresh passes a
62
+ // newer one, the rest stays as the form opened).
63
+ export function formTemplate(cfg, summary, { running = false, kind = SESSION, choice = null, now = summary } = {}) {
64
+ const fresh = kind === NEW_CHAT;
65
+ const chosen = (fresh ? choice : summary.own) || DEFAULT;
66
+ const follow = summary.own && !fresh ? "follow the model" : `follow the model (${summary.level})`;
67
+ return html`<form class=${`${cfg.formClass} llmctx-form`}><div class="ctx-pop-head"><strong>${cfg.name}</strong
68
+ ><span class="ctx-pop-kind">${fresh ? NEW_CHAT : "this session"}</span
69
+ ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
70
+ ><div class=${`ctx-pop-meta ${cfg.nowClass}`}>${nowText(now)}</div
71
+ ><label class="llmctx-field"><span>Level</span><select name="level">${option(DEFAULT, follow, chosen)}${
72
+ cfg.levels.map((level) => option(level, level, chosen))}</select></label
73
+ ><div class="ctx-pop-meta">${cfg.when(fresh)}${running ? " A turn is running: it applies after this turn." : ""}</div
74
+ ><div class="ctx-pop-actions"><button type="submit" class="llmctx-set">Set</button></div></form>`;
75
+ }
76
+
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
84
+ // apart from the other controls on the same element (the anchor defaults
85
+ // to the config's).
86
+ export function createLevelControl({ cfg, el, summary, session = () => null, running = () => false, run,
87
+ kind = SESSION, choice = () => null, choose = null,
88
+ id, anchor: anchorSel = cfg.anchor,
89
+ doc = document, win = window }) {
90
+ 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
+ let initial = null;
98
+ let openFor = null;
99
+ // 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).
102
+ let shown = null;
103
+ const draw = (now) => render(formTemplate(cfg, shown.data, { ...shown.opts, now }), pop);
104
+
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();
144
+ draw(data);
145
+ }
146
+
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;
157
+ const was = initial;
158
+ close();
159
+ if (fresh) return choose(level === DEFAULT ? null : level);
160
+ const line = commandLine(cfg, level, was);
161
+ 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();
171
+ });
172
+
173
+ return { close, refresh, get open() { return isOpen(); } };
174
+ }
@@ -22,7 +22,7 @@ export const RECENT_KEY = "chi_model_recent";
22
22
 
23
23
  // The payload's models as rows: {name, host, id, isDefaultHost}, and
24
24
  // sampling (the configured request parameters as /model words them) when
25
- // the model has some, thinking (the level it starts at, Thinking::Explained#summary),
25
+ // the model has some, thinking (the level it starts at, SettingExplained#summary),
26
26
  // and llm_context (the LLM context it starts under,
27
27
  // LLMContextStrategy::Explained#summary) when the server sent one, and
28
28
  // configured when the host declares the id (hosts.<name>.models), and
@@ -49,9 +49,10 @@ export function relayView(pq) {
49
49
  };
50
50
  }
51
51
 
52
- // @return {null | {tool, what, isCommand, where, why, preview?, delegate?}};
53
- // preview is an edit/write's dry-run diff (diff_view.js draws it); delegate
54
- // is relayView's, for a delegate's approval
52
+ // @return {null | {tool, what, isCommand, where, why, paths?, preview?, delegate?}};
53
+ // paths are what +what+ joins when it shows paths (the card draws a ref
54
+ // for each); preview is an edit/write's dry-run diff (diff_view.js draws
55
+ // it); delegate is relayView's, for a delegate's approval
55
56
  export function approvalView(pq) {
56
57
  if (!isApproval(pq)) return null;
57
58
  const a = pq.approval || {};
@@ -74,6 +75,7 @@ export function approvalView(pq) {
74
75
  // A tool without a command or path (an MCP tool): its compact args.
75
76
  what: command || paths.join("\n") || a.args || "",
76
77
  isCommand: !!command,
78
+ ...(!command && paths.length ? { paths } : {}),
77
79
  where,
78
80
  why: `${reason} (${who})`,
79
81
  ...(a.preview && typeof a.preview === "object" ? { preview: a.preview } : {}),
@@ -254,7 +254,8 @@ export function createQuestionCards({
254
254
  const what = doc.createElement("pre");
255
255
  what.className = view.isCommand ? "approval-what command" : "approval-what paths";
256
256
  const code = doc.createElement("code");
257
- code.textContent = view.what;
257
+ if (view.paths) appendPathRefs(code, view.paths);
258
+ else code.textContent = view.what;
258
259
  what.appendChild(code);
259
260
  box.appendChild(what);
260
261
  }
@@ -279,6 +280,25 @@ export function createQuestionCards({
279
280
  return box;
280
281
  }
281
282
 
283
+ // One file ref per path, a line each (refs.js: the card's path opens
284
+ // the current file at the dry run's first change). A directory (a
285
+ // plugin's path ending in /) stays plain text.
286
+ function appendPathRefs(code, paths) {
287
+ paths.forEach((path, i) => {
288
+ if (i) code.appendChild(doc.createTextNode("\n"));
289
+ if (String(path).endsWith("/")) {
290
+ code.appendChild(doc.createTextNode(String(path)));
291
+ return;
292
+ }
293
+ const ref = doc.createElement("span");
294
+ ref.className = "ref";
295
+ ref.dataset.refKind = "file";
296
+ ref.dataset.refPath = String(path);
297
+ ref.textContent = String(path);
298
+ code.appendChild(ref);
299
+ });
300
+ }
301
+
282
302
  // "Waiting for approval in parent ab12: answering here works too", or
283
303
  // an empty (hidden) line while the question isn't relayed.
284
304
  function renderRelayedTo(pq) {
@@ -0,0 +1,74 @@
1
+ // A read row's output as the file's lines, numbered, apart from the DOM.
2
+ // Only when the output is the file text as-is: each numbered line is a
3
+ // ref's line (refs.js opens the row's file there). The row's preview
4
+ // (turn_html.js) strips any "[tag] " and cuts at 300 chars; numbering
5
+ // needs the text exactly as read, so it is drawn on its own, closed.
6
+
7
+ import { escapeHtml } from "./format.js";
8
+
9
+ // What follows one of these isn't the file's next line: a ToolRunner cap
10
+ // ("[cut: …]"), a head/tail preview's gap ("[... omitted …]") or the note
11
+ // of a range clamped at the end of the file ("[read: end_line …]"). They
12
+ // are appended at the end (a clamp note, then a cut), so only the last
13
+ // MARKER_LINES lines can be one: such a line earlier is the file's own.
14
+ const MARKER_LINES = 2;
15
+ const MARKER = /^(?:\[cut: |\[\.\.\. omitted |\[read: end_line \d+ exceeds )/;
16
+
17
+ // An image's line instead of its text: attached, or why not (ToolRunner).
18
+ const IMAGE = /\((?:\d+×\d+ )?[A-Z]+\)(?: attached\.| is an image; | is not attached: )/;
19
+
20
+ // The file text of a read row's output, or null when it isn't the file
21
+ // as-is: an error, the head/tail preview of a big file, an image's line.
22
+ function fileText(row) {
23
+ if (row?.tool !== "read" || row.status !== "ok" || row.ref?.kind !== "file" || row.images?.length) return null;
24
+ let text = String(row.output || "");
25
+ // The call the gate replaced says so on a line before the tag.
26
+ if (text.startsWith("ran as: ")) text = text.slice(text.indexOf("\n") + 1);
27
+ if (!text.startsWith("[read]\n")) return null;
28
+ text = text.slice("[read]\n".length);
29
+ if (!text || text.startsWith("Error: ") || text.startsWith("truncated=true\n")) return null;
30
+ if (!text.includes("\n") && IMAGE.test(text)) return null;
31
+ return text;
32
+ }
33
+
34
+ // @return {null | Array<{text, line}>} the output's lines, line null for
35
+ // one that isn't the file's: a marker and all after it, and the "…" of a
36
+ // reload's capped output (output_truncated; a cut line already says so
37
+ // for a live one). The first line is the range's start_line (the ref's
38
+ // line), else 1.
39
+ export function readLines(row) {
40
+ const text = fileText(row);
41
+ if (text === null) return null;
42
+ const lines = text.split("\n");
43
+ // The file's last newline ends a line, it doesn't start one.
44
+ if (lines.at(-1) === "") lines.pop();
45
+ let next = Number(row.ref.line) || 1;
46
+ let numbering = true;
47
+ const out = [];
48
+ lines.forEach((line, i) => {
49
+ if (numbering && i >= lines.length - MARKER_LINES && MARKER.test(line)) {
50
+ numbering = false;
51
+ // The newline before a marker is the marker's: the blank it leaves
52
+ // isn't a line of the file.
53
+ if (i > 0 && lines[i - 1] === "" && out.at(-1)?.line !== null) out.pop();
54
+ }
55
+ out.push({ text: line, line: numbering ? next++ : null });
56
+ });
57
+ if (row.output_truncated && numbering) out.push({ text: "…", line: null });
58
+ return out;
59
+ }
60
+
61
+ // A read row's numbered lines behind a closed toggle ("lines 40–60"); ""
62
+ // when its output isn't the file as-is.
63
+ export function readLinesHtml(row) {
64
+ const lines = readLines(row);
65
+ const numbered = lines?.filter((l) => l.line !== null) || [];
66
+ if (!numbered.length) return "";
67
+ const first = numbered[0].line;
68
+ const last = numbered.at(-1).line;
69
+ const label = first === last ? `line ${first}` : `lines ${first}–${last}`;
70
+ const spans = lines.map(({ text, line }) => line === null
71
+ ? `<span class="read-line read-meta">${escapeHtml(text)}</span>`
72
+ : `<span class="read-line" data-line="${line}">${escapeHtml(text) || " "}</span>`).join("");
73
+ return `<details class="activity-read"><summary>${label}</summary><pre class="read-lines"><code>${spans}</code></pre></details>`;
74
+ }
@@ -0,0 +1,49 @@
1
+ // The session bar's reasoning history chip: which of its own reasoning the
2
+ // session's model gets back from the next turn on ("reasoning all";
3
+ // "· session" when the session set its own), and a popover with a level
4
+ // select that runs /reasoning-history in the session's worker. The summary
5
+ // is the session's reasoning_history (SettingExplained#summary; null on a
6
+ // native host, which ignores it, or from an older worker: no chip then).
7
+ // A config on level_chip.js.
8
+
9
+ import * as level from "./level_chip.js";
10
+ import { html, nothing } from "./vendor/lit-html.js";
11
+
12
+ export const LEVELS = ["drop", "turn", "all"];
13
+
14
+ export const CONFIG = Object.freeze({
15
+ name: "Reasoning history",
16
+ prefix: "reasoning",
17
+ command: "/reasoning-history",
18
+ levels: LEVELS,
19
+ anchor: "[data-rh-chip]",
20
+ formClass: "rh-form",
21
+ popoverClass: "rh-popover",
22
+ nowClass: "rh-now",
23
+ when: () => "From the next turn's start. A change to or from all makes the server read the conversation again.",
24
+ });
25
+
26
+ export function chipModel(summary) {
27
+ return level.chipModel(CONFIG, summary);
28
+ }
29
+
30
+ // The session bar's chip (a lit-html template; info_bar.js), nothing
31
+ // without a summary.
32
+ export function chipTemplate(summary) {
33
+ const chip = chipModel(summary);
34
+ if (!chip) return nothing;
35
+ return html`<button type="button" class=${`model rh-chip${chip.own ? " own" : ""}`} title=${chip.title} data-rh-chip aria-haspopup="dialog">${chip.text}</button>`;
36
+ }
37
+
38
+ export function commandLine(choice, initial = null) {
39
+ return level.commandLine(CONFIG, choice, initial);
40
+ }
41
+
42
+ export function formTemplate(summary, opts) {
43
+ return level.formTemplate(CONFIG, summary, opts);
44
+ }
45
+
46
+ // The chip's popover in the session bar (level_chip.js createLevelControl).
47
+ export function createReasoningHistoryControl({ id = "reasoningHistoryPopover", ...opts }) {
48
+ return level.createLevelControl({ cfg: CONFIG, id, ...opts });
49
+ }