Fix truncation for AskQuestion (#18001)

This commit is contained in:
Jacob Richman
2026-01-30 17:07:41 -08:00
committed by GitHub
parent 00fdb30211
commit 7469ea0fca
4 changed files with 210 additions and 76 deletions

View File

@@ -10,6 +10,7 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js'; import { waitFor } from '../../test-utils/async.js';
import { AskUserDialog } from './AskUserDialog.js'; import { AskUserDialog } from './AskUserDialog.js';
import { QuestionType, type Question } from '@google/gemini-cli-core'; import { QuestionType, type Question } from '@google/gemini-cli-core';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
// Helper to write to stdin with proper act() wrapping // Helper to write to stdin with proper act() wrapping
const writeKey = (stdin: { write: (data: string) => void }, key: string) => { const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
@@ -42,7 +43,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()} onSubmit={vi.fn()}
onCancel={vi.fn()} onCancel={vi.fn()}
width={120} width={120}
availableHeight={20}
/>, />,
{ width: 120 }, { width: 120 },
); );
@@ -108,7 +108,6 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit} onSubmit={onSubmit}
onCancel={vi.fn()} onCancel={vi.fn()}
width={120} width={120}
availableHeight={20}
/>, />,
{ width: 120 }, { width: 120 },
); );
@@ -129,7 +128,6 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit} onSubmit={onSubmit}
onCancel={vi.fn()} onCancel={vi.fn()}
width={120} width={120}
availableHeight={20}
/>, />,
{ width: 120 }, { width: 120 },
); );
@@ -159,33 +157,49 @@ describe('AskUserDialog', () => {
}); });
}); });
it('shows scroll arrows when options exceed available height', async () => { describe.each([
const questions: Question[] = [ { useAlternateBuffer: true, expectedArrows: false },
{ { useAlternateBuffer: false, expectedArrows: true },
question: 'Choose an option', ])(
header: 'Scroll Test', 'Scroll Arrows (useAlternateBuffer: $useAlternateBuffer)',
options: Array.from({ length: 15 }, (_, i) => ({ ({ useAlternateBuffer, expectedArrows }) => {
label: `Option ${i + 1}`, it(`shows scroll arrows correctly when useAlternateBuffer is ${useAlternateBuffer}`, async () => {
description: `Description ${i + 1}`, const questions: Question[] = [
})), {
multiSelect: false, question: 'Choose an option',
}, header: 'Scroll Test',
]; options: Array.from({ length: 15 }, (_, i) => ({
label: `Option ${i + 1}`,
description: `Description ${i + 1}`,
})),
multiSelect: false,
},
];
const { lastFrame } = renderWithProviders( const { lastFrame } = renderWithProviders(
<AskUserDialog <AskUserDialog
questions={questions} questions={questions}
onSubmit={vi.fn()} onSubmit={vi.fn()}
onCancel={vi.fn()} onCancel={vi.fn()}
width={80} width={80}
availableHeight={10} // Small height to force scrolling availableHeight={10} // Small height to force scrolling
/>, />,
); { useAlternateBuffer },
);
await waitFor(() => { await waitFor(() => {
expect(lastFrame()).toMatchSnapshot(); if (expectedArrows) {
}); expect(lastFrame()).toContain('▲');
}); expect(lastFrame()).toContain('▼');
} else {
expect(lastFrame()).not.toContain('▲');
expect(lastFrame()).not.toContain('▼');
}
expect(lastFrame()).toMatchSnapshot();
});
});
},
);
it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => { it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => {
const { stdin, lastFrame } = renderWithProviders( const { stdin, lastFrame } = renderWithProviders(
@@ -194,7 +208,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()} onSubmit={vi.fn()}
onCancel={vi.fn()} onCancel={vi.fn()}
width={120} width={120}
availableHeight={20}
/>, />,
{ width: 120 }, { width: 120 },
); );
@@ -246,7 +259,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()} onSubmit={vi.fn()}
onCancel={vi.fn()} onCancel={vi.fn()}
width={120} width={120}
availableHeight={20}
/>, />,
{ width: 120 }, { width: 120 },
); );
@@ -261,7 +273,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()} onSubmit={vi.fn()}
onCancel={vi.fn()} onCancel={vi.fn()}
width={120} width={120}
availableHeight={20}
/>, />,
{ width: 120 }, { width: 120 },
); );
@@ -276,7 +287,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()} onSubmit={vi.fn()}
onCancel={vi.fn()} onCancel={vi.fn()}
width={120} width={120}
availableHeight={20}
/>, />,
{ width: 120 }, { width: 120 },
); );
@@ -308,7 +318,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()} onSubmit={vi.fn()}
onCancel={vi.fn()} onCancel={vi.fn()}
width={120} width={120}
availableHeight={20}
/>, />,
{ width: 120 }, { width: 120 },
); );
@@ -351,7 +360,6 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit} onSubmit={onSubmit}
onCancel={vi.fn()} onCancel={vi.fn()}
width={120} width={120}
availableHeight={20}
/>, />,
{ width: 120 }, { width: 120 },
); );
@@ -420,7 +428,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()} onSubmit={vi.fn()}
onCancel={vi.fn()} onCancel={vi.fn()}
width={120} width={120}
availableHeight={20}
/>, />,
{ width: 120 }, { width: 120 },
); );
@@ -450,7 +457,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()} onSubmit={vi.fn()}
onCancel={vi.fn()} onCancel={vi.fn()}
width={120} width={120}
availableHeight={20}
/>, />,
{ width: 120 }, { width: 120 },
); );
@@ -496,7 +502,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()} onSubmit={vi.fn()}
onCancel={vi.fn()} onCancel={vi.fn()}
width={120} width={120}
availableHeight={20}
/>, />,
{ width: 120 }, { width: 120 },
); );
@@ -533,7 +538,6 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit} onSubmit={onSubmit}
onCancel={vi.fn()} onCancel={vi.fn()}
width={120} width={120}
availableHeight={20}
/>, />,
{ width: 120 }, { width: 120 },
); );
@@ -567,7 +571,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()} onSubmit={vi.fn()}
onCancel={vi.fn()} onCancel={vi.fn()}
width={120} width={120}
availableHeight={20}
/>, />,
{ width: 120 }, { width: 120 },
); );
@@ -590,7 +593,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()} onSubmit={vi.fn()}
onCancel={vi.fn()} onCancel={vi.fn()}
width={120} width={120}
availableHeight={20}
/>, />,
{ width: 120 }, { width: 120 },
); );
@@ -613,7 +615,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()} onSubmit={vi.fn()}
onCancel={vi.fn()} onCancel={vi.fn()}
width={120} width={120}
availableHeight={20}
/>, />,
{ width: 120 }, { width: 120 },
); );
@@ -649,7 +650,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()} onSubmit={vi.fn()}
onCancel={vi.fn()} onCancel={vi.fn()}
width={120} width={120}
availableHeight={20}
/>, />,
{ width: 120 }, { width: 120 },
); );
@@ -681,7 +681,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()} onSubmit={vi.fn()}
onCancel={vi.fn()} onCancel={vi.fn()}
width={120} width={120}
availableHeight={20}
/>, />,
{ width: 120 }, { width: 120 },
); );
@@ -729,7 +728,6 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit} onSubmit={onSubmit}
onCancel={vi.fn()} onCancel={vi.fn()}
width={120} width={120}
availableHeight={20}
/>, />,
{ width: 120 }, { width: 120 },
); );
@@ -780,7 +778,6 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit} onSubmit={onSubmit}
onCancel={vi.fn()} onCancel={vi.fn()}
width={120} width={120}
availableHeight={20}
/>, />,
{ width: 120 }, { width: 120 },
); );
@@ -807,7 +804,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()} onSubmit={vi.fn()}
onCancel={onCancel} onCancel={onCancel}
width={120} width={120}
availableHeight={20}
/>, />,
{ width: 120 }, { width: 120 },
); );
@@ -854,7 +850,6 @@ describe('AskUserDialog', () => {
onSubmit={vi.fn()} onSubmit={vi.fn()}
onCancel={vi.fn()} onCancel={vi.fn()}
width={120} width={120}
availableHeight={20}
/>, />,
{ width: 120 }, { width: 120 },
); );
@@ -914,7 +909,6 @@ describe('AskUserDialog', () => {
onSubmit={onSubmit} onSubmit={onSubmit}
onCancel={vi.fn()} onCancel={vi.fn()}
width={120} width={120}
availableHeight={20}
/>, />,
{ width: 120 }, { width: 120 },
); );
@@ -946,4 +940,72 @@ describe('AskUserDialog', () => {
}); });
}); });
}); });
it('uses availableTerminalHeight from UIStateContext if availableHeight prop is missing', () => {
const questions: Question[] = [
{
question: 'Choose an option',
header: 'Context Test',
options: Array.from({ length: 10 }, (_, i) => ({
label: `Option ${i + 1}`,
description: `Description ${i + 1}`,
})),
multiSelect: false,
},
];
const mockUIState = {
availableTerminalHeight: 5, // Small height to force scroll arrows
} as UIState;
const { lastFrame } = renderWithProviders(
<UIStateContext.Provider value={mockUIState}>
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={80}
/>
</UIStateContext.Provider>,
{ useAlternateBuffer: false },
);
// With height 5 and alternate buffer disabled, it should show scroll arrows (▲)
expect(lastFrame()).toContain('▲');
expect(lastFrame()).toContain('▼');
});
it('does NOT truncate the question when in alternate buffer mode even with small height', () => {
const longQuestion =
'This is a very long question ' + 'with many words '.repeat(10);
const questions: Question[] = [
{
question: longQuestion,
header: 'Alternate Buffer Test',
options: [{ label: 'Option 1', description: 'Desc 1' }],
multiSelect: false,
},
];
const mockUIState = {
availableTerminalHeight: 5,
} as UIState;
const { lastFrame } = renderWithProviders(
<UIStateContext.Provider value={mockUIState}>
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={40} // Small width to force wrapping
/>
</UIStateContext.Provider>,
{ useAlternateBuffer: true },
);
// Should NOT contain the truncation message
expect(lastFrame()).not.toContain('hidden ...');
// Should contain the full long question (or at least its parts)
expect(lastFrame()).toContain('This is a very long question');
});
}); });

View File

@@ -5,7 +5,14 @@
*/ */
import type React from 'react'; import type React from 'react';
import { useCallback, useMemo, useRef, useEffect, useReducer } from 'react'; import {
useCallback,
useMemo,
useRef,
useEffect,
useReducer,
useContext,
} from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
import type { Question } from '@google/gemini-cli-core'; import type { Question } from '@google/gemini-cli-core';
@@ -21,6 +28,8 @@ import { getCachedStringWidth } from '../utils/textUtils.js';
import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js'; import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js';
import { DialogFooter } from './shared/DialogFooter.js'; import { DialogFooter } from './shared/DialogFooter.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js'; import { MaxSizedBox } from './shared/MaxSizedBox.js';
import { UIStateContext } from '../contexts/UIStateContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
interface AskUserDialogState { interface AskUserDialogState {
answers: { [key: string]: string }; answers: { [key: string]: string };
@@ -121,7 +130,7 @@ interface AskUserDialogProps {
/** /**
* Height constraint for scrollable content. * Height constraint for scrollable content.
*/ */
availableHeight: number; availableHeight?: number;
} }
interface ReviewViewProps { interface ReviewViewProps {
@@ -199,7 +208,7 @@ interface TextQuestionViewProps {
onSelectionChange?: (answer: string) => void; onSelectionChange?: (answer: string) => void;
onEditingCustomOption?: (editing: boolean) => void; onEditingCustomOption?: (editing: boolean) => void;
availableWidth: number; availableWidth: number;
availableHeight: number; availableHeight?: number;
initialAnswer?: string; initialAnswer?: string;
progressHeader?: React.ReactNode; progressHeader?: React.ReactNode;
keyboardHints?: React.ReactNode; keyboardHints?: React.ReactNode;
@@ -216,6 +225,7 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
progressHeader, progressHeader,
keyboardHints, keyboardHints,
}) => { }) => {
const isAlternateBuffer = useAlternateBuffer();
const prefix = '> '; const prefix = '> ';
const horizontalPadding = 1; // 1 for cursor const horizontalPadding = 1; // 1 for cursor
const bufferWidth = const bufferWidth =
@@ -279,13 +289,20 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
const INPUT_HEIGHT = 2; // TextInput + margin const INPUT_HEIGHT = 2; // TextInput + margin
const FOOTER_HEIGHT = 2; // DialogFooter + margin const FOOTER_HEIGHT = 2; // DialogFooter + margin
const overhead = HEADER_HEIGHT + INPUT_HEIGHT + FOOTER_HEIGHT; const overhead = HEADER_HEIGHT + INPUT_HEIGHT + FOOTER_HEIGHT;
const questionHeight = Math.max(1, availableHeight - overhead); const questionHeight =
availableHeight && !isAlternateBuffer
? Math.max(1, availableHeight - overhead)
: undefined;
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
{progressHeader} {progressHeader}
<Box marginBottom={1}> <Box marginBottom={1}>
<MaxSizedBox maxHeight={questionHeight} maxWidth={availableWidth}> <MaxSizedBox
maxHeight={questionHeight}
maxWidth={availableWidth}
overflowDirection="bottom"
>
<Text bold color={theme.text.primary}> <Text bold color={theme.text.primary}>
{question.question} {question.question}
</Text> </Text>
@@ -389,7 +406,7 @@ interface ChoiceQuestionViewProps {
onSelectionChange?: (answer: string) => void; onSelectionChange?: (answer: string) => void;
onEditingCustomOption?: (editing: boolean) => void; onEditingCustomOption?: (editing: boolean) => void;
availableWidth: number; availableWidth: number;
availableHeight: number; availableHeight?: number;
initialAnswer?: string; initialAnswer?: string;
progressHeader?: React.ReactNode; progressHeader?: React.ReactNode;
keyboardHints?: React.ReactNode; keyboardHints?: React.ReactNode;
@@ -406,6 +423,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
progressHeader, progressHeader,
keyboardHints, keyboardHints,
}) => { }) => {
const isAlternateBuffer = useAlternateBuffer();
const numOptions = const numOptions =
(question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0); (question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0);
const numLen = String(numOptions).length; const numLen = String(numOptions).length;
@@ -711,18 +729,27 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
const TITLE_MARGIN = 1; const TITLE_MARGIN = 1;
const FOOTER_HEIGHT = 2; // DialogFooter + margin const FOOTER_HEIGHT = 2; // DialogFooter + margin
const overhead = HEADER_HEIGHT + TITLE_MARGIN + FOOTER_HEIGHT; const overhead = HEADER_HEIGHT + TITLE_MARGIN + FOOTER_HEIGHT;
const listHeight = Math.max(1, availableHeight - overhead); const listHeight = availableHeight
const questionHeight = Math.min(3, Math.max(1, listHeight - 4)); ? Math.max(1, availableHeight - overhead)
const maxItemsToShow = Math.max( : undefined;
1, const questionHeight =
Math.floor((listHeight - questionHeight) / 2), listHeight && !isAlternateBuffer
); ? Math.min(15, Math.max(1, listHeight - 4))
: undefined;
const maxItemsToShow =
listHeight && questionHeight
? Math.max(1, Math.floor((listHeight - questionHeight) / 2))
: selectionItems.length;
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
{progressHeader} {progressHeader}
<Box marginBottom={TITLE_MARGIN}> <Box marginBottom={TITLE_MARGIN}>
<MaxSizedBox maxHeight={questionHeight} maxWidth={availableWidth}> <MaxSizedBox
maxHeight={questionHeight}
maxWidth={availableWidth}
overflowDirection="bottom"
>
<Text bold color={theme.text.primary}> <Text bold color={theme.text.primary}>
{question.question} {question.question}
{question.multiSelect && ( {question.multiSelect && (
@@ -824,8 +851,15 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
onCancel, onCancel,
onActiveTextInputChange, onActiveTextInputChange,
width, width,
availableHeight, availableHeight: availableHeightProp,
}) => { }) => {
const uiState = useContext(UIStateContext);
const availableHeight =
availableHeightProp ??
(uiState?.constrainHeight !== false
? uiState?.availableTerminalHeight
: undefined);
const [state, dispatch] = useReducer(askUserDialogReducerLogic, initialState); const [state, dispatch] = useReducer(askUserDialogReducerLogic, initialState);
const { answers, isEditingCustomOption, submitted } = state; const { answers, isEditingCustomOption, submitted } = state;

View File

@@ -1,5 +1,56 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 1`] = `
"Choose an option
● 1. Option 1
Description 1
2. Option 2
Description 2
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 1`] = `
"Choose an option
● 1. Option 1
Description 1
2. Option 2
Description 2
3. Option 3
Description 3
4. Option 4
Description 4
5. Option 5
Description 5
6. Option 6
Description 6
7. Option 7
Description 7
8. Option 8
Description 8
9. Option 9
Description 9
10. Option 10
Description 10
11. Option 11
Description 11
12. Option 12
Description 12
13. Option 13
Description 13
14. Option 14
Description 14
15. Option 15
Description 15
16. Enter a custom value
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = ` exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = `
"What should we name this component? "What should we name this component?
@@ -104,19 +155,6 @@ Which database should we use?
Enter to select · ←/→ to switch questions · Esc to cancel" Enter to select · ←/→ to switch questions · Esc to cancel"
`; `;
exports[`AskUserDialog > shows scroll arrows when options exceed available height 1`] = `
"Choose an option
● 1. Option 1
Description 1
2. Option 2
Description 2
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > shows warning for unanswered questions on Review tab 1`] = ` exports[`AskUserDialog > shows warning for unanswered questions on Review tab 1`] = `
"← □ License │ □ README │ ≡ Review → "← □ License │ □ README │ ≡ Review →

View File

@@ -271,7 +271,7 @@ export const ToolConfirmationMessage: React.FC<
handleConfirm(ToolConfirmationOutcome.Cancel); handleConfirm(ToolConfirmationOutcome.Cancel);
}} }}
width={terminalWidth} width={terminalWidth}
availableHeight={availableBodyContentHeight() ?? 10} availableHeight={availableBodyContentHeight()}
/> />
); );
return { question: '', bodyContent, options: [] }; return { question: '', bodyContent, options: [] };