@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.
@@ -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(): void {
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;