agentgui 1.0.938 → 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 (45) 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/index.html +63 -4
  6. package/site/app/js/app.js +322 -103
  7. package/site/app/js/backend.js +24 -22
  8. package/site/app/vendor/anentrypoint-design/247420.css +5216 -0
  9. package/site/app/vendor/anentrypoint-design/247420.js +247 -0
  10. package/site/app/vendor/cdn/dompurify.js +9 -0
  11. package/site/app/vendor/cdn/fonts/1291de6d401a.woff2 +0 -0
  12. package/site/app/vendor/cdn/fonts/1ba89a87e0b8.woff2 +0 -0
  13. package/site/app/vendor/cdn/fonts/3644d51c507b.woff2 +0 -0
  14. package/site/app/vendor/cdn/fonts/4b91d2650dc2.woff2 +0 -0
  15. package/site/app/vendor/cdn/fonts/530d036ba64a.woff2 +0 -0
  16. package/site/app/vendor/cdn/fonts/570a2bdd8f8b.woff2 +0 -0
  17. package/site/app/vendor/cdn/fonts/5dd6d880fee9.woff2 +0 -0
  18. package/site/app/vendor/cdn/fonts/62de9143afe3.woff2 +0 -0
  19. package/site/app/vendor/cdn/fonts/64884efa2f11.woff2 +0 -0
  20. package/site/app/vendor/cdn/fonts/68cd7063be2e.woff2 +0 -0
  21. package/site/app/vendor/cdn/fonts/6c252abcf99b.woff2 +0 -0
  22. package/site/app/vendor/cdn/fonts/71e69e06516a.woff2 +0 -0
  23. package/site/app/vendor/cdn/fonts/9ea68c62083f.woff2 +0 -0
  24. package/site/app/vendor/cdn/fonts/c010f9b7d6b2.woff2 +0 -0
  25. package/site/app/vendor/cdn/fonts/d69723fc74be.woff2 +0 -0
  26. package/site/app/vendor/cdn/fonts/fonts.css +459 -0
  27. package/site/app/vendor/cdn/marked.js +8 -0
  28. package/site/app/vendor/cdn/prismjs/components/prism-bash.min.js +1 -0
  29. package/site/app/vendor/cdn/prismjs/components/prism-clike.min.js +1 -0
  30. package/site/app/vendor/cdn/prismjs/components/prism-core.min.js +1 -0
  31. package/site/app/vendor/cdn/prismjs/components/prism-css.min.js +1 -0
  32. package/site/app/vendor/cdn/prismjs/components/prism-diff.min.js +1 -0
  33. package/site/app/vendor/cdn/prismjs/components/prism-go.min.js +1 -0
  34. package/site/app/vendor/cdn/prismjs/components/prism-javascript.min.js +1 -0
  35. package/site/app/vendor/cdn/prismjs/components/prism-json.min.js +1 -0
  36. package/site/app/vendor/cdn/prismjs/components/prism-jsx.min.js +1 -0
  37. package/site/app/vendor/cdn/prismjs/components/prism-markdown.min.js +1 -0
  38. package/site/app/vendor/cdn/prismjs/components/prism-markup.min.js +1 -0
  39. package/site/app/vendor/cdn/prismjs/components/prism-python.min.js +1 -0
  40. package/site/app/vendor/cdn/prismjs/components/prism-rust.min.js +1 -0
  41. package/site/app/vendor/cdn/prismjs/components/prism-sql.min.js +1 -0
  42. package/site/app/vendor/cdn/prismjs/components/prism-toml.min.js +1 -0
  43. package/site/app/vendor/cdn/prismjs/components/prism-tsx.min.js +1 -0
  44. package/site/app/vendor/cdn/prismjs/components/prism-typescript.min.js +1 -0
  45. 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,53 +218,70 @@ 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)' }),
187
251
  dot,
188
- ]
252
+ )]
189
253
  : [dot];
190
254
 
