Skip to content

Commit cd76b0b

Browse files
authored
Create Todo List Tab (#11430)
1 parent 2ef3806 commit cd76b0b

12 files changed

Lines changed: 338 additions & 120 deletions

File tree

packages/cli/src/config/keyBindings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export enum Command {
4646

4747
// App level bindings
4848
SHOW_ERROR_DETAILS = 'showErrorDetails',
49+
SHOW_FULL_TODOS = 'showFullTodos',
4950
TOGGLE_IDE_CONTEXT_DETAIL = 'toggleIDEContextDetail',
5051
TOGGLE_MARKDOWN = 'toggleMarkdown',
5152
QUIT = 'quit',
@@ -156,6 +157,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
156157

157158
// App level bindings
158159
[Command.SHOW_ERROR_DETAILS]: [{ key: 'o', ctrl: true }],
160+
[Command.SHOW_FULL_TODOS]: [{ key: 't', ctrl: true }],
159161
[Command.TOGGLE_IDE_CONTEXT_DETAIL]: [{ key: 'g', ctrl: true }],
160162
[Command.TOGGLE_MARKDOWN]: [{ key: 'm', command: true }],
161163
[Command.QUIT]: [{ key: 'c', ctrl: true }],

packages/cli/src/ui/AppContainer.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
788788
);
789789

790790
const [showErrorDetails, setShowErrorDetails] = useState<boolean>(false);
791+
const [showFullTodos, setShowFullTodos] = useState<boolean>(false);
791792
const [renderMarkdown, setRenderMarkdown] = useState<boolean>(true);
792793

793794
const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false);
@@ -961,6 +962,8 @@ Logging in with Google... Please restart Gemini CLI to continue.
961962

