@akon-labs/nexus-cli 0.1.10 → 0.1.11

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.
Files changed (127) hide show
  1. package/dist/cli/require-login.d.ts.map +1 -1
  2. package/dist/cli/require-login.js +2 -2
  3. package/dist/cli/require-login.js.map +1 -1
  4. package/dist/cli.js +2492 -1088
  5. package/dist/core/agent-session.d.ts +3 -1
  6. package/dist/core/agent-session.d.ts.map +1 -1
  7. package/dist/core/agent-session.js +4 -0
  8. package/dist/core/agent-session.js.map +1 -1
  9. package/dist/core/extensions/index.d.ts +1 -1
  10. package/dist/core/extensions/index.d.ts.map +1 -1
  11. package/dist/core/extensions/index.js.map +1 -1
  12. package/dist/core/extensions/loader.d.ts.map +1 -1
  13. package/dist/core/extensions/loader.js +6 -0
  14. package/dist/core/extensions/loader.js.map +1 -1
  15. package/dist/core/extensions/runner.d.ts +7 -1
  16. package/dist/core/extensions/runner.d.ts.map +1 -1
  17. package/dist/core/extensions/runner.js +14 -0
  18. package/dist/core/extensions/runner.js.map +1 -1
  19. package/dist/core/extensions/types.d.ts +18 -0
  20. package/dist/core/extensions/types.d.ts.map +1 -1
  21. package/dist/core/extensions/types.js.map +1 -1
  22. package/dist/core/graph-rail-store.d.ts +10 -0
  23. package/dist/core/graph-rail-store.d.ts.map +1 -1
  24. package/dist/core/graph-rail-store.js +9 -0
  25. package/dist/core/graph-rail-store.js.map +1 -1
  26. package/dist/core/keybindings.d.ts +2 -1
  27. package/dist/core/keybindings.d.ts.map +1 -1
  28. package/dist/core/keybindings.js +4 -1
  29. package/dist/core/keybindings.js.map +1 -1
  30. package/dist/core/tools/find.d.ts.map +1 -1
  31. package/dist/core/tools/find.js +1 -1
  32. package/dist/core/tools/find.js.map +1 -1
  33. package/dist/core/tools/grep.d.ts.map +1 -1
  34. package/dist/core/tools/grep.js +1 -1
  35. package/dist/core/tools/grep.js.map +1 -1
  36. package/dist/extensions/gitnexus/client.d.ts.map +1 -1
  37. package/dist/extensions/gitnexus/client.js +45 -14
  38. package/dist/extensions/gitnexus/client.js.map +1 -1
  39. package/dist/extensions/gitnexus/config.d.ts +15 -1
  40. package/dist/extensions/gitnexus/config.d.ts.map +1 -1
  41. package/dist/extensions/gitnexus/config.js +86 -2
  42. package/dist/extensions/gitnexus/config.js.map +1 -1
  43. package/dist/extensions/gitnexus/hub-api.d.ts +56 -0
  44. package/dist/extensions/gitnexus/hub-api.d.ts.map +1 -0
  45. package/dist/extensions/gitnexus/hub-api.js +105 -0
  46. package/dist/extensions/gitnexus/hub-api.js.map +1 -0
  47. package/dist/extensions/gitnexus/index-repo.d.ts +50 -0
  48. package/dist/extensions/gitnexus/index-repo.d.ts.map +1 -0
  49. package/dist/extensions/gitnexus/index-repo.js +162 -0
  50. package/dist/extensions/gitnexus/index-repo.js.map +1 -0
  51. package/dist/extensions/gitnexus/index.d.ts.map +1 -1
  52. package/dist/extensions/gitnexus/index.js +137 -17
  53. package/dist/extensions/gitnexus/index.js.map +1 -1
  54. package/dist/extensions/gitnexus/observatory.d.ts.map +1 -1
  55. package/dist/extensions/gitnexus/observatory.js +2 -2
  56. package/dist/extensions/gitnexus/observatory.js.map +1 -1
  57. package/dist/extensions/gitnexus/resolve.d.ts +9 -0
  58. package/dist/extensions/gitnexus/resolve.d.ts.map +1 -1
  59. package/dist/extensions/gitnexus/resolve.js +52 -2
  60. package/dist/extensions/gitnexus/resolve.js.map +1 -1
  61. package/dist/extensions/index.d.ts.map +1 -1
  62. package/dist/extensions/index.js +3 -0
  63. package/dist/extensions/index.js.map +1 -1
  64. package/dist/extensions/mcp/commands.d.ts +22 -2
  65. package/dist/extensions/mcp/commands.d.ts.map +1 -1
  66. package/dist/extensions/mcp/commands.js +201 -9
  67. package/dist/extensions/mcp/commands.js.map +1 -1
  68. package/dist/extensions/mcp/config.d.ts +2 -0
  69. package/dist/extensions/mcp/config.d.ts.map +1 -1
  70. package/dist/extensions/mcp/config.js +6 -1
  71. package/dist/extensions/mcp/config.js.map +1 -1
  72. package/dist/extensions/mcp/index.d.ts.map +1 -1
  73. package/dist/extensions/mcp/index.js +54 -5
  74. package/dist/extensions/mcp/index.js.map +1 -1
  75. package/dist/extensions/mcp/mcp-setup-panel.d.ts +63 -2
  76. package/dist/extensions/mcp/mcp-setup-panel.d.ts.map +1 -1
  77. package/dist/extensions/mcp/mcp-setup-panel.js +563 -77
  78. package/dist/extensions/mcp/mcp-setup-panel.js.map +1 -1
  79. package/dist/extensions/subagent/index.d.ts +2 -1
  80. package/dist/extensions/subagent/index.d.ts.map +1 -1
  81. package/dist/extensions/subagent/index.js +19 -6
  82. package/dist/extensions/subagent/index.js.map +1 -1
  83. package/dist/extensions/subagent-render.d.ts +3 -0
  84. package/dist/extensions/subagent-render.d.ts.map +1 -0
  85. package/dist/extensions/subagent-render.js +39 -0
  86. package/dist/extensions/subagent-render.js.map +1 -0
  87. package/dist/index.js +2524 -1120
  88. package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  89. package/dist/modes/interactive/components/assistant-message.js +2 -0
  90. package/dist/modes/interactive/components/assistant-message.js.map +1 -1
  91. package/dist/modes/interactive/components/command-palette.d.ts +2 -1
  92. package/dist/modes/interactive/components/command-palette.d.ts.map +1 -1
  93. package/dist/modes/interactive/components/command-palette.js +11 -4
  94. package/dist/modes/interactive/components/command-palette.js.map +1 -1
  95. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  96. package/dist/modes/interactive/components/footer.js +9 -3
  97. package/dist/modes/interactive/components/footer.js.map +1 -1
  98. package/dist/modes/interactive/components/graph-rail.d.ts.map +1 -1
  99. package/dist/modes/interactive/components/graph-rail.js +18 -3
  100. package/dist/modes/interactive/components/graph-rail.js.map +1 -1
  101. package/dist/modes/interactive/components/keybinding-hints.d.ts +1 -0
  102. package/dist/modes/interactive/components/keybinding-hints.d.ts.map +1 -1
  103. package/dist/modes/interactive/components/keybinding-hints.js +7 -1
  104. package/dist/modes/interactive/components/keybinding-hints.js.map +1 -1
  105. package/dist/modes/interactive/components/landing-panel.d.ts.map +1 -1
  106. package/dist/modes/interactive/components/landing-panel.js +15 -2
  107. package/dist/modes/interactive/components/landing-panel.js.map +1 -1
  108. package/dist/modes/interactive/components/tool-execution.d.ts +3 -2
  109. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  110. package/dist/modes/interactive/components/tool-execution.js +12 -2
  111. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  112. package/dist/modes/interactive/interactive-mode.d.ts +3 -0
  113. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  114. package/dist/modes/interactive/interactive-mode.js +42 -8
  115. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  116. package/dist/rpc-entry.js +2492 -1088
  117. package/dist/utils/hidden-spawn.d.ts +29 -0
  118. package/dist/utils/hidden-spawn.d.ts.map +1 -0
  119. package/dist/utils/hidden-spawn.js +101 -0
  120. package/dist/utils/hidden-spawn.js.map +1 -0
  121. package/dist/vendor/ai.js +233 -233
  122. package/dist/vendor/tui.js +1 -1
  123. package/native/win32/build.mjs +95 -0
  124. package/native/win32/prebuilds/win32-x64/hidden-spawn.exe +0 -0
  125. package/native/win32/src/hidden-spawn.c +157 -0
  126. package/npm-shrinkwrap.json +2 -37
  127. package/package.json +7 -3
