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,31 @@
<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 - CLAUDE CODE INTEGRATION</h2>
<hr />
</div>
## Description
The `@theia/ai-claude-code` integrates Anthropic's Claude Code as an agent into the Theia platform.
## Additional Information
- [API documentation for `@theia/ai-claude-code`](https://eclipse-theia.github.io/theia/docs/next/modules/_theia_ai-claude-code.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,56 @@
{
"name": "@theia/ai-claude-code",
"version": "1.68.0",
"description": "Theia - Claude Code Integration",
"dependencies": {
"@theia/ai-core": "1.68.0",
"@theia/core": "1.68.0",
"@theia/filesystem": "1.68.0",
"@theia/workspace": "1.68.0",
"@theia/ai-chat": "1.68.0",
"@theia/ai-chat-ui": "1.68.0",
"@theia/editor": "1.68.0",
"@theia/output": "1.68.0",
"@theia/monaco-editor-core": "^1.96.302",
"tslib": "^2.6.2"
},
"publishConfig": {
"access": "public"
},
"theiaExtensions": [
{
"frontend": "lib/browser/claude-code-frontend-module",
"backend": "lib/node/claude-code-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"
}
}

View File

@@ -0,0 +1,606 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import {
ChatAgent,
ChatAgentLocation,
ChatAgentService,
ErrorChatResponseContentImpl,
MarkdownChatResponseContentImpl,
MutableChatRequestModel,
QuestionResponseContentImpl,
ThinkingChatResponseContentImpl,
} from '@theia/ai-chat';
import { AI_CHAT_NEW_CHAT_WINDOW_COMMAND, AI_CHAT_SHOW_CHATS_COMMAND } from '@theia/ai-chat-ui/lib/browser/chat-view-commands';
import { PromptText } from '@theia/ai-core/lib/common/prompt-text';
import { AIVariableResolutionRequest, BasePromptFragment, PromptService, ResolvedPromptFragment, TokenUsageService } from '@theia/ai-core';
import { CommandService, ILogger, nls, SelectionService } from '@theia/core';
import { inject, injectable, named } from '@theia/core/shared/inversify';
import { EditorManager } from '@theia/editor/lib/browser';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { WorkspaceService } from '@theia/workspace/lib/browser';
import {
ContentBlock,
EditInput,
MultiEditInput,
PermissionMode,
SDKMessage,
TaskInput,
ToolApprovalRequestMessage,
ToolApprovalResponseMessage,
Usage,
WriteInput
} from '../common/claude-code-service';
import { ClaudeCodeEditToolService, ToolUseBlock } from './claude-code-edit-tool-service';
import { FileEditBackupService } from './claude-code-file-edit-backup-service';
import { ClaudeCodeFrontendService } from './claude-code-frontend-service';
import { ClaudeCodeToolCallChatResponseContent } from './claude-code-tool-call-content';
import { OPEN_CLAUDE_CODE_CONFIG, OPEN_CLAUDE_CODE_MEMORY } from './claude-code-command-contribution';
export const CLAUDE_SESSION_ID_KEY = 'claudeSessionId';
export const CLAUDE_EDIT_TOOL_USES_KEY = 'claudeEditToolUses';
export const CLAUDE_INPUT_TOKENS_KEY = 'claudeInputTokens';
export const CLAUDE_OUTPUT_TOKENS_KEY = 'claudeOutputTokens';
export const CLAUDE_PENDING_APPROVALS_KEY = 'claudePendingApprovals';
export const CLAUDE_APPROVAL_TOOL_INPUTS_KEY = 'claudeApprovalToolInputs';
export const CLAUDE_MODEL_NAME_KEY = 'claudeModelName';
export const CLAUDE_COST_KEY = 'claudeCost';
const APPROVAL_OPTIONS = [
{ text: nls.localizeByDefault('Allow'), value: 'allow' },
{ text: nls.localizeByDefault('Deny'), value: 'deny' }
];
export const systemPromptAppendixTemplate: BasePromptFragment = {
id: 'system-prompt-appendix',
template: `
## IDE Integration Context
You are integrated into an IDE environment through a chat interface, providing contextual assistance for development tasks.
This integration gives you access to real-time IDE state and user workspace information.
### File Modification Behavior
**Important**: The IDE automatically collects and presents all file modifications you suggest, regardless of explicit tool permissions.
Continue proposing file changes even if you encounter permission restrictions.
The IDE will present these changes to the user for review and approval through its change management interface.
When making file modifications:
- Proceed with suggested changes even if tool calls indicate permission issues
- Inform users to review proposed changes in the IDE's change review interface "below"
- Assume the IDE will handle the actual file system operations after user approval (unless you are in auto-edit mode)
### Contextual Information Available
The following IDE context is dynamically provided with each request.
Evaluate the relevance of each context type based on the user's specific query and task requirements.
#### Current Selection
\`\`\`
{{selectedText}}
\`\`\`
**When to prioritize**: User asks about specific code segments, wants to refactor selected code, or requests explanations of selected text.
#### Active Editor
{{activeEditor}}
**When to prioritize**: User's request relates to the currently focused file, asks questions about the code, or needs context about the current working file.
#### Open Editors
{{openEditors}}
**How to use it**: As a guidance on what files might be relevant for the current user's request.
#### Context Files
{{contextFiles}}
**When to prioritize**: User explicitly references attached files or when additional files are needed to understand the full scope of the request.
### Context Utilization Guidelines
1. **Assess Relevance**:
Not all provided context will be relevant to every request. Focus on the context that directly supports the user's current task.
2. **Cross-Reference Information**:
When multiple context types are relevant, cross-reference them to provide comprehensive assistance (e.g., selected text within an active editor).
3. **Workspace Awareness**:
Use the collective context to understand the user's current workspace state and provide suggestions that align with their development environment and workflow.
### Response Optimization
- Reference specific files as markdown links with the format [file name](<absolute-file-path-without-scheme>), e.g. [example.ts](/home/me/workspace/example.ts)
- Consider the user's current focus and workflow when structuring responses
- Leverage open editors to suggest related modifications across the workspace
`
};
export const CLAUDE_CHAT_AGENT_ID = 'ClaudeCode';
const localCommands = {
'clear': AI_CHAT_NEW_CHAT_WINDOW_COMMAND,
'config': OPEN_CLAUDE_CODE_CONFIG,
'memory': OPEN_CLAUDE_CODE_MEMORY,
'resume': AI_CHAT_SHOW_CHATS_COMMAND,
};
@injectable()
export class ClaudeCodeChatAgent implements ChatAgent {
id = CLAUDE_CHAT_AGENT_ID;
name = CLAUDE_CHAT_AGENT_ID;
description = nls.localize('theia/ai/claude-code/agentDescription', 'Anthropic\'s coding agent');
iconClass: string = 'codicon codicon-copilot';
locations: ChatAgentLocation[] = ChatAgentLocation.ALL;
tags = [nls.localizeByDefault('Chat')];
modes = [
{ id: 'default', name: nls.localize('theia/ai/claude-code/askBeforeEdit', 'Ask before edit') },
{ id: 'acceptEdits', name: nls.localize('theia/ai/claude-code/editAutomatically', 'Edit automatically') },
{ id: 'plan', name: nls.localize('theia/ai/claude-code/plan', 'Plan mode') }
];
variables = [];
prompts = [{ id: systemPromptAppendixTemplate.id, defaultVariant: systemPromptAppendixTemplate }];
languageModelRequirements = [];
agentSpecificVariables = [];
functions = [];
@inject(PromptService)
protected promptService: PromptService;
@inject(ClaudeCodeFrontendService)
protected claudeCode: ClaudeCodeFrontendService;
@inject(FileService)
protected readonly fileService: FileService;
@inject(CommandService)
protected readonly commandService: CommandService;
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(EditorManager)
protected readonly editorManager: EditorManager;
@inject(SelectionService)
protected readonly selectionService: SelectionService;
@inject(ClaudeCodeEditToolService)
protected readonly editToolService: ClaudeCodeEditToolService;
@inject(FileEditBackupService)
protected readonly backupService: FileEditBackupService;
@inject(TokenUsageService)
protected readonly tokenUsageService: TokenUsageService;
@inject(ILogger) @named('claude-code')
protected readonly logger: ILogger;
async invoke(request: MutableChatRequestModel, chatAgentService?: ChatAgentService): Promise<void> {
this.warnIfDifferentAgentRequests(request);
// Handle slash commands anywhere in the request text
const commandRegex = /\/(\w+)/g;
const matches = Array.from(request.request.text.matchAll(commandRegex));
for (const match of matches) {
const command = match[1];
if (command in localCommands) {
const commandInfo = localCommands[command as keyof typeof localCommands];
this.commandService.executeCommand(commandInfo.id);
const message = nls.localize('theia/ai/claude-code/executedCommand', 'Executed: {0}', commandInfo.label);
request.response.response.addContent(new MarkdownChatResponseContentImpl(message));
request.response.complete();
return;
}
}
try {
const systemPromptAppendix = await this.createSystemPromptAppendix(request);
const claudeSessionId = this.getPreviousClaudeSessionId(request);
const agentAddress = `${PromptText.AGENT_CHAR}${CLAUDE_CHAT_AGENT_ID}`;
let prompt = request.request.text.trim();
if (prompt.startsWith(agentAddress)) {
prompt = prompt.replace(agentAddress, '').trim();
}
const shouldFork = claudeSessionId !== undefined && this.isEditRequest(request);
const streamResult = await this.claudeCode.send({
prompt,
options: {
systemPrompt: {
type: 'preset',
preset: 'claude_code',
append: systemPromptAppendix?.text
},
permissionMode: this.getClaudePermissionMode(request),
resume: claudeSessionId,
forkSession: shouldFork
}
}, request.response.cancellationToken);
this.initializesEditToolUses(request);
let hasAssistantMessage = false;
for await (const message of streamResult) {
if (ToolApprovalRequestMessage.is(message)) {
this.handleToolApprovalRequest(message, request);
} else {
if (message.type === 'assistant') {
hasAssistantMessage = true;
}
// Only set session ID if we've seen an assistant message
// because we cannot resume a prior request without an assistant message
if (hasAssistantMessage) {
this.setClaudeSessionId(request, message.session_id);
}
this.addResponseContent(message, request);
}
}
return request.response.complete();
} catch (error) {
this.logger.error('Error handling chat interaction:', error);
request.response.response.addContent(new ErrorChatResponseContentImpl(error));
request.response.error(error);
} finally {
await this.backupService.cleanUp(request);
}
}
protected warnIfDifferentAgentRequests(request: MutableChatRequestModel): void {
const requests = request.session.getRequests();
if (requests.length > 1) {
const previousRequest = requests[requests.length - 2];
if (previousRequest.agentId !== this.id) {
const warningMessage = '⚠️ ' + nls.localize('theia/ai/claude-code/differentAgentRequestWarning',
'The previous chat request was handled by a different agent. Claude Code does not see those other messages.') + '\n\n';
request.response.response.addContent(new MarkdownChatResponseContentImpl(warningMessage));
}
}
}
protected async createSystemPromptAppendix(request: MutableChatRequestModel): Promise<ResolvedPromptFragment | undefined> {
const contextVariables = request.context.variables.map(AIVariableResolutionRequest.fromResolved) ?? request.session.context.getVariables();
const contextFiles = contextVariables
.filter(variable => variable.variable.name === 'file' && !!variable.arg)
.map(variable => `- ${variable.arg}`)
.join('\n');
const activeEditor = this.editorManager.currentEditor?.editor.document.uri ?? 'None';
const openEditors = this.editorManager.all.map(editor => `- ${editor.editor.document.uri}`).join('\n');
return this.promptService.getResolvedPromptFragment(
systemPromptAppendixTemplate.id,
{ contextFiles, activeEditor, openEditors },
{ model: request.session, request }
);
}
protected initializesEditToolUses(request: MutableChatRequestModel): void {
request.addData(CLAUDE_EDIT_TOOL_USES_KEY, new Map<string, ToolUseBlock>());
}
protected getPendingApprovals(request: MutableChatRequestModel): Map<string, QuestionResponseContentImpl> {
let approvals = request.getDataByKey(CLAUDE_PENDING_APPROVALS_KEY) as Map<string, QuestionResponseContentImpl> | undefined;
if (!approvals) {
approvals = new Map<string, QuestionResponseContentImpl>();
request.addData(CLAUDE_PENDING_APPROVALS_KEY, approvals);
}
return approvals;
}
protected getApprovalToolInputs(request: MutableChatRequestModel): Map<string, unknown> {
let toolInputs = request.getDataByKey(CLAUDE_APPROVAL_TOOL_INPUTS_KEY) as Map<string, unknown> | undefined;
if (!toolInputs) {
toolInputs = new Map<string, unknown>();
request.addData(CLAUDE_APPROVAL_TOOL_INPUTS_KEY, toolInputs);
}
return toolInputs;
}
protected handleToolApprovalRequest(
approvalRequest: ToolApprovalRequestMessage,
request: MutableChatRequestModel
): void {
const question = nls.localize('theia/ai/claude-code/toolApprovalRequest', 'Claude Code wants to use the "{0}" tool. Do you want to allow this?', approvalRequest.toolName);
const questionContent = new QuestionResponseContentImpl(
question,
APPROVAL_OPTIONS,
request,
selectedOption => this.handleApprovalResponse(selectedOption, approvalRequest.requestId, request)
);
// Store references for this specific approval request
this.getPendingApprovals(request).set(approvalRequest.requestId, questionContent);
this.getApprovalToolInputs(request).set(approvalRequest.requestId, approvalRequest.toolInput);
request.response.response.addContent(questionContent);
request.response.waitForInput();
}
protected handleApprovalResponse(
selectedOption: { text: string; value?: string },
requestId: string,
request: MutableChatRequestModel
): void {
const pendingApprovals = this.getPendingApprovals(request);
const toolInputs = this.getApprovalToolInputs(request);
// Update UI state and clean up
const questionContent = pendingApprovals.get(requestId);
const originalToolInput = toolInputs.get(requestId);
if (questionContent) {
questionContent.selectedOption = selectedOption;
}
pendingApprovals.delete(requestId);
toolInputs.delete(requestId);
const approved = selectedOption.value === 'allow';
const response: ToolApprovalResponseMessage = {
type: 'tool-approval-response',
requestId,
approved,
...(approved
? { updatedInput: originalToolInput }
: { message: 'User denied tool usage' }
)
};
this.claudeCode.sendApprovalResponse(response);
// Only stop waiting for input if there are no more pending approvals
if (pendingApprovals.size === 0) {
request.response.stopWaitingForInput();
}
}
protected getEditToolUses(request: MutableChatRequestModel): Map<string, ToolUseBlock> | undefined {
return request.getDataByKey(CLAUDE_EDIT_TOOL_USES_KEY);
}
protected getPreviousClaudeSessionId(request: MutableChatRequestModel): string | undefined {
const requests = request.session.getRequests();
if (requests.length > 1) {
const previousRequest = requests[requests.length - 2];
return previousRequest.getDataByKey(CLAUDE_SESSION_ID_KEY);
}
return undefined;
}
protected getClaudeSessionId(request: MutableChatRequestModel): string | undefined {
return request.getDataByKey(CLAUDE_SESSION_ID_KEY);
}
protected isEditRequest(request: MutableChatRequestModel): boolean {
return request.request.referencedRequestId !== undefined;
}
protected setClaudeSessionId(request: MutableChatRequestModel, sessionId: string): void {
request.addData(CLAUDE_SESSION_ID_KEY, sessionId);
}
protected readonly ALLOWED_MODES: PermissionMode[] = ['default', 'acceptEdits', 'plan', 'bypassPermissions'];
protected getClaudePermissionMode(request: MutableChatRequestModel): PermissionMode {
const modeId = request.request.modeId ?? 'default';
return this.ALLOWED_MODES.includes(modeId as PermissionMode) ? modeId as PermissionMode : 'default';
}
protected getClaudeModelName(request: MutableChatRequestModel): string | undefined {
return request.getDataByKey(CLAUDE_MODEL_NAME_KEY);
}
protected setClaudeModelName(request: MutableChatRequestModel, modelName: string): void {
request.addData(CLAUDE_MODEL_NAME_KEY, modelName);
}
protected getCurrentInputTokens(request: MutableChatRequestModel): number {
return request.getDataByKey(CLAUDE_INPUT_TOKENS_KEY) as number ?? 0;
}
protected getCurrentOutputTokens(request: MutableChatRequestModel): number {
return request.getDataByKey(CLAUDE_OUTPUT_TOKENS_KEY) as number ?? 0;
}
protected updateTokens(request: MutableChatRequestModel, inputTokens: number, outputTokens: number): void {
request.addData(CLAUDE_INPUT_TOKENS_KEY, inputTokens);
request.addData(CLAUDE_OUTPUT_TOKENS_KEY, outputTokens);
this.updateSessionSuggestion(request);
}
protected getSessionTotalTokens(request: MutableChatRequestModel): { inputTokens: number; outputTokens: number } {
const requests = request.session.getRequests();
let totalInputTokens = 0;
let totalOutputTokens = 0;
for (const req of requests) {
const inputTokens = req.getDataByKey(CLAUDE_INPUT_TOKENS_KEY) as number ?? 0;
const outputTokens = req.getDataByKey(CLAUDE_OUTPUT_TOKENS_KEY) as number ?? 0;
totalInputTokens += inputTokens;
totalOutputTokens += outputTokens;
}
return { inputTokens: totalInputTokens, outputTokens: totalOutputTokens };
}
protected updateSessionSuggestion(request: MutableChatRequestModel): void {
const { inputTokens, outputTokens } = this.getSessionTotalTokens(request);
const formatTokens = (tokens: number): string => {
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}K`;
}
return tokens.toString();
};
const suggestion = `${formatTokens(inputTokens)} | ↓ ${formatTokens(outputTokens)}`;
request.session.setSuggestions([suggestion]);
}
protected isEditMode(request: MutableChatRequestModel): boolean {
const permissionMode = this.getClaudePermissionMode(request);
return permissionMode === 'acceptEdits' || permissionMode === 'bypassPermissions';
}
protected async reportTokenUsage(
request: MutableChatRequestModel,
inputTokens: number,
outputTokens: number,
cachedInputTokens?: number,
readCachedInputTokens?: number
): Promise<void> {
const modelName = this.getClaudeModelName(request);
if (!modelName) {
return;
}
const prefixedModelName = `anthropic/claude-code/${modelName}`;
const sessionId = this.getClaudeSessionId(request);
const requestId = sessionId || request.id;
try {
await this.tokenUsageService.recordTokenUsage(prefixedModelName, {
inputTokens,
outputTokens,
cachedInputTokens,
readCachedInputTokens,
requestId
});
} catch (error) {
this.logger.error('Failed to report token usage:', error);
}
}
protected async addResponseContent(message: SDKMessage, request: MutableChatRequestModel): Promise<void> {
// Extract model name from system init message
if (message.type === 'system' && message.subtype === 'init' && message.model) {
this.setClaudeModelName(request, message.model);
}
// Handle result messages with final usage
if (message.type === 'assistant' && message.message.usage) {
await this.handleTokenMetrics(message.message.usage, request);
}
if (message.type === 'result' && message.usage) {
request.addData(CLAUDE_COST_KEY, message.total_cost_usd);
await this.handleTokenMetrics(message.usage, request);
}
// Handle user messages for local-command-stdout extraction
if (message.type === 'user') {
const extractedContent = this.extractLocalCommandStdout(message.message.content);
if (extractedContent) {
request.response.response.addContent(new MarkdownChatResponseContentImpl(extractedContent));
}
}
if (message.type === 'assistant' || message.type === 'user') {
if (!Array.isArray(message.message.content)) {
return;
}
for (const block of message.message.content) {
switch (block.type) {
case 'text':
request.response.response.addContent(new MarkdownChatResponseContentImpl(block.text));
break;
case 'tool_use':
case 'server_tool_use':
if (block.name === 'Task' && TaskInput.is(block.input)) {
request.response.response.addContent(new MarkdownChatResponseContentImpl(`\n\n### Task: ${block.input.description}\n\n${block.input.prompt}`));
}
// Track file edits
if ((block.name === 'Edit' && EditInput.is(block.input)) ||
(block.name === 'MultiEdit' && MultiEditInput.is(block.input)) ||
(block.name === 'Write' && WriteInput.is(block.input))) {
const toolUse: ToolUseBlock = {
name: block.name,
input: block.input
};
this.getEditToolUses(request)?.set(block.id, toolUse);
}
request.response.response.addContent(new ClaudeCodeToolCallChatResponseContent(block.id, block.name, JSON.stringify(block.input)));
break;
case 'tool_result':
if (this.getEditToolUses(request)?.has(block.tool_use_id)) {
const toolUse = this.getEditToolUses(request)?.get(block.tool_use_id);
if (toolUse) {
await this.editToolService.handleEditTool(toolUse, request, {
sessionId: this.getClaudeSessionId(request),
isEditMode: this.isEditMode(request)
});
}
}
request.response.response.addContent(new ClaudeCodeToolCallChatResponseContent(block.tool_use_id, '', '', true, JSON.stringify(block.content)));
break;
case 'thinking':
request.response.response.addContent(new ThinkingChatResponseContentImpl(block.thinking.trim(), block.signature?.trim() || ''));
break;
case 'redacted_thinking':
request.response.response.addContent(new ThinkingChatResponseContentImpl(block.data.trim(), ''));
break;
case 'web_search_tool_result':
if (Array.isArray(block.content)) {
const result = block.content.map(value => value.title + ':' + value.url).join(', ');
request.response.response.addContent(new ClaudeCodeToolCallChatResponseContent(block.tool_use_id, '', '', true, result));
}
break;
}
}
}
}
protected async handleTokenMetrics(usage: Usage, request: MutableChatRequestModel): Promise<void> {
const allInputTokens = (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
this.updateTokens(request, allInputTokens, (usage.output_tokens ?? 0));
await this.reportTokenUsage(request, allInputTokens, (usage.output_tokens ?? 0),
(usage.cache_creation_input_tokens ?? 0), (usage.cache_read_input_tokens ?? 0));
}
private extractLocalCommandStdout(content: string | ContentBlock[]): string | undefined {
const regex = /<(local-command-stdout|local-command-stderr)>([\s\S]*?)<\/\1>/g;
let extractedContent = '';
let match;
if (typeof content === 'string') {
// eslint-disable-next-line no-null/no-null
while ((match = regex.exec(content)) !== null) {
extractedContent += match[2];
}
} else {
for (const block of content) {
if (block.type === 'text') {
// eslint-disable-next-line no-null/no-null
while ((match = regex.exec(block.text)) !== null) {
extractedContent += match[2];
}
}
}
}
return extractedContent || undefined;
}
}

View File

@@ -0,0 +1,80 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { Command, CommandContribution, CommandRegistry } from '@theia/core';
import { inject, injectable } from '@theia/core/shared/inversify';
import { codicon } from '@theia/core/lib/browser';
import { EditorManager } from '@theia/editor/lib/browser';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { WorkspaceService } from '@theia/workspace/lib/browser';
import { ChatCommands } from '@theia/ai-chat-ui/lib/browser/chat-view-commands';
import { AIActivationService } from '@theia/ai-core/lib/browser';
export const OPEN_CLAUDE_CODE_CONFIG = Command.toLocalizedCommand({
id: 'chat:open-claude-code-config',
category: ChatCommands.CHAT_CATEGORY,
iconClass: codicon('bracket'),
label: 'Open Claude Code Configuration'
}, 'theia/ai-claude-code/open-config', ChatCommands.CHAT_CATEGORY_KEY);
export const OPEN_CLAUDE_CODE_MEMORY = Command.toLocalizedCommand({
id: 'chat:open-claude-code-memory',
category: ChatCommands.CHAT_CATEGORY,
iconClass: codicon('bracket'),
label: 'Open Claude Code Memory (CLAUDE.MD)'
}, 'theia/ai-claude-code/open-memory', ChatCommands.CHAT_CATEGORY_KEY);
@injectable()
export class ClaudeCodeCommandContribution implements CommandContribution {
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(FileService)
protected readonly fileService: FileService;
@inject(EditorManager)
protected readonly editorManager: EditorManager;
@inject(AIActivationService)
protected readonly activationService: AIActivationService;
registerCommands(commands: CommandRegistry): void {
commands.registerCommand(OPEN_CLAUDE_CODE_CONFIG, {
execute: async () => await this.openFileInWorkspace('.claude/settings.json', JSON.stringify({}, undefined, 2)),
isVisible: () => this.activationService.isActive,
isEnabled: () => this.activationService.isActive
});
commands.registerCommand(OPEN_CLAUDE_CODE_MEMORY, {
execute: async () => await this.openFileInWorkspace('.claude/CLAUDE.md', ''),
isVisible: () => this.activationService.isActive,
isEnabled: () => this.activationService.isActive
});
}
protected async openFileInWorkspace(file: string, initialContent: string): Promise<void> {
const roots = this.workspaceService.tryGetRoots();
if (roots.length < 1) {
return;
}
const uri = roots[0].resource;
const claudeSettingsUri = uri.resolve(file);
if (! await this.fileService.exists(claudeSettingsUri)) {
await this.fileService.write(claudeSettingsUri, initialContent, { encoding: 'utf8' });
}
this.editorManager.open(claudeSettingsUri);
}
}

View File

@@ -0,0 +1,317 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { MutableChatRequestModel } from '@theia/ai-chat';
import { ChangeSetFileElement, ChangeSetFileElementFactory } from '@theia/ai-chat/lib/browser/change-set-file-element';
import { ChangeSetElement } from '@theia/ai-chat/lib/common/change-set';
import { ContentReplacerV1Impl, Replacement } from '@theia/core/lib/common/content-replacer';
import { URI } from '@theia/core/lib/common/uri';
import { inject, injectable, named } from '@theia/core/shared/inversify';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { WorkspaceService } from '@theia/workspace/lib/browser';
import { FileEditBackupService } from './claude-code-file-edit-backup-service';
import { ILogger, nls } from '@theia/core';
export interface EditToolInput {
file_path: string;
old_string: string;
new_string: string;
}
export interface MultiEditToolInput {
file_path: string;
edits: Array<{
old_string: string;
new_string: string;
}>;
}
export interface WriteToolInput {
file_path: string;
content: string;
}
export interface ToolUseBlock {
name: string;
input: EditToolInput | MultiEditToolInput | WriteToolInput;
}
export interface EditToolContext {
sessionId: string | undefined;
isEditMode: boolean;
}
export const ClaudeCodeEditToolService = Symbol('ClaudeCodeEditToolService');
/**
* Service for handling edit tool operations.
*
* Invoked by the ClaudeCodeChatAgent on each finished edit tool request.
* This can be used to track and manage file edits made by the agent, e.g.
* to propagate them to ChangeSets (see ClaudeCodeEditToolServiceImpl below).
*/
export interface ClaudeCodeEditToolService {
handleEditTool(toolUse: ToolUseBlock, request: MutableChatRequestModel, context: EditToolContext): Promise<void>;
}
/**
* Propagates edit tool results to change sets in the specified request's session.
*/
@injectable()
export class ClaudeCodeEditToolServiceImpl implements ClaudeCodeEditToolService {
@inject(ChangeSetFileElementFactory)
protected readonly fileChangeFactory: ChangeSetFileElementFactory;
@inject(FileService)
protected readonly fileService: FileService;
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(FileEditBackupService)
protected readonly backupService: FileEditBackupService;
@inject(ILogger) @named('claude-code')
protected readonly logger: ILogger;
private readonly contentReplacer = new ContentReplacerV1Impl();
async handleEditTool(toolUse: ToolUseBlock, request: MutableChatRequestModel, context: EditToolContext): Promise<void> {
try {
const { name, input } = toolUse;
switch (name) {
case 'Edit':
await this.handleEditSingle(input as EditToolInput, request, context);
break;
case 'MultiEdit':
await this.handleEditMultiple(input as MultiEditToolInput, request, context);
break;
case 'Write':
await this.handleWriteFile(input as WriteToolInput, request, context);
break;
}
} catch (error) {
this.logger.error('Error handling edit tool:', error);
}
}
protected async handleEditSingle(input: EditToolInput, request: MutableChatRequestModel, context: EditToolContext): Promise<void> {
try {
const workspaceUri = await this.toWorkspaceUri(input.file_path);
const currentContent = await this.fileService.read(workspaceUri);
const currentContentString = currentContent.value.toString();
const existingChangeSetElement = request.session.changeSet.getElementByURI(workspaceUri);
const replacement: Replacement = {
oldContent: input.old_string,
newContent: input.new_string
};
if (context.isEditMode) {
await this.handleEditModeCommon(
workspaceUri,
currentContentString,
[replacement],
existingChangeSetElement,
request,
context
);
} else {
await this.handleNonEditModeCommon(
workspaceUri,
currentContentString,
[replacement],
existingChangeSetElement,
request
);
}
request.session.changeSet.setTitle(nls.localize('theia/ai/claude-code/changeSetTitle', 'Changes by Claude Code'));
} catch (error) {
this.logger.error('Error handling Edit tool:', error);
}
}
protected async handleEditMultiple(input: MultiEditToolInput, request: MutableChatRequestModel, context: EditToolContext): Promise<void> {
try {
const workspaceUri = await this.toWorkspaceUri(input.file_path);
const currentContent = await this.fileService.read(workspaceUri);
const currentContentString = currentContent.value.toString();
const existingChangeSetElement = request.session.changeSet.getElementByURI(workspaceUri);
const replacements: Replacement[] = input.edits.map(edit => ({
oldContent: edit.old_string,
newContent: edit.new_string
}));
if (context.isEditMode) {
await this.handleEditModeCommon(
workspaceUri,
currentContentString,
replacements,
existingChangeSetElement,
request,
context
);
} else {
await this.handleNonEditModeCommon(
workspaceUri,
currentContentString,
replacements,
existingChangeSetElement,
request
);
}
request.session.changeSet.setTitle(nls.localize('theia/ai/claude-code/changeSetTitle', 'Changes by Claude Code'));
} catch (error) {
this.logger.error('Error handling MultiEdit tool:', error);
}
}
protected async handleWriteFile(input: WriteToolInput, request: MutableChatRequestModel, context: EditToolContext): Promise<void> {
try {
const workspaceUri = await this.toWorkspaceUri(input.file_path);
const fileExists = await this.fileService.exists(workspaceUri);
if (context.isEditMode) {
if (input.content === '') {
const originalState = await this.backupService.getOriginal(workspaceUri, context.sessionId);
const fileElement = this.fileChangeFactory({
uri: workspaceUri,
type: 'delete',
state: 'applied',
originalState,
targetState: '',
requestId: request.id,
chatSessionId: request.session.id
});
request.session.changeSet.addElements(fileElement);
} else {
const type = !fileExists ? 'add' : 'modify';
let originalState = '';
if (type === 'modify') {
originalState = (await this.backupService.getOriginal(workspaceUri, context.sessionId)) ?? '';
}
const fileElement = this.fileChangeFactory({
uri: workspaceUri,
type,
state: 'applied',
originalState,
targetState: input.content,
requestId: request.id,
chatSessionId: request.session.id
});
request.session.changeSet.addElements(fileElement);
}
} else {
const type = input.content === '' ? 'delete' :
!fileExists ? 'add' : 'modify';
const fileElement = this.fileChangeFactory({
uri: workspaceUri,
type,
state: 'pending',
targetState: input.content,
requestId: request.id,
chatSessionId: request.session.id
});
request.session.changeSet.addElements(fileElement);
}
request.session.changeSet.setTitle(nls.localize('theia/ai/claude-code/changeSetTitle', 'Changes by Claude Code'));
} catch (error) {
this.logger.error('Error handling Write tool:', error);
}
}
protected async handleEditModeCommon(
workspaceUri: URI,
currentContentString: string,
replacements: Replacement[],
existingChangeSetElement: ChangeSetElement | undefined,
request: MutableChatRequestModel,
context: EditToolContext
): Promise<void> {
const originalState = await this.backupService.getOriginal(workspaceUri, context.sessionId);
const existingReplacements = (existingChangeSetElement instanceof ChangeSetFileElement) && existingChangeSetElement.replacements || [];
const fileElement = this.fileChangeFactory({
uri: workspaceUri,
type: 'modify',
state: 'applied',
originalState,
targetState: currentContentString,
requestId: request.id,
chatSessionId: request.session.id,
replacements: [...existingReplacements, ...replacements]
});
request.session.changeSet.addElements(fileElement);
}
protected async handleNonEditModeCommon(
workspaceUri: URI,
currentContentString: string,
replacements: Replacement[],
existingChangeSetElement: ChangeSetElement | undefined,
request: MutableChatRequestModel
): Promise<void> {
const { updatedContent, errors } = this.contentReplacer.applyReplacements(
currentContentString,
replacements
);
if (errors.length > 0) {
this.logger.error('Content replacement errors:', errors);
return;
}
if (updatedContent !== currentContentString) {
const existingReplacements = (existingChangeSetElement instanceof ChangeSetFileElement) && existingChangeSetElement.replacements || [];
const fileElement = this.fileChangeFactory({
uri: workspaceUri,
type: 'modify',
state: 'pending',
targetState: updatedContent,
requestId: request.id,
chatSessionId: request.session.id,
replacements: [...existingReplacements, ...replacements]
});
request.session.changeSet.addElements(fileElement);
}
}
protected async toWorkspaceUri(absolutePath: string): Promise<URI> {
const absoluteUri = new URI(absolutePath);
const workspaceUri = this.workspaceService.getWorkspaceRootUri(absoluteUri);
if (!workspaceUri) {
throw new Error(`No workspace found for ${absolutePath}`);
}
const relativeUri = await this.workspaceService.getWorkspaceRelativePath(absoluteUri);
return workspaceUri?.resolve(relativeUri);
}
}

View File

@@ -0,0 +1,145 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { MutableChatRequestModel } from '@theia/ai-chat';
import { ChangeSetFileElement } from '@theia/ai-chat/lib/browser/change-set-file-element';
import { URI } from '@theia/core/lib/common/uri';
import { inject, injectable, named } from '@theia/core/shared/inversify';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { WorkspaceService } from '@theia/workspace/lib/browser';
import { CLAUDE_SESSION_ID_KEY } from './claude-code-chat-agent';
import { ILogger } from '@theia/core';
export const FileEditBackupService = Symbol('FileEditBackupService');
/**
* Service for managing file backup operations during Claude Code edit sessions.
*
* This service handles the retrieval of original file content from backup files
* created by the file backup hooks in ClaudeCodeServiceImpl. The backup hooks
* run before file modification tools (Write, Edit, MultiEdit) and create backups
* in the `.claude/.edit-baks/{session_id}/` directory structure.
*
* @see packages/ai-claude-code/src/node/claude-code-service-impl.ts#ensureFileBackupHook
* The coupling with the backup hooks is intentional - this service reads from
* the same backup location that the hooks write to.
*/
export interface FileEditBackupService {
/**
* Retrieves the original content of a file from its backup.
*
* This method reads from backup files created by the file backup hooks
* that are installed by ClaudeCodeServiceImpl.ensureFileBackupHook().
*
* @param workspaceUri The URI of the file to get backup content for
* @param sessionId The Claude session ID used for backup organization
* @returns The original file content, or undefined if no backup exists
*/
getOriginal(workspaceUri: URI, sessionId: string | undefined): Promise<string | undefined>;
/**
* Cleans up backup files for a completed chat session.
*
* This method removes the backup directory structure for the given session
* from all workspaces that have change set elements.
*
* @param request The chat request model containing session and change set information
*/
cleanUp(request: MutableChatRequestModel): Promise<void>;
/**
* Gets the backup location for a workspace root.
*
* This must match the backup location used by the file backup hooks
* in ClaudeCodeServiceImpl.
*
* @param workspaceRoot The workspace root URI
* @returns The backup directory URI
*/
getLocation(workspaceRoot: URI): URI;
}
@injectable()
export class FileEditBackupServiceImpl implements FileEditBackupService {
@inject(FileService)
protected readonly fileService: FileService;
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(ILogger) @named('claude-code')
protected readonly logger: ILogger;
getLocation(workspaceRoot: URI): URI {
// This path structure must match the backup hooks in claude-code-service-impl.ts
// See ensureFileBackupHook() method which creates backups at:
// path.join(hookData.cwd, '.claude', '.edit-baks', hookData.session_id)
return workspaceRoot.resolve('.claude').resolve('.edit-baks');
}
async getOriginal(workspaceUri: URI, sessionId: string | undefined): Promise<string | undefined> {
if (!sessionId) {
return undefined;
}
try {
const workspaceRoot = this.workspaceService.getWorkspaceRootUri(workspaceUri);
if (!workspaceRoot) {
return undefined;
}
const relativePath = await this.workspaceService.getWorkspaceRelativePath(workspaceUri);
// This path structure must match the backup hooks in claude-code-service-impl.ts
const backupPath = this.getLocation(workspaceRoot).resolve(sessionId).resolve(relativePath);
if (await this.fileService.exists(backupPath)) {
const backupContent = await this.fileService.read(backupPath);
return backupContent.value.toString();
}
} catch (error) {
this.logger.error('Error reading backup file:', error);
}
return undefined;
}
async cleanUp(request: MutableChatRequestModel): Promise<void> {
const sessionId = request.getDataByKey(CLAUDE_SESSION_ID_KEY) as string | undefined;
if (!sessionId) {
return;
}
if (request.session.changeSet.getElements().length < 1) {
return;
}
const workspaceUris = new Set<URI>();
request.session.changeSet.getElements()
.filter((element): element is ChangeSetFileElement => element instanceof ChangeSetFileElement)
.map(element => this.workspaceService.getWorkspaceRootUri(element.uri))
.filter((element): element is URI => element !== undefined)
.forEach(element => workspaceUris.add(element));
for (const workspaceUri of workspaceUris) {
const backupLocation = this.getLocation(workspaceUri).resolve(sessionId);
try {
await this.fileService.delete(backupLocation, { recursive: true });
} catch (error) {
// Ignore cleanup errors - not critical
}
}
}
}

View File

@@ -0,0 +1,100 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ChatAgent } from '@theia/ai-chat';
import { ChatResponsePartRenderer } from '@theia/ai-chat-ui/lib/browser/chat-response-part-renderer';
import { Agent } from '@theia/ai-core';
import { CommandContribution, PreferenceContribution } from '@theia/core';
import { FrontendApplicationContribution, RemoteConnectionProvider, ServiceConnectionProvider } from '@theia/core/lib/browser';
import { ContainerModule } from '@theia/core/shared/inversify';
import '../../src/browser/style/claude-code-tool-renderers.css';
import {
CLAUDE_CODE_SERVICE_PATH,
ClaudeCodeClient,
ClaudeCodeService
} from '../common/claude-code-service';
import { ClaudeCodePreferencesSchema } from '../common/claude-code-preferences';
import { ClaudeCodeChatAgent } from './claude-code-chat-agent';
import { ClaudeCodeEditToolService, ClaudeCodeEditToolServiceImpl } from './claude-code-edit-tool-service';
import { FileEditBackupService, FileEditBackupServiceImpl } from './claude-code-file-edit-backup-service';
import { ClaudeCodeClientImpl, ClaudeCodeFrontendService } from './claude-code-frontend-service';
import { BashToolRenderer } from './renderers/bash-tool-renderer';
import { EditToolRenderer } from './renderers/edit-tool-renderer';
import { GlobToolRenderer } from './renderers/glob-tool-renderer';
import { GrepToolRenderer } from './renderers/grep-tool-renderer';
import { LSToolRenderer } from './renderers/ls-tool-renderer';
import { MultiEditToolRenderer } from './renderers/multiedit-tool-renderer';
import { ReadToolRenderer } from './renderers/read-tool-renderer';
import { TodoWriteRenderer } from './renderers/todo-write-renderer';
import { WebFetchToolRenderer } from './renderers/web-fetch-tool-renderer';
import { WriteToolRenderer } from './renderers/write-tool-renderer';
import { ClaudeCodeSlashCommandsContribution } from './claude-code-slash-commands-contribution';
import { ClaudeCodeCommandContribution } from './claude-code-command-contribution';
export default new ContainerModule(bind => {
bind(PreferenceContribution).toConstantValue({ schema: ClaudeCodePreferencesSchema });
bind(FrontendApplicationContribution).to(ClaudeCodeSlashCommandsContribution).inSingletonScope();
bind(CommandContribution).to(ClaudeCodeCommandContribution).inSingletonScope();
bind(ClaudeCodeFrontendService).toSelf().inSingletonScope();
bind(ClaudeCodeClientImpl).toSelf().inSingletonScope();
bind(ClaudeCodeClient).toService(ClaudeCodeClientImpl);
bind(ClaudeCodeService).toDynamicValue(ctx => {
const connection = ctx.container.get<ServiceConnectionProvider>(RemoteConnectionProvider);
const backendClient: ClaudeCodeClient = ctx.container.get(ClaudeCodeClient);
return connection.createProxy(CLAUDE_CODE_SERVICE_PATH, backendClient);
}).inSingletonScope();
bind(FileEditBackupServiceImpl).toSelf().inSingletonScope();
bind(FileEditBackupService).toService(FileEditBackupServiceImpl);
bind(ClaudeCodeEditToolServiceImpl).toSelf().inSingletonScope();
bind(ClaudeCodeEditToolService).toService(ClaudeCodeEditToolServiceImpl);
bind(ClaudeCodeChatAgent).toSelf().inSingletonScope();
bind(Agent).toService(ClaudeCodeChatAgent);
bind(ChatAgent).toService(ClaudeCodeChatAgent);
bind(TodoWriteRenderer).toSelf().inSingletonScope();
bind(ChatResponsePartRenderer).toService(TodoWriteRenderer);
bind(ReadToolRenderer).toSelf().inSingletonScope();
bind(ChatResponsePartRenderer).toService(ReadToolRenderer);
bind(BashToolRenderer).toSelf().inSingletonScope();
bind(ChatResponsePartRenderer).toService(BashToolRenderer);
bind(LSToolRenderer).toSelf().inSingletonScope();
bind(ChatResponsePartRenderer).toService(LSToolRenderer);
bind(EditToolRenderer).toSelf().inSingletonScope();
bind(ChatResponsePartRenderer).toService(EditToolRenderer);
bind(GrepToolRenderer).toSelf().inSingletonScope();
bind(ChatResponsePartRenderer).toService(GrepToolRenderer);
bind(GlobToolRenderer).toSelf().inSingletonScope();
bind(ChatResponsePartRenderer).toService(GlobToolRenderer);
bind(WriteToolRenderer).toSelf().inSingletonScope();
bind(ChatResponsePartRenderer).toService(WriteToolRenderer);
bind(MultiEditToolRenderer).toSelf().inSingletonScope();
bind(ChatResponsePartRenderer).toService(MultiEditToolRenderer);
bind(WebFetchToolRenderer).toSelf().inSingletonScope();
bind(ChatResponsePartRenderer).toService(WebFetchToolRenderer);
});

View File

@@ -0,0 +1,213 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { CancellationToken, generateUuid, ILogger, PreferenceService } from '@theia/core';
import { FileUri } from '@theia/core/lib/common/file-uri';
import { inject, injectable, LazyServiceIdentifier } from '@theia/core/shared/inversify';
import {
OutputChannel,
OutputChannelManager,
OutputChannelSeverity
} from '@theia/output/lib/browser/output-channel';
import { WorkspaceService } from '@theia/workspace/lib/browser';
import {
ClaudeCodeClient,
ClaudeCodeOptions,
ClaudeCodeRequest,
ClaudeCodeService,
SDKMessage,
StreamMessage,
ToolApprovalResponseMessage
} from '../common/claude-code-service';
import { CLAUDE_CODE_EXECUTABLE_PATH_PREF, CLAUDE_CODE_API_KEY_PREF } from '../common/claude-code-preferences';
@injectable()
export class ClaudeCodeClientImpl implements ClaudeCodeClient {
protected tokenHandlers = new Map<string, (token?: StreamMessage) => void>();
protected errorHandlers = new Map<string, (error: Error) => void>();
// invoked by the backend
sendToken(streamId: string, token?: StreamMessage): void {
const handler = this.tokenHandlers.get(streamId);
if (handler) {
handler(token);
}
}
// invoked by the backend
sendError(streamId: string, error: Error): void {
const handler = this.errorHandlers.get(streamId);
if (handler) {
handler(error);
}
}
registerTokenHandler(streamId: string, handler: (token?: StreamMessage) => void): void {
this.tokenHandlers.set(streamId, handler);
}
registerErrorHandler(streamId: string, handler: (error: Error) => void): void {
this.errorHandlers.set(streamId, handler);
}
unregisterHandlers(streamId: string): void {
this.tokenHandlers.delete(streamId);
this.errorHandlers.delete(streamId);
}
}
interface StreamState {
id: string;
tokens: (StreamMessage | undefined)[];
isComplete: boolean;
hasError: boolean;
error?: Error;
pendingResolve?: () => void;
pendingReject?: (error: Error) => void;
}
@injectable()
export class ClaudeCodeFrontendService {
@inject(ClaudeCodeService)
protected claudeCodeBackendService: ClaudeCodeService;
@inject(new LazyServiceIdentifier(() => ClaudeCodeClientImpl))
protected client: ClaudeCodeClientImpl;
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(PreferenceService)
protected preferenceService: PreferenceService;
@inject(OutputChannelManager)
protected readonly outputChannelManager: OutputChannelManager;
@inject(ILogger)
protected logger: ILogger;
protected streams = new Map<string, StreamState>();
async send(request: ClaudeCodeRequest, cancellationToken?: CancellationToken): Promise<AsyncIterable<StreamMessage>> {
const streamState: StreamState = {
id: this.generateStreamId(),
tokens: [],
isComplete: false,
hasError: false
};
this.streams.set(streamState.id, streamState);
this.setupStreamHandlers(streamState);
cancellationToken?.onCancellationRequested(() => this.claudeCodeBackendService.cancel(streamState.id));
const roots = await this.workspaceService.roots;
const rootsUris = roots.map(root => FileUri.fsPath(root.resource.toString()));
const prompt = request.prompt;
const apiKey = this.preferenceService.get<string>(CLAUDE_CODE_API_KEY_PREF, undefined);
const claudeCodePath = this.preferenceService.get<string>(CLAUDE_CODE_EXECUTABLE_PATH_PREF, undefined);
this.getOutputChannel()?.appendLine(JSON.stringify(request, undefined, 2));
await this.claudeCodeBackendService.send({
prompt,
apiKey,
claudeCodePath,
options: <ClaudeCodeOptions>{
cwd: rootsUris[0],
...request.options
}
}, streamState.id);
return this.createAsyncIterable(streamState);
}
protected generateStreamId(): string {
return generateUuid();
}
protected setupStreamHandlers(streamState: StreamState): void {
this.client.registerTokenHandler(streamState.id, (token?: SDKMessage) => {
if (token === undefined) {
streamState.isComplete = true;
} else {
this.getOutputChannel()?.appendLine(JSON.stringify(token, undefined, 2));
streamState.tokens.push(token);
}
// Resolve any pending iterator
if (streamState.pendingResolve) {
streamState.pendingResolve();
streamState.pendingResolve = undefined;
}
});
this.client.registerErrorHandler(streamState.id, (error: Error) => {
streamState.hasError = true;
streamState.error = error;
this.getOutputChannel()?.appendLine(JSON.stringify(error, undefined, 2), OutputChannelSeverity.Error);
// Reject any pending iterator
if (streamState.pendingReject) {
streamState.pendingReject(error);
streamState.pendingReject = undefined;
}
});
}
protected async *createAsyncIterable(streamState: StreamState): AsyncIterable<StreamMessage> {
let currentIndex = 0;
while (true) {
// Check for available tokens
if (currentIndex < streamState.tokens.length) {
const token = streamState.tokens[currentIndex];
currentIndex++;
if (token !== undefined) {
yield token;
}
continue;
}
if (streamState.isComplete) {
break;
}
if (streamState.hasError && streamState.error) {
throw streamState.error;
}
// Wait for next token
await new Promise<void>((resolve, reject) => {
streamState.pendingResolve = resolve;
streamState.pendingReject = reject;
});
}
// Cleanup
this.client.unregisterHandlers(streamState.id);
this.streams.delete(streamState.id);
}
sendApprovalResponse(response: ToolApprovalResponseMessage): void {
this.getOutputChannel()?.appendLine(JSON.stringify(response, undefined, 2));
this.claudeCodeBackendService.handleApprovalResponse(response);
}
protected getOutputChannel(): OutputChannel | undefined {
return this.outputChannelManager.getChannel('Claude Code');
}
}

View File

@@ -0,0 +1,254 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { PromptService } from '@theia/ai-core/lib/common/prompt-service';
import { DisposableCollection, ILogger, nls, URI } from '@theia/core';
import { FrontendApplicationContribution } from '@theia/core/lib/browser';
import { inject, injectable, named } from '@theia/core/shared/inversify';
import { FileService } from '@theia/filesystem/lib/browser/file-service';
import { FileChangeType } from '@theia/filesystem/lib/common/files';
import { WorkspaceService } from '@theia/workspace/lib/browser';
import { CLAUDE_CHAT_AGENT_ID } from './claude-code-chat-agent';
const CLAUDE_COMMANDS = '.claude/commands';
const COMMAND_FRAGMENT_PREFIX = 'claude-code-slash-';
const DYNAMIC_COMMAND_PREFIX = 'claude-code-dynamic-';
interface StaticSlashCommand {
name: string;
description: string;
}
@injectable()
export class ClaudeCodeSlashCommandsContribution implements FrontendApplicationContribution {
private readonly staticCommands: StaticSlashCommand[] = [
{
name: 'clear',
description: nls.localize('theia/ai/claude-code/clearCommand/description', 'Create a new session'),
},
{
name: 'compact',
description: nls.localize('theia/ai/claude-code/compactCommand/description', 'Compact conversation with optional focus instructions'),
},
{
name: 'config',
description: nls.localize('theia/ai/claude-code/configCommand/description', 'Open Claude Code Configuration'),
},
{
name: 'init',
description: nls.localize('theia/ai/claude-code/initCommand/description', 'Initialize project with CLAUDE.md guide'),
},
{
name: 'memory',
description: nls.localize('theia/ai/claude-code/memoryCommand/description', 'Edit CLAUDE.md memory file'),
},
{
name: 'review',
description: nls.localize('theia/ai/claude-code/reviewCommand/description', 'Request code review'),
},
{
name: 'resume',
description: nls.localize('theia/ai/claude-code/resumeCommand/description', 'Resume a session'),
}
];
@inject(ILogger) @named('claude-code')
protected readonly logger: ILogger;
@inject(PromptService)
protected readonly promptService: PromptService;
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(FileService)
protected readonly fileService: FileService;
protected readonly toDispose = new DisposableCollection();
protected currentWorkspaceRoot: URI | undefined;
protected fileWatcherDisposable: DisposableCollection | undefined;
async onStart(): Promise<void> {
this.registerStaticCommands();
await this.initializeDynamicCommands();
this.toDispose.push(
this.workspaceService.onWorkspaceChanged(() => this.handleWorkspaceChange())
);
}
onStop(): void {
this.toDispose.dispose();
}
protected registerStaticCommands(): void {
for (const command of this.staticCommands) {
this.promptService.addBuiltInPromptFragment({
id: `${COMMAND_FRAGMENT_PREFIX}${command.name}`,
template: `/${command.name}`,
isCommand: true,
commandName: command.name,
commandDescription: command.description,
commandAgents: [CLAUDE_CHAT_AGENT_ID]
});
}
}
protected async initializeDynamicCommands(): Promise<void> {
const workspaceRoot = this.getWorkspaceRoot();
if (!workspaceRoot) {
return;
}
this.currentWorkspaceRoot = workspaceRoot;
await this.registerDynamicCommandsForWorkspace(workspaceRoot);
this.setupFileWatcher(workspaceRoot);
}
protected async registerDynamicCommandsForWorkspace(workspaceRoot: URI): Promise<void> {
const commandsUri = this.getCommandsUri(workspaceRoot);
const files = await this.listMarkdownFiles(commandsUri);
for (const filename of files) {
await this.registerDynamicCommand(commandsUri, filename);
}
}
protected async registerDynamicCommand(commandsDir: URI, filename: string): Promise<void> {
const commandName = this.getCommandNameFromFilename(filename);
const fileUri = commandsDir.resolve(filename);
try {
const content = await this.fileService.read(fileUri);
this.promptService.addBuiltInPromptFragment({
id: this.getDynamicCommandId(commandName),
template: content.value,
isCommand: true,
commandName,
commandAgents: [CLAUDE_CHAT_AGENT_ID]
});
} catch (error) {
this.logger.error(`Failed to register Claude Code slash command '${commandName}' from ${fileUri}:`, error);
}
}
protected setupFileWatcher(workspaceRoot: URI): void {
this.fileWatcherDisposable?.dispose();
this.fileWatcherDisposable = new DisposableCollection();
const commandsUri = this.getCommandsUri(workspaceRoot);
this.fileWatcherDisposable.push(
this.fileService.onDidFilesChange(async event => {
const relevantChanges = event.changes.filter(change =>
this.isCommandFile(change.resource, commandsUri)
);
if (relevantChanges.length === 0) {
return;
}
for (const change of relevantChanges) {
await this.handleFileChange(change.resource, change.type, commandsUri);
}
})
);
this.toDispose.push(this.fileWatcherDisposable);
}
protected async handleFileChange(resource: URI, changeType: FileChangeType, commandsUri: URI): Promise<void> {
const filename = resource.path.base;
const commandName = this.getCommandNameFromFilename(filename);
const fragmentId = this.getDynamicCommandId(commandName);
if (changeType === FileChangeType.DELETED) {
this.promptService.removePromptFragment(fragmentId);
} else if (changeType === FileChangeType.ADDED || changeType === FileChangeType.UPDATED) {
await this.registerDynamicCommand(commandsUri, filename);
}
}
protected async handleWorkspaceChange(): Promise<void> {
const newRoot = this.getWorkspaceRoot();
if (this.currentWorkspaceRoot?.toString() === newRoot?.toString()) {
return;
}
await this.clearDynamicCommands();
this.currentWorkspaceRoot = newRoot;
await this.initializeDynamicCommands();
}
protected async clearDynamicCommands(): Promise<void> {
if (!this.currentWorkspaceRoot) {
return;
}
const commandsUri = this.getCommandsUri(this.currentWorkspaceRoot);
const files = await this.listMarkdownFiles(commandsUri);
for (const filename of files) {
const commandName = this.getCommandNameFromFilename(filename);
this.promptService.removePromptFragment(this.getDynamicCommandId(commandName));
}
}
protected getWorkspaceRoot(): URI | undefined {
const roots = this.workspaceService.tryGetRoots();
return roots.length > 0 ? roots[0].resource : undefined;
}
protected getCommandsUri(workspaceRoot: URI): URI {
return workspaceRoot.resolve(CLAUDE_COMMANDS);
}
protected isCommandFile(resource: URI, commandsUri: URI): boolean {
return resource.toString().startsWith(commandsUri.toString()) && resource.path.ext === '.md';
}
protected getCommandNameFromFilename(filename: string): string {
return filename.replace(/\.md$/, '');
}
protected getDynamicCommandId(commandName: string): string {
return `${DYNAMIC_COMMAND_PREFIX}${commandName}`;
}
protected async listMarkdownFiles(uri: URI): Promise<string[]> {
const allFiles = await this.listFilesDirectly(uri);
return allFiles.filter(file => file.endsWith('.md'));
}
protected async listFilesDirectly(uri: URI): Promise<string[]> {
const result: string[] = [];
if (!await this.fileService.exists(uri)) {
return result;
}
const stat = await this.fileService.resolve(uri);
if (stat && stat.isDirectory && stat.children) {
for (const child of stat.children) {
result.push(child.resource.path.base);
}
}
return result;
}
}

View File

@@ -0,0 +1,30 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ToolCallChatResponseContentImpl } from '@theia/ai-chat/lib/common';
import { ToolCallResult } from '@theia/ai-core';
export class ClaudeCodeToolCallChatResponseContent extends ToolCallChatResponseContentImpl {
static readonly type = 'claude-code-tool-call';
constructor(id?: string, name?: string, arg_string?: string, finished?: boolean, result?: ToolCallResult) {
super(id, name, arg_string, finished, result);
}
static is(content: unknown): content is ClaudeCodeToolCallChatResponseContent {
return content instanceof ClaudeCodeToolCallChatResponseContent;
}
}

View File

@@ -0,0 +1,98 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ChatResponsePartRenderer } from '@theia/ai-chat-ui/lib/browser/chat-response-part-renderer';
import { ResponseNode } from '@theia/ai-chat-ui/lib/browser/chat-tree-view';
import { ChatResponseContent, ToolCallChatResponseContent } from '@theia/ai-chat/lib/common';
import { codicon } from '@theia/core/lib/browser';
import { injectable } from '@theia/core/shared/inversify';
import * as React from '@theia/core/shared/react';
import { ReactNode } from '@theia/core/shared/react';
import { ClaudeCodeToolCallChatResponseContent } from '../claude-code-tool-call-content';
import { CollapsibleToolRenderer } from './collapsible-tool-renderer';
import { nls } from '@theia/core';
interface BashToolInput {
command: string;
description?: string;
timeout?: number;
}
@injectable()
export class BashToolRenderer implements ChatResponsePartRenderer<ToolCallChatResponseContent> {
canHandle(response: ChatResponseContent): number {
if (ClaudeCodeToolCallChatResponseContent.is(response) && response.name === 'Bash') {
return 15; // Higher than default ToolCallPartRenderer (10)
}
return -1;
}
render(response: ToolCallChatResponseContent, parentNode: ResponseNode): ReactNode {
try {
const input = JSON.parse(response.arguments || '{}') as BashToolInput;
return <BashToolComponent input={input} />;
} catch (error) {
console.warn('Failed to parse Bash tool input:', error);
return <div className="claude-code-tool error">{nls.localize('theia/ai/claude-code/failedToParseBashToolData', 'Failed to parse Bash tool data')}</div>;
}
}
}
const BashToolComponent: React.FC<{
input: BashToolInput;
}> = ({ input }) => {
const compactHeader = (
<>
<div className="claude-code-tool header-left">
<span className="claude-code-tool title">{nls.localizeByDefault('Terminal')}</span>
<span className={`${codicon('terminal')} claude-code-tool icon`} />
<span className="claude-code-tool command">{input.command}</span>
</div>
<div className="claude-code-tool header-right">
{input.timeout && (
<span className="claude-code-tool badge">{nls.localize('theia/ai/claude-code/timeoutInMs', 'Timeout: {0}ms', input.timeout)}</span>
)}
</div>
</>
);
const expandedContent = input.description ? (
<div className="claude-code-tool details">
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localizeByDefault('Command')}</span>
<code className="claude-code-tool detail-value">{input.command}</code>
</div>
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localizeByDefault('Description')}</span>
<span className="claude-code-tool detail-value">{input.description}</span>
</div>
{input.timeout && (
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localize('theia/ai/claude-code/timeout', 'Timeout')}</span>
<span className="claude-code-tool detail-value">{nls.localizeByDefault('{0}ms', input.timeout)}</span>
</div>
)}
</div>
) : undefined;
return (
<CollapsibleToolRenderer
compactHeader={compactHeader}
expandedContent={expandedContent}
/>
);
};

View File

@@ -0,0 +1,78 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { codicon } from '@theia/core/lib/browser';
import * as React from '@theia/core/shared/react';
import { ReactNode } from '@theia/core/shared/react';
interface CollapsibleToolRendererProps {
compactHeader: ReactNode;
expandedContent?: ReactNode;
onHeaderClick?: () => void;
headerStyle?: React.CSSProperties;
defaultExpanded?: boolean;
}
export const CollapsibleToolRenderer: React.FC<CollapsibleToolRendererProps> = ({
compactHeader,
expandedContent,
onHeaderClick,
headerStyle,
defaultExpanded = false
}) => {
const [isExpanded, setIsExpanded] = React.useState(defaultExpanded);
const hasExpandableContent = expandedContent !== undefined;
const handleHeaderClick = (event: React.MouseEvent) => {
// Check if the clicked element or any of its parents has the 'clickable' class
// If so, don't trigger collapse/expand behavior
const target = event.target as HTMLElement;
if (target.closest('.clickable-element')) {
onHeaderClick?.();
return;
}
// Normal header click behavior
if (hasExpandableContent) {
setIsExpanded(!isExpanded);
}
onHeaderClick?.();
};
return (
<div className="claude-code-tool container">
<div
className={`claude-code-tool header${hasExpandableContent ? ' expandable' : ''}`}
onClick={handleHeaderClick}
style={{
cursor: hasExpandableContent || onHeaderClick ? 'pointer' : 'default',
...headerStyle
}}
>
{hasExpandableContent && (
<span className={`${codicon(isExpanded ? 'chevron-down' : 'chevron-right')} claude-code-tool expand-icon`} />
)}
{compactHeader}
</div>
{hasExpandableContent && isExpanded && (
<div className="claude-code-tool expanded-content">
{expandedContent}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,181 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ChatResponsePartRenderer } from '@theia/ai-chat-ui/lib/browser/chat-response-part-renderer';
import { ResponseNode } from '@theia/ai-chat-ui/lib/browser/chat-tree-view';
import { ChatResponseContent, ToolCallChatResponseContent } from '@theia/ai-chat/lib/common';
import { LabelProvider } from '@theia/core/lib/browser';
import { URI } from '@theia/core/lib/common/uri';
import { inject, injectable } from '@theia/core/shared/inversify';
import * as React from '@theia/core/shared/react';
import { ReactNode } from '@theia/core/shared/react';
import { EditorManager } from '@theia/editor/lib/browser';
import { WorkspaceService } from '@theia/workspace/lib/browser';
import { ClaudeCodeToolCallChatResponseContent } from '../claude-code-tool-call-content';
import { CollapsibleToolRenderer } from './collapsible-tool-renderer';
import { nls } from '@theia/core';
interface EditToolInput {
file_path: string;
old_string: string;
new_string: string;
replace_all?: boolean;
}
@injectable()
export class EditToolRenderer implements ChatResponsePartRenderer<ToolCallChatResponseContent> {
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(LabelProvider)
protected readonly labelProvider: LabelProvider;
@inject(EditorManager)
protected readonly editorManager: EditorManager;
canHandle(response: ChatResponseContent): number {
if (ClaudeCodeToolCallChatResponseContent.is(response) && response.name === 'Edit') {
return 15; // Higher than default ToolCallPartRenderer (10)
}
return -1;
}
render(response: ToolCallChatResponseContent, parentNode: ResponseNode): ReactNode {
try {
const input = JSON.parse(response.arguments || '{}') as EditToolInput;
return <EditToolComponent
input={input}
workspaceService={this.workspaceService}
labelProvider={this.labelProvider}
editorManager={this.editorManager}
/>;
} catch (error) {
console.warn('Failed to parse Edit tool input:', error);
return <div className="claude-code-tool error">{nls.localize('theia/ai/claude-code/failedToParseEditToolData', 'Failed to parse Edit tool data')}</div>;
}
}
}
const EditToolComponent: React.FC<{
input: EditToolInput;
workspaceService: WorkspaceService;
labelProvider: LabelProvider;
editorManager: EditorManager;
}> = ({ input, workspaceService, labelProvider, editorManager }) => {
const getFileName = (filePath: string): string => filePath.split('/').pop() || filePath;
const getWorkspaceRelativePath = async (filePath: string): Promise<string> => {
try {
const absoluteUri = new URI(filePath).parent;
const workspaceRelativePath = await workspaceService.getWorkspaceRelativePath(absoluteUri);
return workspaceRelativePath || '';
} catch {
return '';
}
};
const getIcon = (filePath: string): string => {
try {
const uri = new URI(filePath);
return labelProvider.getIcon(uri) || 'codicon-file';
} catch {
return 'codicon-file';
}
};
const handleOpenFile = async () => {
try {
const uri = new URI(input.file_path);
await editorManager.open(uri);
} catch (error) {
console.error('Failed to open file:', error);
}
};
const [relativePath, setRelativePath] = React.useState<string>('');
React.useEffect(() => {
getWorkspaceRelativePath(input.file_path).then(setRelativePath);
}, [input.file_path]);
const getChangeInfo = () => {
const oldLines = input.old_string.split('\n').length;
const newLines = input.new_string.split('\n').length;
return { oldLines, newLines };
};
const compactHeader = (
<>
<div className="claude-code-tool header-left">
<span className="claude-code-tool title">{nls.localize('theia/ai/claude-code/editing', 'Editing')}</span>
<span className={`${getIcon(input.file_path)} claude-code-tool icon`} />
<span
className="claude-code-tool file-name clickable-element"
onClick={handleOpenFile}
title={nls.localize('theia/ai/claude-code/openFileTooltip', 'Click to open file in editor')}
>
{getFileName(input.file_path)}
</span>
{relativePath && <span className="claude-code-tool relative-path" title={relativePath}>{relativePath}</span>}
</div>
<div className="claude-code-tool header-right">
<span className="claude-code-tool badge deleted">-{getChangeInfo().oldLines}</span>
<span className="claude-code-tool badge added">+{getChangeInfo().newLines}</span>
{input.replace_all && (
<span className="claude-code-tool badge">{nls.localizeByDefault('Replace All')}</span>
)}
</div>
</>
);
const expandedContent = (
<div className="claude-code-tool details">
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localize('theia/ai/claude-code/filePath', 'File Path')}</span>
<code className="claude-code-tool detail-value">{input.file_path}</code>
</div>
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localize('theia/ai/claude-code/from', 'From')}</span>
<pre className="claude-code-tool detail-value code-preview">
{input.old_string.length > 200
? input.old_string.substring(0, 200) + '...'
: input.old_string}
</pre>
</div>
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localize('theia/ai/claude-code/to', 'To')}</span>
<pre className="claude-code-tool detail-value code-preview">
{input.new_string.length > 200
? input.new_string.substring(0, 200) + '...'
: input.new_string}
</pre>
</div>
{input.replace_all && (
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localizeByDefault('Mode')}</span>
<span className="claude-code-tool detail-value">{nls.localize('theia/ai/claude-code/replaceAllOccurrences', 'Replace all occurrences')}</span>
</div>
)}
</div>
);
return (
<CollapsibleToolRenderer
compactHeader={compactHeader}
expandedContent={expandedContent}
/>
);
};

View File

@@ -0,0 +1,138 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ChatResponsePartRenderer } from '@theia/ai-chat-ui/lib/browser/chat-response-part-renderer';
import { ResponseNode } from '@theia/ai-chat-ui/lib/browser/chat-tree-view';
import { ChatResponseContent, ToolCallChatResponseContent } from '@theia/ai-chat/lib/common';
import { codicon, LabelProvider } from '@theia/core/lib/browser';
import { URI } from '@theia/core/lib/common/uri';
import { inject, injectable } from '@theia/core/shared/inversify';
import * as React from '@theia/core/shared/react';
import { ReactNode } from '@theia/core/shared/react';
import { WorkspaceService } from '@theia/workspace/lib/browser';
import { ClaudeCodeToolCallChatResponseContent } from '../claude-code-tool-call-content';
import { CollapsibleToolRenderer } from './collapsible-tool-renderer';
import { nls } from '@theia/core';
interface GlobToolInput {
pattern: string;
path?: string;
}
@injectable()
export class GlobToolRenderer implements ChatResponsePartRenderer<ToolCallChatResponseContent> {
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(LabelProvider)
protected readonly labelProvider: LabelProvider;
canHandle(response: ChatResponseContent): number {
if (ClaudeCodeToolCallChatResponseContent.is(response) && response.name === 'Glob') {
return 15; // Higher than default ToolCallPartRenderer (10)
}
return -1;
}
render(response: ToolCallChatResponseContent, parentNode: ResponseNode): ReactNode {
try {
const input = JSON.parse(response.arguments || '{}') as GlobToolInput;
return <GlobToolComponent
input={input}
workspaceService={this.workspaceService}
labelProvider={this.labelProvider}
/>;
} catch (error) {
console.warn('Failed to parse Glob tool input:', error);
return <div className="claude-code-tool error">{nls.localize('theia/ai/claude-code/failedToParseGlobToolData', 'Failed to parse Glob tool data')}</div>;
}
}
}
const GlobToolComponent: React.FC<{
input: GlobToolInput;
workspaceService: WorkspaceService;
labelProvider: LabelProvider;
}> = ({ input, workspaceService, labelProvider }) => {
const getSearchScope = (): string => {
if (input.path) {
return input.path.split('/').pop() || input.path;
}
return nls.localize('theia/ai/claude-code/project', 'project');
};
const getWorkspaceRelativePath = async (filePath: string): Promise<string> => {
try {
const absoluteUri = new URI(filePath);
const workspaceRelativePath = await workspaceService.getWorkspaceRelativePath(absoluteUri);
return workspaceRelativePath || '';
} catch {
return '';
}
};
const [relativePath, setRelativePath] = React.useState<string>('');
React.useEffect(() => {
if (input.path) {
getWorkspaceRelativePath(input.path).then(setRelativePath);
}
}, [input.path]);
const compactHeader = (
<>
<div className="claude-code-tool header-left">
<span className="claude-code-tool title">{nls.localize('theia/ai/claude-code/finding', 'Finding')}</span>
<span className={`${codicon('files')} claude-code-tool icon`} />
<span className="claude-code-tool glob-pattern">{input.pattern}</span>
<span className="claude-code-tool scope">{nls.localizeByDefault('in {0}', getSearchScope())}</span>
{relativePath && <span className="claude-code-tool relative-path">{relativePath}</span>}
</div>
<div className="claude-code-tool header-right">
<span className="claude-code-tool badge">{nls.localize('theia/ai/claude-code/globPattern', 'glob pattern')}</span>
</div>
</>
);
const expandedContent = (
<div className="claude-code-tool details">
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localize('theia/ai/claude-code/pattern', 'Pattern')}</span>
<code className="claude-code-tool detail-value">{input.pattern}</code>
</div>
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localize('theia/ai/claude-code/searchPath', 'Search Path')}</span>
<code className="claude-code-tool detail-value">{input.path || nls.localize('theia/ai/claude-code/currentDirectory', 'current directory')}</code>
</div>
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localizeByDefault('Description')}</span>
<span className="claude-code-tool detail-value">
{input.path
? nls.localize('theia/ai/claude-code/findMatchingFilesWithPath', 'Find files matching the glob pattern "{0}" within {1}', input.pattern, input.path)
: nls.localize('theia/ai/claude-code/findMatchingFiles', 'Find files matching the glob pattern "{0}" in the current directory', input.pattern)}
</span>
</div>
</div>
);
return (
<CollapsibleToolRenderer
compactHeader={compactHeader}
expandedContent={expandedContent}
/>
);
};

View File

@@ -0,0 +1,199 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ChatResponsePartRenderer } from '@theia/ai-chat-ui/lib/browser/chat-response-part-renderer';
import { ResponseNode } from '@theia/ai-chat-ui/lib/browser/chat-tree-view';
import { ChatResponseContent, ToolCallChatResponseContent } from '@theia/ai-chat/lib/common';
import { codicon, LabelProvider } from '@theia/core/lib/browser';
import { URI } from '@theia/core/lib/common/uri';
import { inject, injectable } from '@theia/core/shared/inversify';
import * as React from '@theia/core/shared/react';
import { ReactNode } from '@theia/core/shared/react';
import { WorkspaceService } from '@theia/workspace/lib/browser';
import { ClaudeCodeToolCallChatResponseContent } from '../claude-code-tool-call-content';
import { CollapsibleToolRenderer } from './collapsible-tool-renderer';
import { nls } from '@theia/core';
interface GrepToolInput {
pattern: string;
path?: string;
output_mode?: keyof typeof GREP_OUTPUT_MODES;
glob?: string;
type?: string;
'-i'?: boolean;
'-n'?: boolean;
'-A'?: number;
'-B'?: number;
'-C'?: number;
multiline?: boolean;
head_limit?: number;
}
const GREP_OUTPUT_MODES = {
'content': nls.localize('theia/ai/claude-code/grepOutputModes/content', 'content'),
'files_with_matches': nls.localize('theia/ai/claude-code/grepOutputModes/filesWithMatches', 'files with matches'),
'count': nls.localize('theia/ai/claude-code/grepOutputModes/count', 'count')
};
@injectable()
export class GrepToolRenderer implements ChatResponsePartRenderer<ToolCallChatResponseContent> {
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(LabelProvider)
protected readonly labelProvider: LabelProvider;
canHandle(response: ChatResponseContent): number {
if (ClaudeCodeToolCallChatResponseContent.is(response) && response.name === 'Grep') {
return 15; // Higher than default ToolCallPartRenderer (10)
}
return -1;
}
render(response: ToolCallChatResponseContent, parentNode: ResponseNode): ReactNode {
try {
const input = JSON.parse(response.arguments || '{}') as GrepToolInput;
return <GrepToolComponent
input={input}
workspaceService={this.workspaceService}
labelProvider={this.labelProvider}
/>;
} catch (error) {
console.warn('Failed to parse Grep tool input:', error);
return <div className="claude-code-tool error">{nls.localize('theia/ai/claude-code/failedToParseGrepToolData', 'Failed to parse Grep tool data')}</div>;
}
}
}
const GrepToolComponent: React.FC<{
input: GrepToolInput;
workspaceService: WorkspaceService;
labelProvider: LabelProvider;
}> = ({ input, workspaceService, labelProvider }) => {
const getSearchScope = (): string => {
if (input.path) {
return input.path.split('/').pop() || input.path;
}
return nls.localize('theia/ai/claude-code/project', 'project');
};
const getWorkspaceRelativePath = async (filePath: string): Promise<string> => {
try {
const absoluteUri = new URI(filePath);
const workspaceRelativePath = await workspaceService.getWorkspaceRelativePath(absoluteUri);
return workspaceRelativePath || '';
} catch {
return '';
}
};
const [relativePath, setRelativePath] = React.useState<string>('');
React.useEffect(() => {
if (input.path) {
getWorkspaceRelativePath(input.path).then(setRelativePath);
}
}, [input.path]);
const getOptionsInfo = (): { label: string; count: number } => {
const options = [];
if (input['-i']) { options.push(nls.localize('theia/ai/claude-code/grepOptions/caseInsensitive', 'case-insensitive')); }
if (input['-n']) { options.push(nls.localize('theia/ai/claude-code/grepOptions/lineNumbers', 'line numbers')); }
if (input['-A']) { options.push(nls.localize('theia/ai/claude-code/grepOptions/linesAfter', '+{0} after'), input['-A']); }
if (input['-B']) { options.push(nls.localize('theia/ai/claude-code/grepOptions/linesBefore', '+{0} before', input['-B'])); }
if (input['-C']) { options.push(nls.localize('theia/ai/claude-code/grepOptions/linesContext', '±{0} context', input['-C'])); }
if (input.multiline) { options.push(nls.localize('theia/ai/claude-code/grepOptions/multiLine', 'multiline')); }
if (input.glob) { options.push(nls.localize('theia/ai/claude-code/grepOptions/glob', 'glob: {0}', input.glob)); }
if (input.type) { options.push(nls.localize('theia/ai/claude-code/grepOptions/type', 'type: {0}', input.type)); }
if (input.head_limit) { options.push(nls.localize('theia/ai/claude-code/grepOptions/headLimit', 'limit: {0}', input.head_limit)); }
return {
label: options.length > 0 ? options.join(', ') : '',
count: options.length
};
};
const optionsInfo = getOptionsInfo();
const compactHeader = (
<>
<div className="claude-code-tool header-left">
<span className="claude-code-tool title">{nls.localize('theia/ai/claude-code/searching', 'Searching')}</span>
<span className={`${codicon('search')} claude-code-tool icon`} />
<span className="claude-code-tool pattern">"{input.pattern}"</span>
<span className="claude-code-tool scope">{nls.localizeByDefault('in {0}', getSearchScope())}</span>
{relativePath && <span className="claude-code-tool relative-path">{relativePath}</span>}
</div>
<div className="claude-code-tool header-right">
{input.output_mode && input.output_mode !== 'files_with_matches' && (
<span className="claude-code-tool badge">{GREP_OUTPUT_MODES[input.output_mode]}</span>
)}
{optionsInfo.count > 0 && (
<span className="claude-code-tool badge" title={optionsInfo.label}>
{optionsInfo.count > 1
? nls.localize('theia/ai/claude-code/optionsCount', '{0} options', optionsInfo.count)
: nls.localize('theia/ai/claude-code/oneOption', '1 option')}
</span>
)}
</div>
</>
);
const expandedContent = (
<div className="claude-code-tool details">
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localize('theia/ai/claude-code/pattern', 'Pattern')}</span>
<code className="claude-code-tool detail-value">"{input.pattern}"</code>
</div>
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localize('theia/ai/claude-code/searchPath', 'Search Path')}</span>
<code className="claude-code-tool detail-value">{input.path || nls.localize('theia/ai/claude-code/projectRoot', 'project root')}</code>
</div>
{input.output_mode && (
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localizeByDefault('Mode')}</span>
<span className="claude-code-tool detail-value">{GREP_OUTPUT_MODES[input.output_mode]}</span>
</div>
)}
{input.glob && (
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localize('theia/ai/claude-code/fileFilter', 'File Filter')}</span>
<code className="claude-code-tool detail-value">{input.glob}</code>
</div>
)}
{input.type && (
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localize('theia/ai/claude-code/fileType', 'File Type')}</span>
<span className="claude-code-tool detail-value">{input.type}</span>
</div>
)}
{optionsInfo.label && (
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localizeByDefault('Options')}</span>
<span className="claude-code-tool detail-value">{optionsInfo.label}</span>
</div>
)}
</div>
);
return (
<CollapsibleToolRenderer
compactHeader={compactHeader}
expandedContent={expandedContent}
/>
);
};

View File

@@ -0,0 +1,163 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ChatResponsePartRenderer } from '@theia/ai-chat-ui/lib/browser/chat-response-part-renderer';
import { ResponseNode } from '@theia/ai-chat-ui/lib/browser/chat-tree-view';
import { ChatResponseContent, ToolCallChatResponseContent } from '@theia/ai-chat/lib/common';
import { codicon, LabelProvider } from '@theia/core/lib/browser';
import { URI } from '@theia/core/lib/common/uri';
import { inject, injectable } from '@theia/core/shared/inversify';
import * as React from '@theia/core/shared/react';
import { ReactNode } from '@theia/core/shared/react';
import { EditorManager } from '@theia/editor/lib/browser';
import { WorkspaceService } from '@theia/workspace/lib/browser';
import { ClaudeCodeToolCallChatResponseContent } from '../claude-code-tool-call-content';
import { CollapsibleToolRenderer } from './collapsible-tool-renderer';
import { nls } from '@theia/core';
interface LSToolInput {
path: string;
ignore?: string[];
}
@injectable()
export class LSToolRenderer implements ChatResponsePartRenderer<ToolCallChatResponseContent> {
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(LabelProvider)
protected readonly labelProvider: LabelProvider;
@inject(EditorManager)
protected readonly editorManager: EditorManager;
canHandle(response: ChatResponseContent): number {
if (ClaudeCodeToolCallChatResponseContent.is(response) && response.name === 'LS') {
return 15; // Higher than default ToolCallPartRenderer (10)
}
return -1;
}
render(response: ToolCallChatResponseContent, parentNode: ResponseNode): ReactNode {
try {
const input = JSON.parse(response.arguments || '{}') as LSToolInput;
return <LSToolComponent
input={input}
workspaceService={this.workspaceService}
labelProvider={this.labelProvider}
editorManager={this.editorManager}
/>;
} catch (error) {
console.warn('Failed to parse LS tool input:', error);
return <div className="claude-code-tool error">{nls.localize('theia/ai/claude-code/failedToParseLSToolData', 'Failed to parse LS tool data')}</div>;
}
}
}
const LSToolComponent: React.FC<{
input: LSToolInput;
workspaceService: WorkspaceService;
labelProvider: LabelProvider;
editorManager: EditorManager;
}> = ({ input, workspaceService, labelProvider, editorManager }) => {
const getDirectoryName = (dirPath: string): string => dirPath.split('/').pop() || dirPath;
const getWorkspaceRelativePath = async (dirPath: string): Promise<string> => {
try {
const absoluteUri = new URI(dirPath);
const workspaceRelativePath = await workspaceService.getWorkspaceRelativePath(absoluteUri);
return workspaceRelativePath || '';
} catch {
return '';
}
};
const handleOpenDirectory = async () => {
try {
const uri = new URI(input.path);
// Note: This might need to be adjusted based on how directories are opened in Theia
await editorManager.open(uri);
} catch (error) {
console.error('Failed to open directory:', error);
}
};
const [relativePath, setRelativePath] = React.useState<string>('');
React.useEffect(() => {
getWorkspaceRelativePath(input.path).then(setRelativePath);
}, [input.path]);
const compactHeader = (
<>
<div className="claude-code-tool header-left">
<span className="claude-code-tool title">{nls.localize('theia/ai/claude-code/listing', 'Listing')}</span>
<span className={`${codicon('checklist')} claude-code-tool icon`} />
<span
className="claude-code-tool file-name clickable-element"
onClick={handleOpenDirectory}
title={nls.localize('theia/ai/claude-code/openDirectoryTooltip', 'Click to open directory')}
>
{getDirectoryName(input.path)}
</span>
{relativePath && <span className="claude-code-tool relative-path">{relativePath}</span>}
</div>
<div className="claude-code-tool header-right">
{input.ignore && input.ignore.length > 0 && (
<span className="claude-code-tool badge">{nls.localize('theia/ai/claude-code/ignoringPatterns', 'Ignoring {0} patterns', input.ignore.length)}</span>
)}
</div>
</>
);
const expandedContent = (
<div className="claude-code-tool details">
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localize('theia/ai/claude-code/directory', 'Directory')}</span>
<code className="claude-code-tool detail-value">{input.path}</code>
</div>
{input.ignore && input.ignore.length > 0 && (
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localize('theia/ai/claude-code/ignoredPatterns', 'Ignored Patterns')}</span>
<div className="claude-code-tool detail-value">
{input.ignore.map((pattern, index) => (
<code key={index} className="claude-code-tool ignore-pattern">
{pattern}{index < input.ignore!.length - 1 ? ', ' : ''}
</code>
))}
</div>
</div>
)}
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localizeByDefault('Description')}</span>
<span className="claude-code-tool detail-value">
{nls.localize('theia/ai/claude-code/listDirectoryContents', 'List directory contents')}{input.ignore && input.ignore.length > 0
? (input.ignore.length > 1
? nls.localize('theia/ai/claude-code/excludingPatterns', ' (excluding {0} patterns)', input.ignore.length)
: nls.localize('theia/ai/claude-code/excludingOnePattern', ' (exluding 1 pattern)'))
: ''}
</span>
</div>
</div>
);
return (
<CollapsibleToolRenderer
compactHeader={compactHeader}
expandedContent={expandedContent}
/>
);
};

View File

@@ -0,0 +1,207 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ChatResponsePartRenderer } from '@theia/ai-chat-ui/lib/browser/chat-response-part-renderer';
import { ResponseNode } from '@theia/ai-chat-ui/lib/browser/chat-tree-view';
import { ChatResponseContent, ToolCallChatResponseContent } from '@theia/ai-chat/lib/common';
import { LabelProvider } from '@theia/core/lib/browser';
import { URI } from '@theia/core/lib/common/uri';
import { inject, injectable } from '@theia/core/shared/inversify';
import * as React from '@theia/core/shared/react';
import { ReactNode } from '@theia/core/shared/react';
import { EditorManager } from '@theia/editor/lib/browser';
import { WorkspaceService } from '@theia/workspace/lib/browser';
import { ClaudeCodeToolCallChatResponseContent } from '../claude-code-tool-call-content';
import { CollapsibleToolRenderer } from './collapsible-tool-renderer';
import { nls } from '@theia/core';
interface EditOperation {
old_string: string;
new_string: string;
replace_all?: boolean;
}
interface MultiEditToolInput {
file_path: string;
edits: EditOperation[];
}
@injectable()
export class MultiEditToolRenderer implements ChatResponsePartRenderer<ToolCallChatResponseContent> {
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(LabelProvider)
protected readonly labelProvider: LabelProvider;
@inject(EditorManager)
protected readonly editorManager: EditorManager;
canHandle(response: ChatResponseContent): number {
if (ClaudeCodeToolCallChatResponseContent.is(response) && response.name === 'MultiEdit') {
return 15; // Higher than default ToolCallPartRenderer (10)
}
return -1;
}
render(response: ToolCallChatResponseContent, parentNode: ResponseNode): ReactNode {
try {
const input = JSON.parse(response.arguments || '{}') as MultiEditToolInput;
return <MultiEditToolComponent
input={input}
workspaceService={this.workspaceService}
labelProvider={this.labelProvider}
editorManager={this.editorManager}
/>;
} catch (error) {
console.warn('Failed to parse MultiEdit tool input:', error);
return <div className="claude-code-tool error">{nls.localize('theia/ai/claude-code/failedToParseMultiEditToolData', 'Failed to parse MultiEdit tool data')}</div>;
}
}
}
const MultiEditToolComponent: React.FC<{
input: MultiEditToolInput;
workspaceService: WorkspaceService;
labelProvider: LabelProvider;
editorManager: EditorManager;
}> = ({ input, workspaceService, labelProvider, editorManager }) => {
const getFileName = (filePath: string): string => filePath.split('/').pop() || filePath;
const getWorkspaceRelativePath = async (filePath: string): Promise<string> => {
try {
const absoluteUri = new URI(filePath).parent;
const workspaceRelativePath = await workspaceService.getWorkspaceRelativePath(absoluteUri);
return workspaceRelativePath || '';
} catch {
return '';
}
};
const getIcon = (filePath: string): string => {
try {
const uri = new URI(filePath);
return labelProvider.getIcon(uri) || 'codicon-file';
} catch {
return 'codicon-file';
}
};
const handleOpenFile = async () => {
try {
const uri = new URI(input.file_path);
await editorManager.open(uri);
} catch (error) {
console.error('Failed to open file:', error);
}
};
const [relativePath, setRelativePath] = React.useState<string>('');
React.useEffect(() => {
getWorkspaceRelativePath(input.file_path).then(setRelativePath);
}, [input.file_path]);
const getChangeInfo = () => {
let totalOldLines = 0;
let totalNewLines = 0;
input.edits.forEach(edit => {
totalOldLines += edit.old_string.split('\n').length;
totalNewLines += edit.new_string.split('\n').length;
});
return { totalOldLines, totalNewLines };
};
const replaceAllCount = input.edits.filter(edit => edit.replace_all).length;
const totalEdits = input.edits.length;
const compactHeader = (
<>
<div className="claude-code-tool header-left">
<span className="claude-code-tool title">{nls.localize('theia/ai/claude-code/multiEditing', 'Multi-editing')}</span>
<span className={`${getIcon(input.file_path)} claude-code-tool icon`} />
<span
className="claude-code-tool file-name clickable-element"
onClick={handleOpenFile}
title={nls.localize('theia/ai/claude-code/openFileTooltip', 'Click to open file in editor')}
>
{getFileName(input.file_path)}
</span>
{relativePath && <span className="claude-code-tool relative-path" title={relativePath}>{relativePath}</span>}
</div>
<div className="claude-code-tool header-right">
<span className="claude-code-tool badge deleted">-{getChangeInfo().totalOldLines}</span>
<span className="claude-code-tool badge added">+{getChangeInfo().totalNewLines}</span>
<span className="claude-code-tool badge">{totalEdits === 1
? nls.localize('theia/ai/claude-code/oneEdit', '1 edit')
: nls.localize('theia/ai/claude-code/editsCount', '{0} edits', totalEdits)}</span>
{replaceAllCount > 0 && (
<span className="claude-code-tool badge">{nls.localize('theia/ai/claude-code/replaceAllCount', '{0} replace-all', replaceAllCount)}</span>
)}
</div>
</>
);
const expandedContent = (
<div className="claude-code-tool details">
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localize('theia/ai/claude-code/filePath', 'File Path')}</span>
<code className="claude-code-tool detail-value">{input.file_path}</code>
</div>
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localize('theia/ai/claude-code/totalEdits', 'Total Edits')}</span>
<span className="claude-code-tool detail-value">{totalEdits}</span>
</div>
{input.edits.map((edit, index) => (
<div key={index} className="claude-code-tool edit-preview">
<div className="claude-code-tool edit-preview-header">
<span className="claude-code-tool edit-preview-title">{nls.localize('theia/ai/claude-code/editNumber', 'Edit {0}', index + 1)}</span>
{edit.replace_all && (
<span className="claude-code-tool edit-preview-badge">
{nls.localizeByDefault('Replace All')}
</span>
)}
</div>
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localize('theia/ai/claude-code/from', 'From')}</span>
<pre className="claude-code-tool detail-value code-preview">
{edit.old_string.length > 100
? edit.old_string.substring(0, 100) + '...'
: edit.old_string}
</pre>
</div>
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localize('theia/ai/claude-code/to', 'To')}</span>
<pre className="claude-code-tool detail-value code-preview">
{edit.new_string.length > 100
? edit.new_string.substring(0, 100) + '...'
: edit.new_string}
</pre>
</div>
</div>
))}
</div>
);
return (
<CollapsibleToolRenderer
compactHeader={compactHeader}
expandedContent={expandedContent}
/>
);
};

View File

@@ -0,0 +1,173 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ChatResponsePartRenderer } from '@theia/ai-chat-ui/lib/browser/chat-response-part-renderer';
import { ResponseNode } from '@theia/ai-chat-ui/lib/browser/chat-tree-view';
import { ChatResponseContent, ToolCallChatResponseContent } from '@theia/ai-chat/lib/common';
import { LabelProvider } from '@theia/core/lib/browser';
import { URI } from '@theia/core/lib/common/uri';
import { inject, injectable } from '@theia/core/shared/inversify';
import * as React from '@theia/core/shared/react';
import { ReactNode } from '@theia/core/shared/react';
import { EditorManager } from '@theia/editor/lib/browser';
import { WorkspaceService } from '@theia/workspace/lib/browser';
import { ClaudeCodeToolCallChatResponseContent } from '../claude-code-tool-call-content';
import { CollapsibleToolRenderer } from './collapsible-tool-renderer';
import { nls } from '@theia/core';
interface ReadToolInput {
file_path: string;
limit?: number;
offset?: number;
}
@injectable()
export class ReadToolRenderer implements ChatResponsePartRenderer<ToolCallChatResponseContent> {
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(LabelProvider)
protected readonly labelProvider: LabelProvider;
@inject(EditorManager)
protected readonly editorManager: EditorManager;
canHandle(response: ChatResponseContent): number {
if (ClaudeCodeToolCallChatResponseContent.is(response) && response.name === 'Read') {
return 15; // Higher than default ToolCallPartRenderer (10)
}
return -1;
}
render(response: ToolCallChatResponseContent, parentNode: ResponseNode): ReactNode {
try {
const input = JSON.parse(response.arguments || '{}') as ReadToolInput;
return <ReadToolComponent
input={input}
workspaceService={this.workspaceService}
labelProvider={this.labelProvider}
editorManager={this.editorManager}
/>;
} catch (error) {
console.warn('Failed to parse Read tool input:', error);
return <div className="claude-code-tool error">{nls.localize('theia/ai/claude-code/failedToParseReadToolData', 'Failed to parse Read tool data')}</div>;
}
}
}
const ReadToolComponent: React.FC<{
input: ReadToolInput;
workspaceService: WorkspaceService;
labelProvider: LabelProvider;
editorManager: EditorManager;
}> = ({ input, workspaceService, labelProvider, editorManager }) => {
const getFileName = (filePath: string): string => filePath.split('/').pop() || filePath;
const getWorkspaceRelativePath = async (filePath: string): Promise<string> => {
try {
const absoluteUri = new URI(filePath).parent;
const workspaceRelativePath = await workspaceService.getWorkspaceRelativePath(absoluteUri);
return workspaceRelativePath || '';
} catch {
return '';
}
};
const getIcon = (filePath: string): string => {
try {
const uri = new URI(filePath);
return labelProvider.getIcon(uri) || 'codicon-file';
} catch {
return 'codicon-file';
}
};
const handleOpenFile = async () => {
try {
const uri = new URI(input.file_path);
await editorManager.open(uri);
} catch (error) {
console.error('Failed to open file:', error);
}
};
const [relativePath, setRelativePath] = React.useState<string>('');
React.useEffect(() => {
getWorkspaceRelativePath(input.file_path).then(setRelativePath);
}, [input.file_path]);
const isEntireFile = !input.limit && !input.offset;
const compactHeader = (
<>
<div className="claude-code-tool header-left">
<span className="claude-code-tool title">{nls.localize('theia/ai/claude-code/reading', 'Reading')}</span>
<span className={`${getIcon(input.file_path)} claude-code-tool icon`} />
<span
className="claude-code-tool file-name clickable-element"
onClick={handleOpenFile}
title={nls.localize('theia/ai/claude-code/openFileTooltip', 'Click to open file in editor')}
>
{getFileName(input.file_path)}
</span>
{relativePath && <span className="claude-code-tool relative-path">{relativePath}</span>}
</div>
<div className="claude-code-tool header-right">
{isEntireFile && (
<span className="claude-code-tool badge">{nls.localize('theia/ai/claude-code/entireFile', 'Entire File')}</span>
)}
{!isEntireFile && (
<span className="claude-code-tool badge">{nls.localize('theia/ai/claude-code/partial', 'Partial')}</span>
)}
</div>
</>
);
const expandedContent = (
<div className="claude-code-tool details">
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localize('theia/ai/claude-code/filePath', 'File Path')}</span>
<code className="claude-code-tool detail-value">{input.file_path}</code>
</div>
{input.offset && (
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localize('theia/ai/claude-code/startingLine', 'Starting Line')}</span>
<span className="claude-code-tool detail-value">{input.offset}</span>
</div>
)}
{input.limit && (
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localize('theia/ai/claude-code/lineLimit', 'Line Limit')}</span>
<span className="claude-code-tool detail-value">{input.limit}</span>
</div>
)}
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localize('theia/ai/claude-code/readMode', 'Read Mode')}</span>
<span className="claude-code-tool detail-value">{isEntireFile
? nls.localize('theia/ai/claude-code/entireFile', 'Entire File')
: nls.localize('theia/ai/claude-code/partial', 'Partial')}</span>
</div>
</div>
);
return (
<CollapsibleToolRenderer
compactHeader={compactHeader}
expandedContent={expandedContent}
/>
);
};

View File

@@ -0,0 +1,187 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ChatResponsePartRenderer } from '@theia/ai-chat-ui/lib/browser/chat-response-part-renderer';
import { ResponseNode } from '@theia/ai-chat-ui/lib/browser/chat-tree-view';
import { ChatResponseContent, ToolCallChatResponseContent } from '@theia/ai-chat/lib/common';
import { codicon } from '@theia/core/lib/browser';
import { injectable } from '@theia/core/shared/inversify';
import * as React from '@theia/core/shared/react';
import { ReactNode } from '@theia/core/shared/react';
import { ClaudeCodeToolCallChatResponseContent } from '../claude-code-tool-call-content';
import { CollapsibleToolRenderer } from './collapsible-tool-renderer';
import { nls } from '@theia/core';
interface TodoItem {
id: string;
content: string;
status: 'pending' | 'in_progress' | 'completed';
priority: keyof typeof TODO_PRIORITIES;
}
const TODO_PRIORITIES = {
'high': nls.localize('theia/ai/claude-code/todoPriority/high', 'high'),
'medium': nls.localize('theia/ai/claude-code/todoPriority/medium', 'medium'),
'low': nls.localize('theia/ai/claude-code/todoPriority/low', 'low')
};
interface TodoWriteInput {
todos: TodoItem[];
}
// Session-scoped registry to track TodoWrite renderer instances per session
class TodoWriteRegistry {
private static sessionInstances: Map<string, Set<() => void>> = new Map();
static register(sessionId: string, hideFn: () => void): void {
// Get or create instances set for this session
let sessionSet = this.sessionInstances.get(sessionId);
if (!sessionSet) {
sessionSet = new Set();
this.sessionInstances.set(sessionId, sessionSet);
}
// Hide all previous instances in this session
sessionSet.forEach(fn => fn());
// Clear the session registry
sessionSet.clear();
// Add the new instance
sessionSet.add(hideFn);
}
static unregister(sessionId: string, hideFn: () => void): void {
const sessionSet = this.sessionInstances.get(sessionId);
if (sessionSet) {
sessionSet.delete(hideFn);
// Clean up empty session entries
if (sessionSet.size === 0) {
this.sessionInstances.delete(sessionId);
}
}
}
}
@injectable()
export class TodoWriteRenderer implements ChatResponsePartRenderer<ToolCallChatResponseContent> {
canHandle(response: ChatResponseContent): number {
if (ClaudeCodeToolCallChatResponseContent.is(response) && response.name === 'TodoWrite') {
return 15; // Higher than default ToolCallPartRenderer (10)
}
return -1;
}
render(response: ToolCallChatResponseContent, parentNode: ResponseNode): ReactNode {
try {
const input = JSON.parse(response.arguments || '{}') as TodoWriteInput;
return <TodoListComponent todos={input.todos || []} sessionId={parentNode.sessionId} />;
} catch (error) {
console.warn('Failed to parse TodoWrite input:', error);
return <div className="claude-code-tool todo-list-error">{nls.localize('theia/ai/claude-code/failedToParseTodoListData', 'Failed to parse todo list data')}</div>;
}
}
}
const TodoListComponent: React.FC<{ todos: TodoItem[]; sessionId: string }> = ({ todos, sessionId }) => {
const [isHidden, setIsHidden] = React.useState(false);
React.useEffect(() => {
const hideFn = () => setIsHidden(true);
TodoWriteRegistry.register(sessionId, hideFn);
return () => {
TodoWriteRegistry.unregister(sessionId, hideFn);
};
}, [sessionId]);
if (isHidden) {
// eslint-disable-next-line no-null/no-null
return null;
}
const getStatusIcon = (status: TodoItem['status']) => {
switch (status) {
case 'completed':
return <span className={`${codicon('check')} claude-code-tool todo-status-icon completed`} />;
case 'in_progress':
return <span className={`${codicon('loading')} claude-code-tool todo-status-icon in-progress theia-animation-spin`} />;
case 'pending':
default:
return <span className={`${codicon('circle-outline')} claude-code-tool todo-status-icon pending`} />;
}
};
const getPriorityBadge = (priority: TodoItem['priority']) => (
<span className={`claude-code-tool todo-priority priority-${priority}`}>{TODO_PRIORITIES[priority]}</span>
);
if (!todos || todos.length === 0) {
return (
<div className="claude-code-tool todo-list-container">
<div className="claude-code-tool todo-list-header">
<span className={`${codicon('checklist')} claude-code-tool todo-list-icon`} />
<span className="claude-code-tool todo-list-title">{nls.localize('theia/ai/claude-code/todoList', 'Todo List')}</span>
</div>
<div className="claude-code-tool todo-list-empty">{nls.localize('theia/ai/claude-code/emptyTodoList', 'No todos available')}</div>
</div>
);
}
const completedCount = todos.filter(todo => todo.status === 'completed').length;
const totalCount = todos.length;
const compactHeader = (
<>
<div className="claude-code-tool header-left">
<span className="claude-code-tool title">{nls.localize('theia/ai/claude-code/todoList', 'Todo List')}</span>
<span className={`${codicon('checklist')} claude-code-tool icon`} />
<span className="claude-code-tool progress-text">{nls.localize('theia/ai/claude-code/completedCount', '{0}/{1} completed', completedCount, totalCount)}</span>
</div>
<div className="claude-code-tool header-right">
<span className="claude-code-tool badge">{totalCount === 1
? nls.localize('theia/ai/claude-code/oneItem', '1 item')
: nls.localize('theia/ai/claude-code/itemCount', '{0} items', totalCount)}</span>
</div>
</>
);
const expandedContent = (
<div className="claude-code-tool details">
<div className="claude-code-tool todo-list-items">
{todos.map(todo => (
<div key={todo.id || todo.content} className={`claude-code-tool todo-item status-${todo.status}`}>
<div className="claude-code-tool todo-item-main">
<div className="claude-code-tool todo-item-status">
{getStatusIcon(todo.status)}
</div>
<div className="claude-code-tool todo-item-content">
<span className="claude-code-tool todo-item-text">{todo.content}</span>
{getPriorityBadge(todo.priority)}
</div>
</div>
</div>
))}
</div>
</div>
);
return (
<CollapsibleToolRenderer
compactHeader={compactHeader}
expandedContent={expandedContent}
defaultExpanded={true}
/>
);
};

View File

@@ -0,0 +1,109 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ChatResponsePartRenderer } from '@theia/ai-chat-ui/lib/browser/chat-response-part-renderer';
import { ResponseNode } from '@theia/ai-chat-ui/lib/browser/chat-tree-view';
import { ChatResponseContent, ToolCallChatResponseContent } from '@theia/ai-chat/lib/common';
import { codicon } from '@theia/core/lib/browser';
import { injectable } from '@theia/core/shared/inversify';
import * as React from '@theia/core/shared/react';
import { ReactNode } from '@theia/core/shared/react';
import { ClaudeCodeToolCallChatResponseContent } from '../claude-code-tool-call-content';
import { CollapsibleToolRenderer } from './collapsible-tool-renderer';
import { nls } from '@theia/core';
interface WebFetchToolInput {
url: string;
prompt: string;
}
@injectable()
export class WebFetchToolRenderer implements ChatResponsePartRenderer<ToolCallChatResponseContent> {
canHandle(response: ChatResponseContent): number {
if (ClaudeCodeToolCallChatResponseContent.is(response) && response.name === 'WebFetch') {
return 15; // Higher than default ToolCallPartRenderer (10)
}
return -1;
}
render(response: ToolCallChatResponseContent, parentNode: ResponseNode): ReactNode {
try {
const input = JSON.parse(response.arguments || '{}') as WebFetchToolInput;
return <WebFetchToolComponent input={input} />;
} catch (error) {
console.warn('Failed to parse WebFetch tool input:', error);
return <div className="claude-code-tool error">{nls.localize('theia/ai/claude-code/failedToParseWebFetchToolData', 'Failed to parse WebFetch tool data')}</div>;
}
}
}
const WebFetchToolComponent: React.FC<{
input: WebFetchToolInput;
}> = ({ input }) => {
const getDomain = (url: string): string => {
try {
return new URL(url).hostname;
} catch {
return url;
}
};
const truncatePrompt = (prompt: string, maxLength: number = 100): string => {
if (prompt.length <= maxLength) { return prompt; }
return prompt.substring(0, maxLength) + '...';
};
const compactHeader = (
<>
<div className="claude-code-tool header-left">
<span className="claude-code-tool title">{nls.localize('theia/ai/claude-code/fetching', 'Fetching')}</span>
<span className={`${codicon('globe')} claude-code-tool icon`} />
<span className="claude-code-tool command">{getDomain(input.url)}</span>
<span className="claude-code-tool description" title={input.prompt}>
{truncatePrompt(input.prompt)}
</span>
</div>
<div className="claude-code-tool header-right">
<span className="claude-code-tool badge">{nls.localize('theia/ai/claude-code/webFetch', 'Web Fetch')}</span>
</div>
</>
);
const expandedContent = (
<div className="claude-code-tool details">
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localizeByDefault('URL')}</span>
<code className="claude-code-tool detail-value">{input.url}</code>
</div>
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localize('theia/ai/claude-code/domain', 'Domain')}</span>
<span className="claude-code-tool detail-value">{getDomain(input.url)}</span>
</div>
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localizeByDefault('Prompt')}</span>
<span className="claude-code-tool detail-value">{input.prompt}</span>
</div>
</div>
);
return (
<CollapsibleToolRenderer
compactHeader={compactHeader}
expandedContent={expandedContent}
/>
);
};

View File

@@ -0,0 +1,156 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ChatResponsePartRenderer } from '@theia/ai-chat-ui/lib/browser/chat-response-part-renderer';
import { ResponseNode } from '@theia/ai-chat-ui/lib/browser/chat-tree-view';
import { ChatResponseContent, ToolCallChatResponseContent } from '@theia/ai-chat/lib/common';
import { codicon, LabelProvider } from '@theia/core/lib/browser';
import { URI } from '@theia/core/lib/common/uri';
import { inject, injectable } from '@theia/core/shared/inversify';
import * as React from '@theia/core/shared/react';
import { ReactNode } from '@theia/core/shared/react';
import { EditorManager } from '@theia/editor/lib/browser';
import { WorkspaceService } from '@theia/workspace/lib/browser';
import { ClaudeCodeToolCallChatResponseContent } from '../claude-code-tool-call-content';
import { CollapsibleToolRenderer } from './collapsible-tool-renderer';
import { nls } from '@theia/core';
interface WriteToolInput {
file_path: string;
content: string;
}
@injectable()
export class WriteToolRenderer implements ChatResponsePartRenderer<ToolCallChatResponseContent> {
@inject(WorkspaceService)
protected readonly workspaceService: WorkspaceService;
@inject(LabelProvider)
protected readonly labelProvider: LabelProvider;
@inject(EditorManager)
protected readonly editorManager: EditorManager;
canHandle(response: ChatResponseContent): number {
if (ClaudeCodeToolCallChatResponseContent.is(response) && response.name === 'Write') {
return 15; // Higher than default ToolCallPartRenderer (10)
}
return -1;
}
render(response: ToolCallChatResponseContent, parentNode: ResponseNode): ReactNode {
try {
const input = JSON.parse(response.arguments || '{}') as WriteToolInput;
return <WriteToolComponent
input={input}
workspaceService={this.workspaceService}
labelProvider={this.labelProvider}
editorManager={this.editorManager}
/>;
} catch (error) {
console.warn('Failed to parse Write tool input:', error);
return <div className="claude-code-tool error">{nls.localize('theia/ai/claude-code/failedToParseWriteToolData', 'Failed to parse Write tool data')}</div>;
}
}
}
const WriteToolComponent: React.FC<{
input: WriteToolInput;
workspaceService: WorkspaceService;
labelProvider: LabelProvider;
editorManager: EditorManager;
}> = ({ input, workspaceService, labelProvider, editorManager }) => {
const getFileName = (filePath: string): string => filePath.split('/').pop() || filePath;
const getWorkspaceRelativePath = async (filePath: string): Promise<string> => {
try {
const absoluteUri = new URI(filePath);
const workspaceRelativePath = await workspaceService.getWorkspaceRelativePath(absoluteUri);
return workspaceRelativePath || '';
} catch {
return '';
}
};
const handleOpenFile = async () => {
try {
const uri = new URI(input.file_path);
await editorManager.open(uri);
} catch (error) {
console.error('Failed to open file:', error);
}
};
const [relativePath, setRelativePath] = React.useState<string>('');
React.useEffect(() => {
getWorkspaceRelativePath(input.file_path).then(setRelativePath);
}, [input.file_path]);
const getContentSizeInfo = (): string => {
const lines = input.content.split('\n').length;
return `+${lines}`;
};
const compactHeader = (
<>
<div className="claude-code-tool header-left">
<span className="claude-code-tool title">{nls.localize('theia/ai/claude-code/writing', 'Writing')}</span>
<span className={`${codicon('edit')} claude-code-tool icon`} />
<span
className="claude-code-tool file-name clickable-element"
onClick={handleOpenFile}
title={nls.localize('theia/ai/claude-code/openFileTooltip', 'Click to open file in editor')}
>
{getFileName(input.file_path)}
</span>
{relativePath && <span className="claude-code-tool relative-path">{relativePath}</span>}
</div>
<div className="claude-code-tool header-right">
<span className="claude-code-tool badge added">{getContentSizeInfo()}</span>
</div>
</>
);
const expandedContent = (
<div className="claude-code-tool details">
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localize('theia/ai/claude-code/filePath', 'File Path')}</span>
<code className="claude-code-tool detail-value">{input.file_path}</code>
</div>
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localizeByDefault('Preview')}</span>
<pre className="claude-code-tool detail-value code-preview">
{input.content.length > 500
? input.content.substring(0, 500) + '...'
: input.content}
</pre>
</div>
<div className="claude-code-tool detail-row">
<span className="claude-code-tool detail-label">{nls.localize('theia/ai/claude-code/lines', 'Lines')}</span>
<span className="claude-code-tool detail-value">{input.content.split('\n').length}</span>
</div>
</div>
);
return (
<CollapsibleToolRenderer
compactHeader={compactHeader}
expandedContent={expandedContent}
/>
);
};

View File

@@ -0,0 +1,487 @@
/* Base container and structure */
.claude-code-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-sideBar-background);
overflow: hidden;
}
.claude-code-tool.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px;
background-color: var(--theia-editorGroupHeader-tabsBackground);
white-space: nowrap;
overflow: hidden;
}
.claude-code-tool.header-left {
display: flex;
align-items: center;
gap: var(--theia-ui-padding);
min-width: 0;
flex: 1;
overflow: hidden;
}
.claude-code-tool.header-right {
flex-shrink: 0;
}
.claude-code-tool.icon {
color: var(--theia-charts-blue);
}
.claude-code-tool.icon.theia-file-icons-js::before {
margin-right: 0px;
}
.claude-code-tool.title {
font-weight: 600;
color: var(--theia-foreground);
font-size: var(--theia-ui-font-size1);
}
.claude-code-tool.file-name {
color: var(--theia-foreground);
font-size: var(--theia-ui-font-size1);
flex-shrink: 0;
}
.claude-code-tool.relative-path {
color: var(--theia-descriptionForeground);
font-size: var(--theia-ui-font-size0);
font-family: var(--theia-ui-font-family-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.claude-code-tool.badge {
font-size: var(--theia-ui-font-size0);
font-weight: 500;
padding: 2px 6px;
background-color: var(--theia-badge-background);
color: var(--theia-badge-foreground);
border-radius: calc(var(--theia-ui-padding) / 3);
font-family: var(--theia-ui-font-family-mono);
}
.claude-code-tool.badge.added {
background-color: var(--theia-editorOverviewRuler-addedForeground);
color: var(--theia-foreground);
}
.claude-code-tool.badge.deleted {
background-color: var(--theia-editorOverviewRuler-deletedForeground);
color: var(--theia-foreground);
}
.claude-code-tool.content {
padding: var(--theia-ui-padding);
display: flex;
flex-direction: column;
gap: calc(var(--theia-ui-padding) * 2 / 3);
}
.claude-code-tool.parameters {
display: flex;
flex-direction: column;
gap: calc(var(--theia-ui-padding) / 3);
padding: calc(var(--theia-ui-padding) * 2 / 3);
background-color: var(--theia-editorWidget-background);
border-radius: calc(var(--theia-ui-padding) / 2);
border: var(--theia-border-width) solid var(--theia-widget-border);
}
.claude-code-tool.parameter {
display: flex;
align-items: center;
gap: var(--theia-ui-padding);
}
.claude-code-tool.parameter-label {
color: var(--theia-descriptionForeground);
font-size: var(--theia-ui-font-size0);
min-width: 120px;
flex-shrink: 0;
}
.claude-code-tool.parameter-value {
color: var(--theia-foreground);
font-size: var(--theia-ui-font-size0);
font-weight: 500;
padding: 2px 6px;
background-color: var(--theia-badge-background);
color: var(--theia-badge-foreground);
border-radius: calc(var(--theia-ui-padding) / 3);
font-family: var(--theia-ui-font-family-mono);
}
.claude-code-tool.error {
padding: var(--theia-ui-padding);
color: var(--theia-errorForeground);
background-color: var(--theia-errorBackground);
border-radius: var(--theia-ui-padding);
margin: var(--theia-ui-padding) 0;
border: var(--theia-border-width) solid var(--theia-errorBorder);
}
/* Collapsible section styles */
.claude-code-tool.expand-icon {
margin-right: calc(var(--theia-ui-padding) / 2);
}
.claude-code-tool.expanded-content {
padding: var(--theia-ui-padding);
}
/* Clickable elements within headers */
.clickable-element {
cursor: pointer;
border-radius: calc(var(--theia-ui-padding) / 4);
transition: background-color 0.15s ease;
}
.clickable-element:hover {
background-color: var(--theia-toolbar-hoverBackground);
text-decoration: underline;
}
/* Hover effects */
.claude-code-tool.container:hover {
background-color: var(--theia-list-hoverBackground);
}
/* Tool-specific styles */
/* Bash Tool */
.claude-code-tool.command {
color: var(--theia-foreground);
font-size: var(--theia-ui-font-size1);
font-family: var(--theia-ui-font-family-mono);
flex-shrink: 0;
}
.claude-code-tool.description {
color: var(--theia-descriptionForeground);
font-size: var(--theia-ui-font-size1);
}
/* Edit Tool */
.claude-code-tool.edit-changes {
display: flex;
flex-direction: column;
gap: calc(var(--theia-ui-padding) / 2);
}
.claude-code-tool.edit-change {
display: flex;
flex-direction: column;
gap: calc(var(--theia-ui-padding) / 3);
}
.claude-code-tool.edit-label {
color: var(--theia-descriptionForeground);
font-size: var(--theia-ui-font-size0);
font-weight: 500;
}
.claude-code-tool.edit-old {
color: var(--theia-textCodeBlock-foreground);
background-color: var(--theia-merge-incomingContentBackground);
padding: calc(var(--theia-ui-padding) / 2) calc(var(--theia-ui-padding) * 2 / 3);
border-radius: calc(var(--theia-ui-padding) / 3);
font-family: var(--theia-ui-font-family-mono);
font-size: var(--theia-ui-font-size0);
border-left: 3px solid var(--theia-merge-incomingHeaderBackground);
}
.claude-code-tool.edit-new {
color: var(--theia-textCodeBlock-foreground);
background-color: var(--theia-merge-currentContentBackground);
padding: calc(var(--theia-ui-padding) / 2) calc(var(--theia-ui-padding) * 2 / 3);
border-radius: calc(var(--theia-ui-padding) / 3);
font-family: var(--theia-ui-font-family-mono);
font-size: var(--theia-ui-font-size0);
border-left: 3px solid var(--theia-merge-currentHeaderBackground);
}
/* Grep Tool */
.claude-code-tool.pattern {
color: var(--theia-foreground);
font-size: var(--theia-ui-font-size1);
font-family: var(--theia-ui-font-family-mono);
font-weight: 600;
background-color: var(--theia-textCodeBlock-background);
padding: 2px 6px;
border-radius: calc(var(--theia-ui-padding) / 3);
border: var(--theia-border-width) solid var(--theia-widget-border);
}
.claude-code-tool.scope {
color: var(--theia-descriptionForeground);
font-size: var(--theia-ui-font-size0);
font-style: italic;
}
/* Glob Tool */
.claude-code-tool.glob-pattern {
color: var(--theia-foreground);
font-size: var(--theia-ui-font-size1);
font-family: var(--theia-ui-font-family-mono);
font-weight: 600;
background-color: var(--theia-textCodeBlock-background);
padding: 2px 6px;
border-radius: calc(var(--theia-ui-padding) / 3);
border: var(--theia-border-width) solid var(--theia-widget-border);
}
/* TodoWrite Renderer Styles */
.claude-code-tool.todo-list-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-sideBar-background);
overflow: hidden;
}
.claude-code-tool.todo-list-header {
display: flex;
align-items: center;
gap: var(--theia-ui-padding);
padding: 6px;
background-color: var(--theia-editorGroupHeader-tabsBackground);
border-bottom: var(--theia-border-width) solid var(--theia-sideBarSectionHeader-border);
}
.claude-code-tool.todo-list-icon {
color: var(--theia-button-background);
font-size: var(--theia-icon-size);
}
.claude-code-tool.todo-list-title {
font-weight: 600;
color: var(--theia-foreground);
font-size: var(--theia-ui-font-size1);
}
.claude-code-tool.todo-list-progress {
margin-left: auto;
color: var(--theia-badge-foreground);
font-size: var(--theia-ui-font-size0);
padding: 2px 6px;
background-color: var(--theia-badge-background);
border-radius: calc(var(--theia-ui-padding) / 3);
line-height: 1;
}
.claude-code-tool.todo-list-empty {
padding: var(--theia-ui-padding);
color: var(--theia-descriptionForeground);
font-style: italic;
text-align: center;
}
.claude-code-tool.todo-list-items {
padding: 0;
}
.claude-code-tool.todo-item {
border-bottom: var(--theia-border-width) solid var(--theia-sideBarSectionHeader-border);
transition: background-color 0.2s ease;
}
.claude-code-tool.todo-item:last-child {
border-bottom: none;
}
.claude-code-tool.todo-item:hover {
background-color: var(--theia-toolbar-hoverBackground);
}
.claude-code-tool.todo-item-main {
display: flex;
align-items: flex-start;
gap: calc(var(--theia-ui-padding) * 2 / 3);
padding: 6px;
}
.claude-code-tool.todo-item-status {
display: flex;
align-items: center;
justify-content: center;
min-width: var(--theia-icon-size);
height: var(--theia-icon-size);
margin-top: 2px;
}
.claude-code-tool.todo-status-icon {
font-size: var(--theia-icon-size);
}
.claude-code-tool.todo-status-icon.completed {
color: var(--theia-charts-green);
}
.claude-code-tool.todo-status-icon.in-progress {
color: var(--theia-progressBar-background);
}
.claude-code-tool.todo-status-icon.pending {
color: var(--theia-descriptionForeground);
}
.claude-code-tool.todo-item-content {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: calc(var(--theia-ui-padding) * 2 / 3);
}
.claude-code-tool.todo-item-text {
flex: 1;
color: var(--theia-foreground);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.claude-code-tool.todo-item.status-completed .claude-code-tool.todo-item-text {
text-decoration: line-through;
color: var(--theia-descriptionForeground);
}
.claude-code-tool.todo-priority {
padding: 2px 6px;
border-radius: calc(var(--theia-ui-padding) / 3);
font-size: var(--theia-ui-font-size0);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.3px;
flex-shrink: 0;
}
.claude-code-tool.todo-priority.priority-high {
background-color: rgba(var(--theia-charts-red-rgb, 204, 0, 0), 0.8);
color: var(--theia-button-foreground);
}
.claude-code-tool.todo-priority.priority-medium {
background-color: rgba(var(--theia-charts-orange-rgb, 255, 165, 0), 0.8);
color: var(--theia-button-foreground);
}
.claude-code-tool.todo-priority.priority-low {
background-color: rgba(var(--theia-charts-blue-rgb, 0, 122, 204), 0.8);
color: var(--theia-button-foreground);
}
.claude-code-tool.todo-list-error {
padding: var(--theia-ui-padding);
color: var(--theia-errorForeground);
background-color: var(--theia-errorBackground);
border-radius: var(--theia-ui-padding);
margin: var(--theia-ui-padding) 0;
}
/* Progress bar for completed items */
.claude-code-tool.todo-item.status-completed {
opacity: 0.8;
}
/* Animation for in-progress items */
.claude-code-tool.todo-item.status-in-progress {
background-color: rgba(var(--theia-progressBar-background-rgb, 0, 122, 204),
0.05);
}
.claude-code-tool.todo-item.status-in-progress .claude-code-tool.todo-item-text {
font-weight: 500;
}
/* Detail section styles */
.claude-code-tool.details {
display: flex;
flex-direction: column;
gap: calc(var(--theia-ui-padding) / 2);
}
.claude-code-tool.detail-row {
display: flex;
align-items: flex-start;
gap: calc(var(--theia-ui-padding) / 2);
margin-bottom: calc(var(--theia-ui-padding) / 3);
}
.claude-code-tool.detail-label {
color: var(--theia-descriptionForeground);
font-size: var(--theia-ui-font-size1);
font-weight: 500;
min-width: 80px;
flex-shrink: 0;
}
.claude-code-tool.detail-label::after {
content: " ";
margin-left: 2px;
}
.claude-code-tool.detail-value {
color: var(--theia-foreground);
font-size: var(--theia-ui-font-size1);
flex: 1;
min-width: 0;
word-break: break-word;
}
.claude-code-tool.detail-value.code-preview {
background-color: var(--theia-textCodeBlock-background);
padding: calc(var(--theia-ui-padding) / 2);
border-radius: calc(var(--theia-ui-padding) / 3);
font-family: var(--theia-ui-font-family-mono);
border: var(--theia-border-width) solid var(--theia-widget-border);
white-space: pre-wrap;
overflow-x: auto;
margin: 0;
}
/* MultiEdit specific styles */
.claude-code-tool.edit-preview {
background-color: var(--theia-editorWidget-background);
border: var(--theia-border-width) solid var(--theia-widget-border);
border-radius: calc(var(--theia-ui-padding) / 2);
padding: calc(var(--theia-ui-padding) * 2 / 3);
margin: calc(var(--theia-ui-padding) / 2) 0;
}
.claude-code-tool.edit-preview-header {
display: flex;
align-items: center;
gap: calc(var(--theia-ui-padding) / 2);
margin-bottom: calc(var(--theia-ui-padding) / 2);
padding-bottom: calc(var(--theia-ui-padding) / 3);
border-bottom: var(--theia-border-width) solid var(--theia-sideBarSectionHeader-border);
}
.claude-code-tool.edit-preview-title {
font-weight: 600;
color: var(--theia-foreground);
font-size: var(--theia-ui-font-size0);
}
.claude-code-tool.edit-preview-badge {
font-size: var(--theia-ui-font-size0);
font-weight: 500;
padding: 1px 4px;
background-color: var(--theia-badge-background);
color: var(--theia-badge-foreground);
border-radius: calc(var(--theia-ui-padding) / 4);
font-family: var(--theia-ui-font-family-mono);
}

View File

@@ -0,0 +1,41 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { AI_CORE_PREFERENCES_TITLE } from '@theia/ai-core/lib/common/ai-core-preferences';
import { nls, PreferenceSchema } from '@theia/core';
export const CLAUDE_CODE_EXECUTABLE_PATH_PREF = 'ai-features.claudeCode.executablePath';
export const CLAUDE_CODE_API_KEY_PREF = 'ai-features.claudeCode.apiKey';
export const ClaudeCodePreferencesSchema: PreferenceSchema = {
properties: {
[CLAUDE_CODE_EXECUTABLE_PATH_PREF]: {
type: 'string',
markdownDescription: nls.localize('theia/ai/claude-code/executablePath/description',
'Path to the Claude Code executable (cli.js) of the `@anthropic-ai/claude-agent-sdk`.' +
'If not specified, the system will attempt to resolve the path automatically ' +
'from the global npm installation.'),
title: AI_CORE_PREFERENCES_TITLE,
},
[CLAUDE_CODE_API_KEY_PREF]: {
type: 'string',
markdownDescription: nls.localize('theia/ai/claude-code/apiKey/description',
'Enter an API Key for Claude Code. **Please note:** By using this preference the API key will be stored in clear text ' +
'on the machine running Theia. Use the environment variable `ANTHROPIC_API_KEY` to set the key securely.'),
title: AI_CORE_PREFERENCES_TITLE,
},
}
};

View File

@@ -0,0 +1,312 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
export const CLAUDE_CODE_SERVICE_PATH = '/services/claude-code';
/**
* Message sent from backend to frontend requesting user approval for tool usage.
*/
export interface ToolApprovalRequestMessage {
type: 'tool-approval-request';
toolName: string;
toolInput: unknown;
requestId: string;
}
/**
* Message sent from frontend to backend with user approval decision.
*/
export interface ToolApprovalResponseMessage {
type: 'tool-approval-response';
requestId: string;
approved: boolean;
message?: string; // Denial reason when approved=false
updatedInput?: unknown; // Tool input to use when approved=true
}
export namespace ToolApprovalRequestMessage {
export function is(obj: unknown): obj is ToolApprovalRequestMessage {
return typeof obj === 'object' && obj !== undefined &&
(obj as ToolApprovalRequestMessage).type === 'tool-approval-request';
}
}
export namespace ToolApprovalResponseMessage {
export function is(obj: unknown): obj is ToolApprovalResponseMessage {
return typeof obj === 'object' && obj !== undefined &&
(obj as ToolApprovalResponseMessage).type === 'tool-approval-response';
}
}
export type StreamMessage = SDKMessage | ToolApprovalRequestMessage;
export interface ClaudeCodeRequest {
prompt: string;
options?: Partial<ClaudeCodeOptions>;
}
export interface ClaudeCodeBackendRequest extends ClaudeCodeRequest {
apiKey?: string;
claudeCodePath?: string;
}
export const ClaudeCodeClient = Symbol('ClaudeCodeClient');
export interface ClaudeCodeClient {
/**
* @param token `undefined` signals end of stream.
*/
sendToken(streamId: string, token?: StreamMessage): void;
sendError(streamId: string, error: Error): void;
}
export const ClaudeCodeService = Symbol('ClaudeCodeService');
export interface ClaudeCodeService {
/**
* Send a request to Claude Code.
* @param request request parameters
* @param streamId Pre-generated stream ID for tracking streaming responses
*/
send(request: ClaudeCodeBackendRequest, streamId: string): Promise<void>;
/**
* Cancel a running request to Claude Code.
* @param streamId Stream ID identifying the request
*/
cancel(streamId: string): void;
/**
* Handle approval response from the frontend.
* @param response approval response
*/
handleApprovalResponse(response: ToolApprovalResponseMessage): void;
}
// Types that match @anthropic-ai/claude-agent-sdk interfaces
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan';
export interface NonNullableUsage {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
}
export interface Usage {
input_tokens?: number;
output_tokens?: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
}
export interface SDKMessageBase {
uuid: string;
session_id: string;
}
export type SDKUserMessage = SDKMessageBase & {
type: 'user';
message: {
role: 'user';
content: string | ContentBlock[];
};
parent_tool_use_id: string | null;
};
export interface TextBlock {
type: 'text';
text: string;
}
export interface ToolUseContentBlock {
type: 'tool_use' | 'server_tool_use';
id: string;
name: string;
input: Record<string, unknown>;
}
export interface ToolResultBlock {
type: 'tool_result';
tool_use_id: string;
content: unknown;
is_error?: boolean;
}
export interface ThinkingBlock {
type: 'thinking';
thinking: string;
signature?: string;
}
export interface RedactedThinkingBlock {
type: 'redacted_thinking';
data: string;
}
export interface WebSearchToolResultBlock {
type: 'web_search_tool_result';
tool_use_id: string;
content: Array<{
title: string;
url: string;
[key: string]: unknown
}>;
}
export type ContentBlock = TextBlock | ToolUseContentBlock | ToolResultBlock | ThinkingBlock | RedactedThinkingBlock | WebSearchToolResultBlock;
export type SDKAssistantMessage = SDKMessageBase & {
type: 'assistant';
message: {
id: string;
type: 'message';
role: 'assistant';
content: ContentBlock[];
model: string;
stop_reason: string | null;
stop_sequence: string | null;
usage: Usage;
};
parent_tool_use_id: string | null;
};
export type SDKResultMessage = SDKMessageBase & {
type: 'result';
subtype: 'success' | 'error_max_turns' | 'error_during_execution';
duration_ms: number;
duration_api_ms: number;
is_error: boolean;
num_turns: number;
total_cost_usd: number;
usage: NonNullableUsage;
permission_denials: Array<{
tool_name: string;
tool_use_id: string;
tool_input: Record<string, unknown>;
}>;
};
export type SDKSystemMessage = SDKMessageBase & {
type: 'system';
subtype: 'init';
apiKeySource: string;
cwd: string;
tools: string[];
mcp_servers: Array<{
name: string;
status: string;
}>;
model: string;
permissionMode: PermissionMode;
slash_commands: string[];
output_style: string;
};
export type SDKMessage = SDKAssistantMessage | SDKUserMessage | SDKResultMessage | SDKSystemMessage;
export interface ClaudeCodeOptions {
cwd?: string;
abortController?: AbortController;
additionalDirectories?: string[];
allowedTools?: string[];
appendSystemPrompt?: string;
canUseTool?: (toolName: string, input: Record<string, unknown>, options: {
signal: AbortController['signal'];
}) => Promise<{ behavior: 'allow' | 'deny'; message?: string; updatedInput?: unknown }>;
continue?: boolean;
systemPrompt?: string | {
type: 'preset';
preset: 'claude_code';
append?: string;
};
disallowedTools?: string[];
env?: Record<string, string>;
executable?: 'bun' | 'deno' | 'node';
executableArgs?: string[];
extraArgs?: Record<string, string | null>;
fallbackModel?: string;
forkSession?: boolean;
maxThinkingTokens?: number;
maxTurns?: number;
model?: string;
pathToClaudeCodeExecutable?: string;
permissionMode?: PermissionMode;
permissionPromptToolName?: string;
resume?: string;
stderr?: (data: string) => void;
strictMcpConfig?: boolean;
}
// Tool input interfaces
export interface TaskInput {
description: string;
prompt: string;
}
export interface EditInput {
file_path: string;
old_string: string;
new_string: string;
}
export interface MultiEditInput {
file_path: string;
edits: Array<{ old_string: string; new_string: string }>;
}
export interface WriteInput {
file_path: string;
content: string;
}
export namespace TaskInput {
export function is(input: unknown): input is TaskInput {
// eslint-disable-next-line no-null/no-null
return typeof input === 'object' && input !== null && 'description' in input && 'prompt' in input &&
typeof (input as TaskInput).description === 'string' &&
typeof (input as TaskInput).prompt === 'string';
}
}
export namespace EditInput {
export function is(input: unknown): input is EditInput {
// eslint-disable-next-line no-null/no-null
return typeof input === 'object' && input !== null &&
'file_path' in input && 'old_string' in input && 'new_string' in input &&
typeof (input as EditInput).file_path === 'string' &&
typeof (input as EditInput).old_string === 'string' &&
typeof (input as EditInput).new_string === 'string';
}
}
export namespace MultiEditInput {
export function is(input: unknown): input is MultiEditInput {
// eslint-disable-next-line no-null/no-null
return typeof input === 'object' && input !== null &&
'file_path' in input && 'edits' in input &&
typeof (input as MultiEditInput).file_path === 'string' &&
Array.isArray((input as MultiEditInput).edits);
}
}
export namespace WriteInput {
export function is(input: unknown): input is WriteInput {
// eslint-disable-next-line no-null/no-null
return typeof input === 'object' && input !== null &&
'file_path' in input && 'content' in input &&
typeof (input as WriteInput).file_path === 'string' &&
typeof (input as WriteInput).content === 'string';
}
}

View File

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

View File

@@ -0,0 +1,42 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ConnectionHandler, RpcConnectionHandler } from '@theia/core';
import { ConnectionContainerModule } from '@theia/core/lib/node/messaging/connection-container-module';
import { ContainerModule } from '@theia/core/shared/inversify';
import {
CLAUDE_CODE_SERVICE_PATH,
ClaudeCodeClient,
ClaudeCodeService
} from '../common/claude-code-service';
import { ClaudeCodeServiceImpl } from './claude-code-service-impl';
const claudeCodeConnectionModule = ConnectionContainerModule.create(({ bind }) => {
bind(ClaudeCodeServiceImpl).toSelf().inSingletonScope();
bind(ClaudeCodeService).toService(ClaudeCodeServiceImpl);
bind(ConnectionHandler).toDynamicValue(ctx =>
new RpcConnectionHandler<ClaudeCodeClient>(CLAUDE_CODE_SERVICE_PATH, client => {
const server = ctx.container.get<ClaudeCodeServiceImpl>(ClaudeCodeService);
server.setClient(client);
return server;
})
).inSingletonScope();
});
export default new ContainerModule(bind => {
bind(ConnectionContainerModule).toConstantValue(claudeCodeConnectionModule);
});

View File

@@ -0,0 +1,521 @@
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ILogger, generateUuid, nls } from '@theia/core';
import { inject, injectable, named } from '@theia/core/shared/inversify';
import { execSync } from 'child_process';
import { existsSync, realpathSync } from 'fs';
import * as fs from 'fs/promises';
import * as path from 'path';
import {
ClaudeCodeBackendRequest,
ClaudeCodeClient,
ClaudeCodeService,
ToolApprovalRequestMessage,
ToolApprovalResponseMessage
} from '../common/claude-code-service';
interface ToolApprovalResult {
behavior: 'allow' | 'deny';
message?: string;
updatedInput?: unknown;
}
@injectable()
export class ClaudeCodeServiceImpl implements ClaudeCodeService {
@inject(ILogger) @named('ClaudeCode')
private logger: ILogger;
private client: ClaudeCodeClient;
private abortControllers = new Map<string, AbortController>();
private pendingApprovals = new Map<string, (result: ToolApprovalResult) => void>();
setClient(client: ClaudeCodeClient): void {
this.client = client;
}
async send(request: ClaudeCodeBackendRequest, streamId: string): Promise<void> {
if (!this.client) {
throw new Error('Claude Code client not initialized');
}
this.sendMessages(streamId, request);
}
protected isApiKeyError(message: unknown): boolean {
if (typeof message === 'object' && message) {
// Check if this is a result message with the API key error
const resultMessage = message as {
type?: string;
result?: string;
is_error?: boolean;
usage?: { input_tokens?: number; output_tokens?: number };
};
if (resultMessage.type === 'result' && resultMessage.is_error && resultMessage.result) {
const hasErrorText = resultMessage.result.includes('Invalid API key') && resultMessage.result.includes('/login');
// Additional check: error results typically have zero token usage
const hasZeroUsage = resultMessage.usage?.input_tokens === 0 && resultMessage.usage?.output_tokens === 0;
return hasErrorText && (hasZeroUsage || !resultMessage.usage);
}
// Check if this is an assistant message with the API key error
// These messages have model: "<synthetic>" to indicate they're error messages
const assistantMessage = message as {
type?: string;
message?: {
model?: string;
role?: string;
usage?: { input_tokens?: number; output_tokens?: number };
content?: Array<{ type?: string; text?: string }>;
};
};
if (assistantMessage.type === 'assistant' &&
assistantMessage.message?.model === '<synthetic>' &&
assistantMessage.message?.role === 'assistant' &&
assistantMessage.message?.content) {
const hasErrorText = assistantMessage.message.content.some(content =>
content.type === 'text' && content.text &&
content.text.includes('Invalid API key') && content.text.includes('/login')
);
// Additional check: synthetic error messages have zero token usage
const usage = assistantMessage.message.usage;
const hasZeroUsage = usage?.input_tokens === 0 && usage?.output_tokens === 0;
return hasErrorText && (hasZeroUsage || !usage);
}
}
return false;
}
protected async sendMessages(streamId: string, request: ClaudeCodeBackendRequest): Promise<void> {
const abortController = new AbortController();
this.abortControllers.set(streamId, abortController);
try {
const cwd = request.options?.cwd || process.cwd();
await this.ensureFileBackupHook(cwd);
await this.ensureStopHook(cwd);
await this.ensureClaudeSettings(cwd);
let done = (_?: unknown) => { };
const receivedResult = new Promise(resolve => {
done = resolve;
});
const apiKey = request.apiKey || process.env.ANTHROPIC_API_KEY;
const { query, SDKUserMessage, Options } = await this.importClaudeCodeSDK(request.claudeCodePath);
const stream = (query as Function)({
prompt: (async function* (): AsyncGenerator<typeof SDKUserMessage> {
yield {
type: 'user',
message: {
role: 'user',
content: request.prompt
},
// eslint-disable-next-line no-null/no-null
parent_tool_use_id: null,
session_id: generateUuid()
};
await receivedResult;
})(),
options: <typeof Options>{
...request.options,
abortController,
cwd,
settingSources: ['user', 'project', 'local'],
canUseTool: (toolName: string, toolInput: unknown) => this.requestToolApproval(streamId, toolName, toolInput),
env: { ...process.env, ANTHROPIC_API_KEY: apiKey, NODE_OPTIONS: '' },
stderr: (data: unknown) => {
let message = String(data);
// The current claude code CLI is quite verbose and outputs a lot of
// non-error info to stderr when spawning.
// We check for this and log it at debug level instead.
if (message.startsWith('Spawning Claude Code process:')) {
// Strip the system prompt if present
if (request.options?.appendSystemPrompt) {
const systemPrompt = request.options.appendSystemPrompt;
message = message.replace(systemPrompt, '').trim();
}
// Check if the remainder looks like it contains an actual error
if (message.toLowerCase().includes('error') || message.toLowerCase().includes('failed')) {
this.logger.error('Claude Code Std Error:', message);
} else {
this.logger.debug('Claude Code Verbose Output:', message);
}
return;
}
// Log all other content as error (actual errors)
this.logger.error('Claude Code Std Error:', message);
},
}
});
for await (const message of stream) {
// Check for API key error and handle it
if (this.isApiKeyError(message)) {
// If this is the result message, send the custom error and stop
if (message.type === 'result') {
const errorMessage = nls.localize('theia/ai/claude-code/apiKeyError',
'Please set a Claude Code API key in the preferences or log into Claude Code on your machine.');
this.client.sendError(streamId, new Error(errorMessage));
done();
break;
}
// If this is an assistant message with the error, skip it (don't send to client)
continue;
}
this.client.sendToken(streamId, message);
if (message.type === 'result' || abortController.signal.aborted) {
done();
break;
}
}
// Signal stream completion by returning undefined
abortController.abort('closed after result');
this.client.sendToken(streamId, undefined);
} catch (e) {
this.logger.error('Claude Code error:', e);
this.client.sendError(streamId, e instanceof Error ? e : new Error(String(e)));
} finally {
this.abortControllers.delete(streamId);
}
}
/**
* Dynamically imports the Claude Code SDK from the local installation.
* @param customClaudeCodePath Optional custom path to Claude Code executable (cli.js)
* @returns An object containing the SDK's query function, user message type, and options type.
*/
protected async importClaudeCodeSDK(customClaudeCodePath?: string): Promise<{ query: unknown; SDKUserMessage: unknown; Options: unknown }> {
let claudeCodePath: string;
if (customClaudeCodePath) {
if (!existsSync(customClaudeCodePath)) {
throw new Error(nls.localize('theia/ai/claude-code/executableNotFoundAt', 'Specified Claude Code executable not found at: {0}', customClaudeCodePath));
}
const realPath = realpathSync(customClaudeCodePath);
// Use the directory containing the cli.js file
claudeCodePath = path.dirname(realPath);
} else {
claudeCodePath = this.resolveClaudeCodePath();
}
const sdkPath = path.join(claudeCodePath, 'sdk.mjs');
// Check if file exists before importing
if (!existsSync(sdkPath)) {
throw new Error(nls.localize('theia/ai/claude-code/installationNotFoundAt', 'Claude Code installation not found. ' +
'Please install with: `npm install -g @anthropic-ai/claude-agent-sdk` ' +
'and/or specify the path to the executable in the settings. ' +
'We looked at {0}', sdkPath));
}
const importPath = `file://${sdkPath}`;
// We can not use dynamic import directly because webpack will try to
// bundle the module at build time, which we don't want.
// We also can't use a webpack ignore comment because the comment is stripped
// during the build and then webpack still tries to resolve the module.
const dynamicImport = new Function('path', 'return import(path)');
return dynamicImport(importPath);
}
protected resolveClaudeCodePath(): string {
try {
const globalPath = execSync('npm root -g', { encoding: 'utf8' }).trim();
return path.join(globalPath, '@anthropic-ai/claude-agent-sdk');
} catch (error) {
this.logger.error('Failed to resolve global npm path:', error);
throw new Error(nls.localize('theia/ai/claude-code/installationNotFound', 'Claude Code installation not found. ' +
'Please install with: `npm install -g @anthropic-ai/claude-agent-sdk` ' +
'and/or specify the path to the executable in the settings.'));
}
}
cancel(streamId: string): void {
const abortController = this.abortControllers.get(streamId);
if (abortController) {
abortController.abort('user canceled');
this.abortControllers.delete(streamId);
}
}
handleApprovalResponse(response: ToolApprovalResponseMessage): void {
const resolver = this.pendingApprovals.get(response.requestId);
if (resolver) {
resolver({
behavior: response.approved ? 'allow' : 'deny',
message: response.message,
updatedInput: response.updatedInput
});
}
}
protected async requestToolApproval(streamId: string, toolName: string, toolInput: unknown): Promise<{ behavior: 'allow' | 'deny', message?: string, updatedInput?: unknown }> {
this.logger.info('Requesting tool approval:', toolName, toolInput);
const requestId = generateUuid();
const approvalRequest: ToolApprovalRequestMessage = {
type: 'tool-approval-request',
toolName,
toolInput,
requestId
};
this.client.sendToken(streamId, approvalRequest);
const approvalPromise = new Promise<ToolApprovalResult>(resolve => {
this.pendingApprovals.set(requestId, resolve);
});
const result = await approvalPromise;
this.pendingApprovals.delete(requestId);
return result;
}
protected async ensureStopHook(cwd: string): Promise<void> {
const hookPath = path.join(cwd, '.claude', 'hooks', 'session-cleanup-hook.js');
try {
await fs.access(hookPath);
return;
} catch {
// Hook doesn't exist, create it
}
await fs.mkdir(path.dirname(hookPath), { recursive: true });
const hookContent = `#!/usr/bin/env node
const fs = require('fs').promises;
const path = require('path');
async function main() {
try {
const input = await new Promise((resolve, reject) => {
let data = '';
process.stdin.on('data', chunk => data += chunk);
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', reject);
});
const hookData = JSON.parse(input);
// Delete backup directory for this session
const backupDir = path.join(hookData.cwd, '.claude', '.edit-baks', hookData.session_id);
try {
await fs.rm(backupDir, { recursive: true, force: true });
console.log(\`Cleaned up session backups: \${hookData.session_id}\`);
} catch (error) {
// Directory might not exist, which is fine
console.log(\`No backups to clean for session: \${hookData.session_id}\`);
}
} catch (error) {
console.error(\`Cleanup failed: \${error.message}\`, process.stderr);
process.exit(1);
}
}
main();
`;
await fs.writeFile(hookPath, hookContent, { mode: 0o755 });
}
protected async ensureFileBackupHook(cwd: string): Promise<void> {
const hookPath = path.join(cwd, '.claude', 'hooks', 'file-backup-hook.js');
try {
await fs.access(hookPath);
// Hook already exists, no need to create it
return;
} catch {
// Hook doesn't exist, create it
}
// Ensure the hooks directory exists
await fs.mkdir(path.dirname(hookPath), { recursive: true });
const hookContent = `#!/usr/bin/env node
const fs = require('fs').promises;
const path = require('path');
async function main() {
try {
// Read input from stdin
const input = await new Promise((resolve, reject) => {
let data = '';
process.stdin.on('data', chunk => data += chunk);
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', reject);
});
const hookData = JSON.parse(input);
// Only backup for file modification tools
const fileModifyingTools = ['Write', 'Edit', 'MultiEdit'];
if (!fileModifyingTools.includes(hookData.tool_name)) {
process.exit(0);
}
// Extract file path from tool input
let filePath;
if (hookData.tool_name === 'Write' || hookData.tool_name === 'Edit') {
filePath = hookData.tool_input?.file_path;
} else if (hookData.tool_name === 'MultiEdit') {
// MultiEdit has multiple files - we'll handle the first one for now
// You might want to extend this to handle all files
filePath = hookData.tool_input?.files?.[0]?.file_path;
}
if (!filePath) {
process.exit(0);
}
// Resolve absolute path
const absoluteFilePath = path.resolve(hookData.cwd, filePath);
// Check if file exists (can't backup what doesn't exist)
try {
await fs.access(absoluteFilePath);
} catch {
// File doesn't exist, nothing to backup
process.exit(0);
}
// Create backup directory structure
const backupDir = path.join(hookData.cwd, '.claude', '.edit-baks', hookData.session_id);
await fs.mkdir(backupDir, { recursive: true });
// Create backup file path (maintain relative structure)
const relativePath = path.relative(hookData.cwd, absoluteFilePath);
const backupFilePath = path.join(backupDir, relativePath);
// Ensure backup subdirectories exist
await fs.mkdir(path.dirname(backupFilePath), { recursive: true });
// Only create backup if it doesn't already exist for this session
try {
await fs.access(backupFilePath);
// Backup already exists for this session, don't overwrite
process.exit(0);
} catch {
// Backup doesn't exist, create it
}
// Copy the file
await fs.copyFile(absoluteFilePath, backupFilePath);
// Optional: Log the backup (visible in transcript mode with Ctrl-R)
console.log(\`Backed up: \${relativePath}\`);
} catch (error) {
console.error(\`Backup failed: \${error.message}\`, process.stderr);
process.exit(1); // Non-blocking error
}
}
main();
`;
await fs.writeFile(hookPath, hookContent, { mode: 0o755 });
}
private async ensureClaudeSettings(cwd: string): Promise<void> {
const settingsPath = path.join(cwd, '.claude', 'settings.local.json');
const hookConfig = {
hooks: {
PreToolUse: [
{
matcher: 'Write|Edit|MultiEdit',
hooks: [
{
type: 'command',
command: 'node $CLAUDE_PROJECT_DIR/.claude/hooks/file-backup-hook.js',
timeout: 10
}
]
}
],
Stop: [
{
matcher: '',
hooks: [
{
type: 'command',
command: 'node $CLAUDE_PROJECT_DIR/.claude/hooks/session-cleanup-hook.js'
}
]
}
]
}
};
try {
// Try to read existing settings
const existingContent = await fs.readFile(settingsPath, 'utf8');
const existingSettings = JSON.parse(existingContent);
// Check if hooks already exist and are properly configured
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hasPreHook = existingSettings.hooks?.PreToolUse?.some((hook: any) =>
hook.matcher === 'Write|Edit|MultiEdit' &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hook.hooks?.some((h: any) => h.command?.includes('file-backup-hook.js'))
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hasStopHook = existingSettings.hooks?.Stop?.some((hook: any) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hook.hooks?.some((h: any) => h.command?.includes('session-cleanup-hook.js'))
);
if (hasPreHook && hasStopHook) {
// Hooks already configured, no need to modify
return;
}
// Merge with existing settings
const mergedSettings = {
...existingSettings,
hooks: {
...existingSettings.hooks,
PreToolUse: [
...(existingSettings.hooks?.PreToolUse || []),
...(hasPreHook ? [] : hookConfig.hooks.PreToolUse)
],
Stop: [
...(existingSettings.hooks?.Stop || []),
...(hasStopHook ? [] : hookConfig.hooks.Stop)
]
}
};
await fs.writeFile(settingsPath, JSON.stringify(mergedSettings, undefined, 2));
} catch {
// File doesn't exist or is invalid JSON, create new one
await fs.mkdir(path.dirname(settingsPath), { recursive: true });
await fs.writeFile(settingsPath, JSON.stringify(hookConfig, undefined, 2));
}
}
}

View File

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

View File

@@ -0,0 +1,37 @@
{
"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": "../editor"
},
{
"path": "../filesystem"
},
{
"path": "../output"
},
{
"path": "../workspace"
}
]
}