|
1 | 1 | import type { AgentTool } from "@mariozechner/pi-agent-core"; |
| 2 | +import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"; |
2 | 3 |
|
3 | 4 | import type { DriverRegistry } from "../drivers/registry.js"; |
4 | 5 | import type { ToolRegistry } from "../drivers/tool-registry.js"; |
5 | 6 | import type { MCPServerState } from "../mcp/types.js"; |
6 | 7 | import { c } from "./theme.js"; |
7 | 8 |
|
| 9 | +const MIN_VISIBLE_ROWS = 6; |
| 10 | +const MAX_VISIBLE_ROWS = 8; |
| 11 | +const PREVIEW_LINES = 3; |
| 12 | +const PANEL_WIDTH = 78; |
| 13 | +const SHORT_RULE = "────────────────────────────────────────"; |
| 14 | + |
8 | 15 | export interface McpToolViewModel { |
9 | 16 | name: string; |
10 | 17 | label: string; |
@@ -73,54 +80,124 @@ function renderToolState(state: McpToolViewModel["state"]): string { |
73 | 80 | return state === "loaded" ? c.green("Loaded") : c.yellow("Discoverable"); |
74 | 81 | } |
75 | 82 |
|
76 | | -function trimLine(text: string, max = 72): string { |
77 | | - if (text.length <= max) return text; |
78 | | - return `${text.slice(0, max - 1)}…`; |
| 83 | +function truncateAnsi(text: string, max = PANEL_WIDTH): string { |
| 84 | + return truncateToWidth(text, max, "…", false); |
| 85 | +} |
| 86 | + |
| 87 | +function wrapPreview(text: string, width: number, lines: number): string[] { |
| 88 | + const wrapped = wrapTextWithAnsi(text || "", width).slice(0, lines); |
| 89 | + while (wrapped.length < lines) { |
| 90 | + wrapped.push(""); |
| 91 | + } |
| 92 | + return wrapped.map((line) => truncateToWidth(line, width, "…", false)); |
| 93 | +} |
| 94 | + |
| 95 | +export function getMcpVisibleRows(total: number): number { |
| 96 | + if (total <= 0) return 0; |
| 97 | + if (total <= MIN_VISIBLE_ROWS) return total; |
| 98 | + if (total <= MAX_VISIBLE_ROWS) return total; |
| 99 | + return MAX_VISIBLE_ROWS; |
| 100 | +} |
| 101 | + |
| 102 | +function getWindow(total: number, start: number, visibleRows: number): { start: number; end: number } { |
| 103 | + if (total <= visibleRows) { |
| 104 | + return { start: 0, end: total }; |
| 105 | + } |
| 106 | + const safeStart = Math.max(0, Math.min(start, total - visibleRows)); |
| 107 | + return { start: safeStart, end: safeStart + visibleRows }; |
| 108 | +} |
| 109 | + |
| 110 | +function renderRange(start: number, end: number, total: number): string { |
| 111 | + if (total === 0) return "0 total"; |
| 112 | + return `${start + 1}-${end}/${total}`; |
| 113 | +} |
| 114 | + |
| 115 | +function padRows(lines: string[], rowCount: number): void { |
| 116 | + while (lines.length < rowCount) { |
| 117 | + lines.push(""); |
| 118 | + } |
| 119 | +} |
| 120 | + |
| 121 | +function renderListRow(prefix: string, primary: string, suffix?: string): string { |
| 122 | + const suffixText = suffix ? ` ${suffix}` : ""; |
| 123 | + return truncateAnsi(`${prefix} ${primary}${suffixText}`, PANEL_WIDTH); |
| 124 | +} |
| 125 | + |
| 126 | +function renderListFooter(current: number, total: number, hint: string): string { |
| 127 | + return c.dim(`${current}/${total} ${hint}`); |
| 128 | +} |
| 129 | + |
| 130 | +function renderSelectedLabel(label: string, selected: boolean): string { |
| 131 | + return selected ? c.bgBlue(c.white(label)) : c.gray(label); |
79 | 132 | } |
80 | 133 |
|
81 | | -export function renderMcpServerList(servers: McpServerViewModel[], selectedIndex: number): string { |
| 134 | +export function renderMcpServerList(servers: McpServerViewModel[], selectedIndex: number, startIndex: number): string { |
82 | 135 | const lines: string[] = []; |
83 | | - lines.push(c.cyan.bold(" MCP servers ")); |
84 | | - lines.push(c.dim(" ──────────────────────────────────────────────────")); |
| 136 | + lines.push(c.cyan.bold("Browse MCP server")); |
| 137 | + |
| 138 | + const visibleRows = getMcpVisibleRows(servers.length); |
| 139 | + const { start, end } = getWindow(servers.length, startIndex, visibleRows || 1); |
| 140 | + lines.push(c.dim(`servers · ${renderRange(start, end, servers.length)}`)); |
| 141 | + lines.push(c.dim(SHORT_RULE)); |
85 | 142 |
|
86 | 143 | if (servers.length === 0) { |
87 | | - lines.push(" No MCP servers configured."); |
| 144 | + lines.push("No MCP servers configured."); |
88 | 145 | } else { |
89 | | - servers.forEach((server, index) => { |
90 | | - const prefix = index === selectedIndex ? c.cyan(" ▶") : " "; |
91 | | - const desc = server.description ? c.dim(` — ${trimLine(server.description, 44)}`) : ""; |
92 | | - lines.push(`${prefix} ${c.bold(server.name)} [${renderStatus(server.status)}] ${c.dim(`(${server.toolCount} tools)`)}${desc}`); |
93 | | - if (server.status === "error" && server.error) { |
94 | | - lines.push(c.dim(` ${trimLine(server.error, 84)}`)); |
95 | | - } |
| 146 | + const rowLines = servers.slice(start, end).map((server, offset) => { |
| 147 | + const index = start + offset; |
| 148 | + const selected = index === selectedIndex; |
| 149 | + const prefix = selected ? c.cyan("▶") : c.dim(" "); |
| 150 | + return renderListRow(prefix, renderSelectedLabel(server.name, selected), c.dim(`(${server.toolCount} tools)`)); |
96 | 151 | }); |
| 152 | + padRows(rowLines, visibleRows); |
| 153 | + lines.push(...rowLines); |
97 | 154 | } |
98 | 155 |
|
99 | | - lines.push(c.dim(" ──────────────────────────────────────────────────")); |
100 | | - lines.push(c.dim(" ↑↓ navigate Enter view tools Esc close")); |
| 156 | + lines.push(c.dim(SHORT_RULE)); |
| 157 | + if (servers.length > 0) { |
| 158 | + const server = servers[Math.max(0, Math.min(selectedIndex, servers.length - 1))]; |
| 159 | + lines.push(truncateAnsi(`${c.dim("tools:")} ${server.toolCount} ${c.dim("status:")} ${renderStatus(server.status)}`, PANEL_WIDTH)); |
| 160 | + const preview = wrapPreview(c.white(server.description || "No description."), PANEL_WIDTH, PREVIEW_LINES - 2); |
| 161 | + lines.push(...preview); |
| 162 | + lines.push(truncateAnsi(server.error ?? c.dim(`Press Enter to browse ${server.name} tools.`), PANEL_WIDTH)); |
| 163 | + } else { |
| 164 | + lines.push(...wrapPreview(c.dim("No server selected."), PANEL_WIDTH, PREVIEW_LINES)); |
| 165 | + } |
| 166 | + lines.push(renderListFooter(Math.min(selectedIndex + 1, Math.max(servers.length, 1)), servers.length, "↑↓ navigate Enter browse server Esc close")); |
101 | 167 | return lines.join("\n"); |
102 | 168 | } |
103 | 169 |
|
104 | | -export function renderMcpToolList(server: McpServerViewModel, selectedIndex: number): string { |
| 170 | +export function renderMcpToolList(server: McpServerViewModel, selectedIndex: number, startIndex: number): string { |
105 | 171 | const lines: string[] = []; |
106 | | - lines.push(c.cyan.bold(` ${server.name} tools `)); |
107 | | - lines.push(c.dim(` status: ${server.status} · ${server.toolCount} total`)); |
108 | | - lines.push(c.dim(" ──────────────────────────────────────────────────")); |
| 172 | + lines.push(c.cyan.bold(`${server.name} · tools`)); |
| 173 | + |
| 174 | + const visibleRows = getMcpVisibleRows(server.tools.length); |
| 175 | + const { start, end } = getWindow(server.tools.length, startIndex, visibleRows || 1); |
| 176 | + lines.push(c.dim(`tools · ${renderRange(start, end, server.tools.length)}`)); |
| 177 | + lines.push(c.dim(SHORT_RULE)); |
109 | 178 |
|
110 | 179 | if (server.tools.length === 0) { |
111 | | - lines.push(" No registered tools for this MCP server."); |
| 180 | + lines.push("No registered tools for this MCP server."); |
112 | 181 | } else { |
113 | | - server.tools.forEach((tool, index) => { |
114 | | - const prefix = index === selectedIndex ? c.cyan(" ▶") : " "; |
115 | | - lines.push(`${prefix} ${tool.label} ${c.dim(`[${renderToolState(tool.state)}]`)}`); |
116 | | - if (tool.description) { |
117 | | - lines.push(c.dim(` ${trimLine(tool.description, 84)}`)); |
118 | | - } |
119 | | - lines.push(c.dim(` ${tool.name}`)); |
| 182 | + const rowLines = server.tools.slice(start, end).map((tool, offset) => { |
| 183 | + const index = start + offset; |
| 184 | + const selected = index === selectedIndex; |
| 185 | + const prefix = selected ? c.cyan("▶") : c.dim(" "); |
| 186 | + return renderListRow(prefix, renderSelectedLabel(tool.label, selected), c.dim(`[${renderToolState(tool.state)}]`)); |
120 | 187 | }); |
| 188 | + padRows(rowLines, visibleRows); |
| 189 | + lines.push(...rowLines); |
121 | 190 | } |
122 | 191 |
|
123 | | - lines.push(c.dim(" ──────────────────────────────────────────────────")); |
124 | | - lines.push(c.dim(" ↑↓ navigate Esc back Enter stay")); |
| 192 | + lines.push(c.dim(SHORT_RULE)); |
| 193 | + if (server.tools.length > 0) { |
| 194 | + const tool = server.tools[Math.max(0, Math.min(selectedIndex, server.tools.length - 1))]; |
| 195 | + const preview = wrapPreview(c.white(tool.description || "No description."), PANEL_WIDTH, PREVIEW_LINES - 1); |
| 196 | + lines.push(...preview); |
| 197 | + lines.push(c.dim(truncateAnsi(tool.name, PANEL_WIDTH))); |
| 198 | + } else { |
| 199 | + lines.push(...wrapPreview(c.dim("No tool selected."), PANEL_WIDTH, PREVIEW_LINES)); |
| 200 | + } |
| 201 | + lines.push(renderListFooter(Math.min(selectedIndex + 1, Math.max(server.tools.length, 1)), server.tools.length, "↑↓ navigate Esc back to servers")); |
125 | 202 | return lines.join("\n"); |
126 | 203 | } |
0 commit comments