From cd76b0b22d3c00ee276a3b29b654e3a88219eccd Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Fri, 17 Oct 2025 21:10:57 -0700 Subject: [PATCH] Create Todo List Tab (#11430) --- packages/cli/src/config/keyBindings.ts | 2 + packages/cli/src/ui/AppContainer.tsx | 5 + .../cli/src/ui/components/Composer.test.tsx | 1 + packages/cli/src/ui/components/Composer.tsx | 3 + .../src/ui/components/messages/Todo.test.tsx | 142 ++++++++++++++++++ .../cli/src/ui/components/messages/Todo.tsx | 133 ++++++++++++++++ .../messages/TodoListDisplay.test.tsx | 68 --------- .../components/messages/TodoListDisplay.tsx | 47 ------ .../ui/components/messages/ToolMessage.tsx | 7 +- .../messages/__snapshots__/Todo.test.tsx.snap | 43 ++++++ .../cli/src/ui/contexts/UIStateContext.tsx | 1 + packages/cli/src/ui/keyMatchers.test.ts | 6 + 12 files changed, 338 insertions(+), 120 deletions(-) create mode 100644 packages/cli/src/ui/components/messages/Todo.test.tsx create mode 100644 packages/cli/src/ui/components/messages/Todo.tsx delete mode 100644 packages/cli/src/ui/components/messages/TodoListDisplay.test.tsx delete mode 100644 packages/cli/src/ui/components/messages/TodoListDisplay.tsx create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/Todo.test.tsx.snap diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index a199370c5e..0c5d54146c 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -46,6 +46,7 @@ export enum Command { // App level bindings SHOW_ERROR_DETAILS = 'showErrorDetails', + SHOW_FULL_TODOS = 'showFullTodos', TOGGLE_IDE_CONTEXT_DETAIL = 'toggleIDEContextDetail', TOGGLE_MARKDOWN = 'toggleMarkdown', QUIT = 'quit', @@ -156,6 +157,7 @@ export const defaultKeyBindings: KeyBindingConfig = { // App level bindings [Command.SHOW_ERROR_DETAILS]: [{ key: 'o', ctrl: true }], + [Command.SHOW_FULL_TODOS]: [{ key: 't', ctrl: true }], [Command.TOGGLE_IDE_CONTEXT_DETAIL]: [{ key: 'g', ctrl: true }], [Command.TOGGLE_MARKDOWN]: [{ key: 'm', command: true }], [Command.QUIT]: [{ key: 'c', ctrl: true }], diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index a839fb7f7c..caa0178347 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -788,6 +788,7 @@ Logging in with Google... Please restart Gemini CLI to continue. ); const [showErrorDetails, setShowErrorDetails] = useState(false); + const [showFullTodos, setShowFullTodos] = useState(false); const [renderMarkdown, setRenderMarkdown] = useState(true); const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false); @@ -961,6 +962,8 @@ Logging in with Google... Please restart Gemini CLI to continue. if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) { setShowErrorDetails((prev) => !prev); + } else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) { + setShowFullTodos((prev) => !prev); } else if (keyMatchers[Command.TOGGLE_MARKDOWN](key)) { setRenderMarkdown((prev) => { const newValue = !prev; @@ -1129,6 +1132,7 @@ Logging in with Google... Please restart Gemini CLI to continue. isTrustedFolder, constrainHeight, showErrorDetails, + showFullTodos, filteredConsoleMessages, ideContextState, renderMarkdown, @@ -1209,6 +1213,7 @@ Logging in with Google... Please restart Gemini CLI to continue. isTrustedFolder, constrainHeight, showErrorDetails, + showFullTodos, filteredConsoleMessages, ideContextState, renderMarkdown, diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index b533f5963b..6173355c42 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -113,6 +113,7 @@ const createMockUIState = (overrides: Partial = {}): UIState => geminiMdFileCount: 0, renderMarkdown: true, filteredConsoleMessages: [], + history: [], sessionStats: { lastPromptTokenCount: 0, sessionTokenCount: 0, diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index b61c3c7e58..3a4d16228d 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -26,6 +26,7 @@ import { useSettings } from '../contexts/SettingsContext.js'; import { ApprovalMode } from '@google/gemini-cli-core'; import { StreamingState } from '../types.js'; import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js'; +import { AnchoredTodoListDisplay } from './messages/Todo.js'; export const Composer = () => { const config = useConfig(); @@ -129,6 +130,8 @@ export const Composer = () => { )} + + {uiState.isInputActive && ( ', () => { + 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(); + }); +}); + +describe('', () => { + const mockHistoryItem = { + type: 'tool_group', + id: '1', + tools: [ + { + name: 'write_todos_list', + callId: 'tool-1', + status: ToolCallStatus.Success, + resultDisplay: { + todos: [ + { description: 'Pending Task', status: 'pending' }, + { description: 'In Progress Task', status: 'in_progress' }, + { description: 'Completed Task', status: 'completed' }, + ], + }, + }, + ], + } as unknown as HistoryItem; + + const renderWithUiState = (uiState: Partial) => + render( + + + , + ); + + it('renders null when no todos are in the history', () => { + const { lastFrame } = renderWithUiState({ history: [] }); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders null when todos exist but none are in progress and full view is off', () => { + const historyWithNoInProgress = { + type: 'tool_group', + id: '1', + tools: [ + { + name: 'write_todos_list', + callId: 'tool-1', + status: ToolCallStatus.Success, + resultDisplay: { + todos: [ + { description: 'Pending Task', status: 'pending' }, + { description: 'In Progress Task', status: 'cancelled' }, + { description: 'Completed Task', status: 'completed' }, + ], + }, + }, + ], + } as unknown as HistoryItem; + const { lastFrame } = renderWithUiState({ + history: [historyWithNoInProgress], + showFullTodos: false, + }); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders only the in-progress task when full view is off', () => { + const { lastFrame } = renderWithUiState({ + history: [mockHistoryItem], + showFullTodos: false, + }); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders the full todo list when full view is on', () => { + const { lastFrame } = renderWithUiState({ + history: [mockHistoryItem], + showFullTodos: true, + }); + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/messages/Todo.tsx b/packages/cli/src/ui/components/messages/Todo.tsx new file mode 100644 index 0000000000..80f19afea4 --- /dev/null +++ b/packages/cli/src/ui/components/messages/Todo.tsx @@ -0,0 +1,133 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +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 { useUIState } from '../../contexts/UIStateContext.js'; +import { useMemo } from 'react'; +import type { HistoryItemToolGroup } from '../../types.js'; + +const TodoItemDisplay: React.FC<{ todo: Todo }> = ({ todo }) => ( + + + + + + {todo.description} + + +); + +const TodoStatusDisplay: React.FC<{ status: TodoStatus }> = ({ status }) => { + switch (status) { + case 'completed': + return โœ“; + case 'in_progress': + return ยป; + case 'pending': + return โ˜; + case 'cancelled': + default: + return โœ—; + } +}; + +export const AnchoredTodoListDisplay: React.FC = () => { + const uiState = useUIState(); + + const todos: TodoList | null = useMemo(() => { + // Find the most recent todo list written by the WriteTodosTool + for (let i = uiState.history.length - 1; i >= 0; i--) { + const entry = uiState.history[i]; + if (entry.type !== 'tool_group') { + continue; + } + const toolGroup = entry as HistoryItemToolGroup; + for (const tool of toolGroup.tools) { + if ( + typeof tool.resultDisplay !== 'object' || + !('todos' in tool.resultDisplay) + ) { + continue; + } + return tool.resultDisplay as TodoList; + } + } + return null; + }, [uiState.history]); + + const inProgress: Todo | null = useMemo(() => { + if (todos === null) { + return null; + } + return todos.todos.find((todo) => todo.status === 'in_progress') || null; + }, [todos]); + + if (todos === null) { + return null; + } + + if (uiState.showFullTodos) { + return ( + + + ๐Ÿ“ Todo: + (ctrl+t to collapse) + + + + + + + ); + } + + if (inProgress === null) { + return null; + } + + return ( + + + ๐Ÿ“ Todo: + (ctrl+t to expand) + + + + ); +}; + +export interface TodoListDisplayProps { + todos: TodoList; +} + +export const TodoListDisplay: React.FC = ({ todos }) => ( + + {todos.todos.map((todo: Todo, index: number) => ( + + ))} + +); diff --git a/packages/cli/src/ui/components/messages/TodoListDisplay.test.tsx b/packages/cli/src/ui/components/messages/TodoListDisplay.test.tsx deleted file mode 100644 index c2955d406a..0000000000 --- a/packages/cli/src/ui/components/messages/TodoListDisplay.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/** - * @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 deleted file mode 100644 index cc607ae471..0000000000 --- a/packages/cli/src/ui/components/messages/TodoListDisplay.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/** - * @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 df5e81889a..c4326a1700 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -13,7 +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 { TodoListDisplay } from './Todo.js'; import { ShellInputPrompt } from '../ShellInputPrompt.js'; import { SHELL_COMMAND_NAME, @@ -173,10 +173,7 @@ export const ToolMessage: React.FC = ({ /> ) : typeof resultDisplay === 'object' && 'todos' in resultDisplay ? ( - + ) : ( > renders null when no todos are in the history 1`] = `""`; + +exports[` > renders null when todos exist but none are in progress and full view is off 1`] = `""`; + +exports[` > renders only the in-progress task when full view is off 1`] = ` +"โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ ๐Ÿ“ Todo:(ctrl+t to expand)ยป In Progress Task โ”‚" +`; + +exports[` > renders the full todo list when full view is on 1`] = ` +"โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ ๐Ÿ“ Todo:(ctrl+t to collapse) โ”‚ +โ”‚ โ”‚ +โ”‚ โ˜ Pending Task โ”‚ +โ”‚ ยป In Progress Task โ”‚ +โ”‚ โœ“ Completed Task โ”‚" +`; + +exports[` > 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/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index d893d38c10..ba45504ff0 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -120,6 +120,7 @@ export interface UIState { activePtyId: number | undefined; embeddedShellFocused: boolean; showDebugProfiler: boolean; + showFullTodos: boolean; } export const UIStateContext = createContext(null); diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 39ac918a27..46f492f090 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -51,6 +51,7 @@ describe('keyMatchers', () => { key.ctrl && (key.name === 'x' || key.sequence === '\x18'), [Command.PASTE_CLIPBOARD_IMAGE]: (key: Key) => key.ctrl && key.name === 'v', [Command.SHOW_ERROR_DETAILS]: (key: Key) => key.ctrl && key.name === 'o', + [Command.SHOW_FULL_TODOS]: (key: Key) => key.ctrl && key.name === 't', [Command.TOGGLE_IDE_CONTEXT_DETAIL]: (key: Key) => key.ctrl && key.name === 'g', [Command.TOGGLE_MARKDOWN]: (key: Key) => key.meta && key.name === 'm', @@ -214,6 +215,11 @@ describe('keyMatchers', () => { positive: [createKey('o', { ctrl: true })], negative: [createKey('o'), createKey('e', { ctrl: true })], }, + { + command: Command.SHOW_FULL_TODOS, + positive: [createKey('t', { ctrl: true })], + negative: [createKey('t'), createKey('e', { ctrl: true })], + }, { command: Command.TOGGLE_IDE_CONTEXT_DETAIL, positive: [createKey('g', { ctrl: true })],