agentgui 1.0.939 → 1.0.940

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.
Files changed (44) hide show
  1. package/AGENTS.md +10 -0
  2. package/lib/claude-runner-agents.js +25 -0
  3. package/lib/ws-handlers-util.js +27 -1
  4. package/package.json +1 -1
  5. package/site/app/js/app.js +291 -67
  6. package/site/app/js/backend.js +24 -22
  7. package/site/app/vendor/anentrypoint-design/247420.css +1 -1
  8. package/site/app/vendor/anentrypoint-design/247420.js +4 -4
  9. package/site/app/vendor/cdn/dompurify.js +9 -0
  10. package/site/app/vendor/cdn/fonts/1291de6d401a.woff2 +0 -0
  11. package/site/app/vendor/cdn/fonts/1ba89a87e0b8.woff2 +0 -0
  12. package/site/app/vendor/cdn/fonts/3644d51c507b.woff2 +0 -0
  13. package/site/app/vendor/cdn/fonts/4b91d2650dc2.woff2 +0 -0
  14. package/site/app/vendor/cdn/fonts/530d036ba64a.woff2 +0 -0
  15. package/site/app/vendor/cdn/fonts/570a2bdd8f8b.woff2 +0 -0
  16. package/site/app/vendor/cdn/fonts/5dd6d880fee9.woff2 +0 -0
  17. package/site/app/vendor/cdn/fonts/62de9143afe3.woff2 +0 -0
  18. package/site/app/vendor/cdn/fonts/64884efa2f11.woff2 +0 -0
  19. package/site/app/vendor/cdn/fonts/68cd7063be2e.woff2 +0 -0
  20. package/site/app/vendor/cdn/fonts/6c252abcf99b.woff2 +0 -0
  21. package/site/app/vendor/cdn/fonts/71e69e06516a.woff2 +0 -0
  22. package/site/app/vendor/cdn/fonts/9ea68c62083f.woff2 +0 -0
  23. package/site/app/vendor/cdn/fonts/c010f9b7d6b2.woff2 +0 -0
  24. package/site/app/vendor/cdn/fonts/d69723fc74be.woff2 +0 -0
  25. package/site/app/vendor/cdn/fonts/fonts.css +459 -0
  26. package/site/app/vendor/cdn/marked.js +8 -0
  27. package/site/app/vendor/cdn/prismjs/components/prism-bash.min.js +1 -0
  28. package/site/app/vendor/cdn/prismjs/components/prism-clike.min.js +1 -0
  29. package/site/app/vendor/cdn/prismjs/components/prism-core.min.js +1 -0
  30. package/site/app/vendor/cdn/prismjs/components/prism-css.min.js +1 -0
  31. package/site/app/vendor/cdn/prismjs/components/prism-diff.min.js +1 -0
  32. package/site/app/vendor/cdn/prismjs/components/prism-go.min.js +1 -0
  33. package/site/app/vendor/cdn/prismjs/components/prism-javascript.min.js +1 -0
  34. package/site/app/vendor/cdn/prismjs/components/prism-json.min.js +1 -0
  35. package/site/app/vendor/cdn/prismjs/components/prism-jsx.min.js +1 -0
  36. package/site/app/vendor/cdn/prismjs/components/prism-markdown.min.js +1 -0
  37. package/site/app/vendor/cdn/prismjs/components/prism-markup.min.js +1 -0
  38. package/site/app/vendor/cdn/prismjs/components/prism-python.min.js +1 -0
  39. package/site/app/vendor/cdn/prismjs/components/prism-rust.min.js +1 -0
  40. package/site/app/vendor/cdn/prismjs/components/prism-sql.min.js +1 -0
  41. package/site/app/vendor/cdn/prismjs/components/prism-toml.min.js +1 -0
  42. package/site/app/vendor/cdn/prismjs/components/prism-tsx.min.js +1 -0
  43. package/site/app/vendor/cdn/prismjs/components/prism-typescript.min.js +1 -0
  44. package/site/app/vendor/cdn/prismjs/components/prism-yaml.min.js +1 -0
