@pi-unipi/mcp 0.1.0 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/bridge/registry.ts +20 -1
- package/src/bridge/translator.ts +61 -17
- package/src/index.ts +72 -12
- package/src/tui/add-overlay.ts +537 -190
- package/src/tui/settings-overlay.ts +48 -53
package/src/tui/add-overlay.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @pi-unipi/mcp — Add server overlay TUI
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Vim-modal browser for the MCP catalog with split-pane editor.
|
|
5
|
+
*
|
|
6
|
+
* Modes:
|
|
7
|
+
* normal — j/k navigate, Enter select, l/Tab → editor, / search, gg/G top/bottom,
|
|
8
|
+
* Ctrl+d/u half-page, Esc/q close
|
|
9
|
+
* search — typing edits query, Enter accept, Esc cancel
|
|
10
|
+
* editor — JSON editor active, Ctrl+S save, Esc/h back to browse
|
|
7
11
|
*/
|
|
8
12
|
|
|
9
13
|
import {
|
|
@@ -22,57 +26,81 @@ import {
|
|
|
22
26
|
loadMetadata,
|
|
23
27
|
saveMetadata,
|
|
24
28
|
getGlobalConfigDir,
|
|
29
|
+
getProjectConfigDir,
|
|
25
30
|
} from "../config/manager.js";
|
|
26
|
-
import { validateMcpConfig,
|
|
31
|
+
import { validateMcpConfig, DEFAULT_MCP_CONFIG, DEFAULT_METADATA } from "../config/schema.js";
|
|
32
|
+
|
|
33
|
+
type Mode = "normal" | "search" | "editor";
|
|
34
|
+
type StatusKind = "info" | "success" | "warn" | "error";
|
|
27
35
|
|
|
28
|
-
/** State for the add overlay */
|
|
29
36
|
interface AddOverlayState {
|
|
30
|
-
mode:
|
|
37
|
+
mode: Mode;
|
|
31
38
|
searchQuery: string;
|
|
39
|
+
pendingSearch: string;
|
|
32
40
|
filteredServers: CatalogEntry[];
|
|
33
41
|
allServers: CatalogEntry[];
|
|
34
42
|
selectedIndex: number;
|
|
43
|
+
scrollOffset: number;
|
|
35
44
|
editorContent: string;
|
|
36
|
-
|
|
45
|
+
/** ID of the catalog server whose template is currently in the editor. */
|
|
46
|
+
loadedServerId: string | null;
|
|
37
47
|
validationError: string | null;
|
|
38
48
|
scope: "global" | "project";
|
|
39
49
|
saved: boolean;
|
|
50
|
+
pendingG: boolean;
|
|
51
|
+
status: string;
|
|
52
|
+
statusKind: StatusKind;
|
|
40
53
|
}
|
|
41
54
|
|
|
42
|
-
/**
|
|
43
|
-
* Generate a pre-filled JSON config template from a catalog entry.
|
|
44
|
-
*/
|
|
45
55
|
function generateConfigTemplate(server: CatalogEntry): string {
|
|
46
|
-
const
|
|
47
|
-
|
|
56
|
+
const slug = server.name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
|
|
48
57
|
const config: Record<string, unknown> = {
|
|
49
58
|
mcpServers: {
|
|
50
|
-
[
|
|
59
|
+
[slug]: {
|
|
51
60
|
command: server.install?.command ?? "npx",
|
|
52
|
-
args: server.install?.args ?? ["-y", `@modelcontextprotocol/server-${
|
|
61
|
+
args: server.install?.args ?? ["-y", `@modelcontextprotocol/server-${slug}`],
|
|
53
62
|
env: {},
|
|
54
63
|
},
|
|
55
64
|
},
|
|
56
65
|
};
|
|
57
|
-
|
|
58
|
-
// Pre-fill env vars with placeholders
|
|
59
66
|
if (server.install?.envVars) {
|
|
60
67
|
const env: Record<string, string> = {};
|
|
61
|
-
for (const v of server.install.envVars)
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
(config.mcpServers as any)[name].env = env;
|
|
68
|
+
for (const v of server.install.envVars) env[v] = "";
|
|
69
|
+
(config.mcpServers as any)[slug].env = env;
|
|
65
70
|
}
|
|
66
|
-
|
|
67
71
|
return JSON.stringify(config, null, 2);
|
|
68
72
|
}
|
|
69
73
|
|
|
74
|
+
function padVisible(content: string, targetWidth: number): string {
|
|
75
|
+
const pad = Math.max(0, targetWidth - visibleWidth(content));
|
|
76
|
+
return content + " ".repeat(pad);
|
|
77
|
+
}
|
|
78
|
+
|
|
70
79
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
80
|
+
* Strip characters that pi-tui's visibleWidth and the user's terminal may
|
|
81
|
+
* disagree on (RGI emoji, variation selectors, ZWJ, regional indicators).
|
|
82
|
+
* Keeps printable ASCII and standard Latin/box-drawing chars; replaces
|
|
83
|
+
* problem chars with a single * so widths stay deterministic.
|
|
84
|
+
*/
|
|
85
|
+
function widthSafe(s: string): string {
|
|
86
|
+
return s
|
|
87
|
+
.replace(/[\u200D\uFE00-\uFE0F\uFEFF]/g, "")
|
|
88
|
+
.replace(/[\u2600-\u27BF]/g, "*")
|
|
89
|
+
.replace(/[\u{1F000}-\u{1FFFF}]/gu, "*")
|
|
90
|
+
.replace(/[\u{1F1E6}-\u{1F1FF}]/gu, "*");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Wrap text in an OSC 8 hyperlink escape. Modern terminals (iTerm2,
|
|
95
|
+
* kitty, WezTerm, Windows Terminal, GNOME Terminal, Alacritty-with-osc8)
|
|
96
|
+
* make the text Ctrl/Cmd-clickable; other terminals ignore the escapes.
|
|
97
|
+
* The ST used here is BEL (\x07) since it has wider support than ESC\.
|
|
75
98
|
*/
|
|
99
|
+
function hyperlink(url: string, text: string): string {
|
|
100
|
+
if (!url) return text;
|
|
101
|
+
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
|
|
102
|
+
}
|
|
103
|
+
|
|
76
104
|
export function renderMcpAddOverlay(params?: {
|
|
77
105
|
scope?: "global" | "project";
|
|
78
106
|
onComplete?: () => void;
|
|
@@ -85,21 +113,24 @@ export function renderMcpAddOverlay(params?: {
|
|
|
85
113
|
) => {
|
|
86
114
|
const scope = params?.scope ?? "global";
|
|
87
115
|
|
|
88
|
-
// State
|
|
89
116
|
const state: AddOverlayState = {
|
|
90
|
-
mode: "
|
|
117
|
+
mode: "normal",
|
|
91
118
|
searchQuery: "",
|
|
119
|
+
pendingSearch: "",
|
|
92
120
|
filteredServers: [],
|
|
93
121
|
allServers: [],
|
|
94
122
|
selectedIndex: 0,
|
|
123
|
+
scrollOffset: 0,
|
|
95
124
|
editorContent: "{}",
|
|
96
|
-
|
|
125
|
+
loadedServerId: null,
|
|
97
126
|
validationError: null,
|
|
98
127
|
scope,
|
|
99
128
|
saved: false,
|
|
129
|
+
pendingG: false,
|
|
130
|
+
status: "",
|
|
131
|
+
statusKind: "info",
|
|
100
132
|
};
|
|
101
133
|
|
|
102
|
-
// Load catalog
|
|
103
134
|
let catalog: CatalogData;
|
|
104
135
|
try {
|
|
105
136
|
catalog = loadCatalog();
|
|
@@ -107,11 +138,8 @@ export function renderMcpAddOverlay(params?: {
|
|
|
107
138
|
state.filteredServers = catalog.servers;
|
|
108
139
|
} catch {
|
|
109
140
|
catalog = { lastUpdated: "", source: "", totalServers: 0, servers: [] };
|
|
110
|
-
state.allServers = [];
|
|
111
|
-
state.filteredServers = [];
|
|
112
141
|
}
|
|
113
142
|
|
|
114
|
-
// Editor theme
|
|
115
143
|
const editorTheme: EditorTheme = {
|
|
116
144
|
borderColor: (s: any) => theme.fg("accent", s),
|
|
117
145
|
selectList: {
|
|
@@ -124,15 +152,43 @@ export function renderMcpAddOverlay(params?: {
|
|
|
124
152
|
};
|
|
125
153
|
const editor = new Editor(tui, editorTheme);
|
|
126
154
|
editor.setText(state.editorContent);
|
|
155
|
+
// Enter must insert a newline, not submit (which clears the editor).
|
|
156
|
+
// Ctrl+S handles saving; no submit path needed.
|
|
157
|
+
(editor as any).disableSubmit = true;
|
|
127
158
|
|
|
128
159
|
let cachedLines: string[] | undefined;
|
|
160
|
+
let lastListHeight = 12;
|
|
129
161
|
|
|
130
162
|
function refresh() {
|
|
131
163
|
cachedLines = undefined;
|
|
132
164
|
tui.requestRender();
|
|
133
165
|
}
|
|
134
166
|
|
|
135
|
-
function
|
|
167
|
+
function setStatus(msg: string, kind: StatusKind = "info", ttlMs = 2000) {
|
|
168
|
+
state.status = msg;
|
|
169
|
+
state.statusKind = kind;
|
|
170
|
+
setTimeout(() => {
|
|
171
|
+
if (state.status === msg) {
|
|
172
|
+
state.status = "";
|
|
173
|
+
refresh();
|
|
174
|
+
}
|
|
175
|
+
}, ttlMs);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function ensureVisible() {
|
|
179
|
+
const h = lastListHeight;
|
|
180
|
+
if (state.selectedIndex < state.scrollOffset) {
|
|
181
|
+
state.scrollOffset = state.selectedIndex;
|
|
182
|
+
} else if (state.selectedIndex >= state.scrollOffset + h) {
|
|
183
|
+
state.scrollOffset = state.selectedIndex - h + 1;
|
|
184
|
+
}
|
|
185
|
+
const max = Math.max(0, state.filteredServers.length - h);
|
|
186
|
+
if (state.scrollOffset > max) state.scrollOffset = max;
|
|
187
|
+
if (state.scrollOffset < 0) state.scrollOffset = 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function applySearch(query: string) {
|
|
191
|
+
state.searchQuery = query;
|
|
136
192
|
if (!query.trim()) {
|
|
137
193
|
state.filteredServers = state.allServers;
|
|
138
194
|
} else {
|
|
@@ -145,17 +201,49 @@ export function renderMcpAddOverlay(params?: {
|
|
|
145
201
|
);
|
|
146
202
|
}
|
|
147
203
|
state.selectedIndex = 0;
|
|
204
|
+
state.scrollOffset = 0;
|
|
148
205
|
}
|
|
149
206
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
207
|
+
/**
|
|
208
|
+
* Load the catalog template for the currently selected server.
|
|
209
|
+
* Skipped when the same server's template is already loaded so the
|
|
210
|
+
* user's in-progress edits are never silently wiped.
|
|
211
|
+
*/
|
|
212
|
+
function loadSelectedIntoEditor(opts: { force?: boolean } = {}) {
|
|
213
|
+
if (state.selectedIndex < 0 || state.selectedIndex >= state.filteredServers.length) return;
|
|
214
|
+
const server = state.filteredServers[state.selectedIndex];
|
|
215
|
+
if (!opts.force && state.loadedServerId === server.id) return;
|
|
154
216
|
state.editorContent = generateConfigTemplate(server);
|
|
155
217
|
editor.setText(state.editorContent);
|
|
218
|
+
state.loadedServerId = server.id;
|
|
156
219
|
state.validationError = null;
|
|
157
|
-
|
|
158
|
-
|
|
220
|
+
setStatus(`Loaded template for ${widthSafe(server.name)}`, "info", 1500);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function prettifyEditor() {
|
|
224
|
+
try {
|
|
225
|
+
const parsed = JSON.parse(state.editorContent);
|
|
226
|
+
const pretty = JSON.stringify(parsed, null, 2);
|
|
227
|
+
if (pretty !== state.editorContent) {
|
|
228
|
+
state.editorContent = pretty;
|
|
229
|
+
editor.setText(pretty);
|
|
230
|
+
setStatus("✓ Formatted", "success", 1500);
|
|
231
|
+
} else {
|
|
232
|
+
setStatus("Already formatted", "info", 1200);
|
|
233
|
+
}
|
|
234
|
+
} catch (err) {
|
|
235
|
+
setStatus(
|
|
236
|
+
`Cannot prettify: ${err instanceof Error ? err.message : "invalid JSON"}`,
|
|
237
|
+
"error",
|
|
238
|
+
2500,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function getConfigDir(): string {
|
|
244
|
+
return state.scope === "project"
|
|
245
|
+
? getProjectConfigDir(process.cwd())
|
|
246
|
+
: getGlobalConfigDir();
|
|
159
247
|
}
|
|
160
248
|
|
|
161
249
|
function validateAndSave(): boolean {
|
|
@@ -166,28 +254,19 @@ export function renderMcpAddOverlay(params?: {
|
|
|
166
254
|
state.validationError = validation.errors[0];
|
|
167
255
|
return false;
|
|
168
256
|
}
|
|
169
|
-
|
|
170
|
-
// Determine target directory
|
|
171
|
-
const configDir = getGlobalConfigDir();
|
|
172
|
-
|
|
173
|
-
// Load existing config and merge — handle corrupt existing config
|
|
257
|
+
const configDir = getConfigDir();
|
|
174
258
|
let existing: McpConfig;
|
|
175
259
|
try {
|
|
176
260
|
existing = loadMcpConfig(configDir);
|
|
177
261
|
} catch {
|
|
178
|
-
// Existing config is corrupt — start fresh
|
|
179
262
|
existing = { ...DEFAULT_MCP_CONFIG };
|
|
180
263
|
}
|
|
181
|
-
|
|
182
264
|
const newServers = parsed.mcpServers ?? {};
|
|
183
|
-
|
|
184
265
|
for (const [name, def] of Object.entries(newServers)) {
|
|
185
266
|
existing.mcpServers[name] = def as any;
|
|
186
267
|
}
|
|
187
|
-
|
|
188
268
|
saveMcpConfig(configDir, existing);
|
|
189
269
|
|
|
190
|
-
// Update metadata
|
|
191
270
|
let meta: McpMetadata;
|
|
192
271
|
try {
|
|
193
272
|
meta = loadMetadata(configDir);
|
|
@@ -195,10 +274,7 @@ export function renderMcpAddOverlay(params?: {
|
|
|
195
274
|
meta = { ...DEFAULT_METADATA, servers: {}, sync: { ...DEFAULT_METADATA.sync } };
|
|
196
275
|
}
|
|
197
276
|
for (const name of Object.keys(newServers)) {
|
|
198
|
-
meta.servers[name] = {
|
|
199
|
-
enabled: true,
|
|
200
|
-
addedAt: new Date().toISOString(),
|
|
201
|
-
};
|
|
277
|
+
meta.servers[name] = { enabled: true, addedAt: new Date().toISOString() };
|
|
202
278
|
}
|
|
203
279
|
saveMetadata(configDir, meta);
|
|
204
280
|
|
|
@@ -206,82 +282,229 @@ export function renderMcpAddOverlay(params?: {
|
|
|
206
282
|
state.validationError = null;
|
|
207
283
|
return true;
|
|
208
284
|
} catch (err) {
|
|
209
|
-
state.validationError =
|
|
210
|
-
err instanceof Error ? err.message : "Invalid JSON";
|
|
285
|
+
state.validationError = err instanceof Error ? err.message : "Invalid JSON";
|
|
211
286
|
return false;
|
|
212
287
|
}
|
|
213
288
|
}
|
|
214
289
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
290
|
+
// ─── Input handling ───────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
function handleEditorMode(data: string) {
|
|
293
|
+
if (matchesKey(data, Key.escape)) {
|
|
294
|
+
state.mode = "normal";
|
|
295
|
+
state.validationError = null;
|
|
296
|
+
refresh();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
// Ctrl+S — save config to disk
|
|
300
|
+
if (data === "\x13") {
|
|
301
|
+
if (validateAndSave()) {
|
|
302
|
+
const names = (() => {
|
|
303
|
+
try {
|
|
304
|
+
return Object.keys(JSON.parse(state.editorContent).mcpServers ?? {});
|
|
305
|
+
} catch {
|
|
306
|
+
return [];
|
|
307
|
+
}
|
|
308
|
+
})();
|
|
309
|
+
const label = names[0] ?? "server";
|
|
310
|
+
setStatus(`✓ Saved "${label}"! Closing…`, "success", 4000);
|
|
311
|
+
params?.onComplete?.();
|
|
312
|
+
state.mode = "normal";
|
|
313
|
+
refresh();
|
|
314
|
+
setTimeout(() => done({ saved: true }), 1600);
|
|
315
|
+
} else {
|
|
316
|
+
setStatus(state.validationError ?? "Invalid config", "error", 3000);
|
|
222
317
|
refresh();
|
|
223
|
-
return;
|
|
224
318
|
}
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
// Ctrl+P — prettify JSON
|
|
322
|
+
if (data === "\x10") {
|
|
323
|
+
prettifyEditor();
|
|
324
|
+
refresh();
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
// Delegate everything else to the editor
|
|
328
|
+
editor.handleInput(data);
|
|
329
|
+
state.editorContent = editor.getText();
|
|
330
|
+
refresh();
|
|
331
|
+
}
|
|
225
332
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
333
|
+
function handleSearchMode(data: string) {
|
|
334
|
+
if (matchesKey(data, Key.escape)) {
|
|
335
|
+
// Cancel search — restore previous query
|
|
336
|
+
applySearch(state.searchQuery);
|
|
337
|
+
state.pendingSearch = state.searchQuery;
|
|
338
|
+
state.mode = "normal";
|
|
339
|
+
refresh();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (data === "\r" || data === "\n") {
|
|
343
|
+
// Accept search, load selected server into editor
|
|
344
|
+
applySearch(state.pendingSearch);
|
|
345
|
+
state.mode = "normal";
|
|
346
|
+
loadSelectedIntoEditor();
|
|
347
|
+
state.mode = "editor";
|
|
348
|
+
refresh();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
// Arrow keys + j/k navigate list while searching
|
|
352
|
+
if (matchesKey(data, Key.down) || data === "\x1b[B" || data === "j") {
|
|
353
|
+
if (state.selectedIndex < state.filteredServers.length - 1) {
|
|
354
|
+
state.selectedIndex++;
|
|
355
|
+
ensureVisible();
|
|
356
|
+
refresh();
|
|
238
357
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
state.
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (matchesKey(data, Key.up) || data === "\x1b[A" || data === "k") {
|
|
361
|
+
if (state.selectedIndex > 0) {
|
|
362
|
+
state.selectedIndex--;
|
|
363
|
+
ensureVisible();
|
|
364
|
+
refresh();
|
|
365
|
+
}
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
// PgUp/PgDn also work in search
|
|
369
|
+
if (data === "\x1b[6~") {
|
|
370
|
+
state.selectedIndex = Math.min(state.filteredServers.length - 1, state.selectedIndex + lastListHeight);
|
|
371
|
+
ensureVisible();
|
|
372
|
+
refresh();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (data === "\x1b[5~") {
|
|
376
|
+
state.selectedIndex = Math.max(0, state.selectedIndex - lastListHeight);
|
|
377
|
+
ensureVisible();
|
|
378
|
+
refresh();
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (data === "\x7f" || data === "\b") {
|
|
382
|
+
if (state.pendingSearch.length > 0) {
|
|
383
|
+
state.pendingSearch = state.pendingSearch.slice(0, -1);
|
|
384
|
+
applySearch(state.pendingSearch);
|
|
385
|
+
refresh();
|
|
386
|
+
}
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
// Regular printable input
|
|
390
|
+
if (data.length === 1 && data >= " ") {
|
|
391
|
+
state.pendingSearch += data;
|
|
392
|
+
applySearch(state.pendingSearch);
|
|
243
393
|
refresh();
|
|
244
394
|
return;
|
|
245
395
|
}
|
|
396
|
+
}
|
|
246
397
|
|
|
247
|
-
|
|
248
|
-
|
|
398
|
+
function handleNormalMode(data: string) {
|
|
399
|
+
// Close
|
|
400
|
+
if (matchesKey(data, Key.escape) || data === "q") {
|
|
249
401
|
done(null);
|
|
250
402
|
return;
|
|
251
403
|
}
|
|
252
404
|
|
|
253
|
-
|
|
254
|
-
|
|
405
|
+
const listLen = state.filteredServers.length;
|
|
406
|
+
const half = Math.max(1, Math.floor(lastListHeight / 2));
|
|
407
|
+
|
|
408
|
+
// Movement
|
|
409
|
+
if (matchesKey(data, Key.down) || data === "j") {
|
|
410
|
+
if (state.selectedIndex < listLen - 1) {
|
|
411
|
+
state.selectedIndex++;
|
|
412
|
+
ensureVisible();
|
|
413
|
+
refresh();
|
|
414
|
+
}
|
|
415
|
+
state.pendingG = false;
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (matchesKey(data, Key.up) || data === "k") {
|
|
255
419
|
if (state.selectedIndex > 0) {
|
|
256
420
|
state.selectedIndex--;
|
|
421
|
+
ensureVisible();
|
|
257
422
|
refresh();
|
|
258
423
|
}
|
|
424
|
+
state.pendingG = false;
|
|
259
425
|
return;
|
|
260
426
|
}
|
|
261
|
-
|
|
262
|
-
if (
|
|
263
|
-
if (state.
|
|
264
|
-
state.selectedIndex
|
|
427
|
+
// gg → top, G → bottom
|
|
428
|
+
if (data === "g") {
|
|
429
|
+
if (state.pendingG) {
|
|
430
|
+
state.selectedIndex = 0;
|
|
431
|
+
state.scrollOffset = 0;
|
|
432
|
+
state.pendingG = false;
|
|
265
433
|
refresh();
|
|
434
|
+
} else {
|
|
435
|
+
state.pendingG = true;
|
|
266
436
|
}
|
|
267
437
|
return;
|
|
268
438
|
}
|
|
439
|
+
if (data === "G") {
|
|
440
|
+
state.selectedIndex = Math.max(0, listLen - 1);
|
|
441
|
+
ensureVisible();
|
|
442
|
+
state.pendingG = false;
|
|
443
|
+
refresh();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
// Ctrl+d / Ctrl+u — half-page
|
|
447
|
+
if (data === "\x04") {
|
|
448
|
+
state.selectedIndex = Math.min(listLen - 1, state.selectedIndex + half);
|
|
449
|
+
ensureVisible();
|
|
450
|
+
state.pendingG = false;
|
|
451
|
+
refresh();
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (data === "\x15") {
|
|
455
|
+
state.selectedIndex = Math.max(0, state.selectedIndex - half);
|
|
456
|
+
ensureVisible();
|
|
457
|
+
state.pendingG = false;
|
|
458
|
+
refresh();
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
// PageDown / PageUp
|
|
462
|
+
if (data === "\x1b[6~") {
|
|
463
|
+
state.selectedIndex = Math.min(listLen - 1, state.selectedIndex + lastListHeight);
|
|
464
|
+
ensureVisible();
|
|
465
|
+
refresh();
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (data === "\x1b[5~") {
|
|
469
|
+
state.selectedIndex = Math.max(0, state.selectedIndex - lastListHeight);
|
|
470
|
+
ensureVisible();
|
|
471
|
+
refresh();
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
269
474
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
475
|
+
state.pendingG = false;
|
|
476
|
+
|
|
477
|
+
// Search
|
|
478
|
+
if (data === "/") {
|
|
479
|
+
state.mode = "search";
|
|
480
|
+
state.pendingSearch = state.searchQuery;
|
|
273
481
|
refresh();
|
|
274
482
|
return;
|
|
275
483
|
}
|
|
276
484
|
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
485
|
+
// Enter → load template (if new server) + switch to editor.
|
|
486
|
+
// Will NOT wipe existing edits when re-entering editor on the same server.
|
|
487
|
+
if (data === "\r" || data === "\n") {
|
|
488
|
+
loadSelectedIntoEditor();
|
|
489
|
+
state.mode = "editor";
|
|
490
|
+
refresh();
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
// l / Tab → switch focus to editor, never overwrite content.
|
|
494
|
+
if (data === "l" || data === "\t") {
|
|
495
|
+
if (state.editorContent.trim() === "{}") loadSelectedIntoEditor();
|
|
496
|
+
state.mode = "editor";
|
|
497
|
+
refresh();
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
// r → force reload template for the currently selected server.
|
|
501
|
+
if (data === "r") {
|
|
502
|
+
loadSelectedIntoEditor({ force: true });
|
|
280
503
|
refresh();
|
|
281
504
|
return;
|
|
282
505
|
}
|
|
283
506
|
|
|
284
|
-
//
|
|
507
|
+
// c → custom JSON skeleton
|
|
285
508
|
if (data === "c") {
|
|
286
509
|
state.editorContent = JSON.stringify(
|
|
287
510
|
{ mcpServers: { "": { command: "", args: [], env: {} } } },
|
|
@@ -289,143 +512,267 @@ export function renderMcpAddOverlay(params?: {
|
|
|
289
512
|
2,
|
|
290
513
|
);
|
|
291
514
|
editor.setText(state.editorContent);
|
|
292
|
-
state.
|
|
293
|
-
state.mode = "
|
|
515
|
+
state.loadedServerId = null;
|
|
516
|
+
state.mode = "editor";
|
|
517
|
+
setStatus("Custom JSON skeleton", "info", 1200);
|
|
294
518
|
refresh();
|
|
295
519
|
return;
|
|
296
520
|
}
|
|
297
521
|
|
|
298
|
-
//
|
|
299
|
-
if (data === "
|
|
300
|
-
|
|
301
|
-
state.searchQuery = "";
|
|
302
|
-
} else {
|
|
303
|
-
state.searchQuery += data;
|
|
304
|
-
}
|
|
305
|
-
searchServers(state.searchQuery);
|
|
522
|
+
// ? → show help (status flash)
|
|
523
|
+
if (data === "?") {
|
|
524
|
+
setStatus("j/k=move · gg/G=top/bot · Ctrl+d/u=half-page · /=search · Enter=edit · q=quit");
|
|
306
525
|
refresh();
|
|
307
526
|
return;
|
|
308
527
|
}
|
|
528
|
+
}
|
|
309
529
|
|
|
310
|
-
|
|
311
|
-
if (
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
searchServers(state.searchQuery);
|
|
315
|
-
refresh();
|
|
316
|
-
}
|
|
317
|
-
return;
|
|
318
|
-
}
|
|
530
|
+
function handleInput(data: string) {
|
|
531
|
+
if (state.mode === "editor") return handleEditorMode(data);
|
|
532
|
+
if (state.mode === "search") return handleSearchMode(data);
|
|
533
|
+
return handleNormalMode(data);
|
|
319
534
|
}
|
|
320
535
|
|
|
536
|
+
// ─── Rendering ────────────────────────────────────────────────────
|
|
537
|
+
|
|
321
538
|
function render(width: number): string[] {
|
|
322
539
|
if (cachedLines) return cachedLines;
|
|
323
540
|
|
|
324
541
|
const lines: string[] = [];
|
|
325
|
-
const
|
|
542
|
+
const innerWidth = Math.max(40, width - 2);
|
|
543
|
+
|
|
544
|
+
// Pane widths: account for left │, middle │, right │ borders
|
|
545
|
+
const leftW = Math.floor((innerWidth - 1) / 2);
|
|
546
|
+
const rightW = innerWidth - 1 - leftW;
|
|
547
|
+
|
|
548
|
+
const browseFocused = state.mode !== "editor";
|
|
549
|
+
const editorFocused = state.mode === "editor";
|
|
550
|
+
|
|
551
|
+
// Border + active-pane accent helpers
|
|
552
|
+
const border = (s: string) => theme.fg("accent", s);
|
|
553
|
+
const dimBorder = (s: string) => theme.fg("muted", s);
|
|
554
|
+
|
|
555
|
+
// ── Top header ──────────────────────────────────────────────────
|
|
556
|
+
const scopeTag = state.scope === "global" ? "[GLOBAL]" : "[PROJECT]";
|
|
557
|
+
const configPath = state.scope === "global"
|
|
558
|
+
? getGlobalConfigDir() + "/mcp-config.json"
|
|
559
|
+
: ".unipi/config/mcp/mcp-config.json";
|
|
560
|
+
const title = ` Add MCP Server ${theme.fg("accent", scopeTag)} `;
|
|
561
|
+
const modeTag =
|
|
562
|
+
state.mode === "search"
|
|
563
|
+
? "[SEARCH]"
|
|
564
|
+
: state.mode === "editor"
|
|
565
|
+
? "[EDITOR]"
|
|
566
|
+
: "[NORMAL]";
|
|
567
|
+
const headerPad = Math.max(0, innerWidth - visibleWidth(title) - visibleWidth(modeTag));
|
|
568
|
+
lines.push(border(`╭${"─".repeat(innerWidth)}╮`));
|
|
569
|
+
lines.push(
|
|
570
|
+
border("│") +
|
|
571
|
+
theme.bold(title) +
|
|
572
|
+
" ".repeat(headerPad) +
|
|
573
|
+
theme.fg("accent", modeTag) +
|
|
574
|
+
border("│"),
|
|
575
|
+
);
|
|
576
|
+
// Config path row (OSC 8 hyperlink to file for Ctrl+click in supported terminals)
|
|
577
|
+
const configPathAbs = state.scope === "global"
|
|
578
|
+
? getGlobalConfigDir() + "/mcp-config.json"
|
|
579
|
+
: process.cwd() + "/.unipi/config/mcp/mcp-config.json";
|
|
580
|
+
const pathLink = hyperlink(
|
|
581
|
+
"file://" + configPathAbs,
|
|
582
|
+
theme.fg("muted", configPath),
|
|
583
|
+
);
|
|
584
|
+
const pathLabel = ` Config: ${pathLink} ${theme.fg("dim", "(Ctrl+click)")} `;
|
|
585
|
+
lines.push(border("│") + padVisible(pathLabel, innerWidth) + border("│"));
|
|
326
586
|
|
|
327
|
-
//
|
|
328
|
-
const header = " Add MCP Server ";
|
|
329
|
-
const modeLabel = state.mode === "browse" ? "[Browse]" : "[Custom]";
|
|
587
|
+
// Pane headers row: ├──── search bar ────┬──── Config Editor ────┤
|
|
330
588
|
lines.push(
|
|
331
|
-
|
|
589
|
+
border("├") +
|
|
590
|
+
border("─".repeat(leftW)) +
|
|
591
|
+
border("┬") +
|
|
592
|
+
border("─".repeat(rightW)) +
|
|
593
|
+
border("┤"),
|
|
332
594
|
);
|
|
595
|
+
|
|
596
|
+
// Search bar / list header on left, editor title on right
|
|
597
|
+
const searchBar = (() => {
|
|
598
|
+
if (state.mode === "search") {
|
|
599
|
+
const cursor = state.pendingSearch.length > 0 ? "" : "█";
|
|
600
|
+
return ` ${theme.fg("accent", "/")}${theme.fg("text", state.pendingSearch)}${theme.fg("accent", cursor)}`;
|
|
601
|
+
}
|
|
602
|
+
if (state.searchQuery) return ` ${theme.fg("muted", "/")}${theme.fg("text", state.searchQuery)}`;
|
|
603
|
+
return ` ${theme.fg("muted", "/ press / to search")}`;
|
|
604
|
+
})();
|
|
605
|
+
|
|
606
|
+
const editingLabel = (() => {
|
|
607
|
+
const sel = state.filteredServers[state.selectedIndex];
|
|
608
|
+
if (state.loadedServerId && sel && sel.id === state.loadedServerId) {
|
|
609
|
+
return ` Editing: ${widthSafe(sel.name)} `;
|
|
610
|
+
}
|
|
611
|
+
if (state.loadedServerId) return ` Editing: ${widthSafe(state.loadedServerId)} `;
|
|
612
|
+
return " Config Editor ";
|
|
613
|
+
})();
|
|
614
|
+
const editorHeader = editorFocused
|
|
615
|
+
? theme.bold(theme.fg("accent", editingLabel))
|
|
616
|
+
: theme.fg("muted", editingLabel);
|
|
617
|
+
|
|
618
|
+
const total = state.allServers.length;
|
|
619
|
+
const shown = state.filteredServers.length;
|
|
620
|
+
const counter =
|
|
621
|
+
shown < total
|
|
622
|
+
? theme.fg("dim", `${shown}/${total} `)
|
|
623
|
+
: theme.fg("dim", `${total} `);
|
|
624
|
+
const leftHeaderRight = padVisible(searchBar, leftW - visibleWidth(counter)) + counter;
|
|
625
|
+
|
|
333
626
|
lines.push(
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
627
|
+
border("│") +
|
|
628
|
+
padVisible(leftHeaderRight, leftW) +
|
|
629
|
+
(browseFocused ? border("│") : dimBorder("│")) +
|
|
630
|
+
padVisible(editorHeader, rightW) +
|
|
631
|
+
border("│"),
|
|
338
632
|
);
|
|
633
|
+
|
|
339
634
|
lines.push(
|
|
340
|
-
|
|
635
|
+
border("├") +
|
|
636
|
+
border("─".repeat(leftW)) +
|
|
637
|
+
border("┼") +
|
|
638
|
+
border("─".repeat(rightW)) +
|
|
639
|
+
border("┤"),
|
|
341
640
|
);
|
|
342
641
|
|
|
343
|
-
//
|
|
344
|
-
const
|
|
345
|
-
|
|
642
|
+
// ── List + editor body ─────────────────────────────────────────
|
|
643
|
+
const LIST_HEIGHT = 14;
|
|
644
|
+
lastListHeight = LIST_HEIGHT;
|
|
645
|
+
ensureVisible();
|
|
346
646
|
|
|
347
|
-
|
|
647
|
+
// Drive the real Editor: focus reflects mode, render gives lines that
|
|
648
|
+
// already contain the reverse-video cursor when focused. We strip the
|
|
649
|
+
// editor's top/bottom horizontal borders since our pane has its own.
|
|
650
|
+
editor.focused = editorFocused;
|
|
651
|
+
const editorRendered: string[] = (() => {
|
|
652
|
+
try {
|
|
653
|
+
return editor.render(rightW);
|
|
654
|
+
} catch {
|
|
655
|
+
return [];
|
|
656
|
+
}
|
|
657
|
+
})();
|
|
658
|
+
const editorBody =
|
|
659
|
+
editorRendered.length >= 2
|
|
660
|
+
? editorRendered.slice(1, -1)
|
|
661
|
+
: editorRendered;
|
|
662
|
+
|
|
663
|
+
for (let row = 0; row < LIST_HEIGHT; row++) {
|
|
664
|
+
// Left: server list
|
|
348
665
|
let left = "";
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
const
|
|
666
|
+
const serverIdx = state.scrollOffset + row;
|
|
667
|
+
if (serverIdx < state.filteredServers.length) {
|
|
668
|
+
const server = state.filteredServers[serverIdx];
|
|
669
|
+
const selected = serverIdx === state.selectedIndex;
|
|
670
|
+
const scopeTag = server.scope === "cloud" ? "[c]" : "[l]";
|
|
671
|
+
const officialMark = server.official ? theme.fg("success", "*") : " ";
|
|
672
|
+
const prefix = selected
|
|
673
|
+
? (browseFocused ? theme.fg("accent", "> ") : theme.fg("muted", "> "))
|
|
674
|
+
: " ";
|
|
675
|
+
// Reserved cells: " " + prefix(2) + officialMark(1) + " " + scopeTag(3) + " " = 8
|
|
676
|
+
const nameRaw = widthSafe(truncateToWidth(server.name, leftW - 8));
|
|
677
|
+
const scope = server.scope === "cloud"
|
|
678
|
+
? theme.fg("accent", scopeTag)
|
|
679
|
+
: theme.fg("warning", scopeTag);
|
|
361
680
|
const name = selected
|
|
362
|
-
? theme.bold(
|
|
363
|
-
: theme.fg("
|
|
364
|
-
left = ` ${prefix}${
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const desc = truncateToWidth(
|
|
368
|
-
server.description,
|
|
369
|
-
halfW - 4,
|
|
370
|
-
);
|
|
371
|
-
left = ` ${prefix}${scopeIcon} ${name}\n ${theme.muted(desc)}`;
|
|
372
|
-
}
|
|
681
|
+
? (browseFocused ? theme.bold(theme.fg("accent", nameRaw)) : theme.bold(nameRaw))
|
|
682
|
+
: theme.fg("text", nameRaw);
|
|
683
|
+
left = ` ${prefix}${officialMark} ${scope} ${name}`;
|
|
684
|
+
} else if (state.filteredServers.length === 0 && row === 1) {
|
|
685
|
+
left = ` ${theme.fg("warning", "no matches")}`;
|
|
373
686
|
}
|
|
374
687
|
|
|
375
|
-
// Right
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
// Show editor lines
|
|
380
|
-
const editorLines = state.editorContent.split("\n");
|
|
381
|
-
const editorIdx = row - 2;
|
|
382
|
-
if (editorIdx < editorLines.length) {
|
|
383
|
-
right = theme.fg("default", truncateToWidth(editorLines[editorIdx], halfW - 3));
|
|
384
|
-
}
|
|
688
|
+
// Right: editor content (lines already contain cursor + ANSI)
|
|
689
|
+
let right = "";
|
|
690
|
+
if (row < editorBody.length) {
|
|
691
|
+
right = editorBody[row];
|
|
385
692
|
}
|
|
386
693
|
|
|
387
|
-
|
|
388
|
-
const leftPadded = left.padEnd(halfW);
|
|
389
|
-
const rightPadded = right.padEnd(halfW);
|
|
694
|
+
const midBorder = browseFocused && !editorFocused ? border("│") : dimBorder("│");
|
|
390
695
|
lines.push(
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
696
|
+
(browseFocused ? border("│") : dimBorder("│")) +
|
|
697
|
+
padVisible(left, leftW) +
|
|
698
|
+
midBorder +
|
|
699
|
+
padVisible(right, rightW) +
|
|
700
|
+
(editorFocused ? border("│") : dimBorder("│")),
|
|
396
701
|
);
|
|
397
702
|
}
|
|
398
703
|
|
|
399
|
-
//
|
|
704
|
+
// ── Description / status row ──────────────────────────────────
|
|
705
|
+
lines.push(
|
|
706
|
+
border("├") +
|
|
707
|
+
border("─".repeat(leftW)) +
|
|
708
|
+
border("┴") +
|
|
709
|
+
border("─".repeat(rightW)) +
|
|
710
|
+
border("┤"),
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
const sel = state.filteredServers[state.selectedIndex];
|
|
714
|
+
let descContent: string;
|
|
715
|
+
if (sel) {
|
|
716
|
+
const idSafe = widthSafe(sel.id);
|
|
717
|
+
const idVis = visibleWidth(idSafe);
|
|
718
|
+
const descBudget = Math.max(0, innerWidth - idVis - 4);
|
|
719
|
+
const descSafe = widthSafe(sel.description);
|
|
720
|
+
const desc = truncateToWidth(descSafe, descBudget);
|
|
721
|
+
const idLink = sel.github
|
|
722
|
+
? hyperlink(sel.github, theme.fg("accent", idSafe))
|
|
723
|
+
: theme.fg("accent", idSafe);
|
|
724
|
+
const hint = sel.github ? theme.fg("dim", " (Ctrl+click)") : "";
|
|
725
|
+
descContent = ` ${idLink}${hint} ${theme.fg("muted", desc)}`;
|
|
726
|
+
} else {
|
|
727
|
+
descContent = ` ${theme.fg("dim", "no server selected")}`;
|
|
728
|
+
}
|
|
729
|
+
lines.push(border("│") + padVisible(descContent, innerWidth) + border("│"));
|
|
730
|
+
|
|
731
|
+
// ── Validation error ───────────────────────────────────────────
|
|
400
732
|
if (state.validationError) {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
theme.error(` ⚠ ${truncateToWidth(state.validationError, width - 6)}`.padEnd(width - 2)) +
|
|
404
|
-
theme.accent("│"),
|
|
405
|
-
);
|
|
733
|
+
const errText = ` ⚠ ${truncateToWidth(state.validationError, innerWidth - 4)}`;
|
|
734
|
+
lines.push(border("│") + padVisible(theme.fg("error", errText), innerWidth) + border("│"));
|
|
406
735
|
}
|
|
407
736
|
|
|
408
|
-
//
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
737
|
+
// ── Status line ────────────────────────────────────────────────
|
|
738
|
+
if (state.status) {
|
|
739
|
+
const colour =
|
|
740
|
+
state.statusKind === "success"
|
|
741
|
+
? "success"
|
|
742
|
+
: state.statusKind === "error"
|
|
743
|
+
? "error"
|
|
744
|
+
: state.statusKind === "warn"
|
|
745
|
+
? "warning"
|
|
746
|
+
: "accent";
|
|
747
|
+
const text = ` ${state.status}`;
|
|
748
|
+
const styled =
|
|
749
|
+
state.statusKind === "success"
|
|
750
|
+
? theme.bold(theme.fg(colour as any, text))
|
|
751
|
+
: theme.fg(colour as any, text);
|
|
752
|
+
lines.push(border("│") + padVisible(styled, innerWidth) + border("│"));
|
|
753
|
+
}
|
|
418
754
|
|
|
419
|
-
//
|
|
420
|
-
|
|
755
|
+
// ── Footer ─────────────────────────────────────────────────────
|
|
756
|
+
lines.push(border(`├${"─".repeat(innerWidth)}┤`));
|
|
757
|
+
const scopeLabel = state.scope === "global" ? "● Global" : "● Project";
|
|
421
758
|
lines.push(
|
|
422
|
-
|
|
423
|
-
theme.muted
|
|
424
|
-
|
|
759
|
+
border("│") +
|
|
760
|
+
padVisible(theme.fg("muted", ` ${scopeLabel}`), innerWidth) +
|
|
761
|
+
border("│"),
|
|
425
762
|
);
|
|
763
|
+
|
|
764
|
+
const binds =
|
|
765
|
+
state.mode === "editor"
|
|
766
|
+
? " Ctrl+S=save · Ctrl+P=prettify · Enter=newline · Esc=back to list "
|
|
767
|
+
: state.mode === "search"
|
|
768
|
+
? " Type to filter · ↑↓ navigate · Enter load+edit · Esc cancel "
|
|
769
|
+
: " j/k move · / search · Enter edit · r reload tmpl · c custom · Tab pane · q close ";
|
|
426
770
|
lines.push(
|
|
427
|
-
|
|
771
|
+
border("│") +
|
|
772
|
+
padVisible(theme.fg("muted", truncateToWidth(binds, innerWidth)), innerWidth) +
|
|
773
|
+
border("│"),
|
|
428
774
|
);
|
|
775
|
+
lines.push(border(`╰${"─".repeat(innerWidth)}╯`));
|
|
429
776
|
|
|
430
777
|
cachedLines = lines;
|
|
431
778
|
return lines;
|