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

View File

@@ -31,12 +31,6 @@ const createTodoHistoryItem = (todos: Todo[]): HistoryItem =>
}) as unknown as HistoryItem; }) as unknown as HistoryItem;
describe('<TodoTray />', () => { 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>) => const renderWithUiState = (uiState: Partial<UIState>) =>
render( render(
<UIStateContext.Provider value={uiState as UIState}> <UIStateContext.Provider value={uiState as UIState}>
@@ -44,99 +38,106 @@ describe('<TodoTray />', () => {
</UIStateContext.Provider>, </UIStateContext.Provider>,
); );
it('renders null when no todos are in the history', () => { it.each([true, false])(
const { lastFrame } = renderWithUiState({ history: [] }); 'renders null when no todos are in the history',
expect(lastFrame()).toMatchSnapshot(); (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', () => { it.each([true, false])(
const historyWithNoInProgress = createTodoHistoryItem([ 'renders null when todo list is empty',
{ description: 'Pending Task', status: 'pending' }, (showFullTodos) => {
{ description: 'In Progress Task', status: 'cancelled' }, const { lastFrame } = renderWithUiState({
{ description: 'Completed Task', status: 'completed' }, 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({ const { lastFrame } = renderWithUiState({
history: [historyWithNoInProgress], history: [
showFullTodos: false, createTodoHistoryItem([
}); { description: 'Older Task 1', status: 'completed' },
expect(lastFrame()).toMatchSnapshot(); { description: 'Older Task 2', status: 'pending' },
}); ]),
createTodoHistoryItem([
it('renders an empty todo list when full view is on', () => { { description: 'Newer Task 1', status: 'pending' },
const emptyTodosHistoryItem = createTodoHistoryItem([]); { description: 'Newer Task 2', status: 'in_progress' },
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],
showFullTodos: true, showFullTodos: true,
}); });
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();

View File

@@ -16,6 +16,31 @@ import { useUIState } from '../../contexts/UIStateContext.js';
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { HistoryItemToolGroup } from '../../types.js'; 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 }) => { const TodoStatusDisplay: React.FC<{ status: TodoStatus }> = ({ status }) => {
switch (status) { switch (status) {
case 'completed': case 'completed':
@@ -30,13 +55,16 @@ const TodoStatusDisplay: React.FC<{ status: TodoStatus }> = ({ status }) => {
} }
}; };
const TodoItemDisplay: React.FC<{ todo: Todo }> = ({ todo }) => ( const TodoItemDisplay: React.FC<{ todo: Todo; wrap?: 'truncate' }> = ({
<Box flexDirection="row"> todo,
<Box marginRight={1}> wrap,
<TodoStatusDisplay status={todo.status} /> }) => (
</Box> <Box flexDirection="row" columnGap={1}>
<TodoStatusDisplay status={todo.status} />
<Box flexShrink={1}> <Box flexShrink={1}>
<Text color={theme.text.primary}>{todo.description}</Text> <Text color={theme.text.primary} wrap={wrap}>
{todo.description}
</Text>
</Box> </Box>
</Box> </Box>
); );
@@ -72,50 +100,37 @@ export const TodoTray: React.FC = () => {
return todos.todos.find((todo) => todo.status === 'in_progress') || null; return todos.todos.find((todo) => todo.status === 'in_progress') || null;
}, [todos]); }, [todos]);
if (todos === null) { if (todos === null || !todos.todos || todos.todos.length === 0) {
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 null;
} }
return ( return (
<Box <Box
borderStyle="single" borderStyle="single"
borderBottom={false}
borderRight={false}
borderLeft={false}
borderColor={theme.border.default}
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
borderBottom={false}
flexDirection="row"
borderColor={theme.border.default}
> >
<Text color={theme.text.accent}> {uiState.showFullTodos ? (
📝 Todo: <Box flexDirection="column" rowGap={1}>
<Text color={theme.text.secondary}>(ctrl+t to expand)</Text> <TodoTitleDisplay todos={todos} />
</Text> <TodoListDisplay todos={todos!} />
<TodoItemDisplay todo={inProgress} /> </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> </Box>
); );
}; };

View File

@@ -1,63 +1,62 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // 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`] = ` exports[`<TodoTray /> > renders a todo list with long descriptions that wrap when full view is on 1`] = `
"──────────────────────────── "──────────────────────────────────────────────────
📝 Todo:(ctrl+t to 📝 Todo 1/2 (ctrl+t to toggle)
│ collapse) │
│ │ » This is a very long description for a pending
│ ☐ This is a very long │ task that should wrap around multiple lines
description for a │ when the terminal width is constrained.
│ pending task that │ ✓ Another completed task with an equally verbose
should wrap around │ description to test wrapping behavior."
│ 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`] = ` exports[`<TodoTray /> > renders a todo list with long descriptions that wrap when full view is on 2`] = `
"──────────────────────────────────────────────────────────────────────────────────────────────────┐ "──────────────────────────────────────────────────
📝 Todo:(ctrl+t to collapse) │ 📝 Todo 1/2 (ctrl+t to toggle) » This is a ver…"
│ │
│ ☐ 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 null when no todos are in the history 1`] = `""`; 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`] = ` exports[`<TodoTray /> > renders null when todo list is empty 1`] = `""`;
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 📝 Todo:(ctrl+t to expand)» In Progress Task │" 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`] = ` exports[`<TodoTray /> > renders when todos exist and one is in progress 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────── "────────────────────────────────────────────────────────────────────────────────────────────────────
📝 Todo:(ctrl+t to collapse) │ 📝 Todo 1/3 (ctrl+t to toggle)
│ │
☐ Pending Task ☐ Pending Task
│ » In Progress Task │ » Task 2
│ ✓ Completed Task │" ✗ 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'; } from './tools.js';
import { WRITE_TODOS_TOOL_NAME } from './tool-names.js'; import { WRITE_TODOS_TOOL_NAME } from './tool-names.js';
const TODO_STATUSES = [
'pending',
'in_progress',
'completed',
'cancelled',
] as const;
// Inspired by langchain/deepagents. // 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. 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: { status: {
type: 'string', type: 'string',
description: 'The current status of the task.', description: 'The current status of the task.',
enum: ['pending', 'in_progress', 'completed'], enum: TODO_STATUSES,
}, },
}, },
required: ['description', 'status'], required: ['description', 'status'],
@@ -179,8 +186,8 @@ export class WriteTodosTool extends BaseDeclarativeTool<
if (typeof todo.description !== 'string' || !todo.description.trim()) { if (typeof todo.description !== 'string' || !todo.description.trim()) {
return 'Each todo must have a non-empty description string'; return 'Each todo must have a non-empty description string';
} }
if (!['pending', 'in_progress', 'completed'].includes(todo.status)) { if (!TODO_STATUSES.includes(todo.status)) {
return 'Each todo must have a valid status (pending, in_progress, or completed)'; return `Each todo must have a valid status (${TODO_STATUSES.join(', ')})`;
} }
} }