feat(plan): reuse standard tool confirmation for AskUser tool (#17864)

Co-authored-by: jacob314 <jacob314@gmail.com>
This commit is contained in:
Jerop Kipruto
2026-01-30 13:32:21 -05:00
committed by GitHub
parent 13e013230b
commit 62346875e4
24 changed files with 675 additions and 702 deletions

View File

@@ -41,6 +41,8 @@ describe('AskUserDialog', () => {
questions={authQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -105,6 +107,8 @@ describe('AskUserDialog', () => {
questions={questions}
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -124,6 +128,8 @@ describe('AskUserDialog', () => {
questions={authQuestion}
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -153,12 +159,42 @@ describe('AskUserDialog', () => {
});
});
it('shows scroll arrows when options exceed available height', async () => {
const questions: Question[] = [
{
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(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={80}
availableHeight={10} // Small height to force scrolling
/>,
);
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot();
});
});
it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => {
const { stdin, lastFrame } = renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -209,6 +245,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -222,6 +260,8 @@ describe('AskUserDialog', () => {
questions={authQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -235,6 +275,8 @@ describe('AskUserDialog', () => {
questions={authQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -265,6 +307,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -306,6 +350,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -373,6 +419,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -401,6 +449,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -445,6 +495,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -480,6 +532,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -512,6 +566,8 @@ describe('AskUserDialog', () => {
questions={textQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -533,6 +589,8 @@ describe('AskUserDialog', () => {
questions={textQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -554,6 +612,8 @@ describe('AskUserDialog', () => {
questions={textQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -588,6 +648,8 @@ describe('AskUserDialog', () => {
questions={textQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -618,6 +680,8 @@ describe('AskUserDialog', () => {
questions={mixedQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -664,6 +728,8 @@ describe('AskUserDialog', () => {
questions={mixedQuestions}
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -713,6 +779,8 @@ describe('AskUserDialog', () => {
questions={textQuestion}
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -738,6 +806,8 @@ describe('AskUserDialog', () => {
questions={textQuestion}
onSubmit={vi.fn()}
onCancel={onCancel}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -783,6 +853,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);
@@ -841,6 +913,8 @@ describe('AskUserDialog', () => {
questions={multiQuestions}
onSubmit={onSubmit}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);

View File

@@ -5,14 +5,7 @@
*/
import type React from 'react';
import {
useCallback,
useMemo,
useRef,
useEffect,
useReducer,
useContext,
} from 'react';
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';
@@ -24,10 +17,10 @@ import { keyMatchers, Command } from '../keyMatchers.js';
import { checkExhaustive } from '../../utils/checks.js';
import { TextInput } from './shared/TextInput.js';
import { useTextBuffer } from './shared/text-buffer.js';
import { UIStateContext } from '../contexts/UIStateContext.js';
import { getCachedStringWidth } from '../utils/textUtils.js';
import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js';
import { DialogFooter } from './shared/DialogFooter.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js';
interface AskUserDialogState {
answers: { [key: string]: string };
@@ -121,6 +114,14 @@ interface AskUserDialogProps {
* Useful for managing global keypress handlers.
*/
onActiveTextInputChange?: (active: boolean) => void;
/**
* Width of the dialog.
*/
width: number;
/**
* Height constraint for scrollable content.
*/
availableHeight: number;
}
interface ReviewViewProps {
@@ -152,12 +153,7 @@ const ReviewView: React.FC<ReviewViewProps> = ({
);
return (
<Box
flexDirection="column"
borderStyle="round"
paddingX={1}
borderColor={theme.border.default}
>
<Box flexDirection="column">
{progressHeader}
<Box marginBottom={1}>
<Text bold color={theme.text.primary}>
@@ -174,15 +170,19 @@ const ReviewView: React.FC<ReviewViewProps> = ({
</Box>
)}
{questions.map((q, i) => (
<Box key={i} marginBottom={0}>
<Text color={theme.text.secondary}>{q.header}</Text>
<Text color={theme.text.secondary}> </Text>
<Text color={answers[i] ? theme.text.primary : theme.status.warning}>
{answers[i] || '(not answered)'}
</Text>
</Box>
))}
<Box flexDirection="column">
{questions.map((q, i) => (
<Box key={i} marginBottom={0}>
<Text color={theme.text.secondary}>{q.header}</Text>
<Text color={theme.text.secondary}> </Text>
<Text
color={answers[i] ? theme.text.primary : theme.status.warning}
>
{answers[i] || '(not answered)'}
</Text>
</Box>
))}
</Box>
<DialogFooter
primaryAction="Enter to submit"
navigationActions="Tab/Shift+Tab to edit answers"
@@ -199,6 +199,7 @@ interface TextQuestionViewProps {
onSelectionChange?: (answer: string) => void;
onEditingCustomOption?: (editing: boolean) => void;
availableWidth: number;
availableHeight: number;
initialAnswer?: string;
progressHeader?: React.ReactNode;
keyboardHints?: React.ReactNode;
@@ -210,12 +211,13 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
onSelectionChange,
onEditingCustomOption,
availableWidth,
availableHeight,
initialAnswer,
progressHeader,
keyboardHints,
}) => {
const prefix = '> ';
const horizontalPadding = 4 + 1; // Padding from Box (2) and border (2) + 1 for cursor
const horizontalPadding = 1; // 1 for cursor
const bufferWidth =
availableWidth - getCachedStringWidth(prefix) - horizontalPadding;
@@ -241,12 +243,15 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
const handleExtraKeys = useCallback(
(key: Key) => {
if (keyMatchers[Command.QUIT](key)) {
if (textValue === '') {
return false;
}
buffer.setText('');
return true;
}
return false;
},
[buffer],
[buffer, textValue],
);
useKeypress(handleExtraKeys, { isActive: true, priority: true });
@@ -270,18 +275,21 @@ 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"
borderStyle="round"
paddingX={1}
borderColor={theme.border.default}
>
<Box flexDirection="column">
{progressHeader}
<Box marginBottom={1}>
<Text bold color={theme.text.primary}>
{question.question}
</Text>
<MaxSizedBox maxHeight={questionHeight} maxWidth={availableWidth}>
<Text bold color={theme.text.primary}>
{question.question}
</Text>
</MaxSizedBox>
</Box>
<Box flexDirection="row" marginBottom={1}>
@@ -381,6 +389,7 @@ interface ChoiceQuestionViewProps {
onSelectionChange?: (answer: string) => void;
onEditingCustomOption?: (editing: boolean) => void;
availableWidth: number;
availableHeight: number;
initialAnswer?: string;
progressHeader?: React.ReactNode;
keyboardHints?: React.ReactNode;
@@ -391,14 +400,12 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
onAnswer,
onSelectionChange,
onEditingCustomOption,
availableWidth,
availableHeight,
initialAnswer,
progressHeader,
keyboardHints,
}) => {
const uiState = useContext(UIStateContext);
const terminalWidth = uiState?.terminalWidth ?? 80;
const availableWidth = terminalWidth;
const numOptions =
(question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0);
const numLen = String(numOptions).length;
@@ -407,15 +414,9 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
const checkboxWidth = question.multiSelect ? 4 : 1; // "[x] " or " "
const checkmarkWidth = question.multiSelect ? 0 : 2; // "" or " ✓"
const cursorPadding = 1; // Extra character for cursor at end of line
const outerBoxPadding = 4; // border (2) + paddingX (2)
const horizontalPadding =
outerBoxPadding +
radioWidth +
numberWidth +
checkboxWidth +
checkmarkWidth +
cursorPadding;
radioWidth + numberWidth + checkboxWidth + checkmarkWidth + cursorPadding;
const bufferWidth = availableWidth - horizontalPadding;
@@ -544,6 +545,9 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
(key: Key) => {
// If focusing custom option, handle Ctrl+C
if (isCustomOptionFocused && keyMatchers[Command.QUIT](key)) {
if (customOptionText === '') {
return false;
}
customBuffer.setText('');
return true;
}
@@ -586,7 +590,12 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
}
return false;
},
[isCustomOptionFocused, customBuffer, onEditingCustomOption],
[
isCustomOptionFocused,
customBuffer,
onEditingCustomOption,
customOptionText,
],
);
useKeypress(handleExtraKeys, { isActive: true, priority: true });
@@ -698,31 +707,41 @@ 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),
);
return (
<Box
flexDirection="column"
borderStyle="round"
paddingX={1}
borderColor={theme.border.default}
>
<Box flexDirection="column">
{progressHeader}
<Box marginBottom={1}>
<Text bold color={theme.text.primary}>
{question.question}
</Text>
<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>
</Box>
{question.multiSelect && (
<Text color={theme.text.secondary} italic>
{' '}
(Select all that apply)
</Text>
)}
<BaseSelectionList<OptionItem>
items={selectionItems}
onSelect={handleSelect}
onHighlight={handleHighlight}
focusKey={isCustomOptionFocused ? 'other' : undefined}
maxItemsToShow={maxItemsToShow}
showScrollArrows={true}
renderItem={(item, context) => {
const optionItem = item.value;
const isChecked =
@@ -804,14 +823,12 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
onSubmit,
onCancel,
onActiveTextInputChange,
width,
availableHeight,
}) => {
const [state, dispatch] = useReducer(askUserDialogReducerLogic, initialState);
const { answers, isEditingCustomOption, submitted } = state;
const uiState = useContext(UIStateContext);
const terminalWidth = uiState?.terminalWidth ?? 80;
const availableWidth = terminalWidth;
const reviewTabIndex = questions.length;
const tabCount =
questions.length > 1 ? questions.length + 1 : questions.length;
@@ -842,9 +859,12 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
if (keyMatchers[Command.ESCAPE](key)) {
onCancel();
return true;
} else if (keyMatchers[Command.QUIT](key) && !isEditingCustomOption) {
onCancel();
return true;
} else if (keyMatchers[Command.QUIT](key)) {
if (!isEditingCustomOption) {
onCancel();
}
// Return false to let ctrl-C bubble up to AppContainer for exit flow
return false;
}
return false;
},
@@ -1021,7 +1041,8 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
onAnswer={handleAnswer}
onSelectionChange={handleSelectionChange}
onEditingCustomOption={handleEditingCustomOption}
availableWidth={availableWidth}
availableWidth={width}
availableHeight={availableHeight}
initialAnswer={answers[currentQuestionIndex]}
progressHeader={progressHeader}
keyboardHints={keyboardHints}
@@ -1033,7 +1054,8 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
onAnswer={handleAnswer}
onSelectionChange={handleSelectionChange}
onEditingCustomOption={handleEditingCustomOption}
availableWidth={availableWidth}
availableWidth={width}
availableHeight={availableHeight}
initialAnswer={answers[currentQuestionIndex]}
progressHeader={progressHeader}
keyboardHints={keyboardHints}
@@ -1043,7 +1065,7 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
return (
<Box
flexDirection="column"
width={availableWidth}
width={width}
aria-label={`Question ${currentQuestionIndex + 1} of ${questions.length}: ${currentQuestion.question}`}
>
{questionView}

View File

@@ -34,6 +34,8 @@ describe('Key Bubbling Regression', () => {
questions={choiceQuestion}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={120}
availableHeight={20}
/>,
{ width: 120 },
);

View File

@@ -32,8 +32,6 @@ import process from 'node:process';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js';
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
import { AskUserDialog } from './AskUserDialog.js';
import { useAskUserActions } from '../contexts/AskUserActionsContext.js';
import { NewAgentsNotification } from './NewAgentsNotification.js';
import { AgentConfigDialog } from './AgentConfigDialog.js';
@@ -59,22 +57,6 @@ export const DialogManager = ({
terminalWidth: uiTerminalWidth,
} = uiState;
const {
request: askUserRequest,
submit: askUserSubmit,
cancel: askUserCancel,
} = useAskUserActions();
if (askUserRequest) {
return (
<AskUserDialog
questions={askUserRequest.questions}
onSubmit={askUserSubmit}
onCancel={askUserCancel}
/>
);
}
if (uiState.adminSettingsChanged) {
return <AdminSettingsChangedDialog />;
}

View File

@@ -16,6 +16,21 @@ import { OverflowProvider } from '../contexts/OverflowContext.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { StickyHeader } from './StickyHeader.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import type { SerializableConfirmationDetails } from '@google/gemini-cli-core';
function getConfirmationHeader(
details: SerializableConfirmationDetails | undefined,
): string {
const headers: Partial<
Record<SerializableConfirmationDetails['type'], string>
> = {
ask_user: 'Answer Questions',
};
if (!details?.type) {
return 'Action Required';
}
return headers[details.type] ?? 'Action Required';
}
interface ToolConfirmationQueueProps {
confirmingTool: ConfirmingToolState;
@@ -55,6 +70,7 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
: undefined;
const borderColor = theme.status.warning;
const hideToolIdentity = tool.confirmationDetails?.type === 'ask_user';
return (
<OverflowProvider>
@@ -67,25 +83,31 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
>
<Box flexDirection="column" width={mainAreaWidth - 4}>
{/* Header */}
<Box marginBottom={1} justifyContent="space-between">
<Box
marginBottom={hideToolIdentity ? 0 : 1}
justifyContent="space-between"
>
<Text color={theme.status.warning} bold>
Action Required
</Text>
<Text color={theme.text.secondary}>
{index} of {total}
{getConfirmationHeader(tool.confirmationDetails)}
</Text>
{total > 1 && (
<Text color={theme.text.secondary}>
{index} of {total}
</Text>
)}
</Box>
{/* Tool Identity (Context) */}
<Box>
<ToolStatusIndicator status={tool.status} name={tool.name} />
<ToolInfo
name={tool.name}
status={tool.status}
description={tool.description}
emphasis="high"
/>
</Box>
{!hideToolIdentity && (
<Box>
<ToolStatusIndicator status={tool.status} name={tool.name} />
<ToolInfo
name={tool.name}
status={tool.status}
description={tool.description}
emphasis="high"
/>
</Box>
)}
</Box>
</StickyHeader>

View File

@@ -1,138 +1,131 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ What should we name this component? │
│ │
│ > e.g., UserProfileCard │
│ │
│ │
│ Enter to submit · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"What should we name this component?
> e.g., UserProfileCard
Enter to submit · Esc to cancel"
`;
exports[`AskUserDialog > Text type questions > shows correct keyboard hints for text type 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Enter the variable name: │
│ │
│ > Enter your response │
│ │
│ │
│ Enter to submit · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"Enter the variable name:
> Enter your response
Enter to submit · Esc to cancel"
`;
exports[`AskUserDialog > Text type questions > shows default placeholder when none provided 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Enter the database connection string: │
│ │
│ > Enter your response │
│ │
│ │
│ Enter to submit · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"Enter the database connection string:
> Enter your response
Enter to submit · Esc to cancel"
`;
exports[`AskUserDialog > allows navigating to Review tab and back 1`] = `
"╭─────────────────────────────────────────────────────────────────╮
│ ← □ Tests │ □ Docs │ ≡ Review → │
│ │
│ Review your answers: │
│ │
│ ⚠ You have 2 unanswered questions │
│ │
│ Tests → (not answered)
│ Docs → (not answered) │
│ │
│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel │
╰─────────────────────────────────────────────────────────────────╯"
"← □ Tests │ □ Docs │ ≡ Review →
Review your answers:
⚠ You have 2 unanswered questions
Tests → (not answered)
Docs → (not answered)
Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel"
`;
exports[`AskUserDialog > hides progress header for single question 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Which authentication method should we use? │
│ │
│ ● 1. OAuth 2.0 │
│ Industry standard, supports SSO │
2. JWT tokens │
│ Stateless, good for APIs │
│ 3. Enter a custom value │
│ │
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"Which authentication method should we use?
● 1. OAuth 2.0
Industry standard, supports SSO
2. JWT tokens
Stateless, good for APIs
3. Enter a custom value
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > renders question and options 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Which authentication method should we use? │
│ │
│ ● 1. OAuth 2.0 │
│ Industry standard, supports SSO │
2. JWT tokens │
│ Stateless, good for APIs │
│ 3. Enter a custom value │
│ │
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"Which authentication method should we use?
● 1. OAuth 2.0
Industry standard, supports SSO
2. JWT tokens
Stateless, good for APIs
3. Enter a custom value
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > shows Review tab in progress header for multiple questions 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ← □ Framework │ □ Styling │ ≡ Review → │
│ │
│ Which framework? │
│ │
│ ● 1. React │
│ Component library │
2. Vue │
Progressive framework │
│ 3. Enter a custom value │
│ │
│ Enter to select · ←/→ to switch questions · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"← □ Framework │ □ Styling │ ≡ Review →
Which framework?
● 1. React
Component library
2. Vue
Progressive framework
3. Enter a custom value
Enter to select · ←/→ to switch questions · Esc to cancel"
`;
exports[`AskUserDialog > shows keyboard hints 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Which authentication method should we use? │
│ │
│ ● 1. OAuth 2.0 │
│ Industry standard, supports SSO │
2. JWT tokens │
│ Stateless, good for APIs │
│ 3. Enter a custom value │
│ │
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"Which authentication method should we use?
● 1. OAuth 2.0
Industry standard, supports SSO
2. JWT tokens
Stateless, good for APIs
3. Enter a custom value
Enter to select · ↑/↓ to navigate · Esc to cancel"
`;
exports[`AskUserDialog > shows progress header for multiple questions 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ← □ Database │ □ ORM │ ≡ Review → │
│ │
│ Which database should we use? │
│ │
│ ● 1. PostgreSQL │
│ Relational database │
2. MongoDB │
Document database │
│ 3. Enter a custom value │
│ │
│ Enter to select · ←/→ to switch questions · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
"← □ Database │ □ ORM │ ≡ Review →
Which database should we use?
● 1. PostgreSQL
Relational database
2. MongoDB
Document database
3. Enter a custom value
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`] = `
"╭─────────────────────────────────────────────────────────────────╮
│ ← □ License │ □ README │ ≡ Review → │
│ │
│ Review your answers: │
│ │
│ ⚠ You have 2 unanswered questions │
│ │
│ License → (not answered)
│ README → (not answered) │
│ │
│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel │
╰─────────────────────────────────────────────────────────────────╯"
"← □ License │ □ README │ ≡ Review →
Review your answers:
⚠ You have 2 unanswered questions
License → (not answered)
README → (not answered)
Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel"
`;

View File

@@ -2,7 +2,7 @@
exports[`ToolConfirmationQueue > calculates availableContentHeight based on availableTerminalHeight from UI state 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Action Required 1 of 1
│ Action Required
│ │
│ ? replace edit file │
│ │
@@ -22,7 +22,7 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai
exports[`ToolConfirmationQueue > does not render expansion hint when constrainHeight is false 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Action Required 1 of 1
│ Action Required
│ │
│ ? replace edit file │
│ │
@@ -44,7 +44,7 @@ exports[`ToolConfirmationQueue > does not render expansion hint when constrainHe
exports[`ToolConfirmationQueue > renders expansion hint when content is long and constrained 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Action Required 1 of 1
│ Action Required
│ │
│ ? replace edit file │
│ │

View File

@@ -25,12 +25,14 @@ import { sanitizeForDisplay } from '../../utils/textUtils.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { theme } from '../../semantic-colors.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import {
REDIRECTION_WARNING_NOTE_LABEL,
REDIRECTION_WARNING_NOTE_TEXT,
REDIRECTION_WARNING_TIP_LABEL,
REDIRECTION_WARNING_TIP_TEXT,
} from '../../textConstants.js';
import { AskUserDialog } from '../AskUserDialog.js';
export interface ToolConfirmationMessageProps {
callId: string;
@@ -59,9 +61,15 @@ export const ToolConfirmationMessage: React.FC<
const allowPermanentApproval =
settings.merged.security.enablePermanentToolApproval;
const handlesOwnUI = confirmationDetails.type === 'ask_user';
const isTrustedFolder = config.isTrustedFolder();
const handleConfirm = useCallback(
(outcome: ToolConfirmationOutcome) => {
void confirm(callId, outcome).catch((error: unknown) => {
(
outcome: ToolConfirmationOutcome,
payload?: { answers?: { [questionIndex: string]: string } },
) => {
void confirm(callId, outcome, payload).catch((error: unknown) => {
debugLogger.error(
`Failed to handle tool confirmation for ${callId}:`,
error,
@@ -71,15 +79,18 @@ export const ToolConfirmationMessage: React.FC<
[confirm, callId],
);
const isTrustedFolder = config.isTrustedFolder();
useKeypress(
(key) => {
if (!isFocused) return false;
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
if (keyMatchers[Command.ESCAPE](key)) {
handleConfirm(ToolConfirmationOutcome.Cancel);
return true;
}
if (keyMatchers[Command.QUIT](key)) {
// Return false to let ctrl-C bubble up to AppContainer for exit flow.
// AppContainer will call cancelOngoingRequest which will cancel the tool.
return false;
}
return false;
},
{ isActive: isFocused },
@@ -180,7 +191,7 @@ export const ToolConfirmationMessage: React.FC<
value: ToolConfirmationOutcome.Cancel,
key: 'No, suggest changes (esc)',
});
} else {
} else if (confirmationDetails.type === 'mcp') {
// mcp tool confirmation
options.push({
label: 'Allow once',
@@ -251,6 +262,23 @@ export const ToolConfirmationMessage: React.FC<
let question = '';
const options = getOptions();
if (confirmationDetails.type === 'ask_user') {
bodyContent = (
<AskUserDialog
questions={confirmationDetails.questions}
onSubmit={(answers) => {
handleConfirm(ToolConfirmationOutcome.ProceedOnce, { answers });
}}
onCancel={() => {
handleConfirm(ToolConfirmationOutcome.Cancel);
}}
width={terminalWidth}
availableHeight={availableBodyContentHeight() ?? 10}
/>
);
return { question: '', bodyContent, options: [] };
}
if (confirmationDetails.type === 'edit') {
if (!confirmationDetails.isModifying) {
question = `Apply this change?`;
@@ -265,7 +293,7 @@ export const ToolConfirmationMessage: React.FC<
}
} else if (confirmationDetails.type === 'info') {
question = `Do you want to proceed?`;
} else {
} else if (confirmationDetails.type === 'mcp') {
// mcp tool confirmation
const mcpProps = confirmationDetails;
question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`;
@@ -387,7 +415,7 @@ export const ToolConfirmationMessage: React.FC<
)}
</Box>
);
} else {
} else if (confirmationDetails.type === 'mcp') {
// mcp tool confirmation
const mcpProps = confirmationDetails;
@@ -405,6 +433,7 @@ export const ToolConfirmationMessage: React.FC<
getOptions,
availableBodyContentHeight,
terminalWidth,
handleConfirm,
]);
if (confirmationDetails.type === 'edit') {
@@ -429,32 +458,38 @@ export const ToolConfirmationMessage: React.FC<
}
return (
<Box flexDirection="column" paddingTop={0} paddingBottom={1}>
{/* Body Content (Diff Renderer or Command Info) */}
{/* No separate context display here anymore for edits */}
<Box flexGrow={1} flexShrink={1} overflow="hidden">
<MaxSizedBox
maxHeight={availableBodyContentHeight()}
maxWidth={terminalWidth}
overflowDirection="top"
>
{bodyContent}
</MaxSizedBox>
</Box>
<Box
flexDirection="column"
paddingTop={0}
paddingBottom={handlesOwnUI ? 0 : 1}
>
{handlesOwnUI ? (
bodyContent
) : (
<>
<Box flexGrow={1} flexShrink={1} overflow="hidden">
<MaxSizedBox
maxHeight={availableBodyContentHeight()}
maxWidth={terminalWidth}
overflowDirection="top"
>
{bodyContent}
</MaxSizedBox>
</Box>
{/* Confirmation Question */}
<Box marginBottom={1} flexShrink={0}>
<Text color={theme.text.primary}>{question}</Text>
</Box>
<Box marginBottom={1} flexShrink={0}>
<Text color={theme.text.primary}>{question}</Text>
</Box>
{/* Select Input for Options */}
<Box flexShrink={0}>
<RadioButtonSelect
items={options}
onSelect={handleSelect}
isFocused={isFocused}
/>
</Box>
<Box flexShrink={0}>
<RadioButtonSelect
items={options}
onSelect={handleSelect}
isFocused={isFocused}
/>
</Box>
</>
)}
</Box>
);
};

View File

@@ -475,14 +475,7 @@ describe('BaseSelectionList', () => {
);
await waitFor(() => {
const output = lastFrame();
// At the top, should show first 3 items
expect(output).toContain('Item 1');
expect(output).toContain('Item 3');
expect(output).not.toContain('Item 4');
// Both arrows should be visible
expect(output).toContain('▲');
expect(output).toContain('▼');
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -493,15 +486,7 @@ describe('BaseSelectionList', () => {
);
await waitFor(() => {
const output = lastFrame();
// After scrolling to middle, should see items around index 5
expect(output).toContain('Item 4');
expect(output).toContain('Item 6');
expect(output).not.toContain('Item 3');
expect(output).not.toContain('Item 7');
// Both scroll arrows should be visible
expect(output).toContain('▲');
expect(output).toContain('▼');
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -512,32 +497,18 @@ describe('BaseSelectionList', () => {
);
await waitFor(() => {
const output = lastFrame();
// At the end, should show last 3 items
expect(output).toContain('Item 8');
expect(output).toContain('Item 10');
expect(output).not.toContain('Item 7');
// Both arrows should be visible
expect(output).toContain('▲');
expect(output).toContain('▼');
expect(lastFrame()).toMatchSnapshot();
});
});
it('should show both arrows dimmed when list fits entirely', () => {
it('should not show arrows when list fits entirely', () => {
const { lastFrame } = renderComponent({
items,
maxItemsToShow: 5,
showScrollArrows: true,
});
const output = lastFrame();
// Should show all items since maxItemsToShow > items.length
expect(output).toContain('Item A');
expect(output).toContain('Item B');
expect(output).toContain('Item C');
// Both arrows should be visible but dimmed (this test doesn't need waitFor since no scrolling occurs)
expect(output).toContain('▲');
expect(output).toContain('▼');
expect(lastFrame()).toMatchSnapshot();
});
});
});

View File

@@ -100,7 +100,7 @@ export function BaseSelectionList<
return (
<Box flexDirection="column">
{/* Use conditional coloring instead of conditional rendering */}
{showScrollArrows && (
{showScrollArrows && items.length > maxItemsToShow && (
<Text
color={scrollOffset > 0 ? theme.text.primary : theme.text.secondary}
>
@@ -172,7 +172,7 @@ export function BaseSelectionList<
);
})}
{showScrollArrows && (
{showScrollArrows && items.length > maxItemsToShow && (
<Text
color={
scrollOffset + maxItemsToShow < items.length

View File

@@ -0,0 +1,31 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should not show arrows when list fits entirely 1`] = `
"● 1. Item A
2. Item B
3. Item C"
`;
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the end 1`] = `
"▲
8. Item 8
9. Item 9
● 10. Item 10
▼"
`;
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the middle 1`] = `
"▲
4. Item 4
5. Item 5
● 6. Item 6
▼"
`;
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows with correct colors when enabled (at the top) 1`] = `
"▲
● 1. Item 1
2. Item 2
3. Item 3
▼"
`;

View File

@@ -1,14 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DescriptiveRadioButtonSelect > should render correctly with custom props 1`] = `
"
1. Foo Title
" 1. Foo Title
This is Foo.
● 2. Bar Title
This is Bar.
3. Baz Title
This is Baz.
▼"
This is Baz."
`;
exports[`DescriptiveRadioButtonSelect > should render correctly with default props 1`] = `