Apply new style to Todos (#11607)

This commit is contained in:
Tommaso Sciortino
2025-10-21 15:40:45 -07:00
committed by GitHub
parent 16f5f767b2
commit 519bd57e55
5 changed files with 215 additions and 193 deletions

View File

@@ -66,6 +66,8 @@ export const Composer = () => {
<QueuedMessageDisplay messageQueue={uiState.messageQueue} />
<TodoTray />
<Box
marginTop={1}
justifyContent={
@@ -130,8 +132,6 @@ export const Composer = () => {
</OverflowProvider>
)}
<TodoTray />
{uiState.isInputActive && (
<InputPrompt
buffer={uiState.buffer}

View File

@@ -31,12 +31,6 @@ const createTodoHistoryItem = (todos: Todo[]): HistoryItem =>
}) as unknown as HistoryItem;
describe('<TodoTray />', () => {
const mockHistoryItem = createTodoHistoryItem([
{ description: 'Pending Task', status: 'pending' },
{ description: 'In Progress Task', status: 'in_progress' },
{ description: 'Completed Task', status: 'completed' },
]);
const renderWithUiState = (uiState: Partial<UIState>) =>
render(
<UIStateContext.Provider value={uiState as UIState}>
@@ -44,99 +38,106 @@ describe('<TodoTray />', () => {
</UIStateContext.Provider>,
);
it('renders null when no todos are in the history', () => {
const { lastFrame } = renderWithUiState({ history: [] });
expect(lastFrame()).toMatchSnapshot();
});
it.each([true, false])(
'renders null when no todos are in the history',
(showFullTodos) => {
const { lastFrame } = renderWithUiState({ history: [], showFullTodos });
expect(lastFrame()).toMatchSnapshot();
},
);
it('renders null when todos exist but none are in progress and full view is off', () => {
const historyWithNoInProgress = createTodoHistoryItem([
{ description: 'Pending Task', status: 'pending' },
{ description: 'In Progress Task', status: 'cancelled' },
{ description: 'Completed Task', status: 'completed' },
]);
it.each([true, false])(
'renders null when todo list is empty',
(showFullTodos) => {
const { lastFrame } = renderWithUiState({
history: [createTodoHistoryItem([])],
showFullTodos,
});
expect(lastFrame()).toMatchSnapshot();
},
);
it.each([true, false])(
'renders when todos exist but none are in progress',
(showFullTodos) => {
const { lastFrame } = renderWithUiState({
history: [
createTodoHistoryItem([
{ description: 'Pending Task', status: 'pending' },
{ description: 'In Progress Task', status: 'cancelled' },
{ description: 'Completed Task', status: 'completed' },
]),
],
showFullTodos,
});
expect(lastFrame()).toMatchSnapshot();
},
);
it.each([true, false])(
'renders when todos exist and one is in progress',
(showFullTodos) => {
const { lastFrame } = renderWithUiState({
history: [
createTodoHistoryItem([
{ description: 'Pending Task', status: 'pending' },
{ description: 'Task 2', status: 'in_progress' },
{ description: 'In Progress Task', status: 'cancelled' },
{ description: 'Completed Task', status: 'completed' },
]),
],
showFullTodos,
});
expect(lastFrame()).toMatchSnapshot();
},
);
it.each([true, false])(
'renders a todo list with long descriptions that wrap when full view is on',
(showFullTodos) => {
const { lastFrame } = render(
<Box width="50">
<UIStateContext.Provider
value={
{
history: [
createTodoHistoryItem([
{
description:
'This is a very long description for a pending task that should wrap around multiple lines when the terminal width is constrained.',
status: 'in_progress',
},
{
description:
'Another completed task with an equally verbose description to test wrapping behavior.',
status: 'completed',
},
]),
],
showFullTodos,
} as UIState
}
>
<TodoTray />
</UIStateContext.Provider>
</Box>,
);
expect(lastFrame()).toMatchSnapshot();
},
);
it('renders the most recent todo list when multiple write_todos calls are in history', () => {
const { lastFrame } = renderWithUiState({
history: [historyWithNoInProgress],
showFullTodos: false,
});
expect(lastFrame()).toMatchSnapshot();
});
it('renders an empty todo list when full view is on', () => {
const emptyTodosHistoryItem = createTodoHistoryItem([]);
const { lastFrame } = renderWithUiState({
history: [emptyTodosHistoryItem],
showFullTodos: true,
});
expect(lastFrame()).toMatchSnapshot();
});
it('renders a todo list with various statuses when full view is on', () => {
const variousTodosHistoryItem = createTodoHistoryItem([
{ description: 'Task 1', status: 'pending' },
{ description: 'Task 2', status: 'in_progress' },
{ description: 'Task 3', status: 'completed' },
{ description: 'Task 4', status: 'cancelled' },
]);
const { lastFrame } = renderWithUiState({
history: [variousTodosHistoryItem],
showFullTodos: true,
});
expect(lastFrame()).toMatchSnapshot();
});
it('renders a todo list with long descriptions that wrap when full view is on', () => {
const longDescriptionTodosHistoryItem = createTodoHistoryItem([
{
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',
},
{
description:
'Another completed task with an equally verbose description to test wrapping behavior.',
status: 'completed',
},
]);
const { lastFrame } = render(
<Box width="30">
<UIStateContext.Provider
value={
{
history: [longDescriptionTodosHistoryItem],
showFullTodos: true,
} as UIState
}
>
<TodoTray />
</UIStateContext.Provider>
</Box>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders a single todo item when full view is on', () => {
const singleTodoHistoryItem = createTodoHistoryItem([
{ description: 'Single task', status: 'pending' },
]);
const { lastFrame } = renderWithUiState({
history: [singleTodoHistoryItem],
showFullTodos: true,
});
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],
history: [
createTodoHistoryItem([
{ description: 'Older Task 1', status: 'completed' },
{ description: 'Older Task 2', status: 'pending' },
]),
createTodoHistoryItem([
{ description: 'Newer Task 1', status: 'pending' },
{ description: 'Newer Task 2', status: 'in_progress' },
]),
],
showFullTodos: true,
});
expect(lastFrame()).toMatchSnapshot();

View File

@@ -16,6 +16,31 @@ 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}`;
}, [todos]);
return (
<Box flexDirection="row" columnGap={2} height={1}>
<Text color={theme.text.primary} bold>
📝 Todo
</Text>
<Text color={theme.text.secondary}>{score} (ctrl+t to toggle)</Text>
</Box>
);
};
const TodoStatusDisplay: React.FC<{ status: TodoStatus }> = ({ status }) => {
switch (status) {
case 'completed':
@@ -30,13 +55,16 @@ const TodoStatusDisplay: React.FC<{ status: TodoStatus }> = ({ status }) => {
}
};
const TodoItemDisplay: React.FC<{ todo: Todo }> = ({ todo }) => (
<Box flexDirection="row">
<Box marginRight={1}>
<TodoStatusDisplay status={todo.status} />
</Box>
const TodoItemDisplay: React.FC<{ todo: Todo; wrap?: 'truncate' }> = ({
todo,
wrap,
}) => (
<Box flexDirection="row" columnGap={1}>
<TodoStatusDisplay status={todo.status} />
<Box flexShrink={1}>
<Text color={theme.text.primary}>{todo.description}</Text>
<Text color={theme.text.primary} wrap={wrap}>
{todo.description}
</Text>
</Box>
</Box>
);
@@ -72,50 +100,37 @@ export const TodoTray: React.FC = () => {
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) {
if (todos === null || !todos.todos || todos.todos.length === 0) {
return null;
}
return (
<Box
borderStyle="single"
borderBottom={false}
borderRight={false}
borderLeft={false}
borderColor={theme.border.default}
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} />
{uiState.showFullTodos ? (
<Box flexDirection="column" rowGap={1}>
<TodoTitleDisplay todos={todos} />
<TodoListDisplay todos={todos!} />
</Box>
) : (
<Box flexDirection="row" columnGap={1} height={1}>
<Box flexShrink={0} flexGrow={0}>
<TodoTitleDisplay todos={todos} />
</Box>
{inProgress && (
<Box flexShrink={1} flexGrow={1}>
<TodoItemDisplay todo={inProgress!} wrap="truncate" />
</Box>
)}
</Box>
)}
</Box>
);
};

View File

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

View File

@@ -14,6 +14,13 @@ import {
} from './tools.js';
import { WRITE_TODOS_TOOL_NAME } from './tool-names.js';
const TODO_STATUSES = [
'pending',
'in_progress',
'completed',
'cancelled',
] as const;
// Inspired by langchain/deepagents.
export const WRITE_TODOS_DESCRIPTION = `This tool can help you list out the current subtasks that are required to be completed for a given user request. The list of subtasks helps you keep track of the current task, organize complex queries and help ensure that you don't miss any steps. With this list, the user can also see the current progress you are making in executing a given task.
@@ -152,7 +159,7 @@ export class WriteTodosTool extends BaseDeclarativeTool<
status: {
type: 'string',
description: 'The current status of the task.',
enum: ['pending', 'in_progress', 'completed'],
enum: TODO_STATUSES,
},
},
required: ['description', 'status'],
@@ -179,8 +186,8 @@ export class WriteTodosTool extends BaseDeclarativeTool<
if (typeof todo.description !== 'string' || !todo.description.trim()) {
return 'Each todo must have a non-empty description string';
}
if (!['pending', 'in_progress', 'completed'].includes(todo.status)) {
return 'Each todo must have a valid status (pending, in_progress, or completed)';
if (!TODO_STATUSES.includes(todo.status)) {
return `Each todo must have a valid status (${TODO_STATUSES.join(', ')})`;
}
}