zidane 5.6.7 → 5.6.9

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 (59) hide show
  1. package/dist/{agent-D70rr6Uk.d.ts → agent-CZEvtmJk.d.ts} +45 -2
  2. package/dist/agent-CZEvtmJk.d.ts.map +1 -0
  3. package/dist/chat.d.ts +261 -5
  4. package/dist/chat.d.ts.map +1 -1
  5. package/dist/chat.js +3 -3
  6. package/dist/{index-BjwwNjQd.d.ts → index-BBH6XWFt.d.ts} +2 -2
  7. package/dist/{index-BjwwNjQd.d.ts.map → index-BBH6XWFt.d.ts.map} +1 -1
  8. package/dist/{index-8mn3PIaa.d.ts → index-Cf-131kQ.d.ts} +2 -2
  9. package/dist/index-Cf-131kQ.d.ts.map +1 -0
  10. package/dist/index.d.ts +3 -3
  11. package/dist/index.js +7 -7
  12. package/dist/{login-Btpliwct.js → login-CwLWX6vP.js} +17 -37
  13. package/dist/login-CwLWX6vP.js.map +1 -0
  14. package/dist/{mcp-ngMS0S6N.js → mcp-Wzf0qxaj.js} +120 -26
  15. package/dist/mcp-Wzf0qxaj.js.map +1 -0
  16. package/dist/mcp.d.ts +1 -1
  17. package/dist/mcp.js +1 -1
  18. package/dist/{messages-B5k4DAXy.js → messages-BfmXLDT4.js} +56 -2
  19. package/dist/messages-BfmXLDT4.js.map +1 -0
  20. package/dist/{presets-BXmWG3kd.js → presets-DAA0NaK_.js} +2 -2
  21. package/dist/{presets-BXmWG3kd.js.map → presets-DAA0NaK_.js.map} +1 -1
  22. package/dist/presets.d.ts +2 -2
  23. package/dist/presets.js +1 -1
  24. package/dist/{providers-CaJE2ToS.js → providers-C_ahnRBS.js} +2 -2
  25. package/dist/{providers-CaJE2ToS.js.map → providers-C_ahnRBS.js.map} +1 -1
  26. package/dist/providers.d.ts +1 -1
  27. package/dist/providers.js +2 -2
  28. package/dist/restate.d.ts +1 -1
  29. package/dist/session/sqlite.d.ts +1 -1
  30. package/dist/{session-BoEW_wCR.js → session-PUzXZlG6.js} +2 -2
  31. package/dist/{session-BoEW_wCR.js.map → session-PUzXZlG6.js.map} +1 -1
  32. package/dist/session.d.ts +1 -1
  33. package/dist/session.js +2 -2
  34. package/dist/skills.d.ts +2 -2
  35. package/dist/{tools-FerA0zSl.js → tools-B9aQpZVx.js} +6 -4
  36. package/dist/tools-B9aQpZVx.js.map +1 -0
  37. package/dist/tools.d.ts +2 -2
  38. package/dist/tools.js +1 -1
  39. package/dist/{transcript-anchors-C5Sp1Snh.d.ts → transcript-anchors-DQzjiKxZ.d.ts} +78 -4
  40. package/dist/transcript-anchors-DQzjiKxZ.d.ts.map +1 -0
  41. package/dist/tui.d.ts +47 -14
  42. package/dist/tui.d.ts.map +1 -1
  43. package/dist/tui.js +853 -199
  44. package/dist/tui.js.map +1 -1
  45. package/dist/{turn-operations-D-OQYUgS.js → turn-operations-Bsmu9-qU.js} +404 -15
  46. package/dist/turn-operations-Bsmu9-qU.js.map +1 -0
  47. package/dist/types.d.ts +2 -2
  48. package/docs/CHAT.md +4 -1
  49. package/docs/SKILL.md +1 -0
  50. package/docs/TUI.md +1 -0
  51. package/package.json +1 -1
  52. package/dist/agent-D70rr6Uk.d.ts.map +0 -1
  53. package/dist/index-8mn3PIaa.d.ts.map +0 -1
  54. package/dist/login-Btpliwct.js.map +0 -1
  55. package/dist/mcp-ngMS0S6N.js.map +0 -1
  56. package/dist/messages-B5k4DAXy.js.map +0 -1
  57. package/dist/tools-FerA0zSl.js.map +0 -1
  58. package/dist/transcript-anchors-C5Sp1Snh.d.ts.map +0 -1
  59. package/dist/turn-operations-D-OQYUgS.js.map +0 -1
package/dist/tui.js CHANGED
@@ -1,11 +1,11 @@
1
- import { A as getSafelist, Ar as setProviderCredential, At as SETTINGS_TOGGLES, B as oauthUsesManualCodePaste, Bn as KEYBINDING_KEY_COL_WIDTH, Br as modelSupportsReasoning, Cn as extractEditPayload, Cr as shouldAutoCompact, Ct as buildHints, D as useSafeModeQueue, Di as buildBuildSystem, Dn as summarizeEditPayload, Dt as DEFAULT_SETTINGS, E as useSafeModeActions, Et as useEnabledToggleSet, F as suggestSafelistEntry, Fn as stripEditOutcomesAnnotation, G as indexOfEntry, Gn as matchesBinding, Gr as discoverAgentsMd, Gt as useDiscovery, H as supportsOAuth, Hn as formatBindingForDisplay, In as summarizeOutcomes, It as resolveChipColor, J as discoverProjectMcps, Jt as useConfig, K as buildMcpServers, Kt as useDiscoveryOptional, L as splitPromptSegments, Lt as resolveTheme, Mn as parseEditOutcomesFromResult, Mt as clampFps, Nn as resolveApprovalForPayload, Nt as useSettings, Oi as buildPlanSystem, Ot as SETTINGS_CATEGORIES, Pn as rewriteMultiEditHeader, Qn as uniqueSkillNamesFromReferences, Qt as EDIT_TOOL_NAMES, R as formatPathForCwd, Rn as KEYBINDING_DEFS, Rr as getContextWindow, Sr as AUTO_COMPACT_MIN_GROWTH_FRACTION, St as generateSessionTitle, T as SafeModeProvider, Tn as previewEditPayload, Tt as listProjectFiles, U as buildModelCatalog, Un as groupBindings, Ut as createDiscoverySlot, V as runOAuthLogin, Vn as ensureKeybindingsFile, W as filterModelCatalog, Wn as keybindingsPath, Wr as piIdOf, Wt as DiscoveryProvider, Yt as resolveConfig, Z as createFileMcpCredentialStore, Zn as createSkillsCompletionProvider, _ as turnContextSize, _n as updateToolEventOutcomes, _t as EMPTY_HINTS, a as computeTurnAnchors, an as lastContextSizeFromTurns, at as splitMarkdownCodeBlocks, b as defaultSkillScanPaths, bn as buildUnifiedDiff, bt as truncateTrailing, c as formatToolCall, cn as marginTopFor, cr as buildLinearRamp, d as useSelectStyle, dn as stripSpawnTokensLine, dr as bootTick, ei as accentColor, en as deriveSessionTitle, er as createFilesCompletionProvider, et as McpAuthProvider, f as useSurfaces, fn as sumRunCosts, fr as buildUpdateHint, ft as makeRequestInteraction, g as finalizeStreamingMarkdownForOwner, gi as useActiveTodos, gn as turnSelectionOwnership, gt as useInteractionsQueue, h as finalizeStreamingMarkdown, hn as toolResultText, ht as useInteractionsActions, i as turnAsText, in as isVisible, j as isOnSafelist, jn as mergeApprovalAndBodyOutcomes, jt as SettingsProvider, k as addToSafelist, ki as envSection, kn as buildEditOutcomesAnnotation, kt as SETTINGS_CHOICES, l as ThemeProvider, lr as tryOpenBrowser, lt as buildResumedToolResultsTurn, m as useTheme, mn as toolCallPreview, n as deleteTurnSafely, nn as isEditErrorResult, nt as useMcpAuthState, o as TOOL_DISPLAY, oi as TODO_STATUS_GLYPHS, on as listSessionMeta, or as useCompletion, pr as useUpdateCheck, pt as pendingInteractionsFromTurns, qr as findGitRoot, qt as ConfigProvider, r as truncateTurnsAt, rn as isTurnHighlighted, rt as getMcpAuthStatus, s as displayNameFor, sr as blendHsl, st as InteractionsProvider, tn as eventsFromTurns, tt as useMcpAuthDispatch, u as useColors, un as selectableTurnIds, ut as createInteractionTools, v as useStreamBuffer, vt as clipHintsToWidth, w as writeSessionExport, wn as filetypeFromPath, wr as detectAuth, x as discoverProjectSkills, y as buildSkillsConfig, yn as buildContextualDiff, yt as hintsLength, z as fetchOAuthRedirect } from "./turn-operations-D-OQYUgS.js";
2
- import { A as resolvePersistDir, B as formatTaskStatus, H as previewLine, I as ageString, L as compactPath, O as cleanupPersistedSession, R as fmtTokens, U as shortId, V as formatTaskSummary, j as resolveTasksDir, p as createAgent, z as formatDuration } from "./tools-FerA0zSl.js";
1
+ import { $n as ensureKeybindingsFile, A as getSafelist, At as clipHintsToWidth, B as oauthUsesManualCodePaste, Bi as buildPlanSystem, Bt as SETTINGS_CATEGORIES, Cn as stripSpawnTokensLine, Cr as bootTick, D as useSafeModeQueue, Dn as toolResultText, Dt as useInteractionsActions, E as useSafeModeActions, En as toolCallPreview, F as suggestSafelistEntry, Fn as extractEditPayload, Fr as shouldAutoCompact, Ft as buildHints, G as indexOfEntry, Gn as resolveApprovalForPayload, Gt as useSettings, H as supportsOAuth, Hr as setProviderCredential, Ht as SETTINGS_TOGGLES, In as filetypeFromPath, Ir as detectAuth, J as discoverProjectMcps, Jn as summarizeOutcomes, Jt as resolveChipColor, K as buildMcpServers, Kn as rewriteMultiEditHeader, L as splitPromptSegments, Ln as previewEditPayload, Lt as listProjectFiles, Mn as buildUnifiedDiff, Mt as truncateTrailing, Oi as useActiveTodos, On as turnSelectionOwnership, Ot as useInteractionsQueue, Pr as AUTO_COMPACT_MIN_GROWTH_FRACTION, Pt as generateSessionTitle, Q as loadMcpToolsCache, Qn as KEYBINDING_KEY_COL_WIDTH, Qr as modelSupportsReasoning, R as formatPathForCwd, Rt as useEnabledToggleSet, Sn as selectableTurnIds, St as createInteractionTools, T as SafeModeProvider, Tr as useUpdateCheck, Tt as pendingInteractionsFromTurns, U as buildModelCatalog, Un as mergeApprovalAndBodyOutcomes, Ut as SettingsProvider, V as runOAuthLogin, Vi as envSection, Vn as buildEditOutcomesAnnotation, Vt as SETTINGS_CHOICES, W as filterModelCatalog, Wn as parseEditOutcomesFromResult, Wt as clampFps, Xn as KEYBINDING_DEFS, Xr as getContextWindow, Yt as resolveTheme, _ as turnContextSize, _n as lastContextSizeFromTurns, _t as splitMarkdownCodeBlocks, a as computeTurnAnchors, ai as findGitRoot, an as ConfigProvider, b as defaultSkillScanPaths, bn as marginTopFor, br as buildLinearRamp, c as formatToolCall, ct as parentServerName, d as useSelectStyle, er as formatBindingForDisplay, et as refreshMcpToolsCatalog, f as useSurfaces, fi as accentColor, fn as deriveSessionTitle, fr as createFilesCompletionProvider, ft as McpAuthProvider, g as finalizeStreamingMarkdownForOwner, gn as isVisible, h as finalizeStreamingMarkdown, hn as isTurnHighlighted, ht as getMcpAuthStatus, i as turnAsText, in as useDiscoveryOptional, it as useMcpToolToggleMap, j as isOnSafelist, jn as buildContextualDiff, jt as hintsLength, k as addToSafelist, kn as updateToolEventOutcomes, kt as EMPTY_HINTS, l as ThemeProvider, lr as createSkillsCompletionProvider, lt as createFileMcpCredentialStore, m as useTheme, mn as isEditErrorResult, mt as useMcpAuthState, n as deleteTurnSafely, ni as piIdOf, nn as DiscoveryProvider, nr as keybindingsPath, nt as subscribeMcpToolsCache, o as TOOL_DISPLAY, on as useConfig, ot as buildVisibleMcpRows, pn as eventsFromTurns, pt as useMcpAuthDispatch, qn as stripEditOutcomesAnnotation, r as truncateTurnsAt, ri as discoverAgentsMd, rn as useDiscovery, rr as matchesBinding, s as displayNameFor, sn as resolveConfig, st as indexOfServerRow, tn as createDiscoverySlot, tr as groupBindings, u as useColors, un as EDIT_TOOL_NAMES, ur as uniqueSkillNamesFromReferences, v as useStreamBuffer, vi as TODO_STATUS_GLYPHS, vn as listSessionMeta, vr as useCompletion, w as writeSessionExport, wn as sumRunCosts, wr as buildUpdateHint, wt as makeRequestInteraction, x as discoverProjectSkills, xr as tryOpenBrowser, xt as buildResumedToolResultsTurn, y as buildSkillsConfig, yr as blendHsl, yt as InteractionsProvider, z as fetchOAuthRedirect, zi as buildBuildSystem, zn as summarizeEditPayload, zt as DEFAULT_SETTINGS } from "./turn-operations-Bsmu9-qU.js";
2
+ import { A as resolvePersistDir, B as formatTaskStatus, H as previewLine, I as ageString, L as compactPath, O as cleanupPersistedSession, R as fmtTokens, U as shortId, V as formatTaskSummary, j as resolveTasksDir, p as createAgent, z as formatDuration } from "./tools-B9aQpZVx.js";
3
3
  import { n as createProcessContext } from "./contexts-BOtMvzli.js";
