pi-sdk-web 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +131 -0
- package/dist/server.js +526 -0
- package/dist/session.js +52 -0
- package/dist/ui-context.js +147 -0
- package/dist/verify-sdk.js +112 -0
- package/package.json +47 -0
- package/static/app.js +1624 -0
- package/static/index.html +54 -0
- package/static/style.css +925 -0
- package/static/vendor/marked.min.js +74 -0
package/static/app.js
ADDED
|
@@ -0,0 +1,1624 @@
|
|
|
1
|
+
// pi-web browser client
|
|
2
|
+
// Connects to the WebSocket, renders Pi RPC events into a TUI-like view.
|
|
3
|
+
|
|
4
|
+
const BUILTIN_COMMANDS = [
|
|
5
|
+
{ name: 'model', description: 'Select model (opens selector)', builtin: true, action: 'model' },
|
|
6
|
+
{ name: 'thinking', description: 'Select thinking level', builtin: true, action: 'thinking' },
|
|
7
|
+
{ name: 'scoped-models', description: 'Select models available for cycling', builtin: true, action: 'scoped-models' },
|
|
8
|
+
{ name: 'compact', description: 'Manually compact the session context', builtin: true, action: 'compact' },
|
|
9
|
+
{ name: 'reload', description: 'Reload session resources and extensions', builtin: true, action: 'reload' },
|
|
10
|
+
{ name: 'export', description: 'Export session to HTML (or .jsonl)', builtin: true, action: 'export' },
|
|
11
|
+
{ name: 'name', description: 'Set session display name', builtin: true, action: 'name' },
|
|
12
|
+
{ name: 'login', description: 'Configure provider authentication (not supported in web)', builtin: true, unsupported: true },
|
|
13
|
+
{ name: 'logout', description: 'Remove provider authentication (not supported in web)', builtin: true, unsupported: true },
|
|
14
|
+
{ name: 'settings', description: 'Open settings menu (not supported in web)', builtin: true, unsupported: true },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
class PiWebClient {
|
|
18
|
+
constructor() {
|
|
19
|
+
this.ws = null;
|
|
20
|
+
this.contentEl = document.getElementById('content');
|
|
21
|
+
this.statusEl = document.getElementById('conn-status');
|
|
22
|
+
this.statusAreaEl = document.getElementById('status');
|
|
23
|
+
this.pendingEl = document.getElementById('pending');
|
|
24
|
+
this.versionEl = document.getElementById('version');
|
|
25
|
+
this.loadedResourcesEl = document.getElementById('loaded-resources');
|
|
26
|
+
this.footerEl = document.getElementById('footer-line');
|
|
27
|
+
this.inputEl = document.getElementById('input');
|
|
28
|
+
this.sendBtn = document.getElementById('send-btn');
|
|
29
|
+
this.abortBtn = document.getElementById('abort-btn');
|
|
30
|
+
this.commandMenuEl = document.getElementById('command-menu');
|
|
31
|
+
this.modalOverlay = document.getElementById('modal-overlay');
|
|
32
|
+
this.modalTitle = document.getElementById('modal-title');
|
|
33
|
+
this.modalSearch = document.getElementById('modal-search');
|
|
34
|
+
this.modalList = document.getElementById('modal-list');
|
|
35
|
+
this.modalClose = document.getElementById('modal-close');
|
|
36
|
+
this.modalMode = null; // 'model' | 'thinking'
|
|
37
|
+
this.hasConnectedBefore = false;
|
|
38
|
+
this.commandMenuIndex = -1;
|
|
39
|
+
|
|
40
|
+
this.initThemeSwitch();
|
|
41
|
+
|
|
42
|
+
// Streaming state: current assistant message being built
|
|
43
|
+
this.streaming = {
|
|
44
|
+
active: false,
|
|
45
|
+
el: null,
|
|
46
|
+
role: 'assistant',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Tool call rendering state: map toolCallId -> element
|
|
50
|
+
this.toolEls = new Map();
|
|
51
|
+
// Tool execution timers: map toolCallId -> interval id
|
|
52
|
+
this.toolTimers = new Map();
|
|
53
|
+
|
|
54
|
+
// Last known state (model, autoCompaction, etc.) for stats rendering
|
|
55
|
+
this.lastState = null;
|
|
56
|
+
|
|
57
|
+
// Status indicator state (working/retry/compaction/branch summary)
|
|
58
|
+
this.status = {
|
|
59
|
+
working: 0,
|
|
60
|
+
retry: null,
|
|
61
|
+
compaction: null,
|
|
62
|
+
branch: false,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
this.connect();
|
|
66
|
+
this.bindInput();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
connect() {
|
|
70
|
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
71
|
+
const url = `${proto}://${location.host}/ws`;
|
|
72
|
+
this.setStatus('disconnected');
|
|
73
|
+
|
|
74
|
+
const ws = new WebSocket(url);
|
|
75
|
+
ws.onopen = () => {
|
|
76
|
+
this.setStatus('connected');
|
|
77
|
+
// If this is a reconnect (not the first load), reload the page so the
|
|
78
|
+
// full session history is fetched fresh.
|
|
79
|
+
if (this.hasConnectedBefore) {
|
|
80
|
+
location.reload();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
this.hasConnectedBefore = true;
|
|
84
|
+
};
|
|
85
|
+
ws.onmessage = (ev) => this.handleMessage(ev.data);
|
|
86
|
+
ws.onclose = () => {
|
|
87
|
+
this.ws = null;
|
|
88
|
+
this.setStatus('disconnected');
|
|
89
|
+
// Auto-reconnect after a short delay
|
|
90
|
+
setTimeout(() => this.connect(), 2000);
|
|
91
|
+
};
|
|
92
|
+
ws.onerror = () => {
|
|
93
|
+
ws.close();
|
|
94
|
+
};
|
|
95
|
+
this.ws = ws;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
setStatus(status) {
|
|
99
|
+
this.statusEl.textContent = status === 'connected' ? 'Connected' : 'Disconnected';
|
|
100
|
+
this.statusEl.className = status === 'connected' ? 'connected' : 'disconnected';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ------------------------------------------------------------------
|
|
104
|
+
// Theme switch (Dark | Bright)
|
|
105
|
+
// ------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
initThemeSwitch() {
|
|
108
|
+
const stored = localStorage.getItem('piweb-theme') || 'dark';
|
|
109
|
+
this.applyTheme(stored);
|
|
110
|
+
document.querySelectorAll('.theme-option').forEach((el) => {
|
|
111
|
+
el.addEventListener('click', () => this.applyTheme(el.dataset.theme));
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
applyTheme(theme) {
|
|
116
|
+
const bright = theme === 'bright';
|
|
117
|
+
document.body.classList.toggle('theme-bright', bright);
|
|
118
|
+
localStorage.setItem('piweb-theme', bright ? 'bright' : 'dark');
|
|
119
|
+
document.querySelectorAll('.theme-option').forEach((el) => {
|
|
120
|
+
el.classList.toggle('active', el.dataset.theme === (bright ? 'bright' : 'dark'));
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
send(obj) {
|
|
125
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
126
|
+
this.ws.send(JSON.stringify(obj));
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ------------------------------------------------------------------
|
|
133
|
+
// Message handling
|
|
134
|
+
// ------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
handleMessage(raw) {
|
|
137
|
+
let data;
|
|
138
|
+
try {
|
|
139
|
+
data = JSON.parse(raw);
|
|
140
|
+
} catch {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
switch (data.type) {
|
|
145
|
+
case 'state':
|
|
146
|
+
this.renderState(data.data);
|
|
147
|
+
break;
|
|
148
|
+
case 'history':
|
|
149
|
+
this.renderHistory(data.data);
|
|
150
|
+
break;
|
|
151
|
+
case 'stats':
|
|
152
|
+
this.renderStats(data.data, this.lastState);
|
|
153
|
+
break;
|
|
154
|
+
case 'bash_result':
|
|
155
|
+
this.renderBashResult(data);
|
|
156
|
+
break;
|
|
157
|
+
case 'models':
|
|
158
|
+
this.handleModels(data.data);
|
|
159
|
+
break;
|
|
160
|
+
case 'thinking_levels':
|
|
161
|
+
this.handleThinkingLevels(data.data);
|
|
162
|
+
break;
|
|
163
|
+
case 'scoped_models':
|
|
164
|
+
this.handleScopedModels(data.data);
|
|
165
|
+
break;
|
|
166
|
+
case 'error':
|
|
167
|
+
this.appendError(data.error);
|
|
168
|
+
break;
|
|
169
|
+
case 'pi_error':
|
|
170
|
+
this.appendError(data.error);
|
|
171
|
+
break;
|
|
172
|
+
case 'extension_ui_request':
|
|
173
|
+
this.handleExtensionUIRequest(data);
|
|
174
|
+
break;
|
|
175
|
+
default:
|
|
176
|
+
// Agent session event -> render
|
|
177
|
+
this.renderEvent(data);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ------------------------------------------------------------------
|
|
182
|
+
// Rendering: state & history
|
|
183
|
+
// ------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
renderState(state) {
|
|
186
|
+
if (!state) return;
|
|
187
|
+
this.lastState = state;
|
|
188
|
+
|
|
189
|
+
// Page title: pi-web - <session name>
|
|
190
|
+
if (state.sessionName || state.sessionId) {
|
|
191
|
+
document.title = `pi-web - ${state.sessionName || state.sessionId}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Header version
|
|
195
|
+
if (this.versionEl && state.version) {
|
|
196
|
+
this.versionEl.textContent = `v${state.version}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Loaded resources
|
|
200
|
+
this.renderLoadedResources(state.commands);
|
|
201
|
+
|
|
202
|
+
// First footer line: pwd (branch) • session name
|
|
203
|
+
const pwd = state.cwd || '';
|
|
204
|
+
const branch = state.gitBranch ? ` (${state.gitBranch})` : '';
|
|
205
|
+
const name = state.sessionName ? ` • ${state.sessionName}` : '';
|
|
206
|
+
this.footerEl.textContent = `${pwd}${branch}${name}`;
|
|
207
|
+
|
|
208
|
+
// Second footer line: TUI-like stats + model info
|
|
209
|
+
this.renderStats(state.sessionStats, state);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
renderLoadedResources(commands) {
|
|
213
|
+
if (!this.loadedResourcesEl) return;
|
|
214
|
+
if (!commands || commands.length === 0) {
|
|
215
|
+
this.loadedResourcesEl.innerHTML = '';
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const groups = { skill: [], prompt: [], extension: [] };
|
|
220
|
+
for (const cmd of commands) {
|
|
221
|
+
const source = cmd.source;
|
|
222
|
+
if (source === 'skill') groups.skill.push(cmd);
|
|
223
|
+
else if (source === 'prompt') groups.prompt.push(cmd);
|
|
224
|
+
else groups.extension.push(cmd);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const div = document.createElement('div');
|
|
228
|
+
div.className = 'resources-block';
|
|
229
|
+
div.dataset.expanded = 'false';
|
|
230
|
+
|
|
231
|
+
const header = document.createElement('div');
|
|
232
|
+
header.className = 'resources-header';
|
|
233
|
+
header.textContent = 'Loaded Resources (click to expand)';
|
|
234
|
+
|
|
235
|
+
const body = document.createElement('div');
|
|
236
|
+
body.className = 'resources-body';
|
|
237
|
+
body.style.display = 'none';
|
|
238
|
+
|
|
239
|
+
const sections = [];
|
|
240
|
+
if (groups.skill.length > 0) {
|
|
241
|
+
sections.push(this.resourceSection('Skills', groups.skill.map((c) => c.name)));
|
|
242
|
+
}
|
|
243
|
+
if (groups.prompt.length > 0) {
|
|
244
|
+
sections.push(this.resourceSection('Prompts', groups.prompt.map((c) => c.name)));
|
|
245
|
+
}
|
|
246
|
+
if (groups.extension.length > 0) {
|
|
247
|
+
sections.push(this.resourceSection('Extensions', groups.extension.map((c) => c.name)));
|
|
248
|
+
}
|
|
249
|
+
body.innerHTML = sections.join('');
|
|
250
|
+
|
|
251
|
+
div.appendChild(header);
|
|
252
|
+
div.appendChild(body);
|
|
253
|
+
div.addEventListener('click', () => {
|
|
254
|
+
const expanded = div.dataset.expanded === 'true';
|
|
255
|
+
div.dataset.expanded = expanded ? 'false' : 'true';
|
|
256
|
+
body.style.display = expanded ? 'none' : 'block';
|
|
257
|
+
header.textContent = expanded ? 'Loaded Resources (click to expand)' : 'Loaded Resources (click to collapse)';
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
this.loadedResourcesEl.innerHTML = '';
|
|
261
|
+
this.loadedResourcesEl.appendChild(div);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
resourceSection(name, items) {
|
|
265
|
+
const listItems = items
|
|
266
|
+
.map((item) => `<li class="resources-item" title="${this.escapeHtml(item)}">${this.escapeHtml(item)}</li>`)
|
|
267
|
+
.join('');
|
|
268
|
+
return `<div class="resources-section"><div class="resources-section-title">${this.escapeHtml(name)}</div><ul class="resources-list">${listItems}</ul></div>`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
renderStats(stats, state) {
|
|
272
|
+
const statsEl = document.getElementById('footer-stats');
|
|
273
|
+
if (!statsEl) return;
|
|
274
|
+
if (!state) state = this.lastState;
|
|
275
|
+
if (!state) return;
|
|
276
|
+
|
|
277
|
+
const leftParts = [];
|
|
278
|
+
|
|
279
|
+
// Token/cost stats from sessionStats (if available)
|
|
280
|
+
if (stats && stats.tokens) {
|
|
281
|
+
const t = stats.tokens;
|
|
282
|
+
if (t.input) leftParts.push(`↑${this.formatTokens(t.input)}`);
|
|
283
|
+
if (t.output) leftParts.push(`↓${this.formatTokens(t.output)}`);
|
|
284
|
+
if (t.cacheRead) leftParts.push(`R${this.formatTokens(t.cacheRead)}`);
|
|
285
|
+
if (t.cacheWrite) leftParts.push(`W${this.formatTokens(t.cacheWrite)}`);
|
|
286
|
+
if ((t.cacheRead > 0 || t.cacheWrite > 0)) {
|
|
287
|
+
const promptTokens = t.input + t.cacheRead + t.cacheWrite;
|
|
288
|
+
if (promptTokens > 0) {
|
|
289
|
+
const hitRate = (t.cacheRead / promptTokens) * 100;
|
|
290
|
+
leftParts.push(`CH${hitRate.toFixed(1)}%`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (stats.cost) leftParts.push(`$${stats.cost.toFixed(3)}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Context usage: percent/contextWindow (auto)
|
|
297
|
+
if (stats && stats.contextUsage && stats.contextUsage.contextWindow) {
|
|
298
|
+
const cu = stats.contextUsage;
|
|
299
|
+
const ctxWindow = this.formatTokens(cu.contextWindow);
|
|
300
|
+
const auto = state.autoCompactionEnabled ? ' (auto)' : '';
|
|
301
|
+
if (cu.percent !== null && cu.percent !== undefined) {
|
|
302
|
+
leftParts.push(`${cu.percent.toFixed(1)}%/${ctxWindow}${auto}`);
|
|
303
|
+
} else {
|
|
304
|
+
leftParts.push(`?/${ctxWindow}${auto}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Model + thinking on the right (clickable, right-aligned)
|
|
309
|
+
let rightHtml = '';
|
|
310
|
+
if (state.model) {
|
|
311
|
+
const provider = state.model.provider || '';
|
|
312
|
+
const model = state.model.id || state.model.model || '';
|
|
313
|
+
const modelLabel = provider ? `${provider}/${model}` : model;
|
|
314
|
+
const thinking = state.thinkingLevel || 'off';
|
|
315
|
+
const modelHtml = `<span class="clickable model-label" title="Click to change model">${this.escapeHtml(modelLabel)}</span>`;
|
|
316
|
+
const cycleHtml = `<span class="clickable cycle-model-label" title="Cycle to next model (TUI Ctrl+P)">>></span>`;
|
|
317
|
+
const thinkingHtml = `<span class="clickable thinking-label" title="Click to change thinking">${this.escapeHtml(thinking)}</span>`;
|
|
318
|
+
rightHtml = `${modelHtml} ${cycleHtml} · ${thinkingHtml}`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
statsEl.innerHTML = `<span class="stats-left">${leftParts.join(' ')}</span><span class="stats-right">${rightHtml}</span>`;
|
|
322
|
+
|
|
323
|
+
const modelEl = statsEl.querySelector('.model-label');
|
|
324
|
+
if (modelEl) {
|
|
325
|
+
modelEl.addEventListener('click', (e) => {
|
|
326
|
+
e.stopPropagation();
|
|
327
|
+
this.openModelPicker();
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
const cycleEl = statsEl.querySelector('.cycle-model-label');
|
|
331
|
+
if (cycleEl) {
|
|
332
|
+
cycleEl.addEventListener('click', (e) => {
|
|
333
|
+
e.stopPropagation();
|
|
334
|
+
this.send({ type: 'cycle_model' });
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
const thinkingEl = statsEl.querySelector('.thinking-label');
|
|
338
|
+
if (thinkingEl) {
|
|
339
|
+
thinkingEl.addEventListener('click', (e) => {
|
|
340
|
+
e.stopPropagation();
|
|
341
|
+
this.openThinkingPicker();
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
getStats() {
|
|
347
|
+
this.send({ type: 'get_stats' });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ------------------------------------------------------------------
|
|
351
|
+
// Markdown rendering
|
|
352
|
+
// ------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
renderMarkdown(text) {
|
|
355
|
+
if (!text) return '';
|
|
356
|
+
try {
|
|
357
|
+
const raw = marked.parse(text, { breaks: true, gfm: true });
|
|
358
|
+
return this.sanitizeHtml(raw);
|
|
359
|
+
} catch {
|
|
360
|
+
return this.escapeHtml(text);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
escapeHtml(text) {
|
|
365
|
+
const div = document.createElement('div');
|
|
366
|
+
div.textContent = text;
|
|
367
|
+
return div.innerHTML;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
sanitizeHtml(html) {
|
|
371
|
+
const template = document.createElement('template');
|
|
372
|
+
template.innerHTML = html;
|
|
373
|
+
|
|
374
|
+
const allowed = new Set([
|
|
375
|
+
'P', 'BR', 'STRONG', 'EM', 'CODE', 'PRE', 'BLOCKQUOTE',
|
|
376
|
+
'UL', 'OL', 'LI', 'A', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
|
|
377
|
+
'TABLE', 'THEAD', 'TBODY', 'TR', 'TH', 'TD', 'HR', 'SPAN', 'DEL', 'DIV',
|
|
378
|
+
]);
|
|
379
|
+
const badTags = ['SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'LINK', 'META', 'FORM', 'INPUT', 'BUTTON', 'TEXTAREA', 'SELECT', 'OPTION'];
|
|
380
|
+
|
|
381
|
+
for (const el of [...template.content.querySelectorAll('*')]) {
|
|
382
|
+
if (badTags.includes(el.tagName)) {
|
|
383
|
+
el.remove();
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (!allowed.has(el.tagName)) {
|
|
387
|
+
el.replaceWith(...el.childNodes);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
// Clean attributes: only keep safe href/src, remove event handlers
|
|
391
|
+
for (const attr of [...el.attributes]) {
|
|
392
|
+
const name = attr.name.toLowerCase();
|
|
393
|
+
if (name.startsWith('on')) {
|
|
394
|
+
el.removeAttribute(attr.name);
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (name === 'href' || name === 'src') {
|
|
398
|
+
const val = attr.value.trim().toLowerCase();
|
|
399
|
+
const ok = val.startsWith('http://') || val.startsWith('https://') ||
|
|
400
|
+
val.startsWith('mailto:') || val.startsWith('#') || val.startsWith('/') ||
|
|
401
|
+
val.startsWith('./') || val.startsWith('../');
|
|
402
|
+
if (!ok) {
|
|
403
|
+
el.removeAttribute(attr.name);
|
|
404
|
+
}
|
|
405
|
+
} else if (name !== 'class' && name !== 'id' && name !== 'colspan' && name !== 'rowspan' && name !== 'align') {
|
|
406
|
+
el.removeAttribute(attr.name);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return template.innerHTML;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
formatTokens(count) {
|
|
415
|
+
if (count < 1000) return count.toString();
|
|
416
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
417
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
418
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
419
|
+
return `${Math.round(count / 1000000)}M`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
renderHistory(entries) {
|
|
423
|
+
this.clearContent();
|
|
424
|
+
if (!entries || !entries.entries) return;
|
|
425
|
+
for (const entry of entries.entries) {
|
|
426
|
+
this.renderEntry(entry);
|
|
427
|
+
}
|
|
428
|
+
this.scrollToBottom();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
renderEntry(entry) {
|
|
432
|
+
if (!entry) return;
|
|
433
|
+
if (entry.type === 'message') {
|
|
434
|
+
this.renderMessage(entry.message);
|
|
435
|
+
} else if (entry.type === 'branch_summary') {
|
|
436
|
+
this.renderBranchSummary(entry);
|
|
437
|
+
} else if (entry.type === 'compaction') {
|
|
438
|
+
this.renderCompaction(entry);
|
|
439
|
+
} else if (entry.type === 'custom' || entry.type === 'custom_message') {
|
|
440
|
+
this.renderCustom(entry);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
renderMessage(message) {
|
|
445
|
+
if (!message) return;
|
|
446
|
+
if (message.role === 'user') {
|
|
447
|
+
this.appendUserMessage(message);
|
|
448
|
+
} else if (message.role === 'assistant') {
|
|
449
|
+
this.renderHistoryAssistantMessage(message);
|
|
450
|
+
} else if (message.role === 'toolResult') {
|
|
451
|
+
this.renderHistoryToolResult(message);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
renderHistoryAssistantMessage(message) {
|
|
456
|
+
const div = document.createElement('div');
|
|
457
|
+
div.className = 'message assistant';
|
|
458
|
+
const role = document.createElement('div');
|
|
459
|
+
role.className = 'role';
|
|
460
|
+
role.textContent = 'Assistant';
|
|
461
|
+
const body = document.createElement('div');
|
|
462
|
+
body.className = 'body';
|
|
463
|
+
div.appendChild(role);
|
|
464
|
+
div.appendChild(body);
|
|
465
|
+
this.contentEl.appendChild(div);
|
|
466
|
+
this.renderAssistantContent(message, body);
|
|
467
|
+
|
|
468
|
+
// Create tool blocks for tool calls
|
|
469
|
+
for (const block of message.content || []) {
|
|
470
|
+
if (block.type === 'toolCall') {
|
|
471
|
+
const toolDiv = this.createToolBlock(block.name, block.arguments, block.id);
|
|
472
|
+
this.toolEls.set(block.id, toolDiv);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
renderHistoryToolResult(message) {
|
|
478
|
+
const div = this.toolEls.get(message.toolCallId);
|
|
479
|
+
if (div) {
|
|
480
|
+
div.className = message.isError ? 'tool-block error' : 'tool-block success';
|
|
481
|
+
this.setToolOutput(div, this.resultText(message));
|
|
482
|
+
this.toolEls.delete(message.toolCallId);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ------------------------------------------------------------------
|
|
487
|
+
// Special message blocks (branch/compaction/custom)
|
|
488
|
+
// ------------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
renderBranchSummary(entry) {
|
|
491
|
+
const label = '[branch]';
|
|
492
|
+
const summary = entry.summary || '';
|
|
493
|
+
const div = this.createSpecialBlock(label);
|
|
494
|
+
this.updateSpecialBlock(div, {
|
|
495
|
+
collapsedText: 'Branch summary (click to expand)',
|
|
496
|
+
expandedHtml: this.renderMarkdown(`**Branch Summary**\n\n${summary}`),
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
renderCompaction(entry) {
|
|
501
|
+
const label = '[compaction]';
|
|
502
|
+
const summary = entry.summary || '';
|
|
503
|
+
const tokens = entry.tokensBefore ? entry.tokensBefore.toLocaleString() : '?';
|
|
504
|
+
const div = this.createSpecialBlock(label);
|
|
505
|
+
this.updateSpecialBlock(div, {
|
|
506
|
+
collapsedText: `Compacted from ${tokens} tokens (click to expand)`,
|
|
507
|
+
expandedHtml: this.renderMarkdown(`**Compacted from ${tokens} tokens**\n\n${summary}`),
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
renderCustom(entry) {
|
|
512
|
+
const customType = entry.customType || 'custom';
|
|
513
|
+
const label = `[${customType}]`;
|
|
514
|
+
const content = this.customEntryText(entry);
|
|
515
|
+
const div = this.createSpecialBlock(label);
|
|
516
|
+
this.updateSpecialBlock(div, {
|
|
517
|
+
collapsedText: `${customType} (click to expand)`,
|
|
518
|
+
expandedHtml: this.renderMarkdown(content),
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
customEntryText(entry) {
|
|
523
|
+
if (typeof entry.content === 'string') return entry.content;
|
|
524
|
+
if (Array.isArray(entry.content)) {
|
|
525
|
+
return entry.content
|
|
526
|
+
.filter((c) => c.type === 'text')
|
|
527
|
+
.map((c) => c.text)
|
|
528
|
+
.join('\n');
|
|
529
|
+
}
|
|
530
|
+
if (entry.data !== undefined) {
|
|
531
|
+
// magic-context style: {title, text, ...} - prefer the text field
|
|
532
|
+
if (typeof entry.data.text === 'string') return entry.data.text;
|
|
533
|
+
return JSON.stringify(entry.data, null, 2);
|
|
534
|
+
}
|
|
535
|
+
return '';
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
createSpecialBlock(label) {
|
|
539
|
+
const div = document.createElement('div');
|
|
540
|
+
div.className = 'special-block';
|
|
541
|
+
div.dataset.expanded = 'false';
|
|
542
|
+
const labelEl = document.createElement('div');
|
|
543
|
+
labelEl.className = 'special-label';
|
|
544
|
+
labelEl.textContent = label;
|
|
545
|
+
const body = document.createElement('div');
|
|
546
|
+
body.className = 'special-body';
|
|
547
|
+
div.appendChild(labelEl);
|
|
548
|
+
div.appendChild(body);
|
|
549
|
+
div.addEventListener('click', () => this.toggleSpecial(div));
|
|
550
|
+
this.contentEl.appendChild(div);
|
|
551
|
+
return div;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
updateSpecialBlock(div, { collapsedText, expandedHtml }) {
|
|
555
|
+
div.dataset.collapsedText = collapsedText;
|
|
556
|
+
div.dataset.expandedHtml = expandedHtml;
|
|
557
|
+
this.applySpecialBlock(div);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
applySpecialBlock(div) {
|
|
561
|
+
const body = div.querySelector('.special-body');
|
|
562
|
+
if (!body) return;
|
|
563
|
+
const expanded = div.dataset.expanded === 'true';
|
|
564
|
+
if (expanded) {
|
|
565
|
+
body.innerHTML = div.dataset.expandedHtml || '';
|
|
566
|
+
} else {
|
|
567
|
+
body.textContent = div.dataset.collapsedText || '';
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
toggleSpecial(div) {
|
|
572
|
+
const expanded = div.dataset.expanded === 'true';
|
|
573
|
+
div.dataset.expanded = expanded ? 'false' : 'true';
|
|
574
|
+
div.classList.toggle('expanded', !expanded);
|
|
575
|
+
this.applySpecialBlock(div);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
clearContent() {
|
|
579
|
+
this.contentEl.innerHTML = '';
|
|
580
|
+
for (const timer of this.toolTimers.values()) {
|
|
581
|
+
clearInterval(timer);
|
|
582
|
+
}
|
|
583
|
+
this.toolTimers.clear();
|
|
584
|
+
this.toolEls.clear();
|
|
585
|
+
this.streaming = { active: false, el: null, role: 'assistant' };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ------------------------------------------------------------------
|
|
589
|
+
// Rendering: session events
|
|
590
|
+
// ------------------------------------------------------------------
|
|
591
|
+
|
|
592
|
+
renderEvent(ev) {
|
|
593
|
+
switch (ev.type) {
|
|
594
|
+
case 'agent_start':
|
|
595
|
+
this.status.working++;
|
|
596
|
+
this.updateStatusDisplay();
|
|
597
|
+
break;
|
|
598
|
+
case 'message_start':
|
|
599
|
+
this.onMessageStart(ev.message);
|
|
600
|
+
break;
|
|
601
|
+
case 'message_update':
|
|
602
|
+
this.onMessageUpdate(ev);
|
|
603
|
+
break;
|
|
604
|
+
case 'message_end':
|
|
605
|
+
this.onMessageEnd(ev.message);
|
|
606
|
+
break;
|
|
607
|
+
case 'tool_execution_start':
|
|
608
|
+
this.onToolStart(ev);
|
|
609
|
+
break;
|
|
610
|
+
case 'tool_execution_update':
|
|
611
|
+
this.onToolUpdate(ev);
|
|
612
|
+
break;
|
|
613
|
+
case 'tool_execution_end':
|
|
614
|
+
this.onToolEnd(ev);
|
|
615
|
+
break;
|
|
616
|
+
case 'agent_settled':
|
|
617
|
+
this.resetStreaming();
|
|
618
|
+
if (this.status.working > 0) this.status.working--;
|
|
619
|
+
this.updateStatusDisplay();
|
|
620
|
+
break;
|
|
621
|
+
case 'turn_start':
|
|
622
|
+
break;
|
|
623
|
+
case 'turn_end':
|
|
624
|
+
break;
|
|
625
|
+
case 'agent_end':
|
|
626
|
+
this.resetStreaming();
|
|
627
|
+
if (this.status.working > 0) this.status.working--;
|
|
628
|
+
this.updateStatusDisplay();
|
|
629
|
+
break;
|
|
630
|
+
case 'auto_retry_start':
|
|
631
|
+
this.status.retry = {
|
|
632
|
+
attempt: ev.attempt,
|
|
633
|
+
maxAttempts: ev.maxAttempts,
|
|
634
|
+
delayMs: ev.delayMs,
|
|
635
|
+
};
|
|
636
|
+
this.updateStatusDisplay();
|
|
637
|
+
break;
|
|
638
|
+
case 'auto_retry_end':
|
|
639
|
+
this.status.retry = null;
|
|
640
|
+
this.updateStatusDisplay();
|
|
641
|
+
break;
|
|
642
|
+
case 'compaction_start':
|
|
643
|
+
this.status.compaction = ev.reason || 'manual';
|
|
644
|
+
this.updateStatusDisplay();
|
|
645
|
+
break;
|
|
646
|
+
case 'compaction_end':
|
|
647
|
+
this.status.compaction = null;
|
|
648
|
+
this.updateStatusDisplay();
|
|
649
|
+
break;
|
|
650
|
+
case 'summarization_retry_attempt_start':
|
|
651
|
+
if (ev.source === 'branchSummary') {
|
|
652
|
+
this.status.branch = true;
|
|
653
|
+
this.updateStatusDisplay();
|
|
654
|
+
}
|
|
655
|
+
break;
|
|
656
|
+
case 'summarization_retry_finished':
|
|
657
|
+
this.status.branch = false;
|
|
658
|
+
this.updateStatusDisplay();
|
|
659
|
+
break;
|
|
660
|
+
case 'queue_update':
|
|
661
|
+
this.updatePendingMessages(ev);
|
|
662
|
+
break;
|
|
663
|
+
case 'entry_appended':
|
|
664
|
+
// Extension custom entries (e.g. magic-context /ctx-status) arrive live
|
|
665
|
+
this.renderEntry(ev.entry);
|
|
666
|
+
break;
|
|
667
|
+
default:
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
this.scrollToBottom();
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
onMessageStart(message) {
|
|
674
|
+
if (!message) return;
|
|
675
|
+
if (message.role === 'user') {
|
|
676
|
+
this.appendUserMessage(message);
|
|
677
|
+
} else if (message.role === 'assistant') {
|
|
678
|
+
this.startAssistantMessage(message);
|
|
679
|
+
}
|
|
680
|
+
// toolResult handled via tool_execution events
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
onMessageUpdate(ev) {
|
|
684
|
+
const msg = ev.message;
|
|
685
|
+
if (!msg) return;
|
|
686
|
+
// Find the delta event for partial text/thinking updates
|
|
687
|
+
const aev = ev.assistantMessageEvent;
|
|
688
|
+
if (!aev) return;
|
|
689
|
+
if (!this.streaming.active || !this.streaming.el) {
|
|
690
|
+
this.startAssistantMessage(msg);
|
|
691
|
+
}
|
|
692
|
+
if (aev.type === 'text_delta') {
|
|
693
|
+
// Update the assistant text content
|
|
694
|
+
this.updateAssistantText(msg);
|
|
695
|
+
} else if (aev.type === 'thinking_delta') {
|
|
696
|
+
this.updateAssistantThinking(msg);
|
|
697
|
+
} else if (aev.type === 'toolcall_delta') {
|
|
698
|
+
// Tool call streaming - could update later
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
onMessageEnd(message) {
|
|
703
|
+
if (message && message.role === 'assistant') {
|
|
704
|
+
this.finalizeAssistantMessage(message);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
resetStreaming() {
|
|
709
|
+
this.streaming = { active: false, el: null, role: 'assistant' };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ------------------------------------------------------------------
|
|
713
|
+
// Status indicator
|
|
714
|
+
// ------------------------------------------------------------------
|
|
715
|
+
|
|
716
|
+
updateStatusDisplay() {
|
|
717
|
+
if (!this.statusAreaEl) return;
|
|
718
|
+
|
|
719
|
+
let kind = null;
|
|
720
|
+
let text = '';
|
|
721
|
+
|
|
722
|
+
if (this.status.compaction) {
|
|
723
|
+
kind = 'compaction';
|
|
724
|
+
text = this.status.compaction === 'manual' ? 'Compacting context...' : 'Auto-compacting...';
|
|
725
|
+
} else if (this.status.retry) {
|
|
726
|
+
kind = 'retry';
|
|
727
|
+
const secs = Math.ceil((this.status.retry.delayMs || 0) / 1000);
|
|
728
|
+
text = `Retrying (${this.status.retry.attempt}/${this.status.retry.maxAttempts}) in ${secs}s... (to cancel)`;
|
|
729
|
+
} else if (this.status.branch) {
|
|
730
|
+
kind = 'branch';
|
|
731
|
+
text = 'Summarizing branch... (to cancel)';
|
|
732
|
+
} else if (this.status.working > 0) {
|
|
733
|
+
kind = 'working';
|
|
734
|
+
text = 'Working...';
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (!kind) {
|
|
738
|
+
this.statusAreaEl.innerHTML = '';
|
|
739
|
+
this.statusAreaEl.style.display = 'none';
|
|
740
|
+
if (this.abortBtn) this.abortBtn.style.display = 'none';
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
this.statusAreaEl.style.display = 'block';
|
|
745
|
+
this.statusAreaEl.innerHTML =
|
|
746
|
+
`<div class="status-indicator ${kind}"><span class="spinner"></span><span class="status-text"></span></div>`;
|
|
747
|
+
this.statusAreaEl.querySelector('.status-text').textContent = text;
|
|
748
|
+
|
|
749
|
+
// Show Abort button while any operation is running
|
|
750
|
+
if (this.abortBtn) this.abortBtn.style.display = 'inline-block';
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
updatePendingMessages(ev) {
|
|
754
|
+
if (!this.pendingEl) return;
|
|
755
|
+
const steering = ev.steering || [];
|
|
756
|
+
const followUp = ev.followUp || [];
|
|
757
|
+
|
|
758
|
+
if (steering.length === 0 && followUp.length === 0) {
|
|
759
|
+
this.pendingEl.innerHTML = '';
|
|
760
|
+
this.pendingEl.style.display = 'none';
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
this.pendingEl.style.display = 'block';
|
|
765
|
+
const lines = [];
|
|
766
|
+
for (const msg of steering) {
|
|
767
|
+
lines.push(`<div class="pending-line steering">Steering: ${this.escapeHtml(msg)}</div>`);
|
|
768
|
+
}
|
|
769
|
+
for (const msg of followUp) {
|
|
770
|
+
lines.push(`<div class="pending-line follow-up">Follow-up: ${this.escapeHtml(msg)}</div>`);
|
|
771
|
+
}
|
|
772
|
+
this.pendingEl.innerHTML = lines.join('');
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ------------------------------------------------------------------
|
|
776
|
+
// Persistent widget panel (extension setWidget, e.g. magic-context todos)
|
|
777
|
+
// ------------------------------------------------------------------
|
|
778
|
+
|
|
779
|
+
renderWidget(req) {
|
|
780
|
+
const widgetsEl = document.getElementById('widgets');
|
|
781
|
+
if (!widgetsEl) return;
|
|
782
|
+
const key = req.widgetKey || 'widget';
|
|
783
|
+
|
|
784
|
+
if (req.widgetLines === undefined || req.widgetLines === null) {
|
|
785
|
+
// Clear this widget
|
|
786
|
+
const el = widgetsEl.querySelector(`[data-widget-key="${CSS.escape(key)}"]`);
|
|
787
|
+
if (el) el.remove();
|
|
788
|
+
if (widgetsEl.children.length === 0) widgetsEl.style.display = 'none';
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
let el = widgetsEl.querySelector(`[data-widget-key="${CSS.escape(key)}"]`);
|
|
793
|
+
if (!el) {
|
|
794
|
+
el = document.createElement('div');
|
|
795
|
+
el.className = 'widget-block';
|
|
796
|
+
el.dataset.widgetKey = key;
|
|
797
|
+
const title = document.createElement('div');
|
|
798
|
+
title.className = 'widget-title';
|
|
799
|
+
const body = document.createElement('div');
|
|
800
|
+
body.className = 'widget-body';
|
|
801
|
+
el.appendChild(title);
|
|
802
|
+
el.appendChild(body);
|
|
803
|
+
widgetsEl.appendChild(el);
|
|
804
|
+
}
|
|
805
|
+
el.querySelector('.widget-title').textContent = key;
|
|
806
|
+
el.querySelector('.widget-body').textContent = (req.widgetLines || []).join('\n');
|
|
807
|
+
widgetsEl.style.display = 'block';
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// ------------------------------------------------------------------
|
|
811
|
+
// Rendering helpers
|
|
812
|
+
// ------------------------------------------------------------------
|
|
813
|
+
|
|
814
|
+
appendUserMessage(message) {
|
|
815
|
+
const text = this.messageText(message);
|
|
816
|
+
const div = document.createElement('div');
|
|
817
|
+
div.className = 'message user';
|
|
818
|
+
const role = document.createElement('div');
|
|
819
|
+
role.className = 'role';
|
|
820
|
+
role.textContent = 'You';
|
|
821
|
+
const body = document.createElement('div');
|
|
822
|
+
body.className = 'body';
|
|
823
|
+
body.innerHTML = this.renderMarkdown(text);
|
|
824
|
+
div.appendChild(role);
|
|
825
|
+
div.appendChild(body);
|
|
826
|
+
this.contentEl.appendChild(div);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
startAssistantMessage(message) {
|
|
830
|
+
const div = document.createElement('div');
|
|
831
|
+
div.className = 'message assistant';
|
|
832
|
+
const role = document.createElement('div');
|
|
833
|
+
role.className = 'role';
|
|
834
|
+
role.textContent = 'Assistant';
|
|
835
|
+
const body = document.createElement('div');
|
|
836
|
+
body.className = 'body';
|
|
837
|
+
div.appendChild(role);
|
|
838
|
+
div.appendChild(body);
|
|
839
|
+
this.contentEl.appendChild(div);
|
|
840
|
+
|
|
841
|
+
this.streaming = { active: true, el: div, body: body, role: 'assistant' };
|
|
842
|
+
this.renderAssistantContent(message, body);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
updateAssistantText(message) {
|
|
846
|
+
if (!this.streaming.el) return;
|
|
847
|
+
const body = this.streaming.body;
|
|
848
|
+
if (body) {
|
|
849
|
+
this.renderAssistantContent(message, body);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
updateAssistantThinking(message) {
|
|
854
|
+
if (!this.streaming.el) return;
|
|
855
|
+
const body = this.streaming.body;
|
|
856
|
+
if (body) {
|
|
857
|
+
this.renderAssistantContent(message, body);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
renderAssistantContent(message, body) {
|
|
862
|
+
body.innerHTML = '';
|
|
863
|
+
for (const block of message.content || []) {
|
|
864
|
+
if (block.type === 'thinking') {
|
|
865
|
+
const p = document.createElement('div');
|
|
866
|
+
p.className = 'thinking';
|
|
867
|
+
p.innerHTML = this.renderMarkdown(block.thinking);
|
|
868
|
+
body.appendChild(p);
|
|
869
|
+
} else if (block.type === 'text') {
|
|
870
|
+
const p = document.createElement('div');
|
|
871
|
+
p.className = 'body-text';
|
|
872
|
+
p.innerHTML = this.renderMarkdown(block.text);
|
|
873
|
+
body.appendChild(p);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
finalizeAssistantMessage(message) {
|
|
879
|
+
if (!this.streaming.el) return;
|
|
880
|
+
const body = this.streaming.body;
|
|
881
|
+
if (body) {
|
|
882
|
+
this.renderAssistantContent(message, body);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
createToolBlock(toolName, args, toolCallId) {
|
|
887
|
+
const div = document.createElement('div');
|
|
888
|
+
div.className = 'tool-block pending';
|
|
889
|
+
div.dataset.toolCallId = toolCallId || '';
|
|
890
|
+
div.dataset.toolName = toolName || '';
|
|
891
|
+
div.dataset.expanded = 'false';
|
|
892
|
+
|
|
893
|
+
const isBash = toolName === 'bash';
|
|
894
|
+
|
|
895
|
+
const top = document.createElement('div');
|
|
896
|
+
top.className = 'tool-border';
|
|
897
|
+
const content = document.createElement('div');
|
|
898
|
+
content.className = 'tool-content';
|
|
899
|
+
const bottom = document.createElement('div');
|
|
900
|
+
bottom.className = 'tool-border';
|
|
901
|
+
|
|
902
|
+
// Title
|
|
903
|
+
const title = document.createElement('div');
|
|
904
|
+
title.className = 'tool-title';
|
|
905
|
+
if (isBash) {
|
|
906
|
+
const cmd = (args && args.command) || '';
|
|
907
|
+
title.textContent = `$ ${cmd}`;
|
|
908
|
+
} else {
|
|
909
|
+
title.textContent = toolName || 'tool';
|
|
910
|
+
}
|
|
911
|
+
content.appendChild(title);
|
|
912
|
+
|
|
913
|
+
// Args (non-bash)
|
|
914
|
+
if (!isBash) {
|
|
915
|
+
const argsEl = document.createElement('div');
|
|
916
|
+
argsEl.className = 'tool-args';
|
|
917
|
+
argsEl.textContent = JSON.stringify(args || {}, null, 2);
|
|
918
|
+
content.appendChild(argsEl);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// Output
|
|
922
|
+
const output = document.createElement('div');
|
|
923
|
+
output.className = 'tool-output';
|
|
924
|
+
content.appendChild(output);
|
|
925
|
+
|
|
926
|
+
// Meta (duration / truncation)
|
|
927
|
+
const meta = document.createElement('div');
|
|
928
|
+
meta.className = 'tool-meta';
|
|
929
|
+
const duration = document.createElement('span');
|
|
930
|
+
duration.className = 'tool-duration';
|
|
931
|
+
meta.appendChild(duration);
|
|
932
|
+
content.appendChild(meta);
|
|
933
|
+
|
|
934
|
+
div.appendChild(top);
|
|
935
|
+
div.appendChild(content);
|
|
936
|
+
div.appendChild(bottom);
|
|
937
|
+
|
|
938
|
+
// Click to expand/collapse
|
|
939
|
+
div.addEventListener('click', () => this.toggleToolExpand(div));
|
|
940
|
+
|
|
941
|
+
this.contentEl.appendChild(div);
|
|
942
|
+
return div;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
setToolOutput(div, text) {
|
|
946
|
+
const out = div.querySelector('.tool-output');
|
|
947
|
+
if (!out) return;
|
|
948
|
+
div.dataset.fullOutput = text || '';
|
|
949
|
+
this.applyToolPreview(div);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
applyToolPreview(div) {
|
|
953
|
+
const out = div.querySelector('.tool-output');
|
|
954
|
+
if (!out) return;
|
|
955
|
+
const full = div.dataset.fullOutput || '';
|
|
956
|
+
const expanded = div.dataset.expanded === 'true';
|
|
957
|
+
const isBash = div.dataset.toolName === 'bash';
|
|
958
|
+
const limit = isBash ? 5 : 10;
|
|
959
|
+
const lines = full.split('\n');
|
|
960
|
+
if (!expanded && lines.length > limit) {
|
|
961
|
+
const visible = lines.slice(0, limit).join('\n');
|
|
962
|
+
const hidden = lines.length - limit;
|
|
963
|
+
out.textContent = visible + `\n... (${hidden} more lines, click to expand)`;
|
|
964
|
+
} else {
|
|
965
|
+
out.textContent = full;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
toggleToolExpand(div) {
|
|
970
|
+
const expanded = div.dataset.expanded === 'true';
|
|
971
|
+
div.dataset.expanded = expanded ? 'false' : 'true';
|
|
972
|
+
div.classList.toggle('expanded', !expanded);
|
|
973
|
+
this.applyToolPreview(div);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
onToolStart(ev) {
|
|
977
|
+
const div = this.createToolBlock(ev.toolName, ev.args, ev.toolCallId);
|
|
978
|
+
this.toolEls.set(ev.toolCallId, div);
|
|
979
|
+
|
|
980
|
+
// Start elapsed timer
|
|
981
|
+
const start = Date.now();
|
|
982
|
+
div.dataset.startTime = start;
|
|
983
|
+
const durationEl = div.querySelector('.tool-duration');
|
|
984
|
+
if (durationEl) durationEl.textContent = 'Running...';
|
|
985
|
+
const timer = setInterval(() => {
|
|
986
|
+
if (!div.isConnected) {
|
|
987
|
+
clearInterval(timer);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
const dur = ((Date.now() - start) / 1000).toFixed(1);
|
|
991
|
+
const el = div.querySelector('.tool-duration');
|
|
992
|
+
if (el) el.textContent = `Elapsed ${dur}s`;
|
|
993
|
+
}, 1000);
|
|
994
|
+
this.toolTimers.set(ev.toolCallId, timer);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
onToolUpdate(ev) {
|
|
998
|
+
const div = this.toolEls.get(ev.toolCallId);
|
|
999
|
+
if (!div) return;
|
|
1000
|
+
if (ev.partialResult !== undefined && ev.partialResult !== null) {
|
|
1001
|
+
this.setToolOutput(div, this.resultText(ev.partialResult));
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
onToolEnd(ev) {
|
|
1006
|
+
const div = this.toolEls.get(ev.toolCallId);
|
|
1007
|
+
if (!div) return;
|
|
1008
|
+
div.className = ev.isError ? 'tool-block error' : 'tool-block success';
|
|
1009
|
+
|
|
1010
|
+
if (ev.result) {
|
|
1011
|
+
this.setToolOutput(div, this.resultText(ev.result));
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Stop elapsed timer and show final duration
|
|
1015
|
+
const timer = this.toolTimers.get(ev.toolCallId);
|
|
1016
|
+
if (timer) {
|
|
1017
|
+
clearInterval(timer);
|
|
1018
|
+
this.toolTimers.delete(ev.toolCallId);
|
|
1019
|
+
}
|
|
1020
|
+
const start = parseInt(div.dataset.startTime || '0', 10);
|
|
1021
|
+
const durationEl = div.querySelector('.tool-duration');
|
|
1022
|
+
if (durationEl && start) {
|
|
1023
|
+
const dur = ((Date.now() - start) / 1000).toFixed(1);
|
|
1024
|
+
durationEl.textContent = `Took ${dur}s`;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// Truncation / full output warning
|
|
1028
|
+
const result = ev.result;
|
|
1029
|
+
if (result && result.details) {
|
|
1030
|
+
const details = result.details;
|
|
1031
|
+
const warnings = [];
|
|
1032
|
+
if (details.fullOutputPath) {
|
|
1033
|
+
warnings.push(`Full output: ${details.fullOutputPath}`);
|
|
1034
|
+
}
|
|
1035
|
+
if (details.truncation && details.truncation.truncated) {
|
|
1036
|
+
const tr = details.truncation;
|
|
1037
|
+
if (tr.truncatedBy === 'lines') {
|
|
1038
|
+
warnings.push(`Truncated: showing ${tr.outputLines} of ${tr.totalLines} lines`);
|
|
1039
|
+
} else {
|
|
1040
|
+
warnings.push(`Truncated: ${tr.outputLines} lines shown`);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
if (warnings.length > 0) {
|
|
1044
|
+
const meta = div.querySelector('.tool-meta');
|
|
1045
|
+
const warn = document.createElement('div');
|
|
1046
|
+
warn.className = 'tool-truncated';
|
|
1047
|
+
warn.textContent = `[${warnings.join('. ')}]`;
|
|
1048
|
+
meta.appendChild(warn);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
this.toolEls.delete(ev.toolCallId);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
renderBashResult(data) {
|
|
1056
|
+
const div = this.createToolBlock('bash', { command: data.command }, 'bash-' + Date.now());
|
|
1057
|
+
const result = data.data || {};
|
|
1058
|
+
const output = result.output || '';
|
|
1059
|
+
this.setToolOutput(div, output);
|
|
1060
|
+
|
|
1061
|
+
const isError = result.exitCode !== undefined && result.exitCode !== 0;
|
|
1062
|
+
div.className = isError ? 'tool-block error' : 'tool-block success';
|
|
1063
|
+
|
|
1064
|
+
const durationEl = div.querySelector('.tool-duration');
|
|
1065
|
+
if (durationEl) durationEl.remove();
|
|
1066
|
+
|
|
1067
|
+
if (result.truncated || result.fullOutputPath) {
|
|
1068
|
+
const meta = div.querySelector('.tool-meta');
|
|
1069
|
+
const warnings = [];
|
|
1070
|
+
if (result.fullOutputPath) warnings.push(`Full output: ${result.fullOutputPath}`);
|
|
1071
|
+
if (result.truncated) warnings.push('Output truncated');
|
|
1072
|
+
const warn = document.createElement('div');
|
|
1073
|
+
warn.className = 'tool-truncated';
|
|
1074
|
+
warn.textContent = `[${warnings.join('. ')}]`;
|
|
1075
|
+
meta.appendChild(warn);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
this.scrollToBottom();
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
resultText(result) {
|
|
1082
|
+
if (typeof result === 'string') return result;
|
|
1083
|
+
if (result && result.content) {
|
|
1084
|
+
const texts = result.content
|
|
1085
|
+
.filter((c) => c.type === 'text')
|
|
1086
|
+
.map((c) => c.text)
|
|
1087
|
+
.join('\n');
|
|
1088
|
+
return texts || JSON.stringify(result, null, 2);
|
|
1089
|
+
}
|
|
1090
|
+
return JSON.stringify(result, null, 2);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
messageText(message) {
|
|
1094
|
+
if (typeof message === 'string') return message;
|
|
1095
|
+
if (message && Array.isArray(message.content)) {
|
|
1096
|
+
return message.content
|
|
1097
|
+
.filter((c) => c.type === 'text')
|
|
1098
|
+
.map((c) => c.text)
|
|
1099
|
+
.join('\n');
|
|
1100
|
+
}
|
|
1101
|
+
return '';
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
appendError(msg) {
|
|
1105
|
+
const div = document.createElement('div');
|
|
1106
|
+
div.style.color = 'var(--error)';
|
|
1107
|
+
div.textContent = msg;
|
|
1108
|
+
this.contentEl.appendChild(div);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
scrollToBottom() {
|
|
1112
|
+
const scroller = document.getElementById('scroll-view');
|
|
1113
|
+
if (scroller) scroller.scrollTop = scroller.scrollHeight;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// ------------------------------------------------------------------
|
|
1117
|
+
// Input
|
|
1118
|
+
// ------------------------------------------------------------------
|
|
1119
|
+
|
|
1120
|
+
sendMessage() {
|
|
1121
|
+
const text = this.inputEl.value.trim();
|
|
1122
|
+
if (!text) return;
|
|
1123
|
+
if (text.startsWith('!')) {
|
|
1124
|
+
const command = text.slice(1).trim();
|
|
1125
|
+
if (command) {
|
|
1126
|
+
this.send({ type: 'bash', command: command });
|
|
1127
|
+
}
|
|
1128
|
+
} else if (text.startsWith('/')) {
|
|
1129
|
+
// Slash command: builtins are handled locally, everything else is
|
|
1130
|
+
// executed as an extension command by the server.
|
|
1131
|
+
const m = text.slice(1).match(/^(\S+)\s*(.*)$/);
|
|
1132
|
+
const name = (m ? m[1] : text.slice(1)).replace(/^skill:/, '');
|
|
1133
|
+
const args = m ? m[2] : '';
|
|
1134
|
+
const builtin = BUILTIN_COMMANDS.find((c) => c.name === name);
|
|
1135
|
+
if (builtin && builtin.action && !builtin.unsupported) {
|
|
1136
|
+
this.runBuiltinCommand(builtin, args);
|
|
1137
|
+
} else if (builtin && builtin.unsupported) {
|
|
1138
|
+
this.appendError(`/${name} is not supported in web mode`);
|
|
1139
|
+
} else {
|
|
1140
|
+
this.send({ type: 'command', name: name, args: args });
|
|
1141
|
+
}
|
|
1142
|
+
} else {
|
|
1143
|
+
this.send({ type: 'prompt', message: text });
|
|
1144
|
+
}
|
|
1145
|
+
this.inputEl.value = '';
|
|
1146
|
+
this.hideCommandMenu();
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
runBuiltinCommand(cmd, args) {
|
|
1150
|
+
if (cmd.action === 'model') {
|
|
1151
|
+
this.openModelPicker();
|
|
1152
|
+
} else if (cmd.action === 'thinking') {
|
|
1153
|
+
this.openThinkingPicker();
|
|
1154
|
+
} else if (cmd.action === 'scoped-models') {
|
|
1155
|
+
this.openScopedModelsPicker();
|
|
1156
|
+
} else if (cmd.action === 'compact') {
|
|
1157
|
+
this.send({ type: 'compact' });
|
|
1158
|
+
} else if (cmd.action === 'reload') {
|
|
1159
|
+
this.send({ type: 'reload' });
|
|
1160
|
+
} else if (cmd.action === 'export') {
|
|
1161
|
+
const path = (args || '').trim();
|
|
1162
|
+
this.send({ type: 'export', path: path });
|
|
1163
|
+
} else if (cmd.action === 'name') {
|
|
1164
|
+
const newName = window.prompt('Set session display name:', '');
|
|
1165
|
+
if (newName && newName.trim()) {
|
|
1166
|
+
this.send({ type: 'set_session_name', name: newName.trim() });
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
bindInput() {
|
|
1172
|
+
this.inputEl.addEventListener('keydown', (e) => {
|
|
1173
|
+
const menuOpen = this.commandMenuEl && this.commandMenuEl.style.display !== 'none';
|
|
1174
|
+
if (menuOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
|
1175
|
+
e.preventDefault();
|
|
1176
|
+
this.moveCommandMenu(e.key === 'ArrowDown' ? 1 : -1);
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
if (menuOpen && e.key === 'Enter' && !e.shiftKey) {
|
|
1180
|
+
// Enter picks the highlighted item, or the first one if none highlighted
|
|
1181
|
+
e.preventDefault();
|
|
1182
|
+
const items = this.commandMenuEl.querySelectorAll('.command-item');
|
|
1183
|
+
if (items.length > 0) {
|
|
1184
|
+
const idx = this.commandMenuIndex >= 0 ? this.commandMenuIndex : 0;
|
|
1185
|
+
items[idx].click();
|
|
1186
|
+
}
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
1190
|
+
e.preventDefault();
|
|
1191
|
+
this.sendMessage();
|
|
1192
|
+
} else if (e.key === 'Escape') {
|
|
1193
|
+
this.hideCommandMenu();
|
|
1194
|
+
}
|
|
1195
|
+
});
|
|
1196
|
+
this.inputEl.addEventListener('input', () => this.updateCommandMenu());
|
|
1197
|
+
if (this.sendBtn) {
|
|
1198
|
+
this.sendBtn.addEventListener('click', () => this.sendMessage());
|
|
1199
|
+
}
|
|
1200
|
+
if (this.abortBtn) {
|
|
1201
|
+
this.abortBtn.addEventListener('click', () => {
|
|
1202
|
+
this.send({ type: 'abort' });
|
|
1203
|
+
this.abortBtn.style.display = 'none';
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// Modal
|
|
1208
|
+
if (this.modalClose) {
|
|
1209
|
+
this.modalClose.addEventListener('click', () => this.closeModal());
|
|
1210
|
+
}
|
|
1211
|
+
if (this.modalOverlay) {
|
|
1212
|
+
this.modalOverlay.addEventListener('click', (e) => {
|
|
1213
|
+
if (e.target === this.modalOverlay) this.closeModal();
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
if (this.modalSearch) {
|
|
1217
|
+
this.modalSearch.addEventListener('input', () => {
|
|
1218
|
+
if (this.modalMode === 'scoped-models') this.renderScopedModelsList();
|
|
1219
|
+
else this.filterModalItems();
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
document.addEventListener('keydown', (e) => {
|
|
1223
|
+
if (e.key === 'Escape' && this.modalOverlay && this.modalOverlay.style.display !== 'none') {
|
|
1224
|
+
this.closeModal();
|
|
1225
|
+
}
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// ------------------------------------------------------------------
|
|
1230
|
+
// Modal (model/thinking picker)
|
|
1231
|
+
// ------------------------------------------------------------------
|
|
1232
|
+
|
|
1233
|
+
openModal(title, mode) {
|
|
1234
|
+
this.modalMode = mode;
|
|
1235
|
+
this.modalTitle.textContent = title;
|
|
1236
|
+
this.modalSearch.value = '';
|
|
1237
|
+
this.modalList.innerHTML = '';
|
|
1238
|
+
this.modalOverlay.style.display = 'flex';
|
|
1239
|
+
this.modalSearch.focus();
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
closeModal() {
|
|
1243
|
+
this.modalOverlay.style.display = 'none';
|
|
1244
|
+
this.modalList.innerHTML = '';
|
|
1245
|
+
this.modalSearch.value = '';
|
|
1246
|
+
this.modalSearch.style.display = 'block';
|
|
1247
|
+
this.modalMode = null;
|
|
1248
|
+
this.currentExtRequest = null;
|
|
1249
|
+
this.inputEl.focus();
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
openModelPicker() {
|
|
1253
|
+
this.openModal('Select Model', 'model');
|
|
1254
|
+
this.send({ type: 'get_available_models' });
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
openThinkingPicker() {
|
|
1258
|
+
this.openModal('Select Thinking Level', 'thinking');
|
|
1259
|
+
this.send({ type: 'get_available_thinking_levels' });
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
renderModalItems(items, onSelect) {
|
|
1263
|
+
this.modalList.innerHTML = '';
|
|
1264
|
+
this.modalItems = items;
|
|
1265
|
+
this.modalOnSelect = onSelect;
|
|
1266
|
+
this.filterModalItems();
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
filterModalItems() {
|
|
1270
|
+
if (!this.modalItems) return;
|
|
1271
|
+
const query = (this.modalSearch.value || '').toLowerCase();
|
|
1272
|
+
const filtered = this.modalItems.filter((item) => {
|
|
1273
|
+
const name = (item.name || item.id || '').toLowerCase();
|
|
1274
|
+
return name.includes(query);
|
|
1275
|
+
});
|
|
1276
|
+
this.modalList.innerHTML = filtered
|
|
1277
|
+
.map(
|
|
1278
|
+
(item, i) =>
|
|
1279
|
+
`<div class="modal-item" data-index="${i}">` +
|
|
1280
|
+
`<span class="modal-item-name">${this.escapeHtml(item.name || item.id || '')}</span>` +
|
|
1281
|
+
`<span class="modal-item-desc">${this.escapeHtml(item.desc || '')}</span>` +
|
|
1282
|
+
`</div>`,
|
|
1283
|
+
)
|
|
1284
|
+
.join('');
|
|
1285
|
+
this.modalList.querySelectorAll('.modal-item').forEach((el, i) => {
|
|
1286
|
+
el.addEventListener('click', () => {
|
|
1287
|
+
this.modalOnSelect(filtered[i]);
|
|
1288
|
+
this.closeModal();
|
|
1289
|
+
});
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
handleExtensionUIRequest(req) {
|
|
1294
|
+
const method = req.method;
|
|
1295
|
+
if (method === 'select') {
|
|
1296
|
+
this.openExtensionSelect(req);
|
|
1297
|
+
} else if (method === 'confirm') {
|
|
1298
|
+
this.openExtensionConfirm(req);
|
|
1299
|
+
} else if (method === 'input') {
|
|
1300
|
+
this.openExtensionInput(req);
|
|
1301
|
+
} else if (method === 'editor') {
|
|
1302
|
+
this.openExtensionEditor(req);
|
|
1303
|
+
} else if (method === 'notify') {
|
|
1304
|
+
this.openExtensionNotify(req);
|
|
1305
|
+
} else if (method === 'setWidget') {
|
|
1306
|
+
// Persistent widget panel (TUI: above/below editor). Not a popup.
|
|
1307
|
+
this.renderWidget(req);
|
|
1308
|
+
}
|
|
1309
|
+
// setStatus / setTitle / set_editor_text are handled elsewhere or ignored
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
openExtensionSelect(req) {
|
|
1313
|
+
this.openModal(req.title || 'Select', 'extension-select');
|
|
1314
|
+
this.currentExtRequest = req;
|
|
1315
|
+
this.modalSearch.style.display = 'block';
|
|
1316
|
+
const items = (req.options || []).map((opt) => ({ name: opt, desc: '', value: opt }));
|
|
1317
|
+
this.renderModalItems(items, (item) => {
|
|
1318
|
+
this.send({ type: 'extension_ui_response', id: req.id, value: item.value });
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
openExtensionConfirm(req) {
|
|
1323
|
+
this.openModal(req.title || 'Confirm', 'extension-confirm');
|
|
1324
|
+
this.currentExtRequest = req;
|
|
1325
|
+
this.modalSearch.style.display = 'none';
|
|
1326
|
+
this.modalList.innerHTML = `
|
|
1327
|
+
<div class="modal-message">${this.escapeHtml(req.message || '')}</div>
|
|
1328
|
+
<div class="modal-actions">
|
|
1329
|
+
<button class="modal-btn confirm-btn">Confirm</button>
|
|
1330
|
+
<button class="modal-btn cancel-btn">Cancel</button>
|
|
1331
|
+
</div>`;
|
|
1332
|
+
this.modalList.querySelector('.confirm-btn').addEventListener('click', () => {
|
|
1333
|
+
this.send({ type: 'extension_ui_response', id: req.id, confirmed: true });
|
|
1334
|
+
this.closeModal();
|
|
1335
|
+
});
|
|
1336
|
+
this.modalList.querySelector('.cancel-btn').addEventListener('click', () => {
|
|
1337
|
+
this.send({ type: 'extension_ui_response', id: req.id, cancelled: true });
|
|
1338
|
+
this.closeModal();
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
openExtensionInput(req) {
|
|
1343
|
+
this.openModal(req.title || 'Input', 'extension-input');
|
|
1344
|
+
this.currentExtRequest = req;
|
|
1345
|
+
this.modalSearch.style.display = 'none';
|
|
1346
|
+
this.modalList.innerHTML = `
|
|
1347
|
+
<div class="modal-message">${this.escapeHtml(req.message || '')}</div>
|
|
1348
|
+
<input class="modal-input" type="text" placeholder="${this.escapeHtml(req.placeholder || '')}">
|
|
1349
|
+
<div class="modal-actions">
|
|
1350
|
+
<button class="modal-btn ok-btn">OK</button>
|
|
1351
|
+
<button class="modal-btn cancel-btn">Cancel</button>
|
|
1352
|
+
</div>`;
|
|
1353
|
+
const input = this.modalList.querySelector('.modal-input');
|
|
1354
|
+
input.focus();
|
|
1355
|
+
this.modalList.querySelector('.ok-btn').addEventListener('click', () => {
|
|
1356
|
+
this.send({ type: 'extension_ui_response', id: req.id, value: input.value });
|
|
1357
|
+
this.closeModal();
|
|
1358
|
+
});
|
|
1359
|
+
this.modalList.querySelector('.cancel-btn').addEventListener('click', () => {
|
|
1360
|
+
this.send({ type: 'extension_ui_response', id: req.id, cancelled: true });
|
|
1361
|
+
this.closeModal();
|
|
1362
|
+
});
|
|
1363
|
+
input.addEventListener('keydown', (e) => {
|
|
1364
|
+
if (e.key === 'Enter') {
|
|
1365
|
+
this.send({ type: 'extension_ui_response', id: req.id, value: input.value });
|
|
1366
|
+
this.closeModal();
|
|
1367
|
+
}
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
openExtensionEditor(req) {
|
|
1372
|
+
this.openModal(req.title || 'Editor', 'extension-editor');
|
|
1373
|
+
this.currentExtRequest = req;
|
|
1374
|
+
this.modalSearch.style.display = 'none';
|
|
1375
|
+
this.modalList.innerHTML = `
|
|
1376
|
+
<div class="modal-message">${this.escapeHtml(req.title || '')}</div>
|
|
1377
|
+
<textarea class="modal-editor" rows="10">${this.escapeHtml(req.prefill || '')}</textarea>
|
|
1378
|
+
<div class="modal-actions">
|
|
1379
|
+
<button class="modal-btn ok-btn">OK</button>
|
|
1380
|
+
<button class="modal-btn cancel-btn">Cancel</button>
|
|
1381
|
+
</div>`;
|
|
1382
|
+
const editor = this.modalList.querySelector('.modal-editor');
|
|
1383
|
+
editor.focus();
|
|
1384
|
+
this.modalList.querySelector('.ok-btn').addEventListener('click', () => {
|
|
1385
|
+
this.send({ type: 'extension_ui_response', id: req.id, value: editor.value });
|
|
1386
|
+
this.closeModal();
|
|
1387
|
+
});
|
|
1388
|
+
this.modalList.querySelector('.cancel-btn').addEventListener('click', () => {
|
|
1389
|
+
this.send({ type: 'extension_ui_response', id: req.id, cancelled: true });
|
|
1390
|
+
this.closeModal();
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
openExtensionNotify(req) {
|
|
1395
|
+
this.openModal(req.title || 'Notification', 'extension-notify');
|
|
1396
|
+
this.currentExtRequest = req;
|
|
1397
|
+
this.modalSearch.style.display = 'none';
|
|
1398
|
+
// Render as markdown (sanitized) so command outputs (/ctx-status etc.) look right
|
|
1399
|
+
this.modalList.innerHTML = `<div class="modal-message body-text">${this.renderMarkdown(req.message || '')}</div>`;
|
|
1400
|
+
// Close button in modal footer is enough
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
handleModels(models) {
|
|
1404
|
+
if (this.modalMode !== 'model') return;
|
|
1405
|
+
const items = (models || []).map((m) => ({
|
|
1406
|
+
name: `${m.provider}/${m.id}`,
|
|
1407
|
+
desc: m.reasoning ? 'reasoning' : '',
|
|
1408
|
+
value: { provider: m.provider, id: m.id },
|
|
1409
|
+
}));
|
|
1410
|
+
this.renderModalItems(items, (item) => {
|
|
1411
|
+
this.send({ type: 'set_model', provider: item.value.provider, modelId: item.value.id });
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
handleThinkingLevels(levels) {
|
|
1416
|
+
if (this.modalMode !== 'thinking') return;
|
|
1417
|
+
const descriptions = {
|
|
1418
|
+
off: 'No reasoning',
|
|
1419
|
+
minimal: 'Very brief reasoning (~1k tokens)',
|
|
1420
|
+
low: 'Light reasoning (~2k tokens)',
|
|
1421
|
+
medium: 'Moderate reasoning (~8k tokens)',
|
|
1422
|
+
high: 'Deep reasoning (~16k tokens)',
|
|
1423
|
+
xhigh: 'Extra-high reasoning (~32k tokens)',
|
|
1424
|
+
max: 'Maximum reasoning',
|
|
1425
|
+
};
|
|
1426
|
+
const items = (levels || []).map((level) => ({
|
|
1427
|
+
name: level,
|
|
1428
|
+
desc: descriptions[level] || '',
|
|
1429
|
+
value: level,
|
|
1430
|
+
}));
|
|
1431
|
+
this.renderModalItems(items, (item) => {
|
|
1432
|
+
this.send({ type: 'set_thinking_level', level: item.value });
|
|
1433
|
+
});
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// ------------------------------------------------------------------
|
|
1437
|
+
// Scoped models picker (multi-select: models available for cycling)
|
|
1438
|
+
// ------------------------------------------------------------------
|
|
1439
|
+
|
|
1440
|
+
openScopedModelsPicker() {
|
|
1441
|
+
this.openModal('Select Scoped Models (empty = all available)', 'scoped-models');
|
|
1442
|
+
this.scopedModelsAll = [];
|
|
1443
|
+
this.scopedModelsSelected = new Set();
|
|
1444
|
+
this.scopedModelsData = null;
|
|
1445
|
+
this.modalList.innerHTML = '<div class="modal-message">Loading models...</div>';
|
|
1446
|
+
this.send({ type: 'get_scoped_models' });
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
handleScopedModels(data) {
|
|
1450
|
+
if (this.modalMode !== 'scoped-models') return;
|
|
1451
|
+
this.scopedModelsAll = data.available || [];
|
|
1452
|
+
this.scopedModelsData = data;
|
|
1453
|
+
this.scopedModelsSelected = new Set(
|
|
1454
|
+
(data.scoped || []).map((s) => `${s.provider}/${s.id}`),
|
|
1455
|
+
);
|
|
1456
|
+
this.renderScopedModelsList();
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
renderScopedModelsList() {
|
|
1460
|
+
if (this.modalMode !== 'scoped-models') return;
|
|
1461
|
+
const query = (this.modalSearch.value || '').toLowerCase();
|
|
1462
|
+
const items = (this.scopedModelsAll || [])
|
|
1463
|
+
.filter((m) => `${m.provider}/${m.id}`.toLowerCase().includes(query))
|
|
1464
|
+
.sort((a, b) => {
|
|
1465
|
+
// Selected models first, preserving original order within each group
|
|
1466
|
+
const aSel = this.scopedModelsSelected.has(`${a.provider}/${a.id}`) ? 0 : 1;
|
|
1467
|
+
const bSel = this.scopedModelsSelected.has(`${b.provider}/${b.id}`) ? 0 : 1;
|
|
1468
|
+
return aSel - bSel;
|
|
1469
|
+
});
|
|
1470
|
+
const rows = items
|
|
1471
|
+
.map((m) => {
|
|
1472
|
+
const key = `${m.provider}/${m.id}`;
|
|
1473
|
+
const checked = this.scopedModelsSelected.has(key);
|
|
1474
|
+
return (
|
|
1475
|
+
`<div class="modal-item scoped-model ${checked ? 'selected' : ''}" data-key="${key}">` +
|
|
1476
|
+
`<span class="modal-check">${checked ? '☑' : '☐'}</span>` +
|
|
1477
|
+
`<span class="modal-item-name">${this.escapeHtml(key)}</span>` +
|
|
1478
|
+
`</div>`
|
|
1479
|
+
);
|
|
1480
|
+
})
|
|
1481
|
+
.join('');
|
|
1482
|
+
this.modalList.innerHTML =
|
|
1483
|
+
rows +
|
|
1484
|
+
`<div class="modal-actions">
|
|
1485
|
+
<button class="modal-btn ok-btn">Apply</button>
|
|
1486
|
+
<button class="modal-btn cancel-btn">Cancel</button>
|
|
1487
|
+
</div>`;
|
|
1488
|
+
this.modalList.querySelectorAll('.scoped-model').forEach((el) => {
|
|
1489
|
+
el.addEventListener('click', () => {
|
|
1490
|
+
const key = el.dataset.key;
|
|
1491
|
+
if (this.scopedModelsSelected.has(key)) this.scopedModelsSelected.delete(key);
|
|
1492
|
+
else this.scopedModelsSelected.add(key);
|
|
1493
|
+
const checked = this.scopedModelsSelected.has(key);
|
|
1494
|
+
el.classList.toggle('selected', checked);
|
|
1495
|
+
el.querySelector('.modal-check').textContent = checked ? '☑' : '☐';
|
|
1496
|
+
});
|
|
1497
|
+
});
|
|
1498
|
+
const okBtn = this.modalList.querySelector('.ok-btn');
|
|
1499
|
+
if (okBtn) {
|
|
1500
|
+
okBtn.addEventListener('click', () => {
|
|
1501
|
+
const models = [];
|
|
1502
|
+
for (const m of this.scopedModelsAll || []) {
|
|
1503
|
+
const key = `${m.provider}/${m.id}`;
|
|
1504
|
+
if (this.scopedModelsSelected.has(key)) {
|
|
1505
|
+
const scopedInfo = (this.scopedModelsData?.scoped || []).find(
|
|
1506
|
+
(s) => `${s.provider}/${s.id}` === key,
|
|
1507
|
+
);
|
|
1508
|
+
models.push({
|
|
1509
|
+
provider: m.provider,
|
|
1510
|
+
modelId: m.id,
|
|
1511
|
+
thinkingLevel: scopedInfo?.thinkingLevel,
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
this.send({ type: 'set_scoped_models', models });
|
|
1516
|
+
this.closeModal();
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
const cancelBtn = this.modalList.querySelector('.cancel-btn');
|
|
1520
|
+
if (cancelBtn) cancelBtn.addEventListener('click', () => this.closeModal());
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// ------------------------------------------------------------------
|
|
1524
|
+
// Slash command menu
|
|
1525
|
+
// ------------------------------------------------------------------
|
|
1526
|
+
|
|
1527
|
+
updateCommandMenu() {
|
|
1528
|
+
if (!this.commandMenuEl) return;
|
|
1529
|
+
const text = this.inputEl.value;
|
|
1530
|
+
if (!text.startsWith('/')) {
|
|
1531
|
+
this.hideCommandMenu();
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
const query = text.slice(1).toLowerCase();
|
|
1536
|
+
const commands = [
|
|
1537
|
+
...((this.lastState && this.lastState.commands) || []),
|
|
1538
|
+
...BUILTIN_COMMANDS,
|
|
1539
|
+
];
|
|
1540
|
+
const filtered = commands.filter((c) => {
|
|
1541
|
+
const name = (c.name || '').replace(/^skill:/, '').toLowerCase();
|
|
1542
|
+
const desc = (c.description || c.source || '').toLowerCase();
|
|
1543
|
+
return name.includes(query) || desc.includes(query);
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
if (filtered.length === 0) {
|
|
1547
|
+
this.hideCommandMenu();
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
this.commandMenuEl.innerHTML = filtered
|
|
1552
|
+
.map(
|
|
1553
|
+
(c, i) =>
|
|
1554
|
+
`<div class="command-item" data-index="${i}">` +
|
|
1555
|
+
`<span class="command-name">/${this.escapeHtml(c.name.replace(/^skill:/, ''))}</span>` +
|
|
1556
|
+
`<span class="command-desc">${this.escapeHtml(c.description || c.source || '')}</span>` +
|
|
1557
|
+
(c.unsupported ? `<span class="command-unsupported">not supported</span>` : '') +
|
|
1558
|
+
`</div>`,
|
|
1559
|
+
)
|
|
1560
|
+
.join('');
|
|
1561
|
+
this.commandMenuEl.style.display = 'block';
|
|
1562
|
+
this.commandMenuIndex = -1;
|
|
1563
|
+
|
|
1564
|
+
this.commandMenuEl.querySelectorAll('.command-item').forEach((el, i) => {
|
|
1565
|
+
el.addEventListener('click', () => this.selectCommand(filtered[i]));
|
|
1566
|
+
el.addEventListener('mouseenter', () => this.setCommandMenuIndex(i));
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
moveCommandMenu(delta) {
|
|
1571
|
+
const items = this.commandMenuEl.querySelectorAll('.command-item');
|
|
1572
|
+
if (items.length === 0) return;
|
|
1573
|
+
let idx = this.commandMenuIndex < 0 ? (delta > 0 ? 0 : items.length - 1) : this.commandMenuIndex + delta;
|
|
1574
|
+
idx = (idx + items.length) % items.length;
|
|
1575
|
+
this.setCommandMenuIndex(idx);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
setCommandMenuIndex(idx) {
|
|
1579
|
+
this.commandMenuIndex = idx;
|
|
1580
|
+
const items = this.commandMenuEl.querySelectorAll('.command-item');
|
|
1581
|
+
items.forEach((el, i) => el.classList.toggle('selected', i === idx));
|
|
1582
|
+
if (idx >= 0 && items[idx]) {
|
|
1583
|
+
items[idx].scrollIntoView({ block: 'nearest' });
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
selectCommand(cmd) {
|
|
1588
|
+
if (!cmd) return;
|
|
1589
|
+
|
|
1590
|
+
// Builtin commands with actions
|
|
1591
|
+
if (cmd.builtin && cmd.action && !cmd.unsupported) {
|
|
1592
|
+
this.runBuiltinCommand(cmd, '');
|
|
1593
|
+
this.hideCommandMenu();
|
|
1594
|
+
this.inputEl.value = '';
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// Unsupported builtin
|
|
1599
|
+
if (cmd.builtin && cmd.unsupported) {
|
|
1600
|
+
this.appendError(`/${cmd.name} is not supported in web mode`);
|
|
1601
|
+
this.hideCommandMenu();
|
|
1602
|
+
this.inputEl.value = '';
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// Regular commands: insert into input
|
|
1607
|
+
const name = cmd.name.replace(/^skill:/, '');
|
|
1608
|
+
this.inputEl.value = '/' + name + ' ';
|
|
1609
|
+
this.inputEl.focus();
|
|
1610
|
+
this.hideCommandMenu();
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
hideCommandMenu() {
|
|
1614
|
+
this.commandMenuIndex = -1;
|
|
1615
|
+
if (this.commandMenuEl) {
|
|
1616
|
+
this.commandMenuEl.style.display = 'none';
|
|
1617
|
+
this.commandMenuEl.innerHTML = '';
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
window.addEventListener('DOMContentLoaded', () => {
|
|
1623
|
+
new PiWebClient();
|
|
1624
|
+
});
|