diff --git a/packages/cli/src/ui/components/Checklist.test.tsx b/packages/cli/src/ui/components/Checklist.test.tsx
new file mode 100644
index 0000000000..ba1f0e4813
--- /dev/null
+++ b/packages/cli/src/ui/components/Checklist.test.tsx
@@ -0,0 +1,72 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { describe, it, expect } from 'vitest';
+import { Checklist } from './Checklist.js';
+import type { ChecklistItemData } from './ChecklistItem.js';
+
+describe('', () => {
+ const items: ChecklistItemData[] = [
+ { status: 'completed', label: 'Task 1' },
+ { status: 'in_progress', label: 'Task 2' },
+ { status: 'pending', label: 'Task 3' },
+ { status: 'cancelled', label: 'Task 4' },
+ ];
+
+ it('renders nothing when list is empty', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toBe('');
+ });
+
+ it('renders nothing when collapsed and no active items', () => {
+ const inactiveItems: ChecklistItemData[] = [
+ { status: 'completed', label: 'Task 1' },
+ { status: 'cancelled', label: 'Task 2' },
+ ];
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toBe('');
+ });
+
+ it('renders summary view correctly (collapsed)', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('renders expanded view correctly', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('renders summary view without in-progress item if none exists', () => {
+ const pendingItems: ChecklistItemData[] = [
+ { status: 'completed', label: 'Task 1' },
+ { status: 'pending', label: 'Task 2' },
+ ];
+ const { lastFrame } = render(
+ ,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ });
+});
diff --git a/packages/cli/src/ui/components/Checklist.tsx b/packages/cli/src/ui/components/Checklist.tsx
new file mode 100644
index 0000000000..cfbd4268fd
--- /dev/null
+++ b/packages/cli/src/ui/components/Checklist.tsx
@@ -0,0 +1,126 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Box, Text } from 'ink';
+import { theme } from '../semantic-colors.js';
+import { useMemo } from 'react';
+import { ChecklistItem, type ChecklistItemData } from './ChecklistItem.js';
+
+export interface ChecklistProps {
+ title: string;
+ items: ChecklistItemData[];
+ isExpanded: boolean;
+ toggleHint?: string;
+}
+
+const ChecklistTitleDisplay: React.FC<{
+ title: string;
+ items: ChecklistItemData[];
+ toggleHint?: string;
+}> = ({ title, items, toggleHint }) => {
+ const score = useMemo(() => {
+ let total = 0;
+ let completed = 0;
+ for (const item of items) {
+ if (item.status !== 'cancelled') {
+ total += 1;
+ if (item.status === 'completed') {
+ completed += 1;
+ }
+ }
+ }
+ return `${completed}/${total} completed`;
+ }, [items]);
+
+ return (
+
+
+ {title}
+
+
+ {score}
+ {toggleHint ? ` (${toggleHint})` : ''}
+
+
+ );
+};
+
+const ChecklistListDisplay: React.FC<{ items: ChecklistItemData[] }> = ({
+ items,
+}) => (
+
+ {items.map((item, index) => (
+
+ ))}
+
+);
+
+export const Checklist: React.FC = ({
+ title,
+ items,
+ isExpanded,
+ toggleHint,
+}) => {
+ const inProgress: ChecklistItemData | null = useMemo(
+ () => items.find((item) => item.status === 'in_progress') || null,
+ [items],
+ );
+
+ const hasActiveItems = useMemo(
+ () =>
+ items.some(
+ (item) => item.status === 'pending' || item.status === 'in_progress',
+ ),
+ [items],
+ );
+
+ if (items.length === 0 || (!isExpanded && !hasActiveItems)) {
+ return null;
+ }
+
+ return (
+
+ {isExpanded ? (
+
+
+
+
+ ) : (
+
+
+
+
+ {inProgress && (
+
+
+
+ )}
+
+ )}
+
+ );
+};
diff --git a/packages/cli/src/ui/components/ChecklistItem.test.tsx b/packages/cli/src/ui/components/ChecklistItem.test.tsx
new file mode 100644
index 0000000000..7d52f07ae6
--- /dev/null
+++ b/packages/cli/src/ui/components/ChecklistItem.test.tsx
@@ -0,0 +1,50 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { render } from '../../test-utils/render.js';
+import { describe, it, expect } from 'vitest';
+import { ChecklistItem, type ChecklistItemData } from './ChecklistItem.js';
+import { Box } from 'ink';
+
+describe('', () => {
+ it.each([
+ { status: 'pending', label: 'Do this' },
+ { status: 'in_progress', label: 'Doing this' },
+ { status: 'completed', label: 'Done this' },
+ { status: 'cancelled', label: 'Skipped this' },
+ ] as ChecklistItemData[])('renders %s item correctly', (item) => {
+ const { lastFrame } = render();
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('truncates long text when wrap="truncate"', () => {
+ const item: ChecklistItemData = {
+ status: 'in_progress',
+ label:
+ 'This is a very long text that should be truncated because the wrap prop is set to truncate',
+ };
+ const { lastFrame } = render(
+
+
+ ,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ });
+
+ it('wraps long text by default', () => {
+ const item: ChecklistItemData = {
+ status: 'in_progress',
+ label:
+ 'This is a very long text that should wrap because the default behavior is wrapping',
+ };
+ const { lastFrame } = render(
+
+
+ ,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ });
+});
diff --git a/packages/cli/src/ui/components/ChecklistItem.tsx b/packages/cli/src/ui/components/ChecklistItem.tsx
new file mode 100644
index 0000000000..922cec97e1
--- /dev/null
+++ b/packages/cli/src/ui/components/ChecklistItem.tsx
@@ -0,0 +1,92 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Box, Text } from 'ink';
+import { theme } from '../semantic-colors.js';
+import { checkExhaustive } from '../../utils/checks.js';
+
+export type ChecklistStatus =
+ | 'pending'
+ | 'in_progress'
+ | 'completed'
+ | 'cancelled';
+
+export interface ChecklistItemData {
+ status: ChecklistStatus;
+ label: string;
+}
+
+const ChecklistStatusDisplay: React.FC<{ status: ChecklistStatus }> = ({
+ status,
+}) => {
+ switch (status) {
+ case 'completed':
+ return (
+
+ ✓
+
+ );
+ case 'in_progress':
+ return (
+
+ »
+
+ );
+ case 'pending':
+ return (
+
+ ☐
+
+ );
+ case 'cancelled':
+ return (
+
+ ✗
+
+ );
+ default:
+ checkExhaustive(status);
+ }
+};
+
+export interface ChecklistItemProps {
+ item: ChecklistItemData;
+ wrap?: 'truncate';
+ role?: 'listitem';
+}
+
+export const ChecklistItem: React.FC = ({
+ item,
+ wrap,
+ role: ariaRole,
+}) => {
+ const textColor = (() => {
+ switch (item.status) {
+ case 'in_progress':
+ return theme.text.accent;
+ case 'completed':
+ case 'cancelled':
+ return theme.text.secondary;
+ case 'pending':
+ return theme.text.primary;
+ default:
+ checkExhaustive(item.status);
+ }
+ })();
+ const strikethrough = item.status === 'cancelled';
+
+ return (
+
+
+
+
+ {item.label}
+
+
+
+ );
+};
diff --git a/packages/cli/src/ui/components/__snapshots__/Checklist.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Checklist.test.tsx.snap
new file mode 100644
index 0000000000..cc8d2cf036
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/Checklist.test.tsx.snap
@@ -0,0 +1,21 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[` > renders expanded view correctly 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ Test List 1/3 completed (toggle me)
+
+ ✓ Task 1
+ » Task 2
+ ☐ Task 3
+ ✗ Task 4"
+`;
+
+exports[` > renders summary view correctly (collapsed) 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ Test List 1/3 completed (toggle me) » Task 2"
+`;
+
+exports[` > renders summary view without in-progress item if none exists 1`] = `
+"────────────────────────────────────────────────────────────────────────────────────────────────────
+ Test List 1/2 completed"
+`;
diff --git a/packages/cli/src/ui/components/__snapshots__/ChecklistItem.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ChecklistItem.test.tsx.snap
new file mode 100644
index 0000000000..ce1247e376
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/ChecklistItem.test.tsx.snap
@@ -0,0 +1,17 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[` > renders { status: 'cancelled', label: 'Skipped this' } item correctly 1`] = `"✗ Skipped this"`;
+
+exports[` > renders { status: 'completed', label: 'Done this' } item correctly 1`] = `"✓ Done this"`;
+
+exports[` > renders { status: 'in_progress', label: 'Doing this' } item correctly 1`] = `"» Doing this"`;
+
+exports[` > renders { status: 'pending', label: 'Do this' } item correctly 1`] = `"☐ Do this"`;
+
+exports[` > truncates long text when wrap="truncate" 1`] = `"» This is a very long text th…"`;
+
+exports[` > wraps long text by default 1`] = `
+"» This is a very long text
+ that should wrap because the
+ default behavior is wrapping"
+`;
diff --git a/packages/cli/src/ui/components/messages/Todo.tsx b/packages/cli/src/ui/components/messages/Todo.tsx
index fcbc92aafd..4f2b95fd3c 100644
--- a/packages/cli/src/ui/components/messages/Todo.tsx
+++ b/packages/cli/src/ui/components/messages/Todo.tsx
@@ -5,101 +5,12 @@
*/
import type React from 'react';
-import { Box, Text } from 'ink';
-import {
- type Todo,
- type TodoList,
- type TodoStatus,
-} from '@google/gemini-cli-core';
-import { theme } from '../../semantic-colors.js';
+import { type TodoList } from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { useMemo } from 'react';
import type { HistoryItemToolGroup } from '../../types.js';
-
-const TodoTitleDisplay: React.FC<{ todos: TodoList }> = ({ todos }) => {
- const score = useMemo(() => {
- let total = 0;
- let completed = 0;
- for (const todo of todos.todos) {
- if (todo.status !== 'cancelled') {
- total += 1;
- if (todo.status === 'completed') {
- completed += 1;
- }
- }
- }
- return `${completed}/${total} completed`;
- }, [todos]);
-
- return (
-
-
- Todo
-
- {score} (ctrl+t to toggle)
-
- );
-};
-
-const TodoStatusDisplay: React.FC<{ status: TodoStatus }> = ({ status }) => {
- switch (status) {
- case 'completed':
- return (
-
- ✓
-
- );
- case 'in_progress':
- return (
-
- »
-
- );
- case 'pending':
- return (
-
- ☐
-
- );
- case 'cancelled':
- default:
- return (
-
- ✗
-
- );
- }
-};
-
-const TodoItemDisplay: React.FC<{
- todo: Todo;
- wrap?: 'truncate';
- role?: 'listitem';
-}> = ({ todo, wrap, role: ariaRole }) => {
- const textColor = (() => {
- switch (todo.status) {
- case 'in_progress':
- return theme.text.accent;
- case 'completed':
- case 'cancelled':
- return theme.text.secondary;
- default:
- return theme.text.primary;
- }
- })();
- const strikethrough = todo.status === 'cancelled';
-
- return (
-
-
-
-
- {todo.description}
-
-
-
- );
-};
+import { Checklist } from '../Checklist.js';
+import type { ChecklistItemData } from '../ChecklistItem.js';
export const TodoTray: React.FC = () => {
const uiState = useUIState();
@@ -125,68 +36,26 @@ export const TodoTray: React.FC = () => {
return null;
}, [uiState.history]);
- const inProgress: Todo | null = useMemo(() => {
- if (todos === null) {
- return null;
+ const checklistItems: ChecklistItemData[] = useMemo(() => {
+ if (!todos || !todos.todos) {
+ return [];
}
- return todos.todos.find((todo) => todo.status === 'in_progress') || null;
+ return todos.todos.map((todo) => ({
+ status: todo.status,
+ label: todo.description,
+ }));
}, [todos]);
- const hasActiveTodos = useMemo(() => {
- if (!todos || !todos.todos) return false;
- return todos.todos.some(
- (todo) => todo.status === 'pending' || todo.status === 'in_progress',
- );
- }, [todos]);
-
- if (
- todos === null ||
- !todos.todos ||
- todos.todos.length === 0 ||
- (!uiState.showFullTodos && !hasActiveTodos)
- ) {
+ if (!todos || !todos.todos) {
return null;
}
return (
-
- {uiState.showFullTodos ? (
-
-
-
-
- ) : (
-
-
-
-
- {inProgress && (
-
-
-
- )}
-
- )}
-
+
);
};
-
-interface TodoListDisplayProps {
- todos: TodoList;
-}
-
-const TodoListDisplay: React.FC = ({ todos }) => (
-
- {todos.todos.map((todo: Todo, index: number) => (
-
- ))}
-
-);