samagotchi 0.60.0 → 0.62.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +99 -1
- data/docs/architecture.md +10 -4
- data/docs/cli.md +22 -8
- data/docs/configuration.md +32 -19
- data/docs/context.md +8 -0
- data/docs/guardrails.md +2 -1
- data/docs/hooks.md +80 -1
- data/docs/internals/engine.md +57 -15
- data/docs/internals/prompt-caching.md +8 -1
- data/docs/plugins.md +49 -6
- data/docs/sessions.md +7 -3
- data/docs/testing.md +4 -1
- data/lib/samagotchi/answer_display.rb +4 -3
- data/lib/samagotchi/answer_refs.rb +210 -0
- data/lib/samagotchi/bridge/card_store.rb +6 -1
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +47 -9
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +3 -1
- data/lib/samagotchi/interrupted_turn.rb +39 -0
- data/lib/samagotchi/kernel_loop.rb +13 -20
- data/lib/samagotchi/llm/chat_loop.rb +28 -17
- data/lib/samagotchi/log_subscriber.rb +6 -3
- data/lib/samagotchi/plugin/api.rb +36 -18
- data/lib/samagotchi/reasoning_history.rb +22 -4
- data/lib/samagotchi/ref.rb +25 -8
- data/lib/samagotchi/reply_wait.rb +6 -2
- data/lib/samagotchi/row_refs.rb +126 -0
- data/lib/samagotchi/session.rb +40 -4
- data/lib/samagotchi/session_manager.rb +64 -6
- data/lib/samagotchi/session_metrics.rb +24 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +30 -3
- data/lib/samagotchi/tool_activity.rb +8 -4
- data/lib/samagotchi/tool_response.rb +5 -4
- data/lib/samagotchi/tool_row_fields.rb +4 -2
- data/lib/samagotchi/tool_runner.rb +16 -3
- data/lib/samagotchi/tools/builtins.rb +9 -2
- data/lib/samagotchi/tools/execute.rb +15 -3
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/registry.rb +5 -3
- data/lib/samagotchi/turn_checkpoint.rb +128 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +19 -1
- data/lib/samagotchi/web/message_parts.rb +17 -9
- data/lib/samagotchi/web/public/activity.js +4 -1
- data/lib/samagotchi/web/public/answer_refs.js +217 -0
- data/lib/samagotchi/web/public/app.js +74 -33
- data/lib/samagotchi/web/public/chains.js +18 -20
- data/lib/samagotchi/web/public/context_chips.js +92 -64
- data/lib/samagotchi/web/public/families_view.js +51 -39
- data/lib/samagotchi/web/public/index.html +66 -13
- data/lib/samagotchi/web/public/keep_focus.js +24 -0
- data/lib/samagotchi/web/public/lists_view.js +13 -13
- data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
- data/lib/samagotchi/web/public/question_card.js +11 -0
- data/lib/samagotchi/web/public/question_cards.js +60 -5
- data/lib/samagotchi/web/public/ref_menu.js +24 -3
- data/lib/samagotchi/web/public/ref_scan.js +5 -3
- data/lib/samagotchi/web/public/refs.js +140 -20
- data/lib/samagotchi/web/public/scroll.js +42 -0
- data/lib/samagotchi/web/public/stage_model.js +22 -3
- data/lib/samagotchi/web/public/stage_view.js +299 -70
- data/lib/samagotchi/web/public/stream_handlers.js +5 -3
- data/lib/samagotchi/web/public/timing.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +5 -4
- data/lib/samagotchi/web/public/turn_model.js +49 -0
- data/lib/samagotchi/web/public/turn_view.js +121 -17
- data/lib/samagotchi/worker.rb +169 -21
- data/lib/samagotchi/worker_inbound.rb +5 -3
- data/lib/samagotchi/worker_signals.rb +81 -0
- metadata +8 -1
|
@@ -10,9 +10,14 @@
|
|
|
10
10
|
const HIDE_MS = 300;
|
|
11
11
|
// Kept from the viewport's edges (a phone's width).
|
|
12
12
|
const EDGE = 8;
|
|
13
|
+
// The ▾ sits in room its ref makes after itself while it shows
|
|
14
|
+
// (data-ref-more, index.html), so it never covers what follows the ref:
|
|
15
|
+
// the next words of an answer, a row's duration. A ref that room would
|
|
16
|
+
// push onto the next line (it ends its line: nothing follows it there)
|
|
17
|
+
// makes none (data-ref-more="end"), and the ▾ goes just past its end.
|
|
13
18
|
|
|
14
19
|
// +doc+'s ▾ and menu. open(el, items, {focus}) shows +items+ ({id, label,
|
|
15
|
-
// run}) under +el+; showMore(el, onOpen) puts the ▾ after +el+, whose click
|
|
20
|
+
// hint?, run}; the hint is the item's hover: a URL action's URL) under +el+; showMore(el, onOpen) puts the ▾ after +el+, whose click
|
|
16
21
|
// calls +onOpen+; hideMoreSoon(el) takes it away unless the pointer reaches
|
|
17
22
|
// it; close({refocus}) closes the menu.
|
|
18
23
|
export function createRefMenu(doc = globalThis.document) {
|
|
@@ -50,18 +55,28 @@ export function createRefMenu(doc = globalThis.document) {
|
|
|
50
55
|
function showMore(el, onOpen) {
|
|
51
56
|
clearTimeout(hideTimer);
|
|
52
57
|
const button = moreButton();
|
|
58
|
+
if (moreFor && moreFor !== el) delete moreFor.dataset.refMore;
|
|
53
59
|
moreFor = el;
|
|
54
60
|
onMore = onOpen;
|
|
61
|
+
const before = el.dataset.refMore === undefined ? lastRect(el) : null;
|
|
62
|
+
if (el.dataset.refMore === undefined) el.dataset.refMore = "";
|
|
63
|
+
let rect = lastRect(el);
|
|
64
|
+
if (before && wrapped(before, rect)) {
|
|
65
|
+
el.dataset.refMore = "end";
|
|
66
|
+
rect = lastRect(el);
|
|
67
|
+
}
|
|
55
68
|
button.hidden = false;
|
|
56
|
-
const
|
|
69
|
+
const width = button.offsetWidth || 16;
|
|
57
70
|
const top = rect.top + (rect.height - (button.offsetHeight || rect.height)) / 2;
|
|
58
|
-
|
|
71
|
+
const left = el.dataset.refMore === "end" ? rect.right + 2 : rect.right - width - 1;
|
|
72
|
+
button.style.left = `${Math.min(left, win.innerWidth - width - 2)}px`;
|
|
59
73
|
button.style.top = `${top}px`;
|
|
60
74
|
}
|
|
61
75
|
|
|
62
76
|
function hideMore() {
|
|
63
77
|
clearTimeout(hideTimer);
|
|
64
78
|
if (more) more.hidden = true;
|
|
79
|
+
if (moreFor) delete moreFor.dataset.refMore;
|
|
65
80
|
moreFor = null;
|
|
66
81
|
}
|
|
67
82
|
|
|
@@ -88,6 +103,7 @@ export function createRefMenu(doc = globalThis.document) {
|
|
|
88
103
|
button.tabIndex = -1;
|
|
89
104
|
button.dataset.target = item.id;
|
|
90
105
|
button.textContent = item.label;
|
|
106
|
+
if (item.hint) button.title = item.hint;
|
|
91
107
|
button.addEventListener("click", (e) => {
|
|
92
108
|
e.preventDefault();
|
|
93
109
|
e.stopPropagation();
|
|
@@ -182,6 +198,11 @@ export function createRefMenu(doc = globalThis.document) {
|
|
|
182
198
|
|
|
183
199
|
// The ref's last line box (a ref wrapped over two lines ends on the
|
|
184
200
|
// second); its whole box when it has none.
|
|
201
|
+
// The room moved the ref's last box onto another line.
|
|
202
|
+
function wrapped(before, after) {
|
|
203
|
+
return Math.abs(after.top - before.top) > before.height / 2;
|
|
204
|
+
}
|
|
205
|
+
|
|
185
206
|
function lastRect(el) {
|
|
186
207
|
const rects = el.getClientRects?.() || [];
|
|
187
208
|
return rects.length ? rects[rects.length - 1] : el.getBoundingClientRect();
|
|
@@ -103,13 +103,15 @@ function resolved(ref, touched, cwd) {
|
|
|
103
103
|
return path ? { kind: "file", path, line: ref.line, end_line: ref.endLine } : null;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
// The absolute paths of the file refs
|
|
107
|
-
//
|
|
106
|
+
// The absolute paths of the file refs in the session's rows and approval
|
|
107
|
+
// cards under +roots+: what a text ref may name. Not an answer's own refs
|
|
108
|
+
// (a scanned one repeats a row's; a plugin's file ref names a file the
|
|
109
|
+
// session may not have touched).
|
|
108
110
|
export function touchedFiles(roots) {
|
|
109
111
|
const touched = new Set();
|
|
110
112
|
for (const root of roots) {
|
|
111
113
|
for (const el of root.querySelectorAll('[data-ref-kind="file"][data-ref-path]')) {
|
|
112
|
-
touched.add(el.dataset.refPath);
|
|
114
|
+
if (el.closest(".activity-row, .bubble.approval")) touched.add(el.dataset.refPath);
|
|
113
115
|
}
|
|
114
116
|
}
|
|
115
117
|
return touched;
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// bound to its element by bindRef: its label and its URL actions live in a
|
|
11
11
|
// WeakMap off the DOM, and refTarget reads them first. Its actions join the
|
|
12
12
|
// kind's own (the `actions` entry); a kind the page has no table for runs
|
|
13
|
-
// only those.
|
|
13
|
+
// only those. A plugin tool's row (its +refs+, bindRowRefs) puts its first
|
|
14
|
+
// ref on the title and the others in the title's menu (the `more` entry).
|
|
14
15
|
// A plain click runs the first one available (the main target); a ref
|
|
15
16
|
// with more shows a ▾ on hover that opens a menu of all of them
|
|
16
17
|
// (ref_menu.js), and a tap opens it. A ref with a target is a tab stop
|
|
@@ -34,18 +35,34 @@ export function refAttrs(ref) {
|
|
|
34
35
|
// A kind, as the server's Ref allows it (Ref::KIND).
|
|
35
36
|
const KIND = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
36
37
|
|
|
38
|
+
// +kind+ is one bindRef binds.
|
|
39
|
+
export function refKindOk(kind) {
|
|
40
|
+
return typeof kind === "string" && KIND.test(kind);
|
|
41
|
+
}
|
|
42
|
+
|
|
37
43
|
// A plugin's ref data per element (bindRef): {kind, target, label,
|
|
38
44
|
// actions}. A WeakMap, so a redrawn element takes its data with it.
|
|
39
45
|
const REF_DATA = new WeakMap();
|
|
40
46
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
47
|
+
// The longest URL the page opens: the server's RefAction::MAX_URL.
|
|
48
|
+
const MAX_URL = 2000;
|
|
49
|
+
|
|
50
|
+
// +url+ as the page opens it when it is an http(s) one with no userinfo
|
|
51
|
+
// and at most MAX_URL characters (trimmed), else null. The server's
|
|
52
|
+
// RefAction.http_url? checks it too; the page is at least as strict on
|
|
53
|
+
// userinfo, and the URL parser refuses an http(s) URL without a host. The
|
|
54
|
+
// page never opens another scheme a plugin gave (javascript:, data:, an
|
|
55
|
+
// editor's), nor a "https://github.com@evil.example/" (one host shown,
|
|
56
|
+
// another opened).
|
|
44
57
|
export function httpUrl(url) {
|
|
45
58
|
if (typeof url !== "string") return null;
|
|
59
|
+
const trimmed = url.trim();
|
|
60
|
+
if (trimmed.length > MAX_URL) return null;
|
|
46
61
|
try {
|
|
47
|
-
const parsed = new URL(
|
|
48
|
-
|
|
62
|
+
const parsed = new URL(trimmed);
|
|
63
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
64
|
+
if (parsed.username || parsed.password) return null;
|
|
65
|
+
return parsed.href;
|
|
49
66
|
} catch {
|
|
50
67
|
return null;
|
|
51
68
|
}
|
|
@@ -58,20 +75,67 @@ export function httpUrl(url) {
|
|
|
58
75
|
// nothing. A rebind replaces the target attributes. +el+, or null without one.
|
|
59
76
|
export function bindRef(el, ref) {
|
|
60
77
|
if (!el) return null;
|
|
61
|
-
|
|
62
|
-
|
|
78
|
+
const clean = cleanRef(ref);
|
|
79
|
+
if (!clean) return el;
|
|
80
|
+
const { target } = clean;
|
|
63
81
|
el.classList.add("ref");
|
|
64
|
-
el.dataset.refKind =
|
|
82
|
+
el.dataset.refKind = clean.kind;
|
|
65
83
|
for (const [name, value] of [["refPath", target.path], ["refLine", target.line], ["refEndLine", target.end_line]]) {
|
|
66
84
|
if (value) el.dataset[name] = String(value);
|
|
67
85
|
else delete el.dataset[name];
|
|
68
86
|
}
|
|
87
|
+
REF_DATA.set(el, { ...clean, more: [] });
|
|
88
|
+
return el;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// +ref+ (the server's Ref#to_h) as the page keeps it: {kind, target,
|
|
92
|
+
// label, actions}, its actions only those with an id, a label and an
|
|
93
|
+
// http(s) URL; null for a kind the server's Ref refuses.
|
|
94
|
+
function cleanRef(ref) {
|
|
95
|
+
if (!refKindOk(ref?.kind)) return null;
|
|
96
|
+
const target = ref.target && typeof ref.target === "object" ? ref.target : {};
|
|
69
97
|
const actions = (Array.isArray(ref.actions) ? ref.actions : []).flatMap((action) => {
|
|
70
98
|
const url = httpUrl(action?.url);
|
|
71
99
|
return url && action.id && action.label ? [{ id: String(action.id), label: String(action.label), url }] : [];
|
|
72
100
|
});
|
|
73
|
-
|
|
74
|
-
|
|
101
|
+
return { kind: ref.kind, target, label: ref.label ? String(ref.label) : null, actions };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// A tool row's refs: a plugin tool's (the events' and the reloaded part's
|
|
105
|
+
// +refs+, the server's RowRefs: Ref#to_h each) that bindRef binds, after
|
|
106
|
+
// the row's own (a file tool's +ref+, as a Ref) when it has one.
|
|
107
|
+
function rowRefsOf(row) {
|
|
108
|
+
const own = row?.ref?.kind ? [{ kind: row.ref.kind, target: { path: row.ref.path, line: row.ref.line, end_line: row.ref.end_line } }] : [];
|
|
109
|
+
return [...own, ...(Array.isArray(row?.refs) ? row.refs : [])].map(cleanRef).filter(Boolean);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The ref a tool row's title carries in its markup (turn_html.js, refAttrs'
|
|
113
|
+
// shape: {kind, path?, line?, end_line?}): the row's own, else its first
|
|
114
|
+
// plugin ref; null for none.
|
|
115
|
+
export function rowTitleRef(row) {
|
|
116
|
+
const [first] = rowRefsOf(row);
|
|
117
|
+
return first ? { kind: first.kind, path: first.target.path, line: first.target.line, end_line: first.target.end_line } : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Binds a tool row's refs to its title (the element turn_html.js drew from
|
|
121
|
+
// +row+, live or reloaded; again after each redraw): the first as bindRef
|
|
122
|
+
// does, the others the title's menu offers (D8). Only for a row with a
|
|
123
|
+
// plugin ref: a file tool's row needs no binding (its attributes are its
|
|
124
|
+
// ref). The title, or null.
|
|
125
|
+
export function bindRowRefs(rowEl, row) {
|
|
126
|
+
const title = rowEl?.querySelector?.(":scope > .activity-params[data-ref-kind]");
|
|
127
|
+
if (!title || !Array.isArray(row?.refs) || !row.refs.length) return null;
|
|
128
|
+
const [first, ...more] = rowRefsOf(row);
|
|
129
|
+
if (!first) return null;
|
|
130
|
+
bindRef(title, first);
|
|
131
|
+
REF_DATA.get(title).more = more;
|
|
132
|
+
return title;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The plugin ref bound to +el+ (bindRef's data), or null: a row title or
|
|
136
|
+
// a scanned ref (attributes only) has none.
|
|
137
|
+
export function boundRef(el) {
|
|
138
|
+
return (el && REF_DATA.get(el)) || null;
|
|
75
139
|
}
|
|
76
140
|
|
|
77
141
|
// The same URL either way (a link's href against an action's URL).
|
|
@@ -124,6 +188,51 @@ const URL_ACTIONS = Object.freeze({
|
|
|
124
188
|
})),
|
|
125
189
|
});
|
|
126
190
|
|
|
191
|
+
// A short name for a ref in a menu of several: its label, else its id,
|
|
192
|
+
// else its file's name (and line).
|
|
193
|
+
function refName(ref) {
|
|
194
|
+
if (ref.label) return ref.label;
|
|
195
|
+
if (ref.target.id != null) return String(ref.target.id);
|
|
196
|
+
const name = String(ref.target.path || "").split("/").pop();
|
|
197
|
+
return ref.target.line ? `${name}:${ref.target.line}` : name;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// A row's other refs (bindRowRefs: all but the first), after the title's
|
|
201
|
+
// own targets: each one's actions, named "<ref> · <action>", for every
|
|
202
|
+
// viewer, and a file's editor (where the editor opens). A URL the menu
|
|
203
|
+
// already has (the link's, an action's, an earlier one's) isn't repeated.
|
|
204
|
+
const MORE_REFS = Object.freeze({
|
|
205
|
+
id: "more",
|
|
206
|
+
expand(target) {
|
|
207
|
+
const seen = new Set([target.href, ...(target.actions || []).map((action) => action.url)].map(httpUrl).filter(Boolean));
|
|
208
|
+
return (target.more || []).flatMap((ref, at) => {
|
|
209
|
+
const name = refName(ref);
|
|
210
|
+
const items = [];
|
|
211
|
+
if (ref.kind === "file" && ref.target.path) {
|
|
212
|
+
items.push(Object.freeze({
|
|
213
|
+
id: `more:${at}:editor`,
|
|
214
|
+
label: () => `${name} · Open in editor`,
|
|
215
|
+
available: (viewer, target) => !target.isLine && viewer?.editor === true && !!viewer.editorUrl,
|
|
216
|
+
run: (_target, viewer, env) => env.open(editorUrl(viewer.editorUrl, ref.target.path, ref.target.line)),
|
|
217
|
+
hint: () => `Open the current file in your editor${ref.target.line ? ` at line ${ref.target.line}` : ""}`,
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
for (const action of ref.actions) {
|
|
221
|
+
if (seen.has(action.url)) continue;
|
|
222
|
+
seen.add(action.url);
|
|
223
|
+
items.push(Object.freeze({
|
|
224
|
+
id: `more:${at}:${action.id}`,
|
|
225
|
+
label: () => `${name} · ${action.label}`,
|
|
226
|
+
available: (_viewer, target) => !target.isLine,
|
|
227
|
+
run: (_target, _viewer, env) => env.openLink(action.url),
|
|
228
|
+
hint: () => action.url,
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
return items;
|
|
232
|
+
});
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
|
|
127
236
|
export const REF_ACTIONS = Object.freeze({
|
|
128
237
|
file: Object.freeze([
|
|
129
238
|
LINK,
|
|
@@ -149,6 +258,7 @@ export const REF_ACTIONS = Object.freeze({
|
|
|
149
258
|
run: (target, _viewer, env) => env.show(target),
|
|
150
259
|
hint: () => "Show the row that touched it in the turn",
|
|
151
260
|
}),
|
|
261
|
+
MORE_REFS,
|
|
152
262
|
Object.freeze({
|
|
153
263
|
id: "copy",
|
|
154
264
|
label: () => "Copy path",
|
|
@@ -157,8 +267,9 @@ export const REF_ACTIONS = Object.freeze({
|
|
|
157
267
|
run: (target, _viewer, env) => env.copy(target.path),
|
|
158
268
|
}),
|
|
159
269
|
]),
|
|
160
|
-
// Any other kind (a plugin's issue, commit, …): its link, then its
|
|
161
|
-
|
|
270
|
+
// Any other kind (a plugin's issue, commit, …): its link, then its
|
|
271
|
+
// actions, then a row's other refs.
|
|
272
|
+
"*": Object.freeze([LINK, URL_ACTIONS, MORE_REFS]),
|
|
162
273
|
});
|
|
163
274
|
|
|
164
275
|
// The targets +viewer+ has for +target+ (refTarget's), in order; none
|
|
@@ -179,8 +290,9 @@ const OWNERS = ".activity-row, .bubble.approval";
|
|
|
179
290
|
// A line of the owner's file: a diff line, or a read row's numbered line.
|
|
180
291
|
const LINES = ".diff-line[data-line], .read-line[data-line]";
|
|
181
292
|
|
|
182
|
-
// What a click on +el+ points at: {kind, path, id, label, actions,
|
|
183
|
-
// owner, el, isLine, href} or null (+id+, +label
|
|
293
|
+
// What a click on +el+ points at: {kind, path, id, label, actions, more,
|
|
294
|
+
// line, owner, el, isLine, href} or null (+id+, +label+, +actions+ and
|
|
295
|
+
// +more+ (a row's other refs, bindRowRefs) a plugin's
|
|
184
296
|
// ref's, bindRef: the WeakMap is read first). A row title (or an approval card's path) opens its file
|
|
185
297
|
// at the lines the call named (an edit without any at its first changed
|
|
186
298
|
// line); a diff line (or a hunk header) or a read row's numbered line, in
|
|
@@ -204,11 +316,12 @@ export function refTarget(el) {
|
|
|
204
316
|
|
|
205
317
|
function targetOf(el) {
|
|
206
318
|
const data = REF_DATA.get(el);
|
|
207
|
-
if (!data) return { kind: el.dataset.refKind, path: el.dataset.refPath || "", line: Number(el.dataset.refLine) || null, id: null, label: null, actions: [] };
|
|
319
|
+
if (!data) return { kind: el.dataset.refKind, path: el.dataset.refPath || "", line: Number(el.dataset.refLine) || null, id: null, label: null, actions: [], more: [] };
|
|
208
320
|
// Bound data is the whole ref: no fallback to the attributes.
|
|
209
321
|
const { target } = data;
|
|
210
322
|
return {
|
|
211
323
|
kind: data.kind, path: target.path || "", line: Number(target.line) || null, id: target.id ?? null, label: data.label, actions: data.actions,
|
|
324
|
+
more: data.more,
|
|
212
325
|
};
|
|
213
326
|
}
|
|
214
327
|
|
|
@@ -281,19 +394,24 @@ function lineIn(row, line) {
|
|
|
281
394
|
// (the turn's steps, its step), its diff (or the read that shows the
|
|
282
395
|
// line), scrolls to the line (else the row) and flashes both. A view that
|
|
283
396
|
// hides its rows its own way (the stage's cloud) opens on the row's
|
|
284
|
-
// ref:reveal event, which bubbles
|
|
397
|
+
// ref:reveal event, which bubbles; a view that grows for it first sets the
|
|
398
|
+
// event's detail.until (a promise), and the scroll waits for it. The row,
|
|
399
|
+
// or null when there is none.
|
|
285
400
|
export function showInTurn(target, { behavior = smoothScroll() } = {}) {
|
|
286
401
|
const row = turnRowFor(target);
|
|
287
402
|
if (!row) return null;
|
|
288
403
|
const view = row.ownerDocument.defaultView || globalThis;
|
|
289
|
-
|
|
404
|
+
const reveal = new view.CustomEvent("ref:reveal", { bubbles: true, detail: {} });
|
|
405
|
+
row.dispatchEvent(reveal);
|
|
290
406
|
const line = target.line ? lineIn(row, target.line) : null;
|
|
291
407
|
const fold = line?.closest("details.activity-diff, details.activity-read") || row.querySelector("details.activity-diff");
|
|
292
408
|
for (let details = (line || fold || row).closest("details"); details; details = details.parentElement?.closest("details")) {
|
|
293
409
|
details.open = true;
|
|
294
410
|
}
|
|
295
411
|
if (fold) fold.open = true;
|
|
296
|
-
(line || row).scrollIntoView?.({ block: "center", behavior });
|
|
412
|
+
const scroll = () => (line || row).scrollIntoView?.({ block: "center", behavior });
|
|
413
|
+
if (reveal.detail?.until) reveal.detail.until.then(scroll);
|
|
414
|
+
else scroll();
|
|
297
415
|
for (const el of [row, line].filter(Boolean)) flash(el);
|
|
298
416
|
return row;
|
|
299
417
|
}
|
|
@@ -343,7 +461,9 @@ export function installRefClicks(root, viewer, {
|
|
|
343
461
|
} = {}) {
|
|
344
462
|
const env = { open, openLink, show, copy };
|
|
345
463
|
const run = (action, target) => action.run(target, viewer(), env);
|
|
346
|
-
const items = (actions, target) => actions.map((action) => ({
|
|
464
|
+
const items = (actions, target) => actions.map((action) => ({
|
|
465
|
+
id: action.id, label: action.label(target), hint: action.hint?.(target) || "", run: () => run(action, target),
|
|
466
|
+
}));
|
|
347
467
|
const openMenu = (target, actions, options) => menu.open(target.el, items(actions, target), options);
|
|
348
468
|
// The last pointer's type: a tap opens the menu, a mouse click runs the
|
|
349
469
|
// main target.
|
|
@@ -22,3 +22,45 @@ export function keepFollowing(el, mutate, thresholdPx = SCROLL_FOLLOW_PX) {
|
|
|
22
22
|
if (follow) el.scrollTop = el.scrollHeight;
|
|
23
23
|
return result;
|
|
24
24
|
}
|
|
25
|
+
|
|
26
|
+
// A follow that eases instead of jumping: +el+'s scrollTop goes to +top+
|
|
27
|
+
// over +ms+ (ease-out), +el+ marked data-following meanwhile (a layout
|
|
28
|
+
// watcher reads that as a follow, not a jump). +top+ may be a function,
|
|
29
|
+
// read each frame (a target that moves on: the history's end while its
|
|
30
|
+
// bottom reserve eases). With +reduce+ (prefers-reduced-motion) it jumps.
|
|
31
|
+
// A newer call on the same element takes over. Resolves when it is there.
|
|
32
|
+
export const FOLLOW_MS = 180;
|
|
33
|
+
const easing = new WeakMap();
|
|
34
|
+
export function easeScrollTo(el, top, { ms = FOLLOW_MS, reduce = false, raf = (fn) => requestAnimationFrame(fn), now = () => performance.now() } = {}) {
|
|
35
|
+
const target = () => (typeof top === "function" ? top() : top);
|
|
36
|
+
easing.get(el)?.stop();
|
|
37
|
+
if (reduce || ms <= 0) {
|
|
38
|
+
el.scrollTop = target();
|
|
39
|
+
return Promise.resolve();
|
|
40
|
+
}
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
const from = el.scrollTop;
|
|
43
|
+
const start = now();
|
|
44
|
+
let done = false;
|
|
45
|
+
const run = { stop: () => finish() };
|
|
46
|
+
function finish() {
|
|
47
|
+
if (done) return;
|
|
48
|
+
done = true;
|
|
49
|
+
if (easing.get(el) === run) {
|
|
50
|
+
easing.delete(el);
|
|
51
|
+
delete el.dataset.following;
|
|
52
|
+
}
|
|
53
|
+
resolve();
|
|
54
|
+
}
|
|
55
|
+
easing.set(el, run);
|
|
56
|
+
el.dataset.following = "";
|
|
57
|
+
const step = () => {
|
|
58
|
+
if (done) return;
|
|
59
|
+
const t = Math.min(1, (now() - start) / ms);
|
|
60
|
+
el.scrollTop = from + (target() - from) * (1 - (1 - t) ** 3);
|
|
61
|
+
if (t < 1) raf(step);
|
|
62
|
+
else finish();
|
|
63
|
+
};
|
|
64
|
+
raf(step);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -6,6 +6,7 @@ import { toolName } from "./activity.js";
|
|
|
6
6
|
import { splitSentences } from "./sentences.js";
|
|
7
7
|
import { steerSender } from "./format.js";
|
|
8
8
|
import { hookNoticeLabel } from "./turn_events.js";
|
|
9
|
+
import { askingText } from "./turn_model.js";
|
|
9
10
|
|
|
10
11
|
const READ_LIKE = new Set(["read", "memory_read", "web_fetch", "task_get", "task_list", "list_sessions"]);
|
|
11
12
|
const EDITS = new Set(["edit", "write", "memory_write"]);
|
|
@@ -150,11 +151,15 @@ const NO_ANSWER = "no answer";
|
|
|
150
151
|
// ended (nothing happens now);
|
|
151
152
|
// trail the last three finished calls, newest last (a file's title
|
|
152
153
|
// without its dir; command as for tool);
|
|
153
|
-
// ticks one per call: {kind, status, step (the gen's index)}
|
|
154
|
-
//
|
|
154
|
+
// ticks one per call: {kind, status, step (the gen's index)};
|
|
155
|
+
// asking the whole text of the reply whose call a card blocks
|
|
156
|
+
// (turn_model.js askingText), "" when none is blocked.
|
|
157
|
+
// +pending+: a card waits for the user; +blocked+: a card that blocks the
|
|
158
|
+
// turn (a question, an approval; not an action card) waits; +ended+: the
|
|
159
|
+
// turn's end kind;
|
|
155
160
|
// +emptyAnswer+: a completed turn ended with no answer
|
|
156
161
|
// (turn_summary.empty_answer), so it reads "no answer", not "answered".
|
|
157
|
-
export function liveSlots(turn, { narration = "", thinking = "", pending = false, ended = null, emptyAnswer = false } = {}) {
|
|
162
|
+
export function liveSlots(turn, { narration = "", thinking = "", pending = false, blocked = false, ended = null, emptyAnswer = false } = {}) {
|
|
158
163
|
const gens = turn?.gens || [];
|
|
159
164
|
const rows = gens.flatMap((gen, step) => gen.tools.map((row) => ({ row, step })));
|
|
160
165
|
const running = [...rows].reverse().find(({ row }) => row.status === "running")?.row || null;
|
|
@@ -182,9 +187,23 @@ export function liveSlots(turn, { narration = "", thinking = "", pending = false
|
|
|
182
187
|
tool: running ? withTask(withCommand({ name: toolName(running), title: rowTitle(running), kind: toolKind(running.tool) }, running), running) : null,
|
|
183
188
|
trail,
|
|
184
189
|
ticks: rows.map(({ row, step }) => ({ kind: toolKind(row.tool), status: row.status, step })),
|
|
190
|
+
asking: blocked && !ended ? askingText(turn) : "",
|
|
185
191
|
};
|
|
186
192
|
}
|
|
187
193
|
|
|
194
|
+
// The stage's frame height (px) while it holds a turn: grow-only, so a
|
|
195
|
+
// content change inside it (the plain flip, an answered card, a short
|
|
196
|
+
// answer) never moves its top edge. +natural+: its content's own height;
|
|
197
|
+
// +held+: the height it holds (null: none yet); +cap+: --stage-max. A lower
|
|
198
|
+
// cap (a phone's keyboard) clamps it at once and the clamp is the new held
|
|
199
|
+
// height: a cap that rises again doesn't bring the old one back, only new
|
|
200
|
+
// content grows it. null: no frame (not holding a turn, or collapsed to
|
|
201
|
+
// its status row): the content's own height.
|
|
202
|
+
export function frameTarget({ natural, held, cap, holding, collapsed }) {
|
|
203
|
+
if (!holding || collapsed) return null;
|
|
204
|
+
return Math.min(Math.max(natural, held ?? 0), cap);
|
|
205
|
+
}
|
|
206
|
+
|
|
188
207
|
// In use: the pointer over the stage, focus or a selection in it, or a
|
|
189
208
|
// touch/wheel/scroll in it within TOUCH_MS.
|
|
190
209
|
export function inUseFrom({ pointerIn, lastTouchMs, focusInside, selectionInside, nowMs }) {
|