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) => ( - - ))} - -);