import { spawn } from "node:child_process"; import { resolve, sep } from "node:path"; import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; import { Container, type Focusable, Input, Key, matchesKey, Spacer, Text, truncateToWidth, type TUI, visibleWidth, wrapTextWithAnsi, } from "@mariozechner/pi-tui"; import type { ExtensionEntry, ExtensionsRegistry } from "../types.js"; interface ExtensionsManagerOptions { onRefresh: () => Promise; onToggle: (extension: ExtensionEntry, enabled: boolean) => Promise; onUpdateOne: (extension: ExtensionEntry, signal?: AbortSignal) => Promise; onUpdateAll: (signal?: AbortSignal) => Promise; } type Mode = "browse" | "preview" | "updating"; class SingleLineText { constructor( private readonly text: string, private readonly ellipsis = "...", ) {} render(width: number): string[] { return [truncateToWidth(this.text, width, this.ellipsis)]; } invalidate(): void {} } function createFrameLine(theme: ExtensionContext["ui"]["theme"], line: string, innerWidth: number): string { const pad = Math.max(0, innerWidth - visibleWidth(line)); return `${theme.fg("accent", "│ ")}${line}${" ".repeat(pad)}${theme.fg("accent", " │")}`; } function renderFramedPanel(theme: ExtensionContext["ui"]["theme"], width: number, lines: string[]): string[] { const innerWidth = Math.max(20, width - 4); const ellipsis = theme.fg("dim", "..."); const top = theme.fg("accent", `┌${"─".repeat(innerWidth + 2)}┐`); const bottom = theme.fg("accent", `└${"─".repeat(innerWidth + 2)}┘`); return [ top, ...lines.map((line) => createFrameLine(theme, truncateToWidth(line, innerWidth, ellipsis), innerWidth)), bottom, ]; } function centerRenderedLines(lines: string[], width: number): string[] { const renderedWidth = lines.reduce((max, line) => Math.max(max, visibleWidth(line)), 0); const leftPad = Math.max(0, Math.floor((width - renderedWidth) / 2)); if (leftPad === 0) return lines; const prefix = " ".repeat(leftPad); return lines.map((line) => `${prefix}${line}`); } function renderCenteredDialog(theme: ExtensionContext["ui"]["theme"], width: number, lines: string[], maxInnerWidth = 64): string[] { const innerWidth = Math.max(20, Math.min(width - 4, maxInnerWidth)); const ellipsis = theme.fg("dim", "..."); const top = theme.fg("accent", `┌${"─".repeat(innerWidth + 2)}┐`); const bottom = theme.fg("accent", `└${"─".repeat(innerWidth + 2)}┘`); return centerRenderedLines( [top, ...lines.map((line) => createFrameLine(theme, truncateToWidth(line, innerWidth, ellipsis), innerWidth)), bottom], width, ); } function getScopeLabel(extension: ExtensionEntry, cwd: string): string { if (extension.scope === "project") return "project"; if (extension.scope === "user") return "global"; const normalizedCwd = resolve(cwd); const normalizedPath = resolve(extension.path); return normalizedPath === normalizedCwd || normalizedPath.startsWith(normalizedCwd + sep) ? "project" : "global"; } function getSourceLabel(extension: ExtensionEntry): string { return extension.origin === "package" ? extension.packageDisplayName ?? extension.source : extension.path; } function canUpdate(extension: ExtensionEntry): boolean { return extension.origin === "package"; } function isYourExtension(extension: ExtensionEntry): boolean { return extension.origin === "top-level" || extension.scope === "project" || extension.scope === "temporary"; } function openExternalUrl(url: string): void { if (process.platform === "darwin") { const child = spawn("open", [url], { detached: true, stdio: "ignore" }); child.unref(); return; } if (process.platform === "win32") { const child = spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }); child.unref(); return; } const child = spawn("xdg-open", [url], { detached: true, stdio: "ignore" }); child.unref(); } function getPreviewTitle(extension: ExtensionEntry): string { return extension.packageDisplayName ?? extension.name; } class ScrollableExtensionPreview { private scrollOffset = 0; private lastInnerWidth = 1; private lastContentLines: string[] = []; constructor( private extension: ExtensionEntry, private readonly theme: ExtensionContext["ui"]["theme"], private readonly cwd: string, private readonly getTerminalRows: () => number, ) {} setExtension(extension: ExtensionEntry): void { this.extension = extension; this.scrollOffset = 0; this.lastContentLines = []; } invalidate(): void {} private getInnerWidth(width: number): number { return Math.max(1, width - 4); } private getMaxHeight(): number { return Math.max(10, Math.floor(this.getTerminalRows() * 0.78)); } private buildContentLines(innerWidth: number): string[] { const content = new Container(); const separator = this.theme.fg("muted", " • "); const scope = this.theme.fg("muted", getScopeLabel(this.extension, this.cwd)); const version = this.extension.version ? `${separator}${this.theme.fg("muted", `v${this.extension.version}`)}` : ""; const update = `${separator}${this.extension.hasUpdate ? this.theme.fg("warning", "update available") : this.theme.fg("muted", "up to date")}`; const status = this.extension.enabled ? `${separator}${this.theme.fg("success", "enabled")}` : `${separator}${this.theme.fg("warning", "disabled")}`; content.addChild(new Text(this.theme.fg("accent", this.theme.bold(getPreviewTitle(this.extension))), 0, 0)); content.addChild(new Text(`${scope}${version}${update}${status}`, 0, 0)); content.addChild(new Spacer(1)); content.addChild(new Text(this.theme.fg("muted", this.theme.bold("Metadata")), 0, 0)); content.addChild(new Text(`${this.theme.fg("muted", "type:")} ${this.extension.origin === "package" ? "package extension" : "local extension"}`, 0, 0)); content.addChild(new Text(`${this.theme.fg("muted", "size:")} ${this.extension.sizeLabel}`, 0, 0)); content.addChild(new Text(`${this.theme.fg("muted", "path:")} ${this.extension.path}`, 0, 0)); if (this.extension.description) { content.addChild(new Text(`${this.theme.fg("muted", "description:")} ${this.extension.description}`, 0, 0)); } if (this.extension.link) { content.addChild(new Text(`${this.theme.fg("muted", "link:")} ${this.extension.link}`, 0, 0)); } content.addChild(new Spacer(1)); const lines = content.render(innerWidth); this.lastInnerWidth = innerWidth; this.lastContentLines = lines; return lines; } private buildFooter(innerWidth: number, visibleHeight: number, totalLines: number): string { const maxScroll = Math.max(0, totalLines - visibleHeight); const scrollInfo = maxScroll > 0 ? ` • ${this.scrollOffset + 1}-${Math.min(totalLines, this.scrollOffset + visibleHeight)}/${totalLines}` : ""; const updateInfo = canUpdate(this.extension) ? " • ctrl+u update • ctrl+a update all" : ""; const openInfo = this.extension.link ? " • ctrl+o open link" : ""; return truncateToWidth( this.theme.fg("dim", `↑/↓ scroll • ctrl+x enable/disable${updateInfo}${openInfo} • esc back${scrollInfo}`), innerWidth, this.theme.fg("dim", "..."), ); } render(width: number): string[] { const innerWidth = this.getInnerWidth(width); const maxHeight = this.getMaxHeight(); const visibleHeight = Math.max(1, maxHeight - 3); const contentLines = this.buildContentLines(innerWidth); const maxScroll = Math.max(0, contentLines.length - visibleHeight); this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScroll)); const visibleLines = contentLines.slice(this.scrollOffset, this.scrollOffset + visibleHeight); const top = this.theme.fg("accent", `┌${"─".repeat(innerWidth + 2)}┐`); const bottom = this.theme.fg("accent", `└${"─".repeat(innerWidth + 2)}┘`); return [ top, ...visibleLines.map((line) => createFrameLine(this.theme, line, innerWidth)), createFrameLine(this.theme, this.buildFooter(innerWidth, visibleHeight, contentLines.length), innerWidth), bottom, ]; } handleInput(data: string): void { const maxHeight = this.getMaxHeight(); const visibleHeight = Math.max(1, maxHeight - 3); const totalLines = this.lastContentLines.length || this.buildContentLines(this.lastInnerWidth).length; const maxScroll = Math.max(0, totalLines - visibleHeight); if (matchesKey(data, Key.up)) { this.scrollOffset = Math.max(0, this.scrollOffset - 1); return; } if (matchesKey(data, Key.down)) { this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1); return; } if (matchesKey(data, Key.pageUp)) { this.scrollOffset = Math.max(0, this.scrollOffset - visibleHeight); return; } if (matchesKey(data, Key.pageDown)) { this.scrollOffset = Math.min(maxScroll, this.scrollOffset + visibleHeight); return; } if (matchesKey(data, Key.home)) { this.scrollOffset = 0; return; } if (matchesKey(data, Key.end)) { this.scrollOffset = maxScroll; } } } class ExtensionsManagerDialog implements Focusable { private mode: Mode = "browse"; private _focused = false; private registry: ExtensionsRegistry; private filteredExtensions: ExtensionEntry[] = []; private selectedIndex = 0; private readonly searchInput = new Input(); private query = ""; private previewPath: string | undefined; private preview: ScrollableExtensionPreview | undefined; private updateAbortController: AbortController | undefined; private updateRunId = 0; private updateReturnMode: "browse" | "preview" = "browse"; constructor( private readonly ctx: ExtensionContext, registry: ExtensionsRegistry, private readonly theme: ExtensionContext["ui"]["theme"], private readonly tui: TUI, private readonly done: () => void, private readonly options: ExtensionsManagerOptions, initialQuery = "", ) { this.registry = registry; this.query = initialQuery; this.searchInput.setValue(initialQuery); this.refreshList(); } get focused(): boolean { return this._focused; } set focused(value: boolean) { this._focused = value; this.searchInput.focused = value && this.mode === "browse"; } invalidate(): void {} private orderExtensions(extensions: ExtensionEntry[]): ExtensionEntry[] { const ownExtensions = extensions.filter((entry) => isYourExtension(entry)); const installedExtensions = extensions.filter((entry) => !isYourExtension(entry)); return [...ownExtensions, ...installedExtensions]; } private filterExtensions(query: string): ExtensionEntry[] { const trimmed = query.trim().toLowerCase(); const source = this.registry.allExtensions; if (!trimmed) return this.orderExtensions(source); const tokens = trimmed.split(/\s+/).filter(Boolean); return this.orderExtensions(source.filter((extension) => { const haystack = [ extension.name, extension.displayName, extension.packageDisplayName, extension.source, extension.relativePath, extension.path, ].filter(Boolean).join(" ").toLowerCase(); return tokens.every((token) => haystack.includes(token)); })); } private getBrowseRows(): Array<{ kind: "header"; label: string } | { kind: "extension"; entry: ExtensionEntry }> { const ownExtensions = this.filteredExtensions.filter((entry) => isYourExtension(entry)); const installedExtensions = this.filteredExtensions.filter((entry) => !isYourExtension(entry)); const rows: Array<{ kind: "header"; label: string } | { kind: "extension"; entry: ExtensionEntry }> = []; if (ownExtensions.length > 0) { rows.push({ kind: "header", label: "Local Extensions" }); rows.push(...ownExtensions.map((entry) => ({ kind: "extension" as const, entry }))); } if (installedExtensions.length > 0) { rows.push({ kind: "header", label: "Installed Extensions" }); rows.push(...installedExtensions.map((entry) => ({ kind: "extension" as const, entry }))); } return rows; } private refreshList(preferredPath?: string): void { this.filteredExtensions = this.filterExtensions(this.query); if (!preferredPath) { this.selectedIndex = this.filteredExtensions.length > 0 ? 0 : 0; return; } const nextIndex = this.filteredExtensions.findIndex((entry) => entry.path === preferredPath); this.selectedIndex = nextIndex >= 0 ? nextIndex : Math.min(this.selectedIndex, Math.max(0, this.filteredExtensions.length - 1)); } private getSelectedExtension(): ExtensionEntry | undefined { return this.filteredExtensions[this.selectedIndex]; } private getCurrentPreviewExtension(): ExtensionEntry | undefined { return this.previewPath ? this.registry.allExtensions.find((entry) => entry.path === this.previewPath) : undefined; } private async refreshRegistry(preferredPath?: string): Promise { this.registry = await this.options.onRefresh(); this.refreshList(preferredPath); if (this.previewPath) { const current = this.registry.allExtensions.find((entry) => entry.path === this.previewPath); if (!current) { this.previewPath = undefined; this.preview = undefined; this.mode = "browse"; } else { this.preview?.setExtension(current); } } } private async toggleExtension(extension: ExtensionEntry): Promise { try { await this.options.onToggle(extension, !extension.enabled); await this.refreshRegistry(extension.path); this.ctx.ui.notify( extension.enabled ? `Disabled ${extension.name}. Run /reload to fully apply the change.` : `Enabled ${extension.name}. Run /reload to fully apply the change.`, "info", ); } catch (error) { this.ctx.ui.notify(error instanceof Error ? error.message : "Failed to update extension visibility", "error"); } this.tui.requestRender(); } render(width: number): string[] { if (this.mode === "preview") return this.preview?.render(width) ?? this.renderBrowse(width); if (this.mode === "updating") return this.renderUpdating(width); return this.renderBrowse(width); } private renderBrowse(width: number): string[] { const innerWidth = Math.max(20, width - 4); const root = new Container(); root.addChild(new Text(this.theme.fg("accent", this.theme.bold("Extensions")), 1, 0)); root.addChild(new Spacer(1)); root.addChild(this.searchInput); root.addChild(new Spacer(1)); const rows = this.getBrowseRows(); if (rows.length === 0) { root.addChild(new Text(this.theme.fg("dim", "No extensions found."), 1, 0)); } else { const selected = this.getSelectedExtension(); const descriptionEllipsis = this.theme.fg("dim", "..."); let selectedDisplayIndex = 0; if (selected) { const index = rows.findIndex((row) => row.kind === "extension" && row.entry.path === selected.path); selectedDisplayIndex = index >= 0 ? index : 0; } const startIndex = Math.max(0, Math.min(selectedDisplayIndex - 6, Math.max(0, rows.length - 12))); const endIndex = Math.min(startIndex + 12, rows.length); for (let i = startIndex; i < endIndex; i++) { const row = rows[i]!; if (row.kind === "header") { root.addChild(new Spacer(1)); root.addChild(new SingleLineText(this.theme.fg("muted", this.theme.bold(row.label)), descriptionEllipsis)); continue; } const extension = row.entry; const isSelected = selected?.path === extension.path; const prefix = isSelected ? this.theme.fg("accent", "→ ") : " "; const name = isSelected ? this.theme.fg("accent", extension.displayName) : extension.enabled ? extension.displayName : this.theme.fg("muted", extension.displayName); const size = this.theme.fg("dim", ` ${extension.sizeLabel}`); const status = extension.enabled ? "" : this.theme.fg("warning", " [disabled]"); const scope = this.theme.fg("muted", ` [${getScopeLabel(extension, this.ctx.cwd)}]`); const update = extension.hasUpdate ? this.theme.fg("success", " [update]") : ""; const descriptionText = extension.description ?? (extension.origin === "package" ? (extension.packageDisplayName ?? extension.source) : extension.relativePath); const description = this.theme.fg("dim", ` - ${descriptionText}`); root.addChild(new SingleLineText(`${prefix}${name}${size}${status}${scope}${update}${description}`, descriptionEllipsis)); } } root.addChild(new Spacer(1)); const footer = ["type to search", "tab preview", "ctrl+x enable/disable", "ctrl+u update", "ctrl+a update all", "esc close"]; root.addChild(new Text(this.theme.fg("dim", footer.join(" • ")), 1, 0)); return renderFramedPanel(this.theme, width, root.render(innerWidth)); } private renderUpdating(width: number): string[] { return renderCenteredDialog(this.theme, width, [ this.theme.fg("accent", this.theme.bold("Updating extensions")), "", this.theme.fg("dim", "Please wait while the selected extension packages are updated."), "", this.theme.fg("dim", "The dialog will return when the update finishes."), "", this.theme.fg("dim", "esc cancel"), ]); } private async updateOne(extension: ExtensionEntry): Promise { if (!canUpdate(extension)) { this.ctx.ui.notify("Only package extensions can be updated.", "warning"); return; } this.mode = "updating"; this.updateReturnMode = this.previewPath ? "preview" : "browse"; const runId = ++this.updateRunId; const abortController = new AbortController(); this.updateAbortController = abortController; this.tui.requestRender(); try { await this.options.onUpdateOne(extension, abortController.signal); } catch (error) { if (this.updateRunId !== runId) return; this.updateAbortController = undefined; this.mode = this.updateReturnMode; this.tui.requestRender(); return; } if (this.updateRunId !== runId) return; this.updateAbortController = undefined; if (abortController.signal.aborted) { this.mode = this.updateReturnMode; this.tui.requestRender(); return; } await this.refreshRegistry(extension.path); this.mode = this.updateReturnMode; this.tui.requestRender(); } private async updateAll(): Promise { this.mode = "updating"; this.updateReturnMode = this.previewPath ? "preview" : "browse"; const runId = ++this.updateRunId; const abortController = new AbortController(); this.updateAbortController = abortController; const preferredPath = this.previewPath ?? this.getSelectedExtension()?.path; this.tui.requestRender(); try { await this.options.onUpdateAll(abortController.signal); } catch (error) { if (this.updateRunId !== runId) return; this.updateAbortController = undefined; this.mode = this.updateReturnMode; this.tui.requestRender(); return; } if (this.updateRunId !== runId) return; this.updateAbortController = undefined; if (abortController.signal.aborted) { this.mode = this.updateReturnMode; this.tui.requestRender(); return; } await this.refreshRegistry(preferredPath); this.mode = this.updateReturnMode; this.tui.requestRender(); } handleInput(data: string): void { if (this.mode === "updating") { if (matchesKey(data, Key.escape)) { this.updateAbortController?.abort(); this.updateAbortController = undefined; this.updateRunId += 1; this.mode = this.updateReturnMode; this.tui.requestRender(); } return; } if (this.mode === "preview") { const extension = this.getCurrentPreviewExtension(); if (!extension) { this.mode = "browse"; this.previewPath = undefined; return; } if (matchesKey(data, Key.escape) || matchesKey(data, Key.tab)) { this.refreshList(extension.path); this.mode = "browse"; return; } if (matchesKey(data, Key.ctrl("x"))) { void this.toggleExtension(extension); return; } if (matchesKey(data, Key.ctrl("u")) && canUpdate(extension)) { void this.updateOne(extension); return; } if (matchesKey(data, Key.ctrl("a"))) { void this.updateAll(); return; } if (matchesKey(data, Key.ctrl("o")) && extension.link) { try { openExternalUrl(extension.link); } catch { this.ctx.ui.notify("Failed to open link", "error"); } return; } this.preview?.handleInput(data); return; } if (matchesKey(data, Key.up)) { if (this.filteredExtensions.length > 0) this.selectedIndex = this.selectedIndex === 0 ? this.filteredExtensions.length - 1 : this.selectedIndex - 1; return; } if (matchesKey(data, Key.down)) { if (this.filteredExtensions.length > 0) this.selectedIndex = this.selectedIndex === this.filteredExtensions.length - 1 ? 0 : this.selectedIndex + 1; return; } if (matchesKey(data, Key.tab)) { const extension = this.getSelectedExtension(); if (extension) { this.previewPath = extension.path; this.preview = new ScrollableExtensionPreview(extension, this.theme, this.ctx.cwd, () => this.tui.terminal.rows); this.mode = "preview"; } return; } if (matchesKey(data, Key.ctrl("x"))) { const extension = this.getSelectedExtension(); if (extension) void this.toggleExtension(extension); return; } if (matchesKey(data, Key.ctrl("u"))) { const extension = this.getSelectedExtension(); if (extension && canUpdate(extension)) void this.updateOne(extension); return; } if (matchesKey(data, Key.ctrl("a"))) { void this.updateAll(); return; } if (matchesKey(data, Key.escape)) { if (this.searchInput.getValue()) { this.query = ""; this.searchInput.setValue(""); this.refreshList(); return; } this.done(); return; } this.searchInput.handleInput(data); this.query = this.searchInput.getValue(); this.refreshList(); } } export async function showExtensionsManager( ctx: ExtensionContext, registry: ExtensionsRegistry, options: ExtensionsManagerOptions, ): Promise { await ctx.ui.custom((tui, _theme, _kb, done) => { const dialog = new ExtensionsManagerDialog(ctx, registry, ctx.ui.theme, tui, done, options); return { get focused() { return dialog.focused; }, set focused(value: boolean) { dialog.focused = value; }, render(width: number) { return dialog.render(width); }, invalidate() { dialog.invalidate(); }, handleInput(data: string) { dialog.handleInput(data); tui.requestRender(); }, }; }, { overlay: true, overlayOptions: { width: "80%", maxHeight: "85%", anchor: "center" } }); }