From 02283ca318481cb2318018827725d067ae12a4cb Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Fri, 30 Jan 2026 16:06:45 -0500 Subject: [PATCH] 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. --- .../src/ui/components/AskUserDialog.test.tsx | 155 ++++++++++++++++++ .../cli/src/ui/components/AskUserDialog.tsx | 88 ++++++---- .../__snapshots__/AskUserDialog.test.tsx.snap | 109 ++++++++++++ packages/core/src/confirmation-bus/types.ts | 4 + 4 files changed, 322 insertions(+), 34 deletions(-) diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx index 645321dfc0..87ae0b90dc 100644 --- a/packages/cli/src/ui/components/AskUserDialog.test.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -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( + , + { 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( + , + { 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( + , + { 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( + , + { 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( + , + { width: 80 }, + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + }); }); diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index e2892feade..9d521ada3b 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -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 = ({ + context, + maxHeight, + width, +}) => { + if (!context?.trim()) return null; + + return ( + + + + + + ); +}; + interface AskUserDialogState { answers: { [key: string]: string }; isEditingCustomOption: boolean; @@ -275,21 +303,18 @@ const TextQuestionView: React.FC = ({ 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 ( {progressHeader} + - - - {question.question} - - + + {question.question} + @@ -707,32 +732,26 @@ const ChoiceQuestionView: React.FC = ({ } }, [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 ( {progressHeader} - - - - {question.question} - {question.multiSelect && ( - - {' '} - (Select all that apply) - - )} - - + + + + {question.question} + {question.multiSelect && ( + + {' '} + (Select all that apply) + + )} + @@ -753,7 +772,8 @@ const ChoiceQuestionView: React.FC = ({ // 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 ( {showCheck && ( diff --git a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap index fdb34f4adb..8536319ebf 100644 --- a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap @@ -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" diff --git a/packages/core/src/confirmation-bus/types.ts b/packages/core/src/confirmation-bus/types.ts index fcdd600f3c..74b8708fce 100644 --- a/packages/core/src/confirmation-bus/types.ts +++ b/packages/core/src/confirmation-bus/types.ts @@ -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 {