samagotchi 0.96.0 → 0.98.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 +55 -1
- data/bin/chi +3 -0
- data/docs/architecture.md +12 -2
- data/docs/bundle-authoring.md +7 -4
- data/docs/cli.md +13 -4
- data/docs/configuration.md +13 -0
- data/docs/hooks.md +59 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +2 -2
- data/docs/internals/engine.md +21 -1
- data/docs/memory.md +43 -3
- data/docs/plugins.md +8 -1
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/answer_refs.rb +16 -9
- data/lib/samagotchi/bundle_command/check.rb +5 -3
- data/lib/samagotchi/bundle_command/try/run.rb +19 -4
- data/lib/samagotchi/bundle_command.rb +2 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +39 -5
- data/lib/samagotchi/bundles/source-links/manifest.yml +5 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +75 -40
- data/lib/samagotchi/hooks/bundle_loader.rb +18 -15
- data/lib/samagotchi/hooks.rb +4 -1
- data/lib/samagotchi/llm/errors.rb +135 -3
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +28 -9
- data/lib/samagotchi/memory_bundle/manifest.rb +17 -0
- data/lib/samagotchi/memory_bundle/profile.rb +14 -4
- data/lib/samagotchi/memory_command.rb +237 -0
- data/lib/samagotchi/output_formatter.rb +8 -2
- data/lib/samagotchi/pending_question.rb +7 -3
- data/lib/samagotchi/plugin/api.rb +12 -3
- data/lib/samagotchi/question_asked.rb +83 -0
- data/lib/samagotchi/question_desk.rb +3 -2
- data/lib/samagotchi/send_command.rb +11 -11
- data/lib/samagotchi/session_inbox.rb +29 -0
- data/lib/samagotchi/session_manager.rb +11 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -0
- data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
- data/lib/samagotchi/terminal_ui.rb +1 -0
- data/lib/samagotchi/testing/bundle.rb +18 -1
- data/lib/samagotchi/tools/memory.rb +1 -1
- data/lib/samagotchi/turn_checkpoint.rb +6 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +30 -9
- data/lib/samagotchi/web/public/app.js +44 -11
- data/lib/samagotchi/web/public/index.html +36 -13
- data/lib/samagotchi/web/public/info_bar_model.js +0 -2
- data/lib/samagotchi/web/public/lists_view.js +32 -8
- data/lib/samagotchi/web/public/markdown_place.js +25 -8
- data/lib/samagotchi/web/public/question_cards.js +23 -6
- data/lib/samagotchi/web/public/session_rename.js +88 -15
- data/lib/samagotchi/web/public/session_title.js +6 -0
- data/lib/samagotchi/web/public/stage_frame.js +24 -14
- data/lib/samagotchi/web/public/stage_model.js +4 -2
- data/lib/samagotchi/web/public/stage_slots.js +41 -9
- data/lib/samagotchi/web/public/stage_view.js +149 -8
- data/lib/samagotchi/web/public/stream_handlers.js +5 -1
- data/lib/samagotchi/web/public/turn_model.js +20 -0
- data/lib/samagotchi/web/public/turn_view.js +22 -9
- data/lib/samagotchi/worker.rb +19 -5
- data/lib/samagotchi/worker_idle_exit.rb +16 -8
- metadata +3 -1
|
@@ -3,9 +3,14 @@
|
|
|
3
3
|
// a final answer's rendering and refs, and the refs across the page. The
|
|
4
4
|
// page's state comes in as getters (capabilities, roots, currentDir), read
|
|
5
5
|
// at each call.
|
|
6
|
+
|
|
7
|
+
// The texts whose refs linkTextRefs links: the answers and the closed
|
|
8
|
+
// steps' texts.
|
|
9
|
+
const TEXTS = ".bubble.output, .gen:not(.live) > .gen-text";
|
|
10
|
+
|
|
6
11
|
export function createMarkdownPlace({
|
|
7
12
|
capabilities, renderText, roots, touchedFiles, currentDir, linkRefs, applyFinalAnswer, findInTurnRoots, normalize,
|
|
8
|
-
pairAnswerRefs,
|
|
13
|
+
pairAnswerRefs, setAnswerRefs = () => {},
|
|
9
14
|
}) {
|
|
10
15
|
// A text the page already shows as its markdown HTML (POST /api/render,
|
|
11
16
|
// sanitised there), or null: markdown off here (no request), or a failed
|
|
@@ -17,9 +22,20 @@ export function createMarkdownPlace({
|
|
|
17
22
|
.catch(() => null);
|
|
18
23
|
}
|
|
19
24
|
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
25
|
+
// A plugin's +refs+ (a question's: its pending question's refs) paired
|
|
26
|
+
// with one shown text (answer_refs.js), again after each swap of its
|
|
27
|
+
// nodes: setAnswerRefs starts the pairing over. Not +refs+: nothing.
|
|
28
|
+
function pairRefsIn(el, refs) {
|
|
29
|
+
if (refs === undefined || refs === null) return;
|
|
30
|
+
setAnswerRefs(el, refs);
|
|
31
|
+
pairAnswerRefs(el);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The refs in one rendered text (the stage's asking slot, a kept reply),
|
|
35
|
+
// as linkTextRefs links the answers': a plugin's +refs+ first
|
|
36
|
+
// (pairRefsIn), then the `path:line` ones.
|
|
37
|
+
function linkRefsIn(el, refs) {
|
|
38
|
+
pairRefsIn(el, refs);
|
|
23
39
|
linkRefs(el, touchedFiles(roots()), currentDir());
|
|
24
40
|
}
|
|
25
41
|
|
|
@@ -32,7 +48,8 @@ export function createMarkdownPlace({
|
|
|
32
48
|
}
|
|
33
49
|
|
|
34
50
|
// The refs on the answers' and closed steps' texts, on this page: first
|
|
35
|
-
// each
|
|
51
|
+
// each one's own (a plugin's, answer_refs.js: a step's are a question's,
|
|
52
|
+
// on the reply that asked it), then the `path:line`
|
|
36
53
|
// refs that name a file the session's rows (and cards) touched
|
|
37
54
|
// (ref_scan.js), which leave a plugin's alone. Not mid-stream: at a
|
|
38
55
|
// turn's end and after a reload's render. A ref already made stays as it
|
|
@@ -41,13 +58,13 @@ export function createMarkdownPlace({
|
|
|
41
58
|
const pageRoots = roots();
|
|
42
59
|
const touched = touchedFiles(pageRoots);
|
|
43
60
|
for (const root of pageRoots) {
|
|
44
|
-
for (const el of root.querySelectorAll(
|
|
61
|
+
for (const el of root.querySelectorAll(TEXTS)) pairAnswerRefs(el);
|
|
45
62
|
}
|
|
46
63
|
if (!touched.size) return;
|
|
47
64
|
for (const root of pageRoots) {
|
|
48
|
-
for (const el of root.querySelectorAll(
|
|
65
|
+
for (const el of root.querySelectorAll(TEXTS)) linkRefs(el, touched, currentDir());
|
|
49
66
|
}
|
|
50
67
|
}
|
|
51
68
|
|
|
52
|
-
return { renderMarkdown, linkRefsIn, applyFinalMarkdown, linkTextRefs };
|
|
69
|
+
return { renderMarkdown, pairRefsIn, linkRefsIn, applyFinalMarkdown, linkTextRefs };
|
|
53
70
|
}
|
|
@@ -35,7 +35,10 @@ import { plainText } from "./plain_line.js";
|
|
|
35
35
|
// askingText via the page's view), kept by a question or own approval card
|
|
36
36
|
// @param renderMarkdown (text) => Promise<html|null>: the kept reply's
|
|
37
37
|
// markdown (the page's POST /api/render; null keeps the raw text)
|
|
38
|
-
// @param linkRefs (el) the page's
|
|
38
|
+
// @param linkRefs (el, refs) the page's ref pass on it: a plugin's +refs+
|
|
39
|
+
// (the question's) paired, then the path:line ones
|
|
40
|
+
// @param pairRefs (el, refs) a plugin's +refs+ paired only (the question's
|
|
41
|
+
// own text; markdown_place.js pairRefsIn)
|
|
39
42
|
// @param api {sendAnswer, dismissQuestion, sendCommand} (data.js)
|
|
40
43
|
// @param doc the page's document
|
|
41
44
|
// @return {renderQuestion(pq, {said}), resolveQuestion(id, opts), removeQuestion(),
|
|
@@ -43,7 +46,7 @@ import { plainText } from "./plain_line.js";
|
|
|
43
46
|
// renderContinue(offer), resolveContinue(data), forgetContinue()}
|
|
44
47
|
export function createQuestionCards({
|
|
45
48
|
sessionId, clientId, place, isNearBottom, scrollToEnd, removeHintIfEmpty, askingText = () => "",
|
|
46
|
-
renderMarkdown = () => Promise.resolve(null), linkRefs = () => {},
|
|
49
|
+
renderMarkdown = () => Promise.resolve(null), linkRefs = () => {}, pairRefs = () => {},
|
|
47
50
|
api = { sendAnswer, dismissQuestion, sendCommand }, doc = globalThis.document,
|
|
48
51
|
}) {
|
|
49
52
|
// Live ask_user_question card, keyed by pending question id. Re-rendered from
|
|
@@ -73,6 +76,10 @@ export function createQuestionCards({
|
|
|
73
76
|
const card = doc.createElement("details");
|
|
74
77
|
card.className = approval ? "bubble question approval" : stepLimit ? "bubble question step-limit" : "bubble question";
|
|
75
78
|
card.dataset.qid = pq.id;
|
|
79
|
+
// What asks (the stage's question room is a model's own question's,
|
|
80
|
+
// stage_view.js): the kind, and a delegate's question relayed here.
|
|
81
|
+
card.dataset.kind = pq.kind || "question";
|
|
82
|
+
if (relayView(pq)) card.dataset.relay = "1";
|
|
76
83
|
const asked = keepsAskingText(pq) ? String(said ?? askingText() ?? "").trim() : "";
|
|
77
84
|
if (asked) saidOf.set(card, asked);
|
|
78
85
|
// Open while pending: the question must be actionable.
|
|
@@ -98,6 +105,8 @@ export function createQuestionCards({
|
|
|
98
105
|
const q = doc.createElement("div");
|
|
99
106
|
q.className = "question-text";
|
|
100
107
|
q.textContent = (relay && pq.relay.asked) || pq.question || "";
|
|
108
|
+
// A plugin's refs on the question (its step's: one may name a ticket here too).
|
|
109
|
+
if (!relay) pairRefs(q, questionRefsOf(pq));
|
|
101
110
|
card.appendChild(q);
|
|
102
111
|
}
|
|
103
112
|
|
|
@@ -421,6 +430,7 @@ export function createQuestionCards({
|
|
|
421
430
|
// clamped line), and whole, selectable, at the top of the body
|
|
422
431
|
// when it is open (a drag in the summary would toggle the card).
|
|
423
432
|
const asked = saidOf.get(questionEl);
|
|
433
|
+
const refs = questionRefsOf(pending);
|
|
424
434
|
let full = null;
|
|
425
435
|
if (summary && asked) {
|
|
426
436
|
const line = doc.createElement("div");
|
|
@@ -431,6 +441,7 @@ export function createQuestionCards({
|
|
|
431
441
|
full = doc.createElement("div");
|
|
432
442
|
full.className = "question-said-full";
|
|
433
443
|
full.textContent = asked;
|
|
444
|
+
pairRefs(full, refs);
|
|
434
445
|
questionEl.insertBefore(full, summary.nextSibling);
|
|
435
446
|
}
|
|
436
447
|
// 4.02: a reopened card's summary already carries the result (the
|
|
@@ -441,15 +452,15 @@ export function createQuestionCards({
|
|
|
441
452
|
if (summary && !keepNote) note.classList.add("hidden");
|
|
442
453
|
if (!questionEl.dataset.toggled) questionEl.open = false;
|
|
443
454
|
// After the collapse: a card left open by hand renders at once.
|
|
444
|
-
if (full) renderSaidOnOpen(questionEl, full, asked);
|
|
455
|
+
if (full) renderSaidOnOpen(questionEl, full, asked, refs);
|
|
445
456
|
pending = null;
|
|
446
457
|
}
|
|
447
458
|
|
|
448
459
|
// The body's copy as markdown, from the card's first open on (a reload
|
|
449
460
|
// draws every card in the history: rendering them all would ask once per
|
|
450
461
|
// card). Raw until then, and when the render answers null (then the next
|
|
451
|
-
// open asks again).
|
|
452
|
-
function renderSaidOnOpen(card, full, asked) {
|
|
462
|
+
// open asks again). +refs+: the question's, paired again on the swap.
|
|
463
|
+
function renderSaidOnOpen(card, full, asked, refs) {
|
|
453
464
|
let state = "raw";
|
|
454
465
|
const draw = () => {
|
|
455
466
|
if (!card.open || state !== "raw") return;
|
|
@@ -463,13 +474,19 @@ export function createQuestionCards({
|
|
|
463
474
|
// Sanitised on the server (POST /api/render).
|
|
464
475
|
full.innerHTML = html;
|
|
465
476
|
full.classList.add("markdown", "md-compact");
|
|
466
|
-
linkRefs(full);
|
|
477
|
+
linkRefs(full, refs);
|
|
467
478
|
});
|
|
468
479
|
};
|
|
469
480
|
card.addEventListener("toggle", draw);
|
|
470
481
|
draw();
|
|
471
482
|
}
|
|
472
483
|
|
|
484
|
+
// A pending question's refs (a plugin's, on the reply that asks it), or
|
|
485
|
+
// null: none, or not an array.
|
|
486
|
+
function questionRefsOf(pq) {
|
|
487
|
+
return Array.isArray(pq?.refs) && pq.refs.length ? pq.refs : null;
|
|
488
|
+
}
|
|
489
|
+
|
|
473
490
|
function markQuestionSelection(sel) {
|
|
474
491
|
if (!questionEl || !sel.length) return;
|
|
475
492
|
questionEl.querySelectorAll(".question-choice").forEach((button) => {
|
|
@@ -1,15 +1,25 @@
|
|
|
1
|
-
// The
|
|
1
|
+
// The title's ✎ (Rename this session): a small input over the title as it
|
|
2
|
+
// shows on screen (the active strip card's title row, or the top bar's),
|
|
2
3
|
// prefilled with the session's title ("" for none). Enter sends
|
|
3
4
|
// `/title TEXT` (blank: `/title --clear`) through the chips' command path
|
|
4
5
|
// (composer.js run: the reply shows as a command bubble, a refusal in the
|
|
5
6
|
// failed bubble, the composer untouched); leaving the field saves the same
|
|
6
|
-
// way (a phone's keyboard dismissed keeps what was typed). Esc,
|
|
7
|
-
// another session,
|
|
8
|
-
// (the hub's scan), as for any title.
|
|
7
|
+
// way (a phone's keyboard dismissed keeps what was typed). Esc, opening
|
|
8
|
+
// another session, or the title leaving the screen closes it unsent. The
|
|
9
|
+
// cards follow from the saved file (the hub's scan), as for any title.
|
|
10
|
+
//
|
|
11
|
+
// The input lives at the end of body, fixed over its anchor's rect: the
|
|
12
|
+
// strip draws every card from new nodes on each session event, so an input
|
|
13
|
+
// inside a card would be thrown away mid-typing.
|
|
9
14
|
import { normalize } from "./format.js";
|
|
10
15
|
|
|
11
16
|
// As long as a title may be (TitleCommand::LIMIT).
|
|
12
17
|
const TITLE_CHARS = 80;
|
|
18
|
+
// The narrowest field (a short title still gets room to type), the gap kept
|
|
19
|
+
// to the window's right edge, and the lowest field.
|
|
20
|
+
const MIN_WIDTH_PX = 192;
|
|
21
|
+
const EDGE_PX = 16;
|
|
22
|
+
const MIN_HEIGHT_PX = 24;
|
|
13
23
|
|
|
14
24
|
// The line the input's +text+ sends.
|
|
15
25
|
export function titleLine(text) {
|
|
@@ -17,21 +27,47 @@ export function titleLine(text) {
|
|
|
17
27
|
return title ? `/title ${title}` : "/title --clear";
|
|
18
28
|
}
|
|
19
29
|
|
|
20
|
-
//
|
|
30
|
+
// Where the input goes over +rect+ (the anchor's) in a window +innerWidth+
|
|
31
|
+
// wide: the anchor's box, at least MIN_WIDTH_PX wide and MIN_HEIGHT_PX tall
|
|
32
|
+
// (centred on it), never closer than EDGE_PX to the right edge.
|
|
33
|
+
export function inputBox(rect, innerWidth) {
|
|
34
|
+
const height = Math.max(rect.height, MIN_HEIGHT_PX);
|
|
35
|
+
const room = Math.max(innerWidth - rect.left - EDGE_PX, 0);
|
|
36
|
+
const width = Math.min(Math.max(rect.width, MIN_WIDTH_PX), room);
|
|
37
|
+
return { left: rect.left, top: rect.top + (rect.height - height) / 2, width, height };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Whether a card's title row +rect+ has left its scroller's +clip+ (a
|
|
41
|
+
// phone's strip swiped sideways: a fixed input isn't clipped by its overflow).
|
|
42
|
+
export function outsideClip(rect, clip) {
|
|
43
|
+
return rect.left < clip.left - 1 || rect.right > clip.right + 1 || rect.bottom < clip.top || rect.top > clip.bottom;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// @param doc the document (the input goes at the end of its body)
|
|
47
|
+
// @param anchor () => the title row on screen for the open session (its ✎
|
|
48
|
+
// inside), or null when none shows
|
|
21
49
|
// @param session () => the open session's id, or null
|
|
22
50
|
// @param titleOf (id) => that session's title, or null
|
|
51
|
+
// @param shownTitle (id) => the title as the page shows it (the
|
|
52
|
+
// placeholder: an untitled chain link shows its chain's), or ""
|
|
53
|
+
// @param closePopovers () => close the family and chain popovers (their Esc
|
|
54
|
+
// would close the popover and refocus its chip: a blur, so a save)
|
|
55
|
+
// @param onClose () => after the input closed (a held strip catches up)
|
|
23
56
|
// @param run (line) => composer.js run's promise
|
|
24
|
-
export function createRename({
|
|
57
|
+
export function createRename({ doc = document, anchor, session, titleOf, shownTitle = () => "", closePopovers = () => {}, onClose = () => {}, run }) {
|
|
25
58
|
let input = null;
|
|
26
59
|
let openFor = null;
|
|
60
|
+
const win = doc.defaultView;
|
|
27
61
|
|
|
62
|
+
// Removing the input blurs it: input is cleared first, so that blur sends
|
|
63
|
+
// nothing (a close is always unsent).
|
|
28
64
|
function close() {
|
|
29
65
|
if (!input) return;
|
|
30
66
|
const el = input;
|
|
31
67
|
input = null;
|
|
32
68
|
openFor = null;
|
|
33
69
|
el.remove();
|
|
34
|
-
|
|
70
|
+
onClose();
|
|
35
71
|
}
|
|
36
72
|
|
|
37
73
|
// Enter's and blur's: once (closing it blurs it again), and only to the
|
|
@@ -46,14 +82,40 @@ export function createRename({ button, session, titleOf, run }) {
|
|
|
46
82
|
return run(titleLine(text));
|
|
47
83
|
}
|
|
48
84
|
|
|
85
|
+
// Over the anchor's title row, in its title's font size (CSS keeps a
|
|
86
|
+
// phone's at 16px: under that, iOS zooms the page on focus).
|
|
87
|
+
function place(a) {
|
|
88
|
+
const box = inputBox(a.getBoundingClientRect(), win.innerWidth);
|
|
89
|
+
Object.assign(input.style, {
|
|
90
|
+
left: `${box.left}px`, top: `${box.top}px`, width: `${box.width}px`, height: `${box.height}px`,
|
|
91
|
+
});
|
|
92
|
+
const text = a.firstElementChild;
|
|
93
|
+
const size = text && win.getComputedStyle ? win.getComputedStyle(text).fontSize : "";
|
|
94
|
+
if (size) input.style.setProperty("--rename-font", size);
|
|
95
|
+
// The top bar's row runs to the bell: CSS caps the input there, so it
|
|
96
|
+
// reads as the title edited, not a field across the bar.
|
|
97
|
+
input.classList.toggle("over-bar", !a.closest("#topStrip"));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A card's row out of the strip's sight (swiped sideways).
|
|
101
|
+
function clipped(a) {
|
|
102
|
+
const strip = a.closest("#topStrip");
|
|
103
|
+
return !!strip && outsideClip(a.getBoundingClientRect(), strip.getBoundingClientRect());
|
|
104
|
+
}
|
|
105
|
+
|
|
49
106
|
function open() {
|
|
50
107
|
const id = session();
|
|
51
108
|
if (input || !id) return;
|
|
52
|
-
const
|
|
109
|
+
const a = anchor();
|
|
110
|
+
if (!a) return;
|
|
111
|
+
closePopovers();
|
|
112
|
+
// The active card may sit scrolled sideways out of a phone's strip.
|
|
113
|
+
a.scrollIntoView?.({ block: "nearest", inline: "nearest" });
|
|
114
|
+
const el = doc.createElement("input");
|
|
53
115
|
el.type = "text";
|
|
54
116
|
el.className = "rename-input";
|
|
55
117
|
el.maxLength = TITLE_CHARS;
|
|
56
|
-
el.placeholder = "Session title";
|
|
118
|
+
el.placeholder = normalize(shownTitle(id)) || "Session title";
|
|
57
119
|
el.setAttribute("aria-label", "Session title (Enter saves, empty clears, Esc cancels)");
|
|
58
120
|
el.value = titleOf(id) || "";
|
|
59
121
|
el.dataset.before = el.value;
|
|
@@ -64,23 +126,34 @@ export function createRename({ button, session, titleOf, run }) {
|
|
|
64
126
|
} else if (e.key === "Escape") {
|
|
65
127
|
e.preventDefault();
|
|
66
128
|
close();
|
|
129
|
+
// The ✎ as drawn now (the strip may have drawn its card again).
|
|
130
|
+
anchor()?.querySelector("button")?.focus();
|
|
67
131
|
}
|
|
68
132
|
});
|
|
69
133
|
el.addEventListener("blur", () => submit());
|
|
70
134
|
input = el;
|
|
71
135
|
openFor = id;
|
|
72
|
-
|
|
73
|
-
|
|
136
|
+
doc.body.append(el);
|
|
137
|
+
place(a);
|
|
74
138
|
el.focus();
|
|
75
139
|
el.select();
|
|
76
140
|
}
|
|
77
141
|
|
|
78
|
-
// The
|
|
79
|
-
//
|
|
142
|
+
// The title moved (a strip draw, the strip hidden or shown, the page
|
|
143
|
+
// scrolled or resized): the input follows it. With no title on screen (the
|
|
144
|
+
// All sessions view, the title gone, the card swiped away) it closes unsent.
|
|
145
|
+
function reposition() {
|
|
146
|
+
if (!input) return;
|
|
147
|
+
const a = anchor();
|
|
148
|
+
if (!a || clipped(a)) close();
|
|
149
|
+
else place(a);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The page shows +id+ (app.js updateSessionTitle): an input opened for
|
|
153
|
+
// another session closes.
|
|
80
154
|
function follow(id) {
|
|
81
155
|
if (input && openFor !== id) close();
|
|
82
156
|
}
|
|
83
157
|
|
|
84
|
-
|
|
85
|
-
return { open, close, follow, isOpen: () => !!input };
|
|
158
|
+
return { open, close, follow, reposition, isOpen: () => !!input };
|
|
86
159
|
}
|
|
@@ -16,6 +16,12 @@ export function topBarTitle({ title, cardShown, stripHidden, allView }) {
|
|
|
16
16
|
return text;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// Whether the title's ✎ shows (session_rename.js): any session but a plain
|
|
20
|
+
// terminal's (`owner: "tui"`: it takes no commands from the web).
|
|
21
|
+
export function renameShown(owner) {
|
|
22
|
+
return owner !== "tui";
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
// The browser tab: "<title, cut at 40> · <base>", the base alone without a
|
|
20
26
|
// session title (base = "Chi · <project>" or "Chi").
|
|
21
27
|
export function tabTitle({ title, base }) {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// The stage view's cap and frame (stage_view.js): the one place that
|
|
2
2
|
// writes the stage's height, and the stage's own scrolls. The cap: at most
|
|
3
|
-
// ~60% of the page (~80% with the cloud open
|
|
3
|
+
// ~60% of the page (~80% with the cloud open, 85% for the rest of a turn
|
|
4
|
+
// whose question got the question room), and never above the session
|
|
4
5
|
// panel. The frame: held and grow-only while the stage holds a turn
|
|
5
6
|
// (stage_model.js frameTarget), its growth eased. The stage's state comes
|
|
6
|
-
// in as getters (+isHolding+, +isCollapsed+, +isCloudOpen+);
|
|
7
|
-
// changes of the frame's goes through resetHeld and
|
|
7
|
+
// in as getters (+isHolding+, +isCollapsed+, +isCloudOpen+, +isRoomCap+);
|
|
8
|
+
// what the stage changes of the frame's goes through resetHeld and
|
|
9
|
+
// forgetContent.
|
|
8
10
|
import { frameTarget } from "./stage_model.js";
|
|
9
11
|
import { easeScrollTo } from "./scroll.js";
|
|
10
12
|
|
|
@@ -14,6 +16,11 @@ const GROW_MS = 180;
|
|
|
14
16
|
const GROW_EASE = "cubic-bezier(.2,.7,.3,1)";
|
|
15
17
|
// The room between the stage and the composer card (index.html's margin-bottom).
|
|
16
18
|
export const STAGE_GAP = 10;
|
|
19
|
+
// The cap's share of the page: the stage's own, with the cloud open, and
|
|
20
|
+
// with the question room (a long reply that asks, stage_view.js).
|
|
21
|
+
const SHARE = 0.6;
|
|
22
|
+
const CLOUD_SHARE = 0.8;
|
|
23
|
+
const ROOM_SHARE = 0.85;
|
|
17
24
|
|
|
18
25
|
export function reducedMotion() {
|
|
19
26
|
return matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
@@ -21,7 +28,7 @@ export function reducedMotion() {
|
|
|
21
28
|
|
|
22
29
|
// +observed+: the elements whose size changes refit the frame (the stage's
|
|
23
30
|
// slots, in the order they are observed).
|
|
24
|
-
export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, holdEl, asking, extras, dockEl, centerEl, composerEl, observed, isHolding, isCollapsed, isCloudOpen }) {
|
|
31
|
+
export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, holdEl, asking, extras, dockEl, centerEl, composerEl, observed, isHolding, isCollapsed, isCloudOpen, isRoomCap = () => false }) {
|
|
25
32
|
// The frame (fitFrame): the height held while the turn runs and the
|
|
26
33
|
// tallest content seen.
|
|
27
34
|
let held = null;
|
|
@@ -45,25 +52,28 @@ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, h
|
|
|
45
52
|
}));
|
|
46
53
|
}
|
|
47
54
|
|
|
48
|
-
// ── the cap: at most ~60% of the page (~80% with the cloud open
|
|
49
|
-
//
|
|
50
|
-
// a
|
|
55
|
+
// ── the cap: at most ~60% of the page (~80% with the cloud open, 85%
|
|
56
|
+
// once the turn's question got the room), and never above the session
|
|
57
|
+
// panel (the dock is absolute: a tall composer or a phone's keyboard
|
|
58
|
+
// would push the stage's top out) ──────────────────────────────────────
|
|
51
59
|
|
|
52
60
|
// Where the stage's scroll goes so +slot+ (the scroller's child) shows:
|
|
53
61
|
// its end in sight, and the asking text's top too when it and the slot
|
|
54
62
|
// (a waiting card) fit together (the reply reads from its start, the card
|
|
55
|
-
// still in sight).
|
|
56
|
-
|
|
63
|
+
// still in sight). +fromStart+ (the question room's arrival): the asking
|
|
64
|
+
// text's top even when they don't (the card is reached by the stage's
|
|
65
|
+
// jump pill, stage_view.js). null: nothing to show.
|
|
66
|
+
function revealTop(slot, { fromStart = false } = {}) {
|
|
57
67
|
if (stage.hidden || slot.hidden) return null;
|
|
58
68
|
let top = Math.max(scroller.scrollTop, slot.offsetTop + slot.offsetHeight - scroller.clientHeight);
|
|
59
|
-
if (!asking.hidden && asking.offsetTop < top && slot.offsetTop + slot.offsetHeight - asking.offsetTop <= scroller.clientHeight) top = asking.offsetTop;
|
|
69
|
+
if (!asking.hidden && asking.offsetTop < top && (fromStart || slot.offsetTop + slot.offsetHeight - asking.offsetTop <= scroller.clientHeight)) top = asking.offsetTop;
|
|
60
70
|
return top;
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
// Scrolls the stage to show +slot+: eased, or at once (+now+: the cap
|
|
64
|
-
// dropped under it).
|
|
65
|
-
function reveal(slot, { now = false } = {}) {
|
|
66
|
-
const top = revealTop(slot);
|
|
74
|
+
// dropped under it). +fromStart+: revealTop's.
|
|
75
|
+
function reveal(slot, { now = false, fromStart = false } = {}) {
|
|
76
|
+
const top = revealTop(slot, { fromStart });
|
|
67
77
|
if (top === null) return;
|
|
68
78
|
if (now) scroller.scrollTop = top;
|
|
69
79
|
else easeStage(top);
|
|
@@ -75,7 +85,7 @@ export function createStageFrame({ stage, scroller, status, cloud, queuedSlot, h
|
|
|
75
85
|
// out of the dock's flow).
|
|
76
86
|
const rest = dockEl.offsetHeight + STAGE_GAP;
|
|
77
87
|
const room = centerEl.clientHeight - rest - 32;
|
|
78
|
-
const share = window.innerHeight * (isCloudOpen() ?
|
|
88
|
+
const share = window.innerHeight * (isRoomCap() ? ROOM_SHARE : isCloudOpen() ? CLOUD_SHARE : SHARE);
|
|
79
89
|
const cap = Math.max(96, Math.round(Math.min(share, room)));
|
|
80
90
|
const was = capPx();
|
|
81
91
|
if (cap === was) return;
|
|
@@ -6,7 +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
|
+
import { askingRefs, askingText } from "./turn_model.js";
|
|
10
10
|
import { plainLine } from "./plain_line.js";
|
|
11
11
|
|
|
12
12
|
const READ_LIKE = new Set(["read", "memory_read", "web_fetch", "task_get", "task_list", "list_sessions"]);
|
|
@@ -156,7 +156,8 @@ const NO_ANSWER = "no answer";
|
|
|
156
156
|
// without its dir; command as for tool);
|
|
157
157
|
// ticks one per call: {kind, status, step (the gen's index)};
|
|
158
158
|
// asking the whole text of the reply whose call a card blocks
|
|
159
|
-
// (turn_model.js askingText), "" when none is blocked
|
|
159
|
+
// (turn_model.js askingText), "" when none is blocked;
|
|
160
|
+
// askingRefs a plugin's refs on it (turn_model.js askingRefs), or null.
|
|
160
161
|
// +pending+: a card waits for the user; +blocked+: a card that blocks the
|
|
161
162
|
// turn (a question, an approval; not an action card) waits; +ended+: the
|
|
162
163
|
// turn's end kind;
|
|
@@ -191,6 +192,7 @@ export function liveSlots(turn, { narration = "", thinking = "", pending = false
|
|
|
191
192
|
trail,
|
|
192
193
|
ticks: rows.map(({ row, step }) => ({ kind: toolKind(row.tool), status: row.status, step })),
|
|
193
194
|
asking: blocked && !ended ? askingText(turn) : "",
|
|
195
|
+
askingRefs: blocked && !ended ? askingRefs(turn) : null,
|
|
194
196
|
};
|
|
195
197
|
}
|
|
196
198
|
|
|
@@ -21,17 +21,23 @@ export function node(tag, cls, parent = null) {
|
|
|
21
21
|
// command, read at its click); +onChange+ redraws (the stage's
|
|
22
22
|
// scheduleRefresh). +renderMarkdown(text)+: the asking text's HTML
|
|
23
23
|
// (Promise<string|null>; null keeps the raw text: markdown off, a failed
|
|
24
|
-
// read); +linkRefs(el)+: the page's
|
|
24
|
+
// read); +linkRefs(el, refs)+: the page's ref pass on the slot (a
|
|
25
|
+
// plugin's +refs+ paired first, then the path:line ones).
|
|
25
26
|
export function createStageSlots({
|
|
26
27
|
headline, asking, now, trail, ticks, taskStop = null, gens, onChange,
|
|
27
28
|
renderMarkdown = () => Promise.resolve(null), linkRefs = () => {},
|
|
28
29
|
}) {
|
|
29
30
|
// What each slot shows, so an unchanged slot is left alone.
|
|
30
|
-
const shown = { headline: null, now: null, trail: null, asking: null };
|
|
31
|
+
const shown = { headline: null, now: null, trail: null, asking: null, askingRefs: "" };
|
|
32
|
+
// The refs the asking text is paired with (its pending question's).
|
|
33
|
+
let askingRefs = null;
|
|
31
34
|
// The last asking text sent to render and its answer (in flight or
|
|
32
35
|
// done, HTML only): the same question drawn again (after a reset, a
|
|
33
36
|
// blank between) asks no second time.
|
|
34
37
|
let rendering = { text: null, html: null };
|
|
38
|
+
// The asking text shows in its final form (its markdown swapped in, or
|
|
39
|
+
// a null render left the raw text): askingSettled.
|
|
40
|
+
let askingFinal = false;
|
|
35
41
|
|
|
36
42
|
function drawHeadline({ text, thinking }) {
|
|
37
43
|
const key = `${thinking ? "t" : "n"}|${text}`;
|
|
@@ -53,16 +59,28 @@ export function createStageSlots({
|
|
|
53
59
|
// rewrites the same text): a selection in it survives. A new text reads
|
|
54
60
|
// from its top: drawn raw at once, then swapped for its markdown when
|
|
55
61
|
// that comes back while the slot still shows the same text (the text is
|
|
56
|
-
// final: it shows only once the call that asks runs).
|
|
57
|
-
|
|
62
|
+
// final: it shows only once the call that asks runs). +refs+: a plugin's
|
|
63
|
+
// refs on it (the pending question's), paired on both draws; new refs on
|
|
64
|
+
// the same text (a second question in the step) are paired in place.
|
|
65
|
+
function drawAsking(text, refs = null) {
|
|
58
66
|
asking.hidden = !text;
|
|
59
|
-
|
|
67
|
+
const refsKey = text && Array.isArray(refs) && refs.length ? JSON.stringify(refs) : "";
|
|
68
|
+
if (shown.asking === text) {
|
|
69
|
+
if (shown.askingRefs === refsKey) return;
|
|
70
|
+
shown.askingRefs = refsKey;
|
|
71
|
+
askingRefs = refsKey ? refs : null;
|
|
72
|
+
linkRefs(asking, askingRefs);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
60
75
|
shown.asking = text;
|
|
76
|
+
shown.askingRefs = refsKey;
|
|
77
|
+
askingRefs = refsKey ? refs : null;
|
|
61
78
|
asking.classList.remove("markdown", "md-compact");
|
|
62
79
|
asking.textContent = text;
|
|
63
80
|
asking.scrollTop = 0;
|
|
81
|
+
askingFinal = false;
|
|
64
82
|
if (!text) return;
|
|
65
|
-
linkRefs(asking);
|
|
83
|
+
linkRefs(asking, askingRefs);
|
|
66
84
|
if (rendering.text !== text) {
|
|
67
85
|
let html;
|
|
68
86
|
try { html = Promise.resolve(renderMarkdown(text)).catch(() => null); } catch (_) { html = Promise.resolve(null); }
|
|
@@ -72,13 +90,24 @@ export function createStageSlots({
|
|
|
72
90
|
pending.html.then((html) => {
|
|
73
91
|
// A null isn't kept: the next draw of this text asks again.
|
|
74
92
|
if (!html && rendering === pending) rendering = { text: null, html: null };
|
|
93
|
+
if (shown.asking !== text || askingFinal) return;
|
|
94
|
+
// The raw text is final (markdown off, a failed read): the stage
|
|
95
|
+
// measures it now.
|
|
96
|
+
if (!html) {
|
|
97
|
+
askingFinal = true;
|
|
98
|
+
onChange?.();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
75
101
|
// Swapped once: a blank-then-same draw while in flight waits twice.
|
|
76
|
-
if (
|
|
102
|
+
if (asking.classList.contains("markdown")) return;
|
|
77
103
|
// Sanitised on the server (POST /api/render), as an answer's HTML is.
|
|
78
104
|
asking.innerHTML = html;
|
|
79
105
|
asking.classList.add("markdown", "md-compact");
|
|
80
106
|
asking.scrollTop = 0;
|
|
81
|
-
linkRefs(asking);
|
|
107
|
+
linkRefs(asking, askingRefs);
|
|
108
|
+
askingFinal = true;
|
|
109
|
+
// Its height changed: the stage measures it now (the question room).
|
|
110
|
+
onChange?.();
|
|
82
111
|
});
|
|
83
112
|
}
|
|
84
113
|
|
|
@@ -156,7 +185,10 @@ export function createStageSlots({
|
|
|
156
185
|
asking.hidden = true;
|
|
157
186
|
asking.classList.remove("markdown", "md-compact");
|
|
158
187
|
shown.headline = shown.now = shown.trail = shown.asking = null;
|
|
188
|
+
shown.askingRefs = "";
|
|
189
|
+
askingRefs = null;
|
|
190
|
+
askingFinal = false;
|
|
159
191
|
}
|
|
160
192
|
|
|
161
|
-
return { drawHeadline, drawAsking, drawNow, drawTrail, drawTicks, reset };
|
|
193
|
+
return { drawHeadline, drawAsking, drawNow, drawTrail, drawTicks, reset, askingSettled: () => askingFinal };
|
|
162
194
|
}
|