@@ -10,8 +10,11 @@ const state = {
10
10
  backendDraft: B.getBackend(),
11
11
  health: { status: 'unknown' },
12
12
  tab: 'chat',
13
- models: [],
14
- selectedModel: '',
13
+ agents: [],
14
+ selectedAgent: localStorage.getItem('agentgui.agent') || '',
15
+ agentModels: [],
16
+ selectedModel: localStorage.getItem('agentgui.model') || '',
17
+ chatCwd: localStorage.getItem('agentgui.cwd') || '',
15
18
  chat: { messages: [], busy: false, abort: null, draft: '', resumeSid: null },
16
19
  sessions: [],
17
20
  selectedSid: null,
@@ -23,6 +26,8 @@ const state = {
23
26
  sessionsLimit: 60,
24
27
  projectFilter: '',
25
28
  live: { es: null, connected: false, lastEventTs: 0, error: null, eventCount: 0, reconnects: 0 },
29
+ active: [],
30
+ activeTimer: null,
26
31
  };
27
32
 
28
33
  function readHash() {
@@ -85,18 +90,60 @@ function timeNow() {
85
90
  return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
86
91
  }
87
92
 
93
+ async function selectAgent(id) {
94
+ state.selectedAgent = id;
95
+ localStorage.setItem('agentgui.agent', id);
96
+ state.agentModels = [];
97
+ state.selectedModel = '';
98
+ render();
99
+ const models = await B.listAgentModels(state.backend, id);
100
+ if (state.selectedAgent !== id) return; // changed while loading
101
+ state.agentModels = models;
102
+ const saved = localStorage.getItem('agentgui.model');
103
+ state.selectedModel = (saved && models.some(m => m.id === saved)) ? saved : (models[0]?.id || '');
104
+ render();
105
+ }
106
+
107
+ function selectModel(id) {
108
+ state.selectedModel = id;
109
+ localStorage.setItem('agentgui.model', id);
110
+ render();
111
+ }
112
+
113
+ function agentById(id) { return state.agents.find(a => a.id === id); }
114
+ function agentAvailable(id) { const a = agentById(id); return !a || a.available !== false; }
115
+
88
116
  function navTo(tab) {
89
117
  const prev = state.tab;
90
118
  state.tab = tab;
91
119
  if (tab === 'history') {
92
120
  refreshHistory();
93
121
  openLiveStream();
122
+ startActivePolling();
94
123
  } else if (prev === 'history') {
95
124
  closeLiveStream();
125
+ stopActivePolling();
96
126
  }
97
127
  render();
98
128
  }
99
129
 
130
+ async function refreshActive() {
131
+ state.active = await B.listActiveChats(state.backend);
132
+ render();
133
+ }
134
+ function startActivePolling() {
135
+ if (state.activeTimer) return;
136
+ refreshActive();
137
+ state.activeTimer = setInterval(refreshActive, 3000);
138
+ }
139
+ function stopActivePolling() {
140
+ if (state.activeTimer) { clearInterval(state.activeTimer); state.activeTimer = null; }
141
+ }
142
+ async function stopActiveChat(sid) {
143
+ try { await B.cancelChat(state.backend, sid); } catch {}
144
+ refreshActive();
145
+ }
146
+
100
147
  function openLiveStream() {
101
148
  if (state.live.es) return;
102
149
  state.live.error = null;
@@ -171,16 +218,33 @@ function view() {
171
218
  onNav: (label) => navTo(label),
172
219
  });
173
220
 
221
+ const agentOptions = state.agents.map(a => ({
222
+ value: a.id,
223
+ label: a.name + (a.available === false ? (a.npxInstallable ? ' (via npx)' : ' (not installed)') : ''),
224
+ disabled: a.available === false && !a.npxInstallable,
225
+ }));
226
+ const showModelPicker = state.tab === 'chat' && state.agentModels.length > 0;
227
+
174
228
  const crumbRight = state.tab === 'chat'
175
229
  ? [h('div', { key: 'cc', class: 'chat-controls' },
176
230
  Select({
177
- key: 'modelsel',
178
- value: state.selectedModel,
179
- placeholder: '— model —',
180
- title: 'Select AI model',
181
- options: state.models.map(m => ({ value: m.id, label: m.id })),
182
- onChange: (v) => { state.selectedModel = v; render(); },
231
+ key: 'agentsel',
232
+ value: state.selectedAgent,
233
+ placeholder: '— agent —',
234
+ title: 'Select coding agent',
235
+ options: agentOptions,
236
+ onChange: (v) => { selectAgent(v); },
183
237
  }),
238
+ showModelPicker
239
+ ? Select({
240
+ key: 'modelsel',
241
+ value: state.selectedModel,
242
+ placeholder: '— model —',
243
+ title: 'Select model for this agent',
244
+ options: state.agentModels.map(m => ({ value: m.id, label: m.name || m.id })),
245
+ onChange: (v) => { selectModel(v); },
246
+ })
247
+ : null,
184
248
  state.chat.busy
185
249
  ? Btn({ key: 'stop', onClick: cancelChat, children: '◼ stop', title: 'Stop streaming' })
186
250
  : Btn({ key: 'new', onClick: newChat, children: '+ new', title: 'Start new chat (clears history)' }),
@@ -200,15 +264,22 @@ function view() {
200
264
  // sidebar (the topbar already provides primary nav) so main content gets full width.
201
265
  const side = state.tab === 'history' ? historySide() : null;
202
266
 
267
+ const agentLabel = state.selectedAgent
268
+ ? '⌘ ' + (agentById(state.selectedAgent)?.name || state.selectedAgent) + (state.selectedModel ? ' · ' + state.selectedModel : '')
269
+ : '○ no agent';
203
270
  const status = Status({
204
271
  left: [state.backend || 'same-origin', ok ? '● live' : '○ offline'],
205
- right: [state.selectedModel ? '⌘ ' + state.selectedModel : '○ no model'],
272
+ right: [agentLabel],
206
273
  });
207
274
 
208
275
  const mainStyle = state.tab === 'chat'
209
276
  ? 'min-height:0;height:100%;display:flex;flex-direction:column'
210
277
  : 'min-height:0;height:100%;overflow:auto';
211
- const main = h('div', { id: 'agentgui-main', role: 'main', 'data-chat-scroll': '', class: 'agentgui-main agentgui-main-' + state.tab, style: mainStyle }, mainContent());
278
+ const shortcutsHint = state.showShortcuts
279
+ ? Alert({ key: 'sc', kind: 'info', title: 'Keyboard shortcuts',
280
+ children: 'g then c/h/s — chat/history/settings · n — new chat · / — focus search/composer · ? — toggle this · Esc — blur field' })
281
+ : null;
282
+ const main = h('div', { id: 'agentgui-main', role: 'main', 'data-chat-scroll': '', class: 'agentgui-main agentgui-main-' + state.tab, style: mainStyle }, [shortcutsHint, ...mainContent()].filter(Boolean));
212
283
  // settings reads better centered in a measure; chat + history use full width.
213
284
  return AppShell({ topbar, crumb, side, main, status, narrow: state.tab === 'settings' });
214
285
  }
@@ -220,41 +291,77 @@ function mainContent() {
220
291
  }
221
292
 
222
293
  // ── chat ───────────────────────────────────────────────────────────────────
294
+ function canSend() {
295
+ return !!state.selectedAgent && agentAvailable(state.selectedAgent) && !state.chat.busy;
296
+ }
297
+
298
+ function toolSummary(block) {
299
+ const name = block.name || block.kind || 'tool';
300
+ let arg = '';
301
+ const inp = block.input || block.rawInput;
302
+ if (inp && typeof inp === 'object') {
303
+ arg = inp.command || inp.file_path || inp.path || inp.pattern || inp.query || inp.url || '';
304
+ if (!arg) { try { arg = JSON.stringify(inp).slice(0, 120); } catch {} }
305
+ }
306
+ return '⌘ ' + name + (arg ? ' · ' + String(arg).slice(0, 120) : '');
307
+ }
308
+
223
309
  function chatMain() {
224
310
  const lastIdx = state.chat.messages.length - 1;
311
+ const agentName = agentById(state.selectedAgent)?.name || state.selectedAgent || 'agent';
225
312
  const msgs = state.chat.messages.map((m, i) => {
226
313
  const isAssistant = m.role === 'assistant';
227
314
  const isStreaming = state.chat.busy && i === lastIdx && isAssistant;
228
- const isEmptyStreaming = isStreaming && !m.content;
315
+ const hasParts = Array.isArray(m.parts) && m.parts.length > 0;
316
+ const isEmptyStreaming = isStreaming && !m.content && !hasParts;
317
+ const parts = [];
318
+ if (m.content) parts.push({ kind: isAssistant ? 'md' : 'text', text: m.content });
319
+ if (hasParts) for (const p of m.parts) parts.push({ kind: 'text', text: p });
229
320
  return {
230
321
  key: m.id || String(i),
231
322
  who: isAssistant ? 'them' : 'you',
232
- name: isAssistant ? (state.selectedModel || 'agent') : 'you',
323
+ name: isAssistant ? agentName : 'you',
233
324
  time: m.time || '',
234
325
  typing: isEmptyStreaming,
235
- parts: isEmptyStreaming
236
- ? undefined
237
- : [{ kind: isAssistant ? 'md' : 'text', text: m.content || '' }],
326
+ parts: isEmptyStreaming ? undefined : (parts.length ? parts : [{ kind: 'text', text: '' }]),
238
327
  };
239
328
  });
240
329
 
330
+ const placeholder = !state.selectedAgent
331
+ ? 'choose an agent first'
332
+ : (!agentAvailable(state.selectedAgent) ? agentName + ' is not installed' : 'message…');
241
333
  const composer = ChatComposer({
242
334
  value: state.chat.draft,
243
- disabled: state.chat.busy,
244
- placeholder: state.selectedModel ? 'message…' : 'choose a model first',
335
+ disabled: state.chat.busy || !canSend(),
336
+ placeholder,
245
337
  onInput: (v) => { state.chat.draft = v; render(); },
246
338
  onSend: (v) => { state.chat.draft = v; sendChat(); },
247
339
  });
248
340
 
249
- const resumeBanner = state.chat.resumeSid
250
- ? h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
251
- h('span', { class: 'lede' }, '▶ resuming session ' + state.chat.resumeSid.slice(0, 8) + ' via claude --resume'),
252
- Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; render(); }, children: '× clear' }))
253
- : null;
341
+ const banners = [];
342
+ if (state.chat.resumeSid) {
343
+ banners.push(h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
344
+ h('span', { class: 'lede' }, '▶ resuming session ' + state.chat.resumeSid.slice(0, 8) + ' via --resume'),
345
+ Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; render(); }, children: '× clear' })));
346
+ }
347
+ banners.push(h('div', { key: 'cwdb', class: 'resume-banner', role: 'group', 'aria-label': 'Working directory' },
348
+ h('span', { class: 'lede' }, state.chatCwd ? '▣ cwd: ' + state.chatCwd : '▣ cwd: server default'),
349
+ Btn({ key: 'cwdset', onClick: () => {
350
+ const v = prompt('Working directory for new chats (absolute path; blank = server default):', state.chatCwd || '');
351
+ if (v === null) return;
352
+ state.chatCwd = v.trim();
353
+ if (state.chatCwd) localStorage.setItem('agentgui.cwd', state.chatCwd); else localStorage.removeItem('agentgui.cwd');
354
+ render();
355
+ }, children: state.chatCwd ? 'change' : 'set' }),
356
+ state.chatCwd ? Btn({ key: 'cwdclr', onClick: () => { state.chatCwd = ''; localStorage.removeItem('agentgui.cwd'); render(); }, children: '× default' }) : null));
357
+ if (state.selectedAgent && !agentAvailable(state.selectedAgent)) {
358
+ banners.push(Alert({ key: 'unavail', kind: 'warn', title: agentName + ' is not installed',
359
+ children: 'This agent\'s CLI was not found on the server. Pick another agent or install it.' }));
360
+ }
254
361
  return [
255
- resumeBanner,
362
+ ...banners,
256
363
  Chat({
257
- title: (state.selectedModel || 'agent') + (state.chat.resumeSid ? ' · resume' : ''),
364
+ title: agentName + (state.selectedModel ? ' · ' + state.selectedModel : '') + (state.chat.resumeSid ? ' · resume' : ''),
258
365
  sub: state.chat.busy ? 'streaming…' : undefined,
259
366
  messages: msgs,
260
367
  composer,
@@ -263,43 +370,69 @@ function chatMain() {
263
370
  }
264
371
 
265
372
  function newChat() {
266
- if (!confirm('Clear chat history? This cannot be undone.')) return;
373
+ if (state.chat.messages.length && !confirm('Clear chat history? This cannot be undone.')) return;
267
374
  state.chat.abort?.abort();
268
375
  state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null };
376
+ try { localStorage.removeItem(CHAT_KEY); } catch {}
269
377
  render();
270
378
  }
271
379
 
272
380
  function cancelChat() { state.chat.abort?.abort(); }
273
381
 
382
+ const CHAT_KEY = 'agentgui.chat';
383
+ function persistChat() {
384
+ try {
385
+ const msgs = state.chat.messages.map(m => ({ id: m.id, role: m.role, content: m.content, time: m.time, parts: m.parts }));
386
+ if (!msgs.length) { localStorage.removeItem(CHAT_KEY); return; }
387
+ localStorage.setItem(CHAT_KEY, JSON.stringify({ messages: msgs, resumeSid: state.chat.resumeSid, agent: state.selectedAgent, model: state.selectedModel }));
388
+ } catch {}
389
+ }
390
+ function restoreChat() {
391
+ try {
392
+ const raw = localStorage.getItem(CHAT_KEY);
393
+ if (!raw) return;
394
+ const saved = JSON.parse(raw);
395
+ if (Array.isArray(saved.messages) && saved.messages.length) {
396
+ state.chat.messages = saved.messages.map(m => ({ ...m, parts: Array.isArray(m.parts) ? m.parts : [] }));
397
+ state.chat.resumeSid = saved.resumeSid || null;
398
+ }
399
+ } catch {}
400
+ }
401
+
274
402
  async function sendChat() {
275
403
  const text = (state.chat.draft || '').trim();
276
- if (!text || !state.selectedModel || state.chat.busy) return;
404
+ if (!text || !canSend()) return;
277
405
  const t = timeNow();
278
406
  const userMsg = { id: 'u' + Date.now(), role: 'user', content: text, time: t };
279
- const curMsg = { id: 'a' + (Date.now() + 1), role: 'assistant', content: '', time: t };
407
+ const curMsg = { id: 'a' + (Date.now() + 1), role: 'assistant', content: '', time: t, parts: [] };
280
408
  state.chat.messages = [...state.chat.messages, userMsg, curMsg];
281
409
  state.chat.draft = '';
282
410
  state.chat.busy = true;
283
411
  const ctrl = new AbortController();
284
412
  state.chat.abort = ctrl;
413
+ persistChat();
285
414
  render();
286
415
  scrollChatToBottom();
287
416
  const cur = state.chat.messages[state.chat.messages.length - 1];
288
417
  try {
289
418
  for await (const ev of B.streamChat(state.backend, {
290
- model: state.selectedModel,
419
+ agentId: state.selectedAgent,
420
+ model: state.selectedModel || undefined,
421
+ cwd: state.chatCwd || undefined,
291
422
  messages: state.chat.messages.slice(0, -1).map(m => ({ role: m.role, content: m.content })),
292
423
  signal: ctrl.signal,
293
424
  resumeSid: state.chat.resumeSid || undefined,
294
425
  })) {
295
426
  if (ev.type === 'text') { cur.content += ev.text; render(); scrollChatToBottom(); }
296
- if (ev.type === 'error') { cur.content += '\n[error] ' + JSON.stringify(ev.error); render(); }
427
+ else if (ev.type === 'tool') { cur.parts.push(toolSummary(ev.block)); render(); scrollChatToBottom(); }
428
+ else if (ev.type === 'error') { cur.content += '\n[error] ' + JSON.stringify(ev.error); render(); }
297
429
  }
298
430
  } catch (e) {
299
431
  if (e.name !== 'AbortError') cur.content += '\n[error] ' + e.message;
300
432
  } finally {
301
433
  state.chat.busy = false;
302
434
  state.chat.abort = null;
435
+ persistChat();
303
436
  render();
304
437
  scrollChatToBottom();
305
438
  }
@@ -342,6 +475,7 @@ function historyMain() {
342
475
  return [head, actions, Panel({ title: 'events', children: h('div', { key: 'loading', class: 'lede empty-state', role: 'status', 'aria-live': 'polite' }, Spinner({ key: 'spin', size: 'sm' }), 'loading events…') })];
343
476
  }
344
477
 
478
+ if (!state.expandedEvents) state.expandedEvents = new Set();
345
479
  return [
346
480
  head,
347
481
  actions,
@@ -349,16 +483,21 @@ function historyMain() {
349
483
  title: state.events.length + ' events',
350
484
  children: EventList({
351
485
  items: state.events.slice(-300).map((e, i) => {
486
+ const idx = e.i ?? i;
352
487
  const role = e.role || '?';
353
488
  const type = e.type || '?';
354
489
  const tool = e.tool ? ' · ⌘ ' + e.tool : '';
355
490
  const errMark = e.isError ? ' · ⚠' : '';
356
- const text = (e.text || '').replace(/\s+/g, ' ').trim();
491
+ const raw = e.text || '';
492
+ const text = raw.replace(/\s+/g, ' ').trim();
493
+ const expanded = state.expandedEvents.has(idx);
494
+ const full = e.toolInput ? (text + '\n\n' + JSON.stringify(e.toolInput, null, 2)) : raw;
357
495
  return {
358
- key: 'ev' + (e.i ?? i),
359
- code: String((e.i ?? i) + 1).padStart(4, '0'),
360
- title: text.slice(0, 220) || '(' + type + ')',
361
- sub: new Date(e.ts).toLocaleString() + ' · ' + role + ' · ' + type + tool + errMark,
496
+ key: 'ev' + idx,
497
+ code: String(idx + 1).padStart(4, '0'),
498
+ title: expanded ? (full || '(' + type + ')') : (text.slice(0, 220) || '(' + type + ')'),
499
+ sub: new Date(e.ts).toLocaleString() + ' · ' + role + ' · ' + type + tool + errMark + (raw.length > 220 ? ' · ' + (expanded ? 'click to collapse' : 'click to expand') : ''),
500
+ onClick: () => { expanded ? state.expandedEvents.delete(idx) : state.expandedEvents.add(idx); render(); },
362
501
  };
363
502
  }),
364
503
  }),
@@ -372,8 +511,8 @@ function resumeInChat(sess) {
372
511
  state.chat.resumeSid = sess?.sid || state.selectedSid;
373
512
  state.chat.messages = [];
374
513
  state.chat.draft = '';
375
- // Default to claude-code if no model yet (only claude supports --resume by sid here).
376
- if (!state.selectedModel || state.selectedModel !== 'claude-code') state.selectedModel = 'claude-code';
514
+ // Only claude-code supports --resume by sid here.
515
+ if (state.selectedAgent !== 'claude-code') selectAgent('claude-code');
377
516
  render();
378
517
  }
379
518
 
@@ -427,8 +566,22 @@ function historySide() {
427
566
  );
428
567
  const subagentCount = (Array.isArray(state.sessions) ? state.sessions : []).filter(s => s.isSubagent).length;
429
568
  const projects = uniqueProjects();
569
+ const running = Array.isArray(state.active) ? state.active : [];
430
570
 
431
571
  return [
572
+ running.length
573
+ ? Panel({
574
+ key: 'runningPanel',
575
+ title: '▶ running · ' + running.length,
576
+ children: running.map((r, i) => {
577
+ const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
578
+ const elapsed = r.startedAt ? Math.round((Date.now() - r.startedAt) / 1000) : 0;
579
+ return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
580
+ h('span', { class: 'lede' }, '● ' + agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split('/').slice(-1)[0] : '')),
581
+ Btn({ key: 'stop' + r.sessionId, onClick: () => stopActiveChat(r.sessionId), children: '◼ stop' }));
582
+ }),
583
+ })
584
+ : null,
432
585
  Panel({
433
586
  title: searching
434
587
  ? 'matches · ' + (state.searchHits.results?.length || 0)
@@ -441,11 +594,17 @@ function historySide() {
441
594
  value: state.searchQ,
442
595
  onInput: (v) => { state.searchQ = v; debouncedSearch(); },
443
596
  }),
597
+ state.searchBusy
598
+ ? h('div', { key: 'searchbusy', class: 'lede empty-state', role: 'status' }, Spinner({ key: 'ss', size: 'sm' }), 'searching…')
599
+ : null,
444
600
  searching && state.searchHits.error
445
601
  ? Alert({ key: 'searcherr', kind: 'error', title: 'Search failed', children: state.searchHits.error })
446
602
  : null,
447
- state.searchQ && searching
448
- ? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; render(); }, children: '× clear search' })
603
+ searching && !state.searchBusy && !state.searchHits.error && (state.searchHits.results || []).length === 0
604
+ ? h('p', { key: 'nomatch', class: 'lede empty-state' }, 'no matches for "' + state.searchQ + '"')
605
+ : null,
606
+ state.searchQ && (searching || state.searchBusy)
607
+ ? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; render(); }, children: '× clear search' })
449
608
  : null,
