188 lines
7.9 KiB
TypeScript
188 lines
7.9 KiB
TypeScript
// *****************************************************************************
|
|
// 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}
|
|
/>
|
|
);
|
|
};
|