Improve rendering of ToDo lists. (#11315)

This commit is contained in:
Tommaso Sciortino
2025-10-16 16:06:43 -07:00
committed by GitHub
parent c71b749185
commit 9a4211b610
7 changed files with 161 additions and 12 deletions

View File

@@ -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('<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

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

View File

@@ -0,0 +1,22 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
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

@@ -541,7 +541,18 @@ export function hasCycleInSchema(schema: object): boolean {
return traverse(schema, new Set<string>(), new Set<string>());
}
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;

View File

@@ -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);
});
});
});

View File

@@ -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
</example>
`;
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 },
};
}
}