450
609
  !searching && projects.length > 1
451
610
  ? h('div', { key: 'projfilter', class: 'pill-row', role: 'group', 'aria-label': 'Filter sessions by project' },
@@ -466,7 +625,7 @@ function historySide() {
466
625
  : null,
467
626
  ],
468
627
  }),
469
- ];
628
+ ].filter(Boolean);
470
629
  }
471
630
 
472
631
  // ── settings ───────────────────────────────────────────────────────────────
@@ -476,14 +635,16 @@ function isValidUrl(s) {
476
635
  catch { return false; }
477
636
  }
478
637
 
479
- function saveBackend() {
480
- if (!isValidUrl(state.backendDraft)) return;
481
- if (!confirm('Reconnect to new backend? Current session will be lost.')) return;
638
+ async function saveBackend() {
639
+ if (!isValidUrl(state.backendDraft) || state.backendDraft === state.backend) return;
482
640
  B.setBackend(state.backendDraft);
483
641
  state.backend = state.backendDraft;
484
642
  state.health = { status: 'unknown' };
643
+ state.backendStatus = 'connecting';
644
+ render();
645
+ await init();
646
+ state.backendStatus = state.health.status === 'ok' ? 'ok' : 'failed';
485
647
  render();
486
- init();
487
648
  }
488
649
 
489
650
  function healthSummary() {
@@ -525,36 +686,57 @@ function settingsMain() {
525
686
  onInput: (v) => { state.backendDraft = v; render(); },
526
687
  }),
527
688
  !isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, '⚠ Invalid URL format') : null,
