/** * Desktop TUI Extension — Fully Functional Native Chat Window * * Opens a Glimpse native webview window that mirrors the pi terminal session: * - Full bidirectional chat: send messages, stream responses in real-time * - Markdown-rendered assistant messages with syntax highlighting * - Tool execution indicators (tool calls, results) * - Sidebar: threads, skills, settings, explorer, workspace * - Ctrl+Alt+N or /nav or /desktop to open * - Custom footer + context widget in the terminal */ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { join, basename, dirname, extname, resolve, normalize } from "node:path"; import { fileURLToPath } from "node:url"; import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import { exec, spawn, spawnSync } from "node:child_process"; import { getSupportedThinkingLevels, type AssistantMessage, type ModelThinkingLevel } from "@hhyy668/pi-ai"; import { getAgentDir, SettingsManager, DefaultPackageManager, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext } from "@hhyy668/pi-coding-agent"; import { Key, matchesKey, truncateToWidth, visibleWidth } from "@hhyy668/pi-tui"; import { open } from "glimpseui"; import { formatCompactErrorResult } from "./compact-utils.js"; import { getDesktopCommandRequest } from "./desktop-command-utils.js"; import { DEFAULT_DESKTOP_WINDOW_MAX_BYTES, DEFAULT_DESKTOP_WINDOW_MAX_MESSAGES, createDesktopWindowTransport, getDesktopWindowMessageMetadata, migrateDesktopWindowTransportState, } from "./desktop-window-transport.js"; import { createExtensionContext, createExtensionDiscovery } from "./extension-discovery.js"; import { createExtensionViewBridge } from "./extension-view-bridge.js"; import { isHostUIDialogRequest, isHostUIEvent, migrateHostUIUnsubscribers, replaceHostUISubscriptions, } from "./host-ui-utils.js"; import { readConfiguredLanguage, saveConfiguredLanguage } from "./i18n-utils.js"; import { getSystemOpenCommand, isMessageFileWithinWorkspace, resolveMessageLinkTarget } from "./message-link-utils.js"; import { getThinkingDisplayLevel, normalizeThinkingLevels } from "./thinking-level-utils.js"; import { isTemporaryWorkspacePath } from "./workspace-utils.js"; import { buildModelsUrl, buildUpdatedCredential, fetchProviderModels, getRegistryRefreshError, mergeProviderDocument, mayDeleteProviderCredential, parseJsonDocument, parseModelListResponse, persistProviderTransaction, redactProviderConfig, removeProviderFromDocument, resolveDiscoveryApiKey, resolveDiscoveryHeaderValue, restoreCredentialIfUnchanged, restoreDocumentIfUnchanged, sanitizeProviderError, sensitiveHeaderEnvName, validateProviderDraft, } from "./model-provider-utils.js"; import { ANSI_RE, buildSkillInstallArgs, getProjectSkillDirs, isSafeSkillSearchQuery, normalizeSkillApiResults, parseSkillSearchOutput, } from "./skill-package-utils.js"; type GlimpseWindow = ReturnType; type HostUIDialogRequest = | { id: string; method: "select"; title: string; options: string[]; timeout?: number } | { id: string; method: "confirm"; title: string; message: string; timeout?: number } | { id: string; method: "input"; title: string; placeholder?: string; timeout?: number; mask?: boolean }; type HostUIDialogResponse = | { id: string; status: "submitted"; value: string | boolean } | { id: string; status: "cancelled" | "unavailable" }; type HostUIDialogOffer = { request: HostUIDialogRequest; claim(): ((response: HostUIDialogResponse) => void) | undefined; }; type HostUIEvent = | { method: "notify"; message: string; notifyType?: "info" | "warning" | "error" } | { method: "setStatus"; key: string; text: string | undefined } | { method: "setWorkingMessage"; message: string | undefined } | { method: "setWorkingVisible"; visible: boolean } | { method: "dismissDialog"; id: string }; const HOST_UI_DIALOG_CHANNEL = "pi:host-ui:dialog"; const HOST_UI_EVENT_CHANNEL = "pi:host-ui:event"; const HOST_UI_DIALOG_RECEIPT_TIMEOUT_MS = 5_000; // Persist the Glimpse window reference across extension reloads (e.g. /new, /reload). // When pi starts a new session, extensions are re-instantiated — local variables die. // Storing on globalThis lets the new instance "adopt" the surviving window. const existingDesktopGlobal = (globalThis as any).__piDesktop; const __piDesktopCandidate: Record = existingDesktopGlobal && typeof existingDesktopGlobal === "object" && !Array.isArray(existingDesktopGlobal) ? existingDesktopGlobal : {}; const survivingWindow = __piDesktopCandidate.window != null && typeof __piDesktopCandidate.window === "object" ? __piDesktopCandidate.window as GlimpseWindow : null; __piDesktopCandidate.window = survivingWindow; __piDesktopCandidate.windowTransportState = migrateDesktopWindowTransportState(__piDesktopCandidate.windowTransportState, survivingWindow); migrateHostUIUnsubscribers(__piDesktopCandidate); const __piDesktop = __piDesktopCandidate as { window: GlimpseWindow | null; windowTransportState: ReturnType; hostUIUnsubscribers: Array<() => void>; }; (globalThis as any).__piDesktop = __piDesktop; const __dirname = dirname(fileURLToPath(import.meta.url)); const webDir = join(__dirname, "web"); const SKILL_INSTALL_ALLOWLIST_TTL_MS = 10 * 60 * 1000; const skillInstallAllowlist = new Map(); let skillSearchRequestSeq = 0; const extensionDiscovery = createExtensionDiscovery({ agentDir: getAgentDir(), createSettingsManager: (cwd, agentDir, options) => SettingsManager.create(cwd, agentDir, options), createPackageManager: options => new DefaultPackageManager(options), }); // ─── Security Helpers ──────────────────────────────────────── const MAX_ATTACH_SIZE = 25 * 1024 * 1024; // 25 MB max attachment const MAX_PROVIDER_RESPONSE_BYTES = 2 * 1024 * 1024; const PROVIDER_FETCH_TIMEOUT_MS = 15_000; function openWithSystemDefault(target: string): void { const { command, args } = getSystemOpenCommand(process.platform, target); const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true, }); child.on("error", error => console.error(`[desktop] system open failed: ${String(error)}`)); child.unref(); } function readJsonDocument(path: string): Record { if (!existsSync(path)) return {}; return parseJsonDocument(readFileSync(path, "utf8")); } function withFileLock(path: string, operation: () => T): T { const lockPath = `${path}.lock`; const deadline = Date.now() + 10_000; let fd: number | undefined; while (fd === undefined) { try { fd = openSync(lockPath, "wx", 0o600); } catch (error: any) { if (error?.code !== "EEXIST" || Date.now() >= deadline) throw new Error(`Could not acquire configuration lock for ${path}.`); try { if (Date.now() - statSync(lockPath).mtimeMs > 30_000) unlinkSync(lockPath); } catch {} Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25); } } try { return operation(); } finally { closeSync(fd); try { unlinkSync(lockPath); } catch {} } } function writeJsonAtomic(path: string, value: unknown): void { mkdirSync(dirname(path), { recursive: true }); const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`; let fd: number | undefined; try { fd = openSync(temporary, "wx", 0o600); writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`, "utf8"); closeSync(fd); fd = undefined; renameSync(temporary, path); } catch (error) { if (fd !== undefined) closeSync(fd); try { if (existsSync(temporary)) unlinkSync(temporary); } catch {} throw error; } } async function readBoundedResponse(response: Response): Promise { if (!response.body) return ""; const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let total = 0; while (true) { const { done, value } = await reader.read(); if (done) break; total += value.byteLength; if (total > MAX_PROVIDER_RESPONSE_BYTES) { await reader.cancel(); throw new Error("Provider response exceeded the 2 MiB limit."); } chunks.push(value); } const combined = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.byteLength; } return new TextDecoder().decode(combined); } /** Validate that a path is within allowed directories (sessions, cwd, or home). */ function isPathAllowed(filePath: string, ctx: { cwd: string } | null): boolean { const home = process.env.HOME || process.env.USERPROFILE || ""; const resolved = resolve(normalize(filePath)); const sessionsDir = resolve(join(home, ".pi", "agent", "sessions")); // Allow paths under the sessions directory if (resolved.startsWith(sessionsDir + "/") || resolved.startsWith(sessionsDir + "\\")) return true; // Allow paths under the current working directory if (ctx) { const cwdResolved = resolve(ctx.cwd); if (resolved.startsWith(cwdResolved + "/") || resolved.startsWith(cwdResolved + "\\") || resolved === cwdResolved) return true; } return false; } /** Validate that a session file path is a .jsonl file inside the sessions directory. */ function isValidSessionFile(filePath: string): boolean { const home = process.env.HOME || process.env.USERPROFILE || ""; const resolved = resolve(normalize(filePath)); const sessionsDir = resolve(join(home, ".pi", "agent", "sessions")); if (!resolved.endsWith(".jsonl")) return false; if (!resolved.startsWith(sessionsDir + "/") && !resolved.startsWith(sessionsDir + "\\")) return false; // Reject path traversal attempts if (filePath.includes("..")) return false; return true; } /** Feature-detect the upstream ExtensionContext.runCommandAction API (added in pi-mono). * When absent, GUI-driven resume/fork must hard-reject and point the user at the CLI. */ function hasRunCommandAction(ctx: ExtensionContext | null): boolean { return typeof (ctx as any)?.runCommandAction === "function"; } /** Read the recorded cwd from a session file's header line (first JSONL line: * `{"type":"session",...,"cwd":"..."}`). Returns null if unreadable/absent. */ function getSessionRecordedCwd(sessionFile: string): string | null { try { const content = readFileSync(sessionFile, "utf-8"); const firstLine = content.split("\n").find(l => l.trim().length > 0); if (!firstLine) return null; const parsed = JSON.parse(firstLine); if (parsed && parsed.type === "session" && typeof parsed.cwd === "string" && parsed.cwd.length > 0) { return parsed.cwd; } } catch {} return null; } // ─── Hidden Workspaces Persistence ─────────────────────────── function getHiddenWorkspacesPath(): string { const home = process.env.HOME || process.env.USERPROFILE || ""; return join(home, ".pi", "agent", "hidden-workspaces.json"); } function loadHiddenWorkspaces(): Record { try { const fp = getHiddenWorkspacesPath(); if (existsSync(fp)) return JSON.parse(readFileSync(fp, "utf-8")); } catch {} return {}; } function saveHiddenWorkspaces(hidden: Record): void { try { writeFileSync(getHiddenWorkspacesPath(), JSON.stringify(hidden, null, 2)); } catch {} } const BUILTIN_COMMANDS = [ { name: "settings", description: "Open settings menu" }, { name: "model", description: "Select model (opens selector UI)" }, { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" }, { name: "export", description: "Export session (HTML default, or specify path)" }, { name: "import", description: "Import and resume a session from a JSONL file" }, { name: "share", description: "Share session as a secret GitHub gist" }, { name: "copy", description: "Copy last agent message to clipboard" }, { name: "name", description: "Set session display name" }, { name: "session", description: "Show session info and stats" }, { name: "changelog", description: "Show changelog entries" }, { name: "hotkeys", description: "Show all keyboard shortcuts" }, { name: "fork", description: "Create a new fork from a previous message" }, { name: "tree", description: "Navigate session tree (switch branches)" }, { name: "login", description: "Login with OAuth provider" }, { name: "logout", description: "Logout from OAuth provider" }, { name: "new", description: "Start a new session" }, { name: "compact", description: "Manually compact the session context" }, { name: "resume", description: "Resume a different session" }, { name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes" }, { name: "quit", description: "Quit pi" }, ]; function getAllCommands(pi: ExtensionAPI) { const extCommands = pi.getCommands().map(c => ({ name: c.name, description: c.description || "", source: (c as any).sourceInfo?.source || "extension", scope: (c as any).sourceInfo?.scope || "", path: (c as any).sourceInfo?.path || "", })); const extNames = new Set(extCommands.map(c => c.name)); return [ ...BUILTIN_COMMANDS.filter(c => !extNames.has(c.name)).map(c => ({ ...c, source: "built-in", scope: "app", path: "" })), ...extCommands, ].sort((a, b) => a.name.localeCompare(b.name)); } // ─── Helpers ───────────────────────────────────────────────── function getProjectName(cwd: string): string { return basename(cwd); } function fmt(n: number): string { if (n < 1000) return `${n}`; if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`; return `${(n / 1_000_000).toFixed(1)}M`; } function getTokenStats(ctx: ExtensionContext) { let input = 0, output = 0, cost = 0, cache = 0; try { for (const e of ctx.sessionManager.getBranch()) { if (e.type === "message" && e.message.role === "assistant") { const m = e.message as AssistantMessage; input += m.usage.input; output += m.usage.output; cost += m.usage.cost.total; cache += (m.usage as any).cacheRead ?? 0; } } } catch { return { input, output, cost, cache }; } return { input, output, cost, cache }; } function getSessionThreads(sessionDir: string | null) { if (!sessionDir || !existsSync(sessionDir)) return []; try { return readdirSync(sessionDir) .filter(f => f.endsWith(".jsonl")) .map(f => { const fp = join(sessionDir, f); const stat = statSync(fp); let name = f.replace(".jsonl", ""); try { const content = readFileSync(fp, "utf-8"); const line = content.split("\n").find(l => l.includes('"role":"user"') || l.includes('"role": "user"')); if (line) { const parsed = JSON.parse(line); const msgContent = parsed?.message?.content; let text = ""; if (Array.isArray(msgContent)) { const textBlock = msgContent.find((b: any) => b.type === "text"); if (textBlock?.text) text = textBlock.text; } else if (typeof msgContent === "string") { text = msgContent; } if (text.length > 0) name = text.slice(0, 70).replace(/\n/g, " "); } } catch {} return { name, file: fp, date: stat.mtime }; }) .sort((a, b) => b.date.getTime() - a.date.getTime()); } catch { return []; } } type SkillSearchResult = { package: string; installs: string; url: string }; function parseSkillDescription(skillFile: string): string { try { const content = readFileSync(skillFile, "utf-8"); const match = content.match(/description[:\s]*['"]*(.+?)['"]?\s*$/im); if (match) return match[1]!.trim().slice(0, 160); } catch {} return ""; } function addSkillFromDir(skills: { name: string; desc: string }[], dir: string, entry: string): void { const skillFile = join(dir, entry, "SKILL.md"); if (!existsSync(skillFile)) return; if (!skills.find(s => s.name === entry)) { skills.push({ name: entry, desc: parseSkillDescription(skillFile) }); } } function addSkillFile(skills: { name: string; desc: string }[], filePath: string): void { if (!filePath.toLowerCase().endsWith(".md")) return; const name = basename(filePath, extname(filePath)); if (!skills.find(s => s.name === name)) { skills.push({ name, desc: parseSkillDescription(filePath) }); } } function getSkills(cwd?: string) { const skills: { name: string; desc: string }[] = []; const home = process.env.HOME || process.env.USERPROFILE || ""; const globalSkillDir = join(home, ".pi", "agent", "skills"); const projectSkillDir = cwd ? join(cwd, ".pi", "skills") : ""; const dirs = [globalSkillDir, join(home, ".agents", "skills"), ...getProjectSkillDirs(cwd)]; for (const dir of dirs) { if (!existsSync(dir)) continue; try { for (const entry of readdirSync(dir)) { const entryPath = join(dir, entry); try { if (statSync(entryPath).isDirectory()) addSkillFromDir(skills, dir, entry); else if (dir === globalSkillDir || dir === projectSkillDir) addSkillFile(skills, entryPath); } catch {} } } catch {} } return skills; } function runCommandCapture(command: string, args: string[], options: { cwd?: string; timeoutMs: number }): Promise { return new Promise((resolvePromise, reject) => { const executable = process.platform === "win32" ? "cmd.exe" : command; const executableArgs = process.platform === "win32" ? ["/d", "/s", "/c", command, ...args] : args; const child = spawn(executable, executableArgs, { cwd: options.cwd, env: { ...process.env, FORCE_COLOR: "0" }, shell: false, }); let output = ""; const timer = setTimeout(() => { try { child.kill(); } catch {} reject(new Error("Command timed out.")); }, options.timeoutMs); child.stdout?.on("data", chunk => { output += chunk.toString(); }); child.stderr?.on("data", chunk => { output += chunk.toString(); }); child.on("error", err => { clearTimeout(timer); reject(err); }); child.on("close", code => { clearTimeout(timer); if (code === 0) resolvePromise(output.replace(ANSI_RE, "")); else reject(new Error(output.replace(ANSI_RE, "").trim() || `Command exited with ${code}`)); }); }); } async function searchSkillPackages(query: string, limit = 20): Promise { const trimmed = query.trim(); if (!trimmed || trimmed.length > 100) throw new Error("Search query must be 1-100 characters."); const safeLimit = Math.min(50, Math.max(1, Math.floor(limit))); const apiBase = process.env.SKILLS_API_URL || "https://skills.sh"; try { const response = await fetch(`${apiBase}/api/search?q=${encodeURIComponent(trimmed)}&limit=${safeLimit}`, { cache: "no-store" }); if (!response.ok) throw new Error(`skills.sh search failed: HTTP ${response.status}`); const data = await response.json() as { skills?: Array<{ id?: string; name?: string; source?: string; installs?: number }> }; return normalizeSkillApiResults(data.skills || [], apiBase); } catch { if (!isSafeSkillSearchQuery(trimmed)) throw new Error("Search query contains unsupported characters for CLI fallback."); const output = await runCommandCapture("npx", ["skills", "find", trimmed], { timeoutMs: 20_000 }); return parseSkillSearchOutput(output, safeLimit); } } async function installSkillPackage(pkg: string, scope: "global" | "project", cwd?: string): Promise { if (scope === "project" && !cwd) throw new Error("Project install requires an active workspace."); const args = buildSkillInstallArgs(pkg, scope); return runCommandCapture("npx", args, { cwd: scope === "project" ? cwd : undefined, timeoutMs: 120_000 }); } function rememberSkillSearchResults(results: SkillSearchResult[]): void { const expiresAt = Date.now() + SKILL_INSTALL_ALLOWLIST_TTL_MS; for (const result of results) skillInstallAllowlist.set(result.package, expiresAt); } function isSkillInstallAllowed(pkg: string): boolean { const expiresAt = skillInstallAllowlist.get(pkg); if (!expiresAt) return false; if (expiresAt < Date.now()) { skillInstallAllowlist.delete(pkg); return false; } return true; } function decodeSessionDirName(dirName: string): string { // Reverse of: `--${cwd.replace(/^[\/\\]/, "").replace(/[\/\\:]/g, "-")}--` // The encoding is lossy: both path separators and literal hyphens become "-". // We use filesystem probing to resolve ambiguity. let decoded = dirName.replace(/^--/, "").replace(/--$/, ""); const winMatch = decoded.match(/^([A-Za-z])--(.*)$/); if (winMatch) { // Windows path: drive letter + rest const drive = winMatch[1] + ":\\"; const rest = winMatch[2]; if (!rest) return drive; // Split on "-" and greedily resolve by checking which segments exist on disk const parts = rest.split("-"); return drive + resolvePathSegments(drive, parts); } else { // Unix path const parts = decoded.split("-"); return "/" + resolvePathSegments("/", parts); } } /** Greedily resolve ambiguous hyphen-separated segments by probing the filesystem. */ function resolvePathSegments(base: string, parts: string[]): string { if (parts.length === 0) return ""; // Try joining progressively more parts with hyphens (greedy longest match) // At each position, find the longest segment that exists as a child of the current base let result: string[] = []; let i = 0; while (i < parts.length) { let bestLen = 0; // Try joining parts[i..j] with "-" to form a single path segment // Check longest first for greedy match for (let j = parts.length; j > i; j--) { const candidate = parts.slice(i, j).join("-"); const testPath = join(base, ...result, candidate); try { if (existsSync(testPath)) { bestLen = j - i; break; } } catch {} } if (bestLen > 0) { result.push(parts.slice(i, i + bestLen).join("-")); i += bestLen; } else { // No match found on disk — fall back to single segment result.push(parts[i]); i++; } } return result.join("\\"); } function getWorkspaces() { const home = process.env.HOME || process.env.USERPROFILE || ""; const sessionsRoot = join(home, ".pi", "agent", "sessions"); if (!existsSync(sessionsRoot)) return []; const workspaces: { name: string; path: string; dirName: string; sessionCount: number; lastActive: Date; isTemporary: boolean }[] = []; try { for (const dirName of readdirSync(sessionsRoot)) { const dirPath = join(sessionsRoot, dirName); try { if (!statSync(dirPath).isDirectory()) continue; } catch { continue; } // Skip temp/worktree dirs to reduce noise if (dirName.includes("pi-gui-workspace") || dirName.includes("pi-gui-git-workspace") || dirName.includes("worktrees")) continue; const decodedPath = decodeSessionDirName(dirName); const name = basename(decodedPath) || decodedPath; // Count sessions and find most recent let sessionCount = 0; let lastActive = new Date(0); try { for (const f of readdirSync(dirPath)) { if (!f.endsWith(".jsonl")) continue; sessionCount++; try { const mtime = statSync(join(dirPath, f)).mtime; if (mtime > lastActive) lastActive = mtime; } catch {} } } catch {} if (sessionCount === 0) continue; workspaces.push({ name, path: decodedPath, dirName, sessionCount, lastActive, isTemporary: isTemporaryWorkspacePath(decodedPath), }); } } catch {} // Sort by most recently active workspaces.sort((a, b) => b.lastActive.getTime() - a.lastActive.getTime()); return workspaces; } function getWorkspaceSessions(dirName: string) { // Reject traversal attempts if (dirName.includes("..") || dirName.includes("/") || dirName.includes("\\")) return []; const home = process.env.HOME || process.env.USERPROFILE || ""; const dirPath = join(home, ".pi", "agent", "sessions", dirName); return getSessionThreads(dirPath); } function searchSessionThreads(sessionDir: string | null, query: string): Array<{ name: string; file: string; date: Date; matchSnippet: string }> { if (!sessionDir || !existsSync(sessionDir) || !query) return []; const lowerQuery = query.toLowerCase(); const results: Array<{ name: string; file: string; date: Date; matchSnippet: string }> = []; try { const files = readdirSync(sessionDir).filter(f => f.endsWith(".jsonl")); for (const f of files) { const fp = join(sessionDir, f); const stat = statSync(fp); let threadName = f.replace(".jsonl", ""); let matchSnippet = ""; try { const content = readFileSync(fp, "utf-8"); const lines = content.split("\n"); let firstUserText = ""; for (const line of lines) { if (!line.trim()) continue; try { const entry = JSON.parse(line); if (entry.type !== "message") continue; const msg = entry.message; if (!msg) continue; let text = ""; if (Array.isArray(msg.content)) { for (const b of msg.content) { if (b.type === "text" && b.text) text += b.text + " "; } } else if (typeof msg.content === "string") { text = msg.content; } text = text.trim(); if (!text) continue; if (msg.role === "user" && !firstUserText) firstUserText = text; if (text.toLowerCase().includes(lowerQuery)) { // Extract snippet around the match const idx = text.toLowerCase().indexOf(lowerQuery); const start = Math.max(0, idx - 30); const end = Math.min(text.length, idx + query.length + 50); matchSnippet = (start > 0 ? "..." : "") + text.slice(start, end).replace(/\n/g, " ") + (end < text.length ? "..." : ""); break; } } catch {} } if (firstUserText) threadName = firstUserText.slice(0, 70).replace(/\n/g, " "); if (matchSnippet) { results.push({ name: threadName, file: fp, date: stat.mtime, matchSnippet }); } } catch {} } } catch {} results.sort((a, b) => b.date.getTime() - a.date.getTime()); return results; } /** Known content-block shape rendered in the desktop UI. All fields optional so * unexpected shapes (e.g. bashExecution entries) are handled without throwing. */ type MessageContentBlock = { type?: string; text?: string; data?: string; mimeType?: string; name?: string; input?: unknown; }; /** Safely extract concatenated text from a message content field. * Accepts a string, an array of content blocks, or anything else (→ ""). */ function getTextBlocks(content: unknown): string { if (typeof content === "string") return content; if (Array.isArray(content)) { let text = ""; for (const block of content as MessageContentBlock[]) { if (block && block.type === "text" && typeof block.text === "string") text += block.text; } return text; } return ""; } /** Safely extract image blocks from a message content field. */ function getImageBlocks(content: unknown): Array<{ data: string; mimeType: string }> { const images: Array<{ data: string; mimeType: string }> = []; if (Array.isArray(content)) { for (const block of content as MessageContentBlock[]) { if (block && block.type === "image" && block.data) { images.push({ data: block.data, mimeType: block.mimeType || "image/png" }); } } } return images; } /** Safely extract tool_use blocks (assistant tool calls) from a content field. */ function getToolUseBlocks(content: unknown): Array<{ role: string; content: string; toolName?: string }> { const calls: Array<{ role: string; content: string; toolName?: string }> = []; if (Array.isArray(content)) { for (const block of content as MessageContentBlock[]) { if (block && block.type === "tool_use") { calls.push({ role: "tool", content: JSON.stringify(block.input || {}, null, 2).slice(0, 300), toolName: block.name, }); } } } return calls; } function extractSessionMessages(ctx: ExtensionContext) { const messages: Array<{ role: string; content: string; toolName?: string; images?: Array<{ data: string; mimeType: string }> }> = []; // Buffer images from toolResult entries to attach to the next assistant message let pendingImages: Array<{ data: string; mimeType: string }> = []; for (const e of ctx.sessionManager.getBranch()) { if (e.type === "compaction") { // Show compacted history as a summary message const ce = e as any; if (ce.summary) { messages.push({ role: "assistant", content: ce.summary }); } } if (e.type === "custom_message") { const cm = e as any; if (cm.display && cm.content) { const text = getTextBlocks(cm.content); if (text.trim()) messages.push({ role: "assistant", content: text.trim() }); } } if (e.type !== "message") continue; const msg = e.message; // Narrow by role before touching content; only user/assistant/toolResult // carry content in the shapes rendered here. Anything else (e.g. a // bashExecution message) is skipped without accessing .content. const role = (msg as { role?: string }).role; const content = (msg as { content?: unknown }).content; if (role === "toolResult") { // Extract image blocks from tool results for (const img of getImageBlocks(content)) pendingImages.push(img); // Cap at 5 images per tool result to avoid bloating if (pendingImages.length > 5) pendingImages = pendingImages.slice(-5); } else if (role === "user") { const text = getTextBlocks(content); if (text.trim()) messages.push({ role: "user", content: text.trim() }); } else if (role === "assistant") { const text = getTextBlocks(content); // Add tool calls before the text response messages.push(...getToolUseBlocks(content)); if (text.trim()) { const assistMsg: typeof messages[0] = { role: "assistant", content: text.trim() }; // Attach buffered images from preceding toolResult if (pendingImages.length > 0) { assistMsg.images = pendingImages; pendingImages = []; } messages.push(assistMsg); } } } return messages; } function extractThreadMessages(filePath: string) { const messages: Array<{ role: string; content: string; toolName?: string; images?: Array<{ data: string; mimeType: string }> }> = []; let pendingImages: Array<{ data: string; mimeType: string }> = []; try { const content = readFileSync(filePath, "utf-8"); for (const line of content.split("\n")) { if (!line.trim()) continue; try { const entry = JSON.parse(line); if (entry.type === "compaction" && entry.summary) { messages.push({ role: "assistant", content: entry.summary }); continue; } if (entry.type === "custom_message" && entry.display && entry.content) { const text = getTextBlocks(entry.content); if (text.trim()) messages.push({ role: "assistant", content: text.trim() }); continue; } if (entry.type !== "message") continue; const msg = entry.message; // Narrow by role before touching content; only user/assistant/toolResult // carry content in the shapes rendered here. Anything else (e.g. a // bashExecution message) is skipped without accessing .content. // Named msgContent to avoid shadowing the outer file-string `content`. const role = msg?.role; const msgContent = msg?.content; if (role === "toolResult") { // Extract image blocks from tool results for (const img of getImageBlocks(msgContent)) pendingImages.push(img); if (pendingImages.length > 5) pendingImages = pendingImages.slice(-5); } else if (role === "user") { const text = getTextBlocks(msgContent); if (text.trim()) messages.push({ role: "user", content: text.trim() }); } else if (role === "assistant") { const text = getTextBlocks(msgContent); messages.push(...getToolUseBlocks(msgContent)); if (text.trim()) { const assistMsg: typeof messages[0] = { role: "assistant", content: text.trim() }; if (pendingImages.length > 0) { assistMsg.images = pendingImages; pendingImages = []; } messages.push(assistMsg); } } } catch {} } } catch {} return messages; } function getDirEntries(dir: string) { try { return readdirSync(dir) .filter(f => !f.startsWith(".") && f !== "node_modules" && f !== "__pycache__") .map(f => { try { const stat = statSync(join(dir, f)); return { name: f, isDir: stat.isDirectory(), size: stat.isDirectory() ? "" : formatSize(stat.size), path: join(dir, f) }; } catch { return { name: f, isDir: false, size: "", path: join(dir, f) }; } }) .sort((a, b) => { if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; return a.name.localeCompare(b.name); }); } catch { return []; } } function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes}B`; if (bytes < 1048576) return `${(bytes / 1024).toFixed(0)}K`; return `${(bytes / 1048576).toFixed(1)}M`; } /** Escape non-ASCII chars to \uXXXX so only ASCII bytes pass through Glimpse's * Windows webview bridge — prevents UTF-8 → CP1252 mojibake (e.g. — → ΓÇö). */ function escapeNonAscii(str: string): string { return str.replace(/[^\x00-\x7F]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`); } const DESKTOP_SESSION_MAX_MESSAGES = 50; const DESKTOP_SESSION_MAX_MESSAGE_CHARS = 8_000; const DESKTOP_SESSION_MAX_THREADS = 100; const DESKTOP_SESSION_FALLBACK_BYTES = 64 * 1024; const DESKTOP_SESSION_TRUNCATION_MARKER = "\n[truncated]"; const DESKTOP_RESYNC_HEADROOM_RATIO = 0.1; const DESKTOP_RESYNC_MAX_HEADROOM_BYTES = 64 * 1024; const DESKTOP_RESYNC_RESERVED_MESSAGES = 2; const DESKTOP_RESYNC_BATCH_MESSAGES = 3; type DesktopSessionMessage = { role: string; content: string; toolName?: string; images?: Array<{ data: string; mimeType: string }>; [key: string]: unknown; }; type DesktopSessionThread = { name?: unknown; file?: unknown; date?: unknown; [key: string]: unknown; }; type DesktopSessionSnapshot = { type?: unknown; messages?: unknown; threads?: unknown; [key: string]: unknown; }; function truncateDesktopSessionString(value: unknown, maxChars: number): string { const text = typeof value === "string" ? value : String(value ?? ""); if (text.length <= maxChars) return text; const prefixLength = Math.max(0, maxChars - DESKTOP_SESSION_TRUNCATION_MARKER.length); return text.slice(0, prefixLength) + DESKTOP_SESSION_TRUNCATION_MARKER; } export function boundDesktopSessionMessages(messages: unknown): DesktopSessionMessage[] { if (!Array.isArray(messages)) return []; return messages.slice(-DESKTOP_SESSION_MAX_MESSAGES).map(message => { const record = message && typeof message === "object" ? message as Partial : {}; const { images: _images, ...bounded } = record; return { ...bounded, role: truncateDesktopSessionString(record.role, 100), content: truncateDesktopSessionString(record.content, DESKTOP_SESSION_MAX_MESSAGE_CHARS), }; }); } function boundDesktopSessionThreads(threads: unknown): DesktopSessionThread[] { if (!Array.isArray(threads)) return []; return threads.slice(0, DESKTOP_SESSION_MAX_THREADS).map(thread => { const record = thread && typeof thread === "object" ? thread as DesktopSessionThread : {}; return { ...record, name: truncateDesktopSessionString(record.name, 500), file: truncateDesktopSessionString(record.file, 2_000), date: truncateDesktopSessionString(record.date, 100), }; }); } export function createDesktopWindowEvalScript(message: unknown): string { const json = JSON.stringify(message); if (json === undefined) throw new TypeError("Desktop window messages must be JSON serializable"); return escapeNonAscii(`window.__desktopReceive(JSON.parse(${JSON.stringify(json)}))`); } function getDesktopWindowEvalScriptBytes(message: unknown): number { return new TextEncoder().encode(createDesktopWindowEvalScript(message)).byteLength; } export function createBoundedDesktopSessionSnapshot( snapshot: DesktopSessionSnapshot, maxBytes = DEFAULT_DESKTOP_WINDOW_MAX_BYTES, ): DesktopSessionSnapshot & { type: "session-changed"; messages: DesktopSessionMessage[]; threads: DesktopSessionThread[] } { const byteLimit = Number.isSafeInteger(maxBytes) && maxBytes > 0 ? maxBytes : DEFAULT_DESKTOP_WINDOW_MAX_BYTES; const bounded = { ...snapshot, type: "session-changed" as const, reason: truncateDesktopSessionString(snapshot.reason, 100), previousSessionFile: snapshot.previousSessionFile == null ? null : truncateDesktopSessionString(snapshot.previousSessionFile, 2_000), projectName: truncateDesktopSessionString(snapshot.projectName, 500), model: truncateDesktopSessionString(snapshot.model, 500), provider: truncateDesktopSessionString(snapshot.provider, 500), thinkingDisplayLevel: truncateDesktopSessionString(snapshot.thinkingDisplayLevel, 100), thinkingLevels: Array.isArray(snapshot.thinkingLevels) ? snapshot.thinkingLevels.slice(0, 20).map(level => truncateDesktopSessionString(level, 100)) : [], messages: boundDesktopSessionMessages(snapshot.messages), threads: boundDesktopSessionThreads(snapshot.threads), }; const fits = () => getDesktopWindowEvalScriptBytes(bounded) <= byteLimit; while (!fits() && bounded.messages.length > 1) bounded.messages.shift(); while (!fits() && bounded.threads.length > 1) bounded.threads.pop(); if (!fits() && bounded.messages.length === 1) { const latest = bounded.messages[0]; const content = latest.content.endsWith(DESKTOP_SESSION_TRUNCATION_MARKER) ? latest.content.slice(0, -DESKTOP_SESSION_TRUNCATION_MARKER.length) : latest.content; let low = 0; let high = content.length; while (low < high) { const middle = Math.ceil((low + high) / 2); latest.content = content.slice(0, middle) + DESKTOP_SESSION_TRUNCATION_MARKER; if (fits()) low = middle; else high = middle - 1; } latest.content = content.slice(0, low) + DESKTOP_SESSION_TRUNCATION_MARKER; } while (!fits() && bounded.threads.length > 0) bounded.threads.pop(); if (!fits()) throw new RangeError("Desktop session metadata exceeds the transport byte budget"); return bounded; } type DesktopWindowStateMessage = Record & { type: string }; type DesktopWindowResyncPlan = { sendable: boolean; complete: boolean; issue: "message-budget-exhausted" | "static-payload-overflow" | "resync-budget-exhausted" | null; extensionMessage: DesktopWindowStateMessage; thinkingMessage: DesktopWindowStateMessage; boundedSessionSnapshot: ReturnType | null; sessionByteBudget: number; staticBytes: number; headroomBytes: number; reservedMessages: number; }; function createDesktopResyncExtensionFallback(): DesktopWindowStateMessage { return { type: "update-skills", skills: [], extensions: [], extensionsLoading: false, extensionsError: "Extension snapshot omitted because it exceeded the desktop transport budget.", }; } function createDesktopResyncThinkingFallback(message: DesktopWindowStateMessage): DesktopWindowStateMessage { return { type: "thinking-level", thinkingLevel: truncateDesktopSessionString(message.thinkingLevel, 100), thinkingDisplayLevel: truncateDesktopSessionString(message.thinkingDisplayLevel, 100), thinkingLevels: Array.isArray(message.thinkingLevels) ? message.thinkingLevels.slice(0, 20).map(level => truncateDesktopSessionString(level, 100)) : [], }; } export function createDesktopWindowResyncPlan({ sessionSnapshot, extensionMessage, thinkingMessage, maxBytes = DEFAULT_DESKTOP_WINDOW_MAX_BYTES, maxMessages = DEFAULT_DESKTOP_WINDOW_MAX_MESSAGES, }: { sessionSnapshot: DesktopSessionSnapshot; extensionMessage: DesktopWindowStateMessage; thinkingMessage: DesktopWindowStateMessage; maxBytes?: number; maxMessages?: number; }): DesktopWindowResyncPlan { const byteLimit = Number.isSafeInteger(maxBytes) && maxBytes > 0 ? maxBytes : DEFAULT_DESKTOP_WINDOW_MAX_BYTES; const messageLimit = Number.isSafeInteger(maxMessages) && maxMessages > 0 ? maxMessages : DEFAULT_DESKTOP_WINDOW_MAX_MESSAGES; const headroomBytes = Math.min(DESKTOP_RESYNC_MAX_HEADROOM_BYTES, Math.floor(byteLimit * DESKTOP_RESYNC_HEADROOM_RATIO)); const base = { headroomBytes, reservedMessages: DESKTOP_RESYNC_RESERVED_MESSAGES, }; if (messageLimit < DESKTOP_RESYNC_BATCH_MESSAGES + DESKTOP_RESYNC_RESERVED_MESSAGES) { return { ...base, sendable: false, complete: false, issue: "message-budget-exhausted", extensionMessage, thinkingMessage, boundedSessionSnapshot: null, sessionByteBudget: 0, staticBytes: 0, }; } const build = ( selectedExtensionMessage: DesktopWindowStateMessage, selectedThinkingMessage: DesktopWindowStateMessage, complete: boolean, issue: DesktopWindowResyncPlan["issue"], ): DesktopWindowResyncPlan | null => { let staticBytes: number; try { staticBytes = getDesktopWindowEvalScriptBytes(selectedExtensionMessage) + getDesktopWindowEvalScriptBytes(selectedThinkingMessage); } catch { return null; } const sessionByteBudget = byteLimit - headroomBytes - staticBytes; if (sessionByteBudget <= 0) return null; try { return { ...base, sendable: true, complete, issue, extensionMessage: selectedExtensionMessage, thinkingMessage: selectedThinkingMessage, boundedSessionSnapshot: createBoundedDesktopSessionSnapshot(sessionSnapshot, sessionByteBudget), sessionByteBudget, staticBytes, }; } catch { return null; } }; const completePlan = build(extensionMessage, thinkingMessage, true, null); if (completePlan) return completePlan; const fallbackExtensionMessage = createDesktopResyncExtensionFallback(); const fallbackThinkingMessage = createDesktopResyncThinkingFallback(thinkingMessage); const fallbackPlan = build(fallbackExtensionMessage, fallbackThinkingMessage, false, "static-payload-overflow"); if (fallbackPlan) return fallbackPlan; return { ...base, sendable: false, complete: false, issue: "resync-budget-exhausted", extensionMessage: fallbackExtensionMessage, thinkingMessage: fallbackThinkingMessage, boundedSessionSnapshot: null, sessionByteBudget: 0, staticBytes: 0, }; } type DesktopWindowResyncErrorCode = "plan-unavailable" | "batch-rejected" | "incomplete"; export class DesktopWindowResyncError extends Error { readonly code: DesktopWindowResyncErrorCode; readonly issue: DesktopWindowResyncPlan["issue"]; constructor(code: DesktopWindowResyncErrorCode, issue: DesktopWindowResyncPlan["issue"] = null) { super(`Desktop window resync ${code}${issue ? `: ${issue}` : ""}`); this.name = "DesktopWindowResyncError"; this.code = code; this.issue = issue; } } export function sendDesktopWindowResyncPlan( plan: DesktopWindowResyncPlan, { sendSession, sendMessage, }: { sendSession: (sessionByteBudget: number) => boolean; sendMessage: (message: DesktopWindowStateMessage) => boolean; }, ): true { if (!plan.sendable) throw new DesktopWindowResyncError("plan-unavailable", plan.issue); const sentSession = sendSession(plan.sessionByteBudget); const sentExtension = sendMessage(plan.extensionMessage); const sentThinking = sendMessage(plan.thinkingMessage); if (!sentSession || !sentExtension || !sentThinking) { throw new DesktopWindowResyncError("batch-rejected"); } if (!plan.complete) throw new DesktopWindowResyncError("incomplete", plan.issue); return true; } export function sendBoundedDesktopSessionSnapshot( snapshot: DesktopSessionSnapshot, send: (message: DesktopSessionSnapshot) => boolean, maxBytes = DEFAULT_DESKTOP_WINDOW_MAX_BYTES, ): boolean { const primary = createBoundedDesktopSessionSnapshot(snapshot, maxBytes); if (send(primary)) return true; const fallbackSource = { ...snapshot, messages: boundDesktopSessionMessages(snapshot.messages).slice(-1).map(message => ({ ...message, content: truncateDesktopSessionString(message.content, 1_000), })), threads: boundDesktopSessionThreads(snapshot.threads).slice(0, 1), }; const fallback = createBoundedDesktopSessionSnapshot( fallbackSource, Math.min(maxBytes, DESKTOP_SESSION_FALLBACK_BYTES), ); return send(fallback); } // ─── HTML Builder ──────────────────────────────────────────── interface DesktopWindowData { projectName: string; gitBranch: string | null; model: string; thinkingLevel: ModelThinkingLevel; thinkingDisplayLevel: string; thinkingLevels: ModelThinkingLevel[]; language: string; provider: string; cwd: string; stats: { input: number; output: number; cache: number; cost: number }; threads: Array<{ name: string; file: string; date: string }>; skills: Array<{ name: string; desc: string }>; extensions: Array<{ name: string; source: string | null; sourceKind: "package" | "auto" | "settings"; type: "package" | "auto" | "local"; scope: "user" | "project"; path: string; }>; extensionsLoading: boolean; extensionsError: string | null; workspaces: Array<{ name: string; path: string; dirName: string; sessionCount: number; lastActive: string; isTemporary: boolean }>; messages: Array<{ role: string; content: string; toolName?: string; images?: Array<{ data: string; mimeType: string }> }>; explorerFiles: Array<{ name: string; isDir: boolean; size: string; path: string }>; commands: Array<{ name: string; description: string; source: string; scope: string; path: string }>; hiddenWorkspaces: Record; } // Injected into the webview (before app.js and vendor scripts run) so // otherwise-silent webview crashes surface visibly. The webview has no devtools // in the shipped build, so uncaught errors would otherwise vanish. Handlers log // with a clear prefix AND paint a fixed error banner into the window. const WEBVIEW_ERROR_BOOTSTRAP = ``; function buildDesktopHtml(data: DesktopWindowData): string { const templateHtml = readFileSync(join(webDir, "index.html"), "utf8"); const rawAppJs = readFileSync(join(webDir, "app.js"), "utf8"); const messageLinkUtilsJs = readFileSync(join(webDir, "message-link-utils.js"), "utf8").replace(/\bexport\s+/g, ""); const modelProviderViewJs = readFileSync(join(webDir, "model-provider-view.js"), "utf8").replace(/\bexport\s+/g, ""); const thinkingLevelUtilsJs = readFileSync(join(__dirname, "thinking-level-utils.js"), "utf8").replace(/\bexport\s+/g, ""); const appJs = `${thinkingLevelUtilsJs}\n${messageLinkUtilsJs}\n${rawAppJs}`; const desktopAppJs = `${modelProviderViewJs}\n${appJs}`; const languageToggleJs = readFileSync(join(webDir, "language-toggle-utils.js"), "utf8"); const requestIdUtilsJs = readFileSync(join(webDir, "request-id-utils.js"), "utf8").replace(/\bexport\s+/g, ""); const modelProviderStateJs = readFileSync(join(webDir, "model-provider-state.js"), "utf8").replace(/\bexport\s+/g, ""); const temporaryWorkspaceJs = readFileSync(join(webDir, "temporary-workspace-utils.js"), "utf8"); const vendorDir = join(webDir, "vendor"); const vendorFiles = [ "tailwind-browser.js", "marked.min.js", "purify.min.js", "katex.min.css", "katex.min.js", "github.min.css", "github-dark.min.css", ]; const vendorAssets = new Map(vendorFiles.map(file => [ file, readFileSync(join(vendorDir, file), "utf8"), ])); // Base64-encode JSON to avoid Glimpse webview bridge corruption. // The bridge (about:blank + WebView2 on Windows) can corrupt control characters // and \uXXXX escapes during transfer. Base64 is pure alphanumeric + /+= and // survives any encoding conversion. const rawJson = JSON.stringify(data); let base64Json = Buffer.from(rawJson, 'utf8').toString('base64'); // Safety: WebView2 NavigateToString has a hard 2MB limit. // If the full HTML would exceed it, strip messages from data and retry. const vendorSize = Array.from(vendorAssets.values()).reduce((sum, content) => sum + content.length, 0); const vendorMarkerSize = vendorFiles.reduce((sum, file) => sum + `__INLINE_VENDOR:${file}__`.length, 0); const staticSize = templateHtml.length + appJs.length + modelProviderViewJs.length + 1 + languageToggleJs.length + requestIdUtilsJs.length + modelProviderStateJs.length + temporaryWorkspaceJs.length + vendorSize - "__INLINE_DATA__".length - "__INLINE_LANGUAGE_TOGGLE_JS__".length - "__INLINE_REQUEST_ID_UTILS_JS__".length - "__INLINE_MODEL_PROVIDER_STATE_JS__".length - "__INLINE_TEMPORARY_WORKSPACE_JS__".length - "__INLINE_JS__".length - vendorMarkerSize; const MAX_HTML_SIZE = 1_900_000; // leave 100KB headroom under 2MB if (staticSize + base64Json.length > MAX_HTML_SIZE) { data.messages = data.messages.slice(-10); const fallbackJson = JSON.stringify(data); base64Json = Buffer.from(fallbackJson, 'utf8').toString('base64'); } // Use split+join instead of .replace() to avoid $-pattern interpretation let result = templateHtml.split("__INLINE_BOOTSTRAP__").join(WEBVIEW_ERROR_BOOTSTRAP); result = result.split("__INLINE_DATA__").join(base64Json); for (const [file, content] of vendorAssets) { result = result.split(`__INLINE_VENDOR:${file}__`).join(content); } result = result.split("__INLINE_LANGUAGE_TOGGLE_JS__").join(languageToggleJs); result = result.split("__INLINE_REQUEST_ID_UTILS_JS__").join(requestIdUtilsJs); result = result.split("__INLINE_MODEL_PROVIDER_STATE_JS__").join(modelProviderStateJs); result = result.split("__INLINE_TEMPORARY_WORKSPACE_JS__").join(temporaryWorkspaceJs); result = result.split("__INLINE_JS__").join(desktopAppJs); return result; } // ─── Extension Entry Point ─────────────────────────────────── export default function desktopTuiExtension(pi: ExtensionAPI) { let projectName = ""; let gitBranch: string | null = null; let activeWindow: GlimpseWindow | null = __piDesktop.window; const desktopWindowTransport = createDesktopWindowTransport({ state: __piDesktop.windowTransportState, onOverflow: ({ action, droppedMessages, rejectedMessages, queueMessages, queueBytes, maxMessages, maxBytes, category, critical }) => { console.warn(`[desktop] window transport overflow: action=${action}; category=${category}; critical=${critical}; dropped=${droppedMessages}; rejected=${rejectedMessages}; queued=${queueMessages}/${maxMessages}; bytes=${queueBytes}/${maxBytes}`); }, onError: ({ code, count, bytes, category }) => { console.error(`[desktop] window transport error: code=${code}; category=${category}; count=${count}; bytes=${bytes}`); }, onResync: () => sendWindowResync(), }); if (activeWindow) desktopWindowTransport.adopt(activeWindow); let lastCtx: ExtensionContext | null = null; let activeExtensionKey: string | null = null; let lastCommandCtx: ExtensionCommandContext | null = null; let activeExplorerCwd: string | null = null; // override CWD when viewing another workspace let sessionReason: string = "startup"; let sessionTransitioning = false; // true while a session switch is in progress (prevents window close) let compacting = false; // true while a context compaction is in progress (blocks concurrent /compact) let planMode: boolean = false; let pendingDesktopUserMessage: boolean = false; // true when a user message originated from the desktop UI (suppresses steer-message echo) let pendingResponseImages: Array<{ data: string; mimeType: string }> = []; // images from tool results to attach to next assistant response const pendingHostUIDialogs = new Map< string, { method: HostUIDialogRequest["method"]; respond: (response: HostUIDialogResponse) => void; receiptTimer: ReturnType } >(); const extensionStatuses = new Map(); let hostUIReady = false; let hostUIClientId: string | undefined; let hostWorkingMessage: string | undefined; let hostWorkingVisible = true; const PLAN_MODE_PREFIX = `[PLAN MODE ACTIVE — You are in read-only plan mode. STRICT RULES: 1. Do NOT use edit, write, or any tool that modifies files 2. Do NOT run bash commands that create, modify, or delete files (no mkdir, rm, mv, cp, touch, tee, sed -i, etc.) 3. ONLY use: read, grep, find, ls, parallel_search, parallel_research, parallel_extract, todo, subagent (scout only) 4. Safe bash allowed: git log, git diff, git status, cat, head, tail, wc, echo, pwd, env, which, type 5. Focus on: reading code, analyzing architecture, creating plans, reviewing scaffolding, identifying patterns 6. If the user asks you to write or edit, remind them Plan Mode is active and suggest they turn it off first] `; // ─── Window Communication ───────────────────────────────── function setActiveWindow(win: GlimpseWindow | null): void { const previous = activeWindow; if (win) desktopWindowTransport.activate(win); else if (previous) desktopWindowTransport.clear(previous); activeWindow = win; __piDesktop.window = win; } function sendToWindow(message: any): boolean { if (!activeWindow) return false; const metadata = getDesktopWindowMessageMetadata(message?.type); try { const js = createDesktopWindowEvalScript(message); return desktopWindowTransport.send(activeWindow, js, metadata); } catch { console.error(`[desktop] window transport encode failed: category=${metadata.category}`); return false; } } function getThinkingSnapshot(model: ExtensionContext["model"], level: ModelThinkingLevel = pi.getThinkingLevel()) { const thinkingLevels = normalizeThinkingLevels(model ? getSupportedThinkingLevels(model) : ["off"]) as ModelThinkingLevel[]; return { thinkingLevel: level, thinkingDisplayLevel: getThinkingDisplayLevel(model, level), thinkingLevels, }; } function getThinkingMessage(model: ExtensionContext["model"], level: ModelThinkingLevel = pi.getThinkingLevel()) { return { type: "thinking-level", ...getThinkingSnapshot(model, level) }; } function sendThinkingSnapshot(model: ExtensionContext["model"], level: ModelThinkingLevel = pi.getThinkingLevel()): void { sendToWindow(getThinkingMessage(model, level)); } function extensionContextFor(ctx: ExtensionContext) { return createExtensionContext(ctx.cwd, ctx.isProjectTrusted()); } const extensionViewBridge = createExtensionViewBridge({ discovery: extensionDiscovery, getActiveKey: () => activeExtensionKey, getSkills, send: sendToWindow, }); function activateExtensionContext(ctx: ExtensionContext) { const captured = extensionContextFor(ctx); activeExtensionKey = captured.key; return captured; } function refreshExtensionsForContext(ctx: ExtensionContext, force = false) { const captured = extensionContextFor(ctx); return extensionViewBridge.refresh(captured, { force }); } function reportExtensionRefreshFailure(error: unknown): void { console.error("[desktop] extension refresh failed:", error); } function startExtensionRefresh(pending: Promise): void { void pending.catch(reportExtensionRefreshFailure); } function resolvePendingHostDialogs(status: "cancelled" | "unavailable"): void { for (const [id, pending] of pendingHostUIDialogs) { clearTimeout(pending.receiptTimer); pending.respond({ id, status }); } pendingHostUIDialogs.clear(); } function sendHostUIState(): void { sendToWindow({ type: "host-ui-status", statuses: Array.from(extensionStatuses.entries()) }); sendToWindow({ type: "host-ui-working", message: hostWorkingMessage, visible: hostWorkingVisible }); } function sendWindowResync(): void { sendHostUIState(); if (!lastCtx) return; const captured = extensionContextFor(lastCtx); const extensionMessage = extensionViewBridge.getMessage(captured); const thinkingMessage = getThinkingMessage(lastCtx.model); const sessionSnapshot = createSessionSnapshot(lastCtx, "resync"); const resyncPlan = createDesktopWindowResyncPlan({ sessionSnapshot, extensionMessage, thinkingMessage, }); if (!resyncPlan.sendable) { console.error(`[desktop] window resync unavailable: issue=${resyncPlan.issue}`); throw new Error(`Desktop window resync unavailable: ${resyncPlan.issue}`); } if (!resyncPlan.complete) { console.error(`[desktop] window resync degraded: issue=${resyncPlan.issue}`); } sendDesktopWindowResyncPlan(resyncPlan, { sendSession: sessionByteBudget => sendSessionSnapshot(lastCtx, "resync", null, sessionByteBudget, sessionSnapshot), sendMessage: sendToWindow, }); } function handleHostUIDialogOffer(data: unknown): void { if (!activeWindow || !hostUIReady || !data || typeof data !== "object") return; const offer = data as HostUIDialogOffer; const request = offer.request; if (!isHostUIDialogRequest(request) || typeof offer.claim !== "function") return; const respond = offer.claim(); if (!respond) return; const receiptTimer = setTimeout(() => { const pending = pendingHostUIDialogs.get(request.id); if (!pending) return; pendingHostUIDialogs.delete(request.id); pending.respond({ id: request.id, status: "unavailable" }); }, HOST_UI_DIALOG_RECEIPT_TIMEOUT_MS); pendingHostUIDialogs.set(request.id, { method: request.method, respond, receiptTimer }); sendToWindow({ type: "host-ui-dialog", request }); } function handleHostUIEvent(data: unknown): void { if (!isHostUIEvent(data)) return; const event = data as HostUIEvent; switch (event.method) { case "notify": sendToWindow({ type: "host-ui-notify", message: event.message.replace(ANSI_RE, ""), notifyType: event.notifyType }); break; case "setStatus": if (event.text === undefined) extensionStatuses.delete(event.key); else extensionStatuses.set(event.key, event.text.replace(ANSI_RE, "")); sendToWindow({ type: "host-ui-status", statuses: Array.from(extensionStatuses.entries()) }); break; case "setWorkingMessage": hostWorkingMessage = event.message?.replace(ANSI_RE, ""); sendToWindow({ type: "host-ui-working", message: hostWorkingMessage, visible: hostWorkingVisible }); break; case "setWorkingVisible": hostWorkingVisible = event.visible; sendToWindow({ type: "host-ui-working", message: hostWorkingMessage, visible: hostWorkingVisible }); break; case "dismissDialog": { const pending = pendingHostUIDialogs.get(event.id); if (pending) clearTimeout(pending.receiptTimer); pendingHostUIDialogs.delete(event.id); sendToWindow({ type: "host-ui-dialog-dismiss", id: event.id }); break; } } } replaceHostUISubscriptions(__piDesktop, [ () => pi.events.on(HOST_UI_DIALOG_CHANNEL, handleHostUIDialogOffer), () => pi.events.on(HOST_UI_EVENT_CHANNEL, handleHostUIEvent), ], ({ code, count }) => { console.error(`[desktop] Host UI subscription error: code=${code}; count=${count}`); }); // AuthStorage.set/remove persist synchronously but swallow write failures (recording them via // drainErrors instead of throwing) and no-op entirely when a prior loadError is set. A failed auth.json // write would otherwise pass silently, leaving models.json referencing a credential that was never // stored. Surface any recorded error as a throw so persistProviderTransaction can roll models.json back. function assertAuthWritePersisted(authStorage: any): void { const errors = authStorage?.drainErrors?.() ?? []; if (errors.length) throw new Error(`Failed to persist provider credential: ${errors.map((e: any) => (e && e.message) || String(e)).join("; ")}`); } // Every secret tied to a provider draft (submitted key/headers plus the persisted credential and // its env values), so it can be redacted from any error surfaced to the frontend. function collectProviderSecrets(draft: any): string[] { const stored = lastCtx?.modelRegistry.authStorage.get(draft?.id); const submittedHeaderValues = Array.isArray(draft?.headers) ? draft.headers.map((header: any) => header?.value) : []; return [draft?.apiKey, ...submittedHeaderValues, ...(stored?.type === "api_key" ? [stored.key, ...Object.values(stored.env ?? {})] : [])] .filter((value): value is string => typeof value === "string" && value.length > 0); } function sendContextUsage(ctx: ExtensionContext): void { const u = ctx.getContextUsage(); sendToWindow({ type: "context-usage", usage: u ?? null }); } function extractToolResultPayload(result: any): { resultText: string; resultImages: Array<{ data: string; mimeType: string }> } { let resultText = ""; const resultImages: Array<{ data: string; mimeType: string }> = []; try { if (typeof result === "string") { resultText = result; } else if (result?.content) { if (Array.isArray(result.content)) { resultText = result.content .filter((c: any) => c.type === "text") .map((c: any) => c.text) .join("\n"); for (const block of result.content) { if ((block as any).type === "image" && (block as any).data) { resultImages.push({ data: (block as any).data, mimeType: (block as any).mimeType || "image/png", }); } } } else if (typeof result.content === "string") { resultText = result.content; } } else if (result != null) { resultText = JSON.stringify(result, null, 2); } } catch { resultText = "(result unavailable)"; } return { resultText, resultImages }; } function createSessionSnapshot(ctx: ExtensionContext, reason: string, previousSessionFile: string | null = null): DesktopSessionSnapshot { const messages = extractSessionMessages(ctx); const stats = getTokenStats(ctx); const sessionFile = (ctx.sessionManager as any).getSessionFile?.() ?? null; const sessionDir = sessionFile ? join(sessionFile, "..") : null; const threads = getSessionThreads(sessionDir).map(t => ({ name: t.name, file: t.file, date: t.date.toISOString(), })); return { type: "session-changed", reason, previousSessionFile, projectName, model: ctx.model?.id || "no-model", provider: ctx.model?.provider || "unknown", ...getThinkingSnapshot(ctx.model), messages, stats, threads, }; } function sendSessionSnapshot( ctx: ExtensionContext, reason: string, previousSessionFile: string | null = null, maxBytes = DEFAULT_DESKTOP_WINDOW_MAX_BYTES, snapshot = createSessionSnapshot(ctx, reason, previousSessionFile), ): boolean { return sendBoundedDesktopSessionSnapshot(snapshot, sendToWindow, maxBytes); } function closeActiveWindow(): void { if (activeWindow == null) return; const w = activeWindow; resolvePendingHostDialogs("cancelled"); hostUIReady = false; setActiveWindow(null); try { w.close(); } catch {} // Clear custom thinking label when desktop window closes try { lastCtx?.ui?.setHiddenThinkingLabel?.(undefined as any); } catch {} } // ─── Streaming Event Handlers (global) ──────────────────── pi.on("agent_start", (_event, _ctx) => { pendingResponseImages = []; // clear any stale images from previous turn sendToWindow({ type: "agent-start" }); }); pi.on("agent_end", (_event, ctx) => { lastCtx = ctx; const stats = getTokenStats(ctx); sendToWindow({ type: "agent-end" }); sendToWindow({ type: "stats-update", stats }); sendContextUsage(ctx); }); pi.on("before_provider_request", (_event, ctx) => { const model = ctx.model; lastCtx = ctx; sendToWindow({ type: "model-info", model: model?.id ?? "", provider: model?.provider ?? "", }); sendThinkingSnapshot(model); }); pi.on("message_start", (event, _ctx) => { const msg = event.message; if (msg.role === "assistant") { sendToWindow({ type: "message-start", role: "assistant" }); } else if (msg.role === "user") { // Skip forwarding user messages that originated from the desktop UI // (the frontend already added them locally — forwarding would cause duplicates). if (pendingDesktopUserMessage) { pendingDesktopUserMessage = false; return; } // Forward user messages from steers (e.g. subagent completion) // to the desktop window so they appear in the chat. let text = ""; if (typeof msg.content === "string") { text = msg.content; } else if (Array.isArray(msg.content)) { for (const block of msg.content) { if ((block as any).type === "text") text += (block as any).text; } } if (text) { sendToWindow({ type: "steer-message", content: text }); } } }); pi.on("message_update", (event, _ctx) => { const evt = event.assistantMessageEvent as any; switch (evt.type) { case "text_delta": sendToWindow({ type: "message-chunk", text: evt.delta }); break; case "text_start": sendToWindow({ type: "message-chunk-start" }); break; case "text_end": sendToWindow({ type: "message-chunk-end", content: evt.content }); break; case "thinking_delta": sendToWindow({ type: "thinking-chunk", text: evt.delta }); break; case "thinking_start": sendToWindow({ type: "thinking-start" }); break; case "thinking_end": sendToWindow({ type: "thinking-end" }); break; case "toolcall_start": sendToWindow({ type: "toolcall-stream-start", contentIndex: evt.contentIndex }); break; case "toolcall_end": sendToWindow({ type: "toolcall-stream-end", contentIndex: evt.contentIndex, toolName: evt.toolCall?.name }); break; } }); pi.on("message_end", (event, ctx) => { lastCtx = ctx; sendContextUsage(ctx); const msg = event.message; if (msg.role === "assistant") { let text = ""; if (Array.isArray(msg.content)) { for (const block of msg.content) { if ((block as any).type === "text") text += (block as any).text; } } // Attach any buffered images from tool results to the assistant response const images = pendingResponseImages.length > 0 ? pendingResponseImages.splice(0) : undefined; sendToWindow({ type: "message-end", role: "assistant", content: text, images }); } }); pi.on("session_before_compact", (event, _ctx) => { compacting = true; sendToWindow({ type: "compaction-start", reason: (event as any).reason }); }); pi.on("session_compact", (event, ctx) => { compacting = false; lastCtx = ctx; sendToWindow({ type: "compaction-end", reason: (event as any).reason }); sendContextUsage(ctx); }); pi.on("session_info_changed", (_event, ctx) => { const captured = activateExtensionContext(ctx); startExtensionRefresh(extensionViewBridge.refresh(captured)); lastCtx = ctx; sendSessionSnapshot(ctx, "info_changed"); sendContextUsage(ctx); }); pi.on("session_tree", (_event, ctx) => { const captured = activateExtensionContext(ctx); startExtensionRefresh(extensionViewBridge.refresh(captured)); lastCtx = ctx; sendSessionSnapshot(ctx, "tree"); sendContextUsage(ctx); }); pi.on("tool_execution_start", (event, _ctx) => { // Warn in desktop window if a write tool fires during plan mode if (planMode) { const writeTools = new Set(["edit", "write", "claude"]); if (writeTools.has(event.toolName) || (event.toolName === "bash" && event.args?.command)) { sendToWindow({ type: "plan-mode-violation", toolName: event.toolName, argsPreview: JSON.stringify(event.args || {}).slice(0, 200), }); } } // Format args for display let argsDisplay = ""; let editDiffs: Array<{ oldText: string; newText: string }> | null = null; let editPath = ""; try { const args = event.args; if (event.toolName === "bash" && args?.command) { argsDisplay = args.command; } else if (event.toolName === "read" && args?.path) { argsDisplay = `read ${args.path}` + (args.offset ? ` (offset: ${args.offset})` : ""); } else if (event.toolName === "edit" && args?.path) { editPath = args.path; // Handle both formats: edits[] array (normal) and legacy top-level oldText/newText let edits: Array<{oldText: string; newText: string}> = Array.isArray(args.edits) ? args.edits : []; if (edits.length === 0 && typeof args.oldText === "string" && typeof args.newText === "string") { edits = [{ oldText: args.oldText, newText: args.newText }]; } editDiffs = edits.map((e: any) => ({ oldText: e.oldText || "", newText: e.newText || "", })); argsDisplay = `edit ${args.path} (${edits.length} edit(s))`; } else if (event.toolName === "write" && args?.path) { argsDisplay = `write ${args.path}`; } else if (event.toolName === "grep" && args?.pattern) { argsDisplay = `grep "${args.pattern}"` + (args.path ? ` in ${args.path}` : ""); } else if (event.toolName === "find" && args?.path) { argsDisplay = `find ${args.path}` + (args.glob ? ` -name ${args.glob}` : ""); } else if (event.toolName === "ls" && args?.path) { argsDisplay = `ls ${args.path}`; } else { argsDisplay = JSON.stringify(args || {}, null, 2).slice(0, 500); } } catch { argsDisplay = "..."; } // For edit tools, encode diffs as a flat base64 string to survive Glimpse bridge const editDiffsB64 = editDiffs ? Buffer.from(JSON.stringify(editDiffs)).toString("base64") : ""; sendToWindow({ type: "tool-start", toolName: event.toolName, toolCallId: event.toolCallId, argsDisplay, editDiffsB64, editPath, }); }); pi.on("model_select", (event, ctx) => { const model = event.model; lastCtx = ctx; // Update desktop window immediately when user switches model (Ctrl+P, /model) sendToWindow({ type: "model-info", model: model?.id ?? "", provider: model?.provider ?? "", }); sendThinkingSnapshot(model); }); pi.on("thinking_level_select", (event, ctx) => { const model = ctx.model; const currentLevel = pi.getThinkingLevel(); lastCtx = ctx; // Update desktop window immediately when user cycles thinking level (Shift+Tab, /think) sendThinkingSnapshot(model, event.level === currentLevel ? event.level : currentLevel); }); pi.on("tool_execution_update", (event, _ctx) => { const { resultText, resultImages } = extractToolResultPayload((event as any).partialResult); sendToWindow({ type: "tool-update", toolName: event.toolName, toolCallId: event.toolCallId, resultText: resultText.slice(0, 3000), resultImages: resultImages.slice(0, 5), }); }); pi.on("tool_execution_end", (event, _ctx) => { // Extract result text and images const { resultText, resultImages } = extractToolResultPayload(event.result); // Buffer images for the next assistant response if (resultImages.length > 0) { pendingResponseImages.push(...resultImages.slice(0, 5)); } sendToWindow({ type: "tool-end", toolName: event.toolName, toolCallId: event.toolCallId, isError: event.isError, resultText: resultText.slice(0, 3000), resultImages: resultImages.slice(0, 5), }); }); // NOTE: Removed input mirroring - it caused duplicate messages. // The window adds user messages locally when sent from the window. // Terminal messages appear via the streaming events (message_start/end). // ─── Window Message Handler ─────────────────────────────── async function handleWindowMessage(msg: any): Promise { if (!msg || typeof msg !== "object") return; if (!extensionViewBridge.isEnabled()) return; switch (msg.type) { case "host-ui-ready": { const clientId = typeof msg.clientId === "string" ? msg.clientId : ""; if (!clientId) break; if (hostUIClientId && hostUIClientId !== clientId) resolvePendingHostDialogs("unavailable"); hostUIClientId = clientId; hostUIReady = true; sendHostUIState(); break; } case "host-ui-dialog-received": { const pending = pendingHostUIDialogs.get(typeof msg.id === "string" ? msg.id : ""); if (pending) clearTimeout(pending.receiptTimer); break; } case "host-ui-dialog-response": { const id = typeof msg.id === "string" ? msg.id : ""; const pending = pendingHostUIDialogs.get(id); if (!pending) break; clearTimeout(pending.receiptTimer); pendingHostUIDialogs.delete(id); const validValue = pending.method === "confirm" ? typeof msg.value === "boolean" : typeof msg.value === "string"; if (msg.status === "submitted" && validValue) { pending.respond({ id, status: "submitted", value: msg.value }); } else { pending.respond({ id, status: "cancelled" }); } break; } case "open-link": { const openCtx = activeExplorerCwd ? { cwd: activeExplorerCwd } : lastCtx; const target = resolveMessageLinkTarget(msg.href, { cwd: openCtx?.cwd, platform: process.platform, }); if (!target) break; if (target.kind === "file") { if (!openCtx || !existsSync(target.target)) break; try { const realFile = realpathSync(target.target); const realWorkspace = realpathSync(openCtx.cwd); if (!isMessageFileWithinWorkspace(realFile, realWorkspace, process.platform)) break; if (!statSync(realFile).isFile()) break; target.target = realFile; } catch { break; } } try { openWithSystemDefault(target.target); } catch (error) { console.error(`[desktop] failed to open link: ${String(error)}`); } break; } case "send-message": { const text = (msg.text || "").trim(); if (!text || text.length > 100_000) break; if (text.startsWith("/")) { // Slash command from desktop UI. const desktopRequest = getDesktopCommandRequest(text); if (desktopRequest) { await handleWindowMessage(desktopRequest); break; } // 1. Show desktop card for commands we can render natively // 2. Inject into terminal so the real command executes there too const ctx = lastCommandCtx; if (ctx) { try { showDesktopCard(text, ctx); } catch {} } injectIntoTerminal(text); } else { const finalText = planMode ? PLAN_MODE_PREFIX + text : text; pendingDesktopUserMessage = true; try { pi.sendUserMessage(finalText); } catch (err) { pendingDesktopUserMessage = false; sendToWindow({ type: "command-result", command: "send-message", success: false, message: "Session context expired — please resend your message." }); } } break; } case "open-thread": { if (msg.file && isValidSessionFile(msg.file)) { const threadMsgs = extractThreadMessages(msg.file); sendToWindow({ type: "thread-messages", messages: threadMsgs, threadIdx: msg.index ?? 0 }); // Switch explorer CWD when viewing another workspace's thread if (msg.workspace && typeof msg.workspace === "string" && msg.workspace !== "__current__") { const decodedPath = decodeSessionDirName(msg.workspace); if (existsSync(decodedPath)) { activeExplorerCwd = decodedPath; } } else { activeExplorerCwd = null; // back to current workspace } } break; } case "nav": { if (msg.action === "explorer") { const cwd = activeExplorerCwd || lastCtx?.cwd; if (cwd) { const files = getDirEntries(cwd); sendToWindow({ type: "explorer-data", files }); sendToWindow({ type: "explorer-tree-children", parentPath: cwd, children: files }); } } break; } case "explorer-tree-expand": { // Expand a directory in the sidebar tree const explorerCtx = activeExplorerCwd ? { cwd: activeExplorerCwd } : lastCtx; if (msg.path && isPathAllowed(msg.path, explorerCtx)) { try { const stat = statSync(msg.path); if (stat.isDirectory()) { const children = getDirEntries(msg.path); sendToWindow({ type: "explorer-tree-children", parentPath: msg.path, children }); } } catch {} } break; } case "explorer-open": { const openCtx = activeExplorerCwd ? { cwd: activeExplorerCwd } : lastCtx; if (msg.path && isPathAllowed(msg.path, openCtx)) { try { const stat = statSync(msg.path); if (stat.isDirectory()) { const files = getDirEntries(msg.path); sendToWindow({ type: "explorer-data", files }); } else if (stat.isFile()) { // Images and binaries → open with system default app const imageExts = new Set(["png","jpg","jpeg","gif","bmp","svg","webp","ico","tiff","tif"]); const binaryExts = new Set(["pdf","doc","docx","xls","xlsx","ppt","pptx","zip","tar","gz","exe","dll","so","dylib","mp3","mp4","mov","avi","wav"]); const ext = extname(msg.path).slice(1).toLowerCase(); if (imageExts.has(ext) || binaryExts.has(ext)) { // Open with system default app const escapedPath = msg.path.replace(/"/g, '\\"'); const cmd = process.platform === "win32" ? `start "" "${escapedPath}"` : process.platform === "darwin" ? `open "${escapedPath}"` : `xdg-open "${escapedPath}"`; exec(cmd); } else { // Text file → read and send content const MAX_FILE_SIZE = 512 * 1024; if (stat.size > MAX_FILE_SIZE) { 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 }); } else { const content = readFileSync(msg.path, "utf8"); sendToWindow({ type: "file-content", path: msg.path, name: basename(msg.path), ext, content, size: stat.size }); } } } } catch {} } break; } case "get-model-providers": { const requestId = typeof msg.requestId === "string" ? msg.requestId : ""; try { if (!lastCtx) throw new Error("No active session context."); const modelsPath = join(getAgentDir(), "models.json"); if (!modelsPath) throw new Error("The active model registry has no models.json path."); const document = readJsonDocument(modelsPath); const providers = document.providers && typeof document.providers === "object" && !Array.isArray(document.providers) ? Object.entries(document.providers).map(([id, config]) => { const credential = lastCtx!.modelRegistry.authStorage.get(id); return redactProviderConfig( id, config as Record, { ...lastCtx!.modelRegistry.getProviderAuthStatus(id), oauth: credential?.type === "oauth" }, ); }) : []; sendToWindow({ type: "model-providers", requestId, providers, path: modelsPath, readOnly: false }); } catch (error) { sendToWindow({ type: "model-providers", requestId, providers: [], readOnly: true, error: sanitizeProviderError(error) }); } break; } case "fetch-provider-models": { const requestId = typeof msg.requestId === "string" ? msg.requestId : ""; const draft = msg.provider; const validation = validateProviderDraft({ ...draft, models: Array.isArray(draft?.models) && draft.models.length ? draft.models : [{ id: "discovery-placeholder" }] }); const submittedSecrets = [draft?.apiKey, ...(Array.isArray(draft?.headers) ? draft.headers.map((header: any) => header?.value) : [])] .filter((value): value is string => typeof value === "string" && value.length > 0); if (!validation.ok) { sendToWindow({ type: "provider-models-result", requestId, success: false, errors: validation.errors }); break; } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), PROVIDER_FETCH_TIMEOUT_MS); try { const headers: Record = { Accept: "application/json" }; const sensitiveHeaderNames = new Set(); const authStorage = lastCtx?.modelRegistry.authStorage; const stored = authStorage?.get(draft.id); const providerEnv = stored?.type === "api_key" ? stored.env : undefined; const currentDocument = readJsonDocument(join(getAgentDir(), "models.json")); const configuredApiKey = currentDocument.providers?.[draft.id]?.apiKey; const submittedApiKey = typeof draft.apiKey === "string" ? draft.apiKey.trim() : ""; const storedApiKey = stored?.type === "api_key" ? stored.key : undefined; const apiKey = submittedApiKey || resolveDiscoveryApiKey(storedApiKey || configuredApiKey, providerEnv); if (apiKey && !submittedSecrets.includes(apiKey)) submittedSecrets.push(apiKey); if (draft.authHeader !== false && !apiKey) throw new Error(`No API key found for "${draft.id}".`); if (draft.authHeader !== false && apiKey) headers.Authorization = `Bearer ${apiKey}`; for (const header of draft.headers ?? []) { if (header.sensitive) sensitiveHeaderNames.add(header.name); if (header.value) headers[header.name] = header.value; else if (header.sensitive) { if (header.command === true) throw new Error(`Model discovery cannot use the command-backed header "${header.name}". Enter a temporary value.`); const configuredHeader = currentDocument.providers?.[draft.id]?.headers?.[header.name]; const reference = header.template === true ? configuredHeader : header.reference || `$${sensitiveHeaderEnvName(draft.id, header.name)}`; const value = resolveDiscoveryHeaderValue(header.name, reference, providerEnv); if (value) { headers[header.name] = value; if (!submittedSecrets.includes(value)) submittedSecrets.push(value); } } } const response = await fetchProviderModels(new URL(buildModelsUrl(draft.baseUrl)), headers, sensitiveHeaderNames, controller.signal); const responseProtocol = new URL(response.url).protocol; if (responseProtocol !== "http:" && responseProtocol !== "https:") throw new Error("Provider redirected to an unsupported URL scheme."); const body = await readBoundedResponse(response); if (!response.ok) throw new Error(`Provider returned HTTP ${response.status}: ${body}`); const parsed = parseModelListResponse(JSON.parse(body)); sendToWindow({ type: "provider-models-result", requestId, success: true, ...parsed }); } catch (error) { const message = controller.signal.aborted ? "Provider request timed out." : sanitizeProviderError(error, submittedSecrets); sendToWindow({ type: "provider-models-result", requestId, success: false, error: message }); } finally { clearTimeout(timeout); } break; } case "save-model-provider": { const requestId = typeof msg.requestId === "string" ? msg.requestId : ""; const draft = msg.provider; const validation = validateProviderDraft(draft); if (!validation.ok) { sendToWindow({ type: "model-provider-result", requestId, action: "save", success: false, errors: validation.errors }); break; } try { if (!lastCtx) throw new Error("No active session context."); const modelRegistry = lastCtx.modelRegistry; const modelsPath = join(getAgentDir(), "models.json"); const authPath = join(getAgentDir(), "auth.json"); const authStorage = modelRegistry.authStorage; const existingCredential = authStorage.get(draft.id); if (existingCredential && existingCredential.type !== "api_key") throw new Error("OAuth credentials cannot be edited here. Use /login."); let merged: ReturnType; withFileLock(modelsPath, () => { const previousDocument = readJsonDocument(modelsPath); merged = mergeProviderDocument(previousDocument, { ...draft, clearCredential: msg.clearCredential === true }); const credential = buildUpdatedCredential(existingCredential, { submittedKey: typeof draft.apiKey === "string" ? draft.apiKey.trim() : "", sensitiveEnv: merged.sensitiveEnv, removedSensitiveEnvNames: merged.removedSensitiveEnvNames, clearApiKey: msg.clearCredential === true, }); persistProviderTransaction({ writeDocument: () => writeJsonAtomic(modelsPath, merged.document), updateCredential: () => { // When auth.json failed to load, AuthStorage.set/remove silently no-op on disk (and // record nothing), so drainErrors alone can't detect it — check the load state first. if ((authStorage as any).loadError) throw new Error("Cannot persist provider credential: auth.json failed to load."); authStorage.drainErrors?.(); if (credential) authStorage.set(draft.id, credential); else if (existingCredential?.type === "api_key") authStorage.remove(draft.id); assertAuthWritePersisted(authStorage); }, restoreCredential: () => restoreCredentialIfUnchanged( authStorage, draft.id, credential, existingCredential, () => readJsonDocument(authPath)[draft.id], ), restoreDocument: () => restoreDocumentIfUnchanged( merged.document, previousDocument, () => readJsonDocument(modelsPath), value => writeJsonAtomic(modelsPath, value), ), }); }); try { authStorage.reload(); const registryError = getRegistryRefreshError(modelRegistry); if (registryError) throw new Error(registryError); } catch (refreshError) { sendToWindow({ type: "model-provider-result", requestId, action: "save", success: true, saved: true, refreshed: false, error: sanitizeProviderError(refreshError, collectProviderSecrets(draft)) }); break; } sendToWindow({ type: "model-provider-result", requestId, action: "save", success: true, provider: redactProviderConfig(draft.id, merged.document.providers[draft.id], modelRegistry.getProviderAuthStatus(draft.id)) }); } catch (error) { const message = sanitizeProviderError(error, collectProviderSecrets(draft)); // Resync in-memory credentials with disk: a rolled-back transaction may have left an // orphaned credential in memory after a swallowed auth.json write. try { lastCtx?.modelRegistry?.authStorage?.reload?.(); } catch {} sendToWindow({ type: "model-provider-result", requestId, action: "save", success: false, error: message }); } break; } case "delete-model-provider": { const requestId = typeof msg.requestId === "string" ? msg.requestId : ""; const providerId = typeof msg.providerId === "string" ? msg.providerId : ""; try { if (!lastCtx || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(providerId)) throw new Error("Invalid provider ID."); const modelRegistry = lastCtx.modelRegistry; const modelsPath = join(getAgentDir(), "models.json"); const authPath = join(getAgentDir(), "auth.json"); const existingCredential = modelRegistry.authStorage.get(providerId); if (msg.deleteCredential === true && !mayDeleteProviderCredential(existingCredential)) throw new Error("OAuth credentials are read-only here. Use /login."); withFileLock(modelsPath, () => { const previousDocument = readJsonDocument(modelsPath); const updatedDocument = removeProviderFromDocument(previousDocument, providerId); persistProviderTransaction({ writeDocument: () => writeJsonAtomic(modelsPath, updatedDocument), updateCredential: () => { if (msg.deleteCredential !== true) return; if ((modelRegistry.authStorage as any).loadError) throw new Error("Cannot persist provider credential: auth.json failed to load."); modelRegistry.authStorage.drainErrors?.(); modelRegistry.authStorage.remove(providerId); assertAuthWritePersisted(modelRegistry.authStorage); }, restoreCredential: () => { const attemptedCredential = msg.deleteCredential === true ? undefined : existingCredential; return restoreCredentialIfUnchanged( modelRegistry.authStorage, providerId, attemptedCredential, existingCredential, () => readJsonDocument(authPath)[providerId], ); }, restoreDocument: () => restoreDocumentIfUnchanged( updatedDocument, previousDocument, () => readJsonDocument(modelsPath), value => writeJsonAtomic(modelsPath, value), ), }); }); try { modelRegistry.authStorage.reload(); const registryError = getRegistryRefreshError(modelRegistry); if (registryError) throw new Error(registryError); } catch (refreshError) { const deletedSecrets = existingCredential?.type === "api_key" ? [existingCredential.key, ...Object.values(existingCredential.env ?? {})].filter((value): value is string => typeof value === "string" && value.length > 0) : []; sendToWindow({ type: "model-provider-result", requestId, action: "delete", success: true, deleted: true, refreshed: false, providerId, error: sanitizeProviderError(refreshError, deletedSecrets) }); break; } sendToWindow({ type: "model-provider-result", requestId, action: "delete", success: true, providerId }); } catch (error) { const message = sanitizeProviderError(error); try { lastCtx?.modelRegistry?.authStorage?.reload?.(); } catch {} sendToWindow({ type: "model-provider-result", requestId, action: "delete", success: false, error: message }); } break; } case "get-commands": { sendToWindow({ type: "commands-list", commands: getAllCommands(pi) }); break; } case "get-stats": { if (lastCtx) { const stats = getTokenStats(lastCtx); sendToWindow({ type: "stats-update", stats }); } break; } case "set-language": { const requested = typeof msg.language === "string" ? msg.language : ""; const home = process.env.HOME || process.env.USERPROFILE || ""; const settingsPath = join(home, ".pi", "agent", "settings.json"); try { const language = saveConfiguredLanguage(settingsPath, requested); sendToWindow({ type: "language-update", success: true, language }); } catch (error) { sendToWindow({ type: "language-update", success: false, language: readConfiguredLanguage(settingsPath), error: error instanceof Error ? error.message : String(error), }); } break; } case "refresh-threads": { if (lastCtx) { const sessionFile = (lastCtx.sessionManager as any).getSessionFile?.() ?? null; const sessionDir = sessionFile ? join(sessionFile, "..") : null; const threads = getSessionThreads(sessionDir).map(t => ({ name: t.name, file: t.file, date: t.date.toISOString(), })); sendToWindow({ type: "update-threads", threads }); } break; } case "refresh-skills": { const refreshCtx = lastCtx; if (!refreshCtx) break; const captured = activateExtensionContext(refreshCtx); try { await extensionViewBridge.refresh(captured, { force: true }); } catch (refreshError) { reportExtensionRefreshFailure(refreshError); } break; } case "search-skill-packages": { const query = typeof msg.query === "string" ? msg.query : ""; const searchRequestId = ++skillSearchRequestSeq; skillInstallAllowlist.clear(); try { const results = await searchSkillPackages(query, 20); if (searchRequestId !== skillSearchRequestSeq) break; rememberSkillSearchResults(results); sendToWindow({ type: "skill-search-results", query, results }); } catch (err) { if (searchRequestId !== skillSearchRequestSeq) break; sendToWindow({ type: "skill-search-results", query, results: [], error: String((err as Error).message || err) }); } break; } case "install-skill-package": { const pkg = typeof msg.package === "string" ? msg.package.trim() : ""; const scope = msg.scope === "project" ? "project" : "global"; const installCtx = lastCtx; if (!installCtx) { sendToWindow({ type: "skill-install-result", package: pkg, success: false, error: "No active workspace for package installation." }); break; } const captured = activateExtensionContext(installCtx); try { if (!isSkillInstallAllowed(pkg)) { throw new Error("Install package must be selected from current search results."); } const output = await installSkillPackage(pkg, scope, captured.normalizedCwd); let snapshot; try { snapshot = await extensionViewBridge.refresh(captured, { force: true }); } catch (refreshError) { reportExtensionRefreshFailure(refreshError); throw refreshError; } if (extensionViewBridge.isActive(captured)) { sendToWindow({ type: "skill-install-result", package: pkg, success: true, output: output.slice(-2000), skills: getSkills(captured.normalizedCwd), extensions: snapshot.extensions, extensionsLoading: snapshot.loading, extensionsError: snapshot.error, }); } } catch (err) { if (extensionViewBridge.isActive(captured)) { sendToWindow({ type: "skill-install-result", package: pkg, success: false, error: String((err as Error).message || err) }); } } break; } case "get-workspaces": { const wsList = getWorkspaces().map(w => ({ ...w, lastActive: w.lastActive.toISOString(), })); sendToWindow({ type: "workspaces-list", workspaces: wsList }); break; } case "get-workspace-sessions": { if (msg.dirName) { const sessions = getWorkspaceSessions(msg.dirName).map(t => ({ name: t.name, file: t.file, date: t.date.toISOString(), })); sendToWindow({ type: "workspace-sessions", dirName: msg.dirName, sessions }); } break; } case "search-threads": { if (msg.query && typeof msg.query === "string" && msg.query.length >= 2 && msg.query.length <= 200) { const query = msg.query; const allResults: Array<{ name: string; file: string; date: string; matchSnippet: string; workspace: string }> = []; const cwd = lastCtx ? (lastCtx as any).cwd || "" : ""; // Search current workspace (unless it is a temporary workspace, which is excluded from search) if (lastCtx && !(cwd && isTemporaryWorkspacePath(cwd))) { const sessionFile = (lastCtx.sessionManager as any).getSessionFile?.() ?? null; const sessionDir = sessionFile ? join(sessionFile, "..") : null; const results = searchSessionThreads(sessionDir, query); for (const r of results) { allResults.push({ name: r.name, file: r.file, date: r.date.toISOString(), matchSnippet: r.matchSnippet, workspace: "__current__" }); } } // Search other workspaces const home = process.env.HOME || process.env.USERPROFILE || ""; const sessionsRoot = join(home, ".pi", "agent", "sessions"); const workspaces = getWorkspaces().filter(w => w.path !== cwd && !w.isTemporary); for (const ws of workspaces) { const wsDir = join(sessionsRoot, ws.dirName); const results = searchSessionThreads(wsDir, query); for (const r of results) { allResults.push({ name: r.name, file: r.file, date: r.date.toISOString(), matchSnippet: r.matchSnippet, workspace: ws.dirName }); } } sendToWindow({ type: "search-results", query, results: allResults }); } break; } case "open-folder-path": { if (msg.path && typeof msg.path === "string" && msg.path.length < 1000) { // Reject path traversal attempts if (msg.path.includes("..")) break; const folderPath = msg.path.replace(/\\/g, "/").replace(/\/$/, ""); // Encode path to session dir name format const safePath = `--${folderPath.replace(/^\//, "").replace(/[\/\\:]/g, "-")}--`; const home = process.env.HOME || process.env.USERPROFILE || ""; const sessionsRoot = join(home, ".pi", "agent", "sessions"); const sessionDir = join(sessionsRoot, safePath); // Verify the session dir is actually under sessions root (prevent traversal via crafted safePath) const resolvedSessionDir = resolve(normalize(sessionDir)); const resolvedSessionsRoot = resolve(sessionsRoot); if (!resolvedSessionDir.startsWith(resolvedSessionsRoot + "/") && !resolvedSessionDir.startsWith(resolvedSessionsRoot + "\\")) break; if (existsSync(sessionDir)) { // Workspace exists - expand it in sidebar const sessions = getSessionThreads(sessionDir).map(t => ({ name: t.name, file: t.file, date: t.date.toISOString(), })); sendToWindow({ type: "workspace-opened", dirName: safePath, path: msg.path, sessions }); } else { // No sessions for this path yet sendToWindow({ type: "workspace-opened", dirName: safePath, path: msg.path, sessions: [] }); } // Update explorer CWD to the opened folder if it exists on disk const resolvedFolder = resolve(normalize(msg.path)); if (existsSync(resolvedFolder)) { activeExplorerCwd = resolvedFolder; } } break; } case "set-plan-mode": { planMode = msg.active === true; if (lastCtx) { lastCtx.ui.notify( planMode ? "Plan Mode ON — pi will only read, search, and analyze. No writes." : "Plan Mode OFF — full access restored.", "info" ); } break; } case "set-thinking-level": { if (!lastCtx) break; const model = lastCtx.model; const snapshot = getThinkingSnapshot(model); if (snapshot.thinkingLevels.includes(msg.level)) pi.setThinkingLevel(msg.level); sendThinkingSnapshot(model); break; } case "close": { closeActiveWindow(); break; } case "attach-file": { const name = msg.name || "file"; const mimeType = msg.mimeType || "application/octet-stream"; const base64 = msg.base64; if (!base64) break; // Reject oversized attachments (base64 is ~4/3 of original) if (base64.length > MAX_ATTACH_SIZE * 1.37) { sendToWindow({ type: "file-attached-ack", path: "", name, error: "File too large (max 25MB)" }); break; } try { const ext = extname(name) || (mimeType.startsWith("image/") ? "." + (mimeType.split("/")[1] || "png") : ".bin"); // Sanitize filename: strip path separators, restrict to safe characters const safeExt = ext.replace(/[^a-zA-Z0-9.]/g, "").slice(0, 10); const fileName = `pi-attach-${randomUUID()}${safeExt}`; const filePath = join(tmpdir(), fileName); writeFileSync(filePath, Buffer.from(base64, "base64")); sendToWindow({ type: "file-attached-ack", path: filePath, name }); } catch (e) { sendToWindow({ type: "file-attached-ack", path: "", name, error: String(e) }); } break; } case "cancel-streaming": { if (lastCtx && !lastCtx.isIdle()) { lastCtx.abort(); } break; } case "set-hidden-workspaces": { if (msg.hiddenWorkspaces && typeof msg.hiddenWorkspaces === "object") { // Validate shape: must be Record — reject anything else const sanitized: Record = {}; for (const [key, val] of Object.entries(msg.hiddenWorkspaces)) { if (typeof key === "string" && typeof val === "boolean" && key.length < 500) { sanitized[key] = val; } } saveHiddenWorkspaces(sanitized); } break; } case "launch-workspace": { // Windows-only scope for this iteration. if (process.platform !== "win32") { sendCommandResult("launch-workspace", { success: false, message: "Opening a workspace in a new pi window is currently supported on Windows only." }); break; } // Validate path: string, bounded length, no traversal. if (!msg.path || typeof msg.path !== "string" || msg.path.length >= 1000 || msg.path.includes("..")) { sendCommandResult("launch-workspace", { success: false, message: "Can't open workspace — invalid path." }); break; } const targetPath = resolve(normalize(msg.path)); try { if (!existsSync(targetPath) || !statSync(targetPath).isDirectory()) { sendCommandResult("launch-workspace", { success: false, message: `Can't open workspace — not a directory: ${targetPath}` }); break; } // Preflight: confirm `pi` is resolvable before dispatching a hidden launch. const childEnv = { ...process.env, PI_DESKTOP: "1" }; const where = spawnSync("where", ["pi"], { env: childEnv, shell: false, encoding: "utf8", windowsHide: true }); let piResolvable = where.status === 0; if (!piResolvable) { // Fallback: look for a pi executable next to the current runtime binary. const runtimeDir = dirname(process.execPath); piResolvable = ["pi.exe", "pi.cmd", "pi.bat", "pi"].some((name) => existsSync(join(runtimeDir, name))); } if (!piResolvable) { sendCommandResult("launch-workspace", { success: false, message: "Can't find the `pi` executable on PATH. Make sure pi is installed and on your PATH, then try again." }); break; } // Launch a visible, desktop-aware pi window in the target workspace. spawn("cmd.exe", ["/c", "start", "", "pi", "--desktop"], { cwd: targetPath, env: childEnv, shell: false, }); sendCommandResult("launch-workspace", { success: true, message: `Opening pi in ${targetPath}…` }); } catch (err) { sendCommandResult("launch-workspace", { success: false, message: `Couldn't open workspace: ${String(err)}` }); } break; } case "compact": { if (!lastCtx || !lastCtx.isIdle() || compacting) { sendToWindow({ type: "command-result", command: "compact", success: false, message: compacting ? "Compaction already in progress." : "Can't compact while streaming — wait for the response to finish." }); break; } // Set synchronously so a fast double-click can't pass the guard twice // before the async session_before_compact event flips the flag. compacting = true; try { lastCtx.compact({ // No success toast here: the session_compact → compaction-end divider // already marks completion (and fires for CLI compactions too), so a // command-result would duplicate it on the GUI-triggered path. onError: (error: any) => { compacting = false; // guard against a missed session_compact event sendToWindow({ type: "command-result", command: "compact", ...formatCompactErrorResult(error) }); }, }); } catch (err) { compacting = false; sendToWindow({ type: "command-result", command: "compact", ...formatCompactErrorResult(err) }); } break; } case "get-tools": { const activeToolNames = pi.getActiveTools(); const tools = pi.getAllTools().map(t => ({ name: t.name, description: t.description, active: activeToolNames.includes(t.name), })); sendToWindow({ type: "tools-list", tools }); break; } case "set-active-tools": { const names = msg.names; if (!Array.isArray(names) || !names.every((n: any) => typeof n === "string")) { sendCommandResult("set-active-tools", { success: false, message: "Invalid tool selection." }); break; } const allToolNames = new Set(pi.getAllTools().map(t => t.name)); const unknown = names.filter((n: string) => !allToolNames.has(n)); if (unknown.length > 0) { sendCommandResult("set-active-tools", { success: false, message: `Unknown tool(s): ${unknown.join(", ")}` }); break; } pi.setActiveTools(names); const activeToolNames = pi.getActiveTools(); const tools = pi.getAllTools().map(t => ({ name: t.name, description: t.description, active: activeToolNames.includes(t.name), })); sendToWindow({ type: "tools-list", tools }); break; } case "open-model-settings": { sendToWindow({ type: "open-model-settings", providerId: typeof msg.providerId === "string" ? msg.providerId : undefined }); break; } case "open-model-selector": { if (sessionTransitioning) { sendCommandResult("model", { success: false, message: "Can't switch models during a session transition — try again in a moment." }); break; } try { const currentId = lastCtx?.model?.id; const currentProvider = (lastCtx?.model as any)?.provider; const available = lastCtx?.modelRegistry?.getAvailable() ?? []; const models = available.map(m => ({ id: m.id, provider: (m as any).provider, name: m.name, reasoning: m.reasoning === true, current: m.id === currentId && (m as any).provider === currentProvider, })); sendToWindow({ type: "show-model-selector", models, currentModel: currentId }); } catch (err) { sendCommandResult("model", { success: false, message: `Couldn't load models: ${String(err)}` }); } break; } case "model-selected": { if (sessionTransitioning) { sendCommandResult("model", { success: false, message: "Can't switch models during a session transition — try again in a moment." }); break; } const provider = msg.provider; const modelId = msg.modelId; if (typeof provider !== "string" || typeof modelId !== "string" || provider.length === 0 || provider.length > 200 || modelId.length === 0 || modelId.length > 200) { sendCommandResult("model", { success: false, message: "Invalid model selection." }); break; } try { if (!lastCtx || !lastCtx.isIdle()) { sendCommandResult("model", { success: false, message: "Can't switch models while streaming — wait for the response to finish." }); break; } const m = lastCtx.modelRegistry.find(provider, modelId); if (!m) { sendCommandResult("model", { success: false, message: `Model not found: ${provider}/${modelId}` }); break; } const ok = await pi.setModel(m); if (!ok) { sendToWindow({ type: "command-result", command: "model", success: false, code: "missing-auth", providerId: provider, message: `Auth missing for ${provider}/${modelId} — configure an API key or sign in for this provider.` }); break; } sendToWindow({ type: "model-info", model: m.id, provider: (m as any).provider ?? "" }); sendCommandResult("model", { success: true, message: `Model switched to ${m.name || m.id}.` }); } catch (err) { sendCommandResult("model", { success: false, message: `Couldn't switch model: ${String(err)}` }); } break; } case "open-resume-selector": { if (sessionTransitioning) { sendCommandResult("resume", { success: false, message: "Can't resume during a session transition — try again in a moment." }); break; } if (lastCtx && !lastCtx.isIdle()) { sendCommandResult("resume", { success: false, message: "Can't resume while streaming — wait for the response to finish." }); break; } try { const activeFile = (lastCtx?.sessionManager as any)?.getSessionFile?.() ?? null; const sessionDir = activeFile ? join(activeFile, "..") : null; const activeResolved = activeFile ? resolve(normalize(activeFile)) : null; const sessions = getSessionThreads(sessionDir) .filter(t => resolve(normalize(t.file)) !== activeResolved) // exclude the active session .map(t => ({ name: t.name, file: t.file, date: t.date.toISOString() })); sendToWindow({ type: "show-session-selector", sessions, action: "resume" }); } catch (err) { sendCommandResult("resume", { success: false, message: `Couldn't list sessions: ${String(err)}` }); } break; } case "open-fork-selector": { if (sessionTransitioning) { sendCommandResult("fork", { success: false, message: "Can't fork during a session transition — try again in a moment." }); break; } if (lastCtx && !lastCtx.isIdle()) { sendCommandResult("fork", { success: false, message: "Can't fork while streaming — wait for the response to finish." }); break; } try { const entries: Array<{ id: string; preview: string }> = []; for (const e of lastCtx?.sessionManager.getBranch() ?? []) { if (e.type !== "message" || e.message.role !== "user") continue; const text = getTextBlocks(e.message.content).replace(/\n/g, " ").trim(); const id = (e as any).id; // Only offer entries with a usable id — a fork target without a string id // would appear in the picker but fail validation on selection. if (typeof id !== "string" || id.length === 0) continue; const preview = text.length > 0 ? text.slice(0, 120) : "[non-text message]"; entries.push({ id, preview }); } sendToWindow({ type: "show-fork-selector", entries }); } catch (err) { sendCommandResult("fork", { success: false, message: `Couldn't list fork points: ${String(err)}` }); } break; } case "resume-selected": { const sessionFile = msg.sessionFile; if (typeof sessionFile !== "string" || !isValidSessionFile(sessionFile)) { sendCommandResult("resume", { success: false, message: "Invalid session file." }); break; } const activeFile = (lastCtx?.sessionManager as any)?.getSessionFile?.() ?? null; if (activeFile && resolve(normalize(activeFile)) === resolve(normalize(sessionFile))) { sendCommandResult("resume", { success: false, message: "Already active." }); break; } if (!lastCtx || !lastCtx.isIdle()) { sendCommandResult("resume", { success: false, message: "Can't resume while streaming — wait for the response to finish." }); break; } if (sessionTransitioning) { sendCommandResult("resume", { success: false, message: "Can't resume during a session transition — try again in a moment." }); break; } if (!hasRunCommandAction(lastCtx)) { sendCommandResult("resume", { success: false, message: "This pi build can't resume from the desktop UI — use the CLI /resume command." }); break; } // Preflight: if the session recorded a cwd that no longer exists, resuming would // land in a broken working directory. Reject and steer to the CLI. const recordedCwd = getSessionRecordedCwd(sessionFile); if (recordedCwd && !existsSync(recordedCwd)) { sendCommandResult("resume", { success: false, message: `That session's working directory (${recordedCwd}) no longer exists — resume it from the CLI /resume command.` }); break; } sessionTransitioning = true; try { await (lastCtx as any).runCommandAction((cmdCtx: ExtensionCommandContext) => cmdCtx.switchSession(sessionFile, { withSession: async () => {} })); // GUI refresh comes from the subsequent session_start event. } catch (e) { sessionTransitioning = false; sendCommandResult("resume", { success: false, message: `Couldn't resume: ${String(e)}` }); } break; } case "fork-selected": { const entryId = msg.entryId; if (typeof entryId !== "string" || entryId.length === 0 || entryId.length > 200) { sendCommandResult("fork", { success: false, message: "Invalid fork point." }); break; } const branch = lastCtx?.sessionManager.getBranch() ?? []; const target = branch.find(e => (e as any).id === entryId); if (!target || target.type !== "message" || target.message.role !== "user") { sendCommandResult("fork", { success: false, message: "Fork point not found or not a user message." }); break; } if (!lastCtx || !lastCtx.isIdle()) { sendCommandResult("fork", { success: false, message: "Can't fork while streaming — wait for the response to finish." }); break; } if (sessionTransitioning) { sendCommandResult("fork", { success: false, message: "Can't fork during a session transition — try again in a moment." }); break; } if (!hasRunCommandAction(lastCtx)) { sendCommandResult("fork", { success: false, message: "This pi build can't fork from the desktop UI — use the CLI /fork command." }); break; } sessionTransitioning = true; try { await (lastCtx as any).runCommandAction((cmdCtx: ExtensionCommandContext) => cmdCtx.fork(entryId, { withSession: async () => {} })); // GUI refresh comes from the subsequent session_start event. } catch (e) { sessionTransitioning = false; sendCommandResult("fork", { success: false, message: `Couldn't fork: ${String(e)}` }); } break; } } } // ─── Open Window ────────────────────────────────────────── function collectWindowData(ctx: ExtensionContext): DesktopWindowData { const stats = getTokenStats(ctx); const model = ctx.model?.id || "no-model"; const thinkingSnapshot = getThinkingSnapshot(ctx.model); const home = process.env.HOME || process.env.USERPROFILE || ""; const language = readConfiguredLanguage(join(home, ".pi", "agent", "settings.json")); const sessionFile = (ctx.sessionManager as any).getSessionFile?.() ?? null; const sessionDir = sessionFile ? join(sessionFile, "..") : null; const threads = getSessionThreads(sessionDir).map(t => ({ name: t.name, file: t.file, date: t.date.toISOString(), })); const skills = getSkills(ctx.cwd); const extensionSnapshot = extensionDiscovery.getSnapshot(extensionContextFor(ctx)); const allWorkspaces = getWorkspaces().map(w => ({ ...w, lastActive: w.lastActive.toISOString(), })); // Cap initial messages to prevent WebView2 NavigateToString 2MB limit crash. // The 2MB limit applies to the full HTML (template + app.js + base64 data). // Budget: ~1.2MB for message JSON (after base64 inflate ≈ 1.6MB, plus ~155KB static). const MAX_JSON_BYTES = 1_200_000; // total budget for messages JSON let messages = boundDesktopSessionMessages(extractSessionMessages(ctx)); // If still too large, progressively drop oldest messages while (messages.length > 5 && JSON.stringify(messages).length > MAX_JSON_BYTES) { messages = messages.slice(Math.ceil(messages.length * 0.25)); } const explorerFiles = getDirEntries(ctx.cwd); const commands = getAllCommands(pi); return { projectName, gitBranch, model, ...thinkingSnapshot, language, provider: (ctx.model as any)?.provider || "unknown", cwd: ctx.cwd, stats, threads, skills, extensions: extensionSnapshot.extensions, extensionsLoading: extensionSnapshot.loading, extensionsError: extensionSnapshot.error, workspaces: allWorkspaces, messages, explorerFiles, commands, hiddenWorkspaces: loadHiddenWorkspaces(), }; } function openDesktopWindow(ctx: ExtensionContext): void { if (activeWindow != null) { ctx.ui.notify("Desktop window is already open.", "warning"); return; } lastCtx = ctx; const data = collectWindowData(ctx); const html = buildDesktopHtml(data); const win = open(html, { width: 1400, height: 900, title: "pi Desktop", }); setActiveWindow(win); hostUIReady = false; // Customize thinking block label while desktop window is open (v0.64.0) try { ctx.ui.setHiddenThinkingLabel?.("thinking (visible in Desktop ◈)"); } catch {} win.on("message", handleWindowMessage); win.on("closed", () => { if (activeWindow === win) { setActiveWindow(null); hostUIReady = false; resolvePendingHostDialogs("unavailable"); } }); win.on("error", () => { if (activeWindow === win) { setActiveWindow(null); hostUIReady = false; resolvePendingHostDialogs("unavailable"); } }); sendToWindow({ type: "host-ui-ready-probe" }); startExtensionRefresh(refreshExtensionsForContext(ctx)); ctx.ui.notify("Pi Desktop window opened. Chat from here or the window — both are synced.", "info"); } // ─── Custom Footer ──────────────────────────────────────── function enableFooter(ctx: ExtensionContext) { ctx.ui.setFooter((tui, theme, footerData) => { const unsub = footerData.onBranchChange(() => tui.requestRender()); return { dispose: unsub, invalidate() {}, render(width: number): string[] { const branch = footerData.getGitBranch(); gitBranch = branch; const stats = getTokenStats(ctx); const model = ctx.model?.id || "no-model"; const windowIndicator = activeWindow ? theme.fg("accent", " ◈") : ""; 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}`; const right = theme.fg("dim", `In ${fmt(stats.input)} Out ${fmt(stats.output)} Cache ${fmt(stats.cache)} $${stats.cost.toFixed(4)}`); const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right))); return [truncateToWidth(left + pad + right, width)]; }, }; }); } // ─── Context Widget ─────────────────────────────────────── function enableWidget(ctx: ExtensionContext) { ctx.ui.setWidget("desktop-context", (_tui, theme) => ({ render: () => [ theme.fg("dim", " ───") + " " + theme.fg("text", `◈ ${projectName}`) + theme.fg("dim", " / ") + theme.fg("accent", gitBranch || "local") + " " + theme.fg("dim", "─".repeat(30)) ], invalidate: () => {}, })); } // ─── CLI Flag ───────────────────────────────────────────── pi.registerFlag("desktop", { description: "Auto-open Pi Desktop UI on startup", type: "boolean", default: false, }); // ─── Commands ───────────────────────────────────────────── pi.registerCommand("desktop", { description: "Open pi Desktop window (fully functional chat UI)", handler: async (_args, ctx) => { lastCommandCtx = ctx; openDesktopWindow(ctx); }, }); pi.registerCommand("nav", { description: "Open pi Desktop navigation window", handler: async (_args, ctx) => { lastCommandCtx = ctx; openDesktopWindow(ctx); }, }); // ─── Slash Command Dispatch for Desktop UI ─────────────── // pi.sendUserMessage() skips slash command dispatch (expandPromptTemplates: false). // We handle commands programmatically via ExtensionCommandContext and ExtensionAPI, // then send results back to the desktop window so the user gets visual feedback. // Commands we can't handle go through the terminal via injectIntoTerminal(). /** Send a command result/feedback message to the desktop window. */ function sendCommandResult(command: string, opts: { success?: boolean; message?: string }) { sendToWindow({ type: "command-result", command, success: opts.success ?? true, message: opts.message || "", }); } /** Show a desktop info card for commands we can render natively. * Display-only — actual execution always happens via injectIntoTerminal(). */ function showDesktopCard(text: string, ctx: ExtensionContext): void { if (!text.startsWith("/")) return; const spaceIdx = text.indexOf(" ", 1); const cmdName = spaceIdx === -1 ? text.slice(1) : text.slice(1, spaceIdx); const cmdArgs = spaceIdx === -1 ? "" : text.slice(spaceIdx + 1).trim(); switch (cmdName) { case "session": { const stats = getTokenStats(ctx); const sessionFile = (ctx.sessionManager as any).getSessionFile?.() ?? "ephemeral"; const entryCount = ctx.sessionManager.getEntries().length; const branchLen = ctx.sessionManager.getBranch().length; const model = ctx.model?.id || "no model"; const sessionName = pi.getSessionName(); const lines = [ `**Session Info**`, sessionName ? `Name: ${sessionName}` : null, `Model: ${model}`, `Entries: ${entryCount} (branch: ${branchLen})`, `Input: ${fmt(stats.input)} · Output: ${fmt(stats.output)} · Cache: ${fmt(stats.cache)}`, `Cost: $${stats.cost.toFixed(4)}`, `File: ${basename(sessionFile)}`, ].filter(Boolean).join("\n"); sendCommandResult("session", { message: lines }); break; } case "hotkeys": { const shortcuts = [ "Ctrl+C — Cancel / clear input", "Ctrl+D — Quit pi", "Ctrl+P — Cycle model", "Ctrl+L — Clear terminal", "Ctrl+Alt+N — Open Desktop window", "Escape — Cancel streaming", "Tab — Accept autocomplete", "Up/Down — History navigation", "Shift+Enter — Newline in editor", ]; sendCommandResult("hotkeys", { message: "**Keyboard Shortcuts**\n" + shortcuts.join("\n") }); break; } case "context": { const usage = ctx.getContextUsage(); const model = ctx.model; if (!usage || !model) { sendCommandResult("context", { success: false, message: "No context usage data available. Send a message first." }); break; } const contextWindow = usage.contextWindow; const usedTokens = usage.tokens; const maxOutputTokens = model.maxTokens || 0; let systemToolsTokens = 0, messageTokens = 0; const entries = ctx.sessionManager.getBranch(); let lastUsage: any = null; for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; if (entry.type === "message" && entry.message.role === "assistant") { const a = entry.message as any; if (a.stopReason !== "aborted" && a.stopReason !== "error" && a.usage) { lastUsage = a.usage; break; } } } if (lastUsage && usedTokens !== null) { const cacheTokens = (lastUsage.cacheRead || 0) + (lastUsage.cacheWrite || 0); if (cacheTokens > 0) { systemToolsTokens = cacheTokens; messageTokens = Math.max(0, usedTokens - cacheTokens); } else { systemToolsTokens = Math.round(usedTokens * 0.15); messageTokens = usedTokens - systemToolsTokens; } } else if (usedTokens !== null) { systemToolsTokens = Math.round(usedTokens * 0.15); messageTokens = usedTokens - systemToolsTokens; } const bufferTokens = maxOutputTokens; const freeTokens = usedTokens !== null ? Math.max(0, contextWindow - usedTokens - bufferTokens) : contextWindow - bufferTokens; const pct = (n: number) => contextWindow > 0 ? ((n / contextWindow) * 100).toFixed(0) : "0"; const modelName = model.id || (model as any).name || "unknown"; const percentStr = usage.percent !== null ? `${Math.round(usage.percent!)}%` : "?%"; const usedStr = usedTokens !== null ? fmt(usedTokens) : "?"; sendCommandResult("context", { message: [ `**Context Usage**`, ``, `${modelName} · ${usedStr} / ${fmt(contextWindow)} tokens (${percentStr})`, ``, `◍ System/Tools: ${fmt(systemToolsTokens).padStart(7)} (${pct(systemToolsTokens)}%)`, `● Messages: ${fmt(messageTokens).padStart(7)} (${pct(messageTokens)}%)`, `· Free Space: ${fmt(Math.max(0, freeTokens)).padStart(7)} (${pct(Math.max(0, freeTokens))}%)`, `○ Buffer: ${fmt(bufferTokens).padStart(7)} (${pct(bufferTokens)}%)`, ].join("\n") }); break; } case "cost": { const days = cmdArgs ? parseInt(cmdArgs, 10) : 7; if (isNaN(days) || days < 1) { sendCommandResult("cost", { success: false, message: "Usage: /cost [days]" }); break; } const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - days); const cutoffStr = cutoff.toISOString().slice(0, 10); const home = process.env.HOME || process.env.USERPROFILE || ""; const sessionsDir = join(home, ".pi", "agent", "sessions"); const tmpDir = process.env.TMPDIR || (process.platform === "win32" ? process.env.TEMP || "C:\Temp" : "/tmp"); let mainCost = 0, subCost = 0, mainSessions = 0, subSessions = 0; const walkJsonl = (dir: string): string[] => { const files: string[] = []; try { if (!existsSync(dir)) return files; const walk = (d: string) => { for (const e of readdirSync(d, { withFileTypes: true })) { const full = join(d, e.name); if (e.isDirectory()) walk(full); else if (e.name.endsWith(".jsonl") && e.name.slice(0, 10) >= cutoffStr) files.push(full); } }; walk(dir); } catch {} return files; }; const extractCost = (fp: string): number => { let cost = 0; try { for (const line of readFileSync(fp, "utf-8").split("\n")) { if (!line.includes('"cost"')) continue; try { const e = JSON.parse(line); if (e?.message?.usage?.cost?.total) cost += e.message.usage.cost.total; } catch {} } } catch {} return cost; }; for (const f of walkJsonl(sessionsDir)) { const c = extractCost(f); if (c > 0) { mainCost += c; mainSessions++; } } const subDirs: string[] = []; try { for (const e of readdirSync(tmpDir, { withFileTypes: true })) { if (e.isDirectory() && e.name.startsWith("pi-subagent-session-")) subDirs.push(join(tmpDir, e.name)); } } catch {} for (const d of subDirs) { for (const f of walkJsonl(d)) { const c = extractCost(f); if (c > 0) { subCost += c; subSessions++; } } } sendCommandResult("cost", { message: [ `**Cost Summary** (last ${days} days)`, ``, `💰 Total: $${(mainCost + subCost).toFixed(2)} (${mainSessions + subSessions} sessions)`, ` Main: $${mainCost.toFixed(2)} (${mainSessions}) · Subagents: $${subCost.toFixed(2)} (${subSessions})`, ].join("\n") }); break; } case "changelog": { try { const candidates = [ join(__dirname, "node_modules", "@hhyy668", "pi-coding-agent", "CHANGELOG.md"), join(process.env.HOME || process.env.USERPROFILE || "", "AppData", "Roaming", "npm", "node_modules", "@hhyy668", "pi-coding-agent", "CHANGELOG.md"), join("/usr", "local", "lib", "node_modules", "@hhyy668", "pi-coding-agent", "CHANGELOG.md"), // Legacy paths join(__dirname, "node_modules", "@mariozechner", "pi-coding-agent", "CHANGELOG.md"), join(process.env.HOME || process.env.USERPROFILE || "", "AppData", "Roaming", "npm", "node_modules", "@mariozechner", "pi-coding-agent", "CHANGELOG.md"), join("/usr", "local", "lib", "node_modules", "@mariozechner", "pi-coding-agent", "CHANGELOG.md"), ]; const clPath = candidates.find(p => existsSync(p)); if (clPath) { // Parse entries the same way pi does: split on ## headers, reverse (newest first) const content = readFileSync(clPath, "utf-8"); const lines = content.split("\n"); const entries: { content: string }[] = []; let currentLines: string[] = []; let inEntry = false; for (const line of lines) { if (line.startsWith("## ")) { if (inEntry && currentLines.length > 0) { entries.push({ content: currentLines.join("\n").trim() }); } currentLines = [line]; inEntry = true; } else if (inEntry) { currentLines.push(line); } } if (inEntry && currentLines.length > 0) entries.push({ content: currentLines.join("\n").trim() }); if (entries.length > 0) { const md = "**What's New**\n\n" + entries.reverse().map(e => e.content).join("\n\n"); sendCommandResult("changelog", { message: md }); } else { sendCommandResult("changelog", { success: false, message: "No changelog entries found." }); } } else { sendCommandResult("changelog", { success: false, message: "CHANGELOG.md not found." }); } } catch { sendCommandResult("changelog", { success: false, message: "Could not read changelog." }); } break; } case "tree": { // Show session tree structure in desktop try { const roots = (ctx.sessionManager as any).getTree?.() as Array<{ entry: any; children: any[]; label?: string }> | undefined; const leafId = ctx.sessionManager.getLeafId(); if (!roots || roots.length === 0) { sendCommandResult("tree", { message: "Session tree is empty." }); break; } const lines: string[] = ["**Session Tree**", ""]; const renderNode = (node: any, prefix: string, isLast: boolean) => { const e = node.entry; const isLeaf = e.id === leafId; let desc = ""; if (e.type === "message") { const role = e.message?.role || "?"; let text = ""; if (Array.isArray(e.message?.content)) { for (const b of e.message.content) { if (b.type === "text") text += b.text; } } else if (typeof e.message?.content === "string") { text = e.message.content; } text = text.slice(0, 60).replace(/\n/g, " ").trim(); desc = `${role}: ${text || "..."}` ; } else if (e.type === "compaction") { desc = "[compaction]"; } else { desc = `[${e.type}]`; } if (node.label) desc += ` 🏷️ ${node.label}`; const marker = isLeaf ? "◉ " : "○ "; const connector = prefix ? (isLast ? "└─ " : "├─ ") : ""; lines.push(`${prefix}${connector}${marker}${desc}`); const childPrefix = prefix + (prefix ? (isLast ? " " : "│ ") : ""); for (let i = 0; i < node.children.length; i++) { renderNode(node.children[i], childPrefix, i === node.children.length - 1); } }; for (let i = 0; i < roots.length; i++) { renderNode(roots[i], "", i === roots.length - 1); } sendCommandResult("tree", { message: lines.join("\n") }); } catch { sendCommandResult("tree", { success: false, message: "Could not build session tree." }); } break; } } } // ─── Keyboard Shortcut ──────────────────────────────────── pi.registerShortcut(Key.ctrlAlt("n"), { description: "Open pi Desktop window", handler: async (ctx) => { openDesktopWindow(ctx); }, }); let pendingTerminalCmd: string | null = null; /** Inject a slash command into the terminal by routing through sendUserMessage → input event. * The input event intercepts the text, sets it in the terminal editor, and simulates Enter. * The terminal then processes it through its normal pipeline with expandPromptTemplates: true. */ function injectIntoTerminal(cmd: string): void { const lcCmd = cmd.replace(/^\//,"").split(/\s/)[0]; if (lcCmd === "new" || lcCmd === "reload" || lcCmd === "resume" || lcCmd === "fork") { sessionTransitioning = true; } pendingTerminalCmd = cmd; pendingDesktopUserMessage = true; try { pi.sendUserMessage(cmd); } catch (err) { // sendUserMessage may fail if agent is busy. Fall back to direct injection. pendingTerminalCmd = null; if (lastCtx?.hasUI) { lastCtx.ui.setEditorText(cmd); setImmediate(() => { setTimeout(() => { try { process.stdin.emit("data", "\r"); } catch {} }, 100); }); } } } // Intercept sendUserMessage calls for terminal-bound slash commands. pi.on("input", (event, ctx) => { if (pendingTerminalCmd && event.text === pendingTerminalCmd) { const cmd = pendingTerminalCmd; pendingTerminalCmd = null; if (ctx.hasUI) { ctx.ui.setEditorText(cmd); // Use setImmediate to ensure the prompt() call fully returns // before we inject the Enter keystroke into stdin. setImmediate(() => { setTimeout(() => { try { // Emit carriage return on stdin to trigger the TUI's input handler process.stdin.emit("data", "\r"); } catch {} }, 100); }); } return { action: "handled" as const }; } }); // ─── Session Lifecycle ──────────────────────────────────── // Unified session lifecycle — v0.65.0 removed session_switch and session_fork. // Use session_start with event.reason ("startup" | "reload" | "new" | "resume" | "fork"). pi.on("session_start", async (event, ctx) => { if (!ctx.hasUI) return; const captured = activateExtensionContext(ctx); startExtensionRefresh(extensionViewBridge.refresh(captured)); const reason = (event as any).reason || "startup"; const previousSessionFile = (event as any).previousSessionFile || null; sessionReason = reason; sessionTransitioning = false; // Session started successfully — clear transition flag lastCommandCtx = null; // Reset stale command context on session change projectName = getProjectName(ctx.cwd); lastCtx = ctx; sendContextUsage(ctx); enableFooter(ctx); enableWidget(ctx); try { ctx.ui.setStatus("desktop", ctx.ui.theme.fg("dim", "◈ Desktop")); } catch { ctx.ui.setStatus("desktop", "◈ Desktop"); } // Notify desktop window of session change with reason context if (reason !== "startup" || activeWindow) { // Re-attach event handlers whenever we adopt a window from a previous extension instance. // This covers reload, new, resume, fork, AND startup-with-surviving-window. // Remove ALL old listeners first — the previous instance's handlers captured a // now-stale `pi` reference that throws "stale extension ctx" on use. if (activeWindow) { const adoptedWin = activeWindow; adoptedWin.removeAllListeners("message"); adoptedWin.removeAllListeners("closed"); adoptedWin.removeAllListeners("error"); adoptedWin.on("message", handleWindowMessage); adoptedWin.on("closed", () => { if (activeWindow === adoptedWin) { setActiveWindow(null); hostUIReady = false; resolvePendingHostDialogs("unavailable"); } }); adoptedWin.on("error", () => { if (activeWindow === adoptedWin) { setActiveWindow(null); hostUIReady = false; resolvePendingHostDialogs("unavailable"); } }); hostUIReady = false; sendToWindow({ type: "host-ui-ready-probe" }); try { ctx.ui.setHiddenThinkingLabel?.("thinking (visible in Desktop ◈)"); } catch {} } sendSessionSnapshot(ctx, reason, previousSessionFile); } // Auto-open desktop window if --desktop flag or PI_DESKTOP env is set if (reason === "startup") { const desktopFlag = pi.getFlag("desktop"); const desktopEnv = process.env.PI_DESKTOP === "1"; if (desktopFlag || desktopEnv) { setTimeout(() => openDesktopWindow(ctx), 500); } } }); pi.on("session_shutdown", async (event) => { activeExtensionKey = null; extensionViewBridge.deactivate(); // The window should survive session transitions (new/reload/resume/fork) and // receive the new session's data, but must close on any terminal shutdown. // Trust the extension's own transition flag (set for resume/fork/switch we // initiate), and also honor known upstream transition reasons for // transitions we didn't drive. Close on everything else so a real quit — or // an unrecognized terminal reason — can't leak the window and keep the // process alive. const transitionReasons = new Set(["reload", "new", "resume", "fork"]); const reason = (event as any)?.reason; const isTransition = sessionTransitioning || (typeof reason === "string" && transitionReasons.has(reason)); resolvePendingHostDialogs(isTransition ? "unavailable" : "cancelled"); if (!isTransition) { closeActiveWindow(); } }); }