import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { McpcKeybindings, McpcTheme, McpcTui } from "./pi-types.ts"; import type { ServerListItem } from "./types.ts"; export interface PanelChange { action: "block" | "enable"; name: string; } export interface PanelResult { changes: PanelChange[]; cancelled: boolean; } function sanitizeText(text: string): string { return text .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") .replace(/[\x00-\x1f\x7f-\x9f]/g, " ") .replace(/\s+/g, " ") .trim(); } class McpcPanel { private cursor = 0; private readonly maxVisible = 12; private readonly initialEnabled: boolean[]; private readonly draftEnabled: boolean[]; private notice: string | null = null; private closed = false; constructor( private readonly items: ServerListItem[], private readonly tui: McpcTui, private readonly theme: McpcTheme, private readonly keybindings: McpcKeybindings, private readonly done: (result: PanelResult) => void, ) { this.initialEnabled = items.map((item) => item.visibility === "enabled"); this.draftEnabled = [...this.initialEnabled]; } handleInput(data: string): void { if (matchesKey(data, "ctrl+c")) { this.close({ changes: [], cancelled: true }); return; } if (matchesKey(data, "ctrl+s")) { this.saveAndClose(); return; } if ( data === "q" || data === "Q" || matchesKey(data, "escape") || this.keybindings.matches(data, "tui.select.cancel") ) { this.saveAndClose(); return; } if (matchesKey(data, "up") || this.keybindings.matches(data, "tui.select.up")) { this.move(-1); return; } if (matchesKey(data, "down") || this.keybindings.matches(data, "tui.select.down")) { this.move(1); return; } if (matchesKey(data, "space") || data === " ") { this.toggleSelected(); } } private close(result: PanelResult): void { if (this.closed) return; this.closed = true; this.done(result); } private saveAndClose(): void { const changes: PanelChange[] = []; for (let index = 0; index < this.items.length; index++) { const item = this.items[index]!; if (item.visibility === "conflict" || this.draftEnabled[index] === this.initialEnabled[index]) continue; changes.push({ action: this.draftEnabled[index] ? "enable" : "block", name: item.name, }); } this.close({ changes, cancelled: false }); } private toggleSelected(): void { const selected = this.items[this.cursor]; if (!selected) return; if (selected.visibility === "conflict") { this.notice = `Cannot toggle ${sanitizeText(selected.name)} until its stale record is cleaned.`; this.tui.requestRender(); return; } this.draftEnabled[this.cursor] = !this.draftEnabled[this.cursor]; this.notice = null; this.tui.requestRender(); } private move(delta: number): void { if (this.items.length === 0) return; this.cursor = Math.max(0, Math.min(this.items.length - 1, this.cursor + delta)); this.notice = null; this.tui.requestRender(); } private pendingCount(): number { let count = 0; for (let index = 0; index < this.items.length; index++) { if (this.items[index]?.visibility === "conflict") continue; if (this.draftEnabled[index] !== this.initialEnabled[index]) count++; } return count; } render(width: number): string[] { if (width < 4) return [truncateToWidth("MCPC", Math.max(0, width), "")]; const panelWidth = width; const innerWidth = panelWidth - 2; const border = (left: string, fill: string, right: string) => this.theme.fg("border", left + fill.repeat(innerWidth) + right); const row = (content = "") => { const clipped = truncateToWidth(` ${content}`, innerWidth, "..."); const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(clipped))); return this.theme.fg("border", "|") + clipped + padding + this.theme.fg("border", "|"); }; const lines: string[] = [border("+", "-", "+")]; lines.push(row(this.theme.fg("accent", this.theme.bold("MCPC - MCP visibility")))); lines.push(border("+", "-", "+")); if (this.items.length === 0) { lines.push(row(this.theme.fg("muted", "No MCP servers found"))); } else { const start = Math.max( 0, Math.min(this.cursor - Math.floor(this.maxVisible / 2), this.items.length - this.maxVisible), ); const end = Math.min(this.items.length, start + this.maxVisible); for (let index = start; index < end; index++) { const item = this.items[index]!; const selected = index === this.cursor; const changed = item.visibility !== "conflict" && this.draftEnabled[index] !== this.initialEnabled[index]; const cursor = selected ? this.theme.fg("accent", ">") : " "; const toggle = item.visibility === "conflict" ? this.theme.fg("warning", "!") : this.draftEnabled[index] ? this.theme.fg("success", "●") : this.theme.fg("muted", "○"); const status = item.visibility === "conflict" ? this.theme.fg("warning", "conflict") : this.draftEnabled[index] ? this.theme.fg("success", "enable") : this.theme.fg("muted", "block"); const dirty = changed ? this.theme.fg("warning", " *") : ""; const name = selected ? this.theme.fg("accent", this.theme.bold(sanitizeText(item.name))) : sanitizeText(item.name); const sources = this.theme.fg("dim", `${item.sourceCount} source${item.sourceCount === 1 ? "" : "s"}`); lines.push(row(`${cursor} ${toggle} ${name} ${status}${dirty} ${sources}`)); if (selected && item.visibility === "conflict" && item.conflictDetails[0]) { lines.push(row(` ${this.theme.fg("warning", sanitizeText(item.conflictDetails[0]))}`)); } } if (this.items.length > this.maxVisible) { lines.push(row(this.theme.fg("dim", `${this.cursor + 1}/${this.items.length}`))); } } if (this.notice) { lines.push(row(this.theme.fg("warning", this.notice))); } lines.push(border("+", "-", "+")); const pending = this.pendingCount(); lines.push(row( pending > 0 ? this.theme.fg("warning", `${pending} pending change${pending === 1 ? "" : "s"} (unsaved)`) : this.theme.fg("dim", "No pending changes"), )); lines.push(row(this.theme.fg("dim", "Up/Down move Space toggle Ctrl+S/Esc/q save Ctrl+C cancel"))); lines.push(border("+", "-", "+")); return lines; } invalidate(): void {} dispose(): void {} } export function createMcpcPanel( items: ServerListItem[], tui: McpcTui, theme: McpcTheme, keybindings: McpcKeybindings, done: (result: PanelResult) => void, ): McpcPanel { return new McpcPanel(items, tui, theme, keybindings, done); }