agenter-app 1.1.0 → 1.2.0
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/README.md +166 -130
- package/cli.js +43 -0
- package/lib/memory.js +101 -0
- package/lib/terminal.js +127 -0
- package/lib/update.js +69 -0
- package/package.json +1 -1
- package/public/app.js +335 -13
- package/public/index.html +83 -9
- package/public/style.css +106 -10
- package/scripts/pick-folder.ps1 +85 -0
- package/server.js +142 -9
package/public/app.js
CHANGED
|
@@ -102,6 +102,7 @@ const ICONS = {
|
|
|
102
102
|
refresh: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
|
|
103
103
|
gauge: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
|
|
104
104
|
preview: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/>',
|
|
105
|
+
dots: '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
|
|
105
106
|
};
|
|
106
107
|
const icon = (name) => `<svg class="ic" viewBox="0 0 24 24" aria-hidden="true">${ICONS[name] || ICONS.tool}</svg>`;
|
|
107
108
|
|
|
@@ -211,6 +212,7 @@ function connect() {
|
|
|
211
212
|
const es = new EventSource(`/events?client=${CLIENT_ID}&focused=${hasAttention() ? 1 : 0}&token=${encodeURIComponent(TOKEN)}`);
|
|
212
213
|
es.addEventListener('state', (e) => apply(JSON.parse(e.data)));
|
|
213
214
|
es.addEventListener('quit', () => window.close());
|
|
215
|
+
es.addEventListener('terminal', (e) => onTerminal(JSON.parse(e.data)));
|
|
214
216
|
es.onopen = () => (setConn(true), reportFocus(true)); // after a reconnect the URL's focus flag may be stale
|
|
215
217
|
es.onerror = async () => {
|
|
216
218
|
setConn(false);
|
|
@@ -229,6 +231,11 @@ function setConn(ok) {
|
|
|
229
231
|
}
|
|
230
232
|
|
|
231
233
|
function apply(snap) {
|
|
234
|
+
// The server restarted into a new version, so this page is out of date.
|
|
235
|
+
if (state.version && snap.version !== state.version) return location.reload();
|
|
236
|
+
state.version = snap.version;
|
|
237
|
+
state.update = snap.update;
|
|
238
|
+
state.terminal = snap.terminal;
|
|
232
239
|
const prevEventAt = sessionById(state.selected)?.lastEventAt;
|
|
233
240
|
state.sessions = snap.sessions;
|
|
234
241
|
state.pending = snap.pending;
|
|
@@ -421,6 +428,8 @@ function render() {
|
|
|
421
428
|
else renderLiveRow();
|
|
422
429
|
renderDock();
|
|
423
430
|
renderPreview();
|
|
431
|
+
renderTerm();
|
|
432
|
+
renderUpdate();
|
|
424
433
|
markKeyTarget();
|
|
425
434
|
renderUsage();
|
|
426
435
|
refreshAges();
|
|
@@ -528,6 +537,9 @@ function renderSidebar() {
|
|
|
528
537
|
|
|
529
538
|
function renderHead() {
|
|
530
539
|
const menu = `<button class="btn ghost icon menu-btn" data-nav-toggle title="Sessions">${icon('menu')}</button>`;
|
|
540
|
+
const termBtn = state.terminal
|
|
541
|
+
? `<button class="btn small ${term.open ? 'on' : ''}" data-term-toggle title="PowerShell in this project's folder (\`)" aria-pressed="${term.open}">${icon('terminal')}<span class="label">Terminal</span></button>`
|
|
542
|
+
: '';
|
|
531
543
|
if (state.view === 'inbox') {
|
|
532
544
|
const count = (st) => state.sessions.filter((s) => s.status === st).length;
|
|
533
545
|
const parts = [
|
|
@@ -537,7 +549,8 @@ function renderHead() {
|
|
|
537
549
|
].filter(Boolean);
|
|
538
550
|
$('#mainHead').innerHTML = `${menu}<div class="head-text">
|
|
539
551
|
<div class="head-title"><span>Inbox</span></div>
|
|
540
|
-
<div class="head-sub">${esc(parts.join(' · ') || 'Nothing running right now')}</div></div
|
|
552
|
+
<div class="head-sub">${esc(parts.join(' · ') || 'Nothing running right now')}</div></div>
|
|
553
|
+
<div class="head-actions">${termBtn}</div>`;
|
|
541
554
|
return;
|
|
542
555
|
}
|
|
543
556
|
const s = sessionById(state.selected);
|
|
@@ -571,17 +584,20 @@ function renderHead() {
|
|
|
571
584
|
: ''
|
|
572
585
|
}
|
|
573
586
|
${s.cwd ? `<button class="btn small ${preview.open ? 'on' : ''}" data-preview-toggle title="Show the running app next to the conversation" aria-pressed="${preview.open}">${icon('preview')}<span class="label">Preview</span></button>` : ''}
|
|
574
|
-
${
|
|
587
|
+
${termBtn}
|
|
588
|
+
${s.cwd ? `<button class="btn small icon" data-more popovertarget="moreMenu" title="Memory, carry to a new chat, open in VS Code" aria-label="More actions">${icon('dots')}</button>` : ''}
|
|
575
589
|
</div>`;
|
|
576
590
|
}
|
|
577
591
|
|
|
578
592
|
// ---------- app preview ----------
|
|
579
593
|
// The project's running app, side by side with the conversation. An address you pick
|
|
580
|
-
// or type is remembered
|
|
594
|
+
// or type is remembered for that chat, and is the starting point for new chats in the same
|
|
595
|
+
// project. Otherwise the first page detected is shown.
|
|
581
596
|
|
|
582
|
-
const preview = { open: store.get('previewOpen', false), cwd: null, apps: [], scanning: false, detects: true, timer: null, shown: false };
|
|
597
|
+
const preview = { open: store.get('previewOpen', false), sid: null, cwd: null, apps: [], scanning: false, detects: true, timer: null, shown: false };
|
|
583
598
|
const previewKey = () => 'previewUrl:' + (preview.cwd || '').toLowerCase();
|
|
584
|
-
const
|
|
599
|
+
const previewChatKey = () => 'previewUrl:chat:' + preview.sid;
|
|
600
|
+
const chosenUrl = () => store.get(previewChatKey(), null) ?? (store.get(previewKey(), '') || preview.apps.find((a) => a.html)?.url || preview.apps[0]?.url || '');
|
|
585
601
|
|
|
586
602
|
function normalizeUrl(v) {
|
|
587
603
|
v = v.trim();
|
|
@@ -603,7 +619,8 @@ function renderPreview() {
|
|
|
603
619
|
preview.shown = false;
|
|
604
620
|
return clearTimeout(preview.timer);
|
|
605
621
|
}
|
|
606
|
-
if (s.cwd !== preview.cwd) {
|
|
622
|
+
if (s.id !== preview.sid || s.cwd !== preview.cwd) {
|
|
623
|
+
preview.sid = s.id;
|
|
607
624
|
preview.cwd = s.cwd;
|
|
608
625
|
preview.apps = [];
|
|
609
626
|
preview.shown = false;
|
|
@@ -673,6 +690,7 @@ async function scanApps() {
|
|
|
673
690
|
}
|
|
674
691
|
|
|
675
692
|
function pickUrl(url) {
|
|
693
|
+
store.set(previewChatKey(), url);
|
|
676
694
|
store.set(previewKey(), url);
|
|
677
695
|
renderApps();
|
|
678
696
|
loadFrame();
|
|
@@ -958,7 +976,7 @@ function renderConvo(keepScroll = false) {
|
|
|
958
976
|
continue;
|
|
959
977
|
}
|
|
960
978
|
flush();
|
|
961
|
-
if (m.role === 'user') chunks.push([`m${m.seq}`,
|
|
979
|
+
if (m.role === 'user') chunks.push([`m${m.seq}`, userBubble(m.text)]);
|
|
962
980
|
else chunks.push([`m${m.seq}`, `<div class="msg-claude"><span class="claude-mark">${icon('sparkle')}</span><div class="md">${md(m.text)}</div></div>`]);
|
|
963
981
|
}
|
|
964
982
|
flush();
|
|
@@ -966,6 +984,13 @@ function renderConvo(keepScroll = false) {
|
|
|
966
984
|
if (stick) scrollToBottom();
|
|
967
985
|
}
|
|
968
986
|
|
|
987
|
+
// Context carried over from another chat follows the message, folded away.
|
|
988
|
+
function userBubble(text) {
|
|
989
|
+
const c = text.match(/\s*<carried-context from="([^"]*)">([\s\S]*)<\/carried-context>\s*$/);
|
|
990
|
+
if (!c) return `<div class="msg-user">${esc(text)}</div>`;
|
|
991
|
+
return `<div class="msg-user">${esc(text.slice(0, c.index))}<details class="carried"><summary>Context from ${esc(c[1] || 'an earlier chat')}</summary><pre>${esc(c[2].trim())}</pre></details></div>`;
|
|
992
|
+
}
|
|
993
|
+
|
|
969
994
|
// Brings el's children in line with chunks, touching only the ones that changed, so
|
|
970
995
|
// new messages don't reset text you've selected or re-render the whole conversation.
|
|
971
996
|
function patchChildren(el, chunks) {
|
|
@@ -1248,7 +1273,21 @@ const closeNav = () => $('#app').classList.remove('nav-open');
|
|
|
1248
1273
|
|
|
1249
1274
|
document.addEventListener('click', (e) => {
|
|
1250
1275
|
const t = e.target;
|
|
1276
|
+
if (t.closest('#moreMenu button')) $('#moreMenu').hidePopover();
|
|
1251
1277
|
if (onPreviewClick(t)) return;
|
|
1278
|
+
if (t.closest('[data-term-toggle]')) return toggleTerm();
|
|
1279
|
+
if (t.closest('[data-term-stop]')) return post('/api/terminal/stop', { id: term.id });
|
|
1280
|
+
if (t.closest('[data-term-clear]')) return ($('#termOut').textContent = '');
|
|
1281
|
+
if (t.closest('[data-update-now]')) return startUpdate();
|
|
1282
|
+
if (t.closest('[data-update-restart]')) return restartApp();
|
|
1283
|
+
if (t.closest('[data-update-later]')) return ((update.dismissed = update.key), renderUpdate());
|
|
1284
|
+
if (t.closest('[data-nav-min]')) return toggleNavMin();
|
|
1285
|
+
if (t.closest('[data-memory]')) return openMemory();
|
|
1286
|
+
if (t.closest('[data-carry]')) return openNewSession({ carry: state.selected });
|
|
1287
|
+
if (t.closest('[data-carry-drop]')) return setCarry(null);
|
|
1288
|
+
if (t.closest('[data-mem-close]')) return closeMemory();
|
|
1289
|
+
const memFile = t.closest('[data-mem-file]');
|
|
1290
|
+
if (memFile) return showMemFile(memFile.dataset.memFile);
|
|
1252
1291
|
if (t.closest('[data-steer-send]')) return steer(t.closest('.card'));
|
|
1253
1292
|
if (t.closest('[data-drive]')) return setDrive(t.closest('[data-drive]').dataset.drive);
|
|
1254
1293
|
if (t.closest('[data-interrupt]')) return post('/api/session/interrupt', { sessionId: state.selected });
|
|
@@ -1304,6 +1343,10 @@ document.addEventListener('input', (e) => {
|
|
|
1304
1343
|
|
|
1305
1344
|
document.addEventListener('keydown', (e) => {
|
|
1306
1345
|
const t = e.target instanceof Element ? e.target : document.body;
|
|
1346
|
+
if (t.id === 'termInput' && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
|
1347
|
+
e.preventDefault();
|
|
1348
|
+
return termHistory(e.key === 'ArrowUp' ? 1 : -1);
|
|
1349
|
+
}
|
|
1307
1350
|
if (t.matches('.reply-box textarea') && e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
|
1308
1351
|
e.preventDefault();
|
|
1309
1352
|
return t.closest('[data-steer]') ? steer(t.closest('.card')) : act(t.closest('.card'), 'reply');
|
|
@@ -1313,7 +1356,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
1313
1356
|
if (e.key === 'Escape') t.blur();
|
|
1314
1357
|
return;
|
|
1315
1358
|
}
|
|
1316
|
-
if ($('
|
|
1359
|
+
if ($('dialog[open]') || e.ctrlKey || e.metaKey || e.altKey) return;
|
|
1317
1360
|
|
|
1318
1361
|
const perm = keyTarget();
|
|
1319
1362
|
const k = e.key;
|
|
@@ -1325,6 +1368,8 @@ document.addEventListener('keydown', (e) => {
|
|
|
1325
1368
|
chooseTheme(next);
|
|
1326
1369
|
toast(`Theme: ${next}`);
|
|
1327
1370
|
}
|
|
1371
|
+
else if (k === '`') (e.preventDefault(), toggleTerm());
|
|
1372
|
+
else if (k === 'b') toggleNavMin();
|
|
1328
1373
|
else if (k === 'j') step(1);
|
|
1329
1374
|
else if (k === 'k') step(-1);
|
|
1330
1375
|
else if (k === 'n') nextNeedingYou();
|
|
@@ -1356,22 +1401,64 @@ $('#newAgent').addEventListener('click', (e) => {
|
|
|
1356
1401
|
if (b) setNewAgent(b.dataset.newAgent);
|
|
1357
1402
|
});
|
|
1358
1403
|
|
|
1359
|
-
|
|
1404
|
+
let picking = false;
|
|
1405
|
+
// Opens the Windows folder window. { path } | { cancelled } | { busy } | { unsupported }
|
|
1406
|
+
async function chooseFolder(start) {
|
|
1407
|
+
if (picking) return { busy: true };
|
|
1408
|
+
picking = true;
|
|
1409
|
+
const label = $('#navNew span');
|
|
1410
|
+
const idle = label.textContent;
|
|
1411
|
+
label.textContent = 'Choosing folder…';
|
|
1412
|
+
$('#navNew').disabled = $('#newBrowse').disabled = true;
|
|
1413
|
+
const r = await post('/api/pick-folder', { start })
|
|
1414
|
+
.then((res) => res.json())
|
|
1415
|
+
.catch(() => ({ unsupported: true }));
|
|
1416
|
+
picking = false;
|
|
1417
|
+
label.textContent = idle;
|
|
1418
|
+
$('#navNew').disabled = $('#newBrowse').disabled = false;
|
|
1419
|
+
return r;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// The chat a new session brings context from, shown in the dialog until removed.
|
|
1423
|
+
let carryFrom = null;
|
|
1424
|
+
function setCarry(s) {
|
|
1425
|
+
carryFrom = s?.id || null;
|
|
1426
|
+
$('#newCarry').hidden = !s;
|
|
1427
|
+
$('#newCarryText').textContent = s ? `Bringing context from: ${s.title || sessionLabel(s)}` : '';
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// Picks the folder first, in the Windows folder window; cancelling it cancels New session.
|
|
1431
|
+
// Carrying a chat over already has its folder. Where there's no folder window, you type it.
|
|
1432
|
+
async function openNewSession({ carry = null } = {}) {
|
|
1360
1433
|
closeNav();
|
|
1361
1434
|
setNewAgent(store.get('newAgent', 'claude'));
|
|
1362
1435
|
// Folders you've worked in, most recent first; the last one used here goes on top.
|
|
1363
1436
|
const last = store.get('newCwd', '');
|
|
1364
1437
|
const cwds = [...new Set([last, ...[...state.sessions].sort((a, b) => b.lastEventAt - a.lastEventAt).map((s) => s.cwd)].filter(Boolean))];
|
|
1365
1438
|
$('#newCwdList').innerHTML = cwds.map((c) => `<option value="${esc(c)}"></option>`).join('');
|
|
1366
|
-
const current = state.view === 'session' && sessionById(state.selected)?.cwd;
|
|
1367
|
-
|
|
1439
|
+
const current = (state.view === 'session' && sessionById(state.selected)?.cwd) || last || cwds[0] || '';
|
|
1440
|
+
const from = carry && sessionById(carry);
|
|
1441
|
+
let folder = from?.cwd;
|
|
1442
|
+
if (!from) {
|
|
1443
|
+
const picked = await chooseFolder(current);
|
|
1444
|
+
if (picked.cancelled) return;
|
|
1445
|
+
if (picked.busy) return toast('The folder window is already open');
|
|
1446
|
+
folder = picked.path || current;
|
|
1447
|
+
}
|
|
1448
|
+
setCarry(from);
|
|
1449
|
+
$('#newCwd').value = folder;
|
|
1368
1450
|
$('#newError').hidden = true;
|
|
1369
1451
|
$('#newStart').disabled = false;
|
|
1370
1452
|
$('#newDialog').showModal();
|
|
1371
1453
|
$('#newMsg').focus();
|
|
1372
1454
|
}
|
|
1373
1455
|
|
|
1374
|
-
$('#navNew').addEventListener('click', openNewSession);
|
|
1456
|
+
$('#navNew').addEventListener('click', () => openNewSession());
|
|
1457
|
+
$('#newBrowse').addEventListener('click', async () => {
|
|
1458
|
+
const picked = await chooseFolder($('#newCwd').value.trim());
|
|
1459
|
+
if (picked.path) $('#newCwd').value = picked.path;
|
|
1460
|
+
else if (picked.unsupported) toast('No folder window here. Type the full path');
|
|
1461
|
+
});
|
|
1375
1462
|
$('#newDialog [data-new-cancel]').addEventListener('click', () => $('#newDialog').close());
|
|
1376
1463
|
$('#newMsg').addEventListener('keydown', (e) => {
|
|
1377
1464
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) (e.preventDefault(), $('#newForm').requestSubmit());
|
|
@@ -1388,7 +1475,7 @@ $('#newForm').addEventListener('submit', async (e) => {
|
|
|
1388
1475
|
$('#newStart').disabled = true;
|
|
1389
1476
|
let r;
|
|
1390
1477
|
try {
|
|
1391
|
-
r = await post('/api/session/new', { cwd, message, agent: store.get('newAgent', 'claude') });
|
|
1478
|
+
r = await post('/api/session/new', { cwd, message, agent: store.get('newAgent', 'claude'), fromSessionId: carryFrom });
|
|
1392
1479
|
} catch {
|
|
1393
1480
|
return showError("Couldn't reach the Agenter server.");
|
|
1394
1481
|
}
|
|
@@ -1402,6 +1489,241 @@ $('#newForm').addEventListener('submit', async (e) => {
|
|
|
1402
1489
|
toast('Session started');
|
|
1403
1490
|
});
|
|
1404
1491
|
$('#scrim').addEventListener('click', closeNav);
|
|
1492
|
+
|
|
1493
|
+
// ---------- collapsing the sidebar ----------
|
|
1494
|
+
|
|
1495
|
+
const drawerLayout = matchMedia('(max-width: 800px)');
|
|
1496
|
+
function setNavMin(min) {
|
|
1497
|
+
const btn = $('#navMinBtn');
|
|
1498
|
+
$('#app').classList.toggle('nav-min', min);
|
|
1499
|
+
btn.setAttribute('aria-expanded', String(!min));
|
|
1500
|
+
btn.setAttribute('aria-label', min ? 'Expand sidebar' : 'Collapse sidebar');
|
|
1501
|
+
btn.title = `${min ? 'Expand' : 'Collapse'} sidebar (B)`;
|
|
1502
|
+
}
|
|
1503
|
+
function toggleNavMin() {
|
|
1504
|
+
if (drawerLayout.matches) return $('#app').classList.toggle('nav-open');
|
|
1505
|
+
const min = !$('#app').classList.contains('nav-min');
|
|
1506
|
+
store.set('navMin', min);
|
|
1507
|
+
setNavMin(min);
|
|
1508
|
+
}
|
|
1509
|
+
setNavMin(store.get('navMin', false));
|
|
1510
|
+
|
|
1511
|
+
// ---------- updates ----------
|
|
1512
|
+
|
|
1513
|
+
const update = { key: '', dismissed: '', toasted: new Set() };
|
|
1514
|
+
|
|
1515
|
+
function renderUpdate() {
|
|
1516
|
+
const u = state.update;
|
|
1517
|
+
const mode = u?.restart ? 'restart' : u?.available ? 'update' : '';
|
|
1518
|
+
const key = mode && `${mode}:${mode === 'restart' ? u.installed : u.latest}`;
|
|
1519
|
+
const el = $('#updateBar');
|
|
1520
|
+
el.hidden = !key || key === update.dismissed;
|
|
1521
|
+
if (key === update.key) return;
|
|
1522
|
+
update.key = key;
|
|
1523
|
+
if (!key) return;
|
|
1524
|
+
if (mode === 'restart') {
|
|
1525
|
+
el.innerHTML = `<span class="update-text"><b>Agenter ${esc(u.installed)} is installed.</b> Restart to start using it. Running turns stop, and anything waiting goes back to VS Code.</span>
|
|
1526
|
+
<button class="btn primary small" type="button" data-update-restart>Restart</button><button class="btn ghost small" type="button" data-update-later>Later</button>`;
|
|
1527
|
+
} else {
|
|
1528
|
+
el.innerHTML = `<span class="update-text"><b>Agenter ${esc(u.latest)} is available.</b> You have ${esc(u.current)}.${u.canSelfUpdate ? ' Or run <code>agenter update</code>.' : ' Update with <code>git pull</code>.'}</span>
|
|
1529
|
+
${u.canSelfUpdate && state.terminal ? '<button class="btn primary small" type="button" data-update-now>Update now</button>' : ''}<button class="btn ghost small" type="button" data-update-later>Later</button>`;
|
|
1530
|
+
}
|
|
1531
|
+
// Every time the app opens, once per version.
|
|
1532
|
+
if (!update.toasted.has(key)) {
|
|
1533
|
+
update.toasted.add(key);
|
|
1534
|
+
toast(mode === 'restart' ? 'Restart Agenter to finish updating' : `Agenter ${u.latest} is available`);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
async function startUpdate() {
|
|
1539
|
+
const r = await post('/api/update', {}).catch(() => null);
|
|
1540
|
+
const data = await r?.json().catch(() => ({}));
|
|
1541
|
+
if (!r?.ok) return toast(data?.error || "Couldn't start the update");
|
|
1542
|
+
term.open = true;
|
|
1543
|
+
store.set('termOpen', true);
|
|
1544
|
+
term.sid = currentTermSession();
|
|
1545
|
+
await loadTerm({});
|
|
1546
|
+
render();
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
async function restartApp() {
|
|
1550
|
+
const r = await post('/api/restart', {}).catch(() => null);
|
|
1551
|
+
toast(r?.ok ? 'Restarting Agenter…' : "Couldn't restart");
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// ---------- terminal ----------
|
|
1555
|
+
// One PowerShell per folder, kept by the server. The panel follows the session you're on;
|
|
1556
|
+
// the inbox uses your home folder.
|
|
1557
|
+
|
|
1558
|
+
const TERM_KEEP = 200 * 1024;
|
|
1559
|
+
const term = { open: store.get('termOpen', false), id: null, sid: undefined, running: false, cwd: '', history: store.get('termHistory', []), back: -1 };
|
|
1560
|
+
const currentTermSession = () => (state.view === 'session' ? state.selected : null);
|
|
1561
|
+
// Colors and cursor moves mean nothing here; bare carriage returns are progress redraws.
|
|
1562
|
+
const termText = (s) => s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*\x07/g, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
1563
|
+
|
|
1564
|
+
function termStatus(running, cwd) {
|
|
1565
|
+
term.running = running;
|
|
1566
|
+
term.cwd = cwd;
|
|
1567
|
+
$('#termCwd').textContent = cwd;
|
|
1568
|
+
$('#termCwd').title = cwd;
|
|
1569
|
+
$('#termStop').hidden = !running;
|
|
1570
|
+
$('#termPrompt').textContent = running ? '…' : '>';
|
|
1571
|
+
$('#termInput').placeholder = running ? 'Running. Type a line to send it to the program' : 'Type a command';
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
function termAppend(text) {
|
|
1575
|
+
const out = $('#termOut');
|
|
1576
|
+
const stick = out.scrollHeight - out.scrollTop - out.clientHeight < 40;
|
|
1577
|
+
out.append(text);
|
|
1578
|
+
if (out.textContent.length > TERM_KEEP * 1.5) out.textContent = out.textContent.slice(-TERM_KEEP);
|
|
1579
|
+
if (stick) out.scrollTop = out.scrollHeight;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
async function loadTerm(body) {
|
|
1583
|
+
const r = await post('/api/terminal/open', body).catch(() => null);
|
|
1584
|
+
if (!r?.ok) return termAppend("Couldn't open the terminal.\n");
|
|
1585
|
+
const t = await r.json();
|
|
1586
|
+
term.id = t.id;
|
|
1587
|
+
$('#termOut').textContent = termText(t.output);
|
|
1588
|
+
$('#termOut').scrollTop = $('#termOut').scrollHeight;
|
|
1589
|
+
termStatus(t.running, t.cwd);
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
function renderTerm() {
|
|
1593
|
+
const show = term.open && !!state.terminal;
|
|
1594
|
+
$('#term').hidden = !show;
|
|
1595
|
+
if (!show) return;
|
|
1596
|
+
const sid = currentTermSession();
|
|
1597
|
+
if (sid === term.sid) return;
|
|
1598
|
+
term.sid = sid;
|
|
1599
|
+
loadTerm({ sessionId: sid });
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
function toggleTerm() {
|
|
1603
|
+
term.open = !term.open;
|
|
1604
|
+
store.set('termOpen', term.open);
|
|
1605
|
+
render();
|
|
1606
|
+
if (term.open) $('#termInput').focus();
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
function onTerminal(e) {
|
|
1610
|
+
if (e.id !== term.id) return;
|
|
1611
|
+
if (e.data) termAppend(termText(e.data));
|
|
1612
|
+
termStatus(e.running, e.cwd);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
function termHistory(dir) {
|
|
1616
|
+
const next = term.back + dir;
|
|
1617
|
+
if (next < -1 || next >= term.history.length) return;
|
|
1618
|
+
term.back = next;
|
|
1619
|
+
$('#termInput').value = next < 0 ? '' : term.history[next];
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
$('#termForm').addEventListener('submit', async (e) => {
|
|
1623
|
+
e.preventDefault();
|
|
1624
|
+
const input = $('#termInput').value;
|
|
1625
|
+
if (!input.trim() && !term.running) return;
|
|
1626
|
+
$('#termInput').value = '';
|
|
1627
|
+
term.back = -1;
|
|
1628
|
+
if (input.trim() && input !== term.history[0] && !term.running) {
|
|
1629
|
+
term.history = [input, ...term.history].slice(0, 100);
|
|
1630
|
+
store.set('termHistory', term.history);
|
|
1631
|
+
}
|
|
1632
|
+
const r = await post('/api/terminal/run', { id: term.id, input }).catch(() => null);
|
|
1633
|
+
if (r?.ok) return;
|
|
1634
|
+
// The server restarted, so that shell is gone: open a new one and try again from here.
|
|
1635
|
+
$('#termInput').value = input;
|
|
1636
|
+
term.sid = undefined;
|
|
1637
|
+
renderTerm();
|
|
1638
|
+
});
|
|
1639
|
+
|
|
1640
|
+
// ---------- memory ----------
|
|
1641
|
+
// The files the agent reads and keeps for this project. Edits stay in drafts until saved, so
|
|
1642
|
+
// moving between files doesn't lose them.
|
|
1643
|
+
|
|
1644
|
+
const mem = { sid: null, files: [], path: null, drafts: new Map() };
|
|
1645
|
+
const memFile = () => mem.files.find((f) => f.path === mem.path);
|
|
1646
|
+
const memDirty = (f) => mem.drafts.has(f.path) && mem.drafts.get(f.path) !== f.text;
|
|
1647
|
+
|
|
1648
|
+
function renderMemList() {
|
|
1649
|
+
let group = '';
|
|
1650
|
+
$('#memList').innerHTML = mem.files
|
|
1651
|
+
.map((f) => {
|
|
1652
|
+
const head = f.group === group ? '' : `<div class="mem-group">${esc((group = f.group))}</div>`;
|
|
1653
|
+
const mark = memDirty(f) ? '<i class="dirty" title="Not saved"></i>' : f.create ? '<i class="new">new</i>' : '';
|
|
1654
|
+
return `${head}<button type="button" class="mem-file" data-mem-file="${esc(f.path)}" aria-current="${f.path === mem.path}" title="${esc(f.path)}"><span>${esc(f.label)}</span>${mark}</button>`;
|
|
1655
|
+
})
|
|
1656
|
+
.join('');
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
function showMemFile(p) {
|
|
1660
|
+
mem.path = p;
|
|
1661
|
+
const f = memFile();
|
|
1662
|
+
const ta = $('#memText');
|
|
1663
|
+
$('#memPath').textContent = f.path;
|
|
1664
|
+
$('#memPath').title = f.path;
|
|
1665
|
+
ta.value = mem.drafts.get(f.path) ?? f.text;
|
|
1666
|
+
ta.readOnly = !!f.tooLarge;
|
|
1667
|
+
ta.placeholder = f.tooLarge ? 'This file is too large to edit here.' : f.create ? "This file doesn't exist yet. Type here and Save to create it." : '';
|
|
1668
|
+
$('#memError').hidden = true;
|
|
1669
|
+
$('#memSave').disabled = !memDirty(f);
|
|
1670
|
+
renderMemList();
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
async function openMemory() {
|
|
1674
|
+
const s = sessionById(state.selected);
|
|
1675
|
+
if (!s) return;
|
|
1676
|
+
const r = await fetch(`/api/memory?session=${encodeURIComponent(s.id)}`, { headers: authHeaders }).catch(() => null);
|
|
1677
|
+
if (!r?.ok) return toast("Couldn't read this project's memory");
|
|
1678
|
+
mem.sid = s.id;
|
|
1679
|
+
mem.files = (await r.json()).files;
|
|
1680
|
+
mem.drafts.clear();
|
|
1681
|
+
if (!mem.files.length) return toast('This session has no project folder');
|
|
1682
|
+
$('#memSub').textContent = s.cwd;
|
|
1683
|
+
showMemFile((mem.files.find((f) => !f.create && f.text) || mem.files[0]).path);
|
|
1684
|
+
$('#memDialog').showModal();
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
const memCanLeave = () => !mem.files.some(memDirty) || confirm('Discard your unsaved memory changes?');
|
|
1688
|
+
function closeMemory() {
|
|
1689
|
+
if (memCanLeave()) $('#memDialog').close();
|
|
1690
|
+
}
|
|
1691
|
+
$('#memDialog').addEventListener('cancel', (e) => {
|
|
1692
|
+
if (!memCanLeave()) e.preventDefault();
|
|
1693
|
+
});
|
|
1694
|
+
$('#memText').addEventListener('input', (e) => {
|
|
1695
|
+
const f = memFile();
|
|
1696
|
+
mem.drafts.set(f.path, e.target.value);
|
|
1697
|
+
$('#memSave').disabled = !memDirty(f);
|
|
1698
|
+
renderMemList();
|
|
1699
|
+
});
|
|
1700
|
+
$('#memSave').addEventListener('click', async () => {
|
|
1701
|
+
const f = memFile();
|
|
1702
|
+
const text = $('#memText').value;
|
|
1703
|
+
const r = await post('/api/memory/save', { sessionId: mem.sid, path: f.path, text, mtime: f.mtime }).catch(() => null);
|
|
1704
|
+
const data = await r?.json().catch(() => ({}));
|
|
1705
|
+
if (!r?.ok) {
|
|
1706
|
+
$('#memError').textContent = data?.error || "Couldn't save.";
|
|
1707
|
+
$('#memError').hidden = false;
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
Object.assign(f, { text, mtime: data.mtime, create: false });
|
|
1711
|
+
mem.drafts.delete(f.path);
|
|
1712
|
+
$('#memError').hidden = true;
|
|
1713
|
+
$('#memSave').disabled = true;
|
|
1714
|
+
renderMemList();
|
|
1715
|
+
toast(`Saved ${f.label}`);
|
|
1716
|
+
});
|
|
1717
|
+
|
|
1718
|
+
{
|
|
1719
|
+
const menu = $('#moreMenu');
|
|
1720
|
+
menu.addEventListener('beforetoggle', (e) => {
|
|
1721
|
+
if (e.newState !== 'open') return;
|
|
1722
|
+
const r = $('[data-more]').getBoundingClientRect();
|
|
1723
|
+
menu.style.top = `${r.bottom + 6}px`;
|
|
1724
|
+
menu.style.right = `${Math.max(8, innerWidth - r.right)}px`;
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1405
1727
|
$('#helpBtn').addEventListener('click', () => $('#helpDialog').showModal());
|
|
1406
1728
|
$('#search').addEventListener('input', (e) => {
|
|
1407
1729
|
state.filter = e.target.value;
|
package/public/index.html
CHANGED
|
@@ -17,11 +17,14 @@
|
|
|
17
17
|
<div class="app" id="app">
|
|
18
18
|
<aside class="sidebar" id="sidebar">
|
|
19
19
|
<div class="brand-row">
|
|
20
|
-
<div class="brand"><span class="logo"></span>Agenter</div>
|
|
20
|
+
<div class="brand"><span class="logo"></span><span class="brand-name">Agenter</span></div>
|
|
21
21
|
<span class="conn" id="conn" title="Connecting…"></span>
|
|
22
|
+
<button class="btn ghost icon nav-min-btn" id="navMinBtn" type="button" data-nav-min title="Collapse sidebar (B)" aria-label="Collapse sidebar" aria-expanded="true">
|
|
23
|
+
<svg class="ic" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/></svg>
|
|
24
|
+
</button>
|
|
22
25
|
</div>
|
|
23
26
|
|
|
24
|
-
<button class="nav-item" id="navInbox" type="button">
|
|
27
|
+
<button class="nav-item" id="navInbox" type="button" title="Inbox">
|
|
25
28
|
<svg class="ic" viewBox="0 0 24 24"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
|
|
26
29
|
<span>Inbox</span>
|
|
27
30
|
<span class="count" id="inboxCount" hidden></span>
|
|
@@ -80,13 +83,24 @@
|
|
|
80
83
|
</button>
|
|
81
84
|
</div>
|
|
82
85
|
</div>
|
|
83
|
-
<div class="accent-row"
|
|
86
|
+
<div class="accent-row">
|
|
84
87
|
<span>Accent</span>
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
<div class="swatches" id="accentPicker" role="radiogroup" aria-label="Accent color">
|
|
89
|
+
<button type="button" role="radio" data-accent="sage" style="--sw:#b7d4a6" title="Sage"></button>
|
|
90
|
+
<button type="button" role="radio" data-accent="mint" style="--sw:#8fe0b4" title="Mint"></button>
|
|
91
|
+
<button type="button" role="radio" data-accent="teal" style="--sw:#73cfcb" title="Teal"></button>
|
|
92
|
+
<button type="button" role="radio" data-accent="sky" style="--sw:#8cc4ef" title="Sky"></button>
|
|
93
|
+
<button type="button" role="radio" data-accent="indigo" style="--sw:#a0aef5" title="Indigo"></button>
|
|
94
|
+
<button type="button" role="radio" data-accent="lavender" style="--sw:#b8acf2" title="Lavender"></button>
|
|
95
|
+
<button type="button" role="radio" data-accent="plum" style="--sw:#d49ae0" title="Plum"></button>
|
|
96
|
+
<button type="button" role="radio" data-accent="magenta" style="--sw:#f08fd0" title="Magenta"></button>
|
|
97
|
+
<button type="button" role="radio" data-accent="rose" style="--sw:#e58aa6" title="Rose"></button>
|
|
98
|
+
<button type="button" role="radio" data-accent="coral" style="--sw:#f29a8b" title="Coral"></button>
|
|
99
|
+
<button type="button" role="radio" data-accent="clay" style="--sw:#dc9474" title="Clay"></button>
|
|
100
|
+
<button type="button" role="radio" data-accent="amber" style="--sw:#eec15f" title="Amber"></button>
|
|
101
|
+
<button type="button" role="radio" data-accent="lemon" style="--sw:#e3d96b" title="Lemon"></button>
|
|
102
|
+
<button type="button" role="radio" data-accent="slate" style="--sw:#a9b8c6" title="Slate"></button>
|
|
103
|
+
</div>
|
|
90
104
|
</div>
|
|
91
105
|
</div>
|
|
92
106
|
</details>
|
|
@@ -103,6 +117,7 @@
|
|
|
103
117
|
<div class="scrim" id="scrim"></div>
|
|
104
118
|
|
|
105
119
|
<main class="main">
|
|
120
|
+
<div class="update-bar" id="updateBar" role="status" hidden></div>
|
|
106
121
|
<header class="main-head" id="mainHead"></header>
|
|
107
122
|
<div class="split" id="split">
|
|
108
123
|
<div class="chat-col">
|
|
@@ -129,11 +144,42 @@
|
|
|
129
144
|
<div class="pv-body" id="pvBody"></div>
|
|
130
145
|
</section>
|
|
131
146
|
</div>
|
|
147
|
+
<section class="term" id="term" aria-label="Terminal" hidden>
|
|
148
|
+
<div class="term-bar">
|
|
149
|
+
<span class="term-title">PowerShell</span>
|
|
150
|
+
<span class="term-cwd mono" id="termCwd"></span>
|
|
151
|
+
<button class="btn ghost small" type="button" id="termStop" data-term-stop title="Stop the running command. This also ends the shell, so variables start fresh" hidden>Stop</button>
|
|
152
|
+
<button class="btn ghost small" type="button" data-term-clear>Clear</button>
|
|
153
|
+
<button class="btn ghost icon" type="button" data-term-toggle title="Close terminal" aria-label="Close terminal">
|
|
154
|
+
<svg class="ic" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
<pre class="term-out" id="termOut" tabindex="0"></pre>
|
|
158
|
+
<form class="term-line" id="termForm" autocomplete="off">
|
|
159
|
+
<span class="term-prompt mono" id="termPrompt">></span>
|
|
160
|
+
<input class="term-input mono" id="termInput" type="text" spellcheck="false" aria-label="Command" placeholder="Type a command">
|
|
161
|
+
</form>
|
|
162
|
+
</section>
|
|
132
163
|
</main>
|
|
133
164
|
</div>
|
|
134
165
|
|
|
135
166
|
<div class="toasts" id="toasts" aria-live="polite"></div>
|
|
136
167
|
|
|
168
|
+
<div id="moreMenu" class="more-menu" popover role="menu" aria-label="More actions">
|
|
169
|
+
<button type="button" role="menuitem" data-memory>
|
|
170
|
+
<svg class="ic" viewBox="0 0 24 24"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>
|
|
171
|
+
<span>Memory</span>
|
|
172
|
+
</button>
|
|
173
|
+
<button type="button" role="menuitem" data-carry>
|
|
174
|
+
<svg class="ic" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
|
175
|
+
<span>Carry to a new chat</span>
|
|
176
|
+
</button>
|
|
177
|
+
<button type="button" role="menuitem" data-open-vscode>
|
|
178
|
+
<svg class="ic" viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
|
179
|
+
<span>Open in VS Code</span>
|
|
180
|
+
</button>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
137
183
|
<dialog id="newDialog" class="new-dialog">
|
|
138
184
|
<form id="newForm" autocomplete="off">
|
|
139
185
|
<div class="new-head">
|
|
@@ -145,8 +191,15 @@
|
|
|
145
191
|
</div>
|
|
146
192
|
<p class="dlg-sub" id="newSub"></p>
|
|
147
193
|
<label class="field-label" for="newCwd">Project folder</label>
|
|
148
|
-
<
|
|
194
|
+
<div class="folder-row">
|
|
195
|
+
<input class="field mono" id="newCwd" list="newCwdList" placeholder="C:\Users\you\Projects\my-app" spellcheck="false" required>
|
|
196
|
+
<button class="btn" type="button" id="newBrowse">Browse…</button>
|
|
197
|
+
</div>
|
|
149
198
|
<datalist id="newCwdList"></datalist>
|
|
199
|
+
<div class="new-carry" id="newCarry" hidden>
|
|
200
|
+
<span id="newCarryText"></span>
|
|
201
|
+
<button class="btn ghost small" type="button" data-carry-drop>Remove</button>
|
|
202
|
+
</div>
|
|
150
203
|
<label class="field-label" for="newMsg">First message</label>
|
|
151
204
|
<textarea class="field" id="newMsg" rows="4" placeholder="What should Claude do?" required></textarea>
|
|
152
205
|
<div class="dlg-error" id="newError" hidden></div>
|
|
@@ -157,6 +210,25 @@
|
|
|
157
210
|
</form>
|
|
158
211
|
</dialog>
|
|
159
212
|
|
|
213
|
+
<dialog id="memDialog" class="mem-dialog">
|
|
214
|
+
<div class="mem-head">
|
|
215
|
+
<h2>Memory</h2>
|
|
216
|
+
<span id="memSub"></span>
|
|
217
|
+
</div>
|
|
218
|
+
<div class="mem-body">
|
|
219
|
+
<nav class="mem-list" id="memList" aria-label="Memory files"></nav>
|
|
220
|
+
<div class="mem-edit">
|
|
221
|
+
<div class="mem-path mono" id="memPath"></div>
|
|
222
|
+
<textarea class="field mono" id="memText" spellcheck="false" aria-label="File contents"></textarea>
|
|
223
|
+
<div class="dlg-error" id="memError" hidden></div>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
<div class="dlg-actions">
|
|
227
|
+
<button class="btn ghost" type="button" data-mem-close>Close</button>
|
|
228
|
+
<button class="btn primary" type="button" id="memSave" disabled>Save</button>
|
|
229
|
+
</div>
|
|
230
|
+
</dialog>
|
|
231
|
+
|
|
160
232
|
<dialog id="helpDialog">
|
|
161
233
|
<h2>Keyboard shortcuts</h2>
|
|
162
234
|
<dl class="keys">
|
|
@@ -170,6 +242,8 @@
|
|
|
170
242
|
<dt><kbd>I</kbd></dt><dd>Open the inbox</dd>
|
|
171
243
|
<dt><kbd>/</kbd></dt><dd>Filter sessions</dd>
|
|
172
244
|
<dt><kbd>T</kbd></dt><dd>Switch theme (system → light → dark)</dd>
|
|
245
|
+
<dt><kbd>`</kbd></dt><dd>Show or hide the terminal</dd>
|
|
246
|
+
<dt><kbd>B</kbd></dt><dd>Collapse or expand the sidebar</dd>
|
|
173
247
|
<dt><kbd>Esc</kbd></dt><dd>Leave a text box / close this</dd>
|
|
174
248
|
</dl>
|
|
175
249
|
<form method="dialog"><button class="btn primary">Got it</button></form>
|