@jossuealcala/madre 1.0.2 → 1.0.3
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.
- package/CHANGELOG.en.md +69 -1
- package/CHANGELOG.md +69 -1
- package/README.en.md +11 -15
- package/README.md +3 -3
- package/bin/madre.cjs +40 -0
- package/bin/madre.mjs +5 -0
- package/docs/REFERENCE.md +29 -3
- package/docs/SDK.md +4 -2
- package/docs/en/REFERENCE.md +29 -3
- package/docs/en/SDK.md +4 -2
- package/docs/training/README.md +28 -1
- package/docs/training/train.sh +5 -1
- package/package.json +3 -3
- package/public/app.js +1626 -151
- package/public/attribution.js +44 -1
- package/public/brands.js +20 -5
- package/public/code-tools.js +143 -0
- package/public/editor.js +20 -4
- package/public/es.js +393 -25
- package/public/hardware.js +50 -0
- package/public/index.html +5 -1
- package/public/macroverso.js +187 -96
- package/public/market-art.js +161 -0
- package/public/market-view.js +529 -0
- package/public/market.js +189 -0
- package/public/neural.js +110 -0
- package/public/nostromo-engine.js +412 -47
- package/public/power.js +100 -0
- package/public/resay.js +5 -0
- package/public/salas-mac.css +98 -14
- package/public/salas-mac.html +9 -1
- package/public/salas-mac.js +307 -30
- package/public/styles.css +838 -61
- package/public/tour.js +48 -6
- package/public/troubleshooting.js +6 -1
- package/public/wallpaper.js +275 -0
- package/src/adapters/cursor.mjs +96 -0
- package/src/adapters/gemini.mjs +10 -3
- package/src/adapters/madre.mjs +2 -1
- package/src/auth-probe.mjs +143 -12
- package/src/bridge/policy.mjs +1 -1
- package/src/capabilities.mjs +9 -0
- package/src/checkpoint.mjs +16 -1
- package/src/credentials.mjs +3 -0
- package/src/dataset.mjs +76 -4
- package/src/enlaces.mjs +208 -2
- package/src/extensions.mjs +11 -3
- package/src/i18n.mjs +16 -0
- package/src/looks.mjs +125 -2
- package/src/market-key.mjs +8 -0
- package/src/market-remote.mjs +239 -0
- package/src/models.mjs +4 -0
- package/src/module-scope.mjs +68 -0
- package/src/modules/ahp.mjs +102 -7
- package/src/modules/bishop-exam.mjs +122 -0
- package/src/modules/bishop.mjs +71 -12
- package/src/modules/enlaces.mjs +87 -6
- package/src/modules/image-studio.mjs +10 -1
- package/src/modules/intents.mjs +3 -1
- package/src/modules/local-models.mjs +124 -0
- package/src/modules/ollama.mjs +12 -3
- package/src/modules/playwright.mjs +73 -15
- package/src/modules/sdk.mjs +61 -9
- package/src/npm-launch.mjs +18 -0
- package/src/ollama.mjs +6 -5
- package/src/outbound.mjs +9 -1
- package/src/ports.mjs +4 -2
- package/src/room/control.mjs +1 -1
- package/src/room/prompt.mjs +13 -2
- package/src/room.mjs +14 -4
- package/src/router.mjs +1 -1
- package/src/runs.mjs +4 -1
- package/src/runtime-detection.mjs +9 -0
- package/src/salas.mjs +26 -0
- package/src/server.mjs +617 -48
- package/src/setup.mjs +1 -1
package/public/app.js
CHANGED
|
@@ -7,11 +7,17 @@ import { allConditions, detectPlatform, diagnose, fixesFor, PLATFORMS, searchCon
|
|
|
7
7
|
import { answerFor, INQUIRIES, STRIKES } from './inquiry.js';
|
|
8
8
|
import { DEFAULT_LANGUAGE, LANGUAGES, browserLanguage, isLanguage, language, pick, setLanguage, t, fill } from './i18n.js';
|
|
9
9
|
import { createEditor } from './editor.js';
|
|
10
|
+
import { labelPowerKey, powerKey } from './power.js';
|
|
11
|
+
import { drawHardware, hardwareLine } from './hardware.js';
|
|
12
|
+
import { CUSTOM_WALL, addPicture, customTile, customTools, customWall, paintCustomWall, rememberedWall } from './wallpaper.js';
|
|
13
|
+
import { createStrain, inviteNeural, neuralChosen, neuralKey, paintNeuralKey, rememberNeural } from './neural.js';
|
|
10
14
|
import { resay } from './resay.js';
|
|
11
15
|
import { startReviewAsk } from './review-ask.js';
|
|
12
16
|
import { spotlight, startTour as startWelcome } from './tour.js';
|
|
13
17
|
import { dropStickyHover } from './touch.js';
|
|
14
|
-
import { drawAttribution, drawLegal } from './attribution.js';
|
|
18
|
+
import { drawAttribution, drawColophonKeys, drawLegal } from './attribution.js';
|
|
19
|
+
import { MODULE_FACES, appIcon, loadMarket, marketEntry, marketOffer, moduleFace } from './market.js';
|
|
20
|
+
import { openMarket } from './market-view.js';
|
|
15
21
|
// Where nothing can hover (a phone), a tapped key does not stay lit (public/touch.js).
|
|
16
22
|
dropStickyHover();
|
|
17
23
|
// Made the first time the room asks for it (roomTour, below), which can be while this file is
|
|
@@ -22,6 +28,7 @@ let welcomeKept;
|
|
|
22
28
|
const welcomeDone = () => welcomeKept !== undefined && (welcomeKept === null || Boolean(welcomeKept.finished));
|
|
23
29
|
import { LOADER_MARK, creep, finishLoader, makeLoader, setLoader, showCurtain } from './loader.js';
|
|
24
30
|
import { familyOf, languageLabel, tokenize } from './syntax.js';
|
|
31
|
+
import { hunkKey, lineDiff, placeHunks, revertHunk, symbolAt } from './code-tools.js';
|
|
25
32
|
import { COLLAPSED, MEMORY_COLORS, PITCH_LIMIT, TURN_PER_PIXEL, activityOf, collapsedChip, createNostromo, dwarfChip, hexMix, rawActivity, travels } from './nostromo-engine.js';
|
|
26
33
|
|
|
27
34
|
// The window split in two. The right half is this same page loaded with `?pane=split&chat=<id>`:
|
|
@@ -85,6 +92,13 @@ if (SPLIT_CHAT) {
|
|
|
85
92
|
let motionScale = null;
|
|
86
93
|
// The chooser's chevron: one drawing for every menu that opens downward, in the same round line.
|
|
87
94
|
const ICON_CHEVRON = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.2 6.2 8 10l3.8-3.8" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
95
|
+
// The panels' drawings: a panel at the left, a panel at the right, and the code workspace's side
|
|
96
|
+
// by side. Declared this early because the code workspace is built while the page loads.
|
|
97
|
+
const SPLIT_LEFT_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="2.75" y="3.75" width="14.5" height="12.5" rx="3.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><rect class="ic-h" x="5" y="6" width="3.6" height="8" rx="1.3" fill="none" stroke="currentColor" stroke-width="1.2"/><path d="M10.6 6.4v7.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
98
|
+
const SPLIT_RIGHT_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="2.75" y="3.75" width="14.5" height="12.5" rx="3.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><rect class="ic-h" x="11.4" y="6" width="3.6" height="8" rx="1.3" fill="none" stroke="currentColor" stroke-width="1.2"/><path d="M9.4 6.4v7.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
99
|
+
// Side by side in the code workspace: the text on the left in lines, what it makes on the right.
|
|
100
|
+
// Not the window's split (two empty halves), which stands two keys further along the same header.
|
|
101
|
+
const BESIDE_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="2.75" y="3.75" width="14.5" height="12.5" rx="3.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M10 3.75v12.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M5.2 7.6h2.4M5.2 10h1.6M5.2 12.4h2.4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><rect x="11.9" y="8.1" width="3.4" height="3.8" rx="1" fill="currentColor"/></svg>';
|
|
88
102
|
function chevron(className = 'chev') {
|
|
89
103
|
const mark = el('span', `${className} chev-icon`);
|
|
90
104
|
mark.innerHTML = ICON_CHEVRON;
|
|
@@ -205,7 +219,7 @@ const MODES = {
|
|
|
205
219
|
// gives back: with nowhere to type it, the sign-in could only die of its own timeout, which is
|
|
206
220
|
// exactly what it did. This holds what the room knows about the one running — which agent, the
|
|
207
221
|
// address, whether it is waiting — and `.login-ask` is where it is answered.
|
|
208
|
-
const loginNow = { agent: null, method: null, url: null, awaiting: false, asksForCode: false };
|
|
222
|
+
const loginNow = { agent: null, method: null, url: null, deviceCode: null, awaiting: false, asksForCode: false };
|
|
209
223
|
const looksLikeCodePrompt = (line) => /paste\s+(?:the\s+)?code|enter\s+(?:the\s+)?code|c[oó]digo\s*[:>]/i.test(String(line ?? ''));
|
|
210
224
|
|
|
211
225
|
const state = {
|
|
@@ -221,6 +235,7 @@ const state = {
|
|
|
221
235
|
ollamaAsked: false,
|
|
222
236
|
ollamaRecommended: null,
|
|
223
237
|
ratings: new Map(), // messageId → good | bad, from the ledger
|
|
238
|
+
deliveries: new Map(), // D-S17: id of a delivered note → its row
|
|
224
239
|
ratingNodes: new Map(), // messageId → the buttons of that bubble // messageId -> { text, target }
|
|
225
240
|
lastSequence: 0,
|
|
226
241
|
lastSender: null, // for iMessage-style grouping of consecutive bubbles
|
|
@@ -339,13 +354,46 @@ function touchedUnder(dir) {
|
|
|
339
354
|
|
|
340
355
|
/* ---------- helpers ---------- */
|
|
341
356
|
|
|
357
|
+
// The signs a key's words may end in, drawn by tailMark (below).
|
|
358
|
+
const TAIL_MARK = /\s*[↗▾]\s*$/;
|
|
359
|
+
const OUT_ICON = '<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M5.2 10.8 10.6 5.4M6.4 5.2h4.4v4.4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
342
360
|
const el = (tag, className, text) => {
|
|
343
361
|
const node = document.createElement(tag);
|
|
344
362
|
if (className) node.className = className;
|
|
345
363
|
if (text !== undefined) node.textContent = text;
|
|
364
|
+
if ((tag === 'a' || tag === 'button') && typeof text === 'string' && TAIL_MARK.test(text)) tailMark(node);
|
|
346
365
|
return node;
|
|
347
366
|
};
|
|
348
367
|
|
|
368
|
+
// A key or a link whose words end in ↗ goes somewhere outside the room, and one that ends in ▾
|
|
369
|
+
// opens a menu (1.0.3): the sign is drawn, at the size of a mark, and the words stay as they are
|
|
370
|
+
// (the catalogue keeps the sign, so a translation never loses it). The page's own markup goes
|
|
371
|
+
// through the same after it is translated, the way its × does.
|
|
372
|
+
function tailMark(node) {
|
|
373
|
+
const last = [...(node?.childNodes ?? [])].reverse().find((one) => one.nodeType === 3 && one.nodeValue.trim());
|
|
374
|
+
const sign = last?.nodeValue.match(TAIL_MARK)?.[0]?.trim();
|
|
375
|
+
if (!sign) return node;
|
|
376
|
+
last.nodeValue = last.nodeValue.replace(TAIL_MARK, ' ');
|
|
377
|
+
const mark = document.createElement('span');
|
|
378
|
+
mark.className = sign === '↗' ? 'tail-mark out' : 'tail-mark menu';
|
|
379
|
+
mark.innerHTML = sign === '↗' ? OUT_ICON : ICON_CHEVRON; // trusted: our own SVG strings
|
|
380
|
+
node.append(mark);
|
|
381
|
+
return node;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// A label that opens with ◉ (still open here, decided here, memory saved): the ring and its dot are
|
|
385
|
+
// drawn at the size of a mark, the words after them as written.
|
|
386
|
+
const LEAD_ICON = '<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false"><circle cx="8" cy="8" r="5.4" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="8" cy="8" r="2.1" fill="currentColor"/></svg>';
|
|
387
|
+
function leadWords(tag, className, said) {
|
|
388
|
+
const node = el(tag, className);
|
|
389
|
+
const words = String(said ?? '').replace(/^◉\s*/u, '');
|
|
390
|
+
if (words === said) { node.textContent = said; return node; }
|
|
391
|
+
const mark = el('span', 'lead-mark');
|
|
392
|
+
mark.innerHTML = LEAD_ICON; // trusted: our own SVG string
|
|
393
|
+
node.append(mark, words);
|
|
394
|
+
return node;
|
|
395
|
+
}
|
|
396
|
+
|
|
349
397
|
// Every way to close something closes the same way: one × drawn as a line, not a letter, that
|
|
350
398
|
// turns a quarter and thickens under the pointer — the one the conversations panel already had.
|
|
351
399
|
// A labelled button keeps its words («CLOSE ×» says what it closes); only its × becomes the mark.
|
|
@@ -470,20 +518,48 @@ function toastTone(message, tone) {
|
|
|
470
518
|
if (TOAST_LOOK.test(message)) return 'look';
|
|
471
519
|
return 'done';
|
|
472
520
|
}
|
|
473
|
-
|
|
521
|
+
// `action` ({ label, run }) puts one key at its end: the Market's news says «See it» (2026-10-18).
|
|
522
|
+
function toast(message, { tone = null, action = null } = {}) {
|
|
474
523
|
placeToast();
|
|
475
524
|
const text = String(message);
|
|
476
|
-
const kind = toastTone(text, tone);
|
|
525
|
+
const kind = action && !tone ? 'news' : toastTone(text, tone);
|
|
477
526
|
// Who speaks stays in the words a screen reader hears; on screen the dot says enough.
|
|
478
527
|
// The prefix the room's own notices carry, in the room's language (WEB-14): after MU/TH/UR became
|
|
479
528
|
// Diagnóstico the old one was looked for, and «Diagnóstico ›» showed in every notice.
|
|
480
529
|
const from = [t('Diagnostics › '), 'MU/TH/UR › '].find((prefix) => text.startsWith(prefix)) ?? '';
|
|
481
530
|
els.toast.replaceChildren(el('i', 'toast-dot'), ...(from ? [el('span', 'toast-from', from)] : []), el('span', 'toast-text', text.slice(from.length)));
|
|
531
|
+
if (action) {
|
|
532
|
+
const key = el('button', 'toast-act', action.label);
|
|
533
|
+
key.type = 'button';
|
|
534
|
+
key.addEventListener('click', () => { clearTimeout(toastTimer); els.toast.hidden = true; roomForToast(false); action.run(); });
|
|
535
|
+
els.toast.append(key);
|
|
536
|
+
}
|
|
482
537
|
els.toast.dataset.tone = kind;
|
|
483
538
|
els.toast.classList?.toggle('note', kind === 'note');
|
|
539
|
+
els.toast.classList?.remove('leaving');
|
|
484
540
|
els.toast.hidden = false;
|
|
541
|
+
roomForToast(true);
|
|
485
542
|
clearTimeout(toastTimer);
|
|
486
|
-
|
|
543
|
+
// It closes back into its dot before it goes (styles.css, toast-fold).
|
|
544
|
+
toastTimer = setTimeout(() => {
|
|
545
|
+
els.toast.classList?.add('leaving');
|
|
546
|
+
toastTimer = setTimeout(() => { els.toast.hidden = true; els.toast.classList?.remove('leaving'); roomForToast(false); }, 320);
|
|
547
|
+
}, action ? 10000 : 6000);
|
|
548
|
+
}
|
|
549
|
+
// While a notice floats over the end of the conversation, a reader at the end gets the room it
|
|
550
|
+
// takes: the last line rises above it instead of going under it. The thread follows the column as
|
|
551
|
+
// it grows and shrinks.
|
|
552
|
+
function roomForToast(on) {
|
|
553
|
+
const root = document.documentElement;
|
|
554
|
+
if (!root?.classList || !root.style?.setProperty) return;
|
|
555
|
+
root.classList.toggle('toasting', on);
|
|
556
|
+
if (on) root.style.setProperty('--toast-h', `${Math.ceil(els.toast.offsetHeight || 38)}px`);
|
|
557
|
+
const room = on && pinnedToEnd && !pageScrolls();
|
|
558
|
+
if (root.classList.contains('toast-room') === room) return;
|
|
559
|
+
root.classList.toggle('toast-room', room);
|
|
560
|
+
const until = performance.now() + 340;
|
|
561
|
+
const follow = () => { keepPinned(); if (performance.now() < until) requestAnimationFrame(follow); };
|
|
562
|
+
requestAnimationFrame?.(follow);
|
|
487
563
|
}
|
|
488
564
|
|
|
489
565
|
// Nothing in the room speaks on hover: not a button, not a chip, not a cut-off title. Every
|
|
@@ -514,7 +590,7 @@ const scroller = {
|
|
|
514
590
|
set top(value) { if (this.node) this.node.scrollTop = value; },
|
|
515
591
|
get height() { return this.node?.scrollHeight ?? 0; },
|
|
516
592
|
get view() { return pageScrolls() ? (window.innerHeight ?? 0) : (els.thread?.clientHeight ?? 0); },
|
|
517
|
-
to(options) { (pageScrolls() ? window : els.thread)?.scrollTo?.(options); },
|
|
593
|
+
to(options) { ourScroll(options?.behavior === 'smooth' ? 900 : 160); (pageScrolls() ? window : els.thread)?.scrollTo?.(options); },
|
|
518
594
|
};
|
|
519
595
|
|
|
520
596
|
// Back to the box after a choice that is not writing (an agent, a model, an effort). On a phone
|
|
@@ -523,8 +599,18 @@ function focusComposer() {
|
|
|
523
599
|
if (!state.device) els.input?.focus();
|
|
524
600
|
}
|
|
525
601
|
|
|
602
|
+
// What a node says, changed in place: the text it already holds is rewritten rather than replaced.
|
|
603
|
+
// A replaced child is a mutation the window watcher answers with a layout of every window
|
|
604
|
+
// (paintDepth), and the working clock changes once a second.
|
|
605
|
+
function setText(node, value) {
|
|
606
|
+
const first = node.firstChild;
|
|
607
|
+
if (first && first.nodeType === 3 && !first.nextSibling) { if (first.data !== value) first.data = value; return; }
|
|
608
|
+
node.textContent = value;
|
|
609
|
+
}
|
|
610
|
+
|
|
526
611
|
function scrollToEnd() {
|
|
527
|
-
scroller.top
|
|
612
|
+
scroller.to({ top: scroller.height, behavior: 'instant' });
|
|
613
|
+
setPinned(true);
|
|
528
614
|
}
|
|
529
615
|
|
|
530
616
|
// The thread is pinned to its own end, and anything that changes how tall it is has to put it
|
|
@@ -533,13 +619,30 @@ function scrollToEnd() {
|
|
|
533
619
|
// conversation having been lost. The observer is the honest fix — it does not matter what moved,
|
|
534
620
|
// only that the height is no longer the one the scroll position was computed against.
|
|
535
621
|
// Whether the human is reading the end or has gone back for something. Only THEIR scrolling
|
|
536
|
-
// decides it
|
|
537
|
-
//
|
|
622
|
+
// decides it. A programmatic scroll fires the same event, so the room marks its own scrolls for a
|
|
623
|
+
// moment (ourScroll) and only a scroll outside that moment is measured: the wheel, a finger, the
|
|
624
|
+
// scrollbar dragged, the keyboard, ⌘F. It used to be measured 90 ms after a wheel turn, and a
|
|
625
|
+
// relayout inside those 90 ms pulled the thread back to its end before the measure could see the
|
|
626
|
+
// human had gone up — while an agent worked, the thread would not let anyone scroll up.
|
|
538
627
|
let pinnedToEnd = true;
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
628
|
+
let ourScrollUntil = 0;
|
|
629
|
+
function ourScroll(ms = 160) { ourScrollUntil = Math.max(ourScrollUntil, performance.now() + ms); }
|
|
630
|
+
const distanceToEnd = () => scroller.height - scroller.top - scroller.view;
|
|
631
|
+
function setPinned(value) {
|
|
632
|
+
if (pinnedToEnd === value) return;
|
|
633
|
+
pinnedToEnd = value;
|
|
634
|
+
if (value) clearFinishedBelow();
|
|
635
|
+
}
|
|
636
|
+
const measurePin = () => setPinned(distanceToEnd() < 64);
|
|
637
|
+
// A turn of the wheel upward leaves the end at once: nothing may pull the thread back under it.
|
|
638
|
+
els.thread?.addEventListener('wheel', (event) => {
|
|
639
|
+
ourScrollUntil = 0;
|
|
640
|
+
if (event.deltaY < 0 && scroller.top > 0) setPinned(false);
|
|
641
|
+
}, { passive: true });
|
|
642
|
+
for (const how of ['touchstart', 'pointerdown']) els.thread?.addEventListener(how, (event) => { if (how === 'touchstart' || event.target === els.thread) ourScrollUntil = 0; }, { passive: true });
|
|
643
|
+
const onThreadScroll = () => { if (performance.now() >= ourScrollUntil) measurePin(); };
|
|
644
|
+
els.thread?.addEventListener('scroll', onThreadScroll, { passive: true });
|
|
645
|
+
window.addEventListener?.('scroll', () => { if (pageScrolls()) onThreadScroll(); }, { passive: true });
|
|
543
646
|
|
|
544
647
|
// The thread is pinned to its own end, and anything that changes how tall it is has to put it
|
|
545
648
|
// back there. Opening the browser beside it, dragging the divider, closing it again: each one
|
|
@@ -550,6 +653,7 @@ for (const how of ['wheel', 'touchmove', 'keydown']) els.thread?.addEventListene
|
|
|
550
653
|
// next frame of it, which is how the thread ended up parked hundreds of pixels short of its end.
|
|
551
654
|
function keepPinned() {
|
|
552
655
|
if (!pinnedToEnd || !els.thread) return;
|
|
656
|
+
if (distanceToEnd() < 1) return;
|
|
553
657
|
scroller.to({ top: scroller.height, behavior: 'instant' });
|
|
554
658
|
}
|
|
555
659
|
if (typeof ResizeObserver === 'function' && els.thread) {
|
|
@@ -559,6 +663,40 @@ if (typeof ResizeObserver === 'function' && els.thread) {
|
|
|
559
663
|
if (els.composer) watch.observe(els.composer);
|
|
560
664
|
}
|
|
561
665
|
|
|
666
|
+
// Gone up to read while an agent works, the human is not pulled down when it answers: a light
|
|
667
|
+
// round button over the box says something finished below — in the colour of who answered — and
|
|
668
|
+
// takes them there. It goes away at the end, however they get there.
|
|
669
|
+
const finishedBelow = { button: null, who: null };
|
|
670
|
+
function toEndButton() {
|
|
671
|
+
if (finishedBelow.button || !document.querySelector?.('.shell')) return finishedBelow.button;
|
|
672
|
+
const button = el('button', 'to-end');
|
|
673
|
+
button.type = 'button';
|
|
674
|
+
button.hidden = true;
|
|
675
|
+
button.innerHTML = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 4.5v10.5M5.2 10.4 10 15.2l4.8-4.8" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg><i class="to-end-dot"></i>'; // trusted: our own SVG string
|
|
676
|
+
button.addEventListener('click', () => {
|
|
677
|
+
setPinned(true);
|
|
678
|
+
scroller.to({ top: scroller.height, behavior: 'smooth' });
|
|
679
|
+
});
|
|
680
|
+
document.querySelector('.shell').append(button);
|
|
681
|
+
finishedBelow.button = button;
|
|
682
|
+
return button;
|
|
683
|
+
}
|
|
684
|
+
function markFinishedBelow(agent, plan = false) {
|
|
685
|
+
const button = toEndButton();
|
|
686
|
+
if (!button || pinnedToEnd) return;
|
|
687
|
+
if (agent) tint(button, agent);
|
|
688
|
+
finishedBelow.who = agent;
|
|
689
|
+
button.setAttribute('aria-label', plan ? t('The plan finished below · go to the end') : t('@{agent} answered below · go to the end', { agent: agent ?? 'madre' }));
|
|
690
|
+
placeToast();
|
|
691
|
+
if (button.hidden) { button.hidden = false; button.classList.remove('leaving'); }
|
|
692
|
+
}
|
|
693
|
+
function clearFinishedBelow() {
|
|
694
|
+
const button = finishedBelow.button;
|
|
695
|
+
if (!button || button.hidden || button.classList.contains('leaving')) return;
|
|
696
|
+
button.classList.add('leaving');
|
|
697
|
+
setTimeout(() => { if (button.classList.contains('leaving')) { button.hidden = true; button.classList.remove('leaving'); } }, 200);
|
|
698
|
+
}
|
|
699
|
+
|
|
562
700
|
/* ---------- safe Markdown (DOM only, no HTML injection) ---------- */
|
|
563
701
|
|
|
564
702
|
const SAFE_URL = /^https?:\/\//i;
|
|
@@ -731,6 +869,7 @@ function paintSelection() {
|
|
|
731
869
|
// Nobody guesses that a line number is clickable: while nothing is picked, the status line says how.
|
|
732
870
|
if (viewerUI.hintSelect) viewerUI.hintSelect.hidden = has || !viewerUI.lineNodes.length;
|
|
733
871
|
if (has) viewerUI.selection.textContent = runs.map(([lo, hi]) => (lo === hi ? `L${lo}` : `L${lo}–${hi}`)).join(', ');
|
|
872
|
+
if (has) paintCodeCrumbs(runs[0][0]);
|
|
734
873
|
}
|
|
735
874
|
function setSelection(from, to) {
|
|
736
875
|
viewerUI.picked = new Set();
|
|
@@ -818,6 +957,14 @@ function showViewerMenu(x, y) {
|
|
|
818
957
|
function hideViewerMenu() { try { viewerUI.menu.hidePopover?.(); } catch { /* not open */ } viewerUI.menu.hidden = true; }
|
|
819
958
|
viewerUI.review?.addEventListener('click', (event) => {
|
|
820
959
|
const rect = event.currentTarget.getBoundingClientRect();
|
|
960
|
+
// One run of lines picked in a file of the project: the same card the editor opens, with its
|
|
961
|
+
// verbs. Several runs, or a file from elsewhere, keep the list of agents.
|
|
962
|
+
const runs = pickedRuns();
|
|
963
|
+
if (runs.length === 1 && viewerUI.path && viewerMode.editable) {
|
|
964
|
+
const [from, to] = runs[0];
|
|
965
|
+
askAboutCode({ from, to, text: String(viewerMode.text).split('\n').slice(from - 1, to).join('\n'), anchor: rect, dirty: false });
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
821
968
|
if (viewerUI.menu.hidden) showViewerMenu(rect.left, rect.bottom + 6); else hideViewerMenu();
|
|
822
969
|
});
|
|
823
970
|
document.addEventListener('click', (event) => { if (viewerUI.menu && !viewerUI.menu.hidden && !viewerUI.menu.contains(event.target) && !viewerUI.review?.contains(event.target)) hideViewerMenu(); });
|
|
@@ -1024,7 +1171,7 @@ function closeAskCard() {
|
|
|
1024
1171
|
done?.();
|
|
1025
1172
|
}
|
|
1026
1173
|
const ASK_OWN_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M12.7 4.5a1.95 1.95 0 0 1 2.8 2.8l-8.3 8.3-3.6.9.9-3.6z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M11.5 5.7l2.8 2.8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
1027
|
-
function openAskCard({ anchor, head, suggestions, about = '', onAsk, onClose = null }) {
|
|
1174
|
+
function openAskCard({ anchor, head, suggestions, about = '', onAsk, onClose = null, actions = [] }) {
|
|
1028
1175
|
closeAskCard();
|
|
1029
1176
|
askCardClose = onClose;
|
|
1030
1177
|
const ready = [...state.agents.values()].filter((agent) => agent.ready);
|
|
@@ -1048,6 +1195,18 @@ function openAskCard({ anchor, head, suggestions, about = '', onAsk, onClose = n
|
|
|
1048
1195
|
crew.append(pick);
|
|
1049
1196
|
}
|
|
1050
1197
|
askCard.append(crew);
|
|
1198
|
+
// What is done most with code, one press each: the verb on the chip, the whole request behind it.
|
|
1199
|
+
if (actions.length) {
|
|
1200
|
+
const row = el('div', 'ask-actions');
|
|
1201
|
+
for (const [label, prompt] of actions) {
|
|
1202
|
+
const chip = el('button', 'ask-action', t(label));
|
|
1203
|
+
chip.type = 'button';
|
|
1204
|
+
chip.title = t(prompt);
|
|
1205
|
+
chip.addEventListener('click', () => ask(t(prompt)));
|
|
1206
|
+
row.append(chip);
|
|
1207
|
+
}
|
|
1208
|
+
askCard.append(row);
|
|
1209
|
+
}
|
|
1051
1210
|
const group = (title, items) => {
|
|
1052
1211
|
const box = el('div', 'ask-group');
|
|
1053
1212
|
box.append(el('div', 'ask-group-title', title));
|
|
@@ -1059,7 +1218,9 @@ function openAskCard({ anchor, head, suggestions, about = '', onAsk, onClose = n
|
|
|
1059
1218
|
}
|
|
1060
1219
|
return box;
|
|
1061
1220
|
};
|
|
1062
|
-
|
|
1221
|
+
const taken = new Set(actions.map(([, prompt]) => t(prompt)));
|
|
1222
|
+
const rest = suggestions.filter((words) => !taken.has(words));
|
|
1223
|
+
if (rest.length) askCard.append(group(t('SUGGESTED FOR THIS'), rest));
|
|
1063
1224
|
// What the archive holds about it, when the archive is open: asked once, added when it answers.
|
|
1064
1225
|
const remembered = el('div', 'ask-remembered');
|
|
1065
1226
|
askCard.append(remembered);
|
|
@@ -1158,9 +1319,13 @@ const askLeaveEdits = () => roomAsk({
|
|
|
1158
1319
|
body: t('There are unsaved changes in this file. Discard them?'),
|
|
1159
1320
|
choices: [{ value: 'stay', label: t('KEEP EDITING'), focus: true }, { value: 'leave', label: t('DISCARD THEM'), tone: 'danger' }],
|
|
1160
1321
|
});
|
|
1161
|
-
|
|
1322
|
+
// `stash`: in the code workspace a file left with unsaved changes keeps them in its tab, the way
|
|
1323
|
+
// every editor's tabs do, instead of asking; its tab wears the dot until it is saved or discarded.
|
|
1324
|
+
async function leaveEditor({ ask = true, stash = false } = {}) {
|
|
1162
1325
|
if (!editing.editor) return true;
|
|
1163
|
-
|
|
1326
|
+
const tab = stash && viewerUI.path ? codeTab(viewerUI.path) : null;
|
|
1327
|
+
if (tab) { tab.draft = editorDirty() ? editing.editor.value() : null; tab.mtime = viewerMode.mtime; }
|
|
1328
|
+
else if (ask && editorDirty() && await askLeaveEdits() !== 'leave') return false;
|
|
1164
1329
|
editing.editor.destroy();
|
|
1165
1330
|
editing.editor = null;
|
|
1166
1331
|
viewer.dialog.classList.remove('editing');
|
|
@@ -1196,11 +1361,11 @@ function syncEditButton() {
|
|
|
1196
1361
|
if (!viewer.dialog || !viewer.body) return;
|
|
1197
1362
|
const bar = el('div', 'viewer-status');
|
|
1198
1363
|
const mod = /Mac|iPhone|iPad/.test(navigator.platform ?? '') ? '⌘' : 'Ctrl';
|
|
1199
|
-
if (viewerUI.hintSelect) viewerUI.hintSelect.textContent = t('Click a number to pick a line · drag to pick several · Shift for a range · {mod} to add one more', { mod });
|
|
1364
|
+
if (viewerUI.hintSelect) viewerUI.hintSelect.textContent = viewerUI.hintSelect.title = t('Click a number to pick a line · drag to pick several · Shift for a range · {mod} to add one more', { mod });
|
|
1200
1365
|
for (const part of [viewerUI.selection, viewerUI.review, viewerUI.hintSelect]) if (part) bar.append(part);
|
|
1201
1366
|
viewer.body.after?.(bar);
|
|
1202
1367
|
})();
|
|
1203
|
-
async function saveEdited(content) {
|
|
1368
|
+
async function saveEdited(content, { said = null } = {}) {
|
|
1204
1369
|
const send = (extra = {}) => fetch('/api/files', {
|
|
1205
1370
|
method: 'PUT',
|
|
1206
1371
|
headers: { 'content-type': 'application/json' },
|
|
@@ -1238,7 +1403,13 @@ async function saveEdited(content) {
|
|
|
1238
1403
|
if (!response.ok) { toast(`${t('Diagnostics › ')}${result.error ?? t('That could not be saved.')}`); return false; }
|
|
1239
1404
|
viewerMode.mtime = String(result.mtimeMs ?? '');
|
|
1240
1405
|
viewerMode.text = content;
|
|
1241
|
-
toast(t('Saved · {path}', { path: viewerUI.path }), { tone: 'note' });
|
|
1406
|
+
toast(said ?? t('Saved · {path}', { path: viewerUI.path }), { tone: 'note' });
|
|
1407
|
+
// What is beside the text shows the saved file, and the agent's marks are placed again on it.
|
|
1408
|
+
code.preview?.reload?.();
|
|
1409
|
+
code.preview?.render?.();
|
|
1410
|
+
const tab = codeTab(viewerUI.path);
|
|
1411
|
+
if (tab) tab.draft = null;
|
|
1412
|
+
if (viewerUI.path) void loadCodeChanges(viewerUI.path);
|
|
1242
1413
|
return true;
|
|
1243
1414
|
}
|
|
1244
1415
|
function askAboutLines({ from, to, prompt, dirty, agentId = null }) {
|
|
@@ -1267,21 +1438,31 @@ function askAboutCode({ from, to, text, anchor, dirty }) {
|
|
|
1267
1438
|
head: from === to ? t('Line {n}', { n: from }) : t('Lines {from}–{to}', { from, to }),
|
|
1268
1439
|
suggestions: codeSuggestions(text, familyOf(viewerUI.path ?? '')?.id ?? null, viewerUI.path ?? ''),
|
|
1269
1440
|
about: `${viewerUI.path ?? ''} ${text}`,
|
|
1441
|
+
actions: CODE_ACTIONS,
|
|
1270
1442
|
onAsk: (agentId, prompt) => askAboutLines({ from, to, prompt, dirty, agentId }),
|
|
1271
1443
|
});
|
|
1272
1444
|
}
|
|
1273
1445
|
function openEditor(line = null) {
|
|
1274
1446
|
if (editing.editor || !viewerMode.editable) return;
|
|
1275
1447
|
const name = viewerUI.path ?? '';
|
|
1448
|
+
const tab = codeTab(name);
|
|
1276
1449
|
editing.editor = createEditor({
|
|
1277
|
-
text: viewerMode.text, name, family: familyOf(name), tokenize, t, fill,
|
|
1450
|
+
text: viewerMode.text, draft: tab?.draft ?? null, name, family: familyOf(name), tokenize, t, fill,
|
|
1278
1451
|
onSave: saveEdited, onAsk: askAboutCode,
|
|
1452
|
+
onDirty: () => drawCodeTabs(),
|
|
1453
|
+
onCaret: (at) => paintCodeCrumbs(at),
|
|
1279
1454
|
});
|
|
1455
|
+
if (tab) tab.draft = null;
|
|
1280
1456
|
viewer.body.replaceChildren(editing.editor.node);
|
|
1281
1457
|
viewer.dialog.classList.add('editing');
|
|
1282
1458
|
setSelection(null, null);
|
|
1283
1459
|
if (viewerUI.hintSelect) viewerUI.hintSelect.hidden = true;
|
|
1284
1460
|
syncEditButton();
|
|
1461
|
+
decorateCode();
|
|
1462
|
+
attachBeside();
|
|
1463
|
+
// A document beside its text follows the typing; a page waits for the save.
|
|
1464
|
+
let typing = 0;
|
|
1465
|
+
editing.editor.node.addEventListener('input', () => { clearTimeout(typing); typing = setTimeout(() => code.preview?.render?.(), 220); });
|
|
1285
1466
|
requestAnimationFrame(() => editing.editor?.focus(line ?? viewerUI.from ?? null));
|
|
1286
1467
|
}
|
|
1287
1468
|
editing.button?.addEventListener('click', async () => {
|
|
@@ -1291,11 +1472,15 @@ editing.button?.addEventListener('click', async () => {
|
|
|
1291
1472
|
// Closing the viewer, or opening another file in it, never drops unsaved work without asking.
|
|
1292
1473
|
viewer.dialog?.addEventListener('cancel', (event) => {
|
|
1293
1474
|
if (!editorDirty()) return;
|
|
1475
|
+
// Docked, the unsaved changes stay in their tab and the viewer closes as asked.
|
|
1476
|
+
let docked = false;
|
|
1477
|
+
try { docked = codeOn(); } catch { /* not yet */ }
|
|
1478
|
+
if (docked && viewerUI.path && codeTab(viewerUI.path)) { void leaveEditor({ stash: true }); return; }
|
|
1294
1479
|
event.preventDefault();
|
|
1295
1480
|
void askLeaveEdits().then((answer) => { if (answer === 'leave') { void leaveEditor({ ask: false }); viewer.dialog.close(); } });
|
|
1296
1481
|
});
|
|
1297
1482
|
viewer.dialog?.addEventListener('close', () => leaveEditor({ ask: false }));
|
|
1298
|
-
window.addEventListener?.('beforeunload', (event) => { if (editorDirty()) { event.preventDefault(); event.returnValue = ''; } });
|
|
1483
|
+
window.addEventListener?.('beforeunload', (event) => { if (editorDirty() || code.tabs.some((tab) => tab.draft !== null)) { event.preventDefault(); event.returnValue = ''; } });
|
|
1299
1484
|
|
|
1300
1485
|
function showViewerSource() {
|
|
1301
1486
|
if (ripley.nav) ripley.nav.hidden = true;
|
|
@@ -1310,8 +1495,619 @@ function showViewerSource() {
|
|
|
1310
1495
|
}
|
|
1311
1496
|
if (viewerMode.lines) setSelection(viewerMode.lines.from, viewerMode.lines.to);
|
|
1312
1497
|
else if (viewerMode.line) setSelection(viewerMode.line, viewerMode.line);
|
|
1498
|
+
decorateCode();
|
|
1499
|
+
attachBeside();
|
|
1500
|
+
paintCodeCrumbs();
|
|
1313
1501
|
pre.querySelector('.hl')?.scrollIntoView?.({ block: 'center' });
|
|
1314
1502
|
}
|
|
1503
|
+
/* ---------- the code workspace (1.0.3) ---------- */
|
|
1504
|
+
//
|
|
1505
|
+
// The viewer docked beside the conversation is a place to work on code, not a sheet over it: the
|
|
1506
|
+
// project's files at its side, the files open as tabs, the path down to the function the caret is
|
|
1507
|
+
// in, what an agent changed marked in the agent's colour — with a way to keep it, take it back or
|
|
1508
|
+
// ask why — and, for a page or a document, the result beside the text. CODE in the bar opens it
|
|
1509
|
+
// docked; the viewer's own split and window buttons lead to the same place. Over the room (its base)
|
|
1510
|
+
// the viewer stays what it always was: one file, read or edited.
|
|
1511
|
+
const CODE_KEY = 'pulse.code';
|
|
1512
|
+
const CODE_TABS = 12;
|
|
1513
|
+
const code = (() => {
|
|
1514
|
+
let saved = {};
|
|
1515
|
+
try { saved = JSON.parse(localStorage.getItem(CODE_KEY) ?? '{}') ?? {}; } catch { /* no storage */ }
|
|
1516
|
+
const tabs = (Array.isArray(saved.tabs) ? saved.tabs : []).filter((path) => typeof path === 'string' && path).slice(-CODE_TABS).map((path) => ({ path, draft: null, mtime: null }));
|
|
1517
|
+
return {
|
|
1518
|
+
tabs,
|
|
1519
|
+
active: tabs.some((tab) => tab.path === saved.active) ? saved.active : tabs.at(-1)?.path ?? null,
|
|
1520
|
+
files: saved.files !== false,
|
|
1521
|
+
beside: saved.beside !== false,
|
|
1522
|
+
changes: new Map(), // path → { agent, checkpointId, at, messageId, hunks } — what an agent left here
|
|
1523
|
+
button: document.querySelector('#code-button'),
|
|
1524
|
+
besideButton: null,
|
|
1525
|
+
bar: null, crumbs: null, shell: null, explorer: null, list: null, search: null,
|
|
1526
|
+
dirs: new Set(),
|
|
1527
|
+
preview: null,
|
|
1528
|
+
line: null,
|
|
1529
|
+
};
|
|
1530
|
+
})();
|
|
1531
|
+
function rememberCode() {
|
|
1532
|
+
try { localStorage.setItem(CODE_KEY, JSON.stringify({ tabs: code.tabs.map((tab) => tab.path), active: code.active, files: code.files, beside: code.beside })); } catch { /* this page only */ }
|
|
1533
|
+
}
|
|
1534
|
+
const viewerSurface = () => surfaces.find((one) => one.key === 'viewer');
|
|
1535
|
+
const codeOn = () => Boolean(viewer.dialog?.open) && modeOf(viewerSurface()) !== 'base';
|
|
1536
|
+
const codeTab = (path) => code.tabs.find((tab) => tab.path === path) ?? null;
|
|
1537
|
+
const codeDrafts = () => code.tabs.filter((tab) => tab.draft !== null);
|
|
1538
|
+
const CODE_ACTIONS = [
|
|
1539
|
+
['Explain', 'Explain what this does'],
|
|
1540
|
+
['Improve', 'Improve this without changing what it does'],
|
|
1541
|
+
['Write tests', 'Write tests for this'],
|
|
1542
|
+
['Fix', 'Find and fix the bugs here'],
|
|
1543
|
+
];
|
|
1544
|
+
|
|
1545
|
+
// Where it lives in the viewer: the tabs and the path under its header, the files beside its body.
|
|
1546
|
+
(() => {
|
|
1547
|
+
if (!viewer.dialog || !viewer.body || typeof viewer.body.before !== 'function') return;
|
|
1548
|
+
const head = viewer.dialog.querySelector('.viewer-head');
|
|
1549
|
+
code.bar = el('div', 'code-tabs');
|
|
1550
|
+
code.bar.setAttribute('role', 'tablist');
|
|
1551
|
+
code.bar.setAttribute('aria-label', t('Open files'));
|
|
1552
|
+
code.bar.hidden = true;
|
|
1553
|
+
code.crumbs = el('div', 'code-crumbs');
|
|
1554
|
+
code.crumbs.hidden = true;
|
|
1555
|
+
head?.after?.(code.bar, code.crumbs);
|
|
1556
|
+
code.shell = el('div', 'code-shell');
|
|
1557
|
+
code.explorer = el('aside', 'code-files');
|
|
1558
|
+
code.explorer.hidden = true;
|
|
1559
|
+
const top = el('div', 'code-files-head');
|
|
1560
|
+
top.append(el('span', null, t('Files')));
|
|
1561
|
+
const fold = el('button', 'code-files-fold');
|
|
1562
|
+
fold.type = 'button';
|
|
1563
|
+
fold.setAttribute('aria-label', t('Hide the files'));
|
|
1564
|
+
fold.title = t('Hide the files');
|
|
1565
|
+
fold.innerHTML = SPLIT_LEFT_ICON; // trusted: our own SVG string
|
|
1566
|
+
fold.addEventListener('click', () => { code.files = false; rememberCode(); syncCode(); });
|
|
1567
|
+
top.append(fold);
|
|
1568
|
+
code.search = el('input', 'code-files-search');
|
|
1569
|
+
code.search.type = 'search';
|
|
1570
|
+
code.search.placeholder = t('Find a file…');
|
|
1571
|
+
code.search.setAttribute('aria-label', t('Find a file…'));
|
|
1572
|
+
code.search.autocomplete = 'off';
|
|
1573
|
+
code.search.spellcheck = false;
|
|
1574
|
+
code.list = el('ul', 'code-files-list');
|
|
1575
|
+
code.explorer.append(top, code.search, code.list);
|
|
1576
|
+
let typing = 0;
|
|
1577
|
+
code.search.addEventListener('input', () => { clearTimeout(typing); typing = setTimeout(() => void drawCodeFiles(), 140); });
|
|
1578
|
+
code.search.addEventListener('keydown', (event) => {
|
|
1579
|
+
if (event.key === 'Escape' && code.search.value) { event.preventDefault(); event.stopPropagation(); code.search.value = ''; void drawCodeFiles(); }
|
|
1580
|
+
if (event.key === 'Enter') { const first = code.list.querySelector('.code-file'); if (first) { event.preventDefault(); first.click(); } }
|
|
1581
|
+
});
|
|
1582
|
+
viewer.body.before(code.shell);
|
|
1583
|
+
code.shell.append(code.explorer, viewer.body);
|
|
1584
|
+
// Side by side: the text and what it makes, for a page or a document.
|
|
1585
|
+
const modeButton = document.querySelector('#viewer-mode');
|
|
1586
|
+
code.besideButton = el('button', 'viewer-review viewer-beside');
|
|
1587
|
+
code.besideButton.type = 'button';
|
|
1588
|
+
code.besideButton.hidden = true;
|
|
1589
|
+
// Its words, and its drawing for when the header is too narrow for them.
|
|
1590
|
+
code.besideButton.innerHTML = `${BESIDE_ICON}<span></span>`; // trusted: our own SVG string
|
|
1591
|
+
code.besideButton.addEventListener('click', () => { code.beside = !code.beside; rememberCode(); attachBeside(); });
|
|
1592
|
+
modeButton?.before?.(code.besideButton);
|
|
1593
|
+
if (typeof MutationObserver === 'function') new MutationObserver(() => syncCode()).observe(viewer.dialog, { attributes: true, attributeFilter: ['class', 'open'] });
|
|
1594
|
+
if (typeof ResizeObserver === 'function') new ResizeObserver(() => syncCode()).observe(viewer.dialog);
|
|
1595
|
+
code.button?.addEventListener('click', () => void toggleCode());
|
|
1596
|
+
})();
|
|
1597
|
+
|
|
1598
|
+
function syncCode() {
|
|
1599
|
+
if (!code.bar) return;
|
|
1600
|
+
let on = false;
|
|
1601
|
+
try { on = codeOn(); } catch { return; }
|
|
1602
|
+
viewer.dialog.classList.toggle('code', on);
|
|
1603
|
+
code.bar.hidden = !on || !code.tabs.length;
|
|
1604
|
+
code.crumbs.hidden = !on || !viewerUI.path;
|
|
1605
|
+
// The code comes first: the files beside it only when there is room for them and for the code,
|
|
1606
|
+
// and less room is left when what the code makes is beside it too.
|
|
1607
|
+
const width = viewer.dialog.getBoundingClientRect?.().width ?? 0;
|
|
1608
|
+
const wide = width >= (code.preview ? 1040 : 600);
|
|
1609
|
+
// Too narrow for two columns, what the code makes goes under it instead of squeezing it.
|
|
1610
|
+
viewer.body.classList.toggle('stacked', Boolean(code.preview) && width < 720);
|
|
1611
|
+
const showFiles = on && code.files && wide;
|
|
1612
|
+
if (code.explorer.hidden === showFiles) {
|
|
1613
|
+
code.explorer.hidden = !showFiles;
|
|
1614
|
+
if (showFiles) void drawCodeFiles();
|
|
1615
|
+
}
|
|
1616
|
+
code.button?.setAttribute('aria-pressed', String(on));
|
|
1617
|
+
const kind = viewerMode.kind;
|
|
1618
|
+
code.besideButton.hidden = !on || !kind || viewerMode.preview;
|
|
1619
|
+
const besideWords = code.besideButton.querySelector?.('span');
|
|
1620
|
+
if (besideWords) besideWords.textContent = t('Side by side');
|
|
1621
|
+
else code.besideButton.textContent = t('Side by side');
|
|
1622
|
+
code.besideButton.setAttribute('aria-label', t('Side by side'));
|
|
1623
|
+
code.besideButton.setAttribute('aria-pressed', String(Boolean(code.preview)));
|
|
1624
|
+
code.besideButton.title = kind === 'markdown' ? t('The document beside its text, as you write') : t('The page beside its code, reloaded when you save');
|
|
1625
|
+
drawCodeTabs();
|
|
1626
|
+
paintCodeCrumbs();
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// CODE in the bar: the viewer docked, on the file last worked on, or on where to start.
|
|
1630
|
+
async function toggleCode() {
|
|
1631
|
+
if (codeOn()) {
|
|
1632
|
+
if (await leaveEditor({ stash: true })) viewer.dialog.close();
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
const surface = viewerSurface();
|
|
1636
|
+
if (surface && modeOf(surface) === 'base') setSurfaceMode(surface, 'split', { animate: false });
|
|
1637
|
+
if (viewer.dialog.open && viewerUI.path) { syncCode(); return; }
|
|
1638
|
+
if (code.active) await openViewer({ root: 'project', path: code.active });
|
|
1639
|
+
else showCodeHome();
|
|
1640
|
+
}
|
|
1641
|
+
async function openCodeFile(path, { line = null } = {}) {
|
|
1642
|
+
const surface = viewerSurface();
|
|
1643
|
+
if (surface && modeOf(surface) === 'base') setSurfaceMode(surface, 'split', { animate: false });
|
|
1644
|
+
await openViewer({ root: 'project', path, line });
|
|
1645
|
+
}
|
|
1646
|
+
// Nothing open yet: where to start, in a sentence and two ways.
|
|
1647
|
+
function showCodeHome() {
|
|
1648
|
+
void leaveEditor({ stash: true });
|
|
1649
|
+
paintViewerPath(t('Code'));
|
|
1650
|
+
viewerUI.path = null;
|
|
1651
|
+
Object.assign(viewerMode, { kind: null, text: '', src: '', preview: false, editable: false });
|
|
1652
|
+
viewer.open.removeAttribute('href');
|
|
1653
|
+
syncViewerMode();
|
|
1654
|
+
syncEditButton();
|
|
1655
|
+
// No file, nothing to open raw: the chip that holds OPEN RAW goes with READ and EDIT.
|
|
1656
|
+
if (editingSeg) editingSeg.seg.hidden = true;
|
|
1657
|
+
const home = el('div', 'code-home');
|
|
1658
|
+
home.append(el('h3', null, t('Open a file')));
|
|
1659
|
+
const mod = /Mac|iPhone|iPad/.test(navigator.platform ?? '') ? '⌘P' : 'Ctrl+P';
|
|
1660
|
+
home.append(el('p', null, t('Choose one in the files, or press {keys} to find it by its name. What an agent changes is marked in its colour.', { keys: mod })));
|
|
1661
|
+
const find = sxButton(t('Find a file'), () => openQuickOpen(), { tone: 'primary' });
|
|
1662
|
+
home.append(find);
|
|
1663
|
+
const recent = [...touched.entries()].sort((a, b) => b[1].at - a[1].at).slice(0, 6);
|
|
1664
|
+
if (recent.length) {
|
|
1665
|
+
home.append(el('h4', null, t('Changed by the agents')));
|
|
1666
|
+
const list = el('ul', 'code-home-list');
|
|
1667
|
+
for (const [path, touch] of recent) {
|
|
1668
|
+
const item = el('li');
|
|
1669
|
+
const go = el('button', 'code-file');
|
|
1670
|
+
go.type = 'button';
|
|
1671
|
+
tint(go, touch.agent);
|
|
1672
|
+
go.append(el('i', 'code-file-agent'), el('span', 'code-file-name', path.split('/').pop()), el('span', 'code-file-dir', path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''), el('small', null, `@${touch.agent} · ${agoWords(new Date(touch.at).toISOString())}`));
|
|
1673
|
+
go.addEventListener('click', () => void openCodeFile(path));
|
|
1674
|
+
item.append(go);
|
|
1675
|
+
list.append(item);
|
|
1676
|
+
}
|
|
1677
|
+
home.append(list);
|
|
1678
|
+
}
|
|
1679
|
+
viewer.body.replaceChildren(home);
|
|
1680
|
+
if (!viewer.dialog.open) viewer.dialog.showModal();
|
|
1681
|
+
syncCode();
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
/* the tabs */
|
|
1685
|
+
function noteCodeTab(path) {
|
|
1686
|
+
let tab = codeTab(path);
|
|
1687
|
+
if (!tab) {
|
|
1688
|
+
tab = { path, draft: null, mtime: null };
|
|
1689
|
+
code.tabs.push(tab);
|
|
1690
|
+
// Past the limit the oldest tab that holds nothing unsaved gives way.
|
|
1691
|
+
while (code.tabs.length > CODE_TABS) {
|
|
1692
|
+
const old = code.tabs.findIndex((one) => one.draft === null && one.path !== path);
|
|
1693
|
+
if (old < 0) break;
|
|
1694
|
+
code.tabs.splice(old, 1);
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
code.active = path;
|
|
1698
|
+
rememberCode();
|
|
1699
|
+
return tab;
|
|
1700
|
+
}
|
|
1701
|
+
async function closeCodeTab(path) {
|
|
1702
|
+
const tab = codeTab(path);
|
|
1703
|
+
if (!tab) return;
|
|
1704
|
+
const showing = viewerUI.path === path;
|
|
1705
|
+
const dirty = tab.draft !== null || (showing && editorDirty());
|
|
1706
|
+
if (dirty && await askLeaveEdits() !== 'leave') return;
|
|
1707
|
+
if (showing) await leaveEditor({ ask: false });
|
|
1708
|
+
tab.draft = null;
|
|
1709
|
+
code.tabs = code.tabs.filter((one) => one !== tab);
|
|
1710
|
+
code.changes.delete(path);
|
|
1711
|
+
if (code.active === path) code.active = code.tabs.at(-1)?.path ?? null;
|
|
1712
|
+
rememberCode();
|
|
1713
|
+
if (!showing) { drawCodeTabs(); return; }
|
|
1714
|
+
if (code.active) await openViewer({ root: 'project', path: code.active }); else showCodeHome();
|
|
1715
|
+
}
|
|
1716
|
+
function drawCodeTabs() {
|
|
1717
|
+
if (!code.bar || code.bar.hidden) return;
|
|
1718
|
+
code.bar.replaceChildren();
|
|
1719
|
+
for (const tab of code.tabs) {
|
|
1720
|
+
const on = tab.path === viewerUI.path;
|
|
1721
|
+
const node = el('div', `code-tab${on ? ' on' : ''}`);
|
|
1722
|
+
node.setAttribute('role', 'tab');
|
|
1723
|
+
node.setAttribute('aria-selected', String(on));
|
|
1724
|
+
node.title = tab.path;
|
|
1725
|
+
const name = el('button', 'code-tab-name', tab.path.split('/').pop());
|
|
1726
|
+
name.type = 'button';
|
|
1727
|
+
name.addEventListener('click', () => { if (!on) void openViewer({ root: 'project', path: tab.path }); });
|
|
1728
|
+
node.addEventListener('auxclick', (event) => { if (event.button === 1) { event.preventDefault(); void closeCodeTab(tab.path); } });
|
|
1729
|
+
node.append(name);
|
|
1730
|
+
const change = code.changes.get(tab.path);
|
|
1731
|
+
if (change?.hunks?.length) {
|
|
1732
|
+
const dot = el('i', 'code-tab-agent');
|
|
1733
|
+
tint(dot, change.agent);
|
|
1734
|
+
dot.title = t('@{agent} changed it: {n} to review', { agent: change.agent, n: change.hunks.length });
|
|
1735
|
+
node.append(dot);
|
|
1736
|
+
}
|
|
1737
|
+
if (tab.draft !== null || (on && editorDirty())) {
|
|
1738
|
+
const dot = el('i', 'code-tab-dirty');
|
|
1739
|
+
dot.title = t('Unsaved');
|
|
1740
|
+
node.append(dot);
|
|
1741
|
+
}
|
|
1742
|
+
const shut = closeMark('button', 'code-tab-close', t('Close {file}', { file: tab.path.split('/').pop() }));
|
|
1743
|
+
shut.addEventListener('click', (event) => { event.stopPropagation(); void closeCodeTab(tab.path); });
|
|
1744
|
+
node.append(shut);
|
|
1745
|
+
code.bar.append(node);
|
|
1746
|
+
}
|
|
1747
|
+
code.bar.querySelector('.code-tab.on')?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
/* the path above the code: folders quiet, the file, and the function the line is in */
|
|
1751
|
+
function paintCodeCrumbs(line = code.line) {
|
|
1752
|
+
if (!code.crumbs || code.crumbs.hidden || !viewerUI.path) return;
|
|
1753
|
+
code.line = line;
|
|
1754
|
+
code.crumbs.replaceChildren();
|
|
1755
|
+
// The files, put away by hand, come back from here.
|
|
1756
|
+
if (!code.files) {
|
|
1757
|
+
const show = el('button', 'code-files-fold code-files-show');
|
|
1758
|
+
show.type = 'button';
|
|
1759
|
+
show.setAttribute('aria-label', t('Show the files'));
|
|
1760
|
+
show.title = t('Show the files');
|
|
1761
|
+
show.innerHTML = SPLIT_LEFT_ICON; // trusted: our own SVG string
|
|
1762
|
+
show.addEventListener('click', () => { code.files = true; rememberCode(); syncCode(); });
|
|
1763
|
+
code.crumbs.append(show);
|
|
1764
|
+
}
|
|
1765
|
+
const where = el('span', 'code-crumbs-path');
|
|
1766
|
+
const parts = viewerUI.path.split('/');
|
|
1767
|
+
parts.forEach((part, index) => {
|
|
1768
|
+
if (index) where.append(el('i', null, '›'));
|
|
1769
|
+
where.append(el(index === parts.length - 1 ? 'b' : 'span', null, part));
|
|
1770
|
+
});
|
|
1771
|
+
const text = editing.editor ? editing.editor.value() : viewerMode.text;
|
|
1772
|
+
const symbol = line ? symbolAt(String(text ?? '').split('\n'), line - 1, familyOf(viewerUI.path)?.id ?? null) : null;
|
|
1773
|
+
if (symbol) where.append(el('i', null, '›'), el('b', 'code-crumbs-symbol', symbol));
|
|
1774
|
+
code.crumbs.append(where);
|
|
1775
|
+
const change = code.changes.get(viewerUI.path);
|
|
1776
|
+
if (change?.hunks?.length) {
|
|
1777
|
+
const nav = el('span', 'code-crumbs-changes');
|
|
1778
|
+
tint(nav, change.agent);
|
|
1779
|
+
nav.append(el('i', 'code-file-agent'), el('span', null, change.hunks.length === 1 ? t('@{agent} · 1 change to review', { agent: change.agent }) : t('@{agent} · {n} changes to review', { agent: change.agent, n: change.hunks.length })));
|
|
1780
|
+
const step = (direction) => {
|
|
1781
|
+
const marks = change.hunks.map((hunk) => hunk.at + 1);
|
|
1782
|
+
const here = code.line ?? 0;
|
|
1783
|
+
const next = direction > 0 ? marks.find((n) => n > here) ?? marks[0] : [...marks].reverse().find((n) => n < here) ?? marks.at(-1);
|
|
1784
|
+
goToCodeLine(next);
|
|
1785
|
+
};
|
|
1786
|
+
// The chooser's chevron, turned to point the way it goes.
|
|
1787
|
+
const stepKey = (label, direction) => {
|
|
1788
|
+
const key = el('button', `code-crumbs-step ${direction < 0 ? 'back' : 'on'}`);
|
|
1789
|
+
key.type = 'button';
|
|
1790
|
+
key.title = label;
|
|
1791
|
+
key.setAttribute('aria-label', label);
|
|
1792
|
+
key.innerHTML = ICON_CHEVRON; // trusted: our own SVG string
|
|
1793
|
+
key.addEventListener('click', () => step(direction));
|
|
1794
|
+
return key;
|
|
1795
|
+
};
|
|
1796
|
+
nav.append(stepKey(t('Previous change'), -1), stepKey(t('Next change'), 1));
|
|
1797
|
+
code.crumbs.append(nav);
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
function goToCodeLine(n) {
|
|
1801
|
+
if (!n) return;
|
|
1802
|
+
code.line = n;
|
|
1803
|
+
if (editing.editor) { editing.editor.focus(n); return; }
|
|
1804
|
+
const card = viewer.body.querySelector(`.code-change[data-at="${n}"]`);
|
|
1805
|
+
const row = viewerUI.lineNodes[n - 1]?.tx;
|
|
1806
|
+
(card ?? row)?.scrollIntoView?.({ block: 'center', behavior: 'smooth' });
|
|
1807
|
+
paintCodeCrumbs(n);
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
/* the files beside: the project's folders, or what matches what is typed */
|
|
1811
|
+
async function drawCodeFiles() {
|
|
1812
|
+
if (!code.list || code.explorer.hidden) return;
|
|
1813
|
+
const query = code.search.value.trim();
|
|
1814
|
+
if (query) {
|
|
1815
|
+
let matches = [];
|
|
1816
|
+
try { matches = (await fetch(`/api/tree/search?q=${encodeURIComponent(query)}&limit=40`).then((response) => response.json()))?.matches ?? []; } catch { /* said below */ }
|
|
1817
|
+
if (code.search.value.trim() !== query) return;
|
|
1818
|
+
code.list.replaceChildren();
|
|
1819
|
+
if (!matches.length) { code.list.append(el('li', 'code-files-empty', t('No file by that name.'))); return; }
|
|
1820
|
+
for (const match of matches) code.list.append(codeFileRow(match.path, 0, true));
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1823
|
+
code.list.replaceChildren();
|
|
1824
|
+
await drawCodeLevel('.', code.list, 0);
|
|
1825
|
+
}
|
|
1826
|
+
async function drawCodeLevel(path, list, depth) {
|
|
1827
|
+
let entries = [];
|
|
1828
|
+
try {
|
|
1829
|
+
const response = await fetch(`/api/tree?path=${encodeURIComponent(path)}`);
|
|
1830
|
+
if (response.ok) entries = (await response.json()).entries ?? [];
|
|
1831
|
+
} catch { /* an empty folder is said below */ }
|
|
1832
|
+
if (!entries.length && depth === 0) { list.append(el('li', 'code-files-empty', t('This project has no files yet.'))); return; }
|
|
1833
|
+
for (const entry of entries) {
|
|
1834
|
+
const full = path === '.' ? entry.name : `${path}/${entry.name}`;
|
|
1835
|
+
if (entry.type === 'dir' || entry.dir || entry.kind === 'dir') {
|
|
1836
|
+
const item = el('li', 'code-dir');
|
|
1837
|
+
const open = code.dirs.has(full);
|
|
1838
|
+
const row = el('button', 'code-file code-folder');
|
|
1839
|
+
row.type = 'button';
|
|
1840
|
+
row.style.setProperty('--depth', String(depth));
|
|
1841
|
+
row.setAttribute('aria-expanded', String(open));
|
|
1842
|
+
// The chooser's chevron: pointing right while closed, down when open (styles.css turns it).
|
|
1843
|
+
const caret = el('i', 'code-folder-caret');
|
|
1844
|
+
caret.innerHTML = ICON_CHEVRON; // trusted: our own SVG string
|
|
1845
|
+
row.append(caret, el('span', 'code-file-name', entry.name));
|
|
1846
|
+
const touch = touchedUnder(full);
|
|
1847
|
+
if (touch) { const dot = el('i', 'code-file-agent'); tint(dot, touch.agent); row.append(dot); }
|
|
1848
|
+
const inner = el('ul', 'code-files-list');
|
|
1849
|
+
row.addEventListener('click', async () => {
|
|
1850
|
+
if (code.dirs.has(full)) { code.dirs.delete(full); inner.replaceChildren(); row.setAttribute('aria-expanded', 'false'); return; }
|
|
1851
|
+
code.dirs.add(full);
|
|
1852
|
+
row.setAttribute('aria-expanded', 'true');
|
|
1853
|
+
await drawCodeLevel(full, inner, depth + 1);
|
|
1854
|
+
});
|
|
1855
|
+
item.append(row, inner);
|
|
1856
|
+
list.append(item);
|
|
1857
|
+
if (open) void drawCodeLevel(full, inner, depth + 1);
|
|
1858
|
+
} else list.append(codeFileRow(full, depth, false));
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
function codeFileRow(path, depth, withDir) {
|
|
1862
|
+
const item = el('li');
|
|
1863
|
+
const row = el('button', `code-file${path === viewerUI.path ? ' on' : ''}`);
|
|
1864
|
+
row.type = 'button';
|
|
1865
|
+
row.style.setProperty('--depth', String(depth));
|
|
1866
|
+
row.title = path;
|
|
1867
|
+
row.append(el('span', 'code-file-name', path.split('/').pop()));
|
|
1868
|
+
if (withDir && path.includes('/')) row.append(el('span', 'code-file-dir', path.slice(0, path.lastIndexOf('/'))));
|
|
1869
|
+
const touch = touched.get(path);
|
|
1870
|
+
if (touch) { const dot = el('i', 'code-file-agent'); tint(dot, touch.agent); dot.title = t('Changed by @{agent} {when}', { agent: touch.agent, when: agoWords(new Date(touch.at).toISOString()) }); row.append(dot); }
|
|
1871
|
+
row.addEventListener('click', () => void openCodeFile(path));
|
|
1872
|
+
item.append(row);
|
|
1873
|
+
return item;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
/* what an agent changed, marked in the code */
|
|
1877
|
+
async function loadCodeChanges(path) {
|
|
1878
|
+
let answer = null;
|
|
1879
|
+
try { answer = await fetch(`/api/files/changes?path=${encodeURIComponent(path)}`).then((response) => (response.ok ? response.json() : null)); } catch { return; }
|
|
1880
|
+
if (viewerUI.path !== path) return;
|
|
1881
|
+
const change = answer?.change;
|
|
1882
|
+
if (!change) { if (code.changes.delete(path)) { decorateCode(); syncCode(); } return; }
|
|
1883
|
+
const current = viewerMode.text;
|
|
1884
|
+
const after = change.after ?? current;
|
|
1885
|
+
const reviewed = new Set(answer.reviewed ?? []);
|
|
1886
|
+
const hunks = placeHunks(current, change.after, lineDiff(change.before, after)).filter((hunk) => !reviewed.has(hunkKey(hunk)));
|
|
1887
|
+
if (hunks.length) code.changes.set(path, { agent: change.agent, checkpointId: change.checkpointId, at: change.at, messageId: change.messageId, hunks });
|
|
1888
|
+
else code.changes.delete(path);
|
|
1889
|
+
decorateCode();
|
|
1890
|
+
syncCode();
|
|
1891
|
+
}
|
|
1892
|
+
// Into what is on screen: the read view gets the marks and a card under each change; the editor
|
|
1893
|
+
// gets the marks in its margin, and the cards wait in the read view.
|
|
1894
|
+
function decorateCode() {
|
|
1895
|
+
const found = code.changes.get(viewerUI.path);
|
|
1896
|
+
if (editing.editor) {
|
|
1897
|
+
editing.editor.setMarks?.((found?.hunks ?? []).filter((hunk) => hunk.after.length).map((hunk) => ({ from: hunk.at + 1, to: hunk.at + hunk.after.length, color: agentColor(found.agent), title: t('Changed by @{agent}', { agent: found.agent }) })));
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
const pre = viewer.body.querySelector('pre.code');
|
|
1901
|
+
if (!pre) return;
|
|
1902
|
+
for (const node of pre.querySelectorAll('.code-change')) node.remove();
|
|
1903
|
+
for (const row of viewerUI.lineNodes) { row.ln.classList.remove('ag'); row.tx.classList.remove('ag'); }
|
|
1904
|
+
if (!found?.hunks?.length) return;
|
|
1905
|
+
const color = agentColor(found.agent);
|
|
1906
|
+
for (const hunk of found.hunks) {
|
|
1907
|
+
const last = hunk.at + hunk.after.length;
|
|
1908
|
+
for (let n = hunk.at + 1; n <= last; n += 1) {
|
|
1909
|
+
const row = viewerUI.lineNodes[n - 1];
|
|
1910
|
+
if (!row) continue;
|
|
1911
|
+
row.ln.classList.add('ag');
|
|
1912
|
+
row.tx.classList.add('ag');
|
|
1913
|
+
row.ln.style.setProperty('--ag', color);
|
|
1914
|
+
row.tx.style.setProperty('--ag', color);
|
|
1915
|
+
}
|
|
1916
|
+
const card = codeChangeCard(found, hunk);
|
|
1917
|
+
const anchor = viewerUI.lineNodes[Math.max(hunk.at, last) - 1]?.tx;
|
|
1918
|
+
if (anchor && hunk.after.length) anchor.after(card);
|
|
1919
|
+
else if (viewerUI.lineNodes[hunk.at - 1]) viewerUI.lineNodes[hunk.at - 1].tx.after(card);
|
|
1920
|
+
else pre.prepend(card);
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
function codeChangeCard(found, hunk) {
|
|
1924
|
+
const card = el('div', 'code-change');
|
|
1925
|
+
card.dataset.at = String(hunk.at + 1);
|
|
1926
|
+
tint(card, found.agent);
|
|
1927
|
+
const head = el('div', 'code-change-head');
|
|
1928
|
+
const what = hunk.before.length && hunk.after.length ? t('changed these lines') : hunk.after.length ? t('added these lines') : hunk.before.length === 1 ? t('removed 1 line') : t('removed {n} lines', { n: hunk.before.length });
|
|
1929
|
+
head.append(el('b', 'code-change-who', `@${found.agent}`), el('span', null, `${what} · ${agoWords(found.at)}`));
|
|
1930
|
+
card.append(head);
|
|
1931
|
+
// What was there before, line by line, each with its «−»: read, not crossed out.
|
|
1932
|
+
if (hunk.before.length) {
|
|
1933
|
+
const was = el('pre', 'code-change-was');
|
|
1934
|
+
was.setAttribute('aria-label', t('What was there before'));
|
|
1935
|
+
for (const line of hunk.before) was.append(el('span', null, line || ' '));
|
|
1936
|
+
card.append(was);
|
|
1937
|
+
}
|
|
1938
|
+
const acts = el('div', 'code-change-acts');
|
|
1939
|
+
// Tags of a flat tone, each with its drawing: the accent keeps, the ground undoes, the agent answers.
|
|
1940
|
+
const act = (words, icon, tone, title, click) => {
|
|
1941
|
+
const key = el('button', `code-change-act ${tone}`);
|
|
1942
|
+
key.type = 'button';
|
|
1943
|
+
key.title = title;
|
|
1944
|
+
const mark = el('i', 'code-change-ico');
|
|
1945
|
+
mark.innerHTML = icon; // trusted: our own SVG strings
|
|
1946
|
+
key.append(mark, el('span', null, words));
|
|
1947
|
+
key.addEventListener('click', click);
|
|
1948
|
+
return key;
|
|
1949
|
+
};
|
|
1950
|
+
const keep = act(t('Keep'), ICON_DONE, 'keep', t('It stays as the agent left it, and stops being marked'), () => void keepCodeChange(found, hunk, card));
|
|
1951
|
+
const undo = act(t('Undo'), ICON_REPLY, 'undo', t('Puts back what was there before, in these lines only'), () => void undoCodeChange(found, hunk, undo));
|
|
1952
|
+
const why = act(t('Ask why'), ICON_ASK, 'why', t('Asks @{agent} in the conversation', { agent: found.agent }), () => askAboutLines({ from: hunk.at + 1, to: Math.max(hunk.at + 1, hunk.at + hunk.after.length), prompt: t('Why did you change these lines?'), dirty: false, agentId: found.agent }));
|
|
1953
|
+
acts.append(keep, undo, why);
|
|
1954
|
+
card.append(acts);
|
|
1955
|
+
return card;
|
|
1956
|
+
}
|
|
1957
|
+
async function keepCodeChange(found, hunk, card) {
|
|
1958
|
+
const path = viewerUI.path;
|
|
1959
|
+
try {
|
|
1960
|
+
await fetch('/api/files/changes/review', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path, checkpointId: found.checkpointId, hunk: hunkKey(hunk) }) });
|
|
1961
|
+
} catch { /* kept on this page at least */ }
|
|
1962
|
+
found.hunks = found.hunks.filter((one) => one !== hunk);
|
|
1963
|
+
if (!found.hunks.length) code.changes.delete(path);
|
|
1964
|
+
card.remove();
|
|
1965
|
+
decorateCode();
|
|
1966
|
+
syncCode();
|
|
1967
|
+
}
|
|
1968
|
+
async function undoCodeChange(found, hunk, button) {
|
|
1969
|
+
const path = viewerUI.path;
|
|
1970
|
+
if (editing.editor || codeTab(path)?.draft !== null && codeTab(path)?.draft !== undefined) { toast(t('Save or discard your own changes in this file first.'), { tone: 'note' }); return; }
|
|
1971
|
+
button.disabled = true;
|
|
1972
|
+
const next = revertHunk(viewerMode.text, hunk);
|
|
1973
|
+
const ok = await saveEdited(next, { said: t('Taken back · {path}', { path }) });
|
|
1974
|
+
if (!ok) { button.disabled = false; return; }
|
|
1975
|
+
try { await fetch('/api/files/changes/review', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ path, checkpointId: found.checkpointId, hunk: hunkKey(hunk) }) }); } catch { /* the change is gone from the file anyway */ }
|
|
1976
|
+
showViewerSource();
|
|
1977
|
+
void loadCodeChanges(path);
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
/* side by side: the text and what it makes */
|
|
1981
|
+
function attachBeside() {
|
|
1982
|
+
code.preview?.remove();
|
|
1983
|
+
code.preview = null;
|
|
1984
|
+
viewer.body.classList.remove('with-beside');
|
|
1985
|
+
let on = false;
|
|
1986
|
+
try { on = codeOn(); } catch { /* not yet */ }
|
|
1987
|
+
if (on && code.beside && viewerMode.kind && !viewerMode.preview && viewerUI.path) {
|
|
1988
|
+
const pane = el('div', 'code-beside');
|
|
1989
|
+
const head = el('div', 'code-beside-head');
|
|
1990
|
+
head.append(el('b', null, t('Preview')));
|
|
1991
|
+
const text = () => (editing.editor ? editing.editor.value() : viewerMode.text);
|
|
1992
|
+
if (viewerMode.kind === 'markdown') {
|
|
1993
|
+
head.append(el('span', 'code-beside-live', t('as you write')));
|
|
1994
|
+
const doc = el('article', 'viewer-markdown');
|
|
1995
|
+
doc.append(renderMarkdown(text()));
|
|
1996
|
+
pane.append(head, doc);
|
|
1997
|
+
pane.render = () => doc.replaceChildren(renderMarkdown(text()));
|
|
1998
|
+
} else if (!state.ripley) {
|
|
1999
|
+
const off = el('div', 'code-beside-off');
|
|
2000
|
+
off.append(el('p', null, t('Preview shows the page your code makes, in a sealed frame. It is off in this project.')));
|
|
2001
|
+
off.append(sxButton(t('Turn on Preview'), async (event) => {
|
|
2002
|
+
event.currentTarget.disabled = true;
|
|
2003
|
+
try { await switchModule(modules.items.find((one) => one.id === 'ripley') ?? { id: 'ripley', name: 'Preview' }); } catch (error) { toast(`${t('Diagnostics › ')}${error.message}`); }
|
|
2004
|
+
attachBeside();
|
|
2005
|
+
}, { tone: 'primary' }));
|
|
2006
|
+
pane.append(head, off);
|
|
2007
|
+
} else {
|
|
2008
|
+
head.append(el('span', 'code-beside-live', t('reloads when you save')));
|
|
2009
|
+
const frame = el('iframe', 'ripley');
|
|
2010
|
+
frame.setAttribute('sandbox', 'allow-scripts');
|
|
2011
|
+
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
2012
|
+
frame.title = t('Preview of {path}', { path: viewerUI.path });
|
|
2013
|
+
frame.src = previewUrl(viewerMode.src);
|
|
2014
|
+
pane.append(head, frame);
|
|
2015
|
+
pane.reload = () => { frame.src = `${previewUrl(viewerMode.src)}?v=${Date.now()}`; };
|
|
2016
|
+
}
|
|
2017
|
+
// The pane beside says what Preview is; the old line over the code would say it twice.
|
|
2018
|
+
viewer.body.querySelector(':scope > .viewer-hint')?.remove();
|
|
2019
|
+
viewer.body.append(pane);
|
|
2020
|
+
viewer.body.classList.add('with-beside');
|
|
2021
|
+
code.preview = pane;
|
|
2022
|
+
}
|
|
2023
|
+
code.besideButton?.setAttribute('aria-pressed', String(Boolean(code.preview)));
|
|
2024
|
+
syncCode();
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
/* ⌘P: a file by its name, from anywhere in the room */
|
|
2028
|
+
const quickOpen = (() => {
|
|
2029
|
+
const box = el('div', 'quick-open');
|
|
2030
|
+
box.hidden = true;
|
|
2031
|
+
box.setAttribute('role', 'dialog');
|
|
2032
|
+
box.setAttribute('aria-label', t('Open a file'));
|
|
2033
|
+
box.setAttribute('popover', 'manual');
|
|
2034
|
+
const input = el('input', 'quick-open-input');
|
|
2035
|
+
input.type = 'search';
|
|
2036
|
+
input.autocomplete = 'off';
|
|
2037
|
+
input.spellcheck = false;
|
|
2038
|
+
const list = el('ul', 'quick-open-list');
|
|
2039
|
+
list.setAttribute('role', 'listbox');
|
|
2040
|
+
box.append(input, list);
|
|
2041
|
+
document.body?.append(box);
|
|
2042
|
+
return { box, input, list, at: 0, items: [], typing: 0 };
|
|
2043
|
+
})();
|
|
2044
|
+
function closeQuickOpen() {
|
|
2045
|
+
if (quickOpen.box.hidden) return;
|
|
2046
|
+
quickOpen.box.hidden = true;
|
|
2047
|
+
try { quickOpen.box.hidePopover?.(); } catch { /* not shown */ }
|
|
2048
|
+
}
|
|
2049
|
+
async function fillQuickOpen() {
|
|
2050
|
+
const query = quickOpen.input.value.trim();
|
|
2051
|
+
let paths;
|
|
2052
|
+
if (!query) {
|
|
2053
|
+
const recent = [...code.tabs].reverse().map((tab) => tab.path);
|
|
2054
|
+
const changed = [...touched.entries()].sort((a, b) => b[1].at - a[1].at).map(([path]) => path);
|
|
2055
|
+
paths = [...new Set([...recent, ...changed])].slice(0, 12);
|
|
2056
|
+
} else {
|
|
2057
|
+
try { paths = ((await fetch(`/api/tree/search?q=${encodeURIComponent(query)}&limit=20`).then((response) => response.json()))?.matches ?? []).map((match) => match.path); } catch { paths = []; }
|
|
2058
|
+
if (quickOpen.input.value.trim() !== query) return;
|
|
2059
|
+
}
|
|
2060
|
+
quickOpen.items = paths;
|
|
2061
|
+
quickOpen.at = 0;
|
|
2062
|
+
quickOpen.list.replaceChildren();
|
|
2063
|
+
if (!paths.length) { quickOpen.list.append(el('li', 'quick-open-empty', query ? t('No file by that name.') : t('Type part of a file\'s name.'))); return; }
|
|
2064
|
+
paths.forEach((path, index) => {
|
|
2065
|
+
const item = el('li', `quick-open-item${index === 0 ? ' on' : ''}`);
|
|
2066
|
+
item.setAttribute('role', 'option');
|
|
2067
|
+
item.append(el('b', null, path.split('/').pop()), el('span', null, path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''));
|
|
2068
|
+
const touch = touched.get(path);
|
|
2069
|
+
if (touch) { const dot = el('i', 'code-file-agent'); tint(dot, touch.agent); item.append(dot); }
|
|
2070
|
+
item.addEventListener('pointerdown', (event) => { event.preventDefault(); closeQuickOpen(); void openCodeFile(path); });
|
|
2071
|
+
quickOpen.list.append(item);
|
|
2072
|
+
});
|
|
2073
|
+
}
|
|
2074
|
+
function openQuickOpen() {
|
|
2075
|
+
quickOpen.input.placeholder = t('Open a file by its name…');
|
|
2076
|
+
quickOpen.input.value = '';
|
|
2077
|
+
quickOpen.box.hidden = false;
|
|
2078
|
+
try { quickOpen.box.showPopover?.(); } catch { /* fixed in the page */ }
|
|
2079
|
+
quickOpen.input.focus();
|
|
2080
|
+
void fillQuickOpen();
|
|
2081
|
+
}
|
|
2082
|
+
quickOpen.input.addEventListener('input', () => { clearTimeout(quickOpen.typing); quickOpen.typing = setTimeout(() => void fillQuickOpen(), 110); });
|
|
2083
|
+
quickOpen.input.addEventListener('keydown', (event) => {
|
|
2084
|
+
const rows = [...quickOpen.list.querySelectorAll('.quick-open-item')];
|
|
2085
|
+
if (event.key === 'Escape') { event.preventDefault(); closeQuickOpen(); return; }
|
|
2086
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
2087
|
+
event.preventDefault();
|
|
2088
|
+
if (!rows.length) return;
|
|
2089
|
+
rows[quickOpen.at]?.classList.remove('on');
|
|
2090
|
+
quickOpen.at = (quickOpen.at + (event.key === 'ArrowDown' ? 1 : -1) + rows.length) % rows.length;
|
|
2091
|
+
rows[quickOpen.at].classList.add('on');
|
|
2092
|
+
rows[quickOpen.at].scrollIntoView?.({ block: 'nearest' });
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
if (event.key === 'Enter') {
|
|
2096
|
+
event.preventDefault();
|
|
2097
|
+
const path = quickOpen.items[quickOpen.at];
|
|
2098
|
+
if (!path) return;
|
|
2099
|
+
closeQuickOpen();
|
|
2100
|
+
void openCodeFile(path);
|
|
2101
|
+
}
|
|
2102
|
+
});
|
|
2103
|
+
document.addEventListener?.('pointerdown', (event) => { if (!quickOpen.box.hidden && !quickOpen.box.contains(event.target)) closeQuickOpen(); }, true);
|
|
2104
|
+
document.addEventListener?.('keydown', (event) => {
|
|
2105
|
+
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key?.toLowerCase() === 'p' && !state.device) {
|
|
2106
|
+
event.preventDefault();
|
|
2107
|
+
if (quickOpen.box.hidden) openQuickOpen(); else closeQuickOpen();
|
|
2108
|
+
}
|
|
2109
|
+
});
|
|
2110
|
+
|
|
1315
2111
|
// RIPLEY's frame, its small bar (back, reload), its history inside the project,
|
|
1316
2112
|
// what the page reports up (which page, which errors), and reloads when an
|
|
1317
2113
|
// agent changes the file on screen.
|
|
@@ -1688,7 +2484,9 @@ function paintPorts(read) {
|
|
|
1688
2484
|
menu.replaceChildren(el('div', 'ports-note', read.error));
|
|
1689
2485
|
const go = el('button', 'ports-row');
|
|
1690
2486
|
go.type = 'button';
|
|
1691
|
-
|
|
2487
|
+
const gear = el('b', 'ports-gear');
|
|
2488
|
+
gear.innerHTML = GEAR_ICON; // trusted: our own SVG string
|
|
2489
|
+
go.append(gear, el('span', null, t('OPEN MODULES')));
|
|
1692
2490
|
go.addEventListener('click', () => { portsMenuShown(false); document.querySelector('#modules-button, [aria-controls="modules"]')?.click(); });
|
|
1693
2491
|
menu.append(go);
|
|
1694
2492
|
return;
|
|
@@ -2122,6 +2920,10 @@ function ripleyMaybeReload(paths) {
|
|
|
2122
2920
|
}
|
|
2123
2921
|
|
|
2124
2922
|
function showViewerPreview() {
|
|
2923
|
+
// The whole frame for the result: nothing beside it.
|
|
2924
|
+
code.preview = null;
|
|
2925
|
+
viewer.body.classList.remove('with-beside');
|
|
2926
|
+
code.besideButton?.setAttribute('aria-pressed', 'false');
|
|
2125
2927
|
if (viewerMode.kind === 'markdown') {
|
|
2126
2928
|
ripley.nav.hidden = true;
|
|
2127
2929
|
const doc = el('article', 'viewer-markdown bubble');
|
|
@@ -2156,7 +2958,7 @@ function syncViewerMode() {
|
|
|
2156
2958
|
if (!viewerMode.button) return;
|
|
2157
2959
|
const can = Boolean(viewerMode.kind) && state.ripley;
|
|
2158
2960
|
viewerMode.button.hidden = !can;
|
|
2159
|
-
viewerMode.button.textContent = viewerMode.preview ? 'SOURCE' : 'PREVIEW';
|
|
2961
|
+
viewerMode.button.textContent = viewerMode.preview ? t('SOURCE') : t('PREVIEW');
|
|
2160
2962
|
viewerMode.button.title = viewerMode.preview ? t('Show the file as text') : t('Render with Preview in a sealed frame');
|
|
2161
2963
|
}
|
|
2162
2964
|
viewerMode.button?.addEventListener('click', async () => { if (!await leaveEditor()) return; viewerMode.preview = !viewerMode.preview; syncViewerMode(); syncEditButton(); if (viewerMode.preview) showViewerPreview(); else showViewerSource(); });
|
|
@@ -2173,7 +2975,13 @@ function paintViewerPath(text) {
|
|
|
2173
2975
|
viewer.path.setAttribute?.('aria-label', said);
|
|
2174
2976
|
}
|
|
2175
2977
|
async function openViewer({ root = 'project', path = null, url = null, label = null, line = null, lines = null }) {
|
|
2176
|
-
|
|
2978
|
+
let docked = false;
|
|
2979
|
+
try { docked = codeOn(); } catch { /* the layout is not up yet */ }
|
|
2980
|
+
if (!await leaveEditor({ stash: docked })) return;
|
|
2981
|
+
code.preview?.remove();
|
|
2982
|
+
code.preview = null;
|
|
2983
|
+
viewer.body.classList.remove('with-beside');
|
|
2984
|
+
code.line = line ?? lines?.from ?? null;
|
|
2177
2985
|
const src = url ?? `/api/files?root=${root}&path=${encodeURIComponent(path)}`;
|
|
2178
2986
|
paintViewerPath(label ?? path);
|
|
2179
2987
|
viewer.open.href = src;
|
|
@@ -2201,15 +3009,27 @@ async function openViewer({ root = 'project', path = null, url = null, label = n
|
|
|
2201
3009
|
// Text of this project, small enough to edit: the editor is offered. Attachments and frames
|
|
2202
3010
|
// are read, never written.
|
|
2203
3011
|
const editable = root === 'project' && Boolean(path) && !url && response.headers.has('x-mtime') && text.length < 2 * 1024 * 1024 && !text.includes('\u0000');
|
|
2204
|
-
|
|
3012
|
+
// In the code workspace a page or a document opens as its text, with what it makes beside it.
|
|
3013
|
+
let docked = false;
|
|
3014
|
+
try { docked = codeOn(); } catch { /* the layout is not up yet */ }
|
|
3015
|
+
Object.assign(viewerMode, { kind, text, src, line, lines, preview: Boolean(kind) && state.ripley && !line && !lines && !(docked && code.beside), mtime: response.headers.get('x-mtime'), editable });
|
|
3016
|
+
const tab = editable ? noteCodeTab(path) : null;
|
|
2205
3017
|
syncViewerMode();
|
|
2206
3018
|
syncEditButton();
|
|
2207
3019
|
if (viewerMode.preview) showViewerPreview(); else showViewerSource();
|
|
3020
|
+
// A tab left with unsaved changes opens where it was left: in the editor, with them.
|
|
3021
|
+
if (tab && tab.draft !== null) { if (tab.mtime) viewerMode.mtime = tab.mtime; openEditor(line); }
|
|
3022
|
+
if (editable) void loadCodeChanges(path);
|
|
3023
|
+
syncCode();
|
|
2208
3024
|
} catch (error) {
|
|
2209
3025
|
viewer.body.replaceChildren(el('div', 'err', error.message));
|
|
2210
3026
|
}
|
|
2211
3027
|
}
|
|
2212
|
-
viewer.close.addEventListener('click', async () => {
|
|
3028
|
+
viewer.close.addEventListener('click', async () => {
|
|
3029
|
+
let docked = false;
|
|
3030
|
+
try { docked = codeOn(); } catch { /* not yet */ }
|
|
3031
|
+
if (await leaveEditor({ stash: docked })) viewer.dialog.close();
|
|
3032
|
+
});
|
|
2213
3033
|
|
|
2214
3034
|
function codeBlock(lines, lang) {
|
|
2215
3035
|
const wrapper = el('div', `codeblock${lang ? ' has-lang' : ''}`);
|
|
@@ -2392,6 +3212,11 @@ function ringPercent(agent) {
|
|
|
2392
3212
|
// it, and a row is that agent's own card — the same one a sphere opens, with a way back.
|
|
2393
3213
|
const agentsCompact = document.querySelector('#agents-compact');
|
|
2394
3214
|
const agentsCount = document.querySelector('#agents-count');
|
|
3215
|
+
// Whether an agent can answer now: an adapter MADRE can use, and a session. One that cannot is
|
|
3216
|
+
// switched off everywhere: grey in the bar, out of the picker, and a message to it is not sent.
|
|
3217
|
+
function canAnswer(agent) {
|
|
3218
|
+
return Boolean(agent?.ready) && state.sessions?.[agent.id]?.state !== 'signed-out';
|
|
3219
|
+
}
|
|
2395
3220
|
function renderAgentsCompact() {
|
|
2396
3221
|
if (!agentsCompact) return;
|
|
2397
3222
|
const all = [...state.agents.values()];
|
|
@@ -2411,15 +3236,19 @@ function renderAgents() {
|
|
|
2411
3236
|
for (const agent of state.agents.values()) {
|
|
2412
3237
|
const percent = ringPercent(agent);
|
|
2413
3238
|
const official = liveOfficial(agent);
|
|
3239
|
+
// An agent that cannot answer looks switched off, whatever the reason: not installed, no adapter
|
|
3240
|
+
// yet, or installed with no session (2026-10-17). Its colour comes back with its session.
|
|
3241
|
+
const signedOut = agent.ready && !canAnswer(agent);
|
|
2414
3242
|
const node = avatar(agent.id, {
|
|
2415
3243
|
size: 26,
|
|
2416
|
-
ring: agent.ready,
|
|
3244
|
+
ring: agent.ready && !signedOut,
|
|
2417
3245
|
pct: percent,
|
|
2418
3246
|
status: agent.ready ? 'ready' : agent.detected ? 'detected' : 'offline',
|
|
2419
3247
|
});
|
|
3248
|
+
if (!agent.ready || signedOut) node.classList.add('off');
|
|
2420
3249
|
node.title = [
|
|
2421
3250
|
`${agent.label}${vendorWord(agent.id) ? ` · ${vendorWord(agent.id)}` : ''}`,
|
|
2422
|
-
agent.ready ? t('Ready') : agent.detected ? t('Detected, but MADRE cannot use it yet') : t('Not installed'),
|
|
3251
|
+
signedOut ? t('Not signed in') : agent.ready ? t('Ready') : agent.detected ? t('Detected, but MADRE cannot use it yet') : t('Not installed'),
|
|
2423
3252
|
agent.version ? agent.version : null,
|
|
2424
3253
|
official === null
|
|
2425
3254
|
? (agent.tokens ? t('Usage: local 5h window · {pct}% of {total} budget tokens', { pct: Math.round(percent), total: formatTokens(state.budget ?? 0) }) : t('Usage: local window · nothing used yet'))
|
|
@@ -2457,7 +3286,7 @@ if (safetyLine && !safetySeen) {
|
|
|
2457
3286
|
function renderPicker() {
|
|
2458
3287
|
els.picker.replaceChildren();
|
|
2459
3288
|
els.pickOptions?.replaceChildren?.();
|
|
2460
|
-
const ready = [...state.agents.values()].filter(
|
|
3289
|
+
const ready = [...state.agents.values()].filter(canAnswer);
|
|
2461
3290
|
if (!ready.length) { updateCrewLabel(); return; }
|
|
2462
3291
|
if (!els.target.value || !ready.some((agent) => agent.id === els.target.value)) els.target.value = ready[0].id;
|
|
2463
3292
|
const stack = el('div', 'crew-stack');
|
|
@@ -3513,7 +4342,7 @@ function bridgeCard(agent) {
|
|
|
3513
4342
|
card.append(head);
|
|
3514
4343
|
const detail = el('div', 'detail');
|
|
3515
4344
|
if (agent.id === 'madre') detail.append(ollamaDetail(agent));
|
|
3516
|
-
else if (stage === 'missing') detail.append(
|
|
4345
|
+
else if (stage === 'missing') detail.append(t('Not on this computer.'));
|
|
3517
4346
|
else detail.append(`${agent.version ?? t('version unknown')}${session?.detail ? ` · ${session.detail}` : ''}${agent.route?.destination ? ` · ${t('route: {destination}', { destination: agent.route.destination })}` : ''}`);
|
|
3518
4347
|
card.append(detail);
|
|
3519
4348
|
// What is behind this door: the account it needs, and whether there is a way in without paying.
|
|
@@ -3530,24 +4359,35 @@ function bridgeCard(agent) {
|
|
|
3530
4359
|
if (stage === 'signed-out' || (stage === 'ready' && agent.login?.headless)) {
|
|
3531
4360
|
// Every door the CLI has, not just the first one. Claude sells two accounts and MADRE used
|
|
3532
4361
|
// to open only the subscription, which left whoever pays for the API with no way in.
|
|
3533
|
-
|
|
3534
|
-
else if (!agent.key && agent.login) actions.append(el('span', 'note', t('signs in from your terminal:')));
|
|
4362
|
+
const keyFirst = Boolean(agent.key?.first);
|
|
3535
4363
|
if (agent.key) {
|
|
3536
|
-
reveal = el('button', agent.login?.headless ? null : 'primary', t('PASTE API KEY'));
|
|
4364
|
+
reveal = el('button', agent.login?.headless && !keyFirst ? null : 'primary', t('PASTE API KEY'));
|
|
3537
4365
|
reveal.type = 'button';
|
|
3538
|
-
actions.append(reveal);
|
|
3539
4366
|
}
|
|
4367
|
+
if (keyFirst) actions.append(reveal);
|
|
4368
|
+
if (agent.login?.headless) {
|
|
4369
|
+
for (const door of loginDoors(agent, stage === 'ready')) {
|
|
4370
|
+
const signIn = bridgeSignInButton(agent, door, stage === 'ready');
|
|
4371
|
+
if (keyFirst) signIn.classList.remove('primary');
|
|
4372
|
+
actions.append(signIn);
|
|
4373
|
+
}
|
|
4374
|
+
}
|
|
4375
|
+
else if (!agent.key && agent.login) actions.append(el('span', 'note', t('signs in from your terminal:')));
|
|
4376
|
+
if (reveal && !keyFirst) actions.append(reveal);
|
|
3540
4377
|
}
|
|
3541
4378
|
if (actions.childNodes.length) card.append(actions);
|
|
3542
4379
|
if (reveal) {
|
|
3543
4380
|
const slot = el('div', 'key-slot');
|
|
3544
4381
|
slot.hidden = true;
|
|
3545
4382
|
slot.append(keyForm(agent, { onDone: () => renderOnboarding() }));
|
|
3546
|
-
slot.append(el('p', 'key-note', t('Or do it from a terminal: {command}', { command: agent.login.display })));
|
|
3547
4383
|
reveal.addEventListener('click', () => { slot.hidden = !slot.hidden; if (!slot.hidden) slot.querySelector('input')?.focus(); });
|
|
3548
4384
|
card.append(slot);
|
|
3549
|
-
}
|
|
4385
|
+
}
|
|
3550
4386
|
if (stage !== 'missing') card.append(loginAskSlot(agent.id));
|
|
4387
|
+
if (agent.id !== 'madre') {
|
|
4388
|
+
const way = stage === 'missing' ? installTerminalWay(agent) : stage === 'signed-out' || stage === 'ready' ? signInTerminalWay(agent) : null;
|
|
4389
|
+
if (way) card.append(way);
|
|
4390
|
+
}
|
|
3551
4391
|
const log = el('pre', 'bridge-log');
|
|
3552
4392
|
log.id = `bridge-log-${agent.id}`;
|
|
3553
4393
|
for (const entry of state.loginLogs.get(agent.id) ?? []) log.append(loginLine(entry));
|
|
@@ -3682,17 +4522,42 @@ function bridgeInstallButton(agent) {
|
|
|
3682
4522
|
return button;
|
|
3683
4523
|
}
|
|
3684
4524
|
function bridgeSignInButton(agent, door, again = false) {
|
|
3685
|
-
const named = Boolean(agent.login?.methods?.length);
|
|
4525
|
+
const named = Boolean(agent.login?.methods?.length) && !door.again;
|
|
3686
4526
|
const button = el('button', again && !named ? null : 'primary', named ? door.label : again ? t('SIGN IN AGAIN') : t('SIGN IN'));
|
|
3687
4527
|
button.type = 'button';
|
|
3688
4528
|
button.title = t(door.note || agent.login.note);
|
|
3689
4529
|
button.addEventListener('click', async () => {
|
|
4530
|
+
if (door.choose) { choosingAccount.add(agent.id); renderOnboarding(); return; }
|
|
4531
|
+
choosingAccount.delete(agent.id);
|
|
3690
4532
|
button.disabled = true;
|
|
3691
4533
|
try { await startLogin(agent.id, door.id); }
|
|
3692
4534
|
catch (error) { toast(t('Diagnostics › sign-in did not start: {error}', { error: error.message })); renderOnboarding(); }
|
|
3693
4535
|
});
|
|
3694
4536
|
return button;
|
|
3695
4537
|
}
|
|
4538
|
+
// The other way in: the same install or sign-in typed in a terminal, for whoever prefers it.
|
|
4539
|
+
// Folded, so a person who has never opened a terminal is never asked to.
|
|
4540
|
+
// Cards are drawn again on every sign-in event: which folds are open is kept here, so one never
|
|
4541
|
+
// closes on the person reading it.
|
|
4542
|
+
const openTerminalWays = new Set();
|
|
4543
|
+
function terminalWay(lines) {
|
|
4544
|
+
const fold = el('details', 'terminal-way');
|
|
4545
|
+
const key = lines[0];
|
|
4546
|
+
fold.open = openTerminalWays.has(key);
|
|
4547
|
+
fold.addEventListener('toggle', () => { if (fold.open) openTerminalWays.add(key); else openTerminalWays.delete(key); });
|
|
4548
|
+
fold.append(el('summary', null, t('Prefer your terminal?')));
|
|
4549
|
+
fold.append(commandBlock(lines));
|
|
4550
|
+
return fold;
|
|
4551
|
+
}
|
|
4552
|
+
function installTerminalWay(agent) {
|
|
4553
|
+
if (!agent.install) return null;
|
|
4554
|
+
return terminalWay([agent.install.display, ...(agent.install.alternatives ?? []).map((line) => `# ${t(line)}`)]);
|
|
4555
|
+
}
|
|
4556
|
+
function signInTerminalWay(agent) {
|
|
4557
|
+
const way = agent.login?.terminal;
|
|
4558
|
+
if (!way) return null;
|
|
4559
|
+
return terminalWay([way.command, ...(way.note ? [`# ${t(way.note)}`] : [])]);
|
|
4560
|
+
}
|
|
3696
4561
|
|
|
3697
4562
|
/* ---------- thread rendering ---------- */
|
|
3698
4563
|
|
|
@@ -3869,6 +4734,8 @@ function iconButton(svg, title, className) {
|
|
|
3869
4734
|
const ICON_GOOD = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.5 7.5v5.5H3.2a.7.7 0 0 1-.7-.7V8.2a.7.7 0 0 1 .7-.7h2.3Zm0 0 2.6-4.6a1.3 1.3 0 0 1 2.4.8L10 6.8h2.6a1.3 1.3 0 0 1 1.3 1.5l-.8 3.9a1.3 1.3 0 0 1-1.3 1H5.5" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>';
|
|
3870
4735
|
const ICON_BAD = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10.5 8.5V3H12.8a.7.7 0 0 1 .7.7v4.1a.7.7 0 0 1-.7.7h-2.3Zm0 0-2.6 4.6a1.3 1.3 0 0 1-2.4-.8L6 9.2H3.4a1.3 1.3 0 0 1-1.3-1.5l.8-3.9A1.3 1.3 0 0 1 4.2 3h6.3" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>';
|
|
3871
4736
|
const ICON_PREFER = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M7.45 2.7c.22-.45.88-.45 1.1 0l1.3 2.65 2.92.43c.5.07.7.69.34 1.04l-2.12 2.06.5 2.9c.09.5-.44.88-.89.65L8 11.06l-2.6 1.37c-.45.23-.98-.15-.89-.65l.5-2.9-2.12-2.06c-.36-.35-.16-.97.34-1.04l2.92-.43z" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
4737
|
+
// The settings' gear: the drawing AJUSTES wears in the bar, for any way into Settings.
|
|
4738
|
+
const GEAR_ICON = '<svg class="gear-glyph" viewBox="0 0 20 20" aria-hidden="true"><path fill-rule="evenodd" d="M8.65 3.85L8.92 1.97L11.08 1.97L11.35 3.85L13.40 4.69L14.91 3.56L16.44 5.09L15.31 6.60L16.15 8.65L18.03 8.92L18.03 11.08L16.15 11.35L15.31 13.40L16.44 14.91L14.91 16.44L13.40 15.31L11.35 16.15L11.08 18.03L8.92 18.03L8.65 16.15L6.60 15.31L5.09 16.44L3.56 14.91L4.69 13.40L3.85 11.35L1.97 11.08L1.97 8.92L3.85 8.65L4.69 6.60L3.56 5.09L5.09 3.56L6.60 4.69ZM12.6 10a2.6 2.6 0 1 1-5.2 0a2.6 2.6 0 1 1 5.2 0Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>';
|
|
3872
4739
|
// A small ×, in the same round line, for an aside that can be put away.
|
|
3873
4740
|
const ICON_STOP = '<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="5" y="5" width="6" height="6" rx="1.3" fill="currentColor"/></svg>';
|
|
3874
4741
|
const ICON_CLOSE_SMALL = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>';
|
|
@@ -4056,6 +4923,12 @@ const WORKING_VOICE = {
|
|
|
4056
4923
|
late: [t('Reasoning through it…'), t('Verifying the findings…'), t('Sorting the evidence…')],
|
|
4057
4924
|
long: [t('Formulating the answer…'), t('Writing it up…'), t('Wrapping up…')],
|
|
4058
4925
|
},
|
|
4926
|
+
cursor: {
|
|
4927
|
+
early: [t('Reading the repo…'), t('Searching the project…'), t('Grabbing context…')],
|
|
4928
|
+
middle: [t('Thinking…'), t('Digging through the code…'), t('Following the references…'), t('Looking closer…')],
|
|
4929
|
+
late: [t('Working through it…'), t('Double-checking…'), t('Piecing it together…')],
|
|
4930
|
+
long: [t('Writing the response…'), t('Tidying the answer…'), t('Nearly done…')],
|
|
4931
|
+
},
|
|
4059
4932
|
opencode: {
|
|
4060
4933
|
early: [t('Reading the repo…'), t('Listing files…'), t('Grabbing context…')],
|
|
4061
4934
|
middle: [t('Thinking…'), t('Digging through the code…'), t('Following the call chain…'), t('Looking closer…')],
|
|
@@ -4082,10 +4955,11 @@ function showReading(event) {
|
|
|
4082
4955
|
// The answer as it is written. A CLI that says nothing until it is done shows nothing here,
|
|
4083
4956
|
// which is the truth rather than a number invented to keep something moving.
|
|
4084
4957
|
const wrote = out ? ` · ${t('{n} out', { n: (outTokens ?? 0).toLocaleString() || t('{n} ch', { n: out.toLocaleString() }) })}` : '';
|
|
4085
|
-
|
|
4086
|
-
meter
|
|
4958
|
+
// In place, and said without the grey box (see the clock in renderThinking).
|
|
4959
|
+
setText(meter, `~${read}${wrote}`);
|
|
4960
|
+
meter.setAttribute('aria-description', tokens
|
|
4087
4961
|
? t("{chars} characters of briefing and transcript, about {tokens} tokens at this project's measured rate{written}. The exact figure arrives when the agent answers.", { chars: chars.toLocaleString(), tokens: tokens.toLocaleString(), written: out ? t(', and {n} characters written back so far', { n: out.toLocaleString() }) : '' })
|
|
4088
|
-
: t('{chars} characters. This project has not been billed yet, so there is no rate to convert them at.', { chars: chars.toLocaleString() });
|
|
4962
|
+
: t('{chars} characters. This project has not been billed yet, so there is no rate to convert them at.', { chars: chars.toLocaleString() }));
|
|
4089
4963
|
}
|
|
4090
4964
|
|
|
4091
4965
|
// The bill arrived: the estimate is replaced by what was actually charged, and stays on the
|
|
@@ -4177,6 +5051,8 @@ function renderThinking(event) {
|
|
|
4177
5051
|
const status = el('span', 'status');
|
|
4178
5052
|
const tokens = el('span', 'tokens');
|
|
4179
5053
|
const elapsed = el('span', 'elapsed');
|
|
5054
|
+
// The clock is not read aloud every second: the region around it is live.
|
|
5055
|
+
elapsed.setAttribute('aria-hidden', 'true');
|
|
4180
5056
|
// One answer, not the room. PARAR TODO is the master brake and takes down every plan and every
|
|
4181
5057
|
// agent; this one is for the turn in front of you — the agent that hung, or started down the
|
|
4182
5058
|
// wrong road, or hit an error and is still being waited on.
|
|
@@ -4214,7 +5090,9 @@ function renderThinking(event) {
|
|
|
4214
5090
|
phraseChangedAt = seconds;
|
|
4215
5091
|
phraseIndex = (phraseIndex + 1) % pool.length;
|
|
4216
5092
|
const next = pool[phraseIndex];
|
|
4217
|
-
|
|
5093
|
+
// The phrase changes in place and comes in with its own small animation, without asking the
|
|
5094
|
+
// browser to lay the page out again first (the old restart read offsetWidth to replay it).
|
|
5095
|
+
if (status.textContent !== next) { setText(status, next); status.animate?.([{ opacity: 0, transform: 'translateY(3px)' }, { opacity: 1, transform: 'none' }], { duration: 350, easing: 'ease' }); }
|
|
4218
5096
|
};
|
|
4219
5097
|
// The limit the room holds this turn to, said with its start; older records fall back to the setting.
|
|
4220
5098
|
const limitMs = event.payload.timeoutMs ?? state.timeouts[agent] ?? 180000;
|
|
@@ -4230,9 +5108,11 @@ function renderThinking(event) {
|
|
|
4230
5108
|
else if ((wasConnected || Date.now() - born > 5000) && timer) { clearInterval(timer); thinkingClocks.delete(timer); return; }
|
|
4231
5109
|
const seconds = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
|
|
4232
5110
|
speak(seconds);
|
|
4233
|
-
elapsed
|
|
4234
|
-
elapsed.classList.toggle('late', seconds >= limit * 0.6);
|
|
4235
|
-
|
|
5111
|
+
setText(elapsed, `${seconds}s / ${limit}s`);
|
|
5112
|
+
if (elapsed.classList.contains('late') !== seconds >= limit * 0.6) elapsed.classList.toggle('late', seconds >= limit * 0.6);
|
|
5113
|
+
// Said to a screen reader, never as the browser's grey box: a title written every second came
|
|
5114
|
+
// back under a still pointer however many times it was taken off (adoptTitle).
|
|
5115
|
+
bubble.setAttribute('aria-description', t('{label} is reading the project… {seconds}s so far; MADRE gives up at {limit}s.', { label: label(agent), seconds, limit }));
|
|
4236
5116
|
};
|
|
4237
5117
|
tick();
|
|
4238
5118
|
timer = setInterval(tick, 1000);
|
|
@@ -4621,7 +5501,7 @@ function attachMemoryHint(event) {
|
|
|
4621
5501
|
for (const child of [...(col.children ?? [])]) if (child.classList?.contains('memory-hint')) child.remove();
|
|
4622
5502
|
const hint = el('div', 'memory-hint');
|
|
4623
5503
|
tint(hint, agent);
|
|
4624
|
-
hint.append(
|
|
5504
|
+
hint.append(leadWords('span', 'lead', t('◉ memory saved')));
|
|
4625
5505
|
for (const note of notes.slice(0, 3)) {
|
|
4626
5506
|
const pill = el('button', `pill ${note.kind}`);
|
|
4627
5507
|
pill.type = 'button';
|
|
@@ -4787,13 +5667,35 @@ function renderLeaseRefused(event) {
|
|
|
4787
5667
|
// The reply named files and the disk does not have them. Said as the fact it is, without
|
|
4788
5668
|
// deciding what the agent meant: an answer that claims a file it never wrote and an answer that
|
|
4789
5669
|
// suggests one both leave the human better off knowing it is not there.
|
|
5670
|
+
// A reply that says it created files the disk does not have (manual tests on 1.0.2, 2026-10-17:
|
|
5671
|
+
// «Creé gastos.html» and no gastos.html). It used to be a small line that folded into a dot ten
|
|
5672
|
+
// seconds later, under a bubble that said the opposite. Now it says it plainly and offers the way
|
|
5673
|
+
// on: the same request back in the box, with a line telling the agent what happened. Sending it is
|
|
5674
|
+
// the human's; the live button keeps the notice open until then.
|
|
4790
5675
|
function renderCreateMissing(event) {
|
|
4791
|
-
const { agent, files = [] } = event.payload;
|
|
4792
|
-
const
|
|
5676
|
+
const { agent, files = [], requestId = null } = event.payload;
|
|
5677
|
+
const list = files.slice(0, 4).join(', ') + (files.length > 4 ? '…' : '');
|
|
5678
|
+
const node = el('div', 'system memory warn create-missing');
|
|
4793
5679
|
tint(node, agent);
|
|
4794
5680
|
node.append(t('create · '), el('b', 'who', `@${agent}`));
|
|
4795
|
-
node.append(t(' ·
|
|
4796
|
-
node.append(t(' · nothing was written this
|
|
5681
|
+
node.append(t(' · said it created {files}, but it is not in your project', { files: list }));
|
|
5682
|
+
node.append(t(' · nothing was written in this reply'));
|
|
5683
|
+
const asked = requestId ? state.userMessages.get(requestId) : null;
|
|
5684
|
+
if (asked && state.agents.has(agent)) {
|
|
5685
|
+
const again = el('button', 'retry-longer', t('ASK AGAIN'));
|
|
5686
|
+
again.type = 'button';
|
|
5687
|
+
again.addEventListener('click', () => {
|
|
5688
|
+
again.disabled = true;
|
|
5689
|
+
if (els.target.value !== agent) { els.target.value = agent; renderPicker(); }
|
|
5690
|
+
const note = t('Last time you said you created {files}, but nothing was written to disk. Create them now and check that they exist before you answer.', { files: list });
|
|
5691
|
+
els.input.value = [asked.text ?? '', note].filter(Boolean).join('\n\n');
|
|
5692
|
+
if ((asked.mode ?? 1) === 2) setMode(2);
|
|
5693
|
+
else if ((asked.mode ?? 1) >= 3) toast(t('Diagnostics › #{n} needs you to confirm again: choose it in the mode selector before sending.', { n: asked.mode }));
|
|
5694
|
+
autosize();
|
|
5695
|
+
focusComposer();
|
|
5696
|
+
});
|
|
5697
|
+
node.append(again);
|
|
5698
|
+
}
|
|
4797
5699
|
return node;
|
|
4798
5700
|
}
|
|
4799
5701
|
|
|
@@ -4829,12 +5731,102 @@ function renderEnlace(event) {
|
|
|
4829
5731
|
: t(" · with {room} · both read each other's {kinds}, read-only", { room: other, kinds: what })));
|
|
4830
5732
|
if (expires) node.append(t(' · until {date}', { date: new Date(expires).toLocaleDateString(language(), { day: 'numeric', month: 'short' }) }));
|
|
4831
5733
|
if (event.payload.contract?.signed?.by === 'bishop') node.append(t(' · signed by the Coordinator'));
|
|
5734
|
+
if (event.payload.contract?.train) node.append(t(' · the Coordinator learns from it'));
|
|
5735
|
+
if (event.payload.contract?.state) node.append(role === 'serves' ? t(' · it also reads the state of this project') : role === 'reads' ? t(' · this project also reads its state') : t(" · both read each other's state"));
|
|
5736
|
+
if (event.payload.contract?.deliver) node.append(role === 'serves' ? t(' · it can deliver notes here, which you accept') : role === 'reads' ? t(' · this project can deliver notes to it') : t(' · both can deliver notes to each other'));
|
|
4832
5737
|
}
|
|
4833
5738
|
node.title = contract?.id ?? (typeof contract === 'string' ? contract : '');
|
|
4834
5739
|
if (contract?.id) node.dataset.contract = contract.id;
|
|
4835
5740
|
return node;
|
|
4836
5741
|
}
|
|
4837
5742
|
|
|
5743
|
+
// D-S15: what the Coordinator took to learn from, said in the project that handed it over, and in
|
|
5744
|
+
// Salas, where it was taken. One line each time; the notes themselves are never shown here.
|
|
5745
|
+
function renderLearned(event) {
|
|
5746
|
+
const { count = 0 } = event.payload ?? {};
|
|
5747
|
+
const node = el('div', 'system memory enlace');
|
|
5748
|
+
node.append(el('b', null, t('CONNECTION')));
|
|
5749
|
+
node.append(event.type === 'enlace.corpus'
|
|
5750
|
+
? t('The Coordinator learned what this project knows: {n} notes, cleaned of private data.', { n: count })
|
|
5751
|
+
: t('Learned from {room}: {n} notes for the training.', { room: event.payload.from ?? '', n: count }));
|
|
5752
|
+
return node;
|
|
5753
|
+
}
|
|
5754
|
+
|
|
5755
|
+
// D-S17: notes another project delivered here. A card with one row per note, each accepted or
|
|
5756
|
+
// declined by you (or by an agent of this project in #3/#4, which the row then shows). Nothing of
|
|
5757
|
+
// them is in the memory until accepted.
|
|
5758
|
+
function renderDelivered(event) {
|
|
5759
|
+
const { name, agent, coordinator, notes = [] } = event.payload ?? {};
|
|
5760
|
+
if (!notes.length) return null;
|
|
5761
|
+
const from = coordinator ? t('the Coordinator') : name;
|
|
5762
|
+
const node = el('div', 'choice run-request bishop-plan delivered');
|
|
5763
|
+
const head = el('div', 'choice-head');
|
|
5764
|
+
head.append(el('span', 'choice-kicker', t('Delivered by {room}{agent}', { room: from, agent: agent ? ` · @${agent}` : '' })));
|
|
5765
|
+
node.append(head, el('p', 'choice-q', notes.length === 1 ? t('One note for this project. It enters the memory only if it is accepted.') : t('{n} notes for this project. Each enters the memory only if it is accepted.', { n: notes.length })));
|
|
5766
|
+
const list = el('div', 'choice-list');
|
|
5767
|
+
for (const note of notes) {
|
|
5768
|
+
const row = el('div', 'choice-option bishop-order');
|
|
5769
|
+
row.dataset.delivery = note.id;
|
|
5770
|
+
row.append(el('span', 'choice-key', kindWord(note.kind).slice(0, 1).toUpperCase()));
|
|
5771
|
+
const what = el('div', 'choice-text');
|
|
5772
|
+
const where = el('div', 'bishop-where');
|
|
5773
|
+
where.append(el('b', null, kindWord(note.kind)));
|
|
5774
|
+
what.append(where, el('div', 'bishop-task', note.text));
|
|
5775
|
+
if (note.why) what.append(el('p', 'run-why', t('Why: {why}', { why: note.why })));
|
|
5776
|
+
row.append(what, el('div', 'choice-tags'));
|
|
5777
|
+
list.append(row);
|
|
5778
|
+
state.deliveries.set(note.id, row);
|
|
5779
|
+
markDelivery({ id: note.id }, row.dataset.status ?? 'pending', row);
|
|
5780
|
+
}
|
|
5781
|
+
node.append(list);
|
|
5782
|
+
state.lastSender = null;
|
|
5783
|
+
return node;
|
|
5784
|
+
}
|
|
5785
|
+
function markDelivery({ id, by = null }, status, row = null) {
|
|
5786
|
+
const target = row ?? state.deliveries.get(id);
|
|
5787
|
+
if (!target) return;
|
|
5788
|
+
target.dataset.status = status;
|
|
5789
|
+
const side = target.querySelector('.choice-tags');
|
|
5790
|
+
side.replaceChildren();
|
|
5791
|
+
if (status !== 'pending') {
|
|
5792
|
+
const who = by && by !== 'you' ? ` · @${by}` : '';
|
|
5793
|
+
side.append(el('span', 'choice-tag', `${status === 'accepted' ? t('ACCEPTED') : t('DECLINED')}${who}`));
|
|
5794
|
+
return;
|
|
5795
|
+
}
|
|
5796
|
+
if (state.device) { side.append(el('span', 'choice-tag', t('FROM THE MAC'))); return; }
|
|
5797
|
+
const accept = bishopButton(t('Accept'));
|
|
5798
|
+
const decline = bishopButton(t('Decline'), true);
|
|
5799
|
+
accept.addEventListener('click', () => bishopPress('/api/enlace/inbox/decide', { id, accept: true }, [accept, decline]));
|
|
5800
|
+
decline.addEventListener('click', () => bishopPress('/api/enlace/inbox/decide', { id, accept: false }, [accept, decline]));
|
|
5801
|
+
side.append(accept, decline);
|
|
5802
|
+
}
|
|
5803
|
+
// D-S16 and D-S17, the lines that say what crossed: the state read, notes delivered elsewhere.
|
|
5804
|
+
function renderCrossing(event) {
|
|
5805
|
+
const payload = event.payload ?? {};
|
|
5806
|
+
const node = el('div', 'system memory enlace');
|
|
5807
|
+
node.append(el('b', null, t('CONNECTION')));
|
|
5808
|
+
const room = payload.name ?? payload.to ?? payload.from ?? '';
|
|
5809
|
+
if (event.type === 'enlace.state') node.append(payload.source ? t('{room} read the state of this project.', { room: payload.to ?? '' }) : t('{room} asked for the state of this project; it keeps none (Continuity is not installed).', { room: payload.to ?? '' }));
|
|
5810
|
+
else if (event.type === 'enlace.state.received') node.append(t('@{agent} read the state of {room}.', { agent: payload.agent ?? '', room }));
|
|
5811
|
+
else node.append(t('@{agent} delivered {n} notes to {room}. They wait there until it accepts them.', { agent: payload.agent ?? '', n: (payload.notes ?? []).length, room }));
|
|
5812
|
+
return node;
|
|
5813
|
+
}
|
|
5814
|
+
// D-S18: the exam of the Coordinator's local model, said in Home as it starts and as it ends.
|
|
5815
|
+
function renderExam(event) {
|
|
5816
|
+
const payload = event.payload ?? {};
|
|
5817
|
+
const node = el('div', `system memory enlace${event.type === 'bishop.exam.failed' || (event.type === 'bishop.exam.finished' && !payload.passed) ? ' warn' : ''}`);
|
|
5818
|
+
node.append(el('b', null, t('EXAM')));
|
|
5819
|
+
if (event.type === 'bishop.exam.started') node.append(t('The Coordinator\'s local model {model} is being examined: {n} questions and orders.', { model: payload.model ?? '', n: (payload.known ?? 0) + (payload.unknown ?? 0) + (payload.orders ?? 0) }));
|
|
5820
|
+
else if (event.type === 'bishop.exam.failed') node.append(t('The exam of {model} could not finish: {why}', { model: payload.model ?? '', why: payload.error ?? '' }));
|
|
5821
|
+
else {
|
|
5822
|
+
const part = (key) => `${payload.scores?.[key]?.passed ?? 0}/${payload.scores?.[key]?.total ?? 0}`;
|
|
5823
|
+
node.append(payload.passed
|
|
5824
|
+
? t('{model} passed: {known} about the projects, {unknown} said it never discussed, {orders} orders. The Coordinator gives orders with it up to #1.', { model: payload.model ?? '', known: part('known'), unknown: part('unknown'), orders: part('orders') })
|
|
5825
|
+
: t('{model} did not pass: {known} about the projects, {unknown} said it never discussed, {orders} orders. The Coordinator keeps only answering with it.', { model: payload.model ?? '', known: part('known'), unknown: part('unknown'), orders: part('orders') }));
|
|
5826
|
+
}
|
|
5827
|
+
return node;
|
|
5828
|
+
}
|
|
5829
|
+
|
|
4838
5830
|
function renderControlStarted(event) {
|
|
4839
5831
|
const { agent, commit, message } = event.payload;
|
|
4840
5832
|
const node = el('div', 'system control');
|
|
@@ -5122,7 +6114,7 @@ async function renderOpenQuestions() {
|
|
|
5122
6114
|
try { localStorage.setItem('pulse.openqDismissed', signature); } catch { /* closed for this page only */ }
|
|
5123
6115
|
box.hidden = true;
|
|
5124
6116
|
});
|
|
5125
|
-
head.append(
|
|
6117
|
+
head.append(leadWords('span', 'openq-lead', t('◉ still open here')), close);
|
|
5126
6118
|
box.append(head);
|
|
5127
6119
|
// The pills live in a track of their own so the row can scroll sideways instead of stacking:
|
|
5128
6120
|
// these are an aside under the thread, and an aside that grows downwards pushes the composer.
|
|
@@ -5180,7 +6172,7 @@ async function renderSettled() {
|
|
|
5180
6172
|
catch { return; }
|
|
5181
6173
|
if (!decisions.length) { box.hidden = true; return; }
|
|
5182
6174
|
box.replaceChildren();
|
|
5183
|
-
box.append(
|
|
6175
|
+
box.append(leadWords('span', 'settled-lead', t('◉ decided here · {n}', { n: decisions.length })));
|
|
5184
6176
|
for (const note of decisions) {
|
|
5185
6177
|
const pill = el('button', 'settled-note');
|
|
5186
6178
|
pill.type = 'button';
|
|
@@ -6190,6 +7182,17 @@ function renderEventNode(event) {
|
|
|
6190
7182
|
case 'mode.requested': node = renderModeRequest(event); break;
|
|
6191
7183
|
case 'control.started': node = renderControlStarted(event); break;
|
|
6192
7184
|
case 'create.reverted': node = renderCreateReverted(event); break;
|
|
7185
|
+
case 'enlace.corpus':
|
|
7186
|
+
case 'enlace.learned': node = renderLearned(event); break;
|
|
7187
|
+
case 'enlace.delivered': if (!replaying) void refreshDeliveries(); node = renderDelivered(event); break;
|
|
7188
|
+
case 'enlace.accepted': markDelivery(event.payload, 'accepted'); if (!replaying) void refreshDeliveries(); return;
|
|
7189
|
+
case 'enlace.declined': markDelivery(event.payload, 'declined'); if (!replaying) void refreshDeliveries(); return;
|
|
7190
|
+
case 'enlace.state':
|
|
7191
|
+
case 'enlace.state.received':
|
|
7192
|
+
case 'enlace.sent': node = renderCrossing(event); break;
|
|
7193
|
+
case 'bishop.exam.started':
|
|
7194
|
+
case 'bishop.exam.finished':
|
|
7195
|
+
case 'bishop.exam.failed': node = renderExam(event); break;
|
|
6193
7196
|
case 'enlace.signed':
|
|
6194
7197
|
case 'enlace.revoked': if (event.type === 'enlace.revoked' && markRevokedLinks(event.payload.contract?.id)) return; node = renderEnlace(event); break;
|
|
6195
7198
|
case 'bishop.plan.proposed': node = renderBishopPlan(event); break;
|
|
@@ -6233,7 +7236,7 @@ function renderEventNode(event) {
|
|
|
6233
7236
|
// says something about itself: in a new project @madre's arrival used to take it away before
|
|
6234
7237
|
// anyone had written a word.
|
|
6235
7238
|
if (event.type.startsWith('message.') || event.type === 'command.output' || event.type === 'run.requested') removeEmpty();
|
|
6236
|
-
const stickToBottom = !drawingRecord &&
|
|
7239
|
+
const stickToBottom = !drawingRecord && pinnedToEnd;
|
|
6237
7240
|
els.column.append(tidySystemLine(node));
|
|
6238
7241
|
scheduleFold(node);
|
|
6239
7242
|
if (!drawingRecord) followTurn(event, node, stickToBottom);
|
|
@@ -6338,12 +7341,12 @@ function openStrip(strip, open = !strip.classList.contains('open')) {
|
|
|
6338
7341
|
// Opening a row is reading it: the thread stops following the end, or the taller row would be
|
|
6339
7342
|
// scrolled away from under the click.
|
|
6340
7343
|
const last = !strip.nextElementSibling;
|
|
6341
|
-
if (open && !last)
|
|
7344
|
+
if (open && !last) setPinned(false);
|
|
6342
7345
|
strip.classList.toggle('open', open);
|
|
6343
7346
|
for (const node of strip.querySelectorAll(':scope > .system.folded')) node.classList.remove('quiet');
|
|
6344
7347
|
groupRepeats(strip);
|
|
6345
7348
|
countStrip(strip);
|
|
6346
|
-
if (open) requestAnimationFrame(() => strip.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' }));
|
|
7349
|
+
if (open) requestAnimationFrame(() => { ourScroll(900); strip.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' }); });
|
|
6347
7350
|
}
|
|
6348
7351
|
els.column.addEventListener('click', (event) => {
|
|
6349
7352
|
const strip = event.target.closest?.('.sys-dots');
|
|
@@ -6372,18 +7375,51 @@ function drawRecord(events) {
|
|
|
6372
7375
|
drawingRecord = true;
|
|
6373
7376
|
try { for (const event of events) renderEvent(event); } finally { drawingRecord = false; }
|
|
6374
7377
|
}
|
|
7378
|
+
// What is off screen is not drawn (content-visibility), and until a row has been drawn once its
|
|
7379
|
+
// height is a guess (contain-intrinsic-size: 140px). A notice folded to dots is 13 px, an answer
|
|
7380
|
+
// can be 2,000: a jump into history that had never been on screen — the scrollbar dragged up, Home,
|
|
7381
|
+
// a search — landed hundreds of pixels off and the conversation jumped under the reader as the rows
|
|
7382
|
+
// near it found their real heights. Once the record is drawn, every row is laid out one time, a
|
|
7383
|
+
// batch while the page is idle, so the browser remembers its real height (contain-intrinsic-size:
|
|
7384
|
+
// auto) and goes back to skipping it. One pass per conversation opened; nothing stays drawn.
|
|
7385
|
+
let measuring = 0;
|
|
7386
|
+
function measureRows() {
|
|
7387
|
+
const run = ++measuring;
|
|
7388
|
+
const rows = [...(els.column?.children ?? [])];
|
|
7389
|
+
const idle = globalThis.requestIdleCallback ? (fn) => requestIdleCallback(fn, { timeout: 400 }) : (fn) => setTimeout(fn, 16);
|
|
7390
|
+
const frame = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (fn) => setTimeout(fn, 16);
|
|
7391
|
+
let next = rows.length;
|
|
7392
|
+
// From the end up: the rows nearest to where the reader is are measured first.
|
|
7393
|
+
const step = () => {
|
|
7394
|
+
if (run !== measuring || next <= 0) return;
|
|
7395
|
+
const batch = rows.slice(Math.max(0, next - 30), next).filter((row) => row.isConnected !== false);
|
|
7396
|
+
next -= 30;
|
|
7397
|
+
for (const row of batch) row.classList?.add('cv-measure');
|
|
7398
|
+
frame(() => frame(() => {
|
|
7399
|
+
for (const row of batch) row.classList?.remove('cv-measure');
|
|
7400
|
+
idle(step);
|
|
7401
|
+
}));
|
|
7402
|
+
};
|
|
7403
|
+
idle(step);
|
|
7404
|
+
}
|
|
6375
7405
|
function followTurn(event, node, stickToBottom) {
|
|
6376
7406
|
if (replaying) { if (stickToBottom) scrollToEnd(); return; }
|
|
6377
|
-
const
|
|
7407
|
+
const mine = event.type === 'message.created' && event.payload.role === 'user';
|
|
6378
7408
|
const isAnswer = event.type === 'message.created' && event.payload.role !== 'user';
|
|
7409
|
+
// What the human just sent takes them to the end, wherever they were reading.
|
|
7410
|
+
if (mine) { setPinned(true); scroller.to({ top: scroller.height, behavior: 'smooth' }); return; }
|
|
7411
|
+
// Gone up to read: nothing below moves them. An answer or a finished plan says so on a button.
|
|
7412
|
+
if (!pinnedToEnd) {
|
|
7413
|
+
if (isAnswer) markFinishedBelow(event.payload.sender);
|
|
7414
|
+
else if (event.type === 'plan.completed') markFinishedBelow(event.payload.orchestrator ?? null, true);
|
|
7415
|
+
return;
|
|
7416
|
+
}
|
|
6379
7417
|
if (isAnswer) {
|
|
7418
|
+
// A long answer is read from its top: the thread stops following and opens at its start.
|
|
6380
7419
|
const tall = node.getBoundingClientRect?.().height > scroller.view * 0.7;
|
|
6381
|
-
if (tall) node.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
|
|
6382
|
-
else scroller.to({ top: scroller.height, behavior: 'smooth' });
|
|
6383
|
-
return;
|
|
7420
|
+
if (tall) { setPinned(false); ourScroll(900); node.scrollIntoView?.({ behavior: 'smooth', block: 'start' }); return; }
|
|
6384
7421
|
}
|
|
6385
|
-
|
|
6386
|
-
if (stickToBottom) scrollToEnd();
|
|
7422
|
+
scrollToEnd();
|
|
6387
7423
|
}
|
|
6388
7424
|
|
|
6389
7425
|
/* ---------- theme: auto (system) → light → dark ---------- */
|
|
@@ -6442,7 +7478,8 @@ const LOOKS = {
|
|
|
6442
7478
|
accent: { key: 'pulse.accent', values: ['madre', 'mountain', 'caribbean', 'phantom', 'malbec', 'dijon', 'pacific', 'love', 'sierra', 'pitahaya', 'papaya', 'agave', 'dinamo', 'marina', 'pulso', 'leal', 'dulce'] },
|
|
6443
7479
|
bubbles: { key: 'pulse.bubbles', values: ['comfortable', 'large', 'compact', 'fitted'] },
|
|
6444
7480
|
text: { key: 'pulse.text', values: ['comfortable', 'larger', 'smaller', 'visual'] },
|
|
6445
|
-
|
|
7481
|
+
// Last, the person's own picture (public/wallpaper.js): a choice only once there is one.
|
|
7482
|
+
wallpaper: { key: 'pulse.wallpaper', values: ['default', 'jarrito', 'canela', 'paradise', 'ventura', 'magna', 'biznaga', 'oceano', 'quila', 'xico', 'tropical', 'tradicion', 'urbania', CUSTOM_WALL] },
|
|
6446
7483
|
// How a photograph wears the room's ground: a flat layer, or a mesh of its own colours.
|
|
6447
7484
|
wpStyle: { key: 'pulse.wallpaper.style', values: ['veil', 'mesh'] },
|
|
6448
7485
|
};
|
|
@@ -6452,7 +7489,7 @@ const TUNES = {
|
|
|
6452
7489
|
veil: { key: 'pulse.wallpaper.veil', min: 0, max: 140, step: 5, value: 100, apply: (node, v) => node.style.setProperty('--veil-k', String(v / 100)) },
|
|
6453
7490
|
blur: { key: 'pulse.wallpaper.blur', min: 0, max: 24, step: 1, value: 0, apply: (node, v) => node.style.setProperty('--wp-blur', `${v}px`) },
|
|
6454
7491
|
};
|
|
6455
|
-
const PHOTO_WALLPAPERS = new Set(['jarrito', 'canela', 'paradise', 'ventura', 'magna', 'biznaga', 'oceano', 'quila', 'xico', 'tropical', 'tradicion', 'urbania']);
|
|
7492
|
+
const PHOTO_WALLPAPERS = new Set(['jarrito', 'canela', 'paradise', 'ventura', 'magna', 'biznaga', 'oceano', 'quila', 'xico', 'tropical', 'tradicion', 'urbania', CUSTOM_WALL]);
|
|
6456
7493
|
function readTune(name) {
|
|
6457
7494
|
const tune = TUNES[name];
|
|
6458
7495
|
let raw = null;
|
|
@@ -6475,7 +7512,7 @@ const LOOK_WORDS = {
|
|
|
6475
7512
|
accent: { madre: () => t('Madre green'), mountain: () => t('Mountain green'), caribbean: () => t('Caribbean blue'), phantom: () => t('Phantom purple'), malbec: () => t('Malbec red'), dijon: () => t('Dijon yellow'), pacific: () => t('Pacific blue'), love: () => t('Love pink'), sierra: () => t('Sierra blue'), pitahaya: () => 'Pitahaya', papaya: () => 'Papaya', agave: () => 'Agave', dinamo: () => t('Dynamo black'), marina: () => t('Marina blue'), pulso: () => t('Pulse green'), leal: () => t('Loyal orange'), dulce: () => t('Sweet yellow') },
|
|
6476
7513
|
bubbles: { comfortable: () => t('Comfortable'), large: () => t('Large'), compact: () => t('Compact'), fitted: () => t('Fitted') },
|
|
6477
7514
|
text: { comfortable: () => t('Comfortable'), larger: () => t('Larger'), smaller: () => t('Smaller'), visual: () => t('Visual') },
|
|
6478
|
-
wallpaper: { default: () => t('Default'), jarrito: () => 'Jarrito', canela: () => 'Canela', paradise: () => t('Paradise'), ventura: () => 'Ventura', magna: () => 'Magna', biznaga: () => 'Biznaga', oceano: () => 'Oceano', quila: () => 'Quila', xico: () => 'Xico', tropical: () => 'Tropical', tradicion: () => 'Tradicion', urbania: () => 'Urbania' },
|
|
7515
|
+
wallpaper: { default: () => t('Default'), jarrito: () => 'Jarrito', canela: () => 'Canela', paradise: () => t('Paradise'), ventura: () => 'Ventura', magna: () => 'Magna', biznaga: () => 'Biznaga', oceano: () => 'Oceano', quila: () => 'Quila', xico: () => 'Xico', tropical: () => 'Tropical', tradicion: () => 'Tradicion', urbania: () => 'Urbania', custom: () => t('Your picture') },
|
|
6479
7516
|
wpStyle: { veil: () => t('Layer'), mesh: () => t('Mesh') },
|
|
6480
7517
|
};
|
|
6481
7518
|
// Jarrito took MADRE's place among the wallpapers and Canela took Autumn's: a choice saved under
|
|
@@ -6485,6 +7522,8 @@ function readLook(name) {
|
|
|
6485
7522
|
let value = null;
|
|
6486
7523
|
try { value = localStorage.getItem(LOOKS[name].key); } catch { /* no storage */ }
|
|
6487
7524
|
if (name === 'wallpaper' && RENAMED_LOOKS[value]) value = RENAMED_LOOKS[value];
|
|
7525
|
+
// Chosen, and then taken away (here or on another page): the room as it always was.
|
|
7526
|
+
if (name === 'wallpaper' && value === CUSTOM_WALL && !customWall()) value = null;
|
|
6488
7527
|
return LOOKS[name].values.includes(value) ? value : LOOKS[name].values[0];
|
|
6489
7528
|
}
|
|
6490
7529
|
function applyLook(name, value) {
|
|
@@ -6492,6 +7531,8 @@ function applyLook(name, value) {
|
|
|
6492
7531
|
if (!rootElement) return;
|
|
6493
7532
|
if (value === LOOKS[name].values[0]) delete rootElement.dataset[name]; else rootElement.dataset[name] = value;
|
|
6494
7533
|
}
|
|
7534
|
+
// The person's picture as this browser last knew it, before the first look is painted.
|
|
7535
|
+
paintCustomWall(rememberedWall());
|
|
6495
7536
|
for (const name of Object.keys(LOOKS)) applyLook(name, readLook(name));
|
|
6496
7537
|
for (const name of Object.keys(TUNES)) applyTune(name, readTune(name));
|
|
6497
7538
|
// The half on the right and the conversations in windows read the same choices, the moment they
|
|
@@ -6509,7 +7550,7 @@ window.addEventListener?.('storage', (event) => {
|
|
|
6509
7550
|
// phone's bridge.
|
|
6510
7551
|
// The wallpaper is each page's own (2026-10-13): this project keeps the one chosen here, Home the
|
|
6511
7552
|
// one chosen in Home (src/looks.mjs, `rooms`). The rest of the looks are shared.
|
|
6512
|
-
const SHARED_LOOKS = ['pulse.theme', 'pulse.accent', 'pulse.wallpaper.style', 'pulse.wallpaper.veil', 'pulse.wallpaper.blur', 'pulse.bubbles', 'pulse.text'];
|
|
7553
|
+
const SHARED_LOOKS = ['pulse.theme', 'pulse.accent', 'pulse.wallpaper.style', 'pulse.wallpaper.veil', 'pulse.wallpaper.blur', 'pulse.bubbles', 'pulse.text', 'pulse.neural'];
|
|
6513
7554
|
let booted = false;
|
|
6514
7555
|
let looksPush = 0;
|
|
6515
7556
|
// Saved for this project alone, on the server, so it outlives the port the page is served on.
|
|
@@ -6529,8 +7570,12 @@ async function pullLooks() {
|
|
|
6529
7570
|
let answer = null;
|
|
6530
7571
|
try { answer = await fetch('/api/looks', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)); } catch { return; }
|
|
6531
7572
|
if (!answer?.looks) return;
|
|
7573
|
+
// The person's own picture, kept once for every page (public/wallpaper.js): painted as it is now.
|
|
7574
|
+
const before = customWall()?.v ?? null;
|
|
7575
|
+
paintCustomWall(answer.custom ?? null);
|
|
6532
7576
|
// Before a project had its own, it wore the shared one: that is where it starts.
|
|
6533
|
-
|
|
7577
|
+
let wall = answer.own?.wallpaper ?? answer.looks['pulse.wallpaper'] ?? null;
|
|
7578
|
+
if (wall === CUSTOM_WALL && !answer.custom) wall = null;
|
|
6534
7579
|
try {
|
|
6535
7580
|
// Nothing kept yet: the first page that has a choice of its own hands it to the others.
|
|
6536
7581
|
if (!answer.saved) { if (SHARED_LOOKS.some((key) => localStorage.getItem(key) !== null)) pushLooks(); return; }
|
|
@@ -6541,7 +7586,7 @@ async function pullLooks() {
|
|
|
6541
7586
|
if (value === null) localStorage.removeItem(key); else localStorage.setItem(key, value);
|
|
6542
7587
|
changed = true;
|
|
6543
7588
|
}
|
|
6544
|
-
if (changed) reapplyLooks();
|
|
7589
|
+
if (changed || before !== (answer.custom?.v ?? null)) reapplyLooks();
|
|
6545
7590
|
} catch { /* no storage: this page keeps the look it has */ }
|
|
6546
7591
|
}
|
|
6547
7592
|
function reapplyLooks() {
|
|
@@ -6551,6 +7596,8 @@ function reapplyLooks() {
|
|
|
6551
7596
|
applyTheme(themeMode);
|
|
6552
7597
|
for (const name of Object.keys(LOOKS)) applyLook(name, readLook(name));
|
|
6553
7598
|
for (const name of Object.keys(TUNES)) applyTune(name, readTune(name));
|
|
7599
|
+
// The maps' neural mode, chosen on another page (public/neural.js).
|
|
7600
|
+
if (booted) syncNostromoNeural();
|
|
6554
7601
|
if (booted) { renderAgents(); renderPicker(); syncSettingsCards(); }
|
|
6555
7602
|
}
|
|
6556
7603
|
void pullLooks();
|
|
@@ -6687,6 +7734,16 @@ function drawCustom() {
|
|
|
6687
7734
|
const papers = el('div', 'custom-papers');
|
|
6688
7735
|
papers.setAttribute('role', 'radiogroup');
|
|
6689
7736
|
for (const value of LOOKS.wallpaper.values) {
|
|
7737
|
+
// The person's own picture: chosen like any other once it is here; until then, the way to add it.
|
|
7738
|
+
if (value === CUSTOM_WALL) {
|
|
7739
|
+
const mine = customWall();
|
|
7740
|
+
const paper = mine ? radio(el('button', 'paper'), draft.wallpaper === CUSTOM_WALL, () => setDraft('wallpaper', CUSTOM_WALL)) : el('button', 'paper');
|
|
7741
|
+
paper.type = 'button';
|
|
7742
|
+
customTile(paper, mine);
|
|
7743
|
+
if (!mine) paper.addEventListener('click', () => void addPicture({ busy: paper, chosen: () => setDraft('wallpaper', CUSTOM_WALL), failed: (message) => toast(message, { tone: 'note' }) }));
|
|
7744
|
+
papers.append(paper);
|
|
7745
|
+
continue;
|
|
7746
|
+
}
|
|
6690
7747
|
const paper = radio(el('button', 'paper'), value === draft.wallpaper, () => setDraft('wallpaper', value));
|
|
6691
7748
|
paper.dataset.wp = value;
|
|
6692
7749
|
paper.append(el('span', 'paper-swatch'), el('span', 'paper-name', LOOK_WORDS.wallpaper[value]()));
|
|
@@ -6721,6 +7778,15 @@ function drawCustom() {
|
|
|
6721
7778
|
return row;
|
|
6722
7779
|
};
|
|
6723
7780
|
paperTune.append(dial('veil', t('Intensity'), (v) => `${v}%`), dial('blur', t('Blur'), (v) => `${v}px`));
|
|
7781
|
+
// Your picture: where the screen falls on it, and changing it or taking it away.
|
|
7782
|
+
if (draft.wallpaper === CUSTOM_WALL && customWall()) {
|
|
7783
|
+
paperTune.append(customTools(customWall(), {
|
|
7784
|
+
button: (words, click) => sxButton(words, click),
|
|
7785
|
+
changed: (kept, { reframed }) => { if (!reframed) drawCustom(); },
|
|
7786
|
+
removed: () => setDraft('wallpaper', LOOKS.wallpaper.values[0]),
|
|
7787
|
+
failed: (message) => toast(message, { tone: 'note' }),
|
|
7788
|
+
}));
|
|
7789
|
+
}
|
|
6724
7790
|
}
|
|
6725
7791
|
const themes = el('div', 'custom-seg three');
|
|
6726
7792
|
themes.setAttribute('role', 'radiogroup');
|
|
@@ -6879,7 +7945,7 @@ customWindow.node?.addEventListener('close', () => { customButton?.setAttribute(
|
|
|
6879
7945
|
// from the same code and the same answers (state.settingsTargets), wearing the room's ink.
|
|
6880
7946
|
const SETTINGS_SECTIONS = [
|
|
6881
7947
|
['You', 'How it looks, how it works and your files', ['look', 'room', 'files']],
|
|
6882
|
-
['The agents', 'Who works, what they remember and which outside tools they use', ['agents', 'memory', 'mcp']],
|
|
7948
|
+
['The agents', 'Who works, what they remember and which outside tools they use', ['agents', 'memory', 'local', 'mcp']],
|
|
6883
7949
|
['System', 'What agents may do, privacy and updates', ['permissions', 'privacy', 'release']],
|
|
6884
7950
|
];
|
|
6885
7951
|
const SETTINGS_CATS = {
|
|
@@ -6887,13 +7953,16 @@ const SETTINGS_CATS = {
|
|
|
6887
7953
|
room: { name: 'Work and limits', color: '#d97757', icon: '<rect x="3.5" y="3.5" width="5.5" height="5.5" rx="1.4" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="11" y="3.5" width="5.5" height="5.5" rx="1.4" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="3.5" y="11" width="5.5" height="5.5" rx="1.4" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="11" y="11" width="5.5" height="5.5" rx="1.4" fill="none" stroke="currentColor" stroke-width="1.6"/>' },
|
|
6888
7954
|
agents: { name: 'Agents', color: '#10a37f', icon: '<circle cx="7" cy="8" r="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="13.6" cy="8" r="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M2.8 16c.6-2.3 2.2-3.6 4.2-3.6s3.6 1.3 4.2 3.6M9.6 13c.9-.4 2.4-.6 4-.6 2 0 3.6 1.3 4.2 3.6" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>' },
|
|
6889
7955
|
memory: { name: 'What it remembers', color: '#3fb6d9', icon: '<rect x="5" y="5" width="10" height="10" rx="1.8" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="8.1" y="8.1" width="3.8" height="3.8" rx=".6" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M8 2.5V5M12 2.5V5M8 15v2.5M12 15v2.5M2.5 8H5M2.5 12H5M15 8h2.5M15 12h2.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>' },
|
|
7956
|
+
// Local AI, also here and not only in Modules (2026-10-16): the same card, and what this
|
|
7957
|
+
// computer can run and train with it.
|
|
7958
|
+
local: { name: 'Local AI', color: '#7f77dd', icon: '<rect x="5" y="5" width="10" height="10" rx="2.4" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M8 2.6v2.4M12 2.6v2.4M8 15v2.4M12 15v2.4M2.6 8h2.4M2.6 12h2.4M15 8h2.4M15 12h2.4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="10" cy="10" r="1.8" fill="currentColor"/>' },
|
|
6890
7959
|
mcp: { name: 'Outside tools (MCP)', color: '#5b8def', icon: '<path d="M7.2 3.2v3.3M12.8 3.2v3.3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M5.2 6.5h9.6v2.4a4.8 4.8 0 0 1-9.6 0z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M10 13.7v3.1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>' },
|
|
6891
7960
|
files: { name: 'Files and local sites', color: '#c8955a', icon: '<path d="M3.5 6.2c0-.9.7-1.6 1.6-1.6h3l1.6 1.8h5.2c.9 0 1.6.7 1.6 1.6v6.4c0 .9-.7 1.6-1.6 1.6H5.1c-.9 0-1.6-.7-1.6-1.6z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>' },
|
|
6892
7961
|
permissions: { name: 'Permissions', color: '#e5484d', icon: '<path d="M10 2.8 4.2 5v4.6c0 3.6 2.4 6.4 5.8 7.6 3.4-1.2 5.8-4 5.8-7.6V5z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><circle cx="10" cy="9" r="1.7" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M10 10.7v2.6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>' },
|
|
6893
7962
|
privacy: { name: 'Privacy', color: '#d4a72c', icon: '<rect x="4.5" y="8.5" width="11" height="8" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M7 8.5V6.6a3 3 0 0 1 6 0v1.9" fill="none" stroke="currentColor" stroke-width="1.6"/>' },
|
|
6894
7963
|
release: { name: 'Updates and errors', color: '#4caf3a', icon: '<path d="M15.6 10a5.6 5.6 0 1 1-1.64-3.96" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M15.7 3.6v3.2h-3.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>' },
|
|
6895
7964
|
};
|
|
6896
|
-
const SETTINGS_ORDER = ['look', 'room', 'files', 'agents', 'memory', 'mcp', 'permissions', 'privacy', 'release'];
|
|
7965
|
+
const SETTINGS_ORDER = ['look', 'room', 'files', 'agents', 'memory', 'local', 'mcp', 'permissions', 'privacy', 'release'];
|
|
6897
7966
|
// The words the search knows, each pointing to the category that holds it and to the words on the
|
|
6898
7967
|
// screen that carry it there, so a result opens the category and lands on the setting.
|
|
6899
7968
|
const SETTINGS_INDEX = [
|
|
@@ -6933,6 +8002,12 @@ function settingsStatus(id) {
|
|
|
6933
8002
|
return { line: `${finder} · ${ripley ? (ripley.status?.installed ? t('preview on') : t('preview off')) : t('Reading…')}` };
|
|
6934
8003
|
}
|
|
6935
8004
|
if (id === 'mcp') return mcpStatus();
|
|
8005
|
+
if (id === 'local') {
|
|
8006
|
+
const item = modules.items.find((one) => one.id === 'ollama');
|
|
8007
|
+
if (!item) return { line: t('Reading…') };
|
|
8008
|
+
const daily = item.recommended?.hardware?.recommended?.run;
|
|
8009
|
+
return { line: [item.status?.detail, daily ? t('your computer runs {model}', { model: daily.label }) : null].filter(Boolean).join(' · ') };
|
|
8010
|
+
}
|
|
6936
8011
|
if (!data) return { line: t('Reading…') };
|
|
6937
8012
|
if (id === 'agents') {
|
|
6938
8013
|
const here = data.agents.filter((agent) => agent.detected);
|
|
@@ -7290,6 +8365,7 @@ function settingsDetail(id) {
|
|
|
7290
8365
|
if (id === 'release') { part('release'); part('sentinel'); }
|
|
7291
8366
|
if (id === 'files') part('files');
|
|
7292
8367
|
if (id === 'mcp') part('mcp');
|
|
8368
|
+
if (id === 'local') part('local');
|
|
7293
8369
|
return detail;
|
|
7294
8370
|
}
|
|
7295
8371
|
|
|
@@ -7297,6 +8373,7 @@ function settingsDetail(id) {
|
|
|
7297
8373
|
function fillSettingsCategory(id) {
|
|
7298
8374
|
if (id === 'files') { drawFilesTarget(); void refreshModules(); }
|
|
7299
8375
|
if (id === 'mcp') { drawMcpTarget(); void loadMcp(); }
|
|
8376
|
+
if (id === 'local') { drawLocalTarget(); void refreshModules(); }
|
|
7300
8377
|
if (['agents', 'room', 'memory', 'privacy', 'permissions'].includes(id)) { if (settingsUI.data) renderSettings(); void loadSettings(); }
|
|
7301
8378
|
if (id === 'room' || id === 'release') { if (turnsUI.turns || sentinelUI.settings) renderMotherSentinel(); void loadTurns(); void loadSentinel(); }
|
|
7302
8379
|
if (id === 'release') renderUpdate();
|
|
@@ -7638,6 +8715,8 @@ for (const button of document.querySelectorAll?.('button') ?? []) {
|
|
|
7638
8715
|
if (!words.trim()) button.classList.add('x-only');
|
|
7639
8716
|
button.append(xIcon());
|
|
7640
8717
|
}
|
|
8718
|
+
// And its ↗ and ▾, drawn (tailMark).
|
|
8719
|
+
for (const node of document.querySelectorAll?.('a, button') ?? []) tailMark(node);
|
|
7641
8720
|
|
|
7642
8721
|
/* ---------- project files panel ---------- */
|
|
7643
8722
|
|
|
@@ -7902,7 +8981,8 @@ function treeNode(path, entry) {
|
|
|
7902
8981
|
button.title = path;
|
|
7903
8982
|
button.addEventListener('contextmenu', (event) => finderMenu(event, path, entry));
|
|
7904
8983
|
if (entry.kind === 'dir') {
|
|
7905
|
-
const caret = el('span', 'caret'
|
|
8984
|
+
const caret = el('span', 'caret');
|
|
8985
|
+
caret.innerHTML = ICON_CHEVRON; // trusted: our own SVG string · turned by styles.css
|
|
7906
8986
|
button.append(caret, el('span', 'name', entry.name));
|
|
7907
8987
|
// Something under this folder changed: the dot of whoever touched it last, the way down.
|
|
7908
8988
|
const under = touchedUnder(path);
|
|
@@ -7916,7 +8996,6 @@ function treeNode(path, entry) {
|
|
|
7916
8996
|
let opened = false;
|
|
7917
8997
|
const show = (open) => {
|
|
7918
8998
|
opened = open;
|
|
7919
|
-
caret.textContent = opened ? '▾' : '▸';
|
|
7920
8999
|
children.hidden = !opened;
|
|
7921
9000
|
item.setAttribute('aria-expanded', String(opened));
|
|
7922
9001
|
if (opened) tree.openDirs.add(path); else tree.openDirs.delete(path);
|
|
@@ -7952,8 +9031,6 @@ function treeNode(path, entry) {
|
|
|
7952
9031
|
// The two buttons every surface carries in its header, beside its ×: split, and window. Declared
|
|
7953
9032
|
// before the surfaces are built, which happens while this file is still being read.
|
|
7954
9033
|
const FLOAT_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3.25 12.25v-6.5a2.5 2.5 0 0 1 2.5-2.5h6.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><rect class="ic-f" x="6.25" y="6.25" width="10.5" height="10.5" rx="2.3" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>';
|
|
7955
|
-
const SPLIT_LEFT_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="2.75" y="3.75" width="14.5" height="12.5" rx="3.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><rect class="ic-h" x="5" y="6" width="3.6" height="8" rx="1.3" fill="none" stroke="currentColor" stroke-width="1.2"/><path d="M10.6 6.4v7.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
7956
|
-
const SPLIT_RIGHT_ICON = '<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="2.75" y="3.75" width="14.5" height="12.5" rx="3.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><rect class="ic-h" x="11.4" y="6" width="3.6" height="8" rx="1.3" fill="none" stroke="currentColor" stroke-width="1.2"/><path d="M9.4 6.4v7.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
7957
9034
|
|
|
7958
9035
|
/* ---------- surfaces: everything that can stand beside the conversation ---------- */
|
|
7959
9036
|
|
|
@@ -8032,7 +9109,8 @@ function sideWidthFor(surface) {
|
|
|
8032
9109
|
const vw = window.innerWidth;
|
|
8033
9110
|
if (surface.key === 'chats') return Math.max(SIDE_MIN, Math.min(420, vw * .24));
|
|
8034
9111
|
if (surface.key === 'tree') return Math.max(SIDE_MIN, Math.min(460, vw * .26));
|
|
8035
|
-
|
|
9112
|
+
// The viewer docked is the code workspace: the files, the code and what it makes need room.
|
|
9113
|
+
return Math.max(420, Math.min(980, vw * .5));
|
|
8036
9114
|
}
|
|
8037
9115
|
const splitOn = (side) => surfaces.find((one) => one.side === side && isSplit(one) && (one.kind === 'dialog' ? one.node()?.open : !one.node()?.hidden) && !one.node()?.classList.contains('leaving'));
|
|
8038
9116
|
|
|
@@ -8801,7 +9879,12 @@ function windowBoxFor(surface) {
|
|
|
8801
9879
|
const vw = window.innerWidth;
|
|
8802
9880
|
const width = Number.isFinite(set) ? set : surface.key === 'chats' ? Math.max(320, Math.min(460, vw / 2 - 468)) : Math.max(300, Math.min(460, vw / 2 - 476));
|
|
8803
9881
|
const left = surface.side === 'left' ? room.left + RAIL : room.right - RAIL - width;
|
|
8804
|
-
|
|
9882
|
+
// A room not laid out yet (a page still loading its conversation) is measured short and low; the
|
|
9883
|
+
// window then opens under the bar, as tall as the screen allows, instead of at the room's foot.
|
|
9884
|
+
const settled = room.height >= 360 && room.top < window.innerHeight / 3;
|
|
9885
|
+
const y = settled ? room.top + 42 : 96;
|
|
9886
|
+
const h = settled ? Math.min(room.height - 28, 620) : Math.max(WINDOW_MIN_H, Math.min(620, window.innerHeight - y - 120));
|
|
9887
|
+
return { x: left + (surface.side === 'left' ? 36 : -36), y, w: width, h };
|
|
8805
9888
|
}
|
|
8806
9889
|
|
|
8807
9890
|
// First, last, invert, play: the surface is moved to where it now belongs at once, then drawn from
|
|
@@ -8908,7 +9991,9 @@ function setSurfaceMode(surface, next, { animate = true } = {}) {
|
|
|
8908
9991
|
if (next !== 'window') dropWindow(node);
|
|
8909
9992
|
for (const property of ['left', 'top', 'width', 'height']) node.style.removeProperty(property);
|
|
8910
9993
|
if (surface.kind === 'dialog') reseatDialog(node, next === 'base');
|
|
8911
|
-
|
|
9994
|
+
// Its box is kept the moment it becomes a window, not only when it is first carried: the mode
|
|
9995
|
+
// was remembered and the place was not, so a reload brought it back with nowhere to be.
|
|
9996
|
+
if (next === 'window') { placeWindow(surface, windowBoxFor(surface)); bringWindowForward(node); rememberSurfaces(); }
|
|
8912
9997
|
layoutPanels();
|
|
8913
9998
|
};
|
|
8914
9999
|
if (animate && panelShown(surface)) flipPanel(node, change); else change();
|
|
@@ -9000,7 +10085,14 @@ if (shellBox && !SPLIT_CHAT) {
|
|
|
9000
10085
|
node.classList.toggle('window', isWindow(surface));
|
|
9001
10086
|
node.classList.toggle('split', isSplit(surface));
|
|
9002
10087
|
}
|
|
9003
|
-
|
|
10088
|
+
// A window remembered without its box (the mode was kept, the place was not) comes back where a
|
|
10089
|
+
// new window opens. It used to come back at the corner, with nothing to carry it by: «New
|
|
10090
|
+
// conversation» reloads the page, and the conversations window was stuck there (2026-10-18).
|
|
10091
|
+
if (isWindow(surface)) {
|
|
10092
|
+
if (windowBoxes[surface.key]?.w) placeWindow(surface, windowBoxes[surface.key]);
|
|
10093
|
+
// Measured once the room has its shape: on the first frames the thread is not laid out yet.
|
|
10094
|
+
else requestAnimationFrame(() => requestAnimationFrame(() => { placeWindow(surface, windowBoxFor(surface)); rememberSurfaces(); }));
|
|
10095
|
+
}
|
|
9004
10096
|
carrySurface(surface);
|
|
9005
10097
|
surfaceButtons(surface);
|
|
9006
10098
|
sideGrip(surface);
|
|
@@ -9759,7 +10851,11 @@ function carryWindowBy(head, node, { active = () => true, box, place, done }) {
|
|
|
9759
10851
|
event.preventDefault();
|
|
9760
10852
|
const startX = event.clientX;
|
|
9761
10853
|
const startY = event.clientY;
|
|
9762
|
-
|
|
10854
|
+
// Where it is on screen, whenever what was kept of it is not a whole box: a window is always
|
|
10855
|
+
// carried from where the person sees it.
|
|
10856
|
+
const kept = box() ?? {};
|
|
10857
|
+
const seen = node.getBoundingClientRect();
|
|
10858
|
+
const from = [kept.x, kept.y, kept.w, kept.h].every(Number.isFinite) ? { ...kept } : { ...kept, x: seen.left, y: seen.top, w: seen.width, h: seen.height };
|
|
9763
10859
|
let carrying = false;
|
|
9764
10860
|
let frame = 0;
|
|
9765
10861
|
let to = null;
|
|
@@ -9955,10 +11051,10 @@ function measureDock() {
|
|
|
9955
11051
|
const field = els.composer.querySelector?.('.field')?.getBoundingClientRect?.();
|
|
9956
11052
|
if (field && !pageScrolls()) shell.style.setProperty('--type-gap', `${Math.max(0, Math.ceil(shell.getBoundingClientRect().bottom - field.top))}px`);
|
|
9957
11053
|
if (height === measureDock.last) return;
|
|
9958
|
-
const stay = atBottom();
|
|
9959
11054
|
measureDock.last = height;
|
|
9960
11055
|
shell.style.setProperty('--dock-h', `${height}px`);
|
|
9961
|
-
|
|
11056
|
+
placeToast();
|
|
11057
|
+
keepPinned();
|
|
9962
11058
|
}
|
|
9963
11059
|
if (document.querySelector('.shell') && els.composer?.getBoundingClientRect) {
|
|
9964
11060
|
measureDock();
|
|
@@ -10201,7 +11297,7 @@ if (SPLIT_CHAT && !Array.isArray(initial.events)) {
|
|
|
10201
11297
|
}
|
|
10202
11298
|
// The corner of the room: which MADRE this is, and whose it is, readable on any wallpaper, and a
|
|
10203
11299
|
// link to its author (public/attribution.js: NOTICE's attribution, the same on Home).
|
|
10204
|
-
if (initial.version) drawAttribution(document.querySelector('#colophon'), initial.version);
|
|
11300
|
+
if (initial.version) { drawAttribution(document.querySelector('#colophon'), initial.version); void drawColophonKeys(document.querySelector('#colophon')); }
|
|
10205
11301
|
void adoptLanguage(initial.language, initial.languageChosen);
|
|
10206
11302
|
// Whether the copilot is on for this project, so the robot and the mode say so from the start.
|
|
10207
11303
|
void loadCopilot().then(() => showNewHints());
|
|
@@ -10323,6 +11419,7 @@ function boot() {
|
|
|
10323
11419
|
syncAshUI(Boolean(initial.ash?.enabled));
|
|
10324
11420
|
void renderSettled();
|
|
10325
11421
|
void renderOpenQuestions();
|
|
11422
|
+
void refreshDeliveries();
|
|
10326
11423
|
updateStopAll();
|
|
10327
11424
|
if (typeof setInterval === 'function') { const ticker = setInterval(refreshBudgetWindow, 60000); ticker.unref?.(); }
|
|
10328
11425
|
renderAgents();
|
|
@@ -10340,6 +11437,7 @@ function boot() {
|
|
|
10340
11437
|
try { performance.measure?.('madre:history', 'madre:history-start', 'madre:history-end'); } catch { /* no timeline */ }
|
|
10341
11438
|
setLoader(arrival, 0.9);
|
|
10342
11439
|
scrollToEnd();
|
|
11440
|
+
measureRows();
|
|
10343
11441
|
matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { applyTheme(themeMode); renderAgents(); renderPicker(); });
|
|
10344
11442
|
|
|
10345
11443
|
// The live light sits on the conversation it is live for, at the end of the path in the bar.
|
|
@@ -10449,7 +11547,7 @@ function forgetConversation() {
|
|
|
10449
11547
|
state.userMessages.clear();
|
|
10450
11548
|
state.runsReturned.clear();
|
|
10451
11549
|
state.ratings.clear();
|
|
10452
|
-
state.ratingNodes.clear();
|
|
11550
|
+
state.ratingNodes.clear(); state.deliveries.clear();
|
|
10453
11551
|
state.running.clear();
|
|
10454
11552
|
state.plansRunning.clear();
|
|
10455
11553
|
state.agentStats.clear();
|
|
@@ -10490,6 +11588,7 @@ async function paintConversation(payload) {
|
|
|
10490
11588
|
renderOnboarding();
|
|
10491
11589
|
void renderOpenQuestions();
|
|
10492
11590
|
scrollToEnd();
|
|
11591
|
+
measureRows();
|
|
10493
11592
|
connectStream();
|
|
10494
11593
|
}
|
|
10495
11594
|
|
|
@@ -10508,7 +11607,12 @@ function autosize() {
|
|
|
10508
11607
|
els.highlight.style.height = els.input.style.height;
|
|
10509
11608
|
renderHighlight();
|
|
10510
11609
|
}
|
|
10511
|
-
function syncHighlightScroll() {
|
|
11610
|
+
function syncHighlightScroll() {
|
|
11611
|
+
els.highlight.scrollTop = els.input.scrollTop;
|
|
11612
|
+
// Scrolled inside, the first line it shows fades in under the box's edge instead of being cut
|
|
11613
|
+
// in half against it (styles.css, .editor.scrolled).
|
|
11614
|
+
els.input.parentElement?.classList?.toggle('scrolled', els.input.scrollTop > 1);
|
|
11615
|
+
}
|
|
10512
11616
|
els.input.addEventListener('input', autosize);
|
|
10513
11617
|
els.input.addEventListener('scroll', syncHighlightScroll);
|
|
10514
11618
|
autosize();
|
|
@@ -11243,12 +12347,15 @@ function updateCrewLabel() {
|
|
|
11243
12347
|
// The copilot reads as a link of the chain: HUMAN › COPILOT › TOKEN SAVER › @CODEX ›.
|
|
11244
12348
|
const piloting = copilotSends(els.target?.value);
|
|
11245
12349
|
const armed = state.mode >= 3 && !piloting;
|
|
11246
|
-
|
|
12350
|
+
const path = piloting ? `${t('HUMAN ›')} ${t('COPILOT ›')}${order ? ` ${t('TOKEN SAVER ›')}` : ''}` : state.intruder && state.mode < 3 ? t('INTRUDER ›') : armed ? `${t('DIAGNOSTICS')} · ${MODES[state.mode].label} @${(state.modeArmedFor ?? els.target.value ?? '').toUpperCase()} ›`
|
|
11247
12351
|
: state.mode === 0 ? t('HUMAN · INCOGNITO ›')
|
|
11248
12352
|
: state.moduleCommand ? (state.create ? t('DIAGNOSTICS · MODULE · CREATE ›') : t('DIAGNOSTICS · MODULE · NEEDS #2 ›'))
|
|
11249
12353
|
: order ? `${t('HUMAN ›')} ${state.create ? t('TOKEN SAVER · CREATE ›') : t('TOKEN SAVER ›')}`
|
|
11250
12354
|
: state.create ? t('HUMAN · CREATE ›')
|
|
11251
12355
|
: state.expendable ? t('EXPENDABLE ›') : t('HUMAN ›');
|
|
12356
|
+
// The address is two pieces so a narrow box cuts the path with «…» and never who it goes to.
|
|
12357
|
+
if (typeof els.crewLabel.replaceChildren === 'function') els.crewLabel.replaceChildren(el('span', 'crew-path', path));
|
|
12358
|
+
else els.crewLabel.textContent = path;
|
|
11252
12359
|
// Who the message goes to closes the line, in that agent's colour: HUMAN › TOKEN SAVER › @CODEX ›.
|
|
11253
12360
|
const to = els.target?.value;
|
|
11254
12361
|
if (to && !armed && (piloting || !state.intruder) && typeof els.crewLabel.append === 'function') {
|
|
@@ -11448,6 +12555,14 @@ els.composer.addEventListener('submit', async (event) => {
|
|
|
11448
12555
|
// and the effort are the ones chosen for THAT agent; the chip's would be another agent's.
|
|
11449
12556
|
const mention = outgoing.match(/^@([a-z0-9_-]+)\b/i)?.[1]?.toLowerCase();
|
|
11450
12557
|
const addressed = mention && state.agents.has(mention) ? mention : target;
|
|
12558
|
+
// An agent that is switched off (no session, or nothing MADRE can run) is not written to: the
|
|
12559
|
+
// message stays in the box and Settings opens on that agent, where it is connected.
|
|
12560
|
+
if (addressed && state.agents.has(addressed) && !canAnswer(state.agents.get(addressed))) {
|
|
12561
|
+
const off = state.agents.get(addressed);
|
|
12562
|
+
toast(off.ready ? t('@{agent} is not signed in. Connect it in Settings › Agents and send again.', { agent: addressed }) : t('@{agent} is not available on this computer. Set it up in Settings › Agents.', { agent: addressed }), { tone: 'note' });
|
|
12563
|
+
openSettingsOn('agents', { agent: addressed });
|
|
12564
|
+
return;
|
|
12565
|
+
}
|
|
11451
12566
|
// CONTROL and AIRLOCK are armed for one agent. A message that ends up addressed to another one
|
|
11452
12567
|
// is asked for again, for that one; the keys typed for @gemini never open the project to @claude.
|
|
11453
12568
|
const piloted = copilotSends(addressed);
|
|
@@ -11487,6 +12602,7 @@ els.composer.addEventListener('submit', async (event) => {
|
|
|
11487
12602
|
els.input.value = '';
|
|
11488
12603
|
state.replyTo = null;
|
|
11489
12604
|
renderReplyQuote();
|
|
12605
|
+
offerModule(outgoing);
|
|
11490
12606
|
state.pending = [];
|
|
11491
12607
|
renderPendingAttachments();
|
|
11492
12608
|
// The try went with the message; the next one goes at the rung chosen before it.
|
|
@@ -11649,7 +12765,7 @@ function roomRecords() {
|
|
|
11649
12765
|
// word, the ones sharing the most of them come first: «codex no responde timeout» still finds the
|
|
11650
12766
|
// timeout. An agent's name keeps to that agent and the general ones; little words do not count.
|
|
11651
12767
|
const LOOSE_SKIP = new Set(['the', 'and', 'not', 'for', 'with', 'que', 'los', 'las', 'del', 'por', 'una', 'uno', 'con', 'sin', 'esta', 'este', 'muy', 'mas', 'pero']);
|
|
11652
|
-
const LOOSE_AGENTS = new Set(['codex', 'claude', 'gemini', 'opencode', 'madre', 'ollama']);
|
|
12768
|
+
const LOOSE_AGENTS = new Set(['codex', 'claude', 'gemini', 'opencode', 'cursor', 'madre', 'ollama']);
|
|
11653
12769
|
function looseConditions(text) {
|
|
11654
12770
|
const words = settingsFold(text).split(/[^a-z0-9#.@_-]+/).map((word) => word.replace(/^@/, '')).filter((word) => word.length >= 3 && !LOOSE_SKIP.has(word));
|
|
11655
12771
|
const agent = words.find((word) => LOOSE_AGENTS.has(word)) ?? null;
|
|
@@ -12077,6 +13193,7 @@ function renderTerminalCard(event) {
|
|
|
12077
13193
|
undo.textContent = t('RESTORED');
|
|
12078
13194
|
} catch (error) { toast(`${t('Diagnostics › ')}${error.message}`); undo.disabled = false; }
|
|
12079
13195
|
});
|
|
13196
|
+
node.classList.add('has-undo');
|
|
12080
13197
|
node.append(undo);
|
|
12081
13198
|
}
|
|
12082
13199
|
}
|
|
@@ -12092,7 +13209,7 @@ function renderEyecat(event) {
|
|
|
12092
13209
|
const node = el('div', 'command-card eyecat');
|
|
12093
13210
|
node.dataset.eyecat = finding.key;
|
|
12094
13211
|
const head = el('div', 'head');
|
|
12095
|
-
head.append(
|
|
13212
|
+
head.append(leadWords('b', null, t('◉ MEMORY REVIEWER')), el('span', null, finding.kind === 'unsupported' ? t('a note its own sources do not support') : t('two memories that cannot both be true')));
|
|
12096
13213
|
if (finding.confidence !== null && finding.confidence !== undefined) head.append(el('span', 'args', t('{pct}% · judged by @{judge}', { pct: Math.round(finding.confidence * 100), judge: finding.judge })));
|
|
12097
13214
|
else head.append(el('span', 'args', t('judged by @{judge}', { judge: finding.judge })));
|
|
12098
13215
|
node.append(head);
|
|
@@ -12162,6 +13279,9 @@ function appendModuleOutput(event) {
|
|
|
12162
13279
|
// One way to turn a module on or off, whoever asks: its card, its tile on the board, AJUSTES. The
|
|
12163
13280
|
// server's switch flips what is there; what each module changes in the page follows it here.
|
|
12164
13281
|
async function switchModule(item) {
|
|
13282
|
+
// Turning the test browser on may download its browser first (src/modules/playwright.mjs): said
|
|
13283
|
+
// before the wait, so a switch that takes a minute is not mistaken for one that hung.
|
|
13284
|
+
if (item.id === 'playwright' && !item.status?.installed) toast(t('Getting the test browser ready: MADRE downloads what it is missing. It can take a few minutes.'), { tone: 'note' });
|
|
12165
13285
|
const response = await fetch(`/api/extensions/${item.id}/install`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ confirm: true }) });
|
|
12166
13286
|
const result = await response.json().catch(() => ({}));
|
|
12167
13287
|
if (!response.ok) throw new Error(result.error ?? t('{name} could not be turned on or off.', { name: item.name }));
|
|
@@ -12471,8 +13591,19 @@ async function refreshModules() {
|
|
|
12471
13591
|
else renderModuleWindows();
|
|
12472
13592
|
barModulesChanged();
|
|
12473
13593
|
drawFilesTarget();
|
|
13594
|
+
drawLocalTarget();
|
|
12474
13595
|
syncSettingsCards();
|
|
12475
13596
|
}
|
|
13597
|
+
// AJUSTES › Local AI: the module's own blocks (Modules › Local AI), drawn here too.
|
|
13598
|
+
function drawLocalTarget() {
|
|
13599
|
+
const target = state.settingsTargets.local;
|
|
13600
|
+
if (!target?.isConnected) return;
|
|
13601
|
+
const item = modules.items.find((one) => one.id === 'ollama');
|
|
13602
|
+
if (!item) return;
|
|
13603
|
+
target.replaceChildren();
|
|
13604
|
+
ollamaBlock(target, item);
|
|
13605
|
+
hardwareBlock(target, item.recommended?.hardware);
|
|
13606
|
+
}
|
|
12476
13607
|
|
|
12477
13608
|
// What the version on a card means. A module that ships with MADRE has its own version, written
|
|
12478
13609
|
// in its file and starting at 1.0.0; it cannot update by itself, because it arrives in a release.
|
|
@@ -12521,7 +13652,11 @@ function cardFold(card, label, { key, count = null, open = false } = {}) {
|
|
|
12521
13652
|
head.append(el('b', null, label));
|
|
12522
13653
|
if (count !== null) head.append(el('span', 'count', String(count)));
|
|
12523
13654
|
const caret = el('span', 'caret');
|
|
12524
|
-
const
|
|
13655
|
+
const turn = el('span', 'caret-mark');
|
|
13656
|
+
turn.innerHTML = ICON_CHEVRON; // trusted: our own SVG string · turned by styles.css
|
|
13657
|
+
const said = el('span', 'caret-words');
|
|
13658
|
+
caret.append(said, turn);
|
|
13659
|
+
const mark = () => { said.textContent = (box.open ? t('▾ COLLAPSE') : t('▸ EXPAND')).replace(/^[▸▾]\s*/u, ''); };
|
|
12525
13660
|
mark();
|
|
12526
13661
|
head.append(caret);
|
|
12527
13662
|
const body = el('div', 'card-fold-body');
|
|
@@ -12603,7 +13738,7 @@ function cardShell(item, { on, state: stateText }) {
|
|
|
12603
13738
|
head.append(top);
|
|
12604
13739
|
// What it does comes right under its name, in the reading face: it is what decides whether you
|
|
12605
13740
|
// want it, and it used to sit three floors down under the version and the vendor.
|
|
12606
|
-
if (item.summary) head.append(el('p', 'summary', item.summary));
|
|
13741
|
+
if (item.summary) head.append(el('p', 'summary', t(item.summary)));
|
|
12607
13742
|
// Who makes it, which version and whether it is current: one quiet line, not three.
|
|
12608
13743
|
const line = el('div', 'version');
|
|
12609
13744
|
if (item.vendor) line.append(el('span', 'vendor', item.vendor));
|
|
@@ -12650,7 +13785,7 @@ function cardShell(item, { on, state: stateText }) {
|
|
|
12650
13785
|
line.append(get);
|
|
12651
13786
|
}
|
|
12652
13787
|
head.append(line);
|
|
12653
|
-
if (item.status?.detail) head.append(el('div', 'detail', item.status.detail));
|
|
13788
|
+
if (item.status?.detail) head.append(el('div', 'detail', t(item.status.detail)));
|
|
12654
13789
|
if (item.external) head.append(el('div', 'detail', `${item.origin === 'project' ? t('THIS PROJECT') : t('EVERY PROJECT')} · ${item.file}`));
|
|
12655
13790
|
card.append(head);
|
|
12656
13791
|
|
|
@@ -12658,8 +13793,8 @@ function cardShell(item, { on, state: stateText }) {
|
|
|
12658
13793
|
|
|
12659
13794
|
// FLOOR 3 · what it touches, folded, with the count and nothing else.
|
|
12660
13795
|
const bullets = [
|
|
12661
|
-
...(item.creates ?? []).map((text) => [t('WRITES'), text]),
|
|
12662
|
-
...(item.requires ?? []).map((text) => [t('NEEDS'), text]),
|
|
13796
|
+
...(item.creates ?? []).map((text) => [t('WRITES'), t(text)]),
|
|
13797
|
+
...(item.requires ?? []).map((text) => [t('NEEDS'), t(text)]),
|
|
12663
13798
|
];
|
|
12664
13799
|
if (bullets.length) {
|
|
12665
13800
|
const body = cardFold(card, t('WHAT IT TOUCHES'), { key: `mod.${item.id}.touches`, count: bullets.length });
|
|
@@ -13710,13 +14845,29 @@ function moduleCard(item) {
|
|
|
13710
14845
|
const warn = el('div', 'confirm');
|
|
13711
14846
|
warn.append(el('span', 'warn', t('CANNOT INSTALL HERE YET')));
|
|
13712
14847
|
for (const problem of item.preflight.problems) warn.append(el('p', null, problem));
|
|
14848
|
+
// What MADRE can put right itself (Continuity on a Mac with no git): one key, never a terminal.
|
|
14849
|
+
if (item.preflight.fix) {
|
|
14850
|
+
const fix = el('button', 'primary', item.preflight.fix.label ?? t('Set it up'));
|
|
14851
|
+
fix.type = 'button';
|
|
14852
|
+
fix.addEventListener('click', async () => {
|
|
14853
|
+
fix.disabled = true;
|
|
14854
|
+
const response = await reach(`/api/extensions/${item.id}/fix`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
|
|
14855
|
+
const result = await response.json().catch(() => ({}));
|
|
14856
|
+
toast(response.ok ? t('Apple\'s installer opened. When it finishes, come back here and install.') : (result.error ?? t('It could not be started.')));
|
|
14857
|
+
fix.disabled = false;
|
|
14858
|
+
});
|
|
14859
|
+
warn.append(fix);
|
|
14860
|
+
}
|
|
13713
14861
|
panel.append(warn);
|
|
13714
14862
|
}
|
|
13715
14863
|
if (modules.confirming === item.id && !blocked) {
|
|
13716
14864
|
const confirm = el('div', 'confirm');
|
|
13717
|
-
|
|
13718
|
-
confirm.append(
|
|
13719
|
-
|
|
14865
|
+
// Written as it is said, names and all: the card's lowercase used to read «madre» and «(ide)».
|
|
14866
|
+
confirm.append(el('span', 'warn as-written', t('This writes into the project. MADRE will run, in the project folder:')));
|
|
14867
|
+
const first = item.install.prepare ?? [];
|
|
14868
|
+
confirm.append(commandBlock([...first.map((step) => step.display), item.install.display]));
|
|
14869
|
+
for (const step of first) if (step.note) confirm.append(el('span', 'note as-written', step.note));
|
|
14870
|
+
confirm.append(el('span', 'note as-written', item.install.platforms?.length
|
|
13720
14871
|
? t('IDE adapters for the agents detected here: {agents}.', { agents: item.install.platforms.join(', ') })
|
|
13721
14872
|
: t('No detected agent has an adapter for this module; it installs without IDE adapters.')));
|
|
13722
14873
|
const row = el('div', 'actions');
|
|
@@ -13766,22 +14917,6 @@ function moduleCard(item) {
|
|
|
13766
14917
|
// opens the module's card in a sheet, the way a category of AJUSTES opens; the ones with a reading
|
|
13767
14918
|
// can also float as windows of their own. MADRE decides which, for its own modules: nothing in the
|
|
13768
14919
|
// SDK asks for it.
|
|
13769
|
-
const MODULE_FACES = {
|
|
13770
|
-
ash: { line: 'Compact replies from every agent', wide: true, window: true, icon: '<path class="ic-f" d="M4 5.5h12M4 9.5h9M4 13.5h5.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M13.2 13.5l2.3 2.3 2.3-2.3" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M15.5 9.8v6" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>' },
|
|
13771
|
-
intents: { line: 'How hard each agent should think', wide: true, window: true, icon: '<path d="M3.5 14.5a6.5 6.5 0 0 1 13 0" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path class="ic-f" d="M3.5 14.5a6.5 6.5 0 0 1 4.2-6.1l2.3 6.1z" fill="currentColor" fill-opacity=".22" stroke="none"/><path d="M10 14.5l3.2-4.6" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="10" cy="14.5" r="1.3" fill="currentColor"/>' },
|
|
13772
|
-
ollama: { line: 'Memory by meaning, on this machine', wide: true, window: true, icon: '<rect x="3.5" y="4" width="13" height="9.5" rx="2.4" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M7.5 16.5h5M10 13.5v3" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path class="ic-f" d="M10 6.4c-1.6 0-2.6 1-2.6 2.3 0 .9.5 1.5 1.1 1.8v.8h3v-.8c.6-.3 1.1-.9 1.1-1.8 0-1.3-1-2.3-2.6-2.3z" fill="currentColor" fill-opacity=".22" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>' },
|
|
13773
|
-
'image-studio': { line: 'Images for the agents, with your key', icon: '<rect x="3.25" y="4.25" width="13.5" height="11.5" rx="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/><path class="ic-f" d="M3.8 14.2l3.7-3.6 2.6 2.4 1.9-1.6 4 3.3H5.9z" fill="currentColor" fill-opacity=".22" stroke="none"/><path d="M3.8 14.2l3.7-3.6 2.6 2.4 1.9-1.6 4 3.3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/><path d="M13.6 6.1v2.4M12.4 7.3h2.4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>' },
|
|
13774
|
-
playwright: { line: 'A browser the agents can look through', icon: '<rect x="3" y="4" width="14" height="12" rx="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M3 7.6h14" fill="none" stroke="currentColor" stroke-width="1.6"/><path class="ic-f" d="M9 9.6v5.2l1.4-1.4 1 2.1 1-.5-1-2.1h2z" fill="currentColor" fill-opacity=".22" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>' },
|
|
13775
|
-
ripley: { line: 'Preview of HTML, SVG and Markdown', icon: '<path d="M6 3.5h5.5l3.5 3.5v9.5H6z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M11.5 3.5V7H15" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path class="ic-f" d="M7.6 12s1.1-2.1 2.9-2.1 2.9 2.1 2.9 2.1-1.1 2.1-2.9 2.1-2.9-2.1-2.9-2.1z" fill="currentColor" fill-opacity=".22" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>' },
|
|
13776
|
-
'git-pulse': { line: 'The repository, posted to the project', icon: '<circle cx="5.5" cy="5" r="1.7" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="5.5" cy="15" r="1.7" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M5.5 6.7v6.6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path class="ic-f" d="M8.8 11h2l1.2-3.4 1.6 6 1.2-2.6h1.7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>' },
|
|
13777
|
-
runs: { line: 'Commands an agent asks you to run', icon: '<rect x="3" y="4" width="14" height="12" rx="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/><path class="ic-f" d="M7.6 8.1v4.6l3.8-2.3z" fill="currentColor" fill-opacity=".22" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>' },
|
|
13778
|
-
choices: { line: 'Decisions as buttons, not paragraphs', icon: '<rect x="3.5" y="3.8" width="13" height="3.6" rx="1.8" fill="none" stroke="currentColor" stroke-width="1.5"/><rect class="ic-f" x="3.5" y="8.2" width="13" height="3.6" rx="1.8" fill="currentColor" fill-opacity=".22" stroke="currentColor" stroke-width="1.5"/><rect x="3.5" y="12.6" width="13" height="3.6" rx="1.8" fill="none" stroke="currentColor" stroke-width="1.5"/>' },
|
|
13779
|
-
ahp: { line: 'Project state and handoffs in .ahp/', icon: '<path d="M4 8.5h9.5M10.5 5.5l3 3-3 3" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path class="ic-f" d="M16 11.5H6.5M9.5 8.5l-3 3 3 3" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>' },
|
|
13780
|
-
correo: { line: 'Send one email from your account', icon: '<rect x="3" y="5" width="14" height="10" rx="2.4" fill="none" stroke="currentColor" stroke-width="1.6"/><path class="ic-f" d="M3.6 6.2l6.4 4.6 6.4-4.6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>' },
|
|
13781
|
-
github: { line: 'Pull requests, issues and CI, in the project', icon: '<circle cx="5.5" cy="4.8" r="1.8" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="5.5" cy="15.2" r="1.8" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M5.5 6.6v6.8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="M14.5 13.4V8.2a2.6 2.6 0 0 0-2.6-2.6H9.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M11 3.6l-2 2 2 2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><circle class="ic-f" cx="14.5" cy="15.2" r="1.8" fill="currentColor" fill-opacity=".22" stroke="currentColor" stroke-width="1.6"/>' },
|
|
13782
|
-
};
|
|
13783
|
-
const MODULE_ANY = '<path d="M7.5 4.5h2.2a1.6 1.6 0 1 1 3.1 0H15v3.2a1.6 1.6 0 1 1 0 3.1V15h-3.2a1.6 1.6 0 1 0-3.1 0H5.5v-3.6a1.6 1.6 0 1 0 0-3.1V4.5z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>';
|
|
13784
|
-
const moduleFace = (item) => MODULE_FACES[item.id] ?? { line: null, icon: MODULE_ANY };
|
|
13785
14920
|
function moduleIcon(item) {
|
|
13786
14921
|
const face = moduleFace(item);
|
|
13787
14922
|
const span = el('span', 'settings-icon');
|
|
@@ -13817,9 +14952,9 @@ function moduleTile(item) {
|
|
|
13817
14952
|
top.append(sxChip(item.fixed ? t('Always') : item.kind === 'builtin' ? (on ? t('Turned on') : t('Turned off')) : on ? t('Installed') : t('Not installed'), on ? 'on' : ''));
|
|
13818
14953
|
}
|
|
13819
14954
|
tile.append(top);
|
|
13820
|
-
const name = el('b', 'mods-name', t(item.name));
|
|
14955
|
+
const name = el('b', 'mods-name', face.name ?? t(item.name));
|
|
13821
14956
|
if (item.external) name.append(el('span', 'mods-dev', t('Your module')));
|
|
13822
|
-
tile.append(name, el('span', 'mods-line', face.line ? t(face.line) : item.summary));
|
|
14957
|
+
tile.append(name, el('span', 'mods-line', face.line ? t(face.line) : t(item.summary ?? '')));
|
|
13823
14958
|
// The ones with a reading of their own say so: their card can float as a small app.
|
|
13824
14959
|
if (face.window) tile.append(el('span', 'mods-app', t('Opens as a window')));
|
|
13825
14960
|
const open = () => openModuleSheet(item.id);
|
|
@@ -13876,7 +15011,7 @@ function moduleGlance(item) {
|
|
|
13876
15011
|
}
|
|
13877
15012
|
// What it touches and what it adds to the composer, as blocks of the room instead of the card's folds.
|
|
13878
15013
|
function moduleFacts(host, item) {
|
|
13879
|
-
const touches = [...(item.creates ?? []).map((text) => [t('Writes'), text]), ...(item.requires ?? []).map((text) => [t('Needs'), text])];
|
|
15014
|
+
const touches = [...(item.creates ?? []).map((text) => [t('Writes'), t(text)]), ...(item.requires ?? []).map((text) => [t('Needs'), t(text)])];
|
|
13880
15015
|
if (touches.length) {
|
|
13881
15016
|
const block = sxBlock(host, t('What it touches'), t('Everything this module writes or needs, said before it runs.'));
|
|
13882
15017
|
for (const [tag, text] of touches) {
|
|
@@ -13901,17 +15036,19 @@ const sentenceCase = (text) => { const words = String(text ?? ''); return words
|
|
|
13901
15036
|
function moduleSheet(host, item) {
|
|
13902
15037
|
host.append(moduleGlance(item));
|
|
13903
15038
|
if (item.kind !== 'builtin') {
|
|
13904
|
-
const install = sxBlock(host, t('What it does'), item.summary, { className: 'mods-legacy' });
|
|
15039
|
+
const install = sxBlock(host, t('What it does'), t(item.summary ?? ''), { className: 'mods-legacy' });
|
|
13905
15040
|
install.rows.append(moduleCard(item));
|
|
13906
15041
|
moduleFacts(host, item);
|
|
13907
15042
|
return;
|
|
13908
15043
|
}
|
|
13909
15044
|
const on = moduleOn(item);
|
|
13910
|
-
const about = sxBlock(host, t('What it does'), item.summary);
|
|
13911
|
-
if (item.fixed) sxRow(about, t('Always on'), item.status?.detail ? sentenceCase(item.status.detail) : null);
|
|
15045
|
+
const about = sxBlock(host, t('What it does'), moduleFace(item).summary ?? t(item.summary ?? ''));
|
|
15046
|
+
if (item.fixed) sxRow(about, t('Always on'), item.status?.detail ? sentenceCase(t(item.status.detail)) : null);
|
|
13912
15047
|
else {
|
|
13913
15048
|
const blocked = !on && item.preflight && !item.preflight.ok;
|
|
13914
|
-
|
|
15049
|
+
// A project's switch is that project's (src/module-scope.mjs); every project's is moved from Home.
|
|
15050
|
+
const ownSwitch = item.scope && item.id !== 'ollama' && item.id !== 'bishop';
|
|
15051
|
+
sxRow(about, ownSwitch ? t('On in this project') : t('Active'), blocked ? item.preflight.problems.join(' ') : item.status?.detail ? sentenceCase(t(item.status.detail)) : null, sxSwitch(on, async (next, box) => {
|
|
13915
15052
|
box.disabled = true;
|
|
13916
15053
|
try { await switchModule(item); } catch (error) { box.checked = !next; box.disabled = false; toast(error.message); }
|
|
13917
15054
|
}, { disabled: blocked, label: item.name }), { off: blocked });
|
|
@@ -13985,7 +15122,7 @@ function moduleSheet(host, item) {
|
|
|
13985
15122
|
const block = sxBlock(host, t('What your use taught'), `${t('Per agent and per kind of request: the lowest effort that was enough in your own turns, learned for no tokens. Below, a test that learns faster, for a price.')} ${t('When a lower level might be enough, MADRE offers to try it on a single message and watches how it goes; if it falls short, the next one goes back up.')}`, { className: 'mods-reading mods-intents' });
|
|
13986
15123
|
intentsReading(block.rows);
|
|
13987
15124
|
}
|
|
13988
|
-
if (item.id === 'ollama') ollamaBlock(host, item);
|
|
15125
|
+
if (item.id === 'ollama') { ollamaBlock(host, item); hardwareBlock(host, item.recommended?.hardware); }
|
|
13989
15126
|
|
|
13990
15127
|
moduleFacts(host, item);
|
|
13991
15128
|
if (item.external) {
|
|
@@ -13996,6 +15133,9 @@ function moduleSheet(host, item) {
|
|
|
13996
15133
|
}
|
|
13997
15134
|
}
|
|
13998
15135
|
|
|
15136
|
+
// What this computer can do with a local AI (public/hardware.js), drawn with this page's rows.
|
|
15137
|
+
const hardwareBlock = (host, fit) => drawHardware(host, fit, { el, sxBlock, sxRow, sxChip });
|
|
15138
|
+
|
|
13999
15139
|
// Ollama's local brain as rows: the server, its models, the roles it takes in this room.
|
|
14000
15140
|
function ollamaBlock(host, item) {
|
|
14001
15141
|
const info = item.ollama ?? { running: false, models: [], settings: {} };
|
|
@@ -14066,7 +15206,7 @@ function moduleDetail(item) {
|
|
|
14066
15206
|
back.append(chevron('settings-back-icon'));
|
|
14067
15207
|
back.addEventListener('click', () => openModuleSheet(null));
|
|
14068
15208
|
title.append(back);
|
|
14069
|
-
title.append(moduleIcon(item), el('b', null, t(item.name)));
|
|
15209
|
+
title.append(moduleIcon(item), el('b', null, moduleFace(item).name ?? t(item.name)));
|
|
14070
15210
|
if (item.external) title.append(el('span', 'mods-dev', t('Your module')));
|
|
14071
15211
|
if (moduleFace(item).window) {
|
|
14072
15212
|
const float = sxButton(t('Open as a window'), () => floatModule(item.id));
|
|
@@ -14159,7 +15299,7 @@ function openModuleWindow(id) {
|
|
|
14159
15299
|
node.setAttribute('aria-label', t(item.name));
|
|
14160
15300
|
const head = el('header', 'module-window-head custom-head');
|
|
14161
15301
|
const title = el('div', 'module-window-title');
|
|
14162
|
-
title.append(moduleIcon(item), el('b', null, t(item.name)));
|
|
15302
|
+
title.append(moduleIcon(item), el('b', null, moduleFace(item).name ?? t(item.name)));
|
|
14163
15303
|
const group = el('span', 'surface-modes');
|
|
14164
15304
|
// The same keys as every window: arrange, then close.
|
|
14165
15305
|
const arrange = el('button', 'surface-mode window');
|
|
@@ -14331,6 +15471,52 @@ function readyModules() {
|
|
|
14331
15471
|
modulesWindow.observer.observe(node);
|
|
14332
15472
|
}
|
|
14333
15473
|
}
|
|
15474
|
+
// THE MARKET'S OFFER (2026-10-18, market.js). A message that asks for what a module does, while it
|
|
15475
|
+
// is off, gets one card under it: what would help, the key that turns it on here, the module's page
|
|
15476
|
+
// in the Market, and «No, thanks», which is kept. Read from the person's own words; once a page.
|
|
15477
|
+
const MARKET_DECLINED = 'pulse.market.declined';
|
|
15478
|
+
const marketShown = [];
|
|
15479
|
+
const marketDeclined = () => { try { return JSON.parse(localStorage.getItem(MARKET_DECLINED) ?? '[]') ?? []; } catch { return []; } };
|
|
15480
|
+
function offerModule(text) {
|
|
15481
|
+
if (state.device) return;
|
|
15482
|
+
const offer = marketOffer(text, modules.items ?? [], { declined: marketDeclined(), shown: marketShown });
|
|
15483
|
+
if (!offer) return;
|
|
15484
|
+
marketShown.push(offer.item.id);
|
|
15485
|
+
setTimeout(() => {
|
|
15486
|
+
const { item, says } = offer;
|
|
15487
|
+
const card = el('div', 'system market-offer');
|
|
15488
|
+
const mark = appIcon(item, 40);
|
|
15489
|
+
const words = el('div', 'market-offer-words');
|
|
15490
|
+
words.append(el('b', null, t(item.name)), el('span', null, t(says)));
|
|
15491
|
+
const acts = el('div', 'market-offer-acts');
|
|
15492
|
+
const switchable = moduleSwitches(item) && marketEntry(item.id).scope !== 'project';
|
|
15493
|
+
const on = el('button', 'primary', switchable ? t('Turn on') : t('Install'));
|
|
15494
|
+
on.type = 'button';
|
|
15495
|
+
on.addEventListener('click', async () => {
|
|
15496
|
+
if (!switchable) { openModules(); await refreshModules(); renderModules(); openModuleSheet(item.id); return; }
|
|
15497
|
+
on.disabled = true;
|
|
15498
|
+
try {
|
|
15499
|
+
await switchModule(item);
|
|
15500
|
+
card.replaceChildren(mark, el('div', 'market-offer-words', null));
|
|
15501
|
+
card.lastChild.append(el('b', null, t(item.name)), el('span', null, t('Turned on. Ask again and the agents can use it.')));
|
|
15502
|
+
} catch (error) { on.disabled = false; toast(error.message); }
|
|
15503
|
+
});
|
|
15504
|
+
const more = el('button', null, t('See it in the Market'));
|
|
15505
|
+
more.type = 'button';
|
|
15506
|
+
more.addEventListener('click', () => openRoomMarket(item.id));
|
|
15507
|
+
const no = el('button', 'quiet', t('No, thanks'));
|
|
15508
|
+
no.type = 'button';
|
|
15509
|
+
no.addEventListener('click', () => {
|
|
15510
|
+
try { localStorage.setItem(MARKET_DECLINED, JSON.stringify([...new Set([...marketDeclined(), item.id])])); } catch { /* asked again next page */ }
|
|
15511
|
+
card.remove();
|
|
15512
|
+
});
|
|
15513
|
+
acts.append(on, more, no);
|
|
15514
|
+
card.append(mark, words, acts);
|
|
15515
|
+
document.querySelector('#thread-inner')?.append(card);
|
|
15516
|
+
card.scrollIntoView?.({ block: 'nearest', behavior: reducedMotion() ? 'auto' : 'smooth' });
|
|
15517
|
+
}, motion(700));
|
|
15518
|
+
}
|
|
15519
|
+
|
|
14334
15520
|
function openModules() {
|
|
14335
15521
|
const node = modules.dialog;
|
|
14336
15522
|
readyModules();
|
|
@@ -14358,6 +15544,47 @@ modules.button.addEventListener('click', async () => {
|
|
|
14358
15544
|
renderModules();
|
|
14359
15545
|
});
|
|
14360
15546
|
modules.close.addEventListener('click', () => closeModules());
|
|
15547
|
+
// THE MARKET in a project (2026-10-18): the same store as Home's, full screen, where what you get
|
|
15548
|
+
// is for this project. A module that writes into the folder comes back here, to its card, so its
|
|
15549
|
+
// commands are read before they run.
|
|
15550
|
+
function openRoomMarket(focus = null) {
|
|
15551
|
+
return openMarket({
|
|
15552
|
+
where: 'room',
|
|
15553
|
+
focus,
|
|
15554
|
+
project: { name: projectDesignation() || t('this project') },
|
|
15555
|
+
host: {
|
|
15556
|
+
localAi: () => openSettingsOn('local'),
|
|
15557
|
+
toast: (words) => toast(words),
|
|
15558
|
+
installHere: async (item) => { openModules(); await refreshModules(); renderModules(); openModuleSheet(item.id); },
|
|
15559
|
+
changed: async () => { await refreshModules(); if (modules.dialog.open) renderModules(); barModulesChanged(); },
|
|
15560
|
+
},
|
|
15561
|
+
});
|
|
15562
|
+
}
|
|
15563
|
+
{
|
|
15564
|
+
const head = modules.dialog?.querySelector('.mods-head');
|
|
15565
|
+
const add = head?.querySelector('#modules-add');
|
|
15566
|
+
if (head && add) {
|
|
15567
|
+
const market = el('button', 'sx-btn mods-market', t('Market'));
|
|
15568
|
+
market.type = 'button';
|
|
15569
|
+
market.title = t('Every module MADRE can add to this project');
|
|
15570
|
+
market.addEventListener('click', () => openRoomMarket());
|
|
15571
|
+
add.before(market);
|
|
15572
|
+
}
|
|
15573
|
+
}
|
|
15574
|
+
// The Market's catalog from madre.run: the faces of the modules that came after this MADRE, and what
|
|
15575
|
+
// it updated on its own since, said once. Not on a phone: it has no Market.
|
|
15576
|
+
if (!state.device) void loadMarket({ toast: (words, action) => toast(words, { action }), open: (id) => openRoomMarket(id) }).then(() => { if (modules.dialog.open) renderModules(); });
|
|
15577
|
+
// The Market's address in a project: #mercado, or #mercado=<module> on its page.
|
|
15578
|
+
if (/^#mercado(=|$)/.test(globalThis.location?.hash ?? '')) {
|
|
15579
|
+
const id = decodeURIComponent(location.hash.split('=')[1] ?? '') || null;
|
|
15580
|
+
setTimeout(() => openRoomMarket(id), motion(400));
|
|
15581
|
+
}
|
|
15582
|
+
// Sent here from the Market with a module to install in this project: its card, open.
|
|
15583
|
+
if (/^#modulo=/.test(globalThis.location?.hash ?? '')) {
|
|
15584
|
+
const id = decodeURIComponent(location.hash.split('=')[1] ?? '');
|
|
15585
|
+
history.replaceState(null, '', location.pathname + location.search);
|
|
15586
|
+
setTimeout(async () => { openModules(); await refreshModules(); renderModules(); openModuleSheet(id); }, motion(600));
|
|
15587
|
+
}
|
|
14361
15588
|
// Escape closes the open module before the board, and the search before either.
|
|
14362
15589
|
modules.dialog.addEventListener('cancel', (event) => {
|
|
14363
15590
|
event.preventDefault();
|
|
@@ -14443,8 +15670,11 @@ function openCrewPop(anchor) {
|
|
|
14443
15670
|
list.append(row);
|
|
14444
15671
|
}
|
|
14445
15672
|
pop.append(list);
|
|
14446
|
-
const go = el('button', 'crew-go'
|
|
15673
|
+
const go = el('button', 'crew-go');
|
|
14447
15674
|
go.type = 'button';
|
|
15675
|
+
const gear = el('span', 'crew-go-icon');
|
|
15676
|
+
gear.innerHTML = GEAR_ICON; // trusted: our own SVG string
|
|
15677
|
+
go.append(gear, el('span', null, t('Agents in Settings')));
|
|
14448
15678
|
go.addEventListener('click', () => {
|
|
14449
15679
|
closeAgentPop();
|
|
14450
15680
|
openSettings(true, { category: 'agents' });
|
|
@@ -14593,6 +15823,19 @@ function fillLoginAsk(box) {
|
|
|
14593
15823
|
open.rel = 'noopener noreferrer';
|
|
14594
15824
|
box.append(open);
|
|
14595
15825
|
}
|
|
15826
|
+
// A device sign-in (GitHub Copilot through OpenCode): the code is MADRE's to show, the page's
|
|
15827
|
+
// to be typed into. Nothing comes back here; the sign-in ends on its own once it is typed.
|
|
15828
|
+
if (loginNow.deviceCode) {
|
|
15829
|
+
const said = el('div', 'login-device');
|
|
15830
|
+
said.append(el('span', null, t('Type this code on that page:')), el('code', null, loginNow.deviceCode));
|
|
15831
|
+
const copy = el('button', 'sx-btn', t('Copy'));
|
|
15832
|
+
copy.type = 'button';
|
|
15833
|
+
copy.addEventListener('click', async () => {
|
|
15834
|
+
try { await navigator.clipboard.writeText(loginNow.deviceCode ?? ''); copy.textContent = t('Copied'); } catch { copy.textContent = t('Select it'); }
|
|
15835
|
+
});
|
|
15836
|
+
said.append(copy);
|
|
15837
|
+
box.append(said);
|
|
15838
|
+
}
|
|
14596
15839
|
if (loginNow.asksForCode || loginNow.awaiting) {
|
|
14597
15840
|
const form = el('form', 'login-code');
|
|
14598
15841
|
const input = el('input');
|
|
@@ -14630,9 +15873,15 @@ function fillLoginAsk(box) {
|
|
|
14630
15873
|
}
|
|
14631
15874
|
// One door or two: Claude's CLI knows the subscription and the Console, and only the human can
|
|
14632
15875
|
// say which account is paying. `null` is the single flow every other CLI has.
|
|
15876
|
+
// With a session, every agent offers the same «sign in again» as Codex. A single door (Gemini's
|
|
15877
|
+
// Google account) is that door; two doors (Claude's subscription and Console) are chosen after the
|
|
15878
|
+
// click, and which agent is choosing is kept here so a repaint does not fold the choice away.
|
|
15879
|
+
const choosingAccount = new Set();
|
|
14633
15880
|
function loginDoors(agent, signedIn) {
|
|
14634
15881
|
const methods = agent.login?.methods;
|
|
14635
15882
|
if (!methods?.length) return [{ id: null, label: signedIn ? t('Sign in again') : t('Sign in'), note: agent.login?.note ?? '' }];
|
|
15883
|
+
if (signedIn && methods.length === 1) return [{ id: methods[0].id, label: t('Sign in again'), note: methods[0].note ?? '', again: true }];
|
|
15884
|
+
if (signedIn && !choosingAccount.has(agent.id)) return [{ id: null, label: t('Sign in again'), note: agent.login?.note ?? '', again: true, choose: true }];
|
|
14636
15885
|
return methods.map((method) => ({ id: method.id, label: t(method.label), note: method.note ?? '' }));
|
|
14637
15886
|
}
|
|
14638
15887
|
async function startLogin(agentId, method = null) {
|
|
@@ -14643,6 +15892,7 @@ async function startLogin(agentId, method = null) {
|
|
|
14643
15892
|
loginNow.agent = agentId;
|
|
14644
15893
|
loginNow.method = method;
|
|
14645
15894
|
loginNow.url = null;
|
|
15895
|
+
loginNow.deviceCode = null;
|
|
14646
15896
|
loginNow.awaiting = false;
|
|
14647
15897
|
loginNow.asksForCode = Boolean(result.asksForCode);
|
|
14648
15898
|
repaintLoginAsk();
|
|
@@ -14660,10 +15910,10 @@ function renderLoginEvent(event) {
|
|
|
14660
15910
|
node.append(finished
|
|
14661
15911
|
? (ok ? t('@{agent} signed in', { agent }) + (session?.detail ? ` · ${session.detail}` : '') : t('@{agent} sign-in did not complete', { agent }) + (error ? ` · ${error}` : code ? t(' · exit {code}', { code }) : ''))
|
|
14662
15912
|
: t('signing in @{agent} · {command}', { agent, command }));
|
|
14663
|
-
if (finished) { state.sessions[agent] = session ?? state.sessions[agent]; renderOnboarding(); if (state.settingsOpen) void loadSettings(); }
|
|
15913
|
+
if (finished) { state.sessions[agent] = session ?? state.sessions[agent]; renderAgents(); renderPicker(); renderOnboarding(); if (state.settingsOpen) void loadSettings(); }
|
|
14664
15914
|
if (!replaying) {
|
|
14665
|
-
if (finished) { if (loginNow.agent === agent) { loginNow.agent = null; loginNow.url = null; loginNow.awaiting = false; } }
|
|
14666
|
-
else { loginNow.agent = agent; loginNow.method = event.payload.method ?? null; loginNow.url = null; loginNow.awaiting = false; loginNow.asksForCode = Boolean(state.agents.get(agent)?.login?.asksForCode); }
|
|
15915
|
+
if (finished) { if (loginNow.agent === agent) { loginNow.agent = null; loginNow.url = null; loginNow.deviceCode = null; loginNow.awaiting = false; } }
|
|
15916
|
+
else { loginNow.agent = agent; loginNow.method = event.payload.method ?? null; loginNow.url = null; loginNow.deviceCode = null; loginNow.awaiting = false; loginNow.asksForCode = Boolean(state.agents.get(agent)?.login?.asksForCode); }
|
|
14667
15917
|
repaintLoginAsk();
|
|
14668
15918
|
}
|
|
14669
15919
|
state.lastSender = null;
|
|
@@ -14685,13 +15935,15 @@ function renderAgentInstall(event) {
|
|
|
14685
15935
|
}
|
|
14686
15936
|
|
|
14687
15937
|
function appendLoginOutput(event) {
|
|
14688
|
-
const { agent, line, url } = event.payload;
|
|
15938
|
+
const { agent, line, url, deviceCode } = event.payload;
|
|
14689
15939
|
if (!replaying && loginNow.agent === agent) {
|
|
14690
15940
|
// The address stays on screen instead of scrolling past: the heartbeat used to be the only
|
|
14691
15941
|
// line left, so the one thing the human had to open was the first thing MADRE lost.
|
|
14692
15942
|
if (url) loginNow.url = url;
|
|
14693
|
-
|
|
14694
|
-
if (
|
|
15943
|
+
// "Enter code: 3475-C193" is a code to show, not a question: no box to paste into.
|
|
15944
|
+
if (deviceCode) loginNow.deviceCode = deviceCode;
|
|
15945
|
+
else if (looksLikeCodePrompt(line)) loginNow.awaiting = true;
|
|
15946
|
+
if (url || deviceCode || looksLikeCodePrompt(line)) repaintLoginAsk();
|
|
14695
15947
|
}
|
|
14696
15948
|
// The heartbeat («… still running · 42s · …») says one thing, how long: it takes the place of
|
|
14697
15949
|
// the one before instead of piling up thirty lines over the address the person needs.
|
|
@@ -14726,6 +15978,8 @@ async function loadSettings() {
|
|
|
14726
15978
|
settingsUI.data = await fetch('/api/settings').then((response) => response.json());
|
|
14727
15979
|
if (settingsUI.data?.settings?.override) state.override = settingsUI.data.settings.override;
|
|
14728
15980
|
state.sessions = settingsUI.data.sessions ?? state.sessions;
|
|
15981
|
+
renderAgents();
|
|
15982
|
+
renderPicker();
|
|
14729
15983
|
// The server is the one that knows whether a sign-in is still waiting: a page reloaded in the
|
|
14730
15984
|
// middle of one has to find the same address and the same box, not an empty card.
|
|
14731
15985
|
readLoginState(settingsUI.data.login ?? null);
|
|
@@ -14736,6 +15990,7 @@ function readLoginState(live) {
|
|
|
14736
15990
|
loginNow.agent = live?.agent ?? null;
|
|
14737
15991
|
loginNow.method = live?.method ?? null;
|
|
14738
15992
|
loginNow.url = live?.url ?? null;
|
|
15993
|
+
loginNow.deviceCode = live?.deviceCode ?? null;
|
|
14739
15994
|
loginNow.awaiting = Boolean(live?.awaitingCode);
|
|
14740
15995
|
loginNow.asksForCode = Boolean(live?.asksForCode);
|
|
14741
15996
|
repaintLoginAsk();
|
|
@@ -16032,25 +17287,28 @@ function connectionCard(agent) {
|
|
|
16032
17287
|
// One button per account the CLI can sign in with. Which one is paying is the human's to say.
|
|
16033
17288
|
for (const door of loginDoors(agent, signedIn)) {
|
|
16034
17289
|
const login = sxButton(door.label, async () => {
|
|
17290
|
+
if (door.choose) { choosingAccount.add(agent.id); renderSettings(); return; }
|
|
17291
|
+
choosingAccount.delete(agent.id);
|
|
16035
17292
|
login.disabled = true;
|
|
16036
17293
|
try { await startLogin(agent.id, door.id); settingsUI.data.loggingIn = agent.id; renderSettings(); }
|
|
16037
17294
|
catch (error) { toast(error.message || t('The sign-in could not start.')); login.disabled = false; }
|
|
16038
|
-
}, { tone: signedIn ? null : 'primary', title: t(door.note || agent.login.note) });
|
|
17295
|
+
}, { tone: (signedIn && !choosingAccount.has(agent.id)) || agent.key?.first ? null : 'primary', title: t(door.note || agent.login.note) });
|
|
16039
17296
|
login.disabled = Boolean(settingsUI.data.loggingIn);
|
|
16040
17297
|
acts.append(login);
|
|
16041
17298
|
}
|
|
16042
17299
|
}
|
|
16043
17300
|
acts.append(recheck);
|
|
16044
|
-
body.append(acts);
|
|
16045
17301
|
const slot = el('div', 'sx-slot');
|
|
16046
17302
|
// OpenCode's keys are its providers', added in the block under its settings, one row for all.
|
|
16047
17303
|
if (agent.detected && agent.key && agent.id !== 'opencode') slot.append(keyForm(agent, { onDone: () => { void loadSettings(); renderOnboarding(); } }));
|
|
16048
|
-
//
|
|
16049
|
-
if (agent.
|
|
16050
|
-
|
|
16051
|
-
slot.append(commandBlock([agent.login.display, `# ${t(agent.login.note)}`]));
|
|
16052
|
-
}
|
|
17304
|
+
// Gemini's key is its way in (agent.key.first): the field before the Google door.
|
|
17305
|
+
if (agent.key?.first && slot.firstChild) { const first = el('div', 'sx-slot'); first.append(slot.firstChild); body.append(first); }
|
|
17306
|
+
body.append(acts);
|
|
16053
17307
|
if (agent.detected && agent.login?.headless) slot.append(loginAskSlot(agent.id));
|
|
17308
|
+
// The same, typed in a terminal, folded for whoever prefers it. Signed in, OpenCode's providers
|
|
17309
|
+
// are added in its block below; its terminal is for a first sign-in.
|
|
17310
|
+
const way = !agent.detected ? installTerminalWay(agent) : agent.id === 'opencode' && session?.state === 'signed-in' ? null : signInTerminalWay(agent);
|
|
17311
|
+
if (way) slot.append(way);
|
|
16054
17312
|
const log = state.loginLogs.get(agent.id) ?? [];
|
|
16055
17313
|
if (log.length || busy) {
|
|
16056
17314
|
const box = el('div', 'login-log');
|
|
@@ -16073,7 +17331,66 @@ function renderMemoryPanel(data) {
|
|
|
16073
17331
|
const target = state.settingsTargets.memory;
|
|
16074
17332
|
if (target?.isConnected) { target.replaceChildren(); drawMemory(target, data); }
|
|
16075
17333
|
}
|
|
17334
|
+
// D-S17, also in AJUSTES › What it remembers: the notes other projects delivered here, waiting
|
|
17335
|
+
// for you, and what became of the last ones. The same rows as the card in the conversation.
|
|
17336
|
+
const deliveries = { pending: 0 };
|
|
17337
|
+
async function readDeliveries() {
|
|
17338
|
+
try { return (await fetch('/api/enlace/inbox').then((response) => response.json())).notes ?? []; } catch { return null; }
|
|
17339
|
+
}
|
|
17340
|
+
function drawDelivered(host) {
|
|
17341
|
+
const block = sxBlock(host, t('Delivered notes'), t('Notes other projects handed to this one. Each enters the memory only if it is accepted.'));
|
|
17342
|
+
block.id = 'sx-delivered';
|
|
17343
|
+
const fill = async () => {
|
|
17344
|
+
const notes = await readDeliveries();
|
|
17345
|
+
if (!block.isConnected) return;
|
|
17346
|
+
block.rows.replaceChildren();
|
|
17347
|
+
if (!notes) { sxRow(block, t('Could not read them.'), null); return; }
|
|
17348
|
+
const waiting = notes.filter((note) => note.status === 'pending');
|
|
17349
|
+
if (!waiting.length) sxRow(block, t('Nothing waiting'), notes.length ? t('{n} decided before.', { n: notes.length }) : t('No project has delivered notes here yet.'));
|
|
17350
|
+
for (const note of waiting) {
|
|
17351
|
+
const decide = (accept) => async () => {
|
|
17352
|
+
try {
|
|
17353
|
+
const response = await fetch('/api/enlace/inbox/decide', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: note.id, accept }) });
|
|
17354
|
+
const result = await response.json().catch(() => ({}));
|
|
17355
|
+
if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
|
|
17356
|
+
} catch (error) { toast(`${t('Diagnostics › ')}${error.message}`); }
|
|
17357
|
+
void fill();
|
|
17358
|
+
void refreshDeliveries();
|
|
17359
|
+
};
|
|
17360
|
+
const actions = el('div', 'sx-chips');
|
|
17361
|
+
actions.append(sxButton(t('Accept'), decide(true), { tone: 'primary' }), sxButton(t('Decline'), decide(false)));
|
|
17362
|
+
sxRow(block, `${kindWord(note.kind)} · ${note.text}`, [t('Delivered by {room}{agent}', { room: note.name, agent: note.agent ? ` · @${note.agent}` : '' }), note.why ? t('Why: {why}', { why: note.why }) : null].filter(Boolean).join(' · '), actions, { stack: true });
|
|
17363
|
+
}
|
|
17364
|
+
};
|
|
17365
|
+
void fill();
|
|
17366
|
+
}
|
|
17367
|
+
// The bar says how many wait, and goes to them.
|
|
17368
|
+
async function refreshDeliveries() {
|
|
17369
|
+
const notes = await readDeliveries();
|
|
17370
|
+
if (!notes) return;
|
|
17371
|
+
deliveries.pending = notes.filter((note) => note.status === 'pending').length;
|
|
17372
|
+
let pill = document.querySelector('#inbox-pill');
|
|
17373
|
+
if (!pill) {
|
|
17374
|
+
const anchor = document.querySelector('#stop-all');
|
|
17375
|
+
if (!anchor) return;
|
|
17376
|
+
// Shaped like the handoff pill beside it: an icon and its words, never squeezed to a dot.
|
|
17377
|
+
pill = el('button', 'stop-all handoff-pill inbox-pill');
|
|
17378
|
+
pill.id = 'inbox-pill';
|
|
17379
|
+
pill.type = 'button';
|
|
17380
|
+
pill.innerHTML = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3.2 11.2 5.4 4.6c.2-.6.8-1 1.4-1h6.4c.6 0 1.2.4 1.4 1l2.2 6.6v3.6c0 .9-.7 1.6-1.6 1.6H4.8c-.9 0-1.6-.7-1.6-1.6z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M3.4 11.2h3.8l1 2h3.6l1-2h3.8" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg><span class="handoff-words"></span><span class="inbox-count"></span>'; // trusted: our own SVG string
|
|
17381
|
+
pill.addEventListener('click', () => openSettingsOn('memory', { anchor: 'sx-delivered' }));
|
|
17382
|
+
anchor.before(pill);
|
|
17383
|
+
}
|
|
17384
|
+
pill.hidden = state.device || !deliveries.pending;
|
|
17385
|
+
const said = deliveries.pending === 1 ? t('1 note to accept') : t('{n} notes to accept', { n: deliveries.pending });
|
|
17386
|
+
pill.querySelector('.handoff-words').textContent = said;
|
|
17387
|
+
// Its short form, for a bar too narrow for the words (barFit, bar-tight): the tray and the number.
|
|
17388
|
+
pill.querySelector('.inbox-count').textContent = String(deliveries.pending);
|
|
17389
|
+
pill.setAttribute('aria-label', said);
|
|
17390
|
+
pill.title = t('Notes other projects delivered here, waiting for you');
|
|
17391
|
+
}
|
|
16076
17392
|
function drawMemory(host, data) {
|
|
17393
|
+
drawDelivered(host);
|
|
16077
17394
|
const mem = data?.settings?.memory;
|
|
16078
17395
|
if (!mem) { sxBlock(host, t('What it remembers'), t('This project has no memory yet.')); return; }
|
|
16079
17396
|
const save = async (memoryPatch, describe) => { try { await saveSettingNow({ memory: memoryPatch }, describe); await loadSettings(); } catch (error) { toast(t('Memory setting was not saved: {error}', { error: error.message })); } };
|
|
@@ -16150,28 +17467,60 @@ function drawMemory(host, data) {
|
|
|
16150
17467
|
const recipe = el('details', 'sx-more');
|
|
16151
17468
|
recipe.append(el('summary', null, t('Step by step')));
|
|
16152
17469
|
let platform = /Mac/i.test(navigator.platform ?? '') || !/Linux/i.test(navigator.userAgent ?? '') ? 'mac' : 'linux';
|
|
17470
|
+
// The size to train: the largest this computer trains and then runs, unless the human picks
|
|
17471
|
+
// another (to train it on a larger machine, say). Every size is offered; the note says what fits.
|
|
17472
|
+
let size = null;
|
|
17473
|
+
let measured = false;
|
|
16153
17474
|
const steps = el('ol', 'sx-steps');
|
|
16154
17475
|
const pickRow = el('div', 'sx-train-platform');
|
|
16155
17476
|
const platformNote = el('p', 'sx-hint');
|
|
16156
|
-
|
|
17477
|
+
const sizeRow = el('div', 'sx-train-platform');
|
|
17478
|
+
const sizeNote = el('p', 'sx-hint');
|
|
17479
|
+
recipe.append(pickRow, platformNote, sizeRow, sizeNote, steps);
|
|
16157
17480
|
train.append(recipe);
|
|
16158
17481
|
let lastPayload = null;
|
|
16159
17482
|
const drawPlatform = () => {
|
|
16160
17483
|
pickRow.replaceChildren(sxSeg([{ value: 'mac', text: t('Mac with Apple chip') }, { value: 'linux', text: t('Linux with NVIDIA') }], platform, (value) => { platform = value; showDataset(lastPayload); }, { label: t('Your computer') }));
|
|
16161
17484
|
platformNote.textContent = platform === 'mac'
|
|
16162
|
-
? t('Any Mac with an M chip.
|
|
17485
|
+
? t('Any Mac with an M chip. Its memory decides the size you can train, below.')
|
|
16163
17486
|
: t('Experimental: MLX runs on Linux with an NVIDIA card (CUDA 12, compute capability 7.5 or newer, glibc 2.35, Python 3.10), but mlx-lm only promises Apple chips. Step 2 checks the card first.');
|
|
16164
17487
|
};
|
|
17488
|
+
const drawSize = (fit) => {
|
|
17489
|
+
const models = fit?.models ?? [];
|
|
17490
|
+
if (!models.length) { sizeRow.replaceChildren(); sizeNote.textContent = ''; return null; }
|
|
17491
|
+
const chosen = models.find((model) => model.id === size) ?? models.find((model) => model.id === fit.recommended?.train?.id) ?? models[1] ?? models[0];
|
|
17492
|
+
sizeRow.replaceChildren(sxSeg(models.map((model) => ({ value: model.id, text: model.label.replace(/^Qwen2\.5\s*/, ''), title: model.train === 'yes' ? t('Trains on this computer') : t('Needs {gb} GB to train', { gb: model.trainGb }) })), chosen.id, (value) => { size = value; showDataset(lastPayload); }, { label: t('Model size') }));
|
|
17493
|
+
const here = hardwareLine(fit.hardware);
|
|
17494
|
+
sizeNote.textContent = chosen.train === 'yes'
|
|
17495
|
+
? t('{model} trains on this computer ({here}). Needs about {disk} GB free on disk.', { model: chosen.label, here, disk: Math.ceil(chosen.params * 5.5) + 2 })
|
|
17496
|
+
: fit.accelerated
|
|
17497
|
+
? t('{model} needs {gb} GB to train and this computer has {have} GB: train it on a larger one, or pick a smaller size.', { model: chosen.label, gb: chosen.trainGb, have: fit.usableGb })
|
|
17498
|
+
: t('This computer has no Apple chip or NVIDIA card to train with ({here}). The recipe is the one for a computer that has one.', { here });
|
|
17499
|
+
return chosen;
|
|
17500
|
+
};
|
|
16165
17501
|
const showDataset = (payload) => {
|
|
16166
17502
|
lastPayload = payload;
|
|
16167
17503
|
const d = payload?.dataset;
|
|
16168
17504
|
const exported = d ? t('Last export {when} · {train} to train · {valid} to check', { when: new Date(d.exportedAt).toLocaleString(), train: d.train, valid: d.valid }) : t('Not exported yet.');
|
|
16169
17505
|
const trained = payload?.trained ? t('Trained model {model} in use.', { model: payload.trained }) : t('No trained model yet.');
|
|
16170
|
-
|
|
16171
|
-
|
|
17506
|
+
// D-S15: in Home, which projects the Coordinator learned from this time, and how.
|
|
17507
|
+
const sources = (d?.linked ?? []).map((one) => (one.source === 'missing'
|
|
17508
|
+
? t('{room}: did not answer and nothing was kept', { room: one.name })
|
|
17509
|
+
: one.source === 'kept'
|
|
17510
|
+
? t('{room}: {n} notes from the last time it answered', { room: one.name, n: one.notes })
|
|
17511
|
+
: t('{room}: {n} notes', { room: one.name, n: one.notes })));
|
|
17512
|
+
datasetHint.textContent = `${exported} ${trained}${sources.length ? ` ${t('Learned from your projects: {list}.', { list: sources.join(' · ') })}` : ''}`;
|
|
16172
17513
|
const info = payload?.training;
|
|
17514
|
+
// The first reading sets the recipe for this computer: an NVIDIA card goes the Linux way.
|
|
17515
|
+
if (!measured && info?.fit?.hardware) { measured = true; if (info.fit.hardware.accelerator === 'nvidia') platform = 'linux'; else if (info.fit.hardware.accelerator === 'apple') platform = 'mac'; }
|
|
17516
|
+
drawPlatform();
|
|
17517
|
+
const chosen = drawSize(info?.fit);
|
|
16173
17518
|
steps.replaceChildren();
|
|
16174
17519
|
if (!info) { steps.append(el('li', 'sx-hint', t('The training recipe is unavailable.'))); return; }
|
|
17520
|
+
// A base trained in 4 bits (3B and up) is joined back at full precision, so llama.cpp can read
|
|
17521
|
+
// it, and Ollama shrinks it to 4 bits again; the small ones stay at 8.
|
|
17522
|
+
const base = chosen?.base ?? info.baseModel;
|
|
17523
|
+
const quantized = Boolean(chosen?.quantized);
|
|
16175
17524
|
const quote = (path) => `"${path.replace(/\/$/, '')}"`;
|
|
16176
17525
|
const venv = 'source ~/.madre-train/bin/activate';
|
|
16177
17526
|
const setup = platform === 'mac'
|
|
@@ -16186,11 +17535,40 @@ function drawMemory(host, data) {
|
|
|
16186
17535
|
};
|
|
16187
17536
|
step(t('Export the training data'), t('Use «Export» above. Do it again whenever the project has grown.'));
|
|
16188
17537
|
step(t('Prepare Python, once'), platform === 'mac' ? t('A separate environment with mlx-lm, so nothing else on your computer changes.') : t('A separate environment with MLX for CUDA and mlx-lm. It must print «CUDA: True»; if not, stop here.'), setup);
|
|
16189
|
-
step(t('Train · base {model}
|
|
16190
|
-
step(t('Join the adjustment with the base model'), null, `${venv} && cd ${quote(info.roomDir)} && mlx_lm.fuse --model ${
|
|
16191
|
-
step(t('Turn it into a file Ollama reads (GGUF)'), t('The first time, it downloads llama.cpp\'s converter.'), `${venv} && cd ${quote(info.roomDir)} && git clone --depth 1 https://github.com/ggml-org/llama.cpp && pip install -r llama.cpp/requirements.txt && python3 llama.cpp/convert_hf_to_gguf.py fused --outfile madre.gguf --outtype q8_0`);
|
|
16192
|
-
step(t('Register it in Ollama as {model}', { model: info.modelName }), t('@madre switches to it at the next recheck of Local AI.'), `cd ${quote(info.roomDir)} && cp ${quote(`${info.recipeDir}Modelfile`)} . && ollama create ${info.modelName} -f Modelfile`);
|
|
17538
|
+
step(t('Train · base {model}', { model: chosen?.label ?? base }), (chosen?.params ?? 0) > 3 ? t('An hour or more, depending on the size and on how many pairs there are. It learns from the exported pairs and leaves an adjustment («adapters»).') : t('From 10 to 40 minutes. It learns from the exported pairs and leaves an adjustment («adapters»).'), `${venv} && bash ${quote(`${info.recipeDir}train.sh`)} ${quote(info.roomDir)} ${base}`);
|
|
17539
|
+
step(t('Join the adjustment with the base model'), null, `${venv} && cd ${quote(info.roomDir)} && mlx_lm.fuse --model ${base} --adapter-path adapters --save-path fused${quantized ? ' --de-quantize' : ''}`);
|
|
17540
|
+
step(t('Turn it into a file Ollama reads (GGUF)'), t('The first time, it downloads llama.cpp\'s converter.'), `${venv} && cd ${quote(info.roomDir)} && git clone --depth 1 https://github.com/ggml-org/llama.cpp && pip install -r llama.cpp/requirements.txt && python3 llama.cpp/convert_hf_to_gguf.py fused --outfile madre.gguf --outtype ${quantized ? 'f16' : 'q8_0'}`);
|
|
17541
|
+
step(t('Register it in Ollama as {model}', { model: info.modelName }), quantized ? t('Ollama keeps it in 4 bits, the size the table counts on. @madre switches to it at the next recheck of Local AI.') : t('@madre switches to it at the next recheck of Local AI.'), `cd ${quote(info.roomDir)} && cp ${quote(`${info.recipeDir}Modelfile`)} . && ollama create ${info.modelName} -f Modelfile${quantized ? ' --quantize q4_K_M' : ''}`);
|
|
16193
17542
|
step(t('Try it before trusting it'), t('Ask @madre ten things the project decided and five it never discussed. It should get the first right and say it never discussed the others; if it makes things up, remove it with «ollama rm {model}» and train with more pairs.', { model: info.modelName }));
|
|
17543
|
+
if (state.salas) drawExam();
|
|
17544
|
+
};
|
|
17545
|
+
// D-S18 · in Home, the exam the Coordinator's local model passes before it gives orders: the
|
|
17546
|
+
// same test as the step above, run by MADRE, with a fixed bar and kept for that exact model.
|
|
17547
|
+
const exam = el('div', 'sx-exam');
|
|
17548
|
+
if (state.salas) train.append(exam);
|
|
17549
|
+
let examTimer = null;
|
|
17550
|
+
const drawExam = async () => {
|
|
17551
|
+
const reading = await fetch('/api/x/bishop/exam').then((response) => (response.ok ? response.json() : null)).catch(() => null);
|
|
17552
|
+
exam.replaceChildren();
|
|
17553
|
+
if (!reading) return;
|
|
17554
|
+
const last = reading.exam;
|
|
17555
|
+
const part = (key) => `${last?.scores?.[key]?.passed ?? 0}/${last?.scores?.[key]?.total ?? 0}`;
|
|
17556
|
+
const said = reading.running ? t('Examining {model}… a local model answers one question at a time; it takes a few minutes.', { model: reading.model?.name ?? '' })
|
|
17557
|
+
: !last ? t('Not examined yet. Until it passes, the Coordinator only answers with @madre.')
|
|
17558
|
+
: reading.holds ? t('{model} passed on {date}: {known} about the projects, {unknown} said it never discussed, {orders} orders. The Coordinator gives orders with it up to #1.', { model: last.model, date: new Date(last.at).toLocaleDateString(language()), known: part('known'), unknown: part('unknown'), orders: part('orders') })
|
|
17559
|
+
: last.passed ? t('{model} passed, but the model in use is another one now ({now}). Examine it again.', { model: last.model, now: reading.model?.name ?? '—' })
|
|
17560
|
+
: t('{model} did not pass on {date}: {known} about the projects, {unknown} said it never discussed, {orders} orders.{why}', { model: last.model, date: new Date(last.at).toLocaleDateString(language()), known: part('known'), unknown: part('unknown'), orders: part('orders'), why: last.why?.length ? ` ${last.why.map((one) => t(one)).join(' · ')}` : '' });
|
|
17561
|
+
const run = sxButton(t('Examine'), async () => {
|
|
17562
|
+
run.disabled = true;
|
|
17563
|
+
const response = await fetch('/api/x/bishop/exam', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }).catch(() => null);
|
|
17564
|
+
const result = await response?.json().catch(() => ({}));
|
|
17565
|
+
if (!response?.ok) { toast(`${t('Diagnostics › ')}${result?.error ?? t('could not start')}`); run.disabled = false; return; }
|
|
17566
|
+
void drawExam();
|
|
17567
|
+
}, { title: t('Questions about the projects, questions about things nobody discussed, and orders: the bar is fixed and the result is kept for this model') });
|
|
17568
|
+
run.disabled = Boolean(reading.running) || !reading.model;
|
|
17569
|
+
sxRow(exam, t('Exam before giving orders'), said, run);
|
|
17570
|
+
clearTimeout(examTimer);
|
|
17571
|
+
if (reading.running) examTimer = setTimeout(() => { if (exam.isConnected) void drawExam(); }, 8000);
|
|
16194
17572
|
};
|
|
16195
17573
|
fetch('/api/dataset').then((response) => response.json()).then(showDataset).catch(() => showDataset(null));
|
|
16196
17574
|
}
|
|
@@ -16699,7 +18077,33 @@ nostromo.covered = () => {
|
|
|
16699
18077
|
};
|
|
16700
18078
|
nostromo.onClang = () => { const frame = document.querySelector('#nostromo .nostromo-frame'); frame?.classList.remove('shake'); void frame?.offsetWidth; frame?.classList.add('shake'); };
|
|
16701
18079
|
// The physics and the painters, closed over this scene (nostromo-engine.js).
|
|
16702
|
-
const { buildNodes, sizeNostromo, stepNostromo, focusNostromo, projectNostromo, fitNostromo, drawNostromo, toScreen, toWorld, nostromoLive, nostromoAt, beginImplosion } = createNostromo(nostromo);
|
|
18080
|
+
const { buildNodes, sizeNostromo, stepNostromo, focusNostromo, projectNostromo, fitNostromo, drawNostromo, toScreen, toWorld, nostromoLive, nostromoAt, beginImplosion, setNeural } = createNostromo(nostromo);
|
|
18081
|
+
// Neural mode (public/neural.js): the same map drawn as the neurons of a brain, chosen from its head.
|
|
18082
|
+
// The choice is the person's, shared with Home's Projects map (looks.json, `pulse.neural`); when this
|
|
18083
|
+
// machine does not give the map its frames, the key invites to try it, once a session.
|
|
18084
|
+
nostromo.neuralKey = document.querySelector('#nostromo-neural');
|
|
18085
|
+
nostromo.neural = neuralChosen();
|
|
18086
|
+
if (nostromo.neuralKey) {
|
|
18087
|
+
neuralKey(nostromo.neuralKey);
|
|
18088
|
+
paintNeuralKey(nostromo.neuralKey, nostromo.neural);
|
|
18089
|
+
nostromo.neuralKey.addEventListener('click', () => setNostromoNeural(!nostromo.neural));
|
|
18090
|
+
}
|
|
18091
|
+
function setNostromoNeural(on) {
|
|
18092
|
+
setNeural(on, performance.now() / 1000);
|
|
18093
|
+
rememberNeural(on);
|
|
18094
|
+
paintNeuralKey(nostromo.neuralKey, on);
|
|
18095
|
+
nostromo.dialog?.querySelector('.neural-invite')?.remove();
|
|
18096
|
+
pushLooks();
|
|
18097
|
+
}
|
|
18098
|
+
function syncNostromoNeural() {
|
|
18099
|
+
const want = neuralChosen();
|
|
18100
|
+
if (Boolean(nostromo.neural) === want) return;
|
|
18101
|
+
setNeural(want, performance.now() / 1000);
|
|
18102
|
+
paintNeuralKey(nostromo.neuralKey, want);
|
|
18103
|
+
}
|
|
18104
|
+
nostromo.strain = createStrain({
|
|
18105
|
+
onStrain: () => { if (!nostromo.neural && !state.device) inviteNeural({ key: nostromo.neuralKey, host: nostromo.dialog, map: 'memory', onTry: () => setNostromoNeural(true) }); },
|
|
18106
|
+
});
|
|
16703
18107
|
// One frame of the map on demand, what startNostromo's loop does sixty times a second: for whoever
|
|
16704
18108
|
// measures it (`__pulse.nostromoTick`), without waiting for the screen.
|
|
16705
18109
|
function nostromoTick(now) {
|
|
@@ -17299,15 +18703,18 @@ function startNostromo() {
|
|
|
17299
18703
|
if (!nostromo.cage?.closed) nostromo.cage = null;
|
|
17300
18704
|
window.addEventListener('resize', sizeNostromo);
|
|
17301
18705
|
nostromo.last = performance.now();
|
|
18706
|
+
nostromo.strain?.reset();
|
|
17302
18707
|
const frame = (now) => {
|
|
17303
18708
|
const dt = Math.min(48, now - nostromo.last) / 16;
|
|
17304
18709
|
nostromo.last = now;
|
|
17305
18710
|
const clock = now / 1000;
|
|
18711
|
+
const started = performance.now();
|
|
17306
18712
|
stepNostromo(dt, clock);
|
|
17307
18713
|
focusNostromo(dt, clock);
|
|
17308
18714
|
projectNostromo();
|
|
17309
18715
|
fitNostromo();
|
|
17310
18716
|
drawNostromo(clock);
|
|
18717
|
+
nostromo.strain?.frame(performance.now() - started, now);
|
|
17311
18718
|
nostromo.raf = requestAnimationFrame(frame);
|
|
17312
18719
|
};
|
|
17313
18720
|
nostromo.raf = requestAnimationFrame(frame);
|
|
@@ -18453,11 +19860,12 @@ settingsUI.button?.addEventListener('click', () => openSettingsOn('release', { p
|
|
|
18453
19860
|
bar, and in which order, is this browser's own: a convenience, kept in localStorage. */
|
|
18454
19861
|
const BAR_KEY = 'pulse.barItems';
|
|
18455
19862
|
const BAR_FIXED = 'settings';
|
|
18456
|
-
const BAR_DEFAULT = ['salas', 'macroverso', 'crew', 'modules', 'mother', 'nostromo', 'reload', 'servers', 'finder', 'phone'];
|
|
19863
|
+
const BAR_DEFAULT = ['salas', 'macroverso', 'crew', 'code', 'modules', 'mother', 'nostromo', 'reload', 'servers', 'finder', 'phone'];
|
|
18457
19864
|
// The keys that are in the markup. The crew is two elements — five spheres when there is room, one
|
|
18458
19865
|
// button when there is not — and one key: taking it off takes both.
|
|
18459
19866
|
const BAR_BUILT = {
|
|
18460
19867
|
crew: { nodes: ['#agents', '#agents-compact'], name: () => t('Agents') },
|
|
19868
|
+
code: { nodes: ['#code-button'], name: () => t('Code') },
|
|
18461
19869
|
modules: { nodes: ['#modules-button'], name: () => t('Modules') },
|
|
18462
19870
|
mother: { nodes: ['#mother-button'], name: () => t('Diagnostics') },
|
|
18463
19871
|
nostromo: { nodes: ['#nostromo-bar'], name: () => t('Memory map') },
|
|
@@ -18472,6 +19880,8 @@ const BAR_CONTEXTUAL = ['#steps-pill', '#tour-pill-slot', '#handoff-pill', '#upd
|
|
|
18472
19880
|
// A module's name before the board has answered; once it has, the module says its own.
|
|
18473
19881
|
const BAR_MODULE_NAMES = { ash: 'Token saver', ollama: 'Local AI', intents: 'Suggested effort' };
|
|
18474
19882
|
const BAR_MINUS = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 8h7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
|
|
19883
|
+
// Its mirror on a key that can still go on the bar, in the card under it.
|
|
19884
|
+
const BAR_PLUS = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 4.5v7M4.5 8h7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
|
|
18475
19885
|
const barKit = {
|
|
18476
19886
|
node: document.querySelector('#bar'),
|
|
18477
19887
|
order: [],
|
|
@@ -18491,7 +19901,7 @@ const barKit = {
|
|
|
18491
19901
|
|
|
18492
19902
|
// Keys that joined the bar after a person arranged theirs. Each is put at the end once, so it can
|
|
18493
19903
|
// be found; taken off after that, it stays off.
|
|
18494
|
-
const BAR_JOINED = ['phone', 'salas', 'macroverso'];
|
|
19904
|
+
const BAR_JOINED = ['phone', 'salas', 'macroverso', 'code'];
|
|
18495
19905
|
// The MACROVERSO: an orbit, a sun and two worlds. Only the room of SALAS carries the key.
|
|
18496
19906
|
const MACROVERSO_ICON = '<ellipse cx="10" cy="10" rx="8.2" ry="3.6" fill="none" stroke="currentColor" stroke-width="1.4" opacity=".6"/><circle cx="10" cy="10" r="2.2" fill="currentColor"/><circle cx="16.4" cy="8.6" r="1.5" fill="currentColor"/><circle cx="4.2" cy="11.6" r="1.1" fill="currentColor" opacity=".7"/>';
|
|
18497
19907
|
// SALAS: three rooms of one Mac, and what joins them.
|
|
@@ -18505,7 +19915,8 @@ function barStored() {
|
|
|
18505
19915
|
const met = JSON.parse(localStorage.getItem('pulse.barMet') ?? '[]');
|
|
18506
19916
|
const fresh = BAR_JOINED.filter((id) => !met.includes(id));
|
|
18507
19917
|
if (fresh.length) {
|
|
18508
|
-
|
|
19918
|
+
// CODE stands beside the agents, where it is seen first; the others join at the end.
|
|
19919
|
+
for (const id of fresh) if (!list.includes(id)) { if (id === 'code' && list.includes('crew')) list.splice(list.indexOf('crew') + 1, 0, id); else list.push(id); }
|
|
18509
19920
|
localStorage.setItem(BAR_KEY, JSON.stringify(list));
|
|
18510
19921
|
localStorage.setItem('pulse.barMet', JSON.stringify([...met, ...fresh]));
|
|
18511
19922
|
}
|
|
@@ -18522,6 +19933,8 @@ function barCatalogue() {
|
|
|
18522
19933
|
for (const [id, spec] of Object.entries(BAR_BUILT)) {
|
|
18523
19934
|
// Reload lives in the second bar on the Mac; it is not a key of this one there.
|
|
18524
19935
|
if (id === 'reload' && homeOn()) continue;
|
|
19936
|
+
// The code workspace docks beside the conversation: a phone has no room beside it.
|
|
19937
|
+
if (id === 'code' && state.device) continue;
|
|
18525
19938
|
const nodes = spec.nodes.map((query) => document.querySelector(query)).filter(Boolean);
|
|
18526
19939
|
if (nodes.length) out.set(id, { id, name: spec.name(), nodes });
|
|
18527
19940
|
}
|
|
@@ -18634,6 +20047,7 @@ function barPlace() {
|
|
|
18634
20047
|
// «‹ Salas» at its start. The three are not keys you place, so the bar's card never offers them.
|
|
18635
20048
|
const HOME_ICON = '<path d="M3.4 9.2 10 3.6l6.6 5.6" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path class="ic-f" d="M5.2 7.9v7.5a1.2 1.2 0 0 0 1.2 1.2h2.3v-4a1.3 1.3 0 0 1 2.6 0v4h2.3a1.2 1.2 0 0 0 1.2-1.2V7.9" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>';
|
|
18636
20049
|
const homeOn = () => !state.device && Boolean(document.querySelector('#bar-home'));
|
|
20050
|
+
let homePower = null;
|
|
18637
20051
|
function homeBar() {
|
|
18638
20052
|
const home = document.querySelector('#bar-home');
|
|
18639
20053
|
const bar = document.querySelector('#bar');
|
|
@@ -18670,6 +20084,10 @@ function homeBar() {
|
|
|
18670
20084
|
key.classList.remove('bar-off', 'bar-overflow');
|
|
18671
20085
|
if (key.parentNode !== home) home.append(key);
|
|
18672
20086
|
}
|
|
20087
|
+
// Last, after the settings: turning MADRE off (public/power.js). On the Mac only.
|
|
20088
|
+
if (!homePower) { homePower = powerKey('tree-button'); homePower.id = 'power-button'; }
|
|
20089
|
+
labelPowerKey(homePower);
|
|
20090
|
+
if (homePower.parentNode !== home || (home.lastElementChild && home.lastElementChild !== homePower)) home.append(homePower);
|
|
18673
20091
|
home.hidden = false;
|
|
18674
20092
|
bar.classList.add('with-home');
|
|
18675
20093
|
// The bar beside it leaves exactly its width free, measured whenever it changes (fonts, language).
|
|
@@ -18905,6 +20323,8 @@ function barLayout() {
|
|
|
18905
20323
|
else if (on) { const key = barKit.extras.get(id); key.title = entry.name; key.setAttribute('aria-label', entry.name); }
|
|
18906
20324
|
}
|
|
18907
20325
|
for (const [id, node] of barKit.extras) if (!barKit.order.includes(id)) { node.remove(); barKit.extras.delete(id); }
|
|
20326
|
+
// A key the catalogue leaves out on this device (CODE on a phone) is not drawn at all.
|
|
20327
|
+
for (const [id, spec] of Object.entries(BAR_BUILT)) if (!catalogue.has(id) && id !== 'reload') for (const query of spec.nodes) document.querySelector(query)?.classList.add('bar-off');
|
|
18908
20328
|
barPlace();
|
|
18909
20329
|
if (barKit.editing) { barBadges(); barDrawCard(); }
|
|
18910
20330
|
barFit();
|
|
@@ -18941,6 +20361,7 @@ function barSpills(bar) {
|
|
|
18941
20361
|
function barFit() {
|
|
18942
20362
|
const bar = barKit.node;
|
|
18943
20363
|
if (!bar || !barKit.more || typeof bar.getBoundingClientRect !== 'function') return;
|
|
20364
|
+
bar.classList?.remove('bar-tight');
|
|
18944
20365
|
for (const node of bar.querySelectorAll?.('.bar-overflow') ?? []) node.classList.remove('bar-overflow');
|
|
18945
20366
|
barKit.hidden = [];
|
|
18946
20367
|
barKit.more.hidden = true;
|
|
@@ -18956,6 +20377,9 @@ function barFit() {
|
|
|
18956
20377
|
}
|
|
18957
20378
|
if (!barKit.hidden.length) { barKit.more.hidden = true; barCloseMore(); }
|
|
18958
20379
|
else if (barKit.moreCard && !barKit.moreCard.hidden) barDrawMore();
|
|
20380
|
+
// Everything that can go is behind «More» and the project's name is still cut: the pills that
|
|
20381
|
+
// stay (notes to accept) take their short form, so the name reads whole.
|
|
20382
|
+
if (barSpills(bar)) bar.classList?.add('bar-tight');
|
|
18959
20383
|
}
|
|
18960
20384
|
function barDrawMore() {
|
|
18961
20385
|
const catalogue = barCatalogue();
|
|
@@ -18990,6 +20414,20 @@ function barDrawMore() {
|
|
|
18990
20414
|
card.style.left = `${Math.round(Math.max(16, Math.min(window.innerWidth - width - 16, key.right - width)))}px`;
|
|
18991
20415
|
barKit.more.setAttribute('aria-expanded', 'true');
|
|
18992
20416
|
}
|
|
20417
|
+
// The bar folds its keys into 34 px squares at 980 px of its own width (styles.css, @container
|
|
20418
|
+
// bar); the house's keys sit in a bar of their own and fold with it, so one row never holds two
|
|
20419
|
+
// sizes. The conversation's width goes to the root for what the window's width cannot tell.
|
|
20420
|
+
function roomWidth() {
|
|
20421
|
+
const bar = barKit.node;
|
|
20422
|
+
if (!bar || typeof getComputedStyle !== 'function') return;
|
|
20423
|
+
const style = getComputedStyle(bar);
|
|
20424
|
+
const inner = bar.clientWidth - (parseFloat(style.paddingLeft) || 0) - (parseFloat(style.paddingRight) || 0);
|
|
20425
|
+
document.querySelector('#bar-home')?.classList.toggle('keys-square', inner > 0 && inner <= 980);
|
|
20426
|
+
const room = threadBox?.getBoundingClientRect?.().width ?? 0;
|
|
20427
|
+
const root = document.documentElement;
|
|
20428
|
+
root?.classList?.toggle('room-720', room > 0 && room <= 720);
|
|
20429
|
+
root?.classList?.toggle('room-680', room > 0 && room <= 680);
|
|
20430
|
+
}
|
|
18993
20431
|
function barCloseMore() {
|
|
18994
20432
|
if (barKit.moreCard) barKit.moreCard.hidden = true;
|
|
18995
20433
|
barKit.more?.setAttribute('aria-expanded', 'false');
|
|
@@ -19008,6 +20446,8 @@ function barSave(order) {
|
|
|
19008
20446
|
}
|
|
19009
20447
|
// The board answered: modules of yours can take their places, and their names are their own.
|
|
19010
20448
|
function barModulesChanged() {
|
|
20449
|
+
// A module's key beside the version comes and goes with the module (attribution.js).
|
|
20450
|
+
void drawColophonKeys(document.querySelector('#colophon'));
|
|
19011
20451
|
if (modules.items?.length) barKit.modulesSeen = true;
|
|
19012
20452
|
barLayout();
|
|
19013
20453
|
}
|
|
@@ -19115,10 +20555,14 @@ function barDrawCard() {
|
|
|
19115
20555
|
document.body.append(card);
|
|
19116
20556
|
}
|
|
19117
20557
|
const card = barKit.card;
|
|
20558
|
+
card.classList.add('bar-add');
|
|
20559
|
+
// The title and, under it, how the bar is arranged; Done at their right. The keys below.
|
|
19118
20560
|
const head = el('header', 'bar-card-head');
|
|
19119
20561
|
const done = sxButton(t('Done'), () => barEdit(false), { tone: 'primary' });
|
|
19120
20562
|
done.classList.add('bar-done');
|
|
19121
|
-
|
|
20563
|
+
const titles = el('div', 'bar-card-titles');
|
|
20564
|
+
titles.append(el('h3', null, t('Add to the bar')), el('p', 'bar-card-sub', t('Drag a key to move it. Right-click the bar to come back here.')));
|
|
20565
|
+
head.append(titles, done);
|
|
19122
20566
|
const grid = el('div', 'bar-grid');
|
|
19123
20567
|
for (const [id, entry] of barCatalogue()) {
|
|
19124
20568
|
if (id === BAR_FIXED || barKit.order.includes(id)) continue;
|
|
@@ -19131,12 +20575,16 @@ function barDrawCard() {
|
|
|
19131
20575
|
// The crew's own drawing is on its one-button form, not on the spheres.
|
|
19132
20576
|
const drawn = [...(entry.nodes ?? [])].reverse().map((node) => node.querySelector('svg')).find(Boolean)?.cloneNode?.(true);
|
|
19133
20577
|
if (drawn) { drawn.removeAttribute?.('class'); glyph.append(drawn); } else if (entry.icon) glyph.innerHTML = `<svg viewBox="0 0 20 20" aria-hidden="true">${entry.icon}</svg>`; // trusted: our own SVG strings
|
|
20578
|
+
// The «+» on its corner, the mirror of the «−» on the keys of the bar.
|
|
20579
|
+
const plus = el('i', 'bar-tile-plus');
|
|
20580
|
+
plus.innerHTML = BAR_PLUS; // trusted: our own SVG string
|
|
20581
|
+
glyph.append(plus);
|
|
19134
20582
|
tile.append(glyph, el('span', 'bar-tile-name', entry.name));
|
|
19135
20583
|
tile.addEventListener('click', () => barAdd(id));
|
|
19136
20584
|
grid.append(tile);
|
|
19137
20585
|
}
|
|
19138
20586
|
const focused = card.contains?.(document.activeElement) ? document.activeElement?.dataset?.barItem ?? 'done' : null;
|
|
19139
|
-
card.replaceChildren(head, grid.children.length ? grid : el('p', 'bar-card-empty', t('Everything is already on the bar.'))
|
|
20587
|
+
card.replaceChildren(head, grid.children.length ? grid : el('p', 'bar-card-empty', t('Everything is already on the bar.')));
|
|
19140
20588
|
card.hidden = false;
|
|
19141
20589
|
barPlaceCard();
|
|
19142
20590
|
// Focus stays in the card: on the tile that took the place of the one just used, or on Done.
|
|
@@ -19199,7 +20647,7 @@ function barDragStart(event, id) {
|
|
|
19199
20647
|
if (event.button !== 0 || barKit.drag) return;
|
|
19200
20648
|
const node = barShown(id);
|
|
19201
20649
|
if (!node || event.target?.closest?.('.bar-badge')) return;
|
|
19202
|
-
const drag = { id, node, x0: event.clientX, base: 0, moved: false };
|
|
20650
|
+
const drag = { id, node, x0: event.clientX, scroll0: barKit.node.scrollLeft || 0, base: 0, moved: false };
|
|
19203
20651
|
barKit.drag = drag;
|
|
19204
20652
|
drag.move = (moved) => barDragMove(moved);
|
|
19205
20653
|
drag.up = () => barDragEnd({ save: true });
|
|
@@ -19210,13 +20658,27 @@ function barDragStart(event, id) {
|
|
|
19210
20658
|
function barDragMove(event) {
|
|
19211
20659
|
const drag = barKit.drag;
|
|
19212
20660
|
if (!drag) return;
|
|
19213
|
-
const
|
|
20661
|
+
const bar = barKit.node;
|
|
19214
20662
|
if (!drag.moved) {
|
|
19215
|
-
if (Math.abs(
|
|
20663
|
+
if (Math.abs(event.clientX - drag.x0) < 5) return;
|
|
19216
20664
|
drag.moved = true;
|
|
19217
20665
|
drag.base = drag.node.offsetLeft;
|
|
19218
20666
|
drag.node.classList.add('bar-lifted');
|
|
19219
20667
|
}
|
|
20668
|
+
// Near an edge of a bar wider than its box, the bar runs under the key (styles.css: while it is
|
|
20669
|
+
// being arranged it scrolls sideways instead of spilling out of its box).
|
|
20670
|
+
const box = bar.getBoundingClientRect?.();
|
|
20671
|
+
if (box && bar.scrollWidth > bar.clientWidth + 1) {
|
|
20672
|
+
const edge = 36;
|
|
20673
|
+
if (event.clientX < box.left + edge) bar.scrollLeft -= 10;
|
|
20674
|
+
else if (event.clientX > box.right - edge) bar.scrollLeft += 10;
|
|
20675
|
+
}
|
|
20676
|
+
// The key is held between the first and the last key of the bar: it never leaves it.
|
|
20677
|
+
const placed = barKit.order.map((one) => barShown(one)).filter(Boolean);
|
|
20678
|
+
const lo = Math.min(...placed.map((node) => node === drag.node ? drag.base : node.offsetLeft));
|
|
20679
|
+
const hi = Math.max(...placed.map((node) => (node === drag.node ? drag.base : node.offsetLeft) + node.offsetWidth)) - drag.node.offsetWidth;
|
|
20680
|
+
const wanted = drag.base + (event.clientX - drag.x0) + ((bar.scrollLeft || 0) - drag.scroll0);
|
|
20681
|
+
const dx = Math.min(Math.max(wanted, lo), Math.max(lo, hi)) - drag.base;
|
|
19220
20682
|
const centre = drag.base + dx + drag.node.offsetWidth / 2;
|
|
19221
20683
|
const others = barKit.order.filter((one) => one !== drag.id).map((one) => [one, barShown(one)]).filter(([, node]) => node);
|
|
19222
20684
|
const past = others.find(([, node]) => centre < node.offsetLeft + node.offsetWidth / 2)?.[0] ?? null;
|
|
@@ -19264,6 +20726,11 @@ if (barKit.node) {
|
|
|
19264
20726
|
let fitting = 0;
|
|
19265
20727
|
const refit = () => { cancelAnimationFrame?.(fitting); fitting = requestAnimationFrame?.(() => barFit()) ?? 0; };
|
|
19266
20728
|
window.addEventListener?.('resize', refit);
|
|
20729
|
+
// The bar is as wide as the room, not the window (2026-10-17): the code workspace docked beside
|
|
20730
|
+
// the conversation narrows it with the window unmoved, and MODULES ended up under the house's
|
|
20731
|
+
// keys. Measured whenever the bar's own width changes: what fits, the house's keys at the size of
|
|
20732
|
+
// the bar's, and the room's width for the rules that read it (styles.css, room-720 · room-680).
|
|
20733
|
+
if (typeof ResizeObserver === 'function') new ResizeObserver(() => { roomWidth(); refit(); }).observe(barKit.node);
|
|
19267
20734
|
// A key that comes or goes on its own (STOP ALL while agents work, a pill) changes what fits, and
|
|
19268
20735
|
// so does the bar's own font arriving. «More» is left out: it is the fitting's own doing.
|
|
19269
20736
|
if (typeof MutationObserver === 'function') {
|
|
@@ -19276,6 +20743,14 @@ if (barKit.node) {
|
|
|
19276
20743
|
// Right-click on the bar between keys, while it is open, closes it, as a second right-click on a
|
|
19277
20744
|
// key does. While it is open a key does not do what it does: it is being placed, not pressed.
|
|
19278
20745
|
barKit.node.addEventListener('contextmenu', (event) => { if (barKit.editing && !event.barHandled) { event.preventDefault(); barEdit(false); } });
|
|
20746
|
+
// Being arranged, a bar wider than its box scrolls sideways; a mouse's wheel only turns up and
|
|
20747
|
+
// down, so it is turned into sideways there.
|
|
20748
|
+
barKit.node.addEventListener('wheel', (event) => {
|
|
20749
|
+
const bar = barKit.node;
|
|
20750
|
+
if (!barKit.editing || bar.scrollWidth <= bar.clientWidth + 1 || Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
|
|
20751
|
+
bar.scrollLeft += event.deltaY;
|
|
20752
|
+
event.preventDefault();
|
|
20753
|
+
}, { passive: false });
|
|
19279
20754
|
barKit.node.addEventListener('click', (event) => {
|
|
19280
20755
|
if (!barKit.editing && !barKit.swallow) return;
|
|
19281
20756
|
if (barKit.swallow) { barKit.swallow = false; event.preventDefault(); event.stopPropagation(); return; }
|