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

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

View File

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

View File

@@ -0,0 +1,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>

View 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"
}

View 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 [];
}
}
}

View 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();
}
}

View File

@@ -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();
});

View File

@@ -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? Wed 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? Wed 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}}
`
}
}
];

View File

@@ -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`;
}

View 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);
}
}

View 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;
}

View 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;
}

View File

@@ -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');
});
});
});

View File

@@ -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] ?? '' };
}
}

View File

@@ -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;
});
});

View 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');
}

View File

@@ -0,0 +1,102 @@
// *****************************************************************************
// Copyright (C) 2026 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { 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;
});
});
});

View 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();
});

View 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;
}
}

View 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);
});

View 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"
}
]
}