mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
🚀 Add Todo Write Tool for Task Management and Progress Tracking (#478)
This commit is contained in:
97
packages/cli/src/ui/components/TodoDisplay.test.tsx
Normal file
97
packages/cli/src/ui/components/TodoDisplay.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
72
packages/cli/src/ui/components/TodoDisplay.tsx
Normal file
72
packages/cli/src/ui/components/TodoDisplay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user