@jossuealcala/madre 1.0.2 → 1.0.3

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