deploy: current vibn theia state
Made-with: Cursor
This commit is contained in:
10
packages/ai-terminal/.eslintrc.js
Normal file
10
packages/ai-terminal/.eslintrc.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
extends: [
|
||||
'../../configs/build.eslintrc.json'
|
||||
],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: 'tsconfig.json'
|
||||
}
|
||||
};
|
||||
51
packages/ai-terminal/README.md
Normal file
51
packages/ai-terminal/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
<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 TERMINAL EXTENSION</h2>
|
||||
|
||||
<hr />
|
||||
|
||||
</div>
|
||||
|
||||
## Description
|
||||
|
||||
The `@theia/ai-terminal` extension contributes an overlay to the terminal view.\
|
||||
The overlay can be used to ask a dedicated `TerminalAgent` for suggestions of terminal commands.
|
||||
|
||||
It also provides the `shellExecute` tool that allows AI agents to run commands on the host system.
|
||||
|
||||
## Shell Execution Tool
|
||||
|
||||
The `shellExecute` tool enables AI agents to execute shell commands on the host system with user confirmation.
|
||||
|
||||
### Security
|
||||
|
||||
By default, every command requires explicit user approval. The tool is marked with `confirmAlwaysAllow`, which shows an additional warning dialog when users try to enable auto-approval.
|
||||
|
||||
> **Warning**: This tool has full system access. Only enable auto-approval in isolated environments (containers, VMs).
|
||||
|
||||
### Features
|
||||
|
||||
- Execute any shell command (bash on Linux/macOS, cmd/PowerShell on Windows)
|
||||
- Configurable working directory and timeout (default 2 min, max 10 min)
|
||||
- Output truncation (first/last 50 lines) for large outputs
|
||||
- Cancellation support
|
||||
|
||||
## Additional Information
|
||||
|
||||
- [API documentation for `@theia/ai-terminal`](https://eclipse-theia.github.io/theia/docs/next/modules/_theia_ai_terminal.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>
|
||||
54
packages/ai-terminal/package.json
Normal file
54
packages/ai-terminal/package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@theia/ai-terminal",
|
||||
"version": "1.68.0",
|
||||
"description": "Theia - AI Terminal Extension",
|
||||
"dependencies": {
|
||||
"@theia/ai-chat": "1.68.0",
|
||||
"@theia/ai-chat-ui": "1.68.0",
|
||||
"@theia/ai-core": "1.68.0",
|
||||
"@theia/core": "1.68.0",
|
||||
"@theia/terminal": "1.68.0",
|
||||
"@theia/workspace": "1.68.0",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"theiaExtensions": [
|
||||
{
|
||||
"frontend": "lib/browser/ai-terminal-frontend-module",
|
||||
"backend": "lib/node/ai-terminal-backend-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"
|
||||
}
|
||||
177
packages/ai-terminal/src/browser/ai-terminal-agent.ts
Normal file
177
packages/ai-terminal/src/browser/ai-terminal-agent.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
// *****************************************************************************
|
||||
// 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 {
|
||||
Agent,
|
||||
getJsonOfResponse,
|
||||
isLanguageModelParsedResponse,
|
||||
LanguageModelRegistry,
|
||||
LanguageModelRequirement,
|
||||
PromptService,
|
||||
UserRequest
|
||||
} from '@theia/ai-core/lib/common';
|
||||
import { LanguageModelService } from '@theia/ai-core/lib/browser';
|
||||
import { generateUuid, ILogger, nls } from '@theia/core';
|
||||
import { terminalPrompts } from './ai-terminal-prompt-template';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { z } from 'zod';
|
||||
|
||||
const Commands = z.object({
|
||||
commands: z.array(z.string()),
|
||||
});
|
||||
type Commands = z.infer<typeof Commands>;
|
||||
|
||||
@injectable()
|
||||
export class AiTerminalAgent implements Agent {
|
||||
|
||||
id = 'Terminal Assistant';
|
||||
name = 'Terminal Assistant';
|
||||
description = nls.localize('theia/ai/terminal/agent/description', 'This agent provides assistance to write and execute arbitrary terminal commands. \
|
||||
Based on the user\'s request, it suggests commands and allows the user to directly paste and execute them in the terminal. \
|
||||
It accesses the current directory, environment and the recent terminal output of the terminal session to provide context-aware assistance');
|
||||
variables = [];
|
||||
functions = [];
|
||||
agentSpecificVariables = [
|
||||
{
|
||||
name: 'userRequest',
|
||||
usedInPrompt: true,
|
||||
description: nls.localize('theia/ai/terminal/agent/vars/userRequest/description', 'The user\'s question or request.')
|
||||
},
|
||||
{
|
||||
name: 'shell',
|
||||
usedInPrompt: true,
|
||||
description: nls.localize('theia/ai/terminal/agent/vars/shell/description', 'The shell being used, e.g., /usr/bin/zsh.')
|
||||
},
|
||||
{
|
||||
name: 'cwd',
|
||||
usedInPrompt: true,
|
||||
description: nls.localize('theia/ai/terminal/agent/vars/cwd/description', 'The current working directory.')
|
||||
},
|
||||
{
|
||||
name: 'recentTerminalContents',
|
||||
usedInPrompt: true,
|
||||
description: nls.localize('theia/ai/terminal/agent/vars/recentTerminalContents/description', 'The last 0 to 50 recent lines visible in the terminal.')
|
||||
}
|
||||
];
|
||||
prompts = terminalPrompts;
|
||||
languageModelRequirements: LanguageModelRequirement[] = [
|
||||
{
|
||||
purpose: 'suggest-terminal-commands',
|
||||
identifier: 'default/universal',
|
||||
}
|
||||
];
|
||||
|
||||
@inject(LanguageModelRegistry)
|
||||
protected languageModelRegistry: LanguageModelRegistry;
|
||||
|
||||
@inject(PromptService)
|
||||
protected promptService: PromptService;
|
||||
|
||||
@inject(ILogger)
|
||||
protected logger: ILogger;
|
||||
|
||||
@inject(LanguageModelService)
|
||||
protected languageModelService: LanguageModelService;
|
||||
|
||||
async getCommands(
|
||||
userRequest: string,
|
||||
cwd: string,
|
||||
shell: string,
|
||||
recentTerminalContents: string[],
|
||||
): Promise<string[]> {
|
||||
const lm = await this.languageModelRegistry.selectLanguageModel({
|
||||
agent: this.id,
|
||||
...this.languageModelRequirements[0]
|
||||
});
|
||||
if (!lm) {
|
||||
this.logger.error('No language model available for the AI Terminal Agent.');
|
||||
return [];
|
||||
}
|
||||
|
||||
const parameters = {
|
||||
userRequest,
|
||||
shell,
|
||||
cwd,
|
||||
recentTerminalContents
|
||||
};
|
||||
|
||||
const systemMessage = await this.promptService.getResolvedPromptFragment('terminal-system', parameters).then(p => p?.text);
|
||||
const request = await this.promptService.getResolvedPromptFragment('terminal-user', parameters).then(p => p?.text);
|
||||
if (!systemMessage || !request) {
|
||||
this.logger.error('The prompt service didn\'t return prompts for the AI Terminal Agent.');
|
||||
return [];
|
||||
}
|
||||
|
||||
const variantInfo = this.promptService.getPromptVariantInfo('terminal-system');
|
||||
|
||||
// since we do not actually hold complete conversions, the request/response pair is considered a session
|
||||
const sessionId = generateUuid();
|
||||
const requestId = generateUuid();
|
||||
const llmRequest: UserRequest = {
|
||||
messages: [
|
||||
{
|
||||
actor: 'ai',
|
||||
type: 'text',
|
||||
text: systemMessage
|
||||
},
|
||||
{
|
||||
actor: 'user',
|
||||
type: 'text',
|
||||
text: request
|
||||
}
|
||||
],
|
||||
response_format: {
|
||||
type: 'json_schema',
|
||||
json_schema: {
|
||||
name: 'terminal-commands',
|
||||
description: 'Suggested terminal commands based on the user request',
|
||||
schema: Commands.toJSONSchema()
|
||||
}
|
||||
},
|
||||
agentId: this.id,
|
||||
requestId,
|
||||
sessionId,
|
||||
promptVariantId: variantInfo?.variantId,
|
||||
isPromptVariantCustomized: variantInfo?.isCustomized
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await this.languageModelService.sendRequest(lm, llmRequest);
|
||||
|
||||
if (isLanguageModelParsedResponse(result)) {
|
||||
// model returned structured output
|
||||
const parsedResult = Commands.safeParse(result.parsed);
|
||||
if (parsedResult.success) {
|
||||
return parsedResult.data.commands;
|
||||
}
|
||||
}
|
||||
|
||||
// fall back to agent-based parsing of result
|
||||
const jsonResult = await getJsonOfResponse(result);
|
||||
const parsedJsonResult = Commands.safeParse(jsonResult);
|
||||
if (parsedJsonResult.success) {
|
||||
return parsedJsonResult.data.commands;
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('Error obtaining the command suggestions.', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
209
packages/ai-terminal/src/browser/ai-terminal-contribution.ts
Normal file
209
packages/ai-terminal/src/browser/ai-terminal-contribution.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
// *****************************************************************************
|
||||
// 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 { ENABLE_AI_CONTEXT_KEY } from '@theia/ai-core/lib/browser';
|
||||
import { Command, CommandContribution, CommandRegistry, MenuContribution, MenuModelRegistry } from '@theia/core';
|
||||
import { ApplicationShell, codicon, KeybindingContribution, KeybindingRegistry } from '@theia/core/lib/browser';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { TerminalService } from '@theia/terminal/lib/browser/base/terminal-service';
|
||||
import { TerminalMenus } from '@theia/terminal/lib/browser/terminal-frontend-contribution';
|
||||
import { TerminalWidgetImpl } from '@theia/terminal/lib/browser/terminal-widget-impl';
|
||||
import { AiTerminalAgent } from './ai-terminal-agent';
|
||||
import { AICommandHandlerFactory } from '@theia/ai-core/lib/browser/ai-command-handler-factory';
|
||||
import { AgentService } from '@theia/ai-core';
|
||||
import { nls } from '@theia/core/lib/common/nls';
|
||||
|
||||
const AI_TERMINAL_COMMAND = Command.toLocalizedCommand({
|
||||
id: 'ai-terminal:open',
|
||||
label: 'Ask AI',
|
||||
iconClass: codicon('sparkle')
|
||||
}, 'theia/ai/terminal/askAi');
|
||||
|
||||
@injectable()
|
||||
export class AiTerminalCommandContribution implements CommandContribution, MenuContribution, KeybindingContribution {
|
||||
|
||||
@inject(TerminalService)
|
||||
protected terminalService: TerminalService;
|
||||
|
||||
@inject(AiTerminalAgent)
|
||||
protected terminalAgent: AiTerminalAgent;
|
||||
|
||||
@inject(AICommandHandlerFactory)
|
||||
protected commandHandlerFactory: AICommandHandlerFactory;
|
||||
|
||||
@inject(AgentService)
|
||||
private readonly agentService: AgentService;
|
||||
|
||||
@inject(ApplicationShell)
|
||||
protected readonly shell: ApplicationShell;
|
||||
|
||||
registerKeybindings(keybindings: KeybindingRegistry): void {
|
||||
keybindings.registerKeybinding({
|
||||
command: AI_TERMINAL_COMMAND.id,
|
||||
keybinding: 'ctrlcmd+i',
|
||||
when: `terminalFocus && ${ENABLE_AI_CONTEXT_KEY}`
|
||||
});
|
||||
}
|
||||
registerMenus(menus: MenuModelRegistry): void {
|
||||
menus.registerMenuAction([...TerminalMenus.TERMINAL_CONTEXT_MENU, '_5'], {
|
||||
when: ENABLE_AI_CONTEXT_KEY,
|
||||
commandId: AI_TERMINAL_COMMAND.id,
|
||||
icon: AI_TERMINAL_COMMAND.iconClass
|
||||
});
|
||||
}
|
||||
registerCommands(commands: CommandRegistry): void {
|
||||
commands.registerCommand(AI_TERMINAL_COMMAND, this.commandHandlerFactory({
|
||||
execute: () => {
|
||||
const currentTerminal = this.terminalService.currentTerminal;
|
||||
if (currentTerminal instanceof TerminalWidgetImpl && currentTerminal.kind === 'user') {
|
||||
new AiTerminalChatWidget(
|
||||
currentTerminal,
|
||||
this.terminalAgent
|
||||
);
|
||||
}
|
||||
},
|
||||
isEnabled: () =>
|
||||
// Ensure it is only enabled for terminals explicitly launched by the user, not to terminals created e.g. for running tasks
|
||||
this.agentService.isEnabled(this.terminalAgent.id)
|
||||
&& this.shell.currentWidget instanceof TerminalWidgetImpl
|
||||
&& (this.shell.currentWidget as TerminalWidgetImpl).kind === 'user'
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
class AiTerminalChatWidget {
|
||||
|
||||
protected chatContainer: HTMLDivElement;
|
||||
protected chatInput: HTMLTextAreaElement;
|
||||
protected chatResultParagraph: HTMLParagraphElement;
|
||||
protected chatInputContainer: HTMLDivElement;
|
||||
|
||||
protected haveResult = false;
|
||||
commands: string[];
|
||||
|
||||
constructor(
|
||||
protected terminalWidget: TerminalWidgetImpl,
|
||||
protected terminalAgent: AiTerminalAgent
|
||||
) {
|
||||
this.chatContainer = document.createElement('div');
|
||||
this.chatContainer.className = 'ai-terminal-chat-container';
|
||||
|
||||
const chatCloseButton = document.createElement('span');
|
||||
chatCloseButton.className = 'closeButton codicon codicon-close';
|
||||
chatCloseButton.onclick = () => this.dispose();
|
||||
this.chatContainer.appendChild(chatCloseButton);
|
||||
|
||||
const chatResultContainer = document.createElement('div');
|
||||
chatResultContainer.className = 'ai-terminal-chat-result';
|
||||
this.chatResultParagraph = document.createElement('p');
|
||||
this.chatResultParagraph.textContent = nls.localize('theia/ai/terminal/howCanIHelp', 'How can I help you?');
|
||||
chatResultContainer.appendChild(this.chatResultParagraph);
|
||||
this.chatContainer.appendChild(chatResultContainer);
|
||||
|
||||
this.chatInputContainer = document.createElement('div');
|
||||
this.chatInputContainer.className = 'ai-terminal-chat-input-container';
|
||||
|
||||
this.chatInput = document.createElement('textarea');
|
||||
this.chatInput.className = 'theia-input theia-ChatInput';
|
||||
this.chatInput.placeholder = nls.localize('theia/ai/terminal/askTerminalCommand', 'Ask about a terminal command...');
|
||||
this.chatInput.onkeydown = event => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (!this.haveResult) {
|
||||
this.send();
|
||||
} else {
|
||||
this.terminalWidget.sendText(this.chatResultParagraph.innerText);
|
||||
this.dispose();
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
this.dispose();
|
||||
} else if (event.key === 'ArrowUp' && this.haveResult) {
|
||||
this.updateChatResult(this.getNextCommandIndex(1));
|
||||
} else if (event.key === 'ArrowDown' && this.haveResult) {
|
||||
this.updateChatResult(this.getNextCommandIndex(-1));
|
||||
}
|
||||
};
|
||||
this.chatInputContainer.appendChild(this.chatInput);
|
||||
|
||||
const chatInputOptionsContainer = document.createElement('div');
|
||||
const chatInputOptionsSpan = document.createElement('span');
|
||||
chatInputOptionsSpan.className = 'codicon codicon-send option';
|
||||
chatInputOptionsSpan.title = nls.localizeByDefault('Send');
|
||||
chatInputOptionsSpan.onclick = () => this.send();
|
||||
chatInputOptionsContainer.appendChild(chatInputOptionsSpan);
|
||||
this.chatInputContainer.appendChild(chatInputOptionsContainer);
|
||||
|
||||
this.chatContainer.appendChild(this.chatInputContainer);
|
||||
|
||||
terminalWidget.node.appendChild(this.chatContainer);
|
||||
|
||||
this.chatInput.focus();
|
||||
}
|
||||
|
||||
protected async send(): Promise<void> {
|
||||
const userRequest = this.chatInput.value;
|
||||
if (userRequest) {
|
||||
this.chatInput.value = '';
|
||||
|
||||
this.chatResultParagraph.innerText = nls.localize('theia/ai/terminal/loading', 'Loading');
|
||||
this.chatResultParagraph.className = 'loading';
|
||||
|
||||
const cwd = (await this.terminalWidget.cwd).toString();
|
||||
const processInfo = await this.terminalWidget.processInfo;
|
||||
const shell = processInfo.executable;
|
||||
const recentTerminalContents = this.getRecentTerminalCommands();
|
||||
|
||||
this.commands = await this.terminalAgent.getCommands(userRequest, cwd, shell, recentTerminalContents);
|
||||
|
||||
if (this.commands.length > 0) {
|
||||
this.chatResultParagraph.className = 'command';
|
||||
this.chatResultParagraph.innerText = this.commands[0];
|
||||
this.chatInput.placeholder = nls.localize('theia/ai/terminal/hitEnterConfirm', 'Hit enter to confirm');
|
||||
if (this.commands.length > 1) {
|
||||
this.chatInput.placeholder += nls.localize('theia/ai/terminal/useArrowsAlternatives', ' or use ⇅ to show alternatives...');
|
||||
}
|
||||
this.haveResult = true;
|
||||
} else {
|
||||
this.chatResultParagraph.className = '';
|
||||
this.chatResultParagraph.innerText = nls.localizeByDefault('No results');
|
||||
this.chatInput.placeholder = nls.localize('theia/ai/terminal/tryAgain', 'Try again...');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected getRecentTerminalCommands(): string[] {
|
||||
const maxLines = 100;
|
||||
return this.terminalWidget.buffer.getLines(0,
|
||||
this.terminalWidget.buffer.length > maxLines ? maxLines : this.terminalWidget.buffer.length
|
||||
);
|
||||
}
|
||||
|
||||
protected getNextCommandIndex(step: number): number {
|
||||
const currentIndex = this.commands.indexOf(this.chatResultParagraph.innerText);
|
||||
const nextIndex = (currentIndex + step + this.commands.length) % this.commands.length;
|
||||
return nextIndex;
|
||||
}
|
||||
|
||||
protected updateChatResult(index: number): void {
|
||||
this.chatResultParagraph.innerText = this.commands[index];
|
||||
}
|
||||
|
||||
protected dispose(): void {
|
||||
this.chatInput.value = '';
|
||||
this.terminalWidget.node.removeChild(this.chatContainer);
|
||||
this.terminalWidget.getTerminal().focus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// *****************************************************************************
|
||||
// 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 '@theia/ai-chat-ui/lib/browser/chat-response-part-renderer';
|
||||
import { Agent } from '@theia/ai-core/lib/common';
|
||||
import { bindToolProvider } from '@theia/ai-core/lib/common/tool-invocation-registry';
|
||||
import { CommandContribution, MenuContribution } from '@theia/core';
|
||||
import { KeybindingContribution, WebSocketConnectionProvider } from '@theia/core/lib/browser';
|
||||
import { ContainerModule } from '@theia/core/shared/inversify';
|
||||
import { AiTerminalAgent } from './ai-terminal-agent';
|
||||
import { AiTerminalCommandContribution } from './ai-terminal-contribution';
|
||||
import { ShellExecutionTool } from './shell-execution-tool';
|
||||
import { ShellExecutionToolRenderer } from './shell-execution-tool-renderer';
|
||||
import { ShellExecutionServer, shellExecutionPath } from '../common/shell-execution-server';
|
||||
|
||||
import '../../src/browser/style/ai-terminal.css';
|
||||
import '../../src/browser/style/shell-execution-tool.css';
|
||||
|
||||
export default new ContainerModule(bind => {
|
||||
bind(AiTerminalCommandContribution).toSelf().inSingletonScope();
|
||||
for (const identifier of [CommandContribution, MenuContribution, KeybindingContribution]) {
|
||||
bind(identifier).toService(AiTerminalCommandContribution);
|
||||
}
|
||||
|
||||
bind(AiTerminalAgent).toSelf().inSingletonScope();
|
||||
bind(Agent).toService(AiTerminalAgent);
|
||||
|
||||
bindToolProvider(ShellExecutionTool, bind);
|
||||
|
||||
bind(ShellExecutionServer).toDynamicValue(ctx => {
|
||||
const connection = ctx.container.get(WebSocketConnectionProvider);
|
||||
return connection.createProxy<ShellExecutionServer>(shellExecutionPath);
|
||||
}).inSingletonScope();
|
||||
|
||||
bind(ChatResponsePartRenderer).to(ShellExecutionToolRenderer).inSingletonScope();
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/* eslint-disable @typescript-eslint/tslint/config */
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH and others.
|
||||
//
|
||||
// This file is licensed under the MIT License.
|
||||
// See LICENSE-MIT.txt in the project root for license information.
|
||||
// https://opensource.org/license/mit.
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
// *****************************************************************************
|
||||
|
||||
import { PromptVariantSet } from '@theia/ai-core';
|
||||
|
||||
export const terminalPrompts: PromptVariantSet[] = [
|
||||
{
|
||||
id: 'terminal-system',
|
||||
defaultVariant: {
|
||||
id: 'terminal-system-default',
|
||||
template: `{{!-- This prompt is licensed under the MIT License (https://opensource.org/license/mit).
|
||||
Made improvements or adaptations to this prompt template? We’d love for you to share it with the community! Contribute back here:
|
||||
https://github.com/eclipse-theia/theia/discussions/new?category=prompt-template-contribution --}}
|
||||
# Instructions
|
||||
Generate one or more command suggestions based on the user's request, considering the shell being used,
|
||||
the current working directory, and the recent terminal contents. Provide the best suggestion first,
|
||||
followed by other relevant suggestions if the user asks for further options.
|
||||
|
||||
Parameters:
|
||||
- user-request: The user's question or request.
|
||||
- shell: The shell being used, e.g., /usr/bin/zsh.
|
||||
- cwd: The current working directory.
|
||||
- recent-terminal-contents: The last 0 to 50 recent lines visible in the terminal.
|
||||
|
||||
Return the result in the following JSON format:
|
||||
{
|
||||
"commands": [
|
||||
"best_command_suggestion",
|
||||
"next_best_command_suggestion",
|
||||
"another_command_suggestion"
|
||||
]
|
||||
}
|
||||
|
||||
## Example
|
||||
user-request: "How do I commit changes?"
|
||||
shell: "/usr/bin/zsh"
|
||||
cwd: "/home/user/project"
|
||||
recent-terminal-contents:
|
||||
git status
|
||||
On branch main
|
||||
Your branch is up to date with 'origin/main'.
|
||||
nothing to commit, working tree clean
|
||||
|
||||
## Expected JSON output
|
||||
\`\`\`json
|
||||
\{
|
||||
"commands": [
|
||||
"git commit",
|
||||
"git commit --amend",
|
||||
"git commit -a"
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
`
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'terminal-user',
|
||||
defaultVariant: {
|
||||
id: 'terminal-user-default',
|
||||
template: `{{!-- This prompt is licensed under the MIT License (https://opensource.org/license/mit).
|
||||
Made improvements or adaptations to this prompt template? We’d love for you to share it with the community! Contribute back here:
|
||||
https://github.com/eclipse-theia/theia/discussions/new?category=prompt-template-contribution --}}
|
||||
user-request: {{userRequest}}
|
||||
shell: {{shell}}
|
||||
cwd: {{cwd}}
|
||||
recent-terminal-contents:
|
||||
{{recentTerminalContents}}
|
||||
`
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,648 @@
|
||||
// *****************************************************************************
|
||||
// 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 '@theia/ai-chat-ui/lib/browser/chat-response-part-renderer';
|
||||
import { ResponseNode } from '@theia/ai-chat-ui/lib/browser/chat-tree-view';
|
||||
import { ToolConfirmationActions, ConfirmationScope } from '@theia/ai-chat-ui/lib/browser/chat-response-renderer/tool-confirmation';
|
||||
import { ChatResponseContent, ToolCallChatResponseContent } from '@theia/ai-chat/lib/common';
|
||||
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';
|
||||
import { ToolInvocationRegistry, ToolRequest } from '@theia/ai-core';
|
||||
import { nls } from '@theia/core/lib/common/nls';
|
||||
import { codicon, ContextMenuRenderer } from '@theia/core/lib/browser';
|
||||
import { ClipboardService } from '@theia/core/lib/browser/clipboard-service';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import * as React from '@theia/core/shared/react';
|
||||
import { ReactNode } from '@theia/core/shared/react';
|
||||
import { ShellExecutionTool } from './shell-execution-tool';
|
||||
import {
|
||||
SHELL_EXECUTION_FUNCTION_ID,
|
||||
ShellExecutionToolResult,
|
||||
ShellExecutionCanceledResult
|
||||
} from '../common/shell-execution-server';
|
||||
import { parseShellExecutionInput, ShellExecutionInput } from '../common/shell-execution-input-parser';
|
||||
|
||||
@injectable()
|
||||
export class ShellExecutionToolRenderer implements ChatResponsePartRenderer<ToolCallChatResponseContent> {
|
||||
|
||||
@inject(ToolConfirmationManager)
|
||||
protected toolConfirmationManager: ToolConfirmationManager;
|
||||
|
||||
@inject(ToolInvocationRegistry)
|
||||
protected toolInvocationRegistry: ToolInvocationRegistry;
|
||||
|
||||
@inject(ShellExecutionTool)
|
||||
protected shellExecutionTool: ShellExecutionTool;
|
||||
|
||||
@inject(ClipboardService)
|
||||
protected clipboardService: ClipboardService;
|
||||
|
||||
@inject(ContextMenuRenderer)
|
||||
protected contextMenuRenderer: ContextMenuRenderer;
|
||||
|
||||
canHandle(response: ChatResponseContent): number {
|
||||
if (ToolCallChatResponseContent.is(response) && response.name === SHELL_EXECUTION_FUNCTION_ID) {
|
||||
return 20;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
render(response: ToolCallChatResponseContent, parentNode: ResponseNode): ReactNode {
|
||||
const chatId = parentNode.sessionId;
|
||||
const toolRequest = this.toolInvocationRegistry.getFunction(SHELL_EXECUTION_FUNCTION_ID);
|
||||
const confirmationMode = this.toolConfirmationManager.getConfirmationMode(
|
||||
SHELL_EXECUTION_FUNCTION_ID, chatId, toolRequest
|
||||
);
|
||||
|
||||
const input = parseShellExecutionInput(response.arguments);
|
||||
|
||||
return (
|
||||
<ShellExecutionToolComponent
|
||||
response={response}
|
||||
input={input}
|
||||
confirmationMode={confirmationMode}
|
||||
toolConfirmationManager={this.toolConfirmationManager}
|
||||
shellExecutionTool={this.shellExecutionTool}
|
||||
clipboardService={this.clipboardService}
|
||||
toolRequest={toolRequest}
|
||||
chatId={chatId}
|
||||
requestCanceled={parentNode.response.isCanceled}
|
||||
contextMenuRenderer={this.contextMenuRenderer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface ShellExecutionToolComponentProps {
|
||||
response: ToolCallChatResponseContent;
|
||||
input: ShellExecutionInput;
|
||||
confirmationMode: ToolConfirmationPreferenceMode;
|
||||
toolConfirmationManager: ToolConfirmationManager;
|
||||
shellExecutionTool: ShellExecutionTool;
|
||||
clipboardService: ClipboardService;
|
||||
toolRequest?: ToolRequest;
|
||||
chatId: string;
|
||||
requestCanceled: boolean;
|
||||
contextMenuRenderer: ContextMenuRenderer;
|
||||
}
|
||||
|
||||
type ConfirmationState = 'waiting' | 'allowed' | 'denied' | 'rejected';
|
||||
|
||||
const ShellExecutionToolComponent: React.FC<ShellExecutionToolComponentProps> = ({
|
||||
response,
|
||||
input,
|
||||
confirmationMode,
|
||||
toolConfirmationManager,
|
||||
shellExecutionTool,
|
||||
clipboardService,
|
||||
toolRequest,
|
||||
chatId,
|
||||
requestCanceled,
|
||||
contextMenuRenderer
|
||||
}) => {
|
||||
const getInitialState = (): ConfirmationState => {
|
||||
if (confirmationMode === ToolConfirmationPreferenceMode.ALWAYS_ALLOW) {
|
||||
return 'allowed';
|
||||
}
|
||||
if (confirmationMode === ToolConfirmationPreferenceMode.DISABLED) {
|
||||
return 'denied';
|
||||
}
|
||||
if (response.finished) {
|
||||
return ToolCallChatResponseContent.isDenialResult(response.result) ? 'denied' : 'allowed';
|
||||
}
|
||||
return 'waiting';
|
||||
};
|
||||
|
||||
const [confirmationState, setConfirmationState] = React.useState<ConfirmationState>(getInitialState);
|
||||
const [toolFinished, setToolFinished] = React.useState(response.finished);
|
||||
const [isCanceling, setIsCanceling] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (confirmationMode === ToolConfirmationPreferenceMode.ALWAYS_ALLOW) {
|
||||
response.confirm();
|
||||
} else if (confirmationMode === ToolConfirmationPreferenceMode.DISABLED) {
|
||||
response.deny();
|
||||
} else {
|
||||
response.confirmed
|
||||
.then(confirmed => {
|
||||
setConfirmationState(confirmed ? 'allowed' : 'denied');
|
||||
})
|
||||
.catch(err => {
|
||||
console.debug('Shell execution tool confirmation rejected:', err);
|
||||
setConfirmationState('rejected');
|
||||
});
|
||||
}
|
||||
}, [response, confirmationMode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (toolFinished || response.finished) {
|
||||
setToolFinished(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
response.whenFinished.then(() => {
|
||||
if (!cancelled) {
|
||||
setToolFinished(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [toolFinished, response]);
|
||||
|
||||
const handleCancel = React.useCallback(async () => {
|
||||
if (isCanceling || !response.id) {
|
||||
return;
|
||||
}
|
||||
setIsCanceling(true);
|
||||
try {
|
||||
await shellExecutionTool.cancelExecution(response.id);
|
||||
} catch (err) {
|
||||
console.debug('Failed to cancel shell execution:', err);
|
||||
}
|
||||
// Don't reset isCanceling - stay in canceling state until tool finishes
|
||||
}, [response.id, shellExecutionTool, isCanceling]);
|
||||
|
||||
const handleAllow = React.useCallback((scope: ConfirmationScope) => {
|
||||
if (scope === 'forever') {
|
||||
toolConfirmationManager.setConfirmationMode(SHELL_EXECUTION_FUNCTION_ID, ToolConfirmationPreferenceMode.ALWAYS_ALLOW, toolRequest);
|
||||
} else if (scope === 'session') {
|
||||
toolConfirmationManager.setSessionConfirmationMode(SHELL_EXECUTION_FUNCTION_ID, ToolConfirmationPreferenceMode.ALWAYS_ALLOW, chatId);
|
||||
}
|
||||
response.confirm();
|
||||
}, [response, toolConfirmationManager, chatId, toolRequest]);
|
||||
|
||||
const handleDeny = React.useCallback((scope: ConfirmationScope, reason?: string) => {
|
||||
if (scope === 'forever') {
|
||||
toolConfirmationManager.setConfirmationMode(SHELL_EXECUTION_FUNCTION_ID, ToolConfirmationPreferenceMode.DISABLED);
|
||||
} else if (scope === 'session') {
|
||||
toolConfirmationManager.setSessionConfirmationMode(SHELL_EXECUTION_FUNCTION_ID, ToolConfirmationPreferenceMode.DISABLED, chatId);
|
||||
}
|
||||
response.deny(reason);
|
||||
}, [response, toolConfirmationManager, chatId]);
|
||||
|
||||
let result: ShellExecutionToolResult | undefined;
|
||||
let canceledResult: ShellExecutionCanceledResult | undefined;
|
||||
if (toolFinished && response.result) {
|
||||
try {
|
||||
const parsed = typeof response.result === 'string'
|
||||
? JSON.parse(response.result)
|
||||
: response.result;
|
||||
if (ShellExecutionCanceledResult.is(parsed)) {
|
||||
canceledResult = parsed;
|
||||
} else if (ShellExecutionToolResult.is(parsed)) {
|
||||
result = parsed;
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug('Failed to parse shell execution result:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!input.command && !toolFinished) {
|
||||
return (
|
||||
<div className="shell-execution-tool container">
|
||||
<div className="shell-execution-tool header running">
|
||||
<span className={codicon('terminal')} />
|
||||
<span className={`${codicon('loading')} theia-animation-spin`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (confirmationState === 'waiting' && !requestCanceled && !toolFinished) {
|
||||
return (
|
||||
<ConfirmationUI
|
||||
input={input}
|
||||
toolRequest={toolRequest}
|
||||
onAllow={handleAllow}
|
||||
onDeny={handleDeny}
|
||||
contextMenuRenderer={contextMenuRenderer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (confirmationState === 'denied' || confirmationState === 'rejected') {
|
||||
const denialResult = ToolCallChatResponseContent.isDenialResult(response.result) ? response.result : undefined;
|
||||
return (
|
||||
<DeniedUI
|
||||
input={input}
|
||||
confirmationState={confirmationState}
|
||||
denialReason={denialResult?.reason}
|
||||
clipboardService={clipboardService}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Show canceling state when user clicked cancel on this command and it's still stopping
|
||||
if (!toolFinished && isCanceling && !requestCanceled) {
|
||||
return (
|
||||
<CancelingUI input={input} />
|
||||
);
|
||||
}
|
||||
|
||||
if (!toolFinished && confirmationState === 'allowed' && !requestCanceled) {
|
||||
return (
|
||||
<RunningUI
|
||||
input={input}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Show canceled UI when tool was running and got canceled (has a canceled result with partial output)
|
||||
if (canceledResult) {
|
||||
return (
|
||||
<CanceledUI
|
||||
input={input}
|
||||
canceledResult={canceledResult}
|
||||
clipboardService={clipboardService}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FinishedUI
|
||||
input={input}
|
||||
result={result}
|
||||
clipboardService={clipboardService}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface ConfirmationUIProps {
|
||||
input: ShellExecutionInput;
|
||||
toolRequest?: ToolRequest;
|
||||
onAllow: (scope: ConfirmationScope) => void;
|
||||
onDeny: (scope: ConfirmationScope, reason?: string) => void;
|
||||
contextMenuRenderer: ContextMenuRenderer;
|
||||
}
|
||||
|
||||
const ConfirmationUI: React.FC<ConfirmationUIProps> = ({
|
||||
input,
|
||||
toolRequest,
|
||||
onAllow,
|
||||
onDeny,
|
||||
contextMenuRenderer
|
||||
}) => (
|
||||
<div className="shell-execution-tool container">
|
||||
<div className="shell-execution-tool confirmation">
|
||||
<div className="shell-execution-tool confirmation-header">
|
||||
<span className={codicon('shield')} />
|
||||
<span className="shell-execution-tool confirmation-title">
|
||||
{nls.localize('theia/ai-terminal/confirmExecution', 'Confirm Shell Command')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="shell-execution-tool command-display confirmation">
|
||||
<code>{input.command}</code>
|
||||
</div>
|
||||
|
||||
<div className="shell-execution-tool confirmation-meta">
|
||||
{input.cwd && (
|
||||
<span
|
||||
className="shell-execution-tool meta-item"
|
||||
title={nls.localize('theia/ai-terminal/workingDirectory', 'Working directory')}
|
||||
>
|
||||
<span className={codicon('folder')} />
|
||||
{input.cwd}
|
||||
</span>
|
||||
)}
|
||||
{input.timeout && (
|
||||
<span
|
||||
className="shell-execution-tool meta-item"
|
||||
title={nls.localize('theia/ai-terminal/timeout', 'Timeout')}
|
||||
>
|
||||
<span className={codicon('clock')} />
|
||||
{formatDuration(input.timeout)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ToolConfirmationActions
|
||||
toolName={SHELL_EXECUTION_FUNCTION_ID}
|
||||
toolRequest={toolRequest}
|
||||
onAllow={onAllow}
|
||||
onDeny={onDeny}
|
||||
contextMenuRenderer={contextMenuRenderer}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface RunningUIProps {
|
||||
input: ShellExecutionInput;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const RunningUI: React.FC<RunningUIProps> = ({
|
||||
input,
|
||||
onCancel
|
||||
}) => (
|
||||
<div className="shell-execution-tool container">
|
||||
<div className="shell-execution-tool header running">
|
||||
<span className={codicon('terminal')} />
|
||||
<code className="shell-execution-tool command-preview">{truncateCommand(input.command)}</code>
|
||||
<span className="shell-execution-tool meta-badges">
|
||||
<button
|
||||
className="shell-execution-tool cancel-button"
|
||||
onClick={onCancel}
|
||||
title={nls.localize('theia/ai-terminal/cancelExecution', 'Cancel command execution')}
|
||||
>
|
||||
<span className={codicon('debug-stop')} />
|
||||
</button>
|
||||
<span className={`${codicon('loading')} shell-execution-tool status-icon theia-animation-spin`} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface CancelingUIProps {
|
||||
input: ShellExecutionInput;
|
||||
}
|
||||
|
||||
const CancelingUI: React.FC<CancelingUIProps> = ({ input }) => (
|
||||
<div className="shell-execution-tool container">
|
||||
<div className="shell-execution-tool header canceling">
|
||||
<span className={codicon('terminal')} />
|
||||
<code className="shell-execution-tool command-preview">{truncateCommand(input.command)}</code>
|
||||
<span className="shell-execution-tool meta-badges">
|
||||
<span className="shell-execution-tool status-label canceling">
|
||||
<span className={`${codicon('loading')} theia-animation-spin`} />
|
||||
{nls.localize('theia/ai-terminal/canceling', 'Canceling...')}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface DeniedUIProps {
|
||||
input: ShellExecutionInput;
|
||||
confirmationState: 'denied' | 'rejected';
|
||||
denialReason?: string;
|
||||
clipboardService: ClipboardService;
|
||||
}
|
||||
|
||||
const DeniedUI: React.FC<DeniedUIProps> = ({
|
||||
input,
|
||||
confirmationState,
|
||||
denialReason,
|
||||
clipboardService
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
|
||||
const getStatusLabel = (): string => {
|
||||
if (confirmationState === 'rejected') {
|
||||
return nls.localize('theia/ai-terminal/executionCanceled', 'Canceled');
|
||||
}
|
||||
return denialReason
|
||||
? nls.localize('theia/ai-terminal/executionDeniedWithReason', 'Denied with reason')
|
||||
: nls.localize('theia/ai-terminal/executionDenied', 'Denied');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`shell-execution-tool container ${isExpanded ? 'expanded' : ''}`}>
|
||||
<div
|
||||
className="shell-execution-tool header error"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<span className={codicon('terminal')} />
|
||||
<code className="shell-execution-tool command-preview">{truncateCommand(input.command)}</code>
|
||||
<span className="shell-execution-tool meta-badges">
|
||||
<span className="shell-execution-tool status-label error">
|
||||
{getStatusLabel()}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="shell-execution-tool expanded-content">
|
||||
<CommandDisplay command={input.command} clipboardService={clipboardService} />
|
||||
{input.cwd && (
|
||||
<MetaRow icon="folder" label={nls.localize('theia/ai-terminal/workingDirectory', 'Working directory')}>
|
||||
{input.cwd}
|
||||
</MetaRow>
|
||||
)}
|
||||
{denialReason && (
|
||||
<div className="shell-execution-tool denial-reason">
|
||||
<div className="shell-execution-tool denial-reason-header">
|
||||
<span className={codicon('comment')} />
|
||||
{nls.localize('theia/ai-terminal/denialReason', 'Reason')}
|
||||
</div>
|
||||
<div className="shell-execution-tool denial-reason-content">
|
||||
{denialReason}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CanceledUIProps {
|
||||
input: ShellExecutionInput;
|
||||
canceledResult?: ShellExecutionCanceledResult;
|
||||
clipboardService: ClipboardService;
|
||||
}
|
||||
|
||||
const CanceledUI: React.FC<CanceledUIProps> = ({
|
||||
input,
|
||||
canceledResult,
|
||||
clipboardService
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className={`shell-execution-tool container ${isExpanded ? 'expanded' : ''}`}>
|
||||
<div
|
||||
className="shell-execution-tool header canceled"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<span className={codicon('terminal')} />
|
||||
<code className="shell-execution-tool command-preview">{truncateCommand(input.command)}</code>
|
||||
<span className="shell-execution-tool meta-badges">
|
||||
{canceledResult?.duration !== undefined && (
|
||||
<span className="shell-execution-tool duration">{formatDuration(canceledResult.duration)}</span>
|
||||
)}
|
||||
<span className="shell-execution-tool status-label canceled">
|
||||
{nls.localize('theia/ai-terminal/executionCanceled', 'Canceled')}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="shell-execution-tool expanded-content">
|
||||
<CommandDisplay command={input.command} clipboardService={clipboardService} />
|
||||
{input.cwd && (
|
||||
<MetaRow icon="folder" label={nls.localize('theia/ai-terminal/workingDirectory', 'Working directory')}>
|
||||
{input.cwd}
|
||||
</MetaRow>
|
||||
)}
|
||||
{canceledResult?.output && (
|
||||
<OutputBox
|
||||
title={nls.localize('theia/ai-terminal/partialOutput', 'Partial Output')}
|
||||
output={canceledResult.output}
|
||||
clipboardService={clipboardService}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FinishedUIProps {
|
||||
input: ShellExecutionInput;
|
||||
result?: ShellExecutionToolResult;
|
||||
clipboardService: ClipboardService;
|
||||
}
|
||||
|
||||
const FinishedUI: React.FC<FinishedUIProps> = ({
|
||||
input,
|
||||
result,
|
||||
clipboardService
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const isSuccess = result?.success ?? false;
|
||||
|
||||
return (
|
||||
<div className={`shell-execution-tool container ${isExpanded ? 'expanded' : ''}`}>
|
||||
<div
|
||||
className={`shell-execution-tool header finished ${isSuccess ? 'success' : 'failure'}`}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<span className={codicon('terminal')} />
|
||||
<code className="shell-execution-tool command-preview">{truncateCommand(input.command)}</code>
|
||||
<span className="shell-execution-tool meta-badges">
|
||||
{result?.duration !== undefined && (
|
||||
<span className="shell-execution-tool duration">{formatDuration(result.duration)}</span>
|
||||
)}
|
||||
{result?.exitCode !== undefined && result.exitCode !== 0 && (
|
||||
<span className="shell-execution-tool exit-code">{result.exitCode}</span>
|
||||
)}
|
||||
<span className={`${codicon(isSuccess ? 'check' : 'error')} shell-execution-tool status-icon ${isSuccess ? 'success' : 'failure'}`} />
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="shell-execution-tool expanded-content">
|
||||
<CommandDisplay command={input.command} clipboardService={clipboardService} />
|
||||
{(result?.cwd || input.cwd) && (
|
||||
<MetaRow icon="folder" label={nls.localize('theia/ai-terminal/workingDirectory', 'Working directory')}>
|
||||
{result?.cwd || input.cwd}
|
||||
</MetaRow>
|
||||
)}
|
||||
{result?.error && (
|
||||
<div className="shell-execution-tool error-message">
|
||||
{result.error}
|
||||
</div>
|
||||
)}
|
||||
<OutputBox
|
||||
title={nls.localizeByDefault('Output')}
|
||||
output={result?.output}
|
||||
clipboardService={clipboardService}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CommandDisplayProps {
|
||||
command: string;
|
||||
clipboardService: ClipboardService;
|
||||
}
|
||||
|
||||
const CommandDisplay: React.FC<CommandDisplayProps> = ({ command, clipboardService }) => (
|
||||
<div className="shell-execution-tool full-command-container">
|
||||
<div className="shell-execution-tool full-command">
|
||||
<span className="shell-execution-tool prompt">$</span>
|
||||
<code>{command}</code>
|
||||
</div>
|
||||
<CopyButton text={command} clipboardService={clipboardService} />
|
||||
</div>
|
||||
);
|
||||
|
||||
interface MetaRowProps {
|
||||
icon: string;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const MetaRow: React.FC<MetaRowProps> = ({ icon, label, children }) => (
|
||||
<div className="shell-execution-tool meta-row" title={label}>
|
||||
<span className={codicon(icon)} />
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface OutputBoxProps {
|
||||
title: string;
|
||||
output?: string;
|
||||
clipboardService: ClipboardService;
|
||||
}
|
||||
|
||||
const OutputBox: React.FC<OutputBoxProps> = ({ title, output, clipboardService }) => (
|
||||
<div className="shell-execution-tool output-box">
|
||||
<div className="shell-execution-tool output-header">
|
||||
<span className={codicon('output')} />
|
||||
{title}
|
||||
{output && <CopyButton text={output} clipboardService={clipboardService} />}
|
||||
</div>
|
||||
{output ? (
|
||||
<pre className="shell-execution-tool output">{output}</pre>
|
||||
) : (
|
||||
<div className="shell-execution-tool no-output">
|
||||
{nls.localize('theia/ai-terminal/noOutput', 'No output')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface CopyButtonProps {
|
||||
text: string;
|
||||
clipboardService: ClipboardService;
|
||||
}
|
||||
|
||||
const CopyButton: React.FC<CopyButtonProps> = ({ text, clipboardService }) => {
|
||||
const handleCopy = React.useCallback(() => {
|
||||
clipboardService.writeText(text);
|
||||
}, [text, clipboardService]);
|
||||
|
||||
return (
|
||||
<button
|
||||
className="shell-execution-tool copy-button"
|
||||
onClick={handleCopy}
|
||||
title={nls.localizeByDefault('Copy')}
|
||||
>
|
||||
<span className={codicon('copy')} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
function truncateCommand(command: string): string {
|
||||
// Only take first line, CSS handles the ellipsis truncation
|
||||
return command.split('\n')[0];
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
}
|
||||
if (ms < 60000) {
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = Math.floor((ms % 60000) / 1000);
|
||||
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
||||
}
|
||||
204
packages/ai-terminal/src/browser/shell-execution-tool.ts
Normal file
204
packages/ai-terminal/src/browser/shell-execution-tool.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
// *****************************************************************************
|
||||
// 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 { injectable, inject } from '@theia/core/shared/inversify';
|
||||
import { ToolProvider, ToolRequest } from '@theia/ai-core';
|
||||
import { WorkspaceService } from '@theia/workspace/lib/browser';
|
||||
import {
|
||||
SHELL_EXECUTION_FUNCTION_ID,
|
||||
ShellExecutionServer,
|
||||
ShellExecutionToolResult,
|
||||
ShellExecutionCanceledResult,
|
||||
combineAndTruncate
|
||||
} from '../common/shell-execution-server';
|
||||
import { CancellationToken, generateUuid } from '@theia/core';
|
||||
|
||||
@injectable()
|
||||
export class ShellExecutionTool implements ToolProvider {
|
||||
|
||||
@inject(ShellExecutionServer)
|
||||
protected readonly shellServer: ShellExecutionServer;
|
||||
|
||||
@inject(WorkspaceService)
|
||||
protected readonly workspaceService: WorkspaceService;
|
||||
|
||||
protected readonly runningExecutions = new Map<string, string>();
|
||||
|
||||
async cancelExecution(toolCallId: string): Promise<boolean> {
|
||||
const executionId = this.runningExecutions.get(toolCallId);
|
||||
if (executionId) {
|
||||
const canceled = await this.shellServer.cancel(executionId);
|
||||
if (canceled) {
|
||||
this.runningExecutions.delete(toolCallId);
|
||||
}
|
||||
return canceled;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isExecutionRunning(toolCallId: string): boolean {
|
||||
return this.runningExecutions.has(toolCallId);
|
||||
}
|
||||
|
||||
getTool(): ToolRequest {
|
||||
return {
|
||||
id: SHELL_EXECUTION_FUNCTION_ID,
|
||||
name: SHELL_EXECUTION_FUNCTION_ID,
|
||||
providerName: 'ai-terminal',
|
||||
confirmAlwaysAllow: 'This tool has full system access and can execute any command, ' +
|
||||
'modify files outside the workspace, and access network resources.',
|
||||
description: `Execute a shell command on the host system and return the output.
|
||||
|
||||
This tool runs commands in a shell environment (bash on Linux/macOS, cmd/PowerShell on Windows).
|
||||
Use it for:
|
||||
- Running build commands (npm, make, gradle, cargo, etc.)
|
||||
- Executing git commands
|
||||
- Running tests and linters
|
||||
- Efficient search with grep, ripgrep, or find
|
||||
- Bulk search-and-replace operations with sed, awk, or perl
|
||||
- Reading or modifying files outside the workspace
|
||||
- Installing dependencies or packages
|
||||
- System administration and diagnostics
|
||||
- Running scripts (bash, python, node, etc.)
|
||||
|
||||
USER INTERACTION:
|
||||
- Commands require user approval before execution - the user sees the exact command and can approve or deny
|
||||
- Once approved, the user may cancel execution at any time while it's running
|
||||
- Users can configure "Always Allow" to skip confirmations for this tool
|
||||
|
||||
It should not be used for "endless" or long-running processes (e.g., servers, watchers) as the execution
|
||||
will block further tool usage and chat messages until it completes or times out.
|
||||
|
||||
OUTPUT TRUNCATION: To keep responses manageable, output is truncated at multiple levels:
|
||||
- Stream limit: stdout and stderr each capped at 1MB
|
||||
- Line count: Only first 50 and last 50 lines kept (middle lines omitted)
|
||||
- Line length: Lines over 1000 chars show start/end with middle omitted
|
||||
To avoid losing important information, limit output size in your commands:
|
||||
- Use grep/findstr to filter output (e.g., "npm test 2>&1 | grep -E '(FAIL|PASS|Error)'")
|
||||
- Use head/tail to limit lines (e.g., "git log --oneline -20")
|
||||
- Use wc -l to count lines before fetching full output
|
||||
- Redirect verbose output to /dev/null if not needed
|
||||
|
||||
TIMEOUT: Default 2 minutes, max 10 minutes. Specify higher timeout for longer commands.`,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
command: {
|
||||
type: 'string',
|
||||
description: 'The shell command to execute. Can include pipes, redirects, and shell features.'
|
||||
},
|
||||
cwd: {
|
||||
type: 'string',
|
||||
description: 'Working directory for command execution. Can be absolute or relative to workspace root. Defaults to the workspace root.'
|
||||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
description: 'Timeout in milliseconds. Default: 120000 (2 minutes). Max: 600000 (10 minutes).'
|
||||
}
|
||||
},
|
||||
required: ['command']
|
||||
},
|
||||
handler: (argString: string, ctx?: unknown) => this.executeCommand(argString, ctx)
|
||||
};
|
||||
}
|
||||
|
||||
protected async executeCommand(argString: string, ctx?: unknown): Promise<ShellExecutionToolResult | ShellExecutionCanceledResult> {
|
||||
const args: {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
timeout?: number;
|
||||
} = JSON.parse(argString);
|
||||
|
||||
// Get workspace root to pass to backend for path resolution
|
||||
const rootUri = this.workspaceService.getWorkspaceRootUri(undefined);
|
||||
const workspaceRoot = rootUri?.path.fsPath();
|
||||
|
||||
// Generate execution ID and get tool call ID from context
|
||||
const executionId = generateUuid();
|
||||
const toolCallId = this.extractToolCallId(ctx);
|
||||
const cancellationToken = this.extractCancellationToken(ctx);
|
||||
|
||||
// Track this execution
|
||||
if (toolCallId) {
|
||||
this.runningExecutions.set(toolCallId, executionId);
|
||||
}
|
||||
|
||||
const cancellationListener = cancellationToken?.onCancellationRequested(() => {
|
||||
this.shellServer.cancel(executionId);
|
||||
});
|
||||
|
||||
try {
|
||||
// Call the backend service (path resolution happens on the backend)
|
||||
const result = await this.shellServer.execute({
|
||||
command: args.command,
|
||||
cwd: args.cwd,
|
||||
workspaceRoot,
|
||||
timeout: args.timeout,
|
||||
executionId,
|
||||
});
|
||||
|
||||
if (result.canceled) {
|
||||
return {
|
||||
canceled: true,
|
||||
output: this.combineAndTruncate(result.stdout, result.stderr) || undefined,
|
||||
duration: result.duration,
|
||||
};
|
||||
}
|
||||
|
||||
// Combine stdout and stderr, apply truncation
|
||||
const combinedOutput = this.combineAndTruncate(result.stdout, result.stderr);
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
exitCode: result.exitCode,
|
||||
output: combinedOutput,
|
||||
error: result.error,
|
||||
duration: result.duration,
|
||||
cwd: result.resolvedCwd,
|
||||
};
|
||||
} finally {
|
||||
// Clean up
|
||||
cancellationListener?.dispose();
|
||||
if (toolCallId) {
|
||||
this.runningExecutions.delete(toolCallId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected extractToolCallId(ctx: unknown): string | undefined {
|
||||
if (ctx && typeof ctx === 'object' && 'toolCallId' in ctx) {
|
||||
return (ctx as { toolCallId?: string }).toolCallId;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected extractCancellationToken(ctx: unknown): CancellationToken | undefined {
|
||||
if (ctx && typeof ctx === 'object') {
|
||||
// Check for MutableChatRequestModel structure (response.cancellationToken)
|
||||
if ('response' in ctx) {
|
||||
const response = (ctx as { response?: { cancellationToken?: CancellationToken } }).response;
|
||||
if (response?.cancellationToken) {
|
||||
return response.cancellationToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected combineAndTruncate(stdout: string, stderr: string): string {
|
||||
return combineAndTruncate(stdout, stderr);
|
||||
}
|
||||
}
|
||||
102
packages/ai-terminal/src/browser/style/ai-terminal.css
Normal file
102
packages/ai-terminal/src/browser/style/ai-terminal.css
Normal file
@@ -0,0 +1,102 @@
|
||||
.ai-terminal-chat-container {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
background: var(--theia-menu-background);
|
||||
color: var(--theia-menu-foreground);
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
border: var(--theia-border-width) solid var(--theia-dropdown-border);
|
||||
border-radius: 4px;
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
.ai-terminal-chat-container .closeButton {
|
||||
position: absolute;
|
||||
top: 1em;
|
||||
right: 1em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ai-terminal-chat-container .closeButton:hover {
|
||||
color: var(--theia-menu-foreground);
|
||||
}
|
||||
|
||||
.ai-terminal-chat-result {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ai-terminal-chat-input-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#theia-bottom-content-panel .theia-ChatInput {
|
||||
border: var(--theia-border-width) solid var(--theia-dropdown-border);
|
||||
}
|
||||
|
||||
.ai-terminal-chat-input-container .theia-ChatInput {
|
||||
flex-grow: 1;
|
||||
height: 36px;
|
||||
background-color: var(--theia-input-background);
|
||||
border: var(--theia-border-width) solid var(--theia-dropdown-border);
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
padding: 8px;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
line-height: 1.3rem;
|
||||
margin-top: 0;
|
||||
margin-right: 10px; /* Add some space between textarea and button */
|
||||
}
|
||||
|
||||
.ai-terminal-chat-input-container .option {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
border: var(--theia-border-width) solid transparent;
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ai-terminal-chat-input-container .option:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes dots {
|
||||
0%,
|
||||
20% {
|
||||
content: "";
|
||||
}
|
||||
40% {
|
||||
content: ".";
|
||||
}
|
||||
60% {
|
||||
content: "..";
|
||||
}
|
||||
80%,
|
||||
100% {
|
||||
content: "...";
|
||||
}
|
||||
}
|
||||
.ai-terminal-chat-result p.loading::after {
|
||||
content: "";
|
||||
animation: dots 1s steps(1, end) infinite;
|
||||
}
|
||||
|
||||
.ai-terminal-chat-result p.command {
|
||||
font-family: "Droid Sans Mono", "monospace", monospace;
|
||||
}
|
||||
395
packages/ai-terminal/src/browser/style/shell-execution-tool.css
Normal file
395
packages/ai-terminal/src/browser/style/shell-execution-tool.css
Normal file
@@ -0,0 +1,395 @@
|
||||
.shell-execution-tool.container {
|
||||
border: var(--theia-border-width) solid var(--theia-sideBarSectionHeader-border);
|
||||
border-radius: var(--theia-ui-padding);
|
||||
margin: var(--theia-ui-padding) 0;
|
||||
background-color: var(--theia-editorWidget-background);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.shell-execution-tool.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--theia-ui-padding);
|
||||
padding: var(--theia-ui-padding) calc(var(--theia-ui-padding) * 2);
|
||||
border-radius: var(--theia-ui-padding);
|
||||
}
|
||||
|
||||
.shell-execution-tool.container.expanded .shell-execution-tool.header {
|
||||
border-radius: var(--theia-ui-padding) var(--theia-ui-padding) 0 0;
|
||||
}
|
||||
|
||||
.shell-execution-tool.header.finished {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shell-execution-tool.header.finished:hover {
|
||||
background-color: var(--theia-list-hoverBackground);
|
||||
}
|
||||
|
||||
.shell-execution-tool.header.error {
|
||||
background-color: var(--theia-inputValidation-errorBackground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shell-execution-tool.header.error:hover {
|
||||
background-color: var(--theia-inputValidation-errorBackground);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.shell-execution-tool.header .codicon.codicon-terminal {
|
||||
flex-shrink: 0;
|
||||
color: var(--theia-descriptionForeground);
|
||||
font-size: var(--theia-ui-font-size2);
|
||||
}
|
||||
|
||||
.shell-execution-tool.command-preview {
|
||||
font-family: var(--theia-ui-font-family-mono);
|
||||
font-size: var(--theia-ui-font-size1);
|
||||
color: var(--theia-foreground);
|
||||
background-color: var(--theia-textCodeBlock-background);
|
||||
padding: 2px 6px;
|
||||
border-radius: calc(var(--theia-ui-padding) / 2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shell-execution-tool.meta-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--theia-ui-padding);
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.shell-execution-tool.duration {
|
||||
font-size: var(--theia-ui-font-size0);
|
||||
color: var(--theia-descriptionForeground);
|
||||
font-family: var(--theia-ui-font-family-mono);
|
||||
}
|
||||
|
||||
.shell-execution-tool.exit-code {
|
||||
font-size: var(--theia-ui-font-size0);
|
||||
font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
border-radius: calc(var(--theia-ui-padding) / 2);
|
||||
font-family: var(--theia-ui-font-family-mono);
|
||||
background-color: var(--theia-charts-red);
|
||||
color: var(--theia-button-foreground);
|
||||
}
|
||||
|
||||
.shell-execution-tool.status-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.shell-execution-tool.status-icon.success {
|
||||
color: var(--theia-charts-green);
|
||||
}
|
||||
|
||||
.shell-execution-tool.status-icon.failure {
|
||||
color: var(--theia-charts-red);
|
||||
}
|
||||
|
||||
.shell-execution-tool.status-label {
|
||||
font-size: var(--theia-ui-font-size0);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.shell-execution-tool.status-label.error {
|
||||
color: var(--theia-errorForeground);
|
||||
}
|
||||
|
||||
.shell-execution-tool.status-label.canceled {
|
||||
color: var(--theia-errorForeground);
|
||||
}
|
||||
|
||||
.shell-execution-tool.status-label.canceling {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--theia-ui-padding) / 2);
|
||||
color: var(--theia-descriptionForeground);
|
||||
}
|
||||
|
||||
.shell-execution-tool.cancel-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: calc(var(--theia-ui-padding) / 2);
|
||||
background-color: transparent;
|
||||
color: var(--theia-descriptionForeground);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.shell-execution-tool.cancel-button:hover {
|
||||
background-color: var(--theia-toolbar-hoverBackground);
|
||||
color: var(--theia-debugIcon-stopForeground);
|
||||
}
|
||||
|
||||
.shell-execution-tool.cancel-button:active {
|
||||
background-color: var(--theia-toolbar-activeBackground, var(--theia-toolbar-hoverBackground));
|
||||
}
|
||||
|
||||
.shell-execution-tool.canceling-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--theia-ui-padding) / 2);
|
||||
font-size: var(--theia-ui-font-size0);
|
||||
color: var(--theia-descriptionForeground);
|
||||
}
|
||||
|
||||
.shell-execution-tool.canceling-label .codicon {
|
||||
font-size: var(--theia-ui-font-size1);
|
||||
}
|
||||
|
||||
.shell-execution-tool.header.canceling {
|
||||
background-color: var(--theia-inputValidation-warningBackground);
|
||||
}
|
||||
|
||||
.shell-execution-tool.header.canceled {
|
||||
background-color: var(--theia-inputValidation-errorBackground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shell-execution-tool.header.canceled:hover {
|
||||
background-color: var(--theia-inputValidation-errorBackground);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.shell-execution-tool.confirmation {
|
||||
padding: calc(var(--theia-ui-padding) * 2);
|
||||
}
|
||||
|
||||
.shell-execution-tool.confirmation-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--theia-ui-padding);
|
||||
margin-bottom: calc(var(--theia-ui-padding) * 1.5);
|
||||
}
|
||||
|
||||
.shell-execution-tool.confirmation-header .codicon {
|
||||
font-size: var(--theia-ui-font-size2);
|
||||
color: var(--theia-charts-blue);
|
||||
}
|
||||
|
||||
.shell-execution-tool.confirmation-title {
|
||||
font-weight: 600;
|
||||
font-size: var(--theia-ui-font-size1);
|
||||
color: var(--theia-foreground);
|
||||
}
|
||||
|
||||
.shell-execution-tool.command-display {
|
||||
padding: var(--theia-ui-padding) calc(var(--theia-ui-padding) * 1.5);
|
||||
background-color: var(--theia-textCodeBlock-background);
|
||||
border-radius: calc(var(--theia-ui-padding) / 2);
|
||||
margin-bottom: calc(var(--theia-ui-padding) * 1.5);
|
||||
}
|
||||
|
||||
.shell-execution-tool.command-display code {
|
||||
font-family: var(--theia-ui-font-family-mono);
|
||||
font-size: var(--theia-ui-font-size1);
|
||||
color: var(--theia-foreground);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.shell-execution-tool.confirmation-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: calc(var(--theia-ui-padding) * 2);
|
||||
margin-bottom: calc(var(--theia-ui-padding) * 2);
|
||||
font-size: var(--theia-ui-font-size0);
|
||||
color: var(--theia-descriptionForeground);
|
||||
}
|
||||
|
||||
.shell-execution-tool.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--theia-ui-padding) / 2);
|
||||
}
|
||||
|
||||
.shell-execution-tool.meta-item .codicon {
|
||||
font-size: var(--theia-ui-font-size1);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.shell-execution-tool.expanded-content {
|
||||
padding: calc(var(--theia-ui-padding) * 2);
|
||||
border-top: var(--theia-border-width) solid var(--theia-sideBarSectionHeader-border);
|
||||
}
|
||||
|
||||
.shell-execution-tool.full-command-container {
|
||||
position: relative;
|
||||
margin-bottom: var(--theia-ui-padding);
|
||||
}
|
||||
|
||||
.shell-execution-tool.full-command-container .shell-execution-tool.copy-button {
|
||||
position: absolute;
|
||||
top: var(--theia-ui-padding);
|
||||
right: var(--theia-ui-padding);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.shell-execution-tool.full-command-container:hover .shell-execution-tool.copy-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.shell-execution-tool.full-command-container .shell-execution-tool.full-command {
|
||||
margin-bottom: 0;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
.shell-execution-tool.full-command {
|
||||
display: flex;
|
||||
gap: var(--theia-ui-padding);
|
||||
padding: var(--theia-ui-padding) calc(var(--theia-ui-padding) * 1.5);
|
||||
background-color: var(--theia-textCodeBlock-background);
|
||||
border-radius: calc(var(--theia-ui-padding) / 2);
|
||||
margin-bottom: var(--theia-ui-padding);
|
||||
}
|
||||
|
||||
.shell-execution-tool.prompt {
|
||||
font-family: var(--theia-ui-font-family-mono);
|
||||
font-size: var(--theia-ui-font-size1);
|
||||
color: var(--theia-descriptionForeground);
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.shell-execution-tool.full-command code {
|
||||
font-family: var(--theia-ui-font-family-mono);
|
||||
font-size: var(--theia-ui-font-size1);
|
||||
color: var(--theia-foreground);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.shell-execution-tool.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--theia-ui-padding);
|
||||
font-size: var(--theia-ui-font-size0);
|
||||
color: var(--theia-descriptionForeground);
|
||||
margin-bottom: var(--theia-ui-padding);
|
||||
}
|
||||
|
||||
.shell-execution-tool.meta-row .codicon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.shell-execution-tool.error-message {
|
||||
padding: var(--theia-ui-padding) calc(var(--theia-ui-padding) * 1.5);
|
||||
margin-bottom: var(--theia-ui-padding);
|
||||
background-color: var(--theia-inputValidation-errorBackground);
|
||||
border: var(--theia-border-width) solid var(--theia-inputValidation-errorBorder);
|
||||
border-radius: calc(var(--theia-ui-padding) / 2);
|
||||
color: var(--theia-errorForeground);
|
||||
font-size: var(--theia-ui-font-size1);
|
||||
}
|
||||
|
||||
.shell-execution-tool.output-box {
|
||||
margin-top: var(--theia-ui-padding);
|
||||
background-color: var(--theia-textCodeBlock-background);
|
||||
border-radius: calc(var(--theia-ui-padding) / 2);
|
||||
}
|
||||
|
||||
.shell-execution-tool.output-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--theia-ui-padding);
|
||||
font-size: var(--theia-ui-font-size0);
|
||||
color: var(--theia-descriptionForeground);
|
||||
padding: var(--theia-ui-padding) calc(var(--theia-ui-padding) * 1.5);
|
||||
border-bottom: var(--theia-border-width) solid var(--theia-sideBarSectionHeader-border);
|
||||
}
|
||||
|
||||
.shell-execution-tool.output-header .codicon {
|
||||
font-size: var(--theia-ui-font-size1);
|
||||
}
|
||||
|
||||
.shell-execution-tool.output-header .shell-execution-tool.copy-button {
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.shell-execution-tool.output-box:hover .shell-execution-tool.output-header .shell-execution-tool.copy-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.shell-execution-tool.copy-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: calc(var(--theia-ui-padding) / 2);
|
||||
background-color: transparent;
|
||||
color: var(--theia-descriptionForeground);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.shell-execution-tool.copy-button:hover {
|
||||
background-color: var(--theia-toolbar-hoverBackground);
|
||||
color: var(--theia-foreground);
|
||||
}
|
||||
|
||||
.shell-execution-tool.output {
|
||||
font-family: var(--theia-ui-font-family-mono);
|
||||
font-size: var(--theia-ui-font-size0);
|
||||
line-height: 1.4;
|
||||
color: var(--theia-foreground);
|
||||
background-color: transparent;
|
||||
border-radius: 0;
|
||||
padding: calc(var(--theia-ui-padding) * 1.5);
|
||||
margin: 0;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.shell-execution-tool.no-output {
|
||||
color: var(--theia-descriptionForeground);
|
||||
font-style: italic;
|
||||
font-size: var(--theia-ui-font-size0);
|
||||
padding: var(--theia-ui-padding) calc(var(--theia-ui-padding) * 1.5);
|
||||
}
|
||||
|
||||
.shell-execution-tool.denial-reason {
|
||||
margin-top: var(--theia-ui-padding);
|
||||
background-color: var(--theia-textCodeBlock-background);
|
||||
border-radius: calc(var(--theia-ui-padding) / 2);
|
||||
}
|
||||
|
||||
.shell-execution-tool.denial-reason-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--theia-ui-padding);
|
||||
font-size: var(--theia-ui-font-size0);
|
||||
color: var(--theia-descriptionForeground);
|
||||
padding: var(--theia-ui-padding) calc(var(--theia-ui-padding) * 1.5);
|
||||
border-bottom: var(--theia-border-width) solid var(--theia-sideBarSectionHeader-border);
|
||||
}
|
||||
|
||||
.shell-execution-tool.denial-reason-header .codicon {
|
||||
font-size: var(--theia-ui-font-size1);
|
||||
}
|
||||
|
||||
.shell-execution-tool.denial-reason-content {
|
||||
padding: calc(var(--theia-ui-padding) * 1.5);
|
||||
font-size: var(--theia-ui-font-size1);
|
||||
color: var(--theia-foreground);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// *****************************************************************************
|
||||
// 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 { parseShellExecutionInput } from './shell-execution-input-parser';
|
||||
|
||||
describe('parseShellExecutionInput', () => {
|
||||
describe('complete JSON', () => {
|
||||
it('should parse complete JSON with command only', () => {
|
||||
const result = parseShellExecutionInput('{"command": "ls -la"}');
|
||||
expect(result.command).to.equal('ls -la');
|
||||
});
|
||||
|
||||
it('should parse complete JSON with all fields', () => {
|
||||
const result = parseShellExecutionInput('{"command": "npm install", "cwd": "/home/user", "timeout": 30000}');
|
||||
expect(result.command).to.equal('npm install');
|
||||
expect(result.cwd).to.equal('/home/user');
|
||||
expect(result.timeout).to.equal(30000);
|
||||
});
|
||||
|
||||
it('should parse JSON without spaces', () => {
|
||||
const result = parseShellExecutionInput('{"command":"git status"}');
|
||||
expect(result.command).to.equal('git status');
|
||||
});
|
||||
});
|
||||
|
||||
describe('incomplete JSON (streaming)', () => {
|
||||
it('should extract command from incomplete JSON without closing brace', () => {
|
||||
const result = parseShellExecutionInput('{"command": "ls -la"');
|
||||
expect(result.command).to.equal('ls -la');
|
||||
});
|
||||
|
||||
it('should extract partial command value without closing quote', () => {
|
||||
const result = parseShellExecutionInput('{"command": "ls -la');
|
||||
expect(result.command).to.equal('ls -la');
|
||||
});
|
||||
|
||||
it('should extract command when value is being typed', () => {
|
||||
const result = parseShellExecutionInput('{"command": "git st');
|
||||
expect(result.command).to.equal('git st');
|
||||
});
|
||||
|
||||
it('should return empty command when only key is present', () => {
|
||||
const result = parseShellExecutionInput('{"command": "');
|
||||
expect(result.command).to.equal('');
|
||||
});
|
||||
|
||||
it('should handle JSON without spaces around colon', () => {
|
||||
const result = parseShellExecutionInput('{"command":"npm run');
|
||||
expect(result.command).to.equal('npm run');
|
||||
});
|
||||
|
||||
it('should handle incomplete JSON with additional fields after command', () => {
|
||||
const result = parseShellExecutionInput('{"command": "echo hello", "cwd": "/tmp');
|
||||
expect(result.command).to.equal('echo hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return empty command for undefined input', () => {
|
||||
const result = parseShellExecutionInput(undefined);
|
||||
expect(result.command).to.equal('');
|
||||
});
|
||||
|
||||
it('should return empty command for empty string', () => {
|
||||
const result = parseShellExecutionInput('');
|
||||
expect(result.command).to.equal('');
|
||||
});
|
||||
|
||||
it('should return empty command for incomplete JSON without command key', () => {
|
||||
const result = parseShellExecutionInput('{"cwd": "/home"');
|
||||
expect(result.command).to.equal('');
|
||||
});
|
||||
|
||||
it('should return empty command for malformed JSON', () => {
|
||||
const result = parseShellExecutionInput('{command: ls}');
|
||||
expect(result.command).to.equal('');
|
||||
});
|
||||
|
||||
it('should return empty command when just opening brace', () => {
|
||||
const result = parseShellExecutionInput('{');
|
||||
expect(result.command).to.equal('');
|
||||
});
|
||||
|
||||
it('should return empty command for partial key', () => {
|
||||
const result = parseShellExecutionInput('{"com');
|
||||
expect(result.command).to.equal('');
|
||||
});
|
||||
|
||||
it('should handle command with escaped quotes in complete JSON', () => {
|
||||
const result = parseShellExecutionInput('{"command": "echo \\"hello\\""}');
|
||||
expect(result.command).to.equal('echo "hello"');
|
||||
});
|
||||
|
||||
it('should handle incomplete command with backslash', () => {
|
||||
// During streaming, we get partial content - the regex stops at first unescaped quote
|
||||
const result = parseShellExecutionInput('{"command": "echo \\"hello');
|
||||
expect(result.command).to.equal('echo \\');
|
||||
});
|
||||
|
||||
it('should handle command with pipes and redirects', () => {
|
||||
const result = parseShellExecutionInput('{"command": "cat file.txt | grep error > output.log"}');
|
||||
expect(result.command).to.equal('cat file.txt | grep error > output.log');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// *****************************************************************************
|
||||
// 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
|
||||
// *****************************************************************************
|
||||
|
||||
export interface ShellExecutionInput {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses shell execution input from potentially incomplete JSON.
|
||||
* During streaming, extracts partial command value via regex when JSON.parse fails.
|
||||
*/
|
||||
export function parseShellExecutionInput(args: string | undefined): ShellExecutionInput {
|
||||
if (!args) {
|
||||
return { command: '' };
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(args);
|
||||
} catch {
|
||||
// Extract command from incomplete JSON: "command": "value or "command":"value
|
||||
const match = /"command"\s*:\s*"([^"]*)"?/.exec(args);
|
||||
return { command: match?.[1] ?? '' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// *****************************************************************************
|
||||
// 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 {
|
||||
ShellExecutionToolResult,
|
||||
ShellExecutionCanceledResult
|
||||
} from './shell-execution-server';
|
||||
|
||||
describe('ShellExecutionToolResult.is', () => {
|
||||
it('should return true for valid result', () => {
|
||||
expect(ShellExecutionToolResult.is({ success: true, exitCode: 0, output: '', duration: 100 })).to.be.true;
|
||||
});
|
||||
|
||||
it('should return false for invalid inputs', () => {
|
||||
expect(ShellExecutionToolResult.is(undefined)).to.be.false;
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
expect(ShellExecutionToolResult.is(null)).to.be.false;
|
||||
expect(ShellExecutionToolResult.is({ exitCode: 0 })).to.be.false;
|
||||
expect(ShellExecutionToolResult.is({ success: true })).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('ShellExecutionCanceledResult.is', () => {
|
||||
it('should return true for valid canceled result', () => {
|
||||
expect(ShellExecutionCanceledResult.is({ canceled: true })).to.be.true;
|
||||
expect(ShellExecutionCanceledResult.is({ canceled: true, output: 'partial', duration: 100 })).to.be.true;
|
||||
});
|
||||
|
||||
it('should return false for invalid inputs', () => {
|
||||
expect(ShellExecutionCanceledResult.is(undefined)).to.be.false;
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
expect(ShellExecutionCanceledResult.is(null)).to.be.false;
|
||||
expect(ShellExecutionCanceledResult.is({ canceled: false })).to.be.false;
|
||||
expect(ShellExecutionCanceledResult.is({ output: 'test' })).to.be.false;
|
||||
});
|
||||
});
|
||||
122
packages/ai-terminal/src/common/shell-execution-server.ts
Normal file
122
packages/ai-terminal/src/common/shell-execution-server.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// *****************************************************************************
|
||||
// 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
|
||||
// *****************************************************************************
|
||||
|
||||
export const SHELL_EXECUTION_FUNCTION_ID = 'shellExecute';
|
||||
|
||||
export const ShellExecutionServer = Symbol('ShellExecutionServer');
|
||||
export const shellExecutionPath = '/services/shell-execution';
|
||||
|
||||
export interface ShellExecutionRequest {
|
||||
command: string;
|
||||
/** Working directory. Can be absolute or relative to workspaceRoot. */
|
||||
cwd?: string;
|
||||
/** Workspace root path for resolving relative cwd paths */
|
||||
workspaceRoot?: string;
|
||||
timeout?: number; // milliseconds
|
||||
/** Unique ID for this execution, used for cancellation */
|
||||
executionId?: string;
|
||||
}
|
||||
|
||||
export interface ShellExecutionResult {
|
||||
success: boolean;
|
||||
exitCode: number | undefined;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
error?: string;
|
||||
/** Execution duration in milliseconds */
|
||||
duration: number;
|
||||
/** Whether the execution was canceled by user action (not timeout) */
|
||||
canceled?: boolean;
|
||||
/** The resolved working directory where the command was executed */
|
||||
resolvedCwd?: string;
|
||||
}
|
||||
|
||||
export interface ShellExecutionServer {
|
||||
execute(request: ShellExecutionRequest): Promise<ShellExecutionResult>;
|
||||
cancel(executionId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ShellExecutionToolResult {
|
||||
success: boolean;
|
||||
exitCode: number | undefined;
|
||||
output: string;
|
||||
error?: string;
|
||||
duration: number;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface ShellExecutionCanceledResult {
|
||||
canceled: true;
|
||||
output?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export namespace ShellExecutionToolResult {
|
||||
export function is(obj: unknown): obj is ShellExecutionToolResult {
|
||||
return !!obj && typeof obj === 'object' &&
|
||||
'success' in obj && typeof (obj as ShellExecutionToolResult).success === 'boolean' &&
|
||||
'duration' in obj && typeof (obj as ShellExecutionToolResult).duration === 'number';
|
||||
}
|
||||
}
|
||||
|
||||
export namespace ShellExecutionCanceledResult {
|
||||
export function is(obj: unknown): obj is ShellExecutionCanceledResult {
|
||||
return !!obj && typeof obj === 'object' &&
|
||||
'canceled' in obj && (obj as ShellExecutionCanceledResult).canceled === true;
|
||||
}
|
||||
}
|
||||
|
||||
export const HEAD_LINES = 50;
|
||||
export const TAIL_LINES = 50;
|
||||
export const GRACE_LINES = 10;
|
||||
export const MAX_LINE_LENGTH = 1000;
|
||||
|
||||
export function truncateLine(line: string): string {
|
||||
if (line.length <= MAX_LINE_LENGTH) {
|
||||
return line;
|
||||
}
|
||||
const halfLength = Math.floor((MAX_LINE_LENGTH - 30) / 2);
|
||||
const omittedCount = line.length - halfLength * 2;
|
||||
return `${line.slice(0, halfLength)} ... [${omittedCount} chars omitted] ... ${line.slice(-halfLength)}`;
|
||||
}
|
||||
|
||||
export function combineAndTruncate(stdout: string, stderr: string): string {
|
||||
const trimmedStdout = stdout.trim();
|
||||
const trimmedStderr = stderr.trim();
|
||||
|
||||
let output = trimmedStdout;
|
||||
if (trimmedStderr) {
|
||||
output = output
|
||||
? `${output}\n--- stderr ---\n${trimmedStderr}`
|
||||
: trimmedStderr;
|
||||
}
|
||||
|
||||
if (!output) {
|
||||
return output;
|
||||
}
|
||||
|
||||
const lines = output.split('\n');
|
||||
|
||||
if (lines.length <= HEAD_LINES + TAIL_LINES + GRACE_LINES) {
|
||||
return lines.map(truncateLine).join('\n');
|
||||
}
|
||||
|
||||
const headLines = lines.slice(0, HEAD_LINES).map(truncateLine);
|
||||
const tailLines = lines.slice(-TAIL_LINES).map(truncateLine);
|
||||
const omittedCount = lines.length - HEAD_LINES - TAIL_LINES;
|
||||
|
||||
return [...headLines, `\n... [${omittedCount} lines omitted] ...\n`, ...tailLines].join('\n');
|
||||
}
|
||||
102
packages/ai-terminal/src/common/shell-execution-tool.spec.ts
Normal file
102
packages/ai-terminal/src/common/shell-execution-tool.spec.ts
Normal 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 { expect } from 'chai';
|
||||
import {
|
||||
combineAndTruncate,
|
||||
truncateLine,
|
||||
HEAD_LINES,
|
||||
TAIL_LINES,
|
||||
GRACE_LINES,
|
||||
MAX_LINE_LENGTH
|
||||
} from './shell-execution-server';
|
||||
|
||||
describe('Shell Execution Tool', () => {
|
||||
describe('combineAndTruncate', () => {
|
||||
it('should return stdout when no stderr', () => {
|
||||
const result = combineAndTruncate('stdout content', '');
|
||||
expect(result).to.equal('stdout content');
|
||||
});
|
||||
|
||||
it('should return stderr when no stdout', () => {
|
||||
const result = combineAndTruncate('', 'stderr content');
|
||||
expect(result).to.equal('stderr content');
|
||||
});
|
||||
|
||||
it('should combine stdout and stderr with separator', () => {
|
||||
const result = combineAndTruncate('stdout content', 'stderr content');
|
||||
expect(result).to.equal('stdout content\n--- stderr ---\nstderr content');
|
||||
});
|
||||
|
||||
it('should return empty string when both are empty', () => {
|
||||
const result = combineAndTruncate('', '');
|
||||
expect(result).to.equal('');
|
||||
});
|
||||
|
||||
it('should not truncate output within grace area', () => {
|
||||
const lineCount = HEAD_LINES + TAIL_LINES + GRACE_LINES;
|
||||
const lines = Array.from({ length: lineCount }, (_, i) => `line ${i + 1}`);
|
||||
const stdout = lines.join('\n');
|
||||
const result = combineAndTruncate(stdout, '');
|
||||
expect(result).to.not.include('lines omitted');
|
||||
expect(result.split('\n').length).to.equal(lineCount);
|
||||
});
|
||||
|
||||
it('should truncate output exceeding grace area', () => {
|
||||
const lineCount = HEAD_LINES + TAIL_LINES + GRACE_LINES + 1;
|
||||
const lines = Array.from({ length: lineCount }, (_, i) => `line ${i + 1}`);
|
||||
const stdout = lines.join('\n');
|
||||
const result = combineAndTruncate(stdout, '');
|
||||
expect(result).to.include('lines omitted');
|
||||
});
|
||||
|
||||
it('should truncate long lines in output', () => {
|
||||
const longLine = 'x'.repeat(MAX_LINE_LENGTH + 500);
|
||||
const result = combineAndTruncate(longLine, '');
|
||||
expect(result).to.include('chars omitted');
|
||||
expect(result.length).to.be.lessThan(longLine.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateLine', () => {
|
||||
it('should not truncate short lines', () => {
|
||||
const line = 'short line';
|
||||
expect(truncateLine(line)).to.equal(line);
|
||||
});
|
||||
|
||||
it('should not truncate lines at exactly MAX_LINE_LENGTH', () => {
|
||||
const line = 'x'.repeat(MAX_LINE_LENGTH);
|
||||
expect(truncateLine(line)).to.equal(line);
|
||||
});
|
||||
|
||||
it('should truncate lines exceeding MAX_LINE_LENGTH', () => {
|
||||
const line = 'x'.repeat(MAX_LINE_LENGTH + 100);
|
||||
const result = truncateLine(line);
|
||||
expect(result).to.include('chars omitted');
|
||||
expect(result.length).to.be.lessThan(line.length);
|
||||
});
|
||||
|
||||
it('should preserve start and end of truncated lines', () => {
|
||||
const start = 'START_';
|
||||
const end = '_END';
|
||||
const middle = 'x'.repeat(MAX_LINE_LENGTH + 100);
|
||||
const line = start + middle + end;
|
||||
const result = truncateLine(line);
|
||||
expect(result.startsWith(start)).to.be.true;
|
||||
expect(result.endsWith(end)).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
31
packages/ai-terminal/src/node/ai-terminal-backend-module.ts
Normal file
31
packages/ai-terminal/src/node/ai-terminal-backend-module.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// *****************************************************************************
|
||||
// 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 { ContainerModule } from '@theia/core/shared/inversify';
|
||||
import { ConnectionHandler, JsonRpcConnectionHandler } from '@theia/core/lib/common/messaging';
|
||||
import { ShellExecutionServer, shellExecutionPath } from '../common/shell-execution-server';
|
||||
import { ShellExecutionServerImpl } from './shell-execution-server-impl';
|
||||
|
||||
export default new ContainerModule(bind => {
|
||||
bind(ShellExecutionServerImpl).toSelf().inSingletonScope();
|
||||
bind(ShellExecutionServer).toService(ShellExecutionServerImpl);
|
||||
|
||||
bind(ConnectionHandler).toDynamicValue(ctx =>
|
||||
new JsonRpcConnectionHandler(shellExecutionPath, () =>
|
||||
ctx.container.get<ShellExecutionServer>(ShellExecutionServer)
|
||||
)
|
||||
).inSingletonScope();
|
||||
});
|
||||
194
packages/ai-terminal/src/node/shell-execution-server-impl.ts
Normal file
194
packages/ai-terminal/src/node/shell-execution-server-impl.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
// *****************************************************************************
|
||||
// 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 { injectable } from '@theia/core/shared/inversify';
|
||||
import { spawn, ChildProcess, execSync } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import { ShellExecutionServer, ShellExecutionRequest, ShellExecutionResult } from '../common/shell-execution-server';
|
||||
|
||||
const DEFAULT_TIMEOUT = 120000; // 2 minutes
|
||||
const MAX_TIMEOUT = 600000; // 10 minutes
|
||||
const MAX_OUTPUT_SIZE = 1024 * 1024; // 1MB
|
||||
|
||||
@injectable()
|
||||
export class ShellExecutionServerImpl implements ShellExecutionServer {
|
||||
|
||||
protected readonly runningProcesses = new Map<string, ChildProcess>();
|
||||
protected readonly canceledExecutions = new Set<string>();
|
||||
|
||||
async execute(request: ShellExecutionRequest): Promise<ShellExecutionResult> {
|
||||
const { command, cwd, workspaceRoot, timeout, executionId } = request;
|
||||
const effectiveTimeout = Math.min(timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT);
|
||||
const startTime = Date.now();
|
||||
|
||||
const resolvedCwd = this.resolveCwd(cwd, workspaceRoot);
|
||||
|
||||
return new Promise<ShellExecutionResult>(resolve => {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let killed = false;
|
||||
|
||||
const childProcess = spawn(command, [], {
|
||||
cwd: resolvedCwd,
|
||||
shell: true,
|
||||
detached: process.platform !== 'win32',
|
||||
windowsHide: true,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
if (executionId) {
|
||||
this.runningProcesses.set(executionId, childProcess);
|
||||
}
|
||||
|
||||
childProcess.stdout?.on('data', (data: Buffer) => {
|
||||
if (stdout.length < MAX_OUTPUT_SIZE) {
|
||||
stdout += data.toString();
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.stderr?.on('data', (data: Buffer) => {
|
||||
if (stderr.length < MAX_OUTPUT_SIZE) {
|
||||
stderr += data.toString();
|
||||
}
|
||||
});
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
killed = true;
|
||||
this.killProcessTree(childProcess);
|
||||
}, effectiveTimeout);
|
||||
|
||||
childProcess.on('close', (code, signal) => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (executionId) {
|
||||
this.runningProcesses.delete(executionId);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
const wasCanceledByUser = executionId ? this.canceledExecutions.has(executionId) : false;
|
||||
if (executionId) {
|
||||
this.canceledExecutions.delete(executionId);
|
||||
}
|
||||
|
||||
if (signal || killed) {
|
||||
if (wasCanceledByUser) {
|
||||
resolve({
|
||||
success: false,
|
||||
exitCode: undefined,
|
||||
stdout,
|
||||
stderr,
|
||||
error: 'Command canceled by user',
|
||||
duration,
|
||||
canceled: true,
|
||||
resolvedCwd,
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
success: false,
|
||||
exitCode: undefined,
|
||||
stdout,
|
||||
stderr,
|
||||
error: `Command timed out after ${effectiveTimeout}ms`,
|
||||
duration,
|
||||
resolvedCwd,
|
||||
});
|
||||
}
|
||||
} else if (code === 0) {
|
||||
resolve({
|
||||
success: true,
|
||||
exitCode: 0,
|
||||
stdout,
|
||||
stderr,
|
||||
duration,
|
||||
resolvedCwd,
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
success: false,
|
||||
exitCode: code ?? undefined,
|
||||
stdout,
|
||||
stderr,
|
||||
duration,
|
||||
resolvedCwd,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.on('error', (error: Error) => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (executionId) {
|
||||
this.runningProcesses.delete(executionId);
|
||||
this.canceledExecutions.delete(executionId);
|
||||
}
|
||||
|
||||
resolve({
|
||||
success: false,
|
||||
exitCode: undefined,
|
||||
stdout,
|
||||
stderr,
|
||||
error: error.message,
|
||||
duration: Date.now() - startTime,
|
||||
resolvedCwd,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async cancel(executionId: string): Promise<boolean> {
|
||||
const childProcess = this.runningProcesses.get(executionId);
|
||||
if (childProcess) {
|
||||
this.canceledExecutions.add(executionId);
|
||||
this.killProcessTree(childProcess);
|
||||
this.runningProcesses.delete(executionId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected killProcessTree(childProcess: ChildProcess): void {
|
||||
if (!childProcess.pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
execSync(`taskkill /pid ${childProcess.pid} /T /F`, { stdio: 'ignore' });
|
||||
} else {
|
||||
process.kill(-childProcess.pid, 'SIGTERM');
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
childProcess.kill('SIGKILL');
|
||||
} catch {
|
||||
// Process may already be dead
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected resolveCwd(requestedCwd: string | undefined, workspaceRoot: string | undefined): string | undefined {
|
||||
if (!requestedCwd) {
|
||||
return workspaceRoot;
|
||||
}
|
||||
if (path.isAbsolute(requestedCwd)) {
|
||||
return requestedCwd;
|
||||
}
|
||||
if (workspaceRoot) {
|
||||
return path.resolve(workspaceRoot, requestedCwd);
|
||||
}
|
||||
return requestedCwd;
|
||||
}
|
||||
}
|
||||
28
packages/ai-terminal/src/package.spec.ts
Normal file
28
packages/ai-terminal/src/package.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 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
|
||||
// *****************************************************************************
|
||||
|
||||
/* note: this bogus test file is required so that
|
||||
we are able to run mocha unit tests on this
|
||||
package, without having any actual unit tests in it.
|
||||
This way a coverage report will be generated,
|
||||
showing 0% coverage, instead of no report.
|
||||
This file can be removed once we have real unit
|
||||
tests in place. */
|
||||
|
||||
describe('ai-terminal package', () => {
|
||||
|
||||
it('support code coverage statistics', () => true);
|
||||
});
|
||||
31
packages/ai-terminal/tsconfig.json
Normal file
31
packages/ai-terminal/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"extends": "../../configs/base.tsconfig",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../ai-chat"
|
||||
},
|
||||
{
|
||||
"path": "../ai-chat-ui"
|
||||
},
|
||||
{
|
||||
"path": "../ai-core"
|
||||
},
|
||||
{
|
||||
"path": "../core"
|
||||
},
|
||||
{
|
||||
"path": "../terminal"
|
||||
},
|
||||
{
|
||||
"path": "../workspace"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user