Create Todo List Tab (#11430)

This commit is contained in:
Tommaso Sciortino
2025-10-17 21:10:57 -07:00
committed by GitHub
parent 2ef38065c7
commit cd76b0b22d
12 changed files with 338 additions and 120 deletions

View File

@@ -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 }],

View File

@@ -788,6 +788,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
);
const [showErrorDetails, setShowErrorDetails] = useState<boolean>(false);
const [showFullTodos, setShowFullTodos] = useState<boolean>(false);
const [renderMarkdown, setRenderMarkdown] = useState<boolean>(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,

View File

@@ -113,6 +113,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
geminiMdFileCount: 0,
renderMarkdown: true,
filteredConsoleMessages: [],
history: [],
sessionStats: {
lastPromptTokenCount: 0,
sessionTokenCount: 0,

View File

@@ -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 = () => {
</OverflowProvider>
)}
<AnchoredTodoListDisplay />
{uiState.isInputActive && (
<InputPrompt
buffer={uiState.buffer}

View File

@@ -0,0 +1,142 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect } from 'vitest';
import { Box } from 'ink';
import { AnchoredTodoListDisplay, TodoListDisplay } from './Todo.js';
import type { TodoList, TodoStatus } from '@google/gemini-cli-core';
import type { UIState } from '../../contexts/UIStateContext.js';
import { UIStateContext } from '../../contexts/UIStateContext.js';
import type { HistoryItem } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
describe('<TodoListDisplay />', () => {
it('renders an empty todo list correctly', () => {
const todos: TodoList = { todos: [] };
const { lastFrame } = render(<TodoListDisplay todos={todos} />);
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(<TodoListDisplay todos={todos} />);
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(
<Box width="30">
<TodoListDisplay todos={todos} />
</Box>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders a single todo item', () => {
const todos: TodoList = {
todos: [{ description: 'Single task', status: 'pending' as TodoStatus }],
};
const { lastFrame } = render(<TodoListDisplay todos={todos} />);
expect(lastFrame()).toMatchSnapshot();
});
});
describe('<AnchoredTodoListDisplay />', () => {
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<UIState>) =>
render(
<UIStateContext.Provider value={uiState as UIState}>
<AnchoredTodoListDisplay />
</UIStateContext.Provider>,
);
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();
});
});

View File

@@ -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 }) => (
<Box flexDirection="row">
<Box marginRight={1}>
<TodoStatusDisplay status={todo.status} />
</Box>
<Box flexShrink={1}>
<Text color={theme.text.primary}>{todo.description}</Text>
</Box>
</Box>
);
const TodoStatusDisplay: React.FC<{ status: TodoStatus }> = ({ status }) => {
switch (status) {
case 'completed':
return <Text color={theme.status.success}></Text>;
case 'in_progress':
return <Text color={theme.text.accent}>»</Text>;
case 'pending':
return <Text color={theme.text.primary}></Text>;
case 'cancelled':
default:
return <Text color={theme.status.error}></Text>;
}
};
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 (
<Box
borderStyle="single"
paddingLeft={1}
paddingRight={1}
borderBottom={false}
flexDirection="column"
borderColor={theme.border.default}
>
<Text color={theme.text.accent}>
📝 Todo:
<Text color={theme.text.secondary}>(ctrl+t to collapse)</Text>
</Text>
<Box paddingLeft={4} paddingRight={2} paddingTop={1}>
<TodoListDisplay todos={todos!} />
</Box>
</Box>
);
}
if (inProgress === null) {
return null;
}
return (
<Box
borderStyle="single"
paddingLeft={1}
paddingRight={1}
borderBottom={false}
flexDirection="row"
borderColor={theme.border.default}
>
<Text color={theme.text.accent}>
📝 Todo:
<Text color={theme.text.secondary}>(ctrl+t to expand)</Text>
</Text>
<TodoItemDisplay todo={inProgress} />
</Box>
);
};
export interface TodoListDisplayProps {
todos: TodoList;
}
export const TodoListDisplay: React.FC<TodoListDisplayProps> = ({ todos }) => (
<Box flexDirection="column">
{todos.todos.map((todo: Todo, index: number) => (
<TodoItemDisplay todo={todo} key={index} />
))}
</Box>
);

View File

@@ -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('<TodoListDisplay />', () => {
const terminalWidth = 80;
it('renders an empty todo list correctly', () => {
const todos: TodoList = { todos: [] };
const { lastFrame } = render(
<TodoListDisplay todos={todos} terminalWidth={terminalWidth} />,
);
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(
<TodoListDisplay todos={todos} terminalWidth={terminalWidth} />,
);
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(
<TodoListDisplay todos={todos} terminalWidth={40} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders a single todo item', () => {
const todos: TodoList = {
todos: [{ description: 'Single task', status: 'pending' as TodoStatus }],
};
const { lastFrame } = render(
<TodoListDisplay todos={todos} terminalWidth={terminalWidth} />,
);
expect(lastFrame()).toMatchSnapshot();
});
});

View File

@@ -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 <Text color={theme.status.success}></Text>;
case 'in_progress':
return <Text color={theme.text.accent}>»</Text>;
case 'pending':
return <Text color={theme.text.primary}></Text>;
case 'cancelled':
return <Text color={theme.status.error}></Text>;
default:
return null;
}
};
export const TodoListDisplay: React.FC<TodoListDisplayProps> = ({
todos,
terminalWidth,
}) => (
<Box flexDirection="column" width={terminalWidth}>
{todos.todos.map((todo: Todo, index: number) => (
<Box key={index} flexDirection="row">
<Box marginRight={1}>
<TodoStatusDisplay status={todo.status} />
</Box>
<Box flexShrink={1}>
<Text color={theme.text.primary}>{todo.description}</Text>
</Box>
</Box>
))}
</Box>
);

View File

@@ -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<ToolMessageProps> = ({
/>
) : typeof resultDisplay === 'object' &&
'todos' in resultDisplay ? (
<TodoListDisplay
todos={resultDisplay as TodoList}
terminalWidth={childWidth}
/>
<TodoListDisplay todos={resultDisplay as TodoList} />
) : (
<AnsiOutputText
data={resultDisplay as AnsiOutput}

View File

@@ -0,0 +1,43 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<AnchoredTodoListDisplay /> > renders null when no todos are in the history 1`] = `""`;
exports[`<AnchoredTodoListDisplay /> > renders null when todos exist but none are in progress and full view is off 1`] = `""`;
exports[`<AnchoredTodoListDisplay /> > renders only the in-progress task when full view is off 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 📝 Todo:(ctrl+t to expand)» In Progress Task │"
`;
exports[`<AnchoredTodoListDisplay /> > renders the full todo list when full view is on 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 📝 Todo:(ctrl+t to collapse) │
│ │
│ ☐ Pending Task │
│ » In Progress Task │
│ ✓ Completed Task │"
`;
exports[`<TodoListDisplay /> > renders a single todo item 1`] = `"☐ Single task"`;
exports[`<TodoListDisplay /> > 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[`<TodoListDisplay /> > renders a todo list with various statuses correctly 1`] = `
"☐ Task 1
» Task 2
✓ Task 3
✗ Task 4"
`;
exports[`<TodoListDisplay /> > renders an empty todo list correctly 1`] = `""`;

View File

@@ -120,6 +120,7 @@ export interface UIState {
activePtyId: number | undefined;
embeddedShellFocused: boolean;
showDebugProfiler: boolean;
showFullTodos: boolean;
}
export const UIStateContext = createContext<UIState | null>(null);

View File

@@ -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 })],