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/index.ts ADDED
@@ -0,0 +1,1224 @@
1
+ /**
2
+ * Desktop TUI Extension — Fully Functional Native Chat Window
3
+ *
4
+ * Opens a Glimpse native webview window that mirrors the pi terminal session:
5
+ * - Full bidirectional chat: send messages, stream responses in real-time
6
+ * - Markdown-rendered assistant messages with syntax highlighting
7
+ * - Tool execution indicators (tool calls, results)
8
+ * - Sidebar: threads, skills, settings, explorer, workspace
9
+ * - Ctrl+Alt+N or /nav or /desktop to open
10
+ * - Custom footer + context widget in the terminal
11
+ */
12
+
13
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
14
+ import { join, basename, dirname, extname, resolve, normalize } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { randomUUID } from "node:crypto";
17
+ import { tmpdir } from "node:os";
18
+ import { exec } from "node:child_process";
19
+ import type { AssistantMessage } from "@mariozechner/pi-ai";
20
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
21
+ import { Key, matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
22
+ import { open } from "glimpseui";
23
+
24
+ type GlimpseWindow = ReturnType<typeof open>;
25
+
26
+ const __dirname = dirname(fileURLToPath(import.meta.url));
27
+ const webDir = join(__dirname, "web");
28
+
29
+ // ─── Security Helpers ────────────────────────────────────────
30
+
31
+ const MAX_ATTACH_SIZE = 25 * 1024 * 1024; // 25 MB max attachment
32
+
33
+ /** Validate that a path is within allowed directories (sessions, cwd, or home). */
34
+ function isPathAllowed(filePath: string, ctx: { cwd: string } | null): boolean {
35
+ const home = process.env.HOME || process.env.USERPROFILE || "";
36
+ const resolved = resolve(normalize(filePath));
37
+ const sessionsDir = resolve(join(home, ".pi", "agent", "sessions"));
38
+
39
+ // Allow paths under the sessions directory
40
+ if (resolved.startsWith(sessionsDir + "/") || resolved.startsWith(sessionsDir + "\\")) return true;
41
+
42
+ // Allow paths under the current working directory
43
+ if (ctx) {
44
+ const cwdResolved = resolve(ctx.cwd);
45
+ if (resolved.startsWith(cwdResolved + "/") || resolved.startsWith(cwdResolved + "\\") || resolved === cwdResolved) return true;
46
+ }
47
+
48
+ return false;
49
+ }
50
+
51
+ /** Validate that a session file path is a .jsonl file inside the sessions directory. */
52
+ function isValidSessionFile(filePath: string): boolean {
53
+ const home = process.env.HOME || process.env.USERPROFILE || "";
54
+ const resolved = resolve(normalize(filePath));
55
+ const sessionsDir = resolve(join(home, ".pi", "agent", "sessions"));
56
+
57
+ if (!resolved.endsWith(".jsonl")) return false;
58
+ if (!resolved.startsWith(sessionsDir + "/") && !resolved.startsWith(sessionsDir + "\\")) return false;
59
+
60
+ // Reject path traversal attempts
61
+ if (filePath.includes("..")) return false;
62
+
63
+ return true;
64
+ }
65
+
66
+ // ─── Hidden Workspaces Persistence ───────────────────────────
67
+
68
+ function getHiddenWorkspacesPath(): string {
69
+ const home = process.env.HOME || process.env.USERPROFILE || "";
70
+ return join(home, ".pi", "agent", "hidden-workspaces.json");
71
+ }
72
+
73
+ function loadHiddenWorkspaces(): Record<string, boolean> {
74
+ try {
75
+ const fp = getHiddenWorkspacesPath();
76
+ if (existsSync(fp)) return JSON.parse(readFileSync(fp, "utf-8"));
77
+ } catch {}
78
+ return {};
79
+ }
80
+
81
+ function saveHiddenWorkspaces(hidden: Record<string, boolean>): void {
82
+ try {
83
+ writeFileSync(getHiddenWorkspacesPath(), JSON.stringify(hidden, null, 2));
84
+ } catch {}
85
+ }
86
+
87
+ const BUILTIN_COMMANDS = [
88
+ { name: "settings", description: "Open settings menu" },
89
+ { name: "model", description: "Select model (opens selector UI)" },
90
+ { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" },
91
+ { name: "export", description: "Export session (HTML default, or specify path)" },
92
+ { name: "import", description: "Import and resume a session from a JSONL file" },
93
+ { name: "share", description: "Share session as a secret GitHub gist" },
94
+ { name: "copy", description: "Copy last agent message to clipboard" },
95
+ { name: "name", description: "Set session display name" },
96
+ { name: "session", description: "Show session info and stats" },
97
+ { name: "changelog", description: "Show changelog entries" },
98
+ { name: "hotkeys", description: "Show all keyboard shortcuts" },
99
+ { name: "fork", description: "Create a new fork from a previous message" },
100
+ { name: "tree", description: "Navigate session tree (switch branches)" },
101
+ { name: "login", description: "Login with OAuth provider" },
102
+ { name: "logout", description: "Logout from OAuth provider" },
103
+ { name: "new", description: "Start a new session" },
104
+ { name: "compact", description: "Manually compact the session context" },
105
+ { name: "resume", description: "Resume a different session" },
106
+ { name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes" },
107
+ { name: "quit", description: "Quit pi" },
108
+ ];
109
+
110
+ function getAllCommands(pi: ExtensionAPI) {
111
+ const extCommands = pi.getCommands().map(c => ({
112
+ name: c.name,
113
+ description: c.description || "",
114
+ source: (c as any).sourceInfo?.source || "extension",
115
+ scope: (c as any).sourceInfo?.scope || "",
116
+ path: (c as any).sourceInfo?.path || "",
117
+ }));
118
+ const extNames = new Set(extCommands.map(c => c.name));
119
+ return [
120
+ ...BUILTIN_COMMANDS.filter(c => !extNames.has(c.name)).map(c => ({ ...c, source: "built-in", scope: "app", path: "" })),
121
+ ...extCommands,
122
+ ].sort((a, b) => a.name.localeCompare(b.name));
123
+ }
124
+
125
+ // ─── Helpers ─────────────────────────────────────────────────
126
+
127
+ function getProjectName(cwd: string): string { return basename(cwd); }
128
+
129
+ function fmt(n: number): string {
130
+ if (n < 1000) return `${n}`;
131
+ if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
132
+ return `${(n / 1_000_000).toFixed(1)}M`;
133
+ }
134
+
135
+ function getTokenStats(ctx: ExtensionContext) {
136
+ let input = 0, output = 0, cost = 0, cache = 0;
137
+ for (const e of ctx.sessionManager.getBranch()) {
138
+ if (e.type === "message" && e.message.role === "assistant") {
139
+ const m = e.message as AssistantMessage;
140
+ input += m.usage.input;
141
+ output += m.usage.output;
142
+ cost += m.usage.cost.total;
143
+ cache += (m.usage as any).cacheRead ?? 0;
144
+ }
145
+ }
146
+ return { input, output, cost, cache };
147
+ }
148
+
149
+ function getSessionThreads(sessionDir: string | null) {
150
+ if (!sessionDir || !existsSync(sessionDir)) return [];
151
+ try {
152
+ return readdirSync(sessionDir)
153
+ .filter(f => f.endsWith(".jsonl"))
154
+ .map(f => {
155
+ const fp = join(sessionDir, f);
156
+ const stat = statSync(fp);
157
+ let name = f.replace(".jsonl", "");
158
+ try {
159
+ const content = readFileSync(fp, "utf-8");
160
+ const line = content.split("\n").find(l => l.includes('"role":"user"') || l.includes('"role": "user"'));
161
+ if (line) {
162
+ const parsed = JSON.parse(line);
163
+ const msgContent = parsed?.message?.content;
164
+ let text = "";
165
+ if (Array.isArray(msgContent)) {
166
+ const textBlock = msgContent.find((b: any) => b.type === "text");
167
+ if (textBlock?.text) text = textBlock.text;
168
+ } else if (typeof msgContent === "string") {
169
+ text = msgContent;
170
+ }
171
+ if (text.length > 0) name = text.slice(0, 70).replace(/\n/g, " ");
172
+ }
173
+ } catch {}
174
+ return { name, file: fp, date: stat.mtime };
175
+ })
176
+ .sort((a, b) => b.date.getTime() - a.date.getTime());
177
+ } catch { return []; }
178
+ }
179
+
180
+ function getSkills() {
181
+ const skills: { name: string; desc: string }[] = [];
182
+ const home = process.env.HOME || process.env.USERPROFILE || "";
183
+ for (const dir of [join(home, ".pi", "agent", "skills"), join(home, ".agents", "skills")]) {
184
+ if (!existsSync(dir)) continue;
185
+ try {
186
+ for (const entry of readdirSync(dir)) {
187
+ const sp = join(dir, entry, "SKILL.md");
188
+ if (!existsSync(sp)) continue;
189
+ let desc = "";
190
+ try {
191
+ const c = readFileSync(sp, "utf-8");
192
+ const m = c.match(/description[:\s]*['"]*(.+?)['"]?\s*$/im);
193
+ if (m) desc = m[1]!.trim().slice(0, 80);
194
+ } catch {}
195
+ if (!skills.find(s => s.name === entry)) skills.push({ name: entry, desc });
196
+ }
197
+ } catch {}
198
+ }
199
+ return skills;
200
+ }
201
+
202
+ function getExtensions() {
203
+ const extensions: { name: string; source: string; type: string }[] = [];
204
+ const home = process.env.HOME || process.env.USERPROFILE || "";
205
+
206
+ // Built-in extensions from ~/.pi/agent/extensions/
207
+ const builtinDir = join(home, ".pi", "agent", "extensions");
208
+ if (existsSync(builtinDir)) {
209
+ try {
210
+ for (const entry of readdirSync(builtinDir)) {
211
+ const entryPath = join(builtinDir, entry);
212
+ try {
213
+ if (statSync(entryPath).isDirectory() && (existsSync(join(entryPath, "index.ts")) || existsSync(join(entryPath, "index.js")))) {
214
+ extensions.push({ name: entry, source: "built-in", type: "builtin" });
215
+ }
216
+ } catch {}
217
+ }
218
+ } catch {}
219
+ }
220
+
221
+ // Extensions from settings.json
222
+ const settingsPath = join(home, ".pi", "agent", "settings.json");
223
+ if (existsSync(settingsPath)) {
224
+ try {
225
+ const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
226
+
227
+ // Direct extensions array (e.g. "+extensions/cmux/index.ts")
228
+ if (Array.isArray(settings.extensions)) {
229
+ for (const ext of settings.extensions) {
230
+ if (typeof ext === "string") {
231
+ const name = ext.replace(/^\+/, "").replace(/\/index\.(ts|js)$/, "").replace(/^extensions\//, "");
232
+ if (!extensions.find(e => e.name === name)) {
233
+ extensions.push({ name, source: "local", type: "local" });
234
+ }
235
+ }
236
+ }
237
+ }
238
+
239
+ // Package extensions from "packages" array
240
+ if (Array.isArray(settings.packages)) {
241
+ for (const pkg of settings.packages) {
242
+ let source = "";
243
+ let pkgExtensions: string[] = [];
244
+
245
+ if (typeof pkg === "string") {
246
+ source = pkg;
247
+ } else if (typeof pkg === "object" && pkg.source) {
248
+ source = pkg.source;
249
+ if (Array.isArray(pkg.extensions)) pkgExtensions = pkg.extensions;
250
+ }
251
+
252
+ if (pkgExtensions.length > 0) {
253
+ const pkgName = source.replace(/^(git:|npm:)/, "").replace(/^github\.com\//, "").replace(/^https:\/\/github\.com\//, "");
254
+ for (const ext of pkgExtensions) {
255
+ const extName = ext.replace(/^\+/, "").replace(/\/index\.(ts|js)$/, "").replace(/^extensions?\//, "");
256
+ extensions.push({ name: extName, source: pkgName, type: "package" });
257
+ }
258
+ } else if (source) {
259
+ const pkgName = source.replace(/^(git:|npm:)/, "").replace(/^github\.com\//, "").replace(/^https:\/\/github\.com\//, "");
260
+ const gitDir = join(home, ".pi", "agent", "git", "github.com", ...pkgName.split("/"));
261
+ const hasExtension = existsSync(join(gitDir, "extension.ts")) || existsSync(join(gitDir, "extension.js"))
262
+ || existsSync(join(gitDir, "extension", "index.ts")) || existsSync(join(gitDir, "extension", "index.js"));
263
+ if (hasExtension) {
264
+ extensions.push({ name: pkgName.split("/").pop() || pkgName, source: pkgName, type: "package" });
265
+ }
266
+ }
267
+ }
268
+ }
269
+ } catch {}
270
+ }
271
+
272
+ return extensions;
273
+ }
274
+
275
+ function decodeSessionDirName(dirName: string): string {
276
+ // Reverse of: `--${cwd.replace(/^[\/\\]/, "").replace(/[\/\\:]/g, "-")}--`
277
+ // e.g. --C--Users-Jagtprit-- → C:\Users\Jagtprit
278
+ let decoded = dirName.replace(/^--/, "").replace(/--$/, "");
279
+ // First segment after removing leading -- is drive letter on Windows (e.g. "C")
280
+ // Pattern: C--Users-Jagtprit → C:\Users\Jagtprit
281
+ // The double dash after drive letter was from the colon
282
+ const match = decoded.match(/^([A-Za-z])--(.*)$/);
283
+ if (match) {
284
+ decoded = match[1] + ":\\" + match[2].replace(/-/g, "\\");
285
+ } else {
286
+ // Unix path: --home-user-project-- → /home/user/project
287
+ decoded = "/" + decoded.replace(/-/g, "/");
288
+ }
289
+ return decoded;
290
+ }
291
+
292
+ function getWorkspaces() {
293
+ const home = process.env.HOME || process.env.USERPROFILE || "";
294
+ const sessionsRoot = join(home, ".pi", "agent", "sessions");
295
+ if (!existsSync(sessionsRoot)) return [];
296
+
297
+ const workspaces: { name: string; path: string; dirName: string; sessionCount: number; lastActive: Date }[] = [];
298
+
299
+ try {
300
+ for (const dirName of readdirSync(sessionsRoot)) {
301
+ const dirPath = join(sessionsRoot, dirName);
302
+ try {
303
+ if (!statSync(dirPath).isDirectory()) continue;
304
+ } catch { continue; }
305
+
306
+ // Skip temp/worktree dirs to reduce noise
307
+ if (dirName.includes("pi-gui-workspace") || dirName.includes("pi-gui-git-workspace") || dirName.includes("worktrees")) continue;
308
+
309
+ const decodedPath = decodeSessionDirName(dirName);
310
+ const name = basename(decodedPath) || decodedPath;
311
+
312
+ // Count sessions and find most recent
313
+ let sessionCount = 0;
314
+ let lastActive = new Date(0);
315
+ try {
316
+ for (const f of readdirSync(dirPath)) {
317
+ if (!f.endsWith(".jsonl")) continue;
318
+ sessionCount++;
319
+ try {
320
+ const mtime = statSync(join(dirPath, f)).mtime;
321
+ if (mtime > lastActive) lastActive = mtime;
322
+ } catch {}
323
+ }
324
+ } catch {}
325
+
326
+ if (sessionCount === 0) continue;
327
+
328
+ workspaces.push({ name, path: decodedPath, dirName, sessionCount, lastActive });
329
+ }
330
+ } catch {}
331
+
332
+ // Sort by most recently active
333
+ workspaces.sort((a, b) => b.lastActive.getTime() - a.lastActive.getTime());
334
+ return workspaces;
335
+ }
336
+
337
+ function getWorkspaceSessions(dirName: string) {
338
+ // Reject traversal attempts
339
+ if (dirName.includes("..") || dirName.includes("/") || dirName.includes("\\")) return [];
340
+ const home = process.env.HOME || process.env.USERPROFILE || "";
341
+ const dirPath = join(home, ".pi", "agent", "sessions", dirName);
342
+ return getSessionThreads(dirPath);
343
+ }
344
+
345
+ function searchSessionThreads(sessionDir: string | null, query: string): Array<{ name: string; file: string; date: Date; matchSnippet: string }> {
346
+ if (!sessionDir || !existsSync(sessionDir) || !query) return [];
347
+ const lowerQuery = query.toLowerCase();
348
+ const results: Array<{ name: string; file: string; date: Date; matchSnippet: string }> = [];
349
+ try {
350
+ const files = readdirSync(sessionDir).filter(f => f.endsWith(".jsonl"));
351
+ for (const f of files) {
352
+ const fp = join(sessionDir, f);
353
+ const stat = statSync(fp);
354
+ let threadName = f.replace(".jsonl", "");
355
+ let matchSnippet = "";
356
+ try {
357
+ const content = readFileSync(fp, "utf-8");
358
+ const lines = content.split("\n");
359
+ let firstUserText = "";
360
+ for (const line of lines) {
361
+ if (!line.trim()) continue;
362
+ try {
363
+ const entry = JSON.parse(line);
364
+ if (entry.type !== "message") continue;
365
+ const msg = entry.message;
366
+ if (!msg) continue;
367
+ let text = "";
368
+ if (Array.isArray(msg.content)) {
369
+ for (const b of msg.content) {
370
+ if (b.type === "text" && b.text) text += b.text + " ";
371
+ }
372
+ } else if (typeof msg.content === "string") {
373
+ text = msg.content;
374
+ }
375
+ text = text.trim();
376
+ if (!text) continue;
377
+ if (msg.role === "user" && !firstUserText) firstUserText = text;
378
+ if (text.toLowerCase().includes(lowerQuery)) {
379
+ // Extract snippet around the match
380
+ const idx = text.toLowerCase().indexOf(lowerQuery);
381
+ const start = Math.max(0, idx - 30);
382
+ const end = Math.min(text.length, idx + query.length + 50);
383
+ matchSnippet = (start > 0 ? "..." : "") + text.slice(start, end).replace(/\n/g, " ") + (end < text.length ? "..." : "");
384
+ break;
385
+ }
386
+ } catch {}
387
+ }
388
+ if (firstUserText) threadName = firstUserText.slice(0, 70).replace(/\n/g, " ");
389
+ if (matchSnippet) {
390
+ results.push({ name: threadName, file: fp, date: stat.mtime, matchSnippet });
391
+ }
392
+ } catch {}
393
+ }
394
+ } catch {}
395
+ results.sort((a, b) => b.date.getTime() - a.date.getTime());
396
+ return results;
397
+ }
398
+
399
+ function extractSessionMessages(ctx: ExtensionContext) {
400
+ const messages: Array<{ role: string; content: string; toolName?: string }> = [];
401
+
402
+ for (const e of ctx.sessionManager.getBranch()) {
403
+ if (e.type === "compaction") {
404
+ // Show compacted history as a summary message
405
+ const ce = e as any;
406
+ if (ce.summary) {
407
+ messages.push({ role: "assistant", content: ce.summary });
408
+ }
409
+ }
410
+ if (e.type === "custom_message") {
411
+ const cm = e as any;
412
+ if (cm.display && cm.content) {
413
+ const text = typeof cm.content === "string"
414
+ ? cm.content
415
+ : (Array.isArray(cm.content) ? cm.content.filter((b: any) => b.type === "text").map((b: any) => b.text).join("") : "");
416
+ if (text.trim()) messages.push({ role: "assistant", content: text.trim() });
417
+ }
418
+ }
419
+ if (e.type !== "message") continue;
420
+ const msg = e.message;
421
+ if (msg.role === "user") {
422
+ let text = "";
423
+ if (Array.isArray(msg.content)) {
424
+ for (const block of msg.content) {
425
+ if ((block as any).type === "text") text += (block as any).text;
426
+ }
427
+ } else if (typeof msg.content === "string") {
428
+ text = msg.content;
429
+ }
430
+ if (text.trim()) messages.push({ role: "user", content: text.trim() });
431
+ } else if (msg.role === "assistant") {
432
+ let text = "";
433
+ const toolCalls: Array<{ role: string; content: string; toolName?: string }> = [];
434
+ if (Array.isArray(msg.content)) {
435
+ for (const block of msg.content) {
436
+ if ((block as any).type === "text") text += (block as any).text;
437
+ else if ((block as any).type === "tool_use") {
438
+ toolCalls.push({ role: "tool", content: JSON.stringify((block as any).input || {}, null, 2).slice(0, 300), toolName: (block as any).name });
439
+ }
440
+ }
441
+ } else if (typeof msg.content === "string") {
442
+ text = msg.content;
443
+ }
444
+ // Add tool calls before the text response
445
+ messages.push(...toolCalls);
446
+ if (text.trim()) messages.push({ role: "assistant", content: text.trim() });
447
+ }
448
+ }
449
+ return messages;
450
+ }
451
+
452
+ function extractThreadMessages(filePath: string) {
453
+ const messages: Array<{ role: string; content: string; toolName?: string }> = [];
454
+ try {
455
+ const content = readFileSync(filePath, "utf-8");
456
+ for (const line of content.split("\n")) {
457
+ if (!line.trim()) continue;
458
+ try {
459
+ const entry = JSON.parse(line);
460
+ if (entry.type === "compaction" && entry.summary) {
461
+ messages.push({ role: "assistant", content: entry.summary });
462
+ continue;
463
+ }
464
+ if (entry.type === "custom_message" && entry.display && entry.content) {
465
+ const text = typeof entry.content === "string"
466
+ ? entry.content
467
+ : (Array.isArray(entry.content) ? entry.content.filter((b: any) => b.type === "text").map((b: any) => b.text).join("") : "");
468
+ if (text.trim()) messages.push({ role: "assistant", content: text.trim() });
469
+ continue;
470
+ }
471
+ if (entry.type !== "message") continue;
472
+ const msg = entry.message;
473
+ if (msg?.role === "user") {
474
+ let text = "";
475
+ if (Array.isArray(msg.content)) {
476
+ for (const block of msg.content) {
477
+ if (block.type === "text") text += block.text;
478
+ }
479
+ } else if (typeof msg.content === "string") text = msg.content;
480
+ if (text.trim()) messages.push({ role: "user", content: text.trim() });
481
+ } else if (msg?.role === "assistant") {
482
+ let text = "";
483
+ const toolCalls: typeof messages = [];
484
+ if (Array.isArray(msg.content)) {
485
+ for (const block of msg.content) {
486
+ if (block.type === "text") text += block.text;
487
+ else if (block.type === "tool_use") {
488
+ toolCalls.push({ role: "tool", content: JSON.stringify(block.input || {}, null, 2).slice(0, 300), toolName: block.name });
489
+ }
490
+ }
491
+ } else if (typeof msg.content === "string") text = msg.content;
492
+ messages.push(...toolCalls);
493
+ if (text.trim()) messages.push({ role: "assistant", content: text.trim() });
494
+ }
495
+ } catch {}
496
+ }
497
+ } catch {}
498
+ return messages;
499
+ }
500
+
501
+ function getDirEntries(dir: string) {
502
+ try {
503
+ return readdirSync(dir)
504
+ .filter(f => !f.startsWith(".") && f !== "node_modules" && f !== "__pycache__")
505
+ .map(f => {
506
+ try {
507
+ const stat = statSync(join(dir, f));
508
+ return { name: f, isDir: stat.isDirectory(), size: stat.isDirectory() ? "" : formatSize(stat.size), path: join(dir, f) };
509
+ } catch { return { name: f, isDir: false, size: "", path: join(dir, f) }; }
510
+ })
511
+ .sort((a, b) => {
512
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
513
+ return a.name.localeCompare(b.name);
514
+ });
515
+ } catch { return []; }
516
+ }
517
+
518
+ function formatSize(bytes: number): string {
519
+ if (bytes < 1024) return `${bytes}B`;
520
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(0)}K`;
521
+ return `${(bytes / 1048576).toFixed(1)}M`;
522
+ }
523
+
524
+ /** Escape non-ASCII chars to \uXXXX so only ASCII bytes pass through Glimpse's
525
+ * Windows webview bridge — prevents UTF-8 → CP1252 mojibake (e.g. — → ΓÇö). */
526
+ function escapeNonAscii(str: string): string {
527
+ return str.replace(/[^\x00-\x7F]/g, (ch) =>
528
+ `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`);
529
+ }
530
+
531
+ // ─── HTML Builder ────────────────────────────────────────────
532
+
533
+ interface DesktopWindowData {
534
+ projectName: string;
535
+ gitBranch: string | null;
536
+ model: string;
537
+ thinkingLevel: string;
538
+ provider: string;
539
+ cwd: string;
540
+ stats: { input: number; output: number; cache: number; cost: number };
541
+ threads: Array<{ name: string; file: string; date: string }>;
542
+ skills: Array<{ name: string; desc: string }>;
543
+ extensions: Array<{ name: string; source: string; type: string }>;
544
+ workspaces: Array<{ name: string; path: string; dirName: string; sessionCount: number; lastActive: string }>;
545
+ messages: Array<{ role: string; content: string; toolName?: string }>;
546
+ explorerFiles: Array<{ name: string; isDir: boolean; size: string; path: string }>;
547
+ commands: Array<{ name: string; description: string; source: string; scope: string; path: string }>;
548
+ hiddenWorkspaces: Record<string, boolean>;
549
+ }
550
+
551
+ function buildDesktopHtml(data: DesktopWindowData): string {
552
+ const templateHtml = readFileSync(join(webDir, "index.html"), "utf8");
553
+ const appJs = readFileSync(join(webDir, "app.js"), "utf8");
554
+
555
+ // Base64-encode JSON to avoid Glimpse webview bridge corruption.
556
+ // The bridge (about:blank + WebView2 on Windows) can corrupt control characters
557
+ // and \uXXXX escapes during transfer. Base64 is pure alphanumeric + /+= and
558
+ // survives any encoding conversion.
559
+ const rawJson = JSON.stringify(data);
560
+ const base64Json = Buffer.from(rawJson, 'utf8').toString('base64');
561
+
562
+ // Use split+join instead of .replace() to avoid $-pattern interpretation
563
+ let result = templateHtml.split("__INLINE_DATA__").join(base64Json);
564
+ result = result.split("__INLINE_JS__").join(appJs);
565
+ return result;
566
+ }
567
+
568
+ // ─── Extension Entry Point ───────────────────────────────────
569
+
570
+ export default function desktopTuiExtension(pi: ExtensionAPI) {
571
+ let projectName = "";
572
+ let gitBranch: string | null = null;
573
+ let activeWindow: GlimpseWindow | null = null;
574
+ let lastCtx: ExtensionContext | null = null;
575
+ let activeExplorerCwd: string | null = null; // override CWD when viewing another workspace
576
+ let sessionReason: string = "startup";
577
+ let planMode: boolean = false;
578
+
579
+ const PLAN_MODE_PREFIX = `[PLAN MODE ACTIVE — You are in read-only plan mode. STRICT RULES:
580
+ 1. Do NOT use edit, write, or any tool that modifies files
581
+ 2. Do NOT run bash commands that create, modify, or delete files (no mkdir, rm, mv, cp, touch, tee, sed -i, etc.)
582
+ 3. ONLY use: read, grep, find, ls, parallel_search, parallel_research, parallel_extract, todo, subagent (scout only)
583
+ 4. Safe bash allowed: git log, git diff, git status, cat, head, tail, wc, echo, pwd, env, which, type
584
+ 5. Focus on: reading code, analyzing architecture, creating plans, reviewing scaffolding, identifying patterns
585
+ 6. If the user asks you to write or edit, remind them Plan Mode is active and suggest they turn it off first]
586
+
587
+ `;
588
+
589
+ // ─── Window Communication ─────────────────────────────────
590
+
591
+ function sendToWindow(message: any): void {
592
+ if (!activeWindow) return;
593
+ try {
594
+ // Double-stringify: JSON.stringify creates a safe JS string literal,
595
+ // JSON.parse in the webview decodes it. escapeNonAscii ensures only
596
+ // ASCII bytes pass through Glimpse's send() — prevents UTF-8 → CP1252
597
+ // mojibake on Windows (e.g. em dash — rendered as ΓÇö).
598
+ const jsonStr = JSON.stringify(message);
599
+ const js = `window.__desktopReceive(JSON.parse(${JSON.stringify(jsonStr)}))`;
600
+ activeWindow.send(escapeNonAscii(js));
601
+ } catch {}
602
+ }
603
+
604
+ function closeActiveWindow(): void {
605
+ if (activeWindow == null) return;
606
+ const w = activeWindow;
607
+ activeWindow = null;
608
+ try { w.close(); } catch {}
609
+ // Clear custom thinking label when desktop window closes
610
+ try { lastCtx?.ui?.setHiddenThinkingLabel?.(undefined as any); } catch {}
611
+ }
612
+
613
+ // ─── Streaming Event Handlers (global) ────────────────────
614
+
615
+ pi.on("agent_start", (_event, _ctx) => {
616
+ sendToWindow({ type: "agent-start" });
617
+ });
618
+
619
+ pi.on("agent_end", (_event, ctx) => {
620
+ lastCtx = ctx;
621
+ const stats = getTokenStats(ctx);
622
+ sendToWindow({ type: "agent-end" });
623
+ sendToWindow({ type: "stats-update", stats });
624
+ });
625
+
626
+ pi.on("message_start", (event, _ctx) => {
627
+ const msg = event.message;
628
+ if (msg.role === "assistant") {
629
+ sendToWindow({ type: "message-start", role: "assistant" });
630
+ }
631
+ });
632
+
633
+ pi.on("message_update", (event, _ctx) => {
634
+ const evt = event.assistantMessageEvent as any;
635
+ switch (evt.type) {
636
+ case "text_delta":
637
+ sendToWindow({ type: "message-chunk", text: evt.delta });
638
+ break;
639
+ case "text_start":
640
+ sendToWindow({ type: "message-chunk-start" });
641
+ break;
642
+ case "text_end":
643
+ sendToWindow({ type: "message-chunk-end", content: evt.content });
644
+ break;
645
+ case "thinking_delta":
646
+ sendToWindow({ type: "thinking-chunk", text: evt.delta });
647
+ break;
648
+ case "thinking_start":
649
+ sendToWindow({ type: "thinking-start" });
650
+ break;
651
+ case "thinking_end":
652
+ sendToWindow({ type: "thinking-end" });
653
+ break;
654
+ case "toolcall_start":
655
+ sendToWindow({ type: "toolcall-stream-start", contentIndex: evt.contentIndex });
656
+ break;
657
+ case "toolcall_end":
658
+ sendToWindow({ type: "toolcall-stream-end", contentIndex: evt.contentIndex, toolName: evt.toolCall?.name });
659
+ break;
660
+ }
661
+ });
662
+
663
+ pi.on("message_end", (event, ctx) => {
664
+ lastCtx = ctx;
665
+ const msg = event.message;
666
+ if (msg.role === "assistant") {
667
+ let text = "";
668
+ if (Array.isArray(msg.content)) {
669
+ for (const block of msg.content) {
670
+ if ((block as any).type === "text") text += (block as any).text;
671
+ }
672
+ }
673
+ sendToWindow({ type: "message-end", role: "assistant", content: text });
674
+ }
675
+ });
676
+
677
+ pi.on("tool_execution_start", (event, _ctx) => {
678
+ // Warn in desktop window if a write tool fires during plan mode
679
+ if (planMode) {
680
+ const writeTools = new Set(["edit", "write", "claude"]);
681
+ if (writeTools.has(event.toolName) || (event.toolName === "bash" && event.args?.command)) {
682
+ sendToWindow({
683
+ type: "plan-mode-violation",
684
+ toolName: event.toolName,
685
+ argsPreview: JSON.stringify(event.args || {}).slice(0, 200),
686
+ });
687
+ }
688
+ }
689
+
690
+ // Format args for display
691
+ let argsDisplay = "";
692
+ let editDiffs: Array<{ oldText: string; newText: string }> | null = null;
693
+ let editPath = "";
694
+ try {
695
+ const args = event.args;
696
+ if (event.toolName === "bash" && args?.command) {
697
+ argsDisplay = args.command;
698
+ } else if (event.toolName === "read" && args?.path) {
699
+ argsDisplay = `read ${args.path}` + (args.offset ? ` (offset: ${args.offset})` : "");
700
+ } else if (event.toolName === "edit" && args?.path) {
701
+ editPath = args.path;
702
+ editDiffs = (args.edits || []).map((e: any) => ({
703
+ oldText: e.oldText || "",
704
+ newText: e.newText || "",
705
+ }));
706
+ argsDisplay = `edit ${args.path} (${(args.edits || []).length} edit(s))`;
707
+ } else if (event.toolName === "write" && args?.path) {
708
+ argsDisplay = `write ${args.path}`;
709
+ } else if (event.toolName === "grep" && args?.pattern) {
710
+ argsDisplay = `grep "${args.pattern}"` + (args.path ? ` in ${args.path}` : "");
711
+ } else if (event.toolName === "find" && args?.path) {
712
+ argsDisplay = `find ${args.path}` + (args.glob ? ` -name ${args.glob}` : "");
713
+ } else if (event.toolName === "ls" && args?.path) {
714
+ argsDisplay = `ls ${args.path}`;
715
+ } else {
716
+ argsDisplay = JSON.stringify(args || {}, null, 2).slice(0, 500);
717
+ }
718
+ } catch { argsDisplay = "..."; }
719
+
720
+ // For edit tools, encode diffs as a flat base64 string to survive Glimpse bridge
721
+ const editDiffsB64 = editDiffs ? Buffer.from(JSON.stringify(editDiffs)).toString("base64") : "";
722
+
723
+ sendToWindow({
724
+ type: "tool-start",
725
+ toolName: event.toolName,
726
+ toolCallId: event.toolCallId,
727
+ argsDisplay,
728
+ editDiffsB64,
729
+ editPath,
730
+ });
731
+ });
732
+
733
+ pi.on("before_provider_request", ((event: any, _ctx: ExtensionContext) => {
734
+ // Forward provider request metadata to desktop window for real-time model/provider display
735
+ sendToWindow({
736
+ type: "provider-request",
737
+ model: event.model ?? "",
738
+ provider: event.provider ?? "",
739
+ });
740
+ }) as any);
741
+
742
+ pi.on("tool_execution_end", (event, _ctx) => {
743
+ // Extract result text
744
+ let resultText = "";
745
+ try {
746
+ const result = event.result;
747
+ if (typeof result === "string") {
748
+ resultText = result;
749
+ } else if (result?.content) {
750
+ // AgentToolResult has content array
751
+ if (Array.isArray(result.content)) {
752
+ resultText = result.content
753
+ .filter((c: any) => c.type === "text")
754
+ .map((c: any) => c.text)
755
+ .join("\n");
756
+ } else if (typeof result.content === "string") {
757
+ resultText = result.content;
758
+ }
759
+ } else if (result != null) {
760
+ resultText = JSON.stringify(result, null, 2);
761
+ }
762
+ } catch { resultText = "(result unavailable)"; }
763
+
764
+ sendToWindow({
765
+ type: "tool-end",
766
+ toolName: event.toolName,
767
+ toolCallId: event.toolCallId,
768
+ isError: event.isError,
769
+ resultText: resultText.slice(0, 3000),
770
+ });
771
+ });
772
+
773
+ // NOTE: Removed input mirroring - it caused duplicate messages.
774
+ // The window adds user messages locally when sent from the window.
775
+ // Terminal messages appear via the streaming events (message_start/end).
776
+
777
+ // ─── Window Message Handler ───────────────────────────────
778
+
779
+ function handleWindowMessage(msg: any): void {
780
+ if (!msg || typeof msg !== "object") return;
781
+
782
+ switch (msg.type) {
783
+ case "send-message": {
784
+ const text = (msg.text || "").trim();
785
+ if (!text || text.length > 100_000) break;
786
+ // In plan mode, prepend read-only instruction to every message
787
+ const finalText = planMode ? PLAN_MODE_PREFIX + text : text;
788
+ pi.sendUserMessage(finalText);
789
+ break;
790
+ }
791
+
792
+ case "open-thread": {
793
+ if (msg.file && isValidSessionFile(msg.file)) {
794
+ const threadMsgs = extractThreadMessages(msg.file);
795
+ sendToWindow({ type: "thread-messages", messages: threadMsgs, threadIdx: msg.index ?? 0 });
796
+ // Switch explorer CWD when viewing another workspace's thread
797
+ if (msg.workspace && typeof msg.workspace === "string" && msg.workspace !== "__current__") {
798
+ const decodedPath = decodeSessionDirName(msg.workspace);
799
+ if (existsSync(decodedPath)) {
800
+ activeExplorerCwd = decodedPath;
801
+ }
802
+ } else {
803
+ activeExplorerCwd = null; // back to current workspace
804
+ }
805
+ }
806
+ break;
807
+ }
808
+
809
+ case "nav": {
810
+ if (msg.action === "explorer") {
811
+ const cwd = activeExplorerCwd || lastCtx?.cwd;
812
+ if (cwd) {
813
+ const files = getDirEntries(cwd);
814
+ sendToWindow({ type: "explorer-data", files });
815
+ sendToWindow({ type: "explorer-tree-children", parentPath: cwd, children: files });
816
+ }
817
+ }
818
+ break;
819
+ }
820
+
821
+ case "explorer-tree-expand": {
822
+ // Expand a directory in the sidebar tree
823
+ const explorerCtx = activeExplorerCwd ? { cwd: activeExplorerCwd } : lastCtx;
824
+ if (msg.path && isPathAllowed(msg.path, explorerCtx)) {
825
+ try {
826
+ const stat = statSync(msg.path);
827
+ if (stat.isDirectory()) {
828
+ const children = getDirEntries(msg.path);
829
+ sendToWindow({ type: "explorer-tree-children", parentPath: msg.path, children });
830
+ }
831
+ } catch {}
832
+ }
833
+ break;
834
+ }
835
+
836
+ case "explorer-open": {
837
+ const openCtx = activeExplorerCwd ? { cwd: activeExplorerCwd } : lastCtx;
838
+ if (msg.path && isPathAllowed(msg.path, openCtx)) {
839
+ try {
840
+ const stat = statSync(msg.path);
841
+ if (stat.isDirectory()) {
842
+ const files = getDirEntries(msg.path);
843
+ sendToWindow({ type: "explorer-data", files });
844
+ } else if (stat.isFile()) {
845
+ // Images and binaries → open with system default app
846
+ const imageExts = new Set(["png","jpg","jpeg","gif","bmp","svg","webp","ico","tiff","tif"]);
847
+ const binaryExts = new Set(["pdf","doc","docx","xls","xlsx","ppt","pptx","zip","tar","gz","exe","dll","so","dylib","mp3","mp4","mov","avi","wav"]);
848
+ const ext = extname(msg.path).slice(1).toLowerCase();
849
+ if (imageExts.has(ext) || binaryExts.has(ext)) {
850
+ // Open with system default app
851
+ const escapedPath = msg.path.replace(/"/g, '\\"');
852
+ const cmd = process.platform === "win32" ? `start "" "${escapedPath}"`
853
+ : process.platform === "darwin" ? `open "${escapedPath}"`
854
+ : `xdg-open "${escapedPath}"`;
855
+ exec(cmd);
856
+ } else {
857
+ // Text file → read and send content
858
+ const MAX_FILE_SIZE = 512 * 1024;
859
+ if (stat.size > MAX_FILE_SIZE) {
860
+ sendToWindow({ type: "file-content", path: msg.path, name: basename(msg.path), ext, content: null, error: `File too large (${(stat.size / 1024).toFixed(0)}KB). Max: 512KB.`, size: stat.size });
861
+ } else {
862
+ const content = readFileSync(msg.path, "utf8");
863
+ sendToWindow({ type: "file-content", path: msg.path, name: basename(msg.path), ext, content, size: stat.size });
864
+ }
865
+ }
866
+ }
867
+ } catch {}
868
+ }
869
+ break;
870
+ }
871
+
872
+ case "get-commands": {
873
+ sendToWindow({ type: "commands-list", commands: getAllCommands(pi) });
874
+ break;
875
+ }
876
+
877
+ case "get-stats": {
878
+ if (lastCtx) {
879
+ const stats = getTokenStats(lastCtx);
880
+ sendToWindow({ type: "stats-update", stats });
881
+ }
882
+ break;
883
+ }
884
+
885
+ case "refresh-threads": {
886
+ if (lastCtx) {
887
+ const sessionFile = (lastCtx.sessionManager as any).getSessionFile?.() ?? null;
888
+ const sessionDir = sessionFile ? join(sessionFile, "..") : null;
889
+ const threads = getSessionThreads(sessionDir).map(t => ({
890
+ name: t.name, file: t.file, date: t.date.toISOString(),
891
+ }));
892
+ sendToWindow({ type: "update-threads", threads });
893
+ }
894
+ break;
895
+ }
896
+
897
+ case "refresh-skills": {
898
+ const skills = getSkills();
899
+ const extensions = getExtensions();
900
+ sendToWindow({ type: "update-skills", skills, extensions });
901
+ break;
902
+ }
903
+
904
+ case "get-workspaces": {
905
+ const wsList = getWorkspaces().map(w => ({
906
+ ...w, lastActive: w.lastActive.toISOString(),
907
+ }));
908
+ sendToWindow({ type: "workspaces-list", workspaces: wsList });
909
+ break;
910
+ }
911
+
912
+ case "get-workspace-sessions": {
913
+ if (msg.dirName) {
914
+ const sessions = getWorkspaceSessions(msg.dirName).map(t => ({
915
+ name: t.name, file: t.file, date: t.date.toISOString(),
916
+ }));
917
+ sendToWindow({ type: "workspace-sessions", dirName: msg.dirName, sessions });
918
+ }
919
+ break;
920
+ }
921
+
922
+ case "search-threads": {
923
+ if (msg.query && typeof msg.query === "string" && msg.query.length >= 2 && msg.query.length <= 200) {
924
+ const query = msg.query;
925
+ const allResults: Array<{ name: string; file: string; date: string; matchSnippet: string; workspace: string }> = [];
926
+
927
+ // Search current workspace
928
+ if (lastCtx) {
929
+ const sessionFile = (lastCtx.sessionManager as any).getSessionFile?.() ?? null;
930
+ const sessionDir = sessionFile ? join(sessionFile, "..") : null;
931
+ const results = searchSessionThreads(sessionDir, query);
932
+ for (const r of results) {
933
+ allResults.push({ name: r.name, file: r.file, date: r.date.toISOString(), matchSnippet: r.matchSnippet, workspace: "__current__" });
934
+ }
935
+ }
936
+
937
+ // Search other workspaces
938
+ const home = process.env.HOME || process.env.USERPROFILE || "";
939
+ const sessionsRoot = join(home, ".pi", "agent", "sessions");
940
+ const cwd = lastCtx ? (lastCtx as any).cwd || "" : "";
941
+ const workspaces = getWorkspaces().filter(w => w.path !== cwd);
942
+ for (const ws of workspaces) {
943
+ const wsDir = join(sessionsRoot, ws.dirName);
944
+ const results = searchSessionThreads(wsDir, query);
945
+ for (const r of results) {
946
+ allResults.push({ name: r.name, file: r.file, date: r.date.toISOString(), matchSnippet: r.matchSnippet, workspace: ws.dirName });
947
+ }
948
+ }
949
+
950
+ sendToWindow({ type: "search-results", query, results: allResults });
951
+ }
952
+ break;
953
+ }
954
+
955
+ case "open-folder-path": {
956
+ if (msg.path && typeof msg.path === "string" && msg.path.length < 1000) {
957
+ // Reject path traversal attempts
958
+ if (msg.path.includes("..")) break;
959
+
960
+ const folderPath = msg.path.replace(/\\/g, "/").replace(/\/$/, "");
961
+ // Encode path to session dir name format
962
+ const safePath = `--${folderPath.replace(/^\//, "").replace(/[\/\\:]/g, "-")}--`;
963
+ const home = process.env.HOME || process.env.USERPROFILE || "";
964
+ const sessionsRoot = join(home, ".pi", "agent", "sessions");
965
+ const sessionDir = join(sessionsRoot, safePath);
966
+
967
+ // Verify the session dir is actually under sessions root (prevent traversal via crafted safePath)
968
+ const resolvedSessionDir = resolve(normalize(sessionDir));
969
+ const resolvedSessionsRoot = resolve(sessionsRoot);
970
+ if (!resolvedSessionDir.startsWith(resolvedSessionsRoot + "/") && !resolvedSessionDir.startsWith(resolvedSessionsRoot + "\\")) break;
971
+
972
+ if (existsSync(sessionDir)) {
973
+ // Workspace exists - expand it in sidebar
974
+ const sessions = getSessionThreads(sessionDir).map(t => ({
975
+ name: t.name, file: t.file, date: t.date.toISOString(),
976
+ }));
977
+ sendToWindow({ type: "workspace-opened", dirName: safePath, path: msg.path, sessions });
978
+ } else {
979
+ // No sessions for this path yet
980
+ sendToWindow({ type: "workspace-opened", dirName: safePath, path: msg.path, sessions: [] });
981
+ }
982
+ }
983
+ break;
984
+ }
985
+
986
+ case "set-plan-mode": {
987
+ planMode = msg.active === true;
988
+ if (lastCtx) {
989
+ lastCtx.ui.notify(
990
+ planMode
991
+ ? "Plan Mode ON — pi will only read, search, and analyze. No writes."
992
+ : "Plan Mode OFF — full access restored.",
993
+ "info"
994
+ );
995
+ }
996
+ break;
997
+ }
998
+
999
+ case "close": {
1000
+ closeActiveWindow();
1001
+ break;
1002
+ }
1003
+
1004
+ case "attach-file": {
1005
+ const name = msg.name || "file";
1006
+ const mimeType = msg.mimeType || "application/octet-stream";
1007
+ const base64 = msg.base64;
1008
+ if (!base64) break;
1009
+
1010
+ // Reject oversized attachments (base64 is ~4/3 of original)
1011
+ if (base64.length > MAX_ATTACH_SIZE * 1.37) {
1012
+ sendToWindow({ type: "file-attached-ack", path: "", name, error: "File too large (max 25MB)" });
1013
+ break;
1014
+ }
1015
+
1016
+ try {
1017
+ const ext = extname(name) || (mimeType.startsWith("image/") ? "." + (mimeType.split("/")[1] || "png") : ".bin");
1018
+ // Sanitize filename: strip path separators, restrict to safe characters
1019
+ const safeExt = ext.replace(/[^a-zA-Z0-9.]/g, "").slice(0, 10);
1020
+ const fileName = `pi-attach-${randomUUID()}${safeExt}`;
1021
+ const filePath = join(tmpdir(), fileName);
1022
+ writeFileSync(filePath, Buffer.from(base64, "base64"));
1023
+ sendToWindow({ type: "file-attached-ack", path: filePath, name });
1024
+ } catch (e) {
1025
+ sendToWindow({ type: "file-attached-ack", path: "", name, error: String(e) });
1026
+ }
1027
+ break;
1028
+ }
1029
+
1030
+ case "cancel-streaming": {
1031
+ if (lastCtx && !lastCtx.isIdle()) {
1032
+ lastCtx.abort();
1033
+ }
1034
+ break;
1035
+ }
1036
+
1037
+ case "set-hidden-workspaces": {
1038
+ if (msg.hiddenWorkspaces && typeof msg.hiddenWorkspaces === "object") {
1039
+ // Validate shape: must be Record<string, boolean> — reject anything else
1040
+ const sanitized: Record<string, boolean> = {};
1041
+ for (const [key, val] of Object.entries(msg.hiddenWorkspaces)) {
1042
+ if (typeof key === "string" && typeof val === "boolean" && key.length < 500) {
1043
+ sanitized[key] = val;
1044
+ }
1045
+ }
1046
+ saveHiddenWorkspaces(sanitized);
1047
+ }
1048
+ break;
1049
+ }
1050
+ }
1051
+ }
1052
+
1053
+ // ─── Open Window ──────────────────────────────────────────
1054
+
1055
+ function collectWindowData(ctx: ExtensionContext): DesktopWindowData {
1056
+ const stats = getTokenStats(ctx);
1057
+ const model = ctx.model?.id || "no-model";
1058
+ const thinkingLevel = pi.getThinkingLevel();
1059
+ const sessionFile = (ctx.sessionManager as any).getSessionFile?.() ?? null;
1060
+ const sessionDir = sessionFile ? join(sessionFile, "..") : null;
1061
+ const threads = getSessionThreads(sessionDir).map(t => ({
1062
+ name: t.name, file: t.file, date: t.date.toISOString(),
1063
+ }));
1064
+ const skills = getSkills();
1065
+ const extensions = getExtensions();
1066
+ const allWorkspaces = getWorkspaces().map(w => ({
1067
+ ...w, lastActive: w.lastActive.toISOString(),
1068
+ }));
1069
+ const messages = extractSessionMessages(ctx);
1070
+ const explorerFiles = getDirEntries(ctx.cwd);
1071
+ const commands = getAllCommands(pi);
1072
+
1073
+ return {
1074
+ projectName, gitBranch, model, thinkingLevel,
1075
+ provider: (ctx.model as any)?.provider || "unknown",
1076
+ cwd: ctx.cwd, stats, threads, skills, extensions, workspaces: allWorkspaces, messages, explorerFiles, commands,
1077
+ hiddenWorkspaces: loadHiddenWorkspaces(),
1078
+ };
1079
+ }
1080
+
1081
+ function openDesktopWindow(ctx: ExtensionContext): void {
1082
+ if (activeWindow != null) {
1083
+ ctx.ui.notify("Desktop window is already open.", "warning");
1084
+ return;
1085
+ }
1086
+
1087
+ lastCtx = ctx;
1088
+ const data = collectWindowData(ctx);
1089
+ const html = buildDesktopHtml(data);
1090
+ const win = open(html, {
1091
+ width: 1400,
1092
+ height: 900,
1093
+ title: "pi Desktop",
1094
+ });
1095
+ activeWindow = win;
1096
+
1097
+ // Customize thinking block label while desktop window is open (v0.64.0)
1098
+ try { ctx.ui.setHiddenThinkingLabel?.("thinking (visible in Desktop ◈)"); } catch {}
1099
+
1100
+ win.on("message", handleWindowMessage);
1101
+ win.on("closed", () => {
1102
+ if (activeWindow === win) activeWindow = null;
1103
+ });
1104
+ win.on("error", () => {
1105
+ if (activeWindow === win) activeWindow = null;
1106
+ });
1107
+
1108
+ ctx.ui.notify("Pi Desktop window opened. Chat from here or the window — both are synced.", "info");
1109
+ }
1110
+
1111
+ // ─── Custom Footer ────────────────────────────────────────
1112
+
1113
+ function enableFooter(ctx: ExtensionContext) {
1114
+ ctx.ui.setFooter((tui, theme, footerData) => {
1115
+ const unsub = footerData.onBranchChange(() => tui.requestRender());
1116
+ return {
1117
+ dispose: unsub,
1118
+ invalidate() {},
1119
+ render(width: number): string[] {
1120
+ const branch = footerData.getGitBranch();
1121
+ gitBranch = branch;
1122
+ const stats = getTokenStats(ctx);
1123
+ const model = ctx.model?.id || "no-model";
1124
+ const windowIndicator = activeWindow ? theme.fg("accent", " ◈") : "";
1125
+
1126
+ const left = ` ${theme.fg("text", theme.bold(projectName))}${theme.fg("dim", " / ")}${branch ? theme.fg("accent", branch) : theme.fg("dim", "local")}${theme.fg("dim", " / ")}${theme.fg("dim", model)}${windowIndicator}`;
1127
+ const right = theme.fg("dim", `In ${fmt(stats.input)} Out ${fmt(stats.output)} Cache ${fmt(stats.cache)} $${stats.cost.toFixed(4)}`);
1128
+ const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
1129
+ return [truncateToWidth(left + pad + right, width)];
1130
+ },
1131
+ };
1132
+ });
1133
+ }
1134
+
1135
+ // ─── Context Widget ───────────────────────────────────────
1136
+
1137
+ function enableWidget(ctx: ExtensionContext) {
1138
+ ctx.ui.setWidget("desktop-context", (_tui, theme) => ({
1139
+ render: () => [
1140
+ theme.fg("dim", " ───") + " " + theme.fg("text", `◈ ${projectName}`) + theme.fg("dim", " / ") + theme.fg("accent", gitBranch || "local") + " " + theme.fg("dim", "─".repeat(30))
1141
+ ],
1142
+ invalidate: () => {},
1143
+ }));
1144
+ }
1145
+
1146
+ // ─── CLI Flag ─────────────────────────────────────────────
1147
+
1148
+ pi.registerFlag("desktop", {
1149
+ description: "Auto-open Pi Desktop UI on startup",
1150
+ type: "boolean",
1151
+ default: false,
1152
+ });
1153
+
1154
+ // ─── Commands ─────────────────────────────────────────────
1155
+
1156
+ pi.registerCommand("desktop", {
1157
+ description: "Open pi Desktop window (fully functional chat UI)",
1158
+ handler: async (_args, ctx) => {
1159
+ openDesktopWindow(ctx);
1160
+ },
1161
+ });
1162
+
1163
+ pi.registerCommand("nav", {
1164
+ description: "Open pi Desktop navigation window",
1165
+ handler: async (_args, ctx) => {
1166
+ openDesktopWindow(ctx);
1167
+ },
1168
+ });
1169
+
1170
+ // ─── Keyboard Shortcut ────────────────────────────────────
1171
+
1172
+ pi.registerShortcut(Key.ctrlAlt("n"), {
1173
+ description: "Open pi Desktop window",
1174
+ handler: async (ctx) => {
1175
+ openDesktopWindow(ctx);
1176
+ },
1177
+ });
1178
+
1179
+ // ─── Session Lifecycle ────────────────────────────────────
1180
+
1181
+ // Unified session lifecycle — v0.65.0 removed session_switch and session_fork.
1182
+ // Use session_start with event.reason ("startup" | "reload" | "new" | "resume" | "fork").
1183
+ pi.on("session_start", async (event, ctx) => {
1184
+ if (!ctx.hasUI) return;
1185
+
1186
+ const reason = (event as any).reason || "startup";
1187
+ const previousSessionFile = (event as any).previousSessionFile || null;
1188
+ sessionReason = reason;
1189
+
1190
+ projectName = getProjectName(ctx.cwd);
1191
+ lastCtx = ctx;
1192
+ enableFooter(ctx);
1193
+ enableWidget(ctx);
1194
+ ctx.ui.setStatus("desktop", ctx.ui.theme.fg("dim", "◈ Desktop"));
1195
+
1196
+ // Notify desktop window of session change with reason context
1197
+ if (reason !== "startup") {
1198
+ const messages = extractSessionMessages(ctx);
1199
+ const stats = getTokenStats(ctx);
1200
+ sendToWindow({
1201
+ type: "session-changed",
1202
+ reason,
1203
+ previousSessionFile,
1204
+ projectName,
1205
+ model: ctx.model?.id || "no-model",
1206
+ messages,
1207
+ stats,
1208
+ });
1209
+ }
1210
+
1211
+ // Auto-open desktop window if --desktop flag or PI_DESKTOP env is set
1212
+ if (reason === "startup") {
1213
+ const desktopFlag = pi.getFlag("desktop");
1214
+ const desktopEnv = process.env.PI_DESKTOP === "1";
1215
+ if (desktopFlag || desktopEnv) {
1216
+ setTimeout(() => openDesktopWindow(ctx), 500);
1217
+ }
1218
+ }
1219
+ });
1220
+
1221
+ pi.on("session_shutdown", async () => {
1222
+ closeActiveWindow();
1223
+ });
1224
+ }