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/README.md +129 -0
- package/assets/Screenshot_1.png +0 -0
- package/assets/Screenshot_2.png +0 -0
- package/assets/Screenshot_3.png +0 -0
- package/assets/Screenshot_4.png +0 -0
- package/diagrams.md +291 -0
- package/index.ts +1224 -0
- package/package.json +27 -0
- package/pi-desktop.cmd +3 -0
- package/pi-desktop.sh +28 -0
- package/web/app.js +2671 -0
- package/web/index.html +589 -0
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/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(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/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'">×</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">◈</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
|
+
💭 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'">×</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">×</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;">×</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
|
+
💭 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" });
|