689
+ state.backendStatus === 'connecting' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, '◌ connecting…') : null,
690
+ state.backendStatus === 'ok' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, '● connected') : null,
691
+ state.backendStatus === 'failed' ? h('p', { key: 'bst', class: 'lede field-error', role: 'alert' }, '○ connection failed — check the URL') : null,
528
692
  healthSummary(),
529
693
  Btn({
530
694
  key: 'savebtn',
531
695
  type: 'submit',
532
696
  primary: true,
533
- disabled: !isValid,
697
+ disabled: !isValid || state.backendDraft === state.backend || state.backendStatus === 'connecting',
534
698
  onClick: (e) => { e.preventDefault(); saveBackend(); },
535
- children: 'save + reconnect',
699
+ children: state.backendStatus === 'connecting' ? 'connecting…' : 'save + reconnect',
536
700
  title: isValid ? 'Save backend URL and reconnect' : 'Fix URL format first',
537
701
  }),
538
702
  ]),
539
703
  }),
540
- Panel({
541
- title: 'models',
542
- children: state.models.length
543
- ? state.models.slice(0, 40).map((m, i) =>
544
- Row({
545
- key: 'm' + i,
546
- rank: String(i + 1).padStart(3, '0'),
547
- title: m.id,
548
- sub: m.name ? (m.name + ' · ' + (m.protocol || 'agent')) : (m.protocol || 'agent'),
549
- rail: m.id === state.selectedModel ? 'green' : 'purple',
550
- onClick: () => { state.selectedModel = m.id; render(); },
551
- })
552
- )
553
- : h('p', { key: 'none', class: 'lede' }, 'no models loaded'),
554
- }),
704
+ agentsPanel(),
555
705
  ];
