Skip to content

Commit 9682132

Browse files
王璨claude
andcommitted
refactor: polish interactive MCP browser
Refine the /mcp browser into a clearer two-step flow with fixed-height server and tool lists, stable scrolling, safer ANSI truncation, and improved selection styling. Update the README to document the interactive MCP browser entry point. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 953338f commit 9682132

5 files changed

Lines changed: 220 additions & 63 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,8 @@ DeepSeek reasoning 模型通过 `thinkingLevel` 控制思考强度,切换模
198198

199199
MCP 工具注册为 Driver,命名格式 `mcp_<server>_<tool>`,例如 `mcp_blender_get_scene_info`。连接失败不阻塞启动,错误信息输出到控制台。
200200

201+
可在 TUI 中输入 `/mcp` 打开交互式浏览器:先选择 MCP server,再查看该 server 的 tool 列表与加载状态。
202+
201203
## Skills system
202204

203205
dscode 采用 **Agent as OS** 架构:Drivers(内核模块,始终加载)和 Skills(用户态程序,按需激活)。
@@ -242,6 +244,7 @@ Always push the branch before creating a PR.
242244
| `/memory list/add/remove/clear` | 记忆管理 |
243245
| `/skills` | 列出 Skills 及状态 |
244246
| `/drivers` | 列出已加载的驱动 |
247+
| `/mcp` | 交互式浏览 MCP server 与 tools |
245248
| `/permissions` | 查看当前权限授予 |
246249
| `/cost` | 显示 token 用量 |
247250
| `/compact` | 手动压缩上下文 |

src/ui/mcp-browser.ts

Lines changed: 107 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
import type { AgentTool } from "@mariozechner/pi-agent-core";
2+
import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
23

34
import type { DriverRegistry } from "../drivers/registry.js";
45
import type { ToolRegistry } from "../drivers/tool-registry.js";
56
import type { MCPServerState } from "../mcp/types.js";
67
import { c } from "./theme.js";
78

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+
815
export interface McpToolViewModel {
916
name: string;
1017
label: string;
@@ -73,54 +80,124 @@ function renderToolState(state: McpToolViewModel["state"]): string {
7380
return state === "loaded" ? c.green("Loaded") : c.yellow("Discoverable");
7481
}
7582

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);
79132
}
80133

81-
export function renderMcpServerList(servers: McpServerViewModel[], selectedIndex: number): string {
134+
export function renderMcpServerList(servers: McpServerViewModel[], selectedIndex: number, startIndex: number): string {
82135
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));
85142

86143
if (servers.length === 0) {
87-
lines.push(" No MCP servers configured.");
144+
lines.push("No MCP servers configured.");
88145
} 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)`));
96151
});
152+
padRows(rowLines, visibleRows);
153+
lines.push(...rowLines);
97154
}
98155

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"));
101167
return lines.join("\n");
102168
}
103169

104-
export function renderMcpToolList(server: McpServerViewModel, selectedIndex: number): string {
170+
export function renderMcpToolList(server: McpServerViewModel, selectedIndex: number, startIndex: number): string {
105171
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));
109178

110179
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.");
112181
} 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)}]`));
120187
});
188+
padRows(rowLines, visibleRows);
189+
lines.push(...rowLines);
121190
}
122191

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"));
125202
return lines.join("\n");
126203
}

src/ui/theme.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export const c = {
1010
magenta: chalk.magenta,
1111
bold: chalk.bold,
1212
white: chalk.white,
13+
gray: chalk.gray,
14+
bgBlue: chalk.bgBlue,
1315
blue: chalk.blue,
1416
reset: chalk.reset,
1517
};

src/ui/tui-app.ts

Lines changed: 61 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import type { MCPManager } from "../mcp/manager.js";
2626
import { c, editorTheme, TIPS, randomTip } from "./theme.js";
2727
import { ConversationView } from "./conversation.js";
2828
import { getSlashCommandAutocomplete, executeSlashCommand } from "./commands.js";
29-
import { buildMcpServers, renderMcpServerList, renderMcpToolList } from "./mcp-browser.js";
29+
import { buildMcpServers, getMcpVisibleRows, renderMcpServerList, renderMcpToolList } from "./mcp-browser.js";
3030
import { readClipboardImageNonBlocking } from "../utils/image.js";
3131
import { ocrImages } from "../utils/ocr.js";
3232
import type { OcrResult } from "../utils/ocr.js";
@@ -59,11 +59,13 @@ export class TuiApp {
5959
private imageStatus: Text;
6060
private loader: CancellableLoader;
6161
private loaderOverlayHandle: ReturnType<TUI["showOverlay"]> | null = null;
62-
private mcpOverlay = new Text("");
63-
private mcpOverlayHandle: ReturnType<TUI["showOverlay"]> | null = null;
62+
private mcpPanel = new Text("", 1, 0);
63+
private mcpPanelVisible = false;
6464
private mcpSelectedServerIndex: number | null = null;
6565
private mcpServerSelection = 0;
66+
private mcpServerWindowStart = 0;
6667
private mcpToolSelection = 0;
68+
private mcpToolWindowStart = 0;
6769
private processing = false;
6870
private lastCtrlCPress = 0;
6971
private ctrlCDebounceUntil = 0;
@@ -133,6 +135,7 @@ export class TuiApp {
133135
this.tui.addChild(root);
134136
this.tui.addChild(this.imageStatus);
135137
this.tui.addChild(this.editor);
138+
this.tui.addChild(this.mcpPanel);
136139
}
137140

138141
getPromptPermission(): (
@@ -144,8 +147,8 @@ export class TuiApp {
144147

145148
setMcpManager(mcpManager?: MCPManager): void {
146149
this.deps.mcpManager = mcpManager;
147-
if (this.mcpOverlayHandle) {
148-
this.updateMcpOverlay();
150+
if (this.mcpPanelVisible) {
151+
this.updateMcpPanel();
149152
}
150153
}
151154

@@ -203,7 +206,7 @@ export class TuiApp {
203206
return true;
204207
}
205208

206-
if (this.mcpOverlayHandle) {
209+
if (this.mcpPanelVisible) {
207210
return this.handleMcpBrowserInput(data);
208211
}
209212

@@ -241,31 +244,22 @@ export class TuiApp {
241244

242245
this.mcpSelectedServerIndex = null;
243246
this.mcpServerSelection = 0;
247+
this.mcpServerWindowStart = 0;
244248
this.mcpToolSelection = 0;
245-
this.updateMcpOverlay();
246-
247-
if (!this.mcpOverlayHandle) {
248-
this.mcpOverlayHandle = this.tui.showOverlay(this.mcpOverlay, {
249-
anchor: "center",
250-
width: "85%",
251-
maxHeight: "70%",
252-
margin: 2,
253-
nonCapturing: true,
254-
});
255-
} else {
256-
this.mcpOverlayHandle.setHidden(false);
257-
}
258-
249+
this.mcpToolWindowStart = 0;
250+
this.mcpPanelVisible = true;
251+
this.updateMcpPanel();
259252
this.tui.requestRender(true);
260253
}
261254

262255
private closeMcpBrowser(): void {
263-
if (!this.mcpOverlayHandle) return;
264-
this.mcpOverlayHandle.hide();
265-
this.mcpOverlayHandle = null;
256+
this.mcpPanelVisible = false;
257+
this.mcpPanel.setText("");
266258
this.mcpSelectedServerIndex = null;
267259
this.mcpServerSelection = 0;
260+
this.mcpServerWindowStart = 0;
268261
this.mcpToolSelection = 0;
262+
this.mcpToolWindowStart = 0;
269263
this.tui.requestRender(true);
270264
}
271265

@@ -278,24 +272,38 @@ export class TuiApp {
278272
);
279273
}
280274

281-
private updateMcpOverlay(): void {
275+
private updateMcpPanel(): void {
282276
const servers = this.getMcpServers();
277+
if (!this.mcpPanelVisible) {
278+
this.mcpPanel.setText("");
279+
return;
280+
}
283281
if (servers.length === 0) {
284-
this.mcpOverlay.setText(renderMcpServerList([], 0));
282+
this.mcpPanel.setText(renderMcpServerList([], 0, 0));
285283
return;
286284
}
287285

288286
if (this.mcpSelectedServerIndex == null) {
289287
this.mcpServerSelection = Math.max(0, Math.min(this.mcpServerSelection, servers.length - 1));
290-
this.mcpOverlay.setText(renderMcpServerList(servers, this.mcpServerSelection));
288+
const visibleRows = getMcpVisibleRows(servers.length);
289+
this.mcpServerWindowStart = Math.max(
290+
0,
291+
Math.min(this.mcpServerWindowStart, Math.max(servers.length - visibleRows, 0)),
292+
);
293+
this.mcpPanel.setText(renderMcpServerList(servers, this.mcpServerSelection, this.mcpServerWindowStart));
291294
return;
292295
}
293296

294297
const serverIndex = Math.max(0, Math.min(this.mcpSelectedServerIndex, servers.length - 1));
295298
const server = servers[serverIndex];
296299
const maxToolIndex = Math.max(0, server.tools.length - 1);
297300
this.mcpToolSelection = Math.max(0, Math.min(this.mcpToolSelection, maxToolIndex));
298-
this.mcpOverlay.setText(renderMcpToolList(server, this.mcpToolSelection));
301+
const visibleRows = getMcpVisibleRows(server.tools.length);
302+
this.mcpToolWindowStart = Math.max(
303+
0,
304+
Math.min(this.mcpToolWindowStart, Math.max(server.tools.length - visibleRows, 0)),
305+
);
306+
this.mcpPanel.setText(renderMcpToolList(server, this.mcpToolSelection, this.mcpToolWindowStart));
299307
}
300308

301309
private handleMcpBrowserInput(data: string): boolean {
@@ -307,29 +315,49 @@ export class TuiApp {
307315
} else {
308316
this.mcpSelectedServerIndex = null;
309317
this.mcpToolSelection = 0;
310-
this.updateMcpOverlay();
318+
this.mcpToolWindowStart = 0;
319+
this.updateMcpPanel();
311320
}
312321
return true;
313322
}
314323

315324
if (matchesKey(data, Key.up)) {
316325
if (this.mcpSelectedServerIndex == null) {
326+
const prev = this.mcpServerSelection;
317327
this.mcpServerSelection = Math.max(0, this.mcpServerSelection - 1);
328+
if (this.mcpServerSelection < prev) {
329+
this.mcpServerWindowStart = this.mcpServerSelection;
330+
}
318331
} else {
332+
const prev = this.mcpToolSelection;
319333
this.mcpToolSelection = Math.max(0, this.mcpToolSelection - 1);
334+
if (this.mcpToolSelection < prev) {
335+
this.mcpToolWindowStart = this.mcpToolSelection;
336+
}
320337
}
321-
this.updateMcpOverlay();
338+
this.updateMcpPanel();
322339
return true;
323340
}
324341

325342
if (matchesKey(data, Key.down)) {
326343
if (this.mcpSelectedServerIndex == null) {
327-
this.mcpServerSelection = Math.min(Math.max(servers.length - 1, 0), this.mcpServerSelection + 1);
344+
const toolCount = servers.length;
345+
const visibleRows = getMcpVisibleRows(toolCount);
346+
const prev = this.mcpServerSelection;
347+
this.mcpServerSelection = Math.min(Math.max(toolCount - 1, 0), this.mcpServerSelection + 1);
348+
if (this.mcpServerSelection > prev && this.mcpServerSelection >= this.mcpServerWindowStart + visibleRows) {
349+
this.mcpServerWindowStart = this.mcpServerSelection - visibleRows + 1;
350+
}
328351
} else {
329352
const toolCount = servers[this.mcpSelectedServerIndex]?.tools.length ?? 0;
353+
const visibleRows = getMcpVisibleRows(toolCount);
354+
const prev = this.mcpToolSelection;
330355
this.mcpToolSelection = Math.min(Math.max(toolCount - 1, 0), this.mcpToolSelection + 1);
356+
if (this.mcpToolSelection > prev && this.mcpToolSelection >= this.mcpToolWindowStart + visibleRows) {
357+
this.mcpToolWindowStart = this.mcpToolSelection - visibleRows + 1;
358+
}
331359
}
332-
this.updateMcpOverlay();
360+
this.updateMcpPanel();
333361
return true;
334362
}
335363

@@ -340,7 +368,8 @@ export class TuiApp {
340368
} else {
341369
this.mcpSelectedServerIndex = this.mcpServerSelection;
342370
this.mcpToolSelection = 0;
343-
this.updateMcpOverlay();
371+
this.mcpToolWindowStart = 0;
372+
this.updateMcpPanel();
344373
}
345374
}
346375
return true;

0 commit comments

Comments
 (0)