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/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
- ${s.cwd ? `<button class="btn small" data-open-vscode title="Open this project in VS Code">${icon('code')}<span class="label">Open in VS Code</span></button>` : ''}
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 per project; otherwise the first page detected is shown.
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 chosenUrl = () => store.get(previewKey(), '') || preview.apps.find((a) => a.html)?.url || preview.apps[0]?.url || '';
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}`, `<div class="msg-user">${esc(m.text)}</div>`]);
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 ($('#helpDialog').open || e.ctrlKey || e.metaKey || e.altKey) return;
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
- function openNewSession() {
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
- $('#newCwd').value = current || last || cwds[0] || '';
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" id="accentPicker" role="radiogroup" aria-label="Accent color">
86
+ <div class="accent-row">
84
87
  <span>Accent</span>
85
- <button type="button" role="radio" data-accent="sage" style="--sw:#b7d4a6" title="Sage"></button>
86
- <button type="button" role="radio" data-accent="clay" style="--sw:#dc9474" title="Clay"></button>
87
- <button type="button" role="radio" data-accent="teal" style="--sw:#73cfcb" title="Teal"></button>
88
- <button type="button" role="radio" data-accent="rose" style="--sw:#e58aa6" title="Rose"></button>
89
- <button type="button" role="radio" data-accent="lavender" style="--sw:#b8acf2" title="Lavender"></button>
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">&gt;</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
- <input class="field mono" id="newCwd" list="newCwdList" placeholder="C:\Users\you\Projects\my-app" spellcheck="false" required>
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>