🚀 Add Todo Write Tool for Task Management and Progress Tracking (#478)

This commit is contained in:
tanzhenxin
2025-08-28 20:32:21 +08:00
committed by GitHub
parent c1498668b6
commit 1610c1586e
13 changed files with 1901 additions and 103 deletions

View File

@@ -0,0 +1,97 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect } from 'vitest';
import { TodoItem, TodoDisplay } from './TodoDisplay.js';
describe('TodoDisplay', () => {
const mockTodos: TodoItem[] = [
{
id: '1',
content: 'Complete feature implementation',
status: 'completed',
},
{
id: '2',
content: 'Write unit tests',
status: 'in_progress',
},
{
id: '3',
content: 'Update documentation',
status: 'pending',
},
];
it('should render todo list', () => {
const { lastFrame } = render(<TodoDisplay todos={mockTodos} />);
const output = lastFrame();
// Check all todo items are displayed
expect(output).toContain('Complete feature implementation');
expect(output).toContain('Write unit tests');
expect(output).toContain('Update documentation');
});
it('should display correct status icons', () => {
const { lastFrame } = render(<TodoDisplay todos={mockTodos} />);
const output = lastFrame();
// Check status icons are present
expect(output).toContain('●'); // completed
expect(output).toContain('◐'); // in_progress
expect(output).toContain('○'); // pending
});
it('should handle empty todo list', () => {
const { lastFrame } = render(<TodoDisplay todos={[]} />);
const output = lastFrame();
// Should render nothing for empty todos
expect(output).toBe('');
});
it('should handle undefined todos', () => {
const { lastFrame } = render(
<TodoDisplay todos={undefined as unknown as TodoItem[]} />,
);
const output = lastFrame();
// Should render nothing for undefined todos
expect(output).toBe('');
});
it('should render tasks with different statuses', () => {
const allCompleted: TodoItem[] = [
{ id: '1', content: 'Task 1', status: 'completed' },
{ id: '2', content: 'Task 2', status: 'completed' },
];
const { lastFrame } = render(<TodoDisplay todos={allCompleted} />);
const output = lastFrame();
expect(output).toContain('Task 1');
expect(output).toContain('Task 2');
});
it('should render tasks with mixed statuses', () => {
const mixedTodos: TodoItem[] = [
{ id: '1', content: 'Task 1', status: 'pending' },
{ id: '2', content: 'Task 2', status: 'in_progress' },
];
const { lastFrame } = render(<TodoDisplay todos={mixedTodos} />);
const output = lastFrame();
expect(output).toContain('Task 1');
expect(output).toContain('Task 2');
});
});

View File

@@ -0,0 +1,72 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
export interface TodoItem {
id: string;
content: string;
status: 'pending' | 'in_progress' | 'completed';
}
interface TodoDisplayProps {
todos: TodoItem[];
}
const STATUS_ICONS = {
pending: '○',
in_progress: '◐',
completed: '●',
} as const;
export const TodoDisplay: React.FC<TodoDisplayProps> = ({ todos }) => {
if (!todos || todos.length === 0) {
return null;
}
return (
<Box flexDirection="column">
{todos.map((todo) => (
<TodoItemRow key={todo.id} todo={todo} />
))}
</Box>
);
};
interface TodoItemRowProps {
todo: TodoItem;
}
const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
const statusIcon = STATUS_ICONS[todo.status];
const isCompleted = todo.status === 'completed';
const isInProgress = todo.status === 'in_progress';
// Use the same color for both status icon and text, like RadioButtonSelect
const itemColor = isCompleted
? Colors.Foreground
: isInProgress
? Colors.AccentGreen
: Colors.Foreground;
return (
<Box flexDirection="row" minHeight={1}>
{/* Status Icon */}
<Box width={3}>
<Text color={itemColor}>{statusIcon}</Text>
</Box>
{/* Content */}
<Box flexGrow={1}>
<Text color={itemColor} strikethrough={isCompleted} wrap="wrap">
{todo.content}
</Text>
</Box>
</Box>
);
};

View File