4
4
  import { c as errorMessage } from "./errors-DdZXnyXE.js";
5
- import { s as McpOAuthProvider, t as connectMcpServers } from "./mcp-ngMS0S6N.js";
6
- import { C as summaryToTurn, a as selectFilesFromSession, r as buildPostCompactAttachments, s as compactConversation, t as loginMcpServer } from "./login-Btpliwct.js";
5
+ import { s as McpOAuthProvider, t as connectMcpServers } from "./mcp-Wzf0qxaj.js";
6
+ import { C as summaryToTurn, a as selectFilesFromSession, r as buildPostCompactAttachments, s as compactConversation, t as loginMcpServer } from "./login-CwLWX6vP.js";
7
7
  import { n as formatTokenUsage } from "./stats-Lc3zL3RM.js";
8
- import { n as loadSession, t as createSession } from "./session-BoEW_wCR.js";
8
+ import { n as loadSession, t as createSession } from "./session-PUzXZlG6.js";
9
9
  import { createTuiStore } from "./session/sqlite.js";
10
10
  import { basename, join, relative } from "node:path";
11
11
  import { homedir } from "node:os";
@@ -1967,11 +1967,40 @@ function makeMarkdownRenderNode(bag) {
1967
1967
  wrapper.marginTop = topMargin;
1968
1968
  return wrapper;
1969
1969
  }
1970
+ if (inner instanceof CodeRenderable) pinContentTrim(inner);
1970
1971
  inner.marginTop = topMargin;
1971
1972
  return inner;
1972
1973
  };
1973
1974
  }
