@pellux/goodvibes-tui 0.19.96 → 0.19.99
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/CHANGELOG.md +15 -0
- package/README.md +23 -3
- package/docs/foundation-artifacts/operator-contract.json +1376 -290
- package/package.json +2 -2
- package/src/input/command-registry.ts +6 -1
- package/src/input/commands/mcp-runtime.ts +237 -7
- package/src/input/commands/shell-core.ts +18 -1
- package/src/input/feed-context-factory.ts +3 -0
- package/src/input/handler-command-route.ts +27 -0
- package/src/input/handler-content-actions.ts +36 -4
- package/src/input/handler-feed.ts +13 -0
- package/src/input/handler-interactions.ts +1 -0
- package/src/input/handler-modal-stack.ts +3 -0
- package/src/input/handler-modal-token-routes.ts +11 -0
- package/src/input/handler-ui-state.ts +10 -0
- package/src/input/handler.ts +17 -1
- package/src/input/mcp-workspace.ts +554 -0
- package/src/main.ts +1 -0
- package/src/mcp/runtime-reload.ts +81 -0
- package/src/panels/builtin/operations.ts +1 -11
- package/src/panels/builtin/shared.ts +2 -2
- package/src/panels/index.ts +0 -1
- package/src/renderer/conversation-overlays.ts +6 -0
- package/src/renderer/mcp-workspace.ts +297 -0
- package/src/runtime/bootstrap-command-parts.ts +2 -4
- package/src/runtime/bootstrap.ts +26 -2
- package/src/shell/ui-openers.ts +5 -0
- package/src/version.ts +1 -1
- package/src/panels/mcp-panel.ts +0 -215
package/src/input/handler.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { AgentDetailModal } from '../renderer/agent-detail-modal.ts';
|
|
|
25
25
|
import { ContextInspectorModal } from '../renderer/context-inspector.ts';
|
|
26
26
|
import { BookmarkModal } from './bookmark-modal.ts';
|
|
27
27
|
import { SettingsModal } from './settings-modal.ts';
|
|
28
|
+
import { McpWorkspace } from './mcp-workspace.ts';
|
|
28
29
|
import { SessionPickerModal } from './session-picker-modal.ts';
|
|
29
30
|
import { ProfilePickerModal } from './profile-picker-modal.ts';
|
|
30
31
|
import { OnboardingWizardController, type OnboardingWizardAction, type OnboardingWizardMode } from './onboarding/onboarding-wizard.ts';
|
|
@@ -157,6 +158,7 @@ export class InputHandler {
|
|
|
157
158
|
public bookmarkModal: BookmarkModal;
|
|
158
159
|
public blockActionsMenu = new BlockActionsMenu();
|
|
159
160
|
public settingsModal = new SettingsModal();
|
|
161
|
+
public mcpWorkspace = new McpWorkspace();
|
|
160
162
|
public onboardingWizard = new OnboardingWizardController();
|
|
161
163
|
public onboardingModelPickerCancelSnapshot: OnboardingWizardSnapshot | null = null;
|
|
162
164
|
public onboardingHydrationSerial = 0;
|
|
@@ -271,9 +273,11 @@ export class InputHandler {
|
|
|
271
273
|
selection: this.selection,
|
|
272
274
|
pasteRegistry: this.pasteRegistry,
|
|
273
275
|
imageRegistry: this.imageRegistry,
|
|
276
|
+
projectRoot: this.uiServices.environment.shellPaths.workingDirectory,
|
|
274
277
|
selectionModal: this.selectionModal,
|
|
275
278
|
bookmarkModal: this.bookmarkModal,
|
|
276
279
|
settingsModal: this.settingsModal,
|
|
280
|
+
mcpWorkspace: this.mcpWorkspace,
|
|
277
281
|
sessionPickerModal: this.sessionPickerModal,
|
|
278
282
|
profilePickerModal: this.profilePickerModal,
|
|
279
283
|
historySearch: this.historySearch,
|
|
@@ -410,6 +414,13 @@ export class InputHandler {
|
|
|
410
414
|
public restoreOnboardingModelPickerCancelState(): void { restoreOnboardingModelPickerCancelStateForHandler(this); }
|
|
411
415
|
public openModelPickerWithTarget(target: ModelPickerTarget, source: 'settings' | 'onboarding' = 'settings'): boolean { return openModelPickerWithTargetForHandler(this, target, source); }
|
|
412
416
|
public openProviderModelPickerWithTarget(target: ModelPickerTarget, source: 'settings' | 'onboarding' = 'settings'): boolean { return openProviderModelPickerWithTargetForHandler(this, target, source); }
|
|
417
|
+
public openMcpWorkspace(context: CommandContext): void {
|
|
418
|
+
this.panelFocused = false;
|
|
419
|
+
this.indicatorFocused = false;
|
|
420
|
+
this.modalOpened('mcpWorkspace');
|
|
421
|
+
this.mcpWorkspace.open(context);
|
|
422
|
+
this.requestRender();
|
|
423
|
+
}
|
|
413
424
|
public handleModelPickerCommit(): boolean { return handleModelPickerCommitForHandler(this); }
|
|
414
425
|
public async handleOnboardingAction(action: OnboardingWizardAction): Promise<void> { await handleOnboardingActionForHandler(this, action); }
|
|
415
426
|
public async refreshOnboardingHydration(options: { readonly preserveValues?: boolean; readonly targetStepId?: string } = {}): Promise<void> { await refreshOnboardingHydrationForHandler(this, options); }
|
|
@@ -502,7 +513,7 @@ export class InputHandler {
|
|
|
502
513
|
* handlePaste - Shared paste logic for Ctrl+V and middle-click.
|
|
503
514
|
* Tries image clipboard first, falls back to text paste.
|
|
504
515
|
*/
|
|
505
|
-
public handlePaste():
|
|
516
|
+
public handlePaste(): ReturnType<typeof handleClipboardPaste> {
|
|
506
517
|
const result = handleClipboardPaste({
|
|
507
518
|
prompt: this.prompt,
|
|
508
519
|
cursorPos: this.cursorPos,
|
|
@@ -518,6 +529,11 @@ export class InputHandler {
|
|
|
518
529
|
this.cursorPos = result.cursorPos;
|
|
519
530
|
this.nextImageId = result.nextImageId;
|
|
520
531
|
this.nextPasteId = result.nextPasteId;
|
|
532
|
+
if (!result.pasted) {
|
|
533
|
+
this.conversationManager?.log('[Paste: clipboard does not contain supported text or image data]', { fg: '240' });
|
|
534
|
+
this.requestRender();
|
|
535
|
+
}
|
|
536
|
+
return result;
|
|
521
537
|
}
|
|
522
538
|
|
|
523
539
|
/** Content width for wrapping — set by main.ts via setContentWidth(). */
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import type { InputToken } from '@pellux/goodvibes-sdk/platform/core';
|
|
2
|
+
import type {
|
|
3
|
+
McpConfigScope,
|
|
4
|
+
McpEffectiveConfig,
|
|
5
|
+
McpServerConfig,
|
|
6
|
+
McpServerConfigEntry,
|
|
7
|
+
McpServerSecurityRecord,
|
|
8
|
+
RegisteredTool,
|
|
9
|
+
} from '@pellux/goodvibes-sdk/platform/mcp';
|
|
10
|
+
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
11
|
+
import type { CommandContext } from './command-registry.ts';
|
|
12
|
+
import { requireMcpApi, requireShellPaths } from './commands/runtime-services.ts';
|
|
13
|
+
|
|
14
|
+
export const MCP_WORKSPACE_MODAL_NAME = 'mcpWorkspace';
|
|
15
|
+
|
|
16
|
+
const MCP_ROLES = ['general', 'docs', 'filesystem', 'git', 'database', 'browser', 'automation', 'ops', 'remote'] as const;
|
|
17
|
+
const MCP_FORM_TRUST_MODES = ['constrained', 'ask-on-risk', 'blocked'] as const;
|
|
18
|
+
|
|
19
|
+
export type McpWorkspaceMode = 'browse' | 'form' | 'delete-confirm';
|
|
20
|
+
|
|
21
|
+
export interface McpWorkspaceServerRow {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly connected: boolean;
|
|
24
|
+
readonly role: string;
|
|
25
|
+
readonly trustMode: string;
|
|
26
|
+
readonly freshness: string;
|
|
27
|
+
readonly source: 'project' | 'global' | 'external' | 'runtime';
|
|
28
|
+
readonly command?: string;
|
|
29
|
+
readonly args?: readonly string[];
|
|
30
|
+
readonly allowedPaths: readonly string[];
|
|
31
|
+
readonly allowedHosts: readonly string[];
|
|
32
|
+
readonly quarantineReason?: string;
|
|
33
|
+
readonly quarantineDetail?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface McpWorkspaceActionRow {
|
|
37
|
+
readonly type: 'action';
|
|
38
|
+
readonly id: 'add' | 'reload' | 'refresh-tools' | 'config';
|
|
39
|
+
readonly label: string;
|
|
40
|
+
readonly detail: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type McpWorkspaceRow =
|
|
44
|
+
| { readonly type: 'server'; readonly server: McpWorkspaceServerRow }
|
|
45
|
+
| McpWorkspaceActionRow;
|
|
46
|
+
|
|
47
|
+
export interface McpWorkspaceFormField {
|
|
48
|
+
readonly id: keyof McpWorkspaceForm | 'save' | 'cancel';
|
|
49
|
+
readonly label: string;
|
|
50
|
+
readonly value: string;
|
|
51
|
+
readonly help: string;
|
|
52
|
+
readonly editable: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface McpWorkspaceForm {
|
|
56
|
+
scope: McpConfigScope;
|
|
57
|
+
name: string;
|
|
58
|
+
command: string;
|
|
59
|
+
args: string;
|
|
60
|
+
role: NonNullable<McpServerConfig['role']>;
|
|
61
|
+
trustMode: Exclude<NonNullable<McpServerConfig['trustMode']>, 'allow-all'>;
|
|
62
|
+
env: string;
|
|
63
|
+
allowedPaths: string;
|
|
64
|
+
allowedHosts: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface McpWorkspaceSnapshot {
|
|
68
|
+
readonly projectPath: string;
|
|
69
|
+
readonly globalPath: string;
|
|
70
|
+
readonly effectiveConfig: McpEffectiveConfig;
|
|
71
|
+
readonly servers: readonly McpWorkspaceServerRow[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isTextField(field: keyof McpWorkspaceForm | 'save' | 'cancel'): field is 'name' | 'command' | 'args' | 'env' | 'allowedPaths' | 'allowedHosts' {
|
|
75
|
+
return field === 'name' || field === 'command' || field === 'args' || field === 'env' || field === 'allowedPaths' || field === 'allowedHosts';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function splitList(value: string): string[] {
|
|
79
|
+
return value
|
|
80
|
+
.split(',')
|
|
81
|
+
.map((entry) => entry.trim())
|
|
82
|
+
.filter(Boolean);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseArgs(value: string): string[] {
|
|
86
|
+
const args: string[] = [];
|
|
87
|
+
let current = '';
|
|
88
|
+
let quote: '"' | "'" | null = null;
|
|
89
|
+
let escaping = false;
|
|
90
|
+
for (const ch of value) {
|
|
91
|
+
if (escaping) {
|
|
92
|
+
current += ch;
|
|
93
|
+
escaping = false;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (ch === '\\') {
|
|
97
|
+
escaping = true;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (quote) {
|
|
101
|
+
if (ch === quote) quote = null;
|
|
102
|
+
else current += ch;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (ch === '"' || ch === "'") {
|
|
106
|
+
quote = ch;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (/\s/.test(ch)) {
|
|
110
|
+
if (current.length > 0) {
|
|
111
|
+
args.push(current);
|
|
112
|
+
current = '';
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
current += ch;
|
|
117
|
+
}
|
|
118
|
+
if (escaping) current += '\\';
|
|
119
|
+
if (current.length > 0) args.push(current);
|
|
120
|
+
return args;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseEnv(value: string): Record<string, string> | undefined {
|
|
124
|
+
const entries: Array<[string, string]> = [];
|
|
125
|
+
for (const raw of splitList(value)) {
|
|
126
|
+
const eq = raw.indexOf('=');
|
|
127
|
+
if (eq <= 0) throw new Error(`Invalid env entry "${raw}". Use KEY=VALUE.`);
|
|
128
|
+
entries.push([raw.slice(0, eq).trim(), raw.slice(eq + 1)]);
|
|
129
|
+
}
|
|
130
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function serverConfigToForm(server?: McpServerConfig): McpWorkspaceForm {
|
|
134
|
+
return {
|
|
135
|
+
scope: 'project',
|
|
136
|
+
name: server?.name ?? '',
|
|
137
|
+
command: server?.command ?? '',
|
|
138
|
+
args: server?.args?.join(' ') ?? '',
|
|
139
|
+
role: server?.role ?? 'general',
|
|
140
|
+
trustMode: server?.trustMode === 'blocked' || server?.trustMode === 'ask-on-risk' ? server.trustMode : 'constrained',
|
|
141
|
+
env: server?.env ? Object.entries(server.env).map(([key, value]) => `${key}=${value}`).join(', ') : '',
|
|
142
|
+
allowedPaths: server?.allowedPaths?.join(', ') ?? '',
|
|
143
|
+
allowedHosts: server?.allowedHosts?.join(', ') ?? '',
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function formToServerConfig(form: McpWorkspaceForm): McpServerConfig {
|
|
148
|
+
const name = form.name.trim();
|
|
149
|
+
const command = form.command.trim();
|
|
150
|
+
if (!name) throw new Error('Server name is required.');
|
|
151
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
|
|
152
|
+
throw new Error('Server names may contain letters, numbers, dot, underscore, and dash only.');
|
|
153
|
+
}
|
|
154
|
+
if (!command) throw new Error('Command is required.');
|
|
155
|
+
const args = parseArgs(form.args.trim());
|
|
156
|
+
const env = parseEnv(form.env);
|
|
157
|
+
const allowedPaths = splitList(form.allowedPaths);
|
|
158
|
+
const allowedHosts = splitList(form.allowedHosts);
|
|
159
|
+
return {
|
|
160
|
+
name,
|
|
161
|
+
command,
|
|
162
|
+
...(args.length > 0 ? { args } : {}),
|
|
163
|
+
role: form.role,
|
|
164
|
+
trustMode: form.trustMode,
|
|
165
|
+
...(env ? { env } : {}),
|
|
166
|
+
...(allowedPaths.length > 0 ? { allowedPaths } : {}),
|
|
167
|
+
...(allowedHosts.length > 0 ? { allowedHosts } : {}),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function nextEnumValue<T extends readonly string[]>(values: T, current: T[number], direction: 1 | -1): T[number] {
|
|
172
|
+
const index = values.indexOf(current);
|
|
173
|
+
const next = (index + direction + values.length) % values.length;
|
|
174
|
+
return values[next]!;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function mergeServers(
|
|
178
|
+
effectiveConfig: McpEffectiveConfig,
|
|
179
|
+
runtimeServers: readonly McpServerSecurityRecord[],
|
|
180
|
+
): McpWorkspaceServerRow[] {
|
|
181
|
+
const configByName = new Map<string, McpServerConfigEntry>();
|
|
182
|
+
for (const entry of effectiveConfig.servers) configByName.set(entry.server.name, entry);
|
|
183
|
+
const runtimeByName = new Map(runtimeServers.map((server) => [server.name, server]));
|
|
184
|
+
const names = new Set<string>([
|
|
185
|
+
...effectiveConfig.servers.map((entry) => entry.server.name),
|
|
186
|
+
...runtimeServers.map((server) => server.name),
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
return [...names].sort((a, b) => a.localeCompare(b)).map((name) => {
|
|
190
|
+
const entry = configByName.get(name);
|
|
191
|
+
const config = entry?.server;
|
|
192
|
+
const runtime = runtimeByName.get(name);
|
|
193
|
+
return {
|
|
194
|
+
name,
|
|
195
|
+
connected: runtime?.connected ?? false,
|
|
196
|
+
role: runtime?.role ?? config?.role ?? 'general',
|
|
197
|
+
trustMode: runtime?.trustMode ?? config?.trustMode ?? 'constrained',
|
|
198
|
+
freshness: runtime?.schemaFreshness ?? 'unknown',
|
|
199
|
+
source: entry?.source.scope === 'project' ? 'project' : entry?.source.scope === 'global' ? 'global' : config ? 'external' : 'runtime',
|
|
200
|
+
command: config?.command,
|
|
201
|
+
args: config?.args,
|
|
202
|
+
allowedPaths: runtime?.allowedPaths ?? config?.allowedPaths ?? [],
|
|
203
|
+
allowedHosts: runtime?.allowedHosts ?? config?.allowedHosts ?? [],
|
|
204
|
+
quarantineReason: runtime?.quarantineReason,
|
|
205
|
+
quarantineDetail: runtime?.quarantineDetail,
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export class McpWorkspace {
|
|
211
|
+
public active = false;
|
|
212
|
+
public mode: McpWorkspaceMode = 'browse';
|
|
213
|
+
public selectedIndex = 0;
|
|
214
|
+
public formIndex = 0;
|
|
215
|
+
public form: McpWorkspaceForm = serverConfigToForm();
|
|
216
|
+
public editingServerName: string | null = null;
|
|
217
|
+
public status = 'Ready. Add, edit, remove, reload, and inspect MCP servers without restarting the TUI.';
|
|
218
|
+
public tools: readonly RegisteredTool[] = [];
|
|
219
|
+
public loadingTools = false;
|
|
220
|
+
public lastError: string | null = null;
|
|
221
|
+
private context: CommandContext | null = null;
|
|
222
|
+
private snapshot: McpWorkspaceSnapshot = {
|
|
223
|
+
projectPath: '',
|
|
224
|
+
globalPath: '',
|
|
225
|
+
effectiveConfig: { servers: [], locations: [] },
|
|
226
|
+
servers: [],
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
open(context: CommandContext): void {
|
|
230
|
+
this.context = context;
|
|
231
|
+
this.active = true;
|
|
232
|
+
this.mode = 'browse';
|
|
233
|
+
this.selectedIndex = 0;
|
|
234
|
+
this.formIndex = 0;
|
|
235
|
+
this.lastError = null;
|
|
236
|
+
this.refreshSnapshot();
|
|
237
|
+
void this.refreshTools();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
reopen(): void {
|
|
241
|
+
this.active = true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
close(): void {
|
|
245
|
+
this.active = false;
|
|
246
|
+
this.mode = 'browse';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
get projectPath(): string {
|
|
250
|
+
return this.snapshot.projectPath;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
get servers(): readonly McpWorkspaceServerRow[] {
|
|
254
|
+
return this.snapshot.servers;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
get rows(): readonly McpWorkspaceRow[] {
|
|
258
|
+
return [
|
|
259
|
+
...this.snapshot.servers.map((server): McpWorkspaceRow => ({ type: 'server', server })),
|
|
260
|
+
{ type: 'action', id: 'add', label: 'Add server', detail: `Write a server through the SDK config manager. Default scope: ${this.form.scope}.` },
|
|
261
|
+
{ type: 'action', id: 'reload', label: 'Reload runtime', detail: 'Reconnect all MCP servers from global, Claude, and project config files.' },
|
|
262
|
+
{ type: 'action', id: 'refresh-tools', label: 'Refresh tools', detail: 'Fetch the currently available MCP tool list from connected servers.' },
|
|
263
|
+
{ type: 'action', id: 'config', label: 'Config locations', detail: 'Show SDK-scanned config files and writable project/global paths.' },
|
|
264
|
+
];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
get selectedRow(): McpWorkspaceRow | null {
|
|
268
|
+
return this.rows[this.selectedIndex] ?? null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
get selectedServer(): McpWorkspaceServerRow | null {
|
|
272
|
+
const row = this.selectedRow;
|
|
273
|
+
return row?.type === 'server' ? row.server : null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
get formFields(): readonly McpWorkspaceFormField[] {
|
|
277
|
+
return [
|
|
278
|
+
{ id: 'name', label: 'Server name', value: this.form.name, help: 'Unique MCP server id. Project config overrides matching global server names.', editable: true },
|
|
279
|
+
{ id: 'scope', label: 'Scope', value: this.form.scope, help: 'Cycle with Left/Right. Project writes to this workspace; global writes to your user MCP config.', editable: false },
|
|
280
|
+
{ id: 'command', label: 'Command', value: this.form.command, help: 'Executable to launch, for example npx, node, uvx, python, or an absolute path.', editable: true },
|
|
281
|
+
{ id: 'args', label: 'Arguments', value: this.form.args, help: 'Command arguments. Quotes are supported for values with spaces.', editable: true },
|
|
282
|
+
{ id: 'role', label: 'Role', value: this.form.role, help: `Cycle with Left/Right. Values: ${MCP_ROLES.join(', ')}.`, editable: false },
|
|
283
|
+
{ id: 'trustMode', label: 'Trust mode', value: this.form.trustMode, help: 'Cycle with Left/Right. allow-all is intentionally not offered here; use Settings MCP for explicit escalation.', editable: false },
|
|
284
|
+
{ id: 'env', label: 'Environment', value: this.form.env, help: 'Comma-separated KEY=VALUE entries. Prefer env var references or secure secrets for sensitive values.', editable: true },
|
|
285
|
+
{ id: 'allowedPaths', label: 'Allowed paths', value: this.form.allowedPaths, help: 'Comma-separated path prefixes for filesystem-oriented servers.', editable: true },
|
|
286
|
+
{ id: 'allowedHosts', label: 'Allowed hosts', value: this.form.allowedHosts, help: 'Comma-separated hostnames for network-oriented servers.', editable: true },
|
|
287
|
+
{ id: 'save', label: 'Save and reload', value: '', help: 'Write the selected scope config and reconnect the live MCP runtime.', editable: false },
|
|
288
|
+
{ id: 'cancel', label: 'Cancel', value: '', help: 'Return to the MCP server browser without changing config.', editable: false },
|
|
289
|
+
];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
refreshSnapshot(): void {
|
|
293
|
+
if (!this.context) return;
|
|
294
|
+
const roots = requireShellPaths(this.context);
|
|
295
|
+
const api = requireMcpApi(this.context);
|
|
296
|
+
const effectiveConfig = api.getEffectiveConfig(roots);
|
|
297
|
+
const runtimeServers = api.listServerSecurity();
|
|
298
|
+
const projectPath = effectiveConfig.locations.find((location) => location.scope === 'project' && location.writable)?.path ?? `${roots.workingDirectory}/.goodvibes/mcp.json`;
|
|
299
|
+
const globalPath = effectiveConfig.locations.find((location) => location.scope === 'global' && location.writable)?.path ?? `${roots.homeDirectory}/.config/mcp/mcp.json`;
|
|
300
|
+
this.snapshot = {
|
|
301
|
+
projectPath,
|
|
302
|
+
globalPath,
|
|
303
|
+
effectiveConfig,
|
|
304
|
+
servers: mergeServers(effectiveConfig, runtimeServers),
|
|
305
|
+
};
|
|
306
|
+
this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, this.rows.length - 1));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async refreshTools(): Promise<void> {
|
|
310
|
+
if (!this.context) return;
|
|
311
|
+
this.loadingTools = true;
|
|
312
|
+
this.lastError = null;
|
|
313
|
+
try {
|
|
314
|
+
const api = requireMcpApi(this.context);
|
|
315
|
+
this.tools = await api.listAllTools();
|
|
316
|
+
this.status = `Tool list refreshed: ${this.tools.length} tool(s) available.`;
|
|
317
|
+
} catch (error) {
|
|
318
|
+
this.lastError = summarizeError(error);
|
|
319
|
+
this.status = `Tool refresh failed: ${this.lastError}`;
|
|
320
|
+
} finally {
|
|
321
|
+
this.loadingTools = false;
|
|
322
|
+
this.context?.renderRequest();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async reloadRuntime(): Promise<void> {
|
|
327
|
+
if (!this.context) return;
|
|
328
|
+
this.lastError = null;
|
|
329
|
+
try {
|
|
330
|
+
const api = requireMcpApi(this.context);
|
|
331
|
+
const roots = requireShellPaths(this.context);
|
|
332
|
+
const result = await api.reload(roots);
|
|
333
|
+
this.refreshSnapshot();
|
|
334
|
+
const connected = this.snapshot.servers.filter((server) => server.connected).length;
|
|
335
|
+
this.status = `Reloaded MCP runtime: ${connected}/${this.snapshot.servers.length} server(s) connected. Result: +${result.added} ~${result.changed} -${result.removed}, unchanged ${result.unchanged}.`;
|
|
336
|
+
void this.refreshTools();
|
|
337
|
+
} catch (error) {
|
|
338
|
+
this.lastError = summarizeError(error);
|
|
339
|
+
this.status = `Reload failed: ${this.lastError}`;
|
|
340
|
+
} finally {
|
|
341
|
+
this.context?.renderRequest();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
openAddForm(): void {
|
|
346
|
+
this.mode = 'form';
|
|
347
|
+
this.formIndex = 0;
|
|
348
|
+
this.editingServerName = null;
|
|
349
|
+
this.form = serverConfigToForm();
|
|
350
|
+
this.status = 'Add an MCP server. Choose project or global scope, then save and reload.';
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
openEditForm(serverName: string): void {
|
|
354
|
+
const entry = this.snapshot.effectiveConfig.servers.find((configEntry) => configEntry.server.name === serverName);
|
|
355
|
+
this.mode = 'form';
|
|
356
|
+
this.formIndex = 0;
|
|
357
|
+
this.editingServerName = serverName;
|
|
358
|
+
this.form = { ...serverConfigToForm(entry?.server), scope: entry?.source.scope === 'global' ? 'global' : 'project' };
|
|
359
|
+
this.status = entry
|
|
360
|
+
? `Editing ${serverName}. Saving writes a ${this.form.scope} config entry and reloads the live runtime.`
|
|
361
|
+
: `Editing ${serverName}. Runtime status exists, but no launch config was found; enter command details before saving.`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
requestDelete(serverName: string): void {
|
|
365
|
+
this.mode = 'delete-confirm';
|
|
366
|
+
this.editingServerName = serverName;
|
|
367
|
+
const entry = this.snapshot.effectiveConfig.servers.find((configEntry) => configEntry.server.name === serverName);
|
|
368
|
+
const scope = entry?.source.scope === 'global' ? 'global' : 'project';
|
|
369
|
+
this.status = `Remove ${scope} server "${serverName}"? Press y to confirm or n/Esc to cancel.`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async saveForm(): Promise<void> {
|
|
373
|
+
if (!this.context) return;
|
|
374
|
+
try {
|
|
375
|
+
const server = formToServerConfig(this.form);
|
|
376
|
+
this.status = `Saving ${server.name} to ${this.form.scope} MCP config and reloading runtime...`;
|
|
377
|
+
this.mode = 'browse';
|
|
378
|
+
this.editingServerName = null;
|
|
379
|
+
const api = requireMcpApi(this.context);
|
|
380
|
+
const result = await api.upsertServerConfig(requireShellPaths(this.context), this.form.scope, server);
|
|
381
|
+
this.refreshSnapshot();
|
|
382
|
+
this.status = `Saved ${server.name} to ${result.path}. Reload result: +${result.reload.added} ~${result.reload.changed} -${result.reload.removed}, unchanged ${result.reload.unchanged}.`;
|
|
383
|
+
void this.refreshTools();
|
|
384
|
+
} catch (error) {
|
|
385
|
+
this.lastError = summarizeError(error);
|
|
386
|
+
this.status = `Save failed: ${this.lastError}`;
|
|
387
|
+
this.context.renderRequest();
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async confirmDelete(): Promise<void> {
|
|
392
|
+
if (!this.context || !this.editingServerName) return;
|
|
393
|
+
const name = this.editingServerName;
|
|
394
|
+
try {
|
|
395
|
+
const server = this.snapshot.effectiveConfig.servers.find((entry) => entry.server.name === name);
|
|
396
|
+
const scope = server?.source.scope === 'global' ? 'global' : 'project';
|
|
397
|
+
const api = requireMcpApi(this.context);
|
|
398
|
+
const result = await api.removeServerConfig(requireShellPaths(this.context), scope, name);
|
|
399
|
+
this.mode = 'browse';
|
|
400
|
+
this.editingServerName = null;
|
|
401
|
+
this.refreshSnapshot();
|
|
402
|
+
this.status = result.removed
|
|
403
|
+
? `Removed ${scope} server "${name}" from ${result.path}. Reload result: +${result.reload.added} ~${result.reload.changed} -${result.reload.removed}.`
|
|
404
|
+
: `No ${scope} MCP server named "${name}" exists in ${result.path}.`;
|
|
405
|
+
void this.refreshTools();
|
|
406
|
+
} catch (error) {
|
|
407
|
+
this.lastError = summarizeError(error);
|
|
408
|
+
this.status = `Remove failed: ${this.lastError}`;
|
|
409
|
+
this.context.renderRequest();
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
cancelForm(): void {
|
|
414
|
+
this.mode = 'browse';
|
|
415
|
+
this.editingServerName = null;
|
|
416
|
+
this.status = 'Returned to MCP server browser.';
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
moveSelection(delta: number): void {
|
|
420
|
+
const total = this.mode === 'form' ? this.formFields.length : this.rows.length;
|
|
421
|
+
if (total <= 0) return;
|
|
422
|
+
if (this.mode === 'form') {
|
|
423
|
+
this.formIndex = Math.max(0, Math.min(total - 1, this.formIndex + delta));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
this.selectedIndex = Math.max(0, Math.min(total - 1, this.selectedIndex + delta));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async activateSelected(): Promise<void> {
|
|
430
|
+
if (this.mode === 'form') {
|
|
431
|
+
const field = this.formFields[this.formIndex];
|
|
432
|
+
if (field?.id === 'save') await this.saveForm();
|
|
433
|
+
else if (field?.id === 'cancel') this.cancelForm();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (this.mode === 'delete-confirm') {
|
|
437
|
+
await this.confirmDelete();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const row = this.selectedRow;
|
|
441
|
+
if (!row) return;
|
|
442
|
+
if (row.type === 'server') {
|
|
443
|
+
this.openEditForm(row.server.name);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (row.id === 'add') this.openAddForm();
|
|
447
|
+
else if (row.id === 'reload') await this.reloadRuntime();
|
|
448
|
+
else if (row.id === 'refresh-tools') await this.refreshTools();
|
|
449
|
+
else if (row.id === 'config') {
|
|
450
|
+
this.status = [
|
|
451
|
+
`Project config: ${this.snapshot.projectPath}`,
|
|
452
|
+
`Global config: ${this.snapshot.globalPath}`,
|
|
453
|
+
`Scanned: ${this.snapshot.effectiveConfig.locations.map((location) => `${location.kind}:${location.writable ? 'writable' : 'read-only'}`).join(', ')}`,
|
|
454
|
+
].join(' ');
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
adjustFormEnum(direction: 1 | -1): void {
|
|
459
|
+
const field = this.formFields[this.formIndex];
|
|
460
|
+
if (!field) return;
|
|
461
|
+
if (field.id === 'role') {
|
|
462
|
+
this.form.role = nextEnumValue(MCP_ROLES, this.form.role, direction);
|
|
463
|
+
} else if (field.id === 'scope') {
|
|
464
|
+
this.form.scope = nextEnumValue(['project', 'global'] as const, this.form.scope, direction);
|
|
465
|
+
} else if (field.id === 'trustMode') {
|
|
466
|
+
this.form.trustMode = nextEnumValue(MCP_FORM_TRUST_MODES, this.form.trustMode, direction);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
appendFormText(text: string): void {
|
|
471
|
+
const field = this.formFields[this.formIndex];
|
|
472
|
+
if (!field || !isTextField(field.id)) return;
|
|
473
|
+
this.form[field.id] += text;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
backspaceFormText(): void {
|
|
477
|
+
const field = this.formFields[this.formIndex];
|
|
478
|
+
if (!field || !isTextField(field.id)) return;
|
|
479
|
+
this.form[field.id] = this.form[field.id].slice(0, -1);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export function handleMcpWorkspaceToken(
|
|
484
|
+
workspace: McpWorkspace,
|
|
485
|
+
token: InputToken,
|
|
486
|
+
handleEscape: () => void,
|
|
487
|
+
requestRender: () => void,
|
|
488
|
+
): boolean {
|
|
489
|
+
if (!workspace.active) return false;
|
|
490
|
+
|
|
491
|
+
if (token.type === 'mouse') {
|
|
492
|
+
if (token.action === 'press' && token.button === 64) workspace.moveSelection(-3);
|
|
493
|
+
else if (token.action === 'press' && token.button === 65) workspace.moveSelection(3);
|
|
494
|
+
else return true;
|
|
495
|
+
requestRender();
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (token.type === 'text') {
|
|
500
|
+
if (workspace.mode === 'form') {
|
|
501
|
+
workspace.appendFormText(token.value);
|
|
502
|
+
} else if (workspace.mode === 'delete-confirm') {
|
|
503
|
+
if (token.value.toLowerCase() === 'y') void workspace.confirmDelete();
|
|
504
|
+
else if (token.value.toLowerCase() === 'n') workspace.cancelForm();
|
|
505
|
+
} else {
|
|
506
|
+
const value = token.value.toLowerCase();
|
|
507
|
+
if (value === 'a') workspace.openAddForm();
|
|
508
|
+
else if (value === 'e' && workspace.selectedServer) workspace.openEditForm(workspace.selectedServer.name);
|
|
509
|
+
else if (value === 'd' && workspace.selectedServer) workspace.requestDelete(workspace.selectedServer.name);
|
|
510
|
+
else if (value === 'r') void workspace.reloadRuntime();
|
|
511
|
+
else if (value === 't') void workspace.refreshTools();
|
|
512
|
+
}
|
|
513
|
+
requestRender();
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (token.type !== 'key') return true;
|
|
518
|
+
if (token.logicalName === 'escape') {
|
|
519
|
+
if (workspace.mode === 'form' || workspace.mode === 'delete-confirm') {
|
|
520
|
+
workspace.cancelForm();
|
|
521
|
+
requestRender();
|
|
522
|
+
} else {
|
|
523
|
+
handleEscape();
|
|
524
|
+
}
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (token.logicalName === 'up') workspace.moveSelection(token.shift ? -5 : -1);
|
|
529
|
+
else if (token.logicalName === 'down') workspace.moveSelection(token.shift ? 5 : 1);
|
|
530
|
+
else if (token.logicalName === 'pageup') workspace.moveSelection(-10);
|
|
531
|
+
else if (token.logicalName === 'pagedown') workspace.moveSelection(10);
|
|
532
|
+
else if (token.logicalName === 'enter') void workspace.activateSelected();
|
|
533
|
+
else if (token.logicalName === 'backspace' || token.logicalName === 'delete') {
|
|
534
|
+
if (workspace.mode === 'form') workspace.backspaceFormText();
|
|
535
|
+
else if (workspace.mode === 'browse' && workspace.selectedServer) workspace.requestDelete(workspace.selectedServer.name);
|
|
536
|
+
} else if (token.logicalName === 'left') {
|
|
537
|
+
if (workspace.mode === 'form') workspace.adjustFormEnum(-1);
|
|
538
|
+
} else if (token.logicalName === 'right') {
|
|
539
|
+
if (workspace.mode === 'form') workspace.adjustFormEnum(1);
|
|
540
|
+
} else if (token.logicalName === 'space') {
|
|
541
|
+
if (workspace.mode === 'form') workspace.appendFormText(' ');
|
|
542
|
+
} else if (token.logicalName === 'a' && workspace.mode === 'browse') {
|
|
543
|
+
workspace.openAddForm();
|
|
544
|
+
} else if (token.logicalName === 'd' && workspace.mode === 'browse' && workspace.selectedServer) {
|
|
545
|
+
workspace.requestDelete(workspace.selectedServer.name);
|
|
546
|
+
} else if (token.logicalName === 'r' && workspace.mode === 'browse') {
|
|
547
|
+
void workspace.reloadRuntime();
|
|
548
|
+
} else if (token.logicalName === 't' && workspace.mode === 'browse') {
|
|
549
|
+
void workspace.refreshTools();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
requestRender();
|
|
553
|
+
return true;
|
|
554
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -376,6 +376,7 @@ async function main() {
|
|
|
376
376
|
commandContext.submitInput = submitInput;
|
|
377
377
|
commandContext.submitSpokenInput = (text, content) => submitInput(text, content, { spokenOutput: true });
|
|
378
378
|
commandContext.stopSpokenOutput = () => spokenTurns.stop();
|
|
379
|
+
commandContext.pasteFromClipboard = () => input.handlePaste();
|
|
379
380
|
commandContext.executeCommand = (name, args) => commandRegistry.execute(name, args, commandContext);
|
|
380
381
|
commandContext.cancelGeneration = cancelGeneration;
|
|
381
382
|
commandContext.jumpToBookmark = jumpToBookmark;
|