@jossuealcala/madre 1.0.2 → 1.0.4
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 +76 -1
- package/CHANGELOG.md +76 -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 +1635 -159
- 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 +395 -26
- 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 +105 -14
- package/public/salas-mac.html +9 -1
- package/public/salas-mac.js +350 -31
- package/public/styles.css +844 -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 +8 -8
- 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-sentinel.mjs +7 -3
- 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;
|
|
@@ -1715,7 +2513,8 @@ function paintPorts(read) {
|
|
|
1715
2513
|
row.type = 'button';
|
|
1716
2514
|
// A server that sends no Content-Type used to leave the separator hanging: «:5173 node · 200 · ».
|
|
1717
2515
|
const kind = found.html ? '' : (found.contentType ?? '').trim();
|
|
1718
|
-
|
|
2516
|
+
// Open to the whole network too: it opens here on 127.0.0.1, and the row says the rest.
|
|
2517
|
+
const what = `${found.command} · ${found.status}${kind ? ` · ${kind}` : ''}${found.network ? ` · ${t('your network can reach it too')}` : ''}`;
|
|
1719
2518
|
row.append(el('i', 'ports-lamp'), el('b', null, `:${found.port}`), el('span', null, found.framable === false ? t('{what} · cannot be shown in here', { what }) : what));
|
|
1720
2519
|
row.addEventListener('click', () => openPort(found));
|
|
1721
2520
|
menu.append(row);
|
|
@@ -2122,6 +2921,10 @@ function ripleyMaybeReload(paths) {
|
|
|
2122
2921
|
}
|
|
2123
2922
|
|
|
2124
2923
|
function showViewerPreview() {
|
|
2924
|
+
// The whole frame for the result: nothing beside it.
|
|
2925
|
+
code.preview = null;
|
|
2926
|
+
viewer.body.classList.remove('with-beside');
|
|
2927
|
+
code.besideButton?.setAttribute('aria-pressed', 'false');
|
|
2125
2928
|
if (viewerMode.kind === 'markdown') {
|
|
2126
2929
|
ripley.nav.hidden = true;
|
|
2127
2930
|
const doc = el('article', 'viewer-markdown bubble');
|
|
@@ -2156,7 +2959,7 @@ function syncViewerMode() {
|
|
|
2156
2959
|
if (!viewerMode.button) return;
|
|
2157
2960
|
const can = Boolean(viewerMode.kind) && state.ripley;
|
|
2158
2961
|
viewerMode.button.hidden = !can;
|
|
2159
|
-
viewerMode.button.textContent = viewerMode.preview ? 'SOURCE' : 'PREVIEW';
|
|
2962
|
+
viewerMode.button.textContent = viewerMode.preview ? t('SOURCE') : t('PREVIEW');
|
|
2160
2963
|
viewerMode.button.title = viewerMode.preview ? t('Show the file as text') : t('Render with Preview in a sealed frame');
|
|
2161
2964
|
}
|
|
2162
2965
|
viewerMode.button?.addEventListener('click', async () => { if (!await leaveEditor()) return; viewerMode.preview = !viewerMode.preview; syncViewerMode(); syncEditButton(); if (viewerMode.preview) showViewerPreview(); else showViewerSource(); });
|
|
@@ -2173,7 +2976,13 @@ function paintViewerPath(text) {
|
|
|
2173
2976
|
viewer.path.setAttribute?.('aria-label', said);
|
|
2174
2977
|
}
|
|
2175
2978
|
async function openViewer({ root = 'project', path = null, url = null, label = null, line = null, lines = null }) {
|
|
2176
|
-
|
|
2979
|
+
let docked = false;
|
|
2980
|
+
try { docked = codeOn(); } catch { /* the layout is not up yet */ }
|
|
2981
|
+
if (!await leaveEditor({ stash: docked })) return;
|
|
2982
|
+
code.preview?.remove();
|
|
2983
|
+
code.preview = null;
|
|
2984
|
+
viewer.body.classList.remove('with-beside');
|
|
2985
|
+
code.line = line ?? lines?.from ?? null;
|
|
2177
2986
|
const src = url ?? `/api/files?root=${root}&path=${encodeURIComponent(path)}`;
|
|
2178
2987
|
paintViewerPath(label ?? path);
|
|
2179
2988
|
viewer.open.href = src;
|
|
@@ -2201,15 +3010,27 @@ async function openViewer({ root = 'project', path = null, url = null, label = n
|
|
|
2201
3010
|
// Text of this project, small enough to edit: the editor is offered. Attachments and frames
|
|
2202
3011
|
// are read, never written.
|
|
2203
3012
|
const editable = root === 'project' && Boolean(path) && !url && response.headers.has('x-mtime') && text.length < 2 * 1024 * 1024 && !text.includes('\u0000');
|
|
2204
|
-
|
|
3013
|
+
// In the code workspace a page or a document opens as its text, with what it makes beside it.
|
|
3014
|
+
let docked = false;
|
|
3015
|
+
try { docked = codeOn(); } catch { /* the layout is not up yet */ }
|
|
3016
|
+
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 });
|
|
3017
|
+
const tab = editable ? noteCodeTab(path) : null;
|
|
2205
3018
|
syncViewerMode();
|
|
2206
3019
|
syncEditButton();
|
|
2207
3020
|
if (viewerMode.preview) showViewerPreview(); else showViewerSource();
|
|
3021
|
+
// A tab left with unsaved changes opens where it was left: in the editor, with them.
|
|
3022
|
+
if (tab && tab.draft !== null) { if (tab.mtime) viewerMode.mtime = tab.mtime; openEditor(line); }
|
|
3023
|
+
if (editable) void loadCodeChanges(path);
|
|
3024
|
+
syncCode();
|
|
2208
3025
|
} catch (error) {
|
|
2209
3026
|
viewer.body.replaceChildren(el('div', 'err', error.message));
|
|
2210
3027
|
}
|
|
2211
3028
|
}
|
|
2212
|
-
viewer.close.addEventListener('click', async () => {
|
|
3029
|
+
viewer.close.addEventListener('click', async () => {
|
|
3030
|
+
let docked = false;
|
|
3031
|
+
try { docked = codeOn(); } catch { /* not yet */ }
|
|
3032
|
+
if (await leaveEditor({ stash: docked })) viewer.dialog.close();
|
|
3033
|
+
});
|
|
2213
3034
|
|
|
2214
3035
|
function codeBlock(lines, lang) {
|
|
2215
3036
|
const wrapper = el('div', `codeblock${lang ? ' has-lang' : ''}`);
|
|
@@ -2392,6 +3213,11 @@ function ringPercent(agent) {
|
|
|
2392
3213
|
// it, and a row is that agent's own card — the same one a sphere opens, with a way back.
|
|
2393
3214
|
const agentsCompact = document.querySelector('#agents-compact');
|
|
2394
3215
|
const agentsCount = document.querySelector('#agents-count');
|
|
3216
|
+
// Whether an agent can answer now: an adapter MADRE can use, and a session. One that cannot is
|
|
3217
|
+
// switched off everywhere: grey in the bar, out of the picker, and a message to it is not sent.
|
|
3218
|
+
function canAnswer(agent) {
|
|
3219
|
+
return Boolean(agent?.ready) && state.sessions?.[agent.id]?.state !== 'signed-out';
|
|
3220
|
+
}
|
|
2395
3221
|
function renderAgentsCompact() {
|
|
2396
3222
|
if (!agentsCompact) return;
|
|
2397
3223
|
const all = [...state.agents.values()];
|
|
@@ -2411,15 +3237,19 @@ function renderAgents() {
|
|
|
2411
3237
|
for (const agent of state.agents.values()) {
|
|
2412
3238
|
const percent = ringPercent(agent);
|
|
2413
3239
|
const official = liveOfficial(agent);
|
|
3240
|
+
// An agent that cannot answer looks switched off, whatever the reason: not installed, no adapter
|
|
3241
|
+
// yet, or installed with no session (2026-10-17). Its colour comes back with its session.
|
|
3242
|
+
const signedOut = agent.ready && !canAnswer(agent);
|
|
2414
3243
|
const node = avatar(agent.id, {
|
|
2415
3244
|
size: 26,
|
|
2416
|
-
ring: agent.ready,
|
|
3245
|
+
ring: agent.ready && !signedOut,
|
|
2417
3246
|
pct: percent,
|
|
2418
3247
|
status: agent.ready ? 'ready' : agent.detected ? 'detected' : 'offline',
|
|
2419
3248
|
});
|
|
3249
|
+
if (!agent.ready || signedOut) node.classList.add('off');
|
|
2420
3250
|
node.title = [
|
|
2421
3251
|
`${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'),
|
|
3252
|
+
signedOut ? t('Not signed in') : agent.ready ? t('Ready') : agent.detected ? t('Detected, but MADRE cannot use it yet') : t('Not installed'),
|
|
2423
3253
|
agent.version ? agent.version : null,
|
|
2424
3254
|
official === null
|
|
2425
3255
|
? (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 +3287,7 @@ if (safetyLine && !safetySeen) {
|
|
|
2457
3287
|
function renderPicker() {
|
|
2458
3288
|
els.picker.replaceChildren();
|
|
2459
3289
|
els.pickOptions?.replaceChildren?.();
|
|
2460
|
-
const ready = [...state.agents.values()].filter(
|
|
3290
|
+
const ready = [...state.agents.values()].filter(canAnswer);
|
|
2461
3291
|
if (!ready.length) { updateCrewLabel(); return; }
|
|
2462
3292
|
if (!els.target.value || !ready.some((agent) => agent.id === els.target.value)) els.target.value = ready[0].id;
|
|
2463
3293
|
const stack = el('div', 'crew-stack');
|
|
@@ -3513,7 +4343,7 @@ function bridgeCard(agent) {
|
|
|
3513
4343
|
card.append(head);
|
|
3514
4344
|
const detail = el('div', 'detail');
|
|
3515
4345
|
if (agent.id === 'madre') detail.append(ollamaDetail(agent));
|
|
3516
|
-
else if (stage === 'missing') detail.append(
|
|
4346
|
+
else if (stage === 'missing') detail.append(t('Not on this computer.'));
|
|
3517
4347
|
else detail.append(`${agent.version ?? t('version unknown')}${session?.detail ? ` · ${session.detail}` : ''}${agent.route?.destination ? ` · ${t('route: {destination}', { destination: agent.route.destination })}` : ''}`);
|
|
3518
4348
|
card.append(detail);
|
|
3519
4349
|
// What is behind this door: the account it needs, and whether there is a way in without paying.
|
|
@@ -3530,24 +4360,35 @@ function bridgeCard(agent) {
|
|
|
3530
4360
|
if (stage === 'signed-out' || (stage === 'ready' && agent.login?.headless)) {
|
|
3531
4361
|
// Every door the CLI has, not just the first one. Claude sells two accounts and MADRE used
|
|
3532
4362
|
// 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:')));
|
|
4363
|
+
const keyFirst = Boolean(agent.key?.first);
|
|
3535
4364
|
if (agent.key) {
|
|
3536
|
-
reveal = el('button', agent.login?.headless ? null : 'primary', t('PASTE API KEY'));
|
|
4365
|
+
reveal = el('button', agent.login?.headless && !keyFirst ? null : 'primary', t('PASTE API KEY'));
|
|
3537
4366
|
reveal.type = 'button';
|
|
3538
|
-
actions.append(reveal);
|
|
3539
4367
|
}
|
|
4368
|
+
if (keyFirst) actions.append(reveal);
|
|
4369
|
+
if (agent.login?.headless) {
|
|
4370
|
+
for (const door of loginDoors(agent, stage === 'ready')) {
|
|
4371
|
+
const signIn = bridgeSignInButton(agent, door, stage === 'ready');
|
|
4372
|
+
if (keyFirst) signIn.classList.remove('primary');
|
|
4373
|
+
actions.append(signIn);
|
|
4374
|
+
}
|
|
4375
|
+
}
|
|
4376
|
+
else if (!agent.key && agent.login) actions.append(el('span', 'note', t('signs in from your terminal:')));
|
|
4377
|
+
if (reveal && !keyFirst) actions.append(reveal);
|
|
3540
4378
|
}
|
|
3541
4379
|
if (actions.childNodes.length) card.append(actions);
|
|
3542
4380
|
if (reveal) {
|
|
3543
4381
|
const slot = el('div', 'key-slot');
|
|
3544
4382
|
slot.hidden = true;
|
|
3545
4383
|
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
4384
|
reveal.addEventListener('click', () => { slot.hidden = !slot.hidden; if (!slot.hidden) slot.querySelector('input')?.focus(); });
|
|
3548
4385
|
card.append(slot);
|
|
3549
|
-
}
|
|
4386
|
+
}
|
|
3550
4387
|
if (stage !== 'missing') card.append(loginAskSlot(agent.id));
|
|
4388
|
+
if (agent.id !== 'madre') {
|
|
4389
|
+
const way = stage === 'missing' ? installTerminalWay(agent) : stage === 'signed-out' || stage === 'ready' ? signInTerminalWay(agent) : null;
|
|
4390
|
+
if (way) card.append(way);
|
|
4391
|
+
}
|
|
3551
4392
|
const log = el('pre', 'bridge-log');
|
|
3552
4393
|
log.id = `bridge-log-${agent.id}`;
|
|
3553
4394
|
for (const entry of state.loginLogs.get(agent.id) ?? []) log.append(loginLine(entry));
|
|
@@ -3682,17 +4523,42 @@ function bridgeInstallButton(agent) {
|
|
|
3682
4523
|
return button;
|
|
3683
4524
|
}
|
|
3684
4525
|
function bridgeSignInButton(agent, door, again = false) {
|
|
3685
|
-
const named = Boolean(agent.login?.methods?.length);
|
|
4526
|
+
const named = Boolean(agent.login?.methods?.length) && !door.again;
|
|
3686
4527
|
const button = el('button', again && !named ? null : 'primary', named ? door.label : again ? t('SIGN IN AGAIN') : t('SIGN IN'));
|
|
3687
4528
|
button.type = 'button';
|
|
3688
4529
|
button.title = t(door.note || agent.login.note);
|
|
3689
4530
|
button.addEventListener('click', async () => {
|
|
4531
|
+
if (door.choose) { choosingAccount.add(agent.id); renderOnboarding(); return; }
|
|
4532
|
+
choosingAccount.delete(agent.id);
|
|
3690
4533
|
button.disabled = true;
|
|
3691
4534
|
try { await startLogin(agent.id, door.id); }
|
|
3692
4535
|
catch (error) { toast(t('Diagnostics › sign-in did not start: {error}', { error: error.message })); renderOnboarding(); }
|
|
3693
4536
|
});
|
|
3694
4537
|
return button;
|
|
3695
4538
|
}
|
|
4539
|
+
// The other way in: the same install or sign-in typed in a terminal, for whoever prefers it.
|
|
4540
|
+
// Folded, so a person who has never opened a terminal is never asked to.
|
|
4541
|
+
// Cards are drawn again on every sign-in event: which folds are open is kept here, so one never
|
|
4542
|
+
// closes on the person reading it.
|
|
4543
|
+
const openTerminalWays = new Set();
|
|
4544
|
+
function terminalWay(lines) {
|
|
4545
|
+
const fold = el('details', 'terminal-way');
|
|
4546
|
+
const key = lines[0];
|
|
4547
|
+
fold.open = openTerminalWays.has(key);
|
|
4548
|
+
fold.addEventListener('toggle', () => { if (fold.open) openTerminalWays.add(key); else openTerminalWays.delete(key); });
|
|
4549
|
+
fold.append(el('summary', null, t('Prefer your terminal?')));
|
|
4550
|
+
fold.append(commandBlock(lines));
|
|
4551
|
+
return fold;
|
|
4552
|
+
}
|
|
4553
|
+
function installTerminalWay(agent) {
|
|
4554
|
+
if (!agent.install) return null;
|
|
4555
|
+
return terminalWay([agent.install.display, ...(agent.install.alternatives ?? []).map((line) => `# ${t(line)}`)]);
|
|
4556
|
+
}
|
|
4557
|
+
function signInTerminalWay(agent) {
|
|
4558
|
+
const way = agent.login?.terminal;
|
|
4559
|
+
if (!way) return null;
|
|
4560
|
+
return terminalWay([way.command, ...(way.note ? [`# ${t(way.note)}`] : [])]);
|
|
4561
|
+
}
|
|
3696
4562
|
|
|
3697
4563
|
/* ---------- thread rendering ---------- */
|
|
3698
4564
|
|
|
@@ -3869,6 +4735,8 @@ function iconButton(svg, title, className) {
|
|
|
3869
4735
|
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
4736
|
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
4737
|
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>';
|
|
4738
|
+
// The settings' gear: the drawing AJUSTES wears in the bar, for any way into Settings.
|
|
4739
|
+
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
4740
|
// A small ×, in the same round line, for an aside that can be put away.
|
|
3873
4741
|
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
4742
|
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 +4924,12 @@ const WORKING_VOICE = {
|
|
|
4056
4924
|
late: [t('Reasoning through it…'), t('Verifying the findings…'), t('Sorting the evidence…')],
|
|
4057
4925
|
long: [t('Formulating the answer…'), t('Writing it up…'), t('Wrapping up…')],
|
|
4058
4926
|
},
|
|
4927
|
+
cursor: {
|
|
4928
|
+
early: [t('Reading the repo…'), t('Searching the project…'), t('Grabbing context…')],
|
|
4929
|
+
middle: [t('Thinking…'), t('Digging through the code…'), t('Following the references…'), t('Looking closer…')],
|
|
4930
|
+
late: [t('Working through it…'), t('Double-checking…'), t('Piecing it together…')],
|
|
4931
|
+
long: [t('Writing the response…'), t('Tidying the answer…'), t('Nearly done…')],
|
|
4932
|
+
},
|
|
4059
4933
|
opencode: {
|
|
4060
4934
|
early: [t('Reading the repo…'), t('Listing files…'), t('Grabbing context…')],
|
|
4061
4935
|
middle: [t('Thinking…'), t('Digging through the code…'), t('Following the call chain…'), t('Looking closer…')],
|
|
@@ -4082,10 +4956,11 @@ function showReading(event) {
|
|
|
4082
4956
|
// The answer as it is written. A CLI that says nothing until it is done shows nothing here,
|
|
4083
4957
|
// which is the truth rather than a number invented to keep something moving.
|
|
4084
4958
|
const wrote = out ? ` · ${t('{n} out', { n: (outTokens ?? 0).toLocaleString() || t('{n} ch', { n: out.toLocaleString() }) })}` : '';
|
|
4085
|
-
|
|
4086
|
-
meter
|
|
4959
|
+
// In place, and said without the grey box (see the clock in renderThinking).
|
|
4960
|
+
setText(meter, `~${read}${wrote}`);
|
|
4961
|
+
meter.setAttribute('aria-description', tokens
|
|
4087
4962
|
? 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() });
|
|
4963
|
+
: t('{chars} characters. This project has not been billed yet, so there is no rate to convert them at.', { chars: chars.toLocaleString() }));
|
|
4089
4964
|
}
|
|
4090
4965
|
|
|
4091
4966
|
// The bill arrived: the estimate is replaced by what was actually charged, and stays on the
|
|
@@ -4177,6 +5052,8 @@ function renderThinking(event) {
|
|
|
4177
5052
|
const status = el('span', 'status');
|
|
4178
5053
|
const tokens = el('span', 'tokens');
|
|
4179
5054
|
const elapsed = el('span', 'elapsed');
|
|
5055
|
+
// The clock is not read aloud every second: the region around it is live.
|
|
5056
|
+
elapsed.setAttribute('aria-hidden', 'true');
|
|
4180
5057
|
// One answer, not the room. PARAR TODO is the master brake and takes down every plan and every
|
|
4181
5058
|
// agent; this one is for the turn in front of you — the agent that hung, or started down the
|
|
4182
5059
|
// wrong road, or hit an error and is still being waited on.
|
|
@@ -4214,7 +5091,9 @@ function renderThinking(event) {
|
|
|
4214
5091
|
phraseChangedAt = seconds;
|
|
4215
5092
|
phraseIndex = (phraseIndex + 1) % pool.length;
|
|
4216
5093
|
const next = pool[phraseIndex];
|
|
4217
|
-
|
|
5094
|
+
// The phrase changes in place and comes in with its own small animation, without asking the
|
|
5095
|
+
// browser to lay the page out again first (the old restart read offsetWidth to replay it).
|
|
5096
|
+
if (status.textContent !== next) { setText(status, next); status.animate?.([{ opacity: 0, transform: 'translateY(3px)' }, { opacity: 1, transform: 'none' }], { duration: 350, easing: 'ease' }); }
|
|
4218
5097
|
};
|
|
4219
5098
|
// The limit the room holds this turn to, said with its start; older records fall back to the setting.
|
|
4220
5099
|
const limitMs = event.payload.timeoutMs ?? state.timeouts[agent] ?? 180000;
|
|
@@ -4230,9 +5109,11 @@ function renderThinking(event) {
|
|
|
4230
5109
|
else if ((wasConnected || Date.now() - born > 5000) && timer) { clearInterval(timer); thinkingClocks.delete(timer); return; }
|
|
4231
5110
|
const seconds = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
|
|
4232
5111
|
speak(seconds);
|
|
4233
|
-
elapsed
|
|
4234
|
-
elapsed.classList.toggle('late', seconds >= limit * 0.6);
|
|
4235
|
-
|
|
5112
|
+
setText(elapsed, `${seconds}s / ${limit}s`);
|
|
5113
|
+
if (elapsed.classList.contains('late') !== seconds >= limit * 0.6) elapsed.classList.toggle('late', seconds >= limit * 0.6);
|
|
5114
|
+
// Said to a screen reader, never as the browser's grey box: a title written every second came
|
|
5115
|
+
// back under a still pointer however many times it was taken off (adoptTitle).
|
|
5116
|
+
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
5117
|
};
|
|
4237
5118
|
tick();
|
|
4238
5119
|
timer = setInterval(tick, 1000);
|
|
@@ -4621,7 +5502,7 @@ function attachMemoryHint(event) {
|
|
|
4621
5502
|
for (const child of [...(col.children ?? [])]) if (child.classList?.contains('memory-hint')) child.remove();
|
|
4622
5503
|
const hint = el('div', 'memory-hint');
|
|
4623
5504
|
tint(hint, agent);
|
|
4624
|
-
hint.append(
|
|
5505
|
+
hint.append(leadWords('span', 'lead', t('◉ memory saved')));
|
|
4625
5506
|
for (const note of notes.slice(0, 3)) {
|
|
4626
5507
|
const pill = el('button', `pill ${note.kind}`);
|
|
4627
5508
|
pill.type = 'button';
|
|
@@ -4787,13 +5668,35 @@ function renderLeaseRefused(event) {
|
|
|
4787
5668
|
// The reply named files and the disk does not have them. Said as the fact it is, without
|
|
4788
5669
|
// deciding what the agent meant: an answer that claims a file it never wrote and an answer that
|
|
4789
5670
|
// suggests one both leave the human better off knowing it is not there.
|
|
5671
|
+
// A reply that says it created files the disk does not have (manual tests on 1.0.2, 2026-10-17:
|
|
5672
|
+
// «Creé gastos.html» and no gastos.html). It used to be a small line that folded into a dot ten
|
|
5673
|
+
// seconds later, under a bubble that said the opposite. Now it says it plainly and offers the way
|
|
5674
|
+
// on: the same request back in the box, with a line telling the agent what happened. Sending it is
|
|
5675
|
+
// the human's; the live button keeps the notice open until then.
|
|
4790
5676
|
function renderCreateMissing(event) {
|
|
4791
|
-
const { agent, files = [] } = event.payload;
|
|
4792
|
-
const
|
|
5677
|
+
const { agent, files = [], requestId = null } = event.payload;
|
|
5678
|
+
const list = files.slice(0, 4).join(', ') + (files.length > 4 ? '…' : '');
|
|
5679
|
+
const node = el('div', 'system memory warn create-missing');
|
|
4793
5680
|
tint(node, agent);
|
|
4794
5681
|
node.append(t('create · '), el('b', 'who', `@${agent}`));
|
|
4795
|
-
node.append(t(' ·
|
|
4796
|
-
node.append(t(' · nothing was written this
|
|
5682
|
+
node.append(t(' · said it created {files}, but it is not in your project', { files: list }));
|
|
5683
|
+
node.append(t(' · nothing was written in this reply'));
|
|
5684
|
+
const asked = requestId ? state.userMessages.get(requestId) : null;
|
|
5685
|
+
if (asked && state.agents.has(agent)) {
|
|
5686
|
+
const again = el('button', 'retry-longer', t('ASK AGAIN'));
|
|
5687
|
+
again.type = 'button';
|
|
5688
|
+
again.addEventListener('click', () => {
|
|
5689
|
+
again.disabled = true;
|
|
5690
|
+
if (els.target.value !== agent) { els.target.value = agent; renderPicker(); }
|
|
5691
|
+
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 });
|
|
5692
|
+
els.input.value = [asked.text ?? '', note].filter(Boolean).join('\n\n');
|
|
5693
|
+
if ((asked.mode ?? 1) === 2) setMode(2);
|
|
5694
|
+
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 }));
|
|
5695
|
+
autosize();
|
|
5696
|
+
focusComposer();
|
|
5697
|
+
});
|
|
5698
|
+
node.append(again);
|
|
5699
|
+
}
|
|
4797
5700
|
return node;
|
|
4798
5701
|
}
|
|
4799
5702
|
|
|
@@ -4829,12 +5732,102 @@ function renderEnlace(event) {
|
|
|
4829
5732
|
: t(" · with {room} · both read each other's {kinds}, read-only", { room: other, kinds: what })));
|
|
4830
5733
|
if (expires) node.append(t(' · until {date}', { date: new Date(expires).toLocaleDateString(language(), { day: 'numeric', month: 'short' }) }));
|
|
4831
5734
|
if (event.payload.contract?.signed?.by === 'bishop') node.append(t(' · signed by the Coordinator'));
|
|
5735
|
+
if (event.payload.contract?.train) node.append(t(' · the Coordinator learns from it'));
|
|
5736
|
+
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"));
|
|
5737
|
+
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
5738
|
}
|
|
4833
5739
|
node.title = contract?.id ?? (typeof contract === 'string' ? contract : '');
|
|
4834
5740
|
if (contract?.id) node.dataset.contract = contract.id;
|
|
4835
5741
|
return node;
|
|
4836
5742
|
}
|
|
4837
5743
|
|
|
5744
|
+
// D-S15: what the Coordinator took to learn from, said in the project that handed it over, and in
|
|
5745
|
+
// Salas, where it was taken. One line each time; the notes themselves are never shown here.
|
|
5746
|
+
function renderLearned(event) {
|
|
5747
|
+
const { count = 0 } = event.payload ?? {};
|
|
5748
|
+
const node = el('div', 'system memory enlace');
|
|
5749
|
+
node.append(el('b', null, t('CONNECTION')));
|
|
5750
|
+
node.append(event.type === 'enlace.corpus'
|
|
5751
|
+
? t('The Coordinator learned what this project knows: {n} notes, cleaned of private data.', { n: count })
|
|
5752
|
+
: t('Learned from {room}: {n} notes for the training.', { room: event.payload.from ?? '', n: count }));
|
|
5753
|
+
return node;
|
|
5754
|
+
}
|
|
5755
|
+
|
|
5756
|
+
// D-S17: notes another project delivered here. A card with one row per note, each accepted or
|
|
5757
|
+
// declined by you (or by an agent of this project in #3/#4, which the row then shows). Nothing of
|
|
5758
|
+
// them is in the memory until accepted.
|
|
5759
|
+
function renderDelivered(event) {
|
|
5760
|
+
const { name, agent, coordinator, notes = [] } = event.payload ?? {};
|
|
5761
|
+
if (!notes.length) return null;
|
|
5762
|
+
const from = coordinator ? t('the Coordinator') : name;
|
|
5763
|
+
const node = el('div', 'choice run-request bishop-plan delivered');
|
|
5764
|
+
const head = el('div', 'choice-head');
|
|
5765
|
+
head.append(el('span', 'choice-kicker', t('Delivered by {room}{agent}', { room: from, agent: agent ? ` · @${agent}` : '' })));
|
|
5766
|
+
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 })));
|
|
5767
|
+
const list = el('div', 'choice-list');
|
|
5768
|
+
for (const note of notes) {
|
|
5769
|
+
const row = el('div', 'choice-option bishop-order');
|
|
5770
|
+
row.dataset.delivery = note.id;
|
|
5771
|
+
row.append(el('span', 'choice-key', kindWord(note.kind).slice(0, 1).toUpperCase()));
|
|
5772
|
+
const what = el('div', 'choice-text');
|
|
5773
|
+
const where = el('div', 'bishop-where');
|
|
5774
|
+
where.append(el('b', null, kindWord(note.kind)));
|
|
5775
|
+
what.append(where, el('div', 'bishop-task', note.text));
|
|
5776
|
+
if (note.why) what.append(el('p', 'run-why', t('Why: {why}', { why: note.why })));
|
|
5777
|
+
row.append(what, el('div', 'choice-tags'));
|
|
5778
|
+
list.append(row);
|
|
5779
|
+
state.deliveries.set(note.id, row);
|
|
5780
|
+
markDelivery({ id: note.id }, row.dataset.status ?? 'pending', row);
|
|
5781
|
+
}
|
|
5782
|
+
node.append(list);
|
|
5783
|
+
state.lastSender = null;
|
|
5784
|
+
return node;
|
|
5785
|
+
}
|
|
5786
|
+
function markDelivery({ id, by = null }, status, row = null) {
|
|
5787
|
+
const target = row ?? state.deliveries.get(id);
|
|
5788
|
+
if (!target) return;
|
|
5789
|
+
target.dataset.status = status;
|
|
5790
|
+
const side = target.querySelector('.choice-tags');
|
|
5791
|
+
side.replaceChildren();
|
|
5792
|
+
if (status !== 'pending') {
|
|
5793
|
+
const who = by && by !== 'you' ? ` · @${by}` : '';
|
|
5794
|
+
side.append(el('span', 'choice-tag', `${status === 'accepted' ? t('ACCEPTED') : t('DECLINED')}${who}`));
|
|
5795
|
+
return;
|
|
5796
|
+
}
|
|
5797
|
+
if (state.device) { side.append(el('span', 'choice-tag', t('FROM THE MAC'))); return; }
|
|
5798
|
+
const accept = bishopButton(t('Accept'));
|
|
5799
|
+
const decline = bishopButton(t('Decline'), true);
|
|
5800
|
+
accept.addEventListener('click', () => bishopPress('/api/enlace/inbox/decide', { id, accept: true }, [accept, decline]));
|
|
5801
|
+
decline.addEventListener('click', () => bishopPress('/api/enlace/inbox/decide', { id, accept: false }, [accept, decline]));
|
|
5802
|
+
side.append(accept, decline);
|
|
5803
|
+
}
|
|
5804
|
+
// D-S16 and D-S17, the lines that say what crossed: the state read, notes delivered elsewhere.
|
|
5805
|
+
function renderCrossing(event) {
|
|
5806
|
+
const payload = event.payload ?? {};
|
|
5807
|
+
const node = el('div', 'system memory enlace');
|
|
5808
|
+
node.append(el('b', null, t('CONNECTION')));
|
|
5809
|
+
const room = payload.name ?? payload.to ?? payload.from ?? '';
|
|
5810
|
+
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 ?? '' }));
|
|
5811
|
+
else if (event.type === 'enlace.state.received') node.append(t('@{agent} read the state of {room}.', { agent: payload.agent ?? '', room }));
|
|
5812
|
+
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 }));
|
|
5813
|
+
return node;
|
|
5814
|
+
}
|
|
5815
|
+
// D-S18: the exam of the Coordinator's local model, said in Home as it starts and as it ends.
|
|
5816
|
+
function renderExam(event) {
|
|
5817
|
+
const payload = event.payload ?? {};
|
|
5818
|
+
const node = el('div', `system memory enlace${event.type === 'bishop.exam.failed' || (event.type === 'bishop.exam.finished' && !payload.passed) ? ' warn' : ''}`);
|
|
5819
|
+
node.append(el('b', null, t('EXAM')));
|
|
5820
|
+
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) }));
|
|
5821
|
+
else if (event.type === 'bishop.exam.failed') node.append(t('The exam of {model} could not finish: {why}', { model: payload.model ?? '', why: payload.error ?? '' }));
|
|
5822
|
+
else {
|
|
5823
|
+
const part = (key) => `${payload.scores?.[key]?.passed ?? 0}/${payload.scores?.[key]?.total ?? 0}`;
|
|
5824
|
+
node.append(payload.passed
|
|
5825
|
+
? 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') })
|
|
5826
|
+
: 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') }));
|
|
5827
|
+
}
|
|
5828
|
+
return node;
|
|
5829
|
+
}
|
|
5830
|
+
|
|
4838
5831
|
function renderControlStarted(event) {
|
|
4839
5832
|
const { agent, commit, message } = event.payload;
|
|
4840
5833
|
const node = el('div', 'system control');
|
|
@@ -5122,7 +6115,7 @@ async function renderOpenQuestions() {
|
|
|
5122
6115
|
try { localStorage.setItem('pulse.openqDismissed', signature); } catch { /* closed for this page only */ }
|
|
5123
6116
|
box.hidden = true;
|
|
5124
6117
|
});
|
|
5125
|
-
head.append(
|
|
6118
|
+
head.append(leadWords('span', 'openq-lead', t('◉ still open here')), close);
|
|
5126
6119
|
box.append(head);
|
|
5127
6120
|
// The pills live in a track of their own so the row can scroll sideways instead of stacking:
|
|
5128
6121
|
// these are an aside under the thread, and an aside that grows downwards pushes the composer.
|
|
@@ -5180,7 +6173,7 @@ async function renderSettled() {
|
|
|
5180
6173
|
catch { return; }
|
|
5181
6174
|
if (!decisions.length) { box.hidden = true; return; }
|
|
5182
6175
|
box.replaceChildren();
|
|
5183
|
-
box.append(
|
|
6176
|
+
box.append(leadWords('span', 'settled-lead', t('◉ decided here · {n}', { n: decisions.length })));
|
|
5184
6177
|
for (const note of decisions) {
|
|
5185
6178
|
const pill = el('button', 'settled-note');
|
|
5186
6179
|
pill.type = 'button';
|
|
@@ -5391,15 +6384,15 @@ function markRun({ responseMessageId, index }, status, where = null, startedAt =
|
|
|
5391
6384
|
updateRunClock(button);
|
|
5392
6385
|
}
|
|
5393
6386
|
if (status === 'serving' && where?.port) {
|
|
6387
|
+
// A server open to the whole network opens here too, on 127.0.0.1 (2026-10-18); the line says
|
|
6388
|
+
// that the network can reach it as well.
|
|
5394
6389
|
live.append(el('span', 'where', where.network
|
|
5395
|
-
? t('serving on :{port} ·
|
|
6390
|
+
? t('serving on :{port} · your network can reach it too', { port: where.port })
|
|
5396
6391
|
: t('serving on :{port}', { port: where.port })));
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
|
|
5401
|
-
live.append(open);
|
|
5402
|
-
}
|
|
6392
|
+
const open = el('button', 'run-open', t('OPEN IN YOUR SERVERS'));
|
|
6393
|
+
open.type = 'button';
|
|
6394
|
+
open.addEventListener('click', () => { browserShow(); void browserOpen(`http://127.0.0.1:${where.port}/`); });
|
|
6395
|
+
live.append(open);
|
|
5403
6396
|
}
|
|
5404
6397
|
const stop = el('button', 'run-stop', t('STOP'));
|
|
5405
6398
|
stop.type = 'button';
|
|
@@ -6190,6 +7183,17 @@ function renderEventNode(event) {
|
|
|
6190
7183
|
case 'mode.requested': node = renderModeRequest(event); break;
|
|
6191
7184
|
case 'control.started': node = renderControlStarted(event); break;
|
|
6192
7185
|
case 'create.reverted': node = renderCreateReverted(event); break;
|
|
7186
|
+
case 'enlace.corpus':
|
|
7187
|
+
case 'enlace.learned': node = renderLearned(event); break;
|
|
7188
|
+
case 'enlace.delivered': if (!replaying) void refreshDeliveries(); node = renderDelivered(event); break;
|
|
7189
|
+
case 'enlace.accepted': markDelivery(event.payload, 'accepted'); if (!replaying) void refreshDeliveries(); return;
|
|
7190
|
+
case 'enlace.declined': markDelivery(event.payload, 'declined'); if (!replaying) void refreshDeliveries(); return;
|
|
7191
|
+
case 'enlace.state':
|
|
7192
|
+
case 'enlace.state.received':
|
|
7193
|
+
case 'enlace.sent': node = renderCrossing(event); break;
|
|
7194
|
+
case 'bishop.exam.started':
|
|
7195
|
+
case 'bishop.exam.finished':
|
|
7196
|
+
case 'bishop.exam.failed': node = renderExam(event); break;
|
|
6193
7197
|
case 'enlace.signed':
|
|
6194
7198
|
case 'enlace.revoked': if (event.type === 'enlace.revoked' && markRevokedLinks(event.payload.contract?.id)) return; node = renderEnlace(event); break;
|
|
6195
7199
|
case 'bishop.plan.proposed': node = renderBishopPlan(event); break;
|
|
@@ -6233,7 +7237,7 @@ function renderEventNode(event) {
|
|
|
6233
7237
|
// says something about itself: in a new project @madre's arrival used to take it away before
|
|
6234
7238
|
// anyone had written a word.
|
|
6235
7239
|
if (event.type.startsWith('message.') || event.type === 'command.output' || event.type === 'run.requested') removeEmpty();
|
|
6236
|
-
const stickToBottom = !drawingRecord &&
|
|
7240
|
+
const stickToBottom = !drawingRecord && pinnedToEnd;
|
|
6237
7241
|
els.column.append(tidySystemLine(node));
|
|
6238
7242
|
scheduleFold(node);
|
|
6239
7243
|
if (!drawingRecord) followTurn(event, node, stickToBottom);
|
|
@@ -6338,12 +7342,12 @@ function openStrip(strip, open = !strip.classList.contains('open')) {
|
|
|
6338
7342
|
// Opening a row is reading it: the thread stops following the end, or the taller row would be
|
|
6339
7343
|
// scrolled away from under the click.
|
|
6340
7344
|
const last = !strip.nextElementSibling;
|
|
6341
|
-
if (open && !last)
|
|
7345
|
+
if (open && !last) setPinned(false);
|
|
6342
7346
|
strip.classList.toggle('open', open);
|
|
6343
7347
|
for (const node of strip.querySelectorAll(':scope > .system.folded')) node.classList.remove('quiet');
|
|
6344
7348
|
groupRepeats(strip);
|
|
6345
7349
|
countStrip(strip);
|
|
6346
|
-
if (open) requestAnimationFrame(() => strip.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' }));
|
|
7350
|
+
if (open) requestAnimationFrame(() => { ourScroll(900); strip.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' }); });
|
|
6347
7351
|
}
|
|
6348
7352
|
els.column.addEventListener('click', (event) => {
|
|
6349
7353
|
const strip = event.target.closest?.('.sys-dots');
|
|
@@ -6372,18 +7376,51 @@ function drawRecord(events) {
|
|
|
6372
7376
|
drawingRecord = true;
|
|
6373
7377
|
try { for (const event of events) renderEvent(event); } finally { drawingRecord = false; }
|
|
6374
7378
|
}
|
|
7379
|
+
// What is off screen is not drawn (content-visibility), and until a row has been drawn once its
|
|
7380
|
+
// height is a guess (contain-intrinsic-size: 140px). A notice folded to dots is 13 px, an answer
|
|
7381
|
+
// can be 2,000: a jump into history that had never been on screen — the scrollbar dragged up, Home,
|
|
7382
|
+
// a search — landed hundreds of pixels off and the conversation jumped under the reader as the rows
|
|
7383
|
+
// near it found their real heights. Once the record is drawn, every row is laid out one time, a
|
|
7384
|
+
// batch while the page is idle, so the browser remembers its real height (contain-intrinsic-size:
|
|
7385
|
+
// auto) and goes back to skipping it. One pass per conversation opened; nothing stays drawn.
|
|
7386
|
+
let measuring = 0;
|
|
7387
|
+
function measureRows() {
|
|
7388
|
+
const run = ++measuring;
|
|
7389
|
+
const rows = [...(els.column?.children ?? [])];
|
|
7390
|
+
const idle = globalThis.requestIdleCallback ? (fn) => requestIdleCallback(fn, { timeout: 400 }) : (fn) => setTimeout(fn, 16);
|
|
7391
|
+
const frame = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (fn) => setTimeout(fn, 16);
|
|
7392
|
+
let next = rows.length;
|
|
7393
|
+
// From the end up: the rows nearest to where the reader is are measured first.
|
|
7394
|
+
const step = () => {
|
|
7395
|
+
if (run !== measuring || next <= 0) return;
|
|
7396
|
+
const batch = rows.slice(Math.max(0, next - 30), next).filter((row) => row.isConnected !== false);
|
|
7397
|
+
next -= 30;
|
|
7398
|
+
for (const row of batch) row.classList?.add('cv-measure');
|
|
7399
|
+
frame(() => frame(() => {
|
|
7400
|
+
for (const row of batch) row.classList?.remove('cv-measure');
|
|
7401
|
+
idle(step);
|
|
7402
|
+
}));
|
|
7403
|
+
};
|
|
7404
|
+
idle(step);
|
|
7405
|
+
}
|
|
6375
7406
|
function followTurn(event, node, stickToBottom) {
|
|
6376
7407
|
if (replaying) { if (stickToBottom) scrollToEnd(); return; }
|
|
6377
|
-
const
|
|
7408
|
+
const mine = event.type === 'message.created' && event.payload.role === 'user';
|
|
6378
7409
|
const isAnswer = event.type === 'message.created' && event.payload.role !== 'user';
|
|
7410
|
+
// What the human just sent takes them to the end, wherever they were reading.
|
|
7411
|
+
if (mine) { setPinned(true); scroller.to({ top: scroller.height, behavior: 'smooth' }); return; }
|
|
7412
|
+
// Gone up to read: nothing below moves them. An answer or a finished plan says so on a button.
|
|
7413
|
+
if (!pinnedToEnd) {
|
|
7414
|
+
if (isAnswer) markFinishedBelow(event.payload.sender);
|
|
7415
|
+
else if (event.type === 'plan.completed') markFinishedBelow(event.payload.orchestrator ?? null, true);
|
|
7416
|
+
return;
|
|
7417
|
+
}
|
|
6379
7418
|
if (isAnswer) {
|
|
7419
|
+
// A long answer is read from its top: the thread stops following and opens at its start.
|
|
6380
7420
|
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;
|
|
7421
|
+
if (tall) { setPinned(false); ourScroll(900); node.scrollIntoView?.({ behavior: 'smooth', block: 'start' }); return; }
|
|
6384
7422
|
}
|
|
6385
|
-
|
|
6386
|
-
if (stickToBottom) scrollToEnd();
|
|
7423
|
+
scrollToEnd();
|
|
6387
7424
|
}
|
|
6388
7425
|
|
|
6389
7426
|
/* ---------- theme: auto (system) → light → dark ---------- */
|
|
@@ -6442,7 +7479,8 @@ const LOOKS = {
|
|
|
6442
7479
|
accent: { key: 'pulse.accent', values: ['madre', 'mountain', 'caribbean', 'phantom', 'malbec', 'dijon', 'pacific', 'love', 'sierra', 'pitahaya', 'papaya', 'agave', 'dinamo', 'marina', 'pulso', 'leal', 'dulce'] },
|
|
6443
7480
|
bubbles: { key: 'pulse.bubbles', values: ['comfortable', 'large', 'compact', 'fitted'] },
|
|
6444
7481
|
text: { key: 'pulse.text', values: ['comfortable', 'larger', 'smaller', 'visual'] },
|
|
6445
|
-
|
|
7482
|
+
// Last, the person's own picture (public/wallpaper.js): a choice only once there is one.
|
|
7483
|
+
wallpaper: { key: 'pulse.wallpaper', values: ['default', 'jarrito', 'canela', 'paradise', 'ventura', 'magna', 'biznaga', 'oceano', 'quila', 'xico', 'tropical', 'tradicion', 'urbania', CUSTOM_WALL] },
|
|
6446
7484
|
// How a photograph wears the room's ground: a flat layer, or a mesh of its own colours.
|
|
6447
7485
|
wpStyle: { key: 'pulse.wallpaper.style', values: ['veil', 'mesh'] },
|
|
6448
7486
|
};
|
|
@@ -6452,7 +7490,7 @@ const TUNES = {
|
|
|
6452
7490
|
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
7491
|
blur: { key: 'pulse.wallpaper.blur', min: 0, max: 24, step: 1, value: 0, apply: (node, v) => node.style.setProperty('--wp-blur', `${v}px`) },
|
|
6454
7492
|
};
|
|
6455
|
-
const PHOTO_WALLPAPERS = new Set(['jarrito', 'canela', 'paradise', 'ventura', 'magna', 'biznaga', 'oceano', 'quila', 'xico', 'tropical', 'tradicion', 'urbania']);
|
|
7493
|
+
const PHOTO_WALLPAPERS = new Set(['jarrito', 'canela', 'paradise', 'ventura', 'magna', 'biznaga', 'oceano', 'quila', 'xico', 'tropical', 'tradicion', 'urbania', CUSTOM_WALL]);
|
|
6456
7494
|
function readTune(name) {
|
|
6457
7495
|
const tune = TUNES[name];
|
|
6458
7496
|
let raw = null;
|
|
@@ -6475,7 +7513,7 @@ const LOOK_WORDS = {
|
|
|
6475
7513
|
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
7514
|
bubbles: { comfortable: () => t('Comfortable'), large: () => t('Large'), compact: () => t('Compact'), fitted: () => t('Fitted') },
|
|
6477
7515
|
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' },
|
|
7516
|
+
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
7517
|
wpStyle: { veil: () => t('Layer'), mesh: () => t('Mesh') },
|
|
6480
7518
|
};
|
|
6481
7519
|
// Jarrito took MADRE's place among the wallpapers and Canela took Autumn's: a choice saved under
|
|
@@ -6485,6 +7523,8 @@ function readLook(name) {
|
|
|
6485
7523
|
let value = null;
|
|
6486
7524
|
try { value = localStorage.getItem(LOOKS[name].key); } catch { /* no storage */ }
|
|
6487
7525
|
if (name === 'wallpaper' && RENAMED_LOOKS[value]) value = RENAMED_LOOKS[value];
|
|
7526
|
+
// Chosen, and then taken away (here or on another page): the room as it always was.
|
|
7527
|
+
if (name === 'wallpaper' && value === CUSTOM_WALL && !customWall()) value = null;
|
|
6488
7528
|
return LOOKS[name].values.includes(value) ? value : LOOKS[name].values[0];
|
|
6489
7529
|
}
|
|
6490
7530
|
function applyLook(name, value) {
|
|
@@ -6492,6 +7532,8 @@ function applyLook(name, value) {
|
|
|
6492
7532
|
if (!rootElement) return;
|
|
6493
7533
|
if (value === LOOKS[name].values[0]) delete rootElement.dataset[name]; else rootElement.dataset[name] = value;
|
|
6494
7534
|
}
|
|
7535
|
+
// The person's picture as this browser last knew it, before the first look is painted.
|
|
7536
|
+
paintCustomWall(rememberedWall());
|
|
6495
7537
|
for (const name of Object.keys(LOOKS)) applyLook(name, readLook(name));
|
|
6496
7538
|
for (const name of Object.keys(TUNES)) applyTune(name, readTune(name));
|
|
6497
7539
|
// The half on the right and the conversations in windows read the same choices, the moment they
|
|
@@ -6509,7 +7551,7 @@ window.addEventListener?.('storage', (event) => {
|
|
|
6509
7551
|
// phone's bridge.
|
|
6510
7552
|
// The wallpaper is each page's own (2026-10-13): this project keeps the one chosen here, Home the
|
|
6511
7553
|
// 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'];
|
|
7554
|
+
const SHARED_LOOKS = ['pulse.theme', 'pulse.accent', 'pulse.wallpaper.style', 'pulse.wallpaper.veil', 'pulse.wallpaper.blur', 'pulse.bubbles', 'pulse.text', 'pulse.neural'];
|
|
6513
7555
|
let booted = false;
|
|
6514
7556
|
let looksPush = 0;
|
|
6515
7557
|
// Saved for this project alone, on the server, so it outlives the port the page is served on.
|
|
@@ -6529,8 +7571,12 @@ async function pullLooks() {
|
|
|
6529
7571
|
let answer = null;
|
|
6530
7572
|
try { answer = await fetch('/api/looks', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)); } catch { return; }
|
|
6531
7573
|
if (!answer?.looks) return;
|
|
7574
|
+
// The person's own picture, kept once for every page (public/wallpaper.js): painted as it is now.
|
|
7575
|
+
const before = customWall()?.v ?? null;
|
|
7576
|
+
paintCustomWall(answer.custom ?? null);
|
|
6532
7577
|
// Before a project had its own, it wore the shared one: that is where it starts.
|
|
6533
|
-
|
|
7578
|
+
let wall = answer.own?.wallpaper ?? answer.looks['pulse.wallpaper'] ?? null;
|
|
7579
|
+
if (wall === CUSTOM_WALL && !answer.custom) wall = null;
|
|
6534
7580
|
try {
|
|
6535
7581
|
// Nothing kept yet: the first page that has a choice of its own hands it to the others.
|
|
6536
7582
|
if (!answer.saved) { if (SHARED_LOOKS.some((key) => localStorage.getItem(key) !== null)) pushLooks(); return; }
|
|
@@ -6541,7 +7587,7 @@ async function pullLooks() {
|
|
|
6541
7587
|
if (value === null) localStorage.removeItem(key); else localStorage.setItem(key, value);
|
|
6542
7588
|
changed = true;
|
|
6543
7589
|
}
|
|
6544
|
-
if (changed) reapplyLooks();
|
|
7590
|
+
if (changed || before !== (answer.custom?.v ?? null)) reapplyLooks();
|
|
6545
7591
|
} catch { /* no storage: this page keeps the look it has */ }
|
|
6546
7592
|
}
|
|
6547
7593
|
function reapplyLooks() {
|
|
@@ -6551,6 +7597,8 @@ function reapplyLooks() {
|
|
|
6551
7597
|
applyTheme(themeMode);
|
|
6552
7598
|
for (const name of Object.keys(LOOKS)) applyLook(name, readLook(name));
|
|
6553
7599
|
for (const name of Object.keys(TUNES)) applyTune(name, readTune(name));
|
|
7600
|
+
// The maps' neural mode, chosen on another page (public/neural.js).
|
|
7601
|
+
if (booted) syncNostromoNeural();
|
|
6554
7602
|
if (booted) { renderAgents(); renderPicker(); syncSettingsCards(); }
|
|
6555
7603
|
}
|
|
6556
7604
|
void pullLooks();
|
|
@@ -6687,6 +7735,16 @@ function drawCustom() {
|
|
|
6687
7735
|
const papers = el('div', 'custom-papers');
|
|
6688
7736
|
papers.setAttribute('role', 'radiogroup');
|
|
6689
7737
|
for (const value of LOOKS.wallpaper.values) {
|
|
7738
|
+
// The person's own picture: chosen like any other once it is here; until then, the way to add it.
|
|
7739
|
+
if (value === CUSTOM_WALL) {
|
|
7740
|
+
const mine = customWall();
|
|
7741
|
+
const paper = mine ? radio(el('button', 'paper'), draft.wallpaper === CUSTOM_WALL, () => setDraft('wallpaper', CUSTOM_WALL)) : el('button', 'paper');
|
|
7742
|
+
paper.type = 'button';
|
|
7743
|
+
customTile(paper, mine);
|
|
7744
|
+
if (!mine) paper.addEventListener('click', () => void addPicture({ busy: paper, chosen: () => setDraft('wallpaper', CUSTOM_WALL), failed: (message) => toast(message, { tone: 'note' }) }));
|
|
7745
|
+
papers.append(paper);
|
|
7746
|
+
continue;
|
|
7747
|
+
}
|
|
6690
7748
|
const paper = radio(el('button', 'paper'), value === draft.wallpaper, () => setDraft('wallpaper', value));
|
|
6691
7749
|
paper.dataset.wp = value;
|
|
6692
7750
|
paper.append(el('span', 'paper-swatch'), el('span', 'paper-name', LOOK_WORDS.wallpaper[value]()));
|
|
@@ -6721,6 +7779,15 @@ function drawCustom() {
|
|
|
6721
7779
|
return row;
|
|
6722
7780
|
};
|
|
6723
7781
|
paperTune.append(dial('veil', t('Intensity'), (v) => `${v}%`), dial('blur', t('Blur'), (v) => `${v}px`));
|
|
7782
|
+
// Your picture: where the screen falls on it, and changing it or taking it away.
|
|
7783
|
+
if (draft.wallpaper === CUSTOM_WALL && customWall()) {
|
|
7784
|
+
paperTune.append(customTools(customWall(), {
|
|
7785
|
+
button: (words, click) => sxButton(words, click),
|
|
7786
|
+
changed: (kept, { reframed }) => { if (!reframed) drawCustom(); },
|
|
7787
|
+
removed: () => setDraft('wallpaper', LOOKS.wallpaper.values[0]),
|
|
7788
|
+
failed: (message) => toast(message, { tone: 'note' }),
|
|
7789
|
+
}));
|
|
7790
|
+
}
|
|
6724
7791
|
}
|
|
6725
7792
|
const themes = el('div', 'custom-seg three');
|
|
6726
7793
|
themes.setAttribute('role', 'radiogroup');
|
|
@@ -6879,7 +7946,7 @@ customWindow.node?.addEventListener('close', () => { customButton?.setAttribute(
|
|
|
6879
7946
|
// from the same code and the same answers (state.settingsTargets), wearing the room's ink.
|
|
6880
7947
|
const SETTINGS_SECTIONS = [
|
|
6881
7948
|
['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']],
|
|
7949
|
+
['The agents', 'Who works, what they remember and which outside tools they use', ['agents', 'memory', 'local', 'mcp']],
|
|
6883
7950
|
['System', 'What agents may do, privacy and updates', ['permissions', 'privacy', 'release']],
|
|
6884
7951
|
];
|
|
6885
7952
|
const SETTINGS_CATS = {
|
|
@@ -6887,13 +7954,16 @@ const SETTINGS_CATS = {
|
|
|
6887
7954
|
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
7955
|
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
7956
|
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"/>' },
|
|
7957
|
+
// Local AI, also here and not only in Modules (2026-10-16): the same card, and what this
|
|
7958
|
+
// computer can run and train with it.
|
|
7959
|
+
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
7960
|
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
7961
|
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
7962
|
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
7963
|
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
7964
|
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
7965
|
};
|
|
6896
|
-
const SETTINGS_ORDER = ['look', 'room', 'files', 'agents', 'memory', 'mcp', 'permissions', 'privacy', 'release'];
|
|
7966
|
+
const SETTINGS_ORDER = ['look', 'room', 'files', 'agents', 'memory', 'local', 'mcp', 'permissions', 'privacy', 'release'];
|
|
6897
7967
|
// The words the search knows, each pointing to the category that holds it and to the words on the
|
|
6898
7968
|
// screen that carry it there, so a result opens the category and lands on the setting.
|
|
6899
7969
|
const SETTINGS_INDEX = [
|
|
@@ -6933,6 +8003,12 @@ function settingsStatus(id) {
|
|
|
6933
8003
|
return { line: `${finder} · ${ripley ? (ripley.status?.installed ? t('preview on') : t('preview off')) : t('Reading…')}` };
|
|
6934
8004
|
}
|
|
6935
8005
|
if (id === 'mcp') return mcpStatus();
|
|
8006
|
+
if (id === 'local') {
|
|
8007
|
+
const item = modules.items.find((one) => one.id === 'ollama');
|
|
8008
|
+
if (!item) return { line: t('Reading…') };
|
|
8009
|
+
const daily = item.recommended?.hardware?.recommended?.run;
|
|
8010
|
+
return { line: [item.status?.detail, daily ? t('your computer runs {model}', { model: daily.label }) : null].filter(Boolean).join(' · ') };
|
|
8011
|
+
}
|
|
6936
8012
|
if (!data) return { line: t('Reading…') };
|
|
6937
8013
|
if (id === 'agents') {
|
|
6938
8014
|
const here = data.agents.filter((agent) => agent.detected);
|
|
@@ -7290,6 +8366,7 @@ function settingsDetail(id) {
|
|
|
7290
8366
|
if (id === 'release') { part('release'); part('sentinel'); }
|
|
7291
8367
|
if (id === 'files') part('files');
|
|
7292
8368
|
if (id === 'mcp') part('mcp');
|
|
8369
|
+
if (id === 'local') part('local');
|
|
7293
8370
|
return detail;
|
|
7294
8371
|
}
|
|
7295
8372
|
|
|
@@ -7297,6 +8374,7 @@ function settingsDetail(id) {
|
|
|
7297
8374
|
function fillSettingsCategory(id) {
|
|
7298
8375
|
if (id === 'files') { drawFilesTarget(); void refreshModules(); }
|
|
7299
8376
|
if (id === 'mcp') { drawMcpTarget(); void loadMcp(); }
|
|
8377
|
+
if (id === 'local') { drawLocalTarget(); void refreshModules(); }
|
|
7300
8378
|
if (['agents', 'room', 'memory', 'privacy', 'permissions'].includes(id)) { if (settingsUI.data) renderSettings(); void loadSettings(); }
|
|
7301
8379
|
if (id === 'room' || id === 'release') { if (turnsUI.turns || sentinelUI.settings) renderMotherSentinel(); void loadTurns(); void loadSentinel(); }
|
|
7302
8380
|
if (id === 'release') renderUpdate();
|
|
@@ -7638,6 +8716,8 @@ for (const button of document.querySelectorAll?.('button') ?? []) {
|
|
|
7638
8716
|
if (!words.trim()) button.classList.add('x-only');
|
|
7639
8717
|
button.append(xIcon());
|
|
7640
8718
|
}
|
|
8719
|
+
// And its ↗ and ▾, drawn (tailMark).
|
|
8720
|
+
for (const node of document.querySelectorAll?.('a, button') ?? []) tailMark(node);
|
|
7641
8721
|
|
|
7642
8722
|
/* ---------- project files panel ---------- */
|
|
7643
8723
|
|
|
@@ -7902,7 +8982,8 @@ function treeNode(path, entry) {
|
|
|
7902
8982
|
button.title = path;
|
|
7903
8983
|
button.addEventListener('contextmenu', (event) => finderMenu(event, path, entry));
|
|
7904
8984
|
if (entry.kind === 'dir') {
|
|
7905
|
-
const caret = el('span', 'caret'
|
|
8985
|
+
const caret = el('span', 'caret');
|
|
8986
|
+
caret.innerHTML = ICON_CHEVRON; // trusted: our own SVG string · turned by styles.css
|
|
7906
8987
|
button.append(caret, el('span', 'name', entry.name));
|
|
7907
8988
|
// Something under this folder changed: the dot of whoever touched it last, the way down.
|
|
7908
8989
|
const under = touchedUnder(path);
|
|
@@ -7916,7 +8997,6 @@ function treeNode(path, entry) {
|
|
|
7916
8997
|
let opened = false;
|
|
7917
8998
|
const show = (open) => {
|
|
7918
8999
|
opened = open;
|
|
7919
|
-
caret.textContent = opened ? '▾' : '▸';
|
|
7920
9000
|
children.hidden = !opened;
|
|
7921
9001
|
item.setAttribute('aria-expanded', String(opened));
|
|
7922
9002
|
if (opened) tree.openDirs.add(path); else tree.openDirs.delete(path);
|
|
@@ -7952,8 +9032,6 @@ function treeNode(path, entry) {
|
|
|
7952
9032
|
// The two buttons every surface carries in its header, beside its ×: split, and window. Declared
|
|
7953
9033
|
// before the surfaces are built, which happens while this file is still being read.
|
|
7954
9034
|
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
9035
|
|
|
7958
9036
|
/* ---------- surfaces: everything that can stand beside the conversation ---------- */
|
|
7959
9037
|
|
|
@@ -8032,7 +9110,8 @@ function sideWidthFor(surface) {
|
|
|
8032
9110
|
const vw = window.innerWidth;
|
|
8033
9111
|
if (surface.key === 'chats') return Math.max(SIDE_MIN, Math.min(420, vw * .24));
|
|
8034
9112
|
if (surface.key === 'tree') return Math.max(SIDE_MIN, Math.min(460, vw * .26));
|
|
8035
|
-
|
|
9113
|
+
// The viewer docked is the code workspace: the files, the code and what it makes need room.
|
|
9114
|
+
return Math.max(420, Math.min(980, vw * .5));
|
|
8036
9115
|
}
|
|
8037
9116
|
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
9117
|
|
|
@@ -8801,7 +9880,12 @@ function windowBoxFor(surface) {
|
|
|
8801
9880
|
const vw = window.innerWidth;
|
|
8802
9881
|
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
9882
|
const left = surface.side === 'left' ? room.left + RAIL : room.right - RAIL - width;
|
|
8804
|
-
|
|
9883
|
+
// A room not laid out yet (a page still loading its conversation) is measured short and low; the
|
|
9884
|
+
// window then opens under the bar, as tall as the screen allows, instead of at the room's foot.
|
|
9885
|
+
const settled = room.height >= 360 && room.top < window.innerHeight / 3;
|
|
9886
|
+
const y = settled ? room.top + 42 : 96;
|
|
9887
|
+
const h = settled ? Math.min(room.height - 28, 620) : Math.max(WINDOW_MIN_H, Math.min(620, window.innerHeight - y - 120));
|
|
9888
|
+
return { x: left + (surface.side === 'left' ? 36 : -36), y, w: width, h };
|
|
8805
9889
|
}
|
|
8806
9890
|
|
|
8807
9891
|
// First, last, invert, play: the surface is moved to where it now belongs at once, then drawn from
|
|
@@ -8908,7 +9992,9 @@ function setSurfaceMode(surface, next, { animate = true } = {}) {
|
|
|
8908
9992
|
if (next !== 'window') dropWindow(node);
|
|
8909
9993
|
for (const property of ['left', 'top', 'width', 'height']) node.style.removeProperty(property);
|
|
8910
9994
|
if (surface.kind === 'dialog') reseatDialog(node, next === 'base');
|
|
8911
|
-
|
|
9995
|
+
// Its box is kept the moment it becomes a window, not only when it is first carried: the mode
|
|
9996
|
+
// was remembered and the place was not, so a reload brought it back with nowhere to be.
|
|
9997
|
+
if (next === 'window') { placeWindow(surface, windowBoxFor(surface)); bringWindowForward(node); rememberSurfaces(); }
|
|
8912
9998
|
layoutPanels();
|
|
8913
9999
|
};
|
|
8914
10000
|
if (animate && panelShown(surface)) flipPanel(node, change); else change();
|
|
@@ -9000,7 +10086,14 @@ if (shellBox && !SPLIT_CHAT) {
|
|
|
9000
10086
|
node.classList.toggle('window', isWindow(surface));
|
|
9001
10087
|
node.classList.toggle('split', isSplit(surface));
|
|
9002
10088
|
}
|
|
9003
|
-
|
|
10089
|
+
// A window remembered without its box (the mode was kept, the place was not) comes back where a
|
|
10090
|
+
// new window opens. It used to come back at the corner, with nothing to carry it by: «New
|
|
10091
|
+
// conversation» reloads the page, and the conversations window was stuck there (2026-10-18).
|
|
10092
|
+
if (isWindow(surface)) {
|
|
10093
|
+
if (windowBoxes[surface.key]?.w) placeWindow(surface, windowBoxes[surface.key]);
|
|
10094
|
+
// Measured once the room has its shape: on the first frames the thread is not laid out yet.
|
|
10095
|
+
else requestAnimationFrame(() => requestAnimationFrame(() => { placeWindow(surface, windowBoxFor(surface)); rememberSurfaces(); }));
|
|
10096
|
+
}
|
|
9004
10097
|
carrySurface(surface);
|
|
9005
10098
|
surfaceButtons(surface);
|
|
9006
10099
|
sideGrip(surface);
|
|
@@ -9759,7 +10852,11 @@ function carryWindowBy(head, node, { active = () => true, box, place, done }) {
|
|
|
9759
10852
|
event.preventDefault();
|
|
9760
10853
|
const startX = event.clientX;
|
|
9761
10854
|
const startY = event.clientY;
|
|
9762
|
-
|
|
10855
|
+
// Where it is on screen, whenever what was kept of it is not a whole box: a window is always
|
|
10856
|
+
// carried from where the person sees it.
|
|
10857
|
+
const kept = box() ?? {};
|
|
10858
|
+
const seen = node.getBoundingClientRect();
|
|
10859
|
+
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
10860
|
let carrying = false;
|
|
9764
10861
|
let frame = 0;
|
|
9765
10862
|
let to = null;
|
|
@@ -9955,10 +11052,10 @@ function measureDock() {
|
|
|
9955
11052
|
const field = els.composer.querySelector?.('.field')?.getBoundingClientRect?.();
|
|
9956
11053
|
if (field && !pageScrolls()) shell.style.setProperty('--type-gap', `${Math.max(0, Math.ceil(shell.getBoundingClientRect().bottom - field.top))}px`);
|
|
9957
11054
|
if (height === measureDock.last) return;
|
|
9958
|
-
const stay = atBottom();
|
|
9959
11055
|
measureDock.last = height;
|
|
9960
11056
|
shell.style.setProperty('--dock-h', `${height}px`);
|
|
9961
|
-
|
|
11057
|
+
placeToast();
|
|
11058
|
+
keepPinned();
|
|
9962
11059
|
}
|
|
9963
11060
|
if (document.querySelector('.shell') && els.composer?.getBoundingClientRect) {
|
|
9964
11061
|
measureDock();
|
|
@@ -10201,7 +11298,7 @@ if (SPLIT_CHAT && !Array.isArray(initial.events)) {
|
|
|
10201
11298
|
}
|
|
10202
11299
|
// The corner of the room: which MADRE this is, and whose it is, readable on any wallpaper, and a
|
|
10203
11300
|
// link to its author (public/attribution.js: NOTICE's attribution, the same on Home).
|
|
10204
|
-
if (initial.version) drawAttribution(document.querySelector('#colophon'), initial.version);
|
|
11301
|
+
if (initial.version) { drawAttribution(document.querySelector('#colophon'), initial.version); void drawColophonKeys(document.querySelector('#colophon')); }
|
|
10205
11302
|
void adoptLanguage(initial.language, initial.languageChosen);
|
|
10206
11303
|
// Whether the copilot is on for this project, so the robot and the mode say so from the start.
|
|
10207
11304
|
void loadCopilot().then(() => showNewHints());
|
|
@@ -10323,6 +11420,7 @@ function boot() {
|
|
|
10323
11420
|
syncAshUI(Boolean(initial.ash?.enabled));
|
|
10324
11421
|
void renderSettled();
|
|
10325
11422
|
void renderOpenQuestions();
|
|
11423
|
+
void refreshDeliveries();
|
|
10326
11424
|
updateStopAll();
|
|
10327
11425
|
if (typeof setInterval === 'function') { const ticker = setInterval(refreshBudgetWindow, 60000); ticker.unref?.(); }
|
|
10328
11426
|
renderAgents();
|
|
@@ -10340,6 +11438,7 @@ function boot() {
|
|
|
10340
11438
|
try { performance.measure?.('madre:history', 'madre:history-start', 'madre:history-end'); } catch { /* no timeline */ }
|
|
10341
11439
|
setLoader(arrival, 0.9);
|
|
10342
11440
|
scrollToEnd();
|
|
11441
|
+
measureRows();
|
|
10343
11442
|
matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { applyTheme(themeMode); renderAgents(); renderPicker(); });
|
|
10344
11443
|
|
|
10345
11444
|
// The live light sits on the conversation it is live for, at the end of the path in the bar.
|
|
@@ -10449,7 +11548,7 @@ function forgetConversation() {
|
|
|
10449
11548
|
state.userMessages.clear();
|
|
10450
11549
|
state.runsReturned.clear();
|
|
10451
11550
|
state.ratings.clear();
|
|
10452
|
-
state.ratingNodes.clear();
|
|
11551
|
+
state.ratingNodes.clear(); state.deliveries.clear();
|
|
10453
11552
|
state.running.clear();
|
|
10454
11553
|
state.plansRunning.clear();
|
|
10455
11554
|
state.agentStats.clear();
|
|
@@ -10490,6 +11589,7 @@ async function paintConversation(payload) {
|
|
|
10490
11589
|
renderOnboarding();
|
|
10491
11590
|
void renderOpenQuestions();
|
|
10492
11591
|
scrollToEnd();
|
|
11592
|
+
measureRows();
|
|
10493
11593
|
connectStream();
|
|
10494
11594
|
}
|
|
10495
11595
|
|
|
@@ -10508,7 +11608,12 @@ function autosize() {
|
|
|
10508
11608
|
els.highlight.style.height = els.input.style.height;
|
|
10509
11609
|
renderHighlight();
|
|
10510
11610
|
}
|
|
10511
|
-
function syncHighlightScroll() {
|
|
11611
|
+
function syncHighlightScroll() {
|
|
11612
|
+
els.highlight.scrollTop = els.input.scrollTop;
|
|
11613
|
+
// Scrolled inside, the first line it shows fades in under the box's edge instead of being cut
|
|
11614
|
+
// in half against it (styles.css, .editor.scrolled).
|
|
11615
|
+
els.input.parentElement?.classList?.toggle('scrolled', els.input.scrollTop > 1);
|
|
11616
|
+
}
|
|
10512
11617
|
els.input.addEventListener('input', autosize);
|
|
10513
11618
|
els.input.addEventListener('scroll', syncHighlightScroll);
|
|
10514
11619
|
autosize();
|
|
@@ -11243,12 +12348,15 @@ function updateCrewLabel() {
|
|
|
11243
12348
|
// The copilot reads as a link of the chain: HUMAN › COPILOT › TOKEN SAVER › @CODEX ›.
|
|
11244
12349
|
const piloting = copilotSends(els.target?.value);
|
|
11245
12350
|
const armed = state.mode >= 3 && !piloting;
|
|
11246
|
-
|
|
12351
|
+
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
12352
|
: state.mode === 0 ? t('HUMAN · INCOGNITO ›')
|
|
11248
12353
|
: state.moduleCommand ? (state.create ? t('DIAGNOSTICS · MODULE · CREATE ›') : t('DIAGNOSTICS · MODULE · NEEDS #2 ›'))
|
|
11249
12354
|
: order ? `${t('HUMAN ›')} ${state.create ? t('TOKEN SAVER · CREATE ›') : t('TOKEN SAVER ›')}`
|
|
11250
12355
|
: state.create ? t('HUMAN · CREATE ›')
|
|
11251
12356
|
: state.expendable ? t('EXPENDABLE ›') : t('HUMAN ›');
|
|
12357
|
+
// The address is two pieces so a narrow box cuts the path with «…» and never who it goes to.
|
|
12358
|
+
if (typeof els.crewLabel.replaceChildren === 'function') els.crewLabel.replaceChildren(el('span', 'crew-path', path));
|
|
12359
|
+
else els.crewLabel.textContent = path;
|
|
11252
12360
|
// Who the message goes to closes the line, in that agent's colour: HUMAN › TOKEN SAVER › @CODEX ›.
|
|
11253
12361
|
const to = els.target?.value;
|
|
11254
12362
|
if (to && !armed && (piloting || !state.intruder) && typeof els.crewLabel.append === 'function') {
|
|
@@ -11448,6 +12556,14 @@ els.composer.addEventListener('submit', async (event) => {
|
|
|
11448
12556
|
// and the effort are the ones chosen for THAT agent; the chip's would be another agent's.
|
|
11449
12557
|
const mention = outgoing.match(/^@([a-z0-9_-]+)\b/i)?.[1]?.toLowerCase();
|
|
11450
12558
|
const addressed = mention && state.agents.has(mention) ? mention : target;
|
|
12559
|
+
// An agent that is switched off (no session, or nothing MADRE can run) is not written to: the
|
|
12560
|
+
// message stays in the box and Settings opens on that agent, where it is connected.
|
|
12561
|
+
if (addressed && state.agents.has(addressed) && !canAnswer(state.agents.get(addressed))) {
|
|
12562
|
+
const off = state.agents.get(addressed);
|
|
12563
|
+
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' });
|
|
12564
|
+
openSettingsOn('agents', { agent: addressed });
|
|
12565
|
+
return;
|
|
12566
|
+
}
|
|
11451
12567
|
// CONTROL and AIRLOCK are armed for one agent. A message that ends up addressed to another one
|
|
11452
12568
|
// is asked for again, for that one; the keys typed for @gemini never open the project to @claude.
|
|
11453
12569
|
const piloted = copilotSends(addressed);
|
|
@@ -11487,6 +12603,7 @@ els.composer.addEventListener('submit', async (event) => {
|
|
|
11487
12603
|
els.input.value = '';
|
|
11488
12604
|
state.replyTo = null;
|
|
11489
12605
|
renderReplyQuote();
|
|
12606
|
+
offerModule(outgoing);
|
|
11490
12607
|
state.pending = [];
|
|
11491
12608
|
renderPendingAttachments();
|
|
11492
12609
|
// The try went with the message; the next one goes at the rung chosen before it.
|
|
@@ -11649,7 +12766,7 @@ function roomRecords() {
|
|
|
11649
12766
|
// word, the ones sharing the most of them come first: «codex no responde timeout» still finds the
|
|
11650
12767
|
// timeout. An agent's name keeps to that agent and the general ones; little words do not count.
|
|
11651
12768
|
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']);
|
|
12769
|
+
const LOOSE_AGENTS = new Set(['codex', 'claude', 'gemini', 'opencode', 'cursor', 'madre', 'ollama']);
|
|
11653
12770
|
function looseConditions(text) {
|
|
11654
12771
|
const words = settingsFold(text).split(/[^a-z0-9#.@_-]+/).map((word) => word.replace(/^@/, '')).filter((word) => word.length >= 3 && !LOOSE_SKIP.has(word));
|
|
11655
12772
|
const agent = words.find((word) => LOOSE_AGENTS.has(word)) ?? null;
|
|
@@ -12077,6 +13194,7 @@ function renderTerminalCard(event) {
|
|
|
12077
13194
|
undo.textContent = t('RESTORED');
|
|
12078
13195
|
} catch (error) { toast(`${t('Diagnostics › ')}${error.message}`); undo.disabled = false; }
|
|
12079
13196
|
});
|
|
13197
|
+
node.classList.add('has-undo');
|
|
12080
13198
|
node.append(undo);
|
|
12081
13199
|
}
|
|
12082
13200
|
}
|
|
@@ -12092,7 +13210,7 @@ function renderEyecat(event) {
|
|
|
12092
13210
|
const node = el('div', 'command-card eyecat');
|
|
12093
13211
|
node.dataset.eyecat = finding.key;
|
|
12094
13212
|
const head = el('div', 'head');
|
|
12095
|
-
head.append(
|
|
13213
|
+
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
13214
|
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
13215
|
else head.append(el('span', 'args', t('judged by @{judge}', { judge: finding.judge })));
|
|
12098
13216
|
node.append(head);
|
|
@@ -12162,6 +13280,9 @@ function appendModuleOutput(event) {
|
|
|
12162
13280
|
// One way to turn a module on or off, whoever asks: its card, its tile on the board, AJUSTES. The
|
|
12163
13281
|
// server's switch flips what is there; what each module changes in the page follows it here.
|
|
12164
13282
|
async function switchModule(item) {
|
|
13283
|
+
// Turning the test browser on may download its browser first (src/modules/playwright.mjs): said
|
|
13284
|
+
// before the wait, so a switch that takes a minute is not mistaken for one that hung.
|
|
13285
|
+
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
13286
|
const response = await fetch(`/api/extensions/${item.id}/install`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ confirm: true }) });
|
|
12166
13287
|
const result = await response.json().catch(() => ({}));
|
|
12167
13288
|
if (!response.ok) throw new Error(result.error ?? t('{name} could not be turned on or off.', { name: item.name }));
|
|
@@ -12471,8 +13592,19 @@ async function refreshModules() {
|
|
|
12471
13592
|
else renderModuleWindows();
|
|
12472
13593
|
barModulesChanged();
|
|
12473
13594
|
drawFilesTarget();
|
|
13595
|
+
drawLocalTarget();
|
|
12474
13596
|
syncSettingsCards();
|
|
12475
13597
|
}
|
|
13598
|
+
// AJUSTES › Local AI: the module's own blocks (Modules › Local AI), drawn here too.
|
|
13599
|
+
function drawLocalTarget() {
|
|
13600
|
+
const target = state.settingsTargets.local;
|
|
13601
|
+
if (!target?.isConnected) return;
|
|
13602
|
+
const item = modules.items.find((one) => one.id === 'ollama');
|
|
13603
|
+
if (!item) return;
|
|
13604
|
+
target.replaceChildren();
|
|
13605
|
+
ollamaBlock(target, item);
|
|
13606
|
+
hardwareBlock(target, item.recommended?.hardware);
|
|
13607
|
+
}
|
|
12476
13608
|
|
|
12477
13609
|
// What the version on a card means. A module that ships with MADRE has its own version, written
|
|
12478
13610
|
// in its file and starting at 1.0.0; it cannot update by itself, because it arrives in a release.
|
|
@@ -12521,7 +13653,11 @@ function cardFold(card, label, { key, count = null, open = false } = {}) {
|
|
|
12521
13653
|
head.append(el('b', null, label));
|
|
12522
13654
|
if (count !== null) head.append(el('span', 'count', String(count)));
|
|
12523
13655
|
const caret = el('span', 'caret');
|
|
12524
|
-
const
|
|
13656
|
+
const turn = el('span', 'caret-mark');
|
|
13657
|
+
turn.innerHTML = ICON_CHEVRON; // trusted: our own SVG string · turned by styles.css
|
|
13658
|
+
const said = el('span', 'caret-words');
|
|
13659
|
+
caret.append(said, turn);
|
|
13660
|
+
const mark = () => { said.textContent = (box.open ? t('▾ COLLAPSE') : t('▸ EXPAND')).replace(/^[▸▾]\s*/u, ''); };
|
|
12525
13661
|
mark();
|
|
12526
13662
|
head.append(caret);
|
|
12527
13663
|
const body = el('div', 'card-fold-body');
|
|
@@ -12603,7 +13739,7 @@ function cardShell(item, { on, state: stateText }) {
|
|
|
12603
13739
|
head.append(top);
|
|
12604
13740
|
// What it does comes right under its name, in the reading face: it is what decides whether you
|
|
12605
13741
|
// 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));
|
|
13742
|
+
if (item.summary) head.append(el('p', 'summary', t(item.summary)));
|
|
12607
13743
|
// Who makes it, which version and whether it is current: one quiet line, not three.
|
|
12608
13744
|
const line = el('div', 'version');
|
|
12609
13745
|
if (item.vendor) line.append(el('span', 'vendor', item.vendor));
|
|
@@ -12650,7 +13786,7 @@ function cardShell(item, { on, state: stateText }) {
|
|
|
12650
13786
|
line.append(get);
|
|
12651
13787
|
}
|
|
12652
13788
|
head.append(line);
|
|
12653
|
-
if (item.status?.detail) head.append(el('div', 'detail', item.status.detail));
|
|
13789
|
+
if (item.status?.detail) head.append(el('div', 'detail', t(item.status.detail)));
|
|
12654
13790
|
if (item.external) head.append(el('div', 'detail', `${item.origin === 'project' ? t('THIS PROJECT') : t('EVERY PROJECT')} · ${item.file}`));
|
|
12655
13791
|
card.append(head);
|
|
12656
13792
|
|
|
@@ -12658,8 +13794,8 @@ function cardShell(item, { on, state: stateText }) {
|
|
|
12658
13794
|
|
|
12659
13795
|
// FLOOR 3 · what it touches, folded, with the count and nothing else.
|
|
12660
13796
|
const bullets = [
|
|
12661
|
-
...(item.creates ?? []).map((text) => [t('WRITES'), text]),
|
|
12662
|
-
...(item.requires ?? []).map((text) => [t('NEEDS'), text]),
|
|
13797
|
+
...(item.creates ?? []).map((text) => [t('WRITES'), t(text)]),
|
|
13798
|
+
...(item.requires ?? []).map((text) => [t('NEEDS'), t(text)]),
|
|
12663
13799
|
];
|
|
12664
13800
|
if (bullets.length) {
|
|
12665
13801
|
const body = cardFold(card, t('WHAT IT TOUCHES'), { key: `mod.${item.id}.touches`, count: bullets.length });
|
|
@@ -13710,13 +14846,29 @@ function moduleCard(item) {
|
|
|
13710
14846
|
const warn = el('div', 'confirm');
|
|
13711
14847
|
warn.append(el('span', 'warn', t('CANNOT INSTALL HERE YET')));
|
|
13712
14848
|
for (const problem of item.preflight.problems) warn.append(el('p', null, problem));
|
|
14849
|
+
// What MADRE can put right itself (Continuity on a Mac with no git): one key, never a terminal.
|
|
14850
|
+
if (item.preflight.fix) {
|
|
14851
|
+
const fix = el('button', 'primary', item.preflight.fix.label ?? t('Set it up'));
|
|
14852
|
+
fix.type = 'button';
|
|
14853
|
+
fix.addEventListener('click', async () => {
|
|
14854
|
+
fix.disabled = true;
|
|
14855
|
+
const response = await reach(`/api/extensions/${item.id}/fix`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
|
|
14856
|
+
const result = await response.json().catch(() => ({}));
|
|
14857
|
+
toast(response.ok ? t('Apple\'s installer opened. When it finishes, come back here and install.') : (result.error ?? t('It could not be started.')));
|
|
14858
|
+
fix.disabled = false;
|
|
14859
|
+
});
|
|
14860
|
+
warn.append(fix);
|
|
14861
|
+
}
|
|
13713
14862
|
panel.append(warn);
|
|
13714
14863
|
}
|
|
13715
14864
|
if (modules.confirming === item.id && !blocked) {
|
|
13716
14865
|
const confirm = el('div', 'confirm');
|
|
13717
|
-
|
|
13718
|
-
confirm.append(
|
|
13719
|
-
|
|
14866
|
+
// Written as it is said, names and all: the card's lowercase used to read «madre» and «(ide)».
|
|
14867
|
+
confirm.append(el('span', 'warn as-written', t('This writes into the project. MADRE will run, in the project folder:')));
|
|
14868
|
+
const first = item.install.prepare ?? [];
|
|
14869
|
+
confirm.append(commandBlock([...first.map((step) => step.display), item.install.display]));
|
|
14870
|
+
for (const step of first) if (step.note) confirm.append(el('span', 'note as-written', step.note));
|
|
14871
|
+
confirm.append(el('span', 'note as-written', item.install.platforms?.length
|
|
13720
14872
|
? t('IDE adapters for the agents detected here: {agents}.', { agents: item.install.platforms.join(', ') })
|
|
13721
14873
|
: t('No detected agent has an adapter for this module; it installs without IDE adapters.')));
|
|
13722
14874
|
const row = el('div', 'actions');
|
|
@@ -13766,22 +14918,6 @@ function moduleCard(item) {
|
|
|
13766
14918
|
// opens the module's card in a sheet, the way a category of AJUSTES opens; the ones with a reading
|
|
13767
14919
|
// can also float as windows of their own. MADRE decides which, for its own modules: nothing in the
|
|
13768
14920
|
// 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
14921
|
function moduleIcon(item) {
|
|
13786
14922
|
const face = moduleFace(item);
|
|
13787
14923
|
const span = el('span', 'settings-icon');
|
|
@@ -13817,9 +14953,9 @@ function moduleTile(item) {
|
|
|
13817
14953
|
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
14954
|
}
|
|
13819
14955
|
tile.append(top);
|
|
13820
|
-
const name = el('b', 'mods-name', t(item.name));
|
|
14956
|
+
const name = el('b', 'mods-name', face.name ?? t(item.name));
|
|
13821
14957
|
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));
|
|
14958
|
+
tile.append(name, el('span', 'mods-line', face.line ? t(face.line) : t(item.summary ?? '')));
|
|
13823
14959
|
// The ones with a reading of their own say so: their card can float as a small app.
|
|
13824
14960
|
if (face.window) tile.append(el('span', 'mods-app', t('Opens as a window')));
|
|
13825
14961
|
const open = () => openModuleSheet(item.id);
|
|
@@ -13876,7 +15012,7 @@ function moduleGlance(item) {
|
|
|
13876
15012
|
}
|
|
13877
15013
|
// What it touches and what it adds to the composer, as blocks of the room instead of the card's folds.
|
|
13878
15014
|
function moduleFacts(host, item) {
|
|
13879
|
-
const touches = [...(item.creates ?? []).map((text) => [t('Writes'), text]), ...(item.requires ?? []).map((text) => [t('Needs'), text])];
|
|
15015
|
+
const touches = [...(item.creates ?? []).map((text) => [t('Writes'), t(text)]), ...(item.requires ?? []).map((text) => [t('Needs'), t(text)])];
|
|
13880
15016
|
if (touches.length) {
|
|
13881
15017
|
const block = sxBlock(host, t('What it touches'), t('Everything this module writes or needs, said before it runs.'));
|
|
13882
15018
|
for (const [tag, text] of touches) {
|
|
@@ -13901,17 +15037,19 @@ const sentenceCase = (text) => { const words = String(text ?? ''); return words
|
|
|
13901
15037
|
function moduleSheet(host, item) {
|
|
13902
15038
|
host.append(moduleGlance(item));
|
|
13903
15039
|
if (item.kind !== 'builtin') {
|
|
13904
|
-
const install = sxBlock(host, t('What it does'), item.summary, { className: 'mods-legacy' });
|
|
15040
|
+
const install = sxBlock(host, t('What it does'), t(item.summary ?? ''), { className: 'mods-legacy' });
|
|
13905
15041
|
install.rows.append(moduleCard(item));
|
|
13906
15042
|
moduleFacts(host, item);
|
|
13907
15043
|
return;
|
|
13908
15044
|
}
|
|
13909
15045
|
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);
|
|
15046
|
+
const about = sxBlock(host, t('What it does'), moduleFace(item).summary ?? t(item.summary ?? ''));
|
|
15047
|
+
if (item.fixed) sxRow(about, t('Always on'), item.status?.detail ? sentenceCase(t(item.status.detail)) : null);
|
|
13912
15048
|
else {
|
|
13913
15049
|
const blocked = !on && item.preflight && !item.preflight.ok;
|
|
13914
|
-
|
|
15050
|
+
// A project's switch is that project's (src/module-scope.mjs); every project's is moved from Home.
|
|
15051
|
+
const ownSwitch = item.scope && item.id !== 'ollama' && item.id !== 'bishop';
|
|
15052
|
+
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
15053
|
box.disabled = true;
|
|
13916
15054
|
try { await switchModule(item); } catch (error) { box.checked = !next; box.disabled = false; toast(error.message); }
|
|
13917
15055
|
}, { disabled: blocked, label: item.name }), { off: blocked });
|
|
@@ -13985,7 +15123,7 @@ function moduleSheet(host, item) {
|
|
|
13985
15123
|
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
15124
|
intentsReading(block.rows);
|
|
13987
15125
|
}
|
|
13988
|
-
if (item.id === 'ollama') ollamaBlock(host, item);
|
|
15126
|
+
if (item.id === 'ollama') { ollamaBlock(host, item); hardwareBlock(host, item.recommended?.hardware); }
|
|
13989
15127
|
|
|
13990
15128
|
moduleFacts(host, item);
|
|
13991
15129
|
if (item.external) {
|
|
@@ -13996,6 +15134,9 @@ function moduleSheet(host, item) {
|
|
|
13996
15134
|
}
|
|
13997
15135
|
}
|
|
13998
15136
|
|
|
15137
|
+
// What this computer can do with a local AI (public/hardware.js), drawn with this page's rows.
|
|
15138
|
+
const hardwareBlock = (host, fit) => drawHardware(host, fit, { el, sxBlock, sxRow, sxChip });
|
|
15139
|
+
|
|
13999
15140
|
// Ollama's local brain as rows: the server, its models, the roles it takes in this room.
|
|
14000
15141
|
function ollamaBlock(host, item) {
|
|
14001
15142
|
const info = item.ollama ?? { running: false, models: [], settings: {} };
|
|
@@ -14066,7 +15207,7 @@ function moduleDetail(item) {
|
|
|
14066
15207
|
back.append(chevron('settings-back-icon'));
|
|
14067
15208
|
back.addEventListener('click', () => openModuleSheet(null));
|
|
14068
15209
|
title.append(back);
|
|
14069
|
-
title.append(moduleIcon(item), el('b', null, t(item.name)));
|
|
15210
|
+
title.append(moduleIcon(item), el('b', null, moduleFace(item).name ?? t(item.name)));
|
|
14070
15211
|
if (item.external) title.append(el('span', 'mods-dev', t('Your module')));
|
|
14071
15212
|
if (moduleFace(item).window) {
|
|
14072
15213
|
const float = sxButton(t('Open as a window'), () => floatModule(item.id));
|
|
@@ -14159,7 +15300,7 @@ function openModuleWindow(id) {
|
|
|
14159
15300
|
node.setAttribute('aria-label', t(item.name));
|
|
14160
15301
|
const head = el('header', 'module-window-head custom-head');
|
|
14161
15302
|
const title = el('div', 'module-window-title');
|
|
14162
|
-
title.append(moduleIcon(item), el('b', null, t(item.name)));
|
|
15303
|
+
title.append(moduleIcon(item), el('b', null, moduleFace(item).name ?? t(item.name)));
|
|
14163
15304
|
const group = el('span', 'surface-modes');
|
|
14164
15305
|
// The same keys as every window: arrange, then close.
|
|
14165
15306
|
const arrange = el('button', 'surface-mode window');
|
|
@@ -14331,6 +15472,52 @@ function readyModules() {
|
|
|
14331
15472
|
modulesWindow.observer.observe(node);
|
|
14332
15473
|
}
|
|
14333
15474
|
}
|
|
15475
|
+
// THE MARKET'S OFFER (2026-10-18, market.js). A message that asks for what a module does, while it
|
|
15476
|
+
// is off, gets one card under it: what would help, the key that turns it on here, the module's page
|
|
15477
|
+
// in the Market, and «No, thanks», which is kept. Read from the person's own words; once a page.
|
|
15478
|
+
const MARKET_DECLINED = 'pulse.market.declined';
|
|
15479
|
+
const marketShown = [];
|
|
15480
|
+
const marketDeclined = () => { try { return JSON.parse(localStorage.getItem(MARKET_DECLINED) ?? '[]') ?? []; } catch { return []; } };
|
|
15481
|
+
function offerModule(text) {
|
|
15482
|
+
if (state.device) return;
|
|
15483
|
+
const offer = marketOffer(text, modules.items ?? [], { declined: marketDeclined(), shown: marketShown });
|
|
15484
|
+
if (!offer) return;
|
|
15485
|
+
marketShown.push(offer.item.id);
|
|
15486
|
+
setTimeout(() => {
|
|
15487
|
+
const { item, says } = offer;
|
|
15488
|
+
const card = el('div', 'system market-offer');
|
|
15489
|
+
const mark = appIcon(item, 40);
|
|
15490
|
+
const words = el('div', 'market-offer-words');
|
|
15491
|
+
words.append(el('b', null, t(item.name)), el('span', null, t(says)));
|
|
15492
|
+
const acts = el('div', 'market-offer-acts');
|
|
15493
|
+
const switchable = moduleSwitches(item) && marketEntry(item.id).scope !== 'project';
|
|
15494
|
+
const on = el('button', 'primary', switchable ? t('Turn on') : t('Install'));
|
|
15495
|
+
on.type = 'button';
|
|
15496
|
+
on.addEventListener('click', async () => {
|
|
15497
|
+
if (!switchable) { openModules(); await refreshModules(); renderModules(); openModuleSheet(item.id); return; }
|
|
15498
|
+
on.disabled = true;
|
|
15499
|
+
try {
|
|
15500
|
+
await switchModule(item);
|
|
15501
|
+
card.replaceChildren(mark, el('div', 'market-offer-words', null));
|
|
15502
|
+
card.lastChild.append(el('b', null, t(item.name)), el('span', null, t('Turned on. Ask again and the agents can use it.')));
|
|
15503
|
+
} catch (error) { on.disabled = false; toast(error.message); }
|
|
15504
|
+
});
|
|
15505
|
+
const more = el('button', null, t('See it in the Market'));
|
|
15506
|
+
more.type = 'button';
|
|
15507
|
+
more.addEventListener('click', () => openRoomMarket(item.id));
|
|
15508
|
+
const no = el('button', 'quiet', t('No, thanks'));
|
|
15509
|
+
no.type = 'button';
|
|
15510
|
+
no.addEventListener('click', () => {
|
|
15511
|
+
try { localStorage.setItem(MARKET_DECLINED, JSON.stringify([...new Set([...marketDeclined(), item.id])])); } catch { /* asked again next page */ }
|
|
15512
|
+
card.remove();
|
|
15513
|
+
});
|
|
15514
|
+
acts.append(on, more, no);
|
|
15515
|
+
card.append(mark, words, acts);
|
|
15516
|
+
document.querySelector('#thread-inner')?.append(card);
|
|
15517
|
+
card.scrollIntoView?.({ block: 'nearest', behavior: reducedMotion() ? 'auto' : 'smooth' });
|
|
15518
|
+
}, motion(700));
|
|
15519
|
+
}
|
|
15520
|
+
|
|
14334
15521
|
function openModules() {
|
|
14335
15522
|
const node = modules.dialog;
|
|
14336
15523
|
readyModules();
|
|
@@ -14358,6 +15545,47 @@ modules.button.addEventListener('click', async () => {
|
|
|
14358
15545
|
renderModules();
|
|
14359
15546
|
});
|
|
14360
15547
|
modules.close.addEventListener('click', () => closeModules());
|
|
15548
|
+
// THE MARKET in a project (2026-10-18): the same store as Home's, full screen, where what you get
|
|
15549
|
+
// is for this project. A module that writes into the folder comes back here, to its card, so its
|
|
15550
|
+
// commands are read before they run.
|
|
15551
|
+
function openRoomMarket(focus = null) {
|
|
15552
|
+
return openMarket({
|
|
15553
|
+
where: 'room',
|
|
15554
|
+
focus,
|
|
15555
|
+
project: { name: projectDesignation() || t('this project') },
|
|
15556
|
+
host: {
|
|
15557
|
+
localAi: () => openSettingsOn('local'),
|
|
15558
|
+
toast: (words) => toast(words),
|
|
15559
|
+
installHere: async (item) => { openModules(); await refreshModules(); renderModules(); openModuleSheet(item.id); },
|
|
15560
|
+
changed: async () => { await refreshModules(); if (modules.dialog.open) renderModules(); barModulesChanged(); },
|
|
15561
|
+
},
|
|
15562
|
+
});
|
|
15563
|
+
}
|
|
15564
|
+
{
|
|
15565
|
+
const head = modules.dialog?.querySelector('.mods-head');
|
|
15566
|
+
const add = head?.querySelector('#modules-add');
|
|
15567
|
+
if (head && add) {
|
|
15568
|
+
const market = el('button', 'sx-btn mods-market', t('Market'));
|
|
15569
|
+
market.type = 'button';
|
|
15570
|
+
market.title = t('Every module MADRE can add to this project');
|
|
15571
|
+
market.addEventListener('click', () => openRoomMarket());
|
|
15572
|
+
add.before(market);
|
|
15573
|
+
}
|
|
15574
|
+
}
|
|
15575
|
+
// The Market's catalog from madre.run: the faces of the modules that came after this MADRE, and what
|
|
15576
|
+
// it updated on its own since, said once. Not on a phone: it has no Market.
|
|
15577
|
+
if (!state.device) void loadMarket({ toast: (words, action) => toast(words, { action }), open: (id) => openRoomMarket(id) }).then(() => { if (modules.dialog.open) renderModules(); });
|
|
15578
|
+
// The Market's address in a project: #mercado, or #mercado=<module> on its page.
|
|
15579
|
+
if (/^#mercado(=|$)/.test(globalThis.location?.hash ?? '')) {
|
|
15580
|
+
const id = decodeURIComponent(location.hash.split('=')[1] ?? '') || null;
|
|
15581
|
+
setTimeout(() => openRoomMarket(id), motion(400));
|
|
15582
|
+
}
|
|
15583
|
+
// Sent here from the Market with a module to install in this project: its card, open.
|
|
15584
|
+
if (/^#modulo=/.test(globalThis.location?.hash ?? '')) {
|
|
15585
|
+
const id = decodeURIComponent(location.hash.split('=')[1] ?? '');
|
|
15586
|
+
history.replaceState(null, '', location.pathname + location.search);
|
|
15587
|
+
setTimeout(async () => { openModules(); await refreshModules(); renderModules(); openModuleSheet(id); }, motion(600));
|
|
15588
|
+
}
|
|
14361
15589
|
// Escape closes the open module before the board, and the search before either.
|
|
14362
15590
|
modules.dialog.addEventListener('cancel', (event) => {
|
|
14363
15591
|
event.preventDefault();
|
|
@@ -14443,8 +15671,11 @@ function openCrewPop(anchor) {
|
|
|
14443
15671
|
list.append(row);
|
|
14444
15672
|
}
|
|
14445
15673
|
pop.append(list);
|
|
14446
|
-
const go = el('button', 'crew-go'
|
|
15674
|
+
const go = el('button', 'crew-go');
|
|
14447
15675
|
go.type = 'button';
|
|
15676
|
+
const gear = el('span', 'crew-go-icon');
|
|
15677
|
+
gear.innerHTML = GEAR_ICON; // trusted: our own SVG string
|
|
15678
|
+
go.append(gear, el('span', null, t('Agents in Settings')));
|
|
14448
15679
|
go.addEventListener('click', () => {
|
|
14449
15680
|
closeAgentPop();
|
|
14450
15681
|
openSettings(true, { category: 'agents' });
|
|
@@ -14593,6 +15824,19 @@ function fillLoginAsk(box) {
|
|
|
14593
15824
|
open.rel = 'noopener noreferrer';
|
|
14594
15825
|
box.append(open);
|
|
14595
15826
|
}
|
|
15827
|
+
// A device sign-in (GitHub Copilot through OpenCode): the code is MADRE's to show, the page's
|
|
15828
|
+
// to be typed into. Nothing comes back here; the sign-in ends on its own once it is typed.
|
|
15829
|
+
if (loginNow.deviceCode) {
|
|
15830
|
+
const said = el('div', 'login-device');
|
|
15831
|
+
said.append(el('span', null, t('Type this code on that page:')), el('code', null, loginNow.deviceCode));
|
|
15832
|
+
const copy = el('button', 'sx-btn', t('Copy'));
|
|
15833
|
+
copy.type = 'button';
|
|
15834
|
+
copy.addEventListener('click', async () => {
|
|
15835
|
+
try { await navigator.clipboard.writeText(loginNow.deviceCode ?? ''); copy.textContent = t('Copied'); } catch { copy.textContent = t('Select it'); }
|
|
15836
|
+
});
|
|
15837
|
+
said.append(copy);
|
|
15838
|
+
box.append(said);
|
|
15839
|
+
}
|
|
14596
15840
|
if (loginNow.asksForCode || loginNow.awaiting) {
|
|
14597
15841
|
const form = el('form', 'login-code');
|
|
14598
15842
|
const input = el('input');
|
|
@@ -14630,9 +15874,15 @@ function fillLoginAsk(box) {
|
|
|
14630
15874
|
}
|
|
14631
15875
|
// One door or two: Claude's CLI knows the subscription and the Console, and only the human can
|
|
14632
15876
|
// say which account is paying. `null` is the single flow every other CLI has.
|
|
15877
|
+
// With a session, every agent offers the same «sign in again» as Codex. A single door (Gemini's
|
|
15878
|
+
// Google account) is that door; two doors (Claude's subscription and Console) are chosen after the
|
|
15879
|
+
// click, and which agent is choosing is kept here so a repaint does not fold the choice away.
|
|
15880
|
+
const choosingAccount = new Set();
|
|
14633
15881
|
function loginDoors(agent, signedIn) {
|
|
14634
15882
|
const methods = agent.login?.methods;
|
|
14635
15883
|
if (!methods?.length) return [{ id: null, label: signedIn ? t('Sign in again') : t('Sign in'), note: agent.login?.note ?? '' }];
|
|
15884
|
+
if (signedIn && methods.length === 1) return [{ id: methods[0].id, label: t('Sign in again'), note: methods[0].note ?? '', again: true }];
|
|
15885
|
+
if (signedIn && !choosingAccount.has(agent.id)) return [{ id: null, label: t('Sign in again'), note: agent.login?.note ?? '', again: true, choose: true }];
|
|
14636
15886
|
return methods.map((method) => ({ id: method.id, label: t(method.label), note: method.note ?? '' }));
|
|
14637
15887
|
}
|
|
14638
15888
|
async function startLogin(agentId, method = null) {
|
|
@@ -14643,6 +15893,7 @@ async function startLogin(agentId, method = null) {
|
|
|
14643
15893
|
loginNow.agent = agentId;
|
|
14644
15894
|
loginNow.method = method;
|
|
14645
15895
|
loginNow.url = null;
|
|
15896
|
+
loginNow.deviceCode = null;
|
|
14646
15897
|
loginNow.awaiting = false;
|
|
14647
15898
|
loginNow.asksForCode = Boolean(result.asksForCode);
|
|
14648
15899
|
repaintLoginAsk();
|
|
@@ -14660,10 +15911,10 @@ function renderLoginEvent(event) {
|
|
|
14660
15911
|
node.append(finished
|
|
14661
15912
|
? (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
15913
|
: t('signing in @{agent} · {command}', { agent, command }));
|
|
14663
|
-
if (finished) { state.sessions[agent] = session ?? state.sessions[agent]; renderOnboarding(); if (state.settingsOpen) void loadSettings(); }
|
|
15914
|
+
if (finished) { state.sessions[agent] = session ?? state.sessions[agent]; renderAgents(); renderPicker(); renderOnboarding(); if (state.settingsOpen) void loadSettings(); }
|
|
14664
15915
|
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); }
|
|
15916
|
+
if (finished) { if (loginNow.agent === agent) { loginNow.agent = null; loginNow.url = null; loginNow.deviceCode = null; loginNow.awaiting = false; } }
|
|
15917
|
+
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
15918
|
repaintLoginAsk();
|
|
14668
15919
|
}
|
|
14669
15920
|
state.lastSender = null;
|
|
@@ -14685,13 +15936,15 @@ function renderAgentInstall(event) {
|
|
|
14685
15936
|
}
|
|
14686
15937
|
|
|
14687
15938
|
function appendLoginOutput(event) {
|
|
14688
|
-
const { agent, line, url } = event.payload;
|
|
15939
|
+
const { agent, line, url, deviceCode } = event.payload;
|
|
14689
15940
|
if (!replaying && loginNow.agent === agent) {
|
|
14690
15941
|
// The address stays on screen instead of scrolling past: the heartbeat used to be the only
|
|
14691
15942
|
// line left, so the one thing the human had to open was the first thing MADRE lost.
|
|
14692
15943
|
if (url) loginNow.url = url;
|
|
14693
|
-
|
|
14694
|
-
if (
|
|
15944
|
+
// "Enter code: 3475-C193" is a code to show, not a question: no box to paste into.
|
|
15945
|
+
if (deviceCode) loginNow.deviceCode = deviceCode;
|
|
15946
|
+
else if (looksLikeCodePrompt(line)) loginNow.awaiting = true;
|
|
15947
|
+
if (url || deviceCode || looksLikeCodePrompt(line)) repaintLoginAsk();
|
|
14695
15948
|
}
|
|
14696
15949
|
// The heartbeat («… still running · 42s · …») says one thing, how long: it takes the place of
|
|
14697
15950
|
// the one before instead of piling up thirty lines over the address the person needs.
|
|
@@ -14726,6 +15979,8 @@ async function loadSettings() {
|
|
|
14726
15979
|
settingsUI.data = await fetch('/api/settings').then((response) => response.json());
|
|
14727
15980
|
if (settingsUI.data?.settings?.override) state.override = settingsUI.data.settings.override;
|
|
14728
15981
|
state.sessions = settingsUI.data.sessions ?? state.sessions;
|
|
15982
|
+
renderAgents();
|
|
15983
|
+
renderPicker();
|
|
14729
15984
|
// The server is the one that knows whether a sign-in is still waiting: a page reloaded in the
|
|
14730
15985
|
// middle of one has to find the same address and the same box, not an empty card.
|
|
14731
15986
|
readLoginState(settingsUI.data.login ?? null);
|
|
@@ -14736,6 +15991,7 @@ function readLoginState(live) {
|
|
|
14736
15991
|
loginNow.agent = live?.agent ?? null;
|
|
14737
15992
|
loginNow.method = live?.method ?? null;
|
|
14738
15993
|
loginNow.url = live?.url ?? null;
|
|
15994
|
+
loginNow.deviceCode = live?.deviceCode ?? null;
|
|
14739
15995
|
loginNow.awaiting = Boolean(live?.awaitingCode);
|
|
14740
15996
|
loginNow.asksForCode = Boolean(live?.asksForCode);
|
|
14741
15997
|
repaintLoginAsk();
|
|
@@ -16032,25 +17288,28 @@ function connectionCard(agent) {
|
|
|
16032
17288
|
// One button per account the CLI can sign in with. Which one is paying is the human's to say.
|
|
16033
17289
|
for (const door of loginDoors(agent, signedIn)) {
|
|
16034
17290
|
const login = sxButton(door.label, async () => {
|
|
17291
|
+
if (door.choose) { choosingAccount.add(agent.id); renderSettings(); return; }
|
|
17292
|
+
choosingAccount.delete(agent.id);
|
|
16035
17293
|
login.disabled = true;
|
|
16036
17294
|
try { await startLogin(agent.id, door.id); settingsUI.data.loggingIn = agent.id; renderSettings(); }
|
|
16037
17295
|
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) });
|
|
17296
|
+
}, { tone: (signedIn && !choosingAccount.has(agent.id)) || agent.key?.first ? null : 'primary', title: t(door.note || agent.login.note) });
|
|
16039
17297
|
login.disabled = Boolean(settingsUI.data.loggingIn);
|
|
16040
17298
|
acts.append(login);
|
|
16041
17299
|
}
|
|
16042
17300
|
}
|
|
16043
17301
|
acts.append(recheck);
|
|
16044
|
-
body.append(acts);
|
|
16045
17302
|
const slot = el('div', 'sx-slot');
|
|
16046
17303
|
// OpenCode's keys are its providers', added in the block under its settings, one row for all.
|
|
16047
17304
|
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
|
-
}
|
|
17305
|
+
// Gemini's key is its way in (agent.key.first): the field before the Google door.
|
|
17306
|
+
if (agent.key?.first && slot.firstChild) { const first = el('div', 'sx-slot'); first.append(slot.firstChild); body.append(first); }
|
|
17307
|
+
body.append(acts);
|
|
16053
17308
|
if (agent.detected && agent.login?.headless) slot.append(loginAskSlot(agent.id));
|
|
17309
|
+
// The same, typed in a terminal, folded for whoever prefers it. Signed in, OpenCode's providers
|
|
17310
|
+
// are added in its block below; its terminal is for a first sign-in.
|
|
17311
|
+
const way = !agent.detected ? installTerminalWay(agent) : agent.id === 'opencode' && session?.state === 'signed-in' ? null : signInTerminalWay(agent);
|
|
17312
|
+
if (way) slot.append(way);
|
|
16054
17313
|
const log = state.loginLogs.get(agent.id) ?? [];
|
|
16055
17314
|
if (log.length || busy) {
|
|
16056
17315
|
const box = el('div', 'login-log');
|
|
@@ -16073,7 +17332,66 @@ function renderMemoryPanel(data) {
|
|
|
16073
17332
|
const target = state.settingsTargets.memory;
|
|
16074
17333
|
if (target?.isConnected) { target.replaceChildren(); drawMemory(target, data); }
|
|
16075
17334
|
}
|
|
17335
|
+
// D-S17, also in AJUSTES › What it remembers: the notes other projects delivered here, waiting
|
|
17336
|
+
// for you, and what became of the last ones. The same rows as the card in the conversation.
|
|
17337
|
+
const deliveries = { pending: 0 };
|
|
17338
|
+
async function readDeliveries() {
|
|
17339
|
+
try { return (await fetch('/api/enlace/inbox').then((response) => response.json())).notes ?? []; } catch { return null; }
|
|
17340
|
+
}
|
|
17341
|
+
function drawDelivered(host) {
|
|
17342
|
+
const block = sxBlock(host, t('Delivered notes'), t('Notes other projects handed to this one. Each enters the memory only if it is accepted.'));
|
|
17343
|
+
block.id = 'sx-delivered';
|
|
17344
|
+
const fill = async () => {
|
|
17345
|
+
const notes = await readDeliveries();
|
|
17346
|
+
if (!block.isConnected) return;
|
|
17347
|
+
block.rows.replaceChildren();
|
|
17348
|
+
if (!notes) { sxRow(block, t('Could not read them.'), null); return; }
|
|
17349
|
+
const waiting = notes.filter((note) => note.status === 'pending');
|
|
17350
|
+
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.'));
|
|
17351
|
+
for (const note of waiting) {
|
|
17352
|
+
const decide = (accept) => async () => {
|
|
17353
|
+
try {
|
|
17354
|
+
const response = await fetch('/api/enlace/inbox/decide', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: note.id, accept }) });
|
|
17355
|
+
const result = await response.json().catch(() => ({}));
|
|
17356
|
+
if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
|
|
17357
|
+
} catch (error) { toast(`${t('Diagnostics › ')}${error.message}`); }
|
|
17358
|
+
void fill();
|
|
17359
|
+
void refreshDeliveries();
|
|
17360
|
+
};
|
|
17361
|
+
const actions = el('div', 'sx-chips');
|
|
17362
|
+
actions.append(sxButton(t('Accept'), decide(true), { tone: 'primary' }), sxButton(t('Decline'), decide(false)));
|
|
17363
|
+
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 });
|
|
17364
|
+
}
|
|
17365
|
+
};
|
|
17366
|
+
void fill();
|
|
17367
|
+
}
|
|
17368
|
+
// The bar says how many wait, and goes to them.
|
|
17369
|
+
async function refreshDeliveries() {
|
|
17370
|
+
const notes = await readDeliveries();
|
|
17371
|
+
if (!notes) return;
|
|
17372
|
+
deliveries.pending = notes.filter((note) => note.status === 'pending').length;
|
|
17373
|
+
let pill = document.querySelector('#inbox-pill');
|
|
17374
|
+
if (!pill) {
|
|
17375
|
+
const anchor = document.querySelector('#stop-all');
|
|
17376
|
+
if (!anchor) return;
|
|
17377
|
+
// Shaped like the handoff pill beside it: an icon and its words, never squeezed to a dot.
|
|
17378
|
+
pill = el('button', 'stop-all handoff-pill inbox-pill');
|
|
17379
|
+
pill.id = 'inbox-pill';
|
|
17380
|
+
pill.type = 'button';
|
|
17381
|
+
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
|
|
17382
|
+
pill.addEventListener('click', () => openSettingsOn('memory', { anchor: 'sx-delivered' }));
|
|
17383
|
+
anchor.before(pill);
|
|
17384
|
+
}
|
|
17385
|
+
pill.hidden = state.device || !deliveries.pending;
|
|
17386
|
+
const said = deliveries.pending === 1 ? t('1 note to accept') : t('{n} notes to accept', { n: deliveries.pending });
|
|
17387
|
+
pill.querySelector('.handoff-words').textContent = said;
|
|
17388
|
+
// Its short form, for a bar too narrow for the words (barFit, bar-tight): the tray and the number.
|
|
17389
|
+
pill.querySelector('.inbox-count').textContent = String(deliveries.pending);
|
|
17390
|
+
pill.setAttribute('aria-label', said);
|
|
17391
|
+
pill.title = t('Notes other projects delivered here, waiting for you');
|
|
17392
|
+
}
|
|
16076
17393
|
function drawMemory(host, data) {
|
|
17394
|
+
drawDelivered(host);
|
|
16077
17395
|
const mem = data?.settings?.memory;
|
|
16078
17396
|
if (!mem) { sxBlock(host, t('What it remembers'), t('This project has no memory yet.')); return; }
|
|
16079
17397
|
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 +17468,60 @@ function drawMemory(host, data) {
|
|
|
16150
17468
|
const recipe = el('details', 'sx-more');
|
|
16151
17469
|
recipe.append(el('summary', null, t('Step by step')));
|
|
16152
17470
|
let platform = /Mac/i.test(navigator.platform ?? '') || !/Linux/i.test(navigator.userAgent ?? '') ? 'mac' : 'linux';
|
|
17471
|
+
// The size to train: the largest this computer trains and then runs, unless the human picks
|
|
17472
|
+
// another (to train it on a larger machine, say). Every size is offered; the note says what fits.
|
|
17473
|
+
let size = null;
|
|
17474
|
+
let measured = false;
|
|
16153
17475
|
const steps = el('ol', 'sx-steps');
|
|
16154
17476
|
const pickRow = el('div', 'sx-train-platform');
|
|
16155
17477
|
const platformNote = el('p', 'sx-hint');
|
|
16156
|
-
|
|
17478
|
+
const sizeRow = el('div', 'sx-train-platform');
|
|
17479
|
+
const sizeNote = el('p', 'sx-hint');
|
|
17480
|
+
recipe.append(pickRow, platformNote, sizeRow, sizeNote, steps);
|
|
16157
17481
|
train.append(recipe);
|
|
16158
17482
|
let lastPayload = null;
|
|
16159
17483
|
const drawPlatform = () => {
|
|
16160
17484
|
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
17485
|
platformNote.textContent = platform === 'mac'
|
|
16162
|
-
? t('Any Mac with an M chip.
|
|
17486
|
+
? t('Any Mac with an M chip. Its memory decides the size you can train, below.')
|
|
16163
17487
|
: 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
17488
|
};
|
|
17489
|
+
const drawSize = (fit) => {
|
|
17490
|
+
const models = fit?.models ?? [];
|
|
17491
|
+
if (!models.length) { sizeRow.replaceChildren(); sizeNote.textContent = ''; return null; }
|
|
17492
|
+
const chosen = models.find((model) => model.id === size) ?? models.find((model) => model.id === fit.recommended?.train?.id) ?? models[1] ?? models[0];
|
|
17493
|
+
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') }));
|
|
17494
|
+
const here = hardwareLine(fit.hardware);
|
|
17495
|
+
sizeNote.textContent = chosen.train === 'yes'
|
|
17496
|
+
? 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 })
|
|
17497
|
+
: fit.accelerated
|
|
17498
|
+
? 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 })
|
|
17499
|
+
: 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 });
|
|
17500
|
+
return chosen;
|
|
17501
|
+
};
|
|
16165
17502
|
const showDataset = (payload) => {
|
|
16166
17503
|
lastPayload = payload;
|
|
16167
17504
|
const d = payload?.dataset;
|
|
16168
17505
|
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
17506
|
const trained = payload?.trained ? t('Trained model {model} in use.', { model: payload.trained }) : t('No trained model yet.');
|
|
16170
|
-
|
|
16171
|
-
|
|
17507
|
+
// D-S15: in Home, which projects the Coordinator learned from this time, and how.
|
|
17508
|
+
const sources = (d?.linked ?? []).map((one) => (one.source === 'missing'
|
|
17509
|
+
? t('{room}: did not answer and nothing was kept', { room: one.name })
|
|
17510
|
+
: one.source === 'kept'
|
|
17511
|
+
? t('{room}: {n} notes from the last time it answered', { room: one.name, n: one.notes })
|
|
17512
|
+
: t('{room}: {n} notes', { room: one.name, n: one.notes })));
|
|
17513
|
+
datasetHint.textContent = `${exported} ${trained}${sources.length ? ` ${t('Learned from your projects: {list}.', { list: sources.join(' · ') })}` : ''}`;
|
|
16172
17514
|
const info = payload?.training;
|
|
17515
|
+
// The first reading sets the recipe for this computer: an NVIDIA card goes the Linux way.
|
|
17516
|
+
if (!measured && info?.fit?.hardware) { measured = true; if (info.fit.hardware.accelerator === 'nvidia') platform = 'linux'; else if (info.fit.hardware.accelerator === 'apple') platform = 'mac'; }
|
|
17517
|
+
drawPlatform();
|
|
17518
|
+
const chosen = drawSize(info?.fit);
|
|
16173
17519
|
steps.replaceChildren();
|
|
16174
17520
|
if (!info) { steps.append(el('li', 'sx-hint', t('The training recipe is unavailable.'))); return; }
|
|
17521
|
+
// A base trained in 4 bits (3B and up) is joined back at full precision, so llama.cpp can read
|
|
17522
|
+
// it, and Ollama shrinks it to 4 bits again; the small ones stay at 8.
|
|
17523
|
+
const base = chosen?.base ?? info.baseModel;
|
|
17524
|
+
const quantized = Boolean(chosen?.quantized);
|
|
16175
17525
|
const quote = (path) => `"${path.replace(/\/$/, '')}"`;
|
|
16176
17526
|
const venv = 'source ~/.madre-train/bin/activate';
|
|
16177
17527
|
const setup = platform === 'mac'
|
|
@@ -16186,11 +17536,40 @@ function drawMemory(host, data) {
|
|
|
16186
17536
|
};
|
|
16187
17537
|
step(t('Export the training data'), t('Use «Export» above. Do it again whenever the project has grown.'));
|
|
16188
17538
|
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`);
|
|
17539
|
+
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}`);
|
|
17540
|
+
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' : ''}`);
|
|
17541
|
+
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'}`);
|
|
17542
|
+
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
17543
|
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 }));
|
|
17544
|
+
if (state.salas) drawExam();
|
|
17545
|
+
};
|
|
17546
|
+
// D-S18 · in Home, the exam the Coordinator's local model passes before it gives orders: the
|
|
17547
|
+
// same test as the step above, run by MADRE, with a fixed bar and kept for that exact model.
|
|
17548
|
+
const exam = el('div', 'sx-exam');
|
|
17549
|
+
if (state.salas) train.append(exam);
|
|
17550
|
+
let examTimer = null;
|
|
17551
|
+
const drawExam = async () => {
|
|
17552
|
+
const reading = await fetch('/api/x/bishop/exam').then((response) => (response.ok ? response.json() : null)).catch(() => null);
|
|
17553
|
+
exam.replaceChildren();
|
|
17554
|
+
if (!reading) return;
|
|
17555
|
+
const last = reading.exam;
|
|
17556
|
+
const part = (key) => `${last?.scores?.[key]?.passed ?? 0}/${last?.scores?.[key]?.total ?? 0}`;
|
|
17557
|
+
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 ?? '' })
|
|
17558
|
+
: !last ? t('Not examined yet. Until it passes, the Coordinator only answers with @madre.')
|
|
17559
|
+
: 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') })
|
|
17560
|
+
: 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 ?? '—' })
|
|
17561
|
+
: 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(' · ')}` : '' });
|
|
17562
|
+
const run = sxButton(t('Examine'), async () => {
|
|
17563
|
+
run.disabled = true;
|
|
17564
|
+
const response = await fetch('/api/x/bishop/exam', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }).catch(() => null);
|
|
17565
|
+
const result = await response?.json().catch(() => ({}));
|
|
17566
|
+
if (!response?.ok) { toast(`${t('Diagnostics › ')}${result?.error ?? t('could not start')}`); run.disabled = false; return; }
|
|
17567
|
+
void drawExam();
|
|
17568
|
+
}, { 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') });
|
|
17569
|
+
run.disabled = Boolean(reading.running) || !reading.model;
|
|
17570
|
+
sxRow(exam, t('Exam before giving orders'), said, run);
|
|
17571
|
+
clearTimeout(examTimer);
|
|
17572
|
+
if (reading.running) examTimer = setTimeout(() => { if (exam.isConnected) void drawExam(); }, 8000);
|
|
16194
17573
|
};
|
|
16195
17574
|
fetch('/api/dataset').then((response) => response.json()).then(showDataset).catch(() => showDataset(null));
|
|
16196
17575
|
}
|
|
@@ -16699,7 +18078,33 @@ nostromo.covered = () => {
|
|
|
16699
18078
|
};
|
|
16700
18079
|
nostromo.onClang = () => { const frame = document.querySelector('#nostromo .nostromo-frame'); frame?.classList.remove('shake'); void frame?.offsetWidth; frame?.classList.add('shake'); };
|
|
16701
18080
|
// 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);
|
|
18081
|
+
const { buildNodes, sizeNostromo, stepNostromo, focusNostromo, projectNostromo, fitNostromo, drawNostromo, toScreen, toWorld, nostromoLive, nostromoAt, beginImplosion, setNeural } = createNostromo(nostromo);
|
|
18082
|
+
// Neural mode (public/neural.js): the same map drawn as the neurons of a brain, chosen from its head.
|
|
18083
|
+
// The choice is the person's, shared with Home's Projects map (looks.json, `pulse.neural`); when this
|
|
18084
|
+
// machine does not give the map its frames, the key invites to try it, once a session.
|
|
18085
|
+
nostromo.neuralKey = document.querySelector('#nostromo-neural');
|
|
18086
|
+
nostromo.neural = neuralChosen();
|
|
18087
|
+
if (nostromo.neuralKey) {
|
|
18088
|
+
neuralKey(nostromo.neuralKey);
|
|
18089
|
+
paintNeuralKey(nostromo.neuralKey, nostromo.neural);
|
|
18090
|
+
nostromo.neuralKey.addEventListener('click', () => setNostromoNeural(!nostromo.neural));
|
|
18091
|
+
}
|
|
18092
|
+
function setNostromoNeural(on) {
|
|
18093
|
+
setNeural(on, performance.now() / 1000);
|
|
18094
|
+
rememberNeural(on);
|
|
18095
|
+
paintNeuralKey(nostromo.neuralKey, on);
|
|
18096
|
+
nostromo.dialog?.querySelector('.neural-invite')?.remove();
|
|
18097
|
+
pushLooks();
|
|
18098
|
+
}
|
|
18099
|
+
function syncNostromoNeural() {
|
|
18100
|
+
const want = neuralChosen();
|
|
18101
|
+
if (Boolean(nostromo.neural) === want) return;
|
|
18102
|
+
setNeural(want, performance.now() / 1000);
|
|
18103
|
+
paintNeuralKey(nostromo.neuralKey, want);
|
|
18104
|
+
}
|
|
18105
|
+
nostromo.strain = createStrain({
|
|
18106
|
+
onStrain: () => { if (!nostromo.neural && !state.device) inviteNeural({ key: nostromo.neuralKey, host: nostromo.dialog, map: 'memory', onTry: () => setNostromoNeural(true) }); },
|
|
18107
|
+
});
|
|
16703
18108
|
// One frame of the map on demand, what startNostromo's loop does sixty times a second: for whoever
|
|
16704
18109
|
// measures it (`__pulse.nostromoTick`), without waiting for the screen.
|
|
16705
18110
|
function nostromoTick(now) {
|
|
@@ -17299,15 +18704,18 @@ function startNostromo() {
|
|
|
17299
18704
|
if (!nostromo.cage?.closed) nostromo.cage = null;
|
|
17300
18705
|
window.addEventListener('resize', sizeNostromo);
|
|
17301
18706
|
nostromo.last = performance.now();
|
|
18707
|
+
nostromo.strain?.reset();
|
|
17302
18708
|
const frame = (now) => {
|
|
17303
18709
|
const dt = Math.min(48, now - nostromo.last) / 16;
|
|
17304
18710
|
nostromo.last = now;
|
|
17305
18711
|
const clock = now / 1000;
|
|
18712
|
+
const started = performance.now();
|
|
17306
18713
|
stepNostromo(dt, clock);
|
|
17307
18714
|
focusNostromo(dt, clock);
|
|
17308
18715
|
projectNostromo();
|
|
17309
18716
|
fitNostromo();
|
|
17310
18717
|
drawNostromo(clock);
|
|
18718
|
+
nostromo.strain?.frame(performance.now() - started, now);
|
|
17311
18719
|
nostromo.raf = requestAnimationFrame(frame);
|
|
17312
18720
|
};
|
|
17313
18721
|
nostromo.raf = requestAnimationFrame(frame);
|
|
@@ -18453,11 +19861,12 @@ settingsUI.button?.addEventListener('click', () => openSettingsOn('release', { p
|
|
|
18453
19861
|
bar, and in which order, is this browser's own: a convenience, kept in localStorage. */
|
|
18454
19862
|
const BAR_KEY = 'pulse.barItems';
|
|
18455
19863
|
const BAR_FIXED = 'settings';
|
|
18456
|
-
const BAR_DEFAULT = ['salas', 'macroverso', 'crew', 'modules', 'mother', 'nostromo', 'reload', 'servers', 'finder', 'phone'];
|
|
19864
|
+
const BAR_DEFAULT = ['salas', 'macroverso', 'crew', 'code', 'modules', 'mother', 'nostromo', 'reload', 'servers', 'finder', 'phone'];
|
|
18457
19865
|
// The keys that are in the markup. The crew is two elements — five spheres when there is room, one
|
|
18458
19866
|
// button when there is not — and one key: taking it off takes both.
|
|
18459
19867
|
const BAR_BUILT = {
|
|
18460
19868
|
crew: { nodes: ['#agents', '#agents-compact'], name: () => t('Agents') },
|
|
19869
|
+
code: { nodes: ['#code-button'], name: () => t('Code') },
|
|
18461
19870
|
modules: { nodes: ['#modules-button'], name: () => t('Modules') },
|
|
18462
19871
|
mother: { nodes: ['#mother-button'], name: () => t('Diagnostics') },
|
|
18463
19872
|
nostromo: { nodes: ['#nostromo-bar'], name: () => t('Memory map') },
|
|
@@ -18472,6 +19881,8 @@ const BAR_CONTEXTUAL = ['#steps-pill', '#tour-pill-slot', '#handoff-pill', '#upd
|
|
|
18472
19881
|
// A module's name before the board has answered; once it has, the module says its own.
|
|
18473
19882
|
const BAR_MODULE_NAMES = { ash: 'Token saver', ollama: 'Local AI', intents: 'Suggested effort' };
|
|
18474
19883
|
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>';
|
|
19884
|
+
// Its mirror on a key that can still go on the bar, in the card under it.
|
|
19885
|
+
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
19886
|
const barKit = {
|
|
18476
19887
|
node: document.querySelector('#bar'),
|
|
18477
19888
|
order: [],
|
|
@@ -18491,7 +19902,7 @@ const barKit = {
|
|
|
18491
19902
|
|
|
18492
19903
|
// Keys that joined the bar after a person arranged theirs. Each is put at the end once, so it can
|
|
18493
19904
|
// be found; taken off after that, it stays off.
|
|
18494
|
-
const BAR_JOINED = ['phone', 'salas', 'macroverso'];
|
|
19905
|
+
const BAR_JOINED = ['phone', 'salas', 'macroverso', 'code'];
|
|
18495
19906
|
// The MACROVERSO: an orbit, a sun and two worlds. Only the room of SALAS carries the key.
|
|
18496
19907
|
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
19908
|
// SALAS: three rooms of one Mac, and what joins them.
|
|
@@ -18505,7 +19916,8 @@ function barStored() {
|
|
|
18505
19916
|
const met = JSON.parse(localStorage.getItem('pulse.barMet') ?? '[]');
|
|
18506
19917
|
const fresh = BAR_JOINED.filter((id) => !met.includes(id));
|
|
18507
19918
|
if (fresh.length) {
|
|
18508
|
-
|
|
19919
|
+
// CODE stands beside the agents, where it is seen first; the others join at the end.
|
|
19920
|
+
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
19921
|
localStorage.setItem(BAR_KEY, JSON.stringify(list));
|
|
18510
19922
|
localStorage.setItem('pulse.barMet', JSON.stringify([...met, ...fresh]));
|
|
18511
19923
|
}
|
|
@@ -18522,6 +19934,8 @@ function barCatalogue() {
|
|
|
18522
19934
|
for (const [id, spec] of Object.entries(BAR_BUILT)) {
|
|
18523
19935
|
// Reload lives in the second bar on the Mac; it is not a key of this one there.
|
|
18524
19936
|
if (id === 'reload' && homeOn()) continue;
|
|
19937
|
+
// The code workspace docks beside the conversation: a phone has no room beside it.
|
|
19938
|
+
if (id === 'code' && state.device) continue;
|
|
18525
19939
|
const nodes = spec.nodes.map((query) => document.querySelector(query)).filter(Boolean);
|
|
18526
19940
|
if (nodes.length) out.set(id, { id, name: spec.name(), nodes });
|
|
18527
19941
|
}
|
|
@@ -18634,6 +20048,7 @@ function barPlace() {
|
|
|
18634
20048
|
// «‹ Salas» at its start. The three are not keys you place, so the bar's card never offers them.
|
|
18635
20049
|
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
20050
|
const homeOn = () => !state.device && Boolean(document.querySelector('#bar-home'));
|
|
20051
|
+
let homePower = null;
|
|
18637
20052
|
function homeBar() {
|
|
18638
20053
|
const home = document.querySelector('#bar-home');
|
|
18639
20054
|
const bar = document.querySelector('#bar');
|
|
@@ -18670,6 +20085,10 @@ function homeBar() {
|
|
|
18670
20085
|
key.classList.remove('bar-off', 'bar-overflow');
|
|
18671
20086
|
if (key.parentNode !== home) home.append(key);
|
|
18672
20087
|
}
|
|
20088
|
+
// Last, after the settings: turning MADRE off (public/power.js). On the Mac only.
|
|
20089
|
+
if (!homePower) { homePower = powerKey('tree-button'); homePower.id = 'power-button'; }
|
|
20090
|
+
labelPowerKey(homePower);
|
|
20091
|
+
if (homePower.parentNode !== home || (home.lastElementChild && home.lastElementChild !== homePower)) home.append(homePower);
|
|
18673
20092
|
home.hidden = false;
|
|
18674
20093
|
bar.classList.add('with-home');
|
|
18675
20094
|
// The bar beside it leaves exactly its width free, measured whenever it changes (fonts, language).
|
|
@@ -18905,6 +20324,8 @@ function barLayout() {
|
|
|
18905
20324
|
else if (on) { const key = barKit.extras.get(id); key.title = entry.name; key.setAttribute('aria-label', entry.name); }
|
|
18906
20325
|
}
|
|
18907
20326
|
for (const [id, node] of barKit.extras) if (!barKit.order.includes(id)) { node.remove(); barKit.extras.delete(id); }
|
|
20327
|
+
// A key the catalogue leaves out on this device (CODE on a phone) is not drawn at all.
|
|
20328
|
+
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
20329
|
barPlace();
|
|
18909
20330
|
if (barKit.editing) { barBadges(); barDrawCard(); }
|
|
18910
20331
|
barFit();
|
|
@@ -18941,6 +20362,7 @@ function barSpills(bar) {
|
|
|
18941
20362
|
function barFit() {
|
|
18942
20363
|
const bar = barKit.node;
|
|
18943
20364
|
if (!bar || !barKit.more || typeof bar.getBoundingClientRect !== 'function') return;
|
|
20365
|
+
bar.classList?.remove('bar-tight');
|
|
18944
20366
|
for (const node of bar.querySelectorAll?.('.bar-overflow') ?? []) node.classList.remove('bar-overflow');
|
|
18945
20367
|
barKit.hidden = [];
|
|
18946
20368
|
barKit.more.hidden = true;
|
|
@@ -18956,6 +20378,9 @@ function barFit() {
|
|
|
18956
20378
|
}
|
|
18957
20379
|
if (!barKit.hidden.length) { barKit.more.hidden = true; barCloseMore(); }
|
|
18958
20380
|
else if (barKit.moreCard && !barKit.moreCard.hidden) barDrawMore();
|
|
20381
|
+
// Everything that can go is behind «More» and the project's name is still cut: the pills that
|
|
20382
|
+
// stay (notes to accept) take their short form, so the name reads whole.
|
|
20383
|
+
if (barSpills(bar)) bar.classList?.add('bar-tight');
|
|
18959
20384
|
}
|
|
18960
20385
|
function barDrawMore() {
|
|
18961
20386
|
const catalogue = barCatalogue();
|
|
@@ -18990,6 +20415,20 @@ function barDrawMore() {
|
|
|
18990
20415
|
card.style.left = `${Math.round(Math.max(16, Math.min(window.innerWidth - width - 16, key.right - width)))}px`;
|
|
18991
20416
|
barKit.more.setAttribute('aria-expanded', 'true');
|
|
18992
20417
|
}
|
|
20418
|
+
// The bar folds its keys into 34 px squares at 980 px of its own width (styles.css, @container
|
|
20419
|
+
// bar); the house's keys sit in a bar of their own and fold with it, so one row never holds two
|
|
20420
|
+
// sizes. The conversation's width goes to the root for what the window's width cannot tell.
|
|
20421
|
+
function roomWidth() {
|
|
20422
|
+
const bar = barKit.node;
|
|
20423
|
+
if (!bar || typeof getComputedStyle !== 'function') return;
|
|
20424
|
+
const style = getComputedStyle(bar);
|
|
20425
|
+
const inner = bar.clientWidth - (parseFloat(style.paddingLeft) || 0) - (parseFloat(style.paddingRight) || 0);
|
|
20426
|
+
document.querySelector('#bar-home')?.classList.toggle('keys-square', inner > 0 && inner <= 980);
|
|
20427
|
+
const room = threadBox?.getBoundingClientRect?.().width ?? 0;
|
|
20428
|
+
const root = document.documentElement;
|
|
20429
|
+
root?.classList?.toggle('room-720', room > 0 && room <= 720);
|
|
20430
|
+
root?.classList?.toggle('room-680', room > 0 && room <= 680);
|
|
20431
|
+
}
|
|
18993
20432
|
function barCloseMore() {
|
|
18994
20433
|
if (barKit.moreCard) barKit.moreCard.hidden = true;
|
|
18995
20434
|
barKit.more?.setAttribute('aria-expanded', 'false');
|
|
@@ -19008,6 +20447,8 @@ function barSave(order) {
|
|
|
19008
20447
|
}
|
|
19009
20448
|
// The board answered: modules of yours can take their places, and their names are their own.
|
|
19010
20449
|
function barModulesChanged() {
|
|
20450
|
+
// A module's key beside the version comes and goes with the module (attribution.js).
|
|
20451
|
+
void drawColophonKeys(document.querySelector('#colophon'));
|
|
19011
20452
|
if (modules.items?.length) barKit.modulesSeen = true;
|
|
19012
20453
|
barLayout();
|
|
19013
20454
|
}
|
|
@@ -19115,10 +20556,14 @@ function barDrawCard() {
|
|
|
19115
20556
|
document.body.append(card);
|
|
19116
20557
|
}
|
|
19117
20558
|
const card = barKit.card;
|
|
20559
|
+
card.classList.add('bar-add');
|
|
20560
|
+
// The title and, under it, how the bar is arranged; Done at their right. The keys below.
|
|
19118
20561
|
const head = el('header', 'bar-card-head');
|
|
19119
20562
|
const done = sxButton(t('Done'), () => barEdit(false), { tone: 'primary' });
|
|
19120
20563
|
done.classList.add('bar-done');
|
|
19121
|
-
|
|
20564
|
+
const titles = el('div', 'bar-card-titles');
|
|
20565
|
+
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.')));
|
|
20566
|
+
head.append(titles, done);
|
|
19122
20567
|
const grid = el('div', 'bar-grid');
|
|
19123
20568
|
for (const [id, entry] of barCatalogue()) {
|
|
19124
20569
|
if (id === BAR_FIXED || barKit.order.includes(id)) continue;
|
|
@@ -19131,12 +20576,16 @@ function barDrawCard() {
|
|
|
19131
20576
|
// The crew's own drawing is on its one-button form, not on the spheres.
|
|
19132
20577
|
const drawn = [...(entry.nodes ?? [])].reverse().map((node) => node.querySelector('svg')).find(Boolean)?.cloneNode?.(true);
|
|
19133
20578
|
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
|
|
20579
|
+
// The «+» on its corner, the mirror of the «−» on the keys of the bar.
|
|
20580
|
+
const plus = el('i', 'bar-tile-plus');
|
|
20581
|
+
plus.innerHTML = BAR_PLUS; // trusted: our own SVG string
|
|
20582
|
+
glyph.append(plus);
|
|
19134
20583
|
tile.append(glyph, el('span', 'bar-tile-name', entry.name));
|
|
19135
20584
|
tile.addEventListener('click', () => barAdd(id));
|
|
19136
20585
|
grid.append(tile);
|
|
19137
20586
|
}
|
|
19138
20587
|
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.'))
|
|
20588
|
+
card.replaceChildren(head, grid.children.length ? grid : el('p', 'bar-card-empty', t('Everything is already on the bar.')));
|
|
19140
20589
|
card.hidden = false;
|
|
19141
20590
|
barPlaceCard();
|
|
19142
20591
|
// Focus stays in the card: on the tile that took the place of the one just used, or on Done.
|
|
@@ -19199,7 +20648,7 @@ function barDragStart(event, id) {
|
|
|
19199
20648
|
if (event.button !== 0 || barKit.drag) return;
|
|
19200
20649
|
const node = barShown(id);
|
|
19201
20650
|
if (!node || event.target?.closest?.('.bar-badge')) return;
|
|
19202
|
-
const drag = { id, node, x0: event.clientX, base: 0, moved: false };
|
|
20651
|
+
const drag = { id, node, x0: event.clientX, scroll0: barKit.node.scrollLeft || 0, base: 0, moved: false };
|
|
19203
20652
|
barKit.drag = drag;
|
|
19204
20653
|
drag.move = (moved) => barDragMove(moved);
|
|
19205
20654
|
drag.up = () => barDragEnd({ save: true });
|
|
@@ -19210,13 +20659,27 @@ function barDragStart(event, id) {
|
|
|
19210
20659
|
function barDragMove(event) {
|
|
19211
20660
|
const drag = barKit.drag;
|
|
19212
20661
|
if (!drag) return;
|
|
19213
|
-
const
|
|
20662
|
+
const bar = barKit.node;
|
|
19214
20663
|
if (!drag.moved) {
|
|
19215
|
-
if (Math.abs(
|
|
20664
|
+
if (Math.abs(event.clientX - drag.x0) < 5) return;
|
|
19216
20665
|
drag.moved = true;
|
|
19217
20666
|
drag.base = drag.node.offsetLeft;
|
|
19218
20667
|
drag.node.classList.add('bar-lifted');
|
|
19219
20668
|
}
|
|
20669
|
+
// Near an edge of a bar wider than its box, the bar runs under the key (styles.css: while it is
|
|
20670
|
+
// being arranged it scrolls sideways instead of spilling out of its box).
|
|
20671
|
+
const box = bar.getBoundingClientRect?.();
|
|
20672
|
+
if (box && bar.scrollWidth > bar.clientWidth + 1) {
|
|
20673
|
+
const edge = 36;
|
|
20674
|
+
if (event.clientX < box.left + edge) bar.scrollLeft -= 10;
|
|
20675
|
+
else if (event.clientX > box.right - edge) bar.scrollLeft += 10;
|
|
20676
|
+
}
|
|
20677
|
+
// The key is held between the first and the last key of the bar: it never leaves it.
|
|
20678
|
+
const placed = barKit.order.map((one) => barShown(one)).filter(Boolean);
|
|
20679
|
+
const lo = Math.min(...placed.map((node) => node === drag.node ? drag.base : node.offsetLeft));
|
|
20680
|
+
const hi = Math.max(...placed.map((node) => (node === drag.node ? drag.base : node.offsetLeft) + node.offsetWidth)) - drag.node.offsetWidth;
|
|
20681
|
+
const wanted = drag.base + (event.clientX - drag.x0) + ((bar.scrollLeft || 0) - drag.scroll0);
|
|
20682
|
+
const dx = Math.min(Math.max(wanted, lo), Math.max(lo, hi)) - drag.base;
|
|
19220
20683
|
const centre = drag.base + dx + drag.node.offsetWidth / 2;
|
|
19221
20684
|
const others = barKit.order.filter((one) => one !== drag.id).map((one) => [one, barShown(one)]).filter(([, node]) => node);
|
|
19222
20685
|
const past = others.find(([, node]) => centre < node.offsetLeft + node.offsetWidth / 2)?.[0] ?? null;
|
|
@@ -19264,6 +20727,11 @@ if (barKit.node) {
|
|
|
19264
20727
|
let fitting = 0;
|
|
19265
20728
|
const refit = () => { cancelAnimationFrame?.(fitting); fitting = requestAnimationFrame?.(() => barFit()) ?? 0; };
|
|
19266
20729
|
window.addEventListener?.('resize', refit);
|
|
20730
|
+
// The bar is as wide as the room, not the window (2026-10-17): the code workspace docked beside
|
|
20731
|
+
// the conversation narrows it with the window unmoved, and MODULES ended up under the house's
|
|
20732
|
+
// keys. Measured whenever the bar's own width changes: what fits, the house's keys at the size of
|
|
20733
|
+
// the bar's, and the room's width for the rules that read it (styles.css, room-720 · room-680).
|
|
20734
|
+
if (typeof ResizeObserver === 'function') new ResizeObserver(() => { roomWidth(); refit(); }).observe(barKit.node);
|
|
19267
20735
|
// A key that comes or goes on its own (STOP ALL while agents work, a pill) changes what fits, and
|
|
19268
20736
|
// so does the bar's own font arriving. «More» is left out: it is the fitting's own doing.
|
|
19269
20737
|
if (typeof MutationObserver === 'function') {
|
|
@@ -19276,6 +20744,14 @@ if (barKit.node) {
|
|
|
19276
20744
|
// Right-click on the bar between keys, while it is open, closes it, as a second right-click on a
|
|
19277
20745
|
// key does. While it is open a key does not do what it does: it is being placed, not pressed.
|
|
19278
20746
|
barKit.node.addEventListener('contextmenu', (event) => { if (barKit.editing && !event.barHandled) { event.preventDefault(); barEdit(false); } });
|
|
20747
|
+
// Being arranged, a bar wider than its box scrolls sideways; a mouse's wheel only turns up and
|
|
20748
|
+
// down, so it is turned into sideways there.
|
|
20749
|
+
barKit.node.addEventListener('wheel', (event) => {
|
|
20750
|
+
const bar = barKit.node;
|
|
20751
|
+
if (!barKit.editing || bar.scrollWidth <= bar.clientWidth + 1 || Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
|
|
20752
|
+
bar.scrollLeft += event.deltaY;
|
|
20753
|
+
event.preventDefault();
|
|
20754
|
+
}, { passive: false });
|
|
19279
20755
|
barKit.node.addEventListener('click', (event) => {
|
|
19280
20756
|
if (!barKit.editing && !barKit.swallow) return;
|
|
19281
20757
|
if (barKit.swallow) { barKit.swallow = false; event.preventDefault(); event.stopPropagation(); return; }
|