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:
Jerop Kipruto
2026-01-30 16:06:45 -05:00
parent c30f964fba
commit 02283ca318
4 changed files with 322 additions and 34 deletions

View File

@@ -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();
});
});
});

View File

@@ -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 && (

View File

@@ -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"

View File

@@ -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 {