samagotchi 0.57.0 → 0.59.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/docs/architecture.md +3 -3
  4. data/docs/cli.md +5 -4
  5. data/docs/configuration.md +40 -23
  6. data/docs/hooks.md +2 -2
  7. data/docs/internals/background-tasks.md +7 -0
  8. data/docs/internals/engine.md +5 -5
  9. data/docs/internals/prompt-caching.md +2 -2
  10. data/docs/internals/tool-guardrails.md +2 -1
  11. data/docs/plugins.md +26 -6
  12. data/docs/sessions.md +3 -3
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  14. data/lib/samagotchi/bridge.rb +6 -2
  15. data/lib/samagotchi/bundles/slow-commands/manifest.yml +5 -4
  16. data/lib/samagotchi/bundles/slow-commands/plugin.rb +169 -16
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  18. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  19. data/lib/samagotchi/config.rb +11 -5
  20. data/lib/samagotchi/engine.rb +28 -3
  21. data/lib/samagotchi/host_config.rb +3 -1
  22. data/lib/samagotchi/host_registry.rb +2 -1
  23. data/lib/samagotchi/model_profile.rb +14 -3
  24. data/lib/samagotchi/model_settings.rb +4 -2
  25. data/lib/samagotchi/process_group.rb +76 -3
  26. data/lib/samagotchi/redacted_inspect.rb +21 -6
  27. data/lib/samagotchi/send_command.rb +1 -1
  28. data/lib/samagotchi/session_commands.rb +19 -6
  29. data/lib/samagotchi/steer_cut.rb +12 -8
  30. data/lib/samagotchi/steer_cut_after.rb +69 -0
  31. data/lib/samagotchi/system_prompt.rb +5 -5
  32. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -4
  33. data/lib/samagotchi/terminal_ui/formatting.rb +9 -5
  34. data/lib/samagotchi/terminal_ui.rb +15 -6
  35. data/lib/samagotchi/tool_activity.rb +18 -1
  36. data/lib/samagotchi/tool_declarations.rb +1 -1
  37. data/lib/samagotchi/tools/execute.rb +318 -70
  38. data/lib/samagotchi/tools/task_get.rb +2 -0
  39. data/lib/samagotchi/tools/task_runtime.rb +96 -13
  40. data/lib/samagotchi/tools/task_wait.rb +23 -5
  41. data/lib/samagotchi/utf8_default.rb +1 -1
  42. data/lib/samagotchi/version.rb +1 -1
  43. data/lib/samagotchi/web/app.rb +8 -2
  44. data/lib/samagotchi/web/public/app.js +7 -6
  45. data/lib/samagotchi/web/public/index.html +20 -7
  46. data/lib/samagotchi/web/public/read_view.js +3 -1
  47. data/lib/samagotchi/web/public/ref_menu.js +188 -0
  48. data/lib/samagotchi/web/public/ref_scan.js +11 -1
  49. data/lib/samagotchi/web/public/refs.js +271 -51
  50. data/lib/samagotchi/web/public/stage_view.js +21 -0
  51. data/lib/samagotchi/web/public/task_stop.js +25 -7
  52. data/lib/samagotchi/worker.rb +2 -2
  53. metadata +3 -1
@@ -2,16 +2,24 @@
2
2
  // a kind and a target: today a file tool's row title (the server's FileRef,
3
3
  // {kind: "file", path, line?, end_line?}), that row's diff lines and a
4
4
  // read row's numbered lines (the same file at a line), and an edit/write
