mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-30 04:30:48 +00:00
Merge branch 'main' into feat/multimodal-input-support
This commit is contained in:
commit
68760287bd
248 changed files with 22086 additions and 7988 deletions
|
|
@ -6,17 +6,19 @@
|
|||
|
||||
import { Box, Text } from 'ink';
|
||||
import { IdeIntegrationNudge } from '../IdeIntegrationNudge.js';
|
||||
import { CommandFormatMigrationNudge } from '../CommandFormatMigrationNudge.js';
|
||||
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 { PluginChoicePrompt } from './PluginChoicePrompt.js';
|
||||
import { ThemeDialog } from './ThemeDialog.js';
|
||||
import { SettingsDialog } from './SettingsDialog.js';
|
||||
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
|
||||
import { AuthDialog } from '../auth/AuthDialog.js';
|
||||
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
|
||||
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
|
||||
import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js';
|
||||
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
|
||||
import { ModelDialog } from './ModelDialog.js';
|
||||
import { ApprovalModeDialog } from './ApprovalModeDialog.js';
|
||||
|
|
@ -76,15 +78,6 @@ export const DialogManager = ({
|
|||
if (uiState.showIdeRestartPrompt) {
|
||||
return <IdeTrustChangeDialog reason={uiState.ideTrustRestartReason} />;
|
||||
}
|
||||
if (uiState.showWorkspaceMigrationDialog) {
|
||||
return (
|
||||
<WorkspaceMigrationDialog
|
||||
workspaceExtensions={uiState.workspaceExtensions}
|
||||
onOpen={uiActions.onWorkspaceMigrationDialogOpen}
|
||||
onClose={uiActions.onWorkspaceMigrationDialogClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.shouldShowIdePrompt) {
|
||||
return (
|
||||
<IdeIntegrationNudge
|
||||
|
|
@ -93,6 +86,14 @@ export const DialogManager = ({
|
|||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.shouldShowCommandMigrationNudge) {
|
||||
return (
|
||||
<CommandFormatMigrationNudge
|
||||
tomlFiles={uiState.commandMigrationTomlFiles}
|
||||
onComplete={uiActions.handleCommandMigrationComplete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.isFolderTrustDialogOpen) {
|
||||
return (
|
||||
<FolderTrustDialog
|
||||
|
|
@ -132,6 +133,34 @@ 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.pluginChoiceRequests.length > 0) {
|
||||
const request = uiState.pluginChoiceRequests[0];
|
||||
return (
|
||||
<PluginChoicePrompt
|
||||
key={request.marketplaceName}
|
||||
marketplaceName={request.marketplaceName}
|
||||
plugins={request.plugins}
|
||||
onSelect={request.onSelect}
|
||||
onCancel={request.onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.isThemeDialogOpen) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
|
|
|
|||
|
|
@ -36,6 +36,11 @@ vi.mock('../utils/clipboardUtils.js');
|
|||
vi.mock('../contexts/UIStateContext.js', () => ({
|
||||
useUIState: vi.fn(() => ({ isFeedbackDialogOpen: false })),
|
||||
}));
|
||||
vi.mock('../contexts/UIActionsContext.js', () => ({
|
||||
useUIActions: vi.fn(() => ({
|
||||
temporaryCloseFeedbackDialog: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockSlashCommands: SlashCommand[] = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import * as path from 'node:path';
|
|||
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
|
||||
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
|
||||
export interface InputPromptProps {
|
||||
buffer: TextBuffer;
|
||||
|
|
@ -109,6 +110,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
}) => {
|
||||
const isShellFocused = useShellFocusState();
|
||||
const uiState = useUIState();
|
||||
const uiActions = useUIActions();
|
||||
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
||||
const [escPressCount, setEscPressCount] = useState(0);
|
||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||
|
|
@ -337,12 +339,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// Intercept feedback dialog option keys (1, 2) when dialog is open
|
||||
if (
|
||||
uiState.isFeedbackDialogOpen &&
|
||||
(FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)
|
||||
) {
|
||||
return;
|
||||
// Handle feedback dialog keyboard interactions when dialog is open
|
||||
if (uiState.isFeedbackDialogOpen) {
|
||||
// If it's one of the feedback option keys (1-4), let FeedbackDialog handle it
|
||||
if ((FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)) {
|
||||
return;
|
||||
} else {
|
||||
// For any other key, close feedback dialog temporarily and continue with normal processing
|
||||
uiActions.temporaryCloseFeedbackDialog();
|
||||
// Continue processing the key for normal input handling
|
||||
}
|
||||
}
|
||||
|
||||
// Reset ESC count and hide prompt on any non-ESC key
|
||||
|
|
@ -712,6 +718,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
onToggleShortcuts,
|
||||
showShortcuts,
|
||||
uiState,
|
||||
uiActions,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
243
packages/cli/src/ui/components/PluginChoicePrompt.test.tsx
Normal file
243
packages/cli/src/ui/components/PluginChoicePrompt.test.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
/**
|
||||
* @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 { PluginChoicePrompt } from './PluginChoicePrompt.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
|
||||
vi.mock('../hooks/useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseKeypress = vi.mocked(useKeypress);
|
||||
|
||||
describe('PluginChoicePrompt', () => {
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
const terminalWidth = 80;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders marketplace name in title', () => {
|
||||
const { lastFrame } = render(
|
||||
<PluginChoicePrompt
|
||||
marketplaceName="test-marketplace"
|
||||
plugins={[{ name: 'plugin1' }]}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('test-marketplace');
|
||||
});
|
||||
|
||||
it('renders plugin names', () => {
|
||||
const { lastFrame } = render(
|
||||
<PluginChoicePrompt
|
||||
marketplaceName="test"
|
||||
plugins={[
|
||||
{ name: 'plugin1', description: 'First plugin' },
|
||||
{ name: 'plugin2', description: 'Second plugin' },
|
||||
]}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('plugin1');
|
||||
expect(lastFrame()).toContain('plugin2');
|
||||
});
|
||||
|
||||
it('renders description for selected plugin only', () => {
|
||||
const { lastFrame } = render(
|
||||
<PluginChoicePrompt
|
||||
marketplaceName="test"
|
||||
plugins={[
|
||||
{ name: 'plugin1', description: 'First plugin description' },
|
||||
{ name: 'plugin2', description: 'Second plugin description' },
|
||||
]}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
// First plugin is selected by default, should show its description
|
||||
expect(lastFrame()).toContain('First plugin description');
|
||||
});
|
||||
|
||||
it('renders help text', () => {
|
||||
const { lastFrame } = render(
|
||||
<PluginChoicePrompt
|
||||
marketplaceName="test"
|
||||
plugins={[{ name: 'plugin1' }]}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('↑↓');
|
||||
expect(lastFrame()).toContain('Enter');
|
||||
expect(lastFrame()).toContain('Escape');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrolling behavior', () => {
|
||||
it('does not show scroll indicators for small lists', () => {
|
||||
const { lastFrame } = render(
|
||||
<PluginChoicePrompt
|
||||
marketplaceName="test"
|
||||
plugins={[
|
||||
{ name: 'plugin1' },
|
||||
{ name: 'plugin2' },
|
||||
{ name: 'plugin3' },
|
||||
]}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('more above');
|
||||
expect(lastFrame()).not.toContain('more below');
|
||||
});
|
||||
|
||||
it('shows "more below" indicator for long lists', () => {
|
||||
const plugins = Array.from({ length: 15 }, (_, i) => ({
|
||||
name: `plugin${i + 1}`,
|
||||
}));
|
||||
|
||||
const { lastFrame } = render(
|
||||
<PluginChoicePrompt
|
||||
marketplaceName="test"
|
||||
plugins={plugins}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
// At the beginning, should show "more below" but not "more above"
|
||||
expect(lastFrame()).not.toContain('more above');
|
||||
expect(lastFrame()).toContain('more below');
|
||||
});
|
||||
|
||||
it('shows progress indicator for long lists', () => {
|
||||
const plugins = Array.from({ length: 15 }, (_, i) => ({
|
||||
name: `plugin${i + 1}`,
|
||||
}));
|
||||
|
||||
const { lastFrame } = render(
|
||||
<PluginChoicePrompt
|
||||
marketplaceName="test"
|
||||
plugins={plugins}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should show progress like "(1/15)"
|
||||
expect(lastFrame()).toContain('(1/15)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyboard navigation', () => {
|
||||
it('registers keypress handler', () => {
|
||||
render(
|
||||
<PluginChoicePrompt
|
||||
marketplaceName="test"
|
||||
plugins={[{ name: 'plugin1' }]}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockedUseKeypress).toHaveBeenCalledWith(expect.any(Function), {
|
||||
isActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onCancel when escape is pressed', () => {
|
||||
render(
|
||||
<PluginChoicePrompt
|
||||
marketplaceName="test"
|
||||
plugins={[{ name: 'plugin1' }]}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
keypressHandler({ name: 'escape', sequence: '\x1b' } as never);
|
||||
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onSelect with plugin name when enter is pressed', () => {
|
||||
render(
|
||||
<PluginChoicePrompt
|
||||
marketplaceName="test"
|
||||
plugins={[{ name: 'test-plugin' }]}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
keypressHandler({ name: 'return', sequence: '\r' } as never);
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('test-plugin');
|
||||
});
|
||||
|
||||
it('calls onSelect with correct plugin when number key 1-9 is pressed', () => {
|
||||
render(
|
||||
<PluginChoicePrompt
|
||||
marketplaceName="test"
|
||||
plugins={[
|
||||
{ name: 'plugin1' },
|
||||
{ name: 'plugin2' },
|
||||
{ name: 'plugin3' },
|
||||
]}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
keypressHandler({ name: '2', sequence: '2' } as never);
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('plugin2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selection indicator', () => {
|
||||
it('shows selection indicator for first plugin by default', () => {
|
||||
const { lastFrame } = render(
|
||||
<PluginChoicePrompt
|
||||
marketplaceName="test"
|
||||
plugins={[{ name: 'plugin1' }, { name: 'plugin2' }]}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('❯');
|
||||
});
|
||||
});
|
||||
});
|
||||
195
packages/cli/src/ui/components/PluginChoicePrompt.tsx
Normal file
195
packages/cli/src/ui/components/PluginChoicePrompt.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { useKeypress, type Key } from '../hooks/useKeypress.js';
|
||||
|
||||
interface PluginChoice {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
type PluginChoicePromptProps = {
|
||||
marketplaceName: string;
|
||||
plugins: PluginChoice[];
|
||||
onSelect: (pluginName: string) => void;
|
||||
onCancel: () => void;
|
||||
terminalWidth: number;
|
||||
};
|
||||
|
||||
// Maximum number of visible items in the list
|
||||
const MAX_VISIBLE_ITEMS = 8;
|
||||
|
||||
export const PluginChoicePrompt = (props: PluginChoicePromptProps) => {
|
||||
const { marketplaceName, plugins, onSelect, onCancel } = props;
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const prefixWidth = 2; // "❯ " or " "
|
||||
|
||||
const handleKeypress = useCallback(
|
||||
(key: Key) => {
|
||||
const { name, sequence } = key;
|
||||
|
||||
if (name === 'escape') {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'return') {
|
||||
const plugin = plugins[selectedIndex];
|
||||
if (plugin) {
|
||||
onSelect(plugin.name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate up
|
||||
if (name === 'up' || sequence === 'k') {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : plugins.length - 1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate down
|
||||
if (name === 'down' || sequence === 'j') {
|
||||
setSelectedIndex((prev) => (prev < plugins.length - 1 ? prev + 1 : 0));
|
||||
return;
|
||||
}
|
||||
|
||||
// Number shortcuts (1-9)
|
||||
const num = parseInt(sequence || '', 10);
|
||||
if (!isNaN(num) && num >= 1 && num <= plugins.length && num <= 9) {
|
||||
setSelectedIndex(num - 1);
|
||||
const plugin = plugins[num - 1];
|
||||
if (plugin) {
|
||||
onSelect(plugin.name);
|
||||
}
|
||||
}
|
||||
},
|
||||
[plugins, selectedIndex, onSelect, onCancel],
|
||||
);
|
||||
|
||||
useKeypress(handleKeypress, { isActive: true });
|
||||
|
||||
// Calculate visible range for scrolling
|
||||
const { visiblePlugins, startIndex, hasMore, hasLess } = useMemo(() => {
|
||||
const total = plugins.length;
|
||||
if (total <= MAX_VISIBLE_ITEMS) {
|
||||
return {
|
||||
visiblePlugins: plugins,
|
||||
startIndex: 0,
|
||||
hasMore: false,
|
||||
hasLess: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate window position to keep selected item visible
|
||||
let start = 0;
|
||||
const halfWindow = Math.floor(MAX_VISIBLE_ITEMS / 2);
|
||||
|
||||
if (selectedIndex <= halfWindow) {
|
||||
// Near the beginning
|
||||
start = 0;
|
||||
} else if (selectedIndex >= total - halfWindow) {
|
||||
// Near the end
|
||||
start = total - MAX_VISIBLE_ITEMS;
|
||||
} else {
|
||||
// In the middle - center on selected
|
||||
start = selectedIndex - halfWindow;
|
||||
}
|
||||
|
||||
const end = Math.min(start + MAX_VISIBLE_ITEMS, total);
|
||||
|
||||
return {
|
||||
visiblePlugins: plugins.slice(start, end),
|
||||
startIndex: start,
|
||||
hasLess: start > 0,
|
||||
hasMore: end < total,
|
||||
};
|
||||
}, [plugins, selectedIndex]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
paddingY={1}
|
||||
paddingX={2}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={theme.text.accent}>
|
||||
{t('Select a plugin from "{{name}}"', { name: marketplaceName })}
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{/* Show "more items above" indicator */}
|
||||
{hasLess && (
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{' '}
|
||||
↑ {t('{{count}} more above', { count: String(startIndex) })}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{visiblePlugins.map((plugin, visibleIndex) => {
|
||||
const actualIndex = startIndex + visibleIndex;
|
||||
const isSelected = actualIndex === selectedIndex;
|
||||
const prefix = isSelected ? '❯ ' : ' ';
|
||||
|
||||
return (
|
||||
<Box key={plugin.name} flexDirection="column">
|
||||
<Box flexDirection="row">
|
||||
<Text color={isSelected ? theme.text.accent : undefined}>
|
||||
{prefix}
|
||||
</Text>
|
||||
<Text
|
||||
bold={isSelected}
|
||||
color={isSelected ? theme.text.accent : undefined}
|
||||
>
|
||||
{plugin.name}
|
||||
</Text>
|
||||
</Box>
|
||||
{/* Show full description only for selected item */}
|
||||
{isSelected && plugin.description && (
|
||||
<Box marginLeft={prefixWidth}>
|
||||
<Text color={theme.text.accent}>{plugin.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Show "more items below" indicator */}
|
||||
{hasMore && (
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{' '}
|
||||
↓{' '}
|
||||
{t('{{count}} more below', {
|
||||
count: String(plugins.length - startIndex - MAX_VISIBLE_ITEMS),
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} flexDirection="row" gap={2}>
|
||||
<Text dimColor>
|
||||
{t('Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel')}
|
||||
</Text>
|
||||
{plugins.length > MAX_VISIBLE_ITEMS && (
|
||||
<Text dimColor>
|
||||
({selectedIndex + 1}/{plugins.length})
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
133
packages/cli/src/ui/components/SettingInputPrompt.test.tsx
Normal file
133
packages/cli/src/ui/components/SettingInputPrompt.test.tsx
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
157
packages/cli/src/ui/components/SettingInputPrompt.tsx
Normal file
157
packages/cli/src/ui/components/SettingInputPrompt.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1368,7 +1368,7 @@ describe('SettingsDialog', () => {
|
|||
enabled: true,
|
||||
},
|
||||
context: {
|
||||
loadMemoryFromIncludeDirectories: true,
|
||||
loadFromIncludeDirectories: true,
|
||||
fileFiltering: {
|
||||
respectGitIgnore: true,
|
||||
respectQwenIgnore: true,
|
||||
|
|
@ -1540,7 +1540,7 @@ describe('SettingsDialog', () => {
|
|||
enableRecursiveFileSearch: false,
|
||||
disableFuzzySearch: true,
|
||||
},
|
||||
loadMemoryFromIncludeDirectories: true,
|
||||
loadFromIncludeDirectories: true,
|
||||
},
|
||||
});
|
||||
const onSelect = vi.fn();
|
||||
|
|
@ -1605,7 +1605,7 @@ describe('SettingsDialog', () => {
|
|||
enabled: false,
|
||||
},
|
||||
context: {
|
||||
loadMemoryFromIncludeDirectories: false,
|
||||
loadFromIncludeDirectories: false,
|
||||
fileFiltering: {
|
||||
respectGitIgnore: false,
|
||||
respectQwenIgnore: false,
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ def fibonacci(n):
|
|||
availableTerminalHeight={diffHeight}
|
||||
contentWidth={colorizeCodeWidth}
|
||||
theme={previewTheme}
|
||||
settings={settings}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
type Extension,
|
||||
performWorkspaceExtensionMigration,
|
||||
} from '../../config/extension.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useState } from 'react';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
|
||||
export function WorkspaceMigrationDialog(props: {
|
||||
workspaceExtensions: Extension[];
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { workspaceExtensions, onOpen, onClose } = props;
|
||||
const [migrationComplete, setMigrationComplete] = useState(false);
|
||||
const [failedExtensions, setFailedExtensions] = useState<string[]>([]);
|
||||
onOpen();
|
||||
const onMigrate = async () => {
|
||||
const failed = await performWorkspaceExtensionMigration(
|
||||
workspaceExtensions,
|
||||
// We aren't updating extensions, just moving them around, don't need to ask for consent.
|
||||
async (_) => true,
|
||||
);
|
||||
setFailedExtensions(failed);
|
||||
setMigrationComplete(true);
|
||||
};
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (migrationComplete && key.sequence === 'q') {
|
||||
process.exit(0);
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
if (migrationComplete) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
padding={1}
|
||||
>
|
||||
{failedExtensions.length > 0 ? (
|
||||
<>
|
||||
<Text color={theme.text.primary}>
|
||||
The following extensions failed to migrate. Please try installing
|
||||
them manually. To see other changes, Qwen Code must be restarted.
|
||||
Press 'q' to quit.
|
||||
</Text>
|
||||
<Box flexDirection="column" marginTop={1} marginLeft={2}>
|
||||
{failedExtensions.map((failed) => (
|
||||
<Text key={failed}>- {failed}</Text>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Text color={theme.text.primary}>
|
||||
Migration complete. To see changes, Qwen Code must be restarted.
|
||||
Press 'q' to quit.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
padding={1}
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Workspace-level extensions are deprecated{'\n'}
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
Would you like to install them at the user level?
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
The extension definition will remain in your workspace directory.
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
If you opt to skip, you can install them manually using the extensions
|
||||
install command.
|
||||
</Text>
|
||||
|
||||
<Box flexDirection="column" marginTop={1} marginLeft={2}>
|
||||
{workspaceExtensions.map((extension) => (
|
||||
<Text key={extension.config.name}>- {extension.config.name}</Text>
|
||||
))}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect
|
||||
items={[
|
||||
{ label: 'Install all', value: 'migrate', key: 'migrate' },
|
||||
{ label: 'Skip', value: 'skip', key: 'skip' },
|
||||
]}
|
||||
onSelect={(value: string) => {
|
||||
if (value === 'migrate') {
|
||||
onMigrate();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,15 @@ import { render } from 'ink-testing-library';
|
|||
import { DiffRenderer } from './DiffRenderer.js';
|
||||
import * as CodeColorizer from '../../utils/CodeColorizer.js';
|
||||
import { vi } from 'vitest';
|
||||
import type { LoadedSettings } from '../../../config/settings.js';
|
||||
|
||||
const mockSettings: LoadedSettings = {
|
||||
merged: {
|
||||
ui: {
|
||||
showLineNumbers: true,
|
||||
},
|
||||
},
|
||||
} as LoadedSettings;
|
||||
|
||||
describe('<OverflowProvider><DiffRenderer /></OverflowProvider>', () => {
|
||||
const mockColorizeCode = vi.spyOn(CodeColorizer, 'colorizeCode');
|
||||
|
|
@ -17,8 +26,8 @@ describe('<OverflowProvider><DiffRenderer /></OverflowProvider>', () => {
|
|||
mockColorizeCode.mockClear();
|
||||
});
|
||||
|
||||
const sanitizeOutput = (output: string | undefined, terminalWidth: number) =>
|
||||
output?.replace(/GAP_INDICATOR/g, '═'.repeat(terminalWidth));
|
||||
const sanitizeOutput = (output: string | undefined, contentWidth: number) =>
|
||||
output?.replace(/GAP_INDICATOR/g, '═'.repeat(contentWidth));
|
||||
|
||||
it('should call colorizeCode with correct language for new file with known extension', () => {
|
||||
const newFileDiffContent = `
|
||||
|
|
@ -36,6 +45,7 @@ index 0000000..e69de29
|
|||
diffContent={newFileDiffContent}
|
||||
filename="test.py"
|
||||
contentWidth={80}
|
||||
settings={mockSettings}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
|
@ -45,6 +55,7 @@ index 0000000..e69de29
|
|||
undefined,
|
||||
80,
|
||||
undefined,
|
||||
mockSettings,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -64,6 +75,7 @@ index 0000000..e69de29
|
|||
diffContent={newFileDiffContent}
|
||||
filename="test.unknown"
|
||||
contentWidth={80}
|
||||
settings={mockSettings}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
|
@ -73,6 +85,7 @@ index 0000000..e69de29
|
|||
undefined,
|
||||
80,
|
||||
undefined,
|
||||
mockSettings,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -88,7 +101,11 @@ index 0000000..e69de29
|
|||
`;
|
||||
render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer diffContent={newFileDiffContent} contentWidth={80} />
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiffContent}
|
||||
contentWidth={80}
|
||||
settings={mockSettings}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(mockColorizeCode).toHaveBeenCalledWith(
|
||||
|
|
@ -97,6 +114,7 @@ index 0000000..e69de29
|
|||
undefined,
|
||||
80,
|
||||
undefined,
|
||||
mockSettings,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -116,6 +134,7 @@ index 0000001..0000002 100644
|
|||
diffContent={existingFileDiffContent}
|
||||
filename="test.txt"
|
||||
contentWidth={80}
|
||||
settings={mockSettings}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
|
@ -146,6 +165,7 @@ index 1234567..1234567 100644
|
|||
diffContent={noChangeDiff}
|
||||
filename="file.txt"
|
||||
contentWidth={80}
|
||||
settings={mockSettings}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
|
@ -156,7 +176,11 @@ index 1234567..1234567 100644
|
|||
it('should handle empty diff content', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer diffContent="" contentWidth={80} />
|
||||
<DiffRenderer
|
||||
diffContent=""
|
||||
contentWidth={80}
|
||||
settings={mockSettings}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(lastFrame()).toContain('No diff content');
|
||||
|
|
@ -183,6 +207,7 @@ index 123..456 100644
|
|||
diffContent={diffWithGap}
|
||||
filename="file.txt"
|
||||
contentWidth={80}
|
||||
settings={mockSettings}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
|
@ -220,6 +245,7 @@ index abc..def 100644
|
|||
diffContent={diffWithSmallGap}
|
||||
filename="file.txt"
|
||||
contentWidth={80}
|
||||
settings={mockSettings}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
|
@ -251,7 +277,7 @@ index 123..789 100644
|
|||
|
||||
it.each([
|
||||
{
|
||||
terminalWidth: 80,
|
||||
contentWidth: 80,
|
||||
height: undefined,
|
||||
expected: ` 1 console.log('first hunk');
|
||||
2 - const oldVar = 1;
|
||||
|
|
@ -264,7 +290,7 @@ index 123..789 100644
|
|||
22 console.log('end of second hunk');`,
|
||||
},
|
||||
{
|
||||
terminalWidth: 80,
|
||||
contentWidth: 80,
|
||||
height: 6,
|
||||
expected: `... first 4 lines hidden ...
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -274,7 +300,7 @@ index 123..789 100644
|
|||
22 console.log('end of second hunk');`,
|
||||
},
|
||||
{
|
||||
terminalWidth: 30,
|
||||
contentWidth: 30,
|
||||
height: 6,
|
||||
expected: `... first 10 lines hidden ...
|
||||
;
|
||||
|
|
@ -284,20 +310,21 @@ index 123..789 100644
|
|||
second hunk');`,
|
||||
},
|
||||
])(
|
||||
'with terminalWidth $terminalWidth and height $height',
|
||||
({ terminalWidth, height, expected }) => {
|
||||
'with contentWidth $contentWidth and height $height',
|
||||
({ contentWidth, height, expected }) => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={diffWithMultipleHunks}
|
||||
filename="multi.js"
|
||||
contentWidth={terminalWidth}
|
||||
contentWidth={contentWidth}
|
||||
availableTerminalHeight={height}
|
||||
settings={mockSettings}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(sanitizeOutput(output, terminalWidth)).toEqual(expected);
|
||||
expect(sanitizeOutput(output, contentWidth)).toEqual(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -324,6 +351,7 @@ fileDiff Index: file.txt
|
|||
diffContent={newFileDiff}
|
||||
filename="TEST"
|
||||
contentWidth={80}
|
||||
settings={mockSettings}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
|
@ -354,6 +382,7 @@ fileDiff Index: Dockerfile
|
|||
diffContent={newFileDiff}
|
||||
filename="Dockerfile"
|
||||
contentWidth={80}
|
||||
settings={mockSettings}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
|
@ -362,4 +391,86 @@ fileDiff Index: Dockerfile
|
|||
2 RUN npm install
|
||||
3 RUN npm run build`);
|
||||
});
|
||||
|
||||
describe('showLineNumbers setting', () => {
|
||||
const diffContent = `
|
||||
diff --git a/test.txt b/test.txt
|
||||
index 0000001..0000002 100644
|
||||
--- a/test.txt
|
||||
+++ b/test.txt
|
||||
@@ -1,2 +1,2 @@
|
||||
-old line 1
|
||||
+new line 1
|
||||
context line 2
|
||||
`;
|
||||
|
||||
it('should show line numbers by default when settings is undefined', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={diffContent}
|
||||
filename="test.txt"
|
||||
contentWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('1 -');
|
||||
expect(output).toContain('1 +');
|
||||
expect(output).toContain('2 ');
|
||||
});
|
||||
|
||||
it('should show line numbers when showLineNumbers is true', () => {
|
||||
const mockSettings = {
|
||||
merged: {
|
||||
ui: {
|
||||
showLineNumbers: true,
|
||||
},
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={diffContent}
|
||||
filename="test.txt"
|
||||
contentWidth={80}
|
||||
settings={mockSettings}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('1 -');
|
||||
expect(output).toContain('1 +');
|
||||
expect(output).toContain('2 ');
|
||||
});
|
||||
|
||||
it('should hide line numbers when showLineNumbers is false', () => {
|
||||
const mockSettings = {
|
||||
merged: {
|
||||
ui: {
|
||||
showLineNumbers: false,
|
||||
},
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={diffContent}
|
||||
filename="test.txt"
|
||||
contentWidth={80}
|
||||
settings={mockSettings}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
// Line numbers should not be present
|
||||
expect(output).not.toMatch(/^\s*\d+\s*[-+]/m);
|
||||
// But the content should still be there
|
||||
expect(output).toContain('old line 1');
|
||||
expect(output).toContain('new line 1');
|
||||
expect(output).toContain('context line 2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { colorizeCode, colorizeLine } from '../../utils/CodeColorizer.js';
|
|||
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||
import { theme as semanticTheme } from '../../semantic-colors.js';
|
||||
import type { Theme } from '../../themes/theme.js';
|
||||
import type { LoadedSettings } from '../../../config/settings.js';
|
||||
|
||||
interface DiffLine {
|
||||
type: 'add' | 'del' | 'context' | 'hunk' | 'other';
|
||||
|
|
@ -86,6 +87,7 @@ interface DiffRendererProps {
|
|||
availableTerminalHeight?: number;
|
||||
contentWidth: number;
|
||||
theme?: Theme;
|
||||
settings?: LoadedSettings;
|
||||
}
|
||||
|
||||
const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization
|
||||
|
|
@ -97,6 +99,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
|||
availableTerminalHeight,
|
||||
contentWidth,
|
||||
theme,
|
||||
settings,
|
||||
}) => {
|
||||
const screenReaderEnabled = useIsScreenReaderEnabled();
|
||||
if (!diffContent || typeof diffContent !== 'string') {
|
||||
|
|
@ -157,6 +160,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
|||
availableTerminalHeight,
|
||||
contentWidth,
|
||||
theme,
|
||||
settings,
|
||||
);
|
||||
} else {
|
||||
renderedOutput = renderDiffContent(
|
||||
|
|
@ -165,6 +169,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
|||
tabWidth,
|
||||
availableTerminalHeight,
|
||||
contentWidth,
|
||||
settings,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -177,6 +182,7 @@ const renderDiffContent = (
|
|||
tabWidth = DEFAULT_TAB_WIDTH,
|
||||
availableTerminalHeight: number | undefined,
|
||||
contentWidth: number,
|
||||
settings?: LoadedSettings,
|
||||
) => {
|
||||
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing
|
||||
const normalizedLines = parsedLines.map((line) => ({
|
||||
|
|
@ -201,6 +207,8 @@ const renderDiffContent = (
|
|||
);
|
||||
}
|
||||
|
||||
const showLineNumbers = settings?.merged.ui?.showLineNumbers ?? true;
|
||||
|
||||
const maxLineNumber = Math.max(
|
||||
0,
|
||||
...displayableLines.map((l) => l.oldLine ?? 0),
|
||||
|
|
@ -299,18 +307,20 @@ const renderDiffContent = (
|
|||
|
||||
acc.push(
|
||||
<Box key={lineKey} flexDirection="row">
|
||||
<Text
|
||||
color={semanticTheme.text.secondary}
|
||||
backgroundColor={
|
||||
line.type === 'add'
|
||||
? semanticTheme.background.diff.added
|
||||
: line.type === 'del'
|
||||
? semanticTheme.background.diff.removed
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{gutterNumStr.padStart(gutterWidth)}{' '}
|
||||
</Text>
|
||||
{showLineNumbers && (
|
||||
<Text
|
||||
color={semanticTheme.text.secondary}
|
||||
backgroundColor={
|
||||
line.type === 'add'
|
||||
? semanticTheme.background.diff.added
|
||||
: line.type === 'del'
|
||||
? semanticTheme.background.diff.removed
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{gutterNumStr.padStart(gutterWidth)}{' '}
|
||||
</Text>
|
||||
)}
|
||||
{line.type === 'context' ? (
|
||||
<>
|
||||
<Text>{prefixSymbol} </Text>
|
||||
|
|
|
|||
|
|
@ -226,6 +226,7 @@ export const ToolConfirmationMessage: React.FC<
|
|||
filename={confirmationDetails.fileName}
|
||||
availableTerminalHeight={availableBodyContentHeight()}
|
||||
contentWidth={contentWidth}
|
||||
settings={settings}
|
||||
/>
|
||||
);
|
||||
} else if (confirmationDetails.type === 'exec') {
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@ import { ToolMessage } from './ToolMessage.js';
|
|||
import { StreamingState, ToolCallStatus } from '../../types.js';
|
||||
import { Text } from 'ink';
|
||||
import { StreamingContext } from '../../contexts/StreamingContext.js';
|
||||
import { SettingsContext } from '../../contexts/SettingsContext.js';
|
||||
import type {
|
||||
AnsiOutput,
|
||||
AnsiOutputDisplay,
|
||||
Config,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { LoadedSettings } from '../../../config/settings.js';
|
||||
|
||||
vi.mock('../TerminalOutput.js', () => ({
|
||||
TerminalOutput: function MockTerminalOutput({
|
||||
|
|
@ -58,10 +60,17 @@ vi.mock('../GeminiRespondingSpinner.js', () => ({
|
|||
vi.mock('./DiffRenderer.js', () => ({
|
||||
DiffRenderer: function MockDiffRenderer({
|
||||
diffContent,
|
||||
settings,
|
||||
}: {
|
||||
diffContent: string;
|
||||
settings?: unknown;
|
||||
}) {
|
||||
return <Text>MockDiff:{diffContent}</Text>;
|
||||
return (
|
||||
<Text>
|
||||
MockDiff:{diffContent}
|
||||
{settings ? ':withSettings' : ''}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
}));
|
||||
vi.mock('../../utils/MarkdownDisplay.js', () => ({
|
||||
|
|
@ -83,6 +92,15 @@ vi.mock('../subagents/index.js', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
// Mock settings
|
||||
const mockSettings: LoadedSettings = {
|
||||
merged: {
|
||||
ui: {
|
||||
showLineNumbers: true,
|
||||
},
|
||||
},
|
||||
} as LoadedSettings;
|
||||
|
||||
// Helper to render with context
|
||||
const renderWithContext = (
|
||||
ui: React.ReactElement,
|
||||
|
|
@ -90,9 +108,11 @@ const renderWithContext = (
|
|||
) => {
|
||||
const contextValue: StreamingState = streamingState;
|
||||
return render(
|
||||
<StreamingContext.Provider value={contextValue}>
|
||||
{ui}
|
||||
</StreamingContext.Provider>,
|
||||
<SettingsContext.Provider value={mockSettings}>
|
||||
<StreamingContext.Provider value={contextValue}>
|
||||
{ui}
|
||||
</StreamingContext.Provider>
|
||||
</SettingsContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ import {
|
|||
TOOL_STATUS,
|
||||
} from '../../constants.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import type { LoadedSettings } from '../../../config/settings.js';
|
||||
|
||||
const STATIC_HEIGHT = 1;
|
||||
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
|
||||
|
|
@ -210,12 +212,14 @@ const DiffResultRenderer: React.FC<{
|
|||
data: { fileDiff: string; fileName: string };
|
||||
availableHeight?: number;
|
||||
childWidth: number;
|
||||
}> = ({ data, availableHeight, childWidth }) => (
|
||||
settings?: LoadedSettings;
|
||||
}> = ({ data, availableHeight, childWidth, settings }) => (
|
||||
<DiffRenderer
|
||||
diffContent={data.fileDiff}
|
||||
filename={data.fileName}
|
||||
availableTerminalHeight={availableHeight}
|
||||
contentWidth={childWidth}
|
||||
settings={settings}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -243,6 +247,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
|||
ptyId,
|
||||
config,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const isThisShellFocused =
|
||||
(name === SHELL_COMMAND_NAME || name === 'Shell') &&
|
||||
status === ToolCallStatus.Executing &&
|
||||
|
|
@ -348,6 +353,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
|||
data={displayRenderer.data}
|
||||
availableHeight={availableHeight}
|
||||
childWidth={innerWidth}
|
||||
settings={settings}
|
||||
/>
|
||||
)}
|
||||
{displayRenderer.type === 'ansi' && (
|
||||
|
|
|
|||
|
|
@ -58,7 +58,11 @@ export const ActionSelectionStep = ({
|
|||
},
|
||||
];
|
||||
|
||||
const actions = selectedAgent?.isBuiltin
|
||||
// Extension-level agents are also read-only (like builtin)
|
||||
const isReadOnly =
|
||||
selectedAgent?.isBuiltin || selectedAgent?.level === 'extension';
|
||||
|
||||
const actions = isReadOnly
|
||||
? allActions.filter(
|
||||
(action) => action.value === 'view' || action.value === 'back',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,10 +12,11 @@ import { type SubagentConfig } from '@qwen-code/qwen-code-core';
|
|||
import { t } from '../../../../i18n/index.js';
|
||||
|
||||
interface NavigationState {
|
||||
currentBlock: 'project' | 'user' | 'builtin';
|
||||
currentBlock: 'project' | 'user' | 'builtin' | 'extension';
|
||||
projectIndex: number;
|
||||
userIndex: number;
|
||||
builtinIndex: number;
|
||||
extensionIndex: number;
|
||||
}
|
||||
|
||||
interface AgentSelectionStepProps {
|
||||
|
|
@ -32,6 +33,7 @@ export const AgentSelectionStep = ({
|
|||
projectIndex: 0,
|
||||
userIndex: 0,
|
||||
builtinIndex: 0,
|
||||
extensionIndex: 0,
|
||||
});
|
||||
|
||||
// Group agents by level
|
||||
|
|
@ -47,6 +49,10 @@ export const AgentSelectionStep = ({
|
|||
() => availableAgents.filter((agent) => agent.level === 'builtin'),
|
||||
[availableAgents],
|
||||
);
|
||||
const extensionAgents = useMemo(
|
||||
() => availableAgents.filter((agent) => agent.level === 'extension'),
|
||||
[availableAgents],
|
||||
);
|
||||
const projectNames = useMemo(
|
||||
() => new Set(projectAgents.map((agent) => agent.name)),
|
||||
[projectAgents],
|
||||
|
|
@ -60,8 +66,10 @@ export const AgentSelectionStep = ({
|
|||
setNavigation((prev) => ({ ...prev, currentBlock: 'user' }));
|
||||
} else if (builtinAgents.length > 0) {
|
||||
setNavigation((prev) => ({ ...prev, currentBlock: 'builtin' }));
|
||||
} else if (extensionAgents.length > 0) {
|
||||
setNavigation((prev) => ({ ...prev, currentBlock: 'extension' }));
|
||||
}
|
||||
}, [projectAgents, userAgents, builtinAgents]);
|
||||
}, [projectAgents, userAgents, builtinAgents, extensionAgents]);
|
||||
|
||||
// Custom keyboard navigation
|
||||
useKeypress(
|
||||
|
|
@ -87,6 +95,13 @@ export const AgentSelectionStep = ({
|
|||
currentBlock: 'user',
|
||||
userIndex: userAgents.length - 1,
|
||||
};
|
||||
} else if (extensionAgents.length > 0) {
|
||||
// Move to last item in extension block
|
||||
return {
|
||||
...prev,
|
||||
currentBlock: 'extension',
|
||||
extensionIndex: extensionAgents.length - 1,
|
||||
};
|
||||
} else {
|
||||
// Wrap to last item in project block
|
||||
return { ...prev, projectIndex: projectAgents.length - 1 };
|
||||
|
|
@ -108,11 +123,18 @@ export const AgentSelectionStep = ({
|
|||
currentBlock: 'builtin',
|
||||
builtinIndex: builtinAgents.length - 1,
|
||||
};
|
||||
} else if (extensionAgents.length > 0) {
|
||||
// Move to last item in extension block
|
||||
return {
|
||||
...prev,
|
||||
currentBlock: 'extension',
|
||||
extensionIndex: extensionAgents.length - 1,
|
||||
};
|
||||
} else {
|
||||
// Wrap to last item in user block
|
||||
return { ...prev, userIndex: userAgents.length - 1 };
|
||||
}
|
||||
} else {
|
||||
} else if (prev.currentBlock === 'builtin') {
|
||||
// builtin block
|
||||
if (prev.builtinIndex > 0) {
|
||||
return { ...prev, builtinIndex: prev.builtinIndex - 1 };
|
||||
|
|
@ -130,10 +152,46 @@ export const AgentSelectionStep = ({
|
|||
currentBlock: 'project',
|
||||
projectIndex: projectAgents.length - 1,
|
||||
};
|
||||
} else if (extensionAgents.length > 0) {
|
||||
// Move to last item in extension block
|
||||
return {
|
||||
...prev,
|
||||
currentBlock: 'extension',
|
||||
extensionIndex: extensionAgents.length - 1,
|
||||
};
|
||||
} else {
|
||||
// Wrap to last item in builtin block
|
||||
return { ...prev, builtinIndex: builtinAgents.length - 1 };
|
||||
}
|
||||
} else {
|
||||
// extension block
|
||||
if (prev.extensionIndex > 0) {
|
||||
return { ...prev, extensionIndex: prev.extensionIndex - 1 };
|
||||
} else if (userAgents.length > 0) {
|
||||
// Move to last item in user block
|
||||
return {
|
||||
...prev,
|
||||
currentBlock: 'user',
|
||||
userIndex: userAgents.length - 1,
|
||||
};
|
||||
} else if (projectAgents.length > 0) {
|
||||
// Move to last item in project block
|
||||
return {
|
||||
...prev,
|
||||
currentBlock: 'project',
|
||||
projectIndex: projectAgents.length - 1,
|
||||
};
|
||||
} else if (builtinAgents.length > 0) {
|
||||
// Move to last item in builtin block
|
||||
return {
|
||||
...prev,
|
||||
currentBlock: 'builtin',
|
||||
builtinIndex: builtinAgents.length - 1,
|
||||
};
|
||||
} else {
|
||||
// Wrap to last item in extension block
|
||||
return { ...prev, extensionIndex: extensionAgents.length - 1 };
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (name === 'down' || name === 'j') {
|
||||
|
|
@ -147,6 +205,9 @@ export const AgentSelectionStep = ({
|
|||
} else if (builtinAgents.length > 0) {
|
||||
// Move to first item in builtin block
|
||||
return { ...prev, currentBlock: 'builtin', builtinIndex: 0 };
|
||||
} else if (extensionAgents.length > 0) {
|
||||
// Move to first item in extension block
|
||||
return { ...prev, currentBlock: 'extension', extensionIndex: 0 };
|
||||
} else {
|
||||
// Wrap to first item in project block
|
||||
return { ...prev, projectIndex: 0 };
|
||||
|
|
@ -157,6 +218,9 @@ export const AgentSelectionStep = ({
|
|||
} else if (builtinAgents.length > 0) {
|
||||
// Move to first item in builtin block
|
||||
return { ...prev, currentBlock: 'builtin', builtinIndex: 0 };
|
||||
} else if (extensionAgents.length > 0) {
|
||||
// Move to first item in extension block
|
||||
return { ...prev, currentBlock: 'extension', extensionIndex: 0 };
|
||||
} else if (projectAgents.length > 0) {
|
||||
// Move to first item in project block
|
||||
return { ...prev, currentBlock: 'project', projectIndex: 0 };
|
||||
|
|
@ -164,10 +228,13 @@ export const AgentSelectionStep = ({
|
|||
// Wrap to first item in user block
|
||||
return { ...prev, userIndex: 0 };
|
||||
}
|
||||
} else {
|
||||
} else if (prev.currentBlock === 'builtin') {
|
||||
// builtin block
|
||||
if (prev.builtinIndex < builtinAgents.length - 1) {
|
||||
return { ...prev, builtinIndex: prev.builtinIndex + 1 };
|
||||
} else if (extensionAgents.length > 0) {
|
||||
// Move to first item in extension block
|
||||
return { ...prev, currentBlock: 'extension', extensionIndex: 0 };
|
||||
} else if (projectAgents.length > 0) {
|
||||
// Move to first item in project block
|
||||
return { ...prev, currentBlock: 'project', projectIndex: 0 };
|
||||
|
|
@ -178,6 +245,23 @@ export const AgentSelectionStep = ({
|
|||
// Wrap to first item in builtin block
|
||||
return { ...prev, builtinIndex: 0 };
|
||||
}
|
||||
} else {
|
||||
// extension block
|
||||
if (prev.extensionIndex < extensionAgents.length - 1) {
|
||||
return { ...prev, extensionIndex: prev.extensionIndex + 1 };
|
||||
} else if (projectAgents.length > 0) {
|
||||
// Move to first item in project block
|
||||
return { ...prev, currentBlock: 'project', projectIndex: 0 };
|
||||
} else if (userAgents.length > 0) {
|
||||
// Move to first item in user block
|
||||
return { ...prev, currentBlock: 'user', userIndex: 0 };
|
||||
} else if (builtinAgents.length > 0) {
|
||||
// Move to first item in builtin block
|
||||
return { ...prev, currentBlock: 'builtin', builtinIndex: 0 };
|
||||
} else {
|
||||
// Wrap to first item in extension block
|
||||
return { ...prev, extensionIndex: 0 };
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (name === 'return' || name === 'space') {
|
||||
|
|
@ -188,11 +272,17 @@ export const AgentSelectionStep = ({
|
|||
} else if (navigation.currentBlock === 'user') {
|
||||
// User agents come after project agents in the availableAgents array
|
||||
globalIndex = projectAgents.length + navigation.userIndex;
|
||||
} else {
|
||||
// builtin block
|
||||
} else if (navigation.currentBlock === 'builtin') {
|
||||
// Builtin agents come after project and user agents in the availableAgents array
|
||||
globalIndex =
|
||||
projectAgents.length + userAgents.length + navigation.builtinIndex;
|
||||
} else {
|
||||
// Extension agents come after project, user, and builtin agents
|
||||
globalIndex =
|
||||
projectAgents.length +
|
||||
userAgents.length +
|
||||
builtinAgents.length +
|
||||
navigation.extensionIndex;
|
||||
}
|
||||
|
||||
if (globalIndex >= 0 && globalIndex < availableAgents.length) {
|
||||
|
|
@ -218,7 +308,7 @@ export const AgentSelectionStep = ({
|
|||
const renderAgentItem = (
|
||||
agent: {
|
||||
name: string;
|
||||
level: 'project' | 'user' | 'builtin' | 'session';
|
||||
level: 'project' | 'user' | 'builtin' | 'session' | 'extension';
|
||||
isBuiltin?: boolean;
|
||||
},
|
||||
index: number,
|
||||
|
|
@ -258,7 +348,8 @@ export const AgentSelectionStep = ({
|
|||
const enabledAgentsCount =
|
||||
projectAgents.length +
|
||||
userAgents.filter((agent) => !projectNames.has(agent.name)).length +
|
||||
builtinAgents.length;
|
||||
builtinAgents.length +
|
||||
extensionAgents.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
|
|
@ -305,7 +396,10 @@ export const AgentSelectionStep = ({
|
|||
|
||||
{/* Built-in Agents */}
|
||||
{builtinAgents.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
marginBottom={extensionAgents.length > 0 ? 1 : 0}
|
||||
>
|
||||
<Text color={theme.text.primary} bold>
|
||||
{t('Built-in Agents')}
|
||||
</Text>
|
||||
|
|
@ -320,10 +414,28 @@ export const AgentSelectionStep = ({
|
|||
</Box>
|
||||
)}
|
||||
|
||||
{/* Extension Agents */}
|
||||
{extensionAgents.length > 0 && (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.primary} bold>
|
||||
{t('Extension Agents')}
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{extensionAgents.map((agent, index) => {
|
||||
const isSelected =
|
||||
navigation.currentBlock === 'extension' &&
|
||||
navigation.extensionIndex === index;
|
||||
return renderAgentItem(agent, index, isSelected);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Agent count summary */}
|
||||
{(projectAgents.length > 0 ||
|
||||
userAgents.length > 0 ||
|
||||
builtinAgents.length > 0) && (
|
||||
builtinAgents.length > 0 ||
|
||||
extensionAgents.length > 0) && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Using: {{count}} agents', {
|
||||
|
|
|
|||
|
|
@ -95,7 +95,11 @@ export function AgentsManagerDialog({
|
|||
|
||||
try {
|
||||
const subagentManager = config.getSubagentManager();
|
||||
await subagentManager.deleteSubagent(agent.name, agent.level);
|
||||
await subagentManager.deleteSubagent(
|
||||
agent.name,
|
||||
agent.level,
|
||||
agent.extensionName,
|
||||
);
|
||||
|
||||
// Reload agents to get updated state
|
||||
await loadAgents();
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ const mockUseUIState = vi.mocked(useUIState);
|
|||
const mockExtensions = [
|
||||
{ name: 'ext-one', version: '1.0.0', isActive: true },
|
||||
{ name: 'ext-two', version: '2.1.0', isActive: true },
|
||||
{ name: 'ext-disabled', version: '3.0.0', isActive: false },
|
||||
];
|
||||
|
||||
describe('<ExtensionsList />', () => {
|
||||
|
|
@ -29,7 +28,6 @@ describe('<ExtensionsList />', () => {
|
|||
const mockUIState = (
|
||||
extensions: unknown[],
|
||||
extensionsUpdateState: Map<string, ExtensionUpdateState>,
|
||||
disabledExtensions: string[] = [],
|
||||
) => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
commandContext: createMockCommandContext({
|
||||
|
|
@ -37,13 +35,6 @@ describe('<ExtensionsList />', () => {
|
|||
config: {
|
||||
getExtensions: () => extensions,
|
||||
},
|
||||
settings: {
|
||||
merged: {
|
||||
extensions: {
|
||||
disabled: disabledExtensions,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
extensionsUpdateState,
|
||||
|
|
@ -58,12 +49,11 @@ describe('<ExtensionsList />', () => {
|
|||
});
|
||||
|
||||
it('should render a list of extensions with their version and status', () => {
|
||||
mockUIState(mockExtensions, new Map(), ['ext-disabled']);
|
||||
mockUIState(mockExtensions, new Map());
|
||||
const { lastFrame } = render(<ExtensionsList />);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('ext-one (v1.0.0) - active');
|
||||
expect(output).toContain('ext-two (v2.1.0) - active');
|
||||
expect(output).toContain('ext-disabled (v3.0.0) - disabled');
|
||||
});
|
||||
|
||||
it('should display "unknown state" if an extension has no update state', () => {
|
||||
|
|
|
|||
|
|
@ -9,12 +9,10 @@ import { useUIState } from '../../contexts/UIStateContext.js';
|
|||
import { ExtensionUpdateState } from '../../state/extensions.js';
|
||||
|
||||
export const ExtensionsList = () => {
|
||||
const { commandContext, extensionsUpdateState } = useUIState();
|
||||
const allExtensions = commandContext.services.config!.getExtensions();
|
||||
const settings = commandContext.services.settings;
|
||||
const disabledExtensions = settings.merged.extensions?.disabled ?? [];
|
||||
const { extensionsUpdateState, commandContext } = useUIState();
|
||||
const extensions = commandContext.services.config?.getExtensions() || [];
|
||||
|
||||
if (allExtensions.length === 0) {
|
||||
if (extensions.length === 0) {
|
||||
return <Text>No extensions installed.</Text>;
|
||||
}
|
||||
|
||||
|
|
@ -22,10 +20,11 @@ export const ExtensionsList = () => {
|
|||
<Box flexDirection="column" marginTop={1} marginBottom={1}>
|
||||
<Text>Installed extensions:</Text>
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
{allExtensions.map((ext) => {
|
||||
{extensions.map((ext) => {
|
||||
const state = extensionsUpdateState.get(ext.name);
|
||||
const isActive = !disabledExtensions.includes(ext.name);
|
||||
const isActive = ext.isActive;
|
||||
const activeString = isActive ? 'active' : 'disabled';
|
||||
const activeColor = isActive ? 'green' : 'grey';
|
||||
|
||||
let stateColor = 'gray';
|
||||
const stateText = state || 'unknown state';
|
||||
|
|
@ -44,6 +43,7 @@ export const ExtensionsList = () => {
|
|||
break;
|
||||
case ExtensionUpdateState.UP_TO_DATE:
|
||||
case ExtensionUpdateState.NOT_UPDATABLE:
|
||||
case ExtensionUpdateState.UPDATED:
|
||||
stateColor = 'green';
|
||||
break;
|
||||
default:
|
||||
|
|
@ -52,12 +52,22 @@ export const ExtensionsList = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Box key={ext.name}>
|
||||
<Box key={ext.name} flexDirection="column" marginBottom={1}>
|
||||
<Text>
|
||||
<Text color="cyan">{`${ext.name} (v${ext.version})`}</Text>
|
||||
{` - ${activeString}`}
|
||||
<Text color={activeColor}>{` - ${activeString}`}</Text>
|
||||
{<Text color={stateColor}>{` (${stateText})`}</Text>}
|
||||
</Text>
|
||||
{ext.resolvedSettings && ext.resolvedSettings.length > 0 && (
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
<Text>settings:</Text>
|
||||
{ext.resolvedSettings.map((setting) => (
|
||||
<Text key={setting.name}>
|
||||
- {setting.name}: {setting.value}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue