feat(plan): create generic Checklist component and refactor Todo (#17741)

This commit is contained in:
Adib234
2026-01-29 13:13:18 -05:00
committed by GitHub
parent ac7687c1dd
commit 0e30055ae4
7 changed files with 395 additions and 148 deletions

View File

@@ -0,0 +1,72 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import { Checklist } from './Checklist.js';
import type { ChecklistItemData } from './ChecklistItem.js';
describe('<Checklist />', () => {
const items: ChecklistItemData[] = [
{ status: 'completed', label: 'Task 1' },
{ status: 'in_progress', label: 'Task 2' },
{ status: 'pending', label: 'Task 3' },
{ status: 'cancelled', label: 'Task 4' },
];
it('renders nothing when list is empty', () => {
const { lastFrame } = render(
<Checklist title="Test List" items={[]} isExpanded={true} />,
);
expect(lastFrame()).toBe('');
});
it('renders nothing when collapsed and no active items', () => {
const inactiveItems: ChecklistItemData[] = [
{ status: 'completed', label: 'Task 1' },
{ status: 'cancelled', label: 'Task 2' },
];
const { lastFrame } = render(
<Checklist title="Test List" items={inactiveItems} isExpanded={false} />,
);
expect(lastFrame()).toBe('');
});
it('renders summary view correctly (collapsed)', () => {
const { lastFrame } = render(
<Checklist
title="Test List"
items={items}
isExpanded={false}
toggleHint="toggle me"
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders expanded view correctly', () => {
const { lastFrame } = render(
<Checklist
title="Test List"
items={items}
isExpanded={true}
toggleHint="toggle me"
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders summary view without in-progress item if none exists', () => {
const pendingItems: ChecklistItemData[] = [
{ status: 'completed', label: 'Task 1' },
{ status: 'pending', label: 'Task 2' },
];
const { lastFrame } = render(
<Checklist title="Test List" items={pendingItems} isExpanded={false} />,
);
expect(lastFrame()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,126 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useMemo } from 'react';
import { ChecklistItem, type ChecklistItemData } from './ChecklistItem.js';
export interface ChecklistProps {
title: string;
items: ChecklistItemData[];
isExpanded: boolean;
toggleHint?: string;
}
const ChecklistTitleDisplay: React.FC<{
title: string;
items: ChecklistItemData[];
toggleHint?: string;
}> = ({ title, items, toggleHint }) => {
const score = useMemo(() => {
let total = 0;
let completed = 0;
for (const item of items) {
if (item.status !== 'cancelled') {
total += 1;
if (item.status === 'completed') {
completed += 1;
}
}
}
return `${completed}/${total} completed`;
}, [items]);
return (
<Box flexDirection="row" columnGap={2} height={1}>
<Text color={theme.text.primary} bold aria-label={`${title} list`}>
{title}
</Text>
<Text color={theme.text.secondary}>
{score}
{toggleHint ? ` (${toggleHint})` : ''}
</Text>
</Box>
);
};
const ChecklistListDisplay: React.FC<{ items: ChecklistItemData[] }> = ({
items,
}) => (
<Box flexDirection="column" aria-role="list">
{items.map((item, index) => (
<ChecklistItem
item={item}
key={`${index}-${item.label}`}
role="listitem"
/>
))}
</Box>
);
export const Checklist: React.FC<ChecklistProps> = ({
title,
items,
isExpanded,
toggleHint,
}) => {
const inProgress: ChecklistItemData | null = useMemo(
() => items.find((item) => item.status === 'in_progress') || null,
[items],
);
const hasActiveItems = useMemo(
() =>
items.some(
(item) => item.status === 'pending' || item.status === 'in_progress',
),
[items],
);
if (items.length === 0 || (!isExpanded && !hasActiveItems)) {
return null;
}
return (
<Box
borderStyle="single"
borderBottom={false}
borderRight={false}
borderLeft={false}
borderColor={theme.border.default}
paddingLeft={1}
paddingRight={1}
>
{isExpanded ? (
<Box flexDirection="column" rowGap={1}>
<ChecklistTitleDisplay
title={title}
items={items}
toggleHint={toggleHint}
/>
<ChecklistListDisplay items={items} />
</Box>
) : (
<Box flexDirection="row" columnGap={1} height={1}>
<Box flexShrink={0} flexGrow={0}>
<ChecklistTitleDisplay
title={title}
items={items}
toggleHint={toggleHint}
/>
</Box>
{inProgress && (
<Box flexShrink={1} flexGrow={1}>
<ChecklistItem item={inProgress} wrap="truncate" />
</Box>
)}
</Box>
)}
</Box>
);
};

View File

@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import { ChecklistItem, type ChecklistItemData } from './ChecklistItem.js';
import { Box } from 'ink';
describe('<ChecklistItem />', () => {
it.each([
{ status: 'pending', label: 'Do this' },
{ status: 'in_progress', label: 'Doing this' },
{ status: 'completed', label: 'Done this' },
{ status: 'cancelled', label: 'Skipped this' },
] as ChecklistItemData[])('renders %s item correctly', (item) => {
const { lastFrame } = render(<ChecklistItem item={item} />);
expect(lastFrame()).toMatchSnapshot();
});
it('truncates long text when wrap="truncate"', () => {
const item: ChecklistItemData = {
status: 'in_progress',
label:
'This is a very long text that should be truncated because the wrap prop is set to truncate',
};
const { lastFrame } = render(
<Box width={30}>
<ChecklistItem item={item} wrap="truncate" />
</Box>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('wraps long text by default', () => {
const item: ChecklistItemData = {
status: 'in_progress',
label:
'This is a very long text that should wrap because the default behavior is wrapping',
};
const { lastFrame } = render(
<Box width={30}>
<ChecklistItem item={item} />
</Box>,
);
expect(lastFrame()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,92 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { checkExhaustive } from '../../utils/checks.js';
export type ChecklistStatus =
| 'pending'
| 'in_progress'
| 'completed'
| 'cancelled';
export interface ChecklistItemData {
status: ChecklistStatus;
label: string;
}
const ChecklistStatusDisplay: React.FC<{ status: ChecklistStatus }> = ({
status,
}) => {
switch (status) {
case 'completed':
return (
<Text color={theme.status.success} aria-label="Completed">
</Text>
);
case 'in_progress':
return (
<Text color={theme.text.accent} aria-label="In Progress">
»
</Text>
);
case 'pending':
return (
<Text color={theme.text.secondary} aria-label="Pending">
</Text>
);
case 'cancelled':
return (
<Text color={theme.status.error} aria-label="Cancelled">
</Text>
);
default:
checkExhaustive(status);
}
};
export interface ChecklistItemProps {
item: ChecklistItemData;
wrap?: 'truncate';
role?: 'listitem';
}
export const ChecklistItem: React.FC<ChecklistItemProps> = ({
item,
wrap,
role: ariaRole,
}) => {
const textColor = (() => {
switch (item.status) {
case 'in_progress':
return theme.text.accent;
case 'completed':
case 'cancelled':
return theme.text.secondary;
case 'pending':
return theme.text.primary;
default:
checkExhaustive(item.status);
}
})();
const strikethrough = item.status === 'cancelled';
return (
<Box flexDirection="row" columnGap={1} aria-role={ariaRole}>
<ChecklistStatusDisplay status={item.status} />
<Box flexShrink={1}>
<Text color={textColor} wrap={wrap} strikethrough={strikethrough}>
{item.label}
</Text>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,21 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Checklist /> > renders expanded view correctly 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
Test List 1/3 completed (toggle me)
✓ Task 1
» Task 2
☐ Task 3
✗ Task 4"
`;
exports[`<Checklist /> > renders summary view correctly (collapsed) 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
Test List 1/3 completed (toggle me) » Task 2"
`;
exports[`<Checklist /> > renders summary view without in-progress item if none exists 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
Test List 1/2 completed"
`;

View File

@@ -0,0 +1,17 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ChecklistItem /> > renders { status: 'cancelled', label: 'Skipped this' } item correctly 1`] = `"✗ Skipped this"`;
exports[`<ChecklistItem /> > renders { status: 'completed', label: 'Done this' } item correctly 1`] = `"✓ Done this"`;
exports[`<ChecklistItem /> > renders { status: 'in_progress', label: 'Doing this' } item correctly 1`] = `"» Doing this"`;
exports[`<ChecklistItem /> > renders { status: 'pending', label: 'Do this' } item correctly 1`] = `"☐ Do this"`;
exports[`<ChecklistItem /> > truncates long text when wrap="truncate" 1`] = `"» This is a very long text th…"`;
exports[`<ChecklistItem /> > wraps long text by default 1`] = `
"» This is a very long text
that should wrap because the
default behavior is wrapping"
`;

View File

@@ -5,101 +5,12 @@
*/
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 { type TodoList } from '@google/gemini-cli-core';
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} completed`;
}, [todos]);
return (
<Box flexDirection="row" columnGap={2} height={1}>
<Text color={theme.text.primary} bold aria-label="Todo list">
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':
return (
<Text color={theme.status.success} aria-label="Completed">
</Text>
);
case 'in_progress':
return (
<Text color={theme.text.accent} aria-label="In Progress">
»
</Text>
);
case 'pending':
return (
<Text color={theme.text.secondary} aria-label="Pending">
</Text>
);
case 'cancelled':
default:
return (
<Text color={theme.status.error} aria-label="Cancelled">
</Text>
);
}
};
const TodoItemDisplay: React.FC<{
todo: Todo;
wrap?: 'truncate';
role?: 'listitem';
}> = ({ todo, wrap, role: ariaRole }) => {
const textColor = (() => {
switch (todo.status) {
case 'in_progress':
return theme.text.accent;
case 'completed':
case 'cancelled':
return theme.text.secondary;
default:
return theme.text.primary;
}
})();
const strikethrough = todo.status === 'cancelled';
return (
<Box flexDirection="row" columnGap={1} aria-role={ariaRole}>
<TodoStatusDisplay status={todo.status} />
<Box flexShrink={1}>
<Text color={textColor} wrap={wrap} strikethrough={strikethrough}>
{todo.description}
</Text>
</Box>
</Box>
);
};
import { Checklist } from '../Checklist.js';
import type { ChecklistItemData } from '../ChecklistItem.js';
export const TodoTray: React.FC = () => {
const uiState = useUIState();
@@ -125,68 +36,26 @@ export const TodoTray: React.FC = () => {
return null;
}, [uiState.history]);
const inProgress: Todo | null = useMemo(() => {
if (todos === null) {
return null;
const checklistItems: ChecklistItemData[] = useMemo(() => {
if (!todos || !todos.todos) {
return [];
}
return todos.todos.find((todo) => todo.status === 'in_progress') || null;
return todos.todos.map((todo) => ({
status: todo.status,
label: todo.description,
}));
}, [todos]);
const hasActiveTodos = useMemo(() => {
if (!todos || !todos.todos) return false;
return todos.todos.some(
(todo) => todo.status === 'pending' || todo.status === 'in_progress',
);
}, [todos]);
if (
todos === null ||
!todos.todos ||
todos.todos.length === 0 ||
(!uiState.showFullTodos && !hasActiveTodos)
) {
if (!todos || !todos.todos) {
return null;
}
return (
<Box
borderStyle="single"
borderBottom={false}
borderRight={false}
borderLeft={false}
borderColor={theme.border.default}
paddingLeft={1}
paddingRight={1}
>
{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>
<Checklist
title="Todo"
items={checklistItems}
isExpanded={uiState.showFullTodos}
toggleHint="ctrl+t to toggle"
/>
);
};
interface TodoListDisplayProps {
todos: TodoList;
}
const TodoListDisplay: React.FC<TodoListDisplayProps> = ({ todos }) => (
<Box flexDirection="column" aria-role="list">
{todos.todos.map((todo: Todo, index: number) => (
<TodoItemDisplay todo={todo} key={index} role="listitem" />
))}
</Box>
);