decorated-pi 0.2.2 → 0.4.0

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.
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Safety Detection — known secret patterns and safe-pattern exclusions
3
+ */
4
+
5
+ import { basename, extname } from "node:path";
6
+ import {
7
+ type SecretPattern,
8
+ type ConfigStringEntry,
9
+ CONFIG_FILE_EXTENSIONS,
10
+ CONFIG_BASENAME_REGEX,
11
+ SENSITIVE_CONFIG_KEY_REGEX,
12
+ PLACEHOLDER_VALUE_REGEX,
13
+ CONFIG_VALUE_MIN_LENGTH,
14
+ } from "./types.js";
15
+
16
+ // ─── High-confidence Secret Patterns (40+ known formats) ─────────────────
17
+
18
+ export const SECRET_PATTERNS: SecretPattern[] = [
19
+ // AWS
20
+ { name: "AWS Access Key ID", pattern: /AKIA[0-9A-Z]{16}/, minLength: 16, allowsSpaces: false, highConfidence: true },
21
+ { name: "AWS Secret Access Key", pattern: /(?:aws)?_?(?:secret)?_?(?:access)?_?key['"\s:=]+['"]?[0-9a-zA-Z/+]{40}['"]?/i, minLength: 30, allowsSpaces: false, highConfidence: true },
22
+ // GitHub
23
+ { name: "GitHub OAuth Token", pattern: /gho_[0-9a-zA-Z]{36}/, minLength: 36, allowsSpaces: false, highConfidence: true },
24
+ { name: "GitHub App Token", pattern: /(?:ghu|ghs)_[0-9a-zA-Z]{36}/, minLength: 36, allowsSpaces: false, highConfidence: true },
25
+ { name: "GitHub PAT", pattern: /ghp_[0-9a-zA-Z]{36}/, minLength: 36, allowsSpaces: false, highConfidence: true },
26
+ { name: "GitHub Fine-Grained Token", pattern: /github_pat_[0-9a-zA-Z_]{22,}/, minLength: 26, allowsSpaces: false, highConfidence: true },
27
+ // GitLab
28
+ { name: "GitLab PAT", pattern: /glpat-[0-9a-zA-Z\-_]{20,}/, minLength: 20, allowsSpaces: false, highConfidence: true },
29
+ { name: "GitLab Runner Token", pattern: /glrt-[0-9a-zA-Z_\-]{20,}/, minLength: 20, allowsSpaces: false, highConfidence: true },
30
+ // Slack
31
+ { name: "Slack Token", pattern: /xox[baprs]-[0-9a-zA-Z\-]{10,48}/, minLength: 15, allowsSpaces: false, highConfidence: true },
32
+ { name: "Slack Webhook URL", pattern: /https:\/\/hooks\.slack\.com\/services\/T[a-zA-Z0-9_]{8,}\/B[a-zA-Z0-9_]{8,}\/[a-zA-Z0-9_]{24}/, minLength: 60, allowsSpaces: false, highConfidence: true },
33
+ // JWT
34
+ { name: "JSON Web Token", pattern: /eyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}/, minLength: 36, allowsSpaces: false, highConfidence: true },
35
+ // Google
36
+ { name: "Google API Key", pattern: /AIza[0-9A-Za-z\-_]{35}/, minLength: 35, allowsSpaces: false, highConfidence: true },
37
+ { name: "Google OAuth Token", pattern: /ya29\.[0-9A-Za-z\-_]+/, minLength: 10, allowsSpaces: false, highConfidence: true },
38
+ // Stripe
39
+ { name: "Stripe Secret Key", pattern: /sk_live_[0-9a-zA-Z]{24,}/, minLength: 24, allowsSpaces: false, highConfidence: true },
40
+ { name: "Stripe Restricted Key", pattern: /rk_live_[0-9a-zA-Z]{24,}/, minLength: 24, allowsSpaces: false, highConfidence: true },
41
+ // Twilio / SendGrid / Discord
42
+ { name: "Twilio API Key", pattern: /SK[a-z0-9]{32}/, minLength: 30, allowsSpaces: false, highConfidence: true },
43
+ { name: "SendGrid API Key", pattern: /SG\.[a-zA-Z0-9_-]{22,}\.[a-zA-Z0-9_-]{40,}/, minLength: 40, allowsSpaces: false, highConfidence: true },
44
+ { name: "Discord Bot Token", pattern: /[MN][A-Za-z\d]{23,}\.[\w-]{6}\.[\w-]{27,}/, minLength: 40, allowsSpaces: false, highConfidence: true },
45
+ // OpenAI / Anthropic / Volcengine Ark
46
+ { name: "OpenAI API Key", pattern: /sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/, minLength: 40, allowsSpaces: false, highConfidence: true },
47
+ { name: "OpenAI API Key (New)", pattern: /sk-(?:proj-)?[a-zA-Z0-9\-_]{40,}/, minLength: 40, allowsSpaces: false, highConfidence: true },
48
+ { name: "Anthropic API Key", pattern: /sk-ant-api[0-9]{2}-[a-zA-Z0-9\-_]{80,}/, minLength: 80, allowsSpaces: false, highConfidence: true },
49
+ { name: "Volcengine Ark API Key", pattern: /ark-[a-zA-Z0-9\-_]{20,}/, minLength: 20, allowsSpaces: false, highConfidence: true },
50
+ // NPM / PyPI
51
+ { name: "NPM Token", pattern: /npm_[a-zA-Z0-9]{36}/, minLength: 36, allowsSpaces: false, highConfidence: true },
52
+ { name: "PyPI Token", pattern: /pypi-[a-zA-Z0-9_\-]{50,}/, minLength: 50, allowsSpaces: false, highConfidence: true },
53
+ // Private Keys
54
+ { name: "RSA Private Key", pattern: /-----BEGIN RSA PRIVATE KEY-----\r?\n(?:[A-Za-z0-9+/=]+\r?\n)+-----END RSA PRIVATE KEY-----/, minLength: 40, allowsSpaces: true, highConfidence: true },
55
+ { name: "OpenSSH Private Key", pattern: /-----BEGIN OPENSSH PRIVATE KEY-----\r?\n(?:[A-Za-z0-9+/=]+\r?\n)+-----END OPENSSH PRIVATE KEY-----/, minLength: 40, allowsSpaces: true, highConfidence: true },
56
+ { name: "EC Private Key", pattern: /-----BEGIN EC PRIVATE KEY-----\r?\n(?:[A-Za-z0-9+/=]+\r?\n)+-----END EC PRIVATE KEY-----/, minLength: 40, allowsSpaces: true, highConfidence: true },
57
+ { name: "PGP Private Key", pattern: /-----BEGIN PGP PRIVATE KEY BLOCK-----\r?\n(?:[A-Za-z0-9+/=]+\r?\n)+-----END PGP PRIVATE KEY BLOCK-----/, minLength: 40, allowsSpaces: true, highConfidence: true },
58
+ { name: "Generic Private Key", pattern: /-----BEGIN (ENCRYPTED )?PRIVATE KEY-----\r?\n(?:[A-Za-z0-9+/=]+\r?\n)+-----END \1PRIVATE KEY-----/, minLength: 40, allowsSpaces: true, highConfidence: true },
59
+ // Database URIs
60
+ { name: "MongoDB Connection String", pattern: /mongodb(?:\+srv)?:\/\/[^\s'"]+:[^\s'"]+@[^\s'"]+/, minLength: 20, allowsSpaces: false, highConfidence: true },
61
+ { name: "PostgreSQL Connection String", pattern: /postgres(?:ql)?:\/\/[^\s'"]+:[^\s'"]+@[^\s'"]+/, minLength: 20, allowsSpaces: false, highConfidence: true },
62
+ { name: "MySQL Connection String", pattern: /mysql:\/\/[^\s'"]+:[^\s'"]+@[^\s'"]+/, minLength: 20, allowsSpaces: false, highConfidence: true },
63
+ { name: "Redis Connection String", pattern: /redis:\/\/[^\s'"]*:[^\s'"]+@[^\s'"]+/, minLength: 15, allowsSpaces: false, highConfidence: true },
64
+ // URL-embedded passwords
65
+ { name: "Password in URL", pattern: /[a-zA-Z]{3,10}:\/\/[^/\s:@]{3,20}:[^/\s:@]{3,20}@[^\s'"]+/, minLength: 15, allowsSpaces: false, highConfidence: true },
66
+ // Generic assignments (lower confidence — checked against SAFE_PATTERNS)
67
+ { name: "Bearer Token", pattern: /[Bb]earer\s+[a-zA-Z0-9\-._~+/]+=*/, minLength: 15, allowsSpaces: false, highConfidence: false },
68
+ { name: "Basic Auth Header", pattern: /[Bb]asic\s+[a-zA-Z0-9+/]{20,}={0,2}/, minLength: 20, allowsSpaces: false, highConfidence: false },
69
+ { name: "API Key Assignment", pattern: /(?:api[_-]?key|apikey|api[_-]?secret)['"\s:=]+['"]?[a-zA-Z0-9\-._]{20,}['"]?/i, minLength: 20, allowsSpaces: false, highConfidence: false },
70
+ { name: "Secret Assignment", pattern: /(?:secret|token|password|passwd|pwd)['"\s:=]+['"]?[a-zA-Z0-9\-._!@#$%^&*]{8,}['"]?/i, minLength: 12, allowsSpaces: false, highConfidence: false },
71
+ ];
72
+
73
+ // ─── Safe Patterns (false-positive exclusion) ────────────────────────────
74
+
75
+ export const SAFE_PATTERNS: RegExp[] = [
76
+ /^https?:\/\/[a-zA-Z0-9.-]+(?:\/[a-zA-Z0-9.\/_\-?&=#%]*)?$/, // URLs without credentials
77
+ /^\.\.?\/[a-zA-Z0-9_\-./]+$/, // Relative file paths
78
+ /^\/[a-zA-Z0-9_\-./]+$/, // Absolute Unix paths
79
+ /^[a-zA-Z]:\\[a-zA-Z0-9_\-\\./]+$/, // Windows paths
80
+ /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/, // Email addresses
81
+ /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, // UUIDs
82
+ /^v?\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?(?:\+[a-zA-Z0-9.]+)?$/, // Semver
83
+ /^(?:xxx+|your[_-]?(?:api[_-]?)?key|placeholder|example|test|demo|sample)/i, // Placeholders
84
+ /^[0-9a-f]{40}$/i, // Git SHA-1
85
+ /^[0-9a-f]{64}$/i, // SHA-256
86
+ /^@[a-z0-9-]+\/[a-z0-9-]+$/, // npm scoped packages
87
+ ];
88
+
89
+ export function isSafeContent(content: string): boolean {
90
+ for (const pat of SAFE_PATTERNS) {
91
+ if (pat.test(content)) return true;
92
+ }
93
+ return false;
94
+ }
95
+
96
+ // ─── Config-file detection ───────────────────────────────────────────────
97
+
98
+ export function isConfigLikeFile(filePath?: string): boolean {
99
+ if (!filePath) return false;
100
+ const name = basename(filePath);
101
+ if (CONFIG_BASENAME_REGEX.test(name)) return true;
102
+ return CONFIG_FILE_EXTENSIONS.has(extname(name).toLowerCase());
103
+ }
104
+
105
+ const CONFIG_STRING_PATTERNS: RegExp[] = [
106
+ /(?<key>"[^"\r\n]+"|'[^'\r\n]+'|[A-Za-z0-9_.-]+)\s*[:=]\s*"(?<value>(?:\\.|[^"\\])*)"/g,
107
+ /(?<key>"[^"\r\n]+"|'[^'\r\n]+'|[A-Za-z0-9_.-]+)\s*[:=]\s*'(?<value>(?:\\.|[^'\\])*)'/g,
108
+ /(?<key>[A-Za-z0-9_.-]+)\s*=\s*(?<value>[^\r\n#;]+)/g,
109
+ ];
110
+
111
+ export function normalizeConfigKey(key: string): string {
112
+ return key
113
+ .trim()
114
+ .replace(/^['"]|['"]$/g, "")
115
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
116
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
117
+ .toLowerCase()
118
+ .replace(/[.\-\s]+/g, "_")
119
+ .replace(/_+/g, "_")
120
+ .replace(/^_+|_+$/g, "");
121
+ }
122
+
123
+ export function looksLikeSensitiveConfigValue(value: string): boolean {
124
+ const trimmed = value.trim();
125
+ if (!trimmed) return false;
126
+ if (PLACEHOLDER_VALUE_REGEX.test(trimmed)) return false;
127
+ if (isSafeContent(trimmed)) return false;
128
+ if (/^(?:true|false|null)$/i.test(trimmed)) return false;
129
+ if (/^[+-]?\d+(?:\.\d+)?$/.test(trimmed)) return false;
130
+ return trimmed.length >= CONFIG_VALUE_MIN_LENGTH;
131
+ }
132
+
133
+ export function extractConfigStringEntries(content: string): ConfigStringEntry[] {
134
+ const entries: ConfigStringEntry[] = [];
135
+ const seen = new Set<string>();
136
+
137
+ for (const pattern of CONFIG_STRING_PATTERNS) {
138
+ for (const match of content.matchAll(pattern)) {
139
+ const key = match.groups?.key;
140
+ const value = match.groups?.value;
141
+ if (!key || value === undefined || match.index === undefined) continue;
142
+ const full = match[0] ?? "";
143
+ const rel = full.indexOf(value);
144
+ if (rel < 0) continue;
145
+ const start = match.index + rel;
146
+ const end = start + value.length;
147
+ const dedupeKey = `${start}-${end}`;
148
+ if (seen.has(dedupeKey)) continue;
149
+ seen.add(dedupeKey);
150
+ entries.push({ key, normalizedKey: normalizeConfigKey(key), value, start, end });
151
+ }
152
+ }
153
+
154
+ return entries;
155
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Safety Detection — shared types and constants
3
+ */
4
+
5
+ // ─── Match types ──────────────────────────────────────────────────────────
6
+
7
+ export type SecretMatchSource = "pattern" | "regex" | "entropy";
8
+
9
+ export interface SecretMatch {
10
+ name: string;
11
+ start: number;
12
+ end: number;
13
+ original: string;
14
+ source: SecretMatchSource;
15
+ }
16
+
17
+ export interface SecretPattern {
18
+ name: string;
19
+ pattern: RegExp;
20
+ minLength: number;
21
+ allowsSpaces: boolean;
22
+ /** If true, skip safe-pattern exclusion (unambiguous prefix) */
23
+ highConfidence: boolean;
24
+ }
25
+
26
+ export interface DetectSecretsOptions {
27
+ filePath?: string;
28
+ }
29
+
30
+ // ─── Internal types ──────────────────────────────────────────────────────
31
+
32
+ export interface ConfigStringEntry {
33
+ key: string;
34
+ normalizedKey: string;
35
+ value: string;
36
+ start: number;
37
+ end: number;
38
+ }
39
+
40
+ // ─── Constants ────────────────────────────────────────────────────────────
41
+
42
+ export const MIN_SCAN_LENGTH = 10;
43
+ export const CONFIG_VALUE_MIN_LENGTH = 32;
44
+ export const CONFIG_FILE_EXTENSIONS = new Set([
45
+ ".json", ".jsonc", ".env", ".toml", ".yaml", ".yml",
46
+ ".ini", ".cfg", ".conf", ".properties",
47
+ ]);
48
+ export const CONFIG_BASENAME_REGEX = /^\.env(?:\..+)?$/i;
49
+ export const SENSITIVE_CONFIG_KEY_REGEX = /(?:^|_)(?:apikey|api_(?:key|secret|token)|access_(?:key|token)|refresh_token|client_secret|secret(?:_key)?|private_key|bearer_token|auth(?:orization|_token)?|pass(?:word|wd)?|pwd|token|webhook_secret)(?:_|$)/i;
50
+ export const PLACEHOLDER_VALUE_REGEX = /^(?:\$\{[^}]+\}|\{\{[^}]+\}\}|<[^>]+>|xxx+|placeholder|example|sample|demo|test|changeme|your[_-]?(?:api[_-]?)?key(?:[_-]?here)?)$/i;
@@ -25,10 +25,17 @@ export interface ProviderCache {
25
25
  models: ProviderModelEntry[];
26
26
  }
27
27
 
28
+ export interface McpServerEntry {
29
+ url: string;
30
+ enabled?: boolean;
31
+ }
32
+
28
33
  export interface ModuleSettings {
29
34
  safety?: boolean;
30
35
  lsp?: boolean;
31
36
  "smart-at"?: boolean;
37
+ patch?: boolean;
38
+ mcp?: boolean;
32
39
  }
33
40
 
34
41
  export interface DecoratedPiConfig {
@@ -36,6 +43,7 @@ export interface DecoratedPiConfig {
36
43
  compactModelKey?: string | null;
37
44
  providers?: Record<string, ProviderCache>;
38
45
  modules?: ModuleSettings;
46
+ mcpServers?: Record<string, McpServerEntry>;
39
47
  }
40
48
 
41
49
  export function loadConfig(): DecoratedPiConfig {
@@ -111,6 +119,8 @@ const DEFAULT_MODULES: Required<ModuleSettings> = {
111
119
  safety: true,
112
120
  lsp: true,
113
121
  "smart-at": true,
122
+ patch: true,
123
+ mcp: true,
114
124
  };
115
125
 
116
126
  export function isModuleEnabled(name: keyof ModuleSettings): boolean {
@@ -2,14 +2,15 @@
2
2
  * Slash — 所有扩展命令
3
3
  *
4
4
  * /dp-model → 模型选择器 (TAB 切换 Image/Compact)
5
- * /dp-settings → 模块开关 (safety / lsp / smart-at)
5
+ * /dp-settings → 模块开关 (patch / safety / lsp / smart-at)
6
6
  * /retry → 中断后继续
7
7
  */
8
8
 
9
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
- import { ModelPickerComponent } from "./extend-model.js";
9
+ import type { ExtensionAPI, ExtensionContext, Theme as PiTheme } from "@earendil-works/pi-coding-agent";
10
+ import { ModelPickerComponent } from "./model-integration.js";
11
11
  import { getAllModuleSettings, setModuleEnabled, type ModuleSettings } from "./settings.js";
12
- import { Container, SettingsList, type TUI, type Theme as PiTheme, type SettingsListTheme, type Component } from "@earendil-works/pi-tui";
12
+ import { getMcpStatus } from "./mcp/index.js";
13
+ import { Container, SettingsList, Spacer, Text, type TUI, type SettingsListTheme, type Component, getKeybindings } from "@earendil-works/pi-tui";
13
14
 
14
15
  // ─── Border component (matches native DynamicBorder) ────────────────────────
15
16
 
@@ -60,15 +61,19 @@ function setupDpModelCommand(pi: ExtensionAPI) {
60
61
  // ─── /dp-settings ──────────────────────────────────────────────────────────
61
62
 
62
63
  const MODULE_LABELS: Record<keyof ModuleSettings, string> = {
63
- safety: "Safety Layer",
64
- lsp: "LSP Tools",
65
- "smart-at": "Smart @ Search",
64
+ patch: "patch",
65
+ safety: "Secret Redaction",
66
+ lsp: "LSP",
67
+ "smart-at": "@ overload",
68
+ mcp: "MCP",
66
69
  };
67
70
 
68
71
  const MODULE_DESCS: Record<keyof ModuleSettings, string> = {
69
- safety: "Command guard, protected paths, read guard, secret redaction",
72
+ patch: "Replace edit/write with patch tool (old_str/new_str replacement + overwrite)",
73
+ safety: "Redact secrets from read / bash output before they enter model context",
70
74
  lsp: "Language server diagnostics, hover, definition, references, symbols, rename",
71
75
  "smart-at": "Project-aware file search replacing default autocomplete",
76
+ mcp: "MCP client for context7 and exa (zero-config)",
72
77
  };
73
78
 
74
79
  class ModuleSettingsComponent extends Container {
@@ -117,7 +122,7 @@ function setupDpSettingsCommand(pi: ExtensionAPI) {
117
122
  (tui, theme, _kb, done) =>
118
123
  new ModuleSettingsComponent(tui, theme, () => done(undefined))
119
124
  );
120
- ctx.ui.notify("Module settings updated. /reload to apply.", "info");
125
+ ctx.ui.notify("Module settings updated. /reload to apply.", "warning");
121
126
  return;
122
127
  }
123
128
  ctx.ui.notify("dp-settings requires interactive mode.", "warning");
@@ -125,6 +130,156 @@ function setupDpSettingsCommand(pi: ExtensionAPI) {
125
130
  });
126
131
  }
127
132
 
133
+ // ─── /mcp ──────────────────────────────────────────────────────────────────
134
+
135
+ class McpStatusComponent extends Container {
136
+ private textComponent: Text;
137
+ private tui: TUI;
138
+ private theme: PiTheme;
139
+ private done: () => void;
140
+ private timer: ReturnType<typeof setInterval> | null = null;
141
+
142
+ constructor(tui: TUI, theme: PiTheme, onDone: () => void) {
143
+ super();
144
+ this.tui = tui;
145
+ this.theme = theme;
146
+ this.done = onDone;
147
+
148
+ this.addChild(new DynamicBorder(theme));
149
+ this.addChild(new Spacer(1));
150
+
151
+ this.textComponent = new Text("", 1, 0);
152
+ this.addChild(this.textComponent);
153
+
154
+ this.addChild(new Spacer(1));
155
+ this.addChild(new Text(this.theme.fg("dim", "Press q to close."), 1, 0));
156
+ this.addChild(new Spacer(1));
157
+ this.addChild(new DynamicBorder(theme));
158
+
159
+ this.refresh();
160
+
161
+ this.timer = setInterval(() => {
162
+ this.refresh();
163
+ const allSettled = getMcpStatus().every((s) => s.state !== "connecting");
164
+ if (allSettled && this.timer) {
165
+ clearInterval(this.timer);
166
+ this.timer = null;
167
+ }
168
+ }, 500);
169
+ }
170
+
171
+ private refresh() {
172
+ const servers = getMcpStatus();
173
+
174
+ if (servers.length === 0) {
175
+ this.textComponent.setText("No MCP servers configured.");
176
+ this.tui.requestRender();
177
+ return;
178
+ }
179
+
180
+ const connected = servers.filter((s) => s.state === "connected");
181
+ const connecting = servers.filter((s) => s.state === "connecting");
182
+ const failed = servers.filter((s) => s.state === "failed");
183
+
184
+ const lines: string[] = [
185
+ `MCP servers (${servers.length}):`,
186
+ "",
187
+ ];
188
+
189
+ for (const s of connected) {
190
+ lines.push(this.theme.fg("accent", `• ${s.name}`) + ` (${s.source})`);
191
+ lines.push(` URL: ${s.url}`);
192
+ lines.push(` Tools: ${s.toolCount}`);
193
+ for (const tool of s.tools) {
194
+ const desc = tool.description ? ` — ${tool.description.slice(0, 60)}` : "";
195
+ lines.push(` - ${tool.name}${desc}`);
196
+ }
197
+ lines.push("");
198
+ }
199
+
200
+ for (const s of connecting) {
201
+ lines.push(this.theme.fg("accent", `• ${s.name}`) + ` (${s.source})`);
202
+ lines.push(` URL: ${s.url}`);
203
+ lines.push(` Status: ${this.theme.fg("warning", "connecting...")}`);
204
+ lines.push("");
205
+ }
206
+
207
+ for (const s of failed) {
208
+ lines.push(this.theme.fg("accent", `• ${s.name}`) + ` (${s.source})`);
209
+ lines.push(` URL: ${s.url}`);
210
+ lines.push(` Status: ${this.theme.fg("error", "failed")} — ${s.error ?? "unknown error"}`);
211
+ lines.push("");
212
+ }
213
+
214
+ this.textComponent.setText(lines.join("\n"));
215
+ this.tui.requestRender();
216
+ }
217
+
218
+ handleInput(data: string) {
219
+ const kb = getKeybindings();
220
+ if (
221
+ data === "q" ||
222
+ data === "\r" ||
223
+ data === "\n" ||
224
+ kb.matches(data, "tui.select.cancel")
225
+ ) {
226
+ if (this.timer) {
227
+ clearInterval(this.timer);
228
+ this.timer = null;
229
+ }
230
+ this.done();
231
+ }
232
+ }
233
+
234
+ dispose() {
235
+ if (this.timer) {
236
+ clearInterval(this.timer);
237
+ this.timer = null;
238
+ }
239
+ }
240
+ }
241
+
242
+ function setupMcpCommand(pi: ExtensionAPI) {
243
+ pi.registerCommand("mcp", {
244
+ description: "Show active MCP servers and their tools",
245
+ handler: async (_args, ctx) => {
246
+ if (ctx.hasUI) {
247
+ await ctx.ui.custom<void>(
248
+ (tui, theme, _kb, done) => new McpStatusComponent(tui, theme, () => done(undefined))
249
+ );
250
+ return;
251
+ }
252
+
253
+ // Fallback for non-interactive (print / RPC) mode.
254
+ const servers = getMcpStatus();
255
+ if (servers.length === 0) {
256
+ ctx.ui.notify("No MCP servers configured.", "info");
257
+ return;
258
+ }
259
+
260
+ const lines: string[] = [`MCP servers (${servers.length}):`, ""];
261
+ for (const s of servers) {
262
+ lines.push(`• ${s.name} (${s.source})`);
263
+ lines.push(` URL: ${s.url}`);
264
+ if (s.state === "connecting") {
265
+ lines.push(` Status: connecting...`);
266
+ } else if (s.state === "failed") {
267
+ lines.push(` Status: failed — ${s.error ?? "unknown error"}`);
268
+ } else {
269
+ lines.push(` Tools: ${s.toolCount}`);
270
+ for (const tool of s.tools) {
271
+ const desc = tool.description ? ` — ${tool.description.slice(0, 60)}` : "";
272
+ lines.push(` - ${tool.name}${desc}`);
273
+ }
274
+ }
275
+ lines.push("");
276
+ }
277
+
278
+ pi.sendMessage({ customType: "mcp-status", content: lines.join("\n"), display: true }, { triggerTurn: false });
279
+ },
280
+ });
281
+ }
282
+
128
283
  // ─── /retry ────────────────────────────────────────────────────────────────
129
284
 
130
285
  function setupRetryCommand(pi: ExtensionAPI) {
@@ -163,5 +318,6 @@ function setupRetryCommand(pi: ExtensionAPI) {
163
318
  export function setupSlash(pi: ExtensionAPI) {
164
319
  setupDpModelCommand(pi);
165
320
  setupDpSettingsCommand(pi);
321
+ setupMcpCommand(pi);
166
322
  setupRetryCommand(pi);
167
323
  }