@jossuealcala/madre 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.en.md +76 -1
  2. package/CHANGELOG.md +76 -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 +1635 -159
  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 +395 -26
  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 +105 -14
  31. package/public/salas-mac.html +9 -1
  32. package/public/salas-mac.js +350 -31
  33. package/public/styles.css +844 -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 +8 -8
  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-sentinel.mjs +7 -3
  76. package/src/server.mjs +617 -48
  77. package/src/setup.mjs +1 -1
@@ -7,7 +7,7 @@
7
7
  // Appropriate Legal Notices (Settings › Updates and errors › License and authorship). One source
8
8
  // for both pages, so the words cannot drift from NOTICE and the terms (test/attribution.test.mjs).
9
9
 
10
- import { t } from './i18n.js';
10
+ import { language, t } from './i18n.js';
11
11
 
12
12
  export const AUTHOR = 'Jossue Alcalá';
13
13
  export const AUTHOR_URL = 'https://jossuealcala.com';
@@ -35,6 +35,49 @@ export function drawAttribution(host, version) {
35
35
  return link;
36
36
  }
37
37
 
38
+ // The keys modules put beside the version (src/modules/sdk.mjs, `footer`, 2026-10-18): drawn here,
39
+ // with MADRE's own icons, so a module never reaches into the page. Each one opens its address in a
40
+ // new tab; hovered or focused, it shows the module's words and the key that goes there. Asked again
41
+ // whenever the modules change.
42
+ const FOOTER_GLYPHS = {
43
+ heart: '<path d="M10 16.4s-6-3.5-6-7.9A3.3 3.3 0 0 1 10 6.6a3.3 3.3 0 0 1 6 1.9c0 4.4-6 7.9-6 7.9z" fill="currentColor"/>',
44
+ star: '<path d="M10 3.4l2 4.2 4.6.6-3.4 3.2.9 4.6L10 13.8 5.9 16l.9-4.6-3.4-3.2 4.6-.6z" fill="currentColor"/>',
45
+ link: '<path d="M8.6 11.4l2.8-2.8M9 5.8l1.5-1.5a3 3 0 0 1 4.2 4.2L13.2 10M11 14.2l-1.5 1.5a3 3 0 0 1-4.2-4.2L6.8 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>',
46
+ };
47
+ const footerWords = (value) => (value == null ? '' : typeof value === 'string' ? value : value[language()] ?? value.en ?? value.es ?? '');
48
+ export async function drawColophonKeys(host) {
49
+ if (!host) return null;
50
+ const answer = await fetch('/api/modules/footer', { cache: 'no-store' }).then((response) => (response.ok ? response.json() : null)).catch(() => null);
51
+ host.querySelector('.colophon-keys')?.remove();
52
+ const keys = (answer?.keys ?? []).flatMap((module) => (module.footer ?? []).map((key) => ({ ...key, module: module.id })));
53
+ if (!keys.length) return null;
54
+ const make = (tag, className, text) => { const node = document.createElement(tag); if (className) node.className = className; if (text != null) node.textContent = text; return node; };
55
+ const box = make('span', 'colophon-keys');
56
+ for (const key of keys) {
57
+ const label = footerWords(key.label);
58
+ const one = make('span', `colophon-key ${key.icon}`);
59
+ const press = make('a', 'colophon-key-mark');
60
+ press.href = key.url;
61
+ press.target = '_blank';
62
+ press.rel = 'noopener';
63
+ press.setAttribute('aria-label', label);
64
+ press.innerHTML = `<svg viewBox="0 0 20 20" aria-hidden="true">${FOOTER_GLYPHS[key.icon] ?? FOOTER_GLYPHS.link}</svg>`; // trusted: our own SVG strings
65
+ const card = make('span', 'colophon-card');
66
+ card.setAttribute('role', 'tooltip');
67
+ card.append(make('b', null, label));
68
+ if (footerWords(key.text)) card.append(make('span', null, footerWords(key.text)));
69
+ const go = make('a', 'colophon-card-go', footerWords(key.action) || label);
70
+ go.href = key.url;
71
+ go.target = '_blank';
72
+ go.rel = 'noopener';
73
+ card.append(go);
74
+ one.append(press, card);
75
+ box.append(one);
76
+ }
77
+ host.append(box);
78
+ return box;
79
+ }
80
+
38
81
  // The Appropriate Legal Notices (AGPL v3, section 0 and 5(d)): the copyright, that there is no