255
+ // Topbar already shows "agentgui / <tab>"; the crumb is reserved for contextual
256
+ // controls (model picker, new/stop, live status) so it doesn't duplicate the path.
191
257
  const crumb = Crumb({
192
- trail: ['agentgui'],
193
- leaf: state.tab,
258
+ trail: [],
259
+ leaf: '',
194
260
  right: crumbRight,
195
261
  });
196
262
 
197
- const navSide = Side({
198
- sections: [
199
- {
200
- group: 'navigate',
201
- items: [
202
- { glyph: '▣', label: 'chat', key: 'chat', active: state.tab === 'chat',
203
- onClick: (e) => { e.preventDefault(); navTo('chat'); } },
204
- { glyph: '§', label: 'history', key: 'history', active: state.tab === 'history',
205
- onClick: (e) => { e.preventDefault(); navTo('history'); } },
206
- { glyph: '⌘', label: 'settings', key: 'settings', active: state.tab === 'settings',
207
- onClick: (e) => { e.preventDefault(); navTo('settings'); } },
208
- ],
209
- },
210
- ],
211
- });
212
- const side = state.tab === 'history' ? historySide() : navSide;
263
+ // Sidebar is contextual: history shows the session list; chat/settings have no
264
+ // sidebar (the topbar already provides primary nav) so main content gets full width.
265
+ const side = state.tab === 'history' ? historySide() : null;
213
266
 
267
+ const agentLabel = state.selectedAgent
268
+ ? '⌘ ' + (agentById(state.selectedAgent)?.name || state.selectedAgent) + (state.selectedModel ? ' · ' + state.selectedModel : '')
269
+ : '○ no agent';
214
270
  const status = Status({
215
- left: [state.backend, ok ? '● live' : '○ offline'],
216
- right: [state.selectedModel ? '⌘ ' + state.selectedModel : '○ no model'],
271
+ left: [state.backend || 'same-origin', ok ? '● live' : '○ offline'],
272
+ right: [agentLabel],
217
273
  });
218
274
 
219
- const main = h('div', { id: 'agentgui-main', role: 'main', 'data-chat-scroll': '', style: 'min-height:0;height:100%;overflow:auto' }, mainContent());
220
- return AppShell({ topbar, crumb, side, main, status });
275
+ const mainStyle = state.tab === 'chat'
276
+ ? 'min-height:0;height:100%;display:flex;flex-direction:column'
277
+ : 'min-height:0;height:100%;overflow:auto';
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));
283
+ // settings reads better centered in a measure; chat + history use full width.
284
+ return AppShell({ topbar, crumb, side, main, status, narrow: state.tab === 'settings' });
221
285
  }
222
286
 
223
287
  function mainContent() {
@@ -227,42 +291,78 @@ function mainContent() {
227
291
  }
228
292
 
229
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
+
230
309
  function chatMain() {
231
310
  const lastIdx = state.chat.messages.length - 1;
311
+ const agentName = agentById(state.selectedAgent)?.name || state.selectedAgent || 'agent';
232
312
  const msgs = state.chat.messages.map((m, i) => {
233
313
  const isAssistant = m.role === 'assistant';
234
314
  const isStreaming = state.chat.busy && i === lastIdx && isAssistant;
235
- 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 });
236
320
  return {
237
321
  key: m.id || String(i),
238
322
  who: isAssistant ? 'them' : 'you',
239
- name: isAssistant ? (state.selectedModel || 'agent') : 'you',
323
+ name: isAssistant ? agentName : 'you',
240
324
  time: m.time || '',
241
325
  typing: isEmptyStreaming,
242
- parts: isEmptyStreaming
243
- ? undefined
244
- : [{ kind: isAssistant ? 'md' : 'text', text: m.content || '' }],
326
+ parts: isEmptyStreaming ? undefined : (parts.length ? parts : [{ kind: 'text', text: '' }]),
245
327
  };
246
328
  });
247
329
 
