fix settings in interactive mode

This commit is contained in:
LaZzyMan 2026-01-21 11:29:48 +08:00
parent 140e8c926d
commit 1e41965a7e
13 changed files with 668 additions and 9 deletions

View file

@ -11,6 +11,7 @@ import { LoopDetectionConfirmation } from './LoopDetectionConfirmation.js';
import { FolderTrustDialog } from './FolderTrustDialog.js';
import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
import { ConsentPrompt } from './ConsentPrompt.js';
import { SettingInputPrompt } from './SettingInputPrompt.js';
import { ThemeDialog } from './ThemeDialog.js';
import { SettingsDialog } from './SettingsDialog.js';
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
@ -131,6 +132,21 @@ export const DialogManager = ({
/>
);
}
if (uiState.settingInputRequests.length > 0) {
const request = uiState.settingInputRequests[0];
// Use settingName as key to force re-mount when switching between different settings
return (
<SettingInputPrompt
key={request.settingName}
settingName={request.settingName}
settingDescription={request.settingDescription}
sensitive={request.sensitive}
onSubmit={request.onSubmit}
onCancel={request.onCancel}
terminalWidth={terminalWidth}
/>
);
}
if (uiState.isThemeDialogOpen) {
return (
<Box flexDirection="column">

View file

@ -0,0 +1,133 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render } from 'ink-testing-library';
import { SettingInputPrompt } from './SettingInputPrompt.js';
import { TextInput } from './shared/TextInput.js';
vi.mock('./shared/TextInput.js', () => ({
TextInput: vi.fn(() => null),
}));
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
const MockedTextInput = vi.mocked(TextInput);
describe('SettingInputPrompt', () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
const terminalWidth = 80;
beforeEach(() => {
vi.clearAllMocks();
});
it('renders setting name and description', () => {
const { lastFrame } = render(
<SettingInputPrompt
settingName="API_KEY"
settingDescription="Enter your API key"
sensitive={false}
onSubmit={onSubmit}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(lastFrame()).toContain('API_KEY');
expect(lastFrame()).toContain('Enter your API key');
});
it('renders TextInput for non-sensitive values', () => {
render(
<SettingInputPrompt
settingName="USERNAME"
settingDescription="Enter your username"
sensitive={false}
onSubmit={onSubmit}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(MockedTextInput).toHaveBeenCalled();
});
it('does not render TextInput for sensitive values (uses PasswordInput)', () => {
MockedTextInput.mockClear();
render(
<SettingInputPrompt
settingName="SECRET_KEY"
settingDescription="Enter your secret key"
sensitive={true}
onSubmit={onSubmit}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
// TextInput should not be called for sensitive input
expect(MockedTextInput).not.toHaveBeenCalled();
});
it('shows masked input placeholder for sensitive mode', () => {
const { lastFrame } = render(
<SettingInputPrompt
settingName="PASSWORD"
settingDescription="Enter your password"
sensitive={true}
onSubmit={onSubmit}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
// Should show the sensitive placeholder hint
expect(lastFrame()).toContain('PASSWORD');
expect(lastFrame()).toContain('Enter your password');
});
it('displays help text for submit and cancel', () => {
const { lastFrame } = render(
<SettingInputPrompt
settingName="CONFIG"
settingDescription="Enter config value"
sensitive={false}
onSubmit={onSubmit}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(lastFrame()).toContain('Enter');
expect(lastFrame()).toContain('Escape');
});
it('passes correct props to TextInput for non-sensitive input', () => {
render(
<SettingInputPrompt
settingName="ENDPOINT"
settingDescription="Enter endpoint URL"
sensitive={false}
onSubmit={onSubmit}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(MockedTextInput).toHaveBeenCalledWith(
expect.objectContaining({
value: '',
isActive: true,
inputWidth: expect.any(Number),
}),
undefined,
);
});
});

View file

@ -0,0 +1,157 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useState } from 'react';
import { theme } from '../semantic-colors.js';
import { TextInput } from './shared/TextInput.js';
import { t } from '../../i18n/index.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
import chalk from 'chalk';
type SettingInputPromptProps = {
settingName: string;
settingDescription: string;
sensitive: boolean;
onSubmit: (value: string) => void;
onCancel: () => void;
terminalWidth: number;
};
/**
* A simple password input component that masks the input with asterisks.
*/
const PasswordInput = ({
value,
onChange,
onSubmit,
placeholder,
}: {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
placeholder: string;
}) => {
useKeypress(
(key: Key) => {
// Handle submit
if (key.name === 'return') {
onSubmit();
return;
}
// Handle backspace
if (key.name === 'backspace' || key.name === 'delete') {
onChange(value.slice(0, -1));
return;
}
// Handle clear (Ctrl+U)
if (key.ctrl && key.name === 'u') {
onChange('');
return;
}
// Handle printable characters
if (key.sequence && !key.ctrl && !key.meta && key.sequence.length === 1) {
const charCode = key.sequence.charCodeAt(0);
// Only accept printable ASCII characters (32-126)
if (charCode >= 32 && charCode <= 126) {
onChange(value + key.sequence);
}
}
},
{ isActive: true },
);
const maskedValue = '*'.repeat(value.length);
const displayValue = maskedValue || '';
const cursorChar = chalk.inverse(' ');
return (
<Box>
<Text color={theme.text.accent}>{'> '}</Text>
{value.length === 0 ? (
<Text>
{cursorChar}
<Text dimColor>{placeholder.slice(1)}</Text>
</Text>
) : (
<Text>
{displayValue}
{cursorChar}
</Text>
)}
</Box>
);
};
export const SettingInputPrompt = (props: SettingInputPromptProps) => {
const {
settingName,
settingDescription,
sensitive,
onSubmit,
onCancel,
terminalWidth,
} = props;
const [value, setValue] = useState('');
useKeypress(
(key) => {
if (key.name === 'escape') {
onCancel();
}
},
{ isActive: true },
);
const handleSubmit = () => {
if (value.trim()) {
onSubmit(value);
}
};
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingY={1}
paddingX={2}
>
<Text bold color={theme.text.accent}>
{settingName}
</Text>
<Box marginTop={1}>
<Text>{settingDescription}</Text>
</Box>
<Box marginTop={1} flexDirection="column">
{sensitive ? (
<PasswordInput
value={value}
onChange={setValue}
onSubmit={handleSubmit}
placeholder={t('Enter sensitive value...')}
/>
) : (
<TextInput
value={value}
onChange={setValue}
onSubmit={handleSubmit}
placeholder={t('Enter value...')}
inputWidth={Math.min(terminalWidth - 10, 60)}
isActive={true}
/>
)}
</Box>
<Box marginTop={1}>
<Text dimColor>{t('Press Enter to submit, Escape to cancel')}</Text>
</Box>
</Box>
);
};