deploy: current vibn theia state
Some checks failed
Playwright Tests / Playwright Tests (ubuntu-22.04, Node.js 22.x) (push) Has been cancelled
3PP License Check / 3PP License Check (11, 22.x, ubuntu-22.04) (push) Has been cancelled
Publish packages to NPM / Perform Publishing (push) Has been cancelled

Made-with: Cursor
This commit is contained in:
2026-02-27 12:01:08 -08:00
commit 8bb5110148
3782 changed files with 640947 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [
'../../configs/build.eslintrc.json'
],
parserOptions: {
tsconfigRootDir: __dirname,
project: 'tsconfig.json'
}
};

View File

@@ -0,0 +1,67 @@
<div align='center'>
<br />
<img src='https://raw.githubusercontent.com/eclipse-theia/theia/master/logo/theia.svg?sanitize=true' alt='theia-ext-logo' width='100px' />
<h2>ECLIPSE THEIA - AI CHAT UI EXTENSION</h2>
<hr />
</div>
## Description
The `@theia/ai-chat-ui` extension contributes the `AI Chat` view.\
The `AI Chat view` can be used to easily communicate with a language model.
It is based on `@theia/ai-chat`.
## Custom Tool Renderers
To create a specialized renderer for a specific tool, implement the `ChatResponsePartRenderer` interface with a higher priority than the default `ToolCallPartRenderer` (priority `10`):
```typescript
@injectable()
export class MyToolRenderer implements ChatResponsePartRenderer<ToolCallChatResponseContent> {
canHandle(response: ChatResponseContent): number {
if (ToolCallChatResponseContent.is(response) && response.name === 'my_tool_id') {
return 15;
}
return -1;
}
render(response: ToolCallChatResponseContent, parentNode: ResponseNode): ReactNode {
// Custom rendering logic
}
}
```
For custom confirmation UIs, use the `ToolConfirmationActions` component to reuse the standard Allow/Deny buttons with dropdown options:
```typescript
import { ToolConfirmationActions } from '@theia/ai-chat-ui/lib/browser/chat-response-renderer/tool-confirmation';
<ToolConfirmationActions
toolName="my_tool"
toolRequest={toolRequest}
onAllow={(mode) => response.confirm()}
onDeny={(mode) => response.deny()}
/>
```
## Additional Information
- [API documentation for `@theia/ai-chat-ui`](https://eclipse-theia.github.io/theia/docs/next/modules/_theia_ai-chat-ui.html)
- [Theia - GitHub](https://github.com/eclipse-theia/theia)
- [Theia - Website](https://theia-ide.org/)
## License
- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/)
- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp)
## Trademark
"Theia" is a trademark of the Eclipse Foundation
<https://www.eclipse.org/theia>

View File

@@ -0,0 +1,60 @@
{
"name": "@theia/ai-chat-ui",
"version": "1.68.0",
"description": "Theia - AI Chat UI Extension",
"dependencies": {
"@theia/ai-chat": "1.68.0",
"@theia/ai-core": "1.68.0",
"@theia/core": "1.68.0",
"@theia/editor": "1.68.0",
"@theia/editor-preview": "1.68.0",
"@theia/filesystem": "1.68.0",
"@theia/monaco": "1.68.0",
"@theia/monaco-editor-core": "1.96.302",
"@theia/preferences": "1.68.0",
"@theia/workspace": "1.68.0",
"tslib": "^2.6.2",
"uuid": "^9.0.1",
"date-fns": "^4.1.0"
},
"publishConfig": {
"access": "public"
},
"theiaExtensions": [
{
"frontend": "lib/browser/ai-chat-ui-frontend-module",
"secondaryWindow": "lib/browser/ai-chat-ui-frontend-module"
}
],
"keywords": [
"theia-extension"
],
"license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0",
"repository": {
"type": "git",
"url": "https://github.com/eclipse-theia/theia.git"
},
"bugs": {
"url": "https://github.com/eclipse-theia/theia/issues"
},
"homepage": "https://github.com/eclipse-theia/theia",
"files": [
"lib",
"src"
],
"scripts": {
"build": "theiaext build",
"clean": "theiaext clean",
"compile": "theiaext compile",
"lint": "theiaext lint",
"test": "theiaext test",
"watch": "theiaext watch"
},
"devDependencies": {
"@theia/ext-scripts": "1.68.0"
},
"nyc": {
"extends": "../../configs/nyc.json"
},
"gitHead": "21358137e41342742707f660b8e222f940a27652"
}

View File

@@ -0,0 +1,605 @@
// *****************************************************************************
// Copyright (C) 2024 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 { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify';
import { CommandRegistry, Emitter, isOSX, MessageService, nls, PreferenceService, QuickInputButton, QuickInputService, QuickPickItem } from '@theia/core';
import { ILogger } from '@theia/core/lib/common/logger';
import { FrontendApplicationContribution, Widget } from '@theia/core/lib/browser';
import {
AI_CHAT_NEW_CHAT_WINDOW_COMMAND,
AI_CHAT_SHOW_CHATS_COMMAND,
ChatCommands
} from './chat-view-commands';
import { ChatAgent, ChatAgentLocation, ChatService, isActiveSessionChangedEvent } from '@theia/ai-chat';
import { ChatAgentService } from '@theia/ai-chat/lib/common/chat-agent-service';
import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
import { AbstractViewContribution } from '@theia/core/lib/browser/shell/view-contribution';
import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { ChatViewWidget } from './chat-view-widget';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { SecondaryWindowHandler } from '@theia/core/lib/browser/secondary-window-handler';
import { formatDistance } from 'date-fns';
import * as locales from 'date-fns/locale';
import { AI_SHOW_SETTINGS_COMMAND, AIActivationService, ENABLE_AI_CONTEXT_KEY } from '@theia/ai-core/lib/browser';
import { ChatNodeToolbarCommands } from './chat-node-toolbar-action-contribution';
import { isEditableRequestNode, isResponseNode, type EditableRequestNode, type ResponseNode } from './chat-tree-view';
import { TASK_CONTEXT_VARIABLE } from '@theia/ai-chat/lib/browser/task-context-variable';
import { TaskContextService } from '@theia/ai-chat/lib/browser/task-context-service';
import { SESSION_STORAGE_PREF } from '@theia/ai-chat/lib/common/ai-chat-preferences';
export const AI_CHAT_TOGGLE_COMMAND_ID = 'aiChat:toggle';
@injectable()
export class AIChatContribution extends AbstractViewContribution<ChatViewWidget> implements TabBarToolbarContribution, FrontendApplicationContribution {
@inject(ChatService)
protected readonly chatService: ChatService;
@inject(QuickInputService)
protected readonly quickInputService: QuickInputService;
@inject(TaskContextService)
protected readonly taskContextService: TaskContextService;
@inject(MessageService)
protected readonly messageService: MessageService;
@inject(ChatAgentService)
protected readonly chatAgentService: ChatAgentService;
@inject(EditorManager)
protected readonly editorManager: EditorManager;
@inject(AIActivationService)
protected readonly activationService: AIActivationService;
@inject(ILogger) @named('AIChatContribution')
protected readonly logger: ILogger;
@inject(PreferenceService)
protected readonly preferenceService: PreferenceService;
/**
* Store whether there are persisted sessions to make this information available in
* command enablement checks which are synchronous.
*/
protected hasPersistedSessions = false;
protected static readonly RENAME_CHAT_BUTTON: QuickInputButton = {
iconClass: 'codicon-edit',
tooltip: nls.localize('theia/ai/chat-ui/renameChat', 'Rename Chat'),
};
protected static readonly REMOVE_CHAT_BUTTON: QuickInputButton = {
iconClass: 'codicon-remove-close',
tooltip: nls.localize('theia/ai/chat-ui/removeChat', 'Remove Chat'),
};
@inject(SecondaryWindowHandler)
protected readonly secondaryWindowHandler: SecondaryWindowHandler;
constructor() {
super({
widgetId: ChatViewWidget.ID,
widgetName: ChatViewWidget.LABEL,
defaultWidgetOptions: {
area: 'right',
rank: 100
},
toggleCommandId: AI_CHAT_TOGGLE_COMMAND_ID,
toggleKeybinding: isOSX ? 'ctrl+cmd+i' : 'ctrl+alt+i'
});
}
@postConstruct()
initialize(): void {
this.chatService.onSessionEvent(event => {
if (!isActiveSessionChangedEvent(event)) {
return;
}
if (event.focus) {
this.openView({ activate: true });
}
});
// Re-check persisted sessions when storage preferences change
this.preferenceService.onPreferenceChanged(event => {
if (event.preferenceName === SESSION_STORAGE_PREF) {
this.checkPersistedSessions();
}
});
this.checkPersistedSessions();
}
async onStart(): Promise<void> {
// Auto-open AI Chat panel on startup
await this.openView({ activate: false, reveal: true });
}
protected async checkPersistedSessions(): Promise<void> {
try {
this.hasPersistedSessions = await this.chatService.hasPersistedSessions();
} catch (e) {
this.logger.error('Failed to check persisted AI sessions', e);
this.hasPersistedSessions = false;
}
}
override registerCommands(registry: CommandRegistry): void {
super.registerCommands(registry);
registry.registerCommand(ChatCommands.SCROLL_LOCK_WIDGET, {
isEnabled: widget => this.withWidget(widget, chatWidget => !chatWidget.isLocked),
isVisible: widget => this.withWidget(widget, chatWidget => !chatWidget.isLocked),
execute: widget => this.withWidget(widget, chatWidget => {
chatWidget.lock();
return true;
})
});
registry.registerCommand(ChatCommands.SCROLL_UNLOCK_WIDGET, {
isEnabled: widget => this.withWidget(widget, chatWidget => chatWidget.isLocked),
isVisible: widget => this.withWidget(widget, chatWidget => chatWidget.isLocked),
execute: widget => this.withWidget(widget, chatWidget => {
chatWidget.unlock();
return true;
})
});
registry.registerCommand(AI_CHAT_NEW_CHAT_WINDOW_COMMAND, {
execute: () => this.openView().then(() => this.chatService.createSession(ChatAgentLocation.Panel, { focus: true })),
isVisible: widget => this.activationService.isActive,
isEnabled: widget => this.activationService.isActive,
});
registry.registerCommand(ChatCommands.AI_CHAT_NEW_WITH_TASK_CONTEXT, {
execute: async () => {
const activeSession = this.chatService.getActiveSession();
const id = await this.summarizeActiveSession();
if (!id || !activeSession) { return; }
const newSession = this.chatService.createSession(ChatAgentLocation.Panel, { focus: true }, activeSession.pinnedAgent);
const summaryVariable = { variable: TASK_CONTEXT_VARIABLE, arg: id };
newSession.model.context.addVariables(summaryVariable);
},
isVisible: () => false
});
registry.registerCommand(ChatCommands.AI_CHAT_SUMMARIZE_CURRENT_SESSION, {
execute: async () => this.summarizeActiveSession(),
isVisible: widget => {
if (!this.activationService.isActive) { return false; }
if (widget && !this.withWidget(widget)) { return false; }
const activeSession = this.chatService.getActiveSession();
return activeSession?.model.location === ChatAgentLocation.Panel
&& !this.taskContextService.hasSummary(activeSession);
},
isEnabled: widget => {
if (!this.activationService.isActive) { return false; }
if (widget && !this.withWidget(widget)) { return false; }
const activeSession = this.chatService.getActiveSession();
return activeSession?.model.location === ChatAgentLocation.Panel
&& !activeSession.model.isEmpty()
&& !this.taskContextService.hasSummary(activeSession);
}
});
registry.registerCommand(ChatCommands.AI_CHAT_OPEN_SUMMARY_FOR_CURRENT_SESSION, {
execute: async () => {
const id = await this.summarizeActiveSession();
if (!id) { return; }
await this.taskContextService.open(id);
},
isVisible: widget => {
if (!this.activationService.isActive) { return false; }
if (widget && !this.withWidget(widget)) { return false; }
const activeSession = this.chatService.getActiveSession();
return !!activeSession && this.taskContextService.hasSummary(activeSession);
},
isEnabled: widget => {
if (!this.activationService.isActive) { return false; }
return this.withWidget(widget, () => true);
}
});
registry.registerCommand(ChatCommands.AI_CHAT_INITIATE_SESSION_WITH_TASK_CONTEXT, {
execute: async () => {
const selectedContextId = await this.selectTaskContextWithMarking();
if (!selectedContextId) { return; }
const selectedAgent = await this.selectAgent('Coder');
if (!selectedAgent) { return; }
const newSession = this.chatService.createSession(ChatAgentLocation.Panel, { focus: true }, selectedAgent);
newSession.model.context.addVariables({ variable: TASK_CONTEXT_VARIABLE, arg: selectedContextId });
},
isVisible: () => this.activationService.isActive,
isEnabled: () => this.activationService.isActive
});
registry.registerCommand(AI_CHAT_SHOW_CHATS_COMMAND, {
execute: async () => {
await this.openView();
return this.selectChat();
},
isEnabled: () => {
if (!this.activationService.isActive) {
return false;
}
// Enable if there are active sessions with titles OR persisted sessions
return this.chatService.getSessions().some(session => !!session.title) || this.hasPersistedSessions;
},
isVisible: () => this.activationService.isActive
});
registry.registerCommand(ChatNodeToolbarCommands.EDIT, {
isEnabled: node => isEditableRequestNode(node) && !node.request.isEditing,
isVisible: node => isEditableRequestNode(node) && !node.request.isEditing,
execute: (node: EditableRequestNode) => {
node.request.enableEdit();
}
});
registry.registerCommand(ChatNodeToolbarCommands.CANCEL, {
isEnabled: node => isEditableRequestNode(node) && node.request.isEditing,
isVisible: node => isEditableRequestNode(node) && node.request.isEditing,
execute: (node: EditableRequestNode) => {
node.request.cancelEdit();
}
});
registry.registerCommand(ChatNodeToolbarCommands.RETRY, {
isEnabled: node => isResponseNode(node) && (node.response.isError || node.response.isCanceled),
isVisible: node => isResponseNode(node) && (node.response.isError || node.response.isCanceled),
execute: async (node: ResponseNode) => {
try {
// Get the session for this response node
const session = this.chatService.getActiveSession();
if (!session) {
this.messageService.error(nls.localize('theia/ai/chat-ui/sessionNotFoundForRetry', 'Session not found for retry'));
return;
}
// Find the request associated with this response
const request = session.model.getRequests().find(req => req.response.id === node.response.id);
if (!request) {
this.messageService.error(nls.localize('theia/ai/chat-ui/requestNotFoundForRetry', 'Request not found for retry'));
return;
}
// Send the same request again using the chat service
await this.chatService.sendRequest(node.sessionId, request.request);
} catch (error) {
console.error('Failed to retry chat message:', error);
this.messageService.error(nls.localize('theia/ai/chat-ui/failedToRetry', 'Failed to retry message'));
}
}
});
}
registerToolbarItems(registry: TabBarToolbarRegistry): void {
registry.registerItem({
id: AI_CHAT_NEW_CHAT_WINDOW_COMMAND.id,
command: AI_CHAT_NEW_CHAT_WINDOW_COMMAND.id,
tooltip: AI_CHAT_NEW_CHAT_WINDOW_COMMAND.label,
isVisible: widget => this.activationService.isActive && this.withWidget(widget),
when: ENABLE_AI_CONTEXT_KEY
});
registry.registerItem({
id: AI_CHAT_SHOW_CHATS_COMMAND.id,
command: AI_CHAT_SHOW_CHATS_COMMAND.id,
tooltip: AI_CHAT_SHOW_CHATS_COMMAND.label,
isVisible: widget => this.activationService.isActive && this.withWidget(widget),
when: ENABLE_AI_CONTEXT_KEY
});
registry.registerItem({
id: 'chat-view.' + AI_SHOW_SETTINGS_COMMAND.id,
command: AI_SHOW_SETTINGS_COMMAND.id,
group: 'ai-settings',
priority: 3,
tooltip: nls.localize('theia/ai-chat-ui/open-settings-tooltip', 'Open AI settings...'),
isVisible: widget => this.activationService.isActive && this.withWidget(widget),
when: ENABLE_AI_CONTEXT_KEY
});
const sessionSummarizibilityChangedEmitter = new Emitter<void>();
this.taskContextService.onDidChange(() => sessionSummarizibilityChangedEmitter.fire());
this.chatService.onSessionEvent(event => event.type === 'activeChange' && sessionSummarizibilityChangedEmitter.fire());
this.activationService.onDidChangeActiveStatus(() => sessionSummarizibilityChangedEmitter.fire());
registry.registerItem({
id: 'chat-view.' + ChatCommands.AI_CHAT_SUMMARIZE_CURRENT_SESSION.id,
command: ChatCommands.AI_CHAT_SUMMARIZE_CURRENT_SESSION.id,
onDidChange: sessionSummarizibilityChangedEmitter.event,
when: ENABLE_AI_CONTEXT_KEY
});
registry.registerItem({
id: 'chat-view.' + ChatCommands.AI_CHAT_OPEN_SUMMARY_FOR_CURRENT_SESSION.id,
command: ChatCommands.AI_CHAT_OPEN_SUMMARY_FOR_CURRENT_SESSION.id,
onDidChange: sessionSummarizibilityChangedEmitter.event,
when: ENABLE_AI_CONTEXT_KEY
});
}
protected async selectChat(sessionId?: string): Promise<void> {
let activeSessionId = sessionId;
if (!activeSessionId) {
const item = await this.askForChatSession();
if (item === undefined) {
return;
}
activeSessionId = item.id;
}
this.chatService.setActiveSession(activeSessionId!, { focus: true });
}
protected async askForChatSession(): Promise<QuickPickItem | undefined> {
const getItems = async (): Promise<QuickPickItem[]> => {
const activeSessions = this.chatService.getSessions()
.filter(session => session.title)
.map(session => ({
session,
isActive: true,
lastDate: session.lastInteraction ? session.lastInteraction.getTime() : 0
}));
// Try to load persisted sessions, but don't fail if it doesn't work
let persistedSessions: Array<{ metadata: { sessionId: string; title: string; saveDate: number }; isActive: false; lastDate: number }> = [];
try {
const persistedIndex = await this.chatService.getPersistedSessions();
const activeIds = new Set(activeSessions.map(s => s.session.id));
persistedSessions = Object.values(persistedIndex)
.filter(metadata => !activeIds.has(metadata.sessionId))
.map(metadata => ({
metadata,
isActive: false,
lastDate: metadata.saveDate
}));
} catch (error) {
this.logger.error('Failed to load persisted sessions, showing only active sessions', error);
// Continue with just active sessions
}
// Combine and sort by last interaction/message date
const allSessions = [
...activeSessions.map(s => ({
isActive: true,
id: s.session.id,
title: s.session.title!,
lastDate: s.lastDate,
firstRequestText: s.session.model.getRequests().at(0)?.request.text
})),
...persistedSessions.map(s => ({
isActive: false,
id: s.metadata.sessionId,
title: s.metadata.title,
lastDate: s.lastDate,
firstRequestText: undefined
}))
].sort((a, b) => b.lastDate - a.lastDate);
return allSessions.map(session => {
// Add icon for persisted sessions to visually distinguish them
const icon = session.isActive ? '' : '$(archive) ';
const label = `${icon}${session.title}`;
return <QuickPickItem>({
label,
description: formatDistance(new Date(session.lastDate), new Date(), { addSuffix: false, locale: getDateFnsLocale() }),
detail: session.firstRequestText || (session.isActive ? undefined : nls.localize('theia/ai/chat-ui/persistedSession', 'Persisted session (click to restore)')),
id: session.id,
buttons: [AIChatContribution.RENAME_CHAT_BUTTON, AIChatContribution.REMOVE_CHAT_BUTTON]
});
});
};
const defer = new Deferred<QuickPickItem | undefined>();
const quickPick = this.quickInputService.createQuickPick();
quickPick.placeholder = nls.localize('theia/ai/chat-ui/selectChat', 'Select chat');
quickPick.canSelectMany = false;
quickPick.busy = true;
quickPick.show();
// Load items asynchronously
getItems().then(items => {
quickPick.items = items;
quickPick.busy = false;
}).catch(error => {
this.logger.error('Failed to load chat sessions', error);
quickPick.busy = false;
quickPick.placeholder = nls.localize('theia/ai/chat-ui/failedToLoadChats', 'Failed to load chat sessions');
});
quickPick.onDidTriggerItemButton(async context => {
if (context.button === AIChatContribution.RENAME_CHAT_BUTTON) {
quickPick.hide();
this.quickInputService.input({
placeHolder: nls.localize('theia/ai/chat-ui/enterChatName', 'Enter chat name')
}).then(name => {
if (name && name.length > 0) {
const session = this.chatService.getSession(context.item.id!);
if (session) {
session.title = name;
}
}
});
} else if (context.button === AIChatContribution.REMOVE_CHAT_BUTTON) {
const activeSession = this.chatService.getActiveSession();
// Wait for deletion to complete before refreshing the list
this.chatService.deleteSession(context.item.id!).then(() => getItems()).then(items => {
quickPick.items = items;
if (items.length === 0) {
quickPick.hide();
}
// Update persisted sessions flag after deletion
this.checkPersistedSessions();
if (activeSession && activeSession.id === context.item.id) {
this.chatService.createSession(ChatAgentLocation.Panel, {
// Auto-focus only when the quick pick is no longer visible
focus: items.length === 0
});
}
}).catch(error => {
this.logger.error('Failed to delete chat session', error);
this.messageService.error(nls.localize('theia/ai/chat-ui/failedToDeleteSession', 'Failed to delete chat session'));
});
}
});
quickPick.onDidAccept(async () => {
const selectedItem = quickPick.selectedItems[0];
if (selectedItem) {
// Restore session if not already loaded
const session = this.chatService.getSession(selectedItem.id!);
if (!session) {
try {
await this.chatService.getOrRestoreSession(selectedItem.id!);
// Update persisted sessions flag after restoration
this.checkPersistedSessions();
} catch (error) {
this.logger.error('Failed to restore chat session', error);
this.messageService.error(nls.localize('theia/ai/chat-ui/failedToRestoreSession', 'Failed to restore chat session'));
defer.resolve(undefined);
quickPick.hide();
return;
}
}
}
defer.resolve(selectedItem);
quickPick.hide();
});
quickPick.onDidHide(() => defer.resolve(undefined));
return defer.promise;
}
protected withWidget(
widget: Widget | undefined = this.tryGetWidget(),
predicate: (output: ChatViewWidget) => boolean = () => true
): boolean | false {
return widget instanceof ChatViewWidget ? predicate(widget) : false;
}
protected extractChatView(chatView: ChatViewWidget): void {
this.secondaryWindowHandler.moveWidgetToSecondaryWindow(chatView);
}
canExtractChatView(chatView: ChatViewWidget): boolean {
return !chatView.secondaryWindow;
}
protected async summarizeActiveSession(): Promise<string | undefined> {
const activeSession = this.chatService.getActiveSession();
if (!activeSession) { return; }
return this.taskContextService.summarize(activeSession).catch(err => {
console.warn('Error while summarizing session:', err);
this.messageService.error(nls.localize('theia/ai/chat-ui/unableToSummarizeCurrentSession',
'Unable to summarize current session. Please confirm that the summary agent is not disabled.'));
return undefined;
});
}
/**
* Prompts the user to select a chat agent
* @returns The selected agent or undefined if cancelled
*/
/**
* Prompts the user to select a chat agent with an optional default (pre-selected) agent.
* @param defaultAgentId The id of the agent to pre-select, if present
* @returns The selected agent or undefined if cancelled
*/
protected async selectAgent(defaultAgentId?: string): Promise<ChatAgent | undefined> {
const agents = this.chatAgentService.getAgents();
if (agents.length === 0) {
this.messageService.warn(nls.localize('theia/ai/chat-ui/noChatAgentsAvailable', 'No chat agents available.'));
return undefined;
}
const items: QuickPickItem[] = agents.map(agent => ({
label: agent.name || agent.id,
description: agent.description,
id: agent.id
}));
let preselected: QuickPickItem | undefined = undefined;
if (defaultAgentId) {
preselected = items.find(item => item.id === defaultAgentId);
}
const selected = await this.quickInputService.showQuickPick(items, {
placeholder: nls.localize('theia/ai/chat-ui/selectAgentQuickPickPlaceholder', 'Select an agent for the new session'),
activeItem: preselected
});
if (!selected) {
return undefined;
}
return this.chatAgentService.getAgent(selected.id!);
}
/**
* Prompts the user to select a task context with special marking for currently opened files
* @returns The selected task context ID or undefined if cancelled
*/
protected async selectTaskContextWithMarking(): Promise<string | undefined> {
const contexts = this.taskContextService.getAll();
const openedFilesInfo = this.getOpenedTaskContextFiles();
// Create items with opened files marked and prioritized
const items: QuickPickItem[] = contexts.map(summary => {
const isOpened = openedFilesInfo.openedIds.includes(summary.id);
const isActive = openedFilesInfo.activeId === summary.id;
return {
label: isOpened ? `📄 ${summary.label} (${nls.localize('theia/ai/chat-ui/selectTaskContextQuickPickItem/currentlyOpen', 'currently open')})` : summary.label,
description: summary.id,
id: summary.id,
// We'll sort active file first, then opened files, then others
sortText: isActive ? `0-${summary.label}` : isOpened ? `1-${summary.label}` : `2-${summary.label}`
};
}).sort((a, b) => a.sortText!.localeCompare(b.sortText!));
const selected = await this.quickInputService.showQuickPick(items, {
placeholder: nls.localize('theia/ai/chat-ui/selectTaskContextQuickPickPlaceholder', 'Select a task context to attach')
});
return selected?.id;
}
/**
* Returns information about task context files that are currently opened
* @returns Object with arrays of opened context IDs and the active context ID
*/
protected getOpenedTaskContextFiles(): { openedIds: string[], activeId?: string } {
// Get all contexts with their URIs
const allContexts = this.taskContextService.getAll();
const contextMap = new Map<string, string>(); // Map of URI -> ID
// Create a map of URI string -> context ID for lookup
for (const context of allContexts) {
if (context.uri) {
contextMap.set(context.uri.toString(), context.id);
}
}
// Get all open editor URIs
const openEditorUris = this.editorManager.all.map(widget => widget.editor.uri.toString());
// Get the currently active/focused editor URI if any
const activeEditorUri = this.editorManager.currentEditor?.editor.uri.toString();
let activeContextId: string | undefined;
if (activeEditorUri) {
activeContextId = contextMap.get(activeEditorUri);
}
// Filter to only task context files that are currently opened
const openedContextIds: string[] = [];
for (const uri of openEditorUris) {
const contextId = contextMap.get(uri);
if (contextId) {
openedContextIds.push(contextId);
}
}
return { openedIds: openedContextIds, activeId: activeContextId };
}
}
function getDateFnsLocale(): locales.Locale {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return nls.locale ? (locales as any)[nls.locale] ?? locales.enUS : locales.enUS;
}

View File

@@ -0,0 +1,199 @@
// *****************************************************************************
// Copyright (C) 2024 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 '../../src/browser/style/index.css';
import { bindContributionProvider, CommandContribution, MenuContribution } from '@theia/core';
import { bindViewContribution, FrontendApplicationContribution, WidgetFactory, KeybindingContribution } from '@theia/core/lib/browser';
import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { ContainerModule, interfaces } from '@theia/core/shared/inversify';
import { EditorSelectionResolver } from '@theia/editor/lib/browser/editor-manager';
import { AIChatContribution } from './ai-chat-ui-contribution';
import { AIChatInputConfiguration, AIChatInputWidget } from './chat-input-widget';
import { ChatNodeToolbarActionContribution, DefaultChatNodeToolbarActionContribution } from './chat-node-toolbar-action-contribution';
import { ChatResponsePartRenderer } from './chat-response-part-renderer';
import {
CodePartRenderer,
CodePartRendererAction,
CommandPartRenderer,
CopyToClipboardButtonAction,
ErrorPartRenderer,
HorizontalLayoutPartRenderer,
InsertCodeAtCursorButtonAction,
MarkdownPartRenderer,
ToolCallPartRenderer,
NotAvailableToolCallRenderer,
ThinkingPartRenderer,
ProgressPartRenderer,
DelegationResponseRenderer,
TextPartRenderer,
} from './chat-response-renderer';
import { UnknownPartRenderer } from './chat-response-renderer/unknown-part-renderer';
import {
GitHubSelectionResolver,
TextFragmentSelectionResolver,
TypeDocSymbolSelectionResolver,
} from './chat-response-renderer/ai-selection-resolver';
import { QuestionPartRenderer } from './chat-response-renderer/question-part-renderer';
import { createChatViewTreeWidget } from './chat-tree-view';
import { ChatViewTreeWidget } from './chat-tree-view/chat-view-tree-widget';
import { ChatViewMenuContribution } from './chat-view-contribution';
import { ChatViewLanguageContribution } from './chat-view-language-contribution';
import { ChatViewWidget } from './chat-view-widget';
import { ChatViewWidgetToolbarContribution } from './chat-view-widget-toolbar-contribution';
import { ContextVariablePicker } from './context-variable-picker';
import { ChangeSetActionRenderer, ChangeSetActionService } from './change-set-actions/change-set-action-service';
import { ChangeSetAcceptAction } from './change-set-actions/change-set-accept-action';
import { AIChatTreeInputArgs, AIChatTreeInputConfiguration, AIChatTreeInputFactory, AIChatTreeInputWidget } from './chat-tree-view/chat-view-tree-input-widget';
import { SubChatWidget, SubChatWidgetFactory } from './chat-tree-view/sub-chat-widget';
import { ChatInputHistoryService } from './chat-input-history';
import { ChatInputHistoryContribution } from './chat-input-history-contribution';
import { ChatInputModeContribution } from './chat-input-mode-contribution';
import { ChatFocusContribution } from './chat-focus-contribution';
export default new ContainerModule((bind, _unbind, _isBound, rebind) => {
bindViewContribution(bind, AIChatContribution);
bind(TabBarToolbarContribution).toService(AIChatContribution);
bind(FrontendApplicationContribution).toService(AIChatContribution);
bind(ChatInputHistoryService).toSelf().inSingletonScope();
bind(ChatInputHistoryContribution).toSelf().inSingletonScope();
bind(CommandContribution).toService(ChatInputHistoryContribution);
bind(KeybindingContribution).toService(ChatInputHistoryContribution);
bind(ChatInputModeContribution).toSelf().inSingletonScope();
bind(CommandContribution).toService(ChatInputModeContribution);
bind(KeybindingContribution).toService(ChatInputModeContribution);
bind(ChatFocusContribution).toSelf().inSingletonScope();
bind(CommandContribution).toService(ChatFocusContribution);
bind(KeybindingContribution).toService(ChatFocusContribution);
bindContributionProvider(bind, ChatResponsePartRenderer);
bindChatViewWidget(bind);
bind(AIChatInputWidget).toSelf();
bind(AIChatInputConfiguration).toConstantValue({
showContext: true,
showPinnedAgent: true,
showChangeSet: true,
enablePromptHistory: true
} satisfies AIChatInputConfiguration);
bind(WidgetFactory).toDynamicValue(({ container }) => ({
id: AIChatInputWidget.ID,
createWidget: () => container.get(AIChatInputWidget)
})).inSingletonScope();
bind(ChatViewTreeWidget).toDynamicValue(ctx =>
createChatViewTreeWidget(ctx.container)
);
bind(WidgetFactory).toDynamicValue(({ container }) => ({
id: ChatViewTreeWidget.ID,
createWidget: () => container.get(ChatViewTreeWidget)
})).inSingletonScope();
bind(AIChatTreeInputFactory).toFactory(ctx => (args: AIChatTreeInputArgs) => {
const container = ctx.container.createChild();
container.bind(AIChatTreeInputArgs).toConstantValue(args);
container.bind(AIChatTreeInputConfiguration).toConstantValue({
showContext: true,
showPinnedAgent: true,
showChangeSet: false,
showSuggestions: false,
enablePromptHistory: false
} satisfies AIChatInputConfiguration);
container.bind(AIChatTreeInputWidget).toSelf().inSingletonScope();
const widget = container.get(AIChatTreeInputWidget);
const noOp = () => { };
widget.node.classList.add('chat-input-widget');
widget.chatModel = args.node.request.session;
widget.initialValue = args.initialValue;
widget.setEnabled(true);
widget.onQuery = args.onQuery;
// We need to set those values here, otherwise the widget will throw an error
widget.onUnpin = args.onUnpin ?? noOp;
widget.onCancel = args.onCancel ?? noOp;
widget.onDeleteChangeSet = args.onDeleteChangeSet ?? noOp;
widget.onDeleteChangeSetElement = args.onDeleteChangeSetElement ?? noOp;
return widget;
});
bind(ContextVariablePicker).toSelf().inSingletonScope();
bind(ChatResponsePartRenderer).to(HorizontalLayoutPartRenderer).inSingletonScope();
bind(ChatResponsePartRenderer).to(ErrorPartRenderer).inSingletonScope();
bind(ChatResponsePartRenderer).to(MarkdownPartRenderer).inSingletonScope();
bind(ChatResponsePartRenderer).to(CodePartRenderer).inSingletonScope();
bind(ChatResponsePartRenderer).to(CommandPartRenderer).inSingletonScope();
bind(ChatResponsePartRenderer).to(ToolCallPartRenderer).inSingletonScope();
bind(ChatResponsePartRenderer).to(NotAvailableToolCallRenderer).inSingletonScope();
bind(ChatResponsePartRenderer).to(ErrorPartRenderer).inSingletonScope();
bind(ChatResponsePartRenderer).to(ThinkingPartRenderer).inSingletonScope();
bind(ChatResponsePartRenderer).to(QuestionPartRenderer).inSingletonScope();
bind(ChatResponsePartRenderer).to(ProgressPartRenderer).inSingletonScope();
bind(ChatResponsePartRenderer).to(TextPartRenderer).inSingletonScope();
bind(ChatResponsePartRenderer).to(DelegationResponseRenderer).inSingletonScope();
bind(ChatResponsePartRenderer).to(UnknownPartRenderer).inSingletonScope();
[CommandContribution, MenuContribution].forEach(serviceIdentifier =>
bind(serviceIdentifier).to(ChatViewMenuContribution).inSingletonScope()
);
bindContributionProvider(bind, CodePartRendererAction);
bindContributionProvider(bind, ChangeSetActionRenderer);
bind(CopyToClipboardButtonAction).toSelf().inSingletonScope();
bind(CodePartRendererAction).toService(CopyToClipboardButtonAction);
bind(InsertCodeAtCursorButtonAction).toSelf().inSingletonScope();
bind(CodePartRendererAction).toService(InsertCodeAtCursorButtonAction);
bind(EditorSelectionResolver).to(GitHubSelectionResolver).inSingletonScope();
bind(EditorSelectionResolver).to(TypeDocSymbolSelectionResolver).inSingletonScope();
bind(EditorSelectionResolver).to(TextFragmentSelectionResolver).inSingletonScope();
bind(ChatViewWidgetToolbarContribution).toSelf().inSingletonScope();
bind(TabBarToolbarContribution).toService(ChatViewWidgetToolbarContribution);
bind(FrontendApplicationContribution).to(ChatViewLanguageContribution).inSingletonScope();
bind(ChangeSetActionService).toSelf().inSingletonScope();
bind(ChangeSetAcceptAction).toSelf().inSingletonScope();
bind(ChangeSetActionRenderer).toService(ChangeSetAcceptAction);
bindContributionProvider(bind, ChatNodeToolbarActionContribution);
bind(DefaultChatNodeToolbarActionContribution).toSelf().inSingletonScope();
bind(ChatNodeToolbarActionContribution).toService(DefaultChatNodeToolbarActionContribution);
bind(SubChatWidgetFactory).toFactory(ctx => () => {
const container = ctx.container.createChild();
container.bind(SubChatWidget).toSelf().inSingletonScope();
const widget = container.get(SubChatWidget);
return widget;
});
});
function bindChatViewWidget(bind: interfaces.Bind): void {
let chatViewWidget: ChatViewWidget | undefined;
bind(ChatViewWidget).toSelf();
bind(WidgetFactory).toDynamicValue(context => ({
id: ChatViewWidget.ID,
createWidget: () => {
if (chatViewWidget?.isDisposed !== false) {
chatViewWidget = context.container.get<ChatViewWidget>(ChatViewWidget);
}
return chatViewWidget;
}
})).inSingletonScope();
}

View File

@@ -0,0 +1,52 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH and others.
//
// 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 * as React from '@theia/core/shared/react';
import { injectable } from '@theia/core/shared/inversify';
import { ChangeSetActionRenderer } from './change-set-action-service';
import { ChangeSet, ChangeSetElement } from '@theia/ai-chat';
import { nls } from '@theia/core';
@injectable()
export class ChangeSetAcceptAction implements ChangeSetActionRenderer {
readonly id = 'change-set-accept-action';
canRender(changeSet: ChangeSet): boolean {
return changeSet.getElements().length > 0;
}
render(changeSet: ChangeSet): React.ReactNode {
return <button
className='theia-button'
disabled={!hasPendingElementsToAccept(changeSet)}
title={nls.localize('theia/ai/chat-ui/applyAllTitle', 'Apply all pending changes')}
onClick={() => acceptAllPendingElements(changeSet)}
>
{nls.localize('theia/ai/chat-ui/applyAll', 'Apply All')}
</button>;
}
}
function acceptAllPendingElements(changeSet: ChangeSet): void {
acceptablePendingElements(changeSet).forEach(e => e.apply!());
}
function hasPendingElementsToAccept(changeSet: ChangeSet): boolean | undefined {
return acceptablePendingElements(changeSet).length > 0;
}
function acceptablePendingElements(changeSet: ChangeSet): ChangeSetElement[] {
return changeSet.getElements().filter(e => e.apply && (e.state === undefined || e.state === 'pending'));
}

View File

@@ -0,0 +1,65 @@
// *****************************************************************************
// 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 { ContributionProvider, Event, Emitter } from '@theia/core';
import { ChangeSet } from '@theia/ai-chat';
import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify';
export const ChangeSetActionRenderer = Symbol('ChangeSetActionRenderer');
/**
* The CodePartRenderer offers to contribute arbitrary React nodes to the rendered code part.
* Technically anything can be rendered, however it is intended to be used for actions, like
* "Copy to Clipboard" or "Insert at Cursor".
*/
export interface ChangeSetActionRenderer {
readonly id: string;
onDidChange?: Event<void>;
render(changeSet: ChangeSet): React.ReactNode;
/**
* Determines if the action should be rendered for the given response.
*/
canRender?(changeSet: ChangeSet): boolean;
/**
* Actions are ordered by descending priority. (Highest on left).
*/
readonly priority?: number;
}
@injectable()
export class ChangeSetActionService {
protected readonly onDidChangeEmitter = new Emitter<void>();
get onDidChange(): Event<void> {
return this.onDidChangeEmitter.event;
}
@inject(ContributionProvider) @named(ChangeSetActionRenderer)
protected readonly contributions: ContributionProvider<ChangeSetActionRenderer>;
@postConstruct()
protected init(): void {
const actions = this.contributions.getContributions();
actions.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
actions.forEach(contribution => contribution.onDidChange?.(this.onDidChangeEmitter.fire, this.onDidChangeEmitter));
}
getActions(): readonly ChangeSetActionRenderer[] {
return this.contributions.getContributions();
}
getActionsForChangeset(changeSet: ChangeSet): ChangeSetActionRenderer[] {
return this.getActions().filter(candidate => !candidate.canRender || candidate.canRender(changeSet));
}
}

View File

@@ -0,0 +1,102 @@
// *****************************************************************************
// Copyright (C) 2026 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 { Command, CommandContribution, CommandRegistry } from '@theia/core';
import { ApplicationShell, KeybindingContribution, KeybindingRegistry } from '@theia/core/lib/browser';
import { inject, injectable } from '@theia/core/shared/inversify';
import { ChatViewWidget } from './chat-view-widget';
import { ChatCommands } from './chat-view-commands';
export const CHAT_FOCUS_INPUT_COMMAND = Command.toLocalizedCommand({
id: 'ai-chat.focus-input',
category: ChatCommands.CHAT_CATEGORY,
label: 'Focus Chat Input'
}, 'theia/ai/chat-ui/focusInput', ChatCommands.CHAT_CATEGORY_KEY);
export const CHAT_FOCUS_RESPONSE_COMMAND = Command.toLocalizedCommand({
id: 'ai-chat.focus-response',
category: ChatCommands.CHAT_CATEGORY,
label: 'Focus Chat Response'
}, 'theia/ai/chat-ui/focusResponse', ChatCommands.CHAT_CATEGORY_KEY);
@injectable()
export class ChatFocusContribution implements CommandContribution, KeybindingContribution {
@inject(ApplicationShell)
protected readonly shell: ApplicationShell;
registerCommands(commands: CommandRegistry): void {
commands.registerCommand(CHAT_FOCUS_INPUT_COMMAND, {
execute: () => this.focusInput(),
isEnabled: () => this.findActiveChatViewWidget() !== undefined
});
commands.registerCommand(CHAT_FOCUS_RESPONSE_COMMAND, {
execute: () => this.focusResponse(),
isEnabled: () => this.findActiveChatViewWidget() !== undefined
});
}
registerKeybindings(keybindings: KeybindingRegistry): void {
keybindings.registerKeybinding({
command: CHAT_FOCUS_RESPONSE_COMMAND.id,
keybinding: 'ctrlcmd+up',
when: 'chatInputFocus && !suggestWidgetVisible'
});
keybindings.registerKeybinding({
command: CHAT_FOCUS_INPUT_COMMAND.id,
keybinding: 'ctrlcmd+down',
when: 'chatResponseFocus'
});
}
protected focusInput(): void {
const chatViewWidget = this.findActiveChatViewWidget();
if (chatViewWidget) {
chatViewWidget.inputWidget.activate();
}
}
protected focusResponse(): void {
const chatViewWidget = this.findActiveChatViewWidget();
if (chatViewWidget) {
chatViewWidget.treeWidget.node.focus();
}
}
protected findActiveChatViewWidget(): ChatViewWidget | undefined {
const activeWidget = this.shell.activeWidget;
if (activeWidget instanceof ChatViewWidget) {
return activeWidget;
}
// Also check if any part of the chat view has focus
const activeElement = document.activeElement;
if (activeElement instanceof HTMLElement) {
const widget = this.shell.findWidgetForElement(activeElement);
if (widget instanceof ChatViewWidget) {
return widget;
}
// Check parent widgets (e.g., when input widget has focus)
let parent = widget?.parent;
while (parent) {
if (parent instanceof ChatViewWidget) {
return parent;
}
parent = parent.parent;
}
}
return undefined;
}
}

View File

@@ -0,0 +1,85 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH and others.
//
// 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 * as React from '@theia/core/shared/react';
import { DeclaredEventsEventListenerObject, useMarkdownRendering } from './chat-response-renderer/markdown-part-renderer';
import { OpenerService } from '@theia/core/lib/browser';
import { ChatSuggestion, ChatSuggestionCallback } from '@theia/ai-chat';
import { MarkdownString } from '@theia/core/lib/common/markdown-rendering';
interface ChatInputAgentSuggestionsProps {
suggestions: readonly ChatSuggestion[];
opener: OpenerService;
}
function getText(suggestion: ChatSuggestion): string {
if (typeof suggestion === 'string') { return suggestion; }
if ('value' in suggestion) { return suggestion.value; }
if (typeof suggestion.content === 'string') { return suggestion.content; }
return suggestion.content.value;
}
function getContent(suggestion: ChatSuggestion): string | MarkdownString {
if (typeof suggestion === 'string') { return suggestion; }
if ('value' in suggestion) { return suggestion; }
return suggestion.content;
}
export const ChatInputAgentSuggestions: React.FC<ChatInputAgentSuggestionsProps> = ({ suggestions, opener }) => (
!!suggestions?.length && <div className="chat-agent-suggestions">
{suggestions.map(suggestion => <ChatInputAgentSuggestion
key={getText(suggestion)}
suggestion={suggestion}
opener={opener}
handler={ChatSuggestionCallback.is(suggestion) ? new ChatSuggestionClickHandler(suggestion) : undefined}
/>)}
</div>
);
interface ChatInputAgestSuggestionProps {
suggestion: ChatSuggestion;
opener: OpenerService;
handler?: DeclaredEventsEventListenerObject;
}
const ChatInputAgentSuggestion: React.FC<ChatInputAgestSuggestionProps> = ({ suggestion, opener, handler }) => {
const ref = useMarkdownRendering(getContent(suggestion), opener, true, handler);
return <div className="chat-agent-suggestion" style={(!handler || ChatSuggestionCallback.containsCallbackLink(suggestion)) ? undefined : { cursor: 'pointer' }} ref={ref} />;
};
class ChatSuggestionClickHandler implements DeclaredEventsEventListenerObject {
constructor(protected readonly suggestion: ChatSuggestionCallback) { }
handleEvent(event: Event): boolean {
const { target, currentTarget } = event;
if (event.type !== 'click' || !(target instanceof Element)) { return false; }
const link = target.closest('a[href^="_callback"]');
if (link) {
this.suggestion.callback();
return true;
}
if (!(currentTarget instanceof Element)) {
this.suggestion.callback();
return true;
}
const containedLink = currentTarget.querySelector('a[href^="_callback"]');
// Whole body should count.
if (!containedLink) {
this.suggestion.callback();
return true;
}
return false;
}
}

View File

@@ -0,0 +1,167 @@
// *****************************************************************************
// Copyright (C) 2025 STMicroelectronics and others.
//
// 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 { Command, CommandContribution, CommandRegistry } from '@theia/core';
import { ApplicationShell, KeybindingContribution, KeybindingRegistry } from '@theia/core/lib/browser';
import { inject, injectable } from '@theia/core/shared/inversify';
import { AIChatInputWidget } from './chat-input-widget';
import { ChatInputHistoryService } from './chat-input-history';
import { ChatCommands } from './chat-view-commands';
const CHAT_INPUT_PREVIOUS_PROMPT_COMMAND = Command.toLocalizedCommand({
id: 'chat-input:previous-prompt',
label: 'Previous Prompt'
}, 'theia/ai/chat-ui/chatInput/previousPrompt');
const CHAT_INPUT_NEXT_PROMPT_COMMAND = Command.toLocalizedCommand({
id: 'chat-input:next-prompt',
label: 'Next Prompt'
}, 'theia/ai/chat-ui/chatInput/nextPrompt');
const CHAT_INPUT_CLEAR_HISTORY_COMMAND = Command.toLocalizedCommand({
id: 'chat-input:clear-history',
category: ChatCommands.CHAT_CATEGORY,
label: 'Clear Input Prompt History'
}, 'theia/ai/chat-ui/chatInput/clearHistory', ChatCommands.CHAT_CATEGORY_KEY);
@injectable()
export class ChatInputHistoryContribution implements CommandContribution, KeybindingContribution {
@inject(ApplicationShell)
protected readonly shell: ApplicationShell;
@inject(ChatInputHistoryService)
protected readonly historyService: ChatInputHistoryService;
registerCommands(commands: CommandRegistry): void {
commands.registerCommand(CHAT_INPUT_PREVIOUS_PROMPT_COMMAND, {
execute: () => this.executeNavigatePrevious(),
isEnabled: () => this.isNavigationEnabled()
});
commands.registerCommand(CHAT_INPUT_NEXT_PROMPT_COMMAND, {
execute: () => this.executeNavigateNext(),
isEnabled: () => this.isNavigationEnabled()
});
commands.registerCommand(CHAT_INPUT_CLEAR_HISTORY_COMMAND, {
execute: () => this.historyService.clearHistory(),
isEnabled: () => this.historyService.getPrompts().length > 0
});
}
registerKeybindings(keybindings: KeybindingRegistry): void {
keybindings.registerKeybinding({
command: CHAT_INPUT_PREVIOUS_PROMPT_COMMAND.id,
keybinding: 'up',
when: 'chatInputFocus && chatInputFirstLine && !suggestWidgetVisible'
});
keybindings.registerKeybinding({
command: CHAT_INPUT_NEXT_PROMPT_COMMAND.id,
keybinding: 'down',
when: 'chatInputFocus && chatInputLastLine && !suggestWidgetVisible'
});
}
protected executeNavigatePrevious(): void {
const chatInputWidget = this.findFocusedChatInput();
if (!chatInputWidget || !chatInputWidget.editor) {
return;
}
const position = chatInputWidget.editor.getControl().getPosition();
const isCursorAtBeginning = position && (position.lineNumber > 1 || position.column > 1);
if (isCursorAtBeginning) {
this.positionCursorAtBeginning(chatInputWidget);
return;
}
const currentInput = chatInputWidget.editor.getControl().getValue();
const previousPrompt = chatInputWidget.getPreviousPrompt(currentInput);
if (previousPrompt !== undefined) {
chatInputWidget.editor.getControl().setValue(previousPrompt);
this.positionCursorAtBeginning(chatInputWidget);
}
}
protected executeNavigateNext(): void {
const chatInputWidget = this.findFocusedChatInput();
if (!chatInputWidget || !chatInputWidget.editor) {
return;
}
const position = chatInputWidget.editor.getControl().getPosition();
const model = chatInputWidget.editor.getControl().getModel();
const isCursorAtEnd = position && model && (
position.lineNumber < model.getLineCount() || position.column < model.getLineMaxColumn(position.lineNumber)
);
if (isCursorAtEnd) {
this.positionCursorAtEnd(chatInputWidget);
return;
}
const nextPrompt = chatInputWidget.getNextPrompt();
if (nextPrompt !== undefined) {
chatInputWidget.editor.getControl().setValue(nextPrompt);
this.positionCursorAtEnd(chatInputWidget);
}
}
protected positionCursorAtBeginning(widget: AIChatInputWidget): void {
const editor = widget.editor?.getControl();
if (editor) {
editor.setPosition({ lineNumber: 1, column: 1 });
editor.focus();
}
}
protected positionCursorAtEnd(widget: AIChatInputWidget): void {
const editor = widget.editor?.getControl();
const model = editor?.getModel();
if (editor && model) {
const lastLine = model.getLineCount();
const lastColumn = model.getLineContent(lastLine).length + 1;
editor.setPosition({ lineNumber: lastLine, column: lastColumn });
editor.focus();
}
}
protected findFocusedChatInput(): AIChatInputWidget | undefined {
const activeElement = document.activeElement;
if (!(activeElement instanceof HTMLElement)) {
return;
}
const activeWidget = this.shell.findWidgetForElement(activeElement);
if (!(activeWidget instanceof AIChatInputWidget)) {
return;
}
if (!activeWidget.inputConfiguration?.enablePromptHistory) {
return;
}
if (!activeWidget.editor?.getControl().hasWidgetFocus()) {
return;
}
return activeWidget;
}
protected isNavigationEnabled(): boolean {
const chatInputWidget = this.findFocusedChatInput();
return chatInputWidget !== undefined &&
chatInputWidget.inputConfiguration?.enablePromptHistory !== false;
}
}

View File

@@ -0,0 +1,138 @@
// *****************************************************************************
// Copyright (C) 2025 STMicroelectronics and others.
//
// 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 { inject, injectable } from '@theia/core/shared/inversify';
import { StorageService } from '@theia/core/lib/browser';
/**
* Manages navigation state for a single chat input widget.
* Each widget has its own independent navigation state while sharing the same history.
*/
export class ChatInputNavigationState {
private currentIndex: number;
private preservedInput?: string;
private isNavigating = false;
constructor(private readonly historyService: ChatInputHistoryService) {
this.currentIndex = historyService.getPrompts().length;
}
getPreviousPrompt(currentInput: string): string | undefined {
const history = this.historyService.getPrompts();
if (history.length === 0) {
return undefined;
}
if (!this.isNavigating) {
this.preservedInput = currentInput;
this.isNavigating = true;
this.currentIndex = history.length;
}
if (this.currentIndex <= 0) {
// Already at the oldest prompt
return undefined;
}
this.currentIndex--;
return history[this.currentIndex];
}
getNextPrompt(): string | undefined {
const history = this.historyService.getPrompts();
if (!this.isNavigating || this.currentIndex >= history.length) {
return undefined;
}
this.currentIndex++;
if (this.currentIndex >= history.length) {
// Reached end of history - return to preserved input
this.isNavigating = false;
const preserved = this.preservedInput;
this.preservedInput = undefined;
this.currentIndex = history.length;
return preserved || '';
}
return history[this.currentIndex];
}
stopNavigation(): void {
this.isNavigating = false;
this.preservedInput = undefined;
this.currentIndex = this.historyService.getPrompts().length;
}
}
const CHAT_PROMPT_HISTORY_STORAGE_KEY = 'ai-chat-prompt-history';
const MAX_HISTORY_SIZE = 100;
/**
* Manages shared prompt history across all chat input widgets.
* Each prompt is stored only once and shared between all chat inputs.
*/
@injectable()
export class ChatInputHistoryService {
@inject(StorageService)
protected readonly storageService: StorageService;
protected history: string[] = [];
async init(): Promise<void> {
const data = await this.storageService.getData<{ prompts: string[] }>(CHAT_PROMPT_HISTORY_STORAGE_KEY, { prompts: [] });
this.history = data.prompts || [];
}
/**
* Get read-only access to the current prompt history.
*/
getPrompts(): readonly string[] {
return this.history;
}
clearHistory(): void {
this.history = [];
this.persistHistory();
}
addToHistory(prompt: string): void {
const trimmed = prompt.trim();
if (!trimmed) {
return;
}
// Remove existing instance and add to end (most recent)
this.history = this.history
.filter(item => item !== trimmed)
.concat(trimmed)
.slice(-MAX_HISTORY_SIZE);
this.persistHistory();
}
protected async persistHistory(): Promise<void> {
try {
await this.storageService.setData(CHAT_PROMPT_HISTORY_STORAGE_KEY, { prompts: this.history });
} catch (error) {
console.warn('Failed to persist chat prompt history:', error);
}
}
}

View File

@@ -0,0 +1,75 @@
// *****************************************************************************
// 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 { Command, CommandContribution, CommandRegistry } from '@theia/core';
import { ApplicationShell, KeybindingContribution, KeybindingRegistry } from '@theia/core/lib/browser';
import { inject, injectable } from '@theia/core/shared/inversify';
import { AIChatInputWidget } from './chat-input-widget';
const CHAT_INPUT_CYCLE_MODE_COMMAND = Command.toLocalizedCommand({
id: 'chat-input:cycle-mode',
label: 'Cycle Chat Mode'
}, 'theia/ai/chat-ui/chatInput/cycleMode');
@injectable()
export class ChatInputModeContribution implements CommandContribution, KeybindingContribution {
@inject(ApplicationShell)
protected readonly shell: ApplicationShell;
registerCommands(commands: CommandRegistry): void {
commands.registerCommand(CHAT_INPUT_CYCLE_MODE_COMMAND, {
execute: () => this.executeCycleMode(),
isEnabled: () => this.isCycleModeEnabled()
});
}
registerKeybindings(keybindings: KeybindingRegistry): void {
keybindings.registerKeybinding({
command: CHAT_INPUT_CYCLE_MODE_COMMAND.id,
keybinding: 'shift+tab',
when: 'chatInputFocus && chatInputHasModes && !suggestWidgetVisible'
});
}
protected executeCycleMode(): void {
const chatInputWidget = this.findFocusedChatInput();
if (!chatInputWidget) {
return;
}
chatInputWidget.cycleMode();
}
protected isCycleModeEnabled(): boolean {
const chatInputWidget = this.findFocusedChatInput();
return chatInputWidget !== undefined;
}
protected findFocusedChatInput(): AIChatInputWidget | undefined {
const activeElement = document.activeElement;
if (!(activeElement instanceof HTMLElement)) {
return;
}
const activeWidget = this.shell.findWidgetForElement(activeElement);
if (!(activeWidget instanceof AIChatInputWidget)) {
return;
}
if (!activeWidget.editor?.getControl().hasWidgetFocus()) {
return;
}
return activeWidget;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,116 @@
// *****************************************************************************
// Copyright (C) 2024 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 { Command, nls } from '@theia/core';
import { codicon } from '@theia/core/lib/browser';
import { isRequestNode, RequestNode, ResponseNode } from './chat-tree-view';
import { EditableChatRequestModel } from '@theia/ai-chat';
export interface ChatNodeToolbarAction {
/**
* The command to execute when the item is selected. The handler will receive the `RequestNode` or `ResponseNode` as first argument.
*/
commandId: string;
/**
* Icon class name(s) for the item (e.g. 'codicon codicon-feedback').
*/
icon: string;
/**
* Priority among the items. Can be negative. The smaller the number the left-most the item will be placed in the toolbar. It is `0` by default.
*/
priority?: number;
/**
* Optional tooltip for the item.
*/
tooltip?: string;
}
/**
* Clients implement this interface if they want to contribute to the toolbar of chat nodes.
*
* ### Example
* ```ts
* bind(ChatNodeToolbarActionContribution).toDynamicValue(context => ({
* getToolbarActions: (args: RequestNode | ResponseNode) => {
* if (isResponseNode(args)) {
* return [{
* commandId: 'core.about',
* icon: 'codicon codicon-feedback',
* tooltip: 'Show about dialog on response nodes'
* }];
* } else {
* return [];
* }
* }
* }));
* ```
*/
export const ChatNodeToolbarActionContribution = Symbol('ChatNodeToolbarActionContribution');
export interface ChatNodeToolbarActionContribution {
/**
* Returns the toolbar actions for the given node.
*/
getToolbarActions(node: RequestNode | ResponseNode): ChatNodeToolbarAction[];
}
export namespace ChatNodeToolbarCommands {
const CHAT_NODE_TOOLBAR_CATEGORY = 'ChatNodeToolbar';
const CHAT_NODE_TOOLBAR_CATEGORY_KEY = nls.getDefaultKey(CHAT_NODE_TOOLBAR_CATEGORY);
export const EDIT = Command.toLocalizedCommand({
id: 'chat:node:toolbar:edit-request',
category: CHAT_NODE_TOOLBAR_CATEGORY,
}, '', CHAT_NODE_TOOLBAR_CATEGORY_KEY);
export const CANCEL = Command.toLocalizedCommand({
id: 'chat:node:toolbar:cancel-request',
category: CHAT_NODE_TOOLBAR_CATEGORY,
}, '', CHAT_NODE_TOOLBAR_CATEGORY_KEY);
export const RETRY = Command.toLocalizedCommand({
id: 'chat:node:toolbar:retry-message',
category: CHAT_NODE_TOOLBAR_CATEGORY,
}, 'Retry', CHAT_NODE_TOOLBAR_CATEGORY_KEY);
}
export class DefaultChatNodeToolbarActionContribution implements ChatNodeToolbarActionContribution {
getToolbarActions(node: RequestNode | ResponseNode): ChatNodeToolbarAction[] {
if (isRequestNode(node)) {
if (EditableChatRequestModel.isEditing(node.request)) {
return [{
commandId: ChatNodeToolbarCommands.CANCEL.id,
icon: codicon('close'),
tooltip: nls.localizeByDefault('Cancel'),
}];
}
return [{
commandId: ChatNodeToolbarCommands.EDIT.id,
icon: codicon('edit'),
tooltip: nls.localizeByDefault('Edit'),
}];
} else {
const shouldShowRetry = node.response.isError || node.response.isCanceled;
if (shouldShowRetry) {
return [{
commandId: ChatNodeToolbarCommands.RETRY.id,
icon: codicon('refresh'),
tooltip: nls.localizeByDefault('Retry'),
priority: -1 // Higher priority to show it first
}];
}
return [];
}
}
}

View File

@@ -0,0 +1,40 @@
// *****************************************************************************
// 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 { ChatProgressMessage } from '@theia/ai-chat';
import * as React from '@theia/core/shared/react';
export type ProgressMessageProps = Omit<ChatProgressMessage, 'kind' | 'id' | 'show'>;
export const ProgressMessage = (c: ProgressMessageProps) => (
<div className='theia-ResponseNode-ProgressMessage'>
<Indicator {...c} /> {c.content}
</div>
);
export const Indicator = (progressMessage: ProgressMessageProps) => (
<span className='theia-ResponseNode-ProgressMessage-Indicator'>
{progressMessage.status === 'inProgress' &&
<i className={'fa fa-spinner fa-spin ' + progressMessage.status}></i>
}
{progressMessage.status === 'completed' &&
<i className={'fa fa-check ' + progressMessage.status}></i>
}
{progressMessage.status === 'failed' &&
<i className={'fa fa-warning ' + progressMessage.status}></i>
}
</span>
);

View File

@@ -0,0 +1,25 @@
// *****************************************************************************
// Copyright (C) 2024 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 { ChatResponseContent } from '@theia/ai-chat/lib/common';
import { ReactNode } from '@theia/core/shared/react';
import { ResponseNode } from './chat-tree-view/chat-view-tree-widget';
export const ChatResponsePartRenderer = Symbol('ChatResponsePartRenderer');
export interface ChatResponsePartRenderer<T extends ChatResponseContent> {
canHandle(response: ChatResponseContent): number;
render(response: T, parentNode: ResponseNode): ReactNode;
}

View File

@@ -0,0 +1,144 @@
// *****************************************************************************
// Copyright (C) 2024 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 { CancellationToken, RecursivePartial, URI } from '@theia/core';
import { inject, injectable } from '@theia/core/shared/inversify';
import { EditorOpenerOptions, EditorWidget, Range } from '@theia/editor/lib/browser';
import { EditorSelectionResolver } from '@theia/editor/lib/browser/editor-manager';
import { DocumentSymbol } from '@theia/monaco-editor-core/esm/vs/editor/common/languages';
import { TextModel } from '@theia/monaco-editor-core/esm/vs/editor/common/model/textModel';
import { ILanguageFeaturesService } from '@theia/monaco-editor-core/esm/vs/editor/common/services/languageFeatures';
import { StandaloneServices } from '@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneServices';
import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor';
import { MonacoToProtocolConverter } from '@theia/monaco/lib/browser/monaco-to-protocol-converter';
/** Regex to match GitHub-style position and range declaration with line (L) and column (C) */
export const LOCATION_REGEX = /#L(\d+)?(?:C(\d+))?(?:-L(\d+)?(?:C(\d+))?)?$/;
@injectable()
export class GitHubSelectionResolver implements EditorSelectionResolver {
priority = 100;
async resolveSelection(widget: EditorWidget, options: EditorOpenerOptions, uri?: URI): Promise<RecursivePartial<Range> | undefined> {
if (!uri) {
return;
}
// We allow the GitHub syntax of selecting a range in markdown 'L1', 'L1-L2' 'L1-C1_L2-C2' (starting at line 1 and column 1)
const match = uri?.toString().match(LOCATION_REGEX);
if (!match) {
return;
}
// we need to adapt the position information from one-based (in GitHub) to zero-based (in Theia)
const startLine = match[1] ? parseInt(match[1], 10) - 1 : undefined;
// if no start column is given, we assume the start of the line
const startColumn = match[2] ? parseInt(match[2], 10) - 1 : 0;
const endLine = match[3] ? parseInt(match[3], 10) - 1 : undefined;
// if no end column is given, we assume the end of the line
const endColumn = match[4] ? parseInt(match[4], 10) - 1 : endLine ? widget.editor.document.getLineMaxColumn(endLine) : undefined;
return {
start: { line: startLine, character: startColumn },
end: { line: endLine, character: endColumn }
};
}
}
@injectable()
export class TypeDocSymbolSelectionResolver implements EditorSelectionResolver {
priority = 50;
@inject(MonacoToProtocolConverter) protected readonly m2p: MonacoToProtocolConverter;
async resolveSelection(widget: EditorWidget, options: EditorOpenerOptions, uri?: URI): Promise<RecursivePartial<Range> | undefined> {
if (!uri) {
return;
}
const editor = MonacoEditor.get(widget);
const monacoEditor = editor?.getControl();
if (!monacoEditor) {
return;
}
const symbolPath = this.findSymbolPath(uri);
if (!symbolPath) {
return;
}
const textModel = monacoEditor.getModel() as unknown as TextModel;
if (!textModel) {
return;
}
// try to find the symbol through the document symbol provider
// support referencing nested symbols by separating a dot path similar to TypeDoc
for (const provider of StandaloneServices.get(ILanguageFeaturesService).documentSymbolProvider.ordered(textModel)) {
const symbols = await provider.provideDocumentSymbols(textModel, CancellationToken.None);
const match = this.findSymbolByPath(symbols ?? [], symbolPath);
if (match) {
return this.m2p.asRange(match.selectionRange);
}
}
}
protected findSymbolPath(uri: URI): string[] | undefined {
return uri.fragment.split('.');
}
protected findSymbolByPath(symbols: DocumentSymbol[], symbolPath: string[]): DocumentSymbol | undefined {
if (!symbols || symbolPath.length === 0) {
return undefined;
}
let matchedSymbol: DocumentSymbol | undefined = undefined;
let currentSymbols = symbols;
for (const part of symbolPath) {
matchedSymbol = currentSymbols.find(symbol => symbol.name === part);
if (!matchedSymbol) {
return undefined;
}
currentSymbols = matchedSymbol.children || [];
}
return matchedSymbol;
}
}
@injectable()
export class TextFragmentSelectionResolver implements EditorSelectionResolver {
async resolveSelection(widget: EditorWidget, options: EditorOpenerOptions, uri?: URI): Promise<RecursivePartial<Range> | undefined> {
if (!uri) {
return;
}
const fragment = this.findFragment(uri);
if (!fragment) {
return;
}
const matches = widget.editor.document.findMatches?.({ isRegex: false, matchCase: false, matchWholeWord: false, searchString: fragment }) ?? [];
if (matches.length > 0) {
return {
start: {
line: matches[0].range.start.line - 1,
character: matches[0].range.start.character - 1
},
end: {
line: matches[0].range.end.line - 1,
character: matches[0].range.end.character - 1
}
};
}
}
protected findFragment(uri: URI): string | undefined {
return uri.fragment;
}
}

View File

@@ -0,0 +1,272 @@
// *****************************************************************************
// Copyright (C) 2024 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 {
ChatResponseContent,
CodeChatResponseContent,
} from '@theia/ai-chat/lib/common';
import { ContributionProvider, UntitledResourceResolver, URI } from '@theia/core';
import { ContextMenuRenderer, TreeNode } from '@theia/core/lib/browser';
import { ClipboardService } from '@theia/core/lib/browser/clipboard-service';
import { inject, injectable, named } from '@theia/core/shared/inversify';
import * as React from '@theia/core/shared/react';
import { ReactNode } from '@theia/core/shared/react';
import { nls } from '@theia/core/lib/common/nls';
import { Position } from '@theia/core/shared/vscode-languageserver-protocol';
import { EditorManager, EditorWidget } from '@theia/editor/lib/browser';
import { SimpleMonacoEditor } from '@theia/monaco/lib/browser/simple-monaco-editor';
import { MonacoEditorProvider } from '@theia/monaco/lib/browser/monaco-editor-provider';
import { MonacoLanguages } from '@theia/monaco/lib/browser/monaco-languages';
import { ChatResponsePartRenderer } from '../chat-response-part-renderer';
import { ChatViewTreeWidget, ResponseNode } from '../chat-tree-view/chat-view-tree-widget';
import { IMouseEvent } from '@theia/monaco-editor-core';
export const CodePartRendererAction = Symbol('CodePartRendererAction');
/**
* The CodePartRenderer offers to contribute arbitrary React nodes to the rendered code part.
* Technically anything can be rendered, however it is intended to be used for actions, like
* "Copy to Clipboard" or "Insert at Cursor".
*/
export interface CodePartRendererAction {
render(response: CodeChatResponseContent, parentNode: ResponseNode): ReactNode;
/**
* Determines if the action should be rendered for the given response.
*/
canRender?(response: CodeChatResponseContent, parentNode: ResponseNode): boolean;
/**
* The priority determines the order in which the actions are rendered.
* The default priorities are 10 and 20.
*/
priority: number;
}
@injectable()
export class CodePartRenderer
implements ChatResponsePartRenderer<CodeChatResponseContent> {
@inject(EditorManager)
protected readonly editorManager: EditorManager;
@inject(UntitledResourceResolver)
protected readonly untitledResourceResolver: UntitledResourceResolver;
@inject(MonacoEditorProvider)
protected readonly editorProvider: MonacoEditorProvider;
@inject(MonacoLanguages)
protected readonly languageService: MonacoLanguages;
@inject(ContextMenuRenderer)
protected readonly contextMenuRenderer: ContextMenuRenderer;
@inject(ContributionProvider) @named(CodePartRendererAction)
protected readonly codePartRendererActions: ContributionProvider<CodePartRendererAction>;
canHandle(response: ChatResponseContent): number {
if (CodeChatResponseContent.is(response)) {
return 10;
}
return -1;
}
render(response: CodeChatResponseContent, parentNode: ResponseNode): ReactNode {
const language = response.language ? this.languageService.getExtension(response.language) : undefined;
return (
<div className="theia-CodePartRenderer-root">
<div className="theia-CodePartRenderer-top">
<div className="theia-CodePartRenderer-left">{this.renderTitle(response)}</div>
<div className="theia-CodePartRenderer-right theia-CodePartRenderer-actions">
{this.codePartRendererActions.getContributions()
.filter(action => action.canRender ? action.canRender(response, parentNode) : true)
.sort((a, b) => a.priority - b.priority)
.map(action => action.render(response, parentNode))}
</div>
</div>
<div className="theia-CodePartRenderer-separator"></div>
<div className="theia-CodePartRenderer-bottom">
<CodeWrapper
content={response.code}
language={language}
editorProvider={this.editorProvider}
untitledResourceResolver={this.untitledResourceResolver}
contextMenuCallback={e => this.handleContextMenuEvent(parentNode, e, response.code)}></CodeWrapper>
</div>
</div>
);
}
protected renderTitle(response: CodeChatResponseContent): ReactNode {
const uri = response.location?.uri;
const position = response.location?.position;
if (uri && position) {
return <a onClick={this.openFileAtPosition.bind(this, uri, position)}>{this.getTitle(response.location?.uri, response.language)}</a>;
}
return this.getTitle(response.location?.uri, response.language);
}
private getTitle(uri: URI | undefined, language: string | undefined): string {
// If there is a URI, use the file name as the title. Otherwise, use the language as the title.
// If there is no language, use a generic fallback title.
return uri?.path?.toString().split('/').pop() ?? language ?? nls.localize('theia/ai/chat-ui/code-part-renderer/generatedCode', 'Generated Code');
}
/**
* Opens a file and moves the cursor to the specified position.
*
* @param uri - The URI of the file to open.
* @param position - The position to move the cursor to, specified as {line, character}.
*/
async openFileAtPosition(uri: URI, position: Position): Promise<void> {
const editorWidget = await this.editorManager.open(uri) as EditorWidget;
if (editorWidget) {
const editor = editorWidget.editor;
editor.revealPosition(position);
editor.focus();
editor.cursor = position;
}
}
protected handleContextMenuEvent(node: TreeNode | undefined, event: IMouseEvent, code: string): void {
this.contextMenuRenderer.render({
menuPath: ChatViewTreeWidget.CONTEXT_MENU,
anchor: { x: event.posx, y: event.posy },
args: [node, { code }],
context: event.target
});
event.preventDefault();
}
}
@injectable()
export class CopyToClipboardButtonAction implements CodePartRendererAction {
@inject(ClipboardService)
protected readonly clipboardService: ClipboardService;
priority = 10;
render(response: CodeChatResponseContent): ReactNode {
return <CopyToClipboardButton key='copyToClipBoard' code={response.code} clipboardService={this.clipboardService} />;
}
}
const CopyToClipboardButton = (props: { code: string, clipboardService: ClipboardService }) => {
const { code, clipboardService } = props;
const [copied, setCopied] = React.useState(false);
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
React.useEffect(() => () => {
if (timeoutRef.current !== undefined) {
clearTimeout(timeoutRef.current);
}
}, []);
const copyCodeToClipboard = React.useCallback(() => {
clipboardService.writeText(code);
setCopied(true);
if (timeoutRef.current !== undefined) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setCopied(false);
timeoutRef.current = undefined;
}, 2000);
}, [code, clipboardService]);
const iconClass = copied ? 'codicon-check' : 'codicon-copy';
const title = copied ? nls.localize('theia/ai/chat-ui/code-part-renderer/copied', 'Copied') : nls.localizeByDefault('Copy');
return <div className={`button codicon ${iconClass}`} title={title} role='button' onClick={copyCodeToClipboard}></div>;
};
@injectable()
export class InsertCodeAtCursorButtonAction implements CodePartRendererAction {
@inject(EditorManager)
protected readonly editorManager: EditorManager;
priority = 20;
render(response: CodeChatResponseContent): ReactNode {
return <InsertCodeAtCursorButton key='insertCodeAtCursor' code={response.code} editorManager={this.editorManager} />;
}
}
const InsertCodeAtCursorButton = (props: { code: string, editorManager: EditorManager }) => {
const { code, editorManager } = props;
const insertCode = React.useCallback(() => {
const editor = editorManager.currentEditor;
if (editor) {
const currentEditor = editor.editor;
const selection = currentEditor.selection;
// Insert the text at the current cursor position
// If there is a selection, replace the selection with the text
currentEditor.executeEdits([{
range: {
start: selection.start,
end: selection.end
},
newText: code
}]);
}
}, [code, editorManager]);
return <div className='button codicon codicon-insert' title={nls.localizeByDefault('Insert At Cursor')} role='button' onClick={insertCode}></div>;
};
/**
* Renders the given code within a Monaco Editor
*/
export const CodeWrapper = (props: {
content: string,
language?: string,
untitledResourceResolver: UntitledResourceResolver,
editorProvider: MonacoEditorProvider,
contextMenuCallback: (e: IMouseEvent) => void
}) => {
// eslint-disable-next-line no-null/no-null
const ref = React.useRef<HTMLDivElement | null>(null);
const editorRef = React.useRef<SimpleMonacoEditor | undefined>(undefined);
const createInputElement = async () => {
const resource = await props.untitledResourceResolver.createUntitledResource(undefined, props.language);
const editor = await props.editorProvider.createSimpleInline(resource.uri, ref.current!, {
readOnly: true,
autoSizing: true,
scrollBeyondLastLine: false,
scrollBeyondLastColumn: 0,
renderFinalNewline: 'off',
maxHeight: -1,
scrollbar: {
vertical: 'hidden',
alwaysConsumeMouseWheel: false
},
wordWrap: 'off',
codeLens: false,
inlayHints: { enabled: 'off' },
hover: { enabled: false }
});
editor.document.textEditorModel.setValue(props.content);
editor.getControl().onContextMenu(e => props.contextMenuCallback(e.event));
editorRef.current = editor;
};
React.useEffect(() => {
createInputElement();
return () => {
if (editorRef.current) {
editorRef.current.dispose();
}
};
}, []);
React.useEffect(() => {
if (editorRef.current) {
editorRef.current.document.textEditorModel.setValue(props.content);
}
}, [props.content]);
editorRef.current?.resizeToFit();
return <div className='theia-CodeWrapper' ref={ref}></div>;
};

View File

@@ -0,0 +1,61 @@
// *****************************************************************************
// Copyright (C) 2024 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 { ChatResponsePartRenderer } from '../chat-response-part-renderer';
import { inject, injectable } from '@theia/core/shared/inversify';
import { ChatResponseContent, CommandChatResponseContent } from '@theia/ai-chat/lib/common';
import { ReactNode } from '@theia/core/shared/react';
import * as React from '@theia/core/shared/react';
import { CommandRegistry, CommandService, nls } from '@theia/core';
@injectable()
export class CommandPartRenderer implements ChatResponsePartRenderer<CommandChatResponseContent> {
@inject(CommandService) private commandService: CommandService;
@inject(CommandRegistry) private commandRegistry: CommandRegistry;
canHandle(response: ChatResponseContent): number {
if (CommandChatResponseContent.is(response)) {
return 10;
}
return -1;
}
render(response: CommandChatResponseContent): ReactNode {
const label =
response.customCallback?.label ??
response.command?.label ??
response.command?.id
.split('-')
.map(s => s[0].toUpperCase() + s.substring(1))
.join(' ') ?? nls.localizeByDefault('Execute Command');
if (!response.customCallback && response.command) {
const isCommandEnabled = this.commandRegistry.isEnabled(response.command.id);
if (!isCommandEnabled) {
return <div>{nls.localize('theia/ai/chat-ui/command-part-renderer/commandNotExecutable',
'The command has the id "{0}" but it is not executable from the Chat window.', response.command.id)}</div>;
}
}
return <button className='theia-button main' onClick={this.onCommand.bind(this, response)}>{label}</button>;
}
private onCommand(arg: CommandChatResponseContent): void {
if (arg.customCallback) {
arg.customCallback.callback().catch(e => { console.error(e); });
} else if (arg.command) {
this.commandService.executeCommand(arg.command.id, ...(arg.arguments ?? [])).catch(e => { console.error(e); });
} else {
console.warn('No command or custom callback provided in command chat response content');
}
}
}

View File

@@ -0,0 +1,179 @@
// *****************************************************************************
// 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 { inject, injectable } from '@theia/core/shared/inversify';
import { ChatRequestInvocation, ChatResponseContent, ChatResponseModel } from '@theia/ai-chat';
import { ChatResponsePartRenderer } from '../chat-response-part-renderer';
import * as React from '@theia/core/shared/react';
import { DelegationResponseContent, isDelegationResponseContent } from '@theia/ai-chat/lib/browser/delegation-response-content';
import { ResponseNode } from '../chat-tree-view';
import { CompositeTreeNode } from '@theia/core/lib/browser';
import { SubChatWidgetFactory } from '../chat-tree-view/sub-chat-widget';
import { DisposableCollection, nls } from '@theia/core';
@injectable()
export class DelegationResponseRenderer implements ChatResponsePartRenderer<DelegationResponseContent> {
@inject(SubChatWidgetFactory)
subChatWidgetFactory: SubChatWidgetFactory;
canHandle(response: ChatResponseContent): number {
if (isDelegationResponseContent(response)) {
return 10;
}
return -1;
}
render(response: DelegationResponseContent, parentNode: ResponseNode): React.ReactNode {
return this.renderExpandableNode(response, parentNode);
}
private renderExpandableNode(response: DelegationResponseContent, parentNode: ResponseNode): React.ReactNode {
return <DelegatedChat
response={response.response}
agentId={response.agentId}
prompt={response.prompt}
parentNode={parentNode}
subChatWidgetFactory={this.subChatWidgetFactory} />;
}
}
interface DelegatedChatProps {
response: ChatRequestInvocation;
agentId: string;
prompt: string;
parentNode: ResponseNode;
subChatWidgetFactory: SubChatWidgetFactory;
}
interface DelegatedChatState {
node?: ResponseNode;
}
class DelegatedChat extends React.Component<DelegatedChatProps, DelegatedChatState> {
private widget: ReturnType<SubChatWidgetFactory>;
private readonly toDispose = new DisposableCollection();
constructor(props: DelegatedChatProps) {
super(props);
this.state = {
node: undefined
};
this.widget = props.subChatWidgetFactory();
}
override componentDidMount(): void {
// Start rendering as soon as the response is created (streaming mode)
this.props.response.responseCreated.then(chatModel => {
const node = mapResponseToNode(chatModel, this.props.parentNode);
this.setState({ node });
// Listen for changes to update the rendering as the response streams in
const changeListener = () => {
// Force re-render when the response content changes
this.forceUpdate();
};
this.toDispose.push(chatModel.onDidChange(changeListener));
}).catch(error => {
console.error('Failed to create delegated chat response:', error);
// Still try to handle completion in case of partial success
});
// Keep the completion handling for final cleanup if needed
this.props.response.responseCompleted.then(() => {
// Final update when response is complete
this.forceUpdate();
}).catch(error => {
console.error('Error in delegated chat response completion:', error);
// Force update anyway to show any partial content or error state
this.forceUpdate();
});
}
override componentWillUnmount(): void {
this.toDispose.dispose();
}
override render(): React.ReactNode {
const { agentId, prompt } = this.props;
const hasNode = !!this.state.node;
const isComplete = this.state.node?.response.isComplete ?? false;
const isCanceled = this.state.node?.response.isCanceled ?? false;
const isError = this.state.node?.response.isError ?? false;
let statusIcon = '';
let statusText = '';
if (hasNode) {
if (isCanceled) {
statusIcon = 'codicon-close';
statusText = nls.localize('theia/ai/chat-ui/delegation-response-renderer/status/canceled', 'canceled');
} else if (isComplete) {
statusIcon = 'codicon-check';
statusText = nls.localizeByDefault('completed');
} else if (isError) {
statusIcon = 'codicon-error';
statusText = nls.localize('theia/ai/chat-ui/delegation-response-renderer/status/error', 'error');
} else {
statusIcon = 'codicon-loading';
statusText = nls.localize('theia/ai/chat-ui/delegation-response-renderer/status/generating', 'generating...');
}
} else {
statusIcon = 'codicon-loading';
statusText = nls.localize('theia/ai/chat-ui/delegation-response-renderer/status/starting', 'starting...');
}
return (
<div className="theia-delegation-container">
<details className="delegation-response-details">
<summary className="delegation-summary">
<div className="delegation-header">
<span className="delegation-agent">
<span className="codicon codicon-copilot-large" /> {agentId}
</span>
<span className="delegation-status">
<span className={`codicon ${statusIcon} delegation-status-icon`}></span>
<span className="delegation-status-text">{statusText}</span>
</span>
</div>
</summary>
<div className="delegation-content">
<div className="delegation-prompt-section">
<strong>{nls.localize('theia/ai/chat-ui/delegation-response-renderer/prompt/label', 'Delegated prompt:')}</strong>
<div className="delegation-prompt">{prompt}</div>
</div>
<div className="delegation-response-section">
<strong>{nls.localize('theia/ai/chat-ui/delegation-response-renderer/response/label', 'Response:')}</strong>
<div className='delegation-response-placeholder'>
{hasNode && this.state.node ? this.widget.renderChatResponse(this.state.node) :
<div className="theia-ChatContentInProgress">
{nls.localize('theia/ai/chat-ui/delegation-response-renderer/starting', 'Starting delegation...')}
</div>
}
</div>
</div>
</div>
</details>
</div>
);
}
}
function mapResponseToNode(response: ChatResponseModel, parentNode: ResponseNode): ResponseNode {
return {
id: response.id,
parent: parentNode as unknown as CompositeTreeNode,
response,
sessionId: parentNode.sessionId
};
}

View File

@@ -0,0 +1,35 @@
// *****************************************************************************
// Copyright (C) 2024 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 { ChatResponsePartRenderer } from '../chat-response-part-renderer';
import { injectable } from '@theia/core/shared/inversify';
import { ChatResponseContent, ErrorChatResponseContent } from '@theia/ai-chat/lib/common';
import { ReactNode } from '@theia/core/shared/react';
import * as React from '@theia/core/shared/react';
@injectable()
export class ErrorPartRenderer implements ChatResponsePartRenderer<ErrorChatResponseContent> {
canHandle(response: ChatResponseContent): number {
if (ErrorChatResponseContent.is(response)) {
return 10;
}
return -1;
}
render(response: ErrorChatResponseContent): ReactNode {
return <div className='theia-ChatPart-Error'><span className='codicon codicon-error' /><span>{response.error.message}</span></div>;
}
}

View File

@@ -0,0 +1,59 @@
// *****************************************************************************
// Copyright (C) 2024 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 { ChatResponsePartRenderer } from '../chat-response-part-renderer';
import { inject, injectable, named } from '@theia/core/shared/inversify';
import {
ChatResponseContent,
HorizontalLayoutChatResponseContent,
} from '@theia/ai-chat/lib/common';
import { ReactNode } from '@theia/core/shared/react';
import * as React from '@theia/core/shared/react';
import { ContributionProvider } from '@theia/core';
import { ResponseNode } from '../chat-tree-view/chat-view-tree-widget';
@injectable()
export class HorizontalLayoutPartRenderer
implements ChatResponsePartRenderer<ChatResponseContent> {
@inject(ContributionProvider)
@named(ChatResponsePartRenderer)
protected readonly chatResponsePartRenderers: ContributionProvider<
ChatResponsePartRenderer<ChatResponseContent>
>;
canHandle(response: ChatResponseContent): number {
if (HorizontalLayoutChatResponseContent.is(response)) {
return 10;
}
return -1;
}
render(response: HorizontalLayoutChatResponseContent, parentNode: ResponseNode): ReactNode {
const contributions = this.chatResponsePartRenderers.getContributions();
return (
<div className="ai-chat-horizontal-layout" style={{ display: 'flex', flexDirection: 'row', flexWrap: 'wrap' }}>
{response.content.map(content => {
const renderer = contributions
.map(c => ({
prio: c.canHandle(content),
renderer: c,
}))
.sort((a, b) => b.prio - a.prio)[0].renderer;
return renderer.render(content, parentNode);
})}
</div>
);
}
}

View File

@@ -0,0 +1,28 @@
// *****************************************************************************
// Copyright (C) 2024 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
// *****************************************************************************
export * from './ai-selection-resolver';
export * from './code-part-renderer';
export * from './command-part-renderer';
export * from './error-part-renderer';
export * from './horizontal-layout-part-renderer';
export * from './markdown-part-renderer';
export * from './text-part-renderer';
export * from './toolcall-part-renderer';
export * from './not-available-toolcall-renderer';
export * from './thinking-part-renderer';
export * from './progress-part-renderer';
export * from './tool-confirmation';
export * from './delegation-response-renderer';

View File

@@ -0,0 +1,137 @@
// *****************************************************************************
// Copyright (C) 2024 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 { ChatResponsePartRenderer } from '../chat-response-part-renderer';
import { inject, injectable } from '@theia/core/shared/inversify';
import {
ChatResponseContent,
InformationalChatResponseContent,
MarkdownChatResponseContent,
} from '@theia/ai-chat/lib/common';
import { ReactNode, useEffect, useRef } from '@theia/core/shared/react';
import * as React from '@theia/core/shared/react';
import * as markdownit from '@theia/core/shared/markdown-it';
import * as markdownitemoji from '@theia/core/shared/markdown-it-emoji';
import * as DOMPurify from '@theia/core/shared/dompurify';
import { MarkdownString } from '@theia/core/lib/common/markdown-rendering';
import { OpenerService, open } from '@theia/core/lib/browser';
import { URI } from '@theia/core';
@injectable()
export class MarkdownPartRenderer implements ChatResponsePartRenderer<MarkdownChatResponseContent | InformationalChatResponseContent> {
@inject(OpenerService) protected readonly openerService: OpenerService;
protected readonly markdownIt = markdownit().use(markdownitemoji.full);
canHandle(response: ChatResponseContent): number {
if (MarkdownChatResponseContent.is(response)) {
return 10;
}
if (InformationalChatResponseContent.is(response)) {
return 10;
}
return -1;
}
render(response: MarkdownChatResponseContent | InformationalChatResponseContent): ReactNode {
// TODO let the user configure whether they want to see informational content
if (InformationalChatResponseContent.is(response)) {
// null is valid in React
// eslint-disable-next-line no-null/no-null
return null;
}
return <MarkdownRender response={response} openerService={this.openerService} />;
}
}
const MarkdownRender = ({ response, openerService }: { response: MarkdownChatResponseContent | InformationalChatResponseContent; openerService: OpenerService }) => {
const ref = useMarkdownRendering(response.content, openerService);
return <div ref={ref}></div>;
};
export interface DeclaredEventsEventListenerObject extends EventListenerObject {
handledEvents?: (keyof HTMLElementEventMap)[];
}
/**
* This hook uses markdown-it directly to render markdown.
* The reason to use markdown-it directly is that the MarkdownRenderer is
* overridden by theia with a monaco version. This monaco version strips all html
* tags from the markdown with empty content. This leads to unexpected behavior when
* rendering markdown with html tags.
*
* Moreover, we want to intercept link clicks to use the Theia OpenerService instead of the default browser behavior.
*
* @param markdown the string to render as markdown
* @param skipSurroundingParagraph whether to remove a surrounding paragraph element (default: false)
* @param openerService the service to handle link opening
* @param eventHandler `handleEvent` will be called by default for `click` events and additionally
* for all events enumerated in {@link DeclaredEventsEventListenerObject.handledEvents}. If `handleEvent` returns `true`,
* no additional handlers will be run for the event.
* @returns the ref to use in an element to render the markdown
*/
export const useMarkdownRendering = (
markdown: string | MarkdownString,
openerService: OpenerService,
skipSurroundingParagraph: boolean = false,
eventHandler?: DeclaredEventsEventListenerObject
) => {
// null is valid in React
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement | null>(null);
const markdownString = typeof markdown === 'string' ? markdown : markdown.value;
useEffect(() => {
const markdownIt = markdownit().use(markdownitemoji.full);
const host = document.createElement('div');
// markdownIt always puts the content in a paragraph element, so we remove it if we don't want that
const html = skipSurroundingParagraph ? markdownIt.render(markdownString).replace(/^<p>|<\/p>|<p><\/p>$/g, '') : markdownIt.render(markdownString);
host.innerHTML = DOMPurify.sanitize(html, {
// DOMPurify usually strips non http(s) links from hrefs
// but we want to allow them (see handleClick via OpenerService below)
ALLOW_UNKNOWN_PROTOCOLS: true
});
while (ref?.current?.firstChild) {
ref.current.removeChild(ref.current.firstChild);
}
ref?.current?.appendChild(host);
// intercept link clicks to use the Theia OpenerService instead of the default browser behavior
const handleClick = (event: MouseEvent) => {
if ((eventHandler?.handleEvent(event) as unknown) === true) { return; }
let target = event.target as HTMLElement;
while (target && target.tagName !== 'A') {
target = target.parentElement as HTMLElement;
}
if (target && target.tagName === 'A') {
const href = target.getAttribute('href');
if (href) {
open(openerService, new URI(href));
event.preventDefault();
}
}
};
ref?.current?.addEventListener('click', handleClick);
eventHandler?.handledEvents?.forEach(eventType => eventType !== 'click' && ref?.current?.addEventListener(eventType, eventHandler));
return () => {
ref.current?.removeEventListener('click', handleClick);
eventHandler?.handledEvents?.forEach(eventType => eventType !== 'click' && ref?.current?.removeEventListener(eventType, eventHandler));
};
}, [markdownString, skipSurroundingParagraph, openerService]);
return ref;
};

View File

@@ -0,0 +1,57 @@
// *****************************************************************************
// Copyright (C) 2026 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 { ChatResponsePartRenderer } from '../chat-response-part-renderer';
import { injectable } from '@theia/core/shared/inversify';
import { ChatResponseContent, ToolCallChatResponseContent } from '@theia/ai-chat/lib/common';
import { ReactNode } from '@theia/core/shared/react';
import { codicon } from '@theia/core/lib/browser';
import * as React from '@theia/core/shared/react';
import { ResponseNode } from '../chat-tree-view';
/**
* High-priority renderer for tool calls that were not available.
*
* This handles cases where the LLM attempted to call a tool that was not
* made available to it in the request. This takes priority over all other
* tool renderers (including specialized ones like ShellExecutionToolRenderer)
* since unavailable tools should never be processed by tool-specific renderers.
*/
@injectable()
export class NotAvailableToolCallRenderer implements ChatResponsePartRenderer<ToolCallChatResponseContent> {
canHandle(response: ChatResponseContent): number {
if (ToolCallChatResponseContent.is(response) && response.finished) {
if (ToolCallChatResponseContent.isNotAvailableResult(response.result)) {
return 100;
}
}
return -1;
}
render(response: ToolCallChatResponseContent, _parentNode: ResponseNode): ReactNode {
const errorMessage = ToolCallChatResponseContent.getErrorMessage(response.result);
return (
<div className='theia-toolCall'>
<span className='theia-toolCall-unavailable'>
<span className={codicon('warning')}></span>
{' '}
{response.name}: {errorMessage}
</span>
</div>
);
}
}

View File

@@ -0,0 +1,40 @@
// *****************************************************************************
// 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 { ChatResponsePartRenderer } from '../chat-response-part-renderer';
import { injectable } from '@theia/core/shared/inversify';
import { ChatResponseContent, ProgressChatResponseContent } from '@theia/ai-chat/lib/common';
import { ReactNode } from '@theia/core/shared/react';
import * as React from '@theia/core/shared/react';
import { ProgressMessage } from '../chat-progress-message';
@injectable()
export class ProgressPartRenderer implements ChatResponsePartRenderer<ProgressChatResponseContent> {
canHandle(response: ChatResponseContent): number {
if (ProgressChatResponseContent.is(response)) {
return 10;
}
return -1;
}
render(response: ProgressChatResponseContent): ReactNode {
return (
<ProgressMessage content={response.message} status='inProgress' />
);
}
}

View File

@@ -0,0 +1,63 @@
// *****************************************************************************
// Copyright (C) 2024 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 { ChatResponseContent, QuestionResponseContent } from '@theia/ai-chat';
import { injectable } from '@theia/core/shared/inversify';
import * as React from '@theia/core/shared/react';
import { ReactNode } from '@theia/core/shared/react';
import { ChatResponsePartRenderer } from '../chat-response-part-renderer';
import { ResponseNode } from '../chat-tree-view';
@injectable()
export class QuestionPartRenderer
implements ChatResponsePartRenderer<QuestionResponseContent> {
canHandle(response: ChatResponseContent): number {
if (QuestionResponseContent.is(response)) {
return 10;
}
return -1;
}
render(question: QuestionResponseContent, node: ResponseNode): ReactNode {
const isDisabled = question.isReadOnly || question.selectedOption !== undefined || !node.response.isWaitingForInput;
return (
<div className="theia-QuestionPartRenderer-root">
<div className="theia-QuestionPartRenderer-question">{question.question}</div>
<div className="theia-QuestionPartRenderer-options">
{
question.options.map((option, index) => (
<button
className={`theia-button theia-QuestionPartRenderer-option ${question.selectedOption?.text === option.text ? 'selected' : ''}`}
onClick={() => {
if (!question.isReadOnly && question.handler) {
question.selectedOption = option;
question.handler(option);
}
}}
disabled={isDisabled}
key={index}
>
{option.text}
</button>
))
}
</div>
</div>
);
}
}

View File

@@ -0,0 +1,50 @@
// *****************************************************************************
// Copyright (C) 2024 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 { TextPartRenderer } from './text-part-renderer';
import { expect } from 'chai';
import { ChatResponseContent } from '@theia/ai-chat';
describe('TextPartRenderer', () => {
it('accepts all parts', () => {
const renderer = new TextPartRenderer();
expect(renderer.canHandle({ kind: 'text' })).to.be.greaterThan(0);
expect(renderer.canHandle({ kind: 'code' })).to.be.greaterThan(0);
expect(renderer.canHandle({ kind: 'command' })).to.be.greaterThan(0);
expect(renderer.canHandle({ kind: 'error' })).to.be.greaterThan(0);
expect(renderer.canHandle({ kind: 'horizontal' })).to.be.greaterThan(0);
expect(renderer.canHandle({ kind: 'informational' })).to.be.greaterThan(0);
expect(renderer.canHandle({ kind: 'markdownContent' })).to.be.greaterThan(0);
expect(renderer.canHandle({ kind: 'toolCall' })).to.be.greaterThan(0);
expect(renderer.canHandle(undefined as unknown as ChatResponseContent)).to.be.greaterThan(0);
});
it('renders text correctly', () => {
const renderer = new TextPartRenderer();
const part = { kind: 'text', asString: () => 'Hello, World!' };
const node = renderer.render(part);
expect(JSON.stringify(node)).to.contain('Hello, World!');
});
it('handles undefined content gracefully', () => {
const renderer = new TextPartRenderer();
const part = undefined as unknown as ChatResponseContent;
const node = renderer.render(part);
expect(node).to.exist;
});
});

View File

@@ -0,0 +1,38 @@
// *****************************************************************************
// Copyright (C) 2024 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 { ChatResponsePartRenderer } from '../chat-response-part-renderer';
import { injectable } from '@theia/core/shared/inversify';
import { ChatResponseContent } from '@theia/ai-chat/lib/common';
import { ReactNode } from '@theia/core/shared/react';
import { nls } from '@theia/core/lib/common/nls';
import * as React from '@theia/core/shared/react';
@injectable()
export class TextPartRenderer implements ChatResponsePartRenderer<ChatResponseContent> {
canHandle(_reponse: ChatResponseContent): number {
// this is the fallback renderer
return 1;
}
render(response: ChatResponseContent): ReactNode {
if (response && ChatResponseContent.hasAsString(response)) {
return <span>{response.asString()}</span>;
}
return <span>
{nls.localize('theia/ai/chat-ui/text-part-renderer/cantDisplay',
"Can't display response, please check your ChatResponsePartRenderers!")} {JSON.stringify(response)}</span>;
}
}

View File

@@ -0,0 +1,44 @@
// *****************************************************************************
// 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 { ChatResponsePartRenderer } from '../chat-response-part-renderer';
import { injectable } from '@theia/core/shared/inversify';
import { ChatResponseContent, ThinkingChatResponseContent } from '@theia/ai-chat/lib/common';
import { ReactNode } from '@theia/core/shared/react';
import { nls } from '@theia/core/lib/common/nls';
import * as React from '@theia/core/shared/react';
@injectable()
export class ThinkingPartRenderer implements ChatResponsePartRenderer<ThinkingChatResponseContent> {
canHandle(response: ChatResponseContent): number {
if (ThinkingChatResponseContent.is(response)) {
return 10;
}
return -1;
}
render(response: ThinkingChatResponseContent): ReactNode {
return (
<div className='theia-thinking'>
<details>
<summary>{nls.localize('theia/ai/chat-ui/thinking-part-renderer/thinking', 'Thinking')}</summary>
<pre>{response.content}</pre>
</details>
</div>
);
}
}

View File

@@ -0,0 +1,108 @@
// *****************************************************************************
// Copyright (C) 2026 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 { expect } from 'chai';
import { ContextMenuRenderer } from '@theia/core/lib/browser';
import { ConfirmationScope, ToolConfirmationCallbacks, ToolConfirmationActionsProps, ToolConfirmationProps } from './tool-confirmation';
const mockContextMenuRenderer = {} as ContextMenuRenderer;
describe('Tool Confirmation Types', () => {
describe('ConfirmationScope', () => {
it('should accept valid scopes', () => {
const scopes: ConfirmationScope[] = ['once', 'session', 'forever'];
expect(scopes).to.have.length(3);
});
});
describe('ToolConfirmationCallbacks', () => {
it('should define required callback properties', () => {
const callbacks: ToolConfirmationCallbacks = {
onAllow: (_scope: ConfirmationScope) => { },
onDeny: (_scope: ConfirmationScope, _reason?: string) => { }
};
expect(callbacks.onAllow).to.be.a('function');
expect(callbacks.onDeny).to.be.a('function');
});
it('should allow optional toolRequest', () => {
const callbacks: ToolConfirmationCallbacks = {
toolRequest: { id: 'test', name: 'test', handler: async () => '', parameters: { type: 'object', properties: {} } },
onAllow: () => { },
onDeny: () => { }
};
expect(callbacks.toolRequest).to.exist;
});
});
describe('ToolConfirmationActionsProps', () => {
it('should extend ToolConfirmationCallbacks with toolName', () => {
const props: ToolConfirmationActionsProps = {
toolName: 'testTool',
onAllow: () => { },
onDeny: () => { },
contextMenuRenderer: mockContextMenuRenderer
};
expect(props.toolName).to.equal('testTool');
});
it('should support confirmAlwaysAllow string in toolRequest', () => {
const props: ToolConfirmationActionsProps = {
toolName: 'dangerousTool',
toolRequest: {
id: 'test',
name: 'test',
handler: async () => '',
parameters: { type: 'object', properties: {} },
confirmAlwaysAllow: 'This tool can modify system files.'
},
onAllow: () => { },
onDeny: () => { },
contextMenuRenderer: mockContextMenuRenderer
};
expect(props.toolRequest?.confirmAlwaysAllow).to.equal('This tool can modify system files.');
});
it('should support confirmAlwaysAllow boolean in toolRequest', () => {
const props: ToolConfirmationActionsProps = {
toolName: 'dangerousTool',
toolRequest: {
id: 'test',
name: 'test',
handler: async () => '',
parameters: { type: 'object', properties: {} },
confirmAlwaysAllow: true
},
onAllow: () => { },
onDeny: () => { },
contextMenuRenderer: mockContextMenuRenderer
};
expect(props.toolRequest?.confirmAlwaysAllow).to.be.true;
});
});
describe('ToolConfirmationProps', () => {
it('should pick toolRequest from ToolConfirmationCallbacks', () => {
const props: ToolConfirmationProps = {
response: { kind: 'toolCall', id: 'test', name: 'test' } as ToolConfirmationProps['response'],
onAllow: () => { },
onDeny: () => { },
contextMenuRenderer: mockContextMenuRenderer
};
expect(props.toolRequest).to.be.undefined;
});
});
});

View File

@@ -0,0 +1,459 @@
// *****************************************************************************
// 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 * as React from '@theia/core/shared/react';
import { nls } from '@theia/core/lib/common/nls';
import { codicon, ContextMenuRenderer } from '@theia/core/lib/browser';
import { ToolCallChatResponseContent } from '@theia/ai-chat/lib/common';
import { ToolRequest } from '@theia/ai-core';
import { CommandMenu, ContextExpressionMatcher, MenuPath } from '@theia/core/lib/common/menu';
import { GroupImpl } from '@theia/core/lib/browser/menu/composite-menu-node';
import { ToolConfirmationMode as ToolConfirmationPreferenceMode } from '@theia/ai-chat/lib/common/chat-tool-preferences';
import { ToolConfirmationManager } from '@theia/ai-chat/lib/browser/chat-tool-preference-bindings';
export type ToolConfirmationState = 'waiting' | 'allowed' | 'denied' | 'rejected';
export type ConfirmationScope = 'once' | 'session' | 'forever';
export interface ToolConfirmationCallbacks {
toolRequest?: ToolRequest;
onAllow: (scope: ConfirmationScope) => void;
onDeny: (scope: ConfirmationScope, reason?: string) => void;
}
export interface ToolConfirmationActionsProps extends ToolConfirmationCallbacks {
toolName: string;
contextMenuRenderer: ContextMenuRenderer;
}
class InlineActionMenuNode implements CommandMenu {
constructor(
readonly id: string,
readonly label: string,
private readonly action: () => void,
readonly sortString: string,
readonly icon?: string
) { }
isVisible<T>(_effectiveMenuPath: MenuPath, _contextMatcher: ContextExpressionMatcher<T>, _context: T | undefined): boolean {
return true;
}
isEnabled(): boolean {
return true;
}
isToggled(): boolean {
return false;
}
async run(): Promise<void> {
this.action();
}
}
export const ToolConfirmationActions: React.FC<ToolConfirmationActionsProps> = ({
toolName,
toolRequest,
onAllow,
onDeny,
contextMenuRenderer
}) => {
const [allowScope, setAllowScope] = React.useState<ConfirmationScope>('once');
const [denyScope, setDenyScope] = React.useState<ConfirmationScope>('once');
const [showAlwaysAllowConfirmation, setShowAlwaysAllowConfirmation] = React.useState(false);
const [showDenyReasonInput, setShowDenyReasonInput] = React.useState(false);
const [denyReason, setDenyReason] = React.useState('');
// eslint-disable-next-line no-null/no-null
const denyReasonInputRef = React.useRef<HTMLInputElement>(null);
const handleAllow = React.useCallback(() => {
if ((allowScope === 'forever' || allowScope === 'session') && toolRequest?.confirmAlwaysAllow) {
setShowAlwaysAllowConfirmation(true);
return;
}
onAllow(allowScope);
}, [onAllow, allowScope, toolRequest]);
const handleConfirmAlwaysAllow = React.useCallback(() => {
setShowAlwaysAllowConfirmation(false);
onAllow(allowScope);
}, [onAllow, allowScope]);
const handleCancelAlwaysAllow = React.useCallback(() => {
setShowAlwaysAllowConfirmation(false);
}, []);
const handleDeny = React.useCallback(() => {
onDeny(denyScope);
}, [onDeny, denyScope]);
const handleDenyWithReason = React.useCallback(() => {
setShowDenyReasonInput(true);
}, []);
const handleSubmitDenyReason = React.useCallback(() => {
onDeny('once', denyReason.trim() || undefined);
setShowDenyReasonInput(false);
setDenyReason('');
}, [onDeny, denyReason]);
const handleCancelDenyReason = React.useCallback(() => {
setShowDenyReasonInput(false);
setDenyReason('');
}, []);
const handleDenyReasonKeyDown = React.useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSubmitDenyReason();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancelDenyReason();
}
}, [handleSubmitDenyReason, handleCancelDenyReason]);
React.useEffect(() => {
if (showDenyReasonInput && denyReasonInputRef.current) {
denyReasonInputRef.current.focus();
}
}, [showDenyReasonInput]);
const SCOPES: ConfirmationScope[] = ['once', 'session', 'forever'];
const scopeLabel = (type: 'allow' | 'deny', scope: ConfirmationScope): string => {
if (type === 'allow') {
switch (scope) {
case 'once': return nls.localizeByDefault('Allow');
case 'session': return nls.localize('theia/ai/chat-ui/toolconfirmation/allow-session', 'Allow for this Chat');
case 'forever': return nls.localizeByDefault('Always Allow');
}
} else {
switch (scope) {
case 'once': return nls.localizeByDefault('Deny');
case 'session': return nls.localize('theia/ai/chat-ui/toolconfirmation/deny-session', 'Deny for this Chat');
case 'forever': return nls.localize('theia/ai/chat-ui/toolconfirmation/deny-forever', 'Always Deny');
}
}
};
const getAlwaysAllowWarning = (): string => {
if (typeof toolRequest?.confirmAlwaysAllow === 'string') {
return toolRequest.confirmAlwaysAllow;
}
return nls.localize(
'theia/ai/chat-ui/toolconfirmation/alwaysAllowGenericWarning',
'This tool requires confirmation before auto-approval can be enabled. ' +
'Once enabled, all future invocations will execute without confirmation. ' +
'Only enable this if you trust this tool and understand the potential risks.'
);
};
const showDropdownMenu = React.useCallback((
event: React.MouseEvent<HTMLButtonElement>,
type: 'allow' | 'deny',
selectedScope: ConfirmationScope,
setScope: (scope: ConfirmationScope) => void
) => {
const otherScopes = SCOPES.filter(s => s !== selectedScope);
const menu = new GroupImpl('tool-confirmation-dropdown');
const scopesGroup = new GroupImpl('scopes', '1');
otherScopes.forEach((scope, index) => {
scopesGroup.addNode(new InlineActionMenuNode(
`tool-confirmation-${type}-${scope}`,
scopeLabel(type, scope),
() => setScope(scope),
String(index)
));
});
menu.addNode(scopesGroup);
if (type === 'deny') {
const reasonGroup = new GroupImpl('reason', '2');
reasonGroup.addNode(new InlineActionMenuNode(
'tool-confirmation-deny-with-reason',
nls.localize('theia/ai/chat-ui/toolconfirmation/deny-with-reason', 'Deny with reason...'),
handleDenyWithReason,
'0'
));
menu.addNode(reasonGroup);
}
const splitButtonContainer = event.currentTarget.parentElement;
const containerRect = splitButtonContainer?.getBoundingClientRect() ?? event.currentTarget.getBoundingClientRect();
contextMenuRenderer.render({
menuPath: ['tool-confirmation-context-menu'],
menu,
anchor: { x: containerRect.left, y: containerRect.bottom },
context: event.currentTarget,
skipSingleRootNode: true
});
}, [contextMenuRenderer, handleDenyWithReason, scopeLabel]);
const renderSplitButton = (type: 'allow' | 'deny'): React.ReactNode => {
const selectedScope = type === 'allow' ? allowScope : denyScope;
const setScope = type === 'allow' ? setAllowScope : setDenyScope;
const handleMain = type === 'allow' ? handleAllow : handleDeny;
return (
<div
className={`theia-tool-confirmation-split-button ${type}`}
style={{ display: 'inline-flex', position: 'relative' }}
>
<button
className={`theia-button ${type === 'allow' ? 'main' : 'secondary'} theia-tool-confirmation-main-btn`}
onClick={handleMain}
>
{scopeLabel(type, selectedScope)}
</button>
<button
className={`theia-button ${type === 'allow' ? 'main' : 'secondary'} theia-tool-confirmation-chevron-btn`}
onClick={e => showDropdownMenu(e, type, selectedScope, setScope)}
aria-haspopup="true"
tabIndex={0}
title={type === 'allow'
? nls.localize('theia/ai/chat-ui/toolconfirmation/allow-options-dropdown-tooltip', 'More Allow Options')
: nls.localize('theia/ai/chat-ui/toolconfirmation/deny-options-dropdown-tooltip', 'More Deny Options')}
>
<span className={codicon('chevron-down')}></span>
</button>
</div>
);
};
if (showAlwaysAllowConfirmation) {
return (
<div className="theia-tool-confirmation-always-allow-modal">
<div className="theia-tool-confirmation-header">
<span className={codicon('warning')}></span>
{nls.localize('theia/ai/chat-ui/toolconfirmation/alwaysAllowTitle', 'Enable Auto-Approval for "{0}"?', toolName)}
</div>
<div className="theia-tool-confirmation-warning">
{getAlwaysAllowWarning()}
</div>
<div className="theia-tool-confirmation-actions">
<button
className="theia-button secondary"
onClick={handleCancelAlwaysAllow}
>
{nls.localizeByDefault('Cancel')}
</button>
<button
className="theia-button main"
onClick={handleConfirmAlwaysAllow}
>
{nls.localize('theia/ai/chat-ui/toolconfirmation/alwaysAllowConfirm', 'I understand, enable auto-approval')}
</button>
</div>
</div>
);
}
if (showDenyReasonInput) {
return (
<div className="theia-tool-confirmation-deny-reason">
<input
ref={denyReasonInputRef}
type="text"
className="theia-input theia-tool-confirmation-deny-reason-input"
placeholder={nls.localize('theia/ai/chat-ui/toolconfirmation/deny-reason-placeholder', 'Enter reason for denial...')}
value={denyReason}
onChange={e => setDenyReason(e.target.value)}
onKeyDown={handleDenyReasonKeyDown}
/>
<div className="theia-tool-confirmation-deny-reason-actions">
<button
className="theia-button secondary"
onClick={handleCancelDenyReason}
>
{nls.localizeByDefault('Cancel')}
</button>
<button
className="theia-button main"
onClick={handleSubmitDenyReason}
>
{nls.localizeByDefault('Deny')}
</button>
</div>
</div>
);
}
return (
<div className="theia-tool-confirmation-actions">
{renderSplitButton('deny')}
{renderSplitButton('allow')}
</div>
);
};
export interface ToolConfirmationProps extends Pick<ToolConfirmationCallbacks, 'toolRequest'> {
response: ToolCallChatResponseContent;
onAllow: (scope?: ConfirmationScope) => void;
onDeny: (scope?: ConfirmationScope, reason?: string) => void;
contextMenuRenderer: ContextMenuRenderer;
}
export const ToolConfirmation: React.FC<ToolConfirmationProps> = ({ response, toolRequest, onAllow, onDeny, contextMenuRenderer }) => {
const [state, setState] = React.useState<ToolConfirmationState>('waiting');
const handleAllow = React.useCallback((scope: ConfirmationScope) => {
setState('allowed');
onAllow(scope);
}, [onAllow]);
const handleDeny = React.useCallback((scope: ConfirmationScope, reason?: string) => {
setState('denied');
onDeny(scope, reason);
}, [onDeny]);
if (state === 'allowed') {
return (
<div className="theia-tool-confirmation-status allowed">
<span className={codicon('check')}></span> {nls.localize('theia/ai/chat-ui/toolconfirmation/allowed', 'Tool execution allowed')}
</div>
);
}
if (state === 'denied') {
return (
<div className="theia-tool-confirmation-status denied">
<span className={codicon('close')}></span> {nls.localize('theia/ai/chat-ui/toolconfirmation/denied', 'Tool execution denied')}
</div>
);
}
return (
<div className="theia-tool-confirmation">
<div className="theia-tool-confirmation-header">
<span className={codicon('shield')}></span> {nls.localize('theia/ai/chat-ui/toolconfirmation/header', 'Confirm Tool Execution')}
</div>
<div className="theia-tool-confirmation-info">
<div className="theia-tool-confirmation-name">
<span className="label">{nls.localizeByDefault('Tool')}:</span>
<span className="value">{response.name}</span>
</div>
</div>
<ToolConfirmationActions
toolName={response.name ?? 'unknown'}
toolRequest={toolRequest}
onAllow={handleAllow}
onDeny={handleDeny}
contextMenuRenderer={contextMenuRenderer}
/>
</div>
);
};
export interface WithToolCallConfirmationProps {
response: ToolCallChatResponseContent;
confirmationMode: ToolConfirmationPreferenceMode;
toolConfirmationManager: ToolConfirmationManager;
toolRequest?: ToolRequest;
chatId: string;
requestCanceled: boolean;
contextMenuRenderer: ContextMenuRenderer;
}
export function withToolCallConfirmation<P extends object>(
WrappedComponent: React.ComponentType<P>
): React.FC<P & WithToolCallConfirmationProps> {
const WithConfirmation: React.FC<P & WithToolCallConfirmationProps> = props => {
const {
response,
confirmationMode,
toolConfirmationManager,
toolRequest,
chatId,
requestCanceled,
contextMenuRenderer,
...componentProps
} = props;
const [confirmationState, setConfirmationState] = React.useState<ToolConfirmationState>('waiting');
React.useEffect(() => {
if (confirmationMode === ToolConfirmationPreferenceMode.ALWAYS_ALLOW) {
response.confirm();
setConfirmationState('allowed');
return;
} else if (confirmationMode === ToolConfirmationPreferenceMode.DISABLED) {
response.deny();
setConfirmationState('denied');
return;
}
response.confirmed
.then(confirmed => {
setConfirmationState(confirmed === true ? 'allowed' : 'denied');
})
.catch(() => {
setConfirmationState('rejected');
});
}, [response, confirmationMode]);
const handleAllow = React.useCallback((scope: ConfirmationScope = 'once') => {
if (scope === 'forever' && response.name) {
toolConfirmationManager.setConfirmationMode(response.name, ToolConfirmationPreferenceMode.ALWAYS_ALLOW, toolRequest);
} else if (scope === 'session' && response.name) {
toolConfirmationManager.setSessionConfirmationMode(response.name, ToolConfirmationPreferenceMode.ALWAYS_ALLOW, chatId);
}
response.confirm();
}, [response, toolConfirmationManager, chatId, toolRequest]);
const handleDeny = React.useCallback((scope: ConfirmationScope = 'once', reason?: string) => {
if (scope === 'forever' && response.name) {
toolConfirmationManager.setConfirmationMode(response.name, ToolConfirmationPreferenceMode.DISABLED);
} else if (scope === 'session' && response.name) {
toolConfirmationManager.setSessionConfirmationMode(response.name, ToolConfirmationPreferenceMode.DISABLED, chatId);
}
response.deny(reason);
}, [response, toolConfirmationManager, chatId]);
if (confirmationState === 'rejected' || (requestCanceled && !response.finished)) {
return (
<div className="theia-tool-confirmation-status rejected">
<span className={codicon('error')}></span> {nls.localize('theia/ai/chat-ui/toolconfirmation/canceled', 'Tool execution canceled')}
</div>
);
}
if (confirmationState === 'denied') {
return (
<div className="theia-tool-confirmation-status denied">
<span className={codicon('error')}></span> {nls.localize('theia/ai/chat-ui/toolconfirmation/executionDenied', 'Tool execution denied')}
</div>
);
}
if (confirmationState === 'waiting' && !requestCanceled && !response.finished) {
return (
<ToolConfirmation
response={response}
toolRequest={toolRequest}
onAllow={handleAllow}
onDeny={handleDeny}
contextMenuRenderer={contextMenuRenderer}
/>
);
}
return <WrappedComponent {...componentProps as P} />;
};
WithConfirmation.displayName = `withToolCallConfirmation(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`;
return WithConfirmation;
}

View File

@@ -0,0 +1,286 @@
// *****************************************************************************
// Copyright (C) 2024 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 { ChatResponsePartRenderer } from '../chat-response-part-renderer';
import { inject, injectable } from '@theia/core/shared/inversify';
import { ChatResponseContent, ToolCallChatResponseContent } from '@theia/ai-chat/lib/common';
import { ReactNode } from '@theia/core/shared/react';
import { nls } from '@theia/core/lib/common/nls';
import { codicon, ContextMenuRenderer, OpenerService } from '@theia/core/lib/browser';
import * as React from '@theia/core/shared/react';
import { ToolConfirmation, ToolConfirmationState } from './tool-confirmation';
import { ToolConfirmationMode } from '@theia/ai-chat/lib/common/chat-tool-preferences';
import { ResponseNode } from '../chat-tree-view';
import { useMarkdownRendering } from './markdown-part-renderer';
import { ToolCallResult, ToolInvocationRegistry, ToolRequest } from '@theia/ai-core';
import { ToolConfirmationManager } from '@theia/ai-chat/lib/browser/chat-tool-preference-bindings';
@injectable()
export class ToolCallPartRenderer implements ChatResponsePartRenderer<ToolCallChatResponseContent> {
@inject(ToolConfirmationManager)
protected toolConfirmationManager: ToolConfirmationManager;
@inject(OpenerService)
protected openerService: OpenerService;
@inject(ToolInvocationRegistry)
protected toolInvocationRegistry: ToolInvocationRegistry;
@inject(ContextMenuRenderer)
protected contextMenuRenderer: ContextMenuRenderer;
canHandle(response: ChatResponseContent): number {
if (ToolCallChatResponseContent.is(response)) {
return 10;
}
return -1;
}
render(response: ToolCallChatResponseContent, parentNode: ResponseNode): ReactNode {
const chatId = parentNode.sessionId;
const toolRequest = response.name ? this.toolInvocationRegistry.getFunction(response.name) : undefined;
const confirmationMode = response.name ? this.getToolConfirmationSettings(response.name, chatId, toolRequest) : ToolConfirmationMode.DISABLED;
return <ToolCallContent
response={response}
confirmationMode={confirmationMode}
toolConfirmationManager={this.toolConfirmationManager}
toolRequest={toolRequest}
chatId={chatId}
renderCollapsibleArguments={this.renderCollapsibleArguments.bind(this)}
responseRenderer={this.renderResult.bind(this)}
requestCanceled={parentNode.response.isCanceled}
contextMenuRenderer={this.contextMenuRenderer} />;
}
protected renderResult(response: ToolCallChatResponseContent): ReactNode {
const result = this.tryParse(response.result);
if (!result) {
return undefined;
}
if (typeof result === 'string') {
return <pre>{JSON.stringify(result, undefined, 2)}</pre>;
}
if ('content' in result) {
return <div className='theia-toolCall-response-content'>
{result.content.map((content, idx) => {
switch (content.type) {
case 'image': {
return <div key={`content-${idx}-${content.type}`} className='theia-toolCall-image-result'>
<img src={`data:${content.mimeType};base64,${content.base64data}`} />
</div>;
}
case 'text': {
return <div key={`content-${idx}-${content.type}`} className='theia-toolCall-text-result'>
<MarkdownRender text={content.text} openerService={this.openerService} />
</div>;
}
case 'audio':
case 'error':
default: {
return <div key={`content-${idx}-${content.type}`} className='theia-toolCall-default-result'><pre>{JSON.stringify(response, undefined, 2)}</pre></div>;
}
}
})}
</div>;
}
return <pre>{JSON.stringify(result, undefined, 2)}</pre>;
}
private tryParse(result: ToolCallResult): ToolCallResult {
if (!result) {
return undefined;
}
try {
return typeof result === 'string' ? JSON.parse(result) : result;
} catch (error) {
return result;
}
}
protected getToolConfirmationSettings(responseId: string, chatId: string, toolRequest?: ToolRequest): ToolConfirmationMode {
return this.toolConfirmationManager.getConfirmationMode(responseId, chatId, toolRequest);
}
protected renderCollapsibleArguments(args: string | undefined): ReactNode {
if (!args || !args.trim() || args.trim() === '{}') {
return undefined;
}
return (
<details className="collapsible-arguments">
<summary className="collapsible-arguments-summary">...</summary>
<span>{this.prettyPrintArgs(args)}</span>
</details>
);
}
private prettyPrintArgs(args: string): string {
try {
return JSON.stringify(JSON.parse(args), undefined, 2);
} catch (e) {
// fall through
return args;
}
}
}
const Spinner = () => (
<span className={`${codicon('loading')} theia-animation-spin`}></span>
);
interface ToolCallContentProps {
response: ToolCallChatResponseContent;
confirmationMode: ToolConfirmationMode;
toolConfirmationManager: ToolConfirmationManager;
toolRequest?: ToolRequest;
chatId: string;
renderCollapsibleArguments: (args: string | undefined) => ReactNode;
responseRenderer: (response: ToolCallChatResponseContent) => ReactNode | undefined;
requestCanceled: boolean;
contextMenuRenderer: ContextMenuRenderer;
}
/**
* A function component to handle tool call rendering and confirmation
*/
const ToolCallContent: React.FC<ToolCallContentProps> = ({
response,
confirmationMode,
toolConfirmationManager,
toolRequest,
chatId,
responseRenderer,
renderCollapsibleArguments,
requestCanceled,
contextMenuRenderer
}) => {
const [confirmationState, setConfirmationState] = React.useState<ToolConfirmationState>('waiting');
const [rejectionReason, setRejectionReason] = React.useState<unknown>(undefined);
const formatReason = (reason: unknown): string => {
if (!reason) {
return '';
}
if (reason instanceof Error) {
return reason.message;
}
if (typeof reason === 'string') {
return reason;
}
try {
return JSON.stringify(reason);
} catch (e) {
return String(reason);
}
};
React.useEffect(() => {
if (confirmationMode === ToolConfirmationMode.ALWAYS_ALLOW) {
response.confirm();
setConfirmationState('allowed');
return;
} else if (confirmationMode === ToolConfirmationMode.DISABLED) {
response.deny();
setConfirmationState('denied');
return;
}
response.confirmed
.then(confirmed => {
if (confirmed === true) {
setConfirmationState('allowed');
} else {
setConfirmationState('denied');
}
})
.catch(reason => {
setRejectionReason(reason);
setConfirmationState('rejected');
});
}, [response, confirmationMode]);
const handleAllow = React.useCallback((mode: 'once' | 'session' | 'forever' = 'once') => {
if (mode === 'forever' && response.name) {
toolConfirmationManager.setConfirmationMode(response.name, ToolConfirmationMode.ALWAYS_ALLOW, toolRequest);
} else if (mode === 'session' && response.name) {
toolConfirmationManager.setSessionConfirmationMode(response.name, ToolConfirmationMode.ALWAYS_ALLOW, chatId);
}
response.confirm();
}, [response, toolConfirmationManager, chatId, toolRequest]);
const handleDeny = React.useCallback((mode: 'once' | 'session' | 'forever' = 'once', reason?: string) => {
if (mode === 'forever' && response.name) {
toolConfirmationManager.setConfirmationMode(response.name, ToolConfirmationMode.DISABLED);
} else if (mode === 'session' && response.name) {
toolConfirmationManager.setSessionConfirmationMode(response.name, ToolConfirmationMode.DISABLED, chatId);
}
response.deny(reason);
}, [response, toolConfirmationManager, chatId]);
const reasonText = formatReason(rejectionReason);
return (
<div className='theia-toolCall'>
{confirmationState === 'rejected' ? (
<span className='theia-toolCall-rejected'>
<span className={codicon('error')}></span> {nls.localize('theia/ai/chat-ui/toolcall-part-renderer/rejected', 'Execution canceled')}: {response.name}
{reasonText ? <span> {reasonText}</span> : undefined}
</span>
) : requestCanceled && !response.finished ? (
<span className='theia-toolCall-rejected'>
<span className={codicon('error')}></span> {nls.localize('theia/ai/chat-ui/toolcall-part-renderer/rejected', 'Execution canceled')}: {response.name}
</span>
) : confirmationState === 'denied' ? (
<span className='theia-toolCall-denied'>
<span className={codicon('error')}></span> {nls.localize('theia/ai/chat-ui/toolcall-part-renderer/denied', 'Execution denied')}: {response.name}
</span>
) : response.finished ? (
<details className='theia-toolCall-finished'>
<summary>
{nls.localize('theia/ai/chat-ui/toolcall-part-renderer/finished', 'Ran')} {response.name}
({renderCollapsibleArguments(response.arguments)})
</summary>
<div className='theia-toolCall-response-result'>
{responseRenderer(response)}
</div>
</details>
) : (
confirmationState === 'allowed' && !requestCanceled && (
<span className='theia-toolCall-allowed'>
<Spinner /> {nls.localizeByDefault('Running')} {response.name}
</span>
)
)}
{confirmationState === 'waiting' && !requestCanceled && !response.finished && (
<span className='theia-toolCall-waiting'>
<ToolConfirmation
response={response}
toolRequest={toolRequest}
onAllow={handleAllow}
onDeny={handleDeny}
contextMenuRenderer={contextMenuRenderer}
/>
</span>
)}
</div>
);
};
const MarkdownRender = ({ text, openerService }: { text: string; openerService: OpenerService }) => {
const ref = useMarkdownRendering(text, openerService);
return <div ref={ref}></div>;
};

View File

@@ -0,0 +1,52 @@
// *****************************************************************************
// 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 { ChatResponsePartRenderer } from '../chat-response-part-renderer';
import { ChatResponseContent, UnknownChatResponseContent } from '@theia/ai-chat/lib/common';
import { ReactNode } from '@theia/core/shared/react';
import { nls } from '@theia/core/lib/common/nls';
import * as React from '@theia/core/shared/react';
import { codicon } from '@theia/core/lib/browser';
@injectable()
export class UnknownPartRenderer implements ChatResponsePartRenderer<UnknownChatResponseContent> {
canHandle(response: ChatResponseContent): number {
return response.kind === 'unknown' ? 10 : -1;
}
render(response: UnknownChatResponseContent): ReactNode {
const fallbackMessage = response.fallbackMessage || response.asString?.() || '';
return (
<div className="theia-chat-unknown-content">
<div className="theia-chat-unknown-content-warning">
<i className={codicon('warning')} />
<span>
{nls.localize(
'theia/ai/chat-ui/unknown-part-renderer/contentNotRestoreable',
"This content (type '{0}') could not be fully restored. It may be from an extension that is no longer available.",
response.originalKind
)}
</span>
</div>
<div className="theia-chat-unknown-content-fallback">
{fallbackMessage}
</div>
</div>
);
}
}

View File

@@ -0,0 +1,38 @@
// *****************************************************************************
// Copyright (C) 2024 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 { createTreeContainer, TreeProps } from '@theia/core/lib/browser';
import { interfaces } from '@theia/core/shared/inversify';
import { ChatViewTreeWidget } from './chat-view-tree-widget';
const CHAT_VIEW_TREE_PROPS = {
multiSelect: false,
search: false,
viewProps: {
// Let Virtuoso handle auto-scroll natively: follow new output only when
// the user is already at the bottom, and render items from the top
// (prevents short conversations from anchoring to the bottom of the panel).
followOutput: true,
},
} as TreeProps;
export function createChatViewTreeWidget(parent: interfaces.Container): ChatViewTreeWidget {
const child = createTreeContainer(parent, {
props: CHAT_VIEW_TREE_PROPS,
widget: ChatViewTreeWidget,
});
return child.get(ChatViewTreeWidget);
}

View File

@@ -0,0 +1,104 @@
// *****************************************************************************
// 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 { inject, injectable, optional, postConstruct } from '@theia/core/shared/inversify';
import { AIChatInputWidget, type AIChatInputConfiguration } from '../chat-input-widget';
import type { EditableRequestNode } from './chat-view-tree-widget';
import { URI } from '@theia/core';
import { CHAT_VIEW_LANGUAGE_EXTENSION } from '../chat-view-language-contribution';
import type { ChatRequestModel, EditableChatRequestModel, ChatHierarchyBranch } from '@theia/ai-chat';
import type { AIVariableResolutionRequest } from '@theia/ai-core';
import { Key } from '@theia/core/lib/browser';
export const AIChatTreeInputConfiguration = Symbol('AIChatTreeInputConfiguration');
export interface AIChatTreeInputConfiguration extends AIChatInputConfiguration { }
export const AIChatTreeInputArgs = Symbol('AIChatTreeInputArgs');
export interface AIChatTreeInputArgs {
node: EditableRequestNode;
/**
* The branch of the chat tree for this request node (used by the input widget for state tracking).
*/
branch?: ChatHierarchyBranch;
initialValue?: string;
onQuery: (query: string) => Promise<void>;
onUnpin?: () => void;
onCancel?: (requestModel: ChatRequestModel) => void;
onDeleteChangeSet?: (requestModel: ChatRequestModel) => void;
onDeleteChangeSetElement?: (requestModel: ChatRequestModel, index: number) => void;
}
export const AIChatTreeInputFactory = Symbol('AIChatTreeInputFactory');
export type AIChatTreeInputFactory = (args: AIChatTreeInputArgs) => AIChatTreeInputWidget;
@injectable()
export class AIChatTreeInputWidget extends AIChatInputWidget {
public static override ID = 'chat-tree-input-widget';
@inject(AIChatTreeInputArgs)
protected readonly args: AIChatTreeInputArgs;
@inject(AIChatTreeInputConfiguration) @optional()
protected override readonly configuration: AIChatTreeInputConfiguration | undefined;
get requestNode(): EditableRequestNode {
return this.args.node;
}
get request(): EditableChatRequestModel {
return this.requestNode.request;
}
@postConstruct()
protected override init(): void {
super.init();
this.updateBranch();
const request = this.requestNode.request;
this.toDispose.push(request.session.onDidChange(() => {
this.updateBranch();
}));
this.addKeyListener(this.node, Key.ESCAPE, () => {
this.request.cancelEdit();
});
this.editorReady.promise.then(() => {
if (this.editorRef) {
this.editorRef.focus();
}
});
}
protected updateBranch(): void {
this.branch = this.args.branch ?? this.requestNode.branch;
}
protected override getResourceUri(): URI {
return new URI(`ai-chat:/${this.requestNode.id}-input.${CHAT_VIEW_LANGUAGE_EXTENSION}`);
}
override addContext(variable: AIVariableResolutionRequest): void {
this.request.editContextManager.addVariables(variable);
}
protected override getContext(): readonly AIVariableResolutionRequest[] {
return this.request.editContextManager.getVariables();
}
protected override deleteContextElement(index: number): void {
this.request.editContextManager.deleteVariables(index);
}
}

View File

@@ -0,0 +1,932 @@
// *****************************************************************************
// Copyright (C) 2024 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 {
ChatAgent,
ChatAgentService,
ChatModel,
ChatRequestModel,
ChatResponseContent,
ChatResponseModel,
ChatService,
EditableChatRequestModel,
ParsedChatRequestAgentPart,
ParsedChatRequestFunctionPart,
ParsedChatRequestVariablePart,
type ChatRequest,
type ChatHierarchyBranch,
} from '@theia/ai-chat';
import { ImageContextVariable } from '@theia/ai-chat/lib/common/image-context-variable';
import { AIVariableService } from '@theia/ai-core';
import { AIActivationService } from '@theia/ai-core/lib/browser';
import { CommandRegistry, ContributionProvider, Disposable, DisposableCollection, Emitter, Event } from '@theia/core';
import {
codicon,
CompositeTreeNode,
ContextMenuRenderer,
HoverService,
Key,
KeyCode,
NodeProps,
OpenerService,
TreeModel,
TreeNode,
TreeProps,
TreeWidget,
Widget,
type ReactWidget
} from '@theia/core/lib/browser';
import { ContextKey, ContextKeyService } from '@theia/core/lib/browser/context-key-service';
import { nls } from '@theia/core/lib/common/nls';
import {
inject,
injectable,
named,
optional,
postConstruct
} from '@theia/core/shared/inversify';
import * as React from '@theia/core/shared/react';
import { ChatNodeToolbarActionContribution } from '../chat-node-toolbar-action-contribution';
import { ChatResponsePartRenderer } from '../chat-response-part-renderer';
import { useMarkdownRendering } from '../chat-response-renderer/markdown-part-renderer';
import { ProgressMessage } from '../chat-progress-message';
import { AIChatTreeInputFactory, type AIChatTreeInputWidget } from './chat-view-tree-input-widget';
import { PromptVariantBadge } from './prompt-variant-badge';
// TODO Instead of directly operating on the ChatRequestModel we could use an intermediate view model
export interface RequestNode extends TreeNode {
request: ChatRequestModel,
branch: ChatHierarchyBranch,
sessionId: string
}
export const isRequestNode = (node: TreeNode): node is RequestNode => 'request' in node;
export interface EditableRequestNode extends RequestNode {
request: EditableChatRequestModel
}
export const isEditableRequestNode = (node: TreeNode): node is EditableRequestNode => isRequestNode(node) && EditableChatRequestModel.is(node.request);
// TODO Instead of directly operating on the ChatResponseModel we could use an intermediate view model
export interface ResponseNode extends TreeNode {
response: ChatResponseModel,
sessionId: string
}
export const isResponseNode = (node: TreeNode): node is ResponseNode => 'response' in node;
export function isEnterKey(e: React.KeyboardEvent): boolean {
return Key.ENTER.keyCode === KeyCode.createKeyCode(e.nativeEvent).key?.keyCode;
}
export const ChatWelcomeMessageProvider = Symbol('ChatWelcomeMessageProvider');
export interface ChatWelcomeMessageProvider {
renderWelcomeMessage?(): React.ReactNode;
renderDisabledMessage?(): React.ReactNode;
readonly hasReadyModels?: boolean;
readonly modelRequirementBypassed?: boolean;
readonly defaultAgent?: string;
readonly onStateChanged?: Event<void>;
}
@injectable()
export class ChatViewTreeWidget extends TreeWidget {
static readonly ID = 'chat-tree-widget';
static readonly CONTEXT_MENU = ['chat-tree-context-menu'];
@inject(ContributionProvider) @named(ChatResponsePartRenderer)
protected readonly chatResponsePartRenderers: ContributionProvider<ChatResponsePartRenderer<ChatResponseContent>>;
@inject(ContributionProvider) @named(ChatNodeToolbarActionContribution)
protected readonly chatNodeToolbarActionContributions: ContributionProvider<ChatNodeToolbarActionContribution>;
@inject(ChatAgentService)
protected chatAgentService: ChatAgentService;
@inject(AIVariableService)
protected readonly variableService: AIVariableService;
@inject(CommandRegistry)
protected commandRegistry: CommandRegistry;
@inject(OpenerService)
protected readonly openerService: OpenerService;
@inject(HoverService)
protected hoverService: HoverService;
@inject(ChatWelcomeMessageProvider) @optional()
protected welcomeMessageProvider?: ChatWelcomeMessageProvider;
@inject(AIChatTreeInputFactory)
protected inputWidgetFactory: AIChatTreeInputFactory;
@inject(AIActivationService)
protected readonly activationService: AIActivationService;
@inject(ChatService)
protected readonly chatService: ChatService;
@inject(ContextKeyService)
protected readonly contextKeyService: ContextKeyService;
protected chatResponseFocusKey: ContextKey<boolean>;
protected readonly onDidSubmitEditEmitter = new Emitter<ChatRequest>();
onDidSubmitEdit = this.onDidSubmitEditEmitter.event;
protected readonly chatInputs: Map<string, AIChatTreeInputWidget> = new Map();
protected _shouldScrollToEnd = true;
protected isEnabled = false;
protected chatModelId: string;
/** Tracks if we are at the bottom for showing the scroll-to-bottom button. */
protected atBottom = true;
/**
* Track the visibility of the scroll button with debounce logic. Used to prevent flickering when streaming tokens.
*/
protected _showScrollButton = false;
/**
* Timer for debouncing the scroll button activation (prevents flicker on auto-scroll).
* If user scrolls up, this delays showing the button in case auto-scroll-to-bottom kicks in.
*/
protected _scrollButtonDebounceTimer?: number;
/**
* Debounce period in ms before showing scroll-to-bottom button after scrolling up.
* Avoids flickering of the button during LLM token streaming.
*/
protected static readonly SCROLL_BUTTON_GRACE_PERIOD = 100;
onScrollLockChange?: (temporaryLocked: boolean) => void;
protected lastScrollTop = 0;
set shouldScrollToEnd(shouldScrollToEnd: boolean) {
this._shouldScrollToEnd = shouldScrollToEnd;
this.shouldScrollToRow = this._shouldScrollToEnd;
}
get shouldScrollToEnd(): boolean {
return this._shouldScrollToEnd;
}
constructor(
@inject(TreeProps) props: TreeProps,
@inject(TreeModel) model: TreeModel,
@inject(ContextMenuRenderer) contextMenuRenderer: ContextMenuRenderer
) {
super(props, model, contextMenuRenderer);
this.id = ChatViewTreeWidget.ID;
this.title.closable = false;
model.root = {
id: 'ChatTree',
name: 'ChatRootNode',
parent: undefined,
visible: false,
children: [],
} as CompositeTreeNode;
}
@postConstruct()
protected override init(): void {
super.init();
this.id = ChatViewTreeWidget.ID + '-treeContainer';
this.addClass('treeContainer');
this.chatResponseFocusKey = this.contextKeyService.createKey<boolean>('chatResponseFocus', false);
this.node.setAttribute('tabindex', '0');
this.node.setAttribute('aria-label', nls.localize('theia/ai/chat-ui/chatResponses', 'Chat responses'));
this.addEventListener(this.node, 'focusin', () => this.chatResponseFocusKey.set(true));
this.addEventListener(this.node, 'focusout', () => this.chatResponseFocusKey.set(false));
this.toDispose.pushAll([
this.toDisposeOnChatModelChange,
this.activationService.onDidChangeActiveStatus(change => {
this.chatInputs.forEach(widget => {
widget.setEnabled(change);
});
this.update();
}),
this.onScroll(scrollEvent => {
this.handleScrollEvent(scrollEvent);
})
]);
if (this.welcomeMessageProvider?.onStateChanged) {
this.toDispose.push(
this.welcomeMessageProvider.onStateChanged(() => {
this.update();
})
);
}
// Initialize lastScrollTop with current scroll position
this.lastScrollTop = this.getCurrentScrollTop(undefined);
}
public setEnabled(enabled: boolean): void {
this.isEnabled = enabled;
this.update();
}
protected handleScrollEvent(scrollEvent: unknown): void {
const currentScrollTop = this.getCurrentScrollTop(scrollEvent);
const isScrollingUp = currentScrollTop < this.lastScrollTop;
const isScrollingDown = currentScrollTop > this.lastScrollTop;
const isAtBottom = this.isScrolledToBottom();
const isAtAbsoluteBottom = this.isAtAbsoluteBottom();
// Asymmetric threshold logic to prevent jitter:
if (this.shouldScrollToEnd && isScrollingUp) {
if (!isAtAbsoluteBottom) {
this.setTemporaryScrollLock(true);
}
} else if (!this.shouldScrollToEnd && isAtBottom && isScrollingDown) {
this.setTemporaryScrollLock(false);
}
this.updateScrollToBottomButtonState(isAtBottom);
this.lastScrollTop = currentScrollTop;
}
/** Updates the scroll-to-bottom button state and handles debounce. */
protected updateScrollToBottomButtonState(isAtBottom: boolean): void {
const atBottomNow = isAtBottom; // Use isScrolledToBottom for threshold
if (atBottomNow !== this.atBottom) {
this.atBottom = atBottomNow;
if (this.atBottom) {
// We're at the bottom, hide the button immediately and clear any debounce timer.
this._showScrollButton = false;
if (this._scrollButtonDebounceTimer !== undefined) {
clearTimeout(this._scrollButtonDebounceTimer);
this._scrollButtonDebounceTimer = undefined;
}
this.update();
} else {
// User scrolled up; delay showing the scroll-to-bottom button.
if (this._scrollButtonDebounceTimer !== undefined) {
clearTimeout(this._scrollButtonDebounceTimer);
}
this._scrollButtonDebounceTimer = window.setTimeout(() => {
// Re-check: only show if we're still not at bottom
if (!this.atBottom) {
this._showScrollButton = true;
this.update();
}
this._scrollButtonDebounceTimer = undefined;
}, ChatViewTreeWidget.SCROLL_BUTTON_GRACE_PERIOD);
}
}
}
protected setTemporaryScrollLock(enabled: boolean): void {
// Immediately apply scroll lock changes without delay
this.onScrollLockChange?.(enabled);
// Update cached scrollToRow so that outdated values do not cause unwanted scrolling on update()
this.updateScrollToRow();
}
protected getCurrentScrollTop(scrollEvent: unknown): number {
// For virtualized trees, use the virtualized view's scroll state (most reliable)
if (this.props.virtualized !== false && this.view) {
const scrollState = this.getVirtualizedScrollState();
if (scrollState !== undefined) {
return scrollState.scrollTop;
}
}
// Try to extract scroll position from the scroll event
if (scrollEvent && typeof scrollEvent === 'object' && 'scrollTop' in scrollEvent) {
const scrollEventWithScrollTop = scrollEvent as { scrollTop: unknown };
const scrollTop = scrollEventWithScrollTop.scrollTop;
if (typeof scrollTop === 'number' && !isNaN(scrollTop)) {
return scrollTop;
}
}
// Last resort: use DOM scroll position
if (this.node && typeof this.node.scrollTop === 'number') {
return this.node.scrollTop;
}
return 0;
}
/**
* Returns true if the scroll position is at the absolute (1px tolerance) bottom of the scroll container.
* Handles both virtualized and non-virtualized scroll containers.
* Allows for a tiny floating point epsilon (1px).
*/
protected isAtAbsoluteBottom(): boolean {
let scrollTop: number = 0;
let scrollHeight: number = 0;
let clientHeight: number = 0;
const EPSILON = 1; // px
if (this.props.virtualized !== false && this.view) {
const state = this.getVirtualizedScrollState();
if (state) {
scrollTop = state.scrollTop;
scrollHeight = state.scrollHeight ?? 0;
clientHeight = state.clientHeight ?? 0;
}
} else if (this.node) {
scrollTop = this.node.scrollTop;
scrollHeight = this.node.scrollHeight;
clientHeight = this.node.clientHeight;
}
const diff = Math.abs(scrollTop + clientHeight - scrollHeight);
return diff <= EPSILON;
}
protected override renderTree(model: TreeModel): React.ReactNode {
if (!this.isEnabled) {
return this.renderDisabledMessage();
}
const tree = CompositeTreeNode.is(model.root) && model.root.children?.length > 0
? super.renderTree(model)
: this.renderWelcomeMessage();
return <React.Fragment>
{tree}
{this.renderScrollToBottomButton()}
</React.Fragment>;
}
/** Shows the scroll to bottom button if not at the bottom (debounced). */
protected renderScrollToBottomButton(): React.ReactNode {
if (!this._showScrollButton) {
return undefined;
}
// Down-arrow, Theia codicon, fixed overlay on widget
return <button
className="theia-ChatTree-ScrollToBottom codicon codicon-arrow-down"
title={nls.localize('theia/ai/chat-ui/chat-view-tree-widget/scrollToBottom', 'Jump to latest message')}
onClick={() => this.handleScrollToBottomButtonClick()}
/>;
}
/** Scrolls to the bottom row and updates atBottom state. */
protected handleScrollToBottomButtonClick(): void {
this.scrollToRow = this.rows.size;
this.atBottom = true;
this._showScrollButton = false;
if (this._scrollButtonDebounceTimer !== undefined) {
clearTimeout(this._scrollButtonDebounceTimer);
this._scrollButtonDebounceTimer = undefined;
}
this.update();
}
protected renderDisabledMessage(): React.ReactNode {
return this.welcomeMessageProvider?.renderDisabledMessage?.() ?? <></>;
}
protected renderWelcomeMessage(): React.ReactNode {
return this.welcomeMessageProvider?.renderWelcomeMessage?.() ?? <></>;
}
protected mapRequestToNode(branch: ChatHierarchyBranch): RequestNode {
return {
parent: this.model.root as CompositeTreeNode,
get id(): string {
return this.request.id;
},
get request(): ChatRequestModel {
return branch.get();
},
branch,
sessionId: this.chatModelId
};
}
protected mapResponseToNode(response: ChatResponseModel): ResponseNode {
return {
id: response.id,
parent: this.model.root as CompositeTreeNode,
response,
sessionId: this.chatModelId
};
}
protected readonly toDisposeOnChatModelChange = new DisposableCollection();
/**
* Tracks the ChatModel handed over.
* Tracking multiple chat models will result in a weird UI
*/
public trackChatModel(chatModel: ChatModel): void {
this.toDisposeOnChatModelChange.dispose();
this.recreateModelTree(chatModel);
chatModel.getRequests().forEach(request => {
if (!request.response.isComplete) {
request.response.onDidChange(() => this.scheduleUpdateScrollToRow());
}
});
this.toDisposeOnChatModelChange.pushAll([
Disposable.create(() => {
this.chatInputs.forEach(widget => widget.dispose());
this.chatInputs.clear();
}),
chatModel.onDidChange(event => {
if (event.kind === 'enableEdit') {
this.scrollToRow = this.rows.get(event.request.id)?.index;
this.update();
return;
} else if (event.kind === 'cancelEdit') {
this.disposeChatInputWidget(event.request);
this.scrollToRow = undefined;
this.update();
return;
} else if (event.kind === 'changeHierarchyBranch') {
this.scrollToRow = undefined;
}
this.recreateModelTree(chatModel);
if (event.kind === 'addRequest' && !event.request.response.isComplete) {
event.request.response.onDidChange(() => this.scheduleUpdateScrollToRow());
} else if (event.kind === 'submitEdit') {
event.branch.succeedingBranches().forEach(branch => {
this.disposeChatInputWidget(branch.get());
});
this.onDidSubmitEditEmitter.fire(
event.newRequest,
);
}
})
]);
}
protected disposeChatInputWidget(request: ChatRequestModel): void {
const widget = this.chatInputs.get(request.id);
if (widget) {
widget.dispose();
this.chatInputs.delete(request.id);
}
}
protected override getScrollToRow(): number | undefined {
// followOutput on the Virtuoso component handles auto-scrolling when the
// user is at the bottom — returning rows.size here would force an
// artificial anchor to the very bottom of the panel, which makes short
// conversations appear at the bottom instead of the top (Cursor-style).
// The scroll-to-bottom button still works via the direct this.scrollToRow
// assignment in handleScrollToBottomButtonClick().
return undefined;
}
protected async recreateModelTree(chatModel: ChatModel): Promise<void> {
if (CompositeTreeNode.is(this.model.root)) {
const nodes: TreeNode[] = [];
this.chatModelId = chatModel.id;
chatModel.getBranches().forEach(branch => {
const request = branch.get();
nodes.push(this.mapRequestToNode(branch));
nodes.push(this.mapResponseToNode(request.response));
});
this.model.root.children = nodes;
this.model.refresh();
}
}
protected override renderNode(
node: TreeNode,
props: NodeProps
): React.ReactNode {
if (!TreeNode.isVisible(node)) {
return undefined;
}
if (!(isRequestNode(node) || isResponseNode(node))) {
return super.renderNode(node, props);
}
const ariaLabel = isRequestNode(node)
? nls.localize('theia/ai/chat-ui/yourMessage', 'Your message')
: nls.localize('theia/ai/chat-ui/responseFrom', 'Response from {0}', this.getAgentLabel(node));
return <React.Fragment key={node.id}>
<div
className='theia-ChatNode'
role='article'
aria-label={ariaLabel}
onContextMenu={e => this.handleContextMenu(node, e)}
>
{this.renderAgent(node)}
{this.renderDetail(node)}
</div>
</React.Fragment>;
}
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
? this.chatNodeToolbarActionContributions.getContributions()
.flatMap(c => c.getToolbarActions(node))
.filter(action => this.commandRegistry.isEnabled(action.commandId, node))
.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))
: [];
const agentLabel = React.createRef<HTMLHeadingElement>();
const agentDescription = this.getAgent(node)?.description;
const promptVariantId = isResponseNode(node) ? node.response.promptVariantId : undefined;
const isPromptVariantEdited = isResponseNode(node) ? !!node.response.isPromptVariantEdited : false;
return <React.Fragment>
<div className='theia-ChatNodeHeader'>
<div className={`theia-AgentAvatar ${this.getAgentIconClassName(node)}`}></div>
<h3 ref={agentLabel}
className='theia-AgentLabel'
onMouseEnter={() => {
if (agentDescription) {
this.hoverService.requestHover({
content: agentDescription,
target: agentLabel.current!,
position: 'right'
});
}
}}>
{this.getAgentLabel(node)}
</h3>
{promptVariantId && (
<PromptVariantBadge
variantId={promptVariantId}
isEdited={isPromptVariantEdited}
hoverService={this.hoverService}
/>
)}
{inProgress && !waitingForInput &&
<span className='theia-ChatContentInProgress' role='status' aria-live='polite'>
{nls.localize('theia/ai/chat-ui/chat-view-tree-widget/generating', 'Generating')}
</span>}
{inProgress && waitingForInput &&
<span className='theia-ChatContentInProgress' role='status' aria-live='polite'>
{nls.localize('theia/ai/chat-ui/chat-view-tree-widget/waitingForInput', 'Waiting for input')}
</span>}
<div className='theia-ChatNodeToolbar'>
{!inProgress &&
toolbarContributions.length > 0 &&
toolbarContributions.map(action =>
<span
key={action.commandId}
className={`theia-ChatNodeToolbarAction ${action.icon}`}
title={action.tooltip}
aria-label={action.tooltip}
tabIndex={0}
onClick={e => {
e.stopPropagation();
this.commandRegistry.executeCommand(action.commandId, node);
}}
onKeyDown={e => {
if (isEnterKey(e)) {
e.stopPropagation();
this.commandRegistry.executeCommand(action.commandId, node);
}
}}
role='button'
></span>
)}
</div>
</div>
</React.Fragment>;
}
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');
}
return this.getAgent(node)?.name ?? nls.localize('theia/ai/chat-ui/chat-view-tree-widget/ai', 'AI');
}
protected getAgent(node: RequestNode | ResponseNode): ChatAgent | undefined {
if (isRequestNode(node)) {
return undefined;
}
return node.response.agentId ? this.chatAgentService.getAgent(node.response.agentId) : undefined;
}
protected getAgentIconClassName(node: RequestNode | ResponseNode): string | undefined {
if (isRequestNode(node)) {
return codicon('account');
}
const agent = node.response.agentId ? this.chatAgentService.getAgent(node.response.agentId) : undefined;
return agent?.iconClass ?? codicon('copilot');
}
protected renderDetail(node: RequestNode | ResponseNode): React.ReactNode {
if (isRequestNode(node)) {
return this.renderChatRequest(node);
}
if (isResponseNode(node)) {
return this.renderChatResponse(node);
};
}
protected renderChatRequest(node: RequestNode): React.ReactNode {
return <ChatRequestRender
node={node}
hoverService={this.hoverService}
chatAgentService={this.chatAgentService}
variableService={this.variableService}
openerService={this.openerService}
provideChatInputWidget={() => {
const editableNode = node;
if (isEditableRequestNode(editableNode)) {
let widget = this.chatInputs.get(editableNode.id);
if (!widget) {
widget = this.inputWidgetFactory({
node: editableNode,
initialValue: editableNode.request.message.request.text,
onQuery: async query => {
editableNode.request.submitEdit({ text: query });
},
branch: editableNode.branch
});
this.chatInputs.set(editableNode.id, widget);
widget.disposed.connect(() => {
this.chatInputs.delete(editableNode.id);
editableNode.request.cancelEdit();
});
}
return widget;
}
return;
}}
/>;
}
protected renderChatResponse(node: ResponseNode): React.ReactNode {
return (
<div className={'theia-ResponseNode'}>
{!node.response.isComplete
&& node.response.response.content.length === 0
&& node.response.progressMessages
.filter(c => c.show === 'untilFirstContent')
.map((c, i) =>
<ProgressMessage {...c} key={`${node.id}-progress-untilFirstContent-${i}`} />
)
}
{node.response.response.content.map((c, i) =>
<div className='theia-ResponseNode-Content' key={`${node.id}-content-${i}`}>{this.getChatResponsePartRenderer(c, node)}</div>
)}
{!node.response.isComplete
&& node.response.progressMessages
.filter(c => c.show === 'whileIncomplete')
.map((c, i) =>
<ProgressMessage {...c} key={`${node.id}-progress-whileIncomplete-${i}`} />
)
}
{node.response.progressMessages
.filter(c => c.show === 'forever')
.map((c, i) =>
<ProgressMessage {...c} key={`${node.id}-progress-afterComplete-${i}`} />
)
}
</div>
);
}
protected getChatResponsePartRenderer(content: ChatResponseContent, node: ResponseNode): React.ReactNode {
const renderer = this.chatResponsePartRenderers.getContributions().reduce<[number, ChatResponsePartRenderer<ChatResponseContent> | undefined]>(
(prev, current) => {
const prio = current.canHandle(content);
if (prio > prev[0]) {
return [prio, current];
} return prev;
},
[-1, undefined])[1];
if (!renderer) {
console.error('No renderer found for content', content);
return <div>{nls.localize('theia/ai/chat-ui/chat-view-tree-widget/noRenderer', 'Error: No renderer found')}</div>;
}
return renderer.render(content, node);
}
protected handleContextMenu(node: TreeNode | undefined, event: React.MouseEvent<HTMLElement>): void {
this.contextMenuRenderer.render({
menuPath: ChatViewTreeWidget.CONTEXT_MENU,
anchor: { x: event.clientX, y: event.clientY },
args: [node],
context: event.currentTarget
});
event.preventDefault();
}
protected override handleSpace(event: KeyboardEvent): boolean {
// We need to return false to prevent the handler within
// packages/core/src/browser/widgets/widget.ts
// Otherwise, the space key will never be handled by the monaco editor
return false;
}
/**
* Ensure atBottom state is correct when content grows (e.g., LLM streaming while scroll lock is enabled).
*/
protected override updateScrollToRow(): void {
super.updateScrollToRow();
const isAtBottom = this.isScrolledToBottom();
this.updateScrollToBottomButtonState(isAtBottom);
}
}
interface WidgetContainerProps {
widget: ReactWidget;
}
const WidgetContainer: React.FC<WidgetContainerProps> = ({ widget }) => {
// eslint-disable-next-line no-null/no-null
const containerRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
if (containerRef.current && !widget.isAttached) {
Widget.attach(widget, containerRef.current);
}
}, [containerRef.current]);
// Clean up
React.useEffect(() =>
() => {
setTimeout(() => {
// Delay clean up to allow react to finish its rendering cycle
widget.clearFlag(Widget.Flag.IsAttached);
widget.dispose();
});
}, []);
return <div ref={containerRef} />;
};
const ChatRequestRender = (
{
node, hoverService, chatAgentService, variableService, openerService,
provideChatInputWidget
}: {
node: RequestNode,
hoverService: HoverService,
chatAgentService: ChatAgentService,
variableService: AIVariableService,
openerService: OpenerService,
provideChatInputWidget: () => ReactWidget | undefined,
}) => {
const parts = node.request.message.parts;
if (EditableChatRequestModel.isEditing(node.request)) {
const widget = provideChatInputWidget();
if (widget) {
return <div className="theia-RequestNode">
<WidgetContainer widget={widget}></WidgetContainer>
</div>;
}
}
const renderFooter = () => {
if (node.branch.items.length < 2) {
return;
}
const isFirst = node.branch.activeBranchIndex === 0;
const isLast = node.branch.activeBranchIndex === node.branch.items.length - 1;
return (
<div className='theia-RequestNode-Footer'>
<div className={`item ${isFirst ? '' : 'enabled'}`}>
<div className="codicon codicon-chevron-left action-label" title="Previous" onClick={() => {
node.branch.enablePrevious();
}}></div>
</div>
<small>
<span>{node.branch.activeBranchIndex + 1}/</span>
<span>{node.branch.items.length}</span>
</small>
<div className={`item ${isLast ? '' : 'enabled'}`}>
<div className='codicon codicon-chevron-right action-label' title="Next" onClick={() => {
node.branch.enableNext();
}}></div>
</div>
</div>
);
};
// Extract image variables from the request context
const imageVariables = node.request.context.variables
.filter(ImageContextVariable.isResolvedImageContext)
.map(resolved => ImageContextVariable.parseResolved(resolved))
.filter((img): img is NonNullable<typeof img> => img !== undefined);
const renderImages = () => {
if (imageVariables.length === 0) {
return undefined;
}
return (
<div className="theia-RequestNode-Images">
{imageVariables.map((img, index) => (
<div key={index} className="theia-RequestNode-ImagePreview">
<img
src={`data:${img.mimeType};base64,${img.data}`}
alt={img.name ?? img.wsRelativePath ?? 'Image'}
title={img.name ?? img.wsRelativePath ?? 'Image'}
/>
</div>
))}
</div>
);
};
return (
<div className="theia-RequestNode">
<p>
{parts.map((part, index) => {
if (part instanceof ParsedChatRequestAgentPart || part instanceof ParsedChatRequestVariablePart || part instanceof ParsedChatRequestFunctionPart) {
let description = undefined;
let className = '';
if (part instanceof ParsedChatRequestAgentPart) {
description = chatAgentService.getAgent(part.agentId)?.description;
className = 'theia-RequestNode-AgentLabel';
} else if (part instanceof ParsedChatRequestVariablePart) {
description = variableService.getVariable(part.variableName)?.description;
className = 'theia-RequestNode-VariableLabel';
} else if (part instanceof ParsedChatRequestFunctionPart) {
description = part.toolRequest?.description;
className = 'theia-RequestNode-FunctionLabel';
}
return (
<HoverableLabel
key={index}
text={part.text}
description={description}
hoverService={hoverService}
className={className}
/>
);
} else {
const ref = useMarkdownRendering(
part.text
.replace(/^[\r\n]+|[\r\n]+$/g, '') // remove excessive new lines
.replace(/(^ )/g, '&nbsp;'), // enforce keeping space before
openerService,
true
);
return (
<span key={index} ref={ref}></span>
);
}
})}
</p>
{renderImages()}
{renderFooter()}
</div>
);
};
const HoverableLabel = (
{
text, description, hoverService, className
}: {
text: string,
description?: string,
hoverService: HoverService,
className: string
}) => {
const spanRef = React.createRef<HTMLSpanElement>();
return (
<span
className={className}
ref={spanRef}
onMouseEnter={() => {
if (description) {
hoverService.requestHover({
content: description,
target: spanRef.current!,
position: 'right'
});
}
}}
>
{text}
</span>
);
};

View File

@@ -0,0 +1,18 @@
// *****************************************************************************
// Copyright (C) 2024 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
// *****************************************************************************
export * from './chat-view-tree-container';
export * from './chat-view-tree-widget';

View File

@@ -0,0 +1,54 @@
// *****************************************************************************
// 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 * as React from '@theia/core/shared/react';
import { nls } from '@theia/core';
import { HoverService } from '@theia/core/lib/browser';
export interface PromptVariantBadgeProps {
variantId: string;
isEdited: boolean;
hoverService: HoverService;
}
export const PromptVariantBadge: React.FC<PromptVariantBadgeProps> = ({ variantId, isEdited, hoverService }) => {
// eslint-disable-next-line no-null/no-null
const badgeRef = React.useRef<HTMLSpanElement>(null);
const displayText = isEdited
? `[${nls.localize('theia/ai/chat-ui/edited', 'edited')}] ${variantId}`
: variantId;
const baseTooltip = nls.localize('theia/ai/chat-ui/variantTooltip', 'Prompt variant: {0}', variantId);
const tooltip = isEdited
? baseTooltip + '. ' + nls.localize('theia/ai/chat-ui/editedTooltipHint', 'This prompt variant has been edited. You can reset it in the AI Configuration view.')
: baseTooltip;
return (
<span
ref={badgeRef}
className={`theia-PromptVariantBadge ${isEdited ? 'edited' : ''}`}
onMouseEnter={() => {
if (badgeRef.current) {
hoverService.requestHover({
content: tooltip,
target: badgeRef.current,
position: 'right'
});
};
}}
>
{displayText}
</span>
);
};

View File

@@ -0,0 +1,101 @@
// *****************************************************************************
// 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 { inject, injectable, named } from '@theia/core/shared/inversify';
import { ProgressMessage } from '../chat-progress-message';
import { ChatViewTreeWidget, ResponseNode } from './chat-view-tree-widget';
import * as React from '@theia/core/shared/react';
import { ContributionProvider } from '@theia/core';
import { ChatResponsePartRenderer } from '../chat-response-part-renderer';
import { ChatNodeToolbarActionContribution } from '../chat-node-toolbar-action-contribution';
import { ChatResponseContent } from '@theia/ai-chat';
import { ContextMenuRenderer, TreeNode } from '@theia/core/lib/browser';
import { nls } from '@theia/core/lib/common/nls';
/**
* Subset of the ChatViewTreeWidget used to render ResponseNodes for delegated prompts.
*/
@injectable()
export class SubChatWidget {
@inject(ContributionProvider) @named(ChatResponsePartRenderer)
protected readonly chatResponsePartRenderers: ContributionProvider<ChatResponsePartRenderer<ChatResponseContent>>;
@inject(ContributionProvider) @named(ChatNodeToolbarActionContribution)
protected readonly chatNodeToolbarActionContributions: ContributionProvider<ChatNodeToolbarActionContribution>;
@inject(ContextMenuRenderer) contextMenuRenderer: ContextMenuRenderer;
renderChatResponse(node: ResponseNode): React.ReactNode {
return (
<div className={'theia-ResponseNode'}>
{!node.response.isComplete
&& node.response.response.content.length === 0
&& node.response.progressMessages
.filter(c => c.show === 'untilFirstContent')
.map((c, i) =>
<ProgressMessage {...c} key={`${node.id}-progress-untilFirstContent-${i}`} />
)
}
{node.response.response.content.map((c, i) =>
<div className='theia-ResponseNode-Content' key={`${node.id}-content-${i}`}>{this.getChatResponsePartRenderer(c, node)}</div>
)}
{!node.response.isComplete
&& node.response.progressMessages
.filter(c => c.show === 'whileIncomplete')
.map((c, i) =>
<ProgressMessage {...c} key={`${node.id}-progress-whileIncomplete-${i}`} />
)
}
{node.response.progressMessages
.filter(c => c.show === 'forever')
.map((c, i) =>
<ProgressMessage {...c} key={`${node.id}-progress-afterComplete-${i}`} />
)
}
</div>
);
}
protected getChatResponsePartRenderer(content: ChatResponseContent, node: ResponseNode): React.ReactNode {
const renderer = this.chatResponsePartRenderers.getContributions().reduce<[number, ChatResponsePartRenderer<ChatResponseContent> | undefined]>(
(prev, current) => {
const prio = current.canHandle(content);
if (prio > prev[0]) {
return [prio, current];
} return prev;
},
[-1, undefined])[1];
if (!renderer) {
console.error('No renderer found for content', content);
return <div>{nls.localize('theia/ai/chat-ui/chat-view-tree-widget/noRenderer', 'Error: No renderer found')}</div>;
}
return renderer.render(content, node);
}
protected handleContextMenu(node: TreeNode | undefined, event: React.MouseEvent<HTMLElement>): void {
this.contextMenuRenderer.render({
menuPath: ChatViewTreeWidget.CONTEXT_MENU,
anchor: { x: event.clientX, y: event.clientY },
args: [node],
context: event.currentTarget
});
event.preventDefault();
}
}
export const SubChatWidgetFactory = Symbol('SubChatWidgetFactory');
export type SubChatWidgetFactory = () => SubChatWidget;

View File

@@ -0,0 +1,82 @@
// *****************************************************************************
// Copyright (C) 2024 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 { Command, nls } from '@theia/core';
import { codicon } from '@theia/core/lib/browser';
export namespace ChatCommands {
export const CHAT_CATEGORY = 'Chat';
export const CHAT_CATEGORY_KEY = nls.getDefaultKey(CHAT_CATEGORY);
export const SCROLL_LOCK_WIDGET = Command.toLocalizedCommand({
id: 'chat:widget:lock',
category: CHAT_CATEGORY,
iconClass: codicon('unlock'),
label: 'Lock Scroll'
}, 'theia/ai-chat-ui/scroll-lock', CHAT_CATEGORY_KEY);
export const SCROLL_UNLOCK_WIDGET = Command.toLocalizedCommand({
id: 'chat:widget:unlock',
category: CHAT_CATEGORY,
iconClass: codicon('lock'),
label: 'Unlock Scroll'
}, 'theia/ai-chat-ui/scroll-unlock', CHAT_CATEGORY_KEY);
export const EDIT_SESSION_SETTINGS = Command.toLocalizedCommand({
id: 'chat:widget:session-settings',
category: CHAT_CATEGORY,
iconClass: codicon('bracket'),
label: 'Set Session Settings'
}, 'theia/ai-chat-ui/session-settings', CHAT_CATEGORY_KEY);
export const AI_CHAT_NEW_WITH_TASK_CONTEXT: Command = {
id: 'ai-chat.new-with-task-context',
};
export const AI_CHAT_INITIATE_SESSION_WITH_TASK_CONTEXT = Command.toLocalizedCommand({
id: 'ai-chat.initiate-session-with-task-context',
label: 'Task Context: Initiate Session',
category: CHAT_CATEGORY
}, 'theia/ai-chat-ui/initiate-session-task-context', CHAT_CATEGORY_KEY);
export const AI_CHAT_SUMMARIZE_CURRENT_SESSION = Command.toLocalizedCommand({
id: 'ai-chat-summary-current-session',
iconClass: codicon('go-to-editing-session'),
label: 'Summarize Current Session',
category: CHAT_CATEGORY
}, 'theia/ai-chat-ui/summarize-current-session', CHAT_CATEGORY_KEY);
export const AI_CHAT_OPEN_SUMMARY_FOR_CURRENT_SESSION = Command.toLocalizedCommand({
id: 'ai-chat-open-current-session-summary',
iconClass: codicon('note'),
label: 'Open Current Session Summary',
category: CHAT_CATEGORY
}, 'theia/ai-chat-ui/open-current-session-summary', CHAT_CATEGORY_KEY);
}
export const AI_CHAT_NEW_CHAT_WINDOW_COMMAND = Command.toDefaultLocalizedCommand({
id: 'ai-chat-ui.new-chat',
iconClass: codicon('add'),
category: ChatCommands.CHAT_CATEGORY,
label: 'New Chat'
});
export const AI_CHAT_SHOW_CHATS_COMMAND = Command.toLocalizedCommand({
id: 'ai-chat-ui.show-chats',
iconClass: codicon('history'),
category: ChatCommands.CHAT_CATEGORY,
label: 'Show Chats...'
}, 'theia/ai-chat-ui/showChats');

View File

@@ -0,0 +1,185 @@
// *****************************************************************************
// Copyright (C) 2024 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 { Command, CommandContribution, CommandRegistry, CommandService, isObject, MenuContribution, MenuModelRegistry } from '@theia/core';
import { CommonCommands, TreeNode } from '@theia/core/lib/browser';
import { ClipboardService } from '@theia/core/lib/browser/clipboard-service';
import { inject, injectable } from '@theia/core/shared/inversify';
import {
ChatViewTreeWidget, isEditableRequestNode, isRequestNode,
isResponseNode, RequestNode, ResponseNode, type EditableRequestNode
} from './chat-tree-view/chat-view-tree-widget';
import { AIChatInputWidget } from './chat-input-widget';
import { AICommandHandlerFactory, ENABLE_AI_CONTEXT_KEY } from '@theia/ai-core/lib/browser';
export namespace ChatViewCommands {
export const COPY_MESSAGE = Command.toDefaultLocalizedCommand({
id: 'chat.copy.message',
label: 'Copy Message'
});
export const COPY_ALL = Command.toDefaultLocalizedCommand({
id: 'chat.copy.all',
label: 'Copy All'
});
export const COPY_CODE = Command.toLocalizedCommand({
id: 'chat.copy.code',
label: 'Copy Code Block'
}, 'theia/ai/chat-ui/copyCodeBlock');
export const EDIT = Command.toLocalizedCommand({
id: 'chat.edit.request',
label: 'Edit'
}, 'theia/ai/chat-ui/editRequest');
}
@injectable()
export class ChatViewMenuContribution implements MenuContribution, CommandContribution {
@inject(ClipboardService)
protected readonly clipboardService: ClipboardService;
@inject(CommandService)
protected readonly commandService: CommandService;
@inject(AICommandHandlerFactory)
protected readonly commandHandlerFactory: AICommandHandlerFactory;
registerCommands(commands: CommandRegistry): void {
commands.registerHandler(CommonCommands.COPY.id, this.commandHandlerFactory({
execute: (...args: unknown[]) => {
if (window.getSelection()?.type !== 'Range' && containsRequestOrResponseNode(args)) {
this.copyMessage(extractRequestOrResponseNodes(args));
} else {
this.commandService.executeCommand(CommonCommands.COPY.id);
}
},
isEnabled: (...args: unknown[]) => containsRequestOrResponseNode(args)
}));
commands.registerCommand(ChatViewCommands.COPY_MESSAGE, this.commandHandlerFactory({
execute: (...args: unknown[]) => {
if (containsRequestOrResponseNode(args)) {
this.copyMessage(extractRequestOrResponseNodes(args));
}
},
isEnabled: (...args: unknown[]) => containsRequestOrResponseNode(args)
}));
commands.registerCommand(ChatViewCommands.COPY_ALL, this.commandHandlerFactory({
execute: (...args: unknown[]) => {
if (containsRequestOrResponseNode(args)) {
const parent = extractRequestOrResponseNodes(args).find(arg => arg.parent)?.parent;
const text = parent?.children
.filter(isRequestOrResponseNode)
.map(child => this.getCopyText(child))
.join('\n\n---\n\n');
if (text) {
this.clipboardService.writeText(text);
}
}
},
isEnabled: (...args: unknown[]) => containsRequestOrResponseNode(args)
}));
commands.registerCommand(ChatViewCommands.COPY_CODE, this.commandHandlerFactory({
execute: (...args: unknown[]) => {
if (containsCode(args)) {
const code = args
.filter(isCodeArg)
.map(arg => arg.code)
.join();
this.clipboardService.writeText(code);
}
},
isEnabled: (...args: unknown[]) => containsRequestOrResponseNode(args) && containsCode(args)
}));
commands.registerCommand(ChatViewCommands.EDIT, this.commandHandlerFactory({
execute: (...args: [EditableRequestNode, ...unknown[]]) => {
args[0].request.enableEdit();
},
isEnabled: (...args: unknown[]) => hasAsFirstArg(args, isEditableRequestNode) && !args[0].request.isEditing,
isVisible: (...args: unknown[]) => hasAsFirstArg(args, isEditableRequestNode) && !args[0].request.isEditing
}));
}
protected copyMessage(args: (RequestNode | ResponseNode)[]): void {
const text = this.getCopyTextAndJoin(args);
this.clipboardService.writeText(text);
}
protected getCopyTextAndJoin(args: (RequestNode | ResponseNode)[] | undefined): string {
return args !== undefined ? args.map(arg => this.getCopyText(arg)).join() : '';
}
protected getCopyText(arg: RequestNode | ResponseNode): string {
if (isRequestNode(arg)) {
return arg.request.request.text ?? '';
} else if (isResponseNode(arg)) {
return arg.response.response.asDisplayString();
}
return '';
}
registerMenus(menus: MenuModelRegistry): void {
menus.registerMenuAction([...ChatViewTreeWidget.CONTEXT_MENU, '_1'], {
commandId: CommonCommands.COPY.id,
when: ENABLE_AI_CONTEXT_KEY
});
menus.registerMenuAction([...ChatViewTreeWidget.CONTEXT_MENU, '_1'], {
commandId: ChatViewCommands.COPY_MESSAGE.id,
when: ENABLE_AI_CONTEXT_KEY
});
menus.registerMenuAction([...ChatViewTreeWidget.CONTEXT_MENU, '_1'], {
commandId: ChatViewCommands.COPY_ALL.id,
when: ENABLE_AI_CONTEXT_KEY
});
menus.registerMenuAction([...ChatViewTreeWidget.CONTEXT_MENU, '_1'], {
commandId: ChatViewCommands.COPY_CODE.id,
when: ENABLE_AI_CONTEXT_KEY
});
menus.registerMenuAction([...ChatViewTreeWidget.CONTEXT_MENU, '_1'], {
commandId: ChatViewCommands.EDIT.id,
when: ENABLE_AI_CONTEXT_KEY
});
menus.registerMenuAction([...AIChatInputWidget.CONTEXT_MENU, '_1'], {
commandId: CommonCommands.COPY.id,
when: ENABLE_AI_CONTEXT_KEY
});
menus.registerMenuAction([...AIChatInputWidget.CONTEXT_MENU, '_1'], {
commandId: CommonCommands.PASTE.id,
when: ENABLE_AI_CONTEXT_KEY
});
}
}
function hasAsFirstArg<T>(args: unknown[], guard: (arg: unknown) => arg is T): args is [T, ...unknown[]] {
return args.length > 0 && guard(args[0]);
}
function extractRequestOrResponseNodes(args: unknown[]): (RequestNode | ResponseNode)[] {
return args.filter(arg => isRequestOrResponseNode(arg)) as (RequestNode | ResponseNode)[];
}
function containsRequestOrResponseNode(args: unknown[]): args is (unknown | RequestNode | ResponseNode)[] {
return extractRequestOrResponseNodes(args).length > 0;
}
function isRequestOrResponseNode(arg: unknown): arg is RequestNode | ResponseNode {
return TreeNode.is(arg) && (isRequestNode(arg) || isResponseNode(arg));
}
function containsCode(args: unknown[]): args is (unknown | { code: string })[] {
return args.filter(arg => isCodeArg(arg)).length > 0;
}
function isCodeArg(arg: unknown): arg is { code: string } {
return isObject(arg) && 'code' in arg;
}

View File

@@ -0,0 +1,321 @@
// *****************************************************************************
// Copyright (C) 2024 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 { ChatAgentService } from '@theia/ai-chat';
import { AIVariableService } from '@theia/ai-core/lib/common';
import { PromptText } from '@theia/ai-core/lib/common/prompt-text';
import { PromptService, BasePromptFragment } from '@theia/ai-core/lib/common/prompt-service';
import { ToolInvocationRegistry } from '@theia/ai-core/lib/common/tool-invocation-registry';
import { MaybePromise, nls } from '@theia/core';
import { FrontendApplication, FrontendApplicationContribution } from '@theia/core/lib/browser';
import { ContextKeyService } from '@theia/core/lib/browser/context-key-service';
import { inject, injectable } from '@theia/core/shared/inversify';
import * as monaco from '@theia/monaco-editor-core';
import { ProviderResult } from '@theia/monaco-editor-core/esm/vs/editor/common/languages';
import { AIChatFrontendContribution, VARIABLE_ADD_CONTEXT_COMMAND } from '@theia/ai-chat/lib/browser/ai-chat-frontend-contribution';
export const CHAT_VIEW_LANGUAGE_ID = 'theia-ai-chat-view-language';
export const SETTINGS_LANGUAGE_ID = 'theia-ai-chat-settings-language';
export const CHAT_VIEW_LANGUAGE_EXTENSION = 'aichatviewlanguage';
const VARIABLE_RESOLUTION_CONTEXT = { context: 'chat-input-autocomplete' };
const VARIABLE_ARGUMENT_PICKER_COMMAND = 'trigger-variable-argument-picker';
interface CompletionSource<T> {
triggerCharacter: string;
getItems: () => T[];
kind: monaco.languages.CompletionItemKind;
getId: (item: T) => string;
getName: (item: T) => string;
getDescription: (item: T) => string;
command?: monaco.languages.Command;
}
@injectable()
export class ChatViewLanguageContribution implements FrontendApplicationContribution {
@inject(ChatAgentService)
protected readonly agentService: ChatAgentService;
@inject(AIVariableService)
protected readonly variableService: AIVariableService;
@inject(ToolInvocationRegistry)
protected readonly toolInvocationRegistry: ToolInvocationRegistry;
@inject(AIChatFrontendContribution)
protected readonly chatFrontendContribution: AIChatFrontendContribution;
@inject(PromptService)
protected readonly promptService: PromptService;
@inject(ContextKeyService)
protected readonly contextKeyService: ContextKeyService;
onStart(_app: FrontendApplication): MaybePromise<void> {
monaco.languages.register({ id: CHAT_VIEW_LANGUAGE_ID, extensions: [CHAT_VIEW_LANGUAGE_EXTENSION] });
monaco.languages.register({ id: SETTINGS_LANGUAGE_ID, extensions: ['json'], filenames: ['editor'] });
this.registerCompletionProviders();
monaco.editor.registerCommand(VARIABLE_ARGUMENT_PICKER_COMMAND, this.triggerVariableArgumentPicker.bind(this));
}
protected registerCompletionProviders(): void {
this.registerStandardCompletionProvider({
triggerCharacter: PromptText.AGENT_CHAR,
getItems: () => this.agentService.getAgents(),
kind: monaco.languages.CompletionItemKind.Value,
getId: agent => `${agent.id} `,
getName: agent => agent.name,
getDescription: agent => agent.description
});
this.registerStandardCompletionProvider({
triggerCharacter: PromptText.VARIABLE_CHAR,
getItems: () => this.variableService.getVariables(),
kind: monaco.languages.CompletionItemKind.Variable,
getId: variable => variable.args?.some(arg => !arg.isOptional) ? variable.name + PromptText.VARIABLE_SEPARATOR_CHAR : `${variable.name} `,
getName: variable => variable.name,
getDescription: variable => variable.description,
command: {
title: nls.localize('theia/ai/chat-ui/selectVariableArguments', 'Select variable arguments'),
id: VARIABLE_ARGUMENT_PICKER_COMMAND,
}
});
this.registerStandardCompletionProvider({
triggerCharacter: PromptText.FUNCTION_CHAR,
getItems: () => this.toolInvocationRegistry.getAllFunctions(),
kind: monaco.languages.CompletionItemKind.Function,
getId: tool => `${tool.id} `,
getName: tool => tool.name,
getDescription: tool => tool.description ?? ''
});
// Register the variable argument completion provider (special case)
monaco.languages.registerCompletionItemProvider(CHAT_VIEW_LANGUAGE_ID, {
triggerCharacters: [PromptText.VARIABLE_CHAR, PromptText.VARIABLE_SEPARATOR_CHAR],
provideCompletionItems: (model, position, _context, _token): ProviderResult<monaco.languages.CompletionList> =>
this.provideVariableWithArgCompletions(model, position),
});
// Register command completion provider
monaco.languages.registerCompletionItemProvider(CHAT_VIEW_LANGUAGE_ID, {
triggerCharacters: [PromptText.COMMAND_CHAR],
provideCompletionItems: (model, position, _context, _token): ProviderResult<monaco.languages.CompletionList> =>
this.provideCommandCompletions(model, position),
});
}
protected registerStandardCompletionProvider<T>(source: CompletionSource<T>): void {
monaco.languages.registerCompletionItemProvider(CHAT_VIEW_LANGUAGE_ID, {
triggerCharacters: [source.triggerCharacter],
provideCompletionItems: (model, position, _context, _token): ProviderResult<monaco.languages.CompletionList> =>
this.provideCompletions(model, position, source),
});
}
getCompletionRange(model: monaco.editor.ITextModel, position: monaco.Position, triggerCharacter: string): monaco.Range | undefined {
const wordInfo = model.getWordUntilPosition(position);
const lineContent = model.getLineContent(position.lineNumber);
// one to the left, and -1 for 0-based index
const characterBeforeCurrentWord = lineContent[wordInfo.startColumn - 1 - 1];
if (characterBeforeCurrentWord !== triggerCharacter) {
return undefined;
}
// we are not at the beginning of the line
if (wordInfo.startColumn > 2) {
const charBeforeTrigger = model.getValueInRange({
startLineNumber: position.lineNumber,
startColumn: wordInfo.startColumn - 2,
endLineNumber: position.lineNumber,
endColumn: wordInfo.startColumn - 1
});
// If the character before the trigger is not whitespace, don't provide completions
if (!/\s/.test(charBeforeTrigger)) {
return undefined;
}
}
return new monaco.Range(
position.lineNumber,
wordInfo.startColumn,
position.lineNumber,
position.column
);
}
protected provideCompletions<T>(
model: monaco.editor.ITextModel,
position: monaco.Position,
source: CompletionSource<T>
): ProviderResult<monaco.languages.CompletionList> {
const completionRange = this.getCompletionRange(model, position, source.triggerCharacter);
if (completionRange === undefined) {
return { suggestions: [] };
}
const items = source.getItems();
const suggestions = items.map(item => ({
insertText: source.getId(item),
kind: source.kind,
label: source.getName(item),
range: completionRange,
detail: source.getDescription(item),
command: source.command
}));
return { suggestions };
}
async provideVariableWithArgCompletions(model: monaco.editor.ITextModel, position: monaco.Position): Promise<monaco.languages.CompletionList> {
// 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" or "#var-name:args"
// Matches only when # is at the beginning of the string or after whitespace
const variableRegex = /(?:^|\s)#([\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 => ({
command: {
title: VARIABLE_ADD_CONTEXT_COMMAND.label!,
id: VARIABLE_ADD_CONTEXT_COMMAND.id,
arguments: [variable.name, item.insertText]
},
...item,
})));
}
}
}
return { suggestions };
}
protected async triggerVariableArgumentPicker(): Promise<void> {
const inputEditor = monaco.editor.getEditors().find(editor => editor.hasTextFocus());
if (!inputEditor) {
return;
}
const model = inputEditor.getModel();
const position = inputEditor.getPosition();
if (!model || !position) {
return;
}
// // Get the word at cursor
const wordInfo = model.getWordUntilPosition(position);
// account for the variable separator character if present
let endOfWordPosition = position.column;
if (wordInfo.word === '' && this.getCharacterBeforePosition(model, position) === PromptText.VARIABLE_SEPARATOR_CHAR) {
endOfWordPosition = position.column - 1;
} else {
return;
}
const variableName = model.getWordAtPosition({ ...position, column: endOfWordPosition })?.word;
if (!variableName) {
return;
}
const provider = await this.variableService.getArgumentPicker(variableName, VARIABLE_RESOLUTION_CONTEXT);
if (!provider) {
return;
}
const arg = await provider(VARIABLE_RESOLUTION_CONTEXT);
if (!arg) {
return;
}
inputEditor.executeEdits('variable-argument-picker', [{
range: new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column),
text: arg
}]);
await this.chatFrontendContribution.addContextVariable(variableName, arg);
}
protected getCharacterBeforePosition(model: monaco.editor.ITextModel, position: monaco.Position): string {
return model.getLineContent(position.lineNumber)[position.column - 1 - 1];
}
protected provideCommandCompletions(model: monaco.editor.ITextModel, position: monaco.Position): ProviderResult<monaco.languages.CompletionList> {
const range = this.getCompletionRange(model, position, PromptText.COMMAND_CHAR);
if (range === undefined) {
return { suggestions: [] };
}
let currentAgentId: string | undefined;
const allAgents = this.agentService.getAgents();
for (const agent of allAgents) {
if (this.contextKeyService.match(`chatInputReceivingAgent == '${agent.id}'`)) {
currentAgentId = agent.id;
break;
}
}
const commands = this.promptService.getCommands(currentAgentId);
const suggestions = commands.map(cmd => {
const base = cmd as BasePromptFragment;
const label = base.commandName || base.id;
const description = base.commandDescription || '';
const argHint = base.commandArgumentHint || '';
const detail = argHint ? `${description}${argHint}` : description;
return {
insertText: `${label} `,
kind: monaco.languages.CompletionItemKind.Function,
label,
range,
detail
};
});
return { suggestions };
}
}

View File

@@ -0,0 +1,107 @@
// *****************************************************************************
// Copyright (C) 2024 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 { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { AIChatContribution } from './ai-chat-ui-contribution';
import { Emitter, InMemoryResources, URI, nls } from '@theia/core';
import { ChatCommands } from './chat-view-commands';
import { CommandRegistry } from '@theia/core/lib/common/command';
import { SessionSettingsDialog } from './session-settings-dialog';
import { MonacoEditorProvider } from '@theia/monaco/lib/browser/monaco-editor-provider';
import { ChatViewWidget } from './chat-view-widget';
import { AIActivationService, ENABLE_AI_CONTEXT_KEY } from '@theia/ai-core/lib/browser';
@injectable()
export class ChatViewWidgetToolbarContribution implements TabBarToolbarContribution {
@inject(AIChatContribution)
protected readonly chatContribution: AIChatContribution;
@inject(CommandRegistry)
protected readonly commandRegistry: CommandRegistry;
@inject(MonacoEditorProvider)
protected readonly editorProvider: MonacoEditorProvider;
@inject(InMemoryResources)
protected readonly resources: InMemoryResources;
@inject(AIActivationService)
protected readonly activationService: AIActivationService;
protected readonly onChatWidgetStateChangedEmitter = new Emitter<void>();
protected readonly onChatWidgetStateChanged = this.onChatWidgetStateChangedEmitter.event;
private readonly sessionSettingsURI = new URI('chat-view:/settings.json');
@postConstruct()
protected init(): void {
this.resources.add(this.sessionSettingsURI, '{}');
this.chatContribution.widget.then(widget => {
widget.onStateChanged(() => this.onChatWidgetStateChangedEmitter.fire());
});
this.commandRegistry.registerCommand(ChatCommands.EDIT_SESSION_SETTINGS, {
execute: () => this.openJsonDataDialog(),
isEnabled: widget => this.activationService.isActive && widget instanceof ChatViewWidget,
isVisible: widget => this.activationService.isActive && widget instanceof ChatViewWidget
});
}
registerToolbarItems(registry: TabBarToolbarRegistry): void {
registry.registerItem({
id: ChatCommands.SCROLL_LOCK_WIDGET.id,
command: ChatCommands.SCROLL_LOCK_WIDGET.id,
tooltip: nls.localizeByDefault('Turn Auto Scrolling Off'),
onDidChange: this.onChatWidgetStateChanged,
priority: 2,
when: ENABLE_AI_CONTEXT_KEY
});
registry.registerItem({
id: ChatCommands.SCROLL_UNLOCK_WIDGET.id,
command: ChatCommands.SCROLL_UNLOCK_WIDGET.id,
tooltip: nls.localizeByDefault('Turn Auto Scrolling On'),
onDidChange: this.onChatWidgetStateChanged,
priority: 2,
when: ENABLE_AI_CONTEXT_KEY
});
registry.registerItem({
id: ChatCommands.EDIT_SESSION_SETTINGS.id,
command: ChatCommands.EDIT_SESSION_SETTINGS.id,
tooltip: nls.localize('theia/ai/session-settings-dialog/tooltip', 'Set Session Settings'),
priority: 3,
when: ENABLE_AI_CONTEXT_KEY
});
}
protected async openJsonDataDialog(): Promise<void> {
const widget = await this.chatContribution.widget;
if (!widget) {
return;
}
const dialog = new SessionSettingsDialog(this.editorProvider, this.resources, this.sessionSettingsURI, {
initialSettings: widget.getSettings()
});
const result = await dialog.open();
if (result) {
widget.setSettings(result);
}
}
}

View File

@@ -0,0 +1,302 @@
// *****************************************************************************
// Copyright (C) 2024 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 { CommandService, deepClone, Emitter, Event, MessageService, PreferenceService, URI } from '@theia/core';
import { ChatRequest, ChatRequestModel, ChatService, ChatSession, isActiveSessionChangedEvent, MutableChatModel } from '@theia/ai-chat';
import { BaseWidget, codicon, ExtractableWidget, Message, PanelLayout, StatefulWidget } from '@theia/core/lib/browser';
import { nls } from '@theia/core/lib/common/nls';
import { inject, injectable, optional, postConstruct } from '@theia/core/shared/inversify';
import { AIChatInputWidget } from './chat-input-widget';
import { ChatViewTreeWidget, ChatWelcomeMessageProvider } from './chat-tree-view/chat-view-tree-widget';
import { AIActivationService } from '@theia/ai-core/lib/browser/ai-activation-service';
import { AIVariableResolutionRequest } from '@theia/ai-core';
import { ProgressBarFactory } from '@theia/core/lib/browser/progress-bar-factory';
import { FrontendVariableService } from '@theia/ai-core/lib/browser';
import { FrontendLanguageModelRegistry } from '@theia/ai-core/lib/common';
export namespace ChatViewWidget {
export interface State {
locked?: boolean;
temporaryLocked?: boolean;
}
}
@injectable()
export class ChatViewWidget extends BaseWidget implements ExtractableWidget, StatefulWidget {
public static ID = 'chat-view-widget';
static LABEL = nls.localize('theia/ai/chat/view/label', 'AI Chat');
@inject(ChatService)
protected chatService: ChatService;
@inject(MessageService)
protected messageService: MessageService;
@inject(PreferenceService)
protected readonly preferenceService: PreferenceService;
@inject(CommandService)
protected readonly commandService: CommandService;
@inject(AIActivationService)
protected readonly activationService: AIActivationService;
@inject(FrontendVariableService)
protected readonly variableService: FrontendVariableService;
@inject(ProgressBarFactory)
protected readonly progressBarFactory: ProgressBarFactory;
@inject(FrontendLanguageModelRegistry)
protected readonly languageModelRegistry: FrontendLanguageModelRegistry;
@inject(ChatWelcomeMessageProvider) @optional()
protected readonly welcomeProvider?: ChatWelcomeMessageProvider;
protected chatSession: ChatSession;
protected _state: ChatViewWidget.State = { locked: false, temporaryLocked: false };
protected readonly onStateChangedEmitter = new Emitter<ChatViewWidget.State>();
isExtractable = true;
secondaryWindow: Window | undefined;
constructor(
@inject(ChatViewTreeWidget)
readonly treeWidget: ChatViewTreeWidget,
@inject(AIChatInputWidget)
readonly inputWidget: AIChatInputWidget
) {
super();
this.id = ChatViewWidget.ID;
this.title.label = ChatViewWidget.LABEL;
this.title.caption = ChatViewWidget.LABEL;
this.title.iconClass = codicon('comment-discussion');
this.title.closable = false; // Always visible in Product OS
this.node.classList.add('chat-view-widget');
this.update();
}
@postConstruct()
protected init(): void {
this.toDispose.pushAll([
this.treeWidget,
this.inputWidget,
this.onStateChanged(newState => {
const shouldScrollToEnd = !newState.locked && !newState.temporaryLocked;
this.treeWidget.shouldScrollToEnd = shouldScrollToEnd;
this.update();
})
]);
const layout = this.layout = new PanelLayout();
this.treeWidget.node.classList.add('chat-tree-view-widget');
layout.addWidget(this.treeWidget);
this.inputWidget.node.classList.add('chat-input-widget');
layout.addWidget(this.inputWidget);
this.chatSession = this.chatService.createSession();
this.inputWidget.onQuery = this.onQuery.bind(this);
this.inputWidget.onUnpin = this.onUnpin.bind(this);
this.inputWidget.onCancel = this.onCancel.bind(this);
this.inputWidget.chatModel = this.chatSession.model;
this.inputWidget.pinnedAgent = this.chatSession.pinnedAgent;
this.inputWidget.onDeleteChangeSet = this.onDeleteChangeSet.bind(this);
this.inputWidget.onDeleteChangeSetElement = this.onDeleteChangeSetElement.bind(this);
this.treeWidget.trackChatModel(this.chatSession.model);
this.treeWidget.onScrollLockChange = this.onScrollLockChange.bind(this);
this.initListeners();
this.updateInputEnabledState();
this.activationService.onDidChangeActiveStatus(change => {
this.treeWidget.setEnabled(change);
this.updateInputEnabledState();
this.update();
});
this.toDispose.push(
this.languageModelRegistry.onChange(() => {
this.updateInputEnabledState();
})
);
if (this.welcomeProvider?.onStateChanged) {
this.toDispose.push(this.welcomeProvider.onStateChanged(() => {
this.updateInputEnabledState();
this.update();
}));
}
this.toDispose.push(this.progressBarFactory({ container: this.node, insertMode: 'prepend', locationId: 'ai-chat' }));
}
protected async updateInputEnabledState(): Promise<void> {
const shouldEnable = this.activationService.isActive && await this.shouldEnableInput();
this.inputWidget.setEnabled(shouldEnable);
this.treeWidget.setEnabled(this.activationService.isActive);
}
protected async shouldEnableInput(): Promise<boolean> {
if (!this.welcomeProvider) {
return true;
}
const hasReadyModels = await this.hasReadyLanguageModels();
const modelRequirementBypassed = this.welcomeProvider.modelRequirementBypassed ?? false;
return hasReadyModels || modelRequirementBypassed;
}
protected async hasReadyLanguageModels(): Promise<boolean> {
const models = await this.languageModelRegistry.getLanguageModels();
return models.some(model => model.status.status === 'ready');
}
protected initListeners(): void {
this.toDispose.pushAll([
this.chatService.onSessionEvent(event => {
if (!isActiveSessionChangedEvent(event)) {
return;
}
const session = event.sessionId ? this.chatService.getSession(event.sessionId) : this.chatService.createSession();
if (session) {
this.chatSession = session;
this.treeWidget.trackChatModel(this.chatSession.model);
this.inputWidget.chatModel = this.chatSession.model;
this.inputWidget.pinnedAgent = this.chatSession.pinnedAgent;
} else {
console.warn(`Session with ${event.sessionId} not found.`);
}
}),
// The chat view needs to handle the submission of the edit request
this.treeWidget.onDidSubmitEdit(request => {
this.onQuery(request);
})
]);
}
protected override onActivateRequest(msg: Message): void {
super.onActivateRequest(msg);
this.inputWidget.activate();
}
storeState(): object {
return this.state;
}
restoreState(oldState: object & Partial<ChatViewWidget.State>): void {
const copy = deepClone(this.state);
if (oldState.locked) {
copy.locked = oldState.locked;
}
// Don't restore temporary lock state as it should reset on restart
copy.temporaryLocked = false;
this.state = copy;
}
protected get state(): ChatViewWidget.State {
return this._state;
}
protected set state(state: ChatViewWidget.State) {
this._state = state;
this.onStateChangedEmitter.fire(this._state);
}
get onStateChanged(): Event<ChatViewWidget.State> {
return this.onStateChangedEmitter.event;
}
protected async onQuery(query?: string | ChatRequest, modeId?: string): Promise<void> {
const chatRequest: ChatRequest = !query
? { text: '' }
: typeof query === 'string'
? { text: query, modeId }
: { ...query };
if (chatRequest.text.length === 0) { return; }
const requestProgress = await this.chatService.sendRequest(this.chatSession.id, chatRequest);
requestProgress?.responseCompleted.then(responseModel => {
if (responseModel.isError) {
this.messageService.error(responseModel.errorObject?.message ??
nls.localize('theia/ai/chat-ui/errorChatInvocation', 'An error occurred during chat service invocation.'));
}
}).finally(() => {
this.inputWidget.pinnedAgent = this.chatSession.pinnedAgent;
});
if (!requestProgress) {
this.messageService.error(nls.localize('theia/ai/chat-ui/couldNotSendRequestToSession',
'Was not able to send request "{0}" to session {1}', chatRequest.text, this.chatSession.id));
return;
}
// Tree Widget currently tracks the ChatModel itself. Therefore no notification necessary.
}
protected onUnpin(): void {
this.chatSession.pinnedAgent = undefined;
this.inputWidget.pinnedAgent = this.chatSession.pinnedAgent;
}
protected onCancel(requestModel: ChatRequestModel): void {
this.chatService.cancelRequest(requestModel.session.id, requestModel.id);
}
protected onDeleteChangeSet(sessionId: string): void {
this.chatService.deleteChangeSet(sessionId);
}
protected onDeleteChangeSetElement(sessionId: string, uri: URI): void {
this.chatService.deleteChangeSetElement(sessionId, uri);
}
protected onScrollLockChange(temporaryLocked: boolean): void {
this.setTemporaryLock(temporaryLocked);
}
lock(): void {
this.state = { ...deepClone(this.state), locked: true, temporaryLocked: false };
}
unlock(): void {
this.state = { ...deepClone(this.state), locked: false, temporaryLocked: false };
}
setTemporaryLock(locked: boolean): void {
// Only set temporary lock if not permanently locked
if (!this.state.locked) {
this.state = { ...deepClone(this.state), temporaryLocked: locked };
}
}
get isLocked(): boolean {
return !!this.state.locked;
}
addContext(variable: AIVariableResolutionRequest): void {
this.inputWidget.addContext(variable);
}
setSettings(settings: { [key: string]: unknown }): void {
if (this.chatSession && this.chatSession.model) {
const model = this.chatSession.model as MutableChatModel;
model.setSettings(settings);
}
}
getSettings(): { [key: string]: unknown } | undefined {
return this.chatSession.model.settings;
}
}

View File

@@ -0,0 +1,85 @@
// *****************************************************************************
// 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 { AIContextVariable, AIVariableResolutionRequest, AIVariableService, PromptText } from '@theia/ai-core';
import { nls, QuickInputService } from '@theia/core';
import { inject, injectable } from '@theia/core/shared/inversify';
const QUERY_CONTEXT = { type: 'context-variable-picker' };
@injectable()
export class ContextVariablePicker {
@inject(AIVariableService)
protected readonly variableService: AIVariableService;
@inject(QuickInputService)
protected readonly quickInputService: QuickInputService;
async pickContextVariable(): Promise<AIVariableResolutionRequest | undefined> {
const variables = this.variableService.getContextVariables();
const selection = await this.quickInputService.showQuickPick(
variables.map(v => ({
id: v.id,
label: v.label ?? v.name,
variable: v,
iconClasses: v.iconClasses,
})),
{ placeholder: nls.localize('theia/ai/chat-ui/selectContextVariableQuickPickPlaceholder', 'Select a context variable to be attached to the message'), }
);
if (!selection) {
return undefined;
}
const variable = selection.variable;
if (!variable.args || variable.args.length === 0) {
return { variable };
}
const argumentPicker = await this.variableService.getArgumentPicker(variable.name, QUERY_CONTEXT);
if (!argumentPicker) {
return this.useGenericArgumentPicker(variable);
}
const arg = await argumentPicker(QUERY_CONTEXT);
if (!arg) {
return undefined;
}
return { variable, arg };
}
protected async useGenericArgumentPicker(variable: AIContextVariable): Promise<AIVariableResolutionRequest | undefined> {
const args: string[] = [];
for (const argument of variable.args ?? []) {
const placeHolder = argument.description;
let input: string | undefined;
if (argument.enum) {
const picked = await this.quickInputService.pick(
argument.enum.map(enumItem => ({ label: enumItem })),
{ placeHolder, canPickMany: false }
);
input = picked?.label;
} else {
input = await this.quickInputService.input({ placeHolder });
}
if (!input && !argument.isOptional) {
return;
}
args.push(input ?? '');
}
return { variable, arg: args.join(PromptText.VARIABLE_SEPARATOR_CHAR) };
}
}

View File

@@ -0,0 +1,144 @@
// *****************************************************************************
// Copyright (C) 2024 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 { InMemoryResources, URI, nls } from '@theia/core';
import { AbstractDialog } from '@theia/core/lib/browser/dialogs';
import { Message } from '@theia/core/lib/browser';
import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor';
import { MonacoEditorProvider } from '@theia/monaco/lib/browser/monaco-editor-provider';
export interface SessionSettingsDialogProps {
initialSettings: { [key: string]: unknown } | undefined;
}
export class SessionSettingsDialog extends AbstractDialog<{ [key: string]: unknown }> {
protected jsonEditor: MonacoEditor | undefined;
protected dialogContent: HTMLDivElement;
protected errorMessageDiv: HTMLDivElement;
protected settings: { [key: string]: unknown } = {};
protected initialSettingsString: string;
constructor(
protected readonly editorProvider: MonacoEditorProvider,
protected readonly resources: InMemoryResources,
protected readonly uri: URI,
protected readonly options: SessionSettingsDialogProps
) {
super({
title: nls.localize('theia/ai/session-settings-dialog/title', 'Set Session Settings')
});
const initialSettings = options.initialSettings;
this.initialSettingsString = JSON.stringify(initialSettings, undefined, 2) || '{}';
this.contentNode.classList.add('monaco-session-settings-dialog');
this.dialogContent = document.createElement('div');
this.dialogContent.className = 'session-settings-container';
this.contentNode.appendChild(this.dialogContent);
this.errorMessageDiv = document.createElement('div');
this.errorMessageDiv.className = 'session-settings-error';
this.contentNode.appendChild(this.errorMessageDiv);
this.appendCloseButton(nls.localizeByDefault('Cancel'));
this.appendAcceptButton(nls.localizeByDefault('Apply'));
this.createJsonEditor();
this.validateJson();
}
protected override onAfterAttach(msg: Message): void {
super.onAfterAttach(msg);
this.update();
}
protected override onActivateRequest(msg: Message): void {
super.onActivateRequest(msg);
if (this.jsonEditor) {
this.jsonEditor.focus();
}
}
protected async createJsonEditor(): Promise<void> {
this.resources.update(this.uri, this.initialSettingsString);
try {
const editor = await this.editorProvider.createInline(this.uri, this.dialogContent, {
language: 'json',
automaticLayout: true,
minimap: {
enabled: false
},
scrollBeyondLastLine: false,
folding: true,
lineNumbers: 'on',
fontSize: 13,
wordWrap: 'on',
renderValidationDecorations: 'on',
scrollbar: {
vertical: 'auto',
horizontal: 'auto'
}
});
editor.getControl().onDidChangeModelContent(() => {
this.validateJson();
});
editor.document.textEditorModel.setValue(this.initialSettingsString);
this.jsonEditor = editor;
this.validateJson();
} catch (error) {
console.error('Failed to create JSON editor:', error);
}
}
protected validateJson(): void {
if (!this.jsonEditor) {
return;
}
const jsonContent = this.jsonEditor.getControl().getValue();
try {
this.settings = JSON.parse(jsonContent);
this.errorMessageDiv.textContent = '';
this.setErrorButtonState(false);
} catch (error) {
this.errorMessageDiv.textContent = `${error}`;
this.setErrorButtonState(true);
}
}
protected setErrorButtonState(isError: boolean): void {
const acceptButton = this.acceptButton;
if (acceptButton) {
acceptButton.disabled = isError;
if (isError) {
acceptButton.classList.add('disabled');
} else {
acceptButton.classList.remove('disabled');
}
}
}
get value(): { [key: string]: unknown } {
return this.settings;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
{
"extends": "../../configs/base.tsconfig",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
],
"references": [
{
"path": "../ai-chat"
},
{
"path": "../ai-core"
},
{
"path": "../core"
},
{
"path": "../editor"
},
{
"path": "../editor-preview"
},
{
"path": "../filesystem"
},
{
"path": "../monaco"
},
{
"path": "../preferences"
},
{
"path": "../workspace"
}
]
}