diff --git a/packages/cli/src/ui/components/messages/TodoListDisplay.test.tsx b/packages/cli/src/ui/components/messages/TodoListDisplay.test.tsx new file mode 100644 index 0000000000..c2955d406a --- /dev/null +++ b/packages/cli/src/ui/components/messages/TodoListDisplay.test.tsx @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { describe, it, expect } from 'vitest'; +import { TodoListDisplay } from './TodoListDisplay.js'; +import type { TodoList, TodoStatus } from '@google/gemini-cli-core'; + +describe('', () => { + const terminalWidth = 80; + + it('renders an empty todo list correctly', () => { + const todos: TodoList = { todos: [] }; + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders a todo list with various statuses correctly', () => { + const todos: TodoList = { + todos: [ + { description: 'Task 1', status: 'pending' as TodoStatus }, + { description: 'Task 2', status: 'in_progress' as TodoStatus }, + { description: 'Task 3', status: 'completed' as TodoStatus }, + { description: 'Task 4', status: 'cancelled' as TodoStatus }, + ], + }; + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders a todo list with long descriptions that wrap', () => { + const todos: TodoList = { + todos: [ + { + description: + 'This is a very long description for a pending task that should wrap around multiple lines when the terminal width is constrained.', + status: 'pending' as TodoStatus, + }, + { + description: + 'Another completed task with an equally verbose description to test wrapping behavior.', + status: 'completed' as TodoStatus, + }, + ], + }; + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders a single todo item', () => { + const todos: TodoList = { + todos: [{ description: 'Single task', status: 'pending' as TodoStatus }], + }; + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/messages/TodoListDisplay.tsx b/packages/cli/src/ui/components/messages/TodoListDisplay.tsx new file mode 100644 index 0000000000..cc607ae471 --- /dev/null +++ b/packages/cli/src/ui/components/messages/TodoListDisplay.tsx @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import type { Todo, TodoList, TodoStatus } from '@google/gemini-cli-core'; +import { theme } from '../../semantic-colors.js'; + +export interface TodoListDisplayProps { + todos: TodoList; + terminalWidth: number; +} +const TodoStatusDisplay: React.FC<{ status: TodoStatus }> = ({ status }) => { + switch (status) { + case 'completed': + return ; + case 'in_progress': + return »; + case 'pending': + return ; + case 'cancelled': + return ; + default: + return null; + } +}; + +export const TodoListDisplay: React.FC = ({ + todos, + terminalWidth, +}) => ( + + {todos.todos.map((todo: Todo, index: number) => ( + + + + + + {todo.description} + + + ))} + +); diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 93d4528a7e..df5e81889a 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -13,6 +13,7 @@ import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; import { AnsiOutputText } from '../AnsiOutput.js'; import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js'; import { MaxSizedBox } from '../shared/MaxSizedBox.js'; +import { TodoListDisplay } from './TodoListDisplay.js'; import { ShellInputPrompt } from '../ShellInputPrompt.js'; import { SHELL_COMMAND_NAME, @@ -20,7 +21,7 @@ import { TOOL_STATUS, } from '../../constants.js'; import { theme } from '../../semantic-colors.js'; -import type { AnsiOutput, Config } from '@google/gemini-cli-core'; +import type { AnsiOutput, Config, TodoList } from '@google/gemini-cli-core'; import { useUIState } from '../../contexts/UIStateContext.js'; const STATIC_HEIGHT = 1; @@ -170,6 +171,12 @@ export const ToolMessage: React.FC = ({ availableTerminalHeight={availableHeight} terminalWidth={childWidth} /> + ) : typeof resultDisplay === 'object' && + 'todos' in resultDisplay ? ( + ) : ( > renders a single todo item 1`] = `"☐ Single task"`; + +exports[` > renders a todo list with long descriptions that wrap 1`] = ` +"☐ This is a very long description for a + pending task that should wrap around + multiple lines when the terminal width + is constrained. +✓ Another completed task with an equally + verbose description to test wrapping + behavior." +`; + +exports[` > renders a todo list with various statuses correctly 1`] = ` +"☐ Task 1 +» Task 2 +✓ Task 3 +✗ Task 4" +`; + +exports[` > renders an empty todo list correctly 1`] = `""`; diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index af882da8b3..86690cd675 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -541,7 +541,18 @@ export function hasCycleInSchema(schema: object): boolean { return traverse(schema, new Set(), new Set()); } -export type ToolResultDisplay = string | FileDiff | AnsiOutput; +export interface TodoList { + todos: Todo[]; +} + +export type ToolResultDisplay = string | FileDiff | AnsiOutput | TodoList; + +export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; + +export interface Todo { + description: string; + status: TodoStatus; +} export interface FileDiff { fileDiff: string; diff --git a/packages/core/src/tools/write-todos.test.ts b/packages/core/src/tools/write-todos.test.ts index affb18d0d8..9c2bc36fa5 100644 --- a/packages/core/src/tools/write-todos.test.ts +++ b/packages/core/src/tools/write-todos.test.ts @@ -86,7 +86,7 @@ describe('WriteTodosTool', () => { }; const result = await tool.buildAndExecute(params, signal); expect(result.llmContent).toBe('Successfully cleared the todo list.'); - expect(result.returnDisplay).toBe('Successfully cleared the todo list.'); + expect(result.returnDisplay).toEqual({ todos: [] }); }); it('should return a formatted todo list on success', async () => { @@ -103,7 +103,7 @@ describe('WriteTodosTool', () => { 2. [in_progress] Second task 3. [pending] Third task`; expect(result.llmContent).toBe(expectedOutput); - expect(result.returnDisplay).toBe(expectedOutput); + expect(result.returnDisplay).toEqual(params); }); }); }); diff --git a/packages/core/src/tools/write-todos.ts b/packages/core/src/tools/write-todos.ts index eb7664c774..f627504f4c 100644 --- a/packages/core/src/tools/write-todos.ts +++ b/packages/core/src/tools/write-todos.ts @@ -9,6 +9,7 @@ import { BaseDeclarativeTool, BaseToolInvocation, Kind, + type Todo, type ToolResult, } from './tools.js'; import { WRITE_TODOS_TOOL_NAME } from './tool-names.js'; @@ -79,13 +80,6 @@ The agent did not use the todo list because this task could be completed by a ti `; -export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; - -export interface Todo { - description: string; - status: TodoStatus; -} - export interface WriteTodosToolParams { /** * The full list of todos. This will overwrite any existing list. @@ -123,7 +117,7 @@ class WriteTodosToolInvocation extends BaseToolInvocation< return { llmContent, - returnDisplay: llmContent, + returnDisplay: { todos }, }; } }