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 },
};
}
}