@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "0.19.96",
3
+ "version": "0.19.99",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -97,7 +97,7 @@
97
97
  "@anthropic-ai/vertex-sdk": "^0.16.0",
98
98
  "@ast-grep/napi": "^0.42.0",
99
99
  "@aws/bedrock-token-generator": "^1.1.0",
100
- "@pellux/goodvibes-sdk": "0.33.26",
100
+ "@pellux/goodvibes-sdk": "0.33.27",
101
101
  "bash-language-server": "^5.6.0",
102
102
  "fuse.js": "^7.1.0",
103
103
  "graphql": "^16.13.2",
@@ -61,6 +61,11 @@ export interface CommandUiActions {
61
61
  submitInput?: (text: string, content?: import('@pellux/goodvibes-sdk/platform/providers').ContentPart[]) => void;
62
62
  submitSpokenInput?: (text: string, content?: import('@pellux/goodvibes-sdk/platform/providers').ContentPart[]) => void;
63
63
  stopSpokenOutput?: () => void;
64
+ pasteFromClipboard?: () => {
65
+ pasted: boolean;
66
+ kind: 'image' | 'text' | 'none';
67
+ marker?: string;
68
+ };
64
69
  executeCommand?: (name: string, args: string[]) => Promise<boolean>;
65
70
  cancelGeneration?: () => void;
66
71
  completeModelSelection?: (selection: {
@@ -110,7 +115,7 @@ export interface CommandShellUiOpeners {
110
115
  openPolicyPanel?: () => void;
111
116
  openHooksPanel?: () => void;
112
117
  openCommunicationPanel?: () => void;
113
- openMcpPanel?: () => void;
118
+ openMcpWorkspace?: () => void;
114
119
  openSecurityPanel?: () => void;
115
120
  openKnowledgePanel?: () => void;
116
121
  openRemotePanel?: () => void;
@@ -1,20 +1,150 @@
1
- import type { CommandRegistry } from '../command-registry.ts';
2
- import { requireMcpApi } from './runtime-services.ts';
1
+ import type { CommandContext, CommandRegistry } from '../command-registry.ts';
2
+ import type { McpConfigScope, McpReloadResult, McpServerConfig } from '@pellux/goodvibes-sdk/platform/mcp';
3
+ import { requireMcpApi, requireShellPaths } from './runtime-services.ts';
3
4
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
4
5
 
6
+ const MCP_ROLES = ['general', 'docs', 'filesystem', 'git', 'database', 'browser', 'automation', 'ops', 'remote'] as const;
7
+ const MCP_TRUST_MODES = ['constrained', 'ask-on-risk', 'allow-all', 'blocked'] as const;
8
+
9
+ interface ParsedMcpAddArgs {
10
+ readonly scope: McpConfigScope;
11
+ readonly server: McpServerConfig;
12
+ }
13
+
14
+ function isMcpRole(value: string): value is NonNullable<McpServerConfig['role']> {
15
+ return MCP_ROLES.includes(value as NonNullable<McpServerConfig['role']>);
16
+ }
17
+
18
+ function isMcpTrustMode(value: string): value is NonNullable<McpServerConfig['trustMode']> {
19
+ return MCP_TRUST_MODES.includes(value as NonNullable<McpServerConfig['trustMode']>);
20
+ }
21
+
22
+ function isMcpScope(value: string): value is McpConfigScope {
23
+ return value === 'project' || value === 'global';
24
+ }
25
+
26
+ function validateServerName(name: string): string | null {
27
+ if (!name.trim()) return 'MCP server name is required.';
28
+ if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
29
+ return 'MCP server names may contain letters, numbers, dot, underscore, and dash only.';
30
+ }
31
+ return null;
32
+ }
33
+
34
+ function readFlagValue(tokens: string[], index: number, flag: string): string {
35
+ const value = tokens[index + 1];
36
+ if (!value) {
37
+ throw new Error(`Missing value after ${flag}.`);
38
+ }
39
+ return value;
40
+ }
41
+
42
+ function parseAddServerArgs(args: string[]): ParsedMcpAddArgs {
43
+ const name = args[1]?.trim();
44
+ const command = args[2]?.trim();
45
+ if (!name || !command) {
46
+ throw new Error('Usage: /mcp add <name> <command> [args...] [--scope project|global] [--role <role>] [--trust <mode>] [--env KEY=VALUE] [--path <path>] [--host <host>]');
47
+ }
48
+ const nameError = validateServerName(name);
49
+ if (nameError) throw new Error(nameError);
50
+
51
+ const serverArgs: string[] = [];
52
+ const env: Record<string, string> = {};
53
+ const allowedPaths: string[] = [];
54
+ const allowedHosts: string[] = [];
55
+ let role: McpServerConfig['role'];
56
+ let trustMode: McpServerConfig['trustMode'];
57
+ let scope: McpConfigScope = 'project';
58
+ let passthrough = false;
59
+ const tokens = args.slice(3);
60
+
61
+ for (let index = 0; index < tokens.length; index += 1) {
62
+ const token = tokens[index]!;
63
+ if (passthrough) {
64
+ serverArgs.push(token);
65
+ continue;
66
+ }
67
+ if (token === '--') {
68
+ passthrough = true;
69
+ continue;
70
+ }
71
+ if (token === '--role') {
72
+ const value = readFlagValue(tokens, index, token);
73
+ if (!isMcpRole(value)) throw new Error(`Invalid MCP role "${value}". Expected one of: ${MCP_ROLES.join(', ')}`);
74
+ role = value;
75
+ index += 1;
76
+ continue;
77
+ }
78
+ if (token === '--scope') {
79
+ const value = readFlagValue(tokens, index, token);
80
+ if (!isMcpScope(value)) throw new Error(`Invalid MCP scope "${value}". Expected project or global.`);
81
+ scope = value;
82
+ index += 1;
83
+ continue;
84
+ }
85
+ if (token === '--trust') {
86
+ const value = readFlagValue(tokens, index, token);
87
+ if (!isMcpTrustMode(value)) throw new Error(`Invalid MCP trust mode "${value}". Expected one of: ${MCP_TRUST_MODES.join(', ')}`);
88
+ trustMode = value;
89
+ index += 1;
90
+ continue;
91
+ }
92
+ if (token === '--env') {
93
+ const value = readFlagValue(tokens, index, token);
94
+ const eq = value.indexOf('=');
95
+ if (eq <= 0) throw new Error('MCP env entries must use KEY=VALUE.');
96
+ env[value.slice(0, eq)] = value.slice(eq + 1);
97
+ index += 1;
98
+ continue;
99
+ }
100
+ if (token === '--path') {
101
+ allowedPaths.push(readFlagValue(tokens, index, token));
102
+ index += 1;
103
+ continue;
104
+ }
105
+ if (token === '--host') {
106
+ allowedHosts.push(readFlagValue(tokens, index, token));
107
+ index += 1;
108
+ continue;
109
+ }
110
+ serverArgs.push(token);
111
+ }
112
+
113
+ return {
114
+ scope,
115
+ server: {
116
+ name,
117
+ command,
118
+ ...(serverArgs.length > 0 ? { args: serverArgs } : {}),
119
+ ...(Object.keys(env).length > 0 ? { env } : {}),
120
+ ...(role ? { role } : {}),
121
+ ...(trustMode ? { trustMode } : {}),
122
+ ...(allowedPaths.length > 0 ? { allowedPaths } : {}),
123
+ ...(allowedHosts.length > 0 ? { allowedHosts } : {}),
124
+ },
125
+ };
126
+ }
127
+
128
+ async function reloadMcpRuntime(ctx: CommandContext): Promise<McpReloadResult> {
129
+ const result = await requireMcpApi(ctx).reload(requireShellPaths(ctx));
130
+ ctx.renderRequest();
131
+ return result;
132
+ }
133
+
5
134
  export function registerMcpRuntimeCommands(registry: CommandRegistry): void {
6
135
  registry.register({
7
136
  name: 'mcp',
8
137
  aliases: [],
9
- description: 'List connected MCP servers and their tools',
10
- usage: '[review|tools [<server>]|auth-review|repair [server]]',
11
- argsHint: '[review|tools [server]|auth-review|repair [server]]',
138
+ description: 'Manage MCP servers and their tools',
139
+ usage: '[add|remove|reload|config|review|tools [<server>]|auth-review|repair [server]]',
140
+ argsHint: '[add|remove|reload|config|review|tools [server]]',
12
141
  async handler(args, ctx) {
13
142
  const mcpApi = requireMcpApi(ctx);
14
143
  const listServerSecurity = () => mcpApi.listServerSecurity();
15
144
  const subcommand = args[0];
16
- if (!subcommand && ctx.openMcpPanel) {
17
- ctx.openMcpPanel();
145
+ if (!subcommand && ctx.openMcpWorkspace) {
146
+ ctx.openMcpWorkspace();
147
+ return;
18
148
  }
19
149
  if (subcommand === 'review') {
20
150
  const servers = listServerSecurity();
@@ -141,6 +271,102 @@ export function registerMcpRuntimeCommands(registry: CommandRegistry): void {
141
271
  }
142
272
  }
143
273
 
274
+ if (subcommand === 'add') {
275
+ let parsed: ParsedMcpAddArgs;
276
+ try {
277
+ parsed = parseAddServerArgs(args);
278
+ } catch (error) {
279
+ ctx.print(summarizeError(error));
280
+ return;
281
+ }
282
+ const shellPaths = requireShellPaths(ctx);
283
+ try {
284
+ const result = await mcpApi.upsertServerConfig(shellPaths, parsed.scope, parsed.server);
285
+ const connected = listServerSecurity().find((entry) => entry.name === parsed.server.name)?.connected ?? false;
286
+ ctx.print([
287
+ `MCP server "${parsed.server.name}" saved to ${parsed.scope} config: ${result.path}.`,
288
+ `Runtime reload: ${connected ? 'connected' : 'server saved; connection needs attention'} (+${result.reload.added} ~${result.reload.changed} -${result.reload.removed}, unchanged ${result.reload.unchanged}).`,
289
+ `Command: ${parsed.server.command}${parsed.server.args?.length ? ` ${parsed.server.args.join(' ')}` : ''}`,
290
+ 'Next: /mcp tools',
291
+ ].join('\n'));
292
+ } catch (error) {
293
+ ctx.print(`MCP add failed: ${summarizeError(error)}`);
294
+ }
295
+ return;
296
+ }
297
+
298
+ if (subcommand === 'remove') {
299
+ const serverName = args[1]?.trim();
300
+ if (!serverName) {
301
+ ctx.print('Usage: /mcp remove <server> [--scope project|global]');
302
+ return;
303
+ }
304
+ let scope: McpConfigScope = 'project';
305
+ try {
306
+ for (let index = 2; index < args.length; index += 1) {
307
+ if (args[index] === '--scope') {
308
+ const value = readFlagValue(args, index, '--scope');
309
+ if (!isMcpScope(value)) {
310
+ ctx.print(`Invalid MCP scope "${value}". Expected project or global.`);
311
+ return;
312
+ }
313
+ scope = value;
314
+ index += 1;
315
+ }
316
+ }
317
+ } catch (error) {
318
+ ctx.print(summarizeError(error));
319
+ return;
320
+ }
321
+ const shellPaths = requireShellPaths(ctx);
322
+ try {
323
+ const result = await mcpApi.removeServerConfig(shellPaths, scope, serverName);
324
+ ctx.print(result.removed
325
+ ? `Removed MCP server "${serverName}" from ${scope} config ${result.path}. Reload: +${result.reload.added} ~${result.reload.changed} -${result.reload.removed}, unchanged ${result.reload.unchanged}.`
326
+ : `No ${scope} MCP server named "${serverName}" exists in ${result.path}.\nIf it still appears, it is coming from another config scope or external MCP config.`);
327
+ } catch (error) {
328
+ ctx.print(`MCP remove failed: ${summarizeError(error)}`);
329
+ }
330
+ return;
331
+ }
332
+
333
+ if (subcommand === 'reload') {
334
+ try {
335
+ const result = await reloadMcpRuntime(ctx);
336
+ const servers = listServerSecurity();
337
+ ctx.print(`Reloaded MCP runtime from config. ${servers.filter((server) => server.connected).length}/${servers.length} server(s) connected. Result: +${result.added} ~${result.changed} -${result.removed}, unchanged ${result.unchanged}.`);
338
+ } catch (error) {
339
+ ctx.print(`MCP reload failed: ${summarizeError(error)}`);
340
+ }
341
+ return;
342
+ }
343
+
344
+ if (subcommand === 'config') {
345
+ const shellPaths = requireShellPaths(ctx);
346
+ try {
347
+ const effective = mcpApi.getEffectiveConfig(shellPaths);
348
+ ctx.print([
349
+ 'MCP Config',
350
+ ' locations:',
351
+ ...effective.locations.map((location) => ` ${location.scope}/${location.kind}${location.writable ? ' writable' : ' read-only'} ${location.path}`),
352
+ ` effective servers: ${effective.servers.length}`,
353
+ ...effective.servers.map((entry) => {
354
+ const server = entry.server;
355
+ const envKeys = Object.keys(server.env ?? {});
356
+ return ` - ${server.name}: ${server.command}${server.args?.length ? ` ${server.args.join(' ')}` : ''} source=${entry.source.scope}/${entry.source.kind}${envKeys.length ? ` envKeys=${envKeys.join(',')}` : ''}`;
357
+ }),
358
+ '',
359
+ 'Add or update from inside the TUI:',
360
+ ' /mcp add <name> <command> [args...] [--scope project|global] [--role <role>] [--trust <mode>]',
361
+ 'Example:',
362
+ ' /mcp add filesystem npx -y @modelcontextprotocol/server-filesystem . --scope project --role filesystem --trust constrained',
363
+ ].join('\n'));
364
+ } catch (error) {
365
+ ctx.print(`MCP config read failed: ${summarizeError(error)}`);
366
+ }
367
+ return;
368
+ }
369
+
144
370
  if (subcommand === 'quarantine') {
145
371
  const serverName = args[1];
146
372
  const action = args[2];
@@ -170,6 +396,8 @@ export function registerMcpRuntimeCommands(registry: CommandRegistry): void {
170
396
  + ' ~/.config/claude/claude_desktop_config.json (Claude Desktop)\n'
171
397
  + ' .mcp/mcp.json (project-local)\n'
172
398
  + ' .goodvibes/mcp.json (goodvibes project)\n'
399
+ + '\nAdd one from inside the TUI:\n'
400
+ + ' /mcp add filesystem npx -y @modelcontextprotocol/server-filesystem . --scope project --role filesystem\n'
173
401
  + '\nFormat: { "servers": [{ "name": "my-server", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] }] }'
174
402
  );
175
403
  return;
@@ -188,6 +416,8 @@ export function registerMcpRuntimeCommands(registry: CommandRegistry): void {
188
416
  if (connected.length > 0) {
189
417
  lines.push('');
190
418
  lines.push('Run "/mcp tools" to list all tools, or "/mcp tools <server>" for a specific server.');
419
+ lines.push('Run "/mcp" to open the fullscreen MCP workspace, or "/mcp add <name> <command> [args...] [--scope project|global]" to add/update without restarting.');
420
+ lines.push('Run "/mcp reload" after editing MCP config outside the TUI.');
191
421
  lines.push('Run "/mcp trust <server> <mode>" to change trust mode, or "/mcp role <server> <role>" to change its coherence role.');
192
422
  lines.push('Run "/mcp quarantine <server> [detail]" to block a server, or "/mcp quarantine <server> approve [operatorId]" to approve a temporary override.');
193
423
  lines.push('Use /settings → MCP to explicitly enable allow-all for a server.');
@@ -91,6 +91,22 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
91
91
  },
92
92
  });
93
93
 
94
+ registry.register({
95
+ name: 'paste',
96
+ aliases: ['clip'],
97
+ description: 'Insert clipboard text or image into the prompt',
98
+ handler(_args, ctx) {
99
+ if (!ctx.pasteFromClipboard) {
100
+ ctx.print('Paste is not available in this context.');
101
+ return;
102
+ }
103
+ const result = ctx.pasteFromClipboard();
104
+ if (!result.pasted) {
105
+ ctx.print('Clipboard does not contain supported text or image data.');
106
+ }
107
+ },
108
+ });
109
+
94
110
  registry.register({
95
111
  name: 'help',
96
112
  aliases: ['h', '?'],
@@ -135,6 +151,7 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
135
151
  { id: '/template save', label: '/template save <name>', detail: 'Save prompt as template', category: 'Templates' },
136
152
  { id: '/template use', label: '/template use <name>', detail: 'Execute template', category: 'Templates' },
137
153
  { id: '/tools', label: '/tools', detail: 'List available tools', category: 'Tools & System' },
154
+ { id: '/paste', label: '/paste', detail: 'Insert clipboard text or image into the prompt', category: 'Tools & System' },
138
155
  { id: '/shortcuts', label: '/shortcuts', detail: 'View keyboard shortcuts reference', category: 'Tools & System' },
139
156
  { id: '/commands', label: '/commands', detail: 'Browse all commands in a scrollable list', category: 'Tools & System' },
140
157
  { id: '/secrets', label: '/secrets set|link|get|test|list|delete', detail: 'Manage encrypted and provider-backed secrets', category: 'Tools & System' },
@@ -154,7 +171,7 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
154
171
  });
155
172
  return;
156
173
  }
157
- ctx.print('Use /help to open the help modal. Commands: /model, /provider, /config, /template, /tools, /sessions, /bookmarks, /save, /load, /undo, /redo, /retry, /clear, /reset, /compact, /export, /title, /effort, /expand, /collapse, /debug, /quit, /wq');
174
+ ctx.print('Use /help to open the help modal. Commands: /model, /provider, /config, /template, /tools, /paste, /sessions, /bookmarks, /save, /load, /undo, /redo, /retry, /clear, /reset, /compact, /export, /title, /effort, /expand, /collapse, /debug, /quit, /wq');
158
175
  },
159
176
  });
160
177
 
@@ -29,6 +29,7 @@ import type { AgentDetailModal } from '../renderer/agent-detail-modal.ts';
29
29
  import type { ContextInspectorModal } from '../renderer/context-inspector.ts';
30
30
  import type { BookmarkModal } from './bookmark-modal.ts';
31
31
  import type { SettingsModal } from './settings-modal.ts';
32
+ import type { McpWorkspace } from './mcp-workspace.ts';
32
33
  import type { SessionPickerModal } from './session-picker-modal.ts';
33
34
  import type { ProfilePickerModal } from './profile-picker-modal.ts';
34
35
  import type { OnboardingWizardController } from './onboarding/onboarding-wizard.ts';
@@ -98,9 +99,11 @@ export interface FeedContextStableRefs {
98
99
  selection: SelectionManager;
99
100
  pasteRegistry: Map<string, string>;
100
101
  imageRegistry: Map<string, { data: string; mediaType: string }>;
102
+ projectRoot: string;
101
103
  selectionModal: SelectionModal;
102
104
  bookmarkModal: BookmarkModal;
103
105
  settingsModal: SettingsModal;
106
+ mcpWorkspace: McpWorkspace;
104
107
  sessionPickerModal: SessionPickerModal;
105
108
  profilePickerModal: ProfilePickerModal;
106
109
  historySearch: HistorySearch;
@@ -4,6 +4,7 @@ import type { AutocompleteEngine } from './autocomplete.ts';
4
4
  import type { InputToken } from '@pellux/goodvibes-sdk/platform/core';
5
5
  import type { ConversationManager } from '../core/conversation';
6
6
  import type { PanelManager } from '../panels/panel-manager.ts';
7
+ import { handleClipboardPaste, type ClipboardPasteSource } from './handler-content-actions.ts';
7
8
 
8
9
  export type CommandModeRouteState = {
9
10
  commandMode: boolean;
@@ -18,6 +19,14 @@ export type CommandModeRouteState = {
18
19
  conversationManager: ConversationManager | null;
19
20
  requestRender: () => void;
20
21
  handleEscape: () => void;
22
+ projectRoot: string;
23
+ pasteRegistry: Map<string, string>;
24
+ imageRegistry: Map<string, { data: string; mediaType: string }>;
25
+ nextPasteId: number;
26
+ nextImageId: number;
27
+ saveUndoState: () => void;
28
+ ensureInputCursorVisible: () => void;
29
+ clipboard?: ClipboardPasteSource;
21
30
  };
22
31
 
23
32
  export function handleCommandModeToken(state: CommandModeRouteState, token: InputToken): boolean {
@@ -141,6 +150,24 @@ function withPanelFocusSync(context: CommandContext, state: CommandModeRouteStat
141
150
  state.panelFocused = false;
142
151
  }
143
152
  : undefined,
153
+ pasteFromClipboard: () => {
154
+ const result = handleClipboardPaste({
155
+ prompt: state.prompt,
156
+ cursorPos: state.cursorPos,
157
+ pasteRegistry: state.pasteRegistry,
158
+ nextPasteId: state.nextPasteId,
159
+ imageRegistry: state.imageRegistry,
160
+ nextImageId: state.nextImageId,
161
+ saveUndoState: state.saveUndoState,
162
+ ensureInputCursorVisible: state.ensureInputCursorVisible,
163
+ requestRender: state.requestRender,
164
+ }, context.workspace.shellPaths?.workingDirectory ?? state.projectRoot, state.clipboard);
165
+ state.prompt = result.prompt;
166
+ state.cursorPos = result.cursorPos;
167
+ state.nextImageId = result.nextImageId;
168
+ state.nextPasteId = result.nextPasteId;
169
+ return result;
170
+ },
144
171
  executeCommand: async (name, args) => {
145
172
  const wrapped = withPanelFocusSync(context, state);
146
173
  const handled = state.commandRegistry?.get(name)
@@ -56,6 +56,23 @@ export type PasteRegistryState = {
56
56
  nextImageId: number;
57
57
  };
58
58
 
59
+ export type ClipboardPasteKind = 'image' | 'text' | 'none';
60
+
61
+ export interface ClipboardPasteResult {
62
+ prompt: string;
63
+ cursorPos: number;
64
+ nextImageId: number;
65
+ nextPasteId: number;
66
+ pasted: boolean;
67
+ kind: ClipboardPasteKind;
68
+ marker?: string;
69
+ }
70
+
71
+ export interface ClipboardPasteSource {
72
+ pasteImageFromClipboard: typeof pasteImageFromClipboard;
73
+ pasteFromClipboard: typeof pasteFromClipboard;
74
+ }
75
+
59
76
  export function registerPaste(
60
77
  state: PasteRegistryState,
61
78
  content: string,
@@ -436,22 +453,34 @@ export function handleClipboardPaste(
436
453
  requestRender: () => void;
437
454
  },
438
455
  projectRoot: string,
439
- ): { prompt: string; cursorPos: number; nextImageId: number; nextPasteId: number } {
440
- state.saveUndoState();
441
- const img = pasteImageFromClipboard();
456
+ clipboard: ClipboardPasteSource = { pasteImageFromClipboard, pasteFromClipboard },
457
+ ): ClipboardPasteResult {
458
+ const img = clipboard.pasteImageFromClipboard();
459
+ let pasted = false;
460
+ let kind: ClipboardPasteKind = 'none';
461
+ let insertedMarker: string | undefined;
462
+
442
463
  if (img) {
464
+ state.saveUndoState();
443
465
  const id = `img${state.nextImageId++}`;
444
466
  const sizeKB = Math.round(img.data.length * 3 / 4 / 1024);
445
467
  state.imageRegistry.set(id, img);
446
468
  const marker = `[IMAGE: ${id}, clipboard, ${sizeKB}KB]`;
447
469
  state.prompt = state.prompt.slice(0, state.cursorPos) + marker + state.prompt.slice(state.cursorPos);
448
470
  state.cursorPos += marker.length;
471
+ pasted = true;
472
+ kind = 'image';
473
+ insertedMarker = marker;
449
474
  } else {
450
- const raw = pasteFromClipboard();
475
+ const raw = clipboard.pasteFromClipboard();
451
476
  if (raw) {
477
+ state.saveUndoState();
452
478
  const { marker } = registerPaste(state, raw, projectRoot);
453
479
  state.prompt = state.prompt.slice(0, state.cursorPos) + marker + state.prompt.slice(state.cursorPos);
454
480
  state.cursorPos += marker.length;
481
+ pasted = true;
482
+ kind = marker.startsWith('[IMAGE:') ? 'image' : 'text';
483
+ insertedMarker = marker;
455
484
  }
456
485
  }
457
486
  state.ensureInputCursorVisible();
@@ -461,5 +490,8 @@ export function handleClipboardPaste(
461
490
  cursorPos: state.cursorPos,
462
491
  nextImageId: state.nextImageId,
463
492
  nextPasteId: state.nextPasteId,
493
+ pasted,
494
+ kind,
495
+ marker: insertedMarker,
464
496
  };
465
497
  }
@@ -16,6 +16,7 @@ import { AgentDetailModal } from '../renderer/agent-detail-modal.ts';
16
16
  import { ContextInspectorModal } from '../renderer/context-inspector.ts';
17
17
  import { BookmarkModal } from './bookmark-modal.ts';
18
18
  import { SettingsModal } from './settings-modal.ts';
19
+ import type { McpWorkspace } from './mcp-workspace.ts';
19
20
  import { SessionPickerModal } from './session-picker-modal.ts';
20
21
  import { ProfilePickerModal } from './profile-picker-modal.ts';
21
22
  import type { OnboardingWizardController } from './onboarding/onboarding-wizard.ts';
@@ -100,11 +101,13 @@ export interface InputFeedContext {
100
101
  contentWidth: number;
101
102
  readonly pasteRegistry: Map<string, string>;
102
103
  readonly imageRegistry: Map<string, { data: string; mediaType: string }>;
104
+ readonly projectRoot: string;
103
105
  readonly selection: SelectionManager;
104
106
  readonly selectionModal: SelectionModal;
105
107
  selectionCallback: ((result: SelectionResult | null) => void) | null;
106
108
  readonly bookmarkModal: BookmarkModal;
107
109
  readonly settingsModal: SettingsModal;
110
+ readonly mcpWorkspace: McpWorkspace;
108
111
  readonly sessionPickerModal: SessionPickerModal;
109
112
  readonly profilePickerModal: ProfilePickerModal;
110
113
  readonly historySearch: HistorySearch;
@@ -186,6 +189,7 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
186
189
  },
187
190
  bookmarkModal: context.bookmarkModal,
188
191
  settingsModal: context.settingsModal,
192
+ mcpWorkspace: context.mcpWorkspace,
189
193
  sessionPickerModal: context.sessionPickerModal,
190
194
  profilePickerModal: context.profilePickerModal,
191
195
  onboardingWizard: context.onboardingWizard,
@@ -354,12 +358,21 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
354
358
  conversationManager: context.conversationManager,
355
359
  requestRender: context.requestRender,
356
360
  handleEscape: context.handleEscape,
361
+ projectRoot: context.projectRoot,
362
+ pasteRegistry: context.pasteRegistry,
363
+ imageRegistry: context.imageRegistry,
364
+ nextPasteId: context.nextPasteId,
365
+ nextImageId: context.nextImageId,
366
+ saveUndoState: context.saveUndoState,
367
+ ensureInputCursorVisible: () => context.ensureInputCursorVisible(),
357
368
  };
358
369
  if (handleCommandModeToken(commandState, token)) {
359
370
  context.commandMode = commandState.commandMode;
360
371
  context.prompt = commandState.prompt;
361
372
  context.cursorPos = commandState.cursorPos;
362
373
  context.panelFocused = commandState.panelFocused;
374
+ context.nextPasteId = commandState.nextPasteId;
375
+ context.nextImageId = commandState.nextImageId;
363
376
  continue;
364
377
  }
365
378
 
@@ -238,6 +238,7 @@ export function handleEscapeForHandler(handler: InputHandler): void {
238
238
  agentDetailModal: handler.agentDetailModal,
239
239
  liveTailModal: handler.liveTailModal,
240
240
  settingsModal: handler.settingsModal,
241
+ mcpWorkspace: handler.mcpWorkspace,
241
242
  sessionPickerModal: handler.sessionPickerModal,
242
243
  profilePickerModal: handler.profilePickerModal,
243
244
  contextInspectorModal: handler.contextInspectorModal,
@@ -45,6 +45,7 @@ export type EscapeState = ModalStackState & {
45
45
  editingMode: boolean;
46
46
  cancelEdit: () => void;
47
47
  };
48
+ mcpWorkspace?: ModalStackState['mcpWorkspace'];
48
49
  selectionModal: ModalStackState['selectionModal'];
49
50
  autocompleteReset: () => void;
50
51
  autocompleteUpdate?: (query: string) => void;
@@ -122,6 +123,7 @@ export function handleEscape(state: EscapeState): {
122
123
  closeAgentDetail: () => state.agentDetailModal.close(),
123
124
  closeLiveTail: () => state.liveTailModal.close(),
124
125
  closeSettings: () => state.settingsModal.close(),
126
+ closeMcpWorkspace: () => state.mcpWorkspace?.close(),
125
127
  closeSessionPicker: () => state.sessionPickerModal.close(),
126
128
  closeProfilePicker: () => state.profilePickerModal.close(),
127
129
  closeContextInspector: () => state.contextInspectorModal.close(),
@@ -161,6 +163,7 @@ export function handleEscape(state: EscapeState): {
161
163
  openBookmark: () => state.bookmarkModal.open(),
162
164
  openProcess: () => state.processModal.open(),
163
165
  openContextInspector: () => state.contextInspectorModal.open(),
166
+ openMcpWorkspace: () => state.mcpWorkspace?.reopen(),
164
167
  openOnboarding: () => state.onboardingWizard?.reopen(),
165
168
  openCommandMode: () => {
166
169
  commandMode = true;
@@ -7,6 +7,7 @@ import type { SessionPickerModal } from './session-picker-modal.ts';
7
7
  import type { ProfilePickerModal } from './profile-picker-modal.ts';
8
8
  import type { HistorySearch } from './input-history.ts';
9
9
  import type { ModelPickerModal } from './model-picker.ts';
10
+ import { handleMcpWorkspaceToken, type McpWorkspace } from './mcp-workspace.ts';
10
11
  import type { CommandContext } from './command-registry.ts';
11
12
  import type { LiveTailModal } from '../renderer/live-tail-modal.ts';
12
13
  import type { ProcessModal } from '../renderer/process-modal.ts';
@@ -43,6 +44,7 @@ export type ModalTokenRouteState = {
43
44
  setSelectionCallback?: (callback: ((result: SelectionResult | null) => void) | null) => void;
44
45
  bookmarkModal: BookmarkModal;
45
46
  settingsModal: SettingsModal;
47
+ mcpWorkspace: McpWorkspace;
46
48
  sessionPickerModal: SessionPickerModal;
47
49
  profilePickerModal: ProfilePickerModal;
48
50
  onboardingWizard: OnboardingWizardController;
@@ -146,6 +148,15 @@ export function handleModalTokenRoutes(state: ModalTokenRouteState, token: Input
146
148
  return withState(state, true);
147
149
  }
148
150
 
151
+ if (handleMcpWorkspaceToken(
152
+ state.mcpWorkspace,
153
+ token,
154
+ state.handleEscape,
155
+ state.requestRender,
156
+ )) {
157
+ return withState(state, true);
158
+ }
159
+
149
160
  if (handleSessionPickerToken({
150
161
  sessionPickerModal: state.sessionPickerModal,
151
162
  commandContext: state.commandContext,
@@ -58,6 +58,7 @@ export type ActiveModalState = {
58
58
  agentDetailModal: { active: boolean; close: () => void };
59
59
  liveTailModal: { active: boolean; close: () => void };
60
60
  settingsModal: { active: boolean; close: () => void };
61
+ mcpWorkspace?: { active: boolean; close: () => void; reopen: () => void };
61
62
  sessionPickerModal: { active: boolean; close: () => void };
62
63
  profilePickerModal: { active: boolean; close: () => void };
63
64
  contextInspectorModal: { active: boolean; close: () => void };
@@ -77,6 +78,7 @@ export function getActiveModalName(state: ActiveModalState): string | null {
77
78
  if (state.agentDetailModal.active) return 'agentDetail';
78
79
  if (state.liveTailModal.active) return 'liveTail';
79
80
  if (state.settingsModal.active) return 'settings';
81
+ if (state.mcpWorkspace?.active) return 'mcpWorkspace';
80
82
  if (state.sessionPickerModal.active) return 'sessionPicker';
81
83
  if (state.profilePickerModal.active) return 'profilePicker';
82
84
  if (state.contextInspectorModal.active) return 'contextInspector';
@@ -97,6 +99,7 @@ export type ModalCloseOps = {
97
99
  closeAgentDetail: () => void;
98
100
  closeLiveTail: () => void;
99
101
  closeSettings: () => void;
102
+ closeMcpWorkspace: () => void;
100
103
  closeSessionPicker: () => void;
101
104
  closeProfilePicker: () => void;
102
105
  closeContextInspector: () => void;
@@ -129,6 +132,9 @@ export function closeModalByName(name: string, ops: ModalCloseOps): void {
129
132
  case 'settings':
130
133
  ops.closeSettings();
131
134
  break;
135
+ case 'mcpWorkspace':
136
+ ops.closeMcpWorkspace();
137
+ break;
132
138
  case 'sessionPicker':
133
139
  ops.closeSessionPicker();
134
140
  break;
@@ -168,6 +174,7 @@ export type ModalOpenOps = {
168
174
  openBookmark: () => void;
169
175
  openProcess: () => void;
170
176
  openContextInspector: () => void;
177
+ openMcpWorkspace?: () => void;
171
178
  openOnboarding?: () => void;
172
179
  openCommandMode: () => void;
173
180
  };
@@ -189,6 +196,9 @@ export function reopenModalByName(name: string, ops: ModalOpenOps): void {
189
196
  case 'contextInspector':
190
197
  ops.openContextInspector();
191
198
  break;
199
+ case 'mcpWorkspace':
200
+ ops.openMcpWorkspace?.();
201
+ break;
192
202
  case 'onboarding':
193
203
  ops.openOnboarding?.();
194
204
  break;