5
- // approval card's paths and its dry run's diff lines. The DOM carries data-ref-kind and the target's
6
- // attributes, never what a click does: that is an action, kept per kind in
7
- // REF_ACTIONS, and a click runs the first one available to this viewer. A
8
- // ref with none (a phone: no editor) stays plain text; the page marks the
9
- // kinds that have one on its body (refs-<kind>), which the hover styling
10
- // keys on.
5
+ // approval card's paths and its dry run's diff lines, and a `path:line` in
6
+ // an answer or a step's text (ref_scan.js; a link whose text is one too).
7
+ // The DOM carries data-ref-kind and the target's attributes, never what a
8
+ // click does: those are the ref's targets, kept per kind in REF_ACTIONS.
9
+ // A plain click runs the first one available (the main target); a ref
10
+ // with more shows a ▾ on hover that opens a menu of all of them
11
+ // (ref_menu.js), and a tap opens it. A ref with a target is a tab stop
12
+ // (syncRefTabs): Enter is its click, ↓ its menu. A ref with none (a phone's row title:
13
+ // no editor, and the row is the ref's own) stays plain text; refs.js marks
14
+ // a hovered or focused ref with its main target (data-ref-main), which the
15
+ // hover styling keys on.
11
16
 
12
17
  import { escapeHtml } from "./format.js";
18
+ import { copyText } from "./copy.js";
19
+ import { createRefMenu } from "./ref_menu.js";
13
20
 
