agenter-app 1.0.3 → 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
@@ -44,7 +44,25 @@ function ago(t) {
44
44
  return `${Math.floor(s / 86400)}d`;
45
45
  }
46
46
 
47
- const post = (url, body) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'x-agenter': '1' }, body: JSON.stringify(body) });
47
+ // The launchers open the dashboard with #token=…; it's kept here, out of the address bar.
48
+ const TOKEN = (() => {
49
+ const key = 'agenter.token';
50
+ const m = location.hash.match(/^#token=([0-9a-f]+)$/);
51
+ if (m) {
52
+ history.replaceState(null, '', location.pathname + location.search);
53
+ try {
54
+ localStorage.setItem(key, m[1]);
55
+ } catch {}
56
+ return m[1];
57
+ }
58
+ try {
59
+ return localStorage.getItem(key) || '';
60
+ } catch {
61
+ return '';
62
+ }
63
+ })();
64
+ const authHeaders = { 'x-agenter-token': TOKEN };
65
+ const post = (url, body) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...authHeaders }, body: JSON.stringify(body) });
48
66
 
49
67
  const STATUS_LABEL = { working: 'Working', waiting: 'Needs you', vscode: 'Waiting in VS Code', idle: 'Idle', dormant: 'Not connected', ended: 'Ended' };
50
68
  const isLive = (s) => s.status !== 'dormant' && s.status !== 'ended';
@@ -84,6 +102,7 @@ const ICONS = {
84
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"/>',
85
103
  gauge: '<path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
86
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"/>',
87
106
  };
88
107
  const icon = (name) => `<svg class="ic" viewBox="0 0 24 24" aria-hidden="true">${ICONS[name] || ICONS.tool}</svg>`;
89
108
 
@@ -190,10 +209,19 @@ window.addEventListener('blur', () => setTimeout(reportFocus));
190
209
  document.addEventListener('visibilitychange', () => reportFocus());
191
210
 
192
211
  function connect() {
193
- const es = new EventSource(`/events?client=${CLIENT_ID}&focused=${hasAttention() ? 1 : 0}`);
212
+ const es = new EventSource(`/events?client=${CLIENT_ID}&focused=${hasAttention() ? 1 : 0}&token=${encodeURIComponent(TOKEN)}`);
194
213
  es.addEventListener('state', (e) => apply(JSON.parse(e.data)));
214
+ es.addEventListener('quit', () => window.close());
215
+ es.addEventListener('terminal', (e) => onTerminal(JSON.parse(e.data)));
195
216
  es.onopen = () => (setConn(true), reportFocus(true)); // after a reconnect the URL's focus flag may be stale
196
- es.onerror = () => setConn(false);
217
+ es.onerror = async () => {
218
+ setConn(false);
219
+ // EventSource doesn't say why it failed, and it gives up for good on a refused request.
220
+ const refused = await post('/api/focus', {}).then((r) => r.status === 401, () => false);
221
+ if (!refused) return;
222
+ es.close();
223
+ if (!$('#authLost')) document.body.insertAdjacentHTML('afterbegin', '<div class="auth-lost" id="authLost" role="alert">This window isn\'t signed in to Agenter. Open it from the Agenter shortcut, or run <code>agenter</code>.</div>');
224
+ };
197
225
  }
198
226
 
