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.
- package/AGENTS.md +10 -0
- package/lib/claude-runner-agents.js +25 -0
- package/lib/ws-handlers-util.js +27 -1
- package/package.json +1 -1
- package/site/app/js/app.js +291 -67
- package/site/app/js/backend.js +24 -22
- package/site/app/vendor/anentrypoint-design/247420.css +1 -1
- package/site/app/vendor/anentrypoint-design/247420.js +4 -4
- package/site/app/vendor/cdn/dompurify.js +9 -0
- package/site/app/vendor/cdn/fonts/1291de6d401a.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/1ba89a87e0b8.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/3644d51c507b.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/4b91d2650dc2.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/530d036ba64a.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/570a2bdd8f8b.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/5dd6d880fee9.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/62de9143afe3.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/64884efa2f11.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/68cd7063be2e.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/6c252abcf99b.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/71e69e06516a.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/9ea68c62083f.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/c010f9b7d6b2.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/d69723fc74be.woff2 +0 -0
- package/site/app/vendor/cdn/fonts/fonts.css +459 -0
- package/site/app/vendor/cdn/marked.js +8 -0
- package/site/app/vendor/cdn/prismjs/components/prism-bash.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-clike.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-core.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-css.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-diff.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-go.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-javascript.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-json.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-jsx.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-markdown.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-markup.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-python.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-rust.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-sql.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-toml.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-tsx.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-typescript.min.js +1 -0
- package/site/app/vendor/cdn/prismjs/components/prism-yaml.min.js +1 -0
package/site/app/js/app.js
CHANGED
|
@@ -10,8 +10,11 @@ const state = {
|
|
|
10
10
|
backendDraft: B.getBackend(),
|
|
11
11
|
health: { status: 'unknown' },
|
|
12
12
|
tab: 'chat',
|
|
13
|
-
|
|
14
|
-
|
|
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: '
|
|
178
|
-
value: state.
|
|
179
|
-
placeholder: '—
|
|
180
|
-
title: 'Select
|
|
181
|
-
options:
|
|
182
|
-
onChange: (v) => {
|
|
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: [
|
|
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
|
|
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
|
|
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 ?
|
|
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
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
362
|
+
...banners,
|
|
256
363
|
Chat({
|
|
257
|
-
title: (state.selectedModel
|
|
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 || !
|
|
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
|
-
|
|
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 === '
|
|
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
|
|
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' +
|
|
359
|
-
code: String(
|
|
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
|
-
//
|
|
376
|
-
if (
|
|
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.
|
|
448
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
575
|
-
render();
|
|
760
|
+
state.searchHits = await B.searchHistory(state.backend, q, 50);
|
|
576
761
|
} catch (e) {
|
|
577
|
-
state.searchHits = { query:
|
|
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.
|
|
610
|
-
if
|
|
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('
|
|
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();
|
package/site/app/js/backend.js
CHANGED
|
@@ -203,12 +203,28 @@ function addSessionListener(sessionId, fn) {
|
|
|
203
203
|
|
|
204
204
|
// ---------- Agents / models (WS) ----------
|
|
205
205
|
|
|
206
|
-
export async function
|
|
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
|
|
223
|
-
//
|
|
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
|
-
|
|
230
|
-
|
|
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('
|
|
9
|
+
@import url('../cdn/fonts/fonts.css');
|
|
10
10
|
|
|
11
11
|
.ds-247420 {
|
|
12
12
|
/* Tree view indentation tokens */
|