556
706
  }
557
707
 
708
+ function acpStatusFor(agentId) {
709
+ const acp = Array.isArray(state.health.acp) ? state.health.acp : [];
710
+ return acp.find(a => a.id === agentId) || null;
711
+ }
712
+
713
+ function agentsPanel() {
714
+ const installed = state.agents.filter(a => a.available !== false);
715
+ return Panel({
716
+ title: 'agents · ' + installed.length + '/' + state.agents.length + ' installed',
717
+ children: state.agents.length
718
+ ? state.agents.map((a, i) => {
719
+ const acp = acpStatusFor(a.id);
720
+ const avail = a.available !== false;
721
+ const bits = [a.protocol || 'agent'];
722
+ if (!avail) bits.push(a.npxInstallable ? 'runs via npx' : 'not installed');
723
+ if (acp) bits.push(acp.healthy ? 'running·healthy' : (acp.running ? 'running' : 'stopped'));
724
+ if (acp && acp.port) bits.push('port ' + acp.port);
725
+ if (acp && acp.restartCount) bits.push(acp.restartCount + ' restarts');
726
+ return Row({
727
+ key: 'ag' + a.id,
728
+ rank: String(i + 1).padStart(3, '0'),
729
+ title: a.name + (avail ? '' : ' ·'),
730
+ sub: bits.join(' · '),
731
+ rail: a.id === state.selectedAgent ? 'green' : (avail ? 'purple' : 'flame'),
732
+ active: a.id === state.selectedAgent,
733
+ onClick: () => { if (avail || a.npxInstallable) { navTo('chat'); selectAgent(a.id); } },
734
+ });
735
+ })
736
+ : h('p', { key: 'none', class: 'lede' }, 'no agents loaded'),
737
+ });
738
+ }
739
+
558
740
  // ── data ──────────────────────────────────────────────────────────────────
