import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent"; import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import { discoverManagedCli, loadManagedSettings, saveManagedSettings, AXI_RECOVERY_TOOLS, type DetectedCliTool, type ManagedToolPolicy } from "./managed-tools.ts"; import { summarizeResults } from "../runtime/results.ts"; type Tab = "cli" | "plugin"; type Done = (result?: SettingsResult) => void; export interface SettingsResult { managedCli: string[]; managedPlugin: string[]; } export interface ManagedPluginTool { name: string; path: string; detected: boolean; managed: boolean; } type SettingTool = DetectedCliTool | ManagedPluginTool; function pad(text: string, width: number): string { const clipped = truncateToWidth(text, Math.max(1, width), "…"); return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped))); } function frame(lines: string[], width: number, theme: Theme, title: string): string[] { const inner = Math.max(1, width - 2); const border = (value: string) => theme.fg("borderAccent", value); const titleText = ` ${truncateToWidth(title, Math.max(1, inner - 6), "…")} `; const titleWidth = visibleWidth(titleText); const top = `${border("╭──")}${theme.fg("accent", theme.bold(titleText))}${border("─".repeat(Math.max(1, inner - 2 - titleWidth)))}${border("╮")}`; const body = lines.map((line) => `${border("│")} ${pad(line, inner - 2)} ${border("│")}`); return [top, ...body, `${border("╰")}${border("─".repeat(inner))}${border("╯")}`]; } function fuzzyMatch(query: string, value: string): boolean { let cursor = 0; for (const character of query) { cursor = value.indexOf(character, cursor); if (cursor < 0) return false; cursor += 1; } return true; } function hint(theme: Theme, key: string, label: string): string { return `${theme.fg("accent", key)} ${theme.fg("dim", label)}`; } function prefixMatch(value: string, prefix: string): boolean { return value.toLowerCase().startsWith(prefix.toLowerCase()); } function formatBytes(value: number): string { return value < 1024 ? `${value} B` : value < 1024 ** 2 ? `${(value / 1024).toFixed(1)} KiB` : `${(value / 1024 ** 2).toFixed(1)} MiB`; } interface TabState { index: number; search: string; } export class ManagedToolSettingsOverlay { private tab: Tab = "cli"; private readonly state: Record = { cli: { index: 0, search: "" }, plugin: { index: 0, search: "" } }; private confirming = false; private confirmIndex = 0; private saving = false; private readonly selected: Record>; private readonly initial: Record>; private readonly tools: Record; private readonly done: Done; private readonly tui: { requestRender?: () => void }; private readonly theme: Theme; private readonly onSave?: (result: SettingsResult) => void | Promise; private readonly onError?: (error: unknown) => void; constructor( cliTools: DetectedCliTool[], selectedCli: Iterable, pluginTools: ManagedPluginTool[], selectedPlugin: Iterable, done: Done, tui: { requestRender?: () => void }, theme: Theme, onSave?: (result: SettingsResult) => void | Promise, onError?: (error: unknown) => void, ) { this.tools = { cli: cliTools, plugin: pluginTools }; this.initial = { cli: new Set(selectedCli), plugin: new Set(selectedPlugin) }; this.selected = { cli: new Set(selectedCli), plugin: new Set(selectedPlugin) }; this.done = done; this.tui = tui; this.theme = theme; this.onSave = onSave; this.onError = onError; } private currentState(): TabState { return this.state[this.tab]; } private currentTools(): SettingTool[] { return this.tools[this.tab]; } private visibleTools(): SettingTool[] { const query = this.currentState().search.trim().toLowerCase(); const available = this.currentTools().filter((tool) => tool.detected || this.selected[this.tab].has(tool.name)); if (!query) return available; return available.filter((tool) => fuzzyMatch(query, `${tool.name} ${tool.path}`.toLowerCase())); } private result(): SettingsResult { return { managedCli: [...this.selected.cli], managedPlugin: [...this.selected.plugin] }; } private dirty(): boolean { return (["cli", "plugin"] as const).some((tab) => this.selected[tab].size !== this.initial[tab].size || [...this.selected[tab]].some((name) => !this.initial[tab].has(name))); } private requestRender(): void { this.tui.requestRender?.(); } private async save(): Promise { if (this.saving) return; const result = this.result(); if (!this.onSave) { this.done(result); return; } this.saving = true; this.requestRender(); try { await this.onSave(result); this.done(result); } catch (error) { this.saving = false; this.onError?.(error); this.requestRender(); } } private close(): void { if (this.dirty()) { this.confirming = true; this.confirmIndex = 1; this.requestRender(); } else this.done(); } private toggle(): void { const state = this.currentState(); const tool = this.visibleTools()[state.index]; if (!tool) return; if (!tool.detected) { if (this.selected[this.tab].delete(tool.name)) { state.index = Math.min(state.index, Math.max(0, this.visibleTools().length - 1)); this.requestRender(); } return; } if (this.selected[this.tab].has(tool.name)) this.selected[this.tab].delete(tool.name); else this.selected[this.tab].add(tool.name); state.index = Math.min(state.index, Math.max(0, this.visibleTools().length - 1)); this.requestRender(); } private handleConfirmation(data: string): void { if (matchesKey(data, Key.up) || matchesKey(data, Key.left) || matchesKey(data, Key.down) || matchesKey(data, Key.right)) { this.confirmIndex = this.confirmIndex === 0 ? 1 : 0; this.requestRender(); } else if (matchesKey(data, Key.enter) || data === " ") { if (this.confirmIndex === 0) this.done(); else { this.confirming = false; this.requestRender(); } } else if (data === "y" || data === "Y") this.done(); else if (data === "n" || data === "N" || matchesKey(data, Key.escape)) { this.confirming = false; this.requestRender(); } } private handleEditing(data: string): boolean { const state = this.currentState(); if (matchesKey(data, Key.tab)) { this.tab = this.tab === "cli" ? "plugin" : "cli"; this.requestRender(); } else if (matchesKey(data, Key.up)) state.index = Math.max(0, state.index - 1); else if (matchesKey(data, Key.down)) state.index = Math.min(Math.max(0, this.visibleTools().length - 1), state.index + 1); else if (data === " ") { this.toggle(); return true; } else if (matchesKey(data, Key.enter) || matchesKey(data, Key.ctrl("s"))) { void this.save(); return true; } else if (matchesKey(data, Key.escape)) { if (state.search) { state.search = ""; state.index = 0; this.requestRender(); } else this.close(); return true; } else if (matchesKey(data, Key.ctrl("c"))) { this.close(); return true; } else if (matchesKey(data, Key.backspace) || matchesKey(data, Key.ctrl("h"))) { state.search = state.search.slice(0, -1); state.index = 0; } else if (matchesKey(data, Key.ctrl("w"))) { state.search = state.search.trimEnd().replace(/\S+\s*$/, ""); state.index = 0; } else if (matchesKey(data, Key.ctrl("u"))) { state.search = ""; state.index = 0; } else if (data.length === 1 && data >= " " && data !== "\x7f") { state.search += data; state.index = 0; } return false; } handleInput(data: string): void { if (this.saving) return; if (this.confirming) { this.handleConfirmation(data); return; } if (this.handleEditing(data)) return; this.requestRender(); } invalidate(): void {} private renderToolList(visible: SettingTool[], state: TabState, selected: Set, contentWidth: number): string[] { const maxRows = 10; const start = Math.min(Math.max(0, state.index - maxRows + 1), Math.max(0, visible.length - maxRows)); const rows = visible.slice(start, start + maxRows); const lines = start > 0 ? [this.theme.fg("dim", " ↑ more tools")] : []; for (const [offset, tool] of rows.entries()) { const row = start + offset; const focused = row === state.index; const enabled = selected.has(tool.name); const pointer = focused ? this.theme.fg("accent", "▌") : " "; const name = focused ? this.theme.fg("text", tool.name) : this.theme.fg("muted", tool.name); const value = !tool.detected ? `[${this.theme.fg("warning", "✓")}] ${this.theme.fg("warning", "saved")}` : enabled ? `[${this.theme.fg(focused ? "accent" : "success", "✓")}] ${this.theme.fg(focused ? "accent" : "success", "on")}` : `[${this.theme.fg(focused ? "accent" : "muted", " ")}] ${this.theme.fg(focused ? "accent" : "muted", "off")}`; const line = `${pointer} ${pad(name, Math.max(1, contentWidth - 12))} ${value}`; const detail = this.theme.fg("dim", ` ${tool.detected ? tool.path : "unavailable · Space removes saved tool"}`); lines.push(focused ? this.theme.bg("selectedBg", pad(line, contentWidth)) : line); lines.push(focused ? this.theme.bg("selectedBg", pad(detail, contentWidth)) : detail); } if (start + rows.length < visible.length) lines.push(this.theme.fg("dim", " ↓ more tools")); return lines; } render(width: number): string[] { const contentWidth = Math.max(1, width - 4); const state = this.currentState(); const visible = this.visibleTools(); const selected = this.selected[this.tab]; const lines: string[] = [ this.theme.fg("muted", "Select tools whose returned output AXI may capture."), this.theme.fg("dim", "Settings only affect AXI capture; Pi tool execution is unchanged."), "", `${this.tab === "cli" ? this.theme.fg("accent", "[CLI]") : " CLI "} ${this.tab === "plugin" ? this.theme.fg("accent", "[Plugins]") : " Plugins "} ${this.theme.fg("dim", "Tab switches")}`, this.theme.bg("toolPendingBg", pad(` > ${state.search || `type to filter ${this.tab === "cli" ? "CLI tools" : "plugins"}`}`, contentWidth)), "", ]; if (visible.length === 0) lines.push(this.theme.fg("muted", "No matching tools.")); else lines.push(...this.renderToolList(visible, state, selected, contentWidth)); lines.push("", this.theme.fg("dim", `${selected.size} ${this.tab === "cli" ? "CLI" : "plugin"} selected · ${this.selected.cli.size + this.selected.plugin.size} total`)); if (this.saving) lines.push("", this.theme.fg("accent", "Saving settings…")); else if (this.confirming) { lines.push("", this.theme.fg("warning", "Discard unsaved changes?")); lines.push(`${this.confirmIndex === 0 ? "▶" : " "} Discard ${this.confirmIndex === 1 ? "▶" : " "} Cancel`); lines.push(hint(this.theme, "↑↓", "choose") + " " + hint(this.theme, "enter", "confirm")); } else { lines.push("", [hint(this.theme, "tab", "switch"), hint(this.theme, "↑↓", "move"), hint(this.theme, "space", "toggle"), hint(this.theme, "enter", "save"), hint(this.theme, "esc", this.dirty() ? "discard" : "close")].join(this.theme.fg("dim", " · "))); if (state.search) lines.push(hint(this.theme, "ctrl+u", "clear search")); } return frame(lines.map((line) => truncateToWidth(line, contentWidth, "…")), width, this.theme, `AXI settings · ${this.tab === "cli" ? "CLI" : "Plugins"}${this.dirty() ? " • unsaved" : ""}`); } } function selectablePlugin(name: string): boolean { return !AXI_RECOVERY_TOOLS.has(name) && name !== "bash" && name !== "powershell"; } function pluginTools(pi: ExtensionAPI): ManagedPluginTool[] { const all = pi.getAllTools(); return all .filter((tool) => tool.sourceInfo.source !== "builtin" && tool.sourceInfo.source !== "sdk") .filter((tool) => selectablePlugin(tool.name)) .map((tool) => ({ name: tool.name, path: tool.sourceInfo.path, detected: true, managed: false })); } export function registerAxiSettings(pi: ExtensionAPI, policy: ManagedToolPolicy, env: NodeJS.ProcessEnv = process.env): void { const subcommands = [ { value: "settings", label: "Open AXI managed tool settings" }, { value: "stats", label: "Show AXI capture statistics" }, ]; pi.registerCommand("axi", { description: "Configure which CLI and plugin tools AXI manages. Subcommands: [settings] open settings, [stats] show capture statistics", getArgumentCompletions: (prefix: string) => subcommands.filter((item) => !prefix || prefixMatch(item.value, prefix) || (item.value === "settings" && (prefixMatch("st", prefix) || prefixMatch("configure", prefix)))), handler: async (args, ctx) => { const trimmed = args.trim(); if (trimmed === "stats") { const stats = summarizeResults(ctx.sessionManager.getEntries()); ctx.ui.notify(stats.results === 0 ? "AXI stats: no captured results in this session" : `AXI stats: ${stats.results} captured result${stats.results === 1 ? "" : "s"}; ${stats.complete} complete, ${stats.incomplete} incomplete; ${formatBytes(stats.bytes.total)} stored (${formatBytes(stats.bytes.stdout)} stdout, ${formatBytes(stats.bytes.stderr)} stderr, ${formatBytes(stats.bytes.formatted)} formatted)`, "info"); return; } if (trimmed !== "settings" && trimmed !== "configure") { ctx.ui.notify("Usage: /axi settings or /axi stats", "error"); return; } if (ctx.mode !== "tui") { ctx.ui.notify("/axi settings requires interactive mode", "error"); return; } const cli = await discoverManagedCli(env); const detectedCli = new Set(cli.map((tool) => tool.name)); for (const name of policy.getManagedCli()) if (!detectedCli.has(name)) cli.push({ name, path: "not detected on PATH", detected: false, managed: true }); const plugins = pluginTools(pi); const detectedPlugins = new Set(plugins.map((tool) => tool.name)); for (const name of policy.getManagedPlugin()) if (!detectedPlugins.has(name) && selectablePlugin(name)) plugins.push({ name, path: "not available from loaded extensions", detected: false, managed: true }); if (cli.length === 0 && plugins.length === 0) { ctx.ui.notify("No CLI or plugin tools are available", "info"); return; } const selectedPlugins = policy.getManagedPlugin().filter(selectablePlugin); const selected = await ctx.ui.custom((tui, theme, _kb, done) => new ManagedToolSettingsOverlay(cli, policy.getManagedCli(), plugins, selectedPlugins, done, tui, theme), { overlay: true, overlayOptions: { width: "72%", minWidth: 58, maxHeight: "85%", anchor: "center", margin: 2 } }); if (!selected) return; try { const current = await loadManagedSettings(env); await saveManagedSettings({ ...current, managedCli: selected.managedCli, managedPlugin: selected.managedPlugin }, env); policy.setManagedCli(selected.managedCli); policy.setManagedPlugin(selected.managedPlugin); ctx.ui.notify(`AXI now manages ${selected.managedCli.length} CLI and ${selected.managedPlugin.length} plugin tools`, "info"); } catch (error) { ctx.ui.notify(`Could not save AXI settings: ${error instanceof Error ? error.message : String(error)}`, "error"); } }, }); }