330
+ const placeholder = !state.selectedAgent
331
+ ? 'choose an agent first'
332
+ : (!agentAvailable(state.selectedAgent) ? agentName + ' is not installed' : 'message…');
248
333
  const composer = ChatComposer({
249
334
  value: state.chat.draft,
250
- disabled: state.chat.busy,
251
- placeholder: state.selectedModel ? 'message…' : 'choose a model first',
335
+ disabled: state.chat.busy || !canSend(),
336
+ placeholder,
252
337
  onInput: (v) => { state.chat.draft = v; render(); },
253
338
  onSend: (v) => { state.chat.draft = v; sendChat(); },
254
339
  });
255
340
 
256
- const resumeBanner = state.chat.resumeSid
257
- ? h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
258
- h('span', { class: 'lede' }, '▶ resuming session ' + state.chat.resumeSid.slice(0, 8) + ' via claude --resume'),
259
- Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; render(); }, children: '× clear' }))
260
- : 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
+ }
261
361
  return [
262
- resumeBanner,
362
+ ...banners,
263
363
  Chat({
264
- title: (state.selectedModel || 'agent') + (state.chat.resumeSid ? ' · resume' : ''),
265
- sub: state.chat.busy ? 'streaming…' : (state.chat.messages.length + ' messages'),
364
+ title: agentName + (state.selectedModel ? ' · ' + state.selectedModel : '') + (state.chat.resumeSid ? ' · resume' : ''),
365
+ sub: state.chat.busy ? 'streaming…' : undefined,
266
366
  messages: msgs,
267
367
  composer,
268
368
  }),
@@ -270,43 +370,69 @@ function chatMain() {
270
370
  }
271
371
 
272
372
  function newChat() {
273
- 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;
274
374
  state.chat.abort?.abort();
275
375
  state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null };
376
+ try { localStorage.removeItem(CHAT_KEY); } catch {}
276
377
  render();
277
378
  }
278
379
 
279
380
  function cancelChat() { state.chat.abort?.abort(); }
280
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
+
281
402
  async function sendChat() {
282
403
  const text = (state.chat.draft || '').trim();
283
- if (!text || !state.selectedModel || state.chat.busy) return;
404
+ if (!text || !canSend()) return;
284
405
  const t = timeNow();
285
406
  const userMsg = { id: 'u' + Date.now(), role: 'user', content: text, time: t };
286
- 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: [] };
287
408
  state.chat.messages = [...state.chat.messages, userMsg, curMsg];
288
409
  state.chat.draft = '';
289
410
  state.chat.busy = true;
290
411
  const ctrl = new AbortController();
291
412
  state.chat.abort = ctrl;
413
+ persistChat();
292
414
  render();
293
415
  scrollChatToBottom();
294
416
  const cur = state.chat.messages[state.chat.messages.length - 1];
295
417
  try {
296
418
  for await (const ev of B.streamChat(state.backend, {
297
- model: state.selectedModel,
419
+ agentId: state.selectedAgent,
420
+ model: state.selectedModel || undefined,
421
+ cwd: state.chatCwd || undefined,
298
422
  messages: state.chat.messages.slice(0, -1).map(m => ({ role: m.role, content: m.content })),
299
423
  signal: ctrl.signal,
300
424
  resumeSid: state.chat.resumeSid || undefined,
301
425
  })) {
302
426
  if (ev.type === 'text') { cur.content += ev.text; render(); scrollChatToBottom(); }
303
- 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(); }
304
429
  }
305
430
  } catch (e) {
306
431
  if (e.name !== 'AbortError') cur.content += '\n[error] ' + e.message;
307
432
  } finally {
308
433
  state.chat.busy = false;
309
434
  state.chat.abort = null;
435
+ persistChat();
310
436
  render();
311
437
  scrollChatToBottom();
312
438
  }
@@ -349,6 +475,7 @@ function historyMain() {
349
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…') })];
350
476
  }
351
477
 