39
82
  // warranty, that it may be shared under the License, how to read it — and, by term 1(b) of the
40
83
  // additional terms, the author's attribution. Built with the Settings blocks both pages share.
package/public/brands.js CHANGED
@@ -3,7 +3,8 @@
3
3
  // Visual identity per agent.
4
4
  //
5
5
  // Colors follow each vendor's public palette so a Codex bubble reads as Codex
6
- // and a Claude bubble as Claude. The marks are MADRE's own monograms: the
6
+ // and a Claude bubble as Claude. The marks are drawn by MADRE: free takes that
7
+ // recall each vendor's mark without reproducing it (2026-10-17), because the
7
8
  // vendors' logos are registered trademarks and their brand guidelines do not
8
9
  // allow embedding them in third-party products without permission. To use an
9
10
  // official asset under a license you hold, set `mark` to that inline SVG.
@@ -20,7 +21,8 @@ export const BRANDS = {
20
21
  color: '#10A37F',
21
22
  colorLight: '#0B7A5F',
22
23
  ink: '#FFFFFF',
23
- mark: () => glyph('<path fill="currentColor" d="M16 4l10.4 6v12L16 28 5.6 22V10L16 4zm0 3.4L8.5 11.7v8.6L16 24.6l7.5-4.3v-8.6L16 7.4zm0 4.2l3.9 2.2v4.4L16 20.4l-3.9-2.2v-4.4L16 11.6z"/>'),
24
+ // A free take on OpenAI's blossom (2026-10-17): three rounded links turned 60° and knotted.
25
+ mark: () => glyph('<g fill="none" stroke="currentColor" stroke-width="2.4"><rect x="11.2" y="4.4" width="9.6" height="23.2" rx="4.8"/><rect x="11.2" y="4.4" width="9.6" height="23.2" rx="4.8" transform="rotate(60 16 16)"/><rect x="11.2" y="4.4" width="9.6" height="23.2" rx="4.8" transform="rotate(120 16 16)"/></g>'),
24
26
  },