962963
if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) {
963964
setShowErrorDetails((prev) => !prev);
965+
} else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) {
966+
setShowFullTodos((prev) => !prev);
964967
} else if (keyMatchers[Command.TOGGLE_MARKDOWN](key)) {
965968
setRenderMarkdown((prev) => {
966969
const newValue = !prev;
@@ -1129,6 +1132,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
11291132
isTrustedFolder,
11301133
constrainHeight,
11311134
showErrorDetails,
1135+
showFullTodos,
11321136
filteredConsoleMessages,
11331137
ideContextState,
11341138
renderMarkdown,
@@ -1209,6 +1213,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
12091213
isTrustedFolder,
12101214
constrainHeight,
12111215
showErrorDetails,
1216+
showFullTodos,
12121217
filteredConsoleMessages,
12131218
ideContextState,
12141219
renderMarkdown,

packages/cli/src/ui/components/Composer.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
113113
geminiMdFileCount: 0,
114114
renderMarkdown: true,
115115
filteredConsoleMessages: [],
116+
history: [],
116117
sessionStats: {
117118
lastPromptTokenCount: 0,
118119
sessionTokenCount: 0,

packages/cli/src/ui/components/Composer.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { useSettings } from '../contexts/SettingsContext.js';
2626
import { ApprovalMode } from '@google/gemini-cli-core';
2727
import { StreamingState } from '../types.js';
2828
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
29+
import { AnchoredTodoListDisplay } from './messages/Todo.js';
2930

3031
export const Composer = () => {
3132
const config = useConfig();
@@ -129,6 +130,8 @@ export const Composer = () => {
129130
</OverflowProvider>
130131
)}
131132

133+
<AnchoredTodoListDisplay />
134+
132135
{uiState.isInputActive && (
133136
<InputPrompt
134137
buffer={uiState.buffer}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { render } from 'ink-testing-library';
8+
import { describe, it, expect } from 'vitest';
9+
import { Box } from 'ink';
10+
import { AnchoredTodoListDisplay, TodoListDisplay } from './Todo.js';
11+
import type { TodoList, TodoStatus } from '@google/gemini-cli-core';
12+
import type { UIState } from '../../contexts/UIStateContext.js';
13+
import { UIStateContext } from '../../contexts/UIStateContext.js';
14+
import type { HistoryItem } from '../../types.js';
15+
import { ToolCallStatus } from '../../types.js';
16+
17+
describe('<TodoListDisplay />', () => {
18+
it('renders an empty todo list correctly', () => {
19+
const todos: TodoList = { todos: [] };
20+
const { lastFrame } = render(<TodoListDisplay todos={todos} />);
21+
expect(lastFrame()).toMatchSnapshot();
22+
});
23+
24+
it('renders a todo list with various statuses correctly', () => {
25+
const todos: TodoList = {
26+
todos: [
27+
{ description: 'Task 1', status: 'pending' as TodoStatus },
28+
{ description: 'Task 2', status: 'in_progress' as TodoStatus },
29+
{ description: 'Task 3', status: 'completed' as TodoStatus },
30+
{ description: 'Task 4', status: 'cancelled' as TodoStatus },
31+
],
32+
};
33+
const { lastFrame } = render(<TodoListDisplay todos={todos} />);
34+
expect(lastFrame()).toMatchSnapshot();
35+
});
36+
37+
it('renders a todo list with long descriptions that wrap', () => {
38+
const todos: TodoList = {
39+
todos: [
40+
{
41+
description:
42+
'This is a very long description for a pending task that should wrap around multiple lines when the terminal width is constrained.',
43+
status: 'pending' as TodoStatus,
44+
},
45+
{
46+
description:
47+
'Another completed task with an equally verbose description to test wrapping behavior.',
48+
status: 'completed' as TodoStatus,
49+
},
50+
],
51+
};
52+
const { lastFrame } = render(
53+
<Box width="30">
54+
<TodoListDisplay todos={todos} />
55+
</Box>,
56+
);
57+
expect(lastFrame()).toMatchSnapshot();
58+
});
59+
60+
it('renders a single todo item', () => {
61+
const todos: TodoList = {
62+
todos: [{ description: 'Single task', status: 'pending' as TodoStatus }],
63+
};
64+
const { lastFrame } = render(<TodoListDisplay todos={todos} />);
65+
expect(lastFrame()).toMatchSnapshot();
66+
});
67+
});
68+
69+
describe('<AnchoredTodoListDisplay />', () => {
70+
const mockHistoryItem = {
71+
type: 'tool_group',
72+
id: '1',
73+
tools: [
74+
{
75+
name: 'write_todos_list',
76+
callId: 'tool-1',
77+
status: ToolCallStatus.Success,
78+
resultDisplay: {
79+
todos: [
80+
{ description: 'Pending Task', status: 'pending' },
81+
{ description: 'In Progress Task', status: 'in_progress' },
82+
{ description: 'Completed Task', status: 'completed' },
83+
],
84+
},
85+
},
86+
],
87+
} as unknown as HistoryItem;
88+
89+
const renderWithUiState = (uiState: Partial<UIState>) =>
90+
render(
91+
<UIStateContext.Provider value={uiState as UIState}>
92+
<AnchoredTodoListDisplay />
93+
</UIStateContext.Provider>,
94+
);
95+
96+
it('renders null when no todos are in the history', () => {
97+
const { lastFrame } = renderWithUiState({ history: [] });
98+
expect(lastFrame()).toMatchSnapshot();
99+
});
100+
101+
it('renders null when todos exist but none are in progress and full view is off', () => {
102+
const historyWithNoInProgress = {
103+
type: 'tool_group',
104+
id: '1',
105+
tools: [
106+
{
107+
name: 'write_todos_list',
108+
callId: 'tool-1',
109+
status: ToolCallStatus.Success,
110+
resultDisplay: {
111+
todos: [
112+
{ description: 'Pending Task', status: 'pending' },
113+
{ description: 'In Progress Task', status: 'cancelled' },
114+
{ description: 'Completed Task', status: 'completed' },
115+
],
116+
},
117+
},
118+
],
119+
} as unknown as HistoryItem;
120+
const { lastFrame } = renderWithUiState({
121+
history: [historyWithNoInProgress],
122+
showFullTodos: false,
123+
});
124+
expect(lastFrame()).toMatchSnapshot();
125+
});
126+
127+
it('renders only the in-progress task when full view is off', () => {
128+
const { lastFrame } = renderWithUiState({
129+
history: [mockHistoryItem],
130+
showFullTodos: false,
131+
});
132+
expect(lastFrame()).toMatchSnapshot();
133+
});
134+
135+
it('renders the full todo list when full view is on', () => {
136+
const { lastFrame } = renderWithUiState({
137+
history: [mockHistoryItem],
138+
showFullTodos: true,
139+
});
140+
expect(lastFrame()).toMatchSnapshot();
141+
});
142+
});
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import type React from 'react';
8+
import { Box, Text } from 'ink';
9+
import {
10+
type Todo,
11+
type TodoList,
12+
type TodoStatus,
13+
} from '@google/gemini-cli-core';
14+
import { theme } from '../../semantic-colors.js';
15+
import { useUIState } from '../../contexts/UIStateContext.js';
16+
import { useMemo } from 'react';
17+
import type { HistoryItemToolGroup } from '../../types.js';
18+
19+
const TodoItemDisplay: React.FC<{ todo: Todo }> = ({ todo }) => (
20+
<Box flexDirection="row">
21+
<Box marginRight={1}>
22+
<TodoStatusDisplay status={todo.status} />
23+
</Box>
24+
<Box flexShrink={1}>
25+
<Text color={theme.text.primary}>{todo.description}</Text>
26+
</Box>
27+
</Box>
28+
);
29+
30+
const TodoStatusDisplay: React.FC<{ status: TodoStatus }> = ({ status }) => {
31+
switch (status) {
32+
case 'completed':
33+
return <Text color={theme.status.success}></Text>;
34+
case 'in_progress':
35+
return <Text color={theme.text.accent}>»</Text>;
36+
case 'pending':
37+
return <Text color={theme.text.primary}></Text>;
38+
case 'cancelled':
39+
default:
40+
return <Text color={theme.status.error}></Text>;
41+
}
42+
};
43+
44+
export const AnchoredTodoListDisplay: React.FC = () => {
45+
const uiState = useUIState();
46+
47+
const todos: TodoList | null = useMemo(() => {
48+
// Find the most recent todo list written by the WriteTodosTool
49+
for (let i = uiState.history.length - 1; i >= 0; i--) {
50+
const entry = uiState.history[i];
51+
if (entry.type !== 'tool_group') {
52+
continue;
53+
}
54+
const toolGroup = entry as HistoryItemToolGroup;
55+
for (const tool of toolGroup.tools) {
56+
if (
57+
typeof tool.resultDisplay !== 'object' ||
58+
!('todos' in tool.resultDisplay)
59+
) {
60+
continue;
61+
}
62+
return tool.resultDisplay as TodoList;
63+
}
64+
}
65+
return null;
66+
}, [uiState.history]);
67+
68+
const inProgress: Todo | null = useMemo(() => {
69+
if (todos === null) {
70+
return null;
71+
}
72+
return todos.todos.find((todo) => todo.status === 'in_progress') || null;
73+
}, [todos]);
74+
75+
if (todos === null) {
76+
return null;
77+
}
78+
79+
if (uiState.showFullTodos) {
80+
return (
81+
<Box
82+
borderStyle="single"
83+
paddingLeft={1}
84+
paddingRight={1}
85+
borderBottom={false}
86+
flexDirection="column"
87+
borderColor={theme.border.default}
88+
>
89+
<Text color={theme.text.accent}>
90+
📝 Todo:
91+
<Text color={theme.text.secondary}>(ctrl+t to collapse)</Text>
92+
</Text>
93+
94+
<Box paddingLeft={4} paddingRight={2} paddingTop={1}>
95+
<TodoListDisplay todos={todos!} />
96+
</Box>
97+
</Box>
98+
);
99+
}
100+
101+
if (inProgress === null) {
102+
return null;
103+
}
104+
105+
return (
106+
<Box
107+
borderStyle="single"
108+
paddingLeft={1}
109+
paddingRight={1}
110+
borderBottom={false}
111+
flexDirection="row"
112+
borderColor={theme.border.default}
113+
>
114+
<Text color={theme.text.accent}>
115+
📝 Todo:
116+
<Text color={theme.text.secondary}>(ctrl+t to expand)</Text>
117+
</Text>
118+
<TodoItemDisplay todo={inProgress} />
119+
</Box>
120+
);
121+
};
122+
123+
export interface TodoListDisplayProps {
124+
todos: TodoList;
125+
}
126+
127+
export const TodoListDisplay: React.FC<TodoListDisplayProps> = ({ todos }) => (
128+
<Box flexDirection="column">
129+
{todos.todos.map((todo: Todo, index: number) => (
130+
<TodoItemDisplay todo={todo} key={index} />
131+
))}
132+
</Box>
133+
);

0 commit comments

Comments
 (0)