478
+ if (!state.expandedEvents) state.expandedEvents = new Set();
352
479
  return [
353
480
  head,
354
481
  actions,
@@ -356,16 +483,21 @@ function historyMain() {
356
483
  title: state.events.length + ' events',
357
484
  children: EventList({
358
485
  items: state.events.slice(-300).map((e, i) => {
486
+ const idx = e.i ?? i;
359
487
  const role = e.role || '?';
360
488
  const type = e.type || '?';
361
489
  const tool = e.tool ? ' · ⌘ ' + e.tool : '';
362
490
  const errMark = e.isError ? ' · ⚠' : '';
363
- 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;
364
495
  return {
365
- key: 'ev' + (e.i ?? i),
366
- code: String((e.i ?? i) + 1).padStart(4, '0'),
367
- title: text.slice(0, 220) || '(' + type + ')',
368
- 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(); },
369
501
  };
370
502
  }),
371
503
  }),
@@ -379,8 +511,8 @@ function resumeInChat(sess) {
379
511
  state.chat.resumeSid = sess?.sid || state.selectedSid;
380
512
  state.chat.messages = [];
381
513
  state.chat.draft = '';
382
- // Default to claude-code if no model yet (only claude supports --resume by sid here).
383
- 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');
384
516
  render();
385
517
  }
386
518
 
@@ -434,20 +566,22 @@ function historySide() {
434
566
  );
435
567
  const subagentCount = (Array.isArray(state.sessions) ? state.sessions : []).filter(s => s.isSubagent).length;
436
568
  const projects = uniqueProjects();
569
+ const running = Array.isArray(state.active) ? state.active : [];
437
570
 
438
571
  return [
439
- Side({
440
- sections: [
441
- {
442
- group: 'navigate',
443
- items: [
444
- { glyph: '▣', label: 'chat', key: 'chat', onClick: (e) => { e.preventDefault(); navTo('chat'); } },
445
- { glyph: '§', label: 'history', key: 'history', active: true },
446
- { glyph: '', label: 'settings', key: 'settings', onClick: (e) => { e.preventDefault(); navTo('settings'); } },
447
- ],
448
- },
449
- ],
450
- }),
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,
451
585
  Panel({
452
586
  title: searching
453
587
  ? 'matches · ' + (state.searchHits.results?.length || 0)
@@ -460,11 +594,17 @@ function historySide() {
460
594
  value: state.searchQ,
461
595
  onInput: (v) => { state.searchQ = v; debouncedSearch(); },
462
596
  }),
597
+ state.searchBusy
598
+ ? h('div', { key: 'searchbusy', class: 'lede empty-state', role: 'status' }, Spinner({ key: 'ss', size: 'sm' }), 'searching…')
599
+ : null,
463
600
  searching && state.searchHits.error
464
601
  ? Alert({ key: 'searcherr', kind: 'error', title: 'Search failed', children: state.searchHits.error })
465
602
  : null,
466
- state.searchQ && searching
467
- ? 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' })
468
608
  : null,
469
609
  !searching && projects.length > 1
470
610
  ? h('div', { key: 'projfilter', class: 'pill-row', role: 'group', 'aria-label': 'Filter sessions by project' },
@@ -485,7 +625,7 @@ function historySide() {
485
625
  : null,
486
626
  ],
487
627
  }),
488
- ];
628
+ ].filter(Boolean);
489
629
  }
490
630
 
491
631
  // ── settings ───────────────────────────────────────────────────────────────
@@ -495,14 +635,30 @@ function isValidUrl(s) {
495
635
  catch { return false; }
496
636
  }
497
637
 
