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