import type { ExtensionAPI, ExtensionCommandContext, } from "@earendil-works/pi-coding-agent"; import { getSettingsListTheme } from "@earendil-works/pi-coding-agent"; import { Container, Input, type Component, type SettingItem, SettingsList, } from "@earendil-works/pi-tui"; import { testConnection } from "./client.js"; import { type CharlesConfig, type ConfigKey, CONFIG_CLI_ALIASES, CONFIG_DESCRIPTIONS, CONFIG_LABELS, applyConfigValue, displayConfigValue, envOverriddenKeys, formatConfigSummary, getConfig, getConfigPath, loadFileConfig, resetFileConfig, saveFileConfig, } from "./config.js"; const EDITABLE_KEYS: readonly ConfigKey[] = [ "username", "password", "proxyHost", "proxyPort", ]; /** Single-line text editor used as a SettingsList submenu. */ class TextEditSubmenu implements Component { private readonly title: string; private readonly hint: string; private readonly input = new Input(); private readonly done: (value?: string) => void; constructor( title: string, initial: string, hint: string, done: (value?: string) => void, ) { this.title = title; this.hint = hint; this.done = done; this.input.setValue(initial); this.input.focused = true; this.input.onSubmit = (value: string) => this.done(value); this.input.onEscape = () => this.done(); } handleInput(data: string): void { this.input.handleInput(data); } invalidate(): void { this.input.invalidate(); } render(width: number): string[] { return [this.title, "", ...this.input.render(width), "", this.hint]; } } function rawValue(config: CharlesConfig, key: ConfigKey): string { if (key === "proxyPort") return String(config.proxyPort); return String(config[key]); } function buildItems( getFileConfig: () => CharlesConfig, onMutate: () => void, ): SettingItem[] { const fileConfig = getFileConfig(); const overridden = new Set(envOverriddenKeys()); const items: SettingItem[] = EDITABLE_KEYS.map((key) => { const envNote = overridden.has(key) ? " — currently overridden by environment variable at runtime" : ""; return { id: key, label: CONFIG_LABELS[key], description: CONFIG_DESCRIPTIONS[key] + envNote, currentValue: displayConfigValue(key, fileConfig), submenu: (_current: string, done: (selectedValue?: string) => void) => new TextEditSubmenu( `Edit ${CONFIG_LABELS[key]}`, rawValue(getFileConfig(), key), " Enter to save · Esc to cancel", done, ), }; }); items.push( { id: "test", label: "Test connection", description: "Probe Charles with the effective runtime settings", currentValue: "run", values: ["run"], }, { id: "reset", label: "Reset to defaults", description: "Restore username / password / host / port to built-in defaults", currentValue: "reset", values: ["reset"], }, { id: "path", label: "Config file", description: "Settings are saved here and applied immediately", currentValue: getConfigPath(), }, ); // Touch onMutate so callers can keep a stable reference if needed. void onMutate; return items; } function refreshItemValues( settingsList: SettingsList, fileConfig: CharlesConfig, ): void { for (const key of EDITABLE_KEYS) { settingsList.updateValue(key, displayConfigValue(key, fileConfig)); } settingsList.updateValue("test", "run"); settingsList.updateValue("reset", "reset"); settingsList.updateValue("path", getConfigPath()); } async function openConfigUi(ctx: ExtensionCommandContext): Promise { let fileConfig = loadFileConfig(); const getFileConfig = () => fileConfig; await ctx.ui.custom((tui, theme, _kb, done) => { const container = new Container(); container.addChild({ render(_width: number) { return [ theme.fg("accent", theme.bold(" Charles Proxy Settings ")), theme.fg("dim", " Connection settings for harvest / filter tools "), "", ]; }, invalidate() {}, }); const settingsList = new SettingsList( buildItems(getFileConfig, () => {}), 10, getSettingsListTheme(), (id, newValue) => { void (async () => { if (id === "test") { const result = await testConnection(); ctx.ui.notify(result.message, result.ok ? "info" : "error"); settingsList.updateValue("test", "run"); tui.requestRender(); return; } if (id === "reset") { fileConfig = resetFileConfig(); refreshItemValues(settingsList, fileConfig); ctx.ui.notify("Charles settings reset to defaults", "info"); tui.requestRender(); return; } if (id === "path") return; const key = id as ConfigKey; const result = applyConfigValue(fileConfig, key, newValue); if (result.error) { ctx.ui.notify(result.error, "error"); settingsList.updateValue(key, displayConfigValue(key, fileConfig)); tui.requestRender(); return; } fileConfig = result.config; saveFileConfig(fileConfig); settingsList.updateValue(key, displayConfigValue(key, fileConfig)); ctx.ui.notify(`${CONFIG_LABELS[key]} updated`, "info"); tui.requestRender(); })(); }, () => done(undefined), ); container.addChild(settingsList); return { render(width: number) { return container.render(width); }, invalidate() { container.invalidate(); }, handleInput(data: string) { settingsList.handleInput(data); tui.requestRender(); }, }; }); } function printHelp(ctx: ExtensionCommandContext): void { ctx.ui.notify( [ "Usage:", " /charles Open settings UI", " /charles show Show current settings", " /charles reset Reset to defaults", " /charles test Test Charles connection", " /charles set Set one value (user|pass|host|port)", ].join("\n"), "info", ); } async function handleSet( ctx: ExtensionCommandContext, keyArg: string | undefined, valueArg: string | undefined, ): Promise { if (!keyArg || valueArg === undefined || valueArg === "") { ctx.ui.notify("Usage: /charles set ", "error"); return; } const key = CONFIG_CLI_ALIASES[keyArg.toLowerCase()]; if (!key) { ctx.ui.notify( `Unknown key '${keyArg}'. Use: user, pass, host, port`, "error", ); return; } const fileConfig = loadFileConfig(); const result = applyConfigValue(fileConfig, key, valueArg); if (result.error) { ctx.ui.notify(result.error, "error"); return; } saveFileConfig(result.config); ctx.ui.notify( `${CONFIG_LABELS[key]} set to ${displayConfigValue(key, result.config)}`, "info", ); } /** Register the /charles configuration command. */ export function registerCharlesCommand(pi: ExtensionAPI): void { pi.registerCommand("charles", { description: "Configure Charles Proxy connection settings", handler: async (args, ctx) => { const parts = args.trim().split(/\s+/).filter(Boolean); const sub = parts[0]?.toLowerCase(); if (!sub) { if (ctx.mode !== "tui") { ctx.ui.notify( formatConfigSummary(getConfig()) + "\n\nInteractive UI requires TUI mode. Use /charles set|show|test|reset.", "info", ); return; } await openConfigUi(ctx); return; } switch (sub) { case "show": case "status": ctx.ui.notify(formatConfigSummary(getConfig()), "info"); return; case "reset": resetFileConfig(); ctx.ui.notify("Charles settings reset to defaults", "info"); return; case "test": { const result = await testConnection(); ctx.ui.notify(result.message, result.ok ? "info" : "error"); return; } case "set": await handleSet(ctx, parts[1], parts.slice(2).join(" ")); return; case "help": case "-h": case "--help": printHelp(ctx); return; default: ctx.ui.notify(`Unknown subcommand '${sub}'`, "error"); printHelp(ctx); } }, }); }