mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-02-01 14:44:29 +00:00
feat(plan): extend Question interface for plan approval
Add optional fields to `Question` interface: - `context`: markdown content displayed before the question - `customOptionPlaceholder`: custom placeholder for the "Other" option Context is rendered using `MarkdownDisplay` and automatically truncated by `MaxSizedBox` when it exceeds available height. This enables displaying implementation plans for user approval before execution.
This commit is contained in:
@@ -946,4 +946,159 @@ describe('AskUserDialog', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rich context support', () => {
|
||||
it('renders markdown context before the question for choice type', () => {
|
||||
const questionWithContext: Question[] = [
|
||||
{
|
||||
question: 'Which database should we use?',
|
||||
header: 'Database',
|
||||
context:
|
||||
'## Context\n\nWe need a database that supports:\n- High availability\n- Horizontal scaling',
|
||||
options: [
|
||||
{ label: 'PostgreSQL', description: 'Relational database' },
|
||||
{ label: 'MongoDB', description: 'Document database' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questionWithContext}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders markdown context before the question for text type', () => {
|
||||
const questionWithContext: Question[] = [
|
||||
{
|
||||
question: 'Enter the API endpoint:',
|
||||
header: 'API',
|
||||
type: QuestionType.TEXT,
|
||||
context:
|
||||
'### API Configuration\n\nPlease provide the full URL including the protocol.',
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questionWithContext}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('uses custom placeholder for the Other option', async () => {
|
||||
const questionWithCustomPlaceholder: Question[] = [
|
||||
{
|
||||
question: 'Which cloud provider?',
|
||||
header: 'Cloud',
|
||||
options: [
|
||||
{ label: 'AWS', description: 'Amazon Web Services' },
|
||||
{ label: 'GCP', description: 'Google Cloud Platform' },
|
||||
],
|
||||
multiSelect: false,
|
||||
customOptionPlaceholder: 'Type your cloud provider...',
|
||||
},
|
||||
];
|
||||
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questionWithCustomPlaceholder}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
// Navigate to custom option
|
||||
writeKey(stdin, '\x1b[B');
|
||||
writeKey(stdin, '\x1b[B');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: 'undefined', context: undefined },
|
||||
{ name: 'empty string', context: '' },
|
||||
{ name: 'whitespace only', context: ' ' },
|
||||
])('does not render context when $name', ({ context }) => {
|
||||
const question: Question[] = [
|
||||
{
|
||||
question: 'Which option?',
|
||||
header: 'Option',
|
||||
context,
|
||||
options: [
|
||||
{ label: 'A', description: 'Option A' },
|
||||
{ label: 'B', description: 'Option B' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={question}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
availableHeight={20}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('truncates long context with hidden lines indicator', () => {
|
||||
const longContext = Array.from(
|
||||
{ length: 20 },
|
||||
(_, i) => `- Step ${i + 1}: Do something important`,
|
||||
).join('\n');
|
||||
|
||||
const questionWithLongContext: Question[] = [
|
||||
{
|
||||
question: 'Approve this plan?',
|
||||
header: 'Plan',
|
||||
context: `## Implementation Plan\n\n${longContext}`,
|
||||
options: [
|
||||
{ label: 'Yes', description: 'Approve and proceed' },
|
||||
{ label: 'No', description: 'Reject the plan' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questionWithLongContext}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={80}
|
||||
availableHeight={15}
|
||||
/>,
|
||||
{ width: 80 },
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useCallback, useMemo, useRef, useEffect, useReducer } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import type { Question } from '@google/gemini-cli-core';
|
||||
import { MarkdownDisplay } from '../utils/MarkdownDisplay.js';
|
||||
import { BaseSelectionList } from './shared/BaseSelectionList.js';
|
||||
import type { SelectionListItem } from '../hooks/useSelectionList.js';
|
||||
import { TabHeader, type Tab } from './shared/TabHeader.js';
|
||||
@@ -22,6 +23,33 @@ import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js';
|
||||
import { DialogFooter } from './shared/DialogFooter.js';
|
||||
import { MaxSizedBox } from './shared/MaxSizedBox.js';
|
||||
|
||||
// Shared component for rendering question context
|
||||
interface QuestionContextProps {
|
||||
context: string | undefined;
|
||||
maxHeight: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
const QuestionContext: React.FC<QuestionContextProps> = ({
|
||||
context,
|
||||
maxHeight,
|
||||
width,
|
||||
}) => {
|
||||
if (!context?.trim()) return null;
|
||||
|
||||
return (
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<MaxSizedBox maxHeight={maxHeight} overflowDirection="bottom">
|
||||
<MarkdownDisplay
|
||||
text={context}
|
||||
isPending={false}
|
||||
terminalWidth={width}
|
||||
/>
|
||||
</MaxSizedBox>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface AskUserDialogState {
|
||||
answers: { [key: string]: string };
|
||||
isEditingCustomOption: boolean;
|
||||
@@ -275,21 +303,18 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
|
||||
const placeholder = question.placeholder || 'Enter your response';
|
||||
|
||||
const HEADER_HEIGHT = progressHeader ? 2 : 0;
|
||||
const INPUT_HEIGHT = 2; // TextInput + margin
|
||||
const FOOTER_HEIGHT = 2; // DialogFooter + margin
|
||||
const overhead = HEADER_HEIGHT + INPUT_HEIGHT + FOOTER_HEIGHT;
|
||||
const questionHeight = Math.max(1, availableHeight - overhead);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{progressHeader}
|
||||
<QuestionContext
|
||||
context={question.context}
|
||||
maxHeight={availableHeight}
|
||||
width={availableWidth}
|
||||
/>
|
||||
<Box marginBottom={1}>
|
||||
<MaxSizedBox maxHeight={questionHeight} maxWidth={availableWidth}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{question.question}
|
||||
</Text>
|
||||
</MaxSizedBox>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{question.question}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
@@ -707,32 +732,26 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
}
|
||||
}, [customOptionText, isCustomOptionSelected, question.multiSelect]);
|
||||
|
||||
const HEADER_HEIGHT = progressHeader ? 2 : 0;
|
||||
const TITLE_MARGIN = 1;
|
||||
const FOOTER_HEIGHT = 2; // DialogFooter + margin
|
||||
const overhead = HEADER_HEIGHT + TITLE_MARGIN + FOOTER_HEIGHT;
|
||||
const listHeight = Math.max(1, availableHeight - overhead);
|
||||
const questionHeight = Math.min(3, Math.max(1, listHeight - 4));
|
||||
const maxItemsToShow = Math.max(
|
||||
1,
|
||||
Math.floor((listHeight - questionHeight) / 2),
|
||||
);
|
||||
const maxItemsToShow = Math.max(3, Math.floor(availableHeight / 3));
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{progressHeader}
|
||||
<Box marginBottom={TITLE_MARGIN}>
|
||||
<MaxSizedBox maxHeight={questionHeight} maxWidth={availableWidth}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{question.question}
|
||||
{question.multiSelect && (
|
||||
<Text color={theme.text.secondary} italic>
|
||||
{' '}
|
||||
(Select all that apply)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</MaxSizedBox>
|
||||
<QuestionContext
|
||||
context={question.context}
|
||||
maxHeight={availableHeight}
|
||||
width={availableWidth}
|
||||
/>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{question.question}
|
||||
{question.multiSelect && (
|
||||
<Text color={theme.text.secondary} italic>
|
||||
{' '}
|
||||
(Select all that apply)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<BaseSelectionList<OptionItem>
|
||||
@@ -753,7 +772,8 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
|
||||
// Render inline text input for custom option
|
||||
if (optionItem.type === 'other') {
|
||||
const placeholder = 'Enter a custom value';
|
||||
const placeholder =
|
||||
question.customOptionPlaceholder || 'Enter a custom value';
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
{showCheck && (
|
||||
|
||||
@@ -1,5 +1,112 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`AskUserDialog > Rich context support > does not render context when 'empty string' 1`] = `
|
||||
"Which option?
|
||||
|
||||
● 1. A
|
||||
Option A
|
||||
2. B
|
||||
Option B
|
||||
3. Enter a custom value
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Rich context support > does not render context when 'undefined' 1`] = `
|
||||
"Which option?
|
||||
|
||||
● 1. A
|
||||
Option A
|
||||
2. B
|
||||
Option B
|
||||
3. Enter a custom value
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Rich context support > does not render context when 'whitespace only' 1`] = `
|
||||
"Which option?
|
||||
|
||||
● 1. A
|
||||
Option A
|
||||
2. B
|
||||
Option B
|
||||
3. Enter a custom value
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Rich context support > renders markdown context before the question for choice type 1`] = `
|
||||
"Context
|
||||
|
||||
We need a database that supports:
|
||||
- High availability
|
||||
- Horizontal scaling
|
||||
|
||||
Which database should we use?
|
||||
|
||||
● 1. PostgreSQL
|
||||
Relational database
|
||||
2. MongoDB
|
||||
Document database
|
||||
3. Enter a custom value
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Rich context support > renders markdown context before the question for text type 1`] = `
|
||||
"API Configuration
|
||||
|
||||
Please provide the full URL including the protocol.
|
||||
|
||||
Enter the API endpoint:
|
||||
|
||||
> Enter your response
|
||||
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Rich context support > truncates long context with hidden lines indicator 1`] = `
|
||||
"Implementation Plan
|
||||
|
||||
- Step 1: Do something important
|
||||
- Step 2: Do something important
|
||||
- Step 3: Do something important
|
||||
- Step 4: Do something important
|
||||
- Step 5: Do something important
|
||||
- Step 6: Do something important
|
||||
- Step 7: Do something important
|
||||
- Step 8: Do something important
|
||||
- Step 9: Do something important
|
||||
- Step 10: Do something important
|
||||
- Step 11: Do something important
|
||||
- Step 12: Do something important
|
||||
... last 8 lines hidden ...
|
||||
|
||||
Approve this plan?
|
||||
|
||||
● 1. Yes
|
||||
Approve and proceed
|
||||
2. No
|
||||
Reject the plan
|
||||
3. Enter a custom value
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Rich context support > uses custom placeholder for the Other option 1`] = `
|
||||
"Which cloud provider?
|
||||
|
||||
1. AWS
|
||||
Amazon Web Services
|
||||
2. GCP
|
||||
Google Cloud Platform
|
||||
● 3. Type your cloud provider...
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = `
|
||||
"What should we name this component?
|
||||
|
||||
@@ -112,6 +219,8 @@ exports[`AskUserDialog > shows scroll arrows when options exceed available heigh
|
||||
Description 1
|
||||
2. Option 2
|
||||
Description 2
|
||||
3. Option 3
|
||||
Description 3
|
||||
▼
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel"
|
||||
|
||||
@@ -150,6 +150,10 @@ export interface Question {
|
||||
multiSelect?: boolean;
|
||||
/** Placeholder hint text. Only applies when type='text'. */
|
||||
placeholder?: string;
|
||||
/** Markdown context to display before the question. */
|
||||
context?: string;
|
||||
/** Placeholder for the custom "Other" option input. Only applies when type='choice'. Defaults to 'Enter a custom value'. */
|
||||
customOptionPlaceholder?: string;
|
||||
}
|
||||
|
||||
export interface AskUserRequest {
|
||||
|
||||
Reference in New Issue
Block a user