From 240245010314e4605ab0fa3e893fe3b0155163f6 Mon Sep 17 00:00:00 2001 From: Jonas Helming Date: Wed, 26 Mar 2025 10:30:09 +0100 Subject: [PATCH 01/11] Add news section with AI to welcome page (#15269) * Add news section with AI to welcome page --- .../src/browser/getting-started-widget.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/getting-started/src/browser/getting-started-widget.tsx b/packages/getting-started/src/browser/getting-started-widget.tsx index 2caf433314dae..fd961eb8c860f 100644 --- a/packages/getting-started/src/browser/getting-started-widget.tsx +++ b/packages/getting-started/src/browser/getting-started-widget.tsx @@ -149,6 +149,13 @@ export class GettingStartedWidget extends ReactWidget { } {this.renderHeader()}
+ {this.aiIsIncluded && +
+
+ {this.renderNews()} +
+
+ }
{this.renderStart()} @@ -403,6 +410,22 @@ export class GettingStartedWidget extends ReactWidget { return ; } + protected renderNews(): React.ReactNode { + return
+

🚀 AI Support in the Theia IDE is available (Alpha Version)! ✨

+
+ this.doOpenAIChatView()} + onKeyDown={(e: React.KeyboardEvent) => this.doOpenAIChatViewEnter(e)}> + {'Open the AI Chat View now to learn how to start! ✨'} + +
+
; + } + protected renderAIBanner(): React.ReactNode { return
From e266b01d82b55a56f2ab327117fc19523f2022e5 Mon Sep 17 00:00:00 2001 From: Eugen Neufeld Date: Wed, 26 Mar 2025 13:35:52 +0100 Subject: [PATCH 02/11] chore: make parameters in ToolRequest mandatory (#15288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, parameters were optional, indicating that a tool request doesn’t take any. However, most LLMs produce better output when parameters are explicitly set to empty. This is now the default in TheiaAI. --- .../src/node/anthropic-language-model.ts | 7 +------ .../ai-chat/src/common/chat-request-parser.spec.ts | 12 ++++++++++-- packages/ai-core/src/common/language-model.ts | 2 +- packages/ai-ide/src/browser/context-functions.ts | 6 +++++- packages/ai-ide/src/browser/workspace-functions.ts | 4 ++++ packages/ai-mcp/src/browser/mcp-frontend-service.ts | 5 ++++- 6 files changed, 25 insertions(+), 11 deletions(-) diff --git a/packages/ai-anthropic/src/node/anthropic-language-model.ts b/packages/ai-anthropic/src/node/anthropic-language-model.ts index 4f1c043d9207b..80b3e8be9fb3f 100644 --- a/packages/ai-anthropic/src/node/anthropic-language-model.ts +++ b/packages/ai-anthropic/src/node/anthropic-language-model.ts @@ -28,11 +28,6 @@ import { Anthropic } from '@anthropic-ai/sdk'; import { MessageParam } from '@anthropic-ai/sdk/resources'; export const DEFAULT_MAX_TOKENS = 4096; -const EMPTY_INPUT_SCHEMA = { - type: 'object', - properties: {}, - required: [] -} as const; interface ToolCallback { readonly name: string; @@ -254,7 +249,7 @@ export class AnthropicModel implements LanguageModel { return request.tools?.map(tool => ({ name: tool.name, description: tool.description, - input_schema: tool.parameters ?? EMPTY_INPUT_SCHEMA + input_schema: tool.parameters } as Anthropic.Messages.Tool)); } diff --git a/packages/ai-chat/src/common/chat-request-parser.spec.ts b/packages/ai-chat/src/common/chat-request-parser.spec.ts index 3a782c293524c..ea50f44bcf24b 100644 --- a/packages/ai-chat/src/common/chat-request-parser.spec.ts +++ b/packages/ai-chat/src/common/chat-request-parser.spec.ts @@ -137,12 +137,20 @@ describe('ChatRequestParserImpl', () => { const testTool1: ToolRequest = { id: 'testTool1', name: 'Test Tool 1', - handler: async () => undefined + handler: async () => undefined, + parameters: { + type: 'object', + properties: {} + }, }; const testTool2: ToolRequest = { id: 'testTool2', name: 'Test Tool 2', - handler: async () => undefined + handler: async () => undefined, + parameters: { + type: 'object', + properties: {} + }, }; // Configure the tool registry to return our test tools toolInvocationRegistry.getFunction.withArgs(testTool1.id).returns(testTool1); diff --git a/packages/ai-core/src/common/language-model.ts b/packages/ai-core/src/common/language-model.ts index c1fd4aa608d4b..500d76f9cb4a0 100644 --- a/packages/ai-core/src/common/language-model.ts +++ b/packages/ai-core/src/common/language-model.ts @@ -48,7 +48,7 @@ export interface ToolRequestParameters { export interface ToolRequest { id: string; name: string; - parameters?: ToolRequestParameters + parameters: ToolRequestParameters description?: string; handler: (arg_string: string, ctx?: unknown) => Promise; providerName?: string; diff --git a/packages/ai-ide/src/browser/context-functions.ts b/packages/ai-ide/src/browser/context-functions.ts index a86b02a77d22b..d107687cb58d5 100644 --- a/packages/ai-ide/src/browser/context-functions.ts +++ b/packages/ai-ide/src/browser/context-functions.ts @@ -35,7 +35,11 @@ export class ListChatContext implements ToolProvider { type: contextElement.variable.name })); return JSON.stringify(result, undefined, 2); - } + }, + parameters: { + type: 'object', + properties: {} + }, }; } } diff --git a/packages/ai-ide/src/browser/workspace-functions.ts b/packages/ai-ide/src/browser/workspace-functions.ts index c2222e83efa1e..68537169dd964 100644 --- a/packages/ai-ide/src/browser/workspace-functions.ts +++ b/packages/ai-ide/src/browser/workspace-functions.ts @@ -143,6 +143,10 @@ export class GetWorkspaceDirectoryStructure implements ToolProvider { name: GetWorkspaceDirectoryStructure.ID, description: `Retrieve the complete directory structure of the workspace, listing only directories (no file contents). This structure excludes specific directories, such as node_modules and hidden files, ensuring paths are within workspace boundaries.`, + parameters: { + type: 'object', + properties: {} + }, handler: () => this.getDirectoryStructure() }; } diff --git a/packages/ai-mcp/src/browser/mcp-frontend-service.ts b/packages/ai-mcp/src/browser/mcp-frontend-service.ts index 6e2fb399c03a1..d962ef2a31bb4 100644 --- a/packages/ai-mcp/src/browser/mcp-frontend-service.ts +++ b/packages/ai-mcp/src/browser/mcp-frontend-service.ts @@ -94,7 +94,10 @@ export class MCPFrontendService { type: tool.inputSchema.type, properties: tool.inputSchema.properties, required: tool.inputSchema.required - } : undefined, + } : { + type: 'object', + properties: {} + }, description: tool.description, handler: async (arg_string: string) => { try { From c68ef339bbe3db76fec61661ac1b9a973b49736c Mon Sep 17 00:00:00 2001 From: RomanPiperMVTec Date: Wed, 26 Mar 2025 13:57:55 +0100 Subject: [PATCH 03/11] chore: change private members to protected in ChatViewTreeWidget (#15297) Enhance extensibility for adopters by converting private functions and injections to protected in the ChatViewTreeWidget. Signed-off-by: Roman Piper --- .../chat-tree-view/chat-view-tree-widget.tsx | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx b/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx index 61bf584b9069e..20ef2df6637c0 100644 --- a/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx +++ b/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx @@ -88,13 +88,13 @@ export class ChatViewTreeWidget extends TreeWidget { protected readonly variableService: AIVariableService; @inject(CommandRegistry) - private commandRegistry: CommandRegistry; + protected commandRegistry: CommandRegistry; @inject(OpenerService) protected readonly openerService: OpenerService; @inject(HoverService) - private hoverService: HoverService; + protected hoverService: HoverService; protected _shouldScrollToEnd = true; @@ -148,7 +148,7 @@ export class ChatViewTreeWidget extends TreeWidget { return this.renderDisabledMessage(); } - private renderDisabledMessage(): React.ReactNode { + protected renderDisabledMessage(): React.ReactNode { return
@@ -205,7 +205,7 @@ export class ChatViewTreeWidget extends TreeWidget {
; } - private renderLinkButton(title: string, openCommandId: string): React.ReactNode { + protected renderLinkButton(title: string, openCommandId: string): React.ReactNode { return ; } - private mapRequestToNode(request: ChatRequestModel): RequestNode { + protected mapRequestToNode(request: ChatRequestModel): RequestNode { return { id: request.id, parent: this.model.root as CompositeTreeNode, @@ -223,7 +223,7 @@ export class ChatViewTreeWidget extends TreeWidget { }; } - private mapResponseToNode(response: ChatResponseModel): ResponseNode { + protected mapResponseToNode(response: ChatResponseModel): ResponseNode { return { id: response.id, parent: this.model.root as CompositeTreeNode, @@ -259,7 +259,7 @@ export class ChatViewTreeWidget extends TreeWidget { return super.getScrollToRow(); } - private async recreateModelTree(chatModel: ChatModel): Promise { + protected async recreateModelTree(chatModel: ChatModel): Promise { if (CompositeTreeNode.is(this.model.root)) { const nodes: TreeNode[] = []; chatModel.getRequests().forEach(request => { @@ -289,7 +289,7 @@ export class ChatViewTreeWidget extends TreeWidget { ; } - private renderAgent(node: RequestNode | ResponseNode): React.ReactNode { + protected renderAgent(node: RequestNode | ResponseNode): React.ReactNode { const inProgress = isResponseNode(node) && !node.response.isComplete && !node.response.isCanceled && !node.response.isError; const waitingForInput = isResponseNode(node) && node.response.isWaitingForInput; const toolbarContributions = !inProgress @@ -345,7 +345,7 @@ export class ChatViewTreeWidget extends TreeWidget { ; } - private getAgentLabel(node: RequestNode | ResponseNode): string { + protected getAgentLabel(node: RequestNode | ResponseNode): string { if (isRequestNode(node)) { // TODO find user name return nls.localize('theia/ai/chat-ui/chat-view-tree-widget/you', 'You'); @@ -353,14 +353,14 @@ export class ChatViewTreeWidget extends TreeWidget { return this.getAgent(node)?.name ?? nls.localize('theia/ai/chat-ui/chat-view-tree-widget/ai', 'AI'); } - private getAgent(node: RequestNode | ResponseNode): ChatAgent | undefined { + protected getAgent(node: RequestNode | ResponseNode): ChatAgent | undefined { if (isRequestNode(node)) { return undefined; } return node.response.agentId ? this.chatAgentService.getAgent(node.response.agentId) : undefined; } - private getAgentIconClassName(node: RequestNode | ResponseNode): string | undefined { + protected getAgentIconClassName(node: RequestNode | ResponseNode): string | undefined { if (isRequestNode(node)) { return codicon('account'); } @@ -369,7 +369,7 @@ export class ChatViewTreeWidget extends TreeWidget { return agent?.iconClass ?? codicon('copilot'); } - private renderDetail(node: RequestNode | ResponseNode): React.ReactNode { + protected renderDetail(node: RequestNode | ResponseNode): React.ReactNode { if (isRequestNode(node)) { return this.renderChatRequest(node); } @@ -378,7 +378,7 @@ export class ChatViewTreeWidget extends TreeWidget { }; } - private renderChatRequest(node: RequestNode): React.ReactNode { + protected renderChatRequest(node: RequestNode): React.ReactNode { return ; } - private renderChatResponse(node: ResponseNode): React.ReactNode { + protected renderChatResponse(node: ResponseNode): React.ReactNode { return (
{!node.response.isComplete @@ -419,7 +419,7 @@ export class ChatViewTreeWidget extends TreeWidget { ); } - private getChatResponsePartRenderer(content: ChatResponseContent, node: ResponseNode): React.ReactNode { + protected getChatResponsePartRenderer(content: ChatResponseContent, node: ResponseNode): React.ReactNode { const renderer = this.chatResponsePartRenderers.getContributions().reduce<[number, ChatResponsePartRenderer | undefined]>( (prev, current) => { const prio = current.canHandle(content); From 05abadcd9a783cc86c95ff947fc27d62359e171f Mon Sep 17 00:00:00 2001 From: Eugen Neufeld Date: Wed, 26 Mar 2025 15:02:44 +0100 Subject: [PATCH 04/11] Add variable completion for {{}} syntax in prompttemplate (#15026) The prompttemplate editor now supports autocompletion for variables using `{{` and `{{{` . fixes: #15202 --- .../file-chat-variable-contribution.ts | 8 +- .../browser/prompttemplate-contribution.ts | 83 ++++++++++++++++++- .../ai-core/src/common/variable-service.ts | 3 +- 3 files changed, 87 insertions(+), 7 deletions(-) diff --git a/packages/ai-chat/src/browser/file-chat-variable-contribution.ts b/packages/ai-chat/src/browser/file-chat-variable-contribution.ts index 1a14e45a40a74..a5dce2081b48b 100644 --- a/packages/ai-chat/src/browser/file-chat-variable-contribution.ts +++ b/packages/ai-chat/src/browser/file-chat-variable-contribution.ts @@ -69,10 +69,11 @@ export class FileChatVariableContribution implements FrontendVariableContributio protected async provideArgumentCompletionItems( model: monaco.editor.ITextModel, - position: monaco.Position + position: monaco.Position, + matchString?: string ): Promise { const lineContent = model.getLineContent(position.lineNumber); - const indexOfVariableTrigger = lineContent.lastIndexOf(PromptText.VARIABLE_CHAR, position.column - 1); + const indexOfVariableTrigger = lineContent.lastIndexOf(matchString ?? PromptText.VARIABLE_CHAR, position.column - 1); // check if there is a variable trigger and no space typed between the variable trigger and the cursor if (indexOfVariableTrigger === -1 || lineContent.substring(indexOfVariableTrigger).includes(' ')) { @@ -86,7 +87,8 @@ export class FileChatVariableContribution implements FrontendVariableContributio const typedWord = lineContent.substring(triggerCharIndex + 1, position.column - 1); const range = new monaco.Range(position.lineNumber, triggerCharIndex + 2, position.lineNumber, position.column); const picks = await this.quickFileSelectService.getPicks(typedWord, CancellationToken.None); - const prefix = lineContent[triggerCharIndex] === PromptText.VARIABLE_CHAR ? FILE_VARIABLE.name + PromptText.VARIABLE_SEPARATOR_CHAR : ''; + const matchVariableChar = lineContent[triggerCharIndex] === (matchString ? matchString : PromptText.VARIABLE_CHAR); + const prefix = matchVariableChar ? FILE_VARIABLE.name + PromptText.VARIABLE_SEPARATOR_CHAR : ''; return Promise.all( picks diff --git a/packages/ai-core/src/browser/prompttemplate-contribution.ts b/packages/ai-core/src/browser/prompttemplate-contribution.ts index afd7247849a15..1e91d387a5d9d 100644 --- a/packages/ai-core/src/browser/prompttemplate-contribution.ts +++ b/packages/ai-core/src/browser/prompttemplate-contribution.ts @@ -22,8 +22,9 @@ import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/li import { codicon, Widget } from '@theia/core/lib/browser'; import { EditorWidget, ReplaceOperation } from '@theia/editor/lib/browser'; -import { PromptCustomizationService, PromptService, ToolInvocationRegistry } from '../common'; +import { PromptCustomizationService, PromptService, PromptText, ToolInvocationRegistry } from '../common'; import { ProviderResult } from '@theia/monaco-editor-core/esm/vs/editor/common/languages'; +import { AIVariableService } from '../common/variable-service'; const PROMPT_TEMPLATE_LANGUAGE_ID = 'theia-ai-prompt-template'; const PROMPT_TEMPLATE_TEXTMATE_SCOPE = 'source.prompttemplate'; @@ -59,19 +60,28 @@ export class PromptTemplateContribution implements LanguageGrammarDefinitionCont @inject(ToolInvocationRegistry) protected readonly toolInvocationRegistry: ToolInvocationRegistry; + @inject(AIVariableService) + protected readonly variableService: AIVariableService; + readonly config: monaco.languages.LanguageConfiguration = { 'brackets': [ ['${', '}'], - ['~{', '}'] + ['~{', '}'], + ['{{', '}}'], + ['{{{', '}}}'] ], 'autoClosingPairs': [ { 'open': '${', 'close': '}' }, { 'open': '~{', 'close': '}' }, + { 'open': '{{', 'close': '}}' }, + { 'open': '{{{', 'close': '}}}' } ], 'surroundingPairs': [ { 'open': '${', 'close': '}' }, - { 'open': '~{', 'close': '}' } + { 'open': '~{', 'close': '}' }, + { 'open': '{{', 'close': '}}' }, + { 'open': '{{{', 'close': '}}}' } ] }; @@ -95,6 +105,17 @@ export class PromptTemplateContribution implements LanguageGrammarDefinitionCont provideCompletionItems: (model, position, _context, _token): ProviderResult => this.provideFunctionCompletions(model, position), }); + monaco.languages.registerCompletionItemProvider(PROMPT_TEMPLATE_LANGUAGE_ID, { + // Monaco only supports single character trigger characters + triggerCharacters: ['{'], + provideCompletionItems: (model, position, _context, _token): ProviderResult => this.provideVariableCompletions(model, position), + }); + monaco.languages.registerCompletionItemProvider(PROMPT_TEMPLATE_LANGUAGE_ID, { + // Monaco only supports single character trigger characters + triggerCharacters: ['{', ':'], + provideCompletionItems: (model, position, _context, _token): ProviderResult => this.provideVariableWithArgCompletions(model, position), + }); + const textmateGrammar = require('../../data/prompttemplate.tmLanguage.json'); const grammarDefinitionProvider: GrammarDefinitionProvider = { getGrammarDefinition: function (): Promise { @@ -122,6 +143,62 @@ export class PromptTemplateContribution implements LanguageGrammarDefinitionCont ); } + provideVariableCompletions(model: monaco.editor.ITextModel, position: monaco.Position): ProviderResult { + return this.getSuggestions( + model, + position, + '{{', + this.variableService.getVariables(), + monaco.languages.CompletionItemKind.Variable, + variable => variable.args?.some(arg => !arg.isOptional) ? variable.name + PromptText.VARIABLE_SEPARATOR_CHAR : variable.name, + variable => variable.name, + variable => variable.description ?? '' + ); + } + + async provideVariableWithArgCompletions(model: monaco.editor.ITextModel, position: monaco.Position): Promise { + // Get the text of the current line up to the cursor position + const textUntilPosition = model.getValueInRange({ + startLineNumber: position.lineNumber, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column, + }); + + // Regex that captures the variable name in contexts like {{, {{{, {{varname, {{{varname, {{varname:, or {{{varname: + const variableRegex = /(?:\{\{\{|\{\{)([\w-]+)?(?::)?/; + const match = textUntilPosition.match(variableRegex); + + if (!match) { + return { suggestions: [] }; + } + + const currentVariableName = match[1]; + const hasColonSeparator = textUntilPosition.includes(`${currentVariableName}:`); + + const variables = this.variableService.getVariables(); + const suggestions: monaco.languages.CompletionItem[] = []; + + for (const variable of variables) { + // If we have a variable:arg pattern, only process the matching variable + if (hasColonSeparator && variable.name !== currentVariableName) { + continue; + } + + const provider = await this.variableService.getArgumentCompletionProvider(variable.name); + if (provider) { + const items = await provider(model, position, '{'); + if (items) { + suggestions.push(...items.map(item => ({ + ...item + }))); + } + } + } + + return { suggestions }; + } + getCompletionRange(model: monaco.editor.ITextModel, position: monaco.Position, triggerCharacters: string): monaco.Range | undefined { // Check if the characters before the current position are the trigger characters const lineContent = model.getLineContent(position.lineNumber); diff --git a/packages/ai-core/src/common/variable-service.ts b/packages/ai-core/src/common/variable-service.ts index b411337be35d0..06195360525f0 100644 --- a/packages/ai-core/src/common/variable-service.ts +++ b/packages/ai-core/src/common/variable-service.ts @@ -128,7 +128,8 @@ export interface AIVariableContext { export type AIVariableArg = string | { variable: string, arg?: string } | AIVariableResolutionRequest; export type AIVariableArgPicker = (context: AIVariableContext) => MaybePromise; -export type AIVariableArgCompletionProvider = (model: monaco.editor.ITextModel, position: monaco.Position) => MaybePromise; +export type AIVariableArgCompletionProvider = + (model: monaco.editor.ITextModel, position: monaco.Position, matchString?: string) => MaybePromise; export interface AIVariableResolver { canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise, From bfc328a790bcf92d99014142e7af6b54ab478123 Mon Sep 17 00:00:00 2001 From: Simon Graband Date: Wed, 26 Mar 2025 16:03:22 +0100 Subject: [PATCH 05/11] Add MCP Server config view to AI Configuration (#15280) Improve MCP services: - Introduce notification mechanism to listen to MCP server changes. - Created common interface for MCPFrontendService. - Rename getStartedServers() to getRunningServers(). - Added status handling to MCPServer (NotRunning, Starting, Running, Errored): - Add getServerDescription endpoint to retrieve information about the server. - Add status, error message and tool information to MCPServerDescription. - Add MCP Config view to AI Configuration: - This view helps to see the state of MCP servers. - Displays all the created settings. Obfuscates tokens (very basic support). - Shows the status of a server (Running, Starting, Errored, Not Running). - Shows all tools available. Let's the user easily copy the tools for chat and prompttemplate use. - Offers a button to start/stop the view. Fix autostart stopping for following servers if one fails: - Before this the autostart would stop for all following servers if one failed. - This was due to uncatched errors when trying to resolve the tools. - To prevent this in the future, improved the handling of the getTools() on client side. - Also adapted the Start Command to use the new status reporting instead of calling getTools() again. Signed-off-by: Simon Graband --- package-lock.json | 1 + packages/ai-ide/package.json | 1 + .../ai-configuration-widget.tsx | 6 + .../mcp-configuration-widget.tsx | 337 ++++++++++++++++++ .../ai-ide/src/browser/frontend-module.ts | 13 +- packages/ai-ide/src/browser/style/index.css | 184 ++++++++++ packages/ai-ide/tsconfig.json | 3 + .../src/browser/mcp-command-contribution.ts | 26 +- .../mcp-frontend-application-contribution.ts | 2 +- .../ai-mcp/src/browser/mcp-frontend-module.ts | 11 +- .../mcp-frontend-notification-service.ts | 29 ++ .../src/browser/mcp-frontend-service.ts | 39 +- .../ai-mcp/src/common/mcp-server-manager.ts | 54 ++- .../ai-mcp/src/node/mcp-backend-module.ts | 13 +- .../src/node/mcp-server-manager-impl.ts | 65 +++- packages/ai-mcp/src/node/mcp-server.ts | 92 ++++- 16 files changed, 811 insertions(+), 65 deletions(-) create mode 100644 packages/ai-ide/src/browser/ai-configuration/mcp-configuration-widget.tsx create mode 100644 packages/ai-mcp/src/browser/mcp-frontend-notification-service.ts diff --git a/package-lock.json b/package-lock.json index 312d6d97300e4..ed0ec2358282c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30205,6 +30205,7 @@ "@theia/ai-chat": "1.59.0", "@theia/ai-chat-ui": "1.59.0", "@theia/ai-core": "1.59.0", + "@theia/ai-mcp": "1.59.0", "@theia/core": "1.59.0", "@theia/filesystem": "1.59.0", "@theia/markers": "1.59.0", diff --git a/packages/ai-ide/package.json b/packages/ai-ide/package.json index 5a3d63bbf132c..320fd0e423c53 100644 --- a/packages/ai-ide/package.json +++ b/packages/ai-ide/package.json @@ -25,6 +25,7 @@ "@theia/navigator": "1.59.0", "@theia/terminal": "1.59.0", "@theia/workspace": "1.59.0", + "@theia/ai-mcp": "1.59.0", "ignore": "^6.0.0", "minimatch": "^9.0.0" }, diff --git a/packages/ai-ide/src/browser/ai-configuration/ai-configuration-widget.tsx b/packages/ai-ide/src/browser/ai-configuration/ai-configuration-widget.tsx index 7bb2dc0356e35..21c110e813b0e 100644 --- a/packages/ai-ide/src/browser/ai-configuration/ai-configuration-widget.tsx +++ b/packages/ai-ide/src/browser/ai-configuration/ai-configuration-widget.tsx @@ -22,6 +22,7 @@ import { AIAgentConfigurationWidget } from './agent-configuration-widget'; import { AIVariableConfigurationWidget } from './variable-configuration-widget'; import { AIConfigurationSelectionService } from './ai-configuration-service'; import { nls } from '@theia/core'; +import { AIMCPConfigurationWidget } from './mcp-configuration-widget'; @injectable() export class AIConfigurationContainerWidget extends BaseWidget { @@ -39,6 +40,7 @@ export class AIConfigurationContainerWidget extends BaseWidget { protected agentsWidget: AIAgentConfigurationWidget; protected variablesWidget: AIVariableConfigurationWidget; + protected mcpWidget: AIMCPConfigurationWidget; @postConstruct() protected init(): void { @@ -63,8 +65,10 @@ export class AIConfigurationContainerWidget extends BaseWidget { this.agentsWidget = await this.widgetManager.getOrCreateWidget(AIAgentConfigurationWidget.ID); this.variablesWidget = await this.widgetManager.getOrCreateWidget(AIVariableConfigurationWidget.ID); + this.mcpWidget = await this.widgetManager.getOrCreateWidget(AIMCPConfigurationWidget.ID); this.dockpanel.addWidget(this.agentsWidget); this.dockpanel.addWidget(this.variablesWidget); + this.dockpanel.addWidget(this.mcpWidget); this.update(); } @@ -75,6 +79,8 @@ export class AIConfigurationContainerWidget extends BaseWidget { this.dockpanel.activateWidget(this.agentsWidget); } else if (widgetId === AIVariableConfigurationWidget.ID) { this.dockpanel.activateWidget(this.variablesWidget); + } else if (widgetId === AIMCPConfigurationWidget.ID) { + this.dockpanel.activateWidget(this.mcpWidget); } }); } diff --git a/packages/ai-ide/src/browser/ai-configuration/mcp-configuration-widget.tsx b/packages/ai-ide/src/browser/ai-configuration/mcp-configuration-widget.tsx new file mode 100644 index 0000000000000..05e8413409b4f --- /dev/null +++ b/packages/ai-ide/src/browser/ai-configuration/mcp-configuration-widget.tsx @@ -0,0 +1,337 @@ +// ***************************************************************************** +// Copyright (C) 2025 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { ReactWidget } from '@theia/core/lib/browser'; +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import * as React from '@theia/core/shared/react'; +import { HoverService } from '@theia/core/lib/browser/hover-service'; +import { MCPFrontendNotificationService, MCPFrontendService, MCPServerDescription, MCPServerStatus } from '@theia/ai-mcp/lib/common/mcp-server-manager'; +import { MessageService } from '@theia/core'; + +@injectable() +export class AIMCPConfigurationWidget extends ReactWidget { + + static readonly ID = 'ai-mcp-configuration-container-widget'; + static readonly LABEL = 'MCP Servers'; + + protected servers: MCPServerDescription[] = []; + protected expandedTools: Record = {}; + + @inject(MCPFrontendService) + protected readonly mcpFrontendService: MCPFrontendService; + + @inject(MCPFrontendNotificationService) + protected readonly mcpFrontendNotificationService: MCPFrontendNotificationService; + + @inject(HoverService) + protected readonly hoverService: HoverService; + + @inject(MessageService) + protected readonly messageService: MessageService; + + @postConstruct() + protected init(): void { + this.id = AIMCPConfigurationWidget.ID; + this.title.label = AIMCPConfigurationWidget.LABEL; + this.title.closable = false; + this.toDispose.push(this.mcpFrontendNotificationService.onDidUpdateMCPServers(async () => { + this.loadServers(); + })); + this.loadServers(); + } + + protected async loadServers(): Promise { + const serverNames = await this.mcpFrontendService.getServerNames(); + const descriptions = await Promise.all(serverNames.map(name => this.mcpFrontendService.getServerDescription(name))); + this.servers = descriptions.filter((desc): desc is MCPServerDescription => desc !== undefined); + this.update(); + } + + protected getStatusColor(status?: MCPServerStatus): { bg: string, fg: string } { + if (!status) { + return { bg: 'var(--theia-descriptionForeground)', fg: 'white' }; + } + switch (status) { + case MCPServerStatus.Running: + return { bg: 'var(--theia-successBackground)', fg: 'var(--theia-successForeground)' }; + case MCPServerStatus.Starting: + return { bg: 'var(--theia-warningBackground)', fg: 'var(--theia-warningForeground)' }; + case MCPServerStatus.Errored: + return { bg: 'var(--theia-errorBackground)', fg: 'var(--theia-errorForeground)' }; + case MCPServerStatus.NotRunning: + default: + return { bg: 'var(--theia-inputValidation-infoBackground)', fg: 'var(--theia-inputValidation-infoForeground)' }; + } + } + + protected showErrorHover(spanRef: React.RefObject, error: string): void { + this.hoverService.requestHover({ content: error, target: spanRef.current!, position: 'left' }); + } + + protected hideErrorHover(): void { + this.hoverService.cancelHover(); + } + + protected async handleStartServer(serverName: string): Promise { + await this.mcpFrontendService.startServer(serverName); + } + + protected async handleStopServer(serverName: string): Promise { + await this.mcpFrontendService.stopServer(serverName); + } + + protected renderButton(text: React.ReactNode, + title: string, + onClick: React.MouseEventHandler, + className?: string, + style?: React.CSSProperties): React.ReactNode { + return ( + + ); + } + + protected renderStatusBadge(status?: MCPServerStatus, error?: string): React.ReactNode { + const colors = this.getStatusColor(status); + const displayStatus = status || MCPServerStatus.NotRunning; + const spanRef = React.createRef(); + return ( +
+ + {displayStatus} + + {error && ( + this.showErrorHover(spanRef, error)} + onMouseLeave={() => this.hideErrorHover()} + ref={spanRef} + className="mcp-error-indicator" + > + ? + + )} +
+ ); + } + + protected renderServerHeader(server: MCPServerDescription): React.ReactNode { + return ( +
+
{server.name}
+ {this.renderStatusBadge(server.status, server.error)} +
+ ); + } + + protected renderCommandSection(server: MCPServerDescription): React.ReactNode { + return ( +
+ Command: + {server.command} +
+ ); + } + + protected renderArgumentsSection(server: MCPServerDescription): React.ReactNode { + if (!server.args || server.args.length === 0) { + return; + } + return ( +
+ Arguments: + {server.args.join(' ')} +
+ ); + } + + protected renderEnvironmentSection(server: MCPServerDescription): React.ReactNode { + if (!server.env || Object.keys(server.env).length === 0) { + return; + } + return ( +
+ Environment Variables: +
+ {Object.entries(server.env).map(([key, value]) => ( +
+ {key}={key.toLowerCase().includes('token') ? '******' : value} +
+ ))} +
+
+ ); + } + + protected renderAutostartSection(server: MCPServerDescription): React.ReactNode { + return ( +
+ Autostart: + + {server.autostart ? 'Enabled' : 'Disabled'} + +
+ ); + } + + protected renderToolsSection(server: MCPServerDescription): React.ReactNode { + if (!server.tools || server.tools.length === 0) { + return; + } + const isToolsExpanded = this.expandedTools[server.name] || false; + return ( +
+
this.toggleTools(server.name)}> +
+ + {isToolsExpanded ? 'â–¼' : 'â–º'} + +
+
+ Tools: +
+
+ {this.renderButton( + , + 'Copy all (multiple lines prompttemplate)', + e => { + e.stopPropagation(); + if (server.tools) { + const toolNames = server.tools.map(tool => `~{mcp_${server.name}_${tool.name}}`).join('\n'); + navigator.clipboard.writeText(toolNames); + this.messageService.info('Copied all tools to clipboard (multiple lines prompttemplate)'); + } + }, + 'mcp-copy-tool-button' + )} + {this.renderButton( + , + 'Copy all (single line prompttemplate)', + e => { + e.stopPropagation(); + navigator.clipboard.writeText(`~{${this.mcpFrontendService.getPromptTemplateId(server.name)}}`); + this.messageService.info('Copied all tools to clipboard (single line prompttemplate)'); + }, + 'mcp-copy-tool-button' + )} +
+
+ {isToolsExpanded && ( +
+ {server.tools.map(tool => ( +
+
+ {tool.name}: {tool.description} +
+
+ {this.renderButton( + , + 'Copy (for Chat)', + e => { + e.stopPropagation(); + const copied = `~mcp_${server.name}_${tool.name}`; + navigator.clipboard.writeText(copied); + this.messageService.info(`Copied ${copied} to clipboard (for chat)`); + }, + 'mcp-copy-tool-button' + )} + {this.renderButton( + , + 'Copy (for prompttemplate)', + e => { + e.stopPropagation(); + const copied = `~{mcp_${server.name}_${tool.name}}`; + navigator.clipboard.writeText(copied); + this.messageService.info(`Copied ${copied} to clipboard (for prompttemplate)`); + }, + 'mcp-copy-tool-button' + )} +
+
+ ))} +
+ )} +
+ ); + } + + protected toggleTools(serverName: string): void { + this.expandedTools[serverName] = !this.expandedTools[serverName]; + this.update(); + } + + protected renderServerControls(server: MCPServerDescription): React.ReactNode { + const isStoppable = server.status === MCPServerStatus.Running || server.status === MCPServerStatus.Starting; + const isStartable = server.status === MCPServerStatus.NotRunning || server.status === MCPServerStatus.Errored; + return ( +
+ {isStartable && this.renderButton( + <> Start Server, + 'Start Server', + () => this.handleStartServer(server.name), + 'mcp-server-button play-button' + )} + {isStoppable && this.renderButton( + <> Stop Server, + 'Stop Server', + () => this.handleStopServer(server.name), + 'mcp-server-button stop-button' + )} +
+ ); + } + + protected renderServerCard(server: MCPServerDescription): React.ReactNode { + return ( +
+ {this.renderServerHeader(server)} + {this.renderCommandSection(server)} + {this.renderArgumentsSection(server)} + {this.renderEnvironmentSection(server)} + {this.renderAutostartSection(server)} + {this.renderToolsSection(server)} + {this.renderServerControls(server)} +
+ ); + } + + protected render(): React.ReactNode { + if (this.servers.length === 0) { + return ( +
+ No MCP servers configured +
+ ); + } + + return ( +
+

MCP Server Configurations

+ {this.servers.map(server => this.renderServerCard(server))} +
+ ); + } +} diff --git a/packages/ai-ide/src/browser/frontend-module.ts b/packages/ai-ide/src/browser/frontend-module.ts index 181e73e70cd24..419faa3e75d91 100644 --- a/packages/ai-ide/src/browser/frontend-module.ts +++ b/packages/ai-ide/src/browser/frontend-module.ts @@ -39,7 +39,8 @@ import { AIConfigurationContainerWidget } from './ai-configuration/ai-configurat import { AIVariableConfigurationWidget } from './ai-configuration/variable-configuration-widget'; import { ContextFilesVariableContribution } from '../common/context-files-variable'; import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; -import {AiConfigurationPreferences} from './ai-configuration/ai-configuration-preferences'; +import { AiConfigurationPreferences } from './ai-configuration/ai-configuration-preferences'; +import { AIMCPConfigurationWidget } from './ai-configuration/mcp-configuration-widget'; export default new ContainerModule(bind => { bind(PreferenceContribution).toConstantValue({ schema: WorkspacePreferencesSchema }); @@ -109,5 +110,13 @@ export default new ContainerModule(bind => { bind(ToolProvider).to(SimpleReplaceContentInFileProvider); bind(ToolProvider).to(AddFileToChatContext); bind(AIVariableContribution).to(ContextFilesVariableContribution).inSingletonScope(); - bind(PreferenceContribution).toConstantValue({schema: AiConfigurationPreferences}); + bind(PreferenceContribution).toConstantValue({ schema: AiConfigurationPreferences }); + bind(AIMCPConfigurationWidget).toSelf(); + bind(WidgetFactory) + .toDynamicValue(ctx => ({ + id: AIMCPConfigurationWidget.ID, + createWidget: () => ctx.container.get(AIMCPConfigurationWidget) + })) + .inSingletonScope(); + }); diff --git a/packages/ai-ide/src/browser/style/index.css b/packages/ai-ide/src/browser/style/index.css index bcd7437046a30..c06e70242bebc 100644 --- a/packages/ai-ide/src/browser/style/index.css +++ b/packages/ai-ide/src/browser/style/index.css @@ -125,3 +125,187 @@ margin-top: 3em; margin-left: 0; } + +/* MCP Configuration Styles */ +.mcp-configuration-container { + padding: 16px; +} + +.mcp-configuration-title { + margin: 0 0 16px 0; + border-bottom: 1px solid var(--theia-panelTitle-activeBorder); + padding-bottom: 8px; +} + +.mcp-server-card { + border: 1px solid var(--theia-panelTitle-activeBorder); + border-radius: 6px; + padding: 12px; + margin-bottom: 16px; + background-color: var(--theia-editorWidget-background); +} + +.mcp-server-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.mcp-server-name { + font-weight: bold; + font-size: var(--theia-ui-font-size3); +} + +.mcp-status-badge { + padding: 3px 8px; + border-radius: 12px; + font-size: 12px; + font-weight: bold; + display: inline-block; +} + +.mcp-error-indicator { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + background-color: var(--theia-errorBackground); + color: var(--theia-errorForeground); + font-size: 12px; + font-weight: bold; + cursor: pointer; + margin-left: 8px; +} + +.mcp-server-section { + margin-bottom: 6px; +} + +.mcp-section-label { + font-weight: bold; + color: var(--theia-descriptionForeground); +} + +.mcp-code-block { + background-color: var(--theia-editor-background); + padding: 2px 4px; + border-radius: 3px; + font-family: monospace; +} + +.mcp-env-block { + margin-left: 10px; + background-color: var(--theia-editor-background); + padding: 4px 8px; + border-radius: 3px; + font-family: monospace; + font-size: 12px; +} + +.mcp-toggle-indicator { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + margin-right: 6px; + transition: transform 0.2s ease; +} + +.mcp-tools-section { + margin-top: 12px; + cursor: pointer; + user-select: none; +} + +.mcp-tools-list { + margin-left: 24px; + margin-top: 4px; + background-color: var(--theia-editor-background); + border-radius: 3px; + font-family: monospace; + font-size: 12px; +} + +.mcp-autostart-badge { + padding: 2px 6px; + border-radius: 3px; + font-size: 12px; + font-weight: bold; +} + +.mcp-no-servers { + padding: 20px; + text-align: center; + color: var(--theia-descriptionForeground); +} + +.mcp-status-container { + display: flex; + align-items: center; +} + +.mcp-server-controls { + display: flex; + align-items: center; + margin-top: 12px; + border-top: 1px solid var(--theia-panelTitle-inactiveForeground); + padding-top: 12px; +} + +.mcp-server-button { + display: inline-flex; + align-items: center; + justify-content: center; + background-color: var(--theia-button-background); + color: var(--theia-button-foreground); + border: none; + border-radius: 4px; + padding: 6px 8px; + font-size: 12px; + cursor: pointer; + transition: background-color 0.2s; + margin-right: 10px; +} + +.mcp-server-button:hover { + background-color: var(--theia-button-hoverBackground); +} + +.mcp-server-button.play-button { + background-color: var(--theia-successBackground); + color: var(--theia-successForeground); +} + +.mcp-server-button.play-button:hover { + background-color: var(--theia-successBackground); + filter: brightness(1.1); +} + +.mcp-server-button.stop-button { + background-color: var(--theia-errorBackground); + color: var(--theia-errorForeground); +} + +.mcp-server-button.stop-button:hover { + background-color: var(--theia-errorBackground); + filter: brightness(1.1); +} + +.mcp-server-button-icon { + margin-right: 6px; + font-size: 14px; +} + +.mcp-copy-tool-button { + background: transparent; + border: none; + padding: 0; + cursor: pointer; + font-size: 12px; + color: var(--theia-descriptionForeground); + white-space: nowrap; +} diff --git a/packages/ai-ide/tsconfig.json b/packages/ai-ide/tsconfig.json index bd3ed8f797617..455c5360dd1b1 100644 --- a/packages/ai-ide/tsconfig.json +++ b/packages/ai-ide/tsconfig.json @@ -21,6 +21,9 @@ { "path": "../ai-core" }, + { + "path": "../ai-mcp" + }, { "path": "../core" }, diff --git a/packages/ai-mcp/src/browser/mcp-command-contribution.ts b/packages/ai-mcp/src/browser/mcp-command-contribution.ts index f7388ebdea1b8..dda602f1db813 100644 --- a/packages/ai-mcp/src/browser/mcp-command-contribution.ts +++ b/packages/ai-mcp/src/browser/mcp-command-contribution.ts @@ -17,7 +17,7 @@ import { AICommandHandlerFactory } from '@theia/ai-core/lib/browser/ai-command-h import { CommandContribution, CommandRegistry, MessageService, nls } from '@theia/core'; import { QuickInputService } from '@theia/core/lib/browser'; import { inject, injectable } from '@theia/core/shared/inversify'; -import { MCPFrontendService } from './mcp-frontend-service'; +import { MCPFrontendService, MCPServerStatus } from '../common/mcp-server-manager'; export const StartMCPServer = { id: 'mcp.startserver', @@ -91,12 +91,24 @@ export class MCPCommandContribution implements CommandContribution { return; } await this.mcpFrontendService.startServer(selection); - const { tools } = await this.mcpFrontendService.getTools(selection); - const toolNames = tools.map(tool => tool.name || nls.localize('theia/ai/mcp/tool/unnamed', 'Unnamed Tool')).join(', '); - this.messageService.info( - nls.localize('theia/ai/mcp/info/serverStarted', 'MCP server "{0}" successfully started. Registered tools: {1}', selection, toolNames || - nls.localize('theia/ai/mcp/tool/noTools', 'No tools available.')) - ); + const serverDescription = await this.mcpFrontendService.getServerDescription(selection); + if (serverDescription && serverDescription.status) { + if (serverDescription.status === MCPServerStatus.Running) { + let toolNames: string | undefined = undefined; + if (serverDescription.tools) { + toolNames = serverDescription.tools.map(tool => tool.name).join(','); + } + this.messageService.info( + nls.localize('theia/ai/mcp/info/serverStarted', 'MCP server "{0}" successfully started. Registered tools: {1}', selection, toolNames || + nls.localize('theia/ai/mcp/tool/noTools', 'No tools available.')) + ); + return; + } + if (serverDescription.error) { + console.error('Error while starting MCP server:', serverDescription.error); + } + } + this.messageService.error(nls.localize('theia/ai/mcp/error/startFailed', 'An error occurred while starting the MCP server.')); } catch (error) { this.messageService.error(nls.localize('theia/ai/mcp/error/startFailed', 'An error occurred while starting the MCP server.')); console.error('Error while starting MCP server:', error); diff --git a/packages/ai-mcp/src/browser/mcp-frontend-application-contribution.ts b/packages/ai-mcp/src/browser/mcp-frontend-application-contribution.ts index 51963f4fef25f..5ba2b615801ae 100644 --- a/packages/ai-mcp/src/browser/mcp-frontend-application-contribution.ts +++ b/packages/ai-mcp/src/browser/mcp-frontend-application-contribution.ts @@ -19,7 +19,7 @@ import { inject, injectable } from '@theia/core/shared/inversify'; import { MCPServerDescription, MCPServerManager } from '../common'; import { MCP_SERVERS_PREF } from './mcp-preferences'; import { JSONObject } from '@theia/core/shared/@lumino/coreutils'; -import { MCPFrontendService } from './mcp-frontend-service'; +import { MCPFrontendService } from '../common/mcp-server-manager'; interface MCPServersPreferenceValue { command: string; diff --git a/packages/ai-mcp/src/browser/mcp-frontend-module.ts b/packages/ai-mcp/src/browser/mcp-frontend-module.ts index 917f3db6e7d4d..02c0784de0294 100644 --- a/packages/ai-mcp/src/browser/mcp-frontend-module.ts +++ b/packages/ai-mcp/src/browser/mcp-frontend-module.ts @@ -18,18 +18,21 @@ import { CommandContribution } from '@theia/core'; import { ContainerModule } from '@theia/core/shared/inversify'; import { MCPCommandContribution } from './mcp-command-contribution'; import { FrontendApplicationContribution, PreferenceContribution, RemoteConnectionProvider, ServiceConnectionProvider } from '@theia/core/lib/browser'; -import { MCPServerManager, MCPServerManagerPath } from '../common/mcp-server-manager'; +import { MCPFrontendService, MCPServerManager, MCPServerManagerPath, MCPFrontendNotificationService } from '../common/mcp-server-manager'; import { McpServersPreferenceSchema } from './mcp-preferences'; import { McpFrontendApplicationContribution } from './mcp-frontend-application-contribution'; -import { MCPFrontendService } from './mcp-frontend-service'; +import { MCPFrontendServiceImpl } from './mcp-frontend-service'; +import { MCPFrontendNotificationServiceImpl } from './mcp-frontend-notification-service'; export default new ContainerModule(bind => { bind(PreferenceContribution).toConstantValue({ schema: McpServersPreferenceSchema }); bind(FrontendApplicationContribution).to(McpFrontendApplicationContribution).inSingletonScope(); bind(CommandContribution).to(MCPCommandContribution); + bind(MCPFrontendService).to(MCPFrontendServiceImpl).inSingletonScope(); + bind(MCPFrontendNotificationService).to(MCPFrontendNotificationServiceImpl).inSingletonScope(); bind(MCPServerManager).toDynamicValue(ctx => { const connection = ctx.container.get(RemoteConnectionProvider); - return connection.createProxy(MCPServerManagerPath); + const client = ctx.container.get(MCPFrontendNotificationService); + return connection.createProxy(MCPServerManagerPath, client); }).inSingletonScope(); - bind(MCPFrontendService).toSelf().inSingletonScope(); }); diff --git a/packages/ai-mcp/src/browser/mcp-frontend-notification-service.ts b/packages/ai-mcp/src/browser/mcp-frontend-notification-service.ts new file mode 100644 index 0000000000000..646ae0b06494c --- /dev/null +++ b/packages/ai-mcp/src/browser/mcp-frontend-notification-service.ts @@ -0,0 +1,29 @@ +// ***************************************************************************** +// Copyright (C) 2025 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { injectable } from '@theia/core/shared/inversify'; +import { MCPFrontendNotificationService } from '../common'; +import { Emitter, Event } from '@theia/core/lib/common/event'; + +@injectable() +export class MCPFrontendNotificationServiceImpl implements MCPFrontendNotificationService { + protected readonly onDidUpdateMCPServersEmitter = new Emitter(); + public readonly onDidUpdateMCPServers: Event = this.onDidUpdateMCPServersEmitter.event; + + didUpdateMCPServers(): void { + this.onDidUpdateMCPServersEmitter.fire(); + } +} diff --git a/packages/ai-mcp/src/browser/mcp-frontend-service.ts b/packages/ai-mcp/src/browser/mcp-frontend-service.ts index d962ef2a31bb4..de37ad37e0122 100644 --- a/packages/ai-mcp/src/browser/mcp-frontend-service.ts +++ b/packages/ai-mcp/src/browser/mcp-frontend-service.ts @@ -14,11 +14,11 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { injectable, inject } from '@theia/core/shared/inversify'; -import { MCPServer, MCPServerManager } from '../common/mcp-server-manager'; +import { MCPFrontendService, MCPServer, MCPServerDescription, MCPServerManager } from '../common/mcp-server-manager'; import { ToolInvocationRegistry, ToolRequest, PromptService } from '@theia/ai-core'; @injectable() -export class MCPFrontendService { +export class MCPFrontendServiceImpl implements MCPFrontendService { @inject(MCPServerManager) protected readonly mcpServerManager: MCPServerManager; @@ -31,7 +31,7 @@ export class MCPFrontendService { async startServer(serverName: string): Promise { await this.mcpServerManager.startServer(serverName); - this.registerTools(serverName); + await this.registerTools(serverName); } async registerToolsForAllStartedServers(): Promise { @@ -42,16 +42,18 @@ export class MCPFrontendService { } async registerTools(serverName: string): Promise { - const { tools } = await this.getTools(serverName); - const toolRequests: ToolRequest[] = tools.map(tool => this.convertToToolRequest(tool, serverName)); - toolRequests.forEach(toolRequest => - this.toolInvocationRegistry.registerTool(toolRequest) - ); - - this.createPromptTemplate(serverName, toolRequests); + const returnedTools = await this.getTools(serverName); + if (returnedTools) { + const toolRequests: ToolRequest[] = returnedTools.tools.map(tool => this.convertToToolRequest(tool, serverName)); + toolRequests.forEach(toolRequest => + this.toolInvocationRegistry.registerTool(toolRequest) + ); + + this.createPromptTemplate(serverName, toolRequests); + } } - private getPromptTemplateId(serverName: string): string { + getPromptTemplateId(serverName: string): string { return `mcp_${serverName}_tools`; } @@ -73,15 +75,24 @@ export class MCPFrontendService { } getStartedServers(): Promise { - return this.mcpServerManager.getStartedServers(); + return this.mcpServerManager.getRunningServers(); } getServerNames(): Promise { return this.mcpServerManager.getServerNames(); } - getTools(serverName: string): ReturnType { - return this.mcpServerManager.getTools(serverName); + async getServerDescription(name: string): Promise { + return this.mcpServerManager.getServerDescription(name); + } + + async getTools(serverName: string): Promise | undefined> { + try { + return await this.mcpServerManager.getTools(serverName); + } catch (error) { + console.error('Error while trying to get tools: ' + error); + return undefined; + } } private convertToToolRequest(tool: Awaited>['tools'][number], serverName: string): ToolRequest { diff --git a/packages/ai-mcp/src/common/mcp-server-manager.ts b/packages/ai-mcp/src/common/mcp-server-manager.ts index 18414a3312781..325c5a1825bf5 100644 --- a/packages/ai-mcp/src/common/mcp-server-manager.ts +++ b/packages/ai-mcp/src/common/mcp-server-manager.ts @@ -15,21 +15,56 @@ // ***************************************************************************** import type { Client } from '@modelcontextprotocol/sdk/client/index'; +import { Event } from '@theia/core/lib/common/event'; + +export const MCPFrontendService = Symbol('MCPFrontendService'); +export interface MCPFrontendService { + startServer(serverName: string): Promise; + registerToolsForAllStartedServers(): Promise; + stopServer(serverName: string): Promise; + getStartedServers(): Promise; + getServerNames(): Promise; + getServerDescription(name: string): Promise; + getTools(serverName: string): Promise | undefined>; + getPromptTemplateId(serverName: string): string; +} + +export const MCPFrontendNotificationService = Symbol('MCPFrontendNotificationService'); +export interface MCPFrontendNotificationService { + readonly onDidUpdateMCPServers: Event; + didUpdateMCPServers(): void; +} export interface MCPServer { callTool(toolName: string, arg_string: string): ReturnType; getTools(): ReturnType; + description: MCPServerDescription; } export interface MCPServerManager { callTool(serverName: string, toolName: string, arg_string: string): ReturnType; removeServer(name: string): void; addOrUpdateServer(description: MCPServerDescription): void; - getTools(serverName: string): ReturnType + getTools(serverName: string): ReturnType; getServerNames(): Promise; + getServerDescription(name: string): Promise; startServer(serverName: string): Promise; stopServer(serverName: string): Promise; - getStartedServers(): Promise; + getRunningServers(): Promise; + setClient(client: MCPFrontendNotificationService): void; + disconnectClient(client: MCPFrontendNotificationService): void; +} + +export interface ToolInformation { + name: string; + description?: string; +} + +export enum MCPServerStatus { + NotRunning = 'Not Running', + Starting = 'Starting', + Running = 'Running', + Errored = 'Errored' } export interface MCPServerDescription { @@ -57,6 +92,21 @@ export interface MCPServerDescription { * Flag indicating whether the server should automatically start when the application starts. */ autostart?: boolean; + + /** + * The current status of the server. Optional because only set by the server. + */ + status?: MCPServerStatus; + + /** + * Last error message that the server has returned. + */ + error?: string; + + /** + * List of available tools for the server. Returns the name and description if available. + */ + tools?: ToolInformation[]; } export const MCPServerManager = Symbol('MCPServerManager'); diff --git a/packages/ai-mcp/src/node/mcp-backend-module.ts b/packages/ai-mcp/src/node/mcp-backend-module.ts index 736b40ab66016..bde4d71a4b57b 100644 --- a/packages/ai-mcp/src/node/mcp-backend-module.ts +++ b/packages/ai-mcp/src/node/mcp-backend-module.ts @@ -17,17 +17,18 @@ import { ContainerModule } from '@theia/core/shared/inversify'; import { ConnectionHandler, RpcConnectionHandler } from '@theia/core'; import { MCPServerManagerImpl } from './mcp-server-manager-impl'; -import { MCPServerManager, MCPServerManagerPath } from '../common/mcp-server-manager'; +import { MCPFrontendNotificationService, MCPServerManager, MCPServerManagerPath } from '../common/mcp-server-manager'; import { ConnectionContainerModule } from '@theia/core/lib/node/messaging/connection-container-module'; // We use a connection module to handle AI services separately for each frontend. const mcpConnectionModule = ConnectionContainerModule.create(({ bind, bindBackendService, bindFrontendService }) => { bind(MCPServerManager).to(MCPServerManagerImpl).inSingletonScope(); - bind(ConnectionHandler).toDynamicValue(ctx => new RpcConnectionHandler( - MCPServerManagerPath, - () => { - const service = ctx.container.get(MCPServerManager); - return service; + bind(ConnectionHandler).toDynamicValue(ctx => new RpcConnectionHandler( + MCPServerManagerPath, client => { + const server = ctx.container.get(MCPServerManager); + server.setClient(client); + client.onDidCloseConnection(() => server.disconnectClient(client)); + return server; } )).inSingletonScope(); }); diff --git a/packages/ai-mcp/src/node/mcp-server-manager-impl.ts b/packages/ai-mcp/src/node/mcp-server-manager-impl.ts index 06f869f93be73..12f63cfa96e6e 100644 --- a/packages/ai-mcp/src/node/mcp-server-manager-impl.ts +++ b/packages/ai-mcp/src/node/mcp-server-manager-impl.ts @@ -14,13 +14,16 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { injectable } from '@theia/core/shared/inversify'; -import { MCPServerDescription, MCPServerManager } from '../common/mcp-server-manager'; +import { MCPServerDescription, MCPServerManager, MCPFrontendNotificationService } from '../common/mcp-server-manager'; import { MCPServer } from './mcp-server'; +import { Disposable } from '@theia/core/lib/common/disposable'; @injectable() export class MCPServerManagerImpl implements MCPServerManager { protected servers: Map = new Map(); + protected clients: Array = []; + protected serverListeners: Map = new Map(); async stopServer(serverName: string): Promise { const server = this.servers.get(serverName); @@ -29,16 +32,17 @@ export class MCPServerManagerImpl implements MCPServerManager { } server.stop(); console.log(`MCP server "${serverName}" stopped.`); + this.notifyClients(); } - async getStartedServers(): Promise { - const startedServers: string[] = []; + async getRunningServers(): Promise { + const runningServers: string[] = []; for (const [name, server] of this.servers.entries()) { - if (server.isStarted()) { - startedServers.push(name); + if (server.isRunnning()) { + runningServers.push(name); } } - return startedServers; + return runningServers; } callTool(serverName: string, toolName: string, arg_string: string): ReturnType { @@ -55,30 +59,44 @@ export class MCPServerManagerImpl implements MCPServerManager { throw new Error(`MCP server "${serverName}" not found.`); } await server.start(); + this.notifyClients(); } + async getServerNames(): Promise { return Array.from(this.servers.keys()); } + async getServerDescription(name: string): Promise { + const server = this.servers.get(name); + return server ? server.getDescription() : undefined; + } + public async getTools(serverName: string): ReturnType { const server = this.servers.get(serverName); if (!server) { throw new Error(`MCP server "${serverName}" not found.`); } return server.getTools(); - } addOrUpdateServer(description: MCPServerDescription): void { - const { name, command, args, env } = description; - const existingServer = this.servers.get(name); + const existingServer = this.servers.get(description.name); if (existingServer) { - existingServer.update(command, args, env); + existingServer.update(description); } else { - const newServer = new MCPServer(name, command, args, env); - this.servers.set(name, newServer); + const newServer = new MCPServer(description); + this.servers.set(description.name, newServer); + + // Subscribe to status updates from the new server + const listener = newServer.onDidUpdateStatus(() => { + this.notifyClients(); + }); + + // Store the listener for later disposal + this.serverListeners.set(description.name, listener); } + this.notifyClients(); } removeServer(name: string): void { @@ -86,8 +104,31 @@ export class MCPServerManagerImpl implements MCPServerManager { if (server) { server.stop(); this.servers.delete(name); + + // Clean up the status listener + const listener = this.serverListeners.get(name); + if (listener) { + listener.dispose(); + this.serverListeners.delete(name); + } } else { console.warn(`MCP server "${name}" not found.`); } + this.notifyClients(); + } + + setClient(client: MCPFrontendNotificationService): void { + this.clients.push(client); + } + + disconnectClient(client: MCPFrontendNotificationService): void { + const index = this.clients.indexOf(client); + if (index !== -1) { + this.clients.splice(index, 1); + } + } + + private notifyClients(): void { + this.clients.forEach(client => client.didUpdateMCPServers()); } } diff --git a/packages/ai-mcp/src/node/mcp-server.ts b/packages/ai-mcp/src/node/mcp-server.ts index 52a7932762df7..801393b883b68 100644 --- a/packages/ai-mcp/src/node/mcp-server.ts +++ b/packages/ai-mcp/src/node/mcp-server.ts @@ -15,6 +15,8 @@ // ***************************************************************************** import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { MCPServerDescription, MCPServerStatus, ToolInformation } from '../common'; +import { Emitter } from '@theia/core/lib/common/event'; export class MCPServer { private name: string; @@ -22,23 +24,67 @@ export class MCPServer { private args?: string[]; private client: Client; private env?: { [key: string]: string }; - private started: boolean = false; + private autostart?: boolean; + private error?: string; + private status: MCPServerStatus = MCPServerStatus.NotRunning; - constructor(name: string, command: string, args?: string[], env?: Record) { - this.name = name; - this.command = command; - this.args = args; - this.env = env; + // Event emitter for status updates + private readonly onDidUpdateStatusEmitter = new Emitter(); + readonly onDidUpdateStatus = this.onDidUpdateStatusEmitter.event; + + constructor(description: MCPServerDescription) { + this.name = description.name; + this.command = description.command; + this.args = description.args; + this.env = description.env; + this.autostart = description.autostart; + console.log(this.autostart); + } + + getStatus(): MCPServerStatus { + return this.status; + } + + setStatus(status: MCPServerStatus): void { + this.status = status; + this.onDidUpdateStatusEmitter.fire(status); + } + + isRunnning(): boolean { + return this.status === MCPServerStatus.Running; } - isStarted(): boolean { - return this.started; + async getDescription(): Promise { + let toReturnTools: ToolInformation[] | undefined = undefined; + if (this.isRunnning()) { + try { + const { tools } = await this.getTools(); + toReturnTools = tools.map(tool => ({ + name: tool.name, + description: tool.description + })); + } catch (error) { + console.error('Error fetching tools for description:', error); + } + } + + return { + name: this.name, + command: this.command, + args: this.args, + env: this.env, + autostart: this.autostart, + status: this.status, + error: this.error, + tools: toReturnTools + }; } async start(): Promise { - if (this.started) { + if (this.isRunnning() && this.status === MCPServerStatus.Starting) { return; } + this.setStatus(MCPServerStatus.Starting); console.log(`Starting server "${this.name}" with command: ${this.command} and args: ${this.args?.join(' ')} and env: ${JSON.stringify(this.env)}`); // Filter process.env to exclude undefined values const sanitizedEnv: Record = Object.fromEntries( @@ -56,6 +102,8 @@ export class MCPServer { }); transport.onerror = error => { console.error('Error: ' + error); + this.error = 'Error: ' + error; + this.setStatus(MCPServerStatus.Errored); }; this.client = new Client({ @@ -66,10 +114,18 @@ export class MCPServer { }); this.client.onerror = error => { console.error('Error in MCP client: ' + error); + this.error = 'Error in MCP client: ' + error; + this.setStatus(MCPServerStatus.Errored); }; - await this.client.connect(transport); - this.started = true; + try { + await this.client.connect(transport); + this.setStatus(MCPServerStatus.Running); + } catch (e) { + this.error = 'Error on MCP startup: ' + e; + this.client.close(); + this.setStatus(MCPServerStatus.Errored); + } } async callTool(toolName: string, arg_string: string): ReturnType { @@ -94,18 +150,20 @@ export class MCPServer { return this.client.listTools(); } - update(command: string, args?: string[], env?: { [key: string]: string }): void { - this.command = command; - this.args = args; - this.env = env; + update(description: MCPServerDescription): void { + this.name = description.name; + this.command = description.command; + this.args = description.args; + this.env = description.env; + this.autostart = description.autostart; } stop(): void { - if (!this.started || !this.client) { + if (!this.isRunnning() || !this.client) { return; } console.log(`Stopping MCP server "${this.name}"`); this.client.close(); - this.started = false; + this.setStatus(MCPServerStatus.NotRunning); } } From 8a827d4dffb738bfc12c8fa543603b8a11422d05 Mon Sep 17 00:00:00 2001 From: Jonas Helming Date: Wed, 26 Mar 2025 16:30:58 +0100 Subject: [PATCH 06/11] Consolidate widget labels (#15304) fixed #15303 --- packages/ai-chat-ui/src/browser/chat-view-widget.tsx | 2 +- packages/ai-history/src/browser/ai-history-widget.tsx | 2 +- .../src/browser/ai-configuration/ai-configuration-widget.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ai-chat-ui/src/browser/chat-view-widget.tsx b/packages/ai-chat-ui/src/browser/chat-view-widget.tsx index 480851b8be2ed..76ec0659485ad 100644 --- a/packages/ai-chat-ui/src/browser/chat-view-widget.tsx +++ b/packages/ai-chat-ui/src/browser/chat-view-widget.tsx @@ -33,7 +33,7 @@ export namespace ChatViewWidget { export class ChatViewWidget extends BaseWidget implements ExtractableWidget, StatefulWidget { public static ID = 'chat-view-widget'; - static LABEL = `${nls.localizeByDefault('Chat')}`; + static LABEL = nls.localize('theia/ai/chat/view/label', 'AI Chat'); @inject(ChatService) protected chatService: ChatService; diff --git a/packages/ai-history/src/browser/ai-history-widget.tsx b/packages/ai-history/src/browser/ai-history-widget.tsx index 43ffc5d2d239a..54e400b940743 100644 --- a/packages/ai-history/src/browser/ai-history-widget.tsx +++ b/packages/ai-history/src/browser/ai-history-widget.tsx @@ -35,7 +35,7 @@ export class AIHistoryView extends ReactWidget implements StatefulWidget { protected readonly agentService: AgentService; public static ID = 'ai-history-widget'; - static LABEL = nls.localize('theia/ai/history/view/label', '✨ AI Agent History [Alpha]'); + static LABEL = nls.localize('theia/ai/history/view/label', 'AI Agent History [Alpha]'); protected selectedAgent?: Agent; diff --git a/packages/ai-ide/src/browser/ai-configuration/ai-configuration-widget.tsx b/packages/ai-ide/src/browser/ai-configuration/ai-configuration-widget.tsx index 21c110e813b0e..e8780d0f8d568 100644 --- a/packages/ai-ide/src/browser/ai-configuration/ai-configuration-widget.tsx +++ b/packages/ai-ide/src/browser/ai-configuration/ai-configuration-widget.tsx @@ -28,7 +28,7 @@ import { AIMCPConfigurationWidget } from './mcp-configuration-widget'; export class AIConfigurationContainerWidget extends BaseWidget { static readonly ID = 'ai-configuration'; - static readonly LABEL = nls.localize('theia/ai/core/aiConfiguration/label', '✨ AI Configuration [Alpha]'); + static readonly LABEL = nls.localize('theia/ai/core/aiConfiguration/label', 'AI Configuration [Alpha]'); protected dockpanel: DockPanel; @inject(TheiaDockPanel.Factory) From e0dc27d4b7b963a87214f8e0d47b466891775d6e Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Thu, 20 Mar 2025 12:31:20 -0600 Subject: [PATCH 07/11] Ensure editor created before services used --- .../browser/monaco-editor-peek-view-widget.ts | 52 +++++++++++++++ .../browser/dirty-diff/dirty-diff-widget.ts | 63 ++++++++++--------- 2 files changed, 87 insertions(+), 28 deletions(-) diff --git a/packages/monaco/src/browser/monaco-editor-peek-view-widget.ts b/packages/monaco/src/browser/monaco-editor-peek-view-widget.ts index 4c5d90b455c34..94bd47fc1f3d9 100644 --- a/packages/monaco/src/browser/monaco-editor-peek-view-widget.ts +++ b/packages/monaco/src/browser/monaco-editor-peek-view-widget.ts @@ -90,6 +90,14 @@ export class MonacoEditorPeekViewWidget { return this._actionbarWidget; } + fillContainer(container: HTMLElement): void { + super._fillContainer(container); + } + + protected override _fillContainer(container: HTMLElement): void { + that.fillContainer(container); + } + fillHead(container: HTMLElement, noCloseAction?: boolean): void { super._fillHead(container, noCloseAction); } @@ -137,6 +145,26 @@ export class MonacoEditorPeekViewWidget { protected override revealRange(range: monaco.Range, isLastLine: boolean): void { that.doRevealRange(that.editor['m2p'].asRange(range), isLastLine); } + + getBodyElement(): HTMLDivElement | undefined { + return this._bodyElement; + } + + setBodyElement(element: HTMLDivElement | undefined): void { + this._bodyElement = element; + } + + getHeadElement(): HTMLDivElement | undefined { + return this._headElement; + } + + setHeadElement(element: HTMLDivElement | undefined): void { + this._headElement = element; + } + + override setCssClass(className: string, classToReplace?: string | undefined): void { + super.setCssClass(className, classToReplace); + } }( editor.getControl() as unknown as ICodeEditor, Object.assign({}, options, this.convertStyles(styles)), @@ -185,6 +213,10 @@ export class MonacoEditorPeekViewWidget { return action; } + protected fillContainer(container: HTMLElement): void { + this.delegate.fillContainer(container) + } + protected fillHead(container: HTMLElement, noCloseAction?: boolean): void { this.delegate.fillHead(container, noCloseAction); } @@ -209,6 +241,26 @@ export class MonacoEditorPeekViewWidget { this.delegate.doRevealRange(this.editor['p2m'].asRange(range), isLastLine); } + protected get bodyElement(): HTMLDivElement | undefined { + return this.delegate.getBodyElement(); + } + + protected set bodyElement(element: HTMLDivElement | undefined) { + this.delegate.setBodyElement(element); + } + + protected get headElement(): HTMLDivElement | undefined { + return this.delegate.getHeadElement(); + } + + protected set headElement(element: HTMLDivElement | undefined) { + this.delegate.setHeadElement(element); + } + + protected setCssClass(className: string, classToReplace?: string | undefined): void { + this.delegate.setCssClass(className, classToReplace); + } + private convertStyles(styles: MonacoEditorPeekViewWidget.Styles): IPeekViewStyles { return { frameColor: this.convertColor(styles.frameColor), diff --git a/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts b/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts index 4fe5d5b5e58e9..76ab3878d7690 100644 --- a/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts +++ b/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts @@ -48,8 +48,8 @@ export class DirtyDiffWidget implements Disposable { private readonly onDidCloseEmitter = new Emitter(); readonly onDidClose: Event = this.onDidCloseEmitter.event; protected index: number = -1; - private peekView?: DirtyDiffPeekView; - private diffEditorPromise?: Promise; + private peekView: DirtyDiffPeekView; + private diffEditorPromise: Promise; constructor( @inject(DirtyDiffWidgetProps) protected readonly props: DirtyDiffWidgetProps, @@ -90,16 +90,16 @@ export class DirtyDiffWidget implements Disposable { return this.index; } - showChange(index: number): void { - this.checkCreated(); + async showChange(index: number): Promise { + await this.checkCreated(); if (index >= 0 && index < this.changes.length) { this.index = index; this.showCurrentChange(); } } - showNextChange(): void { - this.checkCreated(); + async showNextChange(): Promise { + await this.checkCreated(); const index = this.index; const length = this.changes.length; if (length > 0 && (index < 0 || length > 1)) { @@ -108,8 +108,8 @@ export class DirtyDiffWidget implements Disposable { } } - showPreviousChange(): void { - this.checkCreated(); + async showPreviousChange(): Promise { + await this.checkCreated(); const index = this.index; const length = this.changes.length; if (length > 0 && (index < 0 || length > 1)) { @@ -119,7 +119,7 @@ export class DirtyDiffWidget implements Disposable { } async getContentWithSelectedChanges(predicate: (change: Change, index: number, changes: readonly Change[]) => boolean): Promise { - this.checkCreated(); + await this.checkCreated(); const changes = this.changes.filter(predicate); const { diffEditor } = await this.diffEditorPromise!; const diffEditorModel = diffEditor.getModel()!; @@ -134,9 +134,9 @@ export class DirtyDiffWidget implements Disposable { protected showCurrentChange(): void { this.peekView!.setTitle(this.computePrimaryHeading(), this.computeSecondaryHeading()); const { previousRange, currentRange } = this.changes[this.index]; - this.peekView!.show(Position.create(LineRange.getEndPosition(currentRange).line, 0), + this.peekView.show(Position.create(LineRange.getEndPosition(currentRange).line, 0), this.computeHeightInLines()); - this.diffEditorPromise!.then(({ diffEditor }) => { + this.diffEditorPromise.then(({ diffEditor }) => { let startLine = LineRange.getStartPosition(currentRange).line; let endLine = LineRange.getEndPosition(currentRange).line; if (LineRange.isEmpty(currentRange)) { // the change is a removal @@ -174,10 +174,8 @@ export class DirtyDiffWidget implements Disposable { return Math.min(changeHeightInLines + /* padding */ 8, Math.floor(editorHeightInLines / 3)); } - protected checkCreated(): void { - if (!this.peekView) { - throw new Error('create() method needs to be called first.'); - } + protected async checkCreated(): Promise { + await this.diffEditorPromise; } } @@ -250,7 +248,7 @@ function applyChanges(changes: readonly Change[], original: monaco.editor.ITextM class DirtyDiffPeekView extends MonacoEditorPeekViewWidget { - private diffEditorPromise?: Promise; + private diffEditor?: MonacoDiffEditor; private height?: number; constructor(readonly widget: DirtyDiffWidget) { @@ -259,12 +257,16 @@ class DirtyDiffPeekView extends MonacoEditorPeekViewWidget { override async create(): Promise { try { + this.bodyElement = document.createElement('div'); + this.bodyElement.classList.add('body'); + const diffEditor = await this.widget.editorProvider.createEmbeddedDiffEditor(this.editor, this.bodyElement, this.widget.previousRevisionUri); + this.diffEditor = diffEditor; + this.toDispose.push(diffEditor); super.create(); - const diffEditor = await this.diffEditorPromise!; return new Promise(resolve => { - // setTimeout is needed here because the non-side-by-side diff editor might still not have created the view zones; - // otherwise, the first change shown might not be properly revealed in the diff editor. - // see also https://github.com/microsoft/vscode/blob/b30900b56c4b3ca6c65d7ab92032651f4cb23f15/src/vs/workbench/contrib/scm/browser/dirtydiffDecorator.ts#L248 + // setTimeout is needed here because the non-side-by-side diff editor might still not have created the view zones; + // otherwise, the first change shown might not be properly revealed in the diff editor. + // see also https://github.com/microsoft/vscode/blob/b30900b56c4b3ca6c65d7ab92032651f4cb23f15/src/vs/workbench/contrib/scm/browser/dirtydiffDecorator.ts#L248 const disposable = diffEditor.diffEditor.onDidUpdateDiff(() => setTimeout(() => { resolve(diffEditor); disposable.dispose(); @@ -329,15 +331,20 @@ class DirtyDiffPeekView extends MonacoEditorPeekViewWidget { () => this.dispose()); } - protected override fillHead(container: HTMLElement): void { - super.fillHead(container, true); + protected override fillContainer(container: HTMLElement): void { + this.setCssClass('peekview-widget'); + + this.headElement = document.createElement('div'); + this.headElement.classList.add('head'); + + container.appendChild(this.headElement); + container.appendChild(this.bodyElement!); + + this.fillHead(this.headElement); } - protected override fillBody(container: HTMLElement): void { - this.diffEditorPromise = this.widget.editorProvider.createEmbeddedDiffEditor(this.editor, container, this.widget.previousRevisionUri).then(diffEditor => { - this.toDispose.push(diffEditor); - return diffEditor; - }); + protected override fillHead(container: HTMLElement): void { + super.fillHead(container, true); } protected override doLayoutBody(height: number, width: number): void { @@ -355,7 +362,7 @@ class DirtyDiffPeekView extends MonacoEditorPeekViewWidget { } private layout(height: number, width: number): void { - this.diffEditorPromise?.then(({ diffEditor }) => diffEditor.layout({ height, width })); + this.diffEditor?.diffEditor.layout({ height, width }); } protected override doRevealRange(range: Range): void { From 6d35a4421f032bf1d1388c48f016b7f4f99e2127 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Tue, 25 Mar 2025 08:06:34 -0600 Subject: [PATCH 08/11] Lint --- packages/monaco/src/browser/monaco-editor-peek-view-widget.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/monaco/src/browser/monaco-editor-peek-view-widget.ts b/packages/monaco/src/browser/monaco-editor-peek-view-widget.ts index 94bd47fc1f3d9..2510e375710f2 100644 --- a/packages/monaco/src/browser/monaco-editor-peek-view-widget.ts +++ b/packages/monaco/src/browser/monaco-editor-peek-view-widget.ts @@ -214,7 +214,7 @@ export class MonacoEditorPeekViewWidget { } protected fillContainer(container: HTMLElement): void { - this.delegate.fillContainer(container) + this.delegate.fillContainer(container); } protected fillHead(container: HTMLElement, noCloseAction?: boolean): void { From 118daf9008c6122280295f15cf9f3cd4d6e90e35 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Tue, 25 Mar 2025 08:50:28 -0600 Subject: [PATCH 09/11] Close editor on successful action, not any change --- packages/scm/src/browser/dirty-diff/dirty-diff-navigator.ts | 1 - packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/scm/src/browser/dirty-diff/dirty-diff-navigator.ts b/packages/scm/src/browser/dirty-diff/dirty-diff-navigator.ts index d765797337617..a3a24f037fefc 100644 --- a/packages/scm/src/browser/dirty-diff/dirty-diff-navigator.ts +++ b/packages/scm/src/browser/dirty-diff/dirty-diff-navigator.ts @@ -135,7 +135,6 @@ export class DirtyDiffController implements Disposable { handleDirtyDiffUpdate(dirtyDiff: DirtyDiffUpdate): void { if (dirtyDiff.editor === this.editor) { - this.closeWidget(); this.dirtyDiff = dirtyDiff; } } diff --git a/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts b/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts index 76ab3878d7690..28afe8b93a7ae 100644 --- a/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts +++ b/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts @@ -315,8 +315,10 @@ class DirtyDiffPeekView extends MonacoEditorPeekViewWidget { if (item instanceof ActionMenuNode) { const { command, id, label, icon, when } = item; if (icon && menuCommandExecutor.isVisible(menuPath, command, this.widget) && (!when || contextKeyService.match(when))) { + // Close editor on successful contributed action. + // https://github.com/microsoft/vscode/blob/11b1500e0a2e8b5ba12e98a3905f9d120b8646a0/src/vs/workbench/contrib/scm/browser/quickDiffWidget.ts#L356-L361 this.addAction(id, label, icon, menuCommandExecutor.isEnabled(menuPath, command, this.widget), () => { - menuCommandExecutor.executeCommand(menuPath, command, this.widget); + menuCommandExecutor.executeCommand(menuPath, command, this.widget).then(() => this.dispose()); }); } } From e2cf80c424ee1d55915521a7e0521245a0c47b17 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Tue, 25 Mar 2025 13:50:32 -0600 Subject: [PATCH 10/11] Update DirtyDiffWidget change model when changes change --- .../src/browser/monaco-editor-provider.ts | 2 +- .../decorations/scm-decorations-service.ts | 2 +- .../dirty-diff/dirty-diff-navigator.ts | 6 +- .../browser/dirty-diff/dirty-diff-widget.ts | 57 +++++++++++-------- packages/scm/src/browser/scm-contribution.ts | 2 + 5 files changed, 41 insertions(+), 28 deletions(-) diff --git a/packages/monaco/src/browser/monaco-editor-provider.ts b/packages/monaco/src/browser/monaco-editor-provider.ts index cb9d1c9e07a05..7cf3f64af7b14 100644 --- a/packages/monaco/src/browser/monaco-editor-provider.ts +++ b/packages/monaco/src/browser/monaco-editor-provider.ts @@ -424,7 +424,7 @@ export class MonacoEditorProvider { fixedOverflowWidgets: true, minimap: { enabled: false }, renderSideBySide: false, - readOnly: true, + readOnly: false, renderIndicators: false, diffAlgorithm: 'advanced', stickyScroll: { enabled: false }, diff --git a/packages/scm/src/browser/decorations/scm-decorations-service.ts b/packages/scm/src/browser/decorations/scm-decorations-service.ts index c597dd91385dc..0f85365964181 100644 --- a/packages/scm/src/browser/decorations/scm-decorations-service.ts +++ b/packages/scm/src/browser/decorations/scm-decorations-service.ts @@ -80,7 +80,7 @@ export class ScmDecorationsService { const previousLines = ContentLines.fromString(previousContent); const currentLines = ContentLines.fromTextEditorDocument(editor.document); const dirtyDiff = this.diffComputer.computeDirtyDiff(ContentLines.arrayLike(previousLines), ContentLines.arrayLike(currentLines)); - const update = { editor, previousRevisionUri: uri, ...dirtyDiff }; + const update = { editor, previousRevisionUri: uri, ...dirtyDiff } satisfies DirtyDiffUpdate; this.decorator.applyDecorations(update); this.onDirtyDiffUpdateEmitter.fire(update); } finally { diff --git a/packages/scm/src/browser/dirty-diff/dirty-diff-navigator.ts b/packages/scm/src/browser/dirty-diff/dirty-diff-navigator.ts index a3a24f037fefc..9c506a7e688f5 100644 --- a/packages/scm/src/browser/dirty-diff/dirty-diff-navigator.ts +++ b/packages/scm/src/browser/dirty-diff/dirty-diff-navigator.ts @@ -136,6 +136,9 @@ export class DirtyDiffController implements Disposable { handleDirtyDiffUpdate(dirtyDiff: DirtyDiffUpdate): void { if (dirtyDiff.editor === this.editor) { this.dirtyDiff = dirtyDiff; + if (this.widget) { + this.widget.changes = dirtyDiff.changes ; + } } } @@ -209,7 +212,8 @@ export class DirtyDiffController implements Disposable { protected createWidget(): DirtyDiffWidget | undefined { const { widgetFactory, editor, changes, previousRevisionUri } = this; if (widgetFactory && editor instanceof MonacoEditor && changes?.length && previousRevisionUri) { - const widget = widgetFactory({ editor, previousRevisionUri, changes }); + const widget = widgetFactory({ editor, previousRevisionUri }); + widget.changes = changes; widget.onDidClose(() => { this.widget = undefined; }); diff --git a/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts b/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts index 28afe8b93a7ae..9d78b3e4dc10d 100644 --- a/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts +++ b/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts @@ -36,7 +36,6 @@ export const DirtyDiffWidgetProps = Symbol('DirtyDiffWidgetProps'); export interface DirtyDiffWidgetProps { readonly editor: MonacoEditor; readonly previousRevisionUri: URI; - readonly changes: readonly Change[]; } export const DirtyDiffWidgetFactory = Symbol('DirtyDiffWidgetFactory'); @@ -50,6 +49,7 @@ export class DirtyDiffWidget implements Disposable { protected index: number = -1; private peekView: DirtyDiffPeekView; private diffEditorPromise: Promise; + protected _changes?: readonly Change[]; constructor( @inject(DirtyDiffWidgetProps) protected readonly props: DirtyDiffWidgetProps, @@ -66,6 +66,14 @@ export class DirtyDiffWidget implements Disposable { this.diffEditorPromise = this.peekView.create(); } + get changes(): readonly Change[] { + return this._changes ?? []; + } + + set changes(changes: readonly Change[]) { + this.handleChangedChanges(changes); + } + get editor(): MonacoEditor { return this.props.editor; } @@ -78,10 +86,6 @@ export class DirtyDiffWidget implements Disposable { return this.props.previousRevisionUri; } - get changes(): readonly Change[] { - return this.props.changes; - } - get currentChange(): Change | undefined { return this.changes[this.index]; } @@ -90,6 +94,16 @@ export class DirtyDiffWidget implements Disposable { return this.index; } + protected handleChangedChanges(updated: readonly Change[]): void { + if (this.currentChange) { + const {previousRange: {start, end}} = this.currentChange; + this.index = updated.findIndex(candidate => candidate.previousRange.start === start && candidate.previousRange.end === end); + } else { + this.index = -1; + } + this._changes = updated; + } + async showChange(index: number): Promise { await this.checkCreated(); if (index >= 0 && index < this.changes.length) { @@ -99,23 +113,20 @@ export class DirtyDiffWidget implements Disposable { } async showNextChange(): Promise { - await this.checkCreated(); - const index = this.index; - const length = this.changes.length; - if (length > 0 && (index < 0 || length > 1)) { - this.index = index < 0 ? 0 : cycle(index, 1, length); - this.showCurrentChange(); - } + const editor = await this.checkCreated(); + editor.diffNavigator.next(); + this.updateIndex(editor); } async showPreviousChange(): Promise { - await this.checkCreated(); - const index = this.index; - const length = this.changes.length; - if (length > 0 && (index < 0 || length > 1)) { - this.index = index < 0 ? length - 1 : cycle(index, -1, length); - this.showCurrentChange(); - } + const editor = await this.checkCreated(); + editor.diffNavigator.previous(); + this.updateIndex(editor); + } + + protected updateIndex(editor: MonacoDiffEditor): void { + const line = editor.cursor.line; + this.index = this.changes.findIndex(candidate => candidate.currentRange.start <= line && candidate.currentRange.end >= line); } async getContentWithSelectedChanges(predicate: (change: Change, index: number, changes: readonly Change[]) => boolean): Promise { @@ -174,15 +185,11 @@ export class DirtyDiffWidget implements Disposable { return Math.min(changeHeightInLines + /* padding */ 8, Math.floor(editorHeightInLines / 3)); } - protected async checkCreated(): Promise { - await this.diffEditorPromise; + protected async checkCreated(): Promise { + return this.diffEditorPromise; } } -function cycle(index: number, offset: -1 | 1, length: number): number { - return (index + offset + length) % length; -} - // adapted from https://github.com/microsoft/vscode/blob/823d54f86ee13eb357bc6e8e562e89d793f3c43b/extensions/git/src/staging.ts function applyChanges(changes: readonly Change[], original: monaco.editor.ITextModel, modified: monaco.editor.ITextModel): string { const result: string[] = []; diff --git a/packages/scm/src/browser/scm-contribution.ts b/packages/scm/src/browser/scm-contribution.ts index bcc0b5fbd4964..20fc130ce3773 100644 --- a/packages/scm/src/browser/scm-contribution.ts +++ b/packages/scm/src/browser/scm-contribution.ts @@ -204,6 +204,7 @@ export class ScmContribution extends AbstractViewContribution impleme execute: widget => { if (widget instanceof EditorWidget && widget.editor instanceof MonacoDiffEditor) { widget.editor.diffNavigator.next(); + widget.activate(); } else { this.dirtyDiffNavigator.gotoNextChange(); } @@ -219,6 +220,7 @@ export class ScmContribution extends AbstractViewContribution impleme execute: widget => { if (widget instanceof EditorWidget && widget.editor instanceof MonacoDiffEditor) { widget.editor.diffNavigator.previous(); + widget.activate(); } else { this.dirtyDiffNavigator.gotoPreviousChange(); } From a34ea4f33ccaef12c20ac036fed926880ee5a9fa Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Wed, 26 Mar 2025 09:40:23 -0600 Subject: [PATCH 11/11] Update headings; jump in main editor; close if no changes --- .../browser/dirty-diff/dirty-diff-widget.ts | 56 +++++++++++++------ 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts b/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts index 9d78b3e4dc10d..ca3c73f4d865d 100644 --- a/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts +++ b/packages/scm/src/browser/dirty-diff/dirty-diff-widget.ts @@ -95,13 +95,24 @@ export class DirtyDiffWidget implements Disposable { } protected handleChangedChanges(updated: readonly Change[]): void { + if (!updated.length) { + return this.dispose(); + } if (this.currentChange) { - const {previousRange: {start, end}} = this.currentChange; - this.index = updated.findIndex(candidate => candidate.previousRange.start === start && candidate.previousRange.end === end); + const { previousRange: { start, end } } = this.currentChange; + // Same change or first after it. + const newIndex = updated.findIndex(candidate => candidate.previousRange.start === start && candidate.previousRange.end === end || candidate.previousRange.start > start); + if (newIndex !== -1) { + this.index = newIndex; + } else { + this.index = Math.min(this.index, updated.length - 1); + } + this.showCurrentChange(); } else { this.index = -1; } this._changes = updated; + this.updateHeading(); } async showChange(index: number): Promise { @@ -112,21 +123,24 @@ export class DirtyDiffWidget implements Disposable { } } - async showNextChange(): Promise { - const editor = await this.checkCreated(); - editor.diffNavigator.next(); - this.updateIndex(editor); - } - - async showPreviousChange(): Promise { - const editor = await this.checkCreated(); - editor.diffNavigator.previous(); - this.updateIndex(editor); + showNextChange(): void { + this.checkCreated(); + const index = this.index; + const length = this.changes.length; + if (length > 0 && (index < 0 || length > 1)) { + this.index = index < 0 ? 0 : cycle(index, 1, length); + this.showCurrentChange(); + } } - protected updateIndex(editor: MonacoDiffEditor): void { - const line = editor.cursor.line; - this.index = this.changes.findIndex(candidate => candidate.currentRange.start <= line && candidate.currentRange.end >= line); + showPreviousChange(): void { + this.checkCreated(); + const index = this.index; + const length = this.changes.length; + if (length > 0 && (index < 0 || length > 1)) { + this.index = index < 0 ? length - 1 : cycle(index, -1, length); + this.showCurrentChange(); + } } async getContentWithSelectedChanges(predicate: (change: Change, index: number, changes: readonly Change[]) => boolean): Promise { @@ -143,7 +157,7 @@ export class DirtyDiffWidget implements Disposable { } protected showCurrentChange(): void { - this.peekView!.setTitle(this.computePrimaryHeading(), this.computeSecondaryHeading()); + this.updateHeading(); const { previousRange, currentRange } = this.changes[this.index]; this.peekView.show(Position.create(LineRange.getEndPosition(currentRange).line, 0), this.computeHeightInLines()); @@ -162,6 +176,10 @@ export class DirtyDiffWidget implements Disposable { this.editor.focus(); } + protected updateHeading(): void { + this.peekView.setTitle(this.computePrimaryHeading(), this.computeSecondaryHeading()); + } + protected computePrimaryHeading(): string { return this.uri.path.base; } @@ -190,6 +208,10 @@ export class DirtyDiffWidget implements Disposable { } } +function cycle(index: number, offset: -1 | 1, length: number): number { + return (index + offset + length) % length; +} + // adapted from https://github.com/microsoft/vscode/blob/823d54f86ee13eb357bc6e8e562e89d793f3c43b/extensions/git/src/staging.ts function applyChanges(changes: readonly Change[], original: monaco.editor.ITextModel, modified: monaco.editor.ITextModel): string { const result: string[] = []; @@ -325,7 +347,7 @@ class DirtyDiffPeekView extends MonacoEditorPeekViewWidget { // Close editor on successful contributed action. // https://github.com/microsoft/vscode/blob/11b1500e0a2e8b5ba12e98a3905f9d120b8646a0/src/vs/workbench/contrib/scm/browser/quickDiffWidget.ts#L356-L361 this.addAction(id, label, icon, menuCommandExecutor.isEnabled(menuPath, command, this.widget), () => { - menuCommandExecutor.executeCommand(menuPath, command, this.widget).then(() => this.dispose()); + menuCommandExecutor.executeCommand(menuPath, command, this.widget).then(() => this.dispose()); }); } }