@jossuealcala/madre 0.7.0 → 0.7.2
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.md +68 -1
- package/SECURITY.md +1 -0
- package/docs/REFERENCE.md +4 -1
- package/package.json +1 -1
- package/public/app.js +467 -65
- package/public/es.js +102 -5
- package/public/styles.css +126 -19
- package/src/adapters/claude.mjs +4 -3
- package/src/adapters/codex.mjs +7 -4
- package/src/adapters/process.mjs +5 -2
- package/src/choices.mjs +30 -6
- package/src/file-ops.mjs +19 -0
- package/src/models.mjs +25 -1
- package/src/modules/index.mjs +2 -1
- package/src/modules/ollama.mjs +5 -1
- package/src/modules/runs.mjs +34 -0
- package/src/ollama.mjs +19 -0
- package/src/room/control.mjs +18 -1
- package/src/room/prompt.mjs +16 -4
- package/src/room/runs.mjs +88 -0
- package/src/room.mjs +73 -11
- package/src/runs.mjs +119 -0
- package/src/server.mjs +55 -7
package/public/app.js
CHANGED
|
@@ -133,8 +133,11 @@ const state = {
|
|
|
133
133
|
ashInstalled: false,
|
|
134
134
|
ash: false, // Ash asked for on this message: compact replies, nothing rewritten
|
|
135
135
|
chosenModel: {}, // id -> model name picked in the composer
|
|
136
|
+
chosenEffort: {}, // id -> effort level picked in the composer (null: the CLI's own)
|
|
137
|
+
efforts: {}, // id -> { levels, default } from /api/models; no levels, no chip
|
|
136
138
|
};
|
|
137
139
|
try { state.chosenModel = JSON.parse(localStorage.getItem('pulse.chosenModel') ?? '{}') || {}; } catch { state.chosenModel = {}; }
|
|
140
|
+
try { state.chosenEffort = JSON.parse(localStorage.getItem('pulse.chosenEffort') ?? '{}') || {}; } catch { state.chosenEffort = {}; }
|
|
138
141
|
|
|
139
142
|
// What the human has already dealt with. The ledger is history and is never rewritten, so a
|
|
140
143
|
// dismissal is a reading preference and lives where reading preferences live: this browser.
|
|
@@ -1355,6 +1358,8 @@ function renderPicker() {
|
|
|
1355
1358
|
modelChip.addEventListener('click', (event) => { event.stopPropagation(); toggleModelMenu(current.id, modelChip); });
|
|
1356
1359
|
text.append(modelChip);
|
|
1357
1360
|
}
|
|
1361
|
+
const effortChip = renderEffortChip(current.id);
|
|
1362
|
+
if (effortChip) text.append(effortChip);
|
|
1358
1363
|
els.picker.append(text);
|
|
1359
1364
|
}
|
|
1360
1365
|
if (typeof renderCreateScopes === 'function') renderCreateScopes();
|
|
@@ -1373,6 +1378,7 @@ async function ensureModels(id) {
|
|
|
1373
1378
|
try {
|
|
1374
1379
|
const data = await fetch(`/api/models${id === 'opencode' ? '?opencode=1' : ''}`).then((response) => response.json());
|
|
1375
1380
|
state.models = { ...state.models, ...(data.models ?? {}) };
|
|
1381
|
+
takeEfforts(data.models);
|
|
1376
1382
|
} catch { /* offline: fall back to whatever we know */ }
|
|
1377
1383
|
return state.models[id] ?? { models: [], default: null, note: '' };
|
|
1378
1384
|
}
|
|
@@ -1419,10 +1425,144 @@ function placeModelMenu(anchor) {
|
|
|
1419
1425
|
modelMenu.style.left = `${Math.max(12, Math.min(window.innerWidth - width - 12, rect.left))}px`;
|
|
1420
1426
|
modelMenu.style.top = `${Math.max(12, rect.top - height - 10)}px`;
|
|
1421
1427
|
}
|
|
1422
|
-
function closeModelMenu() { modelMenu.hidden = true; modelMenuFor = null; }
|
|
1428
|
+
function closeModelMenu() { modelMenu.hidden = true; modelMenuFor = null; closeEffortMenu(); }
|
|
1423
1429
|
document.addEventListener('click', (event) => { if (!modelMenu.hidden && !modelMenu.contains(event.target) && !event.target.closest('.picker')) closeModelMenu(); });
|
|
1424
1430
|
document.addEventListener('keydown', (event) => { if (event.key === 'Escape') closeModelMenu(); });
|
|
1425
1431
|
|
|
1432
|
+
/* ---------- effort menu: how hard the model thinks before it answers ---------- */
|
|
1433
|
+
|
|
1434
|
+
// Beside the model, because it is the same kind of decision: what this request is worth. Only
|
|
1435
|
+
// the CLIs that take it per run get the chip (Claude Code's --effort, Codex's reasoning effort);
|
|
1436
|
+
// the levels come from the server so the chip never offers a flag the CLI would refuse.
|
|
1437
|
+
//
|
|
1438
|
+
// "Default" sends nothing, and the CLI does what its own config says. The chip shows that level
|
|
1439
|
+
// dimmed when MADRE can read it (Codex's config.toml), so the human sees what they will get
|
|
1440
|
+
// without having chosen it.
|
|
1441
|
+
const EFFORT_WORDS = {
|
|
1442
|
+
minimal: { label: 'Minimal', hint: 'Answers almost at once. For lookups and one-line edits.' },
|
|
1443
|
+
low: { label: 'Low', hint: 'Quick and light. For clear, contained requests.' },
|
|
1444
|
+
medium: { label: 'Medium', hint: 'The everyday balance of speed and depth.' },
|
|
1445
|
+
high: { label: 'High', hint: 'Thinks it through. For design calls and tricky bugs.' },
|
|
1446
|
+
xhigh: { label: 'Extra high', hint: 'Longer reasoning for hard, multi-step problems.' },
|
|
1447
|
+
max: { label: 'Max', hint: 'Everything it has. The slowest and the most expensive.' },
|
|
1448
|
+
};
|
|
1449
|
+
const effortMenu = el('div', 'model-menu effort-menu');
|
|
1450
|
+
effortMenu.hidden = true;
|
|
1451
|
+
document.body.append(effortMenu);
|
|
1452
|
+
let effortMenuFor = null;
|
|
1453
|
+
let effortsAsked = false;
|
|
1454
|
+
|
|
1455
|
+
function takeEfforts(models) {
|
|
1456
|
+
for (const [id, info] of Object.entries(models ?? {})) {
|
|
1457
|
+
state.efforts[id] = { levels: Array.isArray(info.efforts) ? info.efforts : [], default: info.effortDefault ?? null };
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// Asked once per page: the chip has to know whether to exist before anyone opens a menu. The
|
|
1462
|
+
// plain listing is enough — it never runs `opencode models`, which is the slow one.
|
|
1463
|
+
function ensureEfforts() {
|
|
1464
|
+
if (effortsAsked) return;
|
|
1465
|
+
effortsAsked = true;
|
|
1466
|
+
fetch('/api/models').then((response) => response.json()).then((data) => { takeEfforts(data.models); renderPicker(); }).catch(() => {});
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
function effortMeter(levels, level, { ghost = false } = {}) {
|
|
1470
|
+
const meter = el('span', `effort-meter${ghost ? ' ghost' : ''}`);
|
|
1471
|
+
meter.setAttribute('aria-hidden', 'true');
|
|
1472
|
+
const at = levels.indexOf(level);
|
|
1473
|
+
levels.forEach((_, index) => meter.append(el('i', index <= at ? 'on' : null)));
|
|
1474
|
+
meter.style.setProperty('--bars', String(levels.length));
|
|
1475
|
+
return meter;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function effortLabel(level) { return t(EFFORT_WORDS[level]?.label ?? level); }
|
|
1479
|
+
|
|
1480
|
+
function renderEffortChip(id) {
|
|
1481
|
+
const info = state.efforts[id];
|
|
1482
|
+
if (!info) { ensureEfforts(); return null; }
|
|
1483
|
+
if (!info.levels.length) return null;
|
|
1484
|
+
const chosen = info.levels.includes(state.chosenEffort[id]) ? state.chosenEffort[id] : null;
|
|
1485
|
+
const shown = chosen ?? info.default;
|
|
1486
|
+
const chip = el('button', `effort-chip${chosen ? ' set' : ''}${shown === info.levels.at(-1) ? ' peak' : ''}`);
|
|
1487
|
+
chip.type = 'button';
|
|
1488
|
+
chip.append(effortMeter(info.levels, shown, { ghost: !chosen }), el('span', 'word', chosen ? effortLabel(chosen) : t('effort')), el('span', 'caret', '▾'));
|
|
1489
|
+
chip.title = chosen
|
|
1490
|
+
? `${t('Effort for this agent · {level}', { level: effortLabel(chosen) })}\n${t(EFFORT_WORDS[chosen]?.hint ?? '')}`
|
|
1491
|
+
: info.default
|
|
1492
|
+
? t('Effort for this agent · the CLI default ({level})', { level: effortLabel(info.default) })
|
|
1493
|
+
: t("Effort for this agent · the CLI's own default");
|
|
1494
|
+
chip.setAttribute('aria-haspopup', 'menu');
|
|
1495
|
+
chip.addEventListener('click', (event) => { event.stopPropagation(); toggleEffortMenu(id, chip); });
|
|
1496
|
+
return chip;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
function rememberEffort(id, level) {
|
|
1500
|
+
if (level) state.chosenEffort[id] = level; else delete state.chosenEffort[id];
|
|
1501
|
+
try { localStorage.setItem('pulse.chosenEffort', JSON.stringify(state.chosenEffort)); } catch { /* private mode */ }
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
function toggleEffortMenu(id, anchor) {
|
|
1505
|
+
if (!effortMenu.hidden && effortMenuFor === id) { closeEffortMenu(); return; }
|
|
1506
|
+
modelMenu.hidden = true; modelMenuFor = null;
|
|
1507
|
+
closeModeMenu();
|
|
1508
|
+
const info = state.efforts[id];
|
|
1509
|
+
if (!info?.levels.length) return;
|
|
1510
|
+
effortMenuFor = id;
|
|
1511
|
+
paint(effortMenu, id);
|
|
1512
|
+
effortMenu.replaceChildren();
|
|
1513
|
+
const title = el('div', 'model-menu-title');
|
|
1514
|
+
title.append(el('b', null, `@${id}`), t(' · effort for this request'));
|
|
1515
|
+
effortMenu.append(title);
|
|
1516
|
+
const chosen = info.levels.includes(state.chosenEffort[id]) ? state.chosenEffort[id] : null;
|
|
1517
|
+
const ladder = el('div', 'effort-ladder');
|
|
1518
|
+
ladder.setAttribute('role', 'menu');
|
|
1519
|
+
const options = [null, ...info.levels];
|
|
1520
|
+
for (const level of options) {
|
|
1521
|
+
const current = level === chosen;
|
|
1522
|
+
const option = el('button', `effort-option${current ? ' current' : ''}${level === null ? ' auto' : ''}${level === info.levels.at(-1) ? ' peak' : ''}`);
|
|
1523
|
+
option.type = 'button';
|
|
1524
|
+
option.setAttribute('role', 'menuitemradio');
|
|
1525
|
+
option.setAttribute('aria-checked', String(current));
|
|
1526
|
+
const body = el('span', 'body');
|
|
1527
|
+
if (level === null) {
|
|
1528
|
+
body.append(
|
|
1529
|
+
el('span', 'name', t('Default')),
|
|
1530
|
+
el('span', 'hint', info.default ? t('Sends nothing: the CLI uses its own setting, {level} right now.', { level: effortLabel(info.default) }) : t('Sends nothing: the CLI uses its own setting.')),
|
|
1531
|
+
);
|
|
1532
|
+
option.append(effortMeter(info.levels, info.default, { ghost: true }), body);
|
|
1533
|
+
} else {
|
|
1534
|
+
body.append(el('span', 'name', effortLabel(level)), el('span', 'hint', t(EFFORT_WORDS[level]?.hint ?? '')));
|
|
1535
|
+
option.append(effortMeter(info.levels, level), body);
|
|
1536
|
+
}
|
|
1537
|
+
const tag = current ? t('NOW') : level !== null && level === info.default ? t('CLI') : '';
|
|
1538
|
+
if (tag) {
|
|
1539
|
+
const label = el('span', 'tag', tag);
|
|
1540
|
+
if (current) label.append(el('span', 'dot'));
|
|
1541
|
+
option.append(label);
|
|
1542
|
+
}
|
|
1543
|
+
option.addEventListener('click', () => { rememberEffort(id, level); closeEffortMenu(); renderPicker(); els.input.focus(); });
|
|
1544
|
+
ladder.append(option);
|
|
1545
|
+
}
|
|
1546
|
+
effortMenu.append(ladder);
|
|
1547
|
+
effortMenu.append(el('div', 'model-note', t('More effort is a slower, longer answer, and on a metered plan a more expensive one. It applies to every message to this agent until you change it.')));
|
|
1548
|
+
effortMenu.hidden = false;
|
|
1549
|
+
const rect = anchor.getBoundingClientRect();
|
|
1550
|
+
const width = effortMenu.offsetWidth || 340;
|
|
1551
|
+
const height = effortMenu.offsetHeight || 300;
|
|
1552
|
+
effortMenu.style.left = `${Math.max(12, Math.min(window.innerWidth - width - 12, rect.left))}px`;
|
|
1553
|
+
effortMenu.style.top = `${Math.max(12, rect.top - height - 10)}px`;
|
|
1554
|
+
ladder.querySelector('.effort-option.current, .effort-option')?.focus({ preventScroll: true });
|
|
1555
|
+
}
|
|
1556
|
+
function closeEffortMenu() { effortMenu.hidden = true; effortMenuFor = null; }
|
|
1557
|
+
document.addEventListener('click', (event) => { if (!effortMenu.hidden && !effortMenu.contains(event.target) && !event.target.closest('.picker')) closeEffortMenu(); });
|
|
1558
|
+
effortMenu.addEventListener('keydown', (event) => {
|
|
1559
|
+
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
|
1560
|
+
event.preventDefault();
|
|
1561
|
+
const items = [...effortMenu.querySelectorAll('.effort-option')];
|
|
1562
|
+
const at = items.indexOf(document.activeElement);
|
|
1563
|
+
items[(at + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length]?.focus();
|
|
1564
|
+
});
|
|
1565
|
+
|
|
1426
1566
|
/* ---------- mode menu: how far this message may go ---------- */
|
|
1427
1567
|
|
|
1428
1568
|
const modeMenu = el('div', 'model-menu mode-menu');
|
|
@@ -1439,6 +1579,7 @@ function defaultModeFor(id) { return state.capabilities[id]?.scopes?.defaultMode
|
|
|
1439
1579
|
function toggleModeMenu(id, anchor) {
|
|
1440
1580
|
if (!modeMenu.hidden && modeMenuFor === id) { closeModeMenu(); return; }
|
|
1441
1581
|
closeModelMenu();
|
|
1582
|
+
closeEffortMenu();
|
|
1442
1583
|
modeMenuFor = id;
|
|
1443
1584
|
paint(modeMenu, id);
|
|
1444
1585
|
modeMenu.replaceChildren();
|
|
@@ -1520,6 +1661,7 @@ const override = {
|
|
|
1520
1661
|
cancel: document.querySelector('#override-cancel'),
|
|
1521
1662
|
frame: document.querySelector('#override .override-frame'),
|
|
1522
1663
|
agent: null,
|
|
1664
|
+
then: null, // called once armed, by whoever opened it for something more than arming
|
|
1523
1665
|
raise: false, // the agent is capped below the mode: arming also raises MAX MODE in CONNECTIONS
|
|
1524
1666
|
mode: 3, // #3 CONTROL or #4 AIRLOCK
|
|
1525
1667
|
stage: 'designation', // AIRLOCK asks twice: the designation, then the word AIRLOCK
|
|
@@ -1531,9 +1673,12 @@ const OVERRIDE_BRIEF = {
|
|
|
1531
1673
|
+ (raise ? t(' THIS ALSO RAISES @{id} MAX MODE TO #{n} IN CONNECTIONS.', { id, n: 4 }) : '') + t(' TYPE THE PROJECT DESIGNATION, THEN THE WORD AIRLOCK.'),
|
|
1532
1674
|
};
|
|
1533
1675
|
function projectDesignation() { return (state.projectRoot ?? '').split('/').filter(Boolean).pop() ?? ''; }
|
|
1534
|
-
function openOverride(id, { raise = false, mode = 3 } = {}) {
|
|
1676
|
+
function openOverride(id, { raise = false, mode = 3, then = null } = {}) {
|
|
1535
1677
|
if (!override.dialog) return;
|
|
1536
1678
|
override.agent = id;
|
|
1679
|
+
// What to do once it is armed. The composer's own road arms and waits for the human to send;
|
|
1680
|
+
// the way up on an agent's card sends its permission right away.
|
|
1681
|
+
override.then = then;
|
|
1537
1682
|
override.raise = raise;
|
|
1538
1683
|
override.mode = mode;
|
|
1539
1684
|
override.stage = 'designation';
|
|
@@ -1547,7 +1692,7 @@ function openOverride(id, { raise = false, mode = 3 } = {}) {
|
|
|
1547
1692
|
override.dialog.showModal();
|
|
1548
1693
|
override.input.focus();
|
|
1549
1694
|
}
|
|
1550
|
-
override.cancel?.addEventListener('click', () => override.dialog.close());
|
|
1695
|
+
override.cancel?.addEventListener('click', () => { override.then = null; override.dialog.close(); });
|
|
1551
1696
|
override.form?.addEventListener('submit', (event) => {
|
|
1552
1697
|
event.preventDefault();
|
|
1553
1698
|
const typed = override.input.value.trim();
|
|
@@ -1578,6 +1723,8 @@ override.form?.addEventListener('submit', (event) => {
|
|
|
1578
1723
|
const agent = override.agent;
|
|
1579
1724
|
const raise = override.raise;
|
|
1580
1725
|
const mode = override.mode;
|
|
1726
|
+
const then = override.then;
|
|
1727
|
+
override.then = null;
|
|
1581
1728
|
setTimeout(async () => {
|
|
1582
1729
|
override.dialog.close();
|
|
1583
1730
|
if (raise) {
|
|
@@ -1592,6 +1739,7 @@ override.form?.addEventListener('submit', (event) => {
|
|
|
1592
1739
|
state.modeArmedFor = agent;
|
|
1593
1740
|
els.target.value = agent;
|
|
1594
1741
|
setMode(mode, { wink: true });
|
|
1742
|
+
if (then) { await then(); return; }
|
|
1595
1743
|
toast(mode === 4
|
|
1596
1744
|
? t('MU/TH/UR › AIRLOCK open for @{agent} for this message.{raised} Files are checkpointed; what leaves the machine is not undone.', { agent, raised: raise ? t(' MAX MODE is now #4 in CONNECTIONS.') : '' })
|
|
1597
1745
|
: t('MU/TH/UR › CONTROL armed for @{agent} for this message.{raised} A checkpoint is taken before it runs; every change is listed and UNDO is one click.', { agent, raised: raise ? t(' MAX MODE is now #3 in CONNECTIONS.') : '' }));
|
|
@@ -1864,8 +2012,9 @@ function row(kind, agentId, { compact = false } = {}) {
|
|
|
1864
2012
|
}
|
|
1865
2013
|
|
|
1866
2014
|
function renderUserMessage(event) {
|
|
1867
|
-
const { messageId, target, text, ash, model, attachments = [], create, mode } = event.payload;
|
|
2015
|
+
const { messageId, target, text, ash, model, effort, attachments = [], create, mode } = event.payload;
|
|
1868
2016
|
state.userMessages.set(messageId, { text, target, model, mode: event.payload.mode ?? 1 });
|
|
2017
|
+
settleModeAsksFor(target, event.payload.mode ?? 1);
|
|
1869
2018
|
const reviewed = state.expendable;
|
|
1870
2019
|
const node = row('user');
|
|
1871
2020
|
if (event.ghost || mode === 0) node.classList.add('ghost');
|
|
@@ -1882,7 +2031,7 @@ function renderUserMessage(event) {
|
|
|
1882
2031
|
if (attachments.length) bubble.append(fileTiles(attachments));
|
|
1883
2032
|
col.append(bubble);
|
|
1884
2033
|
const stamp = paint(el('div', 'stamp'), target);
|
|
1885
|
-
stamp.append(el('span', 'to', `→ @${target}${model ? ` · ${model}` : ''}`));
|
|
2034
|
+
stamp.append(el('span', 'to', `→ @${target}${model ? ` · ${model}` : ''}${effort ? ` · ${effortLabel(effort).toLowerCase()}` : ''}`));
|
|
1886
2035
|
stamp.append(el('span', null, formatTime(event.timestamp)));
|
|
1887
2036
|
if (reviewed) stamp.append(el('span', null, t('acknowledged, human')));
|
|
1888
2037
|
col.append(stamp);
|
|
@@ -1893,7 +2042,7 @@ function renderUserMessage(event) {
|
|
|
1893
2042
|
}
|
|
1894
2043
|
|
|
1895
2044
|
function renderAssistantMessage(event) {
|
|
1896
|
-
const { messageId, parentMessageId, sender, target, text, ash, status, step, totalSteps, model, mode } = event.payload;
|
|
2045
|
+
const { messageId, parentMessageId, sender, target, text, ash, status, step, totalSteps, model, effort, mode } = event.payload;
|
|
1897
2046
|
const delegated = status === 'delegated';
|
|
1898
2047
|
const compact = state.lastSender === sender && !delegated && !ash?.active && !event.ghost;
|
|
1899
2048
|
const node = row('assistant', sender, { compact });
|
|
@@ -1914,6 +2063,7 @@ function renderAssistantMessage(event) {
|
|
|
1914
2063
|
}
|
|
1915
2064
|
if (status === 'handoff') who.append(el('span', 'badge', t('handoff note')));
|
|
1916
2065
|
if (model) who.append(el('span', 'badge model', model));
|
|
2066
|
+
if (effort) who.append(el('span', 'badge model effort', t('effort {level}', { level: effortLabel(effort).toLowerCase() })));
|
|
1917
2067
|
if (Number.isInteger(mode) && mode !== 1) who.append(el('span', `badge mode m${mode}`, `#${mode} ${MODES[mode].label}`));
|
|
1918
2068
|
if (event.payload.escalation) who.append(el('span', 'badge mode m1', event.payload.escalation === 'timeout' ? '#2 not answered · read-only' : event.payload.escalation === 'stopped' ? 'stopped' : '#2 denied · read-only'));
|
|
1919
2069
|
if (ash?.active) who.append(el('span', 'badge ash', t('ASH')));
|
|
@@ -1932,7 +2082,7 @@ function renderAssistantMessage(event) {
|
|
|
1932
2082
|
col.append(bubble);
|
|
1933
2083
|
const used = attachMemoryUsed(event.payload);
|
|
1934
2084
|
if (used) col.append(used);
|
|
1935
|
-
const needs = attachModeAsk(event.payload);
|
|
2085
|
+
const needs = attachModeAsk(event.payload, { sequence: event.sequence });
|
|
1936
2086
|
if (needs) col.append(needs);
|
|
1937
2087
|
const stamp = el('div', 'stamp');
|
|
1938
2088
|
stamp.id = `usage-${messageId}`;
|
|
@@ -2331,9 +2481,27 @@ function renderModuleProposed(event) {
|
|
|
2331
2481
|
const everyRoom = el('button', 'act-link', t('INSTALL FOR EVERY ROOM')); everyRoom.type = 'button'; everyRoom.addEventListener('click', () => install('user', everyRoom));
|
|
2332
2482
|
const thisProject = el('button', 'act-link', t('INSTALL FOR THIS PROJECT')); thisProject.type = 'button'; thisProject.addEventListener('click', () => install('project', thisProject));
|
|
2333
2483
|
node.append(everyRoom, ' · ', thisProject, t(' · a module runs inside MADRE with your permissions'));
|
|
2484
|
+
// An agent asked to fix a module writes a new file beside the old one; the room keeps running
|
|
2485
|
+
// the copy that was installed until this one is. The card says so, or a fixed module looks
|
|
2486
|
+
// installed while the broken one keeps answering.
|
|
2487
|
+
void installedModules().then((list) => {
|
|
2488
|
+
const id = String(name ?? path ?? '').split('/').pop().replace(/\.module\.m?js$/, '');
|
|
2489
|
+
const current = list.find((item) => item.id === id && item.external);
|
|
2490
|
+
if (!current) return;
|
|
2491
|
+
everyRoom.textContent = t('REPLACE FOR EVERY ROOM');
|
|
2492
|
+
thisProject.textContent = t('REPLACE FOR THIS PROJECT');
|
|
2493
|
+
node.append(el('span', 'replaces', t('{name} {version} is already installed for {where}; it keeps running until this file replaces it.', { name: current.name, version: current.version ? `v${current.version}` : '', where: current.origin === 'project' ? t('this project') : t('every room') }).replace(/\s+/g, ' ')));
|
|
2494
|
+
});
|
|
2334
2495
|
if (responseMessageId) node.dataset.for = responseMessageId;
|
|
2335
2496
|
return node;
|
|
2336
2497
|
}
|
|
2498
|
+
// The modules this room has, asked once per page load and shared by every card that wants to know.
|
|
2499
|
+
let installedModulesAsked = null;
|
|
2500
|
+
function installedModules() {
|
|
2501
|
+
if (modules.items?.length) return Promise.resolve(modules.items);
|
|
2502
|
+
installedModulesAsked ??= fetch('/api/extensions').then((response) => response.json()).then((data) => data.extensions ?? []).catch(() => []);
|
|
2503
|
+
return installedModulesAsked;
|
|
2504
|
+
}
|
|
2337
2505
|
function renderModuleInstalledOrRemoved(event) {
|
|
2338
2506
|
const { name, origin, by } = event.payload;
|
|
2339
2507
|
const node = el('div', 'system module-proposed');
|
|
@@ -2448,28 +2616,98 @@ function renderDistilled(event) {
|
|
|
2448
2616
|
// the wrong one twice while the turn went nowhere. The ladder belongs next to the agent asking
|
|
2449
2617
|
// to climb it.
|
|
2450
2618
|
//
|
|
2451
|
-
//
|
|
2452
|
-
//
|
|
2453
|
-
|
|
2619
|
+
// The way up grants and goes on. Pressing it used to arm the composer and stop there: the human
|
|
2620
|
+
// then had to write "permission granted" by hand, in a separate message, to the agent that had just
|
|
2621
|
+
// asked — a second step nobody could guess from a button that said ARM. Now the press is the
|
|
2622
|
+
// answer: it opens the same ceremony as always for #3 and #4, where the project designation is
|
|
2623
|
+
// typed by hand (nothing here shortens that), and then sends the agent its permission, quoting
|
|
2624
|
+
// the reply that asked, at the mode it asked for. WRITE IT MYSELF keeps the old road for a human
|
|
2625
|
+
// who wants to add instructions; NOT NOW folds the card.
|
|
2626
|
+
const modeAsks = []; // { agent, wanted, card } still waiting for an answer in this page
|
|
2627
|
+
function attachModeAsk(payload, { sequence = null } = {}) {
|
|
2454
2628
|
if (!payload || payload.role !== 'assistant' || payload.sender === 'you') return null;
|
|
2455
2629
|
const ran = state.userMessages.get(payload.messageId)?.mode ?? payload.mode ?? 1;
|
|
2456
2630
|
const wanted = modeAsked(payload.text, { ran });
|
|
2457
2631
|
if (!wanted || wanted <= state.mode) return null;
|
|
2458
2632
|
const agent = payload.sender;
|
|
2459
2633
|
if (!state.agents.get(agent)?.ready) return null;
|
|
2460
|
-
const
|
|
2461
|
-
|
|
2462
|
-
|
|
2634
|
+
const label = MODES[wanted]?.label ?? '';
|
|
2635
|
+
const card = el('div', 'mode-ask');
|
|
2636
|
+
card.style.setProperty('--agent', agentColor(agent));
|
|
2637
|
+
const what = el('div', 'mode-what');
|
|
2638
|
+
what.append(el('b', 'who', `@${agent}`), t(' asks for #{n} {label} to go on', { n: wanted, label }));
|
|
2639
|
+
const why = el('div', 'mode-why', wanted >= 3
|
|
2640
|
+
? t('You will type the project designation, as always. Then @{agent} gets your permission and continues.', { agent })
|
|
2641
|
+
: t('@{agent} gets your permission for one message and continues.', { agent }));
|
|
2642
|
+
const actions = el('div', 'mode-actions');
|
|
2643
|
+
const go = el('button', 'mode-go', t('GRANT #{n} AND CONTINUE', { n: wanted }));
|
|
2463
2644
|
go.type = 'button';
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2645
|
+
const myself = el('button', 'mode-alt', t('WRITE IT MYSELF'));
|
|
2646
|
+
myself.type = 'button';
|
|
2647
|
+
myself.title = t('Arms #{n} in the box below, so you can add instructions before sending.', { n: wanted });
|
|
2648
|
+
const later = el('button', 'mode-alt', t('NOT NOW'));
|
|
2649
|
+
later.type = 'button';
|
|
2650
|
+
actions.append(go, myself, later);
|
|
2651
|
+
card.append(what, why, actions);
|
|
2652
|
+
const quoted = { sender: agent, sequence, text: (() => { const one = String(payload.text ?? '').replace(/\s+/g, ' ').trim(); return one.length > REPLY_CLIP ? `${one.slice(0, REPLY_CLIP - 1)}…` : one; })() };
|
|
2653
|
+
const ask = { agent, wanted, card };
|
|
2654
|
+
modeAsks.push(ask);
|
|
2655
|
+
const arm = (then) => {
|
|
2656
|
+
if (wanted >= 3) openOverride(agent, { mode: wanted, raise: wanted > ceilingFor(agent) && Boolean(state.capabilities[agent]?.scopes?.write?.capable), then });
|
|
2657
|
+
else { els.target.value = agent; renderPicker(); setMode(wanted, { wink: true }); then?.(); }
|
|
2658
|
+
};
|
|
2659
|
+
go.addEventListener('click', () => arm(async () => {
|
|
2660
|
+
go.disabled = true;
|
|
2661
|
+
const sent = await sendPermission({ agent, mode: wanted, quoted });
|
|
2662
|
+
if (!sent) { go.disabled = false; return; }
|
|
2663
|
+
settleModeAsk(ask, t('#{n} {label} granted · @{agent} continues', { n: wanted, label, agent }));
|
|
2664
|
+
}));
|
|
2665
|
+
myself.addEventListener('click', () => arm(() => {
|
|
2666
|
+
state.replyTo = quoted;
|
|
2667
|
+
renderReplyQuote();
|
|
2668
|
+
els.input.focus();
|
|
2669
|
+
}));
|
|
2670
|
+
later.addEventListener('click', () => settleModeAsk(ask, null));
|
|
2671
|
+
return card;
|
|
2672
|
+
}
|
|
2673
|
+
// A card that has been answered says how, once, and offers nothing more; NOT NOW folds it away.
|
|
2674
|
+
function settleModeAsk(ask, said) {
|
|
2675
|
+
const at = modeAsks.indexOf(ask);
|
|
2676
|
+
if (at >= 0) modeAsks.splice(at, 1);
|
|
2677
|
+
if (!said) { ask.card.remove(); return; }
|
|
2678
|
+
ask.card.classList.add('settled');
|
|
2679
|
+
ask.card.replaceChildren(el('div', 'mode-what', said));
|
|
2680
|
+
}
|
|
2681
|
+
// The human answered the ask their own way — a message to that agent at that mode or above —
|
|
2682
|
+
// and the card stops asking. This is also what quiets old cards when the page is rebuilt.
|
|
2683
|
+
function settleModeAsksFor(target, mode) {
|
|
2684
|
+
for (const ask of [...modeAsks]) {
|
|
2685
|
+
if (ask.agent === target && mode >= ask.wanted) settleModeAsk(ask, t('#{n} {label} granted', { n: ask.wanted, label: MODES[ask.wanted]?.label ?? '' }));
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
// The permission itself: a message from the human, quoting the reply that asked, at the mode it
|
|
2689
|
+
// asked for. It goes through the room like any other message, so the ceiling and the one-holder
|
|
2690
|
+
// rule still answer for it; a refusal comes back as a toast and the card stays.
|
|
2691
|
+
async function sendPermission({ agent, mode, quoted }) {
|
|
2692
|
+
const text = `${quoteHead(quoted)}${t('Permission granted: #{n} {label}. Go ahead with what you proposed.', { n: mode, label: MODES[mode]?.label ?? '' })}`;
|
|
2693
|
+
try {
|
|
2694
|
+
const response = await fetch('/api/messages', {
|
|
2695
|
+
method: 'POST',
|
|
2696
|
+
headers: { 'content-type': 'application/json' },
|
|
2697
|
+
body: JSON.stringify({ text, target: agent, model: state.chosenModel[agent] ?? null, effort: state.chosenEffort[agent] ?? null, attachments: [], create: mode === 2, mode, ash: state.ashInstalled && state.ash }),
|
|
2698
|
+
});
|
|
2699
|
+
if (!response.ok) {
|
|
2700
|
+
const result = await response.json().catch(() => ({}));
|
|
2701
|
+
toast(`MU/TH/UR › ${result.error ?? t('The room rejected the message.')}`);
|
|
2702
|
+
setMode(defaultModeFor(els.target.value));
|
|
2703
|
+
return false;
|
|
2704
|
+
}
|
|
2705
|
+
resetModeAfterSend();
|
|
2706
|
+
return true;
|
|
2707
|
+
} catch (error) {
|
|
2708
|
+
toast(t('Could not reach MADRE: {error}', { error: error.message }));
|
|
2709
|
+
return false;
|
|
2710
|
+
}
|
|
2473
2711
|
}
|
|
2474
2712
|
|
|
2475
2713
|
function attachMemoryUsed(payload) {
|
|
@@ -2709,7 +2947,10 @@ function renderControlReverted(event) {
|
|
|
2709
2947
|
const { agent, checkpointId, restored = [], removed = [], message } = event.payload;
|
|
2710
2948
|
const node = el('div', 'system control reverted');
|
|
2711
2949
|
node.style.setProperty('--agent', agentColor(agent));
|
|
2712
|
-
|
|
2950
|
+
const { command } = event.payload;
|
|
2951
|
+
node.append(el('b', null, command ? '/run' : t('CONTROL')), command
|
|
2952
|
+
? t('project restored to the checkpoint before the command · {restored} restored · {removed} removed', { restored: restored.length, removed: removed.length })
|
|
2953
|
+
: t("project restored to the checkpoint before @{agent}'s turn · {restored} restored · {removed} removed", { agent, restored: restored.length, removed: removed.length }));
|
|
2713
2954
|
node.title = message;
|
|
2714
2955
|
const card = document.getElementById(`control-${checkpointId}`);
|
|
2715
2956
|
card?.querySelector('.undo')?.replaceWith(el('span', 'outcome', t('RESTORED')));
|
|
@@ -2731,7 +2972,7 @@ function renderLeaseMissing(event) {
|
|
|
2731
2972
|
resend.addEventListener('click', async () => {
|
|
2732
2973
|
resend.disabled = true;
|
|
2733
2974
|
try {
|
|
2734
|
-
const response = await fetch('/api/messages', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, target: agent, model: state.chosenModel[agent] ?? null, attachments: [], create: true }) });
|
|
2975
|
+
const response = await fetch('/api/messages', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, target: agent, model: state.chosenModel[agent] ?? null, effort: state.chosenEffort[agent] ?? null, attachments: [], create: true }) });
|
|
2735
2976
|
if (!response.ok) { const result = await response.json().catch(() => ({})); throw new Error(result.error ?? `HTTP ${response.status}`); }
|
|
2736
2977
|
resend.textContent = t('RESENT WITH CREATE');
|
|
2737
2978
|
} catch (error) { toast(t('It could not be resent: {error}', { error: error.message })); resend.disabled = false; }
|
|
@@ -2945,29 +3186,102 @@ async function renderSettled() {
|
|
|
2945
3186
|
box.hidden = false;
|
|
2946
3187
|
}
|
|
2947
3188
|
|
|
3189
|
+
// A decision handed back. Each option is shown whole — it is the sentence the human is about to
|
|
3190
|
+
// send, and the half that got cut was the half that made it an option — and the agent's own pick
|
|
3191
|
+
// leads, marked, so the human can take it with one press or see at once what they are overruling.
|
|
2948
3192
|
function renderChoice(event) {
|
|
2949
|
-
const { question, options = [], agent } = event.payload;
|
|
3193
|
+
const { question, options = [], agent, recommended = null } = event.payload;
|
|
2950
3194
|
if (!options.length) return null;
|
|
2951
3195
|
const node = el('div', 'choice');
|
|
2952
3196
|
node.style.setProperty('--agent', agentColor(agent));
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
3197
|
+
const head = el('div', 'choice-head');
|
|
3198
|
+
head.append(el('span', 'choice-kicker', t('@{agent} asks you to choose', { agent })));
|
|
3199
|
+
node.append(head, el('p', 'choice-q', question));
|
|
3200
|
+
const list = el('div', 'choice-list');
|
|
3201
|
+
list.setAttribute('role', 'group');
|
|
3202
|
+
list.setAttribute('aria-label', question);
|
|
3203
|
+
const order = options.map((option, index) => ({ option, index }));
|
|
3204
|
+
const pick = Number.isInteger(recommended) && order[recommended] ? recommended : null;
|
|
3205
|
+
if (pick !== null) order.unshift(...order.splice(pick, 1));
|
|
3206
|
+
order.forEach(({ option, index }, position) => {
|
|
3207
|
+
const lead = index === pick;
|
|
3208
|
+
const button = el('button', `choice-option${lead ? ' recommended' : ''}`);
|
|
2957
3209
|
button.type = 'button';
|
|
2958
|
-
button.
|
|
3210
|
+
button.append(el('span', 'choice-key', String(position + 1)), el('span', 'choice-text', option));
|
|
3211
|
+
if (lead) button.append(el('span', 'choice-tag', t('RECOMMENDED')));
|
|
2959
3212
|
button.addEventListener('click', () => {
|
|
3213
|
+
for (const other of list.querySelectorAll('.choice-option.picked')) other.classList.remove('picked');
|
|
3214
|
+
button.classList.add('picked');
|
|
2960
3215
|
els.input.value = option;
|
|
2961
3216
|
els.input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
2962
3217
|
els.input.focus();
|
|
2963
3218
|
});
|
|
2964
|
-
|
|
2965
|
-
}
|
|
2966
|
-
node.append(
|
|
3219
|
+
list.append(button);
|
|
3220
|
+
});
|
|
3221
|
+
node.append(list, el('p', 'choice-foot', t('Writes it into the composer · nothing is sent until you press send')));
|
|
3222
|
+
state.lastSender = null;
|
|
3223
|
+
return node;
|
|
3224
|
+
}
|
|
3225
|
+
|
|
3226
|
+
// An agent asked the human to run commands it cannot run in its mode. One button per line; the
|
|
3227
|
+
// press sends only which request and which line, and the server runs what it recorded the agent
|
|
3228
|
+
// asked for. A line the parser refused is shown with its reason and no button.
|
|
3229
|
+
function renderRunRequest(event) {
|
|
3230
|
+
const { reason, commands = [], agent, responseMessageId } = event.payload;
|
|
3231
|
+
if (!commands.length) return null;
|
|
3232
|
+
const node = el('div', 'choice run-request');
|
|
3233
|
+
node.dataset.run = responseMessageId;
|
|
3234
|
+
node.style.setProperty('--agent', agentColor(agent));
|
|
3235
|
+
const head = el('div', 'choice-head');
|
|
3236
|
+
head.append(el('span', 'choice-kicker', t('@{agent} asks you to run', { agent })));
|
|
3237
|
+
node.append(head, el('p', 'choice-q', reason));
|
|
3238
|
+
const list = el('div', 'choice-list');
|
|
3239
|
+
list.setAttribute('role', 'group');
|
|
3240
|
+
list.setAttribute('aria-label', reason);
|
|
3241
|
+
commands.forEach((command, index) => {
|
|
3242
|
+
const button = el('button', `choice-option run-line${command.argv ? '' : ' refused'}`);
|
|
3243
|
+
button.type = 'button';
|
|
3244
|
+
button.dataset.index = String(index);
|
|
3245
|
+
button.append(el('span', 'choice-key', '$'), el('code', 'choice-text', command.line));
|
|
3246
|
+
if (!command.argv) {
|
|
3247
|
+
button.disabled = true;
|
|
3248
|
+
button.append(el('span', 'choice-tag', t('WILL NOT RUN')));
|
|
3249
|
+
button.title = t('This line does not run: it {why}.', { why: t(command.why, command.whyArgs ?? {}) });
|
|
3250
|
+
list.append(button, el('p', 'run-why', t('Does not run: it {why}.', { why: t(command.why, command.whyArgs ?? {}) })));
|
|
3251
|
+
return;
|
|
3252
|
+
}
|
|
3253
|
+
button.append(el('span', 'choice-tag', t('RUN')));
|
|
3254
|
+
button.title = t('Runs {command} from the project root, with no shell. A checkpoint is taken first.', { command: command.line });
|
|
3255
|
+
button.addEventListener('click', async () => {
|
|
3256
|
+
button.disabled = true;
|
|
3257
|
+
try {
|
|
3258
|
+
const response = await fetch('/api/runs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ responseMessageId, index }) });
|
|
3259
|
+
const result = await response.json().catch(() => ({}));
|
|
3260
|
+
if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
|
|
3261
|
+
} catch (error) {
|
|
3262
|
+
toast(`MU/TH/UR › ${error.message}`);
|
|
3263
|
+
markRun({ responseMessageId, index }, null);
|
|
3264
|
+
}
|
|
3265
|
+
});
|
|
3266
|
+
list.append(button);
|
|
3267
|
+
});
|
|
3268
|
+
node.append(list, el('p', 'choice-foot', t('Each line runs only when you press it · from the project root, no shell · the output comes back to the room')));
|
|
2967
3269
|
state.lastSender = null;
|
|
2968
3270
|
return node;
|
|
2969
3271
|
}
|
|
2970
3272
|
|
|
3273
|
+
// The state of one line of a run request, from the events that follow it: running, ran, failed.
|
|
3274
|
+
// A line that ran can be pressed again; tests are run more than once.
|
|
3275
|
+
function markRun({ responseMessageId, index }, status) {
|
|
3276
|
+
const button = document.querySelector(`.run-request[data-run="${CSS.escape(String(responseMessageId))}"] .run-line[data-index="${Number(index)}"]`);
|
|
3277
|
+
if (!button || button.classList.contains('refused')) return;
|
|
3278
|
+
button.classList.remove('running', 'ran', 'failed');
|
|
3279
|
+
if (status) button.classList.add(status);
|
|
3280
|
+
button.disabled = status === 'running';
|
|
3281
|
+
const tag = button.querySelector('.choice-tag');
|
|
3282
|
+
if (tag) tag.textContent = { running: t('RUNNING'), ran: t('RUN AGAIN'), failed: t('RUN AGAIN') }[status] ?? t('RUN');
|
|
3283
|
+
}
|
|
3284
|
+
|
|
2971
3285
|
function renderAlert(event) {
|
|
2972
3286
|
const { message } = event.payload;
|
|
2973
3287
|
if (!replaying) { state.brakeArmed = true; updateStopAll(); }
|
|
@@ -3275,6 +3589,8 @@ void renderOpenQuestions(); return;
|
|
|
3275
3589
|
node = renderPlanEvent(event);
|
|
3276
3590
|
break;
|
|
3277
3591
|
case 'choice.offered': node = renderChoice(event); break;
|
|
3592
|
+
case 'run.requested': node = renderRunRequest(event); break;
|
|
3593
|
+
case 'run.started': markRun(event.payload, 'running'); return;
|
|
3278
3594
|
case 'room.alert': node = renderAlert(event); break;
|
|
3279
3595
|
case 'ash.validated': if (event.payload.ok) return; node = renderAshValidation(event); break;
|
|
3280
3596
|
case 'room.stopped': node = renderHalted(event); break;
|
|
@@ -3417,7 +3733,10 @@ translateMarkup(document.body ?? document);
|
|
|
3417
3733
|
|
|
3418
3734
|
/* ---------- project files panel ---------- */
|
|
3419
3735
|
|
|
3420
|
-
|
|
3736
|
+
// `openDirs` survives a reload of the tree: after a copy, a move or a new folder the panel is
|
|
3737
|
+
// rebuilt, and a tree that folds itself shut hides exactly the thing the human just made.
|
|
3738
|
+
// `reveal` is that thing, lit once when its row is drawn again.
|
|
3739
|
+
const tree = { open: false, loaded: new Map(), openDirs: new Set(), reveal: null };
|
|
3421
3740
|
const formatSize = (bytes) => bytes == null ? '' : bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(bytes < 10240 ? 1 : 0)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
3422
3741
|
const CODE_EXT = /\.(m?[jt]sx?|py|rb|go|rs|java|kt|swift|c|h|cpp|hpp|cs|php|sh|zsh|css|scss|html?|json|ya?ml|toml|sql|md)$/i;
|
|
3423
3742
|
|
|
@@ -3460,40 +3779,49 @@ function finderShowClip() {
|
|
|
3460
3779
|
finder.clip.replaceChildren();
|
|
3461
3780
|
// No t() around a template that is only two slots and a dot: there is nothing in it to say in
|
|
3462
3781
|
// another language, and the catalogue's own guard rejects an entry whose Spanish is the English.
|
|
3463
|
-
|
|
3782
|
+
const what = el('span', 'what', `${finder.held.operation === 'copy' ? t('COPY') : t('MOVE')} · ${finder.held.name}`);
|
|
3783
|
+
what.title = finder.held.path;
|
|
3464
3784
|
const cancel = el('button', null, t('CANCEL'));
|
|
3465
3785
|
cancel.type = 'button';
|
|
3466
3786
|
cancel.addEventListener('click', () => { finder.held = null; finderShowClip(); });
|
|
3467
|
-
|
|
3787
|
+
// Holding something is half an operation; the other half is a right click, and nothing on the
|
|
3788
|
+
// screen said so.
|
|
3789
|
+
finder.clip.append(what, cancel, el('span', 'how', t('Right-click a folder, or an empty spot for the project root, to paste it there.')));
|
|
3468
3790
|
finder.clip.hidden = false;
|
|
3469
3791
|
}
|
|
3470
3792
|
|
|
3471
3793
|
// Asking for a word without leaving the panel. A name, or the designation: both are typed in the
|
|
3472
3794
|
// same strip under the search box, where the hand already is. A browser prompt would take the
|
|
3473
3795
|
// whole window for one word and throw away where the human was looking.
|
|
3474
|
-
|
|
3796
|
+
//
|
|
3797
|
+
// What is being asked is a line of its own, above the box, and it says WHERE: a placeholder is
|
|
3798
|
+
// gone the moment the first letter is typed, and in a narrow panel it was cut off before that.
|
|
3799
|
+
function finderAsk({ label, value = '', hint = '', where = null }) {
|
|
3475
3800
|
return new Promise((resolve) => {
|
|
3476
3801
|
if (!finder.clip) { resolve(null); return; }
|
|
3477
3802
|
const form = el('form', 'tree-ask');
|
|
3803
|
+
const head = el('div', 'ask-label', label);
|
|
3804
|
+
if (where != null) head.append(el('span', 'where', t('in {path}', { path: where === '.' ? t('the project root') : `${where}/` })));
|
|
3478
3805
|
const input = el('input');
|
|
3479
3806
|
input.type = 'text';
|
|
3480
3807
|
input.value = value;
|
|
3481
3808
|
input.spellcheck = false;
|
|
3482
3809
|
input.autocomplete = 'off';
|
|
3483
|
-
input.placeholder = label;
|
|
3484
3810
|
input.setAttribute('aria-label', label);
|
|
3485
3811
|
const ok = el('button', 'go', t('OK'));
|
|
3486
3812
|
ok.type = 'submit';
|
|
3487
3813
|
const no = el('button', null, t('CANCEL'));
|
|
3488
3814
|
no.type = 'button';
|
|
3489
|
-
|
|
3815
|
+
const actions = el('div', 'ask-actions');
|
|
3816
|
+
actions.append(no, ok);
|
|
3817
|
+
form.append(head);
|
|
3818
|
+
if (hint) form.append(el('div', 'ask-hint', hint));
|
|
3819
|
+
form.append(input, actions);
|
|
3490
3820
|
const done = (answer) => { finderShowClip(); resolve(answer); };
|
|
3491
3821
|
form.addEventListener('submit', (event) => { event.preventDefault(); done(input.value.trim() || null); });
|
|
3492
3822
|
no.addEventListener('click', () => done(null));
|
|
3493
|
-
input.addEventListener('keydown', (event) => { if (event.key === 'Escape') { event.stopPropagation(); done(null); } });
|
|
3494
|
-
finder.clip.replaceChildren();
|
|
3495
|
-
if (hint) finder.clip.append(el('span', 'what', hint));
|
|
3496
|
-
finder.clip.append(form);
|
|
3823
|
+
input.addEventListener('keydown', (event) => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); done(null); } });
|
|
3824
|
+
finder.clip.replaceChildren(form);
|
|
3497
3825
|
finder.clip.hidden = false;
|
|
3498
3826
|
input.focus();
|
|
3499
3827
|
// The extension stays out of the selection: renaming is almost always about the name.
|
|
@@ -3502,6 +3830,22 @@ function finderAsk({ label, value = '', hint = '' }) {
|
|
|
3502
3830
|
});
|
|
3503
3831
|
}
|
|
3504
3832
|
|
|
3833
|
+
const finderDirOf = (path) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.');
|
|
3834
|
+
const finderJoin = (dir, name) => (dir === '.' ? name : `${dir}/${name}`);
|
|
3835
|
+
|
|
3836
|
+
// The panel again, as it was: the folders that were open stay open, the one the result landed in
|
|
3837
|
+
// opens too, and the result itself is lit. A search in progress is asked again instead, so the
|
|
3838
|
+
// list the human was working from does not turn into the whole tree under their hand.
|
|
3839
|
+
function finderRefresh(landed) {
|
|
3840
|
+
if (landed) {
|
|
3841
|
+
for (let dir = finderDirOf(landed); dir !== '.'; dir = finderDirOf(dir)) tree.openDirs.add(dir);
|
|
3842
|
+
tree.reveal = landed;
|
|
3843
|
+
}
|
|
3844
|
+
if (finder.q?.value.trim()) { finder.q.dispatchEvent(new Event('input')); return; }
|
|
3845
|
+
els.treeBody.replaceChildren();
|
|
3846
|
+
void setTree(true);
|
|
3847
|
+
}
|
|
3848
|
+
|
|
3505
3849
|
// One door for everything that writes: it asks for the designation only when it already knows one
|
|
3506
3850
|
// will be wanted, so the human is not surprised mid-operation.
|
|
3507
3851
|
async function finderRun(body, { say }) {
|
|
@@ -3514,34 +3858,36 @@ async function finderRun(body, { say }) {
|
|
|
3514
3858
|
const response = await fetch('/api/tree/op', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
3515
3859
|
const result = await response.json().catch(() => ({}));
|
|
3516
3860
|
if (!response.ok) { toast(`MU/TH/UR › ${result.error ?? t('That could not be done.')}`); return false; }
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3861
|
+
// The server may have named a copy itself ("notes copy.md"); what it says is where it went.
|
|
3862
|
+
const landed = result.to ?? body.to;
|
|
3863
|
+
toast(typeof say === 'function' ? say(landed) : say);
|
|
3864
|
+
finderRefresh(landed);
|
|
3520
3865
|
return true;
|
|
3521
3866
|
}
|
|
3522
3867
|
|
|
3523
|
-
const finderDirOf = (path) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.');
|
|
3524
|
-
const finderJoin = (dir, name) => (dir === '.' ? name : `${dir}/${name}`);
|
|
3525
|
-
|
|
3526
3868
|
async function finderPaste(intoDir) {
|
|
3527
3869
|
if (!finder.held) return;
|
|
3528
3870
|
const held = finder.held;
|
|
3529
3871
|
const to = finderJoin(intoDir, held.name);
|
|
3530
|
-
|
|
3872
|
+
// Moving a thing to where it already is: nothing to do, and nothing to complain about.
|
|
3873
|
+
if (held.operation === 'move' && to === held.path) { finder.held = null; finderShowClip(); return; }
|
|
3874
|
+
// A copy that lands on a name already taken gets a name of its own, the way a file manager
|
|
3875
|
+
// does it, instead of a refusal.
|
|
3876
|
+
const did = await finderRun({ operation: held.operation, from: held.path, to, unique: held.operation === 'copy' }, { say: (landed) => `${held.path} → ${landed}` });
|
|
3531
3877
|
if (!did) return;
|
|
3532
3878
|
finder.held = null;
|
|
3533
3879
|
finderShowClip();
|
|
3534
3880
|
}
|
|
3535
3881
|
|
|
3536
3882
|
async function finderRename(path, entry) {
|
|
3537
|
-
const name = await finderAsk({ label: t('NEW NAME'), value: entry.name });
|
|
3883
|
+
const name = await finderAsk({ label: t('NEW NAME'), value: entry.name, where: finderDirOf(path) });
|
|
3538
3884
|
if (!name || name === entry.name) return;
|
|
3539
3885
|
const to = finderJoin(finderDirOf(path), name);
|
|
3540
3886
|
await finderRun({ operation: 'move', from: path, to }, { say: `${path} → ${to}` });
|
|
3541
3887
|
}
|
|
3542
3888
|
|
|
3543
3889
|
async function finderCreate(dir, kind) {
|
|
3544
|
-
const name = await finderAsk({ label: kind === 'new-folder' ? t('NEW FOLDER') : t('NEW FILE') });
|
|
3890
|
+
const name = await finderAsk({ label: kind === 'new-folder' ? t('NEW FOLDER') : t('NEW FILE'), where: dir });
|
|
3545
3891
|
if (!name) return;
|
|
3546
3892
|
const to = finderJoin(dir, name);
|
|
3547
3893
|
await finderRun({ operation: kind, to }, { say: to });
|
|
@@ -3549,6 +3895,7 @@ async function finderCreate(dir, kind) {
|
|
|
3549
3895
|
|
|
3550
3896
|
function finderMenu(event, path, entry) {
|
|
3551
3897
|
event.preventDefault();
|
|
3898
|
+
event.stopPropagation();
|
|
3552
3899
|
const menu = finder.menu;
|
|
3553
3900
|
if (!menu) return;
|
|
3554
3901
|
menu.replaceChildren();
|
|
@@ -3560,14 +3907,17 @@ function finderMenu(event, path, entry) {
|
|
|
3560
3907
|
};
|
|
3561
3908
|
// Where the new thing lands: inside the folder that was clicked, or beside the file.
|
|
3562
3909
|
const here = entry.kind === 'dir' ? path : finderDirOf(path);
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3910
|
+
// The project root is a place to put things, not a thing: it is not copied, moved or renamed.
|
|
3911
|
+
if (!entry.root) {
|
|
3912
|
+
add(t('Copy'), () => { finder.held = { path, name: entry.name, operation: 'copy' }; finderShowClip(); });
|
|
3913
|
+
add(t('Move'), () => { finder.held = { path, name: entry.name, operation: 'move' }; finderShowClip(); });
|
|
3914
|
+
add(t('Rename'), () => void finderRename(path, entry));
|
|
3915
|
+
}
|
|
3916
|
+
if (finder.held && entry.kind === 'dir') add(entry.root ? t('Paste in the project root') : t('Paste here'), () => void finderPaste(path));
|
|
3567
3917
|
if (finder.held && entry.kind === 'file') add(t('Paste beside it'), () => void finderPaste(here));
|
|
3568
3918
|
add(t('New file'), () => void finderCreate(here, 'new-file'));
|
|
3569
3919
|
add(t('New folder'), () => void finderCreate(here, 'new-folder'));
|
|
3570
|
-
if (finderGuarded(path)) menu.append(el('div', 'note', t('MADRE GUARDS THIS PATH · IT WILL ASK FOR THE DESIGNATION')));
|
|
3920
|
+
if (!entry.root && finderGuarded(path)) menu.append(el('div', 'note', t('MADRE GUARDS THIS PATH · IT WILL ASK FOR THE DESIGNATION')));
|
|
3571
3921
|
menu.hidden = false;
|
|
3572
3922
|
// Opened where the hand is, and kept inside the panel: a menu that spills across the room is a
|
|
3573
3923
|
// menu the human has to go looking for.
|
|
@@ -3577,23 +3927,43 @@ function finderMenu(event, path, entry) {
|
|
|
3577
3927
|
menu.style.top = `${Math.max(6, Math.min(event.clientY - panel.top, panel.height - box.height - 6))}px`;
|
|
3578
3928
|
}
|
|
3579
3929
|
document.addEventListener('click', (event) => { if (finder.menu && !finder.menu.hidden && !finder.menu.contains(event.target)) finder.menu.hidden = true; });
|
|
3930
|
+
// The space under the last row is the project root. Without it there was nowhere to paste
|
|
3931
|
+
// something back to the top level, or to make a folder there.
|
|
3932
|
+
els.treeBody?.addEventListener('contextmenu', (event) => {
|
|
3933
|
+
if (event.target.closest?.('button.node')) return;
|
|
3934
|
+
finderMenu(event, '.', { kind: 'dir', name: '.', root: true });
|
|
3935
|
+
});
|
|
3580
3936
|
|
|
3581
3937
|
// Searching the project by name, with the same scoring the composer's "!" menu uses.
|
|
3938
|
+
// Each keystroke outdates the one before: an answer that arrives after the box was cleared, or
|
|
3939
|
+
// after another word was typed, is dropped instead of painted over what the human is now asking.
|
|
3582
3940
|
let finderTimer = null;
|
|
3941
|
+
let finderAsked = 0;
|
|
3583
3942
|
finder.q?.addEventListener('input', () => {
|
|
3584
3943
|
clearTimeout(finderTimer);
|
|
3944
|
+
const asked = ++finderAsked;
|
|
3585
3945
|
finderTimer = setTimeout(async () => {
|
|
3586
3946
|
const query = finder.q.value.trim();
|
|
3587
3947
|
if (!query) { els.treeBody.replaceChildren(); void setTree(true); return; }
|
|
3588
3948
|
let matches = [];
|
|
3589
3949
|
try { matches = (await fetch(`/api/tree/search?q=${encodeURIComponent(query)}&limit=40`).then((r) => r.json()))?.matches ?? []; }
|
|
3590
3950
|
catch { return; }
|
|
3951
|
+
if (asked !== finderAsked) return;
|
|
3591
3952
|
const list = el('ul');
|
|
3592
|
-
for (const match of matches) list.append(treeNode(match.path, { kind: 'file', name: match.path, contentType: match.contentType }));
|
|
3953
|
+
for (const match of matches) list.append(treeNode(match.path, { kind: 'file', name: match.path.slice(match.path.lastIndexOf('/') + 1), label: match.path, contentType: match.contentType }));
|
|
3593
3954
|
els.treeBody.replaceChildren(list);
|
|
3594
3955
|
if (!matches.length) els.treeBody.append(el('div', 'tree-empty', t('NOTHING MATCHES {q}', { q: query })));
|
|
3595
3956
|
}, 160);
|
|
3596
3957
|
});
|
|
3958
|
+
// Escape empties the search and puts the tree back; a second Escape leaves the box. Every browser
|
|
3959
|
+
// draws its own clear button, or none, so the key is the one way that is always there.
|
|
3960
|
+
finder.q?.addEventListener('keydown', (event) => {
|
|
3961
|
+
if (event.key !== 'Escape') return;
|
|
3962
|
+
event.preventDefault();
|
|
3963
|
+
if (!finder.q.value) { finder.q.blur(); return; }
|
|
3964
|
+
finder.q.value = '';
|
|
3965
|
+
finder.q.dispatchEvent(new Event('input'));
|
|
3966
|
+
});
|
|
3597
3967
|
finder.search?.addEventListener('submit', (event) => event.preventDefault());
|
|
3598
3968
|
|
|
3599
3969
|
function treeNode(path, entry) {
|
|
@@ -3609,16 +3979,21 @@ function treeNode(path, entry) {
|
|
|
3609
3979
|
const children = el('ul');
|
|
3610
3980
|
children.hidden = true;
|
|
3611
3981
|
let opened = false;
|
|
3612
|
-
|
|
3613
|
-
opened =
|
|
3982
|
+
const show = (open) => {
|
|
3983
|
+
opened = open;
|
|
3614
3984
|
caret.textContent = opened ? '▾' : '▸';
|
|
3615
3985
|
children.hidden = !opened;
|
|
3616
3986
|
item.setAttribute('aria-expanded', String(opened));
|
|
3987
|
+
if (opened) tree.openDirs.add(path); else tree.openDirs.delete(path);
|
|
3617
3988
|
if (opened && !children.childElementCount) void loadTreeLevel(path, children);
|
|
3618
|
-
}
|
|
3989
|
+
};
|
|
3990
|
+
button.addEventListener('click', () => show(!opened));
|
|
3619
3991
|
item.append(button, children);
|
|
3992
|
+
if (tree.openDirs.has(path)) show(true);
|
|
3620
3993
|
} else {
|
|
3621
|
-
|
|
3994
|
+
// A search result shows where it lives; its NAME is still only the file's, because that is
|
|
3995
|
+
// what a copy or a rename carries along.
|
|
3996
|
+
button.append(el('span', 'caret', ''), el('span', 'name', entry.label ?? entry.name), el('span', 'size', formatSize(entry.size)));
|
|
3622
3997
|
const by = touched.get(path);
|
|
3623
3998
|
if (by) {
|
|
3624
3999
|
// Its colour is the agent's own, so the tree reads the same way the thread does.
|
|
@@ -3629,6 +4004,11 @@ function treeNode(path, entry) {
|
|
|
3629
4004
|
button.addEventListener('click', () => { void openViewer({ root: 'project', path, label: path }); });
|
|
3630
4005
|
item.append(button);
|
|
3631
4006
|
}
|
|
4007
|
+
if (tree.reveal === path) {
|
|
4008
|
+
tree.reveal = null;
|
|
4009
|
+
button.classList.add('fresh');
|
|
4010
|
+
requestAnimationFrame(() => button.scrollIntoView?.({ block: 'nearest' }));
|
|
4011
|
+
}
|
|
3632
4012
|
return item;
|
|
3633
4013
|
}
|
|
3634
4014
|
|
|
@@ -4568,7 +4948,7 @@ els.composer.addEventListener('submit', async (event) => {
|
|
|
4568
4948
|
const response = await fetch('/api/messages', {
|
|
4569
4949
|
method: 'POST',
|
|
4570
4950
|
headers: { 'content-type': 'application/json' },
|
|
4571
|
-
body: JSON.stringify({ text: outgoing, target: target || null, model: state.chosenModel[target] ?? null, attachments: ready.map((item) => item.id), create: state.create, mode: state.mode, ash: state.ashInstalled && state.ash }),
|
|
4951
|
+
body: JSON.stringify({ text: outgoing, target: target || null, model: state.chosenModel[target] ?? null, effort: state.chosenEffort[target] ?? null, attachments: ready.map((item) => item.id), create: state.create, mode: state.mode, ash: state.ashInstalled && state.ash }),
|
|
4572
4952
|
});
|
|
4573
4953
|
if (!response.ok) {
|
|
4574
4954
|
const result = await response.json().catch(() => ({ error: t('Request failed ({status}).', { status: response.status }) }));
|
|
@@ -4972,7 +5352,7 @@ function renderModuleEvent(event) {
|
|
|
4972
5352
|
}
|
|
4973
5353
|
|
|
4974
5354
|
function renderCommandCard(event) {
|
|
4975
|
-
const { name, title, text, ok, args = [] } = event.payload;
|
|
5355
|
+
const { name, title, text, ok, args = [], run = null } = event.payload;
|
|
4976
5356
|
const node = el('div', `command-card${ok === false ? ' failed' : ''}`);
|
|
4977
5357
|
const head = el('div', 'head');
|
|
4978
5358
|
head.append(el('b', null, `/${name}`), el('span', null, title ?? name), el('span', 'args', args.join(' ')));
|
|
@@ -4982,6 +5362,26 @@ function renderCommandCard(event) {
|
|
|
4982
5362
|
else pre.append(`${line}\n`);
|
|
4983
5363
|
}
|
|
4984
5364
|
node.append(head, pre);
|
|
5365
|
+
if (run) {
|
|
5366
|
+
markRun(run, ok === false ? 'failed' : 'ran');
|
|
5367
|
+
// A command the human ran by hand was photographed first: the same UNDO as a CONTROL turn.
|
|
5368
|
+
if (run.checkpointId && run.files?.length) {
|
|
5369
|
+
node.id = `control-${run.checkpointId}`;
|
|
5370
|
+
const undo = el('button', 'undo', t('UNDO · RESTORE CHECKPOINT'));
|
|
5371
|
+
undo.type = 'button';
|
|
5372
|
+
undo.title = t('Put the project files back exactly as they were before this command. What it sent off this machine does not come back.');
|
|
5373
|
+
undo.addEventListener('click', async () => {
|
|
5374
|
+
undo.disabled = true;
|
|
5375
|
+
try {
|
|
5376
|
+
const response = await fetch(`/api/control/${run.checkpointId}/undo`, { method: 'POST' });
|
|
5377
|
+
const result = await response.json().catch(() => ({}));
|
|
5378
|
+
if (!response.ok) throw new Error(result.error ?? `HTTP ${response.status}`);
|
|
5379
|
+
undo.textContent = t('RESTORED');
|
|
5380
|
+
} catch (error) { toast(`MU/TH/UR › ${error.message}`); undo.disabled = false; }
|
|
5381
|
+
});
|
|
5382
|
+
node.append(undo);
|
|
5383
|
+
}
|
|
5384
|
+
}
|
|
4985
5385
|
state.lastSender = null;
|
|
4986
5386
|
return node;
|
|
4987
5387
|
}
|
|
@@ -6193,7 +6593,9 @@ function appendLoginOutput(event) {
|
|
|
6193
6593
|
box.append(loginLine({ line, url }));
|
|
6194
6594
|
box.scrollTop = box.scrollHeight;
|
|
6195
6595
|
}
|
|
6196
|
-
|
|
6596
|
+
// Only while it is happening: a sign-in from earlier in the room, replayed when the page loads,
|
|
6597
|
+
// used to come back as a toast carrying a link that expired hours ago.
|
|
6598
|
+
if (url && !replaying) toast(t('@{agent}: open {url} to finish signing in', { agent, url }));
|
|
6197
6599
|
}
|
|
6198
6600
|
function loginLine({ line, url }) {
|
|
6199
6601
|
const div = el('div');
|