559
741
  async function refreshHistory() {
560
742
  try {
@@ -569,12 +751,17 @@ async function refreshHistory() {
569
751
  }
570
752
 
571
753
  async function runSearch() {
572
- if (!state.searchQ.trim()) { state.searchHits = null; render(); return; }
754
+ const q = state.searchQ.trim();
755
+ if (!q) { state.searchHits = null; state.searchBusy = false; render(); return; }
756
+ if (q.length < 2) { state.searchHits = null; state.searchBusy = false; render(); return; }
757
+ state.searchBusy = true;
758
+ render();
573
759
  try {
574
- state.searchHits = await B.searchHistory(state.backend, state.searchQ, 50);
575
- render();
760
+ state.searchHits = await B.searchHistory(state.backend, q, 50);
576
761
  } catch (e) {
577
- state.searchHits = { query: state.searchQ, results: [], error: e.message };
762
+ state.searchHits = { query: q, results: [], error: e.message };
763
+ } finally {
764
+ state.searchBusy = false;
578
765
  render();
579
766
  }
580
767
  }
@@ -606,10 +793,13 @@ async function init() {
606
793
  }
607
794
  render();
608
795
  try {
609
- state.models = await B.listModels(state.backend);
610
- if (!state.selectedModel && state.models[0]) state.selectedModel = state.models[0].id;
796
+ state.agents = await B.listAgents(state.backend);
797
+ // Restore the saved agent if still present; else first available, else first.
798
+ let target = state.agents.find(a => a.id === state.selectedAgent);
799
+ if (!target) target = state.agents.find(a => a.available !== false) || state.agents[0];
800
+ if (target) await selectAgent(target.id);
611
801
  render();
612
- } catch (e) { console.warn('models fetch failed:', e.message); }
802
+ } catch (e) { console.warn('agents fetch failed:', e.message); }
613
803
 
614
804
  const initialSid = readHash();
615
805
  if (initialSid) {
@@ -627,6 +817,40 @@ async function init() {
627
817
  });
628
818
  }