498
- function saveBackend() {
499
- if (!isValidUrl(state.backendDraft)) return;
500
- 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;
501
640
  B.setBackend(state.backendDraft);
502
641
  state.backend = state.backendDraft;
503
642
  state.health = { status: 'unknown' };
643
+ state.backendStatus = 'connecting';
504
644
  render();
505
- init();
645
+ await init();
646
+ state.backendStatus = state.health.status === 'ok' ? 'ok' : 'failed';
647
+ render();
648
+ }
649
+
650
+ function healthSummary() {
651
+ const hh = state.health || {};
652
+ const ok = hh.status === 'ok';
653
+ const dot = ok ? '●' : (hh.status === 'unknown' ? '◌' : '○');
654
+ const bits = [];
655
+ bits.push(dot + ' ' + (hh.status || 'unknown'));
656
+ if (hh.version) bits.push('v' + hh.version);
657
+ if (typeof hh.agents === 'number') bits.push(hh.agents + ' agents');
658
+ if (typeof hh.activeExecutions === 'number') bits.push(hh.activeExecutions + ' active');
659
+ if (hh.db) bits.push('db ' + (hh.db.ok ? 'ok' : 'down'));
660
+ return h('div', { key: 'hp', class: 'health-summary' + (ok ? ' health-ok' : '') },
661
+ ...bits.map((b, i) => h('span', { key: 'hb' + i, class: 'health-chip' }, b)));
506
662
  }
507
663
 
508
664
  function settingsMain() {
@@ -530,36 +686,57 @@ function settingsMain() {
530
686
  onInput: (v) => { state.backendDraft = v; render(); },
531
687
  }),
532
688
  !isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, '⚠ Invalid URL format') : null,
533
- h('p', { key: 'hp', class: 'lede' }, (ok ? '' : ' ') + JSON.stringify(state.health)),
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,
692
+ healthSummary(),
534
693
  Btn({
535
694
  key: 'savebtn',
536
695
  type: 'submit',
537
696
  primary: true,
538
- disabled: !isValid,
697
+ disabled: !isValid || state.backendDraft === state.backend || state.backendStatus === 'connecting',
539
698
  onClick: (e) => { e.preventDefault(); saveBackend(); },
540
- children: 'save + reconnect',
699
+ children: state.backendStatus === 'connecting' ? 'connecting…' : 'save + reconnect',
541
700
  title: isValid ? 'Save backend URL and reconnect' : 'Fix URL format first',
542
701
  }),
543
702
  ]),
544
703
  }),
545
- Panel({
546
- title: 'models',
547
- children: state.models.length
548
- ? state.models.slice(0, 40).map((m, i) =>
549
- Row({
550
- key: 'm' + i,
551
- rank: String(i + 1).padStart(3, '0'),
552
- title: m.id,
553
- sub: m.name ? (m.name + ' · ' + (m.protocol || 'agent')) : (m.protocol || 'agent'),
554
- rail: m.id === state.selectedModel ? 'green' : 'purple',
555
- onClick: () => { state.selectedModel = m.id; render(); },
556
- })
557
- )
558
- : h('p', { key: 'none', class: 'lede' }, 'no models loaded'),
559
- }),
704
+ agentsPanel(),
560
705
  ];
561
706
  }
562
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
+
563
740
  // ── data ──────────────────────────────────────────────────────────────────
564
741
  async function refreshHistory() {
565
742
  try {
@@ -574,12 +751,17 @@ async function refreshHistory() {
574
751
  }
575
752
 
576
753
  async function runSearch() {
577
- 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();
578
759
  try {
579
- state.searchHits = await B.searchHistory(state.backend, state.searchQ, 50);
580
- render();
760
+ state.searchHits = await B.searchHistory(state.backend, q, 50);
581
761
  } catch (e) {
582
- state.searchHits = { query: state.searchQ, results: [], error: e.message };
762
+ state.searchHits = { query: q, results: [], error: e.message };
763
+ } finally {
764
+ state.searchBusy = false;
583
765
  render();
584
766
  }
585
767
  }
@@ -611,10 +793,13 @@ async function init() {
611
793
  }
612
794
  render();
613
795
  try {
614
- state.models = await B.listModels(state.backend);
615
- 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);
616
801
  render();
617
- } catch (e) { console.warn('models fetch failed:', e.message); }
802
+ } catch (e) { console.warn('agents fetch failed:', e.message); }
618
803
 
619
804
  const initialSid = readHash();
620
805
  if (initialSid) {
@@ -632,6 +817,40 @@ async function init() {
632
817
  });
633
818
  }
634
819
 
820
+ restoreChat();
635
821
  render = mount(document.getElementById('app'), view);
636
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
+
637
856
  init();