/** * Keyboard-driven market panel — search, filter, preview, install/uninstall. * * UI style mirrors pi-mcp-adapter's mcp-panel.ts (same theme codes, border * glyphs, createPanelKeys pattern, inactivity timeout, fuzzy search). * * Modes: * - "list": browse. Typing searches, left/right cycles sources, up/down * moves the cursor (tui.select.* keybindings when available), Enter opens * the detail/install flow (uninstalls when installed, otherwise installs or * starts the requiredEnv form), Esc clears the search (second Esc closes), * Ctrl+C closes. * - "form": inline wizard for requiredEnv fields. Typing fills the focused * field, Enter advances (Tab advances too), Esc skips the rest (installs * with 待配置 badge), Ctrl+C aborts back to the list. */ import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { AuthType, CatalogBundle, MarketCallbacks, MarketPanelResult, MarketServer, ServerEntry } from "./types.ts"; import { t, ti } from "./i18n.ts"; interface PanelTheme { border: string; title: string; selected: string; installed: string; needsAuth: string; placeholder: string; description: string; hint: string; confirm: string; cancel: string; /** Auth badge colors */ authApiKey: string; authBearer: string; authOauth: string; authNone: string; authUnknown: string; /** Preview overlay */ previewBg: string; previewLabel: string; } const DEFAULT_THEME: PanelTheme = { border: "2", title: "2", selected: "36", installed: "32", needsAuth: "33", placeholder: "2;3", description: "2", hint: "2", confirm: "32", cancel: "31", authApiKey: "33", authBearer: "34", authOauth: "35", authNone: "32", authUnknown: "2", previewBg: "2", previewLabel: "37", }; function fg(code: string, text: string): string { if (!code) return text; return `\x1b[${code}m${text}\x1b[0m`; } function bold(text: string): string { return `\x1b[1m${text}\x1b[0m`; } /** Fuzzy score: exact substring wins, otherwise subsequence scoring (adapter-style). */ function fuzzyScore(query: string, text: string): number { const lq = query.toLowerCase(); const lt = text.toLowerCase(); if (lt.includes(lq)) return 100 + (lq.length / lt.length) * 50; let score = 0; let qi = 0; let consecutive = 0; for (let i = 0; i < lt.length && qi < lq.length; i++) { if (lt[i] === lq[qi]) { score += 10 + consecutive; consecutive += 5; qi++; } else { consecutive = 0; } } return qi === lq.length ? score : 0; } /** tui.select.* keybinding ids the panel resolves. */ export type PanelSelectKeybinding = "tui.select.up" | "tui.select.down" | "tui.select.confirm"; /** Structural subset of pi-tui's KeybindingsManager. */ export interface PanelKeybindings { matches(data: string, keybinding: PanelSelectKeybinding): boolean; } export interface PanelKeys { selectUp(data: string): boolean; selectDown(data: string): boolean; selectConfirm(data: string): boolean; } /** Key matchers honoring the user's tui.select.* bindings when available. */ export function createPanelKeys(keybindings?: PanelKeybindings): PanelKeys { if (keybindings) { return { selectUp: (data) => keybindings.matches(data, "tui.select.up"), selectDown: (data) => keybindings.matches(data, "tui.select.down"), selectConfirm: (data) => keybindings.matches(data, "tui.select.confirm"), }; } return { selectUp: (data) => matchesKey(data, "up"), selectDown: (data) => matchesKey(data, "down"), selectConfirm: (data) => matchesKey(data, "return"), }; } interface FormField { key: string; label: string; hint?: string; whereToGet?: string; value: string; focused: boolean; skipped: boolean; } interface MarketPanelOptions { /** Lines rendered in the fixed status row (warning or action failure). */ noticeLines?: string[]; adapterMissing?: boolean; failures?: string[]; /** Render the fixed-height loading view until applyCatalog/applyCatalogError. */ loading?: boolean; /** * Whether any market source is configured. When false and there are no * servers and no failures, the panel shows the "unconfigured" hint. * Defaults to `failures.length > 0` for callers that don't pass it. */ sourcesConfigured?: boolean; keybindings?: PanelKeybindings; /** Names of all configured registries, used for per-source loading states. */ sourceNames?: string[]; } const MAX_VISIBLE = 12; const INACTIVITY_MS = 60_000; export class MarketPanel { private aggregatedServers: MarketServer[] = []; private sources: string[] = ["all"]; private sourceIndex = 0; private cursorIndex = 0; private listStart = 0; private nameQuery = ""; private mode: "list" | "form" | "preview" = "list"; private formFields: FormField[] = []; private formActiveField = 0; private formServer: MarketServer | null = null; /** Preview overlay state */ private previewServer: MarketServer | null = null; private previewEntry: ServerEntry | null = null; private previewEnv: Record | undefined = undefined; private noticeLines: string[]; private adapterMissing: boolean; /** Per-source state: 'loading' / 'loaded' / 'failed' */ private sourceStates: Map = new Map(); /** Whether any market source is configured (distinct from empty sources). */ private sourcesConfigured: boolean; private disposed = false; private inactivityTimeout: ReturnType | null = null; private result: MarketPanelResult = { action: "closed" }; private tui: { requestRender(): void }; private t = DEFAULT_THEME; private keys: PanelKeys; constructor( servers: MarketServer[], callbacks: MarketCallbacks, tui: { requestRender(): void }, done: (result: MarketPanelResult) => void, options: MarketPanelOptions = {}, ) { this.aggregatedServers = []; // Pre-populate aggregatedServers for tests / direct construction if (servers.length > 0) { this.aggregatedServers.push(...servers); } this.callbacks = callbacks; this.tui = tui; this.done = done; this.noticeLines = options.noticeLines ?? []; this.adapterMissing = options.adapterMissing === true; this.sourcesConfigured = options.sourcesConfigured ?? false; // Initialize per-source loading state if (options.sourceNames && options.sourceNames.length > 0) { for (const name of options.sourceNames) { this.sourceStates.set(name, "loading"); } } this.sources = this.buildSources(); this.keys = createPanelKeys(options.keybindings); this.resetInactivityTimer(); } private callbacks: MarketCallbacks; private done: (result: MarketPanelResult) => void; private buildSources(): string[] { const known = new Set(this.sourceStates.keys()); for (const s of this.aggregatedServers) { if (s.source) known.add(s.source); } return ["all", ...Array.from(known).sort()]; } /** Apply one registry's results incrementally. */ applyRegistryResult(sourceName: string, servers: MarketServer[], failed: boolean): void { if (this.disposed) return; this.sourceStates.set(sourceName, failed ? "failed" : "loaded"); if (!failed) { this.aggregatedServers.push(...servers); } this.sources = this.buildSources(); this.tui.requestRender(); } /** Install known servers into a dedicated source for viewing installed + pending items. */ applyInstalledServers(installed: Array<{ installName: string; entry: ServerEntry; missingEnv: string[] }>): void { if (this.disposed) return; const virtual: MarketServer[] = []; for (const item of installed) { const isOauth = item.entry.auth === "oauth"; const needsBearer = !!item.entry.bearerTokenEnv && !item.entry.bearerToken; const missingKeys = [...item.missingEnv]; const pending = isOauth || needsBearer || missingKeys.length > 0; let authType: AuthType | undefined; if (isOauth) authType = "oauth"; else if (needsBearer || !!item.entry.bearerTokenEnv) authType = "bearer"; const pendingParts: string[] = []; if (isOauth) pendingParts.push("(OAuth)"); const envMissing = [...(missingKeys.length > 0 ? missingKeys : []), ...(needsBearer && item.entry.bearerTokenEnv ? [item.entry.bearerTokenEnv] : [])]; if (envMissing.length > 0) pendingParts.push(`${t("market.needsEnv")} ${envMissing.join(", ")}`); const suffix = pendingParts.length > 0 ? ` ${pendingParts.join(" ")}` : ""; const summary = pending ? `${t("market.pendingConfig")}${suffix}` : t("market.ready"); virtual.push({ id: item.installName, name: item.installName, summary, description: pending ? t("market.installedToPending") : t("market.installedTo"), entry: item.entry, source: t("market.sourceInstalled"), authType, installable: false, }); } if (virtual.length > 0) { this.aggregatedServers.push(...virtual); this.sourceStates.set(t("market.sourceInstalled"), "loaded"); this.sources = this.buildSources(); this.tui.requestRender(); } } private resetInactivityTimer(): void { if (this.inactivityTimeout) clearTimeout(this.inactivityTimeout); this.inactivityTimeout = setTimeout(() => { this.disposed = true; this.done({ action: "closed" }); }, INACTIVITY_MS); } /** Servers matching the current source filter + search query. */ private visibleServers(): MarketServer[] { const source = this.sources[this.sourceIndex] ?? "all"; const q = this.nameQuery.trim(); if (source === "all" && !q) return this.aggregatedServers; return this.aggregatedServers .filter((s) => source === "all" || s.source === source) .map((s) => ({ s, score: q ? fuzzyScore(q, `${s.name} ${s.summary} ${s.description ?? ""} ${(s.tags ?? []).join(" ")}`) : 1 })) .filter((x) => x.score > 0) .sort((a, b) => b.score - a.score || a.s.name.localeCompare(b.s.name)) .map((x) => x.s); } private focusedServer(): MarketServer | undefined { const list = this.visibleServers(); return list[this.cursorIndex]; } private ensureCursorVisible(listLength: number): void { if (listLength <= 0) { this.cursorIndex = 0; this.listStart = 0; return; } this.cursorIndex = Math.min(this.cursorIndex, listLength - 1); if (this.cursorIndex < this.listStart) this.listStart = this.cursorIndex; if (this.cursorIndex >= this.listStart + MAX_VISIBLE) { this.listStart = this.cursorIndex - MAX_VISIBLE + 1; } this.listStart = Math.min(this.listStart, Math.max(0, listLength - MAX_VISIBLE)); } private isInstalled(server: MarketServer): boolean { return this.callbacks.isInstalled(server.installName ?? server.name); } /** True when installed but a requiredEnv value is missing/empty. */ private needsConfig(server: MarketServer): boolean { if (!server.requiredEnv || server.requiredEnv.length === 0) return false; const installed = this.callbacks.getInstalledEntry(server.installName ?? server.name); if (!installed) return false; return server.requiredEnv.some((r) => { const value = installed.env?.[r.key]; return value === undefined || value.trim() === ""; }); } // ---- input handling ------------------------------------------------------ handleInput(data: string): void { if (this.disposed) return; this.resetInactivityTimer(); if (this.mode === "form") { this.handleFormInput(data); return; } if (this.mode === "preview") { this.handlePreviewInput(data); return; } this.handleListInput(data); } private handleListInput(data: string): void { // navigation if (this.keys.selectUp(data)) { const list = this.visibleServers(); if (list.length > 0) { this.cursorIndex = (this.cursorIndex - 1 + list.length) % list.length; this.ensureCursorVisible(list.length); this.tui.requestRender(); } return; } if (this.keys.selectDown(data)) { const list = this.visibleServers(); if (list.length > 0) { this.cursorIndex = (this.cursorIndex + 1) % list.length; this.ensureCursorVisible(list.length); this.tui.requestRender(); } return; } if (matchesKey(data, "left")) { this.sourceIndex = (this.sourceIndex - 1 + this.sources.length) % this.sources.length; this.cursorIndex = 0; this.listStart = 0; this.tui.requestRender(); return; } if (matchesKey(data, "right")) { this.sourceIndex = (this.sourceIndex + 1) % this.sources.length; this.cursorIndex = 0; this.listStart = 0; this.tui.requestRender(); return; } if (matchesKey(data, "backspace")) { if (this.nameQuery.length > 0) { this.nameQuery = this.nameQuery.slice(0, -1); this.cursorIndex = 0; this.listStart = 0; this.tui.requestRender(); } return; } if (matchesKey(data, "escape")) { if (this.nameQuery.length > 0) { this.nameQuery = ""; this.cursorIndex = 0; this.listStart = 0; this.tui.requestRender(); } else { this.close(); } return; } if (matchesKey(data, "ctrl+c")) { this.close(); return; } if (this.keys.selectConfirm(data)) { const server = this.focusedServer(); if (server) this.openDetail(server); return; } // search: printable characters (single char, not a modifier sequence) if (isPrintable(data)) { this.nameQuery += data; this.cursorIndex = 0; this.listStart = 0; this.tui.requestRender(); } } private handleFormInput(data: string): void { const field = this.formFields[this.formActiveField]; if (!field) { this.mode = "list"; this.tui.requestRender(); return; } if (this.keys.selectConfirm(data)) { this.advanceForm(); return; } if (matchesKey(data, "tab")) { this.advanceForm(); return; } if (matchesKey(data, "escape")) { // skip remaining fields, install with what we have this.formFields.forEach((f) => { if (!f.value) f.skipped = true; }); this.completeForm(); return; } if (matchesKey(data, "ctrl+c")) { this.mode = "list"; this.formServer = null; this.formFields = []; this.tui.requestRender(); return; } if (matchesKey(data, "backspace")) { field.value = field.value.slice(0, -1); this.tui.requestRender(); return; } if (isPrintable(data)) { field.value += data; this.tui.requestRender(); } } // ---- actions ------------------------------------------------------------- private openDetail(server: MarketServer): void { if (this.isInstalled(server)) { void this.uninstall(server, server.installName ?? server.name); return; } if (server.requiredEnv && server.requiredEnv.length > 0) { this.startForm(server); return; } // Show preview overlay before installing this.showPreview(server, server.entry, undefined); } private startForm(server: MarketServer): void { this.formServer = server; this.formActiveField = 0; this.formFields = (server.requiredEnv ?? []).map((r, i) => ({ key: r.key, label: r.label, hint: r.hint, whereToGet: r.whereToGet, value: "", focused: i === 0, skipped: false, })); this.mode = "form"; this.tui.requestRender(); } private advanceForm(): void { const field = this.formFields[this.formActiveField]; if (field && !field.value) { field.skipped = true; } if (this.formActiveField < this.formFields.length - 1) { this.formFields[this.formActiveField].focused = false; this.formActiveField++; this.formFields[this.formActiveField].focused = true; this.tui.requestRender(); return; } this.completeForm(); } private completeForm(): void { const server = this.formServer; if (!server) { this.mode = "list"; this.tui.requestRender(); return; } const env: Record = {}; let hasValue = false; for (const field of this.formFields) { if (field.value) { env[field.key] = field.value; hasValue = true; } } // Show preview overlay with collected env values this.mode = "list"; this.formServer = null; this.formFields = []; this.showPreview(server, server.entry, hasValue ? env : undefined); } private async install(server: MarketServer, installName: string, env: Record | undefined): Promise { const entry: ServerEntry = { ...server.entry }; if (env && Object.keys(env).length > 0) { entry.env = { ...(entry.env ?? {}), ...env }; } try { await this.callbacks.installServer(installName, entry); if (this.disposed) return; this.result = { action: "installed", name: installName }; this.tui.requestRender(); } catch (error) { if (this.disposed) return; const message = error instanceof Error ? error.message : String(error); this.noticeLines = [ti("install.failed", { msg: message })]; this.tui.requestRender(); } } private async uninstall(server: MarketServer, installName: string): Promise { try { await this.callbacks.uninstallServer(installName); if (this.disposed) return; this.result = { action: "uninstalled", name: installName }; this.tui.requestRender(); } catch (error) { if (this.disposed) return; const message = error instanceof Error ? error.message : String(error); this.noticeLines = [ti("uninstall.failed", { msg: message })]; this.tui.requestRender(); } } /** Show the installation preview overlay. */ private showPreview(server: MarketServer, entry: ServerEntry, env: Record | undefined): void { this.previewServer = server; this.previewEntry = entry; this.previewEnv = env; this.mode = "preview"; this.tui.requestRender(); } /** Confirm installation from the preview overlay. */ private confirmPreview(): void { const server = this.previewServer; if (!server || !this.previewEntry) return; let entry = this.previewEntry; if (this.previewEnv && Object.keys(this.previewEnv).length > 0) { entry = { ...entry, env: { ...(entry.env ?? {}), ...this.previewEnv } }; } this.mode = "list"; this.previewServer = null; this.previewEntry = null; this.previewEnv = undefined; void this.install(server, server.installName ?? server.name, this.previewEnv); } private handlePreviewInput(data: string): void { if (this.keys.selectConfirm(data)) { this.confirmPreview(); return; } if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.mode = "list"; this.previewServer = null; this.previewEntry = null; this.previewEnv = undefined; this.tui.requestRender(); return; } } private close(): void { this.dispose(); this.done(this.result); } // ---- rendering ----------------------------------------------------------- render(width: number): string[] { const w = Math.max(10, width); const innerW = w - 2; const lines: string[] = []; const horizontal = fg(this.t.border, "─".repeat(Math.max(0, innerW))); const top = `┌${horizontal}┐`; const bottom = `└${horizontal}┘`; lines.push(top); lines.push(this.renderHeaderLine(innerW)); lines.push(this.renderSearchLine(innerW)); lines.push(this.renderSourceLine(innerW)); lines.push(this.renderSeparator(innerW)); if (this.mode === "form") { lines.push(...this.renderForm(innerW)); } else if (this.mode === "preview") { lines.push(...this.renderPreview(innerW)); } else { lines.push(...this.renderList(innerW)); } lines.push(bottom); return lines; } private renderHeaderLine(innerW: number): string { const title = ` ${bold(fg(this.t.title, t("market.title")))} `; const note = this.adapterMissing ? fg(this.t.needsAuth, ` ⚠ ${t("market.adapterMissing")}`) : ""; const gap = Math.max(1, innerW - visibleWidth(title) - visibleWidth(note)); return `│${this.fit(`${title}${" ".repeat(gap)}${note}`, innerW)}│`; } private renderSearchLine(innerW: number): string { const label = fg(this.t.hint, t("market.searchLabel")); const query = this.nameQuery ? fg(this.t.selected, this.nameQuery) : fg(this.t.placeholder, t("market.searchPlaceholder")); const hint = fg(this.t.hint, t("market.searchHint")); return this.contentLine(`${label}${query} ${hint}`, innerW); } private renderSourceLine(innerW: number): string { const chips = this.sources.map((s, i) => { const active = i === this.sourceIndex; if (s === "all") { const allLabel = t("market.sourceAll"); return active ? fg(this.t.selected, bold(`[${allLabel}]`)) : fg(this.t.hint, allLabel); } const state = this.sourceStates.get(s); let suffix = ""; if (state === "loading") suffix = fg(this.t.needsAuth, t("market.loadingSpinner")); else if (state === "failed") suffix = fg(this.t.cancel, t("market.failedIcon")); const label = `${s}${suffix}`; return active ? fg(this.t.selected, bold(`[${label}]`)) : fg(this.t.hint, label); }); return this.contentLine(`${chips.join(" ")} ${fg(this.t.hint, t("market.sourceSwitch"))}`, innerW); } private renderSeparator(innerW: number): string { return `│${fg(this.t.border, "─".repeat(innerW))}│`; } private renderList(innerW: number): string[] { const list = this.visibleServers(); this.ensureCursorVisible(list.length); const out: string[] = []; let emptyMessage = ""; let emptyColor = this.t.placeholder; // Resolve whether any source is still loading const anyLoading = Array.from(this.sourceStates.values()).some((s) => s === "loading"); const failedSources = Array.from(this.sourceStates.entries()) .filter(([, state]) => state === "failed") .map(([name]) => name); const anyLoaded = Array.from(this.sourceStates.values()).some((s) => s === "loaded"); if (anyLoading && !anyLoaded) { emptyMessage = t("market.loading"); } else if (this.aggregatedServers.length === 0 && !anyLoading) { if (this.sourceStates.size === 0) { emptyMessage = t("market.noRegistries"); } else if (failedSources.length === this.sourceStates.size) { emptyMessage = t("market.allFailed"); emptyColor = this.t.needsAuth; } else { emptyMessage = t("market.noServers"); } } else if (list.length === 0 && !anyLoading) { emptyMessage = t("market.noMatch"); } for (let slot = 0; slot < MAX_VISIBLE; slot++) { const index = this.listStart + slot; const server = list[index]; if (server) out.push(this.renderServerRow(server, index === this.cursorIndex, innerW)); else if (slot === 0 && emptyMessage) out.push(this.contentLine(fg(emptyColor, emptyMessage), innerW)); else out.push(this.contentLine("", innerW)); } const focused = this.focusedServer(); const description = anyLoading ? "" : (focused?.description ?? ""); out.push(this.contentLine(fg(this.t.description, description), innerW)); // Detail line: auth type + tool count + config template if (focused && !anyLoading) { const detailParts: string[] = []; if (focused.toolCount !== undefined) { detailParts.push(fg(this.t.hint, ti("market.toolCount", { n: focused.toolCount }))); } if (focused.configTemplate) { detailParts.push(fg(this.t.hint, t("market.previewBeforeInstall"))); } if (detailParts.length > 0) { out.push(this.contentLine(detailParts.join(" "), innerW)); } else { out.push(this.contentLine("", innerW)); } } else { out.push(this.contentLine("", innerW)); } const statusParts = [...this.noticeLines]; if (failedSources.length > 0) { statusParts.push(ti("market.failedSources", { sources: failedSources.join(", ") })); } const statusColor = this.noticeLines.length > 0 ? this.t.cancel : this.t.needsAuth; out.push(this.contentLine(fg(statusColor, statusParts.join(" · ")), innerW)); out.push(this.renderHelpLine(innerW)); return out; } private renderServerRow(server: MarketServer, isCursor: boolean, innerW: number): string { const installed = this.isInstalled(server); const config = this.needsConfig(server); let badge: string; if (installed && config) badge = fg(this.t.needsAuth, t("market.badgeConfig")); else if (installed) badge = fg(this.t.installed, t("market.badgeInstalled")); else badge = fg(this.t.placeholder, "○"); const authBadge = this.authBadge(server); const nameStr = isCursor ? bold(fg(this.t.selected, server.name)) : server.name; const sourceTag = fg(this.t.hint, `[${server.source}]`); const summary = server.summary ? fg(this.t.description, truncateToWidth(` — ${server.summary}`, Math.max(5, innerW - 8 - visibleWidth(nameStr) - visibleWidth(sourceTag) - visibleWidth(badge) - visibleWidth(authBadge) - 4), "…")) : ""; const cursor = isCursor ? fg(this.t.selected, "▸") : " "; return this.contentLine(`${cursor} ${badge} ${nameStr} ${authBadge} ${sourceTag}${summary}`, innerW); } /** Render the auth type badge for a server row. Only shown when auth info is explicitly known. */ private authBadge(server: MarketServer): string { const type = server.authType; if (!type) return ""; switch (type) { case "none": return fg(this.t.authNone, "✅"); case "api-key": return fg(this.t.authApiKey, "🔑"); case "bearer": return fg(this.t.authBearer, "🔑"); case "oauth": return fg(this.t.authOauth, "🔐"); case "unknown": return fg(this.t.authUnknown, "❓"); default: return ""; } } private renderHelpLine(innerW: number): string { const parts = [ fg(this.t.hint, t("market.helpUpDown")), fg(this.t.hint, t("market.helpEnter")), fg(this.t.hint, t("market.helpSource")), fg(this.t.confirm, t("market.helpClose")), ]; return this.contentLine(parts.join(" "), innerW); } private renderForm(innerW: number): string[] { const out: string[] = []; const server = this.formServer; if (!server) { this.mode = "list"; return []; } out.push(`│ ${fg(this.t.title, this.pad(ti("form.title", { name: server.name }), innerW - 2))} │`); out.push(`│ ${fg(this.t.hint, this.pad(t("form.hint"), innerW - 2))} │`); out.push(this.renderSeparator(innerW)); for (const field of this.formFields) { const focused = field.focused; const labelStr = focused ? bold(fg(this.t.selected, field.label)) : fg(this.t.description, field.label); const masked = field.value ? "•".repeat(field.value.length) : fg(this.t.placeholder, field.skipped ? t("form.skipped") : t("form.inputPlaceholder")); const valueStr = focused ? fg(this.t.selected, field.value || masked) : masked; let hintStr = ""; if (field.hint) hintStr += field.hint; if (field.hint && field.whereToGet) hintStr += " · "; if (field.whereToGet) hintStr += ti("preview.hintWhereToGet", { url: field.whereToGet }); const colorStr = hintStr ? fg(this.t.hint, ` ${hintStr}`) : fg(this.t.hint, ` ${ti("form.defaultHint", { key: field.key })}`); out.push(`│ ${this.pad(`${focused ? "▸" : " "} ${labelStr}: ${valueStr}${colorStr}`, innerW - 2)} │`); } out.push(this.renderSeparator(innerW)); out.push(`│ ${this.pad( `${fg(this.t.confirm, t("form.helpNext"))} ${fg(this.t.cancel, t("form.helpSkip"))} ${fg(this.t.cancel, t("form.helpCancel"))}`, innerW - 2, )} │`); return out; } private renderPreview(innerW: number): string[] { const out: string[] = []; const server = this.previewServer; if (!server || !this.previewEntry) { this.mode = "list"; return []; } const title = bold(fg(this.t.title, ` ${t("preview.title")} `)); out.push(`│ ${this.pad(title, innerW - 2)} │`); out.push(this.renderSeparator(innerW)); // Server info out.push(`│ ${this.pad(` ${bold(server.name)} ${fg(this.t.hint, `[${server.source}]`)})}`, innerW - 2)} │`); const authStr = server.authType ? this.renderAuthTypeLabel(server.authType) : ""; if (authStr) { out.push(`│ ${this.pad(` ${ti("preview.auth", { type: authStr })}`, innerW - 2)} │`); } if (server.toolCount !== undefined) { out.push(`│ ${this.pad(` ${ti("preview.tools", { n: server.toolCount })}`, innerW - 2)} │`); } out.push(this.renderSeparator(innerW)); // Config snippet out.push(`│ ${this.pad(fg(this.t.hint, t("preview.willWrite")), innerW - 2)} │`); // Build the full entry JSON including env if provided const installName = server.installName ?? server.name; const entryJson: Record = { ...server.entry }; if (this.previewEnv && Object.keys(this.previewEnv).length > 0) { entryJson.env = { ...(server.entry.env ?? {}), ...this.previewEnv }; } const snippet = JSON.stringify({ [installName]: entryJson }, null, 2); const snippetLines = snippet.split("\n"); const snippetInnerW = Math.min(innerW - 6, 56); const snippetPad = 3; const snippetLeftPad = " ".repeat(snippetPad); out.push(`│${fg(this.t.previewBg, ` ${'┌' + "─".repeat(snippetInnerW) + '┐'} `)}│`); for (const line of snippetLines) { const clipped = truncateToWidth(line, snippetInnerW, "…"); out.push(`│${fg(this.t.previewBg, ` ${snippetLeftPad}${clipped}${snippetLeftPad} `)}│`); } out.push(`│${fg(this.t.previewBg, ` ${'└' + "─".repeat(snippetInnerW) + '┘'} `)}│`); // Post-install hints const hints = this.previewInstallHints(server); if (hints.length > 0) { out.push(this.renderSeparator(innerW)); for (const hint of hints) { out.push(`│ ${this.pad(fg(this.t.needsAuth, hint), innerW - 2)} │`); } } // Footer hints out.push(this.renderSeparator(innerW)); out.push(`│ ${this.pad( `${fg(this.t.confirm, t("preview.confirmInstall"))} ${fg(this.t.cancel, t("preview.cancelInstall"))}`, innerW - 2, )} │`); return out; } /** Generate post-install hints based on server entry type. */ private previewInstallHints(server: MarketServer): string[] { const hints: string[] = []; const entry = server.entry; hints.push(t("preview.hintReload")); if (entry.auth === "oauth") { hints.push(t("preview.hintOauth")); } if (entry.bearerTokenEnv) { hints.push(ti("preview.hintBearer", { var: entry.bearerTokenEnv })); const matched = server.requiredEnv?.find((r) => r.key === entry.bearerTokenEnv); if (matched?.whereToGet) { hints.push(ti("preview.hintWhereToGet", { url: matched.whereToGet })); } } if (entry.url && entry.url.includes("onekey")) { hints.push(t("preview.hintOnekey")); hints.push(t("preview.hintOnekeyUrl")); hints.push(t("preview.hintOnekeyCredit")); } if (entry.command === "npx") { hints.push(t("preview.hintNpx")); } if (entry.url) { hints.push(t("preview.hintUrl")); } return hints; } /** Render a human-readable auth type label. */ private renderAuthTypeLabel(authType: AuthType): string { switch (authType) { case "none": return fg(this.t.authNone, t("auth.none")); case "api-key": return fg(this.t.authApiKey, t("auth.apiKey")); case "bearer": return fg(this.t.authBearer, t("auth.bearer")); case "oauth": return fg(this.t.authOauth, t("auth.oauth")); default: return fg(this.t.authUnknown, t("auth.unknown")); } } private contentLine(text: string, innerW: number): string { return `│ ${this.fit(text, Math.max(0, innerW - 2))} │`; } private fit(text: string, width: number): string { if (width <= 0) return ""; const singleLine = text.replace(/[\r\n]+/g, " "); const clipped = truncateToWidth(singleLine, width, "…"); return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped))); } private pad(text: string, width: number): string { return this.fit(text, width); } invalidate(): void { // no cached rendering state } dispose(): void { this.disposed = true; if (this.inactivityTimeout) clearTimeout(this.inactivityTimeout); } } /** Single printable character (one UTF-16 code unit) from terminal input. */ function isPrintable(data: string): boolean { if (data.length !== 1) return false; const code = data.charCodeAt(0); return code >= 0x20 && code !== 0x7f; } export function createMarketPanel( servers: MarketServer[], callbacks: MarketCallbacks, tui: { requestRender(): void }, done: (result: MarketPanelResult) => void, options?: MarketPanelOptions, ): MarketPanel { return new MarketPanel(servers, callbacks, tui, done, options ?? {}); }