25
27
  claude: {
26
28
  label: 'Claude',
@@ -28,7 +30,8 @@ export const BRANDS = {
28
30
  color: '#D97757',
29
31
  colorLight: '#B85C3C',
30
32
  ink: '#FFFFFF',
31
- mark: () => glyph('<path fill="currentColor" d="M15 5h2l1.2 8.3 6.1-5.7 1.4 1.4-5.7 6.1L28.3 16v2l-8.3 1.2 5.7 6.1-1.4 1.4-6.1-5.7L17 29h-2l-1.2-8.3-6.1 5.7-1.4-1.4 5.7-6.1L3.7 18v-2l8.3-1.2-5.7-6.1 1.4-1.4 6.1 5.7L15 5z" opacity=".95"/>'),
33
+ // A free take on Claude's spark (2026-10-17): ten tapered rays of uneven length round a heart.
34
+ mark: () => glyph('<circle cx="16" cy="16" r="3" fill="currentColor"/><path fill="currentColor" d="M15.5 14.48L16.4 2.61L17.4 2.07L18.27 2.79L16.8 14.61ZM16.49 14.48L22.85 7.14L23.85 7.19L24.02 8.18L17.46 15.34ZM17.29 15.05L28.29 12.41L29.09 13.15L28.67 14.16L17.57 16.33ZM17.6 15.99L25.98 19.58L26.25 20.51L25.38 20.93L17.07 17.19ZM17.3 16.93L23.66 27.24L23.16 28.26L22.02 28.2L16.17 17.59ZM16.5 17.52L15.65 27.59L14.78 28.14L14.04 27.43L15.2 17.39ZM15.51 17.52L8.41 25.81L7.35 25.7L7.12 24.65L14.54 16.66ZM14.71 16.95L6.02 18.92L5.25 18.34L5.71 17.49L14.43 15.67ZM14.4 16.01L3.57 11.55L3.37 10.45L4.32 9.86L14.93 14.81ZM14.7 15.07L9.8 6.91L10.15 5.98L11.13 6.14L15.83 14.41Z"/>'),
32
35
  },
33
36
  gemini: {
34
37
  label: 'Gemini',
@@ -37,7 +40,8 @@ export const BRANDS = {
37
40
  colorLight: '#5B45B8',
38
41
  ink: '#FFFFFF',
39
42
  gradient: 'linear-gradient(135deg, #4796E3 0%, #8E75E8 55%, #D96570 100%)',
40
- mark: () => glyph('<path fill="currentColor" d="M16 3c.6 7.1 5.9 12.4 13 13-7.1.6-12.4 5.9-13 13-.6-7.1-5.9-12.4-13-13 7.1-.6 12.4-5.9 13-13z"/>'),
43
+ // A free take on Gemini's sparkle (2026-10-17): four points in long curves, taller than wide.
44
+ mark: () => glyph('<path fill="currentColor" d="M16 2.4C16.8 10.2 21.4 15 29 16 21.4 17 16.8 21.8 16 29.6 15.2 21.8 10.6 17 3 16 10.6 15 15.2 10.2 16 2.4z"/>'),
41
45
  },
42
46
  opencode: {
43
47
  label: 'OpenCode',
@@ -45,7 +49,18 @@ export const BRANDS = {
45
49
  color: '#F2C94C',
46
50
  colorLight: '#9A7A12',
47
51
  ink: '#111111',
48
- mark: () => glyph('<path fill="currentColor" d="M11 8l-7 8 7 8 2.2-2.2L7.4 16l5.8-5.8L11 8zm10 0l-2.2 2.2 5.8 5.8-5.8 5.8L21 24l7-8-7-8zm-3.6-1.5l2.3.6-5.1 18.4-2.3-.6 5.1-18.4z"/>'),
52
+ // A free take on OpenCode's block letter (2026-10-17): a square ring with a lighter block inside.
53
+ mark: () => glyph('<path fill="currentColor" fill-rule="evenodd" d="M6 6h20v20H6zM10.4 10.4v11.2h11.2V10.4z"/><path fill="currentColor" opacity=".5" d="M10.4 15.2h11.2v6.4H10.4z"/>'),
54
+ },
55
+ // MADRE's own drawing for Cursor (2026-10-17), a free take on its mark rather than the mark: a
56
+ // cube seen from above, its three faces in three weights, and a pointer cut out of the right face.
57
+ cursor: {
58
+ label: 'Cursor',
59
+ vendor: 'Cursor',
60
+ color: '#D9DEE5',
61
+ colorLight: '#3A3F47',
62
+ ink: '#111111',
63
+ mark: () => glyph('<path fill="currentColor" d="M16 3.2 27.4 9.6 16 16 4.6 9.6z"/><path fill="currentColor" opacity=".42" d="M4.6 9.6 16 16v12.8L4.6 22.4z"/><path fill="currentColor" fill-rule="evenodd" opacity=".7" d="M27.4 9.6v12.8L16 28.8V16zM16 16l11.4-6.4-6.2 12-1.9-3.9z"/>'),
49
64
  },
50
65
  };
51
66
 
@@ -0,0 +1,143 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // MADRE · © 2026 Jossue Alcalá · GNU AGPL v3 with additional terms (Section 7): ADDITIONAL-TERMS.md
3
+ // The code workspace's arithmetic (1.0.3), kept apart from the page so it can be tested on its own:
4
+ // what an agent changed in a file line by line, where each of those changes stands in the file as
5
+ // it is now, what the file reads like with one of them taken back, and which function a line is in.
6
+ // Nothing here touches the page, the network or the disk.
7
+
8
+ const lines = (text) => String(text ?? '').replace(/\r\n/g, '\n').split('\n');
9
+
10
+ // The changes between two texts as runs of lines: each says where it starts on each side and what
11
+ // it removed and put. The common start and end are set aside first; what is left is compared line
12
+ // by line (longest common subsequence). Past `maxCells` comparisons the middle is one change: a
13
+ // file rewritten that much is read as rewritten, not taken apart.
14
+ export function lineDiff(beforeText, afterText, { maxCells = 2_000_000 } = {}) {
15
+ const a = lines(beforeText);
16
+ const b = lines(afterText);
17
+ let start = 0;
18
+ while (start < a.length && start < b.length && a[start] === b[start]) start += 1;
19
+ let endA = a.length;
20
+ let endB = b.length;
21
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA -= 1; endB -= 1; }
22
+ const A = a.slice(start, endA);
23
+ const B = b.slice(start, endB);
24
+ if (!A.length && !B.length) return [];
25
+ if (A.length * B.length > maxCells) return [{ beforeStart: start, before: A, afterStart: start, after: B }];
26
+ const n = A.length;
27
+ const m = B.length;
28
+ const width = m + 1;
29
+ const table = new Uint32Array((n + 1) * width);
30
+ for (let i = n - 1; i >= 0; i -= 1) {
31
+ for (let j = m - 1; j >= 0; j -= 1) {
32
+ table[i * width + j] = A[i] === B[j] ? table[(i + 1) * width + j + 1] + 1 : Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
33
+ }
34
+ }
35
+ const hunks = [];
36
+ let open = null;
37
+ let i = 0;
38
+ let j = 0;
39
+ while (i < n || j < m) {
40
+ if (i < n && j < m && A[i] === B[j]) { if (open) { hunks.push(open); open = null; } i += 1; j += 1; continue; }
41
+ if (!open) open = { beforeStart: start + i, before: [], afterStart: start + j, after: [] };
42
+ if (j < m && (i === n || table[i * width + j + 1] >= table[(i + 1) * width + j])) { open.after.push(B[j]); j += 1; }
43
+ else { open.before.push(A[i]); i += 1; }
44
+ }
45
+ if (open) hunks.push(open);
46
+ return hunks;
47
+ }
48
+
49
+ // Where each change of an agent stands in the file as it is now. Untouched since the turn, a change
50
+ // is where the turn left it. Edited since, it is found by its own lines, nearest to where it was; a
51
+ // change whose lines are no longer there (the human rewrote them) is no longer the agent's, and is
52
+ // left out. A change that only removed lines is found by the line the turn left just before it.
53
+ export function placeHunks(currentText, afterText, hunks) {
54
+ const now = lines(currentText);
55
+ if (afterText === null || afterText === undefined || String(afterText).replace(/\r\n/g, '\n') === now.join('\n')) return hunks.map((hunk) => ({ ...hunk, at: hunk.afterStart }));
56
+ const after = lines(afterText);
57
+ const out = [];
58
+ for (const hunk of hunks) {
59
+ let at = -1;
60
+ if (hunk.after.length) at = nearest(now, hunk.after, hunk.afterStart);
61
+ else {
62
+ const before = hunk.afterStart > 0 ? after[hunk.afterStart - 1] : null;
63
+ if (before === null) at = now.length && now[0] === after[0] ? 0 : -1;
64
+ else { const found = nearest(now, [before], hunk.afterStart - 1); at = found < 0 ? -1 : found + 1; }
65
+ }
66
+ if (at >= 0) out.push({ ...hunk, at });
67
+ }
68
+ return out;
69
+ }
70
+ function nearest(haystack, block, hint) {
71
+ let best = -1;
72
+ for (let k = 0; k + block.length <= haystack.length; k += 1) {
73
+ let same = true;
74
+ for (let x = 0; x < block.length; x += 1) if (haystack[k + x] !== block[x]) { same = false; break; }
75
+ if (same && (best < 0 || Math.abs(k - hint) < Math.abs(best - hint))) best = k;
76
+ }
77
+ return best;
78
+ }
79
+
80
+ // The file with one change taken back: the lines the agent put, where they stand now, become the
81
+ // lines it removed. Everything else stays byte for byte, line endings included.
82
+ export function revertHunk(currentText, hunk) {
83
+ const text = String(currentText ?? '');
84
+ const crlf = /\r\n/.test(text);
85
+ const now = lines(text);
86
+ now.splice(hunk.at, hunk.after.length, ...hunk.before);
87
+ return now.join(crlf ? '\r\n' : '\n');
88
+ }
89
+
90
+ // A change named by what it says, so «kept» survives a reload and never sticks to another change.
91
+ export function hunkKey(hunk) {
92
+ let hash = 0x811c9dc5;
93
+ const said = `${hunk.before.join('\n')}\u0000${hunk.after.join('\n')}`;
94
+ for (let i = 0; i < said.length; i += 1) {
95
+ hash ^= said.charCodeAt(i);
96
+ hash = Math.imul(hash, 0x01000193) >>> 0;
97
+ }
98
+ return hash.toString(16).padStart(8, '0');
99
+ }
100
+
101
+ // The function, class or heading a line belongs to, for the path above the code: the nearest line
102
+ // above it that opens one and is indented no deeper. `null` when nothing encloses it.
103
+ const OPENS = {
104
+ js: [
105
+ /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/,
106
+ /^\s*(?:export\s+)?(?:default\s+)?class\s+([A-Za-z_$][\w$]*)/,
107
+ /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/,
108
+ /^\s*(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?(#?[A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{\s*$/,
109
+ ],
110
+ py: [/^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)/, /^\s*class\s+([A-Za-z_]\w*)/],
111
+ css: [/^\s*([^{}@\s][^{}]*?)\s*\{\s*$/, /^\s*(@media[^{]*?)\s*\{/],
112
+ sh: [/^\s*(?:function\s+)?([A-Za-z_][\w-]*)\s*\(\)\s*\{?/],
113
+ };
114
+ const NOT_NAMES = new Set(['if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'else', 'do', 'try']);
115
+ export function symbolAt(textOrLines, index, familyId) {
116
+ const all = Array.isArray(textOrLines) ? textOrLines : lines(textOrLines);
117
+ if (index < 0 || index >= all.length) return null;
118
+ if (familyId === 'md') {
119
+ for (let k = index; k >= 0; k -= 1) { const heading = all[k].match(/^#{1,6}\s+(.+?)\s*#*\s*$/); if (heading) return heading[1]; }
120
+ return null;
121
+ }
122
+ const patterns = OPENS[familyId];
123
+ if (!patterns) return null;
124
+ const depth = (line) => line.match(/^\s*/)[0].replace(/\t/g, ' ').length;
125
+ let limit = all[index].trim() ? depth(all[index]) : Infinity;
126
+ // A block closed above the line (a sibling function that ended) is skipped whole: its closing
127
+ // line is counted at its depth, and the opening line at that depth that matches it is passed over.
128
+ const closed = new Map();
129
+ for (let k = index; k >= 0 && index - k < 4000; k -= 1) {
130
+ const line = all[k];
131
+ const said = line.trim();
132
+ if (!said) continue;
133
+ const indent = depth(line);
134
+ if (k !== index && indent > limit) continue;
135
+ if (k !== index && /^[}\])]/.test(said) && !/[{([]\s*$/.test(said)) { closed.set(indent, (closed.get(indent) ?? 0) + 1); continue; }
136
+ const name = patterns.map((pattern) => said && line.match(pattern)?.[1]).find(Boolean) ?? null;
137
+ const opens = Boolean(name) || /[{([]\s*$/.test(said) || (familyId === 'py' && /:\s*$/.test(said));
138
+ if (k !== index && opens && (closed.get(indent) ?? 0) > 0) { closed.set(indent, closed.get(indent) - 1); continue; }
139
+ if (name && !NOT_NAMES.has(name)) return name.trim().slice(0, 80);
140
+ if (k !== index) limit = Math.min(limit, indent);
141
+ }
142
+ return null;
143
+ }
package/public/editor.js CHANGED
@@ -35,6 +35,8 @@ const ICONS = {
35
35
  wrap: svg('M3.5 5.5h13M3.5 10h10a2.6 2.6 0 0 1 0 5.2H10M11.8 13.3 10 15.2l1.8 1.8M3.5 15h3.5'),
36
36
  ask: svg('M4 5.6A2.6 2.6 0 0 1 6.6 3h6.8A2.6 2.6 0 0 1 16 5.6v4.8A2.6 2.6 0 0 1 13.4 13H9.2L5.6 16v-3A2.6 2.6 0 0 1 4 10.4z'),
37
37
  link: svg('M8.3 11.7l3.4-3.4M7.2 13.2 6 14.4a2.6 2.6 0 0 1-3.7-3.7l2.6-2.6a2.6 2.6 0 0 1 3.7 0M12.8 6.8 14 5.6a2.6 2.6 0 0 1 3.7 3.7l-2.6 2.6a2.6 2.6 0 0 1-3.7 0'),
38
+ // The room's chooser chevron (app.js, ICON_CHEVRON), drawn on this grid: a menu opens under it.
39
+ chevron: svg('M5.25 7.75 10 12.5l4.75-4.75'),
38
40
  };
39
41
 
40
42
  // Spaces or tabs, and how many: what the file already does, not what the editor prefers.
@@ -104,7 +106,11 @@ export function findAll(text, query, { caseSensitive = false, regex = false, wor
104
106
  return found;
105
107
  }
106
108
 
107
- export function createEditor({ text = '', name = '', family = null, tokenize = null, t = (s) => s, fill = null, onSave = null, onAsk = null, onDirty = null }) {
109
+ // `draft`: what the human had written and not saved, kept by the room while another file was on
110
+ // screen (the code workspace's tabs); the file on disk stays `text`, so the editor knows it is unsaved.
111
+ // `onCaret(line)`: the line the caret is on, whenever it moves. `setMarks` paints lines in the margin
112
+ // (an agent's changes, in its colour) without touching the text.
113
+ export function createEditor({ text = '', draft = null, name = '', family = null, tokenize = null, t = (s) => s, fill = null, onSave = null, onAsk = null, onDirty = null, onCaret = null }) {
108
114
  const say = (key, vars) => (vars && fill ? fill(t(key), vars) : t(key));
109
115
  const crlf = /\r\n/.test(text);
110
116
  const indent = detectIndent(text);
@@ -155,7 +161,7 @@ export function createEditor({ text = '', name = '', family = null, tokenize = n
155
161
  <div class="ed-code">
156
162
  <div class="ed-active" aria-hidden="true"></div>
157
163
  <div class="ed-float" hidden>
158
- <button type="button" class="ed-chip ask" data-float="ask"><i class="ed-ico">${ICONS.ask}</i><span></span><b>▾</b></button>
164
+ <button type="button" class="ed-chip ask" data-float="ask"><i class="ed-ico">${ICONS.ask}</i><span></span><i class="ed-chev">${ICONS.chevron}</i></button>
159
165
  <button type="button" class="ed-chip" data-float="comment"><i class="ed-ico">${ICONS.comment}</i><span></span></button>
160
166
  <button type="button" class="ed-chip" data-float="copy"><i class="ed-ico">${ICONS.link}</i><span></span></button>
161
167
  </div>
@@ -202,7 +208,7 @@ export function createEditor({ text = '', name = '', family = null, tokenize = n
202
208
  $('.ed-indent').textContent = indent.tabs ? t('Tabs') : say('Spaces: {n}', { n: indent.size });
203
209
  $('.ed-eol').textContent = crlf ? 'CRLF' : 'LF';
204
210
  $('.ed-lang').textContent = family?.label ?? t('Plain text');
205
- input.value = saved;
211
+ input.value = draft === null || draft === undefined ? saved : String(draft).replace(/\r\n/g, '\n');
206
212
  // A textarea given its text holds the caret after the last character; the first focus would jump
207
213
  // there. Editing starts where reading did: at the top, or at the line asked for.
208
214
  input.setSelectionRange?.(0, 0);
@@ -242,6 +248,8 @@ export function createEditor({ text = '', name = '', family = null, tokenize = n
242
248
  return html;
243
249
  }
244
250
  let lineCount = 1;
251
+ let lastCaret = 0;
252
+ let marks = new Map();
245
253
  function countLines() { return lineCount; }
246
254
  let frame = 0;
247
255
  function draw() {
@@ -264,7 +272,8 @@ export function createEditor({ text = '', name = '', family = null, tokenize = n
264
272
  const here = [];
265
273
  for (let j = markAt; j < matches.length && matches[j].start < end; j += 1) here.push({ ...matches[j], cur: j === current });
266
274
  html.push(`<div class="l">${drawLine(line, carry, offset, here) || ' '}</div>`);
267
- numbers.push(`<div>${i + 1}</div>`);
275
+ const mark = marks.get(i + 1);
276
+ numbers.push(mark ? `<div class="mk" style="--mk:${mark.color}" title="${escape(mark.title ?? '')}">${i + 1}</div>` : `<div>${i + 1}</div>`);
268
277
  offset = end + 1;
269
278
  }
270
279
  hl.innerHTML = html.join('');
@@ -324,6 +333,7 @@ export function createEditor({ text = '', name = '', family = null, tokenize = n
324
333
  const line = caretLine(at);
325
334
  const col = at - (value.lastIndexOf('\n', at - 1) + 1) + 1;
326
335
  $('.ed-pos').textContent = say('Ln {line}, Col {col}', { line, col });
336
+ if (line !== lastCaret) { lastCaret = line; onCaret?.(line); }
327
337
  const chosen = input.selectionEnd - input.selectionStart;
328
338
  $('.ed-sel').textContent = chosen ? say('{n} selected', { n: chosen }) : '';
329
339
  const dirty = isDirty();
@@ -740,6 +750,12 @@ export function createEditor({ text = '', name = '', family = null, tokenize = n
740
750
  save,
741
751
  // The file on disk changed under the editor and the human chose the disk's version.
742
752
  reset(next) { saved = String(next).replace(/\r\n/g, '\n'); input.value = saved; changed(); },
753
+ // Lines to mark in the margin: [{ from, to, color, title }], one-based. An empty list clears them.
754
+ setMarks(list = []) {
755
+ marks = new Map();
756
+ for (const one of list) for (let n = one.from; n <= one.to; n += 1) marks.set(n, { color: /^[#\w(),.%\s-]{1,40}$/.test(one.color ?? '') ? one.color : 'currentColor', title: one.title ?? '' });
757
+ draw();
758
+ },
743
759
  // Taken off the page: what it listened to outside itself goes with it.
744
760
  destroy() { document.removeEventListener('selectionchange', followSelection); resized?.disconnect(); cancelAnimationFrame(frame); },
745
761
  };