@@ -12,6 +12,8 @@ import { Colors } from '../../colors.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { TodoDisplay } from '../TodoDisplay.js';
import { TodoResultDisplay } from '@qwen-code/qwen-code-core';
const STATIC_HEIGHT = 1;
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
@@ -23,6 +25,116 @@ const MIN_LINES_SHOWN = 2; // show at least this many lines
const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 1000000;
export type TextEmphasis = 'high' | 'medium' | 'low';
type DisplayRendererResult =
| { type: 'none' }
| { type: 'todo'; data: TodoResultDisplay }
| { type: 'string'; data: string }
| { type: 'diff'; data: { fileDiff: string; fileName: string } };
/**
* Custom hook to determine the type of result display and return appropriate rendering info
*/
const useResultDisplayRenderer = (
resultDisplay: unknown,
): DisplayRendererResult =>
React.useMemo(() => {
if (!resultDisplay) {
return { type: 'none' };
}
// Check for TodoResultDisplay
if (
typeof resultDisplay === 'object' &&
resultDisplay !== null &&
'type' in resultDisplay &&
resultDisplay.type === 'todo_list'
) {
return {
type: 'todo',
data: resultDisplay as TodoResultDisplay,
};
}
// Check for FileDiff
if (
typeof resultDisplay === 'object' &&
resultDisplay !== null &&
'fileDiff' in resultDisplay
) {
return {
type: 'diff',
data: resultDisplay as { fileDiff: string; fileName: string },
};
}
// Default to string
return {
type: 'string',
data: resultDisplay as string,
};
}, [resultDisplay]);
/**
* Component to render todo list results
*/
const TodoResultRenderer: React.FC<{ data: TodoResultDisplay }> = ({
data,
}) => <TodoDisplay todos={data.todos} />;
/**
* Component to render string results (markdown or plain text)
*/
const StringResultRenderer: React.FC<{
data: string;
renderAsMarkdown: boolean;
availableHeight?: number;
childWidth: number;
}> = ({ data, renderAsMarkdown, availableHeight, childWidth }) => {
let displayData = data;
// Truncate if too long
if (displayData.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) {
displayData = '...' + displayData.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS);
}
if (renderAsMarkdown) {
return (
<Box flexDirection="column">
<MarkdownDisplay
text={displayData}
isPending={false}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
/>
</Box>
);
}
return (
<MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>
<Box>
<Text wrap="wrap">{displayData}</Text>
</Box>
</MaxSizedBox>
);
};
/**
* Component to render diff results
*/
const DiffResultRenderer: React.FC<{
data: { fileDiff: string; fileName: string };
availableHeight?: number;
childWidth: number;
}> = ({ data, availableHeight, childWidth }) => (
<DiffRenderer
diffContent={data.fileDiff}
filename={data.fileName}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
/>
);
export interface ToolMessageProps extends IndividualToolCallDisplay {
availableTerminalHeight?: number;
terminalWidth: number;
@@ -55,13 +167,10 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
}
const childWidth = terminalWidth - 3; // account for padding.
if (typeof resultDisplay === 'string') {
if (resultDisplay.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) {
// Truncate the result display to fit within the available width.
resultDisplay =
'...' + resultDisplay.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS);
}
}
// Use the custom hook to determine the display type
const displayRenderer = useResultDisplayRenderer(resultDisplay);
return (
<Box paddingX={1} paddingY={0} flexDirection="column">
<Box minHeight={1}>
@@ -74,32 +183,25 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
/>
{emphasis === 'high' && <TrailingIndicator />}
</Box>
{resultDisplay && (
{displayRenderer.type !== 'none' && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} width="100%" marginTop={1}>
<Box flexDirection="column">
{typeof resultDisplay === 'string' && renderOutputAsMarkdown && (
<Box flexDirection="column">
<MarkdownDisplay
text={resultDisplay}
isPending={false}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
/>
</Box>
{displayRenderer.type === 'todo' && (
<TodoResultRenderer data={displayRenderer.data} />
)}
{typeof resultDisplay === 'string' && !renderOutputAsMarkdown && (
<MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>
<Box>
<Text wrap="wrap">{resultDisplay}</Text>
</Box>
</MaxSizedBox>
{displayRenderer.type === 'string' && (
<StringResultRenderer
data={displayRenderer.data}
renderAsMarkdown={renderOutputAsMarkdown}
availableHeight={availableHeight}
childWidth={childWidth}
/>
)}
{typeof resultDisplay !== 'string' && (
<DiffRenderer
diffContent={resultDisplay.fileDiff}
filename={resultDisplay.fileName}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
{displayRenderer.type === 'diff' && (
<DiffResultRenderer
data={displayRenderer.data}
availableHeight={availableHeight}
childWidth={childWidth}
/>
)}
</Box>

View File

@@ -770,7 +770,28 @@ function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
type: 'content',
content: { type: 'text', text: toolResult.returnDisplay },
};
} else {
} else if (
'type' in toolResult.returnDisplay &&
toolResult.returnDisplay.type === 'todo_list'
) {
// Handle TodoResultDisplay - convert to text representation
const todoText = toolResult.returnDisplay.todos
.map((todo) => {
const statusIcon = {
pending: '○',
in_progress: '◐',
completed: '●',
}[todo.status];
return `${statusIcon} ${todo.content}`;
})
.join('\n');
return {
type: 'content',
content: { type: 'text', text: todoText },
};
} else if ('fileDiff' in toolResult.returnDisplay) {
// Handle FileDiff
return {
type: 'diff',
path: toolResult.returnDisplay.fileName,
@@ -778,9 +799,8 @@ function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
newText: toolResult.returnDisplay.newContent,
};
}
} else {
return null;
}
return null;
}
const basicPermissionOptions = [