629
819
 
820
+ restoreChat();
630
821
  render = mount(document.getElementById('app'), view);
631
822
  window.__agentgui = { state, render };
823
+
824
+ // Keyboard shortcuts. 'g' then c/h/s switches tabs; 'n' new chat; '/' focuses
825
+ // search (history) or composer (chat). Ignored while typing in a field.
826
+ let gPending = false;
827
+ function focusComposer() {
828
+ const el = document.querySelector('#agentgui-main textarea, #agentgui-main [contenteditable="true"], #agentgui-main input[type="text"]');
829
+ el?.focus();
830
+ }
831
+ function focusSearch() {
832
+ const el = document.querySelector('#app input[type="search"]');
833
+ el?.focus();
834
+ }
835
+ window.addEventListener('keydown', (e) => {
836
+ const t = e.target;
837
+ const typing = t && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT' || t.isContentEditable);
838
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
839
+ if (typing) {
840
+ if (e.key === 'Escape') t.blur();
841
+ return;
842
+ }
843
+ if (gPending) {
844
+ gPending = false;
845
+ if (e.key === 'c') { navTo('chat'); return; }
846
+ if (e.key === 'h') { navTo('history'); return; }
847
+ if (e.key === 's') { navTo('settings'); return; }
848
+ return;
849
+ }
850
+ if (e.key === 'g') { gPending = true; setTimeout(() => { gPending = false; }, 1000); return; }
851
+ if (e.key === 'n' && state.tab === 'chat') { e.preventDefault(); newChat(); return; }
852
+ if (e.key === '/') { e.preventDefault(); state.tab === 'history' ? focusSearch() : focusComposer(); return; }
853
+ if (e.key === '?') { state.showShortcuts = !state.showShortcuts; render(); return; }
854
+ });
855
+
632
856
  init();
