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/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
+ });