14
- // The data attributes a ref's element carries.
21
+ // The data attributes a ref's element carries (its tab stop comes from
22
+ // syncRefTabs, for the viewer).
15
23
  export function refAttrs(ref) {
16
24
  if (!ref?.kind) return "";
17
25
  const attr = (name, value) => (value ? ` data-ref-${name}="${escapeHtml(String(value))}"` : "");
@@ -27,29 +35,59 @@ export function editorUrl(template, path, line) {
27
35
  return template.replaceAll("{path}", encoded).replaceAll("{line}", String(line || 1));
28
36
  }
29
37
 
30
- // What a ref can do, per kind, in order: the first available one is a
31
- // click's. +viewer+ is what this page's viewer can do ({editor, editorUrl}:
32
- // the server's capabilities.editor and editor_url).
38
+ // A ref's targets, per kind, in order: the first available one is a plain
39
+ // click's (the main target), all of them are the menu's. Each says when
40
+ // it's available for +viewer+ (what this page's viewer can do: {editor,
41
+ // editorUrl}, the server's capabilities.editor and editor_url) and
42
+ // +target+ (refTarget's). A secondary one (copy) joins a ref that has
43
+ // another, never alone. A line (a diff or read line) has its click only.
33
44
  export const REF_ACTIONS = Object.freeze({
34
45
  file: Object.freeze([
46
+ // A link whose text is the ref (github-pr's PR diff): its href stays
47
+ // the plain click's, which the browser follows.
48
+ Object.freeze({
49
+ id: "link",
50
+ label: (target) => (/\/pull\/\d+\/files\b/.test(target.href) ? "PR diff" : "Open link"),
51
+ available: (_viewer, target) => !!target.href,
52
+ run: (target, _viewer, env) => env.openLink(target.href),
53
+ }),
35
54
  Object.freeze({
36
55
  id: "editor",
56
+ label: () => "Open in editor",
37
57
  available: (viewer) => viewer?.editor === true && !!viewer.editorUrl,
38
58
  url: (target, viewer) => editorUrl(viewer.editorUrl, target.path, target.line),
59
+ run(target, viewer, env) {
60
+ const url = this.url(target, viewer);
61
+ if (url) env.open(url);
62
+ },
39
63
  // The hover hint: it opens the file as it is now.
40
64
  hint: (target) => `Open the current file in your editor${target.line ? ` at line ${target.line}` : ""}`,
41
65
  }),
66
+ // The row that touched the file, in the turn: not for a row's own
67
+ // title or lines (they are in the turn already).
68
+ Object.freeze({
69
+ id: "show",
70
+ label: () => "Show in turn",
71
+ available: (_viewer, target) => !target.isLine && !target.owner?.matches?.(".activity-row") && !!turnRowFor(target),
72
+ run: (target, _viewer, env) => env.show(target),
73
+ hint: () => "Show the row that touched it in the turn",
74
+ }),
75
+ Object.freeze({
76
+ id: "copy",
77
+ label: () => "Copy path",
78
+ secondary: true,
79
+ available: (_viewer, target) => !target.isLine && !!target.path,
80
+ run: (target, _viewer, env) => env.copy(target.path),
81
+ }),
42
82
  ]),
43
83
  });
44
84
 
45
- // The first action +viewer+ has for a ref of +kind+, else null.
46
- export function refAction(kind, viewer, actions = REF_ACTIONS) {
47
- return (actions[kind] || []).find((action) => action.available(viewer)) || null;
48
- }
49
-
50
- // The kinds that have an action for +viewer+ (the page's refs-<kind> classes).
51
- export function availableKinds(viewer, actions = REF_ACTIONS) {
52
- return Object.keys(actions).filter((kind) => refAction(kind, viewer, actions));
85
+ // The targets +viewer+ has for +target+ (refTarget's), in order; none
86
+ // when only a secondary one is.
87
+ export function refActions(target, viewer, actions = REF_ACTIONS) {
88
+ if (!target) return [];
89
+ const found = (actions[target.kind] || []).filter((action) => action.available(viewer, target));
90
+ return found.some((action) => !action.secondary) ? found : [];
53
91
  }
54
92
 
55
93
  // What owns a ref and its diff lines: a tool row, or an edit's approval
@@ -59,23 +97,25 @@ const OWNERS = ".activity-row, .bubble.approval";
59
97
  // A line of the owner's file: a diff line, or a read row's numbered line.
60
98
  const LINES = ".diff-line[data-line], .read-line[data-line]";
61
99
 
62
- // What a click on +el+ points at: {kind, path, line, owner} or null. A row
63
- // title (or an approval card's path) opens its file at the lines the call
64
- // named (an edit without any at its first changed line); a diff line (or a
65
- // hunk header) or a read row's numbered line, in a row or a card with a
66
- // ref, opens that file at that line.
100
+ // What a click on +el+ points at: {kind, path, line, owner, el, isLine,
101
+ // href} or null. A row title (or an approval card's path) opens its file
102
+ // at the lines the call named (an edit without any at its first changed
103
+ // line); a diff line (or a hunk header) or a read row's numbered line, in
104
+ // a row or a card with a ref, opens that file at that line. +el+ is the
105
+ // ref's element (the line for a line), +href+ a link ref's.
67
106
  // A diff line elsewhere is none.
68
107
  export function refTarget(el) {
69
108
  const title = el?.closest?.("[data-ref-kind]");
70
109
  if (title) {
71
110
  const owner = title.closest(OWNERS);
72
- return { ...targetOf(title), line: Number(title.dataset.refLine) || firstChange(owner), owner: owner || title };
111
+ const href = title.matches("a[href]") ? title.getAttribute("href") : null;
112
+ return { ...targetOf(title), line: Number(title.dataset.refLine) || firstChange(owner), owner: owner || title, el: title, isLine: false, href };
73
113
  }
74
114
  const diffLine = el?.closest?.(LINES);
75
115
  const owner = diffLine?.closest(OWNERS);
76
116
  const ref = owner?.querySelector("[data-ref-kind]");
77
117
  if (!ref) return null;
78
- return { ...targetOf(ref), line: Number(diffLine.dataset.line), owner };
118
+ return { ...targetOf(ref), line: Number(diffLine.dataset.line), owner, el: diffLine, isLine: true, href: null };
79
119
  }
80
120
 
81
121
  function targetOf(el) {
@@ -89,6 +129,99 @@ function firstChange(row) {
89
129
  return line ? Number(line.dataset.line) : null;
90
130
  }
91
131
 
132
+ // The tool row that touched +target+'s file, for "show in turn", or null:
133
+ // a row with a ref to the same file that isn't the ref's own. Looked for
134
+ // in the ref's own step (a step's text comes before its rows), then its
135
+ // own turn (turnBlockOf), then the rows before it, then the nearest after
136
+ // it; in the first of those that has any, the last one showing the ref's
137
+ // line, else the last one (after it: the nearest).
138
+ export function turnRowFor(target) {
139
+ const el = target?.el;
140
+ if (!el?.ownerDocument || !target.path) return null;
141
+ const rows = [...el.ownerDocument.querySelectorAll(".activity-row")].filter((row) => {
142
+ if (row.contains(el)) return false;
143
+ const ref = row.querySelector("[data-ref-kind]");
144
+ return ref?.dataset.refKind === target.kind && ref.dataset.refPath === target.path;
145
+ });
146
+ const step = el.closest("details.gen");
147
+ const turn = turnBlockOf(el);
148
+ const before = rows.filter((row) => precedes(row, el));
149
+ const pools = [
150
+ step ? rows.filter((row) => step.contains(row)) : [],
151
+ turn ? rows.filter((row) => turn.contains(row)) : [],
152
+ before,
153
+ rows.filter((row) => !before.includes(row)).reverse(),
154
+ ];
155
+ const pool = pools.find((found) => found.length);
156
+ if (!pool) return null;
157
+ const showing = target.line ? pool.filter((row) => lineIn(row, target.line)) : [];
158
+ return showing.at(-1) || pool.at(-1);
159
+ }
160
+
161
+ // What holds +el+'s turn's rows: the steps block it is in, the stage (a
162
+ // live turn: its answer comes before its cloud), else the nearest steps
163
+ // block before it with no prompt between them (an answer, a card, in the
164
+ // history). null when there is none.
165
+ function turnBlockOf(el) {
166
+ const own = el.closest(".turn-work, .turn-stage");
167
+ if (own) return own;
168
+ const doc = el.ownerDocument;
169
+ const block = [...doc.querySelectorAll(".turn-work")].filter((b) => precedes(b, el)).at(-1);
170
+ if (!block) return null;
171
+ const prompt = [...doc.querySelectorAll(".bubble.user")].some((u) => precedes(block, u) && precedes(u, el));
172
+ return prompt ? null : block;
173
+ }
174
+
175
+ // +a+ comes before +b+ in the page (and doesn't hold it).
176
+ function precedes(a, b) {
177
+ return !a.contains(b) && !!(a.compareDocumentPosition(b) & 4 /* Node.DOCUMENT_POSITION_FOLLOWING */);
178
+ }
179
+
180
+ // +row+'s line +line+: an added or context diff line first (a removed one
181
+ // shares its number), then a read line; null when it doesn't show it.
182
+ function lineIn(row, line) {
183
+ for (const selector of [".diff-line.diff-add", ".diff-line.diff-ctx", ".read-line", ".diff-line"]) {
184
+ const found = row.querySelector(`${selector}[data-line="${Number(line)}"]`);
185
+ if (found) return found;
186
+ }
187
+ return null;
188
+ }
189
+
190
+ // "Show in turn": opens what hides the row that touched +target+'s file
191
+ // (the turn's steps, its step), its diff (or the read that shows the
192
+ // line), scrolls to the line (else the row) and flashes both. A view that
193
+ // hides its rows its own way (the stage's cloud) opens on the row's
194
+ // ref:reveal event, which bubbles. The row, or null when there is none.
195
+ export function showInTurn(target, { behavior = smoothScroll() } = {}) {
196
+ const row = turnRowFor(target);
197
+ if (!row) return null;
198
+ const view = row.ownerDocument.defaultView || globalThis;
199
+ row.dispatchEvent(new view.CustomEvent("ref:reveal", { bubbles: true }));
200
+ const line = target.line ? lineIn(row, target.line) : null;
201
+ const fold = line?.closest("details.activity-diff, details.activity-read") || row.querySelector("details.activity-diff");
202
+ for (let details = (line || fold || row).closest("details"); details; details = details.parentElement?.closest("details")) {
203
+ details.open = true;
204
+ }
205
+ if (fold) fold.open = true;
206
+ (line || row).scrollIntoView?.({ block: "center", behavior });
207
+ for (const el of [row, line].filter(Boolean)) flash(el);
208
+ return row;
209
+ }
210
+
211
+ const FLASH_MS = 1600;
212
+
213
+ function flash(el) {
214
+ el.classList.remove("ref-flash");
215
+ void el.offsetWidth; // restarts the animation on a second show
216
+ el.classList.add("ref-flash");
217
+ clearTimeout(el._refFlash);
218
+ el._refFlash = setTimeout(() => el.classList.remove("ref-flash"), FLASH_MS);
219
+ }
220
+
221
+ function smoothScroll() {
222
+ return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
223
+ }
224
+
92
225
  // A drag that selected text in the clicked ref's row isn't a click on the
93
226
  // ref. A selection elsewhere on the page (or a collapsed one) doesn't count.
94
227
  function selectingIn(owner, selection) {
@@ -102,42 +235,129 @@ function defaultOpen(url) {
102
235
  else window.location.href = url;
103
236
  }
104
237
 
105
- // One delegated listener on +root+ for every ref on the page. +viewer+()
106
- // is read per click and hover, so a capabilities change (another session,
107
- // a reload) is seen; +open+(url) opens an action's URL.
108
- export function installRefClicks(root, viewer, { open = defaultOpen, selection = () => window.getSelection?.() } = {}) {
238
+ // A link ref's href from the menu: a new tab, as the answer's links open.
239
+ function defaultOpenLink(href) {
240
+ window.open(href, "_blank", "noopener");
241
+ }
242
+
243
+ // One delegated listener set on +root+ for every ref on the page.
244
+ // +viewer+() is read per click, key and hover, so a capabilities change
245
+ // (another session, a reload) is seen. The targets run through: +open+(url)
246
+ // (the editor's URL), +openLink+(href), +show+(target) and +copy+(text).
247
+ // +menu+ is the ▾ and its menu (ref_menu.js).
248
+ export function installRefClicks(root, viewer, {
249
+ open = defaultOpen, openLink = defaultOpenLink, show = (target) => showInTurn(target), copy = copyText,
250
+ selection = () => window.getSelection?.(), menu = createRefMenu(root.ownerDocument),
251
+ } = {}) {
252
+ const env = { open, openLink, show, copy };
253
+ const run = (action, target) => action.run(target, viewer(), env);
254
+ const items = (actions, target) => actions.map((action) => ({ id: action.id, label: action.label(target), run: () => run(action, target) }));
255
+ const openMenu = (target, actions, options) => menu.open(target.el, items(actions, target), options);
256
+ // The last pointer's type: a tap opens the menu, a mouse click runs the
257
+ // main target.
258
+ let pointer = "mouse";
259
+ root.addEventListener("pointerdown", (e) => { pointer = e.pointerType || "mouse"; }, true);
260
+ // A key press: the keyboard leads now (Enter on a link after a tap is
261
+ // the link's own click); Tab works out the tab stops before it moves.
262
+ root.addEventListener("keydown", (e) => {
263
+ pointer = "keyboard";
264
+ if (e.key === "Tab") syncRefTabs(root, viewer());
265
+ }, true);
266
+
109
267
  root.addEventListener("click", (e) => {
110
268
  if (e.button !== 0 || e.defaultPrevented) return;
111
269
  const target = refTarget(e.target);
112
270
  if (!target) return;
113
- const action = refAction(target.kind, viewer());
114
- if (!action) return;
271
+ const actions = refActions(target, viewer());
272
+ if (!actions.length) return;
115
273
  if (selectingIn(target.owner, selection())) return;
116
- const url = action.url(target, viewer());
117
- if (!url) return;
274
+ if (pointer === "touch" && !target.isLine && actions.length > 1) {
275
+ e.preventDefault();
276
+ e.stopPropagation();
277
+ openMenu(target, actions, { focus: false });
278
+ return;
279
+ }
280
+ // A link's own click: the browser follows it.
281
+ if (actions[0].id === "link") return;
118
282
  e.preventDefault();
119
283
  e.stopPropagation();
120
- open(url);
284
+ run(actions[0], target);
121
285
  });
122
- // The hover hint, on the ref's own element, worked out on each hover
123
- // (the viewer can change): its own title (a row title's hover) first,
124
- // then the action's hint when there is one.
125
- root.addEventListener("mouseover", (e) => {
286
+
287
+ // A focused ref: Enter is its main target (a link's Enter is its own
288
+ // click), ↓ opens the menu with the first target focused, Esc hides the ▾.
289
+ root.addEventListener("keydown", (e) => {
290
+ if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
291
+ const el = e.target;
292
+ if (!el?.matches?.("[data-ref-kind]")) return;
293
+ if (e.key === "Escape") menu.hideMore();
294
+ if (e.key !== "Enter" && e.key !== "ArrowDown") return;
295
+ const target = refTarget(el);
296
+ const actions = refActions(target, viewer());
297
+ if (!actions.length) return;
298
+ if (e.key === "Enter") {
299
+ if (actions[0].id === "link") return;
300
+ e.preventDefault();
301
+ run(actions[0], target);
302
+ } else if (actions.length > 1) {
303
+ e.preventDefault();
304
+ openMenu(target, actions, { focus: true });
305
+ }
306
+ });
307
+
308
+ // On hover or focus: the ref's main target on it (data-ref-main, for the
309
+ // styling), its hover hint, and the ▾ when it has more than one (not
310
+ // after a tap, which opens the menu itself).
311
+ const mark = (e) => {
126
312
  const target = refTarget(e.target);
127
313
  if (!target) return;
128
- const el = e.target.closest("[data-ref-kind]") || e.target.closest(LINES);
129
- if (!el) return;
130
- if (el.dataset.refTitle === undefined) el.dataset.refTitle = el.getAttribute("title") || "";
131
- const own = el.dataset.refTitle;
132
- const action = refAction(target.kind, viewer());
133
- const title = [own, action ? action.hint(target) : ""].filter(Boolean).join("\n");
134
- if (title) el.setAttribute("title", title);
135
- else el.removeAttribute("title");
136
- });
314
+ const actions = refActions(target, viewer());
315
+ const el = target.el;
316
+ if (actions.length) el.dataset.refMain = actions[0].id;
317
+ else delete el.dataset.refMain;
318
+ if (!target.isLine) setRefRole(el, actions);
319
+ hint(el, target, actions[0]);
320
+ if (!target.isLine && actions.length > 1 && pointer !== "touch") menu.showMore(el, () => openMenu(refTarget(el), refActions(refTarget(el), viewer()), { focus: true }));
321
+ };
322
+ root.addEventListener("mouseover", mark);
323
+ root.addEventListener("focusin", mark);
324
+ const leave = (e) => {
325
+ const el = e.target?.closest?.("[data-ref-kind]");
326
+ if (el && !el.contains(e.relatedTarget)) menu.hideMoreSoon(el);
327
+ };
328
+ root.addEventListener("mouseout", leave);
329
+ root.addEventListener("focusout", leave);
330
+ }
331
+
332
+ // The tab stops under +root+ for +viewer+: a ref is one (a button, with
333
+ // aria-haspopup when it has a menu) only while it has a target, so a
334
+ // phone's or an editorless viewer's row titles aren't. Worked out before a
335
+ // Tab moves the focus and on a ref's hover or focus; a line never is one.
336
+ export function syncRefTabs(root, viewer) {
337
+ for (const el of root.querySelectorAll("[data-ref-kind]")) setRefRole(el, refActions(refTarget(el), viewer));
338
+ }
339
+
340
+ function setRefRole(el, actions) {
341
+ if (actions.length > 1) el.setAttribute("aria-haspopup", "menu");
342
+ else el.removeAttribute("aria-haspopup");
343
+ // A link is focusable and a link already.
344
+ if (el.matches("a[href]")) return;
345
+ if (actions.length) {
346
+ el.tabIndex = 0;
347
+ el.setAttribute("role", "button");
348
+ } else {
349
+ el.removeAttribute("tabindex");
350
+ el.removeAttribute("role");
351
+ }
137
352
  }
138
353
 
139
- // The body's classes for +viewer+: refs-<kind> for each kind with an action.
140
- export function setRefKinds(body, viewer, actions = REF_ACTIONS) {
141
- const kinds = availableKinds(viewer, actions);
142
- for (const kind of Object.keys(actions)) body.classList.toggle(`refs-${kind}`, kinds.includes(kind));
354
+ // The hover hint, on the ref's own element, worked out on each hover (the
355
+ // viewer can change): its own title (a row title's hover) first, then the
356
+ // main target's hint when it has one.
357
+ function hint(el, target, action) {
358
+ if (el.dataset.refTitle === undefined) el.dataset.refTitle = el.getAttribute("title") || "";
359
+ const own = el.dataset.refTitle;
360
+ const title = [own, action?.hint ? action.hint(target) : ""].filter(Boolean).join("\n");
361
+ if (title) el.setAttribute("title", title);
362
+ else el.removeAttribute("title");
143
363
  }
@@ -106,6 +106,8 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
106
106
  let flash = null; // { kind, text, until } a notice or nudge in the trail
107
107
  let collapsed = readCollapsed();
108
108
  let cloudOpen = false;
109
+ // A ref's "show in turn" opened a collapsed stage (not kept).
110
+ let revealed = false;
109
111
  let rafPending = false;
110
112
  let foldTimer = null;
111
113
  let watchTimer = null;
@@ -326,6 +328,7 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
326
328
 
327
329
  function setCollapsed(value) {
328
330
  collapsed = value;
331
+ revealed = false;
329
332
  try { localStorage.setItem(COLLAPSE_KEY, value ? "1" : "0"); } catch (_) {}
330
333
  applyCollapsed();
331
334
  }
@@ -370,6 +373,19 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
370
373
  else setCloud(!cloudOpen);
371
374
  });
372
375
  headline.addEventListener("click", peek);
376
+ // A ref's "show in turn" (refs.js) names a row in the cloud: open the
377
+ // stage and the cloud to it, as a hand toggle would.
378
+ // A collapsed stage opens for this turn only: the kept choice
379
+ // (chi_stage_collapsed) is left alone and comes back at the release.
380
+ stage.addEventListener("ref:reveal", (e) => {
381
+ if (!cloudBody.contains(e.target)) return;
382
+ if (collapsed) {
383
+ collapsed = false;
384
+ revealed = true;
385
+ applyCollapsed();
386
+ }
387
+ setCloud(true);
388
+ });
373
389
  headline.addEventListener("keydown", (e) => {
374
390
  if (e.key === "Enter" || e.key === " ") {
375
391
  e.preventDefault();
@@ -491,6 +507,11 @@ export function createStageView({ historyEl, appendToHistory, isNearBottom: hist
491
507
  settled = true;
492
508
  view.reset();
493
509
  emptySlots();
510
+ if (revealed) {
511
+ revealed = false;
512
+ collapsed = readCollapsed();
513
+ applyCollapsed();
514
+ }
494
515
  }
495
516
 
496
517
  // Everything the stage holds, into the history's end in the turn view's
@@ -22,10 +22,30 @@ export function stopTarget(row, features) {
22
22
  // A task_wait row's title without its wait ("npm test · up to 600s").
23
23
  const WAIT_SUFFIX = / · up to \d+s$/;
24
24
 
25
- // The command a task_create of this turn started +taskId+ with (its row's
26
- // view), else a task_wait row's title for it (the server titles one by its
27
- // task's command, also for a task from an earlier turn: ToolActivity.
28
- // task_title), or null.
25
+ // The first line of an execute that was still running at its limit and
26
+ // went on as a task (Tools::Execute::HANDED_OVER), then its task_id line.
27
+ // Anchored at the start, an optional "ran as: …" line and "[execute]" lead
28
+ // before it: a "task_id:" line anywhere else is the command's own output.
29
+ // The twin of TaskRuntime.handed_over_id.
30
+ const HANDED_OVER = /^(?:ran as: [^\n]*\n)?(?:\[execute\]\n)?Still running at the \d+s limit:[^\n]*\ntask_id: (\S+)/;
31
+
32
+ // The task id an execute row's output hands over, else null.
33
+ export function handedOverId(output) {
34
+ return HANDED_OVER.exec(output || "")?.[1] || null;
35
+ }
36
+
37
+ // The task id a row of this turn started: a task_create's, or an execute's
38
+ // hand-over (handedOverId); else null.
39
+ function startedId(row) {
40
+ if (row.tool === "execute") return handedOverId(row.output);
41
+ if (row.tool !== "task_create") return null;
42
+ return /(?:^|\n)task_id: (\S+)/.exec(row.output || "")?.[1] || null;
43
+ }
44
+
45
+ // The command a task_create or a handed-over execute of this turn started
46
+ // +taskId+ with (its row's view), else a task_wait row's title for it (the
47
+ // server titles one by its task's command, also for a task from an earlier
48
+ // turn: ToolActivity.task_title), or null.
29
49
  export function taskCommand(gens, taskId) {
30
50
  let titled = null;
31
51
  for (const gen of gens || []) {
@@ -33,9 +53,7 @@ export function taskCommand(gens, taskId) {
33
53
  if (row.tool === "task_wait" && row.view?.task_id === taskId && row.title) {
34
54
  titled ||= row.title.replace(WAIT_SUFFIX, "");
35
55
  }
36
- if (row.tool !== "task_create") continue;
37
- const id = /(?:^|\n)task_id: (\S+)/.exec(row.output || "")?.[1];
38
- if (id === taskId) return row.view?.command || null;
56
+ if (startedId(row) === taskId) return row.view?.command || null;
39
57
  }
40
58
  }
41
59
  return titled;
@@ -219,8 +219,8 @@ module Samagotchi
219
219
  @bridge.queue_command(line, client_id: client_id, after_file: after_file)
220
220
  })
221
221
  # /model's default is the config's, as in the REPL; the Engine started
222
- # on the session's model.
223
- @default_model = ModelProfile.required_model_name(nil)
222
+ # on the session's model. nil when there is no default.model.
223
+ @default_model = ModelProfile.default_model_name
224
224
  @commands = SessionCommands.new(engine: @engine, turn_flow: @turn_flow,
225
225
  default_model: @default_model, registry: @engine.command_registry,
226
226
  save: ->(session) { session.save(state_dir: @state_dir) })
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.57.0
4
+ version: 0.59.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -430,6 +430,7 @@ files:
430
430
  - lib/samagotchi/setting_explained.rb
431
431
  - lib/samagotchi/steer.rb
432
432
  - lib/samagotchi/steer_cut.rb
433
+ - lib/samagotchi/steer_cut_after.rb
433
434
  - lib/samagotchi/system_prompt.rb
434
435
  - lib/samagotchi/terminal_ui.rb
435
436
  - lib/samagotchi/terminal_ui/attach_launcher.rb
@@ -562,6 +563,7 @@ files:
562
563
  - lib/samagotchi/web/public/question_cards.js
563
564
  - lib/samagotchi/web/public/read_view.js
564
565
  - lib/samagotchi/web/public/reasoning_history_chip.js
566
+ - lib/samagotchi/web/public/ref_menu.js
565
567
  - lib/samagotchi/web/public/ref_scan.js
566
568
  - lib/samagotchi/web/public/refs.js
567
569
  - lib/samagotchi/web/public/restore.js