@@ -203,12 +203,28 @@ function addSessionListener(sessionId, fn) {
203
203
 
204
204
  // ---------- Agents / models (WS) ----------
205
205
 
206
- export async function listModels(base) {
206
+ export async function listAgents(base) {
207
207
  const { agents } = await wsCall(base, 'agents.list', {});
208
- // Compatibility shape: app.js expects an array of {id, name?, ...}
209
208
  return agents || [];
210
209
  }
211
210
 
211
+ export async function listActiveChats(base) {
212
+ try { const { sessions } = await wsCall(base, 'chat.active', {}); return sessions || []; }
213
+ catch { return []; }
214
+ }
215
+
216
+ export async function cancelChat(base, sessionId) {
217
+ return wsCall(base, 'chat.cancel', { sessionId });
218
+ }
219
+
220
+ export async function listAgentModels(base, agentId) {
221
+ if (!agentId) return [];
222
+ try {
223
+ const { models } = await wsCall(base, 'agents.models', { id: agentId });
224
+ return models || [];
225
+ } catch { return []; }
226
+ }
227
+
212
228
  // ---------- Streaming chat (WS) ----------
213
229
  //
214
230
  // Yields events of shape:
@@ -218,29 +234,15 @@ export async function listModels(base) {
218
234
  // { type: 'error', error: '...' }
219
235
  //
220
236
  // Caller signature kept compatible with the previous HTTP/SSE impl.
221
- export async function* streamChat(base, { model, messages, signal, agentId, resumeSid }) {
222
- // The last user message is the prompt; agentgui's claude-runner doesn't
223
- // accept a full message list it spawns the agent for a single prompt.
224
- // For multi-turn, the agent's own session/resume handles continuity.
237
+ export async function* streamChat(base, { model, messages, signal, agentId, resumeSid, cwd }) {
238
+ // The last user message is the prompt; agentgui's claude-runner spawns the
239
+ // agent for a single prompt. Multi-turn continuity is the agent's own resume.
225
240
  const last = messages[messages.length - 1];
226
241
  const content = last?.content || '';
227
242
  if (!content) return;
228
243
 
229
- // app.js treats the "model" picker as the agent picker (selects from
230
- // agents.list ids). If no explicit agentId is given, model IS the agent.
231
- // If `model` looks like a real model id (has a slash), keep it as model
232
- // and default agent to claude-code.
233
- let resolvedAgentId = agentId;
234
- let resolvedModel = model;
235
- if (!resolvedAgentId) {
236
- if (!model || /^[a-z][a-z0-9-]*$/.test(model)) {
237
- // Bare slug — treat as agentId.
238
- resolvedAgentId = model || 'claude-code';
239
- resolvedModel = undefined;
240
- } else {
241
- resolvedAgentId = 'claude-code';
242
- }
243
- }
244
+ const resolvedAgentId = agentId || 'claude-code';
245
+ const resolvedModel = model || undefined;
244
246
 
245
247
  // Queue events here; the async iterator pulls from it.
246
248
  const queue = [];
@@ -252,7 +254,7 @@ export async function* streamChat(base, { model, messages, signal, agentId, resu
252
254
  // Kick off the chat on the server.
253
255
  let started;
254
256
  try {
255
- started = await wsCall(base, 'chat.sendMessage', { content, agentId: resolvedAgentId, model: resolvedModel, resumeSid });
257
+ started = await wsCall(base, 'chat.sendMessage', { content, agentId: resolvedAgentId, model: resolvedModel, resumeSid, cwd });
256
258
  } catch (e) {
257
259
  yield { type: 'error', error: e.message };
258
260
  return;
@@ -6,7 +6,7 @@
6
6
  Source-of-truth tokens. Component sheet lives in app-shell.css.
7
7
  ============================================================ */
8
8
 
9
- @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400;1,700&family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
9
+ @import url('../cdn/fonts/fonts.css');
10
10
 
11
11
  .ds-247420 {
12
12
  /* Tree view indentation tokens */