199
227
  function setConn(ok) {
@@ -203,6 +231,11 @@ function setConn(ok) {
203
231
  }
204
232
 
205
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;
206
239
  const prevEventAt = sessionById(state.selected)?.lastEventAt;
207
240
  state.sessions = snap.sessions;
208
241
  state.pending = snap.pending;
@@ -395,6 +428,8 @@ function render() {
395
428
  else renderLiveRow();
396
429
  renderDock();
397
430
  renderPreview();
431
+ renderTerm();
432
+ renderUpdate();
398
433
  markKeyTarget();
399
434
  renderUsage();
400
435
  refreshAges();
@@ -502,6 +537,9 @@ function renderSidebar() {
502
537
 
503
538
  function renderHead() {
504
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
+ : '';
505
543
  if (state.view === 'inbox') {
506
544
  const count = (st) => state.sessions.filter((s) => s.status === st).length;
507
545
  const parts = [
@@ -511,7 +549,8 @@ function renderHead() {
511
549
  ].filter(Boolean);
512
550
  $('#mainHead').innerHTML = `${menu}<div class="head-text">
513
551
  <div class="head-title"><span>Inbox</span></div>
514
- <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>`;
515
554
  return;
516
555
  }
517
556
  const s = sessionById(state.selected);
@@ -545,17 +584,20 @@ function renderHead() {
545
584
  : ''
546
585
  }
547
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>` : ''}
548
- ${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>` : ''}
549
589
  </div>`;
550
590
  }
551
591
 
552
592
  // ---------- app preview ----------
553
593
  // The project's running app, side by side with the conversation. An address you pick
554
- // 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.
555
596
 
556
- 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 };
557
598
  const previewKey = () => 'previewUrl:' + (preview.cwd || '').toLowerCase();
558
- 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 || '');
559
601
 
560
602
  function normalizeUrl(v) {
561
603
  v = v.trim();
@@ -577,7 +619,8 @@ function renderPreview() {
577
619
  preview.shown = false;
578
620
  return clearTimeout(preview.timer);
579
621
  }
580
- if (s.cwd !== preview.cwd) {
622
+ if (s.id !== preview.sid || s.cwd !== preview.cwd) {
623
+ preview.sid = s.id;
581
624
  preview.cwd = s.cwd;
582
625
  preview.apps = [];
583
626
  preview.shown = false;
@@ -632,7 +675,7 @@ async function scanApps() {
632
675
  loadFrame();
633
676
  let r = null;
634
677
  try {
635
- r = await fetch(`/api/preview/apps?session=${encodeURIComponent(sid)}`).then((res) => res.json());
678
+ r = await fetch(`/api/preview/apps?session=${encodeURIComponent(sid)}`, { headers: authHeaders }).then((res) => res.json());
636
679
  } catch {}
637
680
  if (cwd !== preview.cwd) return; // switched project meanwhile; that one has its own scan
638
681
  preview.scanning = false;
@@ -647,6 +690,7 @@ async function scanApps() {
647
690
  }
648
691
 
649
692
  function pickUrl(url) {
693
+ store.set(previewChatKey(), url);
650
694
  store.set(previewKey(), url);
651
695
  renderApps();
652
696
  loadFrame();
@@ -748,13 +792,23 @@ const sortPending = (list) => [...list].sort((a, b) => a.createdAt - b.createdAt
748
792
 
749
793
  // ---------- session view ----------
750
794
 
795
+ // Messages you sent that the agent's transcript hasn't recorded yet, by session.
796
+ const sentEchoes = new Map();
797
+
798
+ function clearEchoes(sid, messages) {
799
+ const left = (sentEchoes.get(sid) || []).filter((text) => !messages.some((m) => m.role === 'user' && m.text.trim() === text));
800
+ if (left.length) sentEchoes.set(sid, left);
801
+ else sentEchoes.delete(sid);
802
+ }
803
+
751
804
  function renderLiveRow() {
752
805
  const s = sessionById(state.selected);
753
806
  const el = $('#liveRow');
754
807
  if (!s || !el) return;
755
- let html = '';
756
- if (s.status === 'working') html = `<div class="live-row"><span class="dots"><i></i><i></i><i></i></span><span class="targ">${esc(s.lastAction || 'Working…')}</span></div>`;
757
- else if (s.status === 'vscode') html = `<div class="live-row">${icon('info')}<span>Waiting for you in VS Code</span></div>`;
808
+ if (!s.running) sentEchoes.delete(s.id); // The turn is over, so the transcript has the last word.
809
+ let html = (sentEchoes.get(s.id) || []).map((text) => `<div class="live-echo"><div class="msg-user">${esc(text)}</div></div>`).join('');
810
+ if (s.status === 'working') html += `<div class="live-row"><span class="dots"><i></i><i></i><i></i></span><span class="targ">${esc(s.lastAction || 'Working…')}</span></div>`;
811
+ else if (s.status === 'vscode') html += `<div class="live-row">${icon('info')}<span>Waiting for you in VS Code</span></div>`;
758
812
  if (el.innerHTML !== html) {
759
813
  const stick = nearBottom();
760
814
  el.innerHTML = html;
@@ -793,7 +847,7 @@ function renderSteer(s) {
793
847
  .join('');
794
848
  let when;
795
849
  if (isCodex(s)) {
796
- if (agenter && busy) when = 'Codex gets this as its next turn, when the current one ends.';
850
+ if (s.running || (agenter && busy)) when = 'Codex gets this as its next turn, when the current one ends.';
797
851
  else if (agenter) when = 'Runs here in Agenter, sandboxed to the project folder (no network).';
798
852
  else when = `Queued in Codex: it runs as the next turn when this session is open in Codex (VS Code or the desktop app). Or <button class="link" type="button" data-drive="agenter">drive from Agenter</button>.`;
799
853
  } else if (busy) when = 'Claude gets this after its current step.';
@@ -827,6 +881,11 @@ async function steer(card) {
827
881
  return toast("Couldn't send that");
828
882
  }
829
883
  const { result } = await r.json();
884
+ // A turn Agenter just started: show your message now, ahead of the transcript.
885
+ if (result === 'started') {
886
+ sentEchoes.set(state.selected, [...(sentEchoes.get(state.selected) || []), message]);
887
+ renderLiveRow();
888
+ }
830
889
  const FAILED = {
831
890
  'no-claude': "Couldn't find Claude Code. Install the VS Code extension, or set AGENTER_CLAUDE to claude.exe",
832
891
  'no-codex': "Couldn't find Codex. Install the Codex VS Code extension, or set AGENTER_CODEX_BIN to codex.exe",
@@ -917,7 +976,7 @@ function renderConvo(keepScroll = false) {
917
976
  continue;
918
977
  }
919
978
  flush();
920
- 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)]);
921
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>`]);
922
981
  }
923
982
  flush();
@@ -925,6 +984,13 @@ function renderConvo(keepScroll = false) {
925
984
  if (stick) scrollToBottom();
926
985
  }
927
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
+
928
994
  // Brings el's children in line with chunks, touching only the ones that changed, so
929
995
  // new messages don't reset text you've selected or re-render the whole conversation.
930
996
  function patchChildren(el, chunks) {
@@ -1134,7 +1200,7 @@ function loadTranscript(sid) {
1134
1200
  const have = state.transcript.sessionId === sid && state.transcript.messages ? state.transcript : null;
1135
1201
  const lastSeq = () => have?.messages.at(-1)?.seq ?? 0;
1136
1202
  const since = have ? `&epoch=${encodeURIComponent(have.epoch)}&after=${lastSeq()}` : '';
1137
- const r = await fetch(`/api/transcript?session=${encodeURIComponent(sid)}${since}`);
1203
+ const r = await fetch(`/api/transcript?session=${encodeURIComponent(sid)}${since}`, { headers: authHeaders });
1138
1204
  if (!r.ok) return;
1139
1205
  const { epoch, reset, messages } = await r.json();
1140
1206
  if (sid !== state.selected) return;
@@ -1142,7 +1208,9 @@ function loadTranscript(sid) {
1142
1208
  // A request that overlapped another may repeat messages; seq tells them apart.
1143
1209
  const merged = reset || !have ? messages : [...have.messages, ...messages.filter((m) => m.seq > lastSeq())].slice(-KEEP_MESSAGES);
1144
1210
  state.transcript = { sessionId: sid, epoch, messages: merged };
1211
+ clearEchoes(sid, messages);
1145
1212
  renderConvo(same);
1213
+ renderLiveRow();
1146
1214
  } catch {}
1147
1215
  }, 150);
1148
1216
  }