@@ -38,14 +38,84 @@ function wrapText(text, width) {
38
38
  lines.push(current);
39
39
  return lines.length > 0 ? lines : [""];
40
40
  }
41
+ const SETUP_TABS = [
42
+ { id: "presets", label: "Presets" },
43
+ { id: "custom", label: "Custom" },
44
+ { id: "installed", label: "Installed" },
45
+ ];
46
+ /** Server-type tag shown next to a preset row; color must survive any terminal, so it's always plain colored text. */
47
+ /** Color a JSON-ish line: keys cyan, string values green, braces/brackets dim. Safe only for short, unwrapped lines. */
48
+ function colorizeJsonLine(line) {
49
+ let result = "";
50
+ let i = 0;
51
+ while (i < line.length) {
52
+ const ch = line[i];
53
+ if (ch === '"') {
54
+ let end = i + 1;
55
+ while (end < line.length && line[end] !== '"')
56
+ end++;
57
+ const str = line.slice(i, Math.min(end + 1, line.length));
58
+ const isKey = line
59
+ .slice(i + str.length)
60
+ .trimStart()
61
+ .startsWith(":");
62
+ result += fg(isKey ? "36" : "32", str);
63
+ i += str.length;
64
+ continue;
65
+ }
66
+ if (ch === "{" || ch === "}" || ch === "[" || ch === "]") {
67
+ result += fg("2", ch);
68
+ i++;
69
+ continue;
70
+ }
71
+ result += ch;
72
+ i++;
73
+ }
74
+ return result;
75
+ }
76
+ /** Diff-line color by prefix, or undefined for a context line that needs no color. */
77
+ function diffLineColor(line) {
78
+ if (line.startsWith("+++") || line.startsWith("---"))
79
+ return "2";
80
+ if (line.startsWith("+"))
81
+ return "32";
82
+ if (line.startsWith("-"))
83
+ return "31";
84
+ return undefined;
85
+ }
86
+ /** Color `/mcp add` command syntax: the command itself cyan, --flags yellow, <placeholders> dim italic. Leaves spacing untouched. */
87
+ function colorizeCommandLine(line) {
88
+ return line.replace(/(\/mcp add\b|--[a-zA-Z-]+|<[^>]+>|\[[^\]]+\])/g, (match) => {
89
+ if (match.startsWith("<") || match.startsWith("["))
90
+ return fg("2;3", match);
91
+ if (match.startsWith("--"))
92
+ return fg("33", match);
93
+ return fg("36", match);
94
+ });
95
+ }
96
+ /** Per-preset accent color for the name, so the list scans by identity, not just by type. 256-color codes, distinct from the type-tag palette above. */
97
+ const PRESET_ACCENT_COLORS = {
98
+ deepwiki: "38;5;111", // soft blue
99
+ context7: "38;5;80", // teal
100
+ notion: "38;5;141", // purple
101
+ github: "38;5;215", // orange
102
+ "chrome-devtools": "38;5;211", // pink
103
+ };
104
+ function presetAccent(preset) {
105
+ return PRESET_ACCENT_COLORS[preset.id] ?? "37";
106
+ }
41
107
  export class McpSetupPanel {
42
108
  screen;
109
+ activeTab;
43
110
  actionCursor = 0;
44
111
  importCursor = 0;
45
112
  pathCursor = 0;
113
+ advancedCursor = 0;
46
114
  selectedImports = new Set();
47
115
  busy = false;
48
116
  notice = null;
117
+ pendingAction;
118
+ maxHeight;
49
119
  tui;
50
120
  t = DEFAULT_THEME;
51
121
  keys;
@@ -53,16 +123,20 @@ export class McpSetupPanel {
53
123
  discovery;
54
124
  callbacks;
55
125
  options;
126
+ /** Mutable copy of options.installedServers, updated in-place after a successful add so the Installed tab and Presets "active" tag reflect it immediately, without needing to close and reopen the panel. */
127
+ installedServers;
56
128
  done;
57
129
  static INACTIVITY_MS = 60_000;
58
130
  constructor(discovery, callbacks, options, tui, done) {
59
131
  this.discovery = discovery;
60
132
  this.callbacks = callbacks;
61
133
  this.options = options;
134
+ this.installedServers = [...options.installedServers];
62
135
  this.done = done;
63
136
  this.tui = tui;
64
137
  this.keys = createPanelKeys(options.keybindings);
65
138
  this.screen = options.mode;
139
+ this.activeTab = "presets";
66
140
  for (const entry of discovery.imports) {
67
141
  this.selectedImports.add(entry.kind);
68
142
  }
@@ -82,15 +156,9 @@ export class McpSetupPanel {
82
156
  this.inactivityTimeout = null;
83
157
  }
84
158
  }
85
- getActions() {
159
+ /** The old "Setup" tab's contents — now a sub-screen opened from Custom's "Advanced options...", not a permanent top-level tab. */
160
+ getAdvancedActions() {
86
161
  const actions = [];
87
- if (this.screen === "empty") {
88
- actions.push({
89
- id: "run-setup",
90
- label: "Run setup",
91
- description: "Inspect detected configs, adopt imports, and scaffold a minimal `.nexus/mcp.json`.",
92
- });
93
- }
94
162
  if (this.discovery.imports.length > 0) {
95
163
  actions.push({
96
164
  id: "adopt-imports",
@@ -122,6 +190,10 @@ export class McpSetupPanel {
122
190
  description: "Browse the actual config files that Nexus discovered on this machine.",
123
191
  });
124
192
  }
193
+ return actions;
194
+ }
195
+ getPresetsTabActions() {
196
+ const actions = [];
125
197
  for (const preset of KNOWN_SERVER_PRESETS) {
126
198
  actions.push({ id: "add-known-server", label: preset.name, description: preset.summary, preset });
127
199
  }
@@ -136,9 +208,86 @@ export class McpSetupPanel {
136
208
  description: "Write an MCP entry for RepoPrompt to the recommended Nexus target, then reload MCP in-session.",
137
209
  });
138
210
  }
139
- actions.push({ id: "close", label: "Close", description: "Exit the onboarding flow." });
140
211
  return actions;
141
212
  }
213
+ getCustomTabActions() {
214
+ return [
215
+ {
216
+ id: "add-custom-stdio",
217
+ label: "Add a local server (stdio)...",
218
+ description: "Runs a command on your machine, e.g. an npx-installed MCP server.",
219
+ },
220
+ {
221
+ id: "add-custom-url",
222
+ label: "Add a remote server (URL)...",
223
+ description: "Connects to an MCP server over HTTP.",
224
+ },
225
+ {
226
+ id: "open-advanced",
227
+ label: "Advanced options...",
228
+ description: "Import from other tools, scaffold an empty config, or check how config precedence works.",
229
+ },
230
+ ];
231
+ }
232
+ getInstalledTabActions() {
233
+ const servers = this.installedServers;
234
+ if (servers.length === 0) {
235
+ return [
236
+ {
237
+ id: "view-installed-server",
238
+ label: "No servers configured yet.",
239
+ description: "Add one from the Presets or Custom tab.",
240
+ },
241
+ ];
242
+ }
243
+ return servers.map((server) => ({
244
+ id: "view-installed-server",
245
+ label: server.name,
246
+ description: server.disabled
247
+ ? "Disabled in the effective config."
248
+ : server.authCapable && !server.signedIn
249
+ ? "Active, but not signed in yet."
250
+ : "Active in the effective config.",
251
+ installed: server,
252
+ }));
253
+ }
254
+ /**
255
+ * Single-glyph prefix instead of a tag chip: ✓ for a preset you already have
256
+ * (dim if you've disabled it), ✓/⊘ for an Installed-tab row's active state.
257
+ * No mark at all for a preset you don't have yet, to keep the list scannable.
258
+ */
259
+ getRowMark(action) {
260
+ if (action.preset) {
261
+ const installed = this.installedServers.find((server) => server.name === action.preset.id);
262
+ return installed ? fg(installed.disabled ? this.t.muted : this.t.success, "✓ ") : " ";
263
+ }
264
+ if (action.installed) {
265
+ if (action.installed.disabled)
266
+ return fg(this.t.muted, "⊘ ");
267
+ if (action.installed.authCapable && !action.installed.signedIn)
268
+ return fg(this.t.warning, "! ");
269
+ return fg(this.t.success, "✓ ");
270
+ }
271
+ return "";
272
+ }
273
+ getActions() {
274
+ switch (this.activeTab) {
275
+ case "presets":
276
+ return this.getPresetsTabActions();
277
+ case "custom":
278
+ return this.getCustomTabActions();
279
+ case "installed":
280
+ return this.getInstalledTabActions();
281
+ }
282
+ }
283
+ cycleTab(direction) {
284
+ const order = SETUP_TABS.map((tab) => tab.id);
285
+ const index = order.indexOf(this.activeTab);
286
+ this.activeTab = order[(index + direction + order.length) % order.length];
287
+ this.actionCursor = 0;
288
+ this.notice = null;
289
+ this.tui.requestRender();
290
+ }
142
291
  getDetectedPaths() {
143
292
  const paths = [
144
293
  ...this.discovery.sources.filter((source) => source.exists).map((source) => source.path),
@@ -146,10 +295,6 @@ export class McpSetupPanel {
146
295
  ];
147
296
  return [...new Set(paths)];
148
297
  }
149
- getSelectedAction() {
150
- const actions = this.getActions();
151
- return actions[this.actionCursor];
152
- }
153
298
  handleInput(data) {
154
299
  this.resetInactivityTimeout();
155
300
  if (!this.busy)
@@ -161,6 +306,13 @@ export class McpSetupPanel {
161
306
  }
162
307
  if (matchesKey(data, "escape")) {
163
308
  if (this.screen === "imports" || this.screen === "paths") {
309
+ // Both are only reachable from the Advanced sub-screen now — back out one level, not all the way to the tabs.
310
+ this.screen = "advanced";
311
+ this.tui.requestRender();
312
+ return;
313
+ }
314
+ if (this.screen === "confirm-preset" || this.screen === "advanced") {
315
+ this.pendingAction = undefined;
164
316
  this.screen = this.discovery.hasAnyConfig ? "setup" : "empty";
165
317
  this.tui.requestRender();
166
318
  return;
@@ -179,6 +331,22 @@ export class McpSetupPanel {
179
331
  this.handlePathsInput(data);
180
332
  return;
181
333
  }
334
+ if (this.screen === "confirm-preset") {
335
+ this.handleConfirmPresetInput(data);
336
+ return;
337
+ }
338
+ if (this.screen === "advanced") {
339
+ this.handleAdvancedInput(data);
340
+ return;
341
+ }
342
+ if (matchesKey(data, "left")) {
343
+ this.cycleTab(-1);
344
+ return;
345
+ }
346
+ if (matchesKey(data, "right")) {
347
+ this.cycleTab(1);
348
+ return;
349
+ }
182
350
  const actions = this.getActions();
183
351
  if (this.keys.selectUp(data)) {
184
352
  this.actionCursor = Math.max(0, this.actionCursor - 1);
@@ -190,6 +358,14 @@ export class McpSetupPanel {
190
358
  this.tui.requestRender();
191
359
  return;
192
360
  }
361
+ if (data === "o" || data === "O") {
362
+ this.openSelectedDocs();
363
+ return;
364
+ }
365
+ if ((data === "a" || data === "A") && this.activeTab === "installed") {
366
+ this.authenticateSelected();
367
+ return;
368
+ }
193
369
  if (this.keys.selectConfirm(data)) {
194
370
  const selected = this.getSelectedAction();
195
371
  if (selected)
@@ -247,13 +423,118 @@ export class McpSetupPanel {
247
423
  });
248
424
  }
249
425
  }
250
- async runAction(action) {
251
- if (action.id === "run-setup") {
252
- this.screen = "setup";
253
- this.actionCursor = 0;
426
+ handleAdvancedInput(data) {
427
+ const actions = this.getAdvancedActions();
428
+ if (this.keys.selectUp(data)) {
429
+ this.advancedCursor = Math.max(0, this.advancedCursor - 1);
430
+ this.tui.requestRender();
431
+ return;
432
+ }
433
+ if (this.keys.selectDown(data)) {
434
+ this.advancedCursor = Math.min(actions.length - 1, this.advancedCursor + 1);
254
435
  this.tui.requestRender();
255
436
  return;
256
437
  }
438
+ if (this.keys.selectConfirm(data)) {
439
+ const selected = actions[this.advancedCursor];
440
+ if (selected)
441
+ void this.runAction(selected);
442
+ }
443
+ }
444
+ handleConfirmPresetInput(data) {
445
+ if (data === "o" || data === "O") {
446
+ this.openSelectedDocs();
447
+ return;
448
+ }
449
+ if (this.keys.selectConfirm(data)) {
450
+ void this.confirmPendingAction();
451
+ }
452
+ }
453
+ /** Getter used by openSelectedDocs while the confirm screen is up (no action-list cursor there). */
454
+ getSelectedAction() {
455
+ if (this.screen === "confirm-preset")
456
+ return this.pendingAction;
457
+ const actions = this.getActions();
458
+ return actions[this.actionCursor];
459
+ }
460
+ /** Add or replace an entry so the Installed tab and Presets "active" tag update immediately, without closing the panel. */
461
+ upsertInstalledServer(entry) {
462
+ const index = this.installedServers.findIndex((server) => server.name === entry.name);
463
+ if (index === -1)
464
+ this.installedServers.push(entry);
465
+ else
466
+ this.installedServers[index] = entry;
467
+ }
468
+ async confirmPendingAction() {
469
+ const action = this.pendingAction;
470
+ if (!action)
471
+ return;
472
+ if (action.id === "add-repoprompt") {
473
+ await this.runBusy(async () => {
474
+ const result = await this.callbacks.addRepoPrompt();
475
+ this.callbacks.markSetupCompleted();
476
+ this.upsertInstalledServer({
477
+ name: result.serverName,
478
+ disabled: false,
479
+ kind: "stdio",
480
+ authCapable: false,
481
+ signedIn: false,
482
+ });
483
+ this.notice = {
484
+ text: `Added ${result.serverName} to ${result.path}. Nexus will reload after this panel closes.`,
485
+ tone: "success",
486
+ };
487
+ });
488
+ }
489
+ else if (action.id === "add-known-server" && action.preset) {
490
+ const preset = action.preset;
491
+ const authCapable = preset.entry.auth === "oauth";
492
+ await this.runBusy(async () => {
493
+ const result = await this.callbacks.addKnownServer(preset);
494
+ this.callbacks.markSetupCompleted();
495
+ const kind = preset.entry.command ? "stdio" : "http";
496
+ this.upsertInstalledServer({
497
+ name: result.serverName,
498
+ disabled: false,
499
+ kind,
500
+ authCapable,
501
+ signedIn: false,
502
+ });
503
+ if (!authCapable) {
504
+ this.notice = {
505
+ text: `Added ${result.serverName} to ${result.path}. Nexus will reload after this panel closes.`,
506
+ tone: "success",
507
+ };
508
+ return;
509
+ }
510
+ // Sign in right now, in the same flow — this is the whole point of a preset:
511
+ // don't make the user hunt down a separate command after the fact.
512
+ this.notice = { text: `Added ${result.serverName}. Opening your browser to sign in…`, tone: "muted" };
513
+ this.tui.requestRender();
514
+ const authResult = await this.callbacks.authenticateServer(result.serverName, preset.entry);
515
+ this.upsertInstalledServer({
516
+ name: result.serverName,
517
+ disabled: false,
518
+ kind,
519
+ authCapable,
520
+ signedIn: authResult.ok,
521
+ });
522
+ this.notice = authResult.ok
523
+ ? {
524
+ text: `Added and signed in to ${result.serverName} (${result.path}). Nexus will reload after this panel closes.`,
525
+ tone: "success",
526
+ }
527
+ : {
528
+ text: `Added ${result.serverName} to ${result.path}, but sign-in didn't finish: ${authResult.message} Retry anytime from the Installed tab (press "a").`,
529
+ tone: "warning",
530
+ };
531
+ });
532
+ }
533
+ this.pendingAction = undefined;
534
+ this.screen = this.discovery.hasAnyConfig ? "setup" : "empty";
535
+ this.tui.requestRender();
536
+ }
537
+ async runAction(action) {
257
538
  if (action.id === "adopt-imports") {
258
539
  this.screen = "imports";
259
540
  this.importCursor = 0;
@@ -277,34 +558,38 @@ export class McpSetupPanel {
277
558
  });
278
559
  return;
279
560
  }
280
- if (action.id === "add-repoprompt") {
281
- await this.runBusy(async () => {
282
- const result = await this.callbacks.addRepoPrompt();
283
- this.callbacks.markSetupCompleted();
284
- this.notice = {
285
- text: `Added ${result.serverName} to ${result.path}. Nexus will reload after this panel closes.`,
286
- tone: "success",
287
- };
288
- });
561
+ if (action.id === "add-repoprompt" || (action.id === "add-known-server" && action.preset)) {
562
+ this.pendingAction = action;
563
+ this.screen = "confirm-preset";
564
+ this.tui.requestRender();
289
565
  return;
290
566
  }
291
- if (action.id === "add-known-server" && action.preset) {
292
- const preset = action.preset;
567
+ if (action.id === "add-custom-stdio" || action.id === "add-custom-url") {
568
+ const template = action.id === "add-custom-stdio" ? "/mcp add my-server -- " : "/mcp add my-server --url ";
569
+ this.callbacks.seedEditor(template);
570
+ this.cleanup();
571
+ this.done();
572
+ return;
573
+ }
574
+ if (action.id === "open-advanced") {
575
+ this.screen = "advanced";
576
+ this.advancedCursor = 0;
577
+ this.tui.requestRender();
578
+ return;
579
+ }
580
+ if (action.id === "view-installed-server" && action.installed) {
581
+ const server = action.installed;
582
+ const nextDisabled = !server.disabled;
293
583
  await this.runBusy(async () => {
294
- const result = await this.callbacks.addKnownServer(preset);
295
- this.callbacks.markSetupCompleted();
584
+ const result = await this.callbacks.toggleServerDisabled(server.name, nextDisabled);
585
+ this.upsertInstalledServer({ ...server, disabled: nextDisabled });
296
586
  this.notice = {
297
- text: `Added ${result.serverName} to ${result.path}. Nexus will reload after this panel closes.`,
587
+ text: `${nextDisabled ? "Disabled" : "Enabled"} "${server.name}" in ${result.path} — run /reload to apply.`,
298
588
  tone: "success",
299
589
  };
300
590
  });
301
591
  return;
302
592
  }
303
- if (action.id === "close") {
304
- this.cleanup();
305
- this.done();
306
- return;
307
- }
308
593
  this.notice = {
309
594
  text: "Review the details below. Press Enter on an action with a side effect to apply it.",
310
595
  tone: "muted",
@@ -330,8 +615,39 @@ export class McpSetupPanel {
330
615
  tone: "success",
331
616
  }
332
617
  : { text: `No changes needed in ${result.path}.`, tone: "muted" };
333
- this.screen = this.discovery.hasAnyConfig ? "setup" : "empty";
334
- this.actionCursor = 0;
618
+ this.screen = "advanced";
619
+ this.advancedCursor = 0;
620
+ });
621
+ }
622
+ openSelectedDocs() {
623
+ const selected = this.getSelectedAction();
624
+ const docsUrl = selected?.preset?.docsUrl;
625
+ if (!docsUrl) {
626
+ this.notice = { text: "No docs link for this item.", tone: "muted" };
627
+ this.tui.requestRender();
628
+ return;
629
+ }
630
+ void this.runBusy(async () => {
631
+ await this.callbacks.openPath(docsUrl);
632
+ this.notice = { text: `Opened ${docsUrl}`, tone: "success" };
633
+ });
634
+ }
635
+ authenticateSelected() {
636
+ const selected = this.getSelectedAction();
637
+ const server = selected?.installed;
638
+ if (!server)
639
+ return;
640
+ if (!server.authCapable) {
641
+ this.notice = { text: `"${server.name}" isn't configured for OAuth sign-in.`, tone: "muted" };
642
+ this.tui.requestRender();
643
+ return;
644
+ }
645
+ void this.runBusy(async () => {
646
+ this.notice = { text: `Opening your browser to sign in to "${server.name}"…`, tone: "muted" };
647
+ this.tui.requestRender();
648
+ const result = await this.callbacks.authenticateServer(server.name);
649
+ this.upsertInstalledServer({ ...server, signedIn: result.ok });
650
+ this.notice = { text: result.message, tone: result.ok ? "success" : "warning" };
335
651
  });
336
652
  }
337
653
  async runBusy(fn) {
@@ -385,17 +701,56 @@ export class McpSetupPanel {
385
701
  else if (this.screen === "paths") {
386
702
  lines.push(...this.renderPaths(innerW));
387
703
  }
704
+ else if (this.screen === "confirm-preset") {
705
+ lines.push(...this.renderConfirmPreset(innerW));
706
+ }
707
+ else if (this.screen === "advanced") {
708
+ lines.push(...this.renderAdvanced(innerW));
709
+ }
388
710
  else {
389
711
  lines.push(...this.renderActions(innerW));
390
712
  }
391
713
  lines.push(`└${border}┘`);
392
- return lines;
714
+ return this.clampHeight(lines, innerW, border);
715
+ }
716
+ setMaxHeight(lines) {
717
+ this.maxHeight = lines;
718
+ }
719
+ /**
720
+ * The overlay host may cap total rows and, absent this hook, would slice
721
+ * blindly from the bottom — cutting off the closing border and leaving the
722
+ * box looking broken. Truncate the content instead, keeping the header and
723
+ * action list (the interactive part) and dropping preview/diff lines first.
724
+ */
725
+ clampHeight(lines, innerW, _border) {
726
+ if (this.maxHeight === undefined || lines.length <= this.maxHeight)
727
+ return lines;
728
+ const budget = Math.max(5, this.maxHeight);
729
+ // The last line is the closing border, the one before it is the hint/controls
730
+ // line — both must always stay visible, or the panel loses its own instructions.
731
+ const closing = lines[lines.length - 1];
732
+ const footer = lines[lines.length - 2];
733
+ const head = lines.slice(0, Math.max(1, budget - 3));
734
+ const hidden = lines.length - head.length - 2;
735
+ if (hidden <= 0)
736
+ return lines;
737
+ return [
738
+ ...head,
739
+ this.padLine(fg(this.t.muted, `… ${hidden} more line${hidden === 1 ? "" : "s"} hidden to fit the window`), innerW),
740
+ footer,
741
+ closing,
742
+ ];
743
+ }
744
+ renderTabBar() {
745
+ return SETUP_TABS.map((tab) => tab.id === this.activeTab ? fg(this.t.selected, `[ ${tab.label} ]`) : fg(this.t.muted, ` ${tab.label} `)).join(" ");
393
746
  }
394
747
  renderActions(innerW) {
395
748
  const lines = [];
396
749
  const actions = this.getActions();
397
750
  const compact = innerW < COMPACT_WIDTH;
398
751
  const { start, end } = compact ? this.visibleActionRange(actions.length) : { start: 0, end: actions.length };
752
+ lines.push(this.padLine(this.renderTabBar(), innerW));
753
+ lines.push(this.padLine("", innerW));
399
754
  if (start > 0) {
400
755
  lines.push(this.padLine(fg(this.t.muted, `… ${start} more above`), innerW));
401
756
  }
@@ -403,23 +758,46 @@ export class McpSetupPanel {
403
758
  const action = actions[index];
404
759
  if (!action)
405
760
  continue;
406
- if (action.id === "add-known-server" && (index === start || actions[index - 1]?.id !== "add-known-server")) {
407
- lines.push(this.padLine(fg(this.t.title, "Add a known server"), innerW));
408
- }
409
761
  const selected = index === this.actionCursor;
410
762
  const cursor = selected ? fg(this.t.selected, "›") : " ";
411
- lines.push(this.padLine(`${cursor} ${truncateToWidth(action.label, this.contentWidth(innerW) - 2)}`, innerW));
763
+ const mark = this.getRowMark(action);
764
+ const label = truncateToWidth(action.label, Math.max(1, this.contentWidth(innerW) - 2 - visibleWidth(mark)));
765
+ const installedColor = action.installed
766
+ ? action.installed.disabled
767
+ ? this.t.muted
768
+ : action.installed.authCapable && !action.installed.signedIn
769
+ ? this.t.warning
770
+ : this.t.success
771
+ : undefined;
772
+ const labelRendered = action.preset
773
+ ? fg(presetAccent(action.preset), label)
774
+ : installedColor
775
+ ? fg(installedColor, label)
776
+ : label;
777
+ lines.push(this.padLine(`${cursor} ${mark}${labelRendered}`, innerW));
412
778
  }
413
779
  if (end < actions.length) {
414
780
  lines.push(this.padLine(fg(this.t.muted, `… ${actions.length - end} more below`), innerW));
415
781
  }
416
782
  lines.push(this.padLine("", innerW));
417
- const preview = this.getActionPreview(this.getSelectedAction(), this.previewWidth(innerW));
418
- for (const line of preview) {
419
- lines.push(this.padLine(line, innerW));
783
+ // Presets stay a clean, scannable list — no inline summary/diff while browsing.
784
+ // The write preview only shows on the confirm screen, after pressing Enter.
785
+ if (this.activeTab !== "presets") {
786
+ const preview = this.getActionPreview(this.getSelectedAction(), this.previewWidth(innerW));
787
+ for (const line of preview) {
788
+ lines.push(this.padLine(line, innerW));
789
+ }
790
+ lines.push(this.padLine("", innerW));
420
791
  }
421
- lines.push(this.padLine("", innerW));
422
- const hint = compact ? "Enter select · Esc back" : "Enter selects, Esc goes back, Ctrl+C closes.";
792
+ const docsHint = this.activeTab === "presets" ? " · o docs" : "";
793
+ const confirmHint = this.activeTab === "presets" ? " · enter to review & confirm" : "";
794
+ const checkHint = this.activeTab === "presets" && this.installedServers.length > 0 ? " · ✓ already added" : "";
795
+ const authHint = this.activeTab === "installed" && this.installedServers.some((server) => server.authCapable)
796
+ ? " · a sign in"
797
+ : "";
798
+ const hint = compact
799
+ ? `Enter select · ←/→ tabs${docsHint} · Esc back`
800
+ : `Enter selects${confirmHint}, ←/→ switch tabs${docsHint}${checkHint}${authHint}, Esc goes back, Ctrl+C closes.`;
423
801
  lines.push(this.padLine(fg(this.t.muted, hint), innerW));
424
802
  return lines;
425
803
  }
@@ -458,6 +836,48 @@ export class McpSetupPanel {
458
836
  }
459
837
  return lines;
460
838
  }
839
+ renderAdvanced(innerW) {
840
+ const lines = [];
841
+ const actions = this.getAdvancedActions();
842
+ lines.push(this.padLine(`${fg(this.t.muted, "Custom")} > ${fg(this.t.title, "Advanced options")}`, innerW));
843
+ lines.push(this.padLine("", innerW));
844
+ if (actions.length === 0) {
845
+ lines.push(this.padLine(fg(this.t.muted, "Nothing here right now — nice, config is already tidy."), innerW));
846
+ }
847
+ for (let index = 0; index < actions.length; index++) {
848
+ const action = actions[index];
849
+ if (!action)
850
+ continue;
851
+ const cursor = index === this.advancedCursor ? fg(this.t.selected, "›") : " ";
852
+ const label = truncateToWidth(action.label, Math.max(1, this.contentWidth(innerW) - 2));
853
+ lines.push(this.padLine(`${cursor} ${label}`, innerW));
854
+ }
855
+ lines.push(this.padLine("", innerW));
856
+ // This is the one screen where showing the full explanation on highlight is the point —
857
+ // you only ever get here by deliberately asking for "Advanced options".
858
+ const preview = this.getActionPreview(actions[this.advancedCursor], this.previewWidth(innerW));
859
+ for (const line of preview) {
860
+ lines.push(this.padLine(line, innerW));
861
+ }
862
+ lines.push(this.padLine("", innerW));
863
+ lines.push(this.padLine(fg(this.t.muted, "Enter selects, Esc back to Custom, Ctrl+C closes."), innerW));
864
+ return lines;
865
+ }
866
+ renderConfirmPreset(innerW) {
867
+ const lines = [];
868
+ const action = this.pendingAction;
869
+ const name = action?.preset?.name ?? (action?.id === "add-repoprompt" ? "RepoPrompt" : "this server");
870
+ lines.push(this.padLine(fg(this.t.title, `Confirm: add ${name}`), innerW));
871
+ lines.push(this.padLine("", innerW));
872
+ const preview = this.getActionPreview(action, this.previewWidth(innerW));
873
+ for (const line of preview) {
874
+ lines.push(this.padLine(line, innerW));
875
+ }
876
+ lines.push(this.padLine("", innerW));
877
+ const docsHint = action?.preset?.docsUrl ? " · o docs" : "";
878
+ lines.push(this.padLine(fg(this.t.muted, `Enter writes this file${docsHint} · Esc goes back`), innerW));
879
+ return lines;
880
+ }
461
881
  discoverySummaryLine() {
462
882
  if (!this.discovery.hasAnyConfig) {
463
883
  return fg(this.t.warning, this.options.onboardingState.setupCompleted
@@ -501,10 +921,6 @@ export class McpSetupPanel {
501
921
  }
502
922
  getActionPreview(action, previewW = DESKTOP_PREVIEW_WIDTH) {
503
923
  switch (action?.id) {
504
- case "run-setup":
505
- return this.formatPreview([
506
- "Run setup to adopt host-specific imports, inspect detected paths, and scaffold a minimal `.nexus/mcp.json` if needed.",
507
- ], previewW);
508
924
  case "adopt-imports":
509
925
  return this.formatWritePreview("Compatibility import write preview", this.callbacks.previewImports(this.discovery.imports
510
926
  .filter((entry) => this.selectedImports.has(entry.kind))
@@ -513,31 +929,50 @@ export class McpSetupPanel {
513
929
  "Selected imports are written to the Nexus global MCP configuration shown above.",
514
930
  ], previewW);
515
931
  case "view-example":
932
+ return [
933
+ ...this.formatPreview(["Example Nexus `.nexus/mcp.json`:"], previewW),
934
+ // Truncate, don't word-wrap: wrapText would collapse JSON's indentation.
935
+ ...[
936
+ "{",
937
+ ' "mcpServers": {',
938
+ ' "chrome-devtools": {',
939
+ ' "command": "npx",',
940
+ ' "args": ["-y", "chrome-devtools-mcp@1.6.0"]',
941
+ " }",
942
+ " }",
943
+ "}",
944
+ ].map((line) => colorizeJsonLine(truncateToWidth(line, previewW, "…"))),
945
+ ...this.formatPreview([
946
+ "",
947
+ "Use Scaffold project `.nexus/mcp.json` when you want a safe empty shell instead of a live example server.",
948
+ ], previewW),
949
+ ];
950
+ case "show-precedence": {
951
+ const hostNote = this.discovery.hostConfigDiscovery === "off"
952
+ ? "Configs from other tools (Cursor, Claude Code, VS Code, etc.) are NOT being read right now — Nexus only looks at its own two files above. You can turn that on if you'd rather not redefine servers you already set up elsewhere."
953
+ : this.discovery.hostConfigDiscovery === "prompt"
954
+ ? "Nexus will ask before reading configs from other tools (Cursor, Claude Code, VS Code, etc.). If you say yes, they're used as a fallback — only for a server name that isn't already defined in either file above."
955
+ : "Nexus is also reading configs from other tools (Cursor, Claude Code, VS Code, etc.) as a fallback — they only apply to a server name that isn't already defined in either file above.";
956
+ const conflictNote = this.discovery.conflicts.length === 0
957
+ ? "Right now no server name is defined in more than one place, so there's nothing actually being overridden."
958
+ : `Right now ${this.discovery.conflicts.length} server name${this.discovery.conflicts.length === 1 ? " is" : "s are"} defined in more than one place — whichever file is read last wins, and the loser is silently ignored:`;
516
959
  return this.formatPreview([
517
- "Example Nexus `.nexus/mcp.json`:",
518
- "{",
519
- ' "mcpServers": {',
520
- ' "chrome-devtools": {',
521
- ' "command": "npx",',
522
- ' "args": ["-y", "chrome-devtools-mcp@1.6.0"]',
523
- " }",
524
- " }",
525
- "}",
960
+ "Nexus can load MCP servers from a few different places, so when the same server name shows up twice, something has to decide which definition actually gets used.",
526
961
  "",
527
- "Use Scaffold project `.nexus/mcp.json` when you want a safe empty shell instead of a live example server.",
528
- ], previewW);
529
- case "show-precedence":
530
- return this.formatPreview([
531
- "Read order (later entries win):",
532
- "0. detected host configs (opt-in lowest-precedence fallback)",
533
- "1. ~/.config/nexus/mcp.json",
534
- `2. ${getConfigDirName()}/mcp.json`,
535
- `Host discovery: ${this.discovery.hostConfigDiscovery}. Conflicts reported: ${this.discovery.conflicts.length}.`,
962
+ "It reads, in this order — each one can override a server name defined before it:",
963
+ " 1. Global — ~/.config/nexus/mcp.json (applies to every project on this machine)",
964
+ ` 2. Project — ${getConfigDirName()}/mcp.json (just this project; wins over global for the same name)`,
965
+ "",
966
+ hostNote,
967
+ "",
968
+ "Everything you add from this panel (a preset, a custom server) gets written to the project file, so it's scoped to this project and takes priority over anything global.",
969
+ "",
970
+ conflictNote,
536
971
  ...this.discovery.conflicts
537
972
  .slice(0, 8)
538
- .map((conflict) => `${conflict.serverName}: ${conflict.sources.map((source) => source.path).join(" -> ")} (winner: ${conflict.winner.path})`),
539
- "Nexus writes global MCP state to ~/.config/nexus/mcp.json and project state to .nexus/mcp.json.",
973
+ .map((conflict) => ` ${conflict.serverName}: ${conflict.sources.map((source) => source.path).join(" then ")} — "${conflict.winner.path}" wins`),
540
974
  ], previewW);
975
+ }
541
976
  case "open-paths":
542
977
  return this.formatPreview(this.getDetectedPaths().length > 0
543
978
  ? ["Detected paths:", ...this.getDetectedPaths()]
@@ -558,13 +993,60 @@ export class McpSetupPanel {
558
993
  const preset = action.preset;
559
994
  if (!preset)
560
995
  return this.formatPreview(["Known server preset is unavailable."], previewW);
561
- return this.formatWritePreview(`${preset.name} write preview`, this.callbacks.previewKnownServer(preset), [preset.summary], previewW);
996
+ return this.formatWritePreview(`${preset.name} write preview`, this.callbacks.previewKnownServer(preset), [preset.summary, ...(preset.docsUrl ? [`Docs: ${preset.docsUrl}`, 'Press "o" to open docs.'] : [])], previewW);
562
997
  }
563
998
  case "scaffold-project":
564
999
  return this.formatWritePreview("Starter project `.nexus/mcp.json` write preview", this.callbacks.previewStarterProject(), [
565
- "This writes a minimal `.nexus/mcp.json` in the current project using the Nexus MCP layout.",
566
- "It intentionally avoids adding a fake placeholder server that would fail on first reload.",
1000
+ `This creates ${getConfigDirName()}/mcp.json right here in this project — the file everything you add from the Presets or Custom tab actually gets written into. If you already have one, this action doesn't show up (check the Installed tab instead).`,
1001
+ "",
1002
+ `It starts out empty on purpose (just an empty "mcpServers" list). A fake placeholder server would fail the moment Nexus tried to connect to it on your very next reload, so this just gives you a valid, empty file to add real servers into afterward.`,
567
1003
  ], previewW);
1004
+ case "add-custom-stdio":
1005
+ return [
1006
+ ...this.formatPreview(["Runs a command on your machine over stdio — e.g. an npx-installed MCP server."], previewW),
1007
+ "",
1008
+ colorizeCommandLine(truncateToWidth("/mcp add <name> -- <command> [args...]", previewW, "…")),
1009
+ "",
1010
+ fg(this.t.muted, "Example (real, runnable):"),
1011
+ colorizeCommandLine(truncateToWidth("/mcp add chrome-devtools -- npx -y chrome-devtools-mcp@1.6.0", previewW, "…")),
1012
+ "",
1013
+ ...this.formatPreview([
1014
+ 'Enter fills in "/mcp add my-server -- " into the chat input below — finish typing there.',
1015
+ "Run `/mcp add` with no arguments (from chat) for the full flag list.",
1016
+ ], previewW),
1017
+ ];
1018
+ case "add-custom-url":
1019
+ return [
1020
+ ...this.formatPreview(["Connects to a remote MCP server over HTTP."], previewW),
1021
+ "",
1022
+ colorizeCommandLine(truncateToWidth("/mcp add <name> --url <url> [--header K=V] [--auth oauth|bearer] [--bearer-env VAR]", previewW, "…")),
1023
+ "",
1024
+ fg(this.t.muted, "Example (real, runnable):"),
1025
+ colorizeCommandLine(truncateToWidth("/mcp add context7 --url https://mcp.context7.com/mcp", previewW, "…")),
1026
+ "",
1027
+ ...this.formatPreview([
1028
+ 'Enter fills in "/mcp add my-server --url " into the chat input below — finish typing there.',
1029
+ "Run `/mcp add` with no arguments (from chat) for the full flag list.",
1030
+ ], previewW),
1031
+ ];
1032
+ case "view-installed-server": {
1033
+ const server = action.installed;
1034
+ if (!server)
1035
+ return this.formatPreview(["Add a server from the Presets or Custom tab to see it here."], previewW);
1036
+ return this.formatPreview([
1037
+ `${server.name} — ${server.kind === "stdio" ? "local (stdio)" : "remote (HTTP)"}, currently ${server.disabled ? "disabled" : "active"}${server.authCapable ? (server.signedIn ? ", signed in" : ", NOT signed in yet") : ""}.`,
1038
+ "",
1039
+ `Press Enter to ${server.disabled ? "enable" : "disable"} it here. Takes effect after /reload.`,
1040
+ ...(server.authCapable
1041
+ ? [
1042
+ "",
1043
+ server.signedIn
1044
+ ? 'Press "a" to sign in again (e.g. to switch accounts). This re-opens your browser.'
1045
+ : 'Press "a" to sign in now — opens your browser for this server\'s OAuth login. Tokens refresh automatically after that; you should not need to repeat this unless access is revoked.',
1046
+ ]
1047
+ : []),
1048
+ ], previewW);
1049
+ }
568
1050
  default:
569
1051
  return this.formatPreview(["Close the setup flow."], previewW);
570
1052
  }
@@ -589,10 +1071,14 @@ export class McpSetupPanel {
589
1071
  : "New file will be created. Showing exact content diff.", width));
590
1072
  lines.push("");
591
1073
  const diffLines = preview.diffText.split("\n");
592
- const maxLines = 18;
1074
+ const maxLines = 12;
593
1075
  const shown = diffLines.slice(0, maxLines);
594
1076
  for (const line of shown) {
595
- lines.push(...wrapText(line, width));
1077
+ // Truncate, don't word-wrap: wrapText collapses whitespace and would
1078
+ // destroy JSON's nested indentation.
1079
+ const color = diffLineColor(line);
1080
+ const truncated = truncateToWidth(line, width, "…");
1081
+ lines.push(color ? fg(color, truncated) : colorizeJsonLine(truncated));
596
1082
  }
597
1083
  if (diffLines.length > maxLines) {
598
1084
  lines.push(...wrapText(`… ${diffLines.length - maxLines} more diff line${diffLines.length - maxLines === 1 ? "" : "s"}`, width));