@pi-unipi/mcp 0.1.0 → 0.1.6

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.
@@ -1,9 +1,13 @@
1
1
  /**
2
2
  * @pi-unipi/mcp — Add server overlay TUI
3
3
  *
4
- * Split-pane overlay for adding MCP servers:
5
- * Left: browsable/searchable catalog list
6
- * Right: JSON config editor for selected server
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, createServerTemplate, DEFAULT_MCP_CONFIG, DEFAULT_METADATA } from "../config/schema.js";
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: "browse" | "custom";
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
- focusPane: "browse" | "editor";
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 name = server.name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
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
- [name]: {
59
+ [slug]: {
51
60
  command: server.install?.command ?? "npx",
52
- args: server.install?.args ?? ["-y", `@modelcontextprotocol/server-${name}`],
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
- env[v] = "";
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
- * Render the MCP add overlay.
72
- *
73
- * Returns a callback compatible with ctx.ui.custom():
74
- * (tui, theme, kb, done) => { render, invalidate, handleInput }
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: "browse",
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
- focusPane: "browse",
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 searchServers(query: string) {
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
- function selectServer(index: number) {
151
- if (index < 0 || index >= state.filteredServers.length) return;
152
- state.selectedIndex = index;
153
- const server = state.filteredServers[index];
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
- state.focusPane = "editor";
158
- state.mode = "browse";
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
- function handleInput(data: string) {
216
- // Editor pane focused: delegate to editor
217
- if (state.focusPane === "editor") {
218
- if (matchesKey(data, Key.escape)) {
219
- // Cancel editing, go back to browse
220
- state.focusPane = "browse";
221
- state.validationError = null;
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
- // Ctrl+S or Enter in editor: validate and save
227
- if (
228
- (data === "\x13" || data === "\r") &&
229
- state.editorContent.trim() !== "{}"
230
- ) {
231
- if (validateAndSave()) {
232
- done({ saved: true });
233
- params?.onComplete?.();
234
- } else {
235
- refresh();
236
- }
237
- return;
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
- // Delegate to editor
241
- editor.handleInput(data);
242
- state.editorContent = editor.getText();
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
- // Browse pane focused
248
- if (matchesKey(data, Key.escape)) {
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
- // Navigation
254
- if (matchesKey(data, Key.up)) {
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 (matchesKey(data, Key.down)) {
263
- if (state.selectedIndex < state.filteredServers.length - 1) {
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
- // Enter: select server and move to editor
271
- if (data === "\r" || data === "\n") {
272
- selectServer(state.selectedIndex);
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
- // Tab: toggle focus between panes
278
- if (data === "\t") {
279
- state.focusPane = state.focusPane === "browse" ? "editor" : "browse";
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
- // 'c': switch to custom mode (empty editor)
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.focusPane = "editor";
293
- state.mode = "custom";
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
- // '/' or typing: start search
299
- if (data === "/" || (data.length === 1 && data >= " ")) {
300
- if (data === "/") {
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
- // Backspace: delete from search
311
- if (data === "\x7f" || data === "\b") {
312
- if (state.searchQuery.length > 0) {
313
- state.searchQuery = state.searchQuery.slice(0, -1);
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 halfW = Math.floor(width / 2) - 1;
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
- // Header
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
- theme.accent(`╭${"".repeat(Math.max(0, width - 2))}╮`),
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
- theme.accent("│") +
335
- theme.bold(header) +
336
- theme.muted(modeLabel.padStart(width - visibleWidth(header) - visibleWidth(modeLabel) - 1)) +
337
- theme.accent("│"),
627
+ border("│") +
628
+ padVisible(leftHeaderRight, leftW) +
629
+ (browseFocused ? border("│") : dimBorder("│")) +
630
+ padVisible(editorHeader, rightW) +
631
+ border("│"),
338
632
  );
633
+
339
634
  lines.push(
340
- theme.accent(`├${"".repeat(Math.max(0, width - 2))}┤`),
635
+ border("") +
636
+ border("─".repeat(leftW)) +
637
+ border("┼") +
638
+ border("─".repeat(rightW)) +
639
+ border("┤"),
341
640
  );
342
641
 
343
- // Content area split pane
344
- const browseServers = state.filteredServers;
345
- const maxRows = 16;
642
+ // ── List + editor body ─────────────────────────────────────────
643
+ const LIST_HEIGHT = 14;
644
+ lastListHeight = LIST_HEIGHT;
645
+ ensureVisible();
346
646
 
347
- for (let row = 0; row < maxRows; row++) {
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
- let right = "";
350
-
351
- // Left pane: browse
352
- if (row === 0) {
353
- const searchDisplay = state.searchQuery || "search...";
354
- left = theme.muted(` 🔍 ${searchDisplay}`);
355
- } else if (row >= 2 && row - 2 < browseServers.length) {
356
- const idx = row - 2;
357
- const server = browseServers[idx];
358
- const selected = idx === state.selectedIndex;
359
- const scopeIcon = server.scope === "cloud" ? "☁️" : "🏠";
360
- const prefix = selected ? theme.accent("▸ ") : " ";
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(server.name)
363
- : theme.fg("default", server.name);
364
- left = ` ${prefix}${scopeIcon} ${name}`;
365
- if (selected) {
366
- // Show description on next line if available
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 pane: editor
376
- if (row === 0) {
377
- right = theme.muted(" Config Editor ");
378
- } else if (row >= 2) {
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
- // Pad and combine
388
- const leftPadded = left.padEnd(halfW);
389
- const rightPadded = right.padEnd(halfW);
694
+ const midBorder = browseFocused && !editorFocused ? border("│") : dimBorder("│");
390
695
  lines.push(
391
- theme.accent("│") +
392
- leftPadded +
393
- theme.accent("│") +
394
- rightPadded +
395
- theme.accent("│"),
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
- // Validation error
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
- lines.push(
402
- theme.accent("│") +
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
- // Scope indicator
409
- const scopeLabel = state.scope === "global" ? "Global" : "Project";
410
- lines.push(
411
- theme.accent(`├${"".repeat(Math.max(0, width - 2))}┤`),
412
- );
413
- lines.push(
414
- theme.accent("") +
415
- theme.muted(` ${scopeLabel}`.padEnd(width - 2)) +
416
- theme.accent(""),
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
- // Keybinds
420
- const binds = " ↑↓ navigate Enter select Tab pane c custom q/Esc close";
755
+ // ── Footer ─────────────────────────────────────────────────────
756
+ lines.push(border(`├${"".repeat(innerWidth)}┤`));
757
+ const scopeLabel = state.scope === "global" ? "● Global" : "● Project";
421
758
  lines.push(
422
- theme.accent("│") +
423
- theme.muted(truncateToWidth(binds, width - 2).padEnd(width - 2)) +
424
- theme.accent("│"),
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
- theme.accent(`╰${"".repeat(Math.max(0, width - 2))}╯`),
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;