@@ -1205,7 +1273,21 @@ const closeNav = () => $('#app').classList.remove('nav-open');
1205
1273
 
1206
1274
  document.addEventListener('click', (e) => {
1207
1275
  const t = e.target;
1276
+ if (t.closest('#moreMenu button')) $('#moreMenu').hidePopover();
1208
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);
1209
1291
  if (t.closest('[data-steer-send]')) return steer(t.closest('.card'));
1210
1292
  if (t.closest('[data-drive]')) return setDrive(t.closest('[data-drive]').dataset.drive);
1211
1293
  if (t.closest('[data-interrupt]')) return post('/api/session/interrupt', { sessionId: state.selected });
@@ -1261,6 +1343,10 @@ document.addEventListener('input', (e) => {
1261
1343
 
1262
1344
  document.addEventListener('keydown', (e) => {
1263
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
+ }
1264
1350
  if (t.matches('.reply-box textarea') && e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
1265
1351
  e.preventDefault();
1266
1352
  return t.closest('[data-steer]') ? steer(t.closest('.card')) : act(t.closest('.card'), 'reply');
@@ -1270,7 +1356,7 @@ document.addEventListener('keydown', (e) => {
1270
1356
  if (e.key === 'Escape') t.blur();
1271
1357
  return;
1272
1358
  }
1273
- if ($('#helpDialog').open || e.ctrlKey || e.metaKey || e.altKey) return;
1359
+ if ($('dialog[open]') || e.ctrlKey || e.metaKey || e.altKey) return;
1274
1360
 
1275
1361
  const perm = keyTarget();
1276
1362
  const k = e.key;
@@ -1282,6 +1368,8 @@ document.addEventListener('keydown', (e) => {
1282
1368
  chooseTheme(next);
1283
1369
  toast(`Theme: ${next}`);
1284
1370
  }
1371
+ else if (k === '`') (e.preventDefault(), toggleTerm());
1372
+ else if (k === 'b') toggleNavMin();
1285
1373
  else if (k === 'j') step(1);
1286
1374
  else if (k === 'k') step(-1);
1287
1375
  else if (k === 'n') nextNeedingYou();
@@ -1313,22 +1401,64 @@ $('#newAgent').addEventListener('click', (e) => {
1313
1401
  if (b) setNewAgent(b.dataset.newAgent);
1314
1402
  });
1315
1403
 
1316
- 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 } = {}) {
1317
1433
  closeNav();
1318
1434
  setNewAgent(store.get('newAgent', 'claude'));
1319
1435
  // Folders you've worked in, most recent first; the last one used here goes on top.
1320
1436
  const last = store.get('newCwd', '');
1321
1437
  const cwds = [...new Set([last, ...[...state.sessions].sort((a, b) => b.lastEventAt - a.lastEventAt).map((s) => s.cwd)].filter(Boolean))];
1322
1438
  $('#newCwdList').innerHTML = cwds.map((c) => `<option value="${esc(c)}"></option>`).join('');
1323
- const current = state.view === 'session' && sessionById(state.selected)?.cwd;
1324
- $('#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;
1325
1450
  $('#newError').hidden = true;
1326
1451
  $('#newStart').disabled = false;
1327
1452
  $('#newDialog').showModal();
1328
1453
  $('#newMsg').focus();
1329
1454
  }
1330
1455
 
1331
- $('#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
+ });
1332
1462
  $('#newDialog [data-new-cancel]').addEventListener('click', () => $('#newDialog').close());
1333
1463
  $('#newMsg').addEventListener('keydown', (e) => {
1334
1464
  if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) (e.preventDefault(), $('#newForm').requestSubmit());
@@ -1345,7 +1475,7 @@ $('#newForm').addEventListener('submit', async (e) => {
1345
1475
  $('#newStart').disabled = true;
1346
1476
  let r;
1347
1477
  try {
1348
- 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 });
1349
1479
  } catch {
1350
1480
  return showError("Couldn't reach the Agenter server.");
1351
1481
  }
@@ -1359,6 +1489,241 @@ $('#newForm').addEventListener('submit', async (e) => {
1359
1489
  toast('Session started');
1360
1490
  });
1361
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
+ }
1362
1727
  $('#helpBtn').addEventListener('click', () => $('#helpDialog').showModal());
1363
1728
  $('#search').addEventListener('input', (e) => {
1364
1729
  state.filter = e.target.value;