@jossuealcala/madre 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.en.md +76 -1
- package/CHANGELOG.md +76 -1
- package/README.en.md +11 -15
- package/README.md +3 -3
- package/bin/madre.cjs +40 -0
- package/bin/madre.mjs +5 -0
- package/docs/REFERENCE.md +29 -3
- package/docs/SDK.md +4 -2
- package/docs/en/REFERENCE.md +29 -3
- package/docs/en/SDK.md +4 -2
- package/docs/training/README.md +28 -1
- package/docs/training/train.sh +5 -1
- package/package.json +3 -3
- package/public/app.js +1635 -159
- package/public/attribution.js +44 -1
- package/public/brands.js +20 -5
- package/public/code-tools.js +143 -0
- package/public/editor.js +20 -4
- package/public/es.js +395 -26
- package/public/hardware.js +50 -0
- package/public/index.html +5 -1
- package/public/macroverso.js +187 -96
- package/public/market-art.js +161 -0
- package/public/market-view.js +529 -0
- package/public/market.js +189 -0
- package/public/neural.js +110 -0
- package/public/nostromo-engine.js +412 -47
- package/public/power.js +100 -0
- package/public/resay.js +5 -0
- package/public/salas-mac.css +105 -14
- package/public/salas-mac.html +9 -1
- package/public/salas-mac.js +350 -31
- package/public/styles.css +844 -61
- package/public/tour.js +48 -6
- package/public/troubleshooting.js +6 -1
- package/public/wallpaper.js +275 -0
- package/src/adapters/cursor.mjs +96 -0
- package/src/adapters/gemini.mjs +10 -3
- package/src/adapters/madre.mjs +2 -1
- package/src/auth-probe.mjs +143 -12
- package/src/bridge/policy.mjs +1 -1
- package/src/capabilities.mjs +9 -0
- package/src/checkpoint.mjs +16 -1
- package/src/credentials.mjs +3 -0
- package/src/dataset.mjs +76 -4
- package/src/enlaces.mjs +208 -2
- package/src/extensions.mjs +11 -3
- package/src/i18n.mjs +16 -0
- package/src/looks.mjs +125 -2
- package/src/market-key.mjs +8 -0
- package/src/market-remote.mjs +239 -0
- package/src/models.mjs +4 -0
- package/src/module-scope.mjs +68 -0
- package/src/modules/ahp.mjs +102 -7
- package/src/modules/bishop-exam.mjs +122 -0
- package/src/modules/bishop.mjs +71 -12
- package/src/modules/enlaces.mjs +87 -6
- package/src/modules/image-studio.mjs +10 -1
- package/src/modules/intents.mjs +3 -1
- package/src/modules/local-models.mjs +124 -0
- package/src/modules/ollama.mjs +12 -3
- package/src/modules/playwright.mjs +73 -15
- package/src/modules/sdk.mjs +61 -9
- package/src/npm-launch.mjs +18 -0
- package/src/ollama.mjs +6 -5
- package/src/outbound.mjs +9 -1
- package/src/ports.mjs +8 -8
- package/src/room/control.mjs +1 -1
- package/src/room/prompt.mjs +13 -2
- package/src/room.mjs +14 -4
- package/src/router.mjs +1 -1
- package/src/runs.mjs +4 -1
- package/src/runtime-detection.mjs +9 -0
- package/src/salas.mjs +26 -0
- package/src/server-sentinel.mjs +7 -3
- package/src/server.mjs +617 -48
- package/src/setup.mjs +1 -1
package/public/attribution.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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><
|
|
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
|
-
|
|
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
|
};
|