pi-codemcp 0.1.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.
- package/.python-version +1 -0
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/extensions/index.ts +188 -0
- package/package.json +91 -0
- package/sidecar/__init__.py +1 -0
- package/sidecar/catalog_cache.py +89 -0
- package/sidecar/chains.py +316 -0
- package/sidecar/executor.py +591 -0
- package/sidecar/gateway.py +893 -0
- package/sidecar/json_types.py +11 -0
- package/sidecar/mcp_config.py +278 -0
- package/sidecar/models.py +92 -0
- package/sidecar/pyproject.toml +144 -0
- package/sidecar/settings.py +59 -0
- package/sidecar/tool_catalog.py +838 -0
- package/sidecar/uv.lock +1775 -0
- package/src/chains.ts +452 -0
- package/src/config.ts +58 -0
- package/src/errors.ts +9 -0
- package/src/execution-rendering.ts +183 -0
- package/src/json-file.ts +54 -0
- package/src/lifecycle.ts +59 -0
- package/src/mcp-client.ts +303 -0
- package/src/modal.ts +1233 -0
- package/src/output.ts +52 -0
- package/src/settings.ts +144 -0
- package/src/tools.ts +332 -0
package/src/modal.ts
ADDED
|
@@ -0,0 +1,1233 @@
|
|
|
1
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
Box,
|
|
4
|
+
type Component,
|
|
5
|
+
type Focusable,
|
|
6
|
+
fuzzyFilter,
|
|
7
|
+
Input,
|
|
8
|
+
Key,
|
|
9
|
+
matchesKey,
|
|
10
|
+
Text,
|
|
11
|
+
truncateToWidth,
|
|
12
|
+
visibleWidth,
|
|
13
|
+
} from "@earendil-works/pi-tui";
|
|
14
|
+
import type { ChainScope, SavedChainView } from "./chains.js";
|
|
15
|
+
import { summarizeError } from "./errors.js";
|
|
16
|
+
import {
|
|
17
|
+
type CodeMcpSettings,
|
|
18
|
+
type EditableSettingKey,
|
|
19
|
+
type EditableSettingValue,
|
|
20
|
+
setEditableSetting,
|
|
21
|
+
setToolEnabled,
|
|
22
|
+
} from "./settings.js";
|
|
23
|
+
|
|
24
|
+
export interface ToolModalState {
|
|
25
|
+
name: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
enabled: boolean;
|
|
28
|
+
busy?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ChainModalState {
|
|
32
|
+
name: string;
|
|
33
|
+
scope: ChainScope;
|
|
34
|
+
description: string;
|
|
35
|
+
nativeTool: string;
|
|
36
|
+
code: string;
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
status: "ready" | "disabled" | "stale" | "shadowed";
|
|
39
|
+
inputSchema: Record<string, unknown>;
|
|
40
|
+
outputSchema: Record<string, unknown>;
|
|
41
|
+
dependencies: Array<{
|
|
42
|
+
kind: "mcp_tool" | "saved_chain";
|
|
43
|
+
call: string;
|
|
44
|
+
server: string;
|
|
45
|
+
}>;
|
|
46
|
+
staleDependencies: string[];
|
|
47
|
+
calledBy: string[];
|
|
48
|
+
busy?: boolean;
|
|
49
|
+
error?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ServerModalState {
|
|
53
|
+
name: string;
|
|
54
|
+
transport: string;
|
|
55
|
+
auth?: string;
|
|
56
|
+
enabled: boolean;
|
|
57
|
+
connected: boolean;
|
|
58
|
+
discovered: boolean;
|
|
59
|
+
toolCount: number;
|
|
60
|
+
totalToolCount: number;
|
|
61
|
+
tools: ToolModalState[];
|
|
62
|
+
busy?: boolean;
|
|
63
|
+
error?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface Keybindings {
|
|
67
|
+
matches(data: string, id: "tui.select.up" | "tui.select.down" | "tui.select.cancel"): boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ServerEnabledChange {
|
|
71
|
+
name: string;
|
|
72
|
+
previousEnabled: boolean;
|
|
73
|
+
enabled: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ChainEnabledChange {
|
|
77
|
+
name: string;
|
|
78
|
+
scope: ChainScope;
|
|
79
|
+
previousEnabled: boolean;
|
|
80
|
+
enabled: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface ManagerSaveResult {
|
|
84
|
+
settings: CodeMcpSettings;
|
|
85
|
+
servers: ServerModalState[];
|
|
86
|
+
chains: ChainModalState[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type UnsavedAction = "save" | "discard" | "cancel";
|
|
90
|
+
|
|
91
|
+
interface ServerManagerOptions {
|
|
92
|
+
servers: ServerModalState[];
|
|
93
|
+
chains: ChainModalState[];
|
|
94
|
+
settings: CodeMcpSettings;
|
|
95
|
+
onDiscover(server: ServerModalState): Promise<ServerModalState>;
|
|
96
|
+
onSaveChanges(
|
|
97
|
+
settings: CodeMcpSettings,
|
|
98
|
+
serverChanges: ServerEnabledChange[],
|
|
99
|
+
chainChanges: ChainEnabledChange[],
|
|
100
|
+
): Promise<ManagerSaveResult>;
|
|
101
|
+
onResolveUnsaved(): Promise<UnsavedAction>;
|
|
102
|
+
onRevalidateChain(chain: ChainModalState): Promise<ChainModalState[]>;
|
|
103
|
+
onDeleteChain(chain: ChainModalState): Promise<ChainModalState[]>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface SettingChoice {
|
|
107
|
+
value: EditableSettingValue;
|
|
108
|
+
label: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface SettingDefinition {
|
|
112
|
+
key: EditableSettingKey;
|
|
113
|
+
label: string;
|
|
114
|
+
description: string;
|
|
115
|
+
choices: SettingChoice[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const OVERLAY_OPTIONS = {
|
|
119
|
+
width: "90%",
|
|
120
|
+
minWidth: 72,
|
|
121
|
+
maxHeight: "85%",
|
|
122
|
+
} as const;
|
|
123
|
+
|
|
124
|
+
const SETTING_DEFINITIONS: SettingDefinition[] = [
|
|
125
|
+
{
|
|
126
|
+
key: "backgroundWarmup",
|
|
127
|
+
label: "Background warmup",
|
|
128
|
+
description: "Start the Python sidecar in the background when a Pi session starts.",
|
|
129
|
+
choices: [
|
|
130
|
+
{ value: true, label: "on" },
|
|
131
|
+
{ value: false, label: "off" },
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
key: "cacheTtlHours",
|
|
136
|
+
label: "Catalog cache TTL",
|
|
137
|
+
description: "Maximum age of a cached upstream tools/list response.",
|
|
138
|
+
choices: [0, 1, 6, 12, 24, 72, 168].map((value) => ({
|
|
139
|
+
value,
|
|
140
|
+
label: value === 0 ? "off" : `${value}h`,
|
|
141
|
+
})),
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
key: "executionTimeoutSeconds",
|
|
145
|
+
label: "Execution timeout",
|
|
146
|
+
description: "Maximum wall-clock duration of one sandboxed CodeMCP program.",
|
|
147
|
+
choices: [10, 30, 60, 120, 300].map(secondsChoice),
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
key: "toolTimeoutSeconds",
|
|
151
|
+
label: "Per-tool timeout",
|
|
152
|
+
description: "Maximum duration of one upstream MCP tool call.",
|
|
153
|
+
choices: [10, 30, 60, 120, 300].map(secondsChoice),
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
key: "maxCalls",
|
|
157
|
+
label: "Maximum calls",
|
|
158
|
+
description: "Maximum total upstream-tool and nested-chain calls in one execution graph.",
|
|
159
|
+
choices: [10, 25, 50, 100, 200].map(numberChoice),
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
key: "resultLimitKiB",
|
|
163
|
+
label: "Final result limit",
|
|
164
|
+
description:
|
|
165
|
+
"Maximum serialized value returned by sandbox code before it fails with a shape summary.",
|
|
166
|
+
choices: [4, 8, 16, 32, 64, 128].map(kibChoice),
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
key: "outputLimitKiB",
|
|
170
|
+
label: "Agent output limit",
|
|
171
|
+
description: "Maximum CodeMCP tool-result text placed into the agent context.",
|
|
172
|
+
choices: [10, 25, 50, 100, 200, 512].map(kibChoice),
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
key: "outputLineLimit",
|
|
176
|
+
label: "Agent line limit",
|
|
177
|
+
description: "Maximum CodeMCP tool-result lines placed into the agent context.",
|
|
178
|
+
choices: [500, 1_000, 2_000, 5_000, 10_000].map(numberChoice),
|
|
179
|
+
},
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
export async function showServerManagerModal(
|
|
183
|
+
ctx: ExtensionCommandContext,
|
|
184
|
+
options: ServerManagerOptions,
|
|
185
|
+
): Promise<void> {
|
|
186
|
+
if (ctx.mode !== "tui") {
|
|
187
|
+
throw new Error("CodeMCP server manager requires interactive mode");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
await ctx.ui.custom<void>(
|
|
191
|
+
(tui, theme, keybindings, done) =>
|
|
192
|
+
new ServerManagerModal(
|
|
193
|
+
options,
|
|
194
|
+
theme,
|
|
195
|
+
keybindings,
|
|
196
|
+
() => done(undefined),
|
|
197
|
+
() => tui.requestRender(),
|
|
198
|
+
),
|
|
199
|
+
{ overlay: true, overlayOptions: OVERLAY_OPTIONS },
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function chainStatesFromViews(views: SavedChainView[]): ChainModalState[] {
|
|
204
|
+
return views.map((view) => ({
|
|
205
|
+
name: view.chain.name,
|
|
206
|
+
scope: view.scope,
|
|
207
|
+
description: view.chain.description,
|
|
208
|
+
nativeTool: `mcp_chain_${view.chain.name}`,
|
|
209
|
+
code: view.chain.code,
|
|
210
|
+
enabled: view.chain.enabled,
|
|
211
|
+
status: view.status,
|
|
212
|
+
inputSchema: view.chain.inputSchema,
|
|
213
|
+
outputSchema: view.chain.outputSchema,
|
|
214
|
+
dependencies: view.chain.dependencies.map((dependency) => ({
|
|
215
|
+
kind: dependency.kind,
|
|
216
|
+
call: dependency.call,
|
|
217
|
+
server: dependency.server,
|
|
218
|
+
})),
|
|
219
|
+
staleDependencies: view.staleDependencies,
|
|
220
|
+
calledBy: view.calledBy,
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function serverStatesFromStatus(status: Record<string, unknown>): ServerModalState[] {
|
|
225
|
+
if (!Array.isArray(status.upstreams)) return [];
|
|
226
|
+
return status.upstreams.flatMap((value) => {
|
|
227
|
+
if (!isRecord(value) || typeof value.name !== "string") return [];
|
|
228
|
+
const tools = parseTools(value.tools);
|
|
229
|
+
const toolCount =
|
|
230
|
+
typeof value.tool_count === "number"
|
|
231
|
+
? value.tool_count
|
|
232
|
+
: tools.filter((tool) => tool.enabled).length;
|
|
233
|
+
return [
|
|
234
|
+
{
|
|
235
|
+
name: value.name,
|
|
236
|
+
transport: typeof value.transport === "string" ? value.transport : "unknown",
|
|
237
|
+
...(typeof value.auth === "string" ? { auth: value.auth } : {}),
|
|
238
|
+
enabled: value.enabled !== false,
|
|
239
|
+
connected: value.connected === true,
|
|
240
|
+
discovered: value.discovered === true || tools.length > 0,
|
|
241
|
+
toolCount,
|
|
242
|
+
totalToolCount:
|
|
243
|
+
typeof value.total_tool_count === "number" ? value.total_tool_count : tools.length,
|
|
244
|
+
tools,
|
|
245
|
+
},
|
|
246
|
+
];
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
class ServerManagerModal implements Component, Focusable {
|
|
251
|
+
private readonly search = new Input();
|
|
252
|
+
private activeTab: "servers" | "chains" | "settings" = "servers";
|
|
253
|
+
private activePane: "servers" | "tools" = "servers";
|
|
254
|
+
private selectedServerIndex = 0;
|
|
255
|
+
private selectedToolIndex = 0;
|
|
256
|
+
private selectedChainIndex = 0;
|
|
257
|
+
private selectedSettingIndex = 0;
|
|
258
|
+
private savedSettings: CodeMcpSettings;
|
|
259
|
+
private draftSettings: CodeMcpSettings;
|
|
260
|
+
private savedServerEnabled: Map<string, boolean>;
|
|
261
|
+
private savedChainEnabled: Map<string, boolean>;
|
|
262
|
+
private settingsBusy = false;
|
|
263
|
+
private closePromptBusy = false;
|
|
264
|
+
private settingsError: string | undefined;
|
|
265
|
+
private _focused = false;
|
|
266
|
+
|
|
267
|
+
constructor(
|
|
268
|
+
private readonly options: ServerManagerOptions,
|
|
269
|
+
private readonly theme: Theme,
|
|
270
|
+
private readonly keybindings: Keybindings,
|
|
271
|
+
private readonly close: () => void,
|
|
272
|
+
private readonly requestRender: () => void,
|
|
273
|
+
) {
|
|
274
|
+
this.savedSettings = cloneSettings(options.settings);
|
|
275
|
+
this.draftSettings = cloneSettings(options.settings);
|
|
276
|
+
this.savedServerEnabled = serverEnabledMap(options.servers);
|
|
277
|
+
this.savedChainEnabled = chainEnabledMap(options.chains);
|
|
278
|
+
this.applyDraftToolPolicy();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
get focused(): boolean {
|
|
282
|
+
return this._focused;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
set focused(value: boolean) {
|
|
286
|
+
this._focused = value;
|
|
287
|
+
this.search.focused = value && this.activeTab !== "settings";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
render(width: number): string[] {
|
|
291
|
+
const content = new Box(2, 1);
|
|
292
|
+
content.addChild({
|
|
293
|
+
render: (contentWidth: number) => [this.renderHeader(contentWidth)],
|
|
294
|
+
invalidate: () => {},
|
|
295
|
+
});
|
|
296
|
+
content.addChild({
|
|
297
|
+
render: (bodyWidth: number) => {
|
|
298
|
+
if (this.activeTab === "servers") return this.renderServers(bodyWidth);
|
|
299
|
+
if (this.activeTab === "chains") return this.renderChains(bodyWidth);
|
|
300
|
+
return this.renderSettings(bodyWidth);
|
|
301
|
+
},
|
|
302
|
+
invalidate: () => this.search.invalidate(),
|
|
303
|
+
});
|
|
304
|
+
content.addChild(new Text(this.theme.fg("dim", this.footer()), 0, 0));
|
|
305
|
+
return renderRoundedFrame(content, width, this.theme);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
invalidate(): void {
|
|
309
|
+
this.search.invalidate();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
handleInput(data: string): void {
|
|
313
|
+
if (matchesKey(data, Key.ctrl("s"))) {
|
|
314
|
+
this.saveDraft();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (this.settingsBusy || this.closePromptBusy) return;
|
|
318
|
+
if (this.keybindings.matches(data, "tui.select.cancel") || matchesKey(data, Key.escape)) {
|
|
319
|
+
if (this.activeTab !== "settings" && this.search.getValue()) {
|
|
320
|
+
this.search.setValue("");
|
|
321
|
+
this.resetSelections();
|
|
322
|
+
this.requestRender();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (this.hasUnsavedChanges()) this.resolveUnsavedClose();
|
|
326
|
+
else this.close();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (matchesKey(data, Key.tab)) {
|
|
330
|
+
this.activeTab =
|
|
331
|
+
this.activeTab === "servers"
|
|
332
|
+
? "chains"
|
|
333
|
+
: this.activeTab === "chains"
|
|
334
|
+
? "settings"
|
|
335
|
+
: "servers";
|
|
336
|
+
this.search.setValue("");
|
|
337
|
+
this.search.focused = this._focused && this.activeTab !== "settings";
|
|
338
|
+
this.requestRender();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (this.activeTab === "settings") this.handleSettingsInput(data);
|
|
342
|
+
else if (this.activeTab === "chains") this.handleChainInput(data);
|
|
343
|
+
else this.handleServerInput(data);
|
|
344
|
+
this.requestRender();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private handleServerInput(data: string): void {
|
|
348
|
+
if (data === "d") {
|
|
349
|
+
this.discoverSelected();
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) {
|
|
353
|
+
this.activePane = this.activePane === "servers" ? "tools" : "servers";
|
|
354
|
+
this.search.setValue("");
|
|
355
|
+
this.selectedToolIndex = 0;
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (this.keybindings.matches(data, "tui.select.up") || matchesKey(data, Key.up)) {
|
|
359
|
+
this.moveSelection(-1);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (this.keybindings.matches(data, "tui.select.down") || matchesKey(data, Key.down)) {
|
|
363
|
+
this.moveSelection(1);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (matchesKey(data, Key.enter) || data === " ") {
|
|
367
|
+
if (this.activePane === "servers") this.toggleSelectedServer();
|
|
368
|
+
else this.toggleSelectedTool();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const sanitized = data.replace(/ /g, "");
|
|
372
|
+
if (sanitized) {
|
|
373
|
+
this.search.handleInput(sanitized);
|
|
374
|
+
this.resetSelections();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private handleChainInput(data: string): void {
|
|
379
|
+
if (this.keybindings.matches(data, "tui.select.up") || matchesKey(data, Key.up)) {
|
|
380
|
+
this.selectedChainIndex = cycleIndex(
|
|
381
|
+
this.selectedChainIndex,
|
|
382
|
+
-1,
|
|
383
|
+
this.filteredChains().length,
|
|
384
|
+
);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (this.keybindings.matches(data, "tui.select.down") || matchesKey(data, Key.down)) {
|
|
388
|
+
this.selectedChainIndex = cycleIndex(
|
|
389
|
+
this.selectedChainIndex,
|
|
390
|
+
1,
|
|
391
|
+
this.filteredChains().length,
|
|
392
|
+
);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (data === "r") {
|
|
396
|
+
this.revalidateSelectedChain();
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (matchesKey(data, Key.delete)) {
|
|
400
|
+
this.deleteSelectedChain();
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (matchesKey(data, Key.enter) || data === " ") {
|
|
404
|
+
this.toggleSelectedChain();
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const sanitized = data.replace(/ /g, "");
|
|
408
|
+
if (sanitized) {
|
|
409
|
+
this.search.handleInput(sanitized);
|
|
410
|
+
this.selectedChainIndex = 0;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private handleSettingsInput(data: string): void {
|
|
415
|
+
if (this.settingsBusy) return;
|
|
416
|
+
if (this.keybindings.matches(data, "tui.select.up") || matchesKey(data, Key.up)) {
|
|
417
|
+
this.selectedSettingIndex = cycleIndex(
|
|
418
|
+
this.selectedSettingIndex,
|
|
419
|
+
-1,
|
|
420
|
+
SETTING_DEFINITIONS.length,
|
|
421
|
+
);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (this.keybindings.matches(data, "tui.select.down") || matchesKey(data, Key.down)) {
|
|
425
|
+
this.selectedSettingIndex = cycleIndex(
|
|
426
|
+
this.selectedSettingIndex,
|
|
427
|
+
1,
|
|
428
|
+
SETTING_DEFINITIONS.length,
|
|
429
|
+
);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (matchesKey(data, Key.left)) this.cycleSelectedSetting(-1);
|
|
433
|
+
else if (matchesKey(data, Key.right) || matchesKey(data, Key.enter) || data === " ") {
|
|
434
|
+
this.cycleSelectedSetting(1);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private renderHeader(width: number): string {
|
|
439
|
+
const servers =
|
|
440
|
+
this.activeTab === "servers"
|
|
441
|
+
? this.theme.fg("accent", this.theme.bold("[Servers]"))
|
|
442
|
+
: this.theme.fg("muted", "Servers");
|
|
443
|
+
const chains =
|
|
444
|
+
this.activeTab === "chains"
|
|
445
|
+
? this.theme.fg("accent", this.theme.bold("[Chains]"))
|
|
446
|
+
: this.theme.fg("muted", "Chains");
|
|
447
|
+
const settings =
|
|
448
|
+
this.activeTab === "settings"
|
|
449
|
+
? this.theme.fg("accent", this.theme.bold("[Settings]"))
|
|
450
|
+
: this.theme.fg("muted", "Settings");
|
|
451
|
+
const tabs = `${servers} ${chains} ${settings}`;
|
|
452
|
+
const title = this.theme.fg("dim", this.theme.bold("CodeMCP"));
|
|
453
|
+
const gap = " ".repeat(Math.max(1, width - visibleWidth(tabs) - visibleWidth(title)));
|
|
454
|
+
return truncateToWidth(`${tabs}${gap}${title}`, width);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private renderServers(width: number): string[] {
|
|
458
|
+
const filterLabel = this.activePane === "servers" ? "Filter servers" : "Filter tools";
|
|
459
|
+
const lines = [this.theme.fg("dim", filterLabel), ...this.search.render(width), ""];
|
|
460
|
+
const splitHeight = Math.max(1, modalBodyRows() - lines.length);
|
|
461
|
+
const leftWidth = Math.min(36, Math.max(24, Math.floor(width * 0.32)));
|
|
462
|
+
const rightWidth = Math.max(1, width - leftWidth - 3);
|
|
463
|
+
const left = this.renderServerList(leftWidth, splitHeight);
|
|
464
|
+
const right = this.renderServerDetails(rightWidth, splitHeight);
|
|
465
|
+
for (let index = 0; index < splitHeight; index += 1) {
|
|
466
|
+
lines.push(
|
|
467
|
+
`${padLine(left[index] ?? "", leftWidth)} ${this.theme.fg("dim", "│")} ${truncateToWidth(right[index] ?? "", rightWidth)}`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
return lines;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private renderServerList(width: number, height: number): string[] {
|
|
474
|
+
const servers = this.filteredServers();
|
|
475
|
+
const lines = [this.theme.fg("dim", this.theme.bold("SERVERS"))];
|
|
476
|
+
if (servers.length === 0) return [...lines, this.theme.fg("warning", "No matching servers")];
|
|
477
|
+
this.selectedServerIndex = clampIndex(this.selectedServerIndex, servers.length);
|
|
478
|
+
const visible = visibleWindow(servers, this.selectedServerIndex, height - 1);
|
|
479
|
+
for (const server of visible) {
|
|
480
|
+
const selected =
|
|
481
|
+
this.activePane === "servers" && servers.indexOf(server) === this.selectedServerIndex;
|
|
482
|
+
const prefix = selected ? this.theme.fg("accent", "→") : " ";
|
|
483
|
+
const status = serverIcon(server, this.theme);
|
|
484
|
+
const count = server.discovered ? `${server.toolCount}/${server.totalToolCount}` : "—";
|
|
485
|
+
const reserved = visibleWidth(prefix) + visibleWidth(status) + visibleWidth(count) + 4;
|
|
486
|
+
const name = truncateToWidth(server.name, Math.max(4, width - reserved), "…");
|
|
487
|
+
const gap = " ".repeat(Math.max(1, width - reserved - visibleWidth(name) + 1));
|
|
488
|
+
lines.push(
|
|
489
|
+
truncateToWidth(
|
|
490
|
+
`${prefix} ${status} ${selected ? this.theme.fg("accent", name) : name}${gap}${this.theme.fg("muted", count)}`,
|
|
491
|
+
width,
|
|
492
|
+
),
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
return lines;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
private renderServerDetails(width: number, height: number): string[] {
|
|
499
|
+
const server = this.selectedServer();
|
|
500
|
+
if (!server) return [this.theme.fg("muted", "Select a server")];
|
|
501
|
+
const lines = [
|
|
502
|
+
this.theme.fg("accent", this.theme.bold(server.name)),
|
|
503
|
+
this.theme.fg(
|
|
504
|
+
"muted",
|
|
505
|
+
`${serverStatus(server)} · ${server.toolCount}/${server.totalToolCount} tools enabled`,
|
|
506
|
+
),
|
|
507
|
+
this.theme.fg("dim", `${server.transport}${server.auth ? ` · ${server.auth}` : ""}`),
|
|
508
|
+
server.busy
|
|
509
|
+
? this.theme.fg("warning", "Working…")
|
|
510
|
+
: `${this.theme.fg("accent", "[d]")} Discover tools ${this.theme.fg("accent", "[space]")} ${server.enabled ? "Disable server" : "Enable server"}`,
|
|
511
|
+
...(server.error ? [this.theme.fg("warning", `Error: ${server.error}`)] : []),
|
|
512
|
+
this.theme.fg("dim", "─".repeat(Math.max(1, width))),
|
|
513
|
+
this.theme.fg("dim", this.theme.bold("TOOLS")),
|
|
514
|
+
];
|
|
515
|
+
if (!server.discovered) {
|
|
516
|
+
lines.push(this.theme.fg("muted", "No catalog yet. Press D to discover tools."));
|
|
517
|
+
return lines;
|
|
518
|
+
}
|
|
519
|
+
const tools = this.filteredTools(server);
|
|
520
|
+
if (tools.length === 0) {
|
|
521
|
+
lines.push(this.theme.fg("warning", "No matching tools"));
|
|
522
|
+
return lines;
|
|
523
|
+
}
|
|
524
|
+
this.selectedToolIndex = clampIndex(this.selectedToolIndex, tools.length);
|
|
525
|
+
const selectedTool = tools[this.selectedToolIndex];
|
|
526
|
+
const available = Math.max(1, height - lines.length);
|
|
527
|
+
if (selectedTool && width >= 72 && available >= 6) {
|
|
528
|
+
const cardWidth = Math.min(42, Math.max(28, Math.floor(width * 0.42)));
|
|
529
|
+
const gapWidth = 2;
|
|
530
|
+
const listWidth = Math.max(1, width - cardWidth - gapWidth);
|
|
531
|
+
const list = this.renderToolList(tools, listWidth, available);
|
|
532
|
+
const cardHeight = Math.min(available, 12);
|
|
533
|
+
const card = renderToolCard(selectedTool, cardWidth, cardHeight, this.theme);
|
|
534
|
+
for (let index = 0; index < available; index += 1) {
|
|
535
|
+
lines.push(
|
|
536
|
+
`${padLine(list[index] ?? "", listWidth)}${" ".repeat(gapWidth)}${card[index] ?? ""}`,
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
return lines;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const cardHeight =
|
|
543
|
+
selectedTool && available >= 5
|
|
544
|
+
? Math.min(9, available - 1, Math.max(4, Math.floor(available * 0.35)))
|
|
545
|
+
: 0;
|
|
546
|
+
const listHeight = available - cardHeight;
|
|
547
|
+
const list = listHeight > 0 ? this.renderToolList(tools, width, listHeight) : [];
|
|
548
|
+
lines.push(...list, ...Array.from({ length: listHeight - list.length }, () => ""));
|
|
549
|
+
if (selectedTool && cardHeight >= 4) {
|
|
550
|
+
lines.push(...renderToolCard(selectedTool, width, cardHeight, this.theme));
|
|
551
|
+
}
|
|
552
|
+
return lines;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private renderToolList(tools: ToolModalState[], width: number, height: number): string[] {
|
|
556
|
+
const lines: string[] = [];
|
|
557
|
+
for (const tool of visibleWindow(tools, this.selectedToolIndex, height)) {
|
|
558
|
+
const selected =
|
|
559
|
+
this.activePane === "tools" && tools.indexOf(tool) === this.selectedToolIndex;
|
|
560
|
+
const prefix = selected ? this.theme.fg("accent", "→") : " ";
|
|
561
|
+
const state = tool.busy
|
|
562
|
+
? this.theme.fg("warning", "…")
|
|
563
|
+
: tool.enabled
|
|
564
|
+
? this.theme.fg("success", "✓")
|
|
565
|
+
: this.theme.fg("dim", "○");
|
|
566
|
+
lines.push(
|
|
567
|
+
truncateToWidth(
|
|
568
|
+
`${prefix} ${state} ${selected ? this.theme.fg("accent", tool.name) : tool.name}`,
|
|
569
|
+
width,
|
|
570
|
+
),
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
return lines;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private renderChains(width: number): string[] {
|
|
577
|
+
const lines = [this.theme.fg("dim", "Filter chains"), ...this.search.render(width), ""];
|
|
578
|
+
const splitHeight = Math.max(1, modalBodyRows() - lines.length);
|
|
579
|
+
const leftWidth = Math.min(38, Math.max(26, Math.floor(width * 0.36)));
|
|
580
|
+
const rightWidth = Math.max(1, width - leftWidth - 3);
|
|
581
|
+
const chains = this.filteredChains();
|
|
582
|
+
const left = [this.theme.fg("dim", this.theme.bold("SAVED CHAINS"))];
|
|
583
|
+
if (chains.length === 0) {
|
|
584
|
+
left.push(this.theme.fg("muted", "No saved chains"));
|
|
585
|
+
} else {
|
|
586
|
+
this.selectedChainIndex = clampIndex(this.selectedChainIndex, chains.length);
|
|
587
|
+
for (const chain of visibleWindow(chains, this.selectedChainIndex, splitHeight - 1)) {
|
|
588
|
+
const selected = chains.indexOf(chain) === this.selectedChainIndex;
|
|
589
|
+
const prefix = selected ? this.theme.fg("accent", "→") : " ";
|
|
590
|
+
const icon = chainIcon(chain, this.theme);
|
|
591
|
+
const scope = this.theme.fg("dim", chain.scope === "project" ? "[P]" : "[G]");
|
|
592
|
+
left.push(
|
|
593
|
+
truncateToWidth(
|
|
594
|
+
`${prefix} ${icon} ${scope} ${selected ? this.theme.fg("accent", chain.name) : chain.name}`,
|
|
595
|
+
leftWidth,
|
|
596
|
+
),
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const selected = this.selectedChain();
|
|
602
|
+
const right = selected
|
|
603
|
+
? this.renderChainDetails(selected, rightWidth)
|
|
604
|
+
: [this.theme.fg("muted", "Select a saved chain")];
|
|
605
|
+
for (let index = 0; index < splitHeight; index += 1) {
|
|
606
|
+
lines.push(
|
|
607
|
+
`${padLine(left[index] ?? "", leftWidth)} ${this.theme.fg("dim", "│")} ${truncateToWidth(right[index] ?? "", rightWidth)}`,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
return lines;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
private renderChainDetails(chain: ChainModalState, width: number): string[] {
|
|
614
|
+
const servers = [
|
|
615
|
+
...new Set(
|
|
616
|
+
chain.dependencies
|
|
617
|
+
.filter((dependency) => dependency.kind === "mcp_tool")
|
|
618
|
+
.map((dependency) => dependency.server),
|
|
619
|
+
),
|
|
620
|
+
];
|
|
621
|
+
const lines = [
|
|
622
|
+
this.theme.fg("accent", this.theme.bold(chain.name)),
|
|
623
|
+
this.theme.fg("muted", `${chain.scope} · ${chain.status} · ${chain.nativeTool}`),
|
|
624
|
+
chain.busy
|
|
625
|
+
? this.theme.fg("warning", "Working…")
|
|
626
|
+
: `${this.theme.fg("accent", "[space]")} ${chain.enabled ? "Disable" : "Enable"} ${this.theme.fg("accent", "[r]")} Revalidate ${this.theme.fg("accent", "[del]")} Delete`,
|
|
627
|
+
...(chain.error ? [this.theme.fg("warning", `Error: ${chain.error}`)] : []),
|
|
628
|
+
"",
|
|
629
|
+
...wrapPlainText(chain.description, width).map((line) => this.theme.fg("muted", line)),
|
|
630
|
+
"",
|
|
631
|
+
this.theme.fg("dim", this.theme.bold("INPUT")),
|
|
632
|
+
...schemaSummary(chain.inputSchema).map((line) => this.theme.fg("muted", line)),
|
|
633
|
+
"",
|
|
634
|
+
this.theme.fg("dim", this.theme.bold("OUTPUT")),
|
|
635
|
+
...schemaSummary(chain.outputSchema).map((line) => this.theme.fg("muted", line)),
|
|
636
|
+
"",
|
|
637
|
+
this.theme.fg("dim", this.theme.bold("SERVERS")),
|
|
638
|
+
this.theme.fg("muted", servers.length > 0 ? servers.join(", ") : "none"),
|
|
639
|
+
"",
|
|
640
|
+
this.theme.fg("dim", this.theme.bold("DEPENDENCIES")),
|
|
641
|
+
...(chain.dependencies.length > 0
|
|
642
|
+
? chain.dependencies.map((dependency) => this.theme.fg("muted", dependency.call))
|
|
643
|
+
: [this.theme.fg("muted", "none")]),
|
|
644
|
+
...(chain.calledBy.length > 0
|
|
645
|
+
? [
|
|
646
|
+
"",
|
|
647
|
+
this.theme.fg("dim", this.theme.bold("CALLED BY")),
|
|
648
|
+
this.theme.fg("muted", chain.calledBy.join(", ")),
|
|
649
|
+
]
|
|
650
|
+
: []),
|
|
651
|
+
...(chain.staleDependencies.length > 0
|
|
652
|
+
? [
|
|
653
|
+
"",
|
|
654
|
+
this.theme.fg("warning", this.theme.bold("STALE")),
|
|
655
|
+
...chain.staleDependencies.map((dependency) => this.theme.fg("warning", dependency)),
|
|
656
|
+
]
|
|
657
|
+
: []),
|
|
658
|
+
];
|
|
659
|
+
return lines;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
private renderSettings(width: number): string[] {
|
|
663
|
+
const splitHeight = Math.max(1, modalBodyRows());
|
|
664
|
+
const leftWidth = Math.min(38, Math.max(28, Math.floor(width * 0.42)));
|
|
665
|
+
const rightWidth = Math.max(1, width - leftWidth - 3);
|
|
666
|
+
const left = [this.theme.fg("dim", this.theme.bold("SETTINGS"))];
|
|
667
|
+
for (const [index, definition] of SETTING_DEFINITIONS.entries()) {
|
|
668
|
+
const selected = index === this.selectedSettingIndex;
|
|
669
|
+
const prefix = selected ? this.theme.fg("accent", "→") : " ";
|
|
670
|
+
const value = settingLabel(definition, this.draftSettings[definition.key]);
|
|
671
|
+
const reserved = visibleWidth(prefix) + visibleWidth(value) + 3;
|
|
672
|
+
const label = truncateToWidth(definition.label, Math.max(4, leftWidth - reserved), "…");
|
|
673
|
+
const gap = " ".repeat(Math.max(1, leftWidth - reserved - visibleWidth(label) + 1));
|
|
674
|
+
left.push(
|
|
675
|
+
truncateToWidth(
|
|
676
|
+
`${prefix} ${selected ? this.theme.fg("accent", label) : label}${gap}${this.theme.fg("muted", value)}`,
|
|
677
|
+
leftWidth,
|
|
678
|
+
),
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
const definition = SETTING_DEFINITIONS[this.selectedSettingIndex];
|
|
682
|
+
const right = definition
|
|
683
|
+
? [
|
|
684
|
+
this.theme.fg("accent", this.theme.bold(definition.label)),
|
|
685
|
+
this.theme.fg("muted", settingLabel(definition, this.draftSettings[definition.key])),
|
|
686
|
+
"",
|
|
687
|
+
...wrapPlainText(definition.description, rightWidth).map((line) =>
|
|
688
|
+
this.theme.fg("muted", line),
|
|
689
|
+
),
|
|
690
|
+
"",
|
|
691
|
+
this.theme.fg("dim", "←/→ change · enter next · ctrl+s save"),
|
|
692
|
+
...(this.settingsBusy
|
|
693
|
+
? ["", this.theme.fg("warning", "Saving staged changes…")]
|
|
694
|
+
: this.hasUnsavedChanges()
|
|
695
|
+
? ["", this.theme.fg("warning", "Unsaved changes")]
|
|
696
|
+
: []),
|
|
697
|
+
...(this.settingsError
|
|
698
|
+
? ["", this.theme.fg("warning", `Error: ${this.settingsError}`)]
|
|
699
|
+
: []),
|
|
700
|
+
]
|
|
701
|
+
: [];
|
|
702
|
+
const lines: string[] = [];
|
|
703
|
+
for (let index = 0; index < splitHeight; index += 1) {
|
|
704
|
+
lines.push(
|
|
705
|
+
`${padLine(left[index] ?? "", leftWidth)} ${this.theme.fg("dim", "│")} ${truncateToWidth(right[index] ?? "", rightWidth)}`,
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
return lines;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
private footer(): string {
|
|
712
|
+
const pending = this.hasUnsavedChanges() ? " · * unsaved · ctrl+s save" : "";
|
|
713
|
+
if (this.activeTab === "settings") {
|
|
714
|
+
return `tab servers · ↑/↓ navigate · ←/→/enter change · ctrl+s save · esc close${pending}`;
|
|
715
|
+
}
|
|
716
|
+
if (this.activeTab === "chains") {
|
|
717
|
+
return `tab settings · ↑/↓ navigate · space toggle · r revalidate · del delete · esc close${pending}`;
|
|
718
|
+
}
|
|
719
|
+
return `tab chains · ←/→ pane · ↑/↓ navigate · space toggle · d discover · esc close${pending}`;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
private moveSelection(direction: -1 | 1): void {
|
|
723
|
+
if (this.activePane === "servers") {
|
|
724
|
+
this.selectedServerIndex = cycleIndex(
|
|
725
|
+
this.selectedServerIndex,
|
|
726
|
+
direction,
|
|
727
|
+
this.filteredServers().length,
|
|
728
|
+
);
|
|
729
|
+
this.selectedToolIndex = 0;
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
const server = this.selectedServer();
|
|
733
|
+
this.selectedToolIndex = cycleIndex(
|
|
734
|
+
this.selectedToolIndex,
|
|
735
|
+
direction,
|
|
736
|
+
server ? this.filteredTools(server).length : 0,
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
private toggleSelectedServer(): void {
|
|
741
|
+
const server = this.selectedServer();
|
|
742
|
+
if (!server || server.busy || this.settingsBusy) return;
|
|
743
|
+
server.enabled = !server.enabled;
|
|
744
|
+
delete server.error;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
private discoverSelected(): void {
|
|
748
|
+
const server = this.selectedServer();
|
|
749
|
+
if (!server || server.busy || this.settingsBusy) return;
|
|
750
|
+
if (server.enabled !== this.savedServerEnabled.get(server.name)) {
|
|
751
|
+
server.error = "Save this server change before discovering tools";
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
if (!server.enabled) {
|
|
755
|
+
server.error = "Enable and save this server before discovering tools";
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
server.busy = true;
|
|
759
|
+
delete server.error;
|
|
760
|
+
this.requestRender();
|
|
761
|
+
void this.options
|
|
762
|
+
.onDiscover(server)
|
|
763
|
+
.then((updated) => {
|
|
764
|
+
applyServerUpdate(server, updated);
|
|
765
|
+
this.applyDraftToolPolicy();
|
|
766
|
+
})
|
|
767
|
+
.catch((error: unknown) => {
|
|
768
|
+
server.error = summarizeError(error);
|
|
769
|
+
})
|
|
770
|
+
.finally(() => {
|
|
771
|
+
server.busy = false;
|
|
772
|
+
this.requestRender();
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private toggleSelectedTool(): void {
|
|
777
|
+
const server = this.selectedServer();
|
|
778
|
+
if (!server || server.busy || this.settingsBusy) return;
|
|
779
|
+
const tools = this.filteredTools(server);
|
|
780
|
+
const tool = tools[this.selectedToolIndex];
|
|
781
|
+
if (!tool || tool.busy) return;
|
|
782
|
+
const enabled = !tool.enabled;
|
|
783
|
+
this.draftSettings = setToolEnabled(this.draftSettings, server.name, tool.name, enabled);
|
|
784
|
+
tool.enabled = enabled;
|
|
785
|
+
server.toolCount = server.tools.filter((candidate) => candidate.enabled).length;
|
|
786
|
+
this.settingsError = undefined;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
private toggleSelectedChain(): void {
|
|
790
|
+
const chain = this.selectedChain();
|
|
791
|
+
if (!chain || chain.busy || this.settingsBusy) return;
|
|
792
|
+
applyChainEnabled(chain, !chain.enabled);
|
|
793
|
+
delete chain.error;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
private revalidateSelectedChain(): void {
|
|
797
|
+
const chain = this.selectedChain();
|
|
798
|
+
if (!chain || chain.busy || this.settingsBusy) return;
|
|
799
|
+
if (chain.enabled !== this.savedChainEnabled.get(chainKey(chain))) {
|
|
800
|
+
chain.error = "Save this chain change before revalidation";
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
chain.busy = true;
|
|
804
|
+
delete chain.error;
|
|
805
|
+
void this.options
|
|
806
|
+
.onRevalidateChain(chain)
|
|
807
|
+
.then((updated) => this.replaceChains(updated, chain))
|
|
808
|
+
.catch((error: unknown) => {
|
|
809
|
+
chain.error = summarizeError(error);
|
|
810
|
+
})
|
|
811
|
+
.finally(() => {
|
|
812
|
+
chain.busy = false;
|
|
813
|
+
this.requestRender();
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
private replaceChains(
|
|
818
|
+
updated: ChainModalState[],
|
|
819
|
+
selected?: ChainModalState,
|
|
820
|
+
preserveDrafts = true,
|
|
821
|
+
): void {
|
|
822
|
+
const drafts = preserveDrafts ? this.chainEnabledChanges() : [];
|
|
823
|
+
this.options.chains.splice(0, this.options.chains.length, ...updated);
|
|
824
|
+
this.savedChainEnabled = chainEnabledMap(updated);
|
|
825
|
+
for (const draft of drafts) {
|
|
826
|
+
const chain = this.options.chains.find(
|
|
827
|
+
(candidate) => candidate.name === draft.name && candidate.scope === draft.scope,
|
|
828
|
+
);
|
|
829
|
+
if (chain) applyChainEnabled(chain, draft.enabled);
|
|
830
|
+
}
|
|
831
|
+
if (selected) {
|
|
832
|
+
const index = this.filteredChains().findIndex(
|
|
833
|
+
(chain) => chain.name === selected.name && chain.scope === selected.scope,
|
|
834
|
+
);
|
|
835
|
+
if (index >= 0) {
|
|
836
|
+
this.selectedChainIndex = index;
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
this.selectedChainIndex = clampIndex(this.selectedChainIndex, this.filteredChains().length);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
private deleteSelectedChain(): void {
|
|
844
|
+
const chain = this.selectedChain();
|
|
845
|
+
if (!chain || chain.busy) return;
|
|
846
|
+
chain.busy = true;
|
|
847
|
+
delete chain.error;
|
|
848
|
+
void this.options
|
|
849
|
+
.onDeleteChain(chain)
|
|
850
|
+
.then((updated) => this.replaceChains(updated))
|
|
851
|
+
.catch((error: unknown) => {
|
|
852
|
+
chain.error = summarizeError(error);
|
|
853
|
+
})
|
|
854
|
+
.finally(() => {
|
|
855
|
+
chain.busy = false;
|
|
856
|
+
this.requestRender();
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
private cycleSelectedSetting(direction: -1 | 1): void {
|
|
861
|
+
const definition = SETTING_DEFINITIONS[this.selectedSettingIndex];
|
|
862
|
+
if (!definition || this.settingsBusy) return;
|
|
863
|
+
const current = this.draftSettings[definition.key];
|
|
864
|
+
const currentIndex = Math.max(
|
|
865
|
+
0,
|
|
866
|
+
definition.choices.findIndex((choice) => choice.value === current),
|
|
867
|
+
);
|
|
868
|
+
const choice =
|
|
869
|
+
definition.choices[cycleIndex(currentIndex, direction, definition.choices.length)];
|
|
870
|
+
if (!choice) return;
|
|
871
|
+
this.settingsError = undefined;
|
|
872
|
+
try {
|
|
873
|
+
this.draftSettings = setEditableSetting(this.draftSettings, definition.key, choice.value);
|
|
874
|
+
} catch (error) {
|
|
875
|
+
this.settingsError = summarizeError(error);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
private saveDraft(): void {
|
|
880
|
+
void this.persistDraft();
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
private async persistDraft(): Promise<boolean> {
|
|
884
|
+
if (this.settingsBusy) return false;
|
|
885
|
+
if (!this.hasUnsavedChanges()) return true;
|
|
886
|
+
this.settingsBusy = true;
|
|
887
|
+
this.settingsError = undefined;
|
|
888
|
+
this.requestRender();
|
|
889
|
+
try {
|
|
890
|
+
const result = await this.options.onSaveChanges(
|
|
891
|
+
cloneSettings(this.draftSettings),
|
|
892
|
+
this.serverEnabledChanges(),
|
|
893
|
+
this.chainEnabledChanges(),
|
|
894
|
+
);
|
|
895
|
+
this.savedSettings = cloneSettings(result.settings);
|
|
896
|
+
this.draftSettings = cloneSettings(result.settings);
|
|
897
|
+
this.options.servers.splice(0, this.options.servers.length, ...result.servers);
|
|
898
|
+
this.savedServerEnabled = serverEnabledMap(result.servers);
|
|
899
|
+
this.replaceChains(result.chains, undefined, false);
|
|
900
|
+
this.applyDraftToolPolicy();
|
|
901
|
+
return true;
|
|
902
|
+
} catch (error) {
|
|
903
|
+
this.settingsError = summarizeError(error);
|
|
904
|
+
return false;
|
|
905
|
+
} finally {
|
|
906
|
+
this.settingsBusy = false;
|
|
907
|
+
this.requestRender();
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
private resolveUnsavedClose(): void {
|
|
912
|
+
if (this.closePromptBusy || this.settingsBusy) return;
|
|
913
|
+
this.closePromptBusy = true;
|
|
914
|
+
void this.options
|
|
915
|
+
.onResolveUnsaved()
|
|
916
|
+
.then(async (action) => {
|
|
917
|
+
if (action === "discard") {
|
|
918
|
+
this.close();
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
if (action === "save" && (await this.persistDraft())) this.close();
|
|
922
|
+
})
|
|
923
|
+
.catch((error: unknown) => {
|
|
924
|
+
this.settingsError = summarizeError(error);
|
|
925
|
+
})
|
|
926
|
+
.finally(() => {
|
|
927
|
+
this.closePromptBusy = false;
|
|
928
|
+
this.requestRender();
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
private hasUnsavedChanges(): boolean {
|
|
933
|
+
return (
|
|
934
|
+
!settingsEqual(this.savedSettings, this.draftSettings) ||
|
|
935
|
+
this.serverEnabledChanges().length > 0 ||
|
|
936
|
+
this.chainEnabledChanges().length > 0
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
private serverEnabledChanges(): ServerEnabledChange[] {
|
|
941
|
+
return this.options.servers.flatMap((server) => {
|
|
942
|
+
const previousEnabled = this.savedServerEnabled.get(server.name);
|
|
943
|
+
return previousEnabled === undefined || previousEnabled === server.enabled
|
|
944
|
+
? []
|
|
945
|
+
: [{ name: server.name, previousEnabled, enabled: server.enabled }];
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
private chainEnabledChanges(): ChainEnabledChange[] {
|
|
950
|
+
return this.options.chains.flatMap((chain) => {
|
|
951
|
+
const previousEnabled = this.savedChainEnabled.get(chainKey(chain));
|
|
952
|
+
return previousEnabled === undefined || previousEnabled === chain.enabled
|
|
953
|
+
? []
|
|
954
|
+
: [
|
|
955
|
+
{
|
|
956
|
+
name: chain.name,
|
|
957
|
+
scope: chain.scope,
|
|
958
|
+
previousEnabled,
|
|
959
|
+
enabled: chain.enabled,
|
|
960
|
+
},
|
|
961
|
+
];
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
private applyDraftToolPolicy(): void {
|
|
966
|
+
for (const server of this.options.servers) {
|
|
967
|
+
const disabled = new Set(this.draftSettings.disabledTools[server.name] ?? []);
|
|
968
|
+
for (const tool of server.tools) tool.enabled = !disabled.has(tool.name);
|
|
969
|
+
server.toolCount = server.tools.filter((tool) => tool.enabled).length;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
private filteredServers(): ServerModalState[] {
|
|
974
|
+
const query = this.activePane === "servers" ? this.search.getValue().trim() : "";
|
|
975
|
+
return query
|
|
976
|
+
? fuzzyFilter(this.options.servers, query, (server) =>
|
|
977
|
+
[server.name, serverStatus(server)].join(" "),
|
|
978
|
+
)
|
|
979
|
+
: this.options.servers;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
private filteredTools(server: ServerModalState): ToolModalState[] {
|
|
983
|
+
const query = this.activePane === "tools" ? this.search.getValue().trim() : "";
|
|
984
|
+
return query
|
|
985
|
+
? fuzzyFilter(server.tools, query, (tool) =>
|
|
986
|
+
[tool.name, tool.description].filter(Boolean).join(" "),
|
|
987
|
+
)
|
|
988
|
+
: server.tools;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
private filteredChains(): ChainModalState[] {
|
|
992
|
+
const query = this.search.getValue().trim();
|
|
993
|
+
return query
|
|
994
|
+
? fuzzyFilter(this.options.chains, query, (chain) =>
|
|
995
|
+
[
|
|
996
|
+
chain.name,
|
|
997
|
+
chain.description,
|
|
998
|
+
chain.nativeTool,
|
|
999
|
+
...chain.dependencies.map((dependency) => dependency.call),
|
|
1000
|
+
].join(" "),
|
|
1001
|
+
)
|
|
1002
|
+
: this.options.chains;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
private selectedChain(): ChainModalState | undefined {
|
|
1006
|
+
const chains = this.filteredChains();
|
|
1007
|
+
this.selectedChainIndex = clampIndex(this.selectedChainIndex, chains.length);
|
|
1008
|
+
return chains[this.selectedChainIndex];
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
private selectedServer(): ServerModalState | undefined {
|
|
1012
|
+
const servers = this.filteredServers();
|
|
1013
|
+
this.selectedServerIndex = clampIndex(this.selectedServerIndex, servers.length);
|
|
1014
|
+
return servers[this.selectedServerIndex];
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
private resetSelections(): void {
|
|
1018
|
+
this.selectedServerIndex = 0;
|
|
1019
|
+
this.selectedToolIndex = 0;
|
|
1020
|
+
this.selectedChainIndex = 0;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function parseTools(value: unknown): ToolModalState[] {
|
|
1025
|
+
if (!Array.isArray(value)) return [];
|
|
1026
|
+
return value.flatMap((tool) => {
|
|
1027
|
+
if (!isRecord(tool) || typeof tool.name !== "string") return [];
|
|
1028
|
+
return [
|
|
1029
|
+
{
|
|
1030
|
+
name: tool.name,
|
|
1031
|
+
enabled: tool.enabled !== false,
|
|
1032
|
+
...(typeof tool.description === "string" ? { description: tool.description } : {}),
|
|
1033
|
+
},
|
|
1034
|
+
];
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function applyServerUpdate(target: ServerModalState, updated: ServerModalState): void {
|
|
1039
|
+
target.transport = updated.transport;
|
|
1040
|
+
if (updated.auth === undefined) delete target.auth;
|
|
1041
|
+
else target.auth = updated.auth;
|
|
1042
|
+
target.enabled = updated.enabled;
|
|
1043
|
+
target.connected = updated.connected;
|
|
1044
|
+
target.discovered = updated.discovered;
|
|
1045
|
+
target.toolCount = updated.toolCount;
|
|
1046
|
+
target.totalToolCount = updated.totalToolCount;
|
|
1047
|
+
target.tools = updated.tools;
|
|
1048
|
+
if (updated.error === undefined) delete target.error;
|
|
1049
|
+
else target.error = updated.error;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function chainIcon(chain: ChainModalState, theme: Theme): string {
|
|
1053
|
+
if (chain.busy) return theme.fg("warning", "…");
|
|
1054
|
+
if (chain.status === "shadowed") return theme.fg("dim", "◇");
|
|
1055
|
+
if (!chain.enabled) return theme.fg("dim", "○");
|
|
1056
|
+
if (chain.status === "stale") return theme.fg("warning", "◌");
|
|
1057
|
+
return theme.fg("success", "●");
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function schemaSummary(schema: Record<string, unknown>): string[] {
|
|
1061
|
+
const properties = isRecord(schema.properties) ? schema.properties : undefined;
|
|
1062
|
+
if (!properties || Object.keys(properties).length === 0) {
|
|
1063
|
+
return [typeof schema.type === "string" ? schema.type : "any JSON value"];
|
|
1064
|
+
}
|
|
1065
|
+
const required = new Set(
|
|
1066
|
+
Array.isArray(schema.required)
|
|
1067
|
+
? schema.required.filter((value): value is string => typeof value === "string")
|
|
1068
|
+
: [],
|
|
1069
|
+
);
|
|
1070
|
+
return Object.entries(properties).map(([name, value]) => {
|
|
1071
|
+
const property = isRecord(value) ? value : {};
|
|
1072
|
+
const type =
|
|
1073
|
+
typeof property.type === "string"
|
|
1074
|
+
? property.type
|
|
1075
|
+
: Array.isArray(property.type)
|
|
1076
|
+
? property.type.filter((item) => typeof item === "string").join(" | ")
|
|
1077
|
+
: "value";
|
|
1078
|
+
return `${name}${required.has(name) ? "" : "?"}: ${type}`;
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function serverStatus(server: ServerModalState): string {
|
|
1083
|
+
if (server.busy) return "working";
|
|
1084
|
+
if (!server.enabled) return "disabled";
|
|
1085
|
+
if (server.connected) return "connected";
|
|
1086
|
+
if (server.discovered) return "ready";
|
|
1087
|
+
return "not discovered";
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function serverIcon(server: ServerModalState, theme: Theme): string {
|
|
1091
|
+
if (server.busy) return theme.fg("warning", "…");
|
|
1092
|
+
if (!server.enabled) return theme.fg("dim", "○");
|
|
1093
|
+
if (server.discovered) return theme.fg("success", "●");
|
|
1094
|
+
return theme.fg("warning", "◌");
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function serverEnabledMap(servers: readonly ServerModalState[]): Map<string, boolean> {
|
|
1098
|
+
return new Map(servers.map((server) => [server.name, server.enabled]));
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function chainEnabledMap(chains: readonly ChainModalState[]): Map<string, boolean> {
|
|
1102
|
+
return new Map(chains.map((chain) => [chainKey(chain), chain.enabled]));
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function chainKey(chain: Pick<ChainModalState, "name" | "scope">): string {
|
|
1106
|
+
return `${chain.scope}:${chain.name}`;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function applyChainEnabled(chain: ChainModalState, enabled: boolean): void {
|
|
1110
|
+
const shadowed = chain.status === "shadowed";
|
|
1111
|
+
chain.enabled = enabled;
|
|
1112
|
+
if (shadowed) return;
|
|
1113
|
+
chain.status = enabled ? (chain.staleDependencies.length > 0 ? "stale" : "ready") : "disabled";
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function cloneSettings(settings: CodeMcpSettings): CodeMcpSettings {
|
|
1117
|
+
return {
|
|
1118
|
+
...settings,
|
|
1119
|
+
disabledTools: Object.fromEntries(
|
|
1120
|
+
Object.entries(settings.disabledTools).map(([server, tools]) => [server, [...tools]]),
|
|
1121
|
+
),
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function settingsEqual(left: CodeMcpSettings, right: CodeMcpSettings): boolean {
|
|
1126
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function settingLabel(definition: SettingDefinition, value: EditableSettingValue): string {
|
|
1130
|
+
return definition.choices.find((choice) => choice.value === value)?.label ?? String(value);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function secondsChoice(value: number): SettingChoice {
|
|
1134
|
+
return { value, label: `${value}s` };
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function numberChoice(value: number): SettingChoice {
|
|
1138
|
+
return { value, label: value.toLocaleString("en-US") };
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function kibChoice(value: number): SettingChoice {
|
|
1142
|
+
return { value, label: `${value} KiB` };
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function cycleIndex(index: number, direction: -1 | 1, length: number): number {
|
|
1146
|
+
return length === 0 ? 0 : (index + direction + length) % length;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function clampIndex(index: number, length: number): number {
|
|
1150
|
+
return Math.max(0, Math.min(index, Math.max(0, length - 1)));
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function visibleWindow<T>(items: T[], selectedIndex: number, maximum: number): T[] {
|
|
1154
|
+
const size = Math.max(1, maximum);
|
|
1155
|
+
const start = Math.max(0, Math.min(selectedIndex - Math.floor(size / 2), items.length - size));
|
|
1156
|
+
return items.slice(start, start + size);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function wrapPlainText(text: string, width: number): string[] {
|
|
1160
|
+
const words = text.split(/\s+/);
|
|
1161
|
+
const lines: string[] = [];
|
|
1162
|
+
let line = "";
|
|
1163
|
+
for (const word of words) {
|
|
1164
|
+
if (!line) line = word;
|
|
1165
|
+
else if (line.length + word.length + 1 <= width) line += ` ${word}`;
|
|
1166
|
+
else {
|
|
1167
|
+
lines.push(line);
|
|
1168
|
+
line = word;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
if (line) lines.push(line);
|
|
1172
|
+
return lines;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function modalBodyRows(): number {
|
|
1176
|
+
const reportedRows = process.stdout.rows;
|
|
1177
|
+
const terminalRows = typeof reportedRows === "number" && reportedRows > 0 ? reportedRows : 24;
|
|
1178
|
+
const overlayRows = Math.floor(terminalRows * 0.85);
|
|
1179
|
+
// Frame, Box padding, header, and footer consume six rows; keep one row as
|
|
1180
|
+
// safety because Pi clips overlays at maxHeight before the final border.
|
|
1181
|
+
return Math.max(1, overlayRows - 7);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function renderToolCard(
|
|
1185
|
+
tool: ToolModalState,
|
|
1186
|
+
width: number,
|
|
1187
|
+
height: number,
|
|
1188
|
+
theme: Theme,
|
|
1189
|
+
): string[] {
|
|
1190
|
+
const cardWidth = Math.max(8, width);
|
|
1191
|
+
const cardHeight = Math.max(3, height);
|
|
1192
|
+
const innerWidth = Math.max(1, cardWidth - 2);
|
|
1193
|
+
const title = truncateToWidth(tool.name, Math.max(1, cardWidth - 5), "…");
|
|
1194
|
+
const top = [
|
|
1195
|
+
theme.fg("dim", "╭─ "),
|
|
1196
|
+
theme.fg("accent", theme.bold(title)),
|
|
1197
|
+
theme.fg("dim", ` ${"─".repeat(Math.max(0, cardWidth - visibleWidth(title) - 5))}╮`),
|
|
1198
|
+
].join("");
|
|
1199
|
+
const bottom = theme.fg("dim", `╰${"─".repeat(Math.max(0, cardWidth - 2))}╯`);
|
|
1200
|
+
const description = tool.description ?? "No description provided.";
|
|
1201
|
+
const content = wrapPlainText(description, innerWidth).map((line) => theme.fg("muted", line));
|
|
1202
|
+
const body: string[] = [];
|
|
1203
|
+
for (let index = 0; index < cardHeight - 2; index += 1) {
|
|
1204
|
+
body.push(
|
|
1205
|
+
`${theme.fg("dim", "│")}${padLine(content[index] ?? "", innerWidth)}${theme.fg("dim", "│")}`,
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
return [top, ...body, bottom];
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function padLine(line: string, width: number): string {
|
|
1212
|
+
const truncated = truncateToWidth(line, width, "", true);
|
|
1213
|
+
return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated)));
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function renderRoundedFrame(content: Component, width: number, theme: Theme): string[] {
|
|
1217
|
+
const color = (text: string) => theme.fg("accent", text);
|
|
1218
|
+
const innerWidth = Math.max(1, width - 2);
|
|
1219
|
+
const body = content.render(innerWidth).map((line) => {
|
|
1220
|
+
const truncated = truncateToWidth(line, innerWidth, "", true);
|
|
1221
|
+
const padded = truncated + " ".repeat(Math.max(0, innerWidth - visibleWidth(truncated)));
|
|
1222
|
+
return `${color("│")}${padded}${color("│")}`;
|
|
1223
|
+
});
|
|
1224
|
+
return [
|
|
1225
|
+
color(`╭${"─".repeat(Math.max(0, width - 2))}╮`),
|
|
1226
|
+
...body,
|
|
1227
|
+
color(`╰${"─".repeat(Math.max(0, width - 2))}╯`),
|
|
1228
|
+
];
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1232
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1233
|
+
}
|