1974
1975
  /**
1976
+ * Install an instance-level `content` accessor on a prose
1977
+ * {@link CodeRenderable} that strips trailing newlines before
1978
+ * delegating to the prototype setter. Runs once per block on
1979
+ * creation; subsequent markdown delta passes call
1980
+ * `applyMarkdownCodeRenderable` which writes `token.raw` directly into
1981
+ * `.content` and re-enters our setter, so updates stay trimmed too.
1982
+ *
1983
+ * Idempotent — re-applies cleanly if a block is recreated on a
1984
+ * type-change (which destroys + re-creates the renderable anyway).
1985
+ */
1986
+ function pinContentTrim(renderable) {
1987
+ const desc = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(renderable), "content");
1988
+ if (!desc?.set || !desc.get) return;
1989
+ const protoSet = desc.set;
1990
+ const protoGet = desc.get;
1991
+ Object.defineProperty(renderable, "content", {
1992
+ get() {
1993
+ return protoGet.call(this);
1994
+ },
1995
+ set(value) {
1996
+ const next = typeof value === "string" ? value.replace(/\n+$/, "") : value;
1997
+ protoSet.call(this, next);
1998
+ },
1999
+ configurable: true
2000
+ });
2001
+ renderable.content = renderable.content;
2002
+ }
2003
+ /**
1975
2004
  * Pull the trailing block index out of an OpenTUI renderable id. Returns
1976
2005
  * `0` for any id that doesn't match the expected `…-block-N` shape — the
1977
2006
  * spacing rule then treats unparseable ids as "first block" (no
@@ -2428,6 +2457,7 @@ function ToolCallBlock({ event, display, dim }) {
2428
2457
  });
2429
2458
  return /* @__PURE__ */ jsxs("text", {
2430
2459
  fg: dim ? COLOR.dim : COLOR.model,
2460
+ wrapMode: "none",
2431
2461
  children: [
2432
2462
  /* @__PURE__ */ jsx("span", {
2433
2463
  fg: COLOR.mute,
@@ -2807,6 +2837,7 @@ function CwdPickerModal({ currentCwd, onPick }) {
2807
2837
  */
2808
2838
  const FILES_REFRESH_THROTTLE_MS = 3e3;
2809
2839
  const SKILLS_REFRESH_THROTTLE_MS = 3e4;
2840
+ const MCP_TOOLS_CACHE_POLL_MS = 4e3;
2810
2841
  function debugLog$1(...args) {
2811
2842
  if (process.env.ZIDANE_DEBUG) process.stderr.write(`[zidane/tui] ${args.map(errorMessage).join(" ")}\n`);
2812
2843
  }
@@ -2822,6 +2853,7 @@ function DiscoveryShell({ children }) {
2822
2853
  const [mcpsCatalog, setMcpsCatalog] = useState([]);
2823
2854
  const [mcpsErrors, setMcpsErrors] = useState([]);
2824
2855
  const [filesCatalog, setFilesCatalog] = useState([]);
2856
+ const [mcpToolsByServer, setMcpToolsByServer] = useState(() => readToolsByServer(dataDir));
2825
2857
  const filesSlotRef = useRef(null);
2826
2858
  const skillsSlotRef = useRef(null);
2827
2859
  useEffect(() => {
@@ -2923,11 +2955,22 @@ function DiscoveryShell({ children }) {
2923
2955
  config.prefix,
2924
2956
  mcpCredentialStore
2925
2957
  ]);
2958
+ useEffect(() => {
2959
+ const id = setInterval(() => {
2960
+ try {
2961
+ setMcpToolsByServer(readToolsByServer(dataDir));
2962
+ } catch (err) {
2963
+ debugLog$1("mcp-tools-cache refresh failed", err);
2964
+ }
2965
+ }, MCP_TOOLS_CACHE_POLL_MS);
2966
+ return () => clearInterval(id);
2967
+ }, [dataDir]);
2926
2968
  return /* @__PURE__ */ jsx(DiscoveryProvider, {
2927
2969
  value: useMemo(() => ({
2928
2970
  skillsCatalog,
2929
2971
  mcpsCatalog,
2930
2972
  mcpsErrors,
2973
+ mcpToolsByServer,
2931
2974
  filesCatalog,
2932
2975
  refreshSkills,
2933
2976
  refreshMcps,
@@ -2938,6 +2981,7 @@ function DiscoveryShell({ children }) {
2938
2981
  skillsCatalog,
2939
2982
  mcpsCatalog,
2940
2983
  mcpsErrors,
2984
+ mcpToolsByServer,
2941
2985
  filesCatalog,
2942
2986
  refreshSkills,
2943
2987
  refreshMcps,
@@ -2948,6 +2992,19 @@ function DiscoveryShell({ children }) {
2948
2992
  children
2949
2993
  });
2950
2994
  }
2995
+ /**
2996
+ * Read the on-disk MCP tools cache into a `serverName → tools` map.
2997
+ * Strips the per-entry timestamp / transport metadata that the cache
2998
+ * file keeps for diagnostics; consumers downstream of the context
2999
+ * only need the schemas. Best-effort — a corrupt cache produces an
3000
+ * empty map and the next bootstrap re-populates.
3001
+ */
3002
+ function readToolsByServer(dataDir) {
3003
+ const cache = loadMcpToolsCache({ dataDir });
3004
+ const out = {};
3005
+ for (const [name, entry] of Object.entries(cache)) out[name] = entry.tools;
3006
+ return out;
3007
+ }
2951
3008
  //#endregion
2952
3009
  //#region src/tui/effort-picker.tsx
2953
3010
  const BASE_LEVELS = [
@@ -6876,6 +6933,8 @@ function PromptBlock({ userPrompts, onSubmit, completionProviders, onPopupOpenCh
6876
6933
  const focused = useModalAwareFocus();
6877
6934
  const COLOR = useColors();
6878
6935
  const SURFACE = useSurfaces();
6936
+ const { settings } = useSettings();
6937
+ const attachmentsEnabled = settings.attachmentsEnabled;
6879
6938
  const textareaRef = useRef(null);
6880
6939
  /** Auto-grow: visible content rows the textarea currently occupies (clamped 1..5). */
6881
6940
  const [contentLines, setContentLines] = useState(MIN_CONTENT_LINES);
@@ -6965,7 +7024,7 @@ function PromptBlock({ userPrompts, onSubmit, completionProviders, onPopupOpenCh
6965
7024
  if (!ta) return;
6966
7025
  const normalized = stripAnsiSequences(decodePasteBytes(event.bytes)).replace(/\r\n?/g, "\n");
6967
7026
  if (normalized.length === 0) return;
6968
- if (!normalized.includes("\n")) {
7027
+ if (attachmentsEnabled && !normalized.includes("\n")) {
6969
7028
  const trimmed = normalized.trim().replace(/^["']|["']$/g, "");
6970
7029
  let resolved = trimmed.startsWith("file://") ? decodeURIComponent(trimmed.slice(7)) : trimmed;
6971
7030
  if (resolved.startsWith("~/")) resolved = (process.env.HOME ?? "") + resolved.slice(1);
@@ -6983,7 +7042,7 @@ function PromptBlock({ userPrompts, onSubmit, completionProviders, onPopupOpenCh
6983
7042
  }
6984
7043
  } catch {}
6985
7044
  }
6986
- if (normalized.includes("\n")) {
7045
+ if (attachmentsEnabled && normalized.includes("\n")) {
6987
7046
  setAttachments((prev) => [...prev, {
6988
7047
  name: "paste.txt",
6989
7048
  content: Buffer.from(normalized, "utf-8"),
@@ -6998,7 +7057,7 @@ function PromptBlock({ userPrompts, onSubmit, completionProviders, onPopupOpenCh
6998
7057
  if (part && part.length > 0) ta.insertText(part);
6999
7058
  }
7000
7059
  syncBuffer();
7001
- }, [syncBuffer]);
7060
+ }, [syncBuffer, attachmentsEnabled]);
7002
7061
  const cycleHistory = useCallback((direction) => {
7003
7062
  if (userPrompts.length === 0 || !textareaRef.current) return;
7004
7063
  if (historyRef.current === null) historyRef.current = {
@@ -7964,6 +8023,13 @@ function statusColor(status, COLOR) {
7964
8023
  }
7965
8024
  //#endregion
7966
8025
  //#region src/tui/settings-modal.tsx
8026
+ /**
8027
+ * Stable empty default for `mcpToolsByServer` — using a fresh `{}` per
8028
+ * render would invalidate `useMemo` deps that reference it (and
8029
+ * needlessly re-run filtered / visible-row computation when the cache
8030
+ * is cold).
8031
+ */
8032
+ const EMPTY_TOOLS = Object.freeze({});
7967
8033
  const TAB_LABELS = {
7968
8034
  ...Object.fromEntries(SETTINGS_CATEGORIES.map((c) => [c.id, c.label])),
7969
8035
  keybindings: "Keybindings",
@@ -7979,6 +8045,29 @@ function anchorIdFor(index) {
7979
8045
  }
7980
8046
  const COL_TITLE = " ";
7981
8047
  const SPACER_CHECKBOX_WIDTH = " ";
8048
+ /**
8049
+ * Truncate `s` to `maxCols` terminal columns, appending an ellipsis
8050
+ * when it overflows. Used by every settings list to keep long
8051
+ * descriptions on a single line (`wrapMode="none"` clips silently
8052
+ * mid-word without an indicator; this gives the reader a clear "more
8053
+ * text was here" signal instead).
8054
+ *
8055
+ * Whitespace is normalised first so embedded `\n` in descriptions
8056
+ * doesn't blow up the budget — single space as separator everywhere.
8057
+ *
8058
+ * Column counting assumes 1 col per code point. Most descriptions in
8059
+ * this UI are ASCII; a CJK or wide-emoji description would over-count
8060
+ * its width by ~1 col per wide char but that just slightly under-
8061
+ * truncates rather than overflowing the row, which is the failure
8062
+ * mode we care about.
8063
+ */
8064
+ function truncateToCols(s, maxCols) {
8065
+ const clean = s.replace(/\s+/g, " ").trim();
8066
+ if (maxCols <= 0) return "";
8067
+ if (clean.length <= maxCols) return clean;
8068
+ if (maxCols === 1) return "…";
8069
+ return `${clean.slice(0, maxCols - 1)}…`;
8070
+ }
7982
8071
  function SettingsModal({ skillsCatalog: skillsCatalogProp, mcpsCatalog: mcpsCatalogProp, mcpsErrors: mcpsErrorsProp, keybindings, keybindingsPath, authentication, actions } = {}) {
7983
8072
  const COLOR = useColors();
7984
8073
  const SURFACE = useSurfaces();
@@ -7986,10 +8075,13 @@ function SettingsModal({ skillsCatalog: skillsCatalogProp, mcpsCatalog: mcpsCata
7986
8075
  const authState = useMcpAuthState();
7987
8076
  const inputRef = useRef(null);
7988
8077
  const scrollboxRef = useRef(null);
8078
+ const { width: termWidth } = useTerminalDimensions();
8079
+ const contentCols = Math.max(40, Math.min(160, termWidth - 4) - 2 - 4 - 2 - 1);
7989
8080
  const discovery = useDiscoveryOptional();
7990
8081
  const skillsCatalog = discovery?.skillsCatalog ?? skillsCatalogProp ?? [];
7991
8082
  const mcpsCatalog = discovery?.mcpsCatalog ?? mcpsCatalogProp ?? [];
7992
8083
  const mcpsErrors = discovery?.mcpsErrors ?? mcpsErrorsProp;
8084
+ const mcpToolsByServer = discovery?.mcpToolsByServer ?? EMPTY_TOOLS;
7993
8085
  const skillsToggle = useEnabledToggleSet({
7994
8086
  catalog: skillsCatalog,
7995
8087
  keyOf: (s) => s.name,
@@ -8000,6 +8092,10 @@ function SettingsModal({ skillsCatalog: skillsCatalogProp, mcpsCatalog: mcpsCata
8000
8092
  keyOf: (m) => m.config.name,
8001
8093
  settingKey: "enabledMcps"
8002
8094
  });
8095
+ const mcpToolToggleMap = useMcpToolToggleMap({ catalogByServer: mcpToolsByServer });
8096
+ const [expandedMcps, setExpandedMcps] = useState(() => /* @__PURE__ */ new Set());
8097
+ const onRefreshMcpToolsRef = useRef(actions?.onRefreshMcpTools);
8098
+ onRefreshMcpToolsRef.current = actions?.onRefreshMcpTools;
8003
8099
  const tabOrder = useMemo(() => {
8004
8100
  const tabs = [...SETTINGS_CATEGORIES.map((c) => c.id)];
8005
8101
  if (keybindings) tabs.push("keybindings");
@@ -8013,6 +8109,10 @@ function SettingsModal({ skillsCatalog: skillsCatalogProp, mcpsCatalog: mcpsCata
8013
8109
  useEffect(() => {
8014
8110
  if (!tabOrder.includes(activeTab)) setActiveTab(tabOrder[0]);
8015
8111
  }, [tabOrder, activeTab]);
8112
+ useEffect(() => {
8113
+ if (activeTab !== "mcps") return;
8114
+ onRefreshMcpToolsRef.current?.();
8115
+ }, [activeTab]);
8016
8116
  useEffect(() => {
8017
8117
  inputRef.current?.focus();
8018
8118
  }, []);
@@ -8061,13 +8161,22 @@ function SettingsModal({ skillsCatalog: skillsCatalogProp, mcpsCatalog: mcpsCata
8061
8161
  return buckets;
8062
8162
  }, [generalItems, query]);
8063
8163
  const filteredSkills = useMemo(() => skillsCatalog.filter((s) => matchesQuery(skillCorpus(s), query)), [skillsCatalog, query]);
8064
- const filteredMcps = useMemo(() => mcpsCatalog.filter((m) => matchesQuery(mcpCorpus(m), query)), [mcpsCatalog, query]);
8164
+ const filteredMcps = useMemo(() => mcpsCatalog.filter((m) => matchesQuery(mcpCorpus(m, mcpToolsByServer), query)), [
8165
+ mcpsCatalog,
8166
+ query,
8167
+ mcpToolsByServer
8168
+ ]);
8169
+ const mcpVisibleRows = useMemo(() => buildVisibleMcpRows(filteredMcps, expandedMcps, mcpToolsByServer), [
8170
+ filteredMcps,
8171
+ expandedMcps,
8172
+ mcpToolsByServer
8173
+ ]);
8065
8174
  const filteredProviders = useMemo(() => (authentication?.providers ?? []).filter((p) => matchesQuery(providerCorpus(p), query)), [authentication?.providers, query]);
8066
8175
  const authRowCount = filteredProviders.length + (authentication ? 1 : 0);
8067
8176
  const filteredSize = {
8068
8177
  ...Object.fromEntries(SETTINGS_CATEGORIES.map((c) => [c.id, filteredByCategory[c.id].length])),
8069
8178
  skills: filteredSkills.length,
8070
- mcps: filteredMcps.length,
8179
+ mcps: mcpVisibleRows.length,
8071
8180
  keybindings: 0,
8072
8181
  authentication: authRowCount
8073
8182
  };
@@ -8075,7 +8184,7 @@ function SettingsModal({ skillsCatalog: skillsCatalogProp, mcpsCatalog: mcpsCata
8075
8184
  const moveCursor = useCallback((delta) => setCursorByTab((prev) => {
8076
8185
  const size = filteredSize[activeTab];
8077
8186
  if (size === 0) return prev;
8078
- const next = ((prev[activeTab] + delta) % size + size) % size;
8187
+ const next = ((Math.min(prev[activeTab], size - 1) + delta) % size + size) % size;
8079
8188
  return {
8080
8189
  ...prev,
8081
8190
  [activeTab]: next
@@ -8088,19 +8197,86 @@ function SettingsModal({ skillsCatalog: skillsCatalogProp, mcpsCatalog: mcpsCata
8088
8197
  [activeTab]: 0
8089
8198
  }));
8090
8199
  }, [activeTab]);
8200
+ const expandFocusedMcpRow = useCallback(() => {
8201
+ const row = mcpVisibleRows[cursor];
8202
+ if (!row || row.kind !== "server") return;
8203
+ const name = row.entry.config.name;
8204
+ setExpandedMcps((prev) => {
8205
+ if (prev.has(name)) return prev;
8206
+ const next = new Set(prev);
8207
+ next.add(name);
8208
+ return next;
8209
+ });
8210
+ }, [mcpVisibleRows, cursor]);
8211
+ const collapseFocusedMcpRow = useCallback(() => {
8212
+ const row = mcpVisibleRows[cursor];
8213
+ if (!row) return;
8214
+ const parentName = parentServerName(row);
8215
+ if (row.kind !== "server") {
8216
+ const parentIndex = indexOfServerRow(mcpVisibleRows, parentName);
8217
+ if (parentIndex >= 0) setCursorByTab((prev) => ({
8218
+ ...prev,
8219
+ mcps: parentIndex
8220
+ }));
8221
+ }
8222
+ setExpandedMcps((prev) => {
8223
+ if (!prev.has(parentName)) return prev;
8224
+ const next = new Set(prev);
8225
+ next.delete(parentName);
8226
+ return next;
8227
+ });
8228
+ }, [mcpVisibleRows, cursor]);
8229
+ /**
8230
+ * Single `tab` press toggles between expanded / collapsed based on
8231
+ * the focused row's current state. The split helpers above remain
8232
+ * useful for `→` / `←` (a "tree-style" navigation contract some
8233
+ * users expect — expand-only / collapse-only). Toggle is the
8234
+ * default that the footer hint advertises.
8235
+ */
8236
+ const toggleExpandFocusedMcpRow = useCallback(() => {
8237
+ const row = mcpVisibleRows[cursor];
8238
+ if (!row) return;
8239
+ const parentName = parentServerName(row);
8240
+ if (expandedMcps.has(parentName)) collapseFocusedMcpRow();
8241
+ else expandFocusedMcpRow();
8242
+ }, [
8243
+ mcpVisibleRows,
8244
+ cursor,
8245
+ expandedMcps,
8246
+ collapseFocusedMcpRow,
8247
+ expandFocusedMcpRow
8248
+ ]);
8249
+ const mcpRowCount = mcpVisibleRows.length;
8091
8250
  useEffect(() => {
8092
8251
  if (activeTab === "keybindings") return;
8093
8252
  const sb = scrollboxRef.current;
8094
8253
  if (!sb) return;
8095
- const handle = requestAnimationFrame(() => {
8096
- sb.scrollChildIntoView(anchorIdFor(cursor));
8254
+ let inner = 0;
8255
+ const outer = requestAnimationFrame(() => {
8256
+ inner = requestAnimationFrame(() => {
8257
+ sb.scrollChildIntoView(anchorIdFor(cursor));
8258
+ });
8097
8259
  });
8098
- return () => cancelAnimationFrame(handle);
8260
+ return () => {
8261
+ cancelAnimationFrame(outer);
8262
+ if (inner) cancelAnimationFrame(inner);
8263
+ };
8099
8264
  }, [
8100
8265
  cursor,
8101
8266
  activeTab,
8102
- query
8267
+ query,
8268
+ mcpRowCount
8103
8269
  ]);
8270
+ useEffect(() => {
8271
+ if (mcpRowCount === 0) return;
8272
+ setCursorByTab((prev) => {
8273
+ if (prev.mcps < mcpRowCount) return prev;
8274
+ return {
8275
+ ...prev,
8276
+ mcps: mcpRowCount - 1
8277
+ };
8278
+ });
8279
+ }, [mcpRowCount]);
8104
8280
  useEffect(() => {
8105
8281
  if (activeTab !== "keybindings") return;
8106
8282
  const sb = scrollboxRef.current;
@@ -8110,7 +8286,8 @@ function SettingsModal({ skillsCatalog: skillsCatalogProp, mcpsCatalog: mcpsCata
8110
8286
  });
8111
8287
  return () => cancelAnimationFrame(handle);
8112
8288
  }, [activeTab]);
8113
- const focusedMcp = filteredMcps[cursor];
8289
+ const focusedMcpRow = activeTab === "mcps" ? mcpVisibleRows[cursor] : void 0;
8290
+ const focusedMcp = focusedMcpRow ? mcpsCatalog.find((m) => m.config.name === parentServerName(focusedMcpRow)) : void 0;
8114
8291
  const focusedMcpStatus = focusedMcp ? getMcpAuthStatus(authState, focusedMcp.config.name) : void 0;
8115
8292
  const oauthPasteActive = activeTab === "mcps" && focusedMcpStatus?.kind === "authorizing" && !!focusedMcpStatus.url;
8116
8293
  useKeyboard((key) => {
@@ -8155,6 +8332,10 @@ function SettingsModal({ skillsCatalog: skillsCatalogProp, mcpsCatalog: mcpsCata
8155
8332
  });
8156
8333
  return;
8157
8334
  }
8335
+ if (activeTab === "mcps" && key.name === "tab") {
8336
+ toggleExpandFocusedMcpRow();
8337
+ return;
8338
+ }
8158
8339
  if (key.name === "return") {
8159
8340
  if (isCategoryTab(activeTab)) {
8160
8341
  const it = filteredByCategory[activeTab][cursor];
@@ -8174,8 +8355,10 @@ function SettingsModal({ skillsCatalog: skillsCatalogProp, mcpsCatalog: mcpsCata
8174
8355
  return;
8175
8356
  }
8176
8357
  if (activeTab === "mcps") {
8177
- const m = filteredMcps[cursor];
8178
- if (m) mcpsToggle.toggle(m.config.name);
8358
+ const row = mcpVisibleRows[cursor];
8359
+ if (!row) return;
8360
+ if (row.kind === "server") mcpsToggle.toggle(row.entry.config.name);
8361
+ else if (row.kind === "tool") mcpToolToggleMap[row.serverName]?.toggle(row.tool.name);
8179
8362
  return;
8180
8363
  }
8181
8364
  if (activeTab === "keybindings") {
@@ -8198,11 +8381,12 @@ function SettingsModal({ skillsCatalog: skillsCatalogProp, mcpsCatalog: mcpsCata
8198
8381
  return;
8199
8382
  }
8200
8383
  if (activeTab === "mcps" && focusedMcp && focusedMcpStatus) {
8201
- if (key.ctrl && key.name === "l" && canLogin$1(focusedMcp, focusedMcpStatus)) {
8384
+ const onServerRow = focusedMcpRow?.kind === "server";
8385
+ if (onServerRow && key.ctrl && key.name === "l" && canLogin$1(focusedMcp, focusedMcpStatus)) {
8202
8386
  actions?.onLoginMcp?.(focusedMcp.config.name);
8203
8387
  return;
8204
8388
  }
8205
- if (key.ctrl && key.name === "o" && canLogout$1(focusedMcpStatus)) {
8389
+ if (onServerRow && key.ctrl && key.name === "o" && canLogout$1(focusedMcpStatus)) {
8206
8390
  actions?.onLogoutMcp?.(focusedMcp.config.name);
8207
8391
  return;
8208
8392
  }
@@ -8213,7 +8397,10 @@ function SettingsModal({ skillsCatalog: skillsCatalogProp, mcpsCatalog: mcpsCata
8213
8397
  actions.onRefreshSkills();
8214
8398
  return;
8215
8399
  }
8216
- if (activeTab === "mcps" && actions?.onRefreshMcps) actions.onRefreshMcps();
8400
+ if (activeTab === "mcps") {
8401
+ if (actions?.onRefreshMcps) actions.onRefreshMcps();
8402
+ if (actions?.onRefreshMcpTools) actions.onRefreshMcpTools();
8403
+ }
8217
8404
  }
8218
8405
  });
8219
8406
  const totalsByCategory = useMemo(() => {
@@ -8277,12 +8464,14 @@ function SettingsModal({ skillsCatalog: skillsCatalogProp, mcpsCatalog: mcpsCata
8277
8464
  minHeight: 4
8278
8465
  },
8279
8466
  stickyScroll: false,
8467
+ viewportCulling: false,
8280
8468
  children: [
8281
8469
  isCategoryTab(activeTab) && /* @__PURE__ */ jsx(GeneralList, {
8282
8470
  items: filteredByCategory[activeTab],
8283
8471
  cursor,
8284
8472
  highlightBg: SURFACE.selection,
8285
- query
8473
+ query,
8474
+ contentCols
8286
8475
  }),
8287
8476
  activeTab === "skills" && /* @__PURE__ */ jsx(SkillsList, {
8288
8477
  items: filteredSkills,
@@ -8290,16 +8479,22 @@ function SettingsModal({ skillsCatalog: skillsCatalogProp, mcpsCatalog: mcpsCata
8290
8479
  totalCount: skillsCatalog.length,
8291
8480
  cursor,
8292
8481
  highlightBg: SURFACE.selection,
8293
- query
8482
+ query,
8483
+ contentCols
8294
8484
  }),
8295
8485
  activeTab === "mcps" && /* @__PURE__ */ jsx(McpsList, {
8296
- items: filteredMcps,
8486
+ rows: mcpVisibleRows,
8487
+ filteredCount: filteredMcps.length,
8297
8488
  enabledSet: mcpsToggle.enabledSet,
8489
+ toolToggleMap: mcpToolToggleMap,
8490
+ toolsByServer: mcpToolsByServer,
8491
+ expanded: expandedMcps,
8298
8492
  totalCount: mcpsCatalog.length,
8299
8493
  cursor,
8300
8494
  highlightBg: SURFACE.selection,
8301
8495
  query,
8302
8496
  errors: mcpsErrors,
8497
+ contentCols,
8303
8498
  authState
8304
8499
  }),
8305
8500
  activeTab === "keybindings" && keybindings && /* @__PURE__ */ jsx(KeybindingsList, {
@@ -8331,6 +8526,7 @@ function SettingsModal({ skillsCatalog: skillsCatalogProp, mcpsCatalog: mcpsCata
8331
8526
  children: /* @__PURE__ */ jsx(Hints, {
8332
8527
  activeTab,
8333
8528
  focusedMcp,
8529
+ focusedMcpRow,
8334
8530
  focusedMcpStatus,
8335
8531
  canRefreshSkills: !!actions?.onRefreshSkills,
8336
8532
  canRefreshMcps: !!actions?.onRefreshMcps
@@ -8374,9 +8570,10 @@ function TabStrip({ order, active, counts }) {
8374
8570
  })
8375
8571
  });
8376
8572
  }
8377
- function GeneralList({ items, cursor, highlightBg, query }) {
8573
+ function GeneralList({ items, cursor, highlightBg, query, contentCols }) {
8378
8574
  const { settings } = useSettings();
8379
8575
  if (items.length === 0) return /* @__PURE__ */ jsx(EmptyRow, { label: query ? `no settings match "${query}"` : "no settings" });
8576
+ const descCols = Math.max(0, contentCols - 1 - 1 - 6);
8380
8577
  return /* @__PURE__ */ jsx("box", {
8381
8578
  style: { flexDirection: "column" },
8382
8579
  children: items.map((item, i) => {
@@ -8386,7 +8583,7 @@ function GeneralList({ items, cursor, highlightBg, query }) {
8386
8583
  if (item.kind === "toggle") return /* @__PURE__ */ jsx(ToggleRow, {
8387
8584
  id,
8388
8585
  label: item.label,
8389
- description: item.description,
8586
+ description: truncateToCols(item.description, descCols),
8390
8587
  enabled: settings[item.key],
8391
8588
  focused,
8392
8589
  bg
@@ -8397,7 +8594,7 @@ function GeneralList({ items, cursor, highlightBg, query }) {
8397
8594
  return /* @__PURE__ */ jsx(ChoiceRow, {
8398
8595
  id,
8399
8596
  label: item.label,
8400
- description: item.description,
8597
+ description: truncateToCols(item.description, descCols),
8401
8598
  value: opt?.label ?? String(current),
8402
8599
  cyclable: item.options.length > 1,
8403
8600
  focused,
@@ -8407,14 +8604,14 @@ function GeneralList({ items, cursor, highlightBg, query }) {
8407
8604
  return /* @__PURE__ */ jsx(ActionRow$1, {
8408
8605
  id,
8409
8606
  label: item.label,
8410
- description: item.description,
8607
+ description: truncateToCols(item.description, descCols),
8411
8608
  focused,
8412
8609
  bg
8413
8610
  }, item.id);
8414
8611
  })
8415
8612
  });
8416
8613
  }
8417
- function SkillsList({ items, enabledSet, totalCount, cursor, highlightBg, query }) {
8614
+ function SkillsList({ items, enabledSet, totalCount, cursor, highlightBg, query, contentCols }) {
8418
8615
  const COLOR = useColors();
8419
8616
  if (totalCount === 0) return /* @__PURE__ */ jsxs("box", {
8420
8617
  style: { flexDirection: "column" },
@@ -8449,12 +8646,14 @@ function SkillsList({ items, enabledSet, totalCount, cursor, highlightBg, query
8449
8646
  })]
8450
8647
  });
8451
8648
  if (items.length === 0) return /* @__PURE__ */ jsx(EmptyRow, { label: `no skills match "${query}"` });
8649
+ const descCols = Math.max(0, contentCols - 1 - 1 - 6);
8452
8650
  return /* @__PURE__ */ jsx("box", {
8453
8651
  style: { flexDirection: "column" },
8454
8652
  children: items.map((entry, i) => {
8455
8653
  const focused = i === cursor;
8456
8654
  const bg = focused ? highlightBg : void 0;
8457
8655
  const enabled = enabledSet.has(entry.name);
8656
+ const description = truncateToCols(entry.description ?? "", descCols);
8458
8657
  return /* @__PURE__ */ jsxs("box", {
8459
8658
  id: anchorIdFor(i),
8460
8659
  style: {
@@ -8483,13 +8682,13 @@ function SkillsList({ items, enabledSet, totalCount, cursor, highlightBg, query
8483
8682
  }), /* @__PURE__ */ jsx("text", {
8484
8683
  wrapMode: "none",
8485
8684
  fg: COLOR.mute,
8486
- children: `${COL_TITLE}${entry.description ?? ""}`
8685
+ children: `${COL_TITLE}${description}`
8487
8686
  })]
8488
8687
  }, entry.name);
8489
8688
  })
8490
8689
  });
8491
8690
  }
8492
- function McpsList({ items, enabledSet, totalCount, cursor, highlightBg, query, errors, authState }) {
8691
+ function McpsList({ rows, filteredCount, enabledSet, toolToggleMap, toolsByServer, expanded, totalCount, cursor, highlightBg, query, errors, authState, contentCols }) {
8493
8692
  const COLOR = useColors();
8494
8693
  const home = homedir();
8495
8694
  if (totalCount === 0) return /* @__PURE__ */ jsxs("box", {
@@ -8533,53 +8732,150 @@ function McpsList({ items, enabledSet, totalCount, cursor, highlightBg, query, e
8533
8732
  })
8534
8733
  ]
8535
8734
  });
8536
- if (items.length === 0) return /* @__PURE__ */ jsxs("box", {
8735
+ if (filteredCount === 0) return /* @__PURE__ */ jsxs("box", {
8537
8736
  style: { flexDirection: "column" },
8538
8737
  children: [renderMcpErrors(errors, home, COLOR.warn), /* @__PURE__ */ jsx(EmptyRow, { label: `no servers match "${query}"` })]
8539
8738
  });
8739
+ const groups = [];
8740
+ for (let i = 0; i < rows.length; i++) {
8741
+ const row = rows[i];
8742
+ if (row.kind === "server") {
8743
+ groups.push({
8744
+ serverIndex: i,
8745
+ server: row,
8746
+ children: []
8747
+ });
8748
+ continue;
8749
+ }
8750
+ const last = groups[groups.length - 1];
8751
+ if (last) last.children.push({
8752
+ row,
8753
+ index: i
8754
+ });
8755
+ }
8540
8756
  return /* @__PURE__ */ jsxs("box", {
8541
8757
  style: { flexDirection: "column" },
8542
- children: [renderMcpErrors(errors, home, COLOR.warn), items.map((entry, i) => {
8543
- const focused = i === cursor;
8544
- const bg = focused ? highlightBg : void 0;
8545
- const name = entry.config.name;
8546
- const enabled = enabledSet.has(name);
8547
- const status = getMcpAuthStatus(authState, name);
8548
- return /* @__PURE__ */ jsxs("box", {
8549
- id: anchorIdFor(i),
8550
- style: {
8551
- flexDirection: "column",
8552
- flexShrink: 0,
8553
- paddingLeft: 1,
8554
- paddingRight: 1,
8555
- backgroundColor: bg
8556
- },
8557
- children: [/* @__PURE__ */ jsxs("text", {
8558
- wrapMode: "none",
8559
- children: [
8560
- /* @__PURE__ */ jsx("span", {
8561
- fg: focused ? COLOR.brand : COLOR.mute,
8562
- children: focused ? "▶ " : " "
8563
- }),
8564
- /* @__PURE__ */ jsx("span", {
8565
- fg: enabled ? COLOR.accent : COLOR.mute,
8566
- children: enabled ? "[✓] " : "[ ] "
8567
- }),
8568
- /* @__PURE__ */ jsx("span", {
8569
- fg: focused ? COLOR.brand : COLOR.dim,
8570
- children: name
8571
- }),
8572
- renderInlineMcpBadge(status, COLOR)
8573
- ]
8574
- }), /* @__PURE__ */ jsx("text", {
8575
- wrapMode: "none",
8576
- fg: COLOR.mute,
8577
- children: `${COL_TITLE}${mcpDetail(entry)}`
8578
- })]
8579
- }, name);
8580
- })]
8758
+ children: [renderMcpErrors(errors, home, COLOR.warn), groups.map((group) => /* @__PURE__ */ jsxs("box", {
8759
+ style: { flexDirection: "column" },
8760
+ children: [renderServerListRow({
8761
+ entry: group.server.entry,
8762
+ focused: group.serverIndex === cursor,
8763
+ bg: group.serverIndex === cursor ? highlightBg : void 0,
8764
+ enabled: enabledSet.has(group.server.entry.config.name),
8765
+ expanded: expanded.has(group.server.entry.config.name),
8766
+ toolEnabledSet: toolToggleMap[group.server.entry.config.name]?.enabledSet,
8767
+ toolTotal: toolsByServer[group.server.entry.config.name]?.length,
8768
+ anchorId: anchorIdFor(group.serverIndex),
8769
+ status: getMcpAuthStatus(authState, group.server.entry.config.name),
8770
+ COLOR,
8771
+ contentCols
8772
+ }), group.children.map(({ row, index }) => {
8773
+ const focused = index === cursor;
8774
+ const bg = focused ? highlightBg : void 0;
8775
+ if (row.kind === "tool") return renderToolListRow({
8776
+ serverName: row.serverName,
8777
+ tool: row.tool,
8778
+ focused,
8779
+ bg,
8780
+ enabled: toolToggleMap[row.serverName]?.enabledSet.has(row.tool.name) ?? true,
8781
+ anchorId: anchorIdFor(index),
8782
+ COLOR,
8783
+ contentCols
8784
+ });
8785
+ return renderEmptyToolsListRow({
8786
+ serverName: row.serverName,
8787
+ anchorId: anchorIdFor(index),
8788
+ focused,
8789
+ bg,
8790
+ COLOR
8791
+ });
8792
+ })]
8793
+ }, `group:${group.server.entry.config.name}`))]
8581
8794
  });
8582
8795
  }
8796
+ function renderServerListRow({ entry, focused, bg, enabled, expanded, toolEnabledSet, toolTotal, anchorId, status, COLOR, contentCols }) {
8797
+ const name = entry.config.name;
8798
+ const chevron = expanded ? "▾" : "▸";
8799
+ const toolCountChip = toolEnabledSet && typeof toolTotal === "number" ? ` (${toolEnabledSet.size}/${toolTotal})` : "";
8800
+ const detailRaw = mcpDetail(entry);
8801
+ const detailBudget = Math.max(0, contentCols - 2 - 2 - 4 - name.length - toolCountChip.length - 10 - 3);
8802
+ const detail = detailRaw ? truncateToCols(detailRaw, detailBudget) : "";
8803
+ return /* @__PURE__ */ jsxs("text", {
8804
+ id: anchorId,
8805
+ wrapMode: "none",
8806
+ bg,
8807
+ children: [
8808
+ /* @__PURE__ */ jsx("span", {
8809
+ fg: focused ? COLOR.brand : COLOR.mute,
8810
+ children: focused ? "▶ " : " "
8811
+ }),
8812
+ /* @__PURE__ */ jsx("span", {
8813
+ fg: focused ? COLOR.brand : COLOR.mute,
8814
+ children: `${chevron} `
8815
+ }),
8816
+ /* @__PURE__ */ jsx("span", {
8817
+ fg: enabled ? COLOR.accent : COLOR.mute,
8818
+ children: enabled ? "[✓] " : "[ ] "
8819
+ }),
8820
+ /* @__PURE__ */ jsx("span", {
8821
+ fg: focused ? COLOR.brand : COLOR.dim,
8822
+ children: name
8823
+ }),
8824
+ toolCountChip && /* @__PURE__ */ jsx("span", {
8825
+ fg: COLOR.mute,
8826
+ children: toolCountChip
8827
+ }),
8828
+ renderInlineMcpBadge(status, COLOR),
8829
+ detail && /* @__PURE__ */ jsx("span", {
8830
+ fg: COLOR.mute,
8831
+ children: ` · ${detail}`
8832
+ })
8833
+ ]
8834
+ }, `server:${name}`);
8835
+ }
8836
+ const TOOL_ROW_INDENT$1 = " ";
8837
+ const TOOL_ROW_FOCUS_INDENT$1 = " ▶ ";
8838
+ function renderToolListRow({ serverName, tool, focused, bg, enabled, anchorId, COLOR, contentCols }) {
8839
+ const descBudget = Math.max(0, contentCols - 6 - 4 - tool.name.length - 2);
8840
+ const description = truncateToCols(tool.description ?? "", descBudget);
8841
+ return /* @__PURE__ */ jsxs("text", {
8842
+ id: anchorId,
8843
+ wrapMode: "none",
8844
+ bg,
8845
+ children: [
8846
+ /* @__PURE__ */ jsx("span", {
8847
+ fg: focused ? COLOR.brand : COLOR.mute,
8848
+ children: focused ? TOOL_ROW_FOCUS_INDENT$1 : TOOL_ROW_INDENT$1
8849
+ }),
8850
+ /* @__PURE__ */ jsx("span", {
8851
+ fg: enabled ? COLOR.accent : COLOR.mute,
8852
+ children: enabled ? "[✓] " : "[ ] "
8853
+ }),
8854
+ /* @__PURE__ */ jsx("span", {
8855
+ fg: focused ? COLOR.brand : COLOR.dim,
8856
+ children: tool.name
8857
+ }),
8858
+ description && /* @__PURE__ */ jsxs("span", {
8859
+ fg: focused ? COLOR.dim : COLOR.mute,
8860
+ children: [" ", description]
8861
+ })
8862
+ ]
8863
+ }, `tool:${serverName}/${tool.name}`);
8864
+ }
8865
+ function renderEmptyToolsListRow({ serverName, anchorId, focused, bg, COLOR }) {
8866
+ return /* @__PURE__ */ jsxs("text", {
8867
+ id: anchorId,
8868
+ wrapMode: "none",
8869
+ bg,
8870
+ children: [/* @__PURE__ */ jsx("span", {
8871
+ fg: focused ? COLOR.brand : COLOR.mute,
8872
+ children: focused ? TOOL_ROW_FOCUS_INDENT$1 : TOOL_ROW_INDENT$1
8873
+ }), /* @__PURE__ */ jsx("span", {
8874
+ fg: COLOR.mute,
8875
+ children: "⟲ No cached tools yet. Send any prompt with this server enabled to populate."
8876
+ })]
8877
+ }, `tool-empty:${serverName}`);
8878
+ }
8583
8879
  function KeybindingsList({ bindings, query }) {
8584
8880
  const sections = useMemo(() => groupBindings(bindings), [bindings]);
8585
8881
  const filteredSections = useMemo(() => {
@@ -8937,21 +9233,21 @@ function renderMcpDetailPanel(entry, status, COLOR) {
8937
9233
  function renderInlineMcpBadge(status, COLOR) {
8938
9234
  switch (status.kind) {
8939
9235
  case "idle": return null;
8940
- case "authed": return /* @__PURE__ */ jsxs("span", {
9236
+ case "authed": return /* @__PURE__ */ jsx("span", {
8941
9237
  fg: COLOR.accent,
8942
- children: [" ", "✓ authed"]
9238
+ children: " · ✓ authed"
8943
9239
  });
8944
- case "needs-auth": return /* @__PURE__ */ jsxs("span", {
9240
+ case "needs-auth": return /* @__PURE__ */ jsx("span", {
8945
9241
  fg: COLOR.warn,
8946
- children: [" ", "! needs login"]
9242
+ children: " · ! needs login"
8947
9243
  });
8948
- case "authorizing": return /* @__PURE__ */ jsxs("span", {
9244
+ case "authorizing": return /* @__PURE__ */ jsx("span", {
8949
9245
  fg: COLOR.warn,
8950
- children: [" ", "… authorizing"]
9246
+ children: " · … authorizing"
8951
9247
  });
8952
- case "error": return /* @__PURE__ */ jsxs("span", {
9248
+ case "error": return /* @__PURE__ */ jsx("span", {
8953
9249
  fg: COLOR.error,
8954
- children: [" ", "✗ login failed"]
9250
+ children: " · ✗ login failed"
8955
9251
  });
8956
9252
  }
8957
9253
  }
@@ -8965,10 +9261,11 @@ function renderMcpErrors(errors, home, warnColor) {
8965
9261
  }, err.path))
8966
9262
  });
8967
9263
  }
8968
- function Hints({ activeTab, focusedMcp, focusedMcpStatus, canRefreshSkills, canRefreshMcps }) {
9264
+ function Hints({ activeTab, focusedMcp, focusedMcpRow, focusedMcpStatus, canRefreshSkills, canRefreshMcps }) {
8969
9265
  const COLOR = useColors();
8970
- const showLogin = activeTab === "mcps" && !!focusedMcp && !!focusedMcpStatus && canLogin$1(focusedMcp, focusedMcpStatus);
8971
- const showLogout = activeTab === "mcps" && !!focusedMcpStatus && canLogout$1(focusedMcpStatus);
9266
+ const onServerRow = focusedMcpRow?.kind === "server";
9267
+ const showLogin = activeTab === "mcps" && onServerRow && !!focusedMcp && !!focusedMcpStatus && canLogin$1(focusedMcp, focusedMcpStatus);
9268
+ const showLogout = activeTab === "mcps" && onServerRow && !!focusedMcpStatus && canLogout$1(focusedMcpStatus);
8972
9269
  const showCancel = activeTab === "mcps" && focusedMcpStatus?.kind === "authorizing";
8973
9270
  const showRefresh = activeTab === "skills" && canRefreshSkills || activeTab === "mcps" && canRefreshMcps;
8974
9271
  return /* @__PURE__ */ jsxs("text", {
@@ -8989,6 +9286,14 @@ function Hints({ activeTab, focusedMcp, focusedMcpStatus, canRefreshSkills, canR
8989
9286
  children: "↵"
8990
9287
  }),
8991
9288
  isCategoryTab(activeTab) ? " toggle/cycle/select" : activeTab === "keybindings" ? " edit file" : activeTab === "authentication" ? " switch / re-authenticate" : " toggle",
9289
+ activeTab === "mcps" && focusedMcpRow && /* @__PURE__ */ jsxs("span", { children: [
9290
+ " · ",
9291
+ /* @__PURE__ */ jsx("span", {
9292
+ fg: COLOR.warn,
9293
+ children: "tab"
9294
+ }),
9295
+ focusedMcpRow.kind === "server" ? " expand/collapse" : " collapse"
9296
+ ] }),
8992
9297
  showLogin && /* @__PURE__ */ jsxs("span", { children: [
8993
9298
  " · ",
8994
9299
  /* @__PURE__ */ jsx("span", {
@@ -9044,13 +9349,15 @@ function generalCorpus(item) {
9044
9349
  function skillCorpus(s) {
9045
9350
  return `${s.name} ${s.description ?? ""}`.toLowerCase();
9046
9351
  }
9047
- function mcpCorpus(m) {
9352
+ function mcpCorpus(m, toolsByServer) {
9353
+ const toolCorpus = (toolsByServer[m.config.name] ?? []).map((t) => `${t.name} ${t.description ?? ""}`).join(" ");
9048
9354
  return [
9049
9355
  m.config.name,
9050
9356
  m.config.transport,
9051
9357
  m.config.command ?? "",
9052
9358
  m.config.url ?? "",
9053
- (m.config.args ?? []).join(" ")
9359
+ (m.config.args ?? []).join(" "),
9360
+ toolCorpus
9054
9361
  ].join(" ").toLowerCase();
9055
9362
  }
9056
9363
  function keybindingCorpus(row) {
@@ -9897,6 +10204,8 @@ function AppShell() {
9897
10204
  mcpsCatalogRef.current = mcpsCatalog;
9898
10205
  const enabledMcpsRef = useRef(settings.enabledMcps);
9899
10206
  enabledMcpsRef.current = settings.enabledMcps;
10207
+ const disabledMcpToolsRef = useRef(settings.disabledMcpTools);
10208
+ disabledMcpToolsRef.current = settings.disabledMcpTools;
9900
10209
  const filesCatalogRef = useRef(filesCatalog);
9901
10210
  filesCatalogRef.current = filesCatalog;
9902
10211
  const ensureFilesCatalogRef = useRef(ensureFilesCatalog);
@@ -10034,6 +10343,15 @@ function AppShell() {
10034
10343
  * otherwise.
10035
10344
  */
10036
10345
  const runningRef = useRef(false);
10346
+ const lastCtrlCRef = useRef(0);
10347
+ const [showQuitConfirm, setShowQuitConfirm] = useState(false);
10348
+ const showQuitConfirmRef = useRef(false);
10349
+ showQuitConfirmRef.current = showQuitConfirm;
10350
+ const [quitConfirmSelected, setQuitConfirmSelected] = useState("cancel");
10351
+ const quitConfirmSelectedRef = useRef("cancel");
10352
+ quitConfirmSelectedRef.current = quitConfirmSelected;
10353
+ const settingsRef = useRef(settings);
10354
+ settingsRef.current = settings;
10037
10355
  /** Token count from the most recent assistant turn (caching-aware). */
10038
10356
  const [lastInputTokens, setLastInputTokens] = useState(0);
10039
10357
  /**
@@ -10234,7 +10552,8 @@ function AppShell() {
10234
10552
  });
10235
10553
  const projectMcps = buildMcpServers({
10236
10554
  discovered: mcpsCatalogRef.current,
10237
- enabled: enabledMcpsRef.current
10555
+ enabled: enabledMcpsRef.current,
10556
+ ...disabledMcpToolsRef.current ? { disabledTools: disabledMcpToolsRef.current } : {}
10238
10557
  });
10239
10558
  const allowInteraction = allowInteractionRef.current !== false;
10240
10559
  const interactionTools = allowInteraction ? createInteractionTools({ requestInteraction: makeRequestInteraction(interactions) }) : {};
@@ -10399,6 +10718,15 @@ function AppShell() {
10399
10718
  reason
10400
10719
  });
10401
10720
  });
10721
+ agent.hooks.hook("mcp:bootstrap:end", (ctx) => {
10722
+ if (ctx.ok) return;
10723
+ if (mcpsCatalogRef.current.find((d) => d.config.name === ctx.name)?.config.auth !== "oauth") return;
10724
+ dispatchAuthRef.current({
10725
+ type: "auth-error",
10726
+ name: ctx.name,
10727
+ error: ctx.error.message
10728
+ });
10729
+ });
10402
10730
  agent.hooks.hook("mcp:auth:url", ({ name, url }) => {
10403
10731
  dispatchAuthRef.current({
10404
10732
  type: "auth-url",
@@ -10634,6 +10962,7 @@ function AppShell() {
10634
10962
  agent.hooks.hook("agent:done", () => {
10635
10963
  pendingAnnotationsRef.current.clear();
10636
10964
  });
10965
+ subscribeMcpToolsCache(agent.hooks, { dataDir });
10637
10966
  return agent;
10638
10967
  }, [
10639
10968
  providerRegistry,
@@ -11311,6 +11640,24 @@ function AppShell() {
11311
11640
  const onCancelLoginMcp = useCallback((name) => {
11312
11641
  mcpLoginAbortsRef.current.get(name)?.abort();
11313
11642
  }, []);
11643
+ const onRefreshMcpTools = useCallback(async () => {
11644
+ const agent = agentRef.current;
11645
+ if (!agent) return;
11646
+ const servers = mcpsCatalogRef.current.map((d) => d.config);
11647
+ if (servers.length === 0) return;
11648
+ try {
11649
+ await refreshMcpToolsCatalog({
11650
+ servers,
11651
+ hooks: agent.hooks,
11652
+ buildAuthProvider: (cfg) => new McpOAuthProvider({
11653
+ name: cfg.name,
11654
+ store: mcpCredentialStore
11655
+ })
11656
+ });
11657
+ } catch (err) {
11658
+ debugLog("refreshMcpToolsCatalog failed", err);
11659
+ }
11660
+ }, [mcpCredentialStore]);
11314
11661
  const onOpenKeybindingsFile = useCallback(() => {
11315
11662
  modal.close();
11316
11663
  (async () => {
@@ -11817,7 +12164,8 @@ function AppShell() {
11817
12164
  onLogoutMcp,
11818
12165
  onCancelLoginMcp,
11819
12166
  onRefreshSkills,
11820
- onRefreshMcps
12167
+ onRefreshMcps,
12168
+ onRefreshMcpTools
11821
12169
  }
11822
12170
  }));
11823
12171
  return;
@@ -11910,6 +12258,58 @@ function AppShell() {
11910
12258
  }));
11911
12259
  return;
11912
12260
  }
12261
+ if (showQuitConfirmRef.current) {
12262
+ if (key.name === "escape") {
12263
+ setShowQuitConfirm(false);
12264
+ setQuitConfirmSelected("cancel");
12265
+ return;
12266
+ }
12267
+ if (key.name === "c" && key.ctrl) {
12268
+ renderer.destroy();
12269
+ return;
12270
+ }
12271
+ if (key.name === "return") {
12272
+ if (quitConfirmSelectedRef.current === "quit") renderer.destroy();
12273
+ else {
12274
+ setShowQuitConfirm(false);
12275
+ setQuitConfirmSelected("cancel");
12276
+ }
12277
+ return;
12278
+ }
12279
+ if (key.name === "tab" || key.name === "left" || key.name === "right") {
12280
+ setQuitConfirmSelected((prev) => prev === "cancel" ? "quit" : "cancel");
12281
+ return;
12282
+ }
12283
+ return;
12284
+ }
12285
+ if (key.name === "c" && key.ctrl) {
12286
+ const mode = settingsRef.current.ctrlCBehavior;
12287
+ if (mode === "quit") {
12288
+ renderer.destroy();
12289
+ return;
12290
+ }
12291
+ if (mode === "quit-confirm") {
12292
+ setShowQuitConfirm(true);
12293
+ setQuitConfirmSelected("cancel");
12294
+ return;
12295
+ }
12296
+ const now = Date.now();
12297
+ if (now - lastCtrlCRef.current < 1500) {
12298
+ renderer.destroy();
12299
+ return;
12300
+ }
12301
+ lastCtrlCRef.current = now;
12302
+ if (runningRef.current || messageQueueRef.current.length > 0) {
12303
+ onAbort();
12304
+ return;
12305
+ }
12306
+ const eb = renderer.currentFocusedEditor;
12307
+ if (eb && eb.plainText.length > 0) {
12308
+ eb.clear();
12309
+ lastCtrlCRef.current = 0;
12310
+ }
12311
+ return;
12312
+ }
11913
12313
  if (key.name !== "escape") return;
11914
12314
  if (busy || pendingApproval) return onAbort();
11915
12315
  if (popupOpenRef.current) return;
@@ -12025,54 +12425,90 @@ function AppShell() {
12025
12425
  flexGrow: 1,
12026
12426
  backgroundColor: SURFACE.background
12027
12427
  },
12028
- children: [/* @__PURE__ */ jsxs("box", {
12029
- style: {
12428
+ children: [
12429
+ /* @__PURE__ */ jsxs("box", {
12430
+ style: {
12431
+ flexDirection: "column",
12432
+ flexGrow: 1,
12433
+ paddingLeft: 1,
12434
+ paddingRight: 1
12435
+ },
12436
+ children: [
12437
+ screen === "auth" && /* @__PURE__ */ jsx(AuthScreen, { onPick: onPickProvider }),
12438
+ screen === "sessions" && /* @__PURE__ */ jsx(SessionsScreen, {
12439
+ sessions,
12440
+ currentId: currentSession?.id ?? null,
12441
+ focusedSessionId,
12442
+ onPick: onSwitchSession,
12443
+ onCreate: onCreateSession,
12444
+ onFocusChange: setFocusedSessionId,
12445
+ showAllProjects: settings.showAllProjects,
12446
+ currentProjectRoot: projectDir
12447
+ }),
12448
+ screen === "chat" && /* @__PURE__ */ jsx(ChatScreen, {
12449
+ cwd,
12450
+ events,
12451
+ busy,
12452
+ compacting,
12453
+ queuedMessages: queuedMessagePreviews,
12454
+ queueSelectionIndex,
12455
+ queueShortcuts,
12456
+ onEnterQueueFromEmptyPrompt: enterQueueSelectionFromEmptyPrompt,
12457
+ settings,
12458
+ onSubmit: onSubmitPrompt,
12459
+ session: currentSession,
12460
+ liveSession: sessionRef.current,
12461
+ pending: pendingApproval,
12462
+ onApproval: resolveHead,
12463
+ pendingInteraction,
12464
+ onInteraction: onInteractionResolve,
12465
+ completionProviders,
12466
+ onPopupOpenChange,
12467
+ selectedTurnId,
12468
+ promptTriggerHints
12469
+ })
12470
+ ]
12471
+ }),
12472
+ /* @__PURE__ */ jsx(Footer, {
12473
+ hints,
12474
+ context: contextUsage,
12475
+ cost: footerCost,
12476
+ status: pendingInteraction?.kind === "question" ? "asking" : busy ? "busy" : compacting ? "compacting" : null
12477
+ }),
12478
+ showQuitConfirm && /* @__PURE__ */ jsxs("box", {
12479
+ position: "absolute",
12480
+ top: "50%",
12481
+ left: "50%",
12482
+ marginTop: -3,
12483
+ marginLeft: -18,
12484
+ width: 36,
12485
+ height: 7,
12486
+ borderStyle: "rounded",
12487
+ borderColor: COLOR.warn,
12488
+ backgroundColor: SURFACE.modal,
12030
12489
  flexDirection: "column",
12031
- flexGrow: 1,
12032
- paddingLeft: 1,
12033
- paddingRight: 1
12034
- },
12035
- children: [
12036
- screen === "auth" && /* @__PURE__ */ jsx(AuthScreen, { onPick: onPickProvider }),
12037
- screen === "sessions" && /* @__PURE__ */ jsx(SessionsScreen, {
12038
- sessions,
12039
- currentId: currentSession?.id ?? null,
12040
- focusedSessionId,
12041
- onPick: onSwitchSession,
12042
- onCreate: onCreateSession,
12043
- onFocusChange: setFocusedSessionId,
12044
- showAllProjects: settings.showAllProjects,
12045
- currentProjectRoot: projectDir
12046
- }),
12047
- screen === "chat" && /* @__PURE__ */ jsx(ChatScreen, {
12048
- cwd,
12049
- events,
12050
- busy,
12051
- compacting,
12052
- queuedMessages: queuedMessagePreviews,
12053
- queueSelectionIndex,
12054
- queueShortcuts,
12055
- onEnterQueueFromEmptyPrompt: enterQueueSelectionFromEmptyPrompt,
12056
- settings,
12057
- onSubmit: onSubmitPrompt,
12058
- session: currentSession,
12059
- liveSession: sessionRef.current,
12060
- pending: pendingApproval,
12061
- onApproval: resolveHead,
12062
- pendingInteraction,
12063
- onInteraction: onInteractionResolve,
12064
- completionProviders,
12065
- onPopupOpenChange,
12066
- selectedTurnId,
12067
- promptTriggerHints
12068
- })
12069
- ]
12070
- }), /* @__PURE__ */ jsx(Footer, {
12071
- hints,
12072
- context: contextUsage,
12073
- cost: footerCost,
12074
- status: pendingInteraction?.kind === "question" ? "asking" : busy ? "busy" : compacting ? "compacting" : null
12075
- })]
12490
+ alignItems: "center",
12491
+ justifyContent: "center",
12492
+ gap: 1,
12493
+ children: [/* @__PURE__ */ jsx("text", { children: "Quit zidane?" }), /* @__PURE__ */ jsxs("box", {
12494
+ flexDirection: "row",
12495
+ gap: 2,
12496
+ children: [/* @__PURE__ */ jsx("box", {
12497
+ backgroundColor: quitConfirmSelected === "cancel" ? COLOR.accent : void 0,
12498
+ children: /* @__PURE__ */ jsx("text", {
12499
+ fg: quitConfirmSelected === "cancel" ? SURFACE.modal : COLOR.dim,
12500
+ children: " Cancel "
12501
+ })
12502
+ }), /* @__PURE__ */ jsx("box", {
12503
+ backgroundColor: quitConfirmSelected === "quit" ? COLOR.warn : void 0,
12504
+ children: /* @__PURE__ */ jsx("text", {
12505
+ fg: quitConfirmSelected === "quit" ? SURFACE.modal : COLOR.dim,
12506
+ children: " Quit "
12507
+ })
12508
+ })]
12509
+ })]
12510
+ })
12511
+ ]
12076
12512
  });
12077
12513
  }
12078
12514
  /**
@@ -12287,55 +12723,100 @@ function initTreeSitterWorker() {
12287
12723
  //#endregion
12288
12724
  //#region src/tui/mcps-settings.tsx
12289
12725
  /**
12290
- * MCP server picker. Shows discovered entries with three columns:
12726
+ * MCP server picker. Hierarchical: each server is one row, and an
12727
+ * expanded server reveals one row per advertised tool. The full surface:
12291
12728
  *
12292
- * [enabled?] <name> <transport · detail> <auth status>
12729
+ *[] linear stdio · npx -y mcp-remote … (5/7) ✓ authed
12730
+ * [✓] create_issue Create a new Linear issue
12731
+ * [✓] list_issues Query issues by status / assignee
12732
+ * [ ] update_issue Update fields on an existing issue
12733
+ * ▶ [✓] chrome-devtools stdio · npx -y chrome-devtools-mcp@latest
12293
12734
  *
12294
12735
  * Keybindings:
12295
12736
  *
12296
- * ↑ / ↓ / k / j navigate
12297
- * enter / space toggle enabled/disabled
12298
- * l login (needs-auth or error rows; opens browser)
12299
- * o logout (authed rows; wipes stored tokens)
12300
- * esc close also cancels an in-flight `authorizing` row
12737
+ * ↑ / ↓ / k / j navigate (over servers AND visible tool rows)
12738
+ * / tab expand the focused server
12739
+ * / shift+tab collapse on a tool row, also snaps the cursor
12740
+ * back to the parent server
12741
+ * enter / space toggle server toggles `enabledMcps`; tool toggles
12742
+ * `disabledMcpTools[serverName]`
12743
+ * l login (server rows only — needs-auth / error)
12744
+ * o logout (server rows only — authed / error /
12745
+ * authorizing)
12746
+ * r refresh discovery
12747
+ * esc close — also cancels an in-flight `authorizing`
12748
+ * row
12301
12749
  *
12302
- * Status badge legend:
12750
+ * Status badge legend (server row only):
12303
12751
  *
12304
12752
  * ✓ authed tokens stored + bootstrap connected
12305
- * ! needs login bootstrap needs OAuth, no tokens
12753
+ * ! needs login bootstrap needs OAuth, no tokens
12306
12754
  * … authorizing interactive login in flight
12307
12755
  * ✗ <error> login attempt failed
12308
12756
  *
12309
- * The state behind the badges is read via `useMcpAuthState()` so updates
12310
- * propagate live while the modal is open a `mcp:auth:url` arriving from
12311
- * an in-flight login flips the row to `authorizing` without re-opening.
12757
+ * The (N/M) chip on a server header counts ENABLED / TOTAL advertised
12758
+ * tools, drawn only when the tool catalog has loaded. Without a catalog
12759
+ * (server never bootstrapped this profile, cache cold) the chip is
12760
+ * suppressed and the empty-state row under the expanded server explains
12761
+ * how to populate.
12312
12762
  *
12313
12763
  * Errors (`DiscoveryError[]`) surface in a warn-colored preamble so a
12314
12764
  * broken `mcps.json` is loud rather than invisible.
12315
12765
  */
12316
- function McpsSettingsModal({ catalog, errors, onLogin, onLogout, onCancelLogin, onRefresh }) {
12766
+ function McpsSettingsModal({ catalog, errors, toolsByServer, onLogin, onLogout, onCancelLogin, onRefresh }) {
12317
12767
  const COLOR = useColors();
12318
12768
  const home = homedir();
12319
12769
  const authState = useMcpAuthState();
12320
- const { enabledSet, toggle } = useEnabledToggleSet({
12770
+ const { enabledSet: serverEnabledSet, toggle: toggleServer } = useEnabledToggleSet({
12321
12771
  catalog,
12322
12772
  keyOf: (d) => d.config.name,
12323
12773
  settingKey: "enabledMcps"
12324
12774
  });
12775
+ const toolToggleMap = useMcpToolToggleMap({ catalogByServer: toolsByServer });
12776
+ const [expanded, setExpanded] = useState(() => /* @__PURE__ */ new Set());
12325
12777
  const [cursor, setCursorRaw] = useState(0);
12778
+ const rows = useMemo(() => buildVisibleMcpRows(catalog, expanded, toolsByServer), [
12779
+ catalog,
12780
+ expanded,
12781
+ toolsByServer
12782
+ ]);
12326
12783
  const moveCursor = useCallback((delta) => setCursorRaw((prev) => {
12327
- if (catalog.length === 0) return prev;
12328
- return ((prev + delta) % catalog.length + catalog.length) % catalog.length;
12329
- }), [catalog.length]);
12330
- const safeCursor = Math.min(cursor, Math.max(0, catalog.length - 1));
12331
- const focusedEntry = catalog[safeCursor];
12784
+ if (rows.length === 0) return prev;
12785
+ return ((prev + delta) % rows.length + rows.length) % rows.length;
12786
+ }), [rows.length]);
12787
+ const safeCursor = Math.min(cursor, Math.max(0, rows.length - 1));
12788
+ const focusedRow = rows[safeCursor];
12789
+ const focusedServerName = focusedRow ? focusedRow.kind === "server" ? focusedRow.entry.config.name : focusedRow.serverName : void 0;
12790
+ const focusedEntry = focusedServerName ? catalog.find((c) => c.config.name === focusedServerName) : void 0;
12332
12791
  const focusedStatus = focusedEntry ? getMcpAuthStatus(authState, focusedEntry.config.name) : void 0;
12333
12792
  const inputActive = focusedStatus?.kind === "authorizing" && !!focusedStatus.url;
12793
+ const collapseFocused = useCallback(() => {
12794
+ if (!focusedRow) return;
12795
+ const parentName = focusedRow.kind === "server" ? focusedRow.entry.config.name : focusedRow.serverName;
12796
+ if (focusedRow.kind !== "server") {
12797
+ const parentIndex = indexOfServerRow(rows, parentName);
12798
+ if (parentIndex >= 0) setCursorRaw(parentIndex);
12799
+ }
12800
+ setExpanded((prev) => {
12801
+ if (!prev.has(parentName)) return prev;
12802
+ const next = new Set(prev);
12803
+ next.delete(parentName);
12804
+ return next;
12805
+ });
12806
+ }, [focusedRow, rows]);
12807
+ const expandFocused = useCallback(() => {
12808
+ if (!focusedRow || focusedRow.kind !== "server") return;
12809
+ setExpanded((prev) => {
12810
+ if (prev.has(focusedRow.entry.config.name)) return prev;
12811
+ const next = new Set(prev);
12812
+ next.add(focusedRow.entry.config.name);
12813
+ return next;
12814
+ });
12815
+ }, [focusedRow]);
12334
12816
  useKeyboard((key) => {
12335
12817
  if (key.name === "escape" && focusedEntry) {
12336
- const name = focusedEntry.config.name;
12337
- if (getMcpAuthStatus(authState, name).kind === "authorizing") {
12338
- onCancelLogin(name);
12818
+ if (getMcpAuthStatus(authState, focusedEntry.config.name).kind === "authorizing") {
12819
+ onCancelLogin(focusedEntry.config.name);
12339
12820
  return;
12340
12821
  }
12341
12822
  }
@@ -12348,21 +12829,36 @@ function McpsSettingsModal({ catalog, errors, onLogin, onLogout, onCancelLogin,
12348
12829
  moveCursor(1);
12349
12830
  return;
12350
12831
  }
12351
- if (catalog.length === 0) return;
12352
- const entry = catalog[safeCursor];
12353
- if (!entry) return;
12354
- const name = entry.config.name;
12355
- const status = getMcpAuthStatus(authState, name);
12832
+ if (rows.length === 0) return;
12833
+ if (!focusedRow) return;
12834
+ if (key.name === "tab") {
12835
+ const row = rows[safeCursor];
12836
+ const parentName = row ? parentServerName(row) : void 0;
12837
+ if (parentName && expanded.has(parentName)) collapseFocused();
12838
+ else expandFocused();
12839
+ return;
12840
+ }
12841
+ if (key.name === "right") {
12842
+ expandFocused();
12843
+ return;
12844
+ }
12845
+ if (key.name === "left") {
12846
+ collapseFocused();
12847
+ return;
12848
+ }
12356
12849
  if (key.name === "return" || key.name === "space") {
12357
- toggle(name);
12850
+ if (focusedRow.kind === "server") toggleServer(focusedRow.entry.config.name);
12851
+ else if (focusedRow.kind === "tool") toolToggleMap[focusedRow.serverName]?.toggle(focusedRow.tool.name);
12358
12852
  return;
12359
12853
  }
12360
- if (key.name === "l" && canLogin(entry, status)) {
12361
- onLogin(name);
12854
+ if (focusedRow.kind !== "server") return;
12855
+ const status = getMcpAuthStatus(authState, focusedRow.entry.config.name);
12856
+ if (key.name === "l" && canLogin(focusedRow.entry, status)) {
12857
+ onLogin(focusedRow.entry.config.name);
12362
12858
  return;
12363
12859
  }
12364
12860
  if (key.name === "o" && canLogout(status)) {
12365
- onLogout(name);
12861
+ onLogout(focusedRow.entry.config.name);
12366
12862
  return;
12367
12863
  }
12368
12864
  if (key.name === "r" && onRefresh) onRefresh();
@@ -12445,46 +12941,185 @@ function McpsSettingsModal({ catalog, errors, onLogin, onLogout, onCancelLogin,
12445
12941
  })
12446
12942
  ]
12447
12943
  });
12944
+ const groups = [];
12945
+ for (let i = 0; i < rows.length; i++) {
12946
+ const row = rows[i];
12947
+ if (row.kind === "server") {
12948
+ groups.push({
12949
+ serverIndex: i,
12950
+ server: row,
12951
+ children: []
12952
+ });
12953
+ continue;
12954
+ }
12955
+ const last = groups[groups.length - 1];
12956
+ if (last) last.children.push({
12957
+ row,
12958
+ index: i
12959
+ });
12960
+ }
12448
12961
  return /* @__PURE__ */ jsxs(Modal, {
12449
- title: ` mcp servers · ${enabledSet.size} / ${catalog.length} enabled `,
12962
+ title: ` mcp servers · ${serverEnabledSet.size} / ${catalog.length} enabled `,
12450
12963
  children: [
12451
12964
  renderErrors(errors, home, COLOR.warn),
12452
12965
  /* @__PURE__ */ jsx("box", {
12453
12966
  style: { flexDirection: "column" },
12454
- children: catalog.map((entry, i) => {
12455
- const focused = i === safeCursor;
12456
- const name = entry.config.name;
12457
- const enabled = enabledSet.has(name);
12458
- const status = getMcpAuthStatus(authState, name);
12459
- return /* @__PURE__ */ jsxs("text", {
12460
- fg: focused ? COLOR.brand : COLOR.dim,
12461
- children: [
12462
- /* @__PURE__ */ jsx("span", {
12463
- fg: focused ? COLOR.brand : COLOR.mute,
12464
- children: focused ? "▶ " : " "
12465
- }),
12466
- /* @__PURE__ */ jsx("span", {
12467
- fg: enabled ? COLOR.accent : COLOR.mute,
12468
- children: enabled ? "[✓] " : "[ ] "
12469
- }),
12470
- /* @__PURE__ */ jsx("span", {
12471
- fg: focused ? COLOR.brand : COLOR.dim,
12472
- children: name
12473
- }),
12474
- /* @__PURE__ */ jsxs("span", {
12475
- fg: COLOR.mute,
12476
- children: [" ", detailFor(entry)]
12477
- }),
12478
- renderInlineBadge(status, COLOR)
12479
- ]
12480
- }, name);
12481
- })
12967
+ children: groups.map((group) => /* @__PURE__ */ jsxs("box", {
12968
+ style: { flexDirection: "column" },
12969
+ children: [renderRow({
12970
+ row: group.server,
12971
+ focused: group.serverIndex === safeCursor,
12972
+ serverEnabledSet,
12973
+ toolEnabledSet: toolToggleMap[group.server.entry.config.name]?.enabledSet,
12974
+ toolCatalog: toolsByServer,
12975
+ authState,
12976
+ expanded,
12977
+ COLOR
12978
+ }), group.children.map(({ row, index }) => renderRow({
12979
+ row,
12980
+ focused: index === safeCursor,
12981
+ serverEnabledSet,
12982
+ toolEnabledSet: toolToggleMap[parentServerName(row)]?.enabledSet,
12983
+ toolCatalog: toolsByServer,
12984
+ authState,
12985
+ expanded,
12986
+ COLOR
12987
+ }))]
12988
+ }, `group:${group.server.entry.config.name}`))
12482
12989
  }),
12483
12990
  focusedEntry && focusedStatus && renderDetailPanel(focusedEntry, focusedStatus, COLOR),
12484
- renderActionHints(focusedEntry, focusedStatus, !!onRefresh, COLOR)
12991
+ renderActionHints({
12992
+ focusedRow,
12993
+ focusedEntry,
12994
+ focusedStatus,
12995
+ expanded,
12996
+ showRefresh: !!onRefresh,
12997
+ COLOR
12998
+ })
12485
12999
  ]
12486
13000
  });
12487
13001
  }
13002
+ function renderRow({ row, focused, serverEnabledSet, toolEnabledSet, toolCatalog, authState, expanded, COLOR }) {
13003
+ if (row.kind === "server") return renderServerRow({
13004
+ entry: row.entry,
13005
+ focused,
13006
+ serverEnabledSet,
13007
+ toolEnabledSet,
13008
+ toolCatalog,
13009
+ authState,
13010
+ expanded,
13011
+ COLOR
13012
+ });
13013
+ if (row.kind === "tool") return renderToolRow({
13014
+ row,
13015
+ focused,
13016
+ toolEnabledSet,
13017
+ COLOR
13018
+ });
13019
+ return renderEmptyToolsRow({
13020
+ row,
13021
+ focused,
13022
+ COLOR
13023
+ });
13024
+ }
13025
+ function renderServerRow({ entry, focused, serverEnabledSet, toolEnabledSet, toolCatalog, authState, expanded, COLOR }) {
13026
+ const name = entry.config.name;
13027
+ const enabled = serverEnabledSet.has(name);
13028
+ const status = getMcpAuthStatus(authState, name);
13029
+ const cached = toolCatalog[name];
13030
+ const chevron = expanded.has(name) ? "▼" : "◀";
13031
+ const toolCountChip = cached && toolEnabledSet ? ` (${toolEnabledSet.size}/${cached.length})` : "";
13032
+ return /* @__PURE__ */ jsxs("box", {
13033
+ style: {
13034
+ flexDirection: "row",
13035
+ height: 1,
13036
+ paddingRight: 1
13037
+ },
13038
+ children: [
13039
+ /* @__PURE__ */ jsxs("text", {
13040
+ wrapMode: "none",
13041
+ fg: focused ? COLOR.brand : COLOR.dim,
13042
+ children: [
13043
+ /* @__PURE__ */ jsx("span", {
13044
+ fg: focused ? COLOR.brand : COLOR.mute,
13045
+ children: focused ? "▶ " : " "
13046
+ }),
13047
+ /* @__PURE__ */ jsx("span", {
13048
+ fg: enabled ? COLOR.accent : COLOR.mute,
13049
+ children: enabled ? "[✓] " : "[ ] "
13050
+ }),
13051
+ /* @__PURE__ */ jsx("span", {
13052
+ fg: focused ? COLOR.brand : COLOR.dim,
13053
+ children: name
13054
+ }),
13055
+ /* @__PURE__ */ jsxs("span", {
13056
+ fg: COLOR.mute,
13057
+ children: [" ", detailFor(entry)]
13058
+ }),
13059
+ toolCountChip && /* @__PURE__ */ jsx("span", {
13060
+ fg: COLOR.mute,
13061
+ children: toolCountChip
13062
+ }),
13063
+ renderInlineBadge(status, COLOR)
13064
+ ]
13065
+ }),
13066
+ /* @__PURE__ */ jsx("box", { style: { flexGrow: 1 } }),
13067
+ /* @__PURE__ */ jsx("text", {
13068
+ wrapMode: "none",
13069
+ children: /* @__PURE__ */ jsx("span", {
13070
+ fg: focused ? COLOR.brand : COLOR.mute,
13071
+ children: chevron
13072
+ })
13073
+ })
13074
+ ]
13075
+ }, `server:${name}`);
13076
+ }
13077
+ const TOOL_ROW_INDENT = " ";
13078
+ const TOOL_ROW_FOCUS_INDENT = " ▶ ";
13079
+ function renderToolRow({ row, focused, toolEnabledSet, COLOR }) {
13080
+ const enabled = toolEnabledSet?.has(row.tool.name) ?? true;
13081
+ const description = trimToWidth(row.tool.description ?? "", 60);
13082
+ return /* @__PURE__ */ jsxs("text", {
13083
+ fg: focused ? COLOR.brand : COLOR.dim,
13084
+ children: [
13085
+ /* @__PURE__ */ jsx("span", {
13086
+ fg: focused ? COLOR.brand : COLOR.mute,
13087
+ children: focused ? TOOL_ROW_FOCUS_INDENT : TOOL_ROW_INDENT
13088
+ }),
13089
+ /* @__PURE__ */ jsx("span", {
13090
+ fg: enabled ? COLOR.accent : COLOR.mute,
13091
+ children: enabled ? "[✓] " : "[ ] "
13092
+ }),
13093
+ /* @__PURE__ */ jsx("span", {
13094
+ fg: focused ? COLOR.brand : COLOR.dim,
13095
+ children: row.tool.name
13096
+ }),
13097
+ description && /* @__PURE__ */ jsxs("span", {
13098
+ fg: COLOR.mute,
13099
+ children: [" ", description]
13100
+ })
13101
+ ]
13102
+ }, `tool:${row.serverName}/${row.tool.name}`);
13103
+ }
13104
+ function renderEmptyToolsRow({ row, focused, COLOR }) {
13105
+ return /* @__PURE__ */ jsxs("text", { children: [/* @__PURE__ */ jsx("span", {
13106
+ fg: focused ? COLOR.brand : COLOR.mute,
13107
+ children: focused ? TOOL_ROW_FOCUS_INDENT : TOOL_ROW_INDENT
13108
+ }), /* @__PURE__ */ jsx("span", {
13109
+ fg: COLOR.mute,
13110
+ children: "⟲ No cached tools yet. Send any prompt with this server enabled to populate."
13111
+ })] }, `tool-empty:${row.serverName}`);
13112
+ }
13113
+ /**
13114
+ * Cap a single-line description at `max` columns, suffixing `…` if it
13115
+ * overflows. Renderer uses one column per char; surrogate-pair safety
13116
+ * isn't a concern here because tool descriptions are ASCII in practice.
13117
+ */
13118
+ function trimToWidth(s, max) {
13119
+ const clean = s.replace(/\s+/g, " ").trim();
13120
+ if (clean.length <= max) return clean;
13121
+ return `${clean.slice(0, Math.max(0, max - 1))}…`;
13122
+ }
12488
13123
  function detailFor(entry) {
12489
13124
  const transport = entry.config.transport;
12490
13125
  return `${transport} · ${transport === "stdio" ? entry.config.command ?? "" : entry.config.url ?? ""}`;
@@ -12590,11 +13225,14 @@ function renderDetailPanel(entry, status, COLOR) {
12590
13225
  });
12591
13226
  return null;
12592
13227
  }
12593
- function renderActionHints(entry, status, showRefresh, COLOR) {
12594
- const effectiveStatus = status ?? { kind: "idle" };
12595
- const canL = entry ? canLogin(entry, effectiveStatus) : false;
12596
- const canO = canLogout(effectiveStatus);
13228
+ function renderActionHints({ focusedRow, focusedEntry, focusedStatus, expanded, showRefresh, COLOR }) {
13229
+ const effectiveStatus = focusedStatus ?? { kind: "idle" };
13230
+ const onServerRow = focusedRow?.kind === "server";
13231
+ const canL = onServerRow && focusedEntry ? canLogin(focusedEntry, effectiveStatus) : false;
13232
+ const canO = onServerRow ? canLogout(effectiveStatus) : false;
12597
13233
  const canCancel = effectiveStatus.kind === "authorizing";
13234
+ const canExpand = onServerRow && focusedEntry ? !expanded.has(focusedEntry.config.name) : false;
13235
+ const canCollapse = focusedRow?.kind === "tool" || (onServerRow && focusedEntry ? expanded.has(focusedEntry.config.name) : false);
12598
13236
  return /* @__PURE__ */ jsxs("text", {
12599
13237
  fg: COLOR.mute,
12600
13238
  children: [
@@ -12608,6 +13246,22 @@ function renderActionHints(entry, status, showRefresh, COLOR) {
12608
13246
  children: "↵"
12609
13247
  }),
12610
13248
  " toggle",
13249
+ canExpand && /* @__PURE__ */ jsxs("span", { children: [
13250
+ " · ",
13251
+ /* @__PURE__ */ jsx("span", {
13252
+ fg: COLOR.warn,
13253
+ children: "→"
13254
+ }),
13255
+ " expand"
13256
+ ] }),
13257
+ canCollapse && /* @__PURE__ */ jsxs("span", { children: [
13258
+ " · ",
13259
+ /* @__PURE__ */ jsx("span", {
13260
+ fg: COLOR.warn,
13261
+ children: "←"
13262
+ }),
13263
+ " collapse"
13264
+ ] }),
12611
13265
  canL && /* @__PURE__ */ jsxs("span", { children: [
12612
13266
  " · ",
12613
13267
  /* @__PURE__ */ jsx("span", {
@@ -12909,7 +13563,7 @@ async function runTui(options = {}) {
12909
13563
  const bootFps = clampFps(config.initialSettings.targetFps ?? DEFAULT_SETTINGS.targetFps);
12910
13564
  const gatherStats = !!process.env.ZIDANE_DEBUG;
12911
13565
  const renderer = await createCliRenderer({
12912
- exitOnCtrlC: true,
13566
+ exitOnCtrlC: false,
12913
13567
  onDestroy: () => done(),
12914
13568
  debounceDelay: 0,
12915
13569
  targetFps: bootFps,