mindweave 2.4.2 → 2.4.5
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 +213 -104
- 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/blockSpacing.js +22 -1
- package/dist/cli/blockSpacing.js.map +1 -1
- package/dist/cli/components/ApprovalBox.js +16 -4
- package/dist/cli/components/ApprovalBox.js.map +1 -1
- package/dist/cli/components/BlockView.js +1 -1
- package/dist/cli/components/BlockView.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/ToolLine.js +12 -3
- package/dist/cli/components/ToolLine.js.map +1 -1
- package/dist/cli/components/minitabs.js +71 -0
- package/dist/cli/components/minitabs.js.map +1 -0
- package/dist/cli/framebuffer/writer.js +20 -3
- package/dist/cli/framebuffer/writer.js.map +1 -1
- package/dist/cli/toolDisplay.js +4 -1
- package/dist/cli/toolDisplay.js.map +1 -1
- package/dist/cli/transcript.js +1 -0
- package/dist/cli/transcript.js.map +1 -1
- package/dist/dynamo/engine.js +15 -0
- package/dist/dynamo/engine.js.map +1 -1
- package/dist/dynamo/prompt.js +1 -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 +398 -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/tools/askUser.js +26 -3
- package/dist/tools/askUser.js.map +1 -1
- package/dist/tools/backgroundShells.js +112 -7
- 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/shellTools.js +8 -1
- package/dist/tools/shellTools.js.map +1 -1
- package/package.json +74 -74
package/dist/cli/App.js
CHANGED
|
@@ -38,6 +38,7 @@ import { APPROVAL_DISMISSED, APPROVAL_TEXT } from "../tools/approval.js";
|
|
|
38
38
|
import { KeySetup } from "./components/KeySetup.js";
|
|
39
39
|
import { setupView } from "./keySetup.js";
|
|
40
40
|
import { KeyManager } from "./components/KeyManager.js";
|
|
41
|
+
import { McpMinitabs } from "./components/McpMinitabs.js";
|
|
41
42
|
import { providerRows, keyRowsFor, nextSlotFor } from "./keyManager.js";
|
|
42
43
|
import { keysFor } from "./keyStore.js";
|
|
43
44
|
import { TrustGate } from "./components/TrustGate.js";
|
|
@@ -74,6 +75,7 @@ import { currentInstall, requestRestart, runUpdate } from "./updateRunner.js";
|
|
|
74
75
|
import { enableMouse, readMouse, readWheel } from "./mouse.js";
|
|
75
76
|
import { applySelection, ctrlCShouldCopy, isEmpty, selectionText } from "./selection.js";
|
|
76
77
|
import { latestScreen, repaintOverlay, setFrameOverlay } from "./framebuffer/overlay.js";
|
|
78
|
+
import { requestFullRepaint } from "./framebuffer/writer.js";
|
|
77
79
|
import { copyToClipboard } from "./clipboard.js";
|
|
78
80
|
import { chatLayout, reflowScroll } from "./chatAnchor.js";
|
|
79
81
|
import { growFill, INLINE_LIVE_RESERVE, NO_FILL } from "./startupFill.js";
|
|
@@ -92,7 +94,7 @@ import { workingVerb } from "./workingVerb.js";
|
|
|
92
94
|
import { narrationPending, revealWait } from "./revealPace.js";
|
|
93
95
|
import { summarizeTask, formatTokens } from "../dynamo/pricing.js";
|
|
94
96
|
import { meterReset, meterDelta, meterTick, meterValue } from "../dynamo/liveMeter.js";
|
|
95
|
-
import { addServerToConfig, configPathFor, parseAddSpec, removeServerFromConfig, splitArgs } from "../mcp/configWrite.js";
|
|
97
|
+
import { addServerToConfig, configPathFor, parseAddSpec, removeServerFromConfig, resolveConfigPath, splitArgs } from "../mcp/configWrite.js";
|
|
96
98
|
import { mapPromptArguments, promptCommand, promptUsage } from "../mcp/prompts.js";
|
|
97
99
|
import { DEFAULT_MODE, modeById, modeFromFlags, nextMode } from "./modes.js";
|
|
98
100
|
import { ApprovalChannel } from "./approvalChannel.js";
|
|
@@ -357,6 +359,13 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
357
359
|
// Distinct from the first-run setup screen, which only ever needs to get one key in.
|
|
358
360
|
const [keysOpen, setKeysOpen] = useState(false);
|
|
359
361
|
const [keysTick, setKeysTick] = useState(0);
|
|
362
|
+
// Opened by /mcp — the minitabs manager: a server list, and picking one drops into
|
|
363
|
+
// everything you can do to it. Its own footer overlay, same shape as /key's.
|
|
364
|
+
const [mcpOpen, setMcpOpen] = useState(false);
|
|
365
|
+
/** Which MCP servers we hold a stored credential for. Read when the box opens rather
|
|
366
|
+
* than kept live: it changes only when someone signs in or out, both of which close
|
|
367
|
+
* the box, and each entry is a file read that has no business on a render path. */
|
|
368
|
+
const [mcpSignedIn, setMcpSignedIn] = useState(() => new Set());
|
|
360
369
|
// A /provider switch held back because that provider had no key. Finished the moment
|
|
361
370
|
// one is saved for it, so choosing a provider and adding its key is one flow rather
|
|
362
371
|
// than two commands with a dead end in between.
|
|
@@ -651,6 +660,14 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
651
660
|
// nothing else on screen would tell them their dev server had fallen over.
|
|
652
661
|
if (kind === "ready")
|
|
653
662
|
continue;
|
|
663
|
+
// The watchdog flagged a running shell as stuck — a prompt it is blocked on, or a
|
|
664
|
+
// long silence on a command that should be working. Worth a line: otherwise it sits
|
|
665
|
+
// invisible until it times out.
|
|
666
|
+
if (kind === "stalled") {
|
|
667
|
+
const why = sh.stallReason === "prompt" ? "waiting for input?" : "no output for a while — stuck?";
|
|
668
|
+
addTool(`shell #${sh.id} (${clipCmd(sh.command)}) ${why}`, { error: true });
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
654
671
|
if (sh.status === "killed" && sh.stoppedBy === "user")
|
|
655
672
|
continue;
|
|
656
673
|
const verb = sh.status === "killed" ? "killed" : `finished — exit ${sh.exitCode}`;
|
|
@@ -666,7 +683,7 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
666
683
|
note(notice);
|
|
667
684
|
}
|
|
668
685
|
function attachApproval(s) {
|
|
669
|
-
s.toolContext.requestApproval = (q, o, detail, title) => askApproval.current(q, o, detail, title);
|
|
686
|
+
s.toolContext.requestApproval = (q, o, detail, title, freeText) => askApproval.current(q, o, detail, title, freeText);
|
|
670
687
|
// Same stop Esc performs. A tool reaches for this when the USER has said, by
|
|
671
688
|
// dismissing a question, that the work should not carry on without them.
|
|
672
689
|
s.toolContext.interrupt = () => abortRef.current?.abort();
|
|
@@ -978,6 +995,11 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
978
995
|
// scrolled back down before the view moved at all. A flick or two past the top bought
|
|
979
996
|
// a second of a wheel that did nothing, which reads as the app having frozen.
|
|
980
997
|
const scrollBy = useCallback((lines) => {
|
|
998
|
+
// Repaint the whole next frame. A scroll can move a row out from under the
|
|
999
|
+
// framebuffer's model (see requestFullRepaint), which is what left a transcript row
|
|
1000
|
+
// stranded on the pinned banner; a scroll already redraws almost every visible row, so
|
|
1001
|
+
// redrawing the stable ones on top is nearly free and stops that.
|
|
1002
|
+
requestFullRepaint();
|
|
981
1003
|
setScrollUp((s) => Math.max(0, Math.min(maxScrollRef.current, s + lines)));
|
|
982
1004
|
}, []);
|
|
983
1005
|
/**
|
|
@@ -1030,10 +1052,14 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1030
1052
|
// back (see scrollPill.ts) names ctrl+End, so this is the half that makes the
|
|
1031
1053
|
// chip true. CTRL is what keeps them off the input: plain End and Home belong to
|
|
1032
1054
|
// the caret, whether or not the input claims them yet.
|
|
1033
|
-
else if (key.end && key.ctrl)
|
|
1055
|
+
else if (key.end && key.ctrl) {
|
|
1056
|
+
requestFullRepaint();
|
|
1034
1057
|
setScrollUp(0);
|
|
1035
|
-
|
|
1058
|
+
}
|
|
1059
|
+
else if (key.home && key.ctrl) {
|
|
1060
|
+
requestFullRepaint();
|
|
1036
1061
|
setScrollUp(maxScrollRef.current);
|
|
1062
|
+
}
|
|
1037
1063
|
}, { isActive: ready && overlay === null });
|
|
1038
1064
|
// Leaving the reading view when it reaches the bottom needs no effect of its own:
|
|
1039
1065
|
// `reading` is `scrollUp > 0`, so landing on zero — the wheel, the keys, ctrl+End, or
|
|
@@ -1212,11 +1238,21 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1212
1238
|
return;
|
|
1213
1239
|
}
|
|
1214
1240
|
const caret = caretCell();
|
|
1241
|
+
const { height, y } = measureElement(liveRef.current);
|
|
1215
1242
|
if (!caret) {
|
|
1216
|
-
|
|
1243
|
+
// NO CARET IS NOT "NOTHING TO DO". It is declared null whenever the input is not the
|
|
1244
|
+
// thing being typed into — a picker is open, a turn is in flight — and publishing
|
|
1245
|
+
// zero there says "the cursor is already on the last row", which is a claim, not an
|
|
1246
|
+
// absence. Exiting from that state left the cursor mid-frame and the shell printed
|
|
1247
|
+
// its prompt across the conversation, one row per Enter.
|
|
1248
|
+
//
|
|
1249
|
+
// The whole region's height is the safe answer instead: CUD is clamped by the
|
|
1250
|
+
// terminal at the bottom row, so overshooting costs a blank line and undershooting
|
|
1251
|
+
// costs the corruption above. Only the full-screen shell publishes a true zero, and
|
|
1252
|
+
// it means it — leaving the alternate buffer restores the cursor with it.
|
|
1253
|
+
setRowsBelowCaret(Math.max(0, height - 1));
|
|
1217
1254
|
return;
|
|
1218
1255
|
}
|
|
1219
|
-
const { height, y } = measureElement(liveRef.current);
|
|
1220
1256
|
// `caretCell` reports the caret's row within the live region; `liveRef` measures that
|
|
1221
1257
|
// region. The last row it drew is `height - 1`, so the gap is what remains below.
|
|
1222
1258
|
setRowsBelowCaret(height - 1 - (caret.y - (y ?? 0)));
|
|
@@ -1307,6 +1343,7 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1307
1343
|
const hit = pillHit.current;
|
|
1308
1344
|
if (hit && hitsPill(hit, event.x, event.y)) {
|
|
1309
1345
|
clearSelection();
|
|
1346
|
+
requestFullRepaint();
|
|
1310
1347
|
setScrollUp(0);
|
|
1311
1348
|
continue;
|
|
1312
1349
|
}
|
|
@@ -1364,64 +1401,101 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1364
1401
|
dispatch({ type: "endTurn" });
|
|
1365
1402
|
setBusy(false);
|
|
1366
1403
|
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Write a server's config and connect it live — shared by the typed `/mcp add`, the
|
|
1406
|
+
* `/mcp` box's Add and Edit, and (indirectly, through the same writer) the
|
|
1407
|
+
* `add_mcp_server` tool. One path so a config any of them accepts is one the others
|
|
1408
|
+
* would too. Connects immediately: writing the file and telling the user to restart
|
|
1409
|
+
* would defeat the entire point of a guided add.
|
|
1410
|
+
*/
|
|
1411
|
+
async function mcpWriteAndConnect(spec) {
|
|
1412
|
+
const cur = session.current;
|
|
1413
|
+
if (!cur)
|
|
1414
|
+
return;
|
|
1415
|
+
const path = configPathFor(spec.scope, cur.cwd);
|
|
1416
|
+
try {
|
|
1417
|
+
const written = await addServerToConfig(path, spec);
|
|
1418
|
+
note(`${written.replaced ? "replaced" : "added"} '${spec.name}' in ${path} — connecting…`);
|
|
1419
|
+
const status = await cur.toolContext.mcp?.addServer(spec.config);
|
|
1420
|
+
if (status?.state === "connected") {
|
|
1421
|
+
say(`${spec.name}: connected — ${status.toolCount} tool${status.toolCount === 1 ? "" : "s"} (protocol ${status.version}).`);
|
|
1422
|
+
}
|
|
1423
|
+
else if (status) {
|
|
1424
|
+
// Say what went wrong HERE rather than leaving it to be discovered in /mcp: a
|
|
1425
|
+
// typo'd command is the most likely outcome of typing this by hand.
|
|
1426
|
+
say(`${spec.name}: ${status.state}${status.error ? ` — ${status.error}` : ""}. It's saved; fix the config and /mcp to retry.`);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
catch (e) {
|
|
1430
|
+
say(`Couldn't write ${path}: ${e instanceof Error ? e.message : String(e)}`);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* Stop a server and take it out of the config — the file half AND the live half.
|
|
1435
|
+
*
|
|
1436
|
+
* The file half alone used to be the whole of `/mcp remove`, with its own reply saying
|
|
1437
|
+
* "it stays connected until this session ends" — true, but not what "remove" sounds
|
|
1438
|
+
* like it means. `mcp.removeServer` is the live half: it closes the connection and
|
|
1439
|
+
* drops its tools from what the model is offered, right now, not at the next launch.
|
|
1440
|
+
*/
|
|
1441
|
+
async function mcpRemove(name) {
|
|
1442
|
+
const cur = session.current;
|
|
1443
|
+
const mcp = cur?.toolContext.mcp;
|
|
1444
|
+
if (!cur || !mcp)
|
|
1445
|
+
return;
|
|
1446
|
+
const gone = (await removeServerFromConfig(configPathFor("project", cur.cwd), name)) ||
|
|
1447
|
+
(await removeServerFromConfig(configPathFor("global", cur.cwd), name));
|
|
1448
|
+
const stopped = await mcp.removeServer(name);
|
|
1449
|
+
if (!gone && !stopped)
|
|
1450
|
+
return say(`No server called '${name}' is configured.`);
|
|
1451
|
+
say(`Removed '${name}'${stopped ? " and stopped it" : " from the config (it was not running)"}.`);
|
|
1452
|
+
}
|
|
1367
1453
|
/**
|
|
1368
1454
|
* `/mcp add <name> <command|url> [args…]` and `/mcp remove <name>`.
|
|
1369
1455
|
*
|
|
1370
1456
|
* Shares its parser and writer with the `add_mcp_server` tool, so a config the command
|
|
1371
|
-
* accepts is exactly one the tool would, and vice versa.
|
|
1372
|
-
* writing the file and telling the user to restart would defeat the point.
|
|
1457
|
+
* accepts is exactly one the tool would, and vice versa.
|
|
1373
1458
|
*/
|
|
1374
1459
|
async function mcpConfigCommand(arg) {
|
|
1375
|
-
const cur = session.current;
|
|
1376
|
-
if (!cur)
|
|
1377
|
-
return;
|
|
1378
1460
|
const argv = splitArgs(arg);
|
|
1379
1461
|
const verb = argv.shift();
|
|
1380
1462
|
if (verb === "remove" || verb === "rm") {
|
|
1381
1463
|
const target = argv[0];
|
|
1382
1464
|
if (!target)
|
|
1383
1465
|
return say("Usage: /mcp remove <name>");
|
|
1384
|
-
|
|
1385
|
-
const gone = (await removeServerFromConfig(configPathFor("project", root), target)) ||
|
|
1386
|
-
(await removeServerFromConfig(configPathFor("global", root), target));
|
|
1387
|
-
say(gone
|
|
1388
|
-
? `Removed '${target}' from the config. It stays connected until this session ends.`
|
|
1389
|
-
: `No server called '${target}' is configured.`);
|
|
1390
|
-
return;
|
|
1466
|
+
return mcpRemove(target);
|
|
1391
1467
|
}
|
|
1392
1468
|
const parsed = parseAddSpec(argv);
|
|
1393
1469
|
if (!parsed.ok)
|
|
1394
1470
|
return say(parsed.error);
|
|
1395
|
-
|
|
1471
|
+
await mcpWriteAndConnect(parsed.spec);
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* Flip a server's `disabled` flag and apply it live — the box's Enable/Disable.
|
|
1475
|
+
*
|
|
1476
|
+
* Reads the server's LIVE config rather than the file, because that is what is
|
|
1477
|
+
* actually true right now; writes back to whichever file already defines it
|
|
1478
|
+
* (`resolveConfigPath`), so this cannot create a second, shadowing entry for a
|
|
1479
|
+
* server that already exists in the other config.
|
|
1480
|
+
*/
|
|
1481
|
+
async function mcpSetDisabled(name, disabled) {
|
|
1482
|
+
const cur = session.current;
|
|
1483
|
+
const mcp = cur?.toolContext.mcp;
|
|
1484
|
+
const config = mcp?.configFor(name);
|
|
1485
|
+
if (!cur || !mcp || !config)
|
|
1486
|
+
return;
|
|
1487
|
+
const path = await resolveConfigPath(cur.cwd, name);
|
|
1488
|
+
const scope = path === configPathFor("global", cur.cwd) ? "global" : "project";
|
|
1489
|
+
const next = { ...config, disabled };
|
|
1396
1490
|
try {
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
if (status?.state === "connected") {
|
|
1401
|
-
say(`${parsed.spec.name}: connected — ${status.toolCount} tool${status.toolCount === 1 ? "" : "s"} (protocol ${status.version}).`);
|
|
1402
|
-
}
|
|
1403
|
-
else if (status) {
|
|
1404
|
-
// Say what went wrong HERE rather than leaving it to be discovered in /mcp: a
|
|
1405
|
-
// typo'd command is the most likely outcome of typing this by hand.
|
|
1406
|
-
say(`${parsed.spec.name}: ${status.state}${status.error ? ` — ${status.error}` : ""}. It's saved; fix the config and /mcp to retry.`);
|
|
1407
|
-
}
|
|
1491
|
+
await addServerToConfig(path, { name, scope, config: next });
|
|
1492
|
+
await mcp.addServer(next);
|
|
1493
|
+
note(`${name}: ${disabled ? "disabled" : "enabled"}.`);
|
|
1408
1494
|
}
|
|
1409
1495
|
catch (e) {
|
|
1410
|
-
|
|
1496
|
+
note(`Couldn't update ${path}: ${e instanceof Error ? e.message : String(e)}`, { error: true });
|
|
1411
1497
|
}
|
|
1412
1498
|
}
|
|
1413
|
-
// What Enter does on a /mcp row: review blocked tools if there are any, otherwise
|
|
1414
|
-
// reconnect. Blocked tools take priority because that is the state the user cannot
|
|
1415
|
-
// otherwise resolve — reconnecting will not clear a quarantine.
|
|
1416
|
-
async function mcpAction(name) {
|
|
1417
|
-
const mgr = session.current?.toolContext.mcp;
|
|
1418
|
-
if (mgr && mgr.blockedCountFor(name) > 0) {
|
|
1419
|
-
const allowed = await mgr.reviewQuarantine((q, options) => askApproval.current(q, options));
|
|
1420
|
-
note(allowed ? `${name}: blocked tools allowed for this project.` : `${name}: tools stay blocked.`);
|
|
1421
|
-
return;
|
|
1422
|
-
}
|
|
1423
|
-
await reconnectMcp(name);
|
|
1424
|
-
}
|
|
1425
1499
|
// Reconnect one MCP server by name, reporting the outcome in the chat rather than
|
|
1426
1500
|
// silently. A server that stays down is the thing a user most needs told about.
|
|
1427
1501
|
async function reconnectMcp(name) {
|
|
@@ -1439,6 +1513,57 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1439
1513
|
note(`${name} is ${status.state}${status.error ? ` — ${status.error}` : ""}`);
|
|
1440
1514
|
}
|
|
1441
1515
|
}
|
|
1516
|
+
/**
|
|
1517
|
+
* Sign in to a remote MCP server. Closes the box first, for the same reason Review does:
|
|
1518
|
+
* this takes a browser, a consent screen and a minute of the user's attention, and a
|
|
1519
|
+
* footer overlay frozen on "signing in…" for that long is a frozen app.
|
|
1520
|
+
*
|
|
1521
|
+
* The URL is printed BEFORE the browser is launched. Launching silently fails on
|
|
1522
|
+
* machines with no default browser or no desktop session at all, and the URL on screen
|
|
1523
|
+
* is the difference between "nothing happened" and something the user can paste.
|
|
1524
|
+
*/
|
|
1525
|
+
/**
|
|
1526
|
+
* Sign in to a remote MCP server, reporting INTO the `/mcp` box rather than out here.
|
|
1527
|
+
*
|
|
1528
|
+
* Nothing is written to the transcript, and that is the point: the whole interaction
|
|
1529
|
+
* belongs to one screen about one server, and the alternative was a running commentary
|
|
1530
|
+
* ending in a 400-character URL pasted across the conversation. The box stays open and
|
|
1531
|
+
* shows its own progress; this just does the work and hands back the line to show.
|
|
1532
|
+
*
|
|
1533
|
+
* Throws rather than reporting failure as a string, so the box can style it as an error
|
|
1534
|
+
* and keep the server's own sentence, which is almost always the whole explanation.
|
|
1535
|
+
*/
|
|
1536
|
+
async function mcpSignIn(name, handlers) {
|
|
1537
|
+
const mgr = session.current?.toolContext.mcp;
|
|
1538
|
+
if (!mgr)
|
|
1539
|
+
throw new Error("no MCP manager in this session");
|
|
1540
|
+
const status = await mgr.authenticate(name, handlers);
|
|
1541
|
+
if (!status)
|
|
1542
|
+
throw new Error(`${name} is no longer configured`);
|
|
1543
|
+
if (status.state === "connected") {
|
|
1544
|
+
return `${name} connected — ${status.toolCount} tool${status.toolCount === 1 ? "" : "s"}`;
|
|
1545
|
+
}
|
|
1546
|
+
throw new Error(status.error ? `${name} is ${status.state} — ${status.error}` : `${name} is ${status.state}`);
|
|
1547
|
+
}
|
|
1548
|
+
/** Drop a stored MCP credential. The connection is left running: it keeps working until
|
|
1549
|
+
* its current token is refused, and that 401 is what honestly returns it to needs-auth. */
|
|
1550
|
+
async function mcpSignOut(name) {
|
|
1551
|
+
const mgr = session.current?.toolContext.mcp;
|
|
1552
|
+
if (!mgr)
|
|
1553
|
+
return;
|
|
1554
|
+
await mgr.signOut(name);
|
|
1555
|
+
note(`signed out of ${name}.`);
|
|
1556
|
+
}
|
|
1557
|
+
/** Review blocked tools from the /mcp box: closes it first, since the approval channel
|
|
1558
|
+
* and the box's own screen cannot share the one footer surface at the same time. */
|
|
1559
|
+
async function mcpReviewBlocked(name) {
|
|
1560
|
+
setMcpOpen(false);
|
|
1561
|
+
const mgr = session.current?.toolContext.mcp;
|
|
1562
|
+
if (!mgr)
|
|
1563
|
+
return;
|
|
1564
|
+
const allowed = await mgr.reviewQuarantine((q, options) => askApproval.current(q, options));
|
|
1565
|
+
note(allowed ? `${name}: blocked tools allowed for this project.` : `${name}: tools stay blocked.`);
|
|
1566
|
+
}
|
|
1442
1567
|
// Stop every root's background lane and any running shells from the current
|
|
1443
1568
|
// session (before a swap).
|
|
1444
1569
|
async function stopCurrentLanes() {
|
|
@@ -1522,6 +1647,11 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1522
1647
|
const wait = revealWait({ flush: flush.current });
|
|
1523
1648
|
pumpTimer.current = setTimeout(() => {
|
|
1524
1649
|
pumpTimer.current = null;
|
|
1650
|
+
// A revealed block shifts the transcript, which can move a row out from under the
|
|
1651
|
+
// framebuffer's model and strand it on the pinned banner (see requestFullRepaint).
|
|
1652
|
+
// Set BEFORE the dispatch, so the very frame that shows the new block redraws the
|
|
1653
|
+
// banner too rather than leaving a stray row on it until the next paint.
|
|
1654
|
+
requestFullRepaint();
|
|
1525
1655
|
reveal();
|
|
1526
1656
|
lastRevealAt.current = Date.now();
|
|
1527
1657
|
pump();
|
|
@@ -1534,8 +1664,12 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
1534
1664
|
const a = revealQ.current.shift();
|
|
1535
1665
|
if (a.type === "token")
|
|
1536
1666
|
applySilent(a);
|
|
1537
|
-
else
|
|
1667
|
+
else {
|
|
1668
|
+
// An in-place resolve grows a row into its result, shifting everything below it —
|
|
1669
|
+
// the same de-sync risk a reveal carries, so heal the banner on the same frame.
|
|
1670
|
+
requestFullRepaint();
|
|
1538
1671
|
dispatch(a);
|
|
1672
|
+
}
|
|
1539
1673
|
// A group stays open once shown; anything that isn't part of it (a
|
|
1540
1674
|
// standalone tool, narration, a sub-agent) closes it, mirroring exactly
|
|
1541
1675
|
// what the transcript reducer's own closeToolGroup does on the same actions.
|
|
@@ -2113,11 +2247,6 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
2113
2247
|
note(`stopped shell #${sh.id} (${clipCmd(sh.command)})`);
|
|
2114
2248
|
}
|
|
2115
2249
|
}
|
|
2116
|
-
else if (o.kind === "mcp") {
|
|
2117
|
-
const server = o.items[index];
|
|
2118
|
-
if (server && server.state !== "disabled")
|
|
2119
|
-
void mcpAction(server.name);
|
|
2120
|
-
}
|
|
2121
2250
|
else if (o.kind === "approval") {
|
|
2122
2251
|
approvals.current.answer(o.options[index] ?? o.options[0]);
|
|
2123
2252
|
showNextApproval.current();
|
|
@@ -2432,15 +2561,15 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
2432
2561
|
await mcpConfigCommand(route.arg);
|
|
2433
2562
|
return;
|
|
2434
2563
|
}
|
|
2435
|
-
// /mcp — server
|
|
2564
|
+
// /mcp — the minitabs manager: your servers, and "+ Add a server" always at the top,
|
|
2565
|
+
// so an empty config is a place to add one rather than a dead end.
|
|
2436
2566
|
if (name === "/mcp") {
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
setOverlay({ kind: "mcp", items: mcp.statuses() });
|
|
2567
|
+
// Which servers are signed in is read once, here, before the box is shown. Doing it
|
|
2568
|
+
// as the box opens rather than on every render keeps a handful of file reads off
|
|
2569
|
+
// the render path, and this is the only moment the answer can change unseen.
|
|
2570
|
+
const mgr = session.current?.toolContext.mcp;
|
|
2571
|
+
setMcpSignedIn(mgr ? await mgr.credentialed().catch(() => new Set()) : new Set());
|
|
2572
|
+
setMcpOpen(true);
|
|
2444
2573
|
return;
|
|
2445
2574
|
}
|
|
2446
2575
|
if (name === "/continue") {
|
|
@@ -2881,6 +3010,10 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
2881
3010
|
setKeysOpen(false);
|
|
2882
3011
|
} }, keysTick));
|
|
2883
3012
|
}
|
|
3013
|
+
if (mcpOpen) {
|
|
3014
|
+
const mcp = session.current?.toolContext.mcp;
|
|
3015
|
+
return (_jsx(McpMinitabs, { servers: mcp?.statuses() ?? [], blockedCountFor: (name) => mcp?.blockedCountFor(name) ?? 0, configFor: (name) => mcp?.configFor(name), onSubmit: (spec) => void mcpWriteAndConnect(spec), onSetDisabled: (name, disabled) => void mcpSetDisabled(name, disabled), onRemove: (name) => void mcpRemove(name), onReconnect: (name) => void reconnectMcp(name), onReviewBlocked: (name) => void mcpReviewBlocked(name), signedIn: mcpSignedIn, onSignIn: mcpSignIn, onCopyLink: (url) => copyToClipboard(url), onSignOut: (name) => void mcpSignOut(name), width: width, maxRows: maxRows, onClose: () => setMcpOpen(false) }));
|
|
3016
|
+
}
|
|
2884
3017
|
if (!overlay)
|
|
2885
3018
|
return null;
|
|
2886
3019
|
const cur = session.current;
|
|
@@ -2909,14 +3042,6 @@ export function App({ resumeSessionId, initialScreen }) {
|
|
|
2909
3042
|
}));
|
|
2910
3043
|
return (_jsx(Picker, { title: "Background shells", items: items, width: width, maxRows: maxRows, onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
|
|
2911
3044
|
}
|
|
2912
|
-
if (overlay.kind === "mcp") {
|
|
2913
|
-
const mgr = cur?.toolContext.mcp;
|
|
2914
|
-
const items = overlay.items.map((srv) => ({
|
|
2915
|
-
label: mcpLabel(srv),
|
|
2916
|
-
description: mcpDetail(srv, mgr?.blockedCountFor(srv.name) ?? 0),
|
|
2917
|
-
}));
|
|
2918
|
-
return _jsx(Picker, { title: "MCP servers", items: items, width: width, maxRows: maxRows, onSelect: onOverlaySelect, onCancel: onOverlayCancel });
|
|
2919
|
-
}
|
|
2920
3045
|
if (overlay.kind === "provider") {
|
|
2921
3046
|
const activeModel = cur?.modelConfig.model ?? DEFAULT_MODEL_CONFIG.model;
|
|
2922
3047
|
const active = providerOf(activeModel).id;
|
|
@@ -3716,44 +3841,6 @@ function clipCmd(command, max = 44) {
|
|
|
3716
3841
|
function shellElapsed(sh) {
|
|
3717
3842
|
return fmtElapsed(Math.floor((Date.now() - sh.startedAt) / 1000));
|
|
3718
3843
|
}
|
|
3719
|
-
/** One MCP server's headline row: name plus a state marker readable at a glance. */
|
|
3720
|
-
export function mcpLabel(server) {
|
|
3721
|
-
const marker = server.state === "connected" ? "●" : server.state === "pending" ? "◐" : server.state === "disabled" ? "○" : "✗";
|
|
3722
|
-
return `${marker} ${server.name}`;
|
|
3723
|
-
}
|
|
3724
|
-
/** The dim detail column: what this server is doing, and what Enter would do to it. */
|
|
3725
|
-
export function mcpDetail(server, blocked = 0) {
|
|
3726
|
-
// A blocked tool is the one state the user must act on, so it outranks everything
|
|
3727
|
-
// else in the row — including a healthy connection.
|
|
3728
|
-
if (blocked > 0) {
|
|
3729
|
-
return `${blocked} tool${blocked === 1 ? "" : "s"} BLOCKED (description changed) — Enter to review`;
|
|
3730
|
-
}
|
|
3731
|
-
switch (server.state) {
|
|
3732
|
-
case "connected": {
|
|
3733
|
-
// Prompts are counted separately because they are the user's to invoke, not the
|
|
3734
|
-
// model's: a server offering only prompts would otherwise read as "0 tools" and
|
|
3735
|
-
// look broken.
|
|
3736
|
-
const counts = [
|
|
3737
|
-
`${server.toolCount} tool${server.toolCount === 1 ? "" : "s"}`,
|
|
3738
|
-
...(server.promptCount ? [`${server.promptCount} prompt${server.promptCount === 1 ? "" : "s"}`] : []),
|
|
3739
|
-
...(server.offersResources ? ["resources"] : []),
|
|
3740
|
-
];
|
|
3741
|
-
const tools = counts.join(", ");
|
|
3742
|
-
// The negotiated revision is worth surfacing: "legacy" is why a server may be
|
|
3743
|
-
// missing capabilities, and it is otherwise invisible.
|
|
3744
|
-
const proto = server.version ? ` · ${server.version}${server.legacy ? " (legacy)" : ""}` : "";
|
|
3745
|
-
return `${tools}${proto} — Enter to reconnect`;
|
|
3746
|
-
}
|
|
3747
|
-
case "pending":
|
|
3748
|
-
return "connecting…";
|
|
3749
|
-
case "needs-auth":
|
|
3750
|
-
return "needs authentication — Enter to retry";
|
|
3751
|
-
case "disabled":
|
|
3752
|
-
return "disabled in mcp.json";
|
|
3753
|
-
default:
|
|
3754
|
-
return `${server.error ?? "failed"} — Enter to retry`;
|
|
3755
|
-
}
|
|
3756
|
-
}
|
|
3757
3844
|
/**
|
|
3758
3845
|
* The under-the-chat indicator for background shells: one running shell shows its
|
|
3759
3846
|
* command + elapsed; several collapse to a count. It self-ticks once a second while
|
|
@@ -3764,11 +3851,33 @@ export function mcpDetail(server, blocked = 0) {
|
|
|
3764
3851
|
* The port comes from the process's own startup line (see detectPort), so it is
|
|
3765
3852
|
* shown only when the server actually said where it is listening. */
|
|
3766
3853
|
function BackgroundBar({ shells }) {
|
|
3854
|
+
// Tick once a second while anything runs, so the elapsed clock beside each command
|
|
3855
|
+
// actually moves — that motion is the whole point: it says the command is still
|
|
3856
|
+
// working, not wedged. The interval is torn down the moment nothing is running.
|
|
3857
|
+
const [, setTick] = useState(0);
|
|
3858
|
+
useEffect(() => {
|
|
3859
|
+
if (shells.length === 0)
|
|
3860
|
+
return;
|
|
3861
|
+
const id = setInterval(() => setTick((t) => t + 1), 1000);
|
|
3862
|
+
return () => clearInterval(id);
|
|
3863
|
+
}, [shells.length]);
|
|
3767
3864
|
if (shells.length === 0)
|
|
3768
3865
|
return null;
|
|
3769
|
-
const
|
|
3866
|
+
const now = Date.now();
|
|
3867
|
+
const cmds = shells
|
|
3868
|
+
.map((s) => `$ ${clipCmd(s.command)}${s.port ? ` (${s.port})` : ""} ${bgClock(now - s.startedAt)}`)
|
|
3869
|
+
.join(" • ");
|
|
3770
3870
|
return (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "[BG] " }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: `${shells.length} running: ${cmds}` })] }));
|
|
3771
3871
|
}
|
|
3872
|
+
/** Elapsed for the background bar: whole seconds under a minute, `1m 20s` past it. */
|
|
3873
|
+
function bgClock(ms) {
|
|
3874
|
+
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
3875
|
+
if (seconds < 60)
|
|
3876
|
+
return `${seconds}s`;
|
|
3877
|
+
const m = Math.floor(seconds / 60);
|
|
3878
|
+
const s = seconds % 60;
|
|
3879
|
+
return s === 0 ? `${m}m` : `${m}m ${s}s`;
|
|
3880
|
+
}
|
|
3772
3881
|
// One short, useful line rendered inside the input box (see PromptInput's
|
|
3773
3882
|
// `tip` prop) — picked once per session, not rewritten every render. The
|
|
3774
3883
|
// mode/model/thinking readout already lives in the header, so this slot is
|