import * as fs from "node:fs"; import { homedir } from "node:os"; import * as path from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Input, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; type WorkspaceRoot = { alias: string; path: string; addedAt: number; }; type PersistedEntry = | { version: 1; action: "add"; root: WorkspaceRoot } | { version: 1; action: "remove"; alias?: string; path?: string } | { version: 1; action: "clear" }; type PersistentWorkspace = { enabled: boolean; roots: WorkspaceRoot[]; updatedAt: number; }; type PersistentConfig = { version: 1; workspaces: Record; }; const CUSTOM_TYPE = "workspace-context"; const STATUS_ID = "workspace-context"; const PERSISTENCE_FILE = path.join(homedir(), ".pi", "agent", "workspace-context.json"); const CONTEXT_FILE_NAMES = ["AGENTS.md", "CLAUDE.md"]; const SKILL_DIRS = [path.join(".pi", "skills"), path.join(".agents", "skills")]; let roots: WorkspaceRoot[] = []; let persistenceEnabled = false; let currentWorkspaceKey: string | undefined; let activeCwd = process.cwd(); function expandHome(input: string): string { if (input === "~") return homedir(); if (input.startsWith("~/")) return path.join(homedir(), input.slice(2)); return input; } function normalizeAlias(input: string): string { const withoutPrefix = input.trim().replace(/^[@$]/, ""); const sanitized = withoutPrefix .toLowerCase() .replace(/[^a-z0-9_-]+/g, "-") .replace(/^-+|-+$/g, "") .replace(/-+/g, "-"); const withFallback = sanitized || "root"; return /^[a-z]/.test(withFallback) ? withFallback : `root-${withFallback}`; } function uniqueAlias(baseAlias: string, existing: WorkspaceRoot[], currentPath?: string): string { let candidate = normalizeAlias(baseAlias); let suffix = 2; while (existing.some((root) => root.alias === candidate && root.path !== currentPath)) { candidate = `${normalizeAlias(baseAlias)}-${suffix}`; suffix += 1; } return candidate; } function shellQuote(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'`; } function getRootSkillPaths(root: WorkspaceRoot): string[] { return SKILL_DIRS.map((dir) => path.join(root.path, dir)).filter((dir) => { try { return fs.statSync(dir).isDirectory(); } catch { return false; } }); } function getWorkspaceSkillPaths(): string[] { return Array.from(new Set(roots.flatMap(getRootSkillPaths))); } function formatRoot(root: WorkspaceRoot): string { const hints = CONTEXT_FILE_NAMES.filter((file) => fs.existsSync(path.join(root.path, file))); const skillPaths = getRootSkillPaths(root); const details = [ hints.length > 0 ? `context: ${hints.join(", ")}` : undefined, skillPaths.length > 0 ? `skills: ${skillPaths.map((dir) => path.relative(root.path, dir)).join(", ")}` : undefined, ].filter(Boolean); const context = details.length > 0 ? ` (${details.join("; ")})` : ""; return `@${root.alias} → ${root.path}${context}`; } function formatRootList(): string { if (roots.length === 0) return "No hay directorios extra en el contexto."; return roots.map(formatRoot).join("\n"); } function updateUi(ctx: ExtensionContext): void { if (!ctx.hasUI) return; if (roots.length === 0) { ctx.ui.setStatus(STATUS_ID, persistenceEnabled ? "ctx: persist on" : undefined); return; } const aliases = roots.map((root) => `@${root.alias}`).join(", "); ctx.ui.setStatus(STATUS_ID, `${persistenceEnabled ? "ctx*:" : "ctx:"} ${aliases}`); } async function canonicalDirectory(inputPath: string, cwd: string): Promise { const trimmed = inputPath.trim(); if (!trimmed) throw new Error("Debes indicar un directorio."); const resolved = path.resolve(cwd, expandHome(trimmed)); let stats: fs.Stats; try { stats = await fs.promises.stat(resolved); } catch { throw new Error(`El directorio no existe: ${resolved}`); } if (!stats.isDirectory()) { throw new Error(`La ruta no es un directorio: ${resolved}`); } try { return await fs.promises.realpath(resolved); } catch { return resolved; } } function createPersistentConfig(): PersistentConfig { return { version: 1, workspaces: {} }; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function normalizePersistentRoot(value: unknown): WorkspaceRoot | undefined { if (!isRecord(value) || typeof value.alias !== "string" || typeof value.path !== "string") return undefined; const addedAt = typeof value.addedAt === "number" && Number.isFinite(value.addedAt) ? value.addedAt : Date.now(); return { alias: normalizeAlias(value.alias), path: value.path, addedAt, }; } function normalizePersistentWorkspace(value: unknown): PersistentWorkspace | undefined { if (!isRecord(value)) return undefined; const rootValues = Array.isArray(value.roots) ? value.roots : []; return { enabled: value.enabled === true, roots: rootValues.map(normalizePersistentRoot).filter((root): root is WorkspaceRoot => Boolean(root)), updatedAt: typeof value.updatedAt === "number" && Number.isFinite(value.updatedAt) ? value.updatedAt : Date.now(), }; } async function readPersistentConfig(): Promise { try { const raw = await fs.promises.readFile(PERSISTENCE_FILE, "utf8"); const parsed = JSON.parse(raw) as unknown; if (!isRecord(parsed) || parsed.version !== 1 || !isRecord(parsed.workspaces)) return createPersistentConfig(); const workspaces: Record = {}; for (const [workspaceKey, workspaceValue] of Object.entries(parsed.workspaces)) { const workspace = normalizePersistentWorkspace(workspaceValue); if (workspace) workspaces[workspaceKey] = workspace; } return { version: 1, workspaces }; } catch { return createPersistentConfig(); } } async function writePersistentConfig(config: PersistentConfig): Promise { await fs.promises.mkdir(path.dirname(PERSISTENCE_FILE), { recursive: true }); await fs.promises.writeFile(PERSISTENCE_FILE, `${JSON.stringify(config, null, 2)}\n`, "utf8"); } async function resolveWorkspaceKey(cwd: string): Promise { try { return await fs.promises.realpath(cwd); } catch { return path.resolve(cwd); } } function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error); } function upsertRoot(root: WorkspaceRoot): { root: WorkspaceRoot; created: boolean; aliasChanged: boolean } { const existingIndex = roots.findIndex((candidate) => candidate.path === root.path); if (existingIndex >= 0) { const existing = roots[existingIndex]; const alias = uniqueAlias(root.alias, roots, existing.path); const updated = { ...existing, alias, addedAt: root.addedAt }; roots[existingIndex] = updated; return { root: updated, created: false, aliasChanged: existing.alias !== alias }; } const alias = uniqueAlias(root.alias, roots, root.path); const created = { ...root, alias }; roots = [...roots, created]; return { root: created, created: true, aliasChanged: alias !== root.alias }; } function removeRoot(identifier: string, cwd = process.cwd()): WorkspaceRoot | undefined { const target = identifier.trim(); if (!target) return undefined; const normalizedAlias = normalizeAlias(target); const expandedPath = path.resolve(cwd, expandHome(target)); const index = roots.findIndex((root) => { return root.alias === normalizedAlias || root.path === target || root.path === expandedPath; }); if (index < 0) return undefined; const [removed] = roots.splice(index, 1); return removed; } function parseArgs(input: string): string[] { const args: string[] = []; let current = ""; let quote: "'" | '"' | undefined; let escaped = false; for (const char of input.trim()) { if (escaped) { current += char; escaped = false; continue; } if (char === "\\") { escaped = true; continue; } if (quote) { if (char === quote) quote = undefined; else current += char; continue; } if (char === "'" || char === '"') { quote = char; continue; } if (/\s/.test(char)) { if (current) { args.push(current); current = ""; } continue; } current += char; } if (current) args.push(current); return args; } function hasCompletedFirstArgument(input: string): boolean { let quote: "'" | '"' | undefined; let escaped = false; let sawArgument = false; for (const char of input.trimStart()) { if (escaped) { escaped = false; sawArgument = true; continue; } if (char === "\\") { escaped = true; sawArgument = true; continue; } if (quote) { if (char === quote) quote = undefined; sawArgument = true; continue; } if (char === "'" || char === '"') { quote = char; sawArgument = true; continue; } if (/\s/.test(char)) return sawArgument; sawArgument = true; } return false; } function unquotePathPrefix(input: string): { pathPrefix: string; quote?: "'" | '"' } { const trimmed = input.trimStart(); const quote = trimmed[0] === "'" || trimmed[0] === '"' ? (trimmed[0] as "'" | '"') : undefined; return { pathPrefix: quote ? trimmed.slice(1) : trimmed, quote }; } function toDisplayPath(value: string): string { return value.replace(/\\/g, "/"); } function quoteCompletionPath(value: string, preferredQuote?: "'" | '"'): string { const needsQuotes = Boolean(preferredQuote) || /\s/.test(value); if (!needsQuotes) return value; const quote = preferredQuote || '"'; const escaped = quote === '"' ? value.replace(/"/g, '\\"') : value.replace(/'/g, "'\\''"); return `${quote}${escaped}${quote}`; } function getDirectoryCompletions(argumentPrefix: string): Array<{ value: string; label: string; description?: string }> | null { if (hasCompletedFirstArgument(argumentPrefix)) return null; const { pathPrefix, quote } = unquotePathPrefix(argumentPrefix); let expandedPrefix = expandHome(pathPrefix); const isAbsolute = path.isAbsolute(expandedPrefix); const displayPrefix = pathPrefix; let searchDir: string; let searchPrefix: string; if (expandedPrefix === "") { searchDir = activeCwd; searchPrefix = ""; } else if (expandedPrefix.endsWith(path.sep) || displayPrefix.endsWith("/")) { searchDir = isAbsolute ? expandedPrefix : path.resolve(activeCwd, expandedPrefix); searchPrefix = ""; } else { const directoryPart = path.dirname(expandedPrefix); searchDir = isAbsolute ? directoryPart : path.resolve(activeCwd, directoryPart); searchPrefix = path.basename(expandedPrefix); } let entries: fs.Dirent[]; try { entries = fs.readdirSync(searchDir, { withFileTypes: true }); } catch { return null; } const suggestions = entries .filter((entry) => { if (entry.name === "." || entry.name === "..") return false; if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) return false; if (entry.isDirectory()) return true; if (!entry.isSymbolicLink()) return false; try { return fs.statSync(path.join(searchDir, entry.name)).isDirectory(); } catch { return false; } }) .sort((a, b) => a.name.localeCompare(b.name)) .slice(0, 50) .map((entry) => { let completedPath: string; if (displayPrefix === "" || displayPrefix.endsWith("/")) { completedPath = `${displayPrefix}${entry.name}/`; } else if (displayPrefix.includes("/")) { const displayDir = path.posix.dirname(toDisplayPath(displayPrefix)); completedPath = `${displayDir === "." ? "" : `${displayDir}/`}${entry.name}/`; if (displayPrefix.startsWith("./") && !completedPath.startsWith("./")) completedPath = `./${completedPath}`; } else { completedPath = `${entry.name}/`; } return { value: quoteCompletionPath(toDisplayPath(completedPath), quote), label: `${entry.name}/`, description: toDisplayPath(path.join(searchDir, entry.name)), }; }); return suggestions.length > 0 ? suggestions : null; } function persist(pi: ExtensionAPI, entry: PersistedEntry): void { pi.appendEntry(CUSTOM_TYPE, entry); } async function promptDirectory(ctx: ExtensionContext): Promise { if (!ctx.hasUI) return undefined; return await ctx.ui.custom((tui, theme, _keybindings, done) => { const input = new Input(); let suggestions = getDirectoryCompletions("") || []; let selectedIndex = 0; input.onSubmit = (value) => done(value.trim() || undefined); input.onEscape = () => done(undefined); function refreshSuggestions(): void { suggestions = getDirectoryCompletions(input.getValue()) || []; selectedIndex = Math.min(selectedIndex, Math.max(0, suggestions.length - 1)); } function applySelectedSuggestion(): void { const selected = suggestions[selectedIndex]; if (!selected) return; const current = input.getValue(); if (selected.value.startsWith(current)) { input.handleInput(selected.value.slice(current.length)); } else { input.setValue(selected.value); } refreshSuggestions(); } function padAnsi(value: string, targetWidth: number): string { const truncated = truncateToWidth(value, targetWidth, ""); return truncated + " ".repeat(Math.max(0, targetWidth - visibleWidth(truncated))); } function frameLine(content: string, innerWidth: number): string { return theme.fg("border", "│ ") + padAnsi(content, innerWidth) + theme.fg("border", " │"); } return { render(width: number): string[] { const panelWidth = Math.max(12, Math.min(width, 86)); const innerWidth = Math.max(4, panelWidth - 4); const top = theme.fg("border", `╭${"─".repeat(panelWidth - 2)}╮`); const bottom = theme.fg("border", `╰${"─".repeat(panelWidth - 2)}╯`); const inputLine = input.render(innerWidth)[0] || ""; const maxVisibleSuggestions = 7; const scrollStart = Math.max( 0, Math.min(selectedIndex - Math.floor(maxVisibleSuggestions / 2), suggestions.length - maxVisibleSuggestions), ); const visibleSuggestions = suggestions.slice(scrollStart, scrollStart + maxVisibleSuggestions); const lines = [ top, frameLine(theme.fg("dim", "workspace context"), innerWidth), frameLine(theme.fg("accent", theme.bold("Add a directory")), innerWidth), frameLine(theme.fg("dim", "Choose an extra local root to include in this workspace."), innerWidth), frameLine("", innerWidth), frameLine(theme.fg("muted", "Path"), innerWidth), frameLine(theme.fg("text", inputLine), innerWidth), frameLine("", innerWidth), ]; if (visibleSuggestions.length === 0) { lines.push(frameLine(theme.fg("warning", "No matching directories"), innerWidth)); } else { for (const [index, item] of visibleSuggestions.entries()) { const actualIndex = scrollStart + index; const selected = actualIndex === selectedIndex; const prefix = selected ? theme.fg("accent", "▌ ") : theme.fg("dim", " "); const label = selected ? theme.fg("accent", item.label) : theme.fg("text", item.label); const description = item.description ? theme.fg("dim", ` ${item.description}`) : ""; const row = `${prefix}${label}${description}`; lines.push(frameLine(selected ? theme.bg("selectedBg", padAnsi(row, innerWidth)) : row, innerWidth)); } } if (suggestions.length > visibleSuggestions.length) { const rangeStart = scrollStart + 1; const rangeEnd = scrollStart + visibleSuggestions.length; lines.push(frameLine(theme.fg("dim", `Showing ${rangeStart}-${rangeEnd} of ${suggestions.length} matches`), innerWidth)); } lines.push(frameLine("", innerWidth)); lines.push(frameLine(theme.fg("dim", "Tab complete · ↑↓ select · Enter confirm · Esc cancel"), innerWidth)); lines.push(bottom); return lines; }, invalidate(): void { input.invalidate(); }, handleInput(data: string): void { if (matchesKey(data, Key.tab)) { applySelectedSuggestion(); } else if (matchesKey(data, Key.up)) { selectedIndex = suggestions.length > 0 ? (selectedIndex - 1 + suggestions.length) % suggestions.length : 0; } else if (matchesKey(data, Key.down)) { selectedIndex = suggestions.length > 0 ? (selectedIndex + 1) % suggestions.length : 0; } else { input.handleInput(data); refreshSuggestions(); } tui.requestRender(); }, }; }, { overlay: true }); } async function addDirectory( pi: ExtensionAPI, ctx: ExtensionContext, inputPath: string, alias?: string, shouldPersist = true, ): Promise<{ root: WorkspaceRoot; created: boolean; aliasChanged: boolean }> { const realPath = await canonicalDirectory(inputPath, ctx.cwd); const root: WorkspaceRoot = { alias: normalizeAlias(alias || path.basename(realPath)), path: realPath, addedAt: Date.now(), }; const result = upsertRoot(root); if (shouldPersist) persist(pi, { version: 1, action: "add", root: result.root }); updateUi(ctx); return result; } function copyRoots(): WorkspaceRoot[] { return roots.map((root) => ({ ...root })); } async function getWorkspaceKey(ctx: ExtensionContext): Promise { if (!currentWorkspaceKey) currentWorkspaceKey = await resolveWorkspaceKey(ctx.cwd); return currentWorkspaceKey; } async function restorePersistentWorkspace(ctx: ExtensionContext): Promise { currentWorkspaceKey = await resolveWorkspaceKey(ctx.cwd); const config = await readPersistentConfig(); const workspace = config.workspaces[currentWorkspaceKey]; persistenceEnabled = workspace?.enabled === true; if (!persistenceEnabled || !workspace) return; for (const root of workspace.roots) { upsertRoot(root); } } async function savePersistentWorkspace(ctx: ExtensionContext, enabled: boolean): Promise { const workspaceKey = await getWorkspaceKey(ctx); const config = await readPersistentConfig(); if (enabled) { config.workspaces[workspaceKey] = { enabled: true, roots: copyRoots(), updatedAt: Date.now(), }; } else { delete config.workspaces[workspaceKey]; } await writePersistentConfig(config); persistenceEnabled = enabled; } async function syncPersistentWorkspace(ctx: ExtensionContext): Promise { if (!persistenceEnabled) return; try { await savePersistentWorkspace(ctx, true); } catch (error) { ctx.ui.notify(`No pude guardar el contexto persistente: ${errorText(error)}`, "error"); } } function restoreFromSession(ctx: ExtensionContext, reset = true): void { if (reset) roots = []; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type !== "custom" || entry.customType !== CUSTOM_TYPE) continue; const data = entry.data as PersistedEntry | undefined; if (!data || data.version !== 1) continue; if (data.action === "clear") { roots = []; continue; } if (data.action === "remove") { const identifier = data.alias || data.path; if (identifier) removeRoot(identifier, ctx.cwd); continue; } if (data.action === "add") { upsertRoot(data.root); } } } function resolvePathAlias(value: string): string { const trimmed = value.trim(); for (const root of roots) { const alias = root.alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const atPrefix = new RegExp(`^@${alias}(?:/(.*))?$`); const dollarPrefix = new RegExp(`^\\$${alias}(?:/(.*))?$`); const colonPrefix = new RegExp(`^${alias}:(.*)$`); const match = trimmed.match(atPrefix) || trimmed.match(dollarPrefix) || trimmed.match(colonPrefix); if (!match) continue; const suffix = match[1] || ""; return path.join(root.path, suffix); } return value; } function expandAliasesInCommand(command: string): string { let expanded = command; for (const root of roots) { const alias = root.alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const pattern = new RegExp("(?)]*)?(?![\\w-])", "g"); expanded = expanded.replace(pattern, (token) => { const withoutPrefix = token.slice(1); const suffix = withoutPrefix.slice(root.alias.length); return shellQuote(path.join(root.path, suffix)); }); } return expanded; } function appendWorkspacePrompt(basePrompt: string, cwd: string): string { if (roots.length === 0) return basePrompt; const rootLines = roots.map((root) => `- ${formatRoot(root)}`).join("\n"); return `${basePrompt} ## Additional Workspace Directories The user added or restored these extra local directories as workspace context. Treat them as part of the same workspace as the primary cwd. Primary cwd: ${cwd} Extra roots: ${rootLines} Guidelines for extra roots: - Use absolute paths shown above when calling read/write/edit/bash outside the primary cwd. - For read/write/edit path arguments, aliases like @backend/src/file.ts, $backend/src/file.ts, or backend:src/file.ts are expanded automatically when the alias exists. - For bash commands, prefer \`cd /absolute/root && ...\` or \`git -C /absolute/root ...\`. Simple @alias/path and $alias/path tokens in bash commands are expanded automatically. - If an extra root contains AGENTS.md or CLAUDE.md, read that file before making changes in that root. - Skills found under an extra root's .pi/skills or .agents/skills directories are available through the normal skills mechanism after the context is added/restored. `; } export default function workspaceContextExtension(pi: ExtensionAPI) { pi.on("session_start", async (_event, ctx) => { activeCwd = ctx.cwd; roots = []; await restorePersistentWorkspace(ctx); restoreFromSession(ctx, false); updateUi(ctx); if (roots.length > 0) { const source = persistenceEnabled ? "persistente" : "de la sesión"; ctx.ui.notify(`Workspace context restaurado (${source}): ${roots.map((root) => `@${root.alias}`).join(", ")}`, "info"); } }); pi.on("resources_discover", async () => { const skillPaths = getWorkspaceSkillPaths(); if (skillPaths.length === 0) return undefined; return { skillPaths }; }); pi.on("before_agent_start", async (event, ctx) => { return { systemPrompt: appendWorkspacePrompt(event.systemPrompt, ctx.cwd) }; }); pi.on("tool_call", async (event) => { if (roots.length === 0) return undefined; if (["read", "write", "edit"].includes(event.toolName) && typeof event.input.path === "string") { event.input.path = resolvePathAlias(event.input.path); return undefined; } if (event.toolName === "bash" && typeof event.input.command === "string") { event.input.command = expandAliasesInCommand(event.input.command); return undefined; } return undefined; }); pi.registerCommand("ctx-add", { description: "Agrega un directorio extra al contexto: /ctx-add [alias]", argumentHint: " [alias]", getArgumentCompletions: (prefix) => getDirectoryCompletions(prefix), handler: async (args, ctx) => { let [inputPath, alias] = parseArgs(args); if (!inputPath) { const answer = await promptDirectory(ctx); if (!answer) return; inputPath = answer.trim(); alias = await ctx.ui.input("Alias opcional", path.basename(inputPath)); } try { const result = await addDirectory(pi, ctx, inputPath, alias || undefined); await syncPersistentWorkspace(ctx); const status = result.created ? "Agregado" : "Actualizado"; const adjusted = result.aliasChanged ? " (alias ajustado por conflicto)" : ""; const skillPaths = getRootSkillPaths(result.root); const persisted = persistenceEnabled ? " Guardado para nuevas conversaciones." : ""; const reloadMessage = skillPaths.length > 0 ? " Recargando para registrar skills..." : ""; ctx.ui.notify(`${status}: @${result.root.alias} → ${result.root.path}${adjusted}.${persisted}${reloadMessage}`, "info"); if (skillPaths.length > 0) { await ctx.reload(); return; } } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); } }, }); pi.registerCommand("ctx-persist", { description: "Activa persistencia de los directorios extra para nuevas conversaciones: /ctx-persist [on|off|status]", handler: async (args, ctx) => { const [rawAction] = parseArgs(args); const action = (rawAction || "on").toLowerCase(); try { if (["on", "enable", "enabled", "true", "yes"].includes(action)) { await savePersistentWorkspace(ctx, true); updateUi(ctx); const count = roots.length === 1 ? "1 directorio" : `${roots.length} directorios`; ctx.ui.notify( `Persistencia activada para este workspace (${count}). Nuevas conversaciones desde ${await getWorkspaceKey(ctx)} restaurarán este contexto.`, "info", ); return; } if (["off", "disable", "disabled", "false", "no"].includes(action)) { await savePersistentWorkspace(ctx, false); updateUi(ctx); ctx.ui.notify( "Persistencia desactivada para este workspace. El contexto actual queda sólo en esta sesión.", "info", ); return; } if (["status", "show"].includes(action)) { const workspaceKey = await getWorkspaceKey(ctx); const current = roots.length > 0 ? `Contexto actual:\n${roots.map(formatRoot).join("\n")}` : "Contexto actual: no hay directorios."; ctx.ui.notify( `Persistencia: ${persistenceEnabled ? "activada" : "desactivada"}\nWorkspace: ${workspaceKey}\nArchivo: ${PERSISTENCE_FILE}\n\n${current}`, "info", ); return; } ctx.ui.notify("Uso: /ctx-persist [on|off|status]", "warning"); } catch (error) { ctx.ui.notify(`No pude actualizar la persistencia: ${errorText(error)}`, "error"); } }, }); pi.registerCommand("ctx-list", { description: "Lista los directorios extra agregados al contexto", handler: async (_args, ctx) => { ctx.ui.notify(`${formatRootList()}\n\nPersistencia: ${persistenceEnabled ? "activada" : "desactivada"}`, "info"); updateUi(ctx); }, }); pi.registerCommand("ctx-remove", { description: "Quita un directorio extra del contexto: /ctx-remove ", handler: async (args, ctx) => { let target = args.trim(); if (!target && roots.length > 0) { const choice = await ctx.ui.select( "Quitar directorio del contexto", roots.map((root) => `${root.alias} — ${root.path}`), ); if (!choice) return; target = choice.split(" — ")[0] || ""; } if (!target) { ctx.ui.notify("Uso: /ctx-remove ", "warning"); return; } const removed = removeRoot(target, ctx.cwd); if (!removed) { ctx.ui.notify(`No encontré ese directorio en el contexto: ${target}`, "warning"); return; } const hadSkillPaths = getRootSkillPaths(removed).length > 0; persist(pi, { version: 1, action: "remove", alias: removed.alias, path: removed.path }); await syncPersistentWorkspace(ctx); updateUi(ctx); const persisted = persistenceEnabled ? ". Guardado para nuevas conversaciones." : ""; ctx.ui.notify(`Quitado: @${removed.alias} → ${removed.path}${persisted}${hadSkillPaths ? " Recargando skills..." : ""}`, "info"); if (hadSkillPaths) { await ctx.reload(); return; } }, }); pi.registerCommand("ctx-clear", { description: "Quita todos los directorios extra del contexto", handler: async (_args, ctx) => { if (roots.length === 0) { ctx.ui.notify("No hay directorios extra en el contexto.", "info"); return; } const message = persistenceEnabled ? "¿Quitar todos los directorios extra de esta sesión y de nuevas conversaciones?" : "¿Quitar todos los directorios extra del contexto de esta sesión?"; const ok = await ctx.ui.confirm("Limpiar contexto", message); if (!ok) return; const hadSkillPaths = getWorkspaceSkillPaths().length > 0; roots = []; persist(pi, { version: 1, action: "clear" }); await syncPersistentWorkspace(ctx); updateUi(ctx); const persisted = persistenceEnabled ? " Guardado para nuevas conversaciones." : ""; ctx.ui.notify(`Contexto extra limpiado.${persisted}${hadSkillPaths ? " Recargando skills..." : ""}`, "info"); if (hadSkillPaths) { await ctx.reload(); return; } }, }); }