Files
theia-code-os/packages/ai-claude-code/src/browser/renderers/todo-write-renderer.tsx
mawkone 8bb5110148
Some checks failed
Playwright Tests / Playwright Tests (ubuntu-22.04, Node.js 22.x) (push) Has been cancelled
3PP License Check / 3PP License Check (11, 22.x, ubuntu-22.04) (push) Has been cancelled
Publish packages to NPM / Perform Publishing (push) Has been cancelled
deploy: current vibn theia state
Made-with: Cursor
2026-02-27 12:01:08 -08:00

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