pi-desktop-ui 1.0.1

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/web/app.js ADDED
@@ -0,0 +1,2671 @@
1
+ // ─── Pi Desktop App ───────────────────────────────────────────
2
+ // Fully functional chat UI inside a Glimpse native webview.
3
+ // Bidirectional: send messages → pi processes → streams response back.
4
+
5
+ let data = {};
6
+ try {
7
+ // Data is base64-encoded in the template to avoid Glimpse bridge corruption.
8
+ var b64 = (document.getElementById("desktop-data").textContent || "").trim();
9
+ if (b64) {
10
+ var bytes = Uint8Array.from(atob(b64), function(c) { return c.charCodeAt(0); });
11
+ var jsonStr = new TextDecoder().decode(bytes);
12
+ data = JSON.parse(jsonStr);
13
+ }
14
+ } catch (e) {
15
+ console.error("[desktop] Failed to decode/parse desktop-data:", e);
16
+ // Fallback: try direct parse (in case data is raw JSON, not base64)
17
+ try {
18
+ var raw = document.getElementById("desktop-data").textContent || "{}";
19
+ data = JSON.parse(raw);
20
+ } catch (e2) { console.error("[desktop] Fallback parse also failed:", e2); }
21
+ }
22
+
23
+ // ─── Mermaid (mermaid.js) ─────────────────────────────────
24
+
25
+ let _mermaidReady = false;
26
+ let _mermaidCounter = 0;
27
+
28
+ function initMermaid() {
29
+ if (typeof mermaid === 'undefined') return;
30
+ mermaid.initialize({
31
+ startOnLoad: false,
32
+ theme: 'neutral',
33
+ securityLevel: 'strict',
34
+ fontFamily: 'ui-sans-serif, system-ui, sans-serif',
35
+ });
36
+ _mermaidReady = true;
37
+ }
38
+
39
+ initMermaid();
40
+
41
+ function renderMermaidAsync(el) {
42
+ if (!_mermaidReady || !el) return;
43
+ var code = el.dataset.mermaidSrc;
44
+ if (!code) return;
45
+ var id = 'mermaid-svg-' + (++_mermaidCounter);
46
+ mermaid.render(id, code).then(function(result) {
47
+ el.innerHTML = result.svg;
48
+ el.classList.remove('mermaid-pending');
49
+ el.classList.add('mermaid-rendered');
50
+ }).catch(function() {
51
+ el.innerHTML = '<pre style="text-align:left;margin:0;background:var(--code-bg);border-radius:6px;"><code style="white-space:pre-wrap;word-break:break-word;">' + escapeHtml(code) + '</code></pre>';
52
+ el.classList.remove('mermaid-pending');
53
+ el.classList.add('mermaid-rendered');
54
+ });
55
+ }
56
+
57
+ function renderAllPendingMermaid() {
58
+ document.querySelectorAll('.mermaid-pending').forEach(renderMermaidAsync);
59
+ }
60
+
61
+ // ─── Render Cache ──────────────────────────────────────────
62
+ // Cache rendered HTML for messages to avoid expensive re-processing
63
+ var _renderCache = new Map(); // key: message content hash → rendered HTML
64
+
65
+ function getMsgCacheKey(msg) {
66
+ // Simple cache key from role + content (fast string concat)
67
+ return msg.role + '||' + (msg.content || '') + '||' + (msg.toolName || '') + '||' + (msg.status || '') + '||' + (msg.isError ? '1' : '0');
68
+ }
69
+
70
+ // ─── State ────────────────────────────────────────────────────
71
+
72
+ const state = {
73
+ activeView: "threads",
74
+ activeThreadIdx: -1, // -1 = current session
75
+ messages: data.messages || [],
76
+ theme: "dark",
77
+ isStreaming: false,
78
+ streamingText: "", // accumulated text during streaming
79
+ thinkingText: "", // accumulated thinking text
80
+ isThinking: false, // currently in thinking block
81
+ activeTools: [], // deprecated, tools now tracked in messages
82
+ commands: data.commands || [],
83
+ viewingOldThread: false,
84
+ // Workspace state
85
+ expandedWorkspaces: { "__current__": true }, // current workspace starts expanded
86
+ workspaceSessions: {}, // { dirName: [...sessions] } - cached sessions per workspace
87
+ activeWorkspace: null, // dirName of workspace being viewed (null = current)
88
+ showWorkspaceModal: false,
89
+ hiddenWorkspaces: (data.hiddenWorkspaces || {}), // loaded from disk via backend
90
+ planMode: false, // read-only plan mode
91
+ viewingFile: null, // { path, name, content, ext, size } when viewing a file
92
+ // Explorer tree state (sidebar)
93
+ explorerTreeExpanded: {}, // { dirPath: true } - which dirs are expanded
94
+ explorerTreeChildren: {}, // { dirPath: [...entries] } - cached children per dir
95
+ explorerTreeRoot: null, // root path for tree
96
+ searchResults: null, // null = no search, [] = search with results
97
+ };
98
+
99
+ // ─── DOM References ───────────────────────────────────────────
100
+
101
+ const projectTreeEl = document.getElementById("project-tree");
102
+ const explorerTreeEl = document.getElementById("explorer-tree");
103
+ const breadcrumbEl = document.getElementById("breadcrumb");
104
+ const threadHeaderEl = document.getElementById("thread-header");
105
+ const threadLabelEl = document.getElementById("thread-label");
106
+ const threadTitleEl = document.getElementById("thread-title");
107
+ const messagesEl = document.getElementById("messages");
108
+ const inputTextEl = document.getElementById("input-text");
109
+ const modelLabelEl = document.getElementById("model-label");
110
+ const thinkingLabelEl = document.getElementById("thinking-label");
111
+ const statsBarEl = document.getElementById("stats-bar");
112
+ const btnTheme = document.getElementById("btn-theme");
113
+ const iconMoon = document.getElementById("icon-moon");
114
+ const iconSun = document.getElementById("icon-sun");
115
+ const btnSend = document.getElementById("btn-send");
116
+ const btnNewThread = document.getElementById("btn-new-thread");
117
+ const navItems = document.querySelectorAll("[data-nav]");
118
+
119
+ // Auto-render mermaid diagrams when new content is added (disabled during streaming)
120
+ var _mermaidObserver = null;
121
+ var _mermaidDebounceTimer = null;
122
+ function startMermaidObserver() {
123
+ if (_mermaidObserver || !messagesEl) return;
124
+ _mermaidObserver = new MutationObserver(function() {
125
+ if (_mermaidDebounceTimer) clearTimeout(_mermaidDebounceTimer);
126
+ _mermaidDebounceTimer = setTimeout(renderAllPendingMermaid, 100);
127
+ });
128
+ _mermaidObserver.observe(messagesEl, { childList: true, subtree: true });
129
+ }
130
+ function stopMermaidObserver() {
131
+ if (_mermaidObserver) { _mermaidObserver.disconnect(); _mermaidObserver = null; }
132
+ }
133
+ // Start observer only when not streaming
134
+ if (!state.isStreaming) startMermaidObserver();
135
+
136
+ // ─── Markdown Setup ──────────────────────────────────────────
137
+
138
+ if (typeof marked !== "undefined") {
139
+ marked.setOptions({
140
+ breaks: true,
141
+ gfm: true,
142
+ });
143
+ }
144
+
145
+ // ─── Helpers ──────────────────────────────────────────────────
146
+
147
+ function escapeHtml(str) {
148
+ return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
149
+ }
150
+
151
+ function fmt(n) {
152
+ if (n == null) return "0";
153
+ if (n < 1000) return String(n);
154
+ if (n < 1_000_000) return (n / 1000).toFixed(1) + "k";
155
+ return (n / 1_000_000).toFixed(1) + "M";
156
+ }
157
+
158
+ function timeAgo(dateStr) {
159
+ if (!dateStr) return "";
160
+ const s = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
161
+ if (s < 60) return "now";
162
+ if (s < 3600) return Math.floor(s / 60) + "m";
163
+ if (s < 86400) return Math.floor(s / 3600) + "h";
164
+ return Math.floor(s / 86400) + "d";
165
+ }
166
+
167
+ function sanitizeHtml(html) {
168
+ if (typeof DOMPurify !== "undefined") {
169
+ return DOMPurify.sanitize(html, {
170
+ ALLOWED_TAGS: [
171
+ 'h1','h2','h3','h4','h5','h6','p','br','hr','blockquote',
172
+ 'ul','ol','li','dl','dt','dd',
173
+ 'strong','em','b','i','u','s','del','ins','mark','sub','sup','small',
174
+ 'a','code','pre','kbd','samp','var',
175
+ 'table','thead','tbody','tfoot','tr','th','td','caption',
176
+ 'img','figure','figcaption',
177
+ 'details','summary',
178
+ 'div','span',
179
+ ],
180
+ ALLOWED_ATTR: [
181
+ 'href','title','alt','src','class','id','lang',
182
+ 'colspan','rowspan','headers','scope',
183
+ 'open','width','height','loading',
184
+ ],
185
+ ADD_ATTR: ['target'],
186
+ ALLOW_DATA_ATTR: false,
187
+ FORBID_TAGS: ['style','script','iframe','object','embed','form','input','textarea','button','select'],
188
+ FORBID_ATTR: ['onerror','onload','onclick','onmouseover','onfocus','onblur','style'],
189
+ });
190
+ }
191
+ return html;
192
+ }
193
+
194
+ function renderMarkdown(text) {
195
+ if (!text) return "";
196
+ if (typeof marked !== "undefined") {
197
+ try {
198
+ // Trusted content that bypasses DOMPurify (mermaid SVG, KaTeX HTML)
199
+ var trustedBlocks = [];
200
+
201
+ // 1. Protect LaTeX formulas from markdown processing
202
+ var processed = text;
203
+
204
+ // Block math: $$...$$ (can span multiple lines)
205
+ processed = processed.replace(/\$\$([\s\S]+?)\$\$/g, function(_, formula) {
206
+ var idx = trustedBlocks.length;
207
+ trustedBlocks.push({ type: 'math', formula: formula.trim(), display: true });
208
+ return '\n\nTRUSTED_BLOCK_' + idx + '\n\n';
209
+ });
210
+
211
+ // Inline math: $...$ (single line, not empty)
212
+ processed = processed.replace(/(?:^|[^$])\$([^$\n]+?)\$(?:[^$]|$)/g, function(match, formula) {
213
+ var idx = trustedBlocks.length;
214
+ trustedBlocks.push({ type: 'math', formula: formula.trim(), display: false });
215
+ var leading = match[0] !== '$' ? match[0] : '';
216
+ var trailing = match[match.length - 1] !== '$' ? match[match.length - 1] : '';
217
+ return leading + 'TRUSTED_BLOCK_' + idx + trailing;
218
+ });
219
+
220
+ // 2. Parse markdown
221
+ var html = marked.parse(processed);
222
+
223
+ // 3. Extract mermaid blocks and replace with placeholders
224
+ html = html.replace(/<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/g, function(_, code) {
225
+ var decoded = code.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"');
226
+ var idx = trustedBlocks.length;
227
+ trustedBlocks.push({ type: 'mermaid', code: decoded });
228
+ return 'TRUSTED_BLOCK_' + idx;
229
+ });
230
+
231
+ // 4. Syntax highlighting for code blocks (skip mermaid)
232
+ if (typeof hljs !== "undefined") {
233
+ html = html.replace(/<pre><code class="language-(\w+)">([\s\S]*?)<\/code><\/pre>/g, function(match, lang, code) {
234
+ if (lang === 'mermaid') return match; // already extracted above, but guard just in case
235
+ if (!hljs.getLanguage(lang)) return match; // skip unknown languages
236
+ try {
237
+ var decoded = code.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"');
238
+ var highlighted = hljs.highlight(decoded, { language: lang }).value;
239
+ return '<pre><code class="language-' + lang + ' hljs">' + highlighted + '</code></pre>';
240
+ } catch(e) { return match; }
241
+ });
242
+ }
243
+
244
+ // 5. Sanitize user-generated HTML (safe — trusted blocks are just text placeholders)
245
+ html = sanitizeHtml(html);
246
+
247
+ // 6. Replace placeholders with trusted content AFTER sanitization
248
+ for (var i = 0; i < trustedBlocks.length; i++) {
249
+ var block = trustedBlocks[i];
250
+ var rendered;
251
+ if (block.type === 'math') {
252
+ rendered = renderLatex(block.formula, block.display);
253
+ } else if (block.type === 'mermaid') {
254
+ rendered = renderMermaidBlock(block.code);
255
+ }
256
+ html = html.replace(new RegExp('<p>TRUSTED_BLOCK_' + i + '</p>', 'g'), function() { return rendered; });
257
+ html = html.replace(new RegExp('TRUSTED_BLOCK_' + i, 'g'), function() { return rendered; });
258
+ }
259
+
260
+ return html;
261
+ } catch(e) { console.warn('[desktop] renderMarkdown error:', e); }
262
+ }
263
+ return "<p>" + escapeHtml(text).replace(/\n/g, "<br>") + "</p>";
264
+ }
265
+
266
+ function renderMermaidBlock(code) {
267
+ // Always return a pending placeholder — mermaid.render() is async
268
+ // and will fill in the SVG after DOM insertion.
269
+ return '<div class="mermaid-container mermaid-pending" data-mermaid-src="' + escapeHtml(code) + '">'
270
+ + '<pre style="text-align:left;padding:16px;"><code>' + escapeHtml(code) + '</code></pre>'
271
+ + '</div>';
272
+ }
273
+
274
+ function renderLatex(formula, displayMode) {
275
+ if (typeof katex !== 'undefined') {
276
+ try {
277
+ const html = katex.renderToString(formula, {
278
+ displayMode: displayMode,
279
+ throwOnError: false,
280
+ output: 'htmlAndMathml',
281
+ trust: false,
282
+ strict: false,
283
+ });
284
+ return displayMode
285
+ ? '<div class="math-block">' + html + '</div>'
286
+ : '<span class="math-inline">' + html + '</span>';
287
+ } catch (err) {
288
+ return displayMode
289
+ ? '<div class="math-block" style="color:#e55;">LaTeX error: ' + escapeHtml(err.message) + '</div>'
290
+ : '<code style="color:#e55;">' + escapeHtml(formula) + '</code>';
291
+ }
292
+ }
293
+ // Fallback: show raw formula
294
+ return displayMode
295
+ ? '<div class="math-block"><code>' + escapeHtml(formula) + '</code></div>'
296
+ : '<code>' + escapeHtml(formula) + '</code>';
297
+ }
298
+
299
+ function truncate(str, len) {
300
+ if (!str) return "";
301
+ return str.length > len ? str.slice(0, len) + "…" : str;
302
+ }
303
+
304
+ function scrollToBottom() {
305
+ requestAnimationFrame(() => {
306
+ messagesEl.scrollTop = messagesEl.scrollHeight;
307
+ });
308
+ }
309
+
310
+ // ─── Theme ────────────────────────────────────────────────────
311
+
312
+ function setTheme(theme) {
313
+ state.theme = theme;
314
+ document.documentElement.setAttribute("data-theme", theme);
315
+ iconMoon.classList.toggle("hidden", theme === "dark");
316
+ iconSun.classList.toggle("hidden", theme === "light");
317
+ const lightSheet = document.getElementById("hljs-light");
318
+ const darkSheet = document.getElementById("hljs-dark");
319
+ if (lightSheet) lightSheet.disabled = theme === "dark";
320
+ if (darkSheet) darkSheet.disabled = theme === "light";
321
+ }
322
+
323
+ btnTheme.addEventListener("click", () => {
324
+ setTheme(state.theme === "light" ? "dark" : "light");
325
+ });
326
+
327
+ // ─── Plan Mode ────────────────────────────────────────────────
328
+
329
+ const btnPlanMode = document.getElementById("btn-plan-mode");
330
+ const planModeBanner = document.getElementById("plan-mode-banner");
331
+
332
+ function setPlanMode(active) {
333
+ state.planMode = active;
334
+ btnPlanMode.classList.toggle("plan-active", active);
335
+ planModeBanner.style.display = active ? "flex" : "none";
336
+ inputTextEl.placeholder = active
337
+ ? "Plan mode — ask pi to read, analyze, or plan (no writes)..."
338
+ : "Ask pi to inspect the repo, run a fix, or continue the current thread...";
339
+ // Notify backend
340
+ send({ type: "set-plan-mode", active });
341
+ }
342
+
343
+ btnPlanMode.addEventListener("click", () => {
344
+ setPlanMode(!state.planMode);
345
+ });
346
+
347
+ // ─── Sidebar Toggle ───────────────────────────────────────────
348
+
349
+ const sidebarEl = document.getElementById("sidebar");
350
+ const btnToggleSidebar = document.getElementById("btn-toggle-sidebar");
351
+ let sidebarCollapsed = false;
352
+
353
+ function toggleSidebar() {
354
+ sidebarCollapsed = !sidebarCollapsed;
355
+ if (sidebarCollapsed) {
356
+ sidebarEl.classList.remove("sidebar-expanded");
357
+ sidebarEl.classList.add("sidebar-collapsed");
358
+ } else {
359
+ sidebarEl.classList.remove("sidebar-collapsed");
360
+ sidebarEl.classList.add("sidebar-expanded");
361
+ }
362
+ }
363
+
364
+ if (btnToggleSidebar) {
365
+ btnToggleSidebar.addEventListener("click", toggleSidebar);
366
+ }
367
+
368
+ // ─── Navigation ───────────────────────────────────────────────
369
+
370
+ function setActiveNav(view) {
371
+ state.activeView = view;
372
+ navItems.forEach(item => item.classList.toggle("active", item.dataset.nav === view));
373
+ renderMainContent();
374
+ }
375
+
376
+ navItems.forEach(item => {
377
+ item.addEventListener("click", () => {
378
+ if (item.dataset.nav === "workspace") {
379
+ // Open workspace modal instead of navigating
380
+ showWorkspaceModal();
381
+ return;
382
+ }
383
+ // Toggle explorer: if already active, collapse back to threads
384
+ if (item.dataset.nav === "explorer" && state.activeView === "explorer") {
385
+ setActiveNav("threads");
386
+ return;
387
+ }
388
+ setActiveNav(item.dataset.nav);
389
+ send({ type: "nav", action: item.dataset.nav });
390
+ });
391
+ });
392
+
393
+ // ─── Thread Search ──────────────────────────────────────────
394
+
395
+ let threadSearchQuery = "";
396
+ let _searchDebounceTimer = null;
397
+
398
+ const threadSearchInput = document.getElementById("thread-search");
399
+ const threadSearchClear = document.getElementById("thread-search-clear");
400
+
401
+ function clearThreadSearch() {
402
+ threadSearchQuery = "";
403
+ state.searchResults = null;
404
+ if (threadSearchInput) threadSearchInput.value = "";
405
+ if (threadSearchClear) threadSearchClear.classList.add("hidden");
406
+ if (_searchDebounceTimer) clearTimeout(_searchDebounceTimer);
407
+ renderProjectTree();
408
+ }
409
+
410
+ if (threadSearchClear) {
411
+ threadSearchClear.addEventListener("click", clearThreadSearch);
412
+ }
413
+
414
+ if (threadSearchInput) {
415
+ threadSearchInput.addEventListener("input", () => {
416
+ const raw = threadSearchInput.value.trim();
417
+ threadSearchQuery = raw.toLowerCase();
418
+
419
+ if (_searchDebounceTimer) clearTimeout(_searchDebounceTimer);
420
+
421
+ // Show/hide clear button
422
+ if (threadSearchClear) threadSearchClear.classList.toggle("hidden", !raw);
423
+
424
+ if (!raw) {
425
+ // Cleared search — restore normal tree
426
+ state.searchResults = null;
427
+ renderProjectTree();
428
+ return;
429
+ }
430
+
431
+ // Also do instant name-based filtering for responsiveness
432
+ state.searchResults = null;
433
+ renderProjectTree();
434
+
435
+ // Debounce the backend full-content search
436
+ _searchDebounceTimer = setTimeout(() => {
437
+ send({ type: "search-threads", query: raw });
438
+ }, 350);
439
+ });
440
+ }
441
+
442
+ function matchesThreadSearch(name) {
443
+ if (!threadSearchQuery) return true;
444
+ return (name || "").toLowerCase().includes(threadSearchQuery);
445
+ }
446
+
447
+ function renderSearchResults() {
448
+ const results = state.searchResults || [];
449
+ if (results.length === 0) {
450
+ return `<div class="px-3 py-4 text-center text-[13px] text-pi-text-dim">No threads found</div>`;
451
+ }
452
+
453
+ // Group results by workspace
454
+ const groups = {};
455
+ for (const r of results) {
456
+ const ws = r.workspace || "__current__";
457
+ if (!groups[ws]) groups[ws] = [];
458
+ groups[ws].push(r);
459
+ }
460
+
461
+ let html = "";
462
+ for (const [ws, items] of Object.entries(groups)) {
463
+ // Workspace header
464
+ const wsName = ws === "__current__"
465
+ ? (data.projectName || "Current workspace")
466
+ : ws.replace(/^--/, "").replace(/--$/, "").split("-").pop() || ws;
467
+ html += `
468
+ <div class="px-3 py-1.5 text-[11px] font-medium uppercase tracking-wider text-pi-text-dim">${escapeHtml(wsName)}</div>
469
+ `;
470
+
471
+ for (const r of items) {
472
+ html += `
473
+ <button class="thread-item flex w-full flex-col rounded-md px-3 py-2 text-left text-[13px] gap-0.5"
474
+ data-search-file="${escapeHtml(r.file)}" data-search-ws="${escapeHtml(r.workspace || "")}">
475
+ <div class="flex items-center justify-between w-full">
476
+ <span class="truncate font-medium" style="max-width: 170px;">${escapeHtml(truncate(r.name, 55))}</span>
477
+ <span class="text-[11px] text-pi-text-dim flex-shrink-0">${timeAgo(r.date)}</span>
478
+ </div>
479
+ <div class="text-[11px] text-pi-text-dim truncate" style="max-width: 210px;">${escapeHtml(r.matchSnippet || "")}</div>
480
+ </button>
481
+ `;
482
+ }
483
+ }
484
+ return html;
485
+ }
486
+
487
+ // ─── Project Tree (Sidebar Threads + Workspaces) ────────────
488
+
489
+ function renderProjectTree() {
490
+ const projectTreeEl = document.getElementById("project-tree");
491
+ if (!projectTreeEl) return;
492
+
493
+ // If backend search results are available, render those instead
494
+ if (state.searchResults !== null) {
495
+ const html = renderSearchResults();
496
+ projectTreeEl.innerHTML = html;
497
+ // Attach click handlers for search results
498
+ projectTreeEl.querySelectorAll("[data-search-file]").forEach(btn => {
499
+ btn.addEventListener("click", () => {
500
+ const file = btn.dataset.searchFile;
501
+ const ws = btn.dataset.searchWs;
502
+ if (file) {
503
+ state.activeView = "threads";
504
+ state.viewingOldThread = true;
505
+ if (ws && ws !== "__current__") {
506
+ state.activeWorkspace = ws;
507
+ } else {
508
+ state.activeWorkspace = null;
509
+ }
510
+ send({ type: "open-thread", file });
511
+ }
512
+ });
513
+ });
514
+ renderHiddenWorkspacesBar([]);
515
+ return;
516
+ }
517
+
518
+ const allThreads = data.threads || [];
519
+ const threads = allThreads.filter(t => matchesThreadSearch(t.name));
520
+ const workspaces = data.workspaces || [];
521
+ let html = "";
522
+
523
+ // ─── Current workspace (collapsible) ─────────────────────
524
+ const hasCurrentMatches = threadSearchQuery && threads.length > 0;
525
+ const currentExpanded = hasCurrentMatches || state.expandedWorkspaces["__current__"] !== false;
526
+ // When searching: hide current workspace if no threads match
527
+ const showCurrentWs = !threadSearchQuery || hasCurrentMatches;
528
+ const branch = data.gitBranch
529
+ ? ` <span style="color:var(--accent);">\u00b7 ${escapeHtml(data.gitBranch)}</span>`
530
+ : "";
531
+
532
+ if (showCurrentWs) {
533
+ html += `
534
+ <div class="flex items-center gap-2 px-3 py-2 text-[13px] cursor-pointer hover:bg-pi-sidebar-hover rounded-md" data-ws-toggle="__current__">
535
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" class="text-pi-text-muted" style="flex-shrink:0;transition:transform 0.15s;transform:rotate(${currentExpanded ? 90 : 0}deg);">
536
+ <path d="M8 5l8 7-8 7z"/>
537
+ </svg>
538
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-pi-text-muted" style="flex-shrink:0">
539
+ <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
540
+ </svg>
541
+ <span class="font-medium" style="color:var(--text);">${escapeHtml(data.projectName || "")}</span>${branch}
542
+ </div>
543
+ `;
544
+
545
+ // Current workspace sessions (shown when expanded)
546
+ if (currentExpanded) {
547
+ html += `<div id="ws-sessions-__current__">`;
548
+
549
+ // Current session (hidden when searching)
550
+ if (!threadSearchQuery) {
551
+ html += `
552
+ <button class="thread-item flex w-full items-center justify-between rounded-md px-7 py-1.5 text-left text-[13px] ${state.activeThreadIdx === -1 && state.activeView === "threads" && !state.activeWorkspace ? "active" : ""}"
553
+ data-thread-idx="-1" data-ws="__current__">
554
+ <span class="truncate font-medium" style="max-width: 170px; color: var(--accent);">\u25cf Current session</span>
555
+ <span class="text-[11px] text-pi-text-dim flex-shrink-0">now</span>
556
+ </button>
557
+ `;
558
+ }
559
+
560
+ for (let i = 0; i < threads.length; i++) {
561
+ const t = threads[i];
562
+ const origIdx = allThreads.indexOf(t);
563
+ const isActive = origIdx === state.activeThreadIdx && state.activeView === "threads" && !state.activeWorkspace;
564
+ html += `
565
+ <button class="thread-item flex w-full items-center justify-between rounded-md px-7 py-1.5 text-left text-[13px] ${isActive ? "active" : ""}"
566
+ data-thread-idx="${origIdx}" data-ws="__current__">
567
+ <span class="truncate" style="max-width: 170px;">${escapeHtml(truncate(t.name, 55))}</span>
568
+ <span class="text-[11px] text-pi-text-dim flex-shrink-0">${timeAgo(t.date)}</span>
569
+ </button>
570
+ `;
571
+ }
572
+ html += `</div>`;
573
+ }
574
+ } // end showCurrentWs
575
+
576
+ // ─── Other workspaces ─────────────────────────────────────
577
+ const cwd = data.cwd || "";
578
+ const visibleWs = workspaces.filter(ws => ws.path !== cwd && !state.hiddenWorkspaces[ws.dirName]);
579
+ const hiddenWs = workspaces.filter(ws => ws.path !== cwd && state.hiddenWorkspaces[ws.dirName]);
580
+
581
+ for (const ws of visibleWs) {
582
+ const sessions = state.workspaceSessions[ws.dirName] || [];
583
+ const filteredSessions = sessions.filter(s => matchesThreadSearch(s.name));
584
+ const hasWsMatches = threadSearchQuery && filteredSessions.length > 0;
585
+ const sessionsLoading = threadSearchQuery && !state.workspaceSessions[ws.dirName];
586
+
587
+ // Auto-fetch sessions when searching if not yet loaded
588
+ if (sessionsLoading) {
589
+ send({ type: "get-workspace-sessions", dirName: ws.dirName });
590
+ }
591
+
592
+ // When searching: hide workspaces with no matches (unless still loading)
593
+ if (threadSearchQuery && !hasWsMatches && !sessionsLoading) continue;
594
+
595
+ const isExpanded = hasWsMatches || sessionsLoading || !!state.expandedWorkspaces[ws.dirName];
596
+ const arrowRotation = isExpanded ? "rotate(90deg)" : "rotate(0deg)";
597
+
598
+ html += `
599
+ <div class="flex items-center gap-2 px-3 py-2 text-[13px] cursor-pointer hover:bg-pi-sidebar-hover rounded-md" data-ws-toggle="${escapeHtml(ws.dirName)}">
600
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" class="text-pi-text-muted" style="flex-shrink:0;transition:transform 0.15s;transform:${arrowRotation};">
601
+ <path d="M8 5l8 7-8 7z"/>
602
+ </svg>
603
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-pi-text-muted" style="flex-shrink:0">
604
+ <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
605
+ </svg>
606
+ <span class="font-medium truncate" style="color:var(--text);max-width:140px;">${escapeHtml(ws.name)}</span>
607
+ <span class="ml-auto text-[11px] text-pi-text-dim flex-shrink-0">${ws.sessionCount}</span>
608
+ </div>
609
+ `;
610
+
611
+ if (isExpanded) {
612
+ html += `<div id="ws-sessions-${escapeHtml(ws.dirName)}">`;
613
+ if (sessions.length === 0) {
614
+ html += `<div class="px-7 py-1.5 text-[12px] text-pi-text-dim">Loading...</div>`;
615
+ } else {
616
+ for (let i = 0; i < filteredSessions.length; i++) {
617
+ const s = filteredSessions[i];
618
+ const origIdx = sessions.indexOf(s);
619
+ const isActive = state.activeWorkspace === ws.dirName && state.activeThreadIdx === origIdx && state.activeView === "threads";
620
+ html += `
621
+ <button class="thread-item flex w-full items-center justify-between rounded-md px-7 py-1.5 text-left text-[13px] ${isActive ? "active" : ""}"
622
+ data-thread-idx="${origIdx}" data-ws="${escapeHtml(ws.dirName)}" data-ws-file="${escapeHtml(s.file)}">
623
+ <span class="truncate" style="max-width: 170px;">${escapeHtml(truncate(s.name, 55))}</span>
624
+ <span class="text-[11px] text-pi-text-dim flex-shrink-0">${timeAgo(s.date)}</span>
625
+ </button>
626
+ `;
627
+ }
628
+ }
629
+ html += `</div>`;
630
+ }
631
+ }
632
+
633
+ // Show "no results" when searching and nothing matches
634
+ if (threadSearchQuery && html.trim() === "") {
635
+ html = `<div class="px-3 py-4 text-center text-[13px] text-pi-text-dim">No threads found</div>`;
636
+ }
637
+
638
+ projectTreeEl.innerHTML = html;
639
+
640
+ // Render hidden workspaces bar (pinned at bottom of sidebar)
641
+ renderHiddenWorkspacesBar(hiddenWs);
642
+
643
+ // ─── Event listeners ──────────────────────────────────────
644
+
645
+ // Workspace toggle (expand/collapse)
646
+ projectTreeEl.querySelectorAll("[data-ws-toggle]").forEach(el => {
647
+ el.addEventListener("click", () => {
648
+ const dirName = el.dataset.wsToggle;
649
+ state.expandedWorkspaces[dirName] = !state.expandedWorkspaces[dirName];
650
+ if (dirName !== "__current__" && state.expandedWorkspaces[dirName] && !state.workspaceSessions[dirName]) {
651
+ // Fetch sessions for this workspace
652
+ send({ type: "get-workspace-sessions", dirName });
653
+ }
654
+ renderProjectTree();
655
+ });
656
+ });
657
+
658
+ // Thread click
659
+ projectTreeEl.querySelectorAll("[data-thread-idx]").forEach(btn => {
660
+ btn.addEventListener("click", (e) => {
661
+ e.stopPropagation();
662
+ const idx = parseInt(btn.dataset.threadIdx);
663
+ const ws = btn.dataset.ws;
664
+
665
+ state.activeThreadIdx = idx;
666
+ state.activeView = "threads";
667
+ setActiveNav("threads");
668
+
669
+ if (ws === "__current__") {
670
+ state.activeWorkspace = null;
671
+ // Reset explorer tree to current workspace
672
+ state.explorerTreeExpanded = {};
673
+ state.explorerTreeChildren = {};
674
+ state.explorerTreeRoot = null;
675
+ if (idx === -1) {
676
+ state.viewingOldThread = false;
677
+ send({ type: "get-stats" });
678
+ renderMainContent();
679
+ } else {
680
+ state.viewingOldThread = true;
681
+ send({ type: "open-thread", file: data.threads[idx]?.file, index: idx });
682
+ }
683
+ } else {
684
+ // Viewing a session from another workspace
685
+ state.activeWorkspace = ws;
686
+ // Reset explorer tree for other workspace
687
+ state.explorerTreeExpanded = {};
688
+ state.explorerTreeChildren = {};
689
+ state.explorerTreeRoot = null;
690
+ state.viewingOldThread = true;
691
+ const file = btn.dataset.wsFile;
692
+ if (file) {
693
+ send({ type: "open-thread", file, index: idx, workspace: ws });
694
+ }
695
+ }
696
+ });
697
+ });
698
+
699
+ // Right-click on workspace headers → context menu to hide/show
700
+ projectTreeEl.querySelectorAll("[data-ws-toggle]").forEach(el => {
701
+ const dirName = el.dataset.wsToggle;
702
+ if (dirName === "__current__" || dirName === "__hidden__") return;
703
+ el.addEventListener("contextmenu", (e) => {
704
+ e.preventDefault();
705
+ e.stopPropagation();
706
+ showWsContextMenu(e.clientX, e.clientY, [
707
+ { label: "Hide from sidebar", action: () => { state.hiddenWorkspaces[dirName] = true; send({ type: "set-hidden-workspaces", hiddenWorkspaces: state.hiddenWorkspaces }); renderProjectTree(); } },
708
+ ]);
709
+ });
710
+ });
711
+ }
712
+
713
+ // ─── Workspace Context Menu ─────────────────────────────────
714
+
715
+ function showWsContextMenu(x, y, items) {
716
+ // Remove any existing context menu
717
+ dismissWsContextMenu();
718
+
719
+ const menu = document.createElement("div");
720
+ menu.id = "ws-context-menu";
721
+ menu.style.cssText = `position:fixed;z-index:200;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:4px;min-width:160px;box-shadow:0 8px 24px rgba(0,0,0,0.3);`;
722
+
723
+ // Position: ensure menu stays in viewport
724
+ menu.style.left = x + "px";
725
+ menu.style.top = y + "px";
726
+
727
+ for (const item of items) {
728
+ const btn = document.createElement("button");
729
+ btn.textContent = item.label;
730
+ btn.style.cssText = `display:block;width:100%;text-align:left;padding:8px 12px;border:none;background:none;color:var(--text);font-size:13px;cursor:pointer;border-radius:6px;`;
731
+ btn.addEventListener("mouseover", () => { btn.style.background = "var(--sidebar-hover)"; });
732
+ btn.addEventListener("mouseout", () => { btn.style.background = "none"; });
733
+ btn.addEventListener("click", (e) => {
734
+ e.stopPropagation();
735
+ dismissWsContextMenu();
736
+ item.action();
737
+ });
738
+ menu.appendChild(btn);
739
+ }
740
+
741
+ document.body.appendChild(menu);
742
+
743
+ // Adjust if overflows viewport
744
+ requestAnimationFrame(() => {
745
+ const rect = menu.getBoundingClientRect();
746
+ if (rect.right > window.innerWidth) menu.style.left = (window.innerWidth - rect.width - 8) + "px";
747
+ if (rect.bottom > window.innerHeight) menu.style.top = (y - rect.height) + "px";
748
+ });
749
+
750
+ // Dismiss on click outside or escape
751
+ function onDismiss(e) {
752
+ if (!menu.contains(e.target)) dismissWsContextMenu();
753
+ }
754
+ function onKey(e) {
755
+ if (e.key === "Escape") dismissWsContextMenu();
756
+ }
757
+ setTimeout(() => {
758
+ document.addEventListener("click", onDismiss, { once: true });
759
+ document.addEventListener("contextmenu", onDismiss, { once: true });
760
+ document.addEventListener("keydown", onKey, { once: true });
761
+ }, 0);
762
+
763
+ menu._cleanup = () => {
764
+ document.removeEventListener("click", onDismiss);
765
+ document.removeEventListener("contextmenu", onDismiss);
766
+ document.removeEventListener("keydown", onKey);
767
+ };
768
+ }
769
+
770
+ function dismissWsContextMenu() {
771
+ const existing = document.getElementById("ws-context-menu");
772
+ if (existing) {
773
+ if (existing._cleanup) existing._cleanup();
774
+ existing.remove();
775
+ }
776
+ }
777
+
778
+
779
+ // ─── Hidden Workspaces Bar (pinned bottom of sidebar) ───────
780
+
781
+ function renderHiddenWorkspacesBar(hiddenWs) {
782
+ // Remove existing bar and popover
783
+ const existingBar = document.getElementById("hidden-ws-bar");
784
+ if (existingBar) existingBar.remove();
785
+ const existingPopover = document.getElementById("hidden-ws-popover");
786
+ if (existingPopover) existingPopover.remove();
787
+
788
+ if (!hiddenWs || hiddenWs.length === 0) return;
789
+
790
+ // Create the bar pinned at bottom of sidebar
791
+ const sidebar = document.getElementById("sidebar");
792
+ if (!sidebar) return;
793
+
794
+ const bar = document.createElement("div");
795
+ bar.id = "hidden-ws-bar";
796
+ bar.style.cssText = "border-top:1px solid var(--border);padding:4px 8px;flex-shrink:0;";
797
+ bar.innerHTML = `
798
+ <div class="flex items-center gap-2 px-3 py-2 text-[12px] cursor-pointer hover:bg-pi-sidebar-hover rounded-md sidebar-expanded-only" style="color:var(--text-muted);">
799
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="flex-shrink:0;">
800
+ <path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/>
801
+ </svg>
802
+ <span>Hidden workspaces</span>
803
+ <span class="ml-auto text-[11px]">${hiddenWs.length}</span>
804
+ </div>
805
+ `;
806
+ sidebar.appendChild(bar);
807
+
808
+ // Click handler: toggle popover growing upward
809
+ bar.querySelector("div").addEventListener("click", () => {
810
+ const existing = document.getElementById("hidden-ws-popover");
811
+ if (existing) { existing.remove(); return; }
812
+
813
+ const barRect = bar.getBoundingClientRect();
814
+
815
+ const popover = document.createElement("div");
816
+ popover.id = "hidden-ws-popover";
817
+ popover.style.cssText = `
818
+ position: fixed;
819
+ left: ${barRect.left}px;
820
+ bottom: ${window.innerHeight - barRect.top + 4}px;
821
+ width: ${barRect.width}px;
822
+ max-height: 300px;
823
+ overflow-y: auto;
824
+ background: var(--bg);
825
+ border: 1px solid var(--border);
826
+ border-radius: 10px;
827
+ padding: 6px;
828
+ box-shadow: 0 -8px 24px rgba(0,0,0,0.25);
829
+ z-index: 150;
830
+ `;
831
+
832
+ let popHtml = '<div style="padding:4px 8px 6px;font-size:11px;font-weight:600;color:var(--text-dim);">Hidden workspaces</div>';
833
+ for (const ws of hiddenWs) {
834
+ popHtml += `
835
+ <div class="flex items-center gap-2 px-3 py-2 text-[13px] cursor-pointer hover:bg-pi-sidebar-hover rounded-md" style="color:var(--text-muted);opacity:0.75;transition:opacity 0.15s;" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.75'" data-unhide-ws="${escapeHtml(ws.dirName)}">
836
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="flex-shrink:0;">
837
+ <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
838
+ </svg>
839
+ <span class="truncate" style="max-width:130px;">${escapeHtml(ws.name)}</span>
840
+ <span class="ml-auto text-[11px]" style="color:var(--text-dim);">${ws.sessionCount}</span>
841
+ </div>
842
+ `;
843
+ }
844
+ popover.innerHTML = popHtml;
845
+
846
+ document.body.appendChild(popover);
847
+
848
+ // Right-click on items to show in sidebar
849
+ popover.querySelectorAll("[data-unhide-ws]").forEach(el => {
850
+ const dirName = el.dataset.unhideWs;
851
+ el.addEventListener("contextmenu", (e) => {
852
+ e.preventDefault();
853
+ e.stopPropagation();
854
+ showWsContextMenu(e.clientX, e.clientY, [
855
+ { label: "Show in sidebar", action: () => { delete state.hiddenWorkspaces[dirName]; send({ type: "set-hidden-workspaces", hiddenWorkspaces: state.hiddenWorkspaces }); popover.remove(); renderProjectTree(); } },
856
+ ]);
857
+ });
858
+ el.addEventListener("click", (e) => {
859
+ e.stopPropagation();
860
+ delete state.hiddenWorkspaces[dirName];
861
+ send({ type: "set-hidden-workspaces", hiddenWorkspaces: state.hiddenWorkspaces });
862
+ popover.remove();
863
+ renderProjectTree();
864
+ });
865
+ });
866
+
867
+ // Close popover on click outside (but not if context menu is open)
868
+ function closePopover(e) {
869
+ const ctxMenu = document.getElementById("ws-context-menu");
870
+ if (ctxMenu && ctxMenu.contains(e.target)) return;
871
+ if (!popover.contains(e.target) && !bar.contains(e.target)) {
872
+ popover.remove();
873
+ document.removeEventListener("click", closePopover);
874
+ document.removeEventListener("keydown", closeOnEsc);
875
+ }
876
+ }
877
+ function closeOnEsc(e) {
878
+ if (e.key === "Escape") {
879
+ popover.remove();
880
+ document.removeEventListener("click", closePopover);
881
+ document.removeEventListener("keydown", closeOnEsc);
882
+ }
883
+ }
884
+ setTimeout(() => {
885
+ document.addEventListener("click", closePopover);
886
+ document.addEventListener("keydown", closeOnEsc);
887
+ }, 0);
888
+ });
889
+ }
890
+
891
+ // ─── Breadcrumb ───────────────────────────────────────────────
892
+
893
+ function renderBreadcrumb() {
894
+ // Show active workspace name if viewing another workspace's thread
895
+ let projectLabel = data.projectName;
896
+ if (state.activeWorkspace && state.activeWorkspace !== "__current__") {
897
+ const ws = (data.workspaces || []).find(w => w.dirName === state.activeWorkspace);
898
+ if (ws) projectLabel = ws.name;
899
+ }
900
+ const parts = [escapeHtml(projectLabel)];
901
+ if (state.activeView === "threads") {
902
+ if (data.gitBranch) {
903
+ parts.push(`<span class="rounded px-2 py-0.5 text-[12px] font-medium" style="background:var(--breadcrumb-badge);">${escapeHtml(data.gitBranch)}</span>`);
904
+ }
905
+ if (state.activeThreadIdx === -1) {
906
+ parts.push(`<span style="color:var(--accent);">current session</span>`);
907
+ } else {
908
+ const thread = data.threads?.[state.activeThreadIdx];
909
+ if (thread) parts.push(`<span class="truncate" style="max-width:400px;">${escapeHtml(truncate(thread.name, 80))}</span>`);
910
+ }
911
+ } else {
912
+ const labels = { workspace: "Workspace", skills: "Skills & Extensions", settings: "Settings", explorer: "Explorer" };
913
+ parts.push(labels[state.activeView] || state.activeView);
914
+ if (state.activeView === "explorer" && state.viewingFile) {
915
+ parts.push(`<span class="truncate" style="max-width:400px;">${escapeHtml(state.viewingFile.name)}</span>`);
916
+ }
917
+ }
918
+ breadcrumbEl.innerHTML = parts.join(`<span class="text-pi-text-dim"> / </span>`);
919
+ }
920
+
921
+ // ─── Messages Rendering ──────────────────────────────────────
922
+
923
+ function renderMessageHtml(msg) {
924
+ // Check cache first (skip for running tools since they update)
925
+ var cacheKey = getMsgCacheKey(msg);
926
+ if (msg.status !== 'running' && _renderCache.has(cacheKey)) {
927
+ return _renderCache.get(cacheKey);
928
+ }
929
+
930
+ var result;
931
+ if (msg.role === "user") {
932
+ result = `
933
+ <div class="msg-animate mb-4 flex justify-end">
934
+ <div class="max-w-[75%] rounded-2xl px-4 py-3 text-[14px]" style="background:var(--user-bubble);">
935
+ ${escapeHtml(msg.content)}
936
+ </div>
937
+ </div>
938
+ `;
939
+ } else if (msg.role === "assistant") {
940
+ result = `
941
+ <div class="msg-animate mb-5">
942
+ <div class="message-content text-[14.5px] leading-relaxed" style="color:var(--text);">
943
+ ${renderMarkdown(msg.content)}
944
+ </div>
945
+ </div>
946
+ `;
947
+ } else if (msg.role === "thinking") {
948
+ // Persisted thinking block — always collapsed
949
+ result = `
950
+ <div class="msg-animate mb-3">
951
+ <details class="rounded-lg border" style="border-color: var(--border);">
952
+ <summary class="cursor-pointer px-3 py-2 text-[12px] font-medium" style="color: var(--text-muted);">
953
+ \uD83D\uDCAD Thinking <span class="text-[11px] font-normal" style="color: var(--text-dim);">(${msg.content.length} chars)</span>
954
+ </summary>
955
+ <div class="border-t px-3 py-2" style="border-color: var(--border); max-height: 300px; overflow-y: auto;">
956
+ <pre class="text-[12px] whitespace-pre-wrap" style="color:var(--text-muted);">${escapeHtml(msg.content)}</pre>
957
+ </div>
958
+ </details>
959
+ </div>
960
+ `;
961
+ } else if (msg.role === "tool") {
962
+ const isRunning = msg.status === "running";
963
+ const statusIcon = isRunning
964
+ ? '<span class="tool-spinner" style="width:12px;height:12px;display:inline-block;vertical-align:middle;margin-right:4px;"></span>'
965
+ : msg.isError
966
+ ? '<span style="color:#e55;">\u2717 Error</span>'
967
+ : '<span style="color:#3b3;">\u2713</span>';
968
+ const toolIcon = getToolIcon(msg.toolName);
969
+ const hasEditDiffs = msg.editDiffs && msg.editDiffs.length > 0;
970
+ const diffId = hasEditDiffs ? `diff-inline-${ensureMsgId(msg)}` : null;
971
+
972
+ let detailsHtml = "";
973
+
974
+ // For edit tools with diffs, show inline diff preview
975
+ if (hasEditDiffs) {
976
+ let diffPreview = "";
977
+ msg.editDiffs.forEach((diff, i) => {
978
+ const editLabel = msg.editDiffs.length > 1 ? `<div style="color:var(--text-dim);font-size:11px;font-weight:600;padding:4px 0;">Edit ${i+1}</div>` : "";
979
+ const oldLines = (diff.oldText || "").split("\n");
980
+ const newLines = (diff.newText || "").split("\n");
981
+ diffPreview += `${editLabel}<div style="display:grid;grid-template-columns:1fr 1fr;gap:0;border:1px solid var(--border);border-radius:6px;overflow:hidden;margin-bottom:6px;">
982
+ <div style="padding:8px 10px;font-family:monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all;background:color-mix(in srgb, #e55 8%, var(--bg));border-right:1px solid var(--border);">${oldLines.map(l => '<span style="display:block;background:color-mix(in srgb, #e55 15%, transparent);">' + escapeHtml(l) + '</span>').join("")}</div>
983
+ <div style="padding:8px 10px;font-family:monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all;background:color-mix(in srgb, #3b3 8%, var(--bg));">${newLines.map(l => '<span style="display:block;background:color-mix(in srgb, #3b3 15%, transparent);">' + escapeHtml(l) + '</span>').join("")}</div>
984
+ </div>`;
985
+ });
986
+
987
+ detailsHtml += `
988
+ <div class="border-t px-3 py-2" style="border-color: var(--border);cursor:pointer;" id="${diffId}" title="Click to expand full diff">
989
+ <div class="text-[11px] font-semibold mb-1" style="color: var(--accent);">\u{1F50D} ${escapeHtml(msg.editPath || "")} \u2014 ${msg.editDiffs.length} edit(s) <span style="color:var(--text-dim);">(click to expand)</span></div>
990
+ <div style="max-height:200px;overflow:hidden;">${diffPreview}</div>
991
+ </div>
992
+ `;
993
+ } else if (msg.argsDisplay) {
994
+ detailsHtml += `
995
+ <div class="border-t px-3 py-2" style="border-color: var(--border);">
996
+ <div class="text-[11px] font-semibold mb-1" style="color: var(--text-muted);">Input</div>
997
+ <pre class="text-[12px] whitespace-pre-wrap overflow-x-auto" style="color:var(--text); max-height: 200px; overflow-y: auto;"><code>${escapeHtml(msg.argsDisplay)}</code></pre>
998
+ </div>
999
+ `;
1000
+ }
1001
+ if (msg.resultText && !isRunning) {
1002
+ detailsHtml += `
1003
+ <div class="border-t px-3 py-2" style="border-color: var(--border);">
1004
+ <div class="text-[11px] font-semibold mb-1" style="color: var(--text-muted);">Output</div>
1005
+ <pre class="text-[12px] whitespace-pre-wrap overflow-x-auto" style="color:var(--text-muted); max-height: 300px; overflow-y: auto;"><code>${escapeHtml(msg.resultText)}</code></pre>
1006
+ </div>
1007
+ `;
1008
+ }
1009
+
1010
+ result = `
1011
+ <div class="msg-animate mb-3">
1012
+ <details class="rounded-lg border" style="border-color: var(--border);" ${isRunning || hasEditDiffs ? 'open' : ''}>
1013
+ <summary class="cursor-pointer px-3 py-2 text-[12px] font-medium flex items-center gap-2" style="color: var(--text-muted);">
1014
+ <span>${toolIcon}</span>
1015
+ <span>${escapeHtml(msg.toolName || "Tool call")}</span>
1016
+ <span class="ml-auto">${statusIcon}</span>
1017
+ </summary>
1018
+ ${detailsHtml}
1019
+ </details>
1020
+ </div>
1021
+ `;
1022
+ }
1023
+ if (result && msg.status !== 'running') {
1024
+ _renderCache.set(cacheKey, result);
1025
+ }
1026
+ return result || "";
1027
+ }
1028
+
1029
+ function getToolIcon(toolName) {
1030
+ const icons = {
1031
+ bash: '\u2699\uFE0F', read: '\uD83D\uDCC4', edit: '\u270F\uFE0F', write: '\uD83D\uDCDD',
1032
+ grep: '\uD83D\uDD0D', find: '\uD83D\uDD0E', ls: '\uD83D\uDCC2', mcp: '\uD83D\uDD0C',
1033
+ parallel_search: '\uD83C\uDF10', parallel_research: '\uD83E\uDDEA', parallel_extract: '\uD83D\uDCE5',
1034
+ subagent: '\uD83E\uDD16', claude: '\uD83E\uDDE0', todo: '\u2611\uFE0F',
1035
+ };
1036
+ return icons[toolName] || '\uD83D\uDD27';
1037
+ }
1038
+
1039
+ // ─── Diff Overlay ─────────────────────────────────────────────
1040
+
1041
+ // ─── Word-level diff engine ───────────────────────────────────
1042
+
1043
+ function computeWordDiff(oldStr, newStr) {
1044
+ const oldWords = oldStr.split(/(\s+)/);
1045
+ const newWords = newStr.split(/(\s+)/);
1046
+
1047
+ // Simple LCS-based word diff
1048
+ const m = oldWords.length, n = newWords.length;
1049
+ const dp = Array.from({length: m + 1}, () => new Array(n + 1).fill(0));
1050
+ for (let i = 1; i <= m; i++) {
1051
+ for (let j = 1; j <= n; j++) {
1052
+ dp[i][j] = oldWords[i-1] === newWords[j-1]
1053
+ ? dp[i-1][j-1] + 1
1054
+ : Math.max(dp[i-1][j], dp[i][j-1]);
1055
+ }
1056
+ }
1057
+
1058
+ // Backtrack to get diff operations
1059
+ const result = [];
1060
+ let i = m, j = n;
1061
+ while (i > 0 || j > 0) {
1062
+ if (i > 0 && j > 0 && oldWords[i-1] === newWords[j-1]) {
1063
+ result.unshift({ type: "same", text: oldWords[i-1] });
1064
+ i--; j--;
1065
+ } else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) {
1066
+ result.unshift({ type: "add", text: newWords[j-1] });
1067
+ j--;
1068
+ } else {
1069
+ result.unshift({ type: "del", text: oldWords[i-1] });
1070
+ i--;
1071
+ }
1072
+ }
1073
+ return result;
1074
+ }
1075
+
1076
+ function renderUnifiedDiff(oldText, newText) {
1077
+ const oldLines = oldText.split("\n");
1078
+ const newLines = newText.split("\n");
1079
+
1080
+ // Build line pairs using simple LCS for line-level alignment
1081
+ const pairs = [];
1082
+ let oi = 0, ni = 0;
1083
+ while (oi < oldLines.length || ni < newLines.length) {
1084
+ if (oi < oldLines.length && ni < newLines.length && oldLines[oi] === newLines[ni]) {
1085
+ pairs.push({ type: "same", old: oldLines[oi], new: newLines[ni], oldNum: oi + 1, newNum: ni + 1 });
1086
+ oi++; ni++;
1087
+ } else if (oi < oldLines.length && ni < newLines.length) {
1088
+ pairs.push({ type: "changed", old: oldLines[oi], new: newLines[ni], oldNum: oi + 1, newNum: ni + 1 });
1089
+ oi++; ni++;
1090
+ } else if (oi < oldLines.length) {
1091
+ pairs.push({ type: "deleted", old: oldLines[oi], new: null, oldNum: oi + 1, newNum: null });
1092
+ oi++;
1093
+ } else {
1094
+ pairs.push({ type: "added", old: null, new: newLines[ni], oldNum: null, newNum: ni + 1 });
1095
+ ni++;
1096
+ }
1097
+ }
1098
+
1099
+ // Group into chunks: changed/added/deleted lines, or runs of unchanged
1100
+ let leftHtml = "";
1101
+ let rightHtml = "";
1102
+ let i = 0;
1103
+ const CONTEXT = 2; // unchanged lines to show around changes
1104
+
1105
+ while (i < pairs.length) {
1106
+ const p = pairs[i];
1107
+
1108
+ if (p.type === "same") {
1109
+ // Find run of unchanged lines
1110
+ let runStart = i;
1111
+ while (i < pairs.length && pairs[i].type === "same") i++;
1112
+ const runLen = i - runStart;
1113
+
1114
+ if (runLen > CONTEXT * 2 + 1) {
1115
+ // Show first CONTEXT, collapse middle, show last CONTEXT
1116
+ for (let j = runStart; j < runStart + CONTEXT; j++) {
1117
+ const ln = pairs[j];
1118
+ leftHtml += `<div class="diff-line diff-line-unchanged"><span class="diff-line-num">${ln.oldNum}</span><span class="diff-line-content">${escapeHtml(ln.old)}</span></div>`;
1119
+ rightHtml += `<div class="diff-line diff-line-unchanged"><span class="diff-line-num">${ln.newNum}</span><span class="diff-line-content">${escapeHtml(ln.new)}</span></div>`;
1120
+ }
1121
+ const hidden = runLen - CONTEXT * 2;
1122
+ leftHtml += `<div class="diff-collapsed">\u22EF ${hidden} unchanged lines \u22EF</div>`;
1123
+ rightHtml += `<div class="diff-collapsed">\u22EF ${hidden} unchanged lines \u22EF</div>`;
1124
+ for (let j = i - CONTEXT; j < i; j++) {
1125
+ const ln = pairs[j];
1126
+ leftHtml += `<div class="diff-line diff-line-unchanged"><span class="diff-line-num">${ln.oldNum}</span><span class="diff-line-content">${escapeHtml(ln.old)}</span></div>`;
1127
+ rightHtml += `<div class="diff-line diff-line-unchanged"><span class="diff-line-num">${ln.newNum}</span><span class="diff-line-content">${escapeHtml(ln.new)}</span></div>`;
1128
+ }
1129
+ } else {
1130
+ for (let j = runStart; j < i; j++) {
1131
+ const ln = pairs[j];
1132
+ leftHtml += `<div class="diff-line diff-line-unchanged"><span class="diff-line-num">${ln.oldNum}</span><span class="diff-line-content">${escapeHtml(ln.old)}</span></div>`;
1133
+ rightHtml += `<div class="diff-line diff-line-unchanged"><span class="diff-line-num">${ln.newNum}</span><span class="diff-line-content">${escapeHtml(ln.new)}</span></div>`;
1134
+ }
1135
+ }
1136
+ } else if (p.type === "changed") {
1137
+ const wordDiff = computeWordDiff(p.old, p.new);
1138
+ let oldWords = "", newWords = "";
1139
+ for (const op of wordDiff) {
1140
+ if (op.type === "same") {
1141
+ oldWords += escapeHtml(op.text);
1142
+ newWords += escapeHtml(op.text);
1143
+ } else if (op.type === "del") {
1144
+ oldWords += `<span class="diff-word-removed">${escapeHtml(op.text)}</span>`;
1145
+ } else {
1146
+ newWords += `<span class="diff-word-added">${escapeHtml(op.text)}</span>`;
1147
+ }
1148
+ }
1149
+ leftHtml += `<div class="diff-line diff-line-removed"><span class="diff-line-num">${p.oldNum}</span><span class="diff-line-content">${oldWords}</span></div>`;
1150
+ rightHtml += `<div class="diff-line diff-line-added"><span class="diff-line-num">${p.newNum}</span><span class="diff-line-content">${newWords}</span></div>`;
1151
+ i++;
1152
+ } else if (p.type === "deleted") {
1153
+ leftHtml += `<div class="diff-line diff-line-removed"><span class="diff-line-num">${p.oldNum}</span><span class="diff-line-content">${escapeHtml(p.old)}</span></div>`;
1154
+ rightHtml += `<div class="diff-line diff-empty-line"></div>`;
1155
+ i++;
1156
+ } else if (p.type === "added") {
1157
+ leftHtml += `<div class="diff-line diff-empty-line"></div>`;
1158
+ rightHtml += `<div class="diff-line diff-line-added"><span class="diff-line-num">${p.newNum}</span><span class="diff-line-content">${escapeHtml(p.new)}</span></div>`;
1159
+ i++;
1160
+ } else {
1161
+ i++;
1162
+ }
1163
+ }
1164
+
1165
+ return `<div class="diff-columns">
1166
+ <div class="diff-col-header diff-col-header-old">\u2212 Original</div>
1167
+ <div class="diff-col-header diff-col-header-new">+ Modified</div>
1168
+ </div>
1169
+ <div class="diff-columns" style="flex:1;overflow:hidden;">
1170
+ <div class="diff-col diff-col-old scrollbar-thin diff-sync-scroll" style="overflow-y:auto;">${leftHtml}</div>
1171
+ <div class="diff-col diff-col-new scrollbar-thin diff-sync-scroll" style="overflow-y:auto;">${rightHtml}</div>
1172
+ </div>`;
1173
+ }
1174
+
1175
+ function showDiffOverlay(editPath, editDiffs) {
1176
+ closeDiffOverlay();
1177
+
1178
+ const overlay = document.createElement("div");
1179
+ overlay.id = "diff-overlay";
1180
+ overlay.className = "diff-overlay";
1181
+ overlay.addEventListener("click", (e) => {
1182
+ if (e.target === overlay) closeDiffOverlay();
1183
+ });
1184
+
1185
+ const fileName = editPath.replace(/\\/g, "/").split("/").pop() || editPath;
1186
+
1187
+ let editsHtml = "";
1188
+ editDiffs.forEach((diff, i) => {
1189
+ const label = editDiffs.length > 1 ? `Edit ${i + 1} of ${editDiffs.length}` : "";
1190
+ const labelHtml = label ? `<div class="diff-edit-label">${escapeHtml(label)}</div>` : "";
1191
+
1192
+ editsHtml += `
1193
+ <div class="diff-edit-block" style="display:flex;flex-direction:column;">
1194
+ ${labelHtml}
1195
+ <div style="font-family:'SF Mono','Cascadia Code','Fira Code','Consolas',monospace;font-size:13px;line-height:1.6;display:flex;flex-direction:column;flex:1;">
1196
+ ${renderUnifiedDiff(diff.oldText || "", diff.newText || "")}
1197
+ </div>
1198
+ </div>
1199
+ `;
1200
+ });
1201
+
1202
+ const stats = editDiffs.reduce((acc, d) => {
1203
+ acc.removed += (d.oldText || "").split("\n").length;
1204
+ acc.added += (d.newText || "").split("\n").length;
1205
+ return acc;
1206
+ }, { removed: 0, added: 0 });
1207
+
1208
+ overlay.innerHTML = `
1209
+ <div class="diff-panel">
1210
+ <div class="diff-header">
1211
+ <div style="display:flex;align-items:center;gap:12px;">
1212
+ <span style="color:var(--accent);font-weight:600;font-size:14px;">\u270F\uFE0F ${escapeHtml(fileName)}</span>
1213
+ <span style="color:#e55;font-size:12px;font-weight:600;">\u2212${stats.removed}</span>
1214
+ <span style="color:#3b3;font-size:12px;font-weight:600;">+${stats.added}</span>
1215
+ <span style="color:var(--text-dim);font-size:12px;">${editDiffs.length} edit(s)</span>
1216
+ </div>
1217
+ <div style="display:flex;align-items:center;gap:8px;">
1218
+ <span style="color:var(--text-dim);font-size:11px;">${escapeHtml(editPath)}</span>
1219
+ <button id="diff-close-btn" style="color:var(--text-muted);font-size:22px;cursor:pointer;padding:2px 10px;border:none;background:none;border-radius:6px;line-height:1;" onmouseover="this.style.background='var(--sidebar-hover)'" onmouseout="this.style.background='none'">&times;</button>
1220
+ </div>
1221
+ </div>
1222
+ <div class="diff-body scrollbar-thin">
1223
+ ${editsHtml}
1224
+ </div>
1225
+ </div>
1226
+ `;
1227
+
1228
+ document.body.appendChild(overlay);
1229
+ document.getElementById("diff-close-btn")?.addEventListener("click", closeDiffOverlay);
1230
+
1231
+ // Sync scroll between left and right diff columns
1232
+ const syncPairs = overlay.querySelectorAll(".diff-columns");
1233
+ syncPairs.forEach(pair => {
1234
+ const cols = pair.querySelectorAll(".diff-sync-scroll");
1235
+ if (cols.length === 2) {
1236
+ let syncing = false;
1237
+ cols[0].addEventListener("scroll", () => { if (syncing) return; syncing = true; cols[1].scrollTop = cols[0].scrollTop; syncing = false; });
1238
+ cols[1].addEventListener("scroll", () => { if (syncing) return; syncing = true; cols[0].scrollTop = cols[1].scrollTop; syncing = false; });
1239
+ }
1240
+ });
1241
+
1242
+ const escHandler = (e) => {
1243
+ if (e.key === "Escape") { closeDiffOverlay(); document.removeEventListener("keydown", escHandler); }
1244
+ };
1245
+ document.addEventListener("keydown", escHandler);
1246
+ }
1247
+
1248
+ function closeDiffOverlay() {
1249
+ const el = document.getElementById("diff-overlay");
1250
+ if (el) el.remove();
1251
+ }
1252
+
1253
+ // Give each message a stable ID for DOM updates
1254
+ let msgIdCounter = 0;
1255
+ function ensureMsgId(msg) {
1256
+ if (!msg._id) msg._id = 'msg-' + (++msgIdCounter);
1257
+ return msg._id;
1258
+ }
1259
+
1260
+ function renderMessages() {
1261
+ const msgs = state.messages;
1262
+ if ((!msgs || msgs.length === 0) && !state.isStreaming) {
1263
+ messagesEl.innerHTML = `
1264
+ <div class="flex h-full items-center justify-center">
1265
+ <div class="text-center" style="color: var(--text-dim);">
1266
+ <div class="mb-2 text-3xl">&#x25C8;</div>
1267
+ <div class="text-sm">Start a conversation</div>
1268
+ <div class="mt-1 text-[12px]">Type a message below or use the terminal</div>
1269
+ </div>
1270
+ </div>
1271
+ `;
1272
+ return;
1273
+ }
1274
+
1275
+ let html = "";
1276
+ for (const msg of msgs) {
1277
+ const id = ensureMsgId(msg);
1278
+ html += `<div id="${id}">${renderMessageHtml(msg)}</div>`;
1279
+ }
1280
+
1281
+ // Show live thinking block
1282
+ if (state.isStreaming && state.isThinking && state.thinkingText) {
1283
+ html += `
1284
+ <div class="mb-3" id="thinking-msg">
1285
+ <details open class="rounded-lg border" style="border-color: var(--border);">
1286
+ <summary class="cursor-pointer px-3 py-2 text-[12px] font-medium" style="color: var(--text-muted);">
1287
+ &#x1F4AD; Thinking...
1288
+ </summary>
1289
+ <div class="border-t px-3 py-2" style="border-color: var(--border);">
1290
+ <div class="text-[12px] opacity-70 whitespace-pre-wrap" style="color:var(--text-muted); max-height: 200px; overflow-y: auto;">${escapeHtml(state.thinkingText.slice(-500))}</div>
1291
+ </div>
1292
+ </details>
1293
+ </div>
1294
+ `;
1295
+ }
1296
+
1297
+ // Show streaming message
1298
+ if (state.isStreaming && state.streamingText) {
1299
+ html += `
1300
+ <div class="mb-5" id="streaming-msg">
1301
+ <div class="message-content text-[14.5px] leading-relaxed" style="color:var(--text);">
1302
+ ${renderMarkdown(state.streamingText)}
1303
+ </div>
1304
+ <div class="mt-1 flex items-center gap-1">
1305
+ <span class="tool-spinner" style="width:10px;height:10px;"></span>
1306
+ <span class="text-[11px]" style="color: var(--text-dim);">streaming...</span>
1307
+ </div>
1308
+ </div>
1309
+ `;
1310
+ }
1311
+
1312
+ // Show waiting indicator
1313
+ if (state.isStreaming && !state.streamingText && !state.isThinking) {
1314
+ html += `
1315
+ <div class="mb-4 flex items-center gap-2 text-[13px]" id="waiting-indicator" style="color: var(--text-muted);">
1316
+ <span class="thinking-dots"><span>.</span><span>.</span><span>.</span></span>
1317
+ <span>Working...</span>
1318
+ </div>
1319
+ `;
1320
+ }
1321
+
1322
+ messagesEl.innerHTML = html;
1323
+ scrollToBottom();
1324
+ }
1325
+
1326
+ // ---- Incremental DOM updates (avoid full re-render) ----
1327
+
1328
+ // Append a single message without rebuilding everything
1329
+ function appendMessage(msg) {
1330
+ const id = ensureMsgId(msg);
1331
+ // Remove waiting/streaming indicators before appending
1332
+ removeEphemeralElements();
1333
+ const div = document.createElement('div');
1334
+ div.id = id;
1335
+ div.innerHTML = renderMessageHtml(msg);
1336
+ messagesEl.appendChild(div);
1337
+ scrollToBottom();
1338
+ }
1339
+
1340
+ // Update a tool message in-place without touching other elements
1341
+ function updateToolInPlace(toolCallId, updates) {
1342
+ const msg = state.messages.find(m => m.role === 'tool' && m.toolCallId === toolCallId);
1343
+ if (!msg) return;
1344
+ Object.assign(msg, updates);
1345
+ const el = document.getElementById(msg._id);
1346
+ if (el) {
1347
+ el.innerHTML = renderMessageHtml(msg);
1348
+ }
1349
+ }
1350
+
1351
+ // Remove ephemeral elements (streaming msg, thinking msg, waiting indicator)
1352
+ function removeEphemeralElements() {
1353
+ for (const id of ['streaming-msg', 'thinking-msg', 'waiting-indicator']) {
1354
+ const el = document.getElementById(id);
1355
+ if (el) el.remove();
1356
+ }
1357
+ }
1358
+
1359
+ // Add or update the streaming message without full re-render
1360
+ function showStreamingBlock() {
1361
+ removeEphemeralElements();
1362
+ if (state.streamingText) {
1363
+ const div = document.createElement('div');
1364
+ div.id = 'streaming-msg';
1365
+ div.className = 'mb-5';
1366
+ div.innerHTML = `
1367
+ <div class="message-content text-[14.5px] leading-relaxed" style="color:var(--text);">
1368
+ ${renderMarkdown(state.streamingText)}
1369
+ </div>
1370
+ <div class="mt-1 flex items-center gap-1">
1371
+ <span class="tool-spinner" style="width:10px;height:10px;"></span>
1372
+ <span class="text-[11px]" style="color: var(--text-dim);">streaming...</span>
1373
+ </div>
1374
+ `;
1375
+ messagesEl.appendChild(div);
1376
+ scrollToBottom();
1377
+ }
1378
+ }
1379
+
1380
+ function showWaitingIndicator() {
1381
+ if (document.getElementById('waiting-indicator')) return;
1382
+ removeEphemeralElements();
1383
+ const div = document.createElement('div');
1384
+ div.id = 'waiting-indicator';
1385
+ div.className = 'mb-4 flex items-center gap-2 text-[13px]';
1386
+ div.style.color = 'var(--text-muted)';
1387
+ div.innerHTML = '<span class="thinking-dots"><span>.</span><span>.</span><span>.</span></span><span>Working...</span>';
1388
+ messagesEl.appendChild(div);
1389
+ scrollToBottom();
1390
+ }
1391
+
1392
+ // ─── Skills & Extensions View ─────────────────────────────────
1393
+
1394
+ function renderSkillsView() {
1395
+ const skills = data.skills || [];
1396
+ const extensions = data.extensions || [];
1397
+ let html = `<div class="space-y-2 p-2">`;
1398
+ html += `<div class="flex items-center justify-between px-2 pb-2">
1399
+ <h3 class="text-sm font-semibold">${skills.length} Skills Available</h3>
1400
+ <button id="btn-refresh-skills" class="text-[12px] px-2 py-1 rounded hover:bg-pi-sidebar-hover" style="color:var(--accent);">Refresh</button>
1401
+ </div>`;
1402
+ for (const s of skills) {
1403
+ html += `
1404
+ <div class="skill-card rounded-lg border p-3 cursor-pointer hover:bg-pi-sidebar-hover" style="border-color: var(--border); transition: background 0.1s;" data-skill="${escapeHtml(s.name)}">
1405
+ <div class="flex items-center gap-2">
1406
+ <span>\uD83E\uDDE9</span>
1407
+ <span class="font-semibold text-[14px]">${escapeHtml(s.name)}</span>
1408
+ </div>
1409
+ <div class="mt-1 text-[13px]" style="color: var(--text-muted);">${escapeHtml(s.desc || "No description")}</div>
1410
+ </div>
1411
+ `;
1412
+ }
1413
+
1414
+ // Extensions section
1415
+ if (extensions.length > 0) {
1416
+ html += `<div class="px-2 pt-4 pb-2">
1417
+ <h3 class="text-sm font-semibold">${extensions.length} Extensions Loaded</h3>
1418
+ </div>`;
1419
+ for (const ext of extensions) {
1420
+ const typeLabel = ext.type === "builtin" ? "built-in" : ext.type === "package" ? "package" : "local";
1421
+ const typeColor = ext.type === "builtin" ? "var(--accent)" : ext.type === "package" ? "var(--text-muted)" : "var(--text-muted)";
1422
+ html += `
1423
+ <div class="rounded-lg border p-3" style="border-color: var(--border); transition: background 0.1s;">
1424
+ <div class="flex items-center gap-2">
1425
+ <span style="color: var(--accent);">\u2699</span>
1426
+ <span class="font-semibold text-[14px]">${escapeHtml(ext.name)}</span>
1427
+ <span class="text-[11px] px-1.5 py-0.5 rounded" style="background: color-mix(in srgb, ${typeColor} 15%, transparent); color: ${typeColor};">${typeLabel}</span>
1428
+ </div>
1429
+ <div class="mt-1 text-[13px]" style="color: var(--text-muted);">${escapeHtml(ext.source)}</div>
1430
+ </div>
1431
+ `;
1432
+ }
1433
+ }
1434
+
1435
+ html += `</div>`;
1436
+ messagesEl.innerHTML = html;
1437
+
1438
+ // Skill click handler: ask pi to load the skill
1439
+ messagesEl.querySelectorAll("[data-skill]").forEach(card => {
1440
+ card.addEventListener("click", () => {
1441
+ const skillName = card.dataset.skill;
1442
+ send({ type: "send-message", text: `Load the ${skillName} skill and tell me what it does.` });
1443
+ setActiveNav("threads");
1444
+ state.activeThreadIdx = -1;
1445
+ state.viewingOldThread = false;
1446
+ });
1447
+ });
1448
+
1449
+ const refreshBtn = document.getElementById("btn-refresh-skills");
1450
+ if (refreshBtn) refreshBtn.addEventListener("click", () => send({ type: "refresh-skills" }));
1451
+ }
1452
+
1453
+ // ─── Settings View ────────────────────────────────────────────
1454
+
1455
+ function renderSettingsView() {
1456
+ const items = [
1457
+ ["Model", data.model],
1458
+ ["Thinking", data.thinkingLevel],
1459
+ ["Directory", data.cwd],
1460
+ ["Git Branch", data.gitBranch || "none"],
1461
+ ["Provider", data.provider || "unknown"],
1462
+ ];
1463
+ let html = `<div class="mx-auto max-w-xl space-y-4 p-4">
1464
+ <h2 class="text-lg font-semibold">Settings</h2>
1465
+ <div class="rounded-lg border overflow-hidden" style="border-color: var(--border);">`;
1466
+ for (const [label, value] of items) {
1467
+ html += `
1468
+ <div class="flex items-center justify-between border-b px-4 py-3" style="border-color: var(--border);">
1469
+ <span class="text-[13px]" style="color: var(--text-muted);">${escapeHtml(label)}</span>
1470
+ <span class="text-[13px] font-medium" style="color:var(--accent);">${escapeHtml(value || "—")}</span>
1471
+ </div>
1472
+ `;
1473
+ }
1474
+ html += `</div>`;
1475
+
1476
+ // Available commands
1477
+ html += `<h3 class="text-sm font-semibold mt-6">Available Commands</h3>
1478
+ <div class="rounded-lg border overflow-hidden" style="border-color: var(--border);">`;
1479
+ const cmds = state.commands.length ? state.commands : data.commands || [];
1480
+ for (const cmd of cmds.slice(0, 30)) {
1481
+ html += `
1482
+ <div class="flex items-center justify-between border-b px-4 py-2 cursor-pointer hover:bg-pi-sidebar-hover" style="border-color: var(--border);" data-cmd="/${escapeHtml(cmd.name)}">
1483
+ <span class="text-[13px] font-mono" style="color:var(--accent);">/${escapeHtml(cmd.name)}</span>
1484
+ <span class="text-[12px]" style="color: var(--text-muted);">${escapeHtml(truncate(cmd.description, 50))}</span>
1485
+ </div>
1486
+ `;
1487
+ }
1488
+ html += `</div></div>`;
1489
+ messagesEl.innerHTML = html;
1490
+
1491
+ // Click a command to type it
1492
+ messagesEl.querySelectorAll("[data-cmd]").forEach(el => {
1493
+ el.addEventListener("click", () => {
1494
+ inputTextEl.value = el.dataset.cmd;
1495
+ inputTextEl.focus();
1496
+ setActiveNav("threads");
1497
+ state.activeThreadIdx = -1;
1498
+ state.viewingOldThread = false;
1499
+ });
1500
+ });
1501
+ }
1502
+
1503
+ // ─── Workspace View ───────────────────────────────────────────
1504
+
1505
+ function renderWorkspaceView() {
1506
+ const s = data.stats || {};
1507
+ const html = `<div class="mx-auto max-w-2xl p-4">
1508
+ <h2 class="text-lg font-semibold mb-4">\u25C8 ${escapeHtml(data.projectName)}</h2>
1509
+ <div class="grid grid-cols-4 gap-3 mb-6">
1510
+ ${[
1511
+ [fmt(s.input || 0), "Input"],
1512
+ [fmt(s.output || 0), "Output"],
1513
+ [fmt(s.cache || 0), "Cache"],
1514
+ ["$" + (s.cost || 0).toFixed(4), "Cost"],
1515
+ ].map(([val, label]) => `
1516
+ <div class="rounded-lg border p-4 text-center" style="border-color: var(--border);">
1517
+ <div class="text-xl font-bold" style="color:var(--accent);">${val}</div>
1518
+ <div class="text-[12px] mt-1" style="color: var(--text-dim);">${label}</div>
1519
+ </div>
1520
+ `).join("")}
1521
+ </div>
1522
+ <div class="rounded-lg border p-4" style="border-color: var(--border);">
1523
+ <div class="text-sm font-semibold mb-3">Session Info</div>
1524
+ <div class="grid grid-cols-2 gap-2 text-[13px]">
1525
+ <div style="color:var(--text-muted);">Directory</div><div>${escapeHtml(data.cwd)}</div>
1526
+ <div style="color:var(--text-muted);">Model</div><div style="color:var(--accent);">${escapeHtml(data.model)}</div>
1527
+ <div style="color:var(--text-muted);">Git Branch</div><div style="color:var(--accent);">${escapeHtml(data.gitBranch || "none")}</div>
1528
+ <div style="color:var(--text-muted);">Thinking</div><div>${escapeHtml(data.thinkingLevel)}</div>
1529
+ <div style="color:var(--text-muted);">Messages</div><div>${state.messages.length}</div>
1530
+ <div style="color:var(--text-muted);">Threads</div><div>${(data.threads || []).length}</div>
1531
+ </div>
1532
+ </div>
1533
+ </div>`;
1534
+ messagesEl.innerHTML = html;
1535
+ }
1536
+
1537
+ // ─── Workspace Modal ──────────────────────────────────────────
1538
+
1539
+ function showWorkspaceModal() {
1540
+ // Request fresh workspace list
1541
+ send({ type: "get-workspaces" });
1542
+ state.showWorkspaceModal = true;
1543
+ renderWorkspaceModal();
1544
+ }
1545
+
1546
+ function renderWorkspaceModal() {
1547
+ // Remove existing modal
1548
+ const existing = document.getElementById("workspace-modal");
1549
+ if (existing) existing.remove();
1550
+
1551
+ if (!state.showWorkspaceModal) return;
1552
+
1553
+ const workspaces = data.workspaces || [];
1554
+
1555
+ let wsListHtml = "";
1556
+ for (const ws of workspaces) {
1557
+ const isCurrent = ws.path === data.cwd;
1558
+ wsListHtml += `
1559
+ <button class="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left hover:bg-pi-sidebar-hover" style="transition: background 0.1s;" data-ws-add="${escapeHtml(ws.dirName)}" data-ws-path="${escapeHtml(ws.path)}">
1560
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:var(--accent);">
1561
+ <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
1562
+ </svg>
1563
+ <div class="min-w-0 flex-1">
1564
+ <div class="font-medium text-[14px]" style="color:var(--text);">${escapeHtml(ws.name)}${isCurrent ? ' <span style="color:var(--accent);font-size:12px;">(current)</span>' : ''}</div>
1565
+ <div class="text-[12px] truncate" style="color:var(--text-dim);">${escapeHtml(ws.path)}</div>
1566
+ </div>
1567
+ <div class="text-[12px] flex-shrink-0" style="color:var(--text-dim);">${ws.sessionCount} sessions</div>
1568
+ </button>
1569
+ `;
1570
+ }
1571
+
1572
+ const modalHtml = `
1573
+ <div id="workspace-modal" style="position:fixed;inset:0;z-index:100;display:flex;align-items:flex-start;justify-content:center;padding-top:80px;background:rgba(0,0,0,0.5);">
1574
+ <div style="background:var(--bg);border:1px solid var(--border);border-radius:12px;width:560px;max-height:70vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);">
1575
+ <div style="padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;">
1576
+ <div class="font-semibold text-[15px]" style="color:var(--text);">Open Workspace</div>
1577
+ <button id="ws-modal-close" style="color:var(--text-muted);font-size:20px;cursor:pointer;padding:2px 8px;border:none;background:none;border-radius:6px;line-height:1;" onmouseover="this.style.background='var(--sidebar-hover)'" onmouseout="this.style.background='none'">&times;</button>
1578
+ </div>
1579
+ <div style="padding:12px 20px;border-bottom:1px solid var(--border);">
1580
+ <div class="text-[12px] font-medium" style="color:var(--text-muted);margin-bottom:6px;">Open folder path</div>
1581
+ <div style="display:flex;gap:8px;">
1582
+ <input id="ws-path-input" type="text" placeholder="Enter or paste folder path..." style="flex:1;padding:8px 12px;border-radius:8px;border:1px solid var(--border);background:var(--input-bg, var(--sidebar-bg));color:var(--text);font-size:13px;outline:none;" />
1583
+ <button id="ws-path-open" style="padding:8px 16px;border-radius:8px;border:none;background:var(--accent);color:var(--bg);font-size:13px;font-weight:600;cursor:pointer;">Open</button>
1584
+ </div>
1585
+ </div>
1586
+ <div style="padding:8px 20px 4px;">
1587
+ <div class="text-[12px] font-medium" style="color:var(--text-muted);">Recent Workspaces</div>
1588
+ </div>
1589
+ <div style="overflow-y:auto;flex:1;padding:4px 12px 12px;">
1590
+ ${wsListHtml || '<div style="padding:20px;text-align:center;color:var(--text-dim);">No workspaces found</div>'}
1591
+ </div>
1592
+ </div>
1593
+ </div>
1594
+ `;
1595
+
1596
+ document.body.insertAdjacentHTML("beforeend", modalHtml);
1597
+
1598
+ // Close modal
1599
+ document.getElementById("ws-modal-close").addEventListener("click", closeWorkspaceModal);
1600
+ document.getElementById("workspace-modal").addEventListener("click", (e) => {
1601
+ if (e.target.id === "workspace-modal") closeWorkspaceModal();
1602
+ });
1603
+
1604
+ // Workspace click — add to sidebar expanded
1605
+ document.querySelectorAll("[data-ws-add]").forEach(btn => {
1606
+ btn.addEventListener("click", () => {
1607
+ const dirName = btn.dataset.wsAdd;
1608
+ state.expandedWorkspaces[dirName] = true;
1609
+ if (!state.workspaceSessions[dirName]) {
1610
+ send({ type: "get-workspace-sessions", dirName });
1611
+ }
1612
+ closeWorkspaceModal();
1613
+ renderProjectTree();
1614
+ });
1615
+ });
1616
+
1617
+ // Open folder path input
1618
+ const pathInput = document.getElementById("ws-path-input");
1619
+ const pathOpenBtn = document.getElementById("ws-path-open");
1620
+ if (pathInput) pathInput.focus();
1621
+
1622
+ function openFolderPath() {
1623
+ const path = (pathInput?.value || "").trim();
1624
+ if (!path) return;
1625
+ send({ type: "open-folder-path", path });
1626
+ closeWorkspaceModal();
1627
+ }
1628
+
1629
+ if (pathOpenBtn) pathOpenBtn.addEventListener("click", openFolderPath);
1630
+ if (pathInput) pathInput.addEventListener("keydown", (e) => {
1631
+ if (e.key === "Enter") openFolderPath();
1632
+ if (e.key === "Escape") { e.stopPropagation(); closeWorkspaceModal(); }
1633
+ });
1634
+ }
1635
+
1636
+ function closeWorkspaceModal() {
1637
+ state.showWorkspaceModal = false;
1638
+ const modal = document.getElementById("workspace-modal");
1639
+ if (modal) modal.remove();
1640
+ }
1641
+
1642
+ // ─── Explorer Sidebar Tree ───────────────────────────────────
1643
+
1644
+ function renderExplorerTree() {
1645
+ if (!explorerTreeEl) return;
1646
+ const rootPath = state.explorerTreeRoot;
1647
+ // Back button to return to threads
1648
+ let html = `<div class="flex items-center gap-1.5 px-2 py-1.5 mb-1 cursor-pointer hover:bg-pi-sidebar-hover rounded text-[12px]" id="explorer-tree-back" style="color:var(--text-muted);">
1649
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
1650
+ <span>Threads</span>
1651
+ </div>`;
1652
+ if (!rootPath) {
1653
+ html += `<div class="py-4 text-center text-[12px]" style="color:var(--text-dim);">Loading…</div>`;
1654
+ explorerTreeEl.innerHTML = html;
1655
+ attachExplorerTreeBack();
1656
+ return;
1657
+ }
1658
+ const rootChildren = state.explorerTreeChildren[rootPath] || [];
1659
+ if (rootChildren.length === 0) {
1660
+ html += `<div class="py-4 text-center text-[12px]" style="color:var(--text-dim);">No files found.</div>`;
1661
+ explorerTreeEl.innerHTML = html;
1662
+ attachExplorerTreeBack();
1663
+ return;
1664
+ }
1665
+ html += renderTreeNodes(rootChildren, 0);
1666
+ explorerTreeEl.innerHTML = html;
1667
+ attachExplorerTreeBack();
1668
+ attachTreeListeners(explorerTreeEl);
1669
+ }
1670
+
1671
+ function attachExplorerTreeBack() {
1672
+ document.getElementById("explorer-tree-back")?.addEventListener("click", () => {
1673
+ setActiveNav("threads");
1674
+ });
1675
+ }
1676
+
1677
+ function renderTreeNodes(entries, depth) {
1678
+ let html = "";
1679
+ for (const f of entries) {
1680
+ const indent = depth * 16;
1681
+ const isExpanded = !!state.explorerTreeExpanded[f.path];
1682
+ if (f.isDir) {
1683
+ const chevron = isExpanded
1684
+ ? `<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" style="flex-shrink:0;"><path d="M7 10l5 5 5-5z"/></svg>`
1685
+ : `<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" style="flex-shrink:0;"><path d="M10 7l5 5-5 5z"/></svg>`;
1686
+ html += `<div class="flex items-center gap-1 cursor-pointer hover:bg-pi-sidebar-hover rounded px-1 py-[2px] text-[12px]"
1687
+ style="padding-left:${indent + 4}px;" data-tree-dir="${escapeHtml(f.path)}">
1688
+ ${chevron}
1689
+ <span style="color:var(--accent);flex-shrink:0;">📁</span>
1690
+ <span class="truncate" style="color:var(--text);">${escapeHtml(f.name)}</span>
1691
+ </div>`;
1692
+ if (isExpanded) {
1693
+ const children = state.explorerTreeChildren[f.path] || [];
1694
+ if (children.length > 0) {
1695
+ html += renderTreeNodes(children, depth + 1);
1696
+ } else {
1697
+ html += `<div class="text-[11px]" style="padding-left:${indent + 24}px;color:var(--text-dim);">Loading…</div>`;
1698
+ }
1699
+ }
1700
+ } else {
1701
+ html += `<div class="flex items-center gap-1 cursor-pointer hover:bg-pi-sidebar-hover rounded px-1 py-[2px] text-[12px]"
1702
+ style="padding-left:${indent + 4}px;" data-tree-file="${escapeHtml(f.path)}">
1703
+ <span style="flex-shrink:0;width:10px;"></span>
1704
+ <span style="flex-shrink:0;">📄</span>
1705
+ <span class="truncate" style="color:var(--text-muted);">${escapeHtml(f.name)}</span>
1706
+ </div>`;
1707
+ }
1708
+ }
1709
+ return html;
1710
+ }
1711
+
1712
+ function attachTreeListeners(container) {
1713
+ container.querySelectorAll("[data-tree-dir]").forEach(el => {
1714
+ el.addEventListener("click", () => {
1715
+ const dirPath = el.dataset.treeDir;
1716
+ if (state.explorerTreeExpanded[dirPath]) {
1717
+ delete state.explorerTreeExpanded[dirPath];
1718
+ renderExplorerTree();
1719
+ } else {
1720
+ state.explorerTreeExpanded[dirPath] = true;
1721
+ if (!state.explorerTreeChildren[dirPath]) {
1722
+ send({ type: "explorer-tree-expand", path: dirPath });
1723
+ }
1724
+ renderExplorerTree();
1725
+ }
1726
+ });
1727
+ });
1728
+ container.querySelectorAll("[data-tree-file]").forEach(el => {
1729
+ el.addEventListener("click", () => {
1730
+ send({ type: "explorer-open", path: el.dataset.treeFile });
1731
+ });
1732
+ });
1733
+ }
1734
+
1735
+ // ─── Explorer View ────────────────────────────────────────────
1736
+
1737
+ function renderExplorerView() {
1738
+ // If viewing a file, show the file viewer instead
1739
+ if (state.viewingFile) {
1740
+ renderFileViewer();
1741
+ return;
1742
+ }
1743
+ const files = data.explorerFiles || [];
1744
+ let html = `<div class="p-2">`;
1745
+ if (files.length === 0) {
1746
+ html += `<div class="py-8 text-center text-sm" style="color: var(--text-dim);">No files loaded.</div>`;
1747
+ }
1748
+ for (const f of files) {
1749
+ const icon = f.isDir ? "\uD83D\uDCC1" : "\uD83D\uDCC4";
1750
+ html += `
1751
+ <button class="flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-left text-[13px] hover:bg-pi-sidebar-hover"
1752
+ data-explorer-path="${escapeHtml(f.path || "")}">
1753
+ <span>${icon}</span>
1754
+ <span class="${f.isDir ? "font-medium" : ""}">${escapeHtml(f.name)}</span>
1755
+ ${!f.isDir && f.size ? `<span class="ml-auto text-[11px]" style="color: var(--text-dim);">${f.size}</span>` : ""}
1756
+ ${f.isDir ? `<span class="ml-auto" style="color: var(--text-dim);">▸</span>` : ""}
1757
+ </button>
1758
+ `;
1759
+ }
1760
+ html += `</div>`;
1761
+ messagesEl.innerHTML = html;
1762
+
1763
+ messagesEl.querySelectorAll("[data-explorer-path]").forEach(btn => {
1764
+ btn.addEventListener("click", () => {
1765
+ send({ type: "explorer-open", path: btn.dataset.explorerPath });
1766
+ });
1767
+ });
1768
+ }
1769
+
1770
+ // ─── File Viewer ──────────────────────────────────────────────
1771
+
1772
+ const EXT_TO_LANG = {
1773
+ js: "javascript", ts: "typescript", jsx: "javascript", tsx: "typescript",
1774
+ py: "python", rb: "ruby", rs: "rust", go: "go", java: "java",
1775
+ c: "c", cpp: "cpp", h: "c", hpp: "cpp", cs: "csharp",
1776
+ html: "xml", htm: "xml", xml: "xml", svg: "xml",
1777
+ css: "css", scss: "scss", less: "less",
1778
+ json: "json", yaml: "yaml", yml: "yaml", toml: "ini",
1779
+ md: "markdown", sh: "bash", bash: "bash", zsh: "bash",
1780
+ sql: "sql", php: "php", swift: "swift", kt: "kotlin",
1781
+ lua: "lua", r: "r", pl: "perl", dockerfile: "dockerfile",
1782
+ makefile: "makefile", cmake: "cmake",
1783
+ };
1784
+
1785
+ function renderFileViewer() {
1786
+ const f = state.viewingFile;
1787
+ if (!f) return;
1788
+
1789
+ const fileName = f.name || f.path.replace(/\\/g, "/").split("/").pop();
1790
+ const fileSize = f.size ? (f.size < 1024 ? f.size + " B" : (f.size / 1024).toFixed(1) + " KB") : "";
1791
+
1792
+ let html = `<div style="display:flex;flex-direction:column;height:100%;">`;
1793
+
1794
+ // Header bar with back button, file name, size
1795
+ html += `
1796
+ <div style="display:flex;align-items:center;gap:10px;padding:10px 16px;border-bottom:1px solid var(--border);flex-shrink:0;">
1797
+ <button id="file-viewer-back" style="background:none;border:1px solid var(--border);border-radius:6px;padding:4px 10px;cursor:pointer;color:var(--text-muted);font-size:13px;display:flex;align-items:center;gap:4px;" title="Back to file list">
1798
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
1799
+ Back
1800
+ </button>
1801
+ <span style="font-size:14px;font-weight:600;color:var(--text);">📄 ${escapeHtml(fileName)}</span>
1802
+ <span style="font-size:12px;color:var(--text-dim);margin-left:auto;">${escapeHtml(fileSize)}</span>
1803
+ <span style="font-size:11px;color:var(--text-dim);font-family:monospace;">${escapeHtml(f.path || "")}</span>
1804
+ </div>
1805
+ `;
1806
+
1807
+ if (f.error) {
1808
+ html += `<div style="padding:32px;text-align:center;color:var(--text-muted);font-size:14px;">${escapeHtml(f.error)}</div>`;
1809
+ } else if (f.content != null) {
1810
+ // Detect language from extension
1811
+ const lang = EXT_TO_LANG[(f.ext || "").toLowerCase()] || "";
1812
+ let codeHtml = escapeHtml(f.content);
1813
+
1814
+ // Syntax highlight if hljs supports the language
1815
+ if (typeof hljs !== "undefined" && lang && hljs.getLanguage(lang)) {
1816
+ try {
1817
+ codeHtml = hljs.highlight(f.content, { language: lang }).value;
1818
+ } catch (e) { /* fallback to escaped */ }
1819
+ }
1820
+
1821
+ // Line numbers + code
1822
+ const lines = f.content.split("\n");
1823
+ const lineCount = lines.length;
1824
+ const gutterWidth = String(lineCount).length * 9 + 16;
1825
+
1826
+ html += `
1827
+ <div style="flex:1;overflow:auto;" class="scrollbar-thin">
1828
+ <div style="display:flex;font-family:'SF Mono','Cascadia Code','Fira Code','Consolas',monospace;font-size:13px;line-height:1.6;">
1829
+ <div style="flex-shrink:0;width:${gutterWidth}px;text-align:right;padding:12px 8px 12px 12px;color:var(--text-dim);user-select:none;border-right:1px solid var(--border);">
1830
+ ${Array.from({length: lineCount}, (_, i) => `<div>${i + 1}</div>`).join("")}
1831
+ </div>
1832
+ <pre style="flex:1;margin:0;padding:12px 16px;overflow-x:auto;"><code class="${lang ? 'language-' + lang + ' hljs' : ''}" style="white-space:pre;">${codeHtml}</code></pre>
1833
+ </div>
1834
+ </div>
1835
+ `;
1836
+ } else {
1837
+ html += `<div style="padding:32px;text-align:center;color:var(--text-muted);font-size:14px;">Unable to load file content.</div>`;
1838
+ }
1839
+
1840
+ html += `</div>`;
1841
+ messagesEl.innerHTML = html;
1842
+
1843
+ // Back button handler
1844
+ document.getElementById("file-viewer-back")?.addEventListener("click", () => {
1845
+ state.viewingFile = null;
1846
+ renderExplorerView();
1847
+ });
1848
+ }
1849
+
1850
+ // ─── Main Content Router ──────────────────────────────────────
1851
+
1852
+ function renderMainContent() {
1853
+ renderBreadcrumb();
1854
+ renderProjectTree();
1855
+
1856
+ // Toggle explorer tree visibility in sidebar (inline after Explorer button)
1857
+ if (state.activeView === "explorer") {
1858
+ explorerTreeEl.style.display = "";
1859
+ renderExplorerTree();
1860
+ } else {
1861
+ explorerTreeEl.style.display = "none";
1862
+ }
1863
+
1864
+ switch (state.activeView) {
1865
+ case "threads": {
1866
+ const thread = state.activeThreadIdx >= 0 ? data.threads?.[state.activeThreadIdx] : null;
1867
+ threadLabelEl.textContent = `${(data.projectName || "").toUpperCase()} \u00b7 ${(data.gitBranch || "LOCAL").toUpperCase()}`;
1868
+ threadTitleEl.textContent = thread ? truncate(thread.name, 100) : "Current session";
1869
+ threadHeaderEl.style.display = "";
1870
+ renderMessages();
1871
+ break;
1872
+ }
1873
+ case "skills":
1874
+ threadHeaderEl.style.display = "none";
1875
+ renderSkillsView();
1876
+ break;
1877
+ case "settings":
1878
+ threadHeaderEl.style.display = "none";
1879
+ renderSettingsView();
1880
+ break;
1881
+ case "workspace":
1882
+ threadHeaderEl.style.display = "none";
1883
+ renderWorkspaceView();
1884
+ break;
1885
+ case "explorer":
1886
+ threadHeaderEl.style.display = "none";
1887
+ renderExplorerView();
1888
+ break;
1889
+ }
1890
+ }
1891
+
1892
+ // ─── Diff Click Delegation ────────────────────────────────────
1893
+
1894
+ messagesEl.addEventListener("click", (e) => {
1895
+ const diffArea = e.target.closest("[id^='diff-inline-']");
1896
+ if (!diffArea) return;
1897
+ const msgId = diffArea.id.replace("diff-inline-", "");
1898
+ const msg = state.messages.find(m => m._id === msgId);
1899
+ if (msg && msg.editDiffs && msg.editDiffs.length > 0) {
1900
+ e.preventDefault();
1901
+ e.stopPropagation();
1902
+ showDiffOverlay(msg.editPath || msg.argsDisplay || "", msg.editDiffs);
1903
+ }
1904
+ });
1905
+
1906
+ // ─── Stats Bar ────────────────────────────────────────────────
1907
+
1908
+ function renderStats() {
1909
+ const s = data.stats || {};
1910
+ statsBarEl.innerHTML = `
1911
+ <span>In ${fmt(s.input || 0)}</span>
1912
+ <span>Out ${fmt(s.output || 0)}</span>
1913
+ <span>Cache ${fmt(s.cache || 0)}</span>
1914
+ <span>Total ${fmt((s.input || 0) + (s.output || 0))}</span>
1915
+ <span>$${(s.cost || 0).toFixed(4)}</span>
1916
+ `;
1917
+ modelLabelEl.textContent = data.model || "unknown";
1918
+ thinkingLabelEl.textContent = data.thinkingLevel || "medium";
1919
+ }
1920
+
1921
+ // ─── Input Handling ───────────────────────────────────────────
1922
+
1923
+ function autoResizeInput() {
1924
+ inputTextEl.style.height = "auto";
1925
+ inputTextEl.style.height = Math.min(inputTextEl.scrollHeight, 120) + "px";
1926
+ }
1927
+
1928
+ inputTextEl.addEventListener("input", () => {
1929
+ autoResizeInput();
1930
+ updateCommandSuggestions();
1931
+ });
1932
+
1933
+ inputTextEl.addEventListener("keydown", (e) => {
1934
+ // Handle command suggestion navigation — only when popup is actually visible
1935
+ const suggestionsEl = document.getElementById("cmd-suggestions");
1936
+ const suggestionsVisible = suggestionsEl && suggestionsEl.style.display !== "none" && suggestionsEl.children.length > 0;
1937
+
1938
+ if (suggestionsVisible) {
1939
+ const items = suggestionsEl.querySelectorAll("[data-cmd-suggestion]");
1940
+ let activeIdx = -1;
1941
+ items.forEach((item, i) => { if (item.classList.contains("cmd-active")) activeIdx = i; });
1942
+
1943
+ if (e.key === "ArrowDown") {
1944
+ e.preventDefault();
1945
+ const next = Math.min(activeIdx + 1, items.length - 1);
1946
+ items.forEach((item, i) => item.classList.toggle("cmd-active", i === next));
1947
+ return;
1948
+ }
1949
+ if (e.key === "ArrowUp") {
1950
+ e.preventDefault();
1951
+ const prev = Math.max(activeIdx - 1, 0);
1952
+ items.forEach((item, i) => item.classList.toggle("cmd-active", i === prev));
1953
+ return;
1954
+ }
1955
+ if (e.key === "Tab") {
1956
+ e.preventDefault();
1957
+ const selected = items[Math.max(activeIdx, 0)];
1958
+ if (selected) {
1959
+ inputTextEl.value = "/" + selected.dataset.cmdSuggestion + " ";
1960
+ hideCommandSuggestions();
1961
+ autoResizeInput();
1962
+ }
1963
+ return;
1964
+ }
1965
+ if (e.key === "Escape") {
1966
+ e.preventDefault();
1967
+ hideCommandSuggestions();
1968
+ return;
1969
+ }
1970
+ // Enter always sends — don't intercept it for selection
1971
+ }
1972
+
1973
+ if (e.key === "Enter" && !e.shiftKey) {
1974
+ e.preventDefault();
1975
+ hideCommandSuggestions();
1976
+ sendMessage();
1977
+ }
1978
+
1979
+ // Escape cancels streaming when not in suggestions
1980
+ if (e.key === "Escape" && !suggestionsVisible && state.isStreaming) {
1981
+ e.preventDefault();
1982
+ cancelStreaming();
1983
+ }
1984
+ });
1985
+
1986
+ // Global Escape / Ctrl+C to cancel streaming
1987
+ document.addEventListener("keydown", function(e) {
1988
+ if (e.key === "Escape" && state.isStreaming) {
1989
+ e.preventDefault();
1990
+ cancelStreaming();
1991
+ }
1992
+ if (e.key === "c" && e.ctrlKey && state.isStreaming) {
1993
+ e.preventDefault();
1994
+ cancelStreaming();
1995
+ }
1996
+ });
1997
+
1998
+ btnSend.addEventListener("click", function() {
1999
+ if (state.isStreaming) { cancelStreaming(); } else { sendMessage(); }
2000
+ });
2001
+
2002
+ // ─── Attach Button ─────────────────────────────────────────────
2003
+
2004
+ const btnAttach = document.getElementById("btn-attach");
2005
+ const pendingAttachments = [];
2006
+
2007
+ function renderAttachmentPills() {
2008
+ let container = document.getElementById("attachment-pills");
2009
+ if (!container) {
2010
+ container = document.createElement("div");
2011
+ container.id = "attachment-pills";
2012
+ container.className = "flex flex-wrap gap-1.5 px-1 pb-2";
2013
+ const inputWrapper = inputTextEl.closest(".rounded-xl");
2014
+ if (inputWrapper) inputWrapper.parentNode.insertBefore(container, inputWrapper);
2015
+ }
2016
+ if (pendingAttachments.length === 0) {
2017
+ container.innerHTML = "";
2018
+ container.style.display = "none";
2019
+ return;
2020
+ }
2021
+ container.style.display = "flex";
2022
+ container.innerHTML = pendingAttachments.map((att, i) => {
2023
+ const isImage = att.mimeType.startsWith("image/");
2024
+ const icon = isImage ? "\uD83D\uDDBC\uFE0F" : "\uD83D\uDCCE";
2025
+ return `<span class="inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-[12px]" style="border-color:var(--border);background:var(--card-bg);">
2026
+ <span>${icon}</span>
2027
+ <span class="max-w-[120px] truncate">${escapeHtml(att.name)}</span>
2028
+ <button class="ml-0.5 hover:opacity-70" data-remove-attach="${i}" title="Remove">&times;</button>
2029
+ </span>`;
2030
+ }).join("");
2031
+ container.querySelectorAll("[data-remove-attach]").forEach(btn => {
2032
+ btn.addEventListener("click", (e) => {
2033
+ e.stopPropagation();
2034
+ pendingAttachments.splice(parseInt(btn.dataset.removeAttach), 1);
2035
+ renderAttachmentPills();
2036
+ });
2037
+ });
2038
+ }
2039
+
2040
+ function handleAttachedFileAck(path, name, error) {
2041
+ if (error || !path) {
2042
+ // Show error notification
2043
+ return;
2044
+ }
2045
+ pendingAttachments.push({ path, name, mimeType: "" });
2046
+ renderAttachmentPills();
2047
+ inputTextEl.focus();
2048
+ }
2049
+
2050
+ if (btnAttach) {
2051
+ btnAttach.addEventListener("click", () => {
2052
+ const fileInput = document.createElement("input");
2053
+ fileInput.type = "file";
2054
+ fileInput.multiple = true;
2055
+ fileInput.accept = "image/*,.txt,.md,.js,.ts,.py,.json,.yaml,.yml,.toml,.csv,.log,.xml,.html,.css,.sh,.bat,.ps1,.go,.rs,.c,.cpp,.h,.java,.rb,.php,.sql,.pdf,.docx,.xlsx";
2056
+ fileInput.addEventListener("change", () => {
2057
+ if (!fileInput.files || fileInput.files.length === 0) return;
2058
+ for (const file of fileInput.files) {
2059
+ const reader = new FileReader();
2060
+ reader.onload = () => {
2061
+ const base64 = reader.result.split(",")[1];
2062
+ send({
2063
+ type: "attach-file",
2064
+ name: file.name,
2065
+ mimeType: file.type || "application/octet-stream",
2066
+ base64,
2067
+ });
2068
+ };
2069
+ reader.readAsDataURL(file);
2070
+ }
2071
+ });
2072
+ fileInput.click();
2073
+ });
2074
+ }
2075
+
2076
+ // ─── Image Paste (Ctrl+V / Cmd+V) ─────────────────────────────
2077
+
2078
+ document.addEventListener("paste", (e) => {
2079
+ const items = e.clipboardData?.items;
2080
+ if (!items) return;
2081
+ for (const item of items) {
2082
+ if (item.type.startsWith("image/")) {
2083
+ e.preventDefault();
2084
+ const blob = item.getAsFile();
2085
+ if (!blob) continue;
2086
+ const reader = new FileReader();
2087
+ reader.onload = () => {
2088
+ const base64 = reader.result.split(",")[1];
2089
+ send({
2090
+ type: "attach-file",
2091
+ name: `clipboard-image.${blob.type.split("/")[1] || "png"}`,
2092
+ mimeType: blob.type,
2093
+ base64,
2094
+ });
2095
+ };
2096
+ reader.readAsDataURL(blob);
2097
+ break;
2098
+ }
2099
+ }
2100
+ });
2101
+
2102
+ function sendMessage() {
2103
+ const text = inputTextEl.value.trim();
2104
+ if (!text && pendingAttachments.length === 0) return;
2105
+
2106
+ hideCommandSuggestions();
2107
+
2108
+ // If viewing old thread, switch to current session first
2109
+ if (state.viewingOldThread) {
2110
+ state.viewingOldThread = false;
2111
+ state.activeThreadIdx = -1;
2112
+ // Restore current session messages (will be rebuilt from streaming)
2113
+ state.messages = data.messages || [];
2114
+ renderProjectTree();
2115
+ }
2116
+
2117
+ // Build message text with attachment paths
2118
+ let fullText = text;
2119
+ if (pendingAttachments.length > 0) {
2120
+ const paths = pendingAttachments.map(a => a.path).join(" ");
2121
+ fullText = fullText ? `${fullText} ${paths}` : paths;
2122
+ }
2123
+
2124
+ // Add user message locally
2125
+ state.messages.push({ role: "user", content: fullText });
2126
+ renderMessages();
2127
+
2128
+ // Send to pi
2129
+ send({ type: "send-message", text: fullText });
2130
+
2131
+ // Clear input and attachments
2132
+ pendingAttachments.length = 0;
2133
+ renderAttachmentPills();
2134
+ inputTextEl.value = "";
2135
+ autoResizeInput();
2136
+ inputTextEl.focus();
2137
+ }
2138
+
2139
+ btnNewThread.addEventListener("click", () => {
2140
+ send({ type: "send-message", text: "/new" });
2141
+ });
2142
+
2143
+ // ─── Command Suggestions ──────────────────────────────────────
2144
+
2145
+ function updateCommandSuggestions() {
2146
+ const text = inputTextEl.value;
2147
+ // Only show suggestions when actively typing a command (starts with / and no spaces yet)
2148
+ if (!text.startsWith("/") || text.includes(" ")) {
2149
+ hideCommandSuggestions();
2150
+ return;
2151
+ }
2152
+
2153
+ const query = text.slice(1).toLowerCase();
2154
+ const allCmds = state.commands.length ? state.commands : data.commands || [];
2155
+ const matches = allCmds
2156
+ .filter(c => c.name.toLowerCase().startsWith(query))
2157
+ .slice(0, 8);
2158
+
2159
+ if (matches.length === 0) {
2160
+ hideCommandSuggestions();
2161
+ return;
2162
+ }
2163
+
2164
+ showCommandSuggestions(matches);
2165
+ }
2166
+
2167
+ function showCommandSuggestions(commands) {
2168
+ let el = document.getElementById("cmd-suggestions");
2169
+ if (!el) {
2170
+ el = document.createElement("div");
2171
+ el.id = "cmd-suggestions";
2172
+ el.className = "cmd-suggestions";
2173
+ // Insert above the input area
2174
+ const inputArea = inputTextEl.closest(".input-area");
2175
+ if (inputArea) inputArea.style.position = "relative";
2176
+ inputArea?.insertBefore(el, inputArea.firstChild);
2177
+ }
2178
+
2179
+ el.innerHTML = commands.map((cmd, i) => `
2180
+ <div class="cmd-suggestion-item ${i === 0 ? 'cmd-active' : ''}" data-cmd-suggestion="${escapeHtml(cmd.name)}">
2181
+ <span class="font-mono font-medium" style="color: var(--accent);">/​${escapeHtml(cmd.name)}</span>
2182
+ <span class="text-[12px]" style="color: var(--text-muted);">${escapeHtml(truncate(cmd.description || '', 50))}</span>
2183
+ </div>
2184
+ `).join("");
2185
+
2186
+ el.style.display = "block";
2187
+
2188
+ // Click handler for suggestions
2189
+ el.querySelectorAll("[data-cmd-suggestion]").forEach(item => {
2190
+ item.addEventListener("click", () => {
2191
+ inputTextEl.value = "/" + item.dataset.cmdSuggestion + " ";
2192
+ hideCommandSuggestions();
2193
+ inputTextEl.focus();
2194
+ autoResizeInput();
2195
+ });
2196
+ });
2197
+ }
2198
+
2199
+ function hideCommandSuggestions() {
2200
+ const el = document.getElementById("cmd-suggestions");
2201
+ if (el) {
2202
+ el.style.display = "none";
2203
+ el.innerHTML = "";
2204
+ }
2205
+ }
2206
+
2207
+ // ─── Glimpse Communication ───────────────────────────────────
2208
+
2209
+ function send(payload) {
2210
+ if (window.glimpse?.send) {
2211
+ window.glimpse.send(payload);
2212
+ }
2213
+ }
2214
+
2215
+ // ─── Receive Messages from Extension ─────────────────────────
2216
+
2217
+ window.__desktopReceive = function(message) {
2218
+ if (!message || typeof message !== "object") return;
2219
+
2220
+ switch (message.type) {
2221
+ // ─── Streaming ─────────────────────────────
2222
+ case "agent-start":
2223
+ state.isStreaming = true;
2224
+ state.streamingText = "";
2225
+ state.activeTools = [];
2226
+ stopMermaidObserver();
2227
+ if (state.activeView === "threads" && !state.viewingOldThread) showWaitingIndicator();
2228
+ updateStreamingUI();
2229
+ break;
2230
+
2231
+ case "agent-end":
2232
+ state.isStreaming = false;
2233
+ state.activeTools = [];
2234
+ removeEphemeralElements();
2235
+ startMermaidObserver();
2236
+ updateStreamingUI();
2237
+ inputTextEl.disabled = false;
2238
+ btnSend.disabled = false;
2239
+ break;
2240
+
2241
+ case "message-start":
2242
+ if (message.role === "assistant") {
2243
+ state.streamingText = "";
2244
+ state.thinkingText = "";
2245
+ state.isThinking = false;
2246
+ }
2247
+ break;
2248
+
2249
+ case "message-chunk-start":
2250
+ state.streamingText = "";
2251
+ break;
2252
+
2253
+ case "message-chunk":
2254
+ state.streamingText += message.text;
2255
+ state.isThinking = false;
2256
+ updateStreamingMessage();
2257
+ break;
2258
+
2259
+ case "message-chunk-end":
2260
+ // text_end with full content — could use for validation
2261
+ break;
2262
+
2263
+ case "thinking-start":
2264
+ state.isThinking = true;
2265
+ state.thinkingText = "";
2266
+ if (state.activeView === "threads" && !state.viewingOldThread) renderMessages();
2267
+ break;
2268
+
2269
+ case "thinking-chunk":
2270
+ state.thinkingText += message.text;
2271
+ updateThinkingMessage();
2272
+ break;
2273
+
2274
+ case "thinking-end":
2275
+ // Persist thinking text as a collapsed message
2276
+ if (state.thinkingText.trim()) {
2277
+ const thinkMsg = { role: "thinking", content: state.thinkingText };
2278
+ state.messages.push(thinkMsg);
2279
+ if (state.activeView === "threads" && !state.viewingOldThread) {
2280
+ appendMessage(thinkMsg);
2281
+ }
2282
+ }
2283
+ state.isThinking = false;
2284
+ state.thinkingText = "";
2285
+ break;
2286
+
2287
+ case "toolcall-stream-start":
2288
+ break;
2289
+
2290
+ case "toolcall-stream-end":
2291
+ break;
2292
+
2293
+ case "message-end":
2294
+ if (message.role === "assistant" && message.content) {
2295
+ const assistMsg = { role: "assistant", content: message.content };
2296
+ state.messages.push(assistMsg);
2297
+ state.streamingText = "";
2298
+ state.thinkingText = "";
2299
+ if (state.activeView === "threads" && !state.viewingOldThread) {
2300
+ removeEphemeralElements();
2301
+ appendMessage(assistMsg);
2302
+ }
2303
+ }
2304
+ break;
2305
+
2306
+ case "tool-start": {
2307
+ // Decode base64-encoded diffs for edit tools
2308
+ let editDiffs = null;
2309
+ let editPath = message.editPath || "";
2310
+ if (message.editDiffsB64) {
2311
+ try {
2312
+ const binary = atob(message.editDiffsB64);
2313
+ const bytes = new Uint8Array(binary.length);
2314
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
2315
+ const decoded = new TextDecoder().decode(bytes);
2316
+ editDiffs = JSON.parse(decoded);
2317
+ } catch (e) { console.log("[desktop] failed to decode diffs:", e); }
2318
+ }
2319
+
2320
+ const toolMsg = {
2321
+ role: "tool",
2322
+ toolName: message.toolName,
2323
+ toolCallId: message.toolCallId,
2324
+ argsDisplay: message.argsDisplay || "",
2325
+ resultText: "",
2326
+ status: "running",
2327
+ isError: false,
2328
+ editDiffs: editDiffs,
2329
+ editPath: editPath,
2330
+ };
2331
+ state.messages.push(toolMsg);
2332
+ if (state.activeView === "threads" && !state.viewingOldThread) appendMessage(toolMsg);
2333
+ break;
2334
+ }
2335
+
2336
+ case "tool-end": {
2337
+ const existing = state.messages.find(
2338
+ m => m.role === "tool" && m.toolCallId === message.toolCallId && m.status === "running"
2339
+ );
2340
+ if (existing) {
2341
+ // In-place update - preserves <details> open state of other elements
2342
+ updateToolInPlace(message.toolCallId, {
2343
+ status: "done",
2344
+ isError: message.isError,
2345
+ resultText: message.resultText || "",
2346
+ });
2347
+ } else {
2348
+ const toolMsg2 = {
2349
+ role: "tool",
2350
+ toolName: message.toolName,
2351
+ toolCallId: message.toolCallId,
2352
+ argsDisplay: "",
2353
+ resultText: message.resultText || "",
2354
+ status: "done",
2355
+ isError: message.isError,
2356
+ };
2357
+ state.messages.push(toolMsg2);
2358
+ if (state.activeView === "threads" && !state.viewingOldThread) appendMessage(toolMsg2);
2359
+ }
2360
+ break;
2361
+ }
2362
+
2363
+ // ─── User input from terminal ──────────────
2364
+ // Removed - was causing duplicate messages
2365
+
2366
+ // ─── Thread/data updates ───────────────────
2367
+ case "thread-messages":
2368
+ state.messages = message.messages || [];
2369
+ if (message.threadIdx !== undefined) state.activeThreadIdx = message.threadIdx;
2370
+ state.viewingOldThread = true;
2371
+ state.activeView = "threads";
2372
+ setActiveNav("threads");
2373
+ renderMainContent();
2374
+ break;
2375
+
2376
+ case "stats-update":
2377
+ data.stats = message.stats;
2378
+ renderStats();
2379
+ break;
2380
+
2381
+ case "explorer-data":
2382
+ data.explorerFiles = message.files || [];
2383
+ state.viewingFile = null; // back to file list
2384
+ if (state.activeView === "explorer") renderExplorerView();
2385
+ break;
2386
+
2387
+ case "explorer-tree-children":
2388
+ if (message.parentPath) {
2389
+ state.explorerTreeChildren[message.parentPath] = message.children || [];
2390
+ if (!state.explorerTreeRoot) state.explorerTreeRoot = message.parentPath;
2391
+ if (state.activeView === "explorer") renderExplorerTree();
2392
+ }
2393
+ break;
2394
+
2395
+ case "file-content":
2396
+ if (message.error) {
2397
+ state.viewingFile = { path: message.path, name: message.name, ext: message.ext, content: null, error: message.error, size: message.size };
2398
+ } else {
2399
+ state.viewingFile = { path: message.path, name: message.name, ext: message.ext, content: message.content, size: message.size };
2400
+ }
2401
+ if (state.activeView === "explorer") renderExplorerView();
2402
+ break;
2403
+
2404
+ case "update-threads":
2405
+ data.threads = message.threads || [];
2406
+ renderProjectTree();
2407
+ break;
2408
+
2409
+ case "update-skills":
2410
+ data.skills = message.skills || [];
2411
+ data.extensions = message.extensions || data.extensions || [];
2412
+ if (state.activeView === "skills") renderSkillsView();
2413
+ break;
2414
+
2415
+ case "commands-list":
2416
+ state.commands = message.commands || [];
2417
+ if (state.activeView === "settings") renderSettingsView();
2418
+ break;
2419
+
2420
+ case "workspaces-list":
2421
+ data.workspaces = message.workspaces || [];
2422
+ if (state.showWorkspaceModal) renderWorkspaceModal();
2423
+ renderProjectTree();
2424
+ break;
2425
+
2426
+ case "workspace-sessions":
2427
+ if (message.dirName) {
2428
+ state.workspaceSessions[message.dirName] = message.sessions || [];
2429
+ renderProjectTree();
2430
+ }
2431
+ break;
2432
+
2433
+ case "search-results":
2434
+ if (message.query && threadSearchQuery && message.query.toLowerCase() === threadSearchQuery) {
2435
+ state.searchResults = message.results || [];
2436
+ renderProjectTree();
2437
+ }
2438
+ break;
2439
+
2440
+ case "workspace-opened":
2441
+ if (message.dirName) {
2442
+ // Reset explorer tree for new workspace
2443
+ state.explorerTreeExpanded = {};
2444
+ state.explorerTreeChildren = {};
2445
+ state.explorerTreeRoot = null;
2446
+ state.viewingFile = null;
2447
+ data.explorerFiles = [];
2448
+ // Re-request explorer data if explorer is active
2449
+ if (state.activeView === "explorer") {
2450
+ send({ type: "nav", action: "explorer" });
2451
+ }
2452
+ // Add to workspaces list if not already there
2453
+ const wsPath = message.path || "";
2454
+ const wsName = wsPath.split(/[\\/]/).pop() || wsPath;
2455
+ if (!data.workspaces) data.workspaces = [];
2456
+ if (!data.workspaces.find(w => w.dirName === message.dirName)) {
2457
+ data.workspaces.push({
2458
+ name: wsName,
2459
+ path: wsPath,
2460
+ dirName: message.dirName,
2461
+ sessionCount: (message.sessions || []).length,
2462
+ lastActive: new Date().toISOString(),
2463
+ });
2464
+ }
2465
+ state.workspaceSessions[message.dirName] = message.sessions || [];
2466
+ state.expandedWorkspaces[message.dirName] = true;
2467
+ renderProjectTree();
2468
+ }
2469
+ break;
2470
+
2471
+ case "file-attached-ack": {
2472
+ const att = {
2473
+ path: message.path || "",
2474
+ name: message.name || "file",
2475
+ mimeType: message.mimeType || "",
2476
+ };
2477
+ if (message.error || !att.path) break;
2478
+ pendingAttachments.push(att);
2479
+ renderAttachmentPills();
2480
+ inputTextEl.focus();
2481
+ break;
2482
+ }
2483
+
2484
+ case "plan-mode-violation": {
2485
+ showPlanModeWarning(message.toolName, message.argsPreview);
2486
+ break;
2487
+ }
2488
+ }
2489
+ };
2490
+
2491
+ // ─── Plan Mode Warning Toast ────────────────────────────────────
2492
+
2493
+ function showPlanModeWarning(toolName, argsPreview) {
2494
+ // Remove existing warning if any
2495
+ const existing = document.getElementById("plan-mode-warning");
2496
+ if (existing) existing.remove();
2497
+
2498
+ const toast = document.createElement("div");
2499
+ toast.id = "plan-mode-warning";
2500
+ toast.style.cssText = `
2501
+ position: fixed;
2502
+ top: 16px;
2503
+ right: 16px;
2504
+ z-index: 200;
2505
+ background: color-mix(in srgb, #e55 12%, var(--bg));
2506
+ border: 1px solid color-mix(in srgb, #e55 40%, var(--border));
2507
+ border-radius: 10px;
2508
+ padding: 12px 16px;
2509
+ max-width: 400px;
2510
+ box-shadow: 0 8px 24px rgba(0,0,0,0.25);
2511
+ animation: fadeIn 0.2s ease-out;
2512
+ `;
2513
+ toast.innerHTML = `
2514
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
2515
+ <span style="font-size:16px;">\u26a0\ufe0f</span>
2516
+ <span style="font-weight:600;font-size:13px;color:#e55;">Plan Mode Violation</span>
2517
+ <button onclick="this.closest('#plan-mode-warning').remove()" style="margin-left:auto;color:var(--text-dim);border:none;background:none;cursor:pointer;font-size:16px;line-height:1;">&times;</button>
2518
+ </div>
2519
+ <div style="font-size:12px;color:var(--text-muted);">
2520
+ <strong>${escapeHtml(toolName)}</strong> attempted while Plan Mode is active.
2521
+ <div style="margin-top:4px;font-family:monospace;font-size:11px;color:var(--text-dim);max-height:60px;overflow:hidden;">${escapeHtml(argsPreview || "")}</div>
2522
+ </div>
2523
+ `;
2524
+ document.body.appendChild(toast);
2525
+
2526
+ // Auto-dismiss after 6 seconds
2527
+ setTimeout(() => {
2528
+ toast.style.opacity = "0";
2529
+ toast.style.transition = "opacity 0.3s";
2530
+ setTimeout(() => toast.remove(), 300);
2531
+ }, 6000);
2532
+ }
2533
+
2534
+ // ─── Streaming UI Updates ─────────────────────────────────────
2535
+
2536
+ function cancelStreaming() {
2537
+ if (!state.isStreaming) return;
2538
+ send({ type: "cancel-streaming" });
2539
+ }
2540
+
2541
+ function updateStreamingUI() {
2542
+ // Update send button: show cancel (stop) button while streaming
2543
+ if (state.isStreaming) {
2544
+ btnSend.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>`;
2545
+ btnSend.title = "Cancel (Escape)";
2546
+ btnSend.style.background = "#c0392b";
2547
+ btnSend.onclick = function(e) { e.preventDefault(); cancelStreaming(); };
2548
+ } else {
2549
+ btnSend.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>`;
2550
+ btnSend.title = "Send";
2551
+ btnSend.style.background = '';
2552
+ btnSend.onclick = null;
2553
+ }
2554
+
2555
+ if (state.activeView === "threads" && !state.viewingOldThread) {
2556
+ renderMessages();
2557
+ }
2558
+ }
2559
+
2560
+ function updateStreamingMessage() {
2561
+ const streamDiv = document.getElementById("streaming-msg");
2562
+ if (streamDiv) {
2563
+ const contentDiv = streamDiv.querySelector(".message-content");
2564
+ if (contentDiv) {
2565
+ contentDiv.innerHTML = renderMarkdown(state.streamingText);
2566
+ scrollToBottom();
2567
+ return;
2568
+ }
2569
+ }
2570
+ // No streaming div yet - create one (append, don't rebuild)
2571
+ if (state.activeView === "threads" && !state.viewingOldThread) {
2572
+ showStreamingBlock();
2573
+ }
2574
+ }
2575
+
2576
+ function updateThinkingMessage() {
2577
+ const thinkDiv = document.getElementById("thinking-msg");
2578
+ if (thinkDiv) {
2579
+ const contentDiv = thinkDiv.querySelector(".whitespace-pre-wrap");
2580
+ if (contentDiv) {
2581
+ contentDiv.textContent = state.thinkingText.slice(-500);
2582
+ scrollToBottom();
2583
+ return;
2584
+ }
2585
+ }
2586
+ // No thinking div yet - create one
2587
+ if (state.activeView === "threads" && !state.viewingOldThread) {
2588
+ removeEphemeralElements();
2589
+ const div = document.createElement('div');
2590
+ div.id = 'thinking-msg';
2591
+ div.className = 'mb-3';
2592
+ div.innerHTML = `
2593
+ <details open class="rounded-lg border" style="border-color: var(--border);">
2594
+ <summary class="cursor-pointer px-3 py-2 text-[12px] font-medium" style="color: var(--text-muted);">
2595
+ &#x1F4AD; Thinking...
2596
+ </summary>
2597
+ <div class="border-t px-3 py-2" style="border-color: var(--border);">
2598
+ <div class="text-[12px] opacity-70 whitespace-pre-wrap" style="color:var(--text-muted); max-height: 200px; overflow-y: auto;">${escapeHtml(state.thinkingText.slice(-500))}</div>
2599
+ </div>
2600
+ </details>
2601
+ `;
2602
+ messagesEl.appendChild(div);
2603
+ scrollToBottom();
2604
+ }
2605
+ }
2606
+
2607
+ // ─── Keyboard Shortcuts ──────────────────────────────────────
2608
+
2609
+ document.addEventListener("keydown", (e) => {
2610
+ if (e.ctrlKey && e.key >= "1" && e.key <= "5") {
2611
+ e.preventDefault();
2612
+ const views = ["workspace", "threads", "skills", "settings", "explorer"];
2613
+ const view = views[parseInt(e.key) - 1];
2614
+ if (view === "workspace") {
2615
+ showWorkspaceModal();
2616
+ } else {
2617
+ setActiveNav(view);
2618
+ }
2619
+ }
2620
+
2621
+ // Ctrl+B: toggle sidebar
2622
+ if (e.ctrlKey && e.key === "b") {
2623
+ e.preventDefault();
2624
+ toggleSidebar();
2625
+ }
2626
+
2627
+ // Ctrl+P: toggle plan mode
2628
+ if (e.ctrlKey && !e.shiftKey && e.key === "p") {
2629
+ e.preventDefault();
2630
+ setPlanMode(!state.planMode);
2631
+ }
2632
+
2633
+ if (e.key === "Escape") {
2634
+ // Close workspace modal first
2635
+ if (state.showWorkspaceModal) {
2636
+ closeWorkspaceModal();
2637
+ return;
2638
+ }
2639
+ // If viewing old thread, go back to current
2640
+ if (state.viewingOldThread) {
2641
+ state.viewingOldThread = false;
2642
+ state.activeThreadIdx = -1;
2643
+ state.activeWorkspace = null;
2644
+ state.messages = data.messages || [];
2645
+ renderMainContent();
2646
+ }
2647
+ }
2648
+
2649
+ if (e.key === "/" && document.activeElement !== inputTextEl) {
2650
+ e.preventDefault();
2651
+ inputTextEl.focus();
2652
+ }
2653
+
2654
+ // Ctrl+N: focus input
2655
+ if (e.ctrlKey && e.key === "n") {
2656
+ e.preventDefault();
2657
+ inputTextEl.focus();
2658
+ inputTextEl.value = "";
2659
+ }
2660
+ });
2661
+
2662
+ // ─── Init ─────────────────────────────────────────────────────
2663
+
2664
+ renderStats();
2665
+ renderMainContent();
2666
+ setTheme("dark");
2667
+ inputTextEl.focus();
2668
+
2669
+ // Request fresh data
2670
+ send({ type: "get-commands" });
2671
+ send({ type: "get-stats" });