mindweave 2.4.4 → 2.4.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.
- package/dist/cli/App.js +158 -99
- package/dist/cli/App.js.map +1 -1
- package/dist/cli/attachments.js +14 -1
- package/dist/cli/attachments.js.map +1 -1
- package/dist/cli/components/KeyManager.js +3 -42
- package/dist/cli/components/KeyManager.js.map +1 -1
- package/dist/cli/components/McpMinitabs.js +689 -0
- package/dist/cli/components/McpMinitabs.js.map +1 -0
- package/dist/cli/components/minitabs.js +71 -0
- package/dist/cli/components/minitabs.js.map +1 -0
- package/dist/dynamo/engine.js +29 -7
- package/dist/dynamo/engine.js.map +1 -1
- package/dist/dynamo/prompt.js +2 -0
- package/dist/dynamo/prompt.js.map +1 -1
- package/dist/mcp/config.js +57 -3
- package/dist/mcp/config.js.map +1 -1
- package/dist/mcp/configWrite.js +40 -2
- package/dist/mcp/configWrite.js.map +1 -1
- package/dist/mcp/connection.js +51 -1
- package/dist/mcp/connection.js.map +1 -1
- package/dist/mcp/manager.js +65 -0
- package/dist/mcp/manager.js.map +1 -1
- package/dist/mcp/oauth/callback.js +171 -0
- package/dist/mcp/oauth/callback.js.map +1 -0
- package/dist/mcp/oauth/flow.js +415 -0
- package/dist/mcp/oauth/flow.js.map +1 -0
- package/dist/mcp/oauth/index.js +243 -0
- package/dist/mcp/oauth/index.js.map +1 -0
- package/dist/mcp/oauth/metadata.js +192 -0
- package/dist/mcp/oauth/metadata.js.map +1 -0
- package/dist/mcp/oauth/pkce.js +56 -0
- package/dist/mcp/oauth/pkce.js.map +1 -0
- package/dist/mcp/oauth/tokenStore.js +103 -0
- package/dist/mcp/oauth/tokenStore.js.map +1 -0
- package/dist/mcp/transport/http.js +33 -5
- package/dist/mcp/transport/http.js.map +1 -1
- package/dist/memory/session.js +4 -0
- package/dist/memory/session.js.map +1 -1
- package/dist/tools/backgroundShells.js +42 -4
- package/dist/tools/backgroundShells.js.map +1 -1
- package/dist/tools/mcpAdd.js +7 -0
- package/dist/tools/mcpAdd.js.map +1 -1
- package/dist/tools/subagent.js +41 -3
- package/dist/tools/subagent.js.map +1 -1
- package/dist/tools/todo.js +35 -0
- package/dist/tools/todo.js.map +1 -1
- package/dist/tools/verifyReport.js +144 -0
- package/dist/tools/verifyReport.js.map +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* McpMinitabs.tsx — what `/mcp` shows.
|
|
4
|
+
*
|
|
5
|
+
* Two minitabs, the same shape `/key` proved: a flat SERVERS list ("+ Add a server" first,
|
|
6
|
+
* then every server you have), and picking one drops into its own MANAGE screen with
|
|
7
|
+
* everything you can do to it — Enable/Disable, Reconnect, Review blocked tools, Edit,
|
|
8
|
+
* Remove — each one applied the instant you press Enter. Nothing here stages a change for
|
|
9
|
+
* a later "save": there is nothing left to save once an action has already run.
|
|
10
|
+
*
|
|
11
|
+
* Add and Edit share one small step-through form (type, name, command/URL, env, where to
|
|
12
|
+
* save it, then a review) rather than two — editing a server IS adding one, with the name
|
|
13
|
+
* fixed and the fields pre-filled, so building two forms would only be two places for the
|
|
14
|
+
* same bug. Both end at `onSubmit`, which runs the one validator (`parseAddSpec`) every
|
|
15
|
+
* other way of adding a server already goes through — the typed `/mcp add` and the
|
|
16
|
+
* `add_mcp_server` tool — so this cannot accept something they would reject.
|
|
17
|
+
*
|
|
18
|
+
* ANOTHER STEP-THROUGH FORM'S KEYS, and why they differ from the list/manage screens: a
|
|
19
|
+
* text field already owns Left/Right (moving the cursor inside what you're typing) and
|
|
20
|
+
* Enter (submitting that field), so this form cannot ALSO bind those to "change step"
|
|
21
|
+
* without breaking the one thing you came to do — fix a typo in the middle of a command
|
|
22
|
+
* line. PageUp goes back a step instead, because nothing here ever needs it for anything
|
|
23
|
+
* else, and Escape always cancels the whole form outright rather than stepping back one —
|
|
24
|
+
* one job per key, everywhere in this form, which is what `/key`'s Esc-doubles-as-back
|
|
25
|
+
* cannot promise once a key is also expected to move a text cursor.
|
|
26
|
+
*/
|
|
27
|
+
import { Box, Text, useInput } from "ink";
|
|
28
|
+
import { useRef, useState } from "react";
|
|
29
|
+
import { stripMouse } from "../mouse.js";
|
|
30
|
+
import { MiniTabPanel as Panel, MiniTabRow as Row, miniTabPosition as position, miniTabWindowStart as windowStart, MINITAB_WINDOW as WINDOW } from "./minitabs.js";
|
|
31
|
+
import { parseAddSpec, splitArgs } from "../../mcp/configWrite.js";
|
|
32
|
+
const ACTION_REVIEW_BLOCKED = "Review blocked tools";
|
|
33
|
+
const ACTION_SIGN_IN = "Sign in (opens your browser)";
|
|
34
|
+
const ACTION_SIGN_OUT = "Sign out";
|
|
35
|
+
const ACTION_ENABLE = "Enable";
|
|
36
|
+
const ACTION_DISABLE = "Disable";
|
|
37
|
+
const ACTION_RECONNECT = "Reconnect";
|
|
38
|
+
const ACTION_EDIT = "Edit (command, URL, env)";
|
|
39
|
+
const ACTION_REMOVE = "Remove";
|
|
40
|
+
const ACTION_BACK = "Back";
|
|
41
|
+
// "MCP servers" is the longer of the two titles that alternate on the same tab bar
|
|
42
|
+
// ("MCP servers" on Servers, "Manage" or a server's own name on Manage) — reserving its
|
|
43
|
+
// width keeps the tab bar in the same column switching either way, instead of it sliding
|
|
44
|
+
// left when the shorter title is showing.
|
|
45
|
+
const TITLE_WIDTH = "MCP servers".length;
|
|
46
|
+
const ESCAPE_BYTE = String.fromCharCode(27);
|
|
47
|
+
/**
|
|
48
|
+
* Which fields this form needs.
|
|
49
|
+
*
|
|
50
|
+
* There is no "pick a transport" step and no "server name" step, and both absences are
|
|
51
|
+
* the point. Choosing "A command" and then being asked for a NAME is a form arguing with
|
|
52
|
+
* itself: you said what you wanted to enter, so the next thing you do should be entering
|
|
53
|
+
* it. So the two transports are the two rows of the FIRST field — ↑/↓ says which one you
|
|
54
|
+
* mean and you type straight into it — and the name is worked out from what you typed
|
|
55
|
+
* (see `deriveName`), shown on the review before anything is written.
|
|
56
|
+
*/
|
|
57
|
+
export function stepsFor(type) {
|
|
58
|
+
return ["command", type === "stdio" ? "env" : "headers", "scope", "review"];
|
|
59
|
+
}
|
|
60
|
+
/** The ghost text in each transport's field — also what that row reads as when it is not
|
|
61
|
+
* the one you are typing into. */
|
|
62
|
+
export const CMD_GHOST = "A command (runs locally, e.g. npx …)";
|
|
63
|
+
export const URL_GHOST = "A URL (http/https server)";
|
|
64
|
+
/**
|
|
65
|
+
* The server's name, worked out from what was typed, because nothing should have to be
|
|
66
|
+
* typed twice. `npx -y @modelcontextprotocol/server-github` is already carrying "github"
|
|
67
|
+
* and `https://mcp.linear.app/mcp` is already carrying "linear" — asking for the name
|
|
68
|
+
* separately is asking someone to repeat themselves.
|
|
69
|
+
*
|
|
70
|
+
* Always returns SOMETHING for a non-empty command, even if it is just the first word:
|
|
71
|
+
* a blank name is refused by `parseAddSpec`, and a dead end on the review step with no
|
|
72
|
+
* name field left to fix it in would be unescapable.
|
|
73
|
+
*/
|
|
74
|
+
export function deriveName(draft) {
|
|
75
|
+
const raw = draft.commandLine.trim();
|
|
76
|
+
if (!raw)
|
|
77
|
+
return "";
|
|
78
|
+
if (draft.type === "http") {
|
|
79
|
+
let host;
|
|
80
|
+
try {
|
|
81
|
+
host = new URL(raw).hostname;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return "";
|
|
85
|
+
}
|
|
86
|
+
const labels = host.split(".").filter(Boolean);
|
|
87
|
+
// Drop the parts every host has ("www.", "api.", an "mcp." subdomain) and the public
|
|
88
|
+
// suffix, so mcp.linear.app reads as the thing it is: linear.
|
|
89
|
+
const meaningful = labels.slice(0, Math.max(1, labels.length - 1)).filter((l) => !["www", "api", "mcp"].includes(l));
|
|
90
|
+
return tidyName(meaningful[0] ?? labels[0] ?? "");
|
|
91
|
+
}
|
|
92
|
+
// stdio: the last thing that is not a flag is the package or script being run.
|
|
93
|
+
const words = splitArgs(raw).filter((w) => !w.startsWith("-"));
|
|
94
|
+
const last = words[words.length - 1] ?? "";
|
|
95
|
+
return tidyName(last.split(/[/\\]/).pop() ?? last);
|
|
96
|
+
}
|
|
97
|
+
/** Strip the parts of a package name that say "this is an MCP server" rather than WHICH
|
|
98
|
+
* one, and fall back to the original if that would leave nothing at all. */
|
|
99
|
+
function tidyName(raw) {
|
|
100
|
+
const trimmed = raw
|
|
101
|
+
.replace(/\.[a-z0-9]+$/i, "") // a file extension: server.py -> server
|
|
102
|
+
.replace(/^server[-_]/i, "")
|
|
103
|
+
.replace(/[-_]?(mcp|server)$/i, "");
|
|
104
|
+
return trimmed || raw;
|
|
105
|
+
}
|
|
106
|
+
export function blankDraft() {
|
|
107
|
+
return { type: "stdio", name: "", commandLine: "", env: "", headers: "", scope: "project" };
|
|
108
|
+
}
|
|
109
|
+
/** Parse "Name: value, Name2: value2" into the header map `parseAddSpec` expects. Each
|
|
110
|
+
* pair is split on the FIRST colon only, matching typed `--header 'Name: value'`. */
|
|
111
|
+
function parseHeaderPairs(text) {
|
|
112
|
+
return text
|
|
113
|
+
.split(",")
|
|
114
|
+
.map((s) => s.trim())
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.map((pair) => {
|
|
117
|
+
const colon = pair.indexOf(":");
|
|
118
|
+
return colon > 0 ? [pair.slice(0, colon).trim(), pair.slice(colon + 1).trim()] : null;
|
|
119
|
+
})
|
|
120
|
+
.filter((p) => p !== null);
|
|
121
|
+
}
|
|
122
|
+
/** The text a step is editing, or null when the step is a choice rather than a field. One
|
|
123
|
+
* place that mapping lives, so the key handler and the render cannot disagree about which
|
|
124
|
+
* field a keystroke belongs to. */
|
|
125
|
+
function fieldOf(draft, step) {
|
|
126
|
+
if (step === "command")
|
|
127
|
+
return draft.commandLine;
|
|
128
|
+
if (step === "env")
|
|
129
|
+
return draft.env;
|
|
130
|
+
if (step === "headers")
|
|
131
|
+
return draft.headers;
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
/** The same mapping, writing. */
|
|
135
|
+
function withField(draft, step, value) {
|
|
136
|
+
if (step === "command")
|
|
137
|
+
return { ...draft, commandLine: value };
|
|
138
|
+
if (step === "env")
|
|
139
|
+
return { ...draft, env: value };
|
|
140
|
+
if (step === "headers")
|
|
141
|
+
return { ...draft, headers: value };
|
|
142
|
+
return draft;
|
|
143
|
+
}
|
|
144
|
+
/** The form's starting point when editing a server already on disk. */
|
|
145
|
+
export function draftFromConfig(config, scope) {
|
|
146
|
+
if (config.type === "http") {
|
|
147
|
+
const headers = Object.entries(config.headers ?? {}).map(([k, v]) => `${k}: ${v}`).join(", ");
|
|
148
|
+
return { type: "http", name: config.name, commandLine: config.url, env: "", headers, scope };
|
|
149
|
+
}
|
|
150
|
+
const env = Object.entries(config.env ?? {}).map(([k, v]) => `${k}=${v}`).join(" ");
|
|
151
|
+
return { type: "stdio", name: config.name, commandLine: [config.command, ...config.args].join(" "), env, headers: "", scope };
|
|
152
|
+
}
|
|
153
|
+
/** The same argv `/mcp add` would receive, so ONE parser (`parseAddSpec`) validates every
|
|
154
|
+
* route to a server config — this form cannot accept something typed add would reject. */
|
|
155
|
+
export function argvFromDraft(draft) {
|
|
156
|
+
// Editing carries the server's existing name; adding has none, so it comes from what
|
|
157
|
+
// was typed. Either way ONE name reaches the validator, and it is the one the review
|
|
158
|
+
// step showed.
|
|
159
|
+
const name = draft.name.trim() || deriveName(draft);
|
|
160
|
+
const argv = [];
|
|
161
|
+
if (draft.scope === "global")
|
|
162
|
+
argv.push("--global");
|
|
163
|
+
if (draft.type === "http") {
|
|
164
|
+
argv.push("--http");
|
|
165
|
+
for (const [k, v] of parseHeaderPairs(draft.headers))
|
|
166
|
+
argv.push("--header", `${k}: ${v}`);
|
|
167
|
+
argv.push(name, draft.commandLine.trim());
|
|
168
|
+
return argv;
|
|
169
|
+
}
|
|
170
|
+
for (const pair of draft.env.trim().split(/\s+/).filter(Boolean))
|
|
171
|
+
argv.push("--env", pair);
|
|
172
|
+
argv.push(name, ...splitArgs(draft.commandLine));
|
|
173
|
+
return argv;
|
|
174
|
+
}
|
|
175
|
+
export function McpMinitabs({ servers, blockedCountFor, configFor, onSubmit, onSetDisabled, onRemove, onReconnect, onReviewBlocked, signedIn, onSignIn, onSignOut, onCopyLink, width, maxRows = WINDOW, onClose, active = true, }) {
|
|
176
|
+
const win = Math.max(2, maxRows - 1);
|
|
177
|
+
const [mode, setMode] = useState({ kind: "tabs", tab: 0, serverSel: 0, manageSel: 0, selected: null });
|
|
178
|
+
/** The in-flight sign-in, so Esc can abort THIS one rather than a stale closure's. */
|
|
179
|
+
const signInAbort = useRef(null);
|
|
180
|
+
/**
|
|
181
|
+
* Who is signed in, as of the last thing that happened HERE.
|
|
182
|
+
*
|
|
183
|
+
* The prop is read once when the box opens, which was fine until signing in and out
|
|
184
|
+
* moved inside the box: after either, the prop describes a world one action out of date,
|
|
185
|
+
* and the action list offered the wrong row — most visibly, no way back in after signing
|
|
186
|
+
* out. `null` means nothing has changed yet and the prop is still the truth.
|
|
187
|
+
*/
|
|
188
|
+
const [credentialed, setCredentialed] = useState(null);
|
|
189
|
+
const holdsCredential = (name) => (credentialed ?? signedIn)?.has(name) ?? false;
|
|
190
|
+
const noteCredential = (name, held) => setCredentialed((prev) => {
|
|
191
|
+
const next = new Set(prev ?? signedIn ?? []);
|
|
192
|
+
if (held)
|
|
193
|
+
next.add(name);
|
|
194
|
+
else
|
|
195
|
+
next.delete(name);
|
|
196
|
+
return next;
|
|
197
|
+
});
|
|
198
|
+
// The Servers tab's rows — "+ Add a server" first, then every server. Built once so
|
|
199
|
+
// both the key handler and the render read the identical list.
|
|
200
|
+
const serverRows = [
|
|
201
|
+
{ label: "+ Add a server", add: true },
|
|
202
|
+
...servers.map((s) => ({ label: s.name, detail: serverDetail(s, blockedCountFor(s.name)) })),
|
|
203
|
+
];
|
|
204
|
+
// The server Manage is showing — from `mode.selected` normally, or `mode.editing` while
|
|
205
|
+
// the wizard is mid-edit, so the title and hint keep naming the right server even though
|
|
206
|
+
// editing does not live in `mode.tab`'s state at all.
|
|
207
|
+
const selectedName = mode.kind === "tabs" ? mode.selected : mode.kind === "signing" ? mode.name : mode.editing;
|
|
208
|
+
// Falling back to the FIRST server matters: → from the "+ Add a server" row used to land
|
|
209
|
+
// on a Manage tab with nothing selected and nothing to do. Managing the first server is
|
|
210
|
+
// the useful thing to show there, and the empty state below is then only for the case
|
|
211
|
+
// that really is empty — no servers at all.
|
|
212
|
+
const selected = servers.find((s) => s.name === selectedName) ?? servers[0];
|
|
213
|
+
const actions = selected ? actionsFor(selected, blockedCountFor(selected.name), holdsCredential(selected.name)) : [];
|
|
214
|
+
/** ← one step. From the FIRST step there is no earlier step, so it closes the form and
|
|
215
|
+
* hands focus back to the list beside it — which makes ← the single key that walks all
|
|
216
|
+
* the way out of here, however deep you are, without ever needing Esc. */
|
|
217
|
+
function stepBack(w) {
|
|
218
|
+
if (w.step === 0)
|
|
219
|
+
return cancelWizard(w);
|
|
220
|
+
setMode({ ...w, step: w.step - 1, error: null });
|
|
221
|
+
}
|
|
222
|
+
function stepForward(w) {
|
|
223
|
+
const steps = stepsFor(w.draft.type);
|
|
224
|
+
if (w.step >= steps.length - 1)
|
|
225
|
+
return;
|
|
226
|
+
setMode({ ...w, step: w.step + 1, error: null });
|
|
227
|
+
}
|
|
228
|
+
function cancelWizard(w) {
|
|
229
|
+
setMode({ kind: "tabs", tab: w.returnTab, serverSel: 0, manageSel: 0, selected: w.editing });
|
|
230
|
+
}
|
|
231
|
+
function trySubmit(w) {
|
|
232
|
+
const parsed = parseAddSpec(argvFromDraft(w.draft));
|
|
233
|
+
if (!parsed.ok)
|
|
234
|
+
return setMode({ ...w, error: parsed.error });
|
|
235
|
+
onSubmit(parsed.spec);
|
|
236
|
+
// Land back on Servers after Add (there is nothing yet to manage the moment it is
|
|
237
|
+
// written — the connect is async); land on Manage, still on the same server, after
|
|
238
|
+
// an Edit, since that is the screen you were already looking at.
|
|
239
|
+
setMode(w.editing
|
|
240
|
+
? { kind: "tabs", tab: 1, serverSel: 0, manageSel: 0, selected: w.editing }
|
|
241
|
+
: { kind: "tabs", tab: 0, serverSel: 0, manageSel: 0, selected: null });
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Start a sign-in and STAY on this screen while it runs.
|
|
245
|
+
*
|
|
246
|
+
* The abort controller is what makes Esc mean something here: the browser may never come
|
|
247
|
+
* back, and without it the only way out of a forgotten sign-in is the five-minute
|
|
248
|
+
* timeout. It is held in a ref rather than state because the key handler needs the
|
|
249
|
+
* current one, and a stale closure would abort the wrong attempt.
|
|
250
|
+
*/
|
|
251
|
+
function startSignIn(name) {
|
|
252
|
+
if (!onSignIn)
|
|
253
|
+
return;
|
|
254
|
+
const controller = new AbortController();
|
|
255
|
+
signInAbort.current = controller;
|
|
256
|
+
setMode({ kind: "signing", name, url: null, copied: false, error: null, done: null });
|
|
257
|
+
void onSignIn(name, {
|
|
258
|
+
onUrl: (url) => setMode((m) => (m.kind === "signing" && m.name === name ? { ...m, url } : m)),
|
|
259
|
+
signal: controller.signal,
|
|
260
|
+
}).then((message) => {
|
|
261
|
+
noteCredential(name, true);
|
|
262
|
+
setMode((m) => (m.kind === "signing" && m.name === name ? { ...m, done: message } : m));
|
|
263
|
+
}, (error) => setMode((m) => m.kind === "signing" && m.name === name ? { ...m, error: String(error?.message ?? error) } : m));
|
|
264
|
+
}
|
|
265
|
+
/** → (or Enter on a real server row): switch to Manage. Always switches, even with
|
|
266
|
+
* nothing to manage yet — that is what lets the tab show its own empty state ("add a
|
|
267
|
+
* server first") instead of → silently doing nothing, which read as the tab bar being
|
|
268
|
+
* broken rather than as there being nothing there yet. */
|
|
269
|
+
function goToManage(m, serverSelOverride) {
|
|
270
|
+
const row = serverRows[serverSelOverride ?? m.serverSel];
|
|
271
|
+
const name = row && !row.add ? row.label : m.selected;
|
|
272
|
+
setMode({ ...m, tab: 1, selected: name, manageSel: 0 });
|
|
273
|
+
}
|
|
274
|
+
function commitServers(m) {
|
|
275
|
+
const row = serverRows[m.serverSel];
|
|
276
|
+
if (!row)
|
|
277
|
+
return;
|
|
278
|
+
if (row.add)
|
|
279
|
+
return setMode({ kind: "wizard", editing: null, step: 0, draft: blankDraft(), error: null, returnTab: 0 });
|
|
280
|
+
goToManage(m);
|
|
281
|
+
}
|
|
282
|
+
function commitManage(m) {
|
|
283
|
+
if (!selected)
|
|
284
|
+
return;
|
|
285
|
+
const chosen = actions[m.manageSel] ?? ACTION_BACK;
|
|
286
|
+
// The parent closes this whole overlay to run the approval channel — see the file
|
|
287
|
+
// header on why a second dialog cannot share this box with the manage screen.
|
|
288
|
+
if (chosen === ACTION_REVIEW_BLOCKED)
|
|
289
|
+
return onReviewBlocked(selected.name);
|
|
290
|
+
if (chosen === ACTION_SIGN_IN)
|
|
291
|
+
return startSignIn(selected.name);
|
|
292
|
+
if (chosen === ACTION_SIGN_OUT) {
|
|
293
|
+
noteCredential(selected.name, false);
|
|
294
|
+
setMode({ ...m, manageSel: 0 });
|
|
295
|
+
return onSignOut?.(selected.name);
|
|
296
|
+
}
|
|
297
|
+
// These three change the action list's own SHAPE (Reconnect appears/disappears,
|
|
298
|
+
// Enable/Disable swaps) without leaving the tab, so the highlight is reset rather
|
|
299
|
+
// than risking it pointing at a row that moved or is no longer there.
|
|
300
|
+
if (chosen === ACTION_ENABLE) {
|
|
301
|
+
setMode({ ...m, manageSel: 0 });
|
|
302
|
+
return onSetDisabled(selected.name, false);
|
|
303
|
+
}
|
|
304
|
+
if (chosen === ACTION_DISABLE) {
|
|
305
|
+
setMode({ ...m, manageSel: 0 });
|
|
306
|
+
return onSetDisabled(selected.name, true);
|
|
307
|
+
}
|
|
308
|
+
if (chosen === ACTION_RECONNECT) {
|
|
309
|
+
setMode({ ...m, manageSel: 0 });
|
|
310
|
+
return onReconnect(selected.name);
|
|
311
|
+
}
|
|
312
|
+
if (chosen === ACTION_EDIT) {
|
|
313
|
+
const config = configFor(selected.name);
|
|
314
|
+
const scope = "project"; // the caller resolves the real file; this only seeds the picker
|
|
315
|
+
return setMode({
|
|
316
|
+
kind: "wizard",
|
|
317
|
+
editing: selected.name,
|
|
318
|
+
step: 0,
|
|
319
|
+
draft: config ? draftFromConfig(config, scope) : blankDraft(),
|
|
320
|
+
error: null,
|
|
321
|
+
returnTab: 1,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
if (chosen === ACTION_REMOVE) {
|
|
325
|
+
onRemove(selected.name);
|
|
326
|
+
return setMode({ kind: "tabs", tab: 0, serverSel: 0, manageSel: 0, selected: null });
|
|
327
|
+
}
|
|
328
|
+
// ACTION_BACK — the same place ← already goes; kept as a row for the same reason
|
|
329
|
+
// /key's actions screen keeps one, even though Esc/⌫ also work.
|
|
330
|
+
return setMode({ ...m, tab: 0 });
|
|
331
|
+
}
|
|
332
|
+
useInput((input, key) => {
|
|
333
|
+
// `key.escape` is Ink's own read of the byte; `input === ESCAPE_BYTE` is a direct
|
|
334
|
+
// check of the raw byte itself as a fallback. They should never disagree, but this
|
|
335
|
+
// overlay turns mouse tracking on for drag-select elsewhere in the app, and a
|
|
336
|
+
// solitary Escape arriving in the same chunk as a mouse report is exactly the kind
|
|
337
|
+
// of ambiguous byte sequence a terminal's own parser can misclassify — checking the
|
|
338
|
+
// raw byte too costs nothing and closes that gap if it is ever the cause.
|
|
339
|
+
const isEscape = key.escape || input === ESCAPE_BYTE;
|
|
340
|
+
// ONE job for Esc, everywhere in this box: close it. Not "cancel the form", not
|
|
341
|
+
// "step back one" — those are ← now. A key that means something different depending
|
|
342
|
+
// on how deep you are is a key you have to think about, and this one is also the
|
|
343
|
+
// least reliable byte a terminal sends (a lone ESC that arrives sharing a chunk with
|
|
344
|
+
// anything else is not recognised as ESC at all), so nothing you NEED in order to
|
|
345
|
+
// get out may depend on it. ← walks all the way back to the list on its own.
|
|
346
|
+
if (mode.kind === "signing") {
|
|
347
|
+
// Esc while it is still running ABANDONS the attempt rather than closing the box:
|
|
348
|
+
// there is a browser tab open and a port held, and leaving both running while the
|
|
349
|
+
// screen disappears is the shape of bug that makes a second attempt fail too.
|
|
350
|
+
// Once it has finished, Esc is Esc again.
|
|
351
|
+
const finished = mode.done !== null || mode.error !== null;
|
|
352
|
+
if (isEscape) {
|
|
353
|
+
if (finished)
|
|
354
|
+
return onClose();
|
|
355
|
+
signInAbort.current?.abort();
|
|
356
|
+
return setMode({ kind: "tabs", tab: 1, serverSel: 0, manageSel: 0, selected: mode.name });
|
|
357
|
+
}
|
|
358
|
+
if (key.return || key.leftArrow) {
|
|
359
|
+
if (!finished)
|
|
360
|
+
signInAbort.current?.abort();
|
|
361
|
+
return setMode({ kind: "tabs", tab: 1, serverSel: 0, manageSel: 0, selected: mode.name });
|
|
362
|
+
}
|
|
363
|
+
// The link is offered to COPY rather than printed: it is 400 characters, and the
|
|
364
|
+
// only machine that needs it is one whose browser never opened.
|
|
365
|
+
if (mode.url && (input === "c" || input === "C")) {
|
|
366
|
+
onCopyLink?.(mode.url);
|
|
367
|
+
return setMode({ ...mode, copied: true });
|
|
368
|
+
}
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (isEscape)
|
|
372
|
+
return onClose();
|
|
373
|
+
if (mode.kind === "wizard") {
|
|
374
|
+
const steps = stepsFor(mode.draft.type);
|
|
375
|
+
const kind = steps[mode.step];
|
|
376
|
+
// ← means BACK on every step without exception, and → means forward. They are not
|
|
377
|
+
// shared with a text cursor, because this form has no mid-string cursor to share
|
|
378
|
+
// them with: typing appends and Backspace deletes, which is all these one-line
|
|
379
|
+
// fields need. That is the trade that buys ← exactly one meaning everywhere in
|
|
380
|
+
// this box — the thing that kept going wrong when "back" moved from key to key
|
|
381
|
+
// depending on which step you were standing on.
|
|
382
|
+
if (key.leftArrow || key.pageUp)
|
|
383
|
+
return stepBack(mode);
|
|
384
|
+
if (key.rightArrow || key.return)
|
|
385
|
+
return kind === "review" ? trySubmit(mode) : stepForward(mode);
|
|
386
|
+
// ↑/↓ pick between the two rows a step offers — which transport you are typing
|
|
387
|
+
// (adding only: editing one does not change its transport), or which scope.
|
|
388
|
+
if (key.upArrow || key.downArrow) {
|
|
389
|
+
if (kind === "command" && mode.editing === null) {
|
|
390
|
+
return setMode({ ...mode, draft: { ...mode.draft, type: mode.draft.type === "stdio" ? "http" : "stdio" } });
|
|
391
|
+
}
|
|
392
|
+
if (kind === "scope") {
|
|
393
|
+
return setMode({ ...mode, draft: { ...mode.draft, scope: mode.draft.scope === "project" ? "global" : "project" } });
|
|
394
|
+
}
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const current = fieldOf(mode.draft, kind);
|
|
398
|
+
if (current === null)
|
|
399
|
+
return; // scope and review have nothing to type into
|
|
400
|
+
if (key.backspace || key.delete) {
|
|
401
|
+
return setMode({ ...mode, draft: withField(mode.draft, kind, current.slice(0, -1)), error: null });
|
|
402
|
+
}
|
|
403
|
+
const typed = key.ctrl || key.meta || key.tab ? "" : stripMouse(input);
|
|
404
|
+
if (typed)
|
|
405
|
+
setMode({ ...mode, draft: withField(mode.draft, kind, current + typed), error: null });
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (key.backspace || key.delete)
|
|
409
|
+
return onClose();
|
|
410
|
+
if (key.leftArrow)
|
|
411
|
+
return setMode({ ...mode, tab: 0 });
|
|
412
|
+
if (key.rightArrow)
|
|
413
|
+
return goToManage(mode);
|
|
414
|
+
if (mode.tab === 0) {
|
|
415
|
+
const count = serverRows.length;
|
|
416
|
+
if (count === 0)
|
|
417
|
+
return;
|
|
418
|
+
if (key.upArrow)
|
|
419
|
+
return setMode({ ...mode, serverSel: (mode.serverSel - 1 + count) % count });
|
|
420
|
+
if (key.downArrow)
|
|
421
|
+
return setMode({ ...mode, serverSel: (mode.serverSel + 1) % count });
|
|
422
|
+
if (key.return)
|
|
423
|
+
return commitServers(mode);
|
|
424
|
+
const n = Number.parseInt(input, 10);
|
|
425
|
+
if (Number.isInteger(n) && n >= 1 && n <= Math.min(9, count))
|
|
426
|
+
return commitServers({ ...mode, serverSel: n - 1 });
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
// tab 1 — Manage
|
|
430
|
+
const count = actions.length;
|
|
431
|
+
if (count === 0)
|
|
432
|
+
return;
|
|
433
|
+
if (key.upArrow)
|
|
434
|
+
return setMode({ ...mode, manageSel: (mode.manageSel - 1 + count) % count });
|
|
435
|
+
if (key.downArrow)
|
|
436
|
+
return setMode({ ...mode, manageSel: (mode.manageSel + 1) % count });
|
|
437
|
+
if (key.return)
|
|
438
|
+
return commitManage(mode);
|
|
439
|
+
const n = Number.parseInt(input, 10);
|
|
440
|
+
if (Number.isInteger(n) && n >= 1 && n <= Math.min(9, count))
|
|
441
|
+
return commitManage({ ...mode, manageSel: n - 1 });
|
|
442
|
+
}, { isActive: active });
|
|
443
|
+
// Which of the two real tabs is "behind" the current screen — the list you'll land back
|
|
444
|
+
// on after the wizard, or the one you're looking at right now. The tab bar reads off of
|
|
445
|
+
// this everywhere, wizard included: it must never go away, because that is the whole
|
|
446
|
+
// complaint the wizard used to earn ("opens a different window and the taskbar is gone").
|
|
447
|
+
const activeTab = mode.kind === "wizard" ? mode.returnTab : mode.kind === "signing" ? 1 : mode.tab;
|
|
448
|
+
const tabsBar = [{ active: activeTab === 0 }, { active: activeTab === 1 }];
|
|
449
|
+
const serverSel = mode.kind === "tabs" ? mode.serverSel : 0;
|
|
450
|
+
const manageSel = mode.kind === "tabs" ? mode.manageSel : 0;
|
|
451
|
+
// TWO PANES while a form is open, one box and one tab bar over both: the list keeps the
|
|
452
|
+
// LEFT, the form takes the RIGHT. Stacking the form under the list is what this replaced,
|
|
453
|
+
// and the reason is that a form row directly below a list row reads as one more list row
|
|
454
|
+
// — you cannot tell what you are looking at. Side by side, the list is plainly context
|
|
455
|
+
// and the form is plainly the thing you are filling in. With no form open the list has
|
|
456
|
+
// the whole width back, exactly as before, so nothing moves for the common case.
|
|
457
|
+
const inner = Math.max(12, width - 4);
|
|
458
|
+
const PANE_GAP = 3;
|
|
459
|
+
const wizard = mode.kind === "wizard" ? mode : null;
|
|
460
|
+
// MANAGE is always two panes, for the same reason a form is: the left is the list you
|
|
461
|
+
// are working from and the right is what you are doing to it. That makes both tabs the
|
|
462
|
+
// same shape — left never changes meaning, right is always the work — so moving between
|
|
463
|
+
// them is not learning a second layout. Only tab 1 with no form open is a single pane,
|
|
464
|
+
// because there the list IS the whole screen and nothing is being done to it yet.
|
|
465
|
+
const twoPane = wizard !== null || activeTab === 1;
|
|
466
|
+
const leftPane = twoPane ? Math.max(18, Math.min(34, Math.floor(inner * 0.42))) : inner;
|
|
467
|
+
const rightPane = twoPane ? Math.max(16, inner - leftPane - PANE_GAP) : 0;
|
|
468
|
+
// A server's status ("connected · 3 tools") needs a second column the narrowed left pane
|
|
469
|
+
// may not have room for. Dropped rather than truncated to four characters: a status
|
|
470
|
+
// clipped to "conn" is worse than no status while you are looking at a form anyway.
|
|
471
|
+
const leftDetail = leftPane >= 46;
|
|
472
|
+
const wizardView = wizard ? wizardBody(wizard, rightPane) : null;
|
|
473
|
+
const hint = (mode.kind === "signing" ? signInView(mode).hint : undefined) ?? wizardView?.hint ?? "←/→ tab · ↑/↓ move · Enter select · Esc closes";
|
|
474
|
+
/** The list on the left, the form on the right, a dim rule between them. `rows` is how
|
|
475
|
+
* tall the rule is drawn — the taller of the two panes, so it spans the whole body.
|
|
476
|
+
* `topPad` is blank lines above the RIGHT pane, matching whatever blank line the left
|
|
477
|
+
* list opens with, so the first row of each pane lands on the same screen line. Without
|
|
478
|
+
* it the two panes sit one row out of step and read as unrelated. */
|
|
479
|
+
function TwoPane({ left, right, rows, topPad = 0 }) {
|
|
480
|
+
return (_jsxs(Box, { flexShrink: 0, flexDirection: "row", width: inner, children: [_jsx(Box, { flexShrink: 0, flexDirection: "column", width: leftPane, children: left }), _jsx(Box, { flexShrink: 0, flexDirection: "column", width: PANE_GAP, children: Array.from({ length: rows }).map((_, i) => (_jsx(Box, { flexShrink: 0, children: _jsx(Text, { dimColor: true, children: " │ " }) }, i))) }), _jsxs(Box, { flexShrink: 0, flexDirection: "column", width: rightPane, children: [Array.from({ length: topPad }).map((_, i) => (_jsx(Box, { flexShrink: 0, children: _jsx(Text, { children: " " }) }, `pad${i}`))), right] })] }));
|
|
481
|
+
}
|
|
482
|
+
/** One row of the form: the `›` marker if it is the row you are on, then its content. */
|
|
483
|
+
function FieldRow({ on, pane, children }) {
|
|
484
|
+
return (_jsxs(Box, { flexShrink: 0, width: pane, children: [_jsx(Text, { color: on ? "cyan" : undefined, bold: on, children: on ? " › " : " " }), children] }));
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* What you have typed, or the GHOST of what you could type, with the block cursor.
|
|
488
|
+
*
|
|
489
|
+
* The placeholder is not a label beside an empty box — it is the text sitting IN the
|
|
490
|
+
* field, dim, and it is gone the moment there is a real character to show instead.
|
|
491
|
+
*/
|
|
492
|
+
function Ghost({ value, placeholder }) {
|
|
493
|
+
if (value.length > 0) {
|
|
494
|
+
return (_jsxs(_Fragment, { children: [_jsx(Text, { wrap: "truncate-end", children: value }), _jsx(Text, { inverse: true, children: " " })] }));
|
|
495
|
+
}
|
|
496
|
+
return (_jsxs(_Fragment, { children: [_jsx(Text, { inverse: true, dimColor: true, children: placeholder.slice(0, 1) }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: placeholder.slice(1) })] }));
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* The sign-in, reported where it is happening.
|
|
500
|
+
*
|
|
501
|
+
* Three states and no URL in any of them until it is asked for. The successful path is
|
|
502
|
+
* over in seconds and says one line; the stuck path offers the link to COPY, because a
|
|
503
|
+
* 400-character query string is something to paste, never something to read; the failed
|
|
504
|
+
* path says what the server said, which is almost always the whole explanation.
|
|
505
|
+
*/
|
|
506
|
+
function signInView(s) {
|
|
507
|
+
const line = (text, color, dim = false) => (_jsx(Box, { flexShrink: 0, width: rightPane, children: _jsx(Text, { color: color, dimColor: dim, wrap: "truncate-end", children: ` ${text}` }) }));
|
|
508
|
+
if (s.error) {
|
|
509
|
+
return {
|
|
510
|
+
rows: 3,
|
|
511
|
+
hint: "Enter or Esc go back",
|
|
512
|
+
node: (_jsxs(_Fragment, { children: [line(`Could not sign in to ${s.name}.`, "yellow"), _jsx(Box, { flexShrink: 0, children: _jsx(Text, { children: " " }) }), line(s.error, undefined, true)] })),
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
if (s.done) {
|
|
516
|
+
return { rows: 1, hint: "Enter or Esc go back", node: line(s.done, "green") };
|
|
517
|
+
}
|
|
518
|
+
return {
|
|
519
|
+
rows: 3,
|
|
520
|
+
hint: s.url ? "c copy the link · Esc cancels" : "Esc cancels",
|
|
521
|
+
node: (_jsxs(_Fragment, { children: [line(`Waiting for your browser…`), _jsx(Box, { flexShrink: 0, children: _jsx(Text, { children: " " }) }), s.copied
|
|
522
|
+
? line("Link copied. Paste it into a browser.", "green")
|
|
523
|
+
: s.url
|
|
524
|
+
? line("Nothing opened? Press c to copy the link.", undefined, true)
|
|
525
|
+
: line(`Approve ${s.name} in the window that opened.`, undefined, true)] })),
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
function wizardBody(w, pane) {
|
|
529
|
+
const steps = stepsFor(w.draft.type);
|
|
530
|
+
const kind = steps[w.step];
|
|
531
|
+
// ← is back and → is forward on every single step, so every hint says exactly that,
|
|
532
|
+
// and Esc is only ever named for the one thing it does: closing the box.
|
|
533
|
+
const stepHint = kind === "review"
|
|
534
|
+
? "→ saves and connects · ← back · Esc closes"
|
|
535
|
+
: kind === "scope"
|
|
536
|
+
? "↑/↓ choose · → next · ← back · Esc closes"
|
|
537
|
+
: kind === "command" && w.editing === null
|
|
538
|
+
? "type it · ↑/↓ swaps kind · → next · ← back · Esc closes"
|
|
539
|
+
: "type it · → next · ← back · Esc closes";
|
|
540
|
+
if (kind === "command") {
|
|
541
|
+
// The two transports ARE the two rows of this one field. Whichever is selected is
|
|
542
|
+
// the one you are typing into; the other stays as its own ghost description, so the
|
|
543
|
+
// alternative is still readable without being a separate step you had to get past.
|
|
544
|
+
// Editing shows only the transport the server already has — a server does not change
|
|
545
|
+
// transport by being edited, and offering the swap here would imply it could.
|
|
546
|
+
const stdio = w.draft.type === "stdio";
|
|
547
|
+
const rowFor = (mine, ghost) => (_jsx(FieldRow, { on: mine, pane: pane, children: mine ? _jsx(Ghost, { value: w.draft.commandLine, placeholder: ghost }) : _jsx(Text, { dimColor: true, wrap: "truncate-end", children: ghost }) }));
|
|
548
|
+
return {
|
|
549
|
+
rows: w.editing === null ? 2 : 1,
|
|
550
|
+
hint: stepHint,
|
|
551
|
+
node: w.editing !== null ? (rowFor(true, stdio ? CMD_GHOST : URL_GHOST)) : (_jsxs(_Fragment, { children: [rowFor(stdio, CMD_GHOST), rowFor(!stdio, URL_GHOST)] })),
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
if (kind === "env" || kind === "headers") {
|
|
555
|
+
const env = kind === "env";
|
|
556
|
+
return {
|
|
557
|
+
rows: 1,
|
|
558
|
+
hint: stepHint,
|
|
559
|
+
node: (_jsxs(FieldRow, { on: true, pane: pane, children: [_jsx(Text, { bold: true, color: "cyan", children: env ? "env " : "headers " }), _jsx(Ghost, { value: env ? w.draft.env : w.draft.headers, placeholder: env ? "GITHUB_TOKEN=ghp_xxxx (optional)" : "Authorization: Bearer xyz (optional)" })] })),
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
if (kind === "scope") {
|
|
563
|
+
return {
|
|
564
|
+
rows: 2,
|
|
565
|
+
hint: stepHint,
|
|
566
|
+
node: (_jsxs(_Fragment, { children: [_jsx(Row, { on: w.draft.scope === "project", n: 1, showNumber: false, leftDim: true, left: "This project only (.mindweave/mcp.json)", width: pane + 4 }), _jsx(Row, { on: w.draft.scope === "global", n: 2, showNumber: false, leftDim: true, left: "Every project (~/.mindweave/mcp.json)", width: pane + 4 })] })),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
const rows = [
|
|
570
|
+
["Name", w.draft.name.trim() || deriveName(w.draft)],
|
|
571
|
+
[w.draft.type === "http" ? "URL" : "Command", w.draft.commandLine],
|
|
572
|
+
...(w.draft.type === "stdio" && w.draft.env.trim() ? [["Env", w.draft.env.trim()]] : []),
|
|
573
|
+
...(w.draft.type === "http" && w.draft.headers.trim() ? [["Headers", w.draft.headers.trim()]] : []),
|
|
574
|
+
["Save to", w.draft.scope === "global" ? "Every project" : "This project only"],
|
|
575
|
+
];
|
|
576
|
+
return {
|
|
577
|
+
rows: rows.length + (w.error ? 2 : 0),
|
|
578
|
+
hint: stepHint,
|
|
579
|
+
node: (_jsxs(_Fragment, { children: [rows.map(([k, v]) => (_jsxs(Box, { flexShrink: 0, width: pane, children: [_jsx(Box, { flexShrink: 0, width: 10, children: _jsx(Text, { dimColor: true, children: k }) }), _jsx(Text, { wrap: "truncate-end", children: v })] }, k))), w.error ? (_jsx(Box, { flexShrink: 0, width: pane, marginTop: 1, children: _jsx(Text, { color: "yellow", wrap: "truncate-end", children: ` ${w.error}` }) })) : null] })),
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* The LEFT pane, on both tabs: the list, spaced, with one row marked.
|
|
584
|
+
*
|
|
585
|
+
* One function rather than one per tab, because "the left pane is the list" is the whole
|
|
586
|
+
* reason the two tabs read as one screen. `paneW` is the only thing that differs between
|
|
587
|
+
* a single-pane tab 1 and either tab beside a right pane. Returns its own height so the
|
|
588
|
+
* rule between the panes can be drawn to the taller of the two — which is what makes the
|
|
589
|
+
* rule grow as you add servers instead of stopping short of them.
|
|
590
|
+
*/
|
|
591
|
+
function listPane(rows, anchor, paneW) {
|
|
592
|
+
const from = windowStart(anchor, rows.length, win);
|
|
593
|
+
const shown = rows.slice(from, from + win);
|
|
594
|
+
return {
|
|
595
|
+
height: Math.max(0, shown.length * 2 - 1) + 1,
|
|
596
|
+
node: (_jsxs(_Fragment, { children: [_jsx(Box, { flexShrink: 0, children: _jsx(Text, { children: " " }) }), shown.map((r, i) => (_jsxs(Box, { flexShrink: 0, flexDirection: "column", children: [_jsx(Row, { on: from + i === anchor, n: from + i + 1, showNumber: false, left: r.label, leftColor: r.add ? "yellow" : undefined,
|
|
597
|
+
// A status needs a second column the narrowed pane may not have room for.
|
|
598
|
+
// Dropped rather than clipped to "conn", which says less than nothing.
|
|
599
|
+
right: paneW >= 46 ? r.detail : undefined, width: paneW + 4 }), i < shown.length - 1 ? _jsx(Box, { flexShrink: 0, children: _jsx(Text, { children: " " }) }) : null] }, r.label)))] })),
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
if (activeTab === 1) {
|
|
603
|
+
if (!selected) {
|
|
604
|
+
// Genuinely nothing to manage — no servers exist at all. (→ from the "+ Add a
|
|
605
|
+
// server" row no longer lands here: it manages the first server instead.)
|
|
606
|
+
return (_jsxs(Panel, { title: "Manage", tabs: tabsBar, titleWidth: TITLE_WIDTH, rows: 3, maxRows: maxRows, width: width, hint: hint, children: [_jsx(Box, { flexShrink: 0, children: _jsx(Text, { children: " " }) }), _jsx(Box, { flexShrink: 0, children: _jsx(Text, { dimColor: true, children: " Nothing to manage yet." }) }), _jsx(Box, { flexShrink: 0, children: _jsx(Text, { dimColor: true, children: " \u2190 add a server on the Servers tab first." }) })] }));
|
|
607
|
+
}
|
|
608
|
+
// Left: every server, the managed one marked. Right: what you can do to it — or the
|
|
609
|
+
// edit form, once you pick Edit. Same shape as tab 1, one row down the same column.
|
|
610
|
+
const managed = servers.findIndex((s) => s.name === selected.name);
|
|
611
|
+
const left = listPane(servers.map((s) => ({ label: s.name, detail: serverDetail(s, blockedCountFor(s.name)) })), Math.max(0, managed), leftPane);
|
|
612
|
+
const from = windowStart(manageSel, actions.length, win);
|
|
613
|
+
const shownActions = actions.slice(from, from + win);
|
|
614
|
+
const signing = mode.kind === "signing" ? signInView(mode) : null;
|
|
615
|
+
const right = signing ? signing.node : wizardView ? wizardView.node : (shownActions.map((a, i) => (_jsx(Row, { on: from + i === manageSel, n: from + i + 1, showNumber: false, left: a, width: rightPane + 4 }, a))));
|
|
616
|
+
const rightHeight = signing ? signing.rows : wizardView ? wizardView.rows : shownActions.length;
|
|
617
|
+
const bodyHeight = Math.max(left.height, rightHeight + 1);
|
|
618
|
+
return (_jsx(Panel, { title: "Manage", tabs: tabsBar, titleWidth: TITLE_WIDTH, counter: wizardView ? "" : position(manageSel, actions.length, win), rows: bodyHeight, maxRows: maxRows, width: width, hint: hint, children: _jsx(TwoPane, { rows: bodyHeight, topPad: 1, left: left.node, right: right }) }));
|
|
619
|
+
}
|
|
620
|
+
// The anchor with a form open is "+ Add a server" (row 0) — the row the form came from.
|
|
621
|
+
const left = listPane(serverRows, wizardView ? 0 : serverSel, wizardView ? leftPane : inner);
|
|
622
|
+
return (_jsx(Panel, { title: "MCP servers", tabs: tabsBar, titleWidth: TITLE_WIDTH, counter: wizardView ? "" : position(serverSel, serverRows.length, win), rows: wizardView ? Math.max(left.height, wizardView.rows + 1) : left.height, maxRows: maxRows, width: width, hint: hint, children: wizardView ? (_jsx(TwoPane, { rows: Math.max(left.height, wizardView.rows + 1), topPad: 1, left: left.node, right: wizardView.node })) : (left.node) }));
|
|
623
|
+
}
|
|
624
|
+
/** What can be done to one server, in the order it is shown. */
|
|
625
|
+
export function actionsFor(server, blocked, signedIn = false) {
|
|
626
|
+
const acts = [];
|
|
627
|
+
if (blocked > 0)
|
|
628
|
+
acts.push(ACTION_REVIEW_BLOCKED);
|
|
629
|
+
// WHETHER A CREDENTIAL IS HELD decides this, not the connection state, and that is the
|
|
630
|
+
// correction: gating Sign in on `needs-auth` left signing out as a one-way door. Signing
|
|
631
|
+
// out deliberately leaves the connection running — the token is not refused until the
|
|
632
|
+
// next call — so the server sits there `connected` with no credential and no way to get
|
|
633
|
+
// one back. Offered for any http server without a token instead; a local command server
|
|
634
|
+
// has no authorization server to send anyone to, so it never appears there.
|
|
635
|
+
if (server.type === "http" && !signedIn)
|
|
636
|
+
acts.push(ACTION_SIGN_IN);
|
|
637
|
+
acts.push(server.state === "disabled" ? ACTION_ENABLE : ACTION_DISABLE);
|
|
638
|
+
if (server.state === "failed" || server.state === "needs-auth")
|
|
639
|
+
acts.push(ACTION_RECONNECT);
|
|
640
|
+
// Below Reconnect: it is the rarer thing to want, and it is destructive in the small way
|
|
641
|
+
// that logging out always is.
|
|
642
|
+
if (signedIn)
|
|
643
|
+
acts.push(ACTION_SIGN_OUT);
|
|
644
|
+
acts.push(ACTION_EDIT, ACTION_REMOVE, ACTION_BACK);
|
|
645
|
+
return acts;
|
|
646
|
+
}
|
|
647
|
+
/** The list row's dim detail — facts only, no call-to-action verb: Enter always opens the
|
|
648
|
+
* manage screen now, whatever the server's state, so a verb here would describe the old
|
|
649
|
+
* flat list's behaviour rather than this one's. */
|
|
650
|
+
export function serverDetail(s, blocked) {
|
|
651
|
+
if (blocked > 0)
|
|
652
|
+
return `${blocked} tool${blocked === 1 ? "" : "s"} blocked`;
|
|
653
|
+
switch (s.state) {
|
|
654
|
+
case "connected": {
|
|
655
|
+
// Prompts are counted separately because they are the user's to invoke, not the
|
|
656
|
+
// model's: a server offering only prompts would otherwise read as "0 tools" and
|
|
657
|
+
// look broken.
|
|
658
|
+
const counts = [
|
|
659
|
+
`${s.toolCount} tool${s.toolCount === 1 ? "" : "s"}`,
|
|
660
|
+
...(s.promptCount ? [`${s.promptCount} prompt${s.promptCount === 1 ? "" : "s"}`] : []),
|
|
661
|
+
...(s.offersResources ? ["resources"] : []),
|
|
662
|
+
].join(", ");
|
|
663
|
+
// The negotiated revision is worth surfacing: "legacy" is why a server may be
|
|
664
|
+
// missing capabilities, and it is otherwise invisible.
|
|
665
|
+
const proto = s.version ? ` · ${s.version}${s.legacy ? " (legacy)" : ""}` : "";
|
|
666
|
+
return `connected · ${counts}${proto}`;
|
|
667
|
+
}
|
|
668
|
+
case "pending":
|
|
669
|
+
return "connecting…";
|
|
670
|
+
case "needs-auth":
|
|
671
|
+
// Names the fix rather than the state. This is the one row where a user genuinely
|
|
672
|
+
// does not know what to do next, and "needs authentication" describes the problem
|
|
673
|
+
// to someone already looking at a screen full of problems.
|
|
674
|
+
return "sign in to connect";
|
|
675
|
+
case "disabled":
|
|
676
|
+
return "disabled";
|
|
677
|
+
default:
|
|
678
|
+
return s.error ? `error · ${clip(s.error, 40)}` : "failed";
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
/** A server's own error text is unbounded — this keeps a long one from being the thing
|
|
682
|
+
* that overflows the row (`MiniTabRow` also truncates `right`, but clipping the source
|
|
683
|
+
* string is what keeps the SUMMARY line in `note()`/`say()` readable too, not just the
|
|
684
|
+
* box row). */
|
|
685
|
+
function clip(s, max) {
|
|
686
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
687
|
+
return flat.length <= max ? flat : flat.slice(0, max - 1) + "…";
|
|
688
|
+
}
|
|
689
|
+
//# sourceMappingURL=McpMinitabs.js.map
|