samagotchi 0.134.0 → 0.136.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 (114) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -1
  3. data/bin/chi +2 -3
  4. data/docs/architecture.md +6 -3
  5. data/docs/cli.md +43 -38
  6. data/docs/configuration.md +11 -9
  7. data/docs/guardrails.md +53 -9
  8. data/docs/internals/engine.md +6 -3
  9. data/docs/internals/participants.md +1 -1
  10. data/docs/internals/prompt-caching.md +1 -1
  11. data/docs/internals/web-frontend.md +263 -0
  12. data/docs/internals/working-set.md +2 -2
  13. data/docs/sessions.md +5 -3
  14. data/docs/testing.md +36 -6
  15. data/lib/samagotchi/bridge.rb +11 -6
  16. data/lib/samagotchi/bridge_client.rb +8 -5
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/config.rb +0 -2
  19. data/lib/samagotchi/engine.rb +69 -12
  20. data/lib/samagotchi/guardrail_wiring.rb +8 -6
  21. data/lib/samagotchi/guardrails/approval.rb +41 -5
  22. data/lib/samagotchi/guardrails/implicit_writers.rb +19 -4
  23. data/lib/samagotchi/guardrails/pair_boundary.rb +5 -1
  24. data/lib/samagotchi/guardrails/pair_command.rb +387 -78
  25. data/lib/samagotchi/guardrails/parent_approvals.rb +12 -15
  26. data/lib/samagotchi/guardrails/read_only_shell.rb +24 -2
  27. data/lib/samagotchi/guardrails/shared_git.rb +1 -1
  28. data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
  29. data/lib/samagotchi/guardrails/shell_lex.rb +22 -0
  30. data/lib/samagotchi/guardrails_command.rb +13 -10
  31. data/lib/samagotchi/llm/chat_loop.rb +4 -3
  32. data/lib/samagotchi/message_parts.rb +1 -1
  33. data/lib/samagotchi/revise_anchor.rb +33 -0
  34. data/lib/samagotchi/session_inbox.rb +20 -12
  35. data/lib/samagotchi/session_manager.rb +13 -11
  36. data/lib/samagotchi/system_one/approval_question.rb +21 -16
  37. data/lib/samagotchi/system_one/self_protect.rb +13 -15
  38. data/lib/samagotchi/tools/delegate_relay.rb +6 -5
  39. data/lib/samagotchi/tools/delegate_wait.rb +5 -5
  40. data/lib/samagotchi/tools/execute.rb +6 -6
  41. data/lib/samagotchi/tools/output_guardrails.rb +9 -2
  42. data/lib/samagotchi/tools/read.rb +5 -5
  43. data/lib/samagotchi/turn_anchors.rb +49 -0
  44. data/lib/samagotchi/verify_anchor.rb +5 -5
  45. data/lib/samagotchi/version.rb +1 -1
  46. data/lib/samagotchi/web/app.rb +6 -18
  47. data/lib/samagotchi/web/message_display.rb +33 -1
  48. data/lib/samagotchi/web/participant_access.rb +2 -2
  49. data/lib/samagotchi/web/participant_view.rb +8 -0
  50. data/lib/samagotchi/web/public/activity.js +1 -1
  51. data/lib/samagotchi/web/public/annotations.js +1 -1
  52. data/lib/samagotchi/web/public/answer_fold.js +100 -31
  53. data/lib/samagotchi/web/public/app.js +451 -367
  54. data/lib/samagotchi/web/public/bubbles.js +30 -10
  55. data/lib/samagotchi/web/public/cards_place.js +4 -2
  56. data/lib/samagotchi/web/public/chip_popover.js +2 -2
  57. data/lib/samagotchi/web/public/composer.css +8 -0
  58. data/lib/samagotchi/web/public/context_chips.js +9 -6
  59. data/lib/samagotchi/web/public/data.js +4 -2
  60. data/lib/samagotchi/web/public/dock_reserve.js +13 -15
  61. data/lib/samagotchi/web/public/feature_gates.js +33 -0
  62. data/lib/samagotchi/web/public/history_reveal.js +10 -6
  63. data/lib/samagotchi/web/public/index.html +105 -53
  64. data/lib/samagotchi/web/public/info_bar.js +11 -12
  65. data/lib/samagotchi/web/public/info_bar_model.js +8 -10
  66. data/lib/samagotchi/web/public/latest.js +46 -0
  67. data/lib/samagotchi/web/public/level_chip.js +33 -95
  68. data/lib/samagotchi/web/public/lists_view.js +15 -13
  69. data/lib/samagotchi/web/public/live_timing.js +2 -0
  70. data/lib/samagotchi/web/public/llm_context_chip.js +13 -144
  71. data/lib/samagotchi/web/public/markdown_place.js +1 -1
  72. data/lib/samagotchi/web/public/method_api.js +14 -0
  73. data/lib/samagotchi/web/public/notifications.js +4 -11
  74. data/lib/samagotchi/web/public/participant.js +11 -6
  75. data/lib/samagotchi/web/public/participant_names.js +1 -1
  76. data/lib/samagotchi/web/public/participants_panel.js +4 -2
  77. data/lib/samagotchi/web/public/project_picker.js +18 -22
  78. data/lib/samagotchi/web/public/question_cards.js +2 -2
  79. data/lib/samagotchi/web/public/reasoning_history_chip.js +8 -34
  80. data/lib/samagotchi/web/public/restore.js +2 -0
  81. data/lib/samagotchi/web/public/revise_stack.js +378 -0
  82. data/lib/samagotchi/web/public/scope.js +12 -20
  83. data/lib/samagotchi/web/public/send_turn.js +29 -16
  84. data/lib/samagotchi/web/public/session_actions.js +15 -14
  85. data/lib/samagotchi/web/public/session_fields.js +152 -0
  86. data/lib/samagotchi/web/public/session_payload.js +35 -0
  87. data/lib/samagotchi/web/public/session_state.js +9 -5
  88. data/lib/samagotchi/web/public/session_store.js +191 -0
  89. data/lib/samagotchi/web/public/settings_chip.js +212 -0
  90. data/lib/samagotchi/web/public/settings_panel.js +121 -0
  91. data/lib/samagotchi/web/public/shell_command.js +26 -0
  92. data/lib/samagotchi/web/public/stage_model.js +4 -4
  93. data/lib/samagotchi/web/public/stage_view.js +11 -16
  94. data/lib/samagotchi/web/public/start_settings.js +25 -88
  95. data/lib/samagotchi/web/public/stream_handlers.js +88 -90
  96. data/lib/samagotchi/web/public/task_stop.js +1 -1
  97. data/lib/samagotchi/web/public/thinking_chip.js +7 -45
  98. data/lib/samagotchi/web/public/time_gaps.js +5 -2
  99. data/lib/samagotchi/web/public/timing.js +15 -5
  100. data/lib/samagotchi/web/public/toolkit_chip.js +37 -21
  101. data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +33 -93
  102. data/lib/samagotchi/web/public/turn_events.js +3 -3
  103. data/lib/samagotchi/web/public/turn_html.js +20 -16
  104. data/lib/samagotchi/web/public/turn_model.js +6 -6
  105. data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
  106. data/lib/samagotchi/web/public/working_set_view.js +3 -1
  107. data/lib/samagotchi/web/server.rb +4 -4
  108. data/lib/samagotchi/web/session_control.rb +2 -2
  109. data/lib/samagotchi/web/session_create.rb +1 -1
  110. data/lib/samagotchi/web/session_show.rb +6 -3
  111. data/lib/samagotchi/worker.rb +9 -9
  112. data/lib/samagotchi/worker_inbound.rb +6 -5
  113. metadata +15 -3
  114. data/lib/samagotchi/web/public/hold.js +0 -78
@@ -0,0 +1,212 @@
1
+ // The session bar's ⚙ chip and its panel: the open session's thinking,
2
+ // reasoning history and LLM context in one place, as the start page's ⚙
3
+ // panel has them for a new session (settings_panel.js draws both). The chip
4
+ // reads "⚙" alone while the session follows the model for all three, else
5
+ // "⚙" and the session's own in the start page's words ("⚙ think low · ctx
6
+ // stale"; class own, the accent). A pick in the panel runs that row's
7
+ // command with --yes in the session's worker at once (one reply line in the
8
+ // conversation, like a typed /thinking low) and the panel stays open; the
9
+ // pressed segment follows the session's summary when the reply's command_ran
10
+ // lands, never the tap. The summaries are the session's thinking,
11
+ // reasoning_history and llm_context (SettingExplained#summary,
12
+ // LLMContextStrategy::Explained#summary: the worker's snapshot, a
13
+ // command_ran, or the server's own reading of the session file).
14
+ //
15
+ // A new module: the page's modules are cached at unversioned URLs, and a
16
+ // new export of an old module would leave a cached copy without it.
17
+
18
+ import { createChipPopover, SHEET_TALL } from "./chip_popover.js";
19
+ import { DEFAULT, commandLine as levelLine, segments } from "./level_chip.js";
20
+ import { BUDGET_ERROR, commandLine as contextLine, ownValues, parseBudget } from "./llm_context_chip.js";
21
+ import { CONFIG as REASONING } from "./reasoning_history_chip.js";
22
+ import { contextSegments, contextWords, settingsPanelTemplate } from "./settings_panel.js";
23
+ import { CONFIG as THINKING } from "./thinking_chip.js";
24
+ import { html, nothing, render } from "./vendor/lit-html.js";
25
+
26
+ export const PANEL_LABEL = "Session settings";
27
+ export const NOTE = "From the next turn's start.";
28
+ export const RUNNING_NOTE = "A turn is running: it applies after this turn.";
29
+ const CONTEXT_COST = "Turning forget on or off re-reads the whole prompt once.";
30
+
31
+ // A level's own value ("low"), null when it follows the model.
32
+ const levelOwn = (summary) => summary?.own || null;
33
+
34
+ // The context's own values in a few words ("stale · budget 64000"), null
35
+ // when the session has none.
36
+ const contextOwn = (summary) => (summary?.own ? contextWords(summary.own) : null);
37
+
38
+ function levelTitle(cfg, summary) {
39
+ return `${cfg.name}: ${summary.level} (${summary.own ? "this session" : summary.source || "the model's own"})`;
40
+ }
41
+
42
+ function contextTitle(summary) {
43
+ const budget = summary.budget_tokens ? `${summary.budget_tokens} tokens` : "off";
44
+ return `LLM context: ${summary.strategy} (${summary.strategy_where}); apply ${summary.apply} (${summary.apply_where}); ` +
45
+ `budget ${budget} (${summary.budget_where})`;
46
+ }
47
+
48
+ // The chip for +summaries+ ({thinking, reasoningHistory, llmContext}, each
49
+ // a summary or null): {text (the own values past ⚙, "" for none), own,
50
+ // title (each value and where it comes from), label (the accessible name:
51
+ // the panel and what is set, settingsLabel's shape)}; null without any
52
+ // summary (an older worker: no chip).
53
+ export function chipModel({ thinking = null, reasoningHistory = null, llmContext = null } = {}) {
54
+ const think = thinking?.level ? thinking : null;
55
+ const rh = reasoningHistory?.level ? reasoningHistory : null;
56
+ const ctx = llmContext?.strategy ? llmContext : null;
57
+ if (!think && !rh && !ctx) return null;
58
+ const set = [
59
+ levelOwn(think) && `${THINKING.prefix} ${levelOwn(think)}`,
60
+ levelOwn(rh) && `${REASONING.prefix} ${levelOwn(rh)}`,
61
+ contextOwn(ctx) && `ctx ${contextOwn(ctx)}`,
62
+ ].filter(Boolean);
63
+ const lines = [think && levelTitle(THINKING, think), rh && levelTitle(REASONING, rh), ctx && contextTitle(ctx)].filter(Boolean);
64
+ return {
65
+ text: set.join(" · "),
66
+ own: set.length > 0,
67
+ title: `${lines.join(". ")}. Click to change them for this session.`,
68
+ label: set.length ? `${PANEL_LABEL}: ${set.join(", ")}` : PANEL_LABEL,
69
+ };
70
+ }
71
+
72
+ // The session bar's chip (a lit-html template; info_bar.js), nothing
73
+ // without a summary. ⚙ is drawn as the start page's settings icon (the
74
+ // glyph stays the text); the own values are a span of their own: a phone
75
+ // shows ⚙ with a dot instead (index.html's CSS).
76
+ export function chipTemplate(summaries) {
77
+ const chip = summaries && chipModel(summaries);
78
+ if (!chip) return nothing;
79
+ return html`<button type="button" class=${`model settings-chip${chip.own ? " own" : ""}`} title=${chip.title}
80
+ aria-label=${chip.label} aria-haspopup="dialog" data-settings-chip><span class="chip-icon sc-gear" aria-hidden="true">⚙</span>${
81
+ chip.text ? html`<span class="sc-own"> ${chip.text}</span>` : nothing}</button>`;
82
+ }
83
+
84
+ // The panel's note: when a change applies (after the running turn, while
85
+ // one runs) and what each drawn row's change costs.
86
+ export function noteText({ thinking, reasoningHistory, llmContext, running = false }) {
87
+ return [NOTE, running ? RUNNING_NOTE : null, thinking ? THINKING.cost : null, reasoningHistory ? REASONING.cost : null,
88
+ llmContext ? CONTEXT_COST : null].filter(Boolean).join(" ");
89
+ }
90
+
91
+ // The panel (settings_panel.js settingsPanelTemplate) over the session's
92
+ // summaries: the Thinking row, the Reasoning history row (a chat host's
93
+ // only), the Context row with its Apply and budget disclosure; each row's
94
+ // pressed segment the session's own. +more+: the disclosure open; +error+:
95
+ // its Set's.
96
+ export function panelTemplate({ thinking = null, reasoningHistory = null, llmContext = null, running = false,
97
+ more = false, error = null }) {
98
+ const think = segments(THINKING, thinking, levelOwn(thinking));
99
+ const rh = segments(REASONING, reasoningHistory, levelOwn(reasoningHistory));
100
+ const ctx = contextSegments(llmContext, llmContext?.own || null);
101
+ return settingsPanelTemplate({
102
+ heading: PANEL_LABEL,
103
+ kind: "this session",
104
+ rows: [
105
+ think ? { name: "think", label: THINKING.name, segments: think, act: "think" } : null,
106
+ // "Reasoning", the chip's word: "Reasoning history" wraps in the label's column.
107
+ rh ? { name: "rh", label: "Reasoning", segments: rh, act: "reasoning" } : null,
108
+ ctx ? { name: "ctx", label: "Context", segments: ctx, act: "ctx",
109
+ more: { open: more, summary: llmContext, choice: llmContext.own || null, error } } : null,
110
+ ],
111
+ note: noteText({ thinking: think, reasoningHistory: rh, llmContext: ctx, running }),
112
+ });
113
+ }
114
+
115
+ // What a pick (+act+, a segment's data-act: "think:low", "reasoning:all",
116
+ // "ctx:stale", "ctx:default") runs for +summaries+: the line, or null (the
117
+ // segment pressed already). One field alone (D2): a strategy tap sends the
118
+ // strategy only, the session's own apply and budget stay; the Context
119
+ // row's default is the reset (strategy, apply and budget follow the model
120
+ // again), only when the session has any of its own.
121
+ export function actLine(act, { thinking = null, reasoningHistory = null, llmContext = null } = {}) {
122
+ const [what, value] = act.split(/:(.*)/s);
123
+ if (what === "think") return levelLine(THINKING, value, levelOwn(thinking) || DEFAULT);
124
+ if (what === "reasoning") return levelLine(REASONING, value, levelOwn(reasoningHistory) || DEFAULT);
125
+ if (what !== "ctx") return null;
126
+ if (value === DEFAULT) return llmContext?.own ? contextLine() : null;
127
+ const initial = ownValues(llmContext);
128
+ return contextLine({ ...initial, strategy: value }, initial);
129
+ }
130
+
131
+ // The disclosure's Set: the apply rule and the budget as typed, against the
132
+ // session's own; only the field that changed goes. {line} (null: nothing
133
+ // changed) or {error} (a budget chi wouldn't take).
134
+ export function moreLine({ apply, budget }, llmContext) {
135
+ const typed = String(budget ?? "").trim();
136
+ if (typed && parseBudget(typed) === null) return { error: BUDGET_ERROR };
137
+ const initial = ownValues(llmContext);
138
+ return { line: contextLine({ ...initial, apply, budget: typed }, initial) };
139
+ }
140
+
141
+ // The chip's panel on chip_popover.js' shell (a bottom sheet on a phone).
142
+ // el: where the chip is drawn (#infoText, info_bar.js: clicks are
143
+ // delegated); summaries(): the open session's {thinking, reasoningHistory,
144
+ // llmContext} (null: no session); session(): its id; running(): a turn
145
+ // runs; run(line): sends the command.
146
+ // @return {close, refresh, open}
147
+ export function createSettingsControl({ el, summaries, session = () => null, running = () => false, run,
148
+ id = "settingsPopover", anchor = "[data-settings-chip]",
149
+ doc = document, win = window }) {
150
+ // The summaries the open panel is drawn from (each refresh's newer ones),
151
+ // the disclosure and its Set's error.
152
+ let shown = null;
153
+ let more = false;
154
+ let error = null;
155
+
156
+ function draw() {
157
+ render(panelTemplate({ ...shown, running: running(), more, error }), shell.pop);
158
+ shell.replace();
159
+ }
160
+
161
+ function show(data, opening) {
162
+ shown = data;
163
+ if (opening) {
164
+ const own = data.llmContext?.own;
165
+ more = !!(own?.apply || typeof own?.budget_tokens === "number");
166
+ error = null;
167
+ }
168
+ draw();
169
+ }
170
+
171
+ function act(name) {
172
+ if (name === "ctx-more") {
173
+ more = !more;
174
+ return draw();
175
+ }
176
+ const line = actLine(name, shown);
177
+ if (line) run(line);
178
+ }
179
+
180
+ function submit(form) {
181
+ if (!form.classList.contains("sset-ctx-form")) return;
182
+ const result = moreLine({ apply: form.elements.apply.value, budget: form.elements.budget.value }, shown.llmContext);
183
+ error = result.error || null;
184
+ draw();
185
+ if (result.line) run(result.line);
186
+ }
187
+
188
+ // Focus inside on open (the popover sits at the end of <body>): the
189
+ // pressed segment of the first row.
190
+ function focusInside() {
191
+ shell.pop.querySelector(".sset-seg [aria-pressed=true]")?.focus();
192
+ }
193
+
194
+ const present = () => {
195
+ const data = summaries();
196
+ return data && chipModel(data) ? data : null;
197
+ };
198
+ const shell = createChipPopover({
199
+ id, className: "ctx-popover start-settings session-settings", width: 440, el, anchor, summary: present, session,
200
+ show, sheet: true, sheetShare: SHEET_TALL, modal: true, label: PANEL_LABEL, onOpen: focusInside,
201
+ onClose: () => { shown = null; error = null; }, onSubmit: submit, onAct: act, doc, win,
202
+ });
203
+ // An edit makes the disclosure Set's error stale.
204
+ shell.pop.addEventListener("input", (e) => {
205
+ if (error && e.target.closest(".sset-ctx-form")) {
206
+ error = null;
207
+ draw();
208
+ }
209
+ });
210
+
211
+ return { close: shell.close, refresh: shell.refresh, get open() { return shell.isOpen(); } };
212
+ }
@@ -0,0 +1,121 @@
1
+ // The settings panel's rows, drawn by the start page's ⚙ panel
2
+ // (start_settings.js) over a new session's choices and by the session bar's
3
+ // (settings_chip.js) over the open session's own: rows of segments
4
+ // (Thinking, Context), the Context row's Apply and budget disclosure, a
5
+ // line for a choice whose row is hidden, the review files' line. The caller
6
+ // passes the rows it draws (settingsPanelTemplate's +rows+) and applies the
7
+ // picks itself; the segments' data-act ("think:low", "ctx:stale",
8
+ // "ctx-more") is the same for every caller.
9
+
10
+ import { APPLIES, choiceWords, ownValues, strategyValue } from "./llm_context_chip.js";
11
+ import { DEFAULT, followSegment, segments } from "./level_chip.js";
12
+ import { reviewChipModel } from "./review_start.js";
13
+ import { CONFIG as THINKING } from "./thinking_chip.js";
14
+ import { html, nothing } from "./vendor/lit-html.js";
15
+
16
+ export { DEFAULT };
17
+
18
+ const STRATEGIES = [
19
+ { value: "none", label: "none" },
20
+ { value: "stale", label: "stale" },
21
+ { value: "stale,forget", label: "stale + forget", title: "stale + forget (experimental)" },
22
+ ];
23
+
24
+ // The Thinking row's segments (level_chip.js segments over thinking_chip.js'
25
+ // levels): the model's own level first ("default (high)"; "default" while
26
+ // the session has its own), then the levels;
27
+ // the pressed one is the choice. null without the model's thinking (the row
28
+ // is hidden).
29
+ export function thinkingSegments(summary, choice) {
30
+ return segments(THINKING, summary, choice);
31
+ }
32
+
33
+ // Whether the summary's +field+ (strategy, apply, budget) is the session's
34
+ // own: the value in force is then not the model's, so a default label
35
+ // doesn't name it.
36
+ const ownField = (summary, field) => summary?.[`${field}_source`] === "session";
37
+
38
+ // The Context row's segments over the strategies (a choice that isn't one
39
+ // of them, a newer chi's layer, is added); null without the model's
40
+ // llm_context (the row is hidden).
41
+ export function contextSegments(summary, choice) {
42
+ if (!summary?.strategy) return null;
43
+ const pressed = Array.isArray(choice?.strategy) ? strategyValue(choice.strategy) : DEFAULT;
44
+ const list = STRATEGIES.some((s) => s.value === pressed) || pressed === DEFAULT ? STRATEGIES
45
+ : [...STRATEGIES, { value: pressed, label: pressed }];
46
+ return [followSegment(summary.strategy, ownField(summary, "strategy")), ...list]
47
+ .map((s) => ({ ...s, pressed: s.value === pressed }));
48
+ }
49
+
50
+ // The context choice in a few words ("stale · budget 64000"), or null.
51
+ export function contextWords(choice) {
52
+ const words = choiceWords(choice);
53
+ if (!words) return null;
54
+ return [words.strategy, words.apply && `apply ${words.apply}`, words.budget && `budget ${words.budget}`]
55
+ .filter(Boolean).join(" · ");
56
+ }
57
+
58
+ export const segmentsTemplate = (name, label, segs, act) => html`<div class="sset-row"><span class="sset-label"
59
+ id=${`sset-${name}-label`}>${label}</span><div class="sset-seg" role="group" aria-labelledby=${`sset-${name}-label`}>${
60
+ segs.map((s) => html`<button type="button" data-act=${`${act}:${s.value}`} aria-pressed=${s.pressed ? "true" : "false"}
61
+ title=${s.title || nothing}>${s.label}</button>`)}</div></div>`;
62
+
63
+ const option = (value, label, chosen) => html`<option value=${value} ?selected=${value === chosen}>${label}</option>`;
64
+
65
+ // The disclosure's fields: the apply rule and the budget, with their own
66
+ // Set (a typed budget needs a commit) and mergeChoice's error under them.
67
+ // Each default names the model's value, unless the session set its own
68
+ // (the summary's value is the session's then).
69
+ export function contextMoreTemplate(summary, choice, error) {
70
+ const own = ownValues({ own: choice });
71
+ const budget = summary.budget_tokens ? String(summary.budget_tokens) : "off";
72
+ const apply = ownField(summary, "apply") ? DEFAULT : `${DEFAULT} (${summary.apply})`;
73
+ const budgetDefault = ownField(summary, "budget") ? DEFAULT : `${DEFAULT} (${budget})`;
74
+ return html`<form class="sset-ctx-form"><label class="llmctx-field"><span>Apply</span><span class="select-wrap"><select
75
+ name="apply">${option(DEFAULT, apply, own.apply)}${APPLIES.map((a) => option(a, a, own.apply))}</select
76
+ ></span></label><label class="llmctx-field"><span>Budget</span><input name="budget" type="text" inputmode="numeric"
77
+ autocomplete="off" placeholder=${`${budgetDefault}: 64000, 64k or off`} .value=${own.budget}></label
78
+ ><div class="ctx-pop-error" role="alert" ?hidden=${!error}>${error || ""}</div
79
+ ><div class="ctx-pop-actions"><button type="submit" class="llmctx-set">Set</button></div></form>`;
80
+ }
81
+
82
+ // A choice whose row is hidden (the picked model has no values for it).
83
+ export const sentAnywayTemplate = (text, act) => html`<div class="sset-anyway ctx-pop-meta">${text}
84
+ <button type="button" class="sset-link" data-act=${act}>Clear</button></div>`;
85
+
86
+ // One of the panel's rows, null drawing nothing:
87
+ // {name, label, segments, act}: a row of segments (segmentsTemplate);
88
+ // with +more+ ({open, summary, choice, error}) the Apply and budget
89
+ // disclosure under it, its fields while open (contextMoreTemplate)
90
+ // {anyway, act}: the line for a choice whose row is hidden, its Clear
91
+ // button's data-act (sentAnywayTemplate)
92
+ function rowTemplate(row) {
93
+ if (!row) return nothing;
94
+ if (row.anyway) return sentAnywayTemplate(row.anyway, row.act);
95
+ const { more } = row;
96
+ return html`${segmentsTemplate(row.name, row.label, row.segments, row.act)}${more ? html`<div class="sset-ctx-more"
97
+ ><button type="button" class="sset-link" data-act="ctx-more" aria-expanded=${more.open ? "true" : "false"}>Apply and budget ${
98
+ more.open ? "▾" : "▸"}</button></div>${more.open ? contextMoreTemplate(more.summary, more.choice, more.error) : nothing}`
99
+ : nothing}`;
100
+ }
101
+
102
+ // The review files' line: the files set (Edit, Remove), else Review files….
103
+ function reviewTemplate(review, ruled) {
104
+ const chip = review.length ? reviewChipModel(review) : null;
105
+ return html`${ruled ? html`<hr class="sset-rule">` : nothing}<div class="sset-review">${chip
106
+ ? html`<span class="sset-review-set" title=${chip.title}>Review: ${chip.text.replace(/^review: /, "")}</span
107
+ ><button type="button" class="sset-link" data-act="review">Edit</button><button type="button" class="sset-link"
108
+ data-act="review-clear">Remove</button>`
109
+ : html`<button type="button" class="sset-link" data-act="review" title=${reviewChipModel([]).title}>Review files…</button>`}</div>`;
110
+ }
111
+
112
+ // The panel: its heading (+kind+ beside it, or none) and close button, the
113
+ // +rows+ (rowTemplate's), the review files' line when +review+ is a list
114
+ // (null: no review line; a rule above it when a row of segments is drawn),
115
+ // and the +note+.
116
+ export function settingsPanelTemplate({ heading, kind = null, rows = [], review = null, note }) {
117
+ const segmented = rows.some((row) => row?.segments);
118
+ return html`<div class="ctx-pop-head"><strong>${heading}</strong>${kind ? html`<span class="ctx-pop-kind">${kind}</span>` : nothing
119
+ }<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
120
+ >${rows.map(rowTemplate)}${review ? reviewTemplate(review, segmented) : nothing}<div class="ctx-pop-meta">${note}</div>`;
121
+ }
@@ -0,0 +1,26 @@
1
+ // A `!cmd`'s saved user message: the worker appends "!(<command>)\n<output>"
2
+ // for the next turn (session_commands.rb, no turn id, no time). The history
3
+ // draws it as its command bubble (turn_html.js) and the reload's turns
4
+ // leave it out (timing.js turnGroups: a group of its own, no steps, no
5
+ // timing). Pure, no imports: timing.js and turn_html.js both read it, and
6
+ // turn_html.js imports timing.js. A module of its own: static JS is cached
7
+ // at unversioned URLs, and a new export on an old module would blank the
8
+ // page.
9
+
10
+ // Its command line and its output, or null when the content is any other
11
+ // message. The command sits on the first line inside `!(` `)`; the output
12
+ // is everything after that first newline. A message that merely starts
13
+ // with "!" or "!(" without the newline shape is a plain message.
14
+ export function shellCommandMessage(content) {
15
+ const text = String(content ?? "");
16
+ const match = text.match(/^!\((.*)\)\n([\s\S]*)$/);
17
+ if (!match) return null;
18
+ return { line: `!${match[1]}`, text: match[2] };
19
+ }
20
+
21
+ // A history item that is a `!cmd`'s record, not a prompt: a user message of
22
+ // that shape with no turn id (a prompt saves its turn's; session_chain.rb
23
+ // reads the record the same way).
24
+ export function isShellCommandItem(item) {
25
+ return item?.role === "user" && !item.turn_id && shellCommandMessage(item.content) !== null;
26
+ }
@@ -1,4 +1,4 @@
1
- // The stage view's pure parts (web.view: stage): what the live slots show
1
+ // The stage's pure parts (stage_view.js): what the live slots show
2
2
  // for a turn (turn_model.js's), when a finished turn hands off into the
3
3
  // history, and in what order its pieces go there. No DOM, so `node --test`
4
4
  // covers the rules; stage_view.js draws them.
@@ -128,7 +128,7 @@ export function miniToolText({ collapsed, tool }) {
128
128
  }
129
129
 
130
130
  // While the first step has no thinking and no tool rows the turn is plain:
131
- // its text streams in the stage's answer slot, as the turn view's
131
+ // its text streams in the stage's answer slot, as the turn block's
132
132
  // chrome-less block streams like a bubble.
133
133
  export function isPlain(turn) {
134
134
  if (!turn || turn.ended || turn.gens.length > 1) return false;
@@ -137,7 +137,7 @@ export function isPlain(turn) {
137
137
  }
138
138
 
139
139
  const END_PHASES = { completed: "answered", canceled: "canceled" };
140
- // A completed turn whose model returned nothing (the turn view's notice).
140
+ // A completed turn whose model returned nothing (the turn block's notice).
141
141
  const NO_ANSWER = "no answer";
142
142
 
143
143
  // What the live slots show for +turn+:
@@ -224,7 +224,7 @@ export function handOffDue({ ended, pending, inUse, quietSinceMs, nowMs }) {
224
224
  return { due: nowMs - since >= QUIET_MS, quietSinceMs: since };
225
225
  }
226
226
 
227
- // A handed-off turn's nodes in the order the turn view leaves them: the
227
+ // A handed-off turn's nodes in the order the turn block leaves them: the
228
228
  // prompt bubble (none for a reminder turn), the block, its working set's
229
229
  // footprint line (web.working_set), the extras in arrival order, the
230
230
  // answer's pieces, the timing line, the end line, the kept cards, then the
@@ -1,21 +1,20 @@
1
- // The stage view of a running turn (web.view: stage): the active turn pinned
1
+ // The stage of a running turn (the page's view of it): the active turn pinned
2
2
  // above the composer, its own card floating over the dock's (out of the
3
3
  // dock's flow: its height never moves the composer). It shows only
4
4
  // what happens now in fixed slots (a status row, the prompt, a two-line
5
5
  // headline, the running tool, a one-line trail of the last calls), the
6
6
  // cards and rows that arrive meanwhile in their own capped scroller, the
7
7
  // answer, and a chip with one tick per tool call, the stage's footer (no
8
- // content above it can push it away): the "cloud", the turn view's block
9
- // (turn_view.js) reused as is, opens above it as the scroller's last slot,
8
+ // content above it can push it away): the "cloud", the turn block
9
+ // (turn_block.js) reused as is, opens above it as the scroller's last slot,
10
10
  // newest step first while the turn runs. When the turn has ended and the stage is not
11
- // in use, everything moves into the history in the turn view's order (the
12
- // hand-off), so a handed-off turn reads like the turn view's and like its
13
- // reload. Slots are patched in place, never rebuilt from HTML: a selection
11
+ // in use, everything moves into the history in the turn block's order (the
12
+ // hand-off), so a handed-off turn reads like its reload. Slots are patched in place, never rebuilt from HTML: a selection
14
13
  // and annotate survive. The rules are pure in stage_model.js; the live
15
14
  // slots are drawn by stage_slots.js, the cap and the held height are
16
15
  // stage_frame.js's, and the in-use watch that times the hand-off is
17
16
  // stage_watch.js's.
18
- import { createTurnView } from "./turn_view.js";
17
+ import { createTurnBlock } from "./turn_block.js";
19
18
  import { chipLabelText, flashOf, handOffDue, handOffOrder, headlineOf, isPlain, liveSlots, MARKS, miniToolText, placeFor, placeKind, QUEUED_CHILDREN, stepSlotText } from "./stage_model.js";
20
19
  import { createStageSlots, node } from "./stage_slots.js";
21
20
  import { createStageFrame, reducedMotion } from "./stage_frame.js";
@@ -60,7 +59,7 @@ function readCollapsed() {
60
59
  // page's working set (working_set_view.js, web.working_set; null: off): its
61
60
  // rail in the live slots, its footprint at the hand-off.
62
61
  export function createStageView({
63
- historyEl, appendToHistory, isNearBottom: historyNearBottom, sawText, sessionId, dockEl, centerEl, taskStop = null,
62
+ historyEl, isNearBottom: historyNearBottom, sawText, sessionId, dockEl, centerEl, taskStop = null,
64
63
  renderMarkdown, linkRefs, workingSet = null,
65
64
  }) {
66
65
  // The dock becomes a clear column: the stage's card, then the composer's
@@ -182,9 +181,8 @@ export function createStageView({
182
181
  let arrived = null;
183
182
  let cardInSight = true;
184
183
 
185
- const view = createTurnView({
184
+ const view = createTurnBlock({
186
185
  historyEl,
187
- appendToHistory,
188
186
  sawText,
189
187
  sessionId,
190
188
  // The newest step is on top of the cloud: "near the bottom" is near its top.
@@ -199,8 +197,6 @@ export function createStageView({
199
197
  answer.append(...pieces);
200
198
  showAnswerTop();
201
199
  },
202
- useHold: false,
203
- newestFirst: true,
204
200
  taskStop,
205
201
  onChange: () => scheduleRefresh(),
206
202
  onThinkingLine: (text) => {
@@ -305,7 +301,7 @@ export function createStageView({
305
301
  const host = plain ? answer : cloudBody;
306
302
  if (blockEl.parentNode !== host) host.appendChild(blockEl);
307
303
  }
308
- // The turn view closes its block at the end; the cloud shows its steps.
304
+ // The turn block closes at the end; the cloud shows its steps.
309
305
  if (blockEl && blockEl.parentNode === cloudBody) blockEl.open = true;
310
306
  const narration = gen?.text ? headlineOf(gen.text, gen.textDone || gen.closed) : "";
311
307
  const thinking = thinkingLine && thinkingLine.step === turn.gens.length ? thinkingLine.text : "";
@@ -626,7 +622,7 @@ export function createStageView({
626
622
  }
627
623
 
628
624
  // The hand-off: everything the stage holds goes into the history's end in
629
- // the turn view's order (prompt, block (chronological; its outline open,
625
+ // the turn block's order (prompt, block (chronological; its outline open,
630
626
  // else collapsed), extras, answer, timing line, then the end line and
631
627
  // what came after the end). The turn is released at once; the stage
632
628
  // slides away under the composer still drawn (a transform: nothing on the
@@ -668,7 +664,7 @@ export function createStageView({
668
664
  const fold = folding;
669
665
  if (!fold) return;
670
666
  folding = null;
671
- // Closed, unless it is an outline (turn_view.js outlineBlock): the
667
+ // Closed, unless it is an outline (turn_block.js outlineBlock): the
672
668
  // history reads its narrated steps without a click.
673
669
  if (fold.block) fold.block.open = fold.block.classList.contains("outline");
674
670
  historyEl.querySelector(":scope > .hint")?.remove();
@@ -764,7 +760,6 @@ export function createStageView({
764
760
  answerReady() {
765
761
  settled = true;
766
762
  clearTimeout(settleTimer);
767
- view.answerReady();
768
763
  },
769
764
  containsLive: (el) => view.containsLive(el),
770
765
  askingText: () => view.askingText(),
@@ -11,57 +11,21 @@
11
11
  // instead (settingsLabel()).
12
12
 
13
13
  import { createChipPopover, SHEET_TALL } from "./chip_popover.js";
14
- import { APPLIES, choiceWords, mergeChoice, ownValues, strategyValue } from "./llm_context_chip.js";
14
+ import { mergeChoice, ownValues } from "./llm_context_chip.js";
15
15
  import { readSubjects, reviewChipModel, reviewFormTemplate } from "./review_start.js";
16
- import { LEVELS } from "./thinking_chip.js";
17
- import { html, nothing, render } from "./vendor/lit-html.js";
18
-
19
- export const DEFAULT = "default";
16
+ import {
17
+ contextSegments, contextWords, DEFAULT, settingsPanelTemplate, thinkingSegments,
18
+ } from "./settings_panel.js";
19
+ import { render } from "./vendor/lit-html.js";
20
+
21
+ // The panel's rows (settings_panel.js), named here too: the start page and
22
+ // its specs read them from this module.
23
+ export { contextSegments, contextWords, DEFAULT, thinkingSegments };
20
24
  export const NOTE = "For this new session only.";
21
25
  export const PANEL_LABEL = "Session settings";
22
26
  const NO_THINKING = "the picked model has no thinking setting; it is sent anyway";
23
27
  const NO_LLM_CONTEXT = "the picked model has no LLM context values; it is sent anyway";
24
28
 
25
- const STRATEGIES = [
26
- { value: "none", label: "none" },
27
- { value: "stale", label: "stale" },
28
- { value: "stale,forget", label: "stale + forget", title: "stale + forget (experimental)" },
29
- ];
30
-
31
- // The first segment: follow the model, its value in brackets ("default
32
- // (high)"; a model with no level of its own, "default" alone).
33
- const followSegment = (value) => ({ value: DEFAULT, label: value === DEFAULT ? DEFAULT : `${DEFAULT} (${value})`,
34
- title: `Follow the model (${value})` });
35
-
36
- // The Thinking row's segments: the model's own level first ("default
37
- // (high)"), then the levels; the pressed one is the choice. null without
38
- // the model's thinking (the row is hidden).
39
- export function thinkingSegments(summary, choice) {
40
- if (!summary?.level) return null;
41
- const pressed = choice || DEFAULT;
42
- return [followSegment(summary.level),
43
- ...LEVELS.map((level) => ({ value: level, label: level }))].map((s) => ({ ...s, pressed: s.value === pressed }));
44
- }
45
-
46
- // The Context row's segments over the strategies (a choice that isn't one
47
- // of them, a newer chi's layer, is added); null without the model's
48
- // llm_context (the row is hidden).
49
- export function contextSegments(summary, choice) {
50
- if (!summary?.strategy) return null;
51
- const pressed = Array.isArray(choice?.strategy) ? strategyValue(choice.strategy) : DEFAULT;
52
- const list = STRATEGIES.some((s) => s.value === pressed) || pressed === DEFAULT ? STRATEGIES
53
- : [...STRATEGIES, { value: pressed, label: pressed }];
54
- return [followSegment(summary.strategy), ...list].map((s) => ({ ...s, pressed: s.value === pressed }));
55
- }
56
-
57
- // The context choice in a few words ("stale · budget 64000"), or null.
58
- export function contextWords(choice) {
59
- const words = choiceWords(choice);
60
- if (!words) return null;
61
- return [words.strategy, words.apply && `apply ${words.apply}`, words.budget && `budget ${words.budget}`]
62
- .filter(Boolean).join(" · ");
63
- }
64
-
65
29
  // The row's override chips: what is set for the new session, each
66
30
  // {text, title} or null. A choice the picked model shows no row for (its
67
31
  // summary is null) stays, its title saying it is sent anyway.
@@ -92,53 +56,26 @@ export function settingsLabel(over) {
92
56
  return { set: set.length > 0, label: set.length ? `${PANEL_LABEL}: ${set.map((o) => o.text).join(", ")}` : PANEL_LABEL };
93
57
  }
94
58
 
95
- const segmentsTemplate = (name, label, segments, act) => html`<div class="sset-row"><span class="sset-label"
96
- id=${`sset-${name}-label`}>${label}</span><div class="sset-seg" role="group" aria-labelledby=${`sset-${name}-label`}>${
97
- segments.map((s) => html`<button type="button" data-act=${`${act}:${s.value}`} aria-pressed=${s.pressed ? "true" : "false"}
98
- title=${s.title || nothing}>${s.label}</button>`)}</div></div>`;
99
-
100
- const option = (value, label, chosen) => html`<option value=${value} ?selected=${value === chosen}>${label}</option>`;
101
-
102
- // The disclosure's fields: the apply rule and the budget, with their own
103
- // Set (a typed budget needs a commit) and mergeChoice's error under them.
104
- function contextMoreTemplate(summary, choice, error) {
105
- const own = ownValues({ own: choice });
106
- const budget = summary.budget_tokens ? String(summary.budget_tokens) : "off";
107
- return html`<form class="sset-ctx-form"><label class="llmctx-field"><span>Apply</span><span class="select-wrap"><select
108
- name="apply">${option(DEFAULT, `${DEFAULT} (${summary.apply})`, own.apply)}${APPLIES.map((a) => option(a, a, own.apply))}</select
109
- ></span></label><label class="llmctx-field"><span>Budget</span><input name="budget" type="text" inputmode="numeric"
110
- autocomplete="off" placeholder=${`${DEFAULT} (${budget}): 64000, 64k or off`} .value=${own.budget}></label
111
- ><div class="ctx-pop-error" role="alert" ?hidden=${!error}>${error || ""}</div
112
- ><div class="ctx-pop-actions"><button type="submit" class="llmctx-set">Set</button></div></form>`;
113
- }
114
-
115
- // A choice whose row is hidden (the picked model has no values for it).
116
- const sentAnywayTemplate = (text, act) => html`<div class="sset-anyway ctx-pop-meta">${text}
117
- <button type="button" class="sset-link" data-act=${act}>Clear</button></div>`;
118
-
119
- // The main view. +state+: {thinking: {summary, choice}, llmContext: {summary,
120
- // choice}, review: subjects or null (no review row), more: the disclosure
121
- // open, error: the context Set's}.
59
+ // The main view (settings_panel.js settingsPanelTemplate over the new
60
+ // session's choices). +state+: {thinking: {summary, choice}, llmContext:
61
+ // {summary, choice}, review: subjects or null (no review row), more: the
62
+ // disclosure open, error: the context Set's}.
122
63
  export function panelTemplate({ thinking, llmContext, review = null, more = false, error = null }) {
123
64
  const think = thinkingSegments(thinking.summary, thinking.choice);
124
65
  const ctx = contextSegments(llmContext.summary, llmContext.choice);
125
66
  const ctxAnyway = !ctx && contextWords(llmContext.choice);
126
- const reviewChip = review?.length ? reviewChipModel(review) : null;
127
- return html`<div class="ctx-pop-head"><strong>New session</strong
128
- ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
129
- >${think ? segmentsTemplate("think", "Thinking", think, "think") : nothing
130
- }${!think && thinking.choice ? sentAnywayTemplate(`Thinking ${thinking.choice}: ${NO_THINKING}.`, "think-clear") : nothing
131
- }${ctx ? html`${segmentsTemplate("ctx", "Context", ctx, "ctx")}<div class="sset-ctx-more"><button type="button" class="sset-link"
132
- data-act="ctx-more" aria-expanded=${more ? "true" : "false"}>Apply and budget ${more ? "▾" : "▸"}</button></div
133
- >${more ? contextMoreTemplate(llmContext.summary, llmContext.choice, error) : nothing}` : nothing
134
- }${ctxAnyway ? sentAnywayTemplate(`Context ${ctxAnyway}: ${NO_LLM_CONTEXT}.`, "ctx-clear") : nothing
135
- }${review ? html`${think || ctx ? html`<hr class="sset-rule">` : nothing}<div class="sset-review">${reviewChip
136
- ? html`<span class="sset-review-set" title=${reviewChip.title}>Review: ${reviewChip.text.replace(/^review: /, "")}</span
137
- ><button type="button" class="sset-link" data-act="review">Edit</button><button type="button" class="sset-link"
138
- data-act="review-clear">Remove</button>`
139
- : html`<button type="button" class="sset-link" data-act="review" title=${reviewChipModel([]).title}>Review files…</button>`}</div>`
140
- : nothing
141
- }<div class="ctx-pop-meta">${NOTE}</div>`;
67
+ return settingsPanelTemplate({
68
+ heading: "New session",
69
+ rows: [
70
+ think ? { name: "think", label: "Thinking", segments: think, act: "think" } : null,
71
+ !think && thinking.choice ? { anyway: `Thinking ${thinking.choice}: ${NO_THINKING}.`, act: "think-clear" } : null,
72
+ ctx ? { name: "ctx", label: "Context", segments: ctx, act: "ctx",
73
+ more: { open: more, summary: llmContext.summary, choice: llmContext.choice, error } } : null,
74
+ ctxAnyway ? { anyway: `Context ${ctxAnyway}: ${NO_LLM_CONTEXT}.`, act: "ctx-clear" } : null,
75
+ ],
76
+ review,
77
+ note: NOTE,
78
+ });
142
79
  }
143
80
 
144
81
  // The panel on chip_popover.js' shell, a bottom sheet on a phone. +el+: