deploy: current vibn theia state
Made-with: Cursor
This commit is contained in:
10
packages/ai-claude-code/.eslintrc.js
Normal file
10
packages/ai-claude-code/.eslintrc.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
extends: [
|
||||
'../../configs/build.eslintrc.json'
|
||||
],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: 'tsconfig.json'
|
||||
}
|
||||
};
|
||||
31
packages/ai-claude-code/README.md
Normal file
31
packages/ai-claude-code/README.md
Normal 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>
|
||||
56
packages/ai-claude-code/package.json
Normal file
56
packages/ai-claude-code/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
606
packages/ai-claude-code/src/browser/claude-code-chat-agent.ts
Normal file
606
packages/ai-claude-code/src/browser/claude-code-chat-agent.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
};
|
||||
312
packages/ai-claude-code/src/common/claude-code-service.ts
Normal file
312
packages/ai-claude-code/src/common/claude-code-service.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
17
packages/ai-claude-code/src/common/index.ts
Normal file
17
packages/ai-claude-code/src/common/index.ts
Normal 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';
|
||||
@@ -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);
|
||||
});
|
||||
521
packages/ai-claude-code/src/node/claude-code-service-impl.ts
Normal file
521
packages/ai-claude-code/src/node/claude-code-service-impl.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
27
packages/ai-claude-code/src/package.spec.ts
Normal file
27
packages/ai-claude-code/src/package.spec.ts
Normal 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);
|
||||
});
|
||||
37
packages/ai-claude-code/tsconfig.json
Normal file
37
packages/ai-claude-code/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user