UNPKG

@earendil-works/pi-coding-agent

Version:

Coding agent CLI with read, bash, edit, write tools and session management

195 lines • 8.01 kB
/** * The `/mcp` manager view: menus that rebuild while servers connect, a read-only status screen, and * the sign-in screen that accepts a pasted redirect URL. */ import { Container, Input, SelectList, Spacer, Text, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui"; import { AuthUrlComponent } from "../../modes/interactive/components/auth-url.js"; import { DynamicBorder } from "../../modes/interactive/components/dynamic-border.js"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.js"; import { getSelectListTheme } from "../../modes/interactive/theme/theme.js"; function frame(theme, title, body, footer) { const container = new Container(); container.addChild(new DynamicBorder((text) => theme.fg("accent", text))); container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0)); for (const child of body) container.addChild(child); if (footer) { container.addChild(new Spacer(1)); container.addChild(new Text(theme.fg("dim", footer), 1, 0)); } container.addChild(new DynamicBorder((text) => theme.fg("accent", text))); return container; } const MAX_VISIBLE_ITEMS = 12; export class McpManagerView { tui; theme; keybindings; content; inputHandler; inputTarget; _focused = false; constructor(tui, theme, keybindings) { this.tui = tui; this.theme = theme; this.keybindings = keybindings; this.content = frame(theme, "MCP servers", [new Text(theme.fg("muted", "Loading…"), 1, 1)]); } get focused() { return this._focused; } set focused(value) { this._focused = value; if (this.inputTarget) this.inputTarget.focused = value; } get capturesViewportInput() { return this.inputHandler?.capturesViewportInput === true; } setContent(content, inputHandler, inputTarget) { if (this.inputTarget) this.inputTarget.focused = false; this.content = content; this.inputHandler = inputHandler; this.inputTarget = inputTarget; if (this.inputTarget) this.inputTarget.focused = this._focused; this.tui.requestRender(); } menu(build, subscribe) { return new Promise((resolve) => { let unsubscribe; let settled = false; const finish = (value) => { if (settled) return; settled = true; unsubscribe?.(); resolve(value); }; let selected; const render = () => { const menu = build(); const wanted = selected ?? menu.selected; const body = []; if (menu.details) body.push(new Text(this.theme.fg("muted", menu.details), 1, 0)); if (menu.error) body.push(new Text(this.theme.fg("error", menu.error), 1, 0)); body.push(new Spacer(1)); const footer = `${keyHint("tui.select.confirm", menu.confirmLabel)} • ${keyHint("tui.select.cancel", menu.cancelLabel)}`; if (menu.items.length === 0) { body.push(new Text(this.theme.fg("muted", menu.empty ?? "Nothing to show."), 1, 0)); this.setContent(frame(this.theme, menu.title, body, keyHint("tui.select.cancel", menu.cancelLabel)), { handleInput: (data) => { if (this.keybindings.matches(data, "tui.select.cancel")) finish(undefined); }, }); return; } const list = new SelectList(menu.items, Math.min(menu.items.length, MAX_VISIBLE_ITEMS), getSelectListTheme()); const index = menu.items.findIndex((item) => item.value === wanted); if (index !== -1) list.setSelectedIndex(index); selected = list.getSelectedItem()?.value; list.onSelectionChange = (item) => { selected = item.value; }; list.onSelect = (item) => finish(item.value); list.onCancel = () => finish(undefined); body.push(list); this.setContent(frame(this.theme, menu.title, body, footer), list); }; render(); unsubscribe = subscribe?.(() => { if (!settled) render(); }); }); } status(title, message, onCancel) { const body = [new Spacer(1), new Text(this.theme.fg("muted", message), 1, 0)]; if (!onCancel) { this.setContent(frame(this.theme, title, body)); return; } this.setContent(frame(this.theme, title, body, keyHint("tui.select.cancel", "cancel")), { handleInput: (data) => { if (this.keybindings.matches(data, "tui.select.cancel")) onCancel(); }, }); } redirectUrl(title, authorizationUrl, signal) { return new Promise((resolve) => { let settled = false; const finish = (value) => { if (settled) return; settled = true; signal.removeEventListener("abort", onAbort); resolve(value); }; const onAbort = () => finish(undefined); if (signal.aborted) { finish(undefined); return; } signal.addEventListener("abort", onAbort, { once: true }); const input = new Input(); const link = new AuthUrlComponent(this.tui, authorizationUrl); const body = [ new Spacer(1), new Text(this.theme.fg("muted", "Approve access in your browser. If it did not open, visit:"), 1, 0), link, new Spacer(1), new Text(this.theme.fg("muted", "If the browser runs on another machine, paste the URL it was redirected to:"), 1, 0), input, ]; this.setContent(frame(this.theme, title, body, `${keyHint("tui.select.confirm", "submit")} • ${keyHint("tui.select.cancel", "cancel")}`), { handleInput: (data) => { if (this.keybindings.matches(data, "tui.select.confirm")) { const value = input.getValue().trim(); if (value) finish(value); return; } if (this.keybindings.matches(data, "tui.select.cancel")) { finish(undefined); return; } if (this.keybindings.matches(data, "app.message.copy")) { void link.copy(); return; } input.handleInput(data); }, }, input); }); } handleInput(data) { this.inputHandler?.handleInput?.(data); this.tui.requestRender(); } render(width) { return this.content .render(width) .map((line) => (visibleWidth(line) > width ? truncateToWidth(line, width, "") : line)); } invalidate() { this.content.invalidate(); } } /** Run `manage` in the manager view until it returns. */ export async function showMcpManager(ctx, manage) { await ctx.ui.custom((tui, theme, keybindings, done) => { const view = new McpManagerView(tui, theme, keybindings); void manage(view).then(() => done(), (error) => { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); done(); }); return view; }); } //# sourceMappingURL=ui.js.map