mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-02-01 22:48:03 +00:00
feat(plan): create generic Checklist component and refactor Todo (#17741)
This commit is contained in:
72
packages/cli/src/ui/components/Checklist.test.tsx
Normal file
72
packages/cli/src/ui/components/Checklist.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
126
packages/cli/src/ui/components/Checklist.tsx
Normal file
126
packages/cli/src/ui/components/Checklist.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
50
packages/cli/src/ui/components/ChecklistItem.test.tsx
Normal file
50
packages/cli/src/ui/components/ChecklistItem.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
92
packages/cli/src/ui/components/ChecklistItem.tsx
Normal file
92
packages/cli/src/ui/components/ChecklistItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
`;
|
||||
@@ -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"
|
||||
`;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user