mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 20:20:57 +00:00
Merge pull request #1513 from QwenLM/feat/cli-welcome-screen
feat: Redesign CLI welcome screen and settings dialog
This commit is contained in:
commit
ae9ba8be18
111 changed files with 3776 additions and 2660 deletions
|
|
@ -8,16 +8,14 @@ import type React from 'react';
|
|||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import type { ExtendedSystemInfo } from '../../utils/systemInfo.js';
|
||||
import {
|
||||
getSystemInfoFields,
|
||||
getFieldValue,
|
||||
type SystemInfoField,
|
||||
} from '../../utils/systemInfoFields.js';
|
||||
import { getSystemInfoFields } from '../../utils/systemInfoFields.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
type AboutBoxProps = ExtendedSystemInfo;
|
||||
type AboutBoxProps = ExtendedSystemInfo & {
|
||||
width?: number;
|
||||
};
|
||||
|
||||
export const AboutBox: React.FC<AboutBoxProps> = (props) => {
|
||||
export const AboutBox: React.FC<AboutBoxProps> = ({ width, ...props }) => {
|
||||
const fields = getSystemInfoFields(props);
|
||||
|
||||
return (
|
||||
|
|
@ -26,25 +24,26 @@ export const AboutBox: React.FC<AboutBoxProps> = (props) => {
|
|||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
marginY={1}
|
||||
width="100%"
|
||||
width={width}
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
{t('About Qwen Code')}
|
||||
{t('Status')}
|
||||
</Text>
|
||||
</Box>
|
||||
{fields.map((field: SystemInfoField) => (
|
||||
<Box key={field.key} flexDirection="row">
|
||||
{fields.map((field) => (
|
||||
<Box
|
||||
key={field.label}
|
||||
flexDirection="row"
|
||||
marginTop={field.label === t('Auth') ? 1 : 0}
|
||||
>
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
{field.label}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>
|
||||
{getFieldValue(field, props)}
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>{field.value}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
|
|
|
|||
93
packages/cli/src/ui/components/AppHeader.test.tsx
Normal file
93
packages/cli/src/ui/components/AppHeader.test.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
import { type UIState, UIStateContext } from '../contexts/UIStateContext.js';
|
||||
import { VimModeProvider } from '../contexts/VimModeContext.js';
|
||||
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
vi.mock('../hooks/useTerminalSize.js');
|
||||
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
|
||||
|
||||
const createSettings = (options?: { hideTips?: boolean }): LoadedSettings =>
|
||||
({
|
||||
merged: {
|
||||
ui: {
|
||||
hideTips: options?.hideTips ?? true,
|
||||
},
|
||||
},
|
||||
}) as never;
|
||||
|
||||
const createMockConfig = (overrides = {}) => ({
|
||||
getContentGeneratorConfig: vi.fn(() => ({ authType: undefined })),
|
||||
getModel: vi.fn(() => 'gemini-pro'),
|
||||
getTargetDir: vi.fn(() => '/projects/qwen-code'),
|
||||
getMcpServers: vi.fn(() => ({})),
|
||||
getBlockedMcpServers: vi.fn(() => []),
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getScreenReader: vi.fn(() => false),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
({
|
||||
branchName: 'main',
|
||||
nightly: false,
|
||||
debugMessage: '',
|
||||
sessionStats: {
|
||||
lastPromptTokenCount: 0,
|
||||
},
|
||||
...overrides,
|
||||
}) as UIState;
|
||||
|
||||
const renderWithProviders = (
|
||||
uiState: UIState,
|
||||
settings = createSettings(),
|
||||
config = createMockConfig(),
|
||||
) => {
|
||||
useTerminalSizeMock.mockReturnValue({ columns: 120, rows: 24 });
|
||||
return render(
|
||||
<ConfigContext.Provider value={config as never}>
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<VimModeProvider settings={settings}>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<AppHeader version="1.2.3" />
|
||||
</UIStateContext.Provider>
|
||||
</VimModeProvider>
|
||||
</SettingsContext.Provider>
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('<AppHeader />', () => {
|
||||
it('shows the working directory', () => {
|
||||
const { lastFrame } = renderWithProviders(createMockUIState());
|
||||
expect(lastFrame()).toContain('/projects/qwen-code');
|
||||
});
|
||||
|
||||
it('hides the header when screen reader is enabled', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
createMockUIState(),
|
||||
createSettings(),
|
||||
createMockConfig({ getScreenReader: vi.fn(() => true) }),
|
||||
);
|
||||
// When screen reader is enabled, header is not rendered
|
||||
expect(lastFrame()).not.toContain('/projects/qwen-code');
|
||||
expect(lastFrame()).not.toContain('Qwen Code');
|
||||
});
|
||||
|
||||
it('shows the header with all info when banner is visible', () => {
|
||||
const { lastFrame } = renderWithProviders(createMockUIState());
|
||||
expect(lastFrame()).toContain('>_ Qwen Code');
|
||||
expect(lastFrame()).toContain('gemini-pro');
|
||||
expect(lastFrame()).toContain('/projects/qwen-code');
|
||||
});
|
||||
});
|
||||
|
|
@ -9,7 +9,6 @@ import { Header } from './Header.js';
|
|||
import { Tips } from './Tips.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
|
||||
interface AppHeaderProps {
|
||||
version: string;
|
||||
|
|
@ -18,16 +17,25 @@ interface AppHeaderProps {
|
|||
export const AppHeader = ({ version }: AppHeaderProps) => {
|
||||
const settings = useSettings();
|
||||
const config = useConfig();
|
||||
const { nightly } = useUIState();
|
||||
|
||||
const contentGeneratorConfig = config.getContentGeneratorConfig();
|
||||
const authType = contentGeneratorConfig?.authType;
|
||||
const model = config.getModel();
|
||||
const targetDir = config.getTargetDir();
|
||||
const showBanner = !config.getScreenReader();
|
||||
const showTips = !(settings.merged.ui?.hideTips || config.getScreenReader());
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{!(settings.merged.ui?.hideBanner || config.getScreenReader()) && (
|
||||
<Header version={version} nightly={nightly} />
|
||||
)}
|
||||
{!(settings.merged.ui?.hideTips || config.getScreenReader()) && (
|
||||
<Tips config={config} />
|
||||
{showBanner && (
|
||||
<Header
|
||||
version={version}
|
||||
authType={authType}
|
||||
model={model}
|
||||
workingDirectory={targetDir}
|
||||
/>
|
||||
)}
|
||||
{showTips && <Tips />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export function ApprovalModeDialog({
|
|||
}: ApprovalModeDialogProps): React.JSX.Element {
|
||||
// Start with User scope by default
|
||||
const [selectedScope, setSelectedScope] = useState<SettingScope>(
|
||||
SettingScope.Workspace,
|
||||
SettingScope.User,
|
||||
);
|
||||
|
||||
// Track the currently highlighted approval mode
|
||||
|
|
@ -90,19 +90,17 @@ export function ApprovalModeDialog({
|
|||
setSelectedScope(scope);
|
||||
}, []);
|
||||
|
||||
const handleScopeSelect = useCallback(
|
||||
(scope: SettingScope) => {
|
||||
onSelect(highlightedMode, scope);
|
||||
},
|
||||
[onSelect, highlightedMode],
|
||||
);
|
||||
const handleScopeSelect = useCallback((scope: SettingScope) => {
|
||||
setSelectedScope(scope);
|
||||
setMode('mode');
|
||||
}, []);
|
||||
|
||||
const [focusSection, setFocusSection] = useState<'mode' | 'scope'>('mode');
|
||||
const [mode, setMode] = useState<'mode' | 'scope'>('mode');
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'tab') {
|
||||
setFocusSection((prev) => (prev === 'mode' ? 'scope' : 'mode'));
|
||||
setMode((prev) => (prev === 'mode' ? 'scope' : 'mode'));
|
||||
}
|
||||
if (key.name === 'escape') {
|
||||
onSelect(undefined, selectedScope);
|
||||
|
|
@ -127,59 +125,56 @@ export function ApprovalModeDialog({
|
|||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="row"
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{/* Approval Mode Selection */}
|
||||
<Text bold={focusSection === 'mode'} wrap="truncate">
|
||||
{focusSection === 'mode' ? '> ' : ' '}
|
||||
{t('Approval Mode')}{' '}
|
||||
<Text color={theme.text.secondary}>{otherScopeModifiedMessage}</Text>
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<RadioButtonSelect
|
||||
items={modeItems}
|
||||
initialIndex={safeInitialModeIndex}
|
||||
onSelect={handleModeSelect}
|
||||
onHighlight={handleModeHighlight}
|
||||
isFocused={focusSection === 'mode'}
|
||||
maxItemsToShow={10}
|
||||
showScrollArrows={false}
|
||||
showNumbers={focusSection === 'mode'}
|
||||
/>
|
||||
|
||||
<Box height={1} />
|
||||
|
||||
{/* Scope Selection */}
|
||||
<Box marginTop={1}>
|
||||
<ScopeSelector
|
||||
onSelect={handleScopeSelect}
|
||||
onHighlight={handleScopeHighlight}
|
||||
isFocused={focusSection === 'scope'}
|
||||
initialScope={selectedScope}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box height={1} />
|
||||
|
||||
{/* Warning when workspace setting will override user setting */}
|
||||
{showWorkspacePriorityWarning && (
|
||||
<>
|
||||
<Text color={theme.status.warning} wrap="wrap">
|
||||
⚠{' '}
|
||||
{t(
|
||||
'Workspace approval mode exists and takes priority. User-level change will have no effect.',
|
||||
)}
|
||||
{mode === 'mode' ? (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{/* Approval Mode Selection */}
|
||||
<Text bold={mode === 'mode'} wrap="truncate">
|
||||
{mode === 'mode' ? '> ' : ' '}
|
||||
{t('Approval Mode')}{' '}
|
||||
<Text color={theme.text.secondary}>
|
||||
{otherScopeModifiedMessage}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('(Use Enter to select, Tab to change focus)')}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<RadioButtonSelect
|
||||
items={modeItems}
|
||||
initialIndex={safeInitialModeIndex}
|
||||
onSelect={handleModeSelect}
|
||||
onHighlight={handleModeHighlight}
|
||||
isFocused={mode === 'mode'}
|
||||
maxItemsToShow={10}
|
||||
showScrollArrows={false}
|
||||
showNumbers={mode === 'mode'}
|
||||
/>
|
||||
{/* Warning when workspace setting will override user setting */}
|
||||
{showWorkspacePriorityWarning && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.status.warning} wrap="wrap">
|
||||
⚠{' '}
|
||||
{t(
|
||||
'Workspace approval mode exists and takes priority. User-level change will have no effect.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<ScopeSelector
|
||||
onSelect={handleScopeSelect}
|
||||
onHighlight={handleScopeHighlight}
|
||||
isFocused={mode === 'scope'}
|
||||
initialScope={selectedScope}
|
||||
/>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
{mode === 'mode'
|
||||
? t('(Use Enter to select, Tab to configure scope)')
|
||||
: t('(Use Enter to apply scope, Tab to go back)')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -5,29 +5,10 @@
|
|||
*/
|
||||
|
||||
export const shortAsciiLogo = `
|
||||
██████╗ ██╗ ██╗███████╗███╗ ██╗
|
||||
▄▄▄▄▄▄ ▄▄ ▄▄ ▄▄▄▄▄▄▄ ▄▄▄ ▄▄
|
||||
██╔═══██╗██║ ██║██╔════╝████╗ ██║
|
||||
██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║
|
||||
██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║
|
||||
╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║
|
||||
╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
|
||||
`;
|
||||
export const longAsciiLogo = `
|
||||
██╗ ██████╗ ██╗ ██╗███████╗███╗ ██╗
|
||||
╚██╗ ██╔═══██╗██║ ██║██╔════╝████╗ ██║
|
||||
╚██╗ ██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║
|
||||
██╔╝ ██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║
|
||||
██╔╝ ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║
|
||||
╚═╝ ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
|
||||
`;
|
||||
|
||||
export const tinyAsciiLogo = `
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import {
|
|||
type UIActions,
|
||||
} from '../contexts/UIActionsContext.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
// Mock VimModeContext hook
|
||||
vi.mock('../contexts/VimModeContext.js', () => ({
|
||||
useVimMode: vi.fn(() => ({
|
||||
|
|
@ -146,92 +145,33 @@ const createMockConfig = (overrides = {}) => ({
|
|||
...overrides,
|
||||
});
|
||||
|
||||
const createMockSettings = (merged = {}) => ({
|
||||
merged: {
|
||||
hideFooter: false,
|
||||
showMemoryUsage: false,
|
||||
...merged,
|
||||
},
|
||||
});
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const renderComposer = (
|
||||
uiState: UIState,
|
||||
settings = createMockSettings(),
|
||||
config = createMockConfig(),
|
||||
uiActions = createMockUIActions(),
|
||||
) =>
|
||||
render(
|
||||
<ConfigContext.Provider value={config as any}>
|
||||
<SettingsContext.Provider value={settings as any}>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<UIActionsContext.Provider value={uiActions}>
|
||||
<Composer />
|
||||
</UIActionsContext.Provider>
|
||||
</UIStateContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<UIActionsContext.Provider value={uiActions}>
|
||||
<Composer />
|
||||
</UIActionsContext.Provider>
|
||||
</UIStateContext.Provider>
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
describe('Composer', () => {
|
||||
describe('Footer Display Settings', () => {
|
||||
it('renders Footer by default when hideFooter is false', () => {
|
||||
describe('Footer Display', () => {
|
||||
it('renders Footer by default', () => {
|
||||
const uiState = createMockUIState();
|
||||
const settings = createMockSettings({ hideFooter: false });
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings);
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
// Smoke check that the Footer renders when enabled.
|
||||
// Smoke check that the Footer renders
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
});
|
||||
|
||||
it('does NOT render Footer when hideFooter is true', () => {
|
||||
const uiState = createMockUIState();
|
||||
const settings = createMockSettings({ hideFooter: true });
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings);
|
||||
|
||||
// Check for content that only appears IN the Footer component itself
|
||||
expect(lastFrame()).not.toContain('[NORMAL]'); // Vim mode indicator
|
||||
expect(lastFrame()).not.toContain('(main'); // Branch name with parentheses
|
||||
});
|
||||
|
||||
it('passes correct props to Footer including vim mode when enabled', async () => {
|
||||
const uiState = createMockUIState({
|
||||
branchName: 'feature-branch',
|
||||
errorCount: 2,
|
||||
sessionStats: {
|
||||
sessionId: 'test-session',
|
||||
sessionStartTime: new Date(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
metrics: {} as any,
|
||||
lastPromptTokenCount: 150,
|
||||
promptCount: 5,
|
||||
},
|
||||
});
|
||||
const config = createMockConfig({
|
||||
getModel: vi.fn(() => 'gemini-1.5-flash'),
|
||||
getTargetDir: vi.fn(() => '/project/path'),
|
||||
getDebugMode: vi.fn(() => true),
|
||||
});
|
||||
const settings = createMockSettings({
|
||||
hideFooter: false,
|
||||
showMemoryUsage: true,
|
||||
});
|
||||
// Mock vim mode for this test
|
||||
const { useVimMode } = await import('../contexts/VimModeContext.js');
|
||||
vi.mocked(useVimMode).mockReturnValueOnce({
|
||||
vimEnabled: true,
|
||||
vimMode: 'INSERT',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings, config);
|
||||
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
// Footer should be rendered with all the state passed through
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading Indicator', () => {
|
||||
|
|
@ -261,7 +201,7 @@ describe('Composer', () => {
|
|||
getAccessibility: vi.fn(() => ({ disableLoadingPhrases: true })),
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, undefined, config);
|
||||
const { lastFrame } = renderComposer(uiState, config);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('LoadingIndicator');
|
||||
|
|
@ -318,7 +258,8 @@ describe('Composer', () => {
|
|||
});
|
||||
|
||||
describe('Context and Status Display', () => {
|
||||
it('shows ContextSummaryDisplay in normal state', () => {
|
||||
// Note: ContextSummaryDisplay and status prompts are now rendered in Footer, not Composer
|
||||
it('shows empty space in normal state (ContextSummaryDisplay moved to Footer)', () => {
|
||||
const uiState = createMockUIState({
|
||||
ctrlCPressedOnce: false,
|
||||
ctrlDPressedOnce: false,
|
||||
|
|
@ -327,37 +268,43 @@ describe('Composer', () => {
|
|||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('ContextSummaryDisplay');
|
||||
// ContextSummaryDisplay is now in Footer, so we just verify normal state renders
|
||||
expect(lastFrame()).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows Ctrl+C exit prompt when ctrlCPressedOnce is true', () => {
|
||||
// Note: Ctrl+C, Ctrl+D, and Escape prompts are now rendered in Footer component
|
||||
// These are tested in Footer.test.tsx
|
||||
it('renders Footer which handles Ctrl+C exit prompt', () => {
|
||||
const uiState = createMockUIState({
|
||||
ctrlCPressedOnce: true,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('Press Ctrl+C again to exit');
|
||||
// Ctrl+C prompt is now inside Footer, verify Footer renders
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
});
|
||||
|
||||
it('shows Ctrl+D exit prompt when ctrlDPressedOnce is true', () => {
|
||||
it('renders Footer which handles Ctrl+D exit prompt', () => {
|
||||
const uiState = createMockUIState({
|
||||
ctrlDPressedOnce: true,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('Press Ctrl+D again to exit');
|
||||
// Ctrl+D prompt is now inside Footer, verify Footer renders
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
});
|
||||
|
||||
it('shows escape prompt when showEscapePrompt is true', () => {
|
||||
it('renders Footer which handles escape prompt', () => {
|
||||
const uiState = createMockUIState({
|
||||
showEscapePrompt: true,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('Press Esc again to clear');
|
||||
// Escape prompt is now inside Footer, verify Footer renders
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -382,7 +329,9 @@ describe('Composer', () => {
|
|||
expect(lastFrame()).not.toContain('InputPrompt');
|
||||
});
|
||||
|
||||
it('shows AutoAcceptIndicator when approval mode is not default and shell mode is inactive', () => {
|
||||
// Note: AutoAcceptIndicator and ShellModeIndicator are now rendered inside Footer component
|
||||
// These are tested in Footer.test.tsx
|
||||
it('renders Footer which contains AutoAcceptIndicator when approval mode is not default', () => {
|
||||
const uiState = createMockUIState({
|
||||
showAutoAcceptIndicator: ApprovalMode.YOLO,
|
||||
shellModeActive: false,
|
||||
|
|
@ -390,17 +339,19 @@ describe('Composer', () => {
|
|||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('AutoAcceptIndicator');
|
||||
// AutoAcceptIndicator is now inside Footer, verify Footer renders
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
});
|
||||
|
||||
it('shows ShellModeIndicator when shell mode is active', () => {
|
||||
it('renders Footer which contains ShellModeIndicator when shell mode is active', () => {
|
||||
const uiState = createMockUIState({
|
||||
shellModeActive: true,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('ShellModeIndicator');
|
||||
// ShellModeIndicator is now inside Footer, verify Footer renders
|
||||
expect(lastFrame()).toContain('Footer');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,26 +4,20 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import { useMemo } from 'react';
|
||||
import { Box, useIsScreenReaderEnabled } from 'ink';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
|
||||
import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
|
||||
import { ShellModeIndicator } from './ShellModeIndicator.js';
|
||||
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
|
||||
import { InputPrompt, calculatePromptWidths } from './InputPrompt.js';
|
||||
import { Footer } from './Footer.js';
|
||||
import { ShowMoreLines } from './ShowMoreLines.js';
|
||||
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
|
||||
import { KeyboardShortcuts } from './KeyboardShortcuts.js';
|
||||
import { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
|
||||
import { FeedbackDialog } from '../FeedbackDialog.js';
|
||||
|
|
@ -31,16 +25,26 @@ import { t } from '../../i18n/index.js';
|
|||
|
||||
export const Composer = () => {
|
||||
const config = useConfig();
|
||||
const settings = useSettings();
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
const uiState = useUIState();
|
||||
const uiActions = useUIActions();
|
||||
const { vimEnabled } = useVimMode();
|
||||
const terminalWidth = process.stdout.columns;
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
|
||||
|
||||
const { contextFileNames, showAutoAcceptIndicator } = uiState;
|
||||
const { showAutoAcceptIndicator } = uiState;
|
||||
|
||||
// State for keyboard shortcuts display toggle
|
||||
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||
const handleToggleShortcuts = useCallback(() => {
|
||||
setShowShortcuts((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// State for suggestions visibility
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const handleSuggestionsVisibilityChange = useCallback((visible: boolean) => {
|
||||
setShowSuggestions(visible);
|
||||
}, []);
|
||||
|
||||
// Use the container width of InputPrompt for width of DetailedMessagesDisplay
|
||||
const { containerWidth } = useMemo(
|
||||
|
|
@ -49,7 +53,7 @@ export const Composer = () => {
|
|||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{!uiState.embeddedShellFocused && (
|
||||
<LoadingIndicator
|
||||
thought={
|
||||
|
|
@ -71,55 +75,6 @@ export const Composer = () => {
|
|||
|
||||
<QueuedMessageDisplay messageQueue={uiState.messageQueue} />
|
||||
|
||||
<Box
|
||||
marginTop={1}
|
||||
justifyContent={
|
||||
settings.merged.ui?.hideContextSummary
|
||||
? 'flex-start'
|
||||
: 'space-between'
|
||||
}
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
<Box marginRight={1}>
|
||||
{process.env['GEMINI_SYSTEM_MD'] && (
|
||||
<Text color={theme.status.error}>|⌐■_■| </Text>
|
||||
)}
|
||||
{uiState.ctrlCPressedOnce ? (
|
||||
<Text color={theme.status.warning}>
|
||||
{t('Press Ctrl+C again to exit.')}
|
||||
</Text>
|
||||
) : uiState.ctrlDPressedOnce ? (
|
||||
<Text color={theme.status.warning}>
|
||||
{t('Press Ctrl+D again to exit.')}
|
||||
</Text>
|
||||
) : uiState.showEscapePrompt ? (
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Press Esc again to clear.')}
|
||||
</Text>
|
||||
) : (
|
||||
!settings.merged.ui?.hideContextSummary && (
|
||||
<ContextSummaryDisplay
|
||||
ideContext={uiState.ideContextState}
|
||||
geminiMdFileCount={uiState.geminiMdFileCount}
|
||||
contextFileNames={contextFileNames}
|
||||
mcpServers={config.getMcpServers()}
|
||||
blockedMcpServers={config.getBlockedMcpServers()}
|
||||
showToolDescriptions={uiState.showToolDescriptions}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
<Box paddingTop={isNarrow ? 1 : 0}>
|
||||
{showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&
|
||||
!uiState.shellModeActive && (
|
||||
<AutoAcceptIndicator approvalMode={showAutoAcceptIndicator} />
|
||||
)}
|
||||
{uiState.shellModeActive && <ShellModeIndicator />}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{uiState.showErrorDetails && (
|
||||
<OverflowProvider>
|
||||
<Box flexDirection="column">
|
||||
|
|
@ -152,6 +107,9 @@ export const Composer = () => {
|
|||
setShellModeActive={uiActions.setShellModeActive}
|
||||
approvalMode={showAutoAcceptIndicator}
|
||||
onEscapePromptChange={uiActions.onEscapePromptChange}
|
||||
onToggleShortcuts={handleToggleShortcuts}
|
||||
showShortcuts={showShortcuts}
|
||||
onSuggestionsVisibilityChange={handleSuggestionsVisibilityChange}
|
||||
focus={true}
|
||||
vimHandleInput={uiActions.vimHandleInput}
|
||||
isEmbeddedShellFocused={uiState.embeddedShellFocused}
|
||||
|
|
@ -163,7 +121,13 @@ export const Composer = () => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{!settings.merged.ui?.hideFooter && !isScreenReaderEnabled && <Footer />}
|
||||
{/* Exclusive area: only one component visible at a time */}
|
||||
{!showSuggestions &&
|
||||
(showShortcuts ? (
|
||||
<KeyboardShortcuts />
|
||||
) : (
|
||||
!isScreenReaderEnabled && <Footer />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ describe('ConsentPrompt', () => {
|
|||
{
|
||||
isPending: true,
|
||||
text: prompt,
|
||||
terminalWidth,
|
||||
contentWidth: terminalWidth,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export const ConsentPrompt = (props: ConsentPromptProps) => {
|
|||
<MarkdownDisplay
|
||||
isPending={true}
|
||||
text={prompt}
|
||||
terminalWidth={terminalWidth}
|
||||
contentWidth={terminalWidth}
|
||||
/>
|
||||
) : (
|
||||
prompt
|
||||
|
|
|
|||
|
|
@ -17,15 +17,19 @@ export const ContextUsageDisplay = ({
|
|||
model: string;
|
||||
terminalWidth: number;
|
||||
}) => {
|
||||
const percentage = promptTokenCount / tokenLimit(model);
|
||||
const percentageLeft = ((1 - percentage) * 100).toFixed(0);
|
||||
if (promptTokenCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = terminalWidth < 100 ? '%' : '% context left';
|
||||
const percentage = promptTokenCount / tokenLimit(model);
|
||||
const percentageUsed = (percentage * 100).toFixed(1);
|
||||
|
||||
const label = terminalWidth < 100 ? '% used' : '% context used';
|
||||
|
||||
return (
|
||||
<Text color={theme.text.secondary}>
|
||||
({percentageLeft}
|
||||
{label})
|
||||
{percentageUsed}
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -152,12 +152,38 @@ export const DialogManager = ({
|
|||
</Box>
|
||||
);
|
||||
}
|
||||
if (uiState.isEditorDialogOpen) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{uiState.editorError && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.status.error}>{uiState.editorError}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<EditorSettingsDialog
|
||||
onSelect={uiActions.handleEditorSelect}
|
||||
settings={settings}
|
||||
onExit={uiActions.exitEditorDialog}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (uiState.isSettingsDialogOpen) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<SettingsDialog
|
||||
settings={settings}
|
||||
onSelect={() => uiActions.closeSettingsDialog()}
|
||||
onSelect={(settingName) => {
|
||||
if (settingName === 'ui.theme') {
|
||||
uiActions.openThemeDialog();
|
||||
return;
|
||||
}
|
||||
if (settingName === 'general.preferredEditor') {
|
||||
uiActions.openEditorDialog();
|
||||
return;
|
||||
}
|
||||
uiActions.closeSettingsDialog();
|
||||
}}
|
||||
onRestartRequest={() => process.exit(0)}
|
||||
availableTerminalHeight={terminalHeight - staticExtraHeight}
|
||||
config={config}
|
||||
|
|
@ -237,22 +263,6 @@ export const DialogManager = ({
|
|||
);
|
||||
}
|
||||
}
|
||||
if (uiState.isEditorDialogOpen) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{uiState.editorError && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.status.error}>{uiState.editorError}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<EditorSettingsDialog
|
||||
onSelect={uiActions.handleEditorSelect}
|
||||
settings={settings}
|
||||
onExit={uiActions.exitEditorDialog}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (uiState.isPermissionsDialogOpen) {
|
||||
return (
|
||||
<PermissionsModifyTrustDialog
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
type EditorDisplay,
|
||||
} from '../editors/editorSettingsManager.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { ScopeSelector } from './shared/ScopeSelector.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import type { EditorType } from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -35,13 +36,12 @@ export function EditorSettingsDialog({
|
|||
const [selectedScope, setSelectedScope] = useState<SettingScope>(
|
||||
SettingScope.User,
|
||||
);
|
||||
const [focusedSection, setFocusedSection] = useState<'editor' | 'scope'>(
|
||||
'editor',
|
||||
);
|
||||
const [mode, setMode] = useState<'editor' | 'scope'>('editor');
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'tab') {
|
||||
setFocusedSection((prev) => (prev === 'editor' ? 'scope' : 'editor'));
|
||||
setMode((prev) => (prev === 'editor' ? 'scope' : 'editor'));
|
||||
}
|
||||
if (key.name === 'escape') {
|
||||
onExit();
|
||||
|
|
@ -65,23 +65,6 @@ export function EditorSettingsDialog({
|
|||
editorIndex = 0;
|
||||
}
|
||||
|
||||
const scopeItems = [
|
||||
{
|
||||
get label() {
|
||||
return t('User Settings');
|
||||
},
|
||||
value: SettingScope.User,
|
||||
key: SettingScope.User,
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('Workspace Settings');
|
||||
},
|
||||
value: SettingScope.Workspace,
|
||||
key: SettingScope.Workspace,
|
||||
},
|
||||
];
|
||||
|
||||
const handleEditorSelect = (editorType: EditorType | 'not_set') => {
|
||||
if (editorType === 'not_set') {
|
||||
onSelect(undefined, selectedScope);
|
||||
|
|
@ -92,7 +75,11 @@ export function EditorSettingsDialog({
|
|||
|
||||
const handleScopeSelect = (scope: SettingScope) => {
|
||||
setSelectedScope(scope);
|
||||
setFocusedSection('editor');
|
||||
setMode('editor');
|
||||
};
|
||||
|
||||
const handleScopeHighlight = (scope: SettingScope) => {
|
||||
setSelectedScope(scope);
|
||||
};
|
||||
|
||||
let otherScopeModifiedMessage = '';
|
||||
|
|
@ -131,54 +118,59 @@ export function EditorSettingsDialog({
|
|||
width="100%"
|
||||
>
|
||||
<Box flexDirection="column" width="45%" paddingRight={2}>
|
||||
<Text bold={focusedSection === 'editor'}>
|
||||
{focusedSection === 'editor' ? '> ' : ' '}Select Editor{' '}
|
||||
<Text color={theme.text.secondary}>{otherScopeModifiedMessage}</Text>
|
||||
</Text>
|
||||
<RadioButtonSelect
|
||||
items={editorItems.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.type,
|
||||
disabled: item.disabled,
|
||||
key: item.type,
|
||||
}))}
|
||||
initialIndex={editorIndex}
|
||||
onSelect={handleEditorSelect}
|
||||
isFocused={focusedSection === 'editor'}
|
||||
key={selectedScope}
|
||||
/>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text bold={focusedSection === 'scope'}>
|
||||
{focusedSection === 'scope' ? '> ' : ' '}
|
||||
{t('Apply To')}
|
||||
</Text>
|
||||
<RadioButtonSelect
|
||||
items={scopeItems}
|
||||
initialIndex={0}
|
||||
{mode === 'editor' ? (
|
||||
<Box flexDirection="column">
|
||||
<Text bold={mode === 'editor'} wrap="truncate">
|
||||
{mode === 'editor' ? '> ' : ' '}
|
||||
{t('Select Editor')}{' '}
|
||||
<Text color={theme.text.secondary}>
|
||||
{otherScopeModifiedMessage}
|
||||
</Text>
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<RadioButtonSelect
|
||||
items={editorItems.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.type,
|
||||
disabled: item.disabled,
|
||||
key: item.type,
|
||||
}))}
|
||||
initialIndex={editorIndex}
|
||||
onSelect={handleEditorSelect}
|
||||
isFocused={mode === 'editor'}
|
||||
key={selectedScope}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<ScopeSelector
|
||||
onSelect={handleScopeSelect}
|
||||
isFocused={focusedSection === 'scope'}
|
||||
onHighlight={handleScopeHighlight}
|
||||
isFocused={mode === 'scope'}
|
||||
initialScope={selectedScope}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
(Use Enter to select, Tab to change focus)
|
||||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
{mode === 'editor'
|
||||
? t('(Use Enter to select, Tab to configure scope)')
|
||||
: t('(Use Enter to apply scope, Tab to go back)')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" width="55%" paddingLeft={2}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Editor Preference
|
||||
{t('Editor Preference')}
|
||||
</Text>
|
||||
<Box flexDirection="column" gap={1} marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
These editors are currently supported. Please note that some editors
|
||||
cannot be used in sandbox mode.
|
||||
{t(
|
||||
'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.',
|
||||
)}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Your preferred editor is:{' '}
|
||||
{t('Your preferred editor is:')}{' '}
|
||||
<Text
|
||||
color={
|
||||
mergedEditorName === 'None'
|
||||
|
|
|
|||
|
|
@ -8,41 +8,23 @@ import { render } from 'ink-testing-library';
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Footer } from './Footer.js';
|
||||
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
||||
import { tildeifyPath } from '@qwen-code/qwen-code-core';
|
||||
import { type UIState, UIStateContext } from '../contexts/UIStateContext.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { VimModeProvider } from '../contexts/VimModeContext.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
vi.mock('../hooks/useTerminalSize.js');
|
||||
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...original,
|
||||
shortenPath: (p: string, len: number) => {
|
||||
if (p.length > len) {
|
||||
return '...' + p.slice(p.length - len + 3);
|
||||
}
|
||||
return p;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
model: 'gemini-pro',
|
||||
targetDir:
|
||||
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long',
|
||||
branchName: 'main',
|
||||
};
|
||||
|
||||
const createMockConfig = (overrides = {}) => ({
|
||||
getModel: vi.fn(() => defaultProps.model),
|
||||
getTargetDir: vi.fn(() => defaultProps.targetDir),
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getMcpServers: vi.fn(() => ({})),
|
||||
getBlockedMcpServers: vi.fn(() => []),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
|
@ -51,46 +33,31 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
|||
sessionStats: {
|
||||
lastPromptTokenCount: 100,
|
||||
},
|
||||
branchName: defaultProps.branchName,
|
||||
geminiMdFileCount: 0,
|
||||
contextFileNames: [],
|
||||
showToolDescriptions: false,
|
||||
ideContextState: undefined,
|
||||
...overrides,
|
||||
}) as UIState;
|
||||
|
||||
const createDefaultSettings = (
|
||||
options: {
|
||||
showMemoryUsage?: boolean;
|
||||
hideCWD?: boolean;
|
||||
hideSandboxStatus?: boolean;
|
||||
hideModelInfo?: boolean;
|
||||
} = {},
|
||||
): LoadedSettings =>
|
||||
const createMockSettings = (): LoadedSettings =>
|
||||
({
|
||||
merged: {
|
||||
ui: {
|
||||
showMemoryUsage: options.showMemoryUsage,
|
||||
footer: {
|
||||
hideCWD: options.hideCWD,
|
||||
hideSandboxStatus: options.hideSandboxStatus,
|
||||
hideModelInfo: options.hideModelInfo,
|
||||
},
|
||||
general: {
|
||||
vimMode: false,
|
||||
},
|
||||
},
|
||||
}) as never;
|
||||
}) as LoadedSettings;
|
||||
|
||||
const renderWithWidth = (
|
||||
width: number,
|
||||
uiState: UIState,
|
||||
settings: LoadedSettings = createDefaultSettings(),
|
||||
) => {
|
||||
const renderWithWidth = (width: number, uiState: UIState) => {
|
||||
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
|
||||
return render(
|
||||
<ConfigContext.Provider value={createMockConfig() as never}>
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<VimModeProvider settings={settings}>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<Footer />
|
||||
</UIStateContext.Provider>
|
||||
</VimModeProvider>
|
||||
</SettingsContext.Provider>
|
||||
<VimModeProvider settings={createMockSettings()}>
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<Footer />
|
||||
</UIStateContext.Provider>
|
||||
</VimModeProvider>
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
|
@ -101,161 +68,28 @@ describe('<Footer />', () => {
|
|||
expect(lastFrame()).toBeDefined();
|
||||
});
|
||||
|
||||
describe('path display', () => {
|
||||
it('should display a shortened path on a narrow terminal', () => {
|
||||
const { lastFrame } = renderWithWidth(79, createMockUIState());
|
||||
const tildePath = tildeifyPath(defaultProps.targetDir);
|
||||
const pathLength = Math.max(20, Math.floor(79 * 0.25));
|
||||
const expectedPath =
|
||||
'...' + tildePath.slice(tildePath.length - pathLength + 3);
|
||||
expect(lastFrame()).toContain(expectedPath);
|
||||
});
|
||||
|
||||
it('should use wide layout at 80 columns', () => {
|
||||
const { lastFrame } = renderWithWidth(80, createMockUIState());
|
||||
const tildePath = tildeifyPath(defaultProps.targetDir);
|
||||
const expectedPath =
|
||||
'...' + tildePath.slice(tildePath.length - 80 * 0.25 + 3);
|
||||
expect(lastFrame()).toContain(expectedPath);
|
||||
});
|
||||
});
|
||||
|
||||
it('displays the branch name when provided', () => {
|
||||
it('does not display the working directory or branch name', () => {
|
||||
const { lastFrame } = renderWithWidth(120, createMockUIState());
|
||||
expect(lastFrame()).toContain(`(${defaultProps.branchName}*)`);
|
||||
expect(lastFrame()).not.toMatch(/\(.*\*\)/);
|
||||
});
|
||||
|
||||
it('does not display the branch name when not provided', () => {
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState({
|
||||
branchName: undefined,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).not.toContain(`(${defaultProps.branchName}*)`);
|
||||
});
|
||||
|
||||
it('displays the model name and context percentage', () => {
|
||||
it('displays the context percentage', () => {
|
||||
const { lastFrame } = renderWithWidth(120, createMockUIState());
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).toMatch(/\(\d+% context left\)/);
|
||||
expect(lastFrame()).toMatch(/\d+(\.\d+)?% context used/);
|
||||
});
|
||||
|
||||
it('displays the model name and abbreviated context percentage', () => {
|
||||
it('displays the abbreviated context percentage on narrow terminal', () => {
|
||||
const { lastFrame } = renderWithWidth(99, createMockUIState());
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).toMatch(/\(\d+%\)/);
|
||||
expect(lastFrame()).toMatch(/\d+%/);
|
||||
});
|
||||
|
||||
describe('sandbox and trust info', () => {
|
||||
it('should display untrusted when isTrustedFolder is false', () => {
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState({
|
||||
isTrustedFolder: false,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toContain('untrusted');
|
||||
});
|
||||
|
||||
it('should display custom sandbox info when SANDBOX env is set', () => {
|
||||
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState({
|
||||
isTrustedFolder: undefined,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toContain('test');
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should display macOS Seatbelt info when SANDBOX is sandbox-exec', () => {
|
||||
vi.stubEnv('SANDBOX', 'sandbox-exec');
|
||||
vi.stubEnv('SEATBELT_PROFILE', 'test-profile');
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState({
|
||||
isTrustedFolder: true,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toMatch(/macOS Seatbelt.*\(test-profile\)/s);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should display "no sandbox" when SANDBOX is not set and folder is trusted', () => {
|
||||
// Clear any SANDBOX env var that might be set.
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState({
|
||||
isTrustedFolder: true,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toContain('no sandbox');
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should prioritize untrusted message over sandbox info', () => {
|
||||
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState({
|
||||
isTrustedFolder: false,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toContain('untrusted');
|
||||
expect(lastFrame()).not.toMatch(/test-sandbox/s);
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
|
||||
describe('footer configuration filtering (golden snapshots)', () => {
|
||||
it('renders complete footer with all sections visible (baseline)', () => {
|
||||
describe('footer rendering (golden snapshots)', () => {
|
||||
it('renders complete footer on wide terminal', () => {
|
||||
const { lastFrame } = renderWithWidth(120, createMockUIState());
|
||||
expect(lastFrame()).toMatchSnapshot('complete-footer-wide');
|
||||
});
|
||||
|
||||
it('renders footer with all optional sections hidden (minimal footer)', () => {
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState(),
|
||||
createDefaultSettings({
|
||||
hideCWD: true,
|
||||
hideSandboxStatus: true,
|
||||
hideModelInfo: true,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot('footer-minimal');
|
||||
});
|
||||
|
||||
it('renders footer with only model info hidden (partial filtering)', () => {
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState(),
|
||||
createDefaultSettings({
|
||||
hideCWD: false,
|
||||
hideSandboxStatus: false,
|
||||
hideModelInfo: true,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot('footer-no-model');
|
||||
});
|
||||
|
||||
it('renders footer with CWD and model info hidden to test alignment (only sandbox visible)', () => {
|
||||
const { lastFrame } = renderWithWidth(
|
||||
120,
|
||||
createMockUIState(),
|
||||
createDefaultSettings({
|
||||
hideCWD: true,
|
||||
hideSandboxStatus: false,
|
||||
hideModelInfo: true,
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot('footer-only-sandbox');
|
||||
});
|
||||
|
||||
it('renders complete footer in narrow terminal (baseline narrow)', () => {
|
||||
it('renders complete footer on narrow terminal', () => {
|
||||
const { lastFrame } = renderWithWidth(79, createMockUIState());
|
||||
expect(lastFrame()).toMatchSnapshot('complete-footer-narrow');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,159 +7,134 @@
|
|||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core';
|
||||
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
|
||||
import process from 'node:process';
|
||||
import Gradient from 'ink-gradient';
|
||||
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
|
||||
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
||||
import { DebugProfiler } from './DebugProfiler.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { AutoAcceptIndicator } from './AutoAcceptIndicator.js';
|
||||
import { ShellModeIndicator } from './ShellModeIndicator.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import { ApprovalMode } from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const Footer: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
const config = useConfig();
|
||||
const settings = useSettings();
|
||||
const { vimEnabled, vimMode } = useVimMode();
|
||||
|
||||
const {
|
||||
model,
|
||||
targetDir,
|
||||
debugMode,
|
||||
branchName,
|
||||
debugMessage,
|
||||
errorCount,
|
||||
showErrorDetails,
|
||||
promptTokenCount,
|
||||
nightly,
|
||||
isTrustedFolder,
|
||||
showAutoAcceptIndicator,
|
||||
} = {
|
||||
model: config.getModel(),
|
||||
targetDir: config.getTargetDir(),
|
||||
debugMode: config.getDebugMode(),
|
||||
branchName: uiState.branchName,
|
||||
debugMessage: uiState.debugMessage,
|
||||
errorCount: uiState.errorCount,
|
||||
showErrorDetails: uiState.showErrorDetails,
|
||||
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
|
||||
nightly: uiState.nightly,
|
||||
isTrustedFolder: uiState.isTrustedFolder,
|
||||
showAutoAcceptIndicator: uiState.showAutoAcceptIndicator,
|
||||
};
|
||||
|
||||
const showMemoryUsage =
|
||||
config.getDebugMode() || settings.merged.ui?.showMemoryUsage || false;
|
||||
const hideCWD = settings.merged.ui?.footer?.hideCWD || false;
|
||||
const hideSandboxStatus =
|
||||
settings.merged.ui?.footer?.hideSandboxStatus || false;
|
||||
const hideModelInfo = settings.merged.ui?.footer?.hideModelInfo || false;
|
||||
const showErrorIndicator = !showErrorDetails && errorCount > 0;
|
||||
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
|
||||
const pathLength = Math.max(20, Math.floor(terminalWidth * 0.25));
|
||||
const displayPath = shortenPath(tildeifyPath(targetDir), pathLength);
|
||||
// Determine sandbox info from environment
|
||||
const sandboxEnv = process.env['SANDBOX'];
|
||||
const sandboxInfo = sandboxEnv
|
||||
? sandboxEnv === 'sandbox-exec'
|
||||
? 'seatbelt'
|
||||
: sandboxEnv.startsWith('qwen-code')
|
||||
? 'docker'
|
||||
: sandboxEnv
|
||||
: null;
|
||||
|
||||
const justifyContent = hideCWD && hideModelInfo ? 'center' : 'space-between';
|
||||
const displayVimMode = vimEnabled ? vimMode : undefined;
|
||||
// Check if debug mode is enabled
|
||||
const debugMode = config.getDebugMode();
|
||||
|
||||
// Left section should show exactly ONE thing at any time, in priority order.
|
||||
const leftContent = uiState.ctrlCPressedOnce ? (
|
||||
<Text color={theme.status.warning}>{t('Press Ctrl+C again to exit.')}</Text>
|
||||
) : uiState.ctrlDPressedOnce ? (
|
||||
<Text color={theme.status.warning}>{t('Press Ctrl+D again to exit.')}</Text>
|
||||
) : uiState.showEscapePrompt ? (
|
||||
<Text color={theme.text.secondary}>{t('Press Esc again to clear.')}</Text>
|
||||
) : vimEnabled && vimMode === 'INSERT' ? (
|
||||
<Text color={theme.text.secondary}>-- INSERT --</Text>
|
||||
) : uiState.shellModeActive ? (
|
||||
<ShellModeIndicator />
|
||||
) : showAutoAcceptIndicator !== undefined &&
|
||||
showAutoAcceptIndicator !== ApprovalMode.DEFAULT ? (
|
||||
<AutoAcceptIndicator approvalMode={showAutoAcceptIndicator} />
|
||||
) : (
|
||||
<Text color={theme.text.secondary}>{t('? for shortcuts')}</Text>
|
||||
);
|
||||
|
||||
const rightItems: Array<{ key: string; node: React.ReactNode }> = [];
|
||||
if (sandboxInfo) {
|
||||
rightItems.push({
|
||||
key: 'sandbox',
|
||||
node: <Text color={theme.status.success}>🔒 {sandboxInfo}</Text>,
|
||||
});
|
||||
}
|
||||
if (debugMode) {
|
||||
rightItems.push({
|
||||
key: 'debug',
|
||||
node: <Text color={theme.status.warning}>Debug Mode</Text>,
|
||||
});
|
||||
}
|
||||
if (promptTokenCount > 0) {
|
||||
rightItems.push({
|
||||
key: 'context',
|
||||
node: (
|
||||
<Text color={theme.text.accent}>
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={promptTokenCount}
|
||||
model={model}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
</Text>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showErrorIndicator) {
|
||||
rightItems.push({
|
||||
key: 'errors',
|
||||
node: <ConsoleSummaryDisplay errorCount={errorCount} />,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
justifyContent={justifyContent}
|
||||
justifyContent="space-between"
|
||||
width="100%"
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
>
|
||||
{(debugMode || displayVimMode || !hideCWD) && (
|
||||
<Box>
|
||||
{debugMode && <DebugProfiler />}
|
||||
{displayVimMode && (
|
||||
<Text color={theme.text.secondary}>[{displayVimMode}] </Text>
|
||||
)}
|
||||
{!hideCWD &&
|
||||
(nightly ? (
|
||||
<Gradient colors={theme.ui.gradient}>
|
||||
<Text>
|
||||
{displayPath}
|
||||
{branchName && <Text> ({branchName}*)</Text>}
|
||||
</Text>
|
||||
</Gradient>
|
||||
) : (
|
||||
<Text color={theme.text.link}>
|
||||
{displayPath}
|
||||
{branchName && (
|
||||
<Text color={theme.text.secondary}> ({branchName}*)</Text>
|
||||
)}
|
||||
</Text>
|
||||
))}
|
||||
{debugMode && (
|
||||
<Text color={theme.status.error}>
|
||||
{' ' + (debugMessage || '--debug')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{/* Left Section: Exactly one status line (exit prompts / mode indicator / default hint) */}
|
||||
<Box
|
||||
marginLeft={2}
|
||||
justifyContent="flex-start"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
{leftContent}
|
||||
</Box>
|
||||
|
||||
{/* Middle Section: Centered Trust/Sandbox Info */}
|
||||
{!hideSandboxStatus && (
|
||||
<Box
|
||||
flexGrow={1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
display="flex"
|
||||
>
|
||||
{isTrustedFolder === false ? (
|
||||
<Text color={theme.status.warning}>untrusted</Text>
|
||||
) : process.env['SANDBOX'] &&
|
||||
process.env['SANDBOX'] !== 'sandbox-exec' ? (
|
||||
<Text color="green">
|
||||
{process.env['SANDBOX'].replace(/^gemini-(?:cli-)?/, '')}
|
||||
</Text>
|
||||
) : process.env['SANDBOX'] === 'sandbox-exec' ? (
|
||||
<Text color={theme.status.warning}>
|
||||
macOS Seatbelt{' '}
|
||||
<Text color={theme.text.secondary}>
|
||||
({process.env['SEATBELT_PROFILE']})
|
||||
</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={theme.status.error}>
|
||||
no sandbox
|
||||
{terminalWidth >= 100 && (
|
||||
<Text color={theme.text.secondary}> (see /docs)</Text>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Right Section: Gemini Label and Console Summary */}
|
||||
{!hideModelInfo && (
|
||||
<Box alignItems="center" justifyContent="flex-end">
|
||||
<Box alignItems="center">
|
||||
<Text color={theme.text.accent}>
|
||||
{model}{' '}
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={promptTokenCount}
|
||||
model={model}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
</Text>
|
||||
{showMemoryUsage && <MemoryUsageDisplay />}
|
||||
{/* Right Section: Sandbox Info, Debug Mode, Context Usage, and Console Summary */}
|
||||
<Box alignItems="center" justifyContent="flex-end" marginRight={2}>
|
||||
{rightItems.map(({ key, node }, index) => (
|
||||
<Box key={key} alignItems="center">
|
||||
{index > 0 && <Text color={theme.text.secondary}> | </Text>}
|
||||
{node}
|
||||
</Box>
|
||||
<Box alignItems="center" paddingLeft={2}>
|
||||
{!showErrorDetails && errorCount > 0 && (
|
||||
<Box>
|
||||
<Text color={theme.ui.symbol}>| </Text>
|
||||
<ConsoleSummaryDisplay errorCount={errorCount} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,39 +6,96 @@
|
|||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { Header } from './Header.js';
|
||||
import * as useTerminalSize from '../hooks/useTerminalSize.js';
|
||||
import { longAsciiLogo } from './AsciiArt.js';
|
||||
|
||||
vi.mock('../hooks/useTerminalSize.js');
|
||||
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
|
||||
|
||||
const defaultProps = {
|
||||
version: '1.0.0',
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
model: 'qwen-coder-plus',
|
||||
workingDirectory: '/home/user/projects/test',
|
||||
};
|
||||
|
||||
describe('<Header />', () => {
|
||||
beforeEach(() => {});
|
||||
|
||||
it('renders the long logo on a wide terminal', () => {
|
||||
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
|
||||
columns: 120,
|
||||
rows: 20,
|
||||
});
|
||||
const { lastFrame } = render(<Header version="1.0.0" nightly={false} />);
|
||||
expect(lastFrame()).toContain(longAsciiLogo);
|
||||
beforeEach(() => {
|
||||
// Default to wide terminal (shows both logo and info panel)
|
||||
useTerminalSizeMock.mockReturnValue({ columns: 120, rows: 24 });
|
||||
});
|
||||
|
||||
it('renders custom ASCII art when provided', () => {
|
||||
it('renders the ASCII logo on wide terminal', () => {
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
// Check that parts of the shortAsciiLogo are rendered
|
||||
expect(lastFrame()).toContain('██╔═══██╗');
|
||||
});
|
||||
|
||||
it('hides the ASCII logo on narrow terminal', () => {
|
||||
useTerminalSizeMock.mockReturnValue({ columns: 60, rows: 24 });
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
// Should not contain the logo but still show the info panel
|
||||
expect(lastFrame()).not.toContain('██╔═══██╗');
|
||||
expect(lastFrame()).toContain('>_ Qwen Code');
|
||||
});
|
||||
|
||||
it('renders custom ASCII art when provided on wide terminal', () => {
|
||||
const customArt = 'CUSTOM ART';
|
||||
const { lastFrame } = render(
|
||||
<Header version="1.0.0" nightly={false} customAsciiArt={customArt} />,
|
||||
<Header {...defaultProps} customAsciiArt={customArt} />,
|
||||
);
|
||||
expect(lastFrame()).toContain(customArt);
|
||||
});
|
||||
|
||||
it('displays the version number when nightly is true', () => {
|
||||
const { lastFrame } = render(<Header version="1.0.0" nightly={true} />);
|
||||
it('displays the version number', () => {
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
expect(lastFrame()).toContain('v1.0.0');
|
||||
});
|
||||
|
||||
it('does not display the version number when nightly is false', () => {
|
||||
const { lastFrame } = render(<Header version="1.0.0" nightly={false} />);
|
||||
expect(lastFrame()).not.toContain('v1.0.0');
|
||||
it('displays Qwen Code title with >_ prefix', () => {
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
expect(lastFrame()).toContain('>_ Qwen Code');
|
||||
});
|
||||
|
||||
it('displays auth type and model', () => {
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
expect(lastFrame()).toContain('Qwen OAuth');
|
||||
expect(lastFrame()).toContain('qwen-coder-plus');
|
||||
});
|
||||
|
||||
it('displays working directory', () => {
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
expect(lastFrame()).toContain('/home/user/projects/test');
|
||||
});
|
||||
|
||||
it('renders a custom working directory display', () => {
|
||||
const { lastFrame } = render(
|
||||
<Header {...defaultProps} workingDirectory="custom display" />,
|
||||
);
|
||||
expect(lastFrame()).toContain('custom display');
|
||||
});
|
||||
|
||||
it('displays working directory without branch name', () => {
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
// Branch name is no longer shown in header
|
||||
expect(lastFrame()).toContain('/home/user/projects/test');
|
||||
expect(lastFrame()).not.toContain('(main*)');
|
||||
});
|
||||
|
||||
it('formats home directory with tilde', () => {
|
||||
const { lastFrame } = render(
|
||||
<Header {...defaultProps} workingDirectory="/Users/testuser/projects" />,
|
||||
);
|
||||
// The actual home dir replacement depends on os.homedir()
|
||||
// Just verify the path is shown
|
||||
expect(lastFrame()).toContain('projects');
|
||||
});
|
||||
|
||||
it('renders with border around info panel', () => {
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
// Check for border characters (round border style uses these)
|
||||
expect(lastFrame()).toContain('╭');
|
||||
expect(lastFrame()).toContain('╯');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,64 +7,175 @@
|
|||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import Gradient from 'ink-gradient';
|
||||
import { AuthType, shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { shortAsciiLogo, longAsciiLogo, tinyAsciiLogo } from './AsciiArt.js';
|
||||
import { getAsciiArtWidth } from '../utils/textUtils.js';
|
||||
import { shortAsciiLogo } from './AsciiArt.js';
|
||||
import { getAsciiArtWidth, getCachedStringWidth } from '../utils/textUtils.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
|
||||
interface HeaderProps {
|
||||
customAsciiArt?: string; // For user-defined ASCII art
|
||||
version: string;
|
||||
nightly: boolean;
|
||||
authType?: AuthType;
|
||||
model: string;
|
||||
workingDirectory: string;
|
||||
}
|
||||
|
||||
function titleizeAuthType(value: string): string {
|
||||
return value
|
||||
.split(/[-_]/g)
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
if (part.toLowerCase() === 'ai') {
|
||||
return 'AI';
|
||||
}
|
||||
return part.charAt(0).toUpperCase() + part.slice(1);
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
// Format auth type for display
|
||||
function formatAuthType(authType?: AuthType): string {
|
||||
if (!authType) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
switch (authType) {
|
||||
case AuthType.QWEN_OAUTH:
|
||||
return 'Qwen OAuth';
|
||||
case AuthType.USE_OPENAI:
|
||||
return 'OpenAI';
|
||||
case AuthType.USE_GEMINI:
|
||||
return 'Gemini';
|
||||
case AuthType.USE_VERTEX_AI:
|
||||
return 'Vertex AI';
|
||||
case AuthType.USE_ANTHROPIC:
|
||||
return 'Anthropic';
|
||||
default:
|
||||
return titleizeAuthType(String(authType));
|
||||
}
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
customAsciiArt,
|
||||
version,
|
||||
nightly,
|
||||
authType,
|
||||
model,
|
||||
workingDirectory,
|
||||
}) => {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
let displayTitle;
|
||||
const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
|
||||
const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo);
|
||||
|
||||
if (customAsciiArt) {
|
||||
displayTitle = customAsciiArt;
|
||||
} else if (terminalWidth >= widthOfLongLogo) {
|
||||
displayTitle = longAsciiLogo;
|
||||
} else if (terminalWidth >= widthOfShortLogo) {
|
||||
displayTitle = shortAsciiLogo;
|
||||
} else {
|
||||
displayTitle = tinyAsciiLogo;
|
||||
}
|
||||
const displayLogo = customAsciiArt ?? shortAsciiLogo;
|
||||
const logoWidth = getAsciiArtWidth(displayLogo);
|
||||
const formattedAuthType = formatAuthType(authType);
|
||||
|
||||
const artWidth = getAsciiArtWidth(displayTitle);
|
||||
// Calculate available space properly:
|
||||
// First determine if logo can be shown, then use remaining space for path
|
||||
const containerMarginX = 2; // marginLeft + marginRight on the outer container
|
||||
const logoGap = 2; // Gap between logo and info panel
|
||||
const infoPanelPaddingX = 1;
|
||||
const infoPanelBorderWidth = 2; // left + right border
|
||||
const infoPanelChromeWidth = infoPanelBorderWidth + infoPanelPaddingX * 2;
|
||||
const minPathLength = 40; // Minimum readable path length
|
||||
const minInfoPanelWidth = minPathLength + infoPanelChromeWidth;
|
||||
|
||||
const availableTerminalWidth = Math.max(
|
||||
0,
|
||||
terminalWidth - containerMarginX * 2,
|
||||
);
|
||||
|
||||
// Check if we have enough space for logo + gap + minimum info panel
|
||||
const showLogo =
|
||||
availableTerminalWidth >= logoWidth + logoGap + minInfoPanelWidth;
|
||||
|
||||
// Calculate available width for info panel (use all remaining space)
|
||||
// Cap at 60 when in two-column layout (with logo)
|
||||
const maxInfoPanelWidth = 60;
|
||||
const availableInfoPanelWidth = showLogo
|
||||
? Math.min(availableTerminalWidth - logoWidth - logoGap, maxInfoPanelWidth)
|
||||
: availableTerminalWidth;
|
||||
|
||||
// Calculate max path length (subtract padding/borders from available space)
|
||||
const maxPathLength = Math.max(
|
||||
0,
|
||||
availableInfoPanelWidth - infoPanelChromeWidth,
|
||||
);
|
||||
|
||||
const infoPanelContentWidth = Math.max(
|
||||
0,
|
||||
availableInfoPanelWidth - infoPanelChromeWidth,
|
||||
);
|
||||
const authModelText = `${formattedAuthType} | ${model}`;
|
||||
const authHintText = ' (/auth to change)';
|
||||
const showAuthHint =
|
||||
infoPanelContentWidth > 0 &&
|
||||
getCachedStringWidth(authModelText + authHintText) <= infoPanelContentWidth;
|
||||
|
||||
// Now shorten the path to fit the available space
|
||||
const tildeifiedPath = tildeifyPath(workingDirectory);
|
||||
const shortenedPath = shortenPath(tildeifiedPath, Math.max(3, maxPathLength));
|
||||
const displayPath =
|
||||
maxPathLength <= 0
|
||||
? ''
|
||||
: shortenedPath.length > maxPathLength
|
||||
? shortenedPath.slice(0, maxPathLength)
|
||||
: shortenedPath;
|
||||
|
||||
// Use theme gradient colors if available, otherwise use text colors (excluding primary)
|
||||
const gradientColors = theme.ui.gradient || [
|
||||
theme.text.secondary,
|
||||
theme.text.link,
|
||||
theme.text.accent,
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
alignItems="flex-start"
|
||||
width={artWidth}
|
||||
flexShrink={0}
|
||||
flexDirection="column"
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
marginX={containerMarginX}
|
||||
width={availableTerminalWidth}
|
||||
>
|
||||
{theme.ui.gradient ? (
|
||||
<Gradient colors={theme.ui.gradient}>
|
||||
<Text>{displayTitle}</Text>
|
||||
</Gradient>
|
||||
) : (
|
||||
<Text>{displayTitle}</Text>
|
||||
)}
|
||||
{nightly && (
|
||||
<Box width="100%" flexDirection="row" justifyContent="flex-end">
|
||||
{theme.ui.gradient ? (
|
||||
<Gradient colors={theme.ui.gradient}>
|
||||
<Text>v{version}</Text>
|
||||
{/* Left side: ASCII logo (only if enough space) */}
|
||||
{showLogo && (
|
||||
<>
|
||||
<Box flexShrink={0}>
|
||||
<Gradient colors={gradientColors}>
|
||||
<Text>{displayLogo}</Text>
|
||||
</Gradient>
|
||||
) : (
|
||||
<Text>v{version}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{/* Fixed gap between logo and info panel */}
|
||||
<Box width={logoGap} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Right side: Info panel (flexible width, max 60 in two-column layout) */}
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={infoPanelPaddingX}
|
||||
flexGrow={showLogo ? 0 : 1}
|
||||
width={showLogo ? availableInfoPanelWidth : undefined}
|
||||
>
|
||||
{/* Title line: >_ Qwen Code (v{version}) */}
|
||||
<Text>
|
||||
<Text bold color={theme.text.accent}>
|
||||
>_ Qwen Code
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> (v{version})</Text>
|
||||
</Text>
|
||||
{/* Empty line for spacing */}
|
||||
<Text> </Text>
|
||||
{/* Auth and Model line */}
|
||||
<Text>
|
||||
<Text color={theme.text.secondary}>{authModelText}</Text>
|
||||
{showAuthHint && (
|
||||
<Text color={theme.text.secondary}>{authHintText}</Text>
|
||||
)}
|
||||
</Text>
|
||||
{/* Directory line */}
|
||||
<Text color={theme.text.secondary}>{displayPath}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,15 +12,16 @@ import { t } from '../../i18n/index.js';
|
|||
|
||||
interface Help {
|
||||
commands: readonly SlashCommand[];
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const Help: React.FC<Help> = ({ commands }) => (
|
||||
export const Help: React.FC<Help> = ({ commands, width }) => (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
marginBottom={1}
|
||||
borderColor={theme.border.default}
|
||||
borderStyle="round"
|
||||
padding={1}
|
||||
width={width}
|
||||
>
|
||||
{/* Basics */}
|
||||
<Text bold color={theme.text.primary}>
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ describe('<HistoryItemDisplay />', () => {
|
|||
const { lastFrame } = renderWithProviders(
|
||||
<HistoryItemDisplay {...baseItem} item={item} />,
|
||||
);
|
||||
expect(lastFrame()).toContain('About Qwen Code');
|
||||
expect(lastFrame()).toContain('Status');
|
||||
});
|
||||
|
||||
it('renders ModelStatsDisplay for "model_stats" type', () => {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ interface HistoryItemDisplayProps {
|
|||
item: HistoryItem;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
mainAreaWidth?: number;
|
||||
isPending: boolean;
|
||||
isFocused?: boolean;
|
||||
commands?: readonly SlashCommand[];
|
||||
|
|
@ -50,6 +51,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
|||
item,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
mainAreaWidth,
|
||||
isPending,
|
||||
commands,
|
||||
isFocused = true,
|
||||
|
|
@ -58,9 +60,16 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
|||
availableTerminalHeightGemini,
|
||||
}) => {
|
||||
const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);
|
||||
const contentWidth = terminalWidth - 4;
|
||||
const boxWidth = mainAreaWidth || contentWidth;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" key={itemForDisplay.id}>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
key={itemForDisplay.id}
|
||||
marginLeft={2}
|
||||
marginRight={2}
|
||||
>
|
||||
{/* Render standard message types */}
|
||||
{itemForDisplay.type === 'user' && (
|
||||
<UserMessage text={itemForDisplay.text} />
|
||||
|
|
@ -75,7 +84,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
|||
availableTerminalHeight={
|
||||
availableTerminalHeightGemini ?? availableTerminalHeight
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
contentWidth={contentWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'gemini_content' && (
|
||||
|
|
@ -85,7 +94,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
|||
availableTerminalHeight={
|
||||
availableTerminalHeightGemini ?? availableTerminalHeight
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
contentWidth={contentWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'gemini_thought' && (
|
||||
|
|
@ -95,7 +104,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
|||
availableTerminalHeight={
|
||||
availableTerminalHeightGemini ?? availableTerminalHeight
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
contentWidth={contentWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'gemini_thought_content' && (
|
||||
|
|
@ -105,7 +114,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
|||
availableTerminalHeight={
|
||||
availableTerminalHeightGemini ?? availableTerminalHeight
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
contentWidth={contentWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'info' && (
|
||||
|
|
@ -118,25 +127,32 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
|||
<ErrorMessage text={itemForDisplay.text} />
|
||||
)}
|
||||
{itemForDisplay.type === 'about' && (
|
||||
<AboutBox {...itemForDisplay.systemInfo} />
|
||||
<AboutBox {...itemForDisplay.systemInfo} width={boxWidth} />
|
||||
)}
|
||||
{itemForDisplay.type === 'help' && commands && (
|
||||
<Help commands={commands} />
|
||||
<Help commands={commands} width={boxWidth} />
|
||||
)}
|
||||
{itemForDisplay.type === 'stats' && (
|
||||
<StatsDisplay duration={itemForDisplay.duration} />
|
||||
<StatsDisplay duration={itemForDisplay.duration} width={boxWidth} />
|
||||
)}
|
||||
{itemForDisplay.type === 'model_stats' && (
|
||||
<ModelStatsDisplay width={boxWidth} />
|
||||
)}
|
||||
{itemForDisplay.type === 'tool_stats' && (
|
||||
<ToolStatsDisplay width={boxWidth} />
|
||||
)}
|
||||
{itemForDisplay.type === 'model_stats' && <ModelStatsDisplay />}
|
||||
{itemForDisplay.type === 'tool_stats' && <ToolStatsDisplay />}
|
||||
{itemForDisplay.type === 'quit' && (
|
||||
<SessionSummaryDisplay duration={itemForDisplay.duration} />
|
||||
<SessionSummaryDisplay
|
||||
duration={itemForDisplay.duration}
|
||||
width={boxWidth}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'tool_group' && (
|
||||
<ToolGroupMessage
|
||||
toolCalls={itemForDisplay.tools}
|
||||
groupId={itemForDisplay.id}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
contentWidth={contentWidth}
|
||||
isFocused={isFocused}
|
||||
activeShellPtyId={activeShellPtyId}
|
||||
embeddedShellFocused={embeddedShellFocused}
|
||||
|
|
@ -149,7 +165,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
|||
{itemForDisplay.type === 'extensions_list' && <ExtensionsList />}
|
||||
{itemForDisplay.type === 'tools_list' && (
|
||||
<ToolsList
|
||||
terminalWidth={terminalWidth}
|
||||
contentWidth={contentWidth}
|
||||
tools={itemForDisplay.tools}
|
||||
showDescriptions={itemForDisplay.showDescriptions}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -54,6 +54,9 @@ export interface InputPromptProps {
|
|||
setShellModeActive: (value: boolean) => void;
|
||||
approvalMode: ApprovalMode;
|
||||
onEscapePromptChange?: (showPrompt: boolean) => void;
|
||||
onToggleShortcuts?: () => void;
|
||||
showShortcuts?: boolean;
|
||||
onSuggestionsVisibilityChange?: (visible: boolean) => void;
|
||||
vimHandleInput?: (key: Key) => boolean;
|
||||
isEmbeddedShellFocused?: boolean;
|
||||
}
|
||||
|
|
@ -98,6 +101,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
setShellModeActive,
|
||||
approvalMode,
|
||||
onEscapePromptChange,
|
||||
onToggleShortcuts,
|
||||
showShortcuts,
|
||||
onSuggestionsVisibilityChange,
|
||||
vimHandleInput,
|
||||
isEmbeddedShellFocused,
|
||||
}) => {
|
||||
|
|
@ -351,11 +357,31 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
buffer.text === '' &&
|
||||
!completion.showSuggestions
|
||||
) {
|
||||
// Hide shortcuts when toggling shell mode
|
||||
if (showShortcuts && onToggleShortcuts) {
|
||||
onToggleShortcuts();
|
||||
}
|
||||
setShellModeActive(!shellModeActive);
|
||||
buffer.setText(''); // Clear the '!' from input
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle keyboard shortcuts display with "?" when buffer is empty
|
||||
if (
|
||||
key.sequence === '?' &&
|
||||
buffer.text === '' &&
|
||||
!completion.showSuggestions &&
|
||||
onToggleShortcuts
|
||||
) {
|
||||
onToggleShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide shortcuts on any other key press
|
||||
if (showShortcuts && onToggleShortcuts) {
|
||||
onToggleShortcuts();
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
const cancelSearch = (
|
||||
setActive: (active: boolean) => void,
|
||||
|
|
@ -683,6 +709,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
recentPasteTime,
|
||||
commandSearchActive,
|
||||
commandSearchCompletion,
|
||||
onToggleShortcuts,
|
||||
showShortcuts,
|
||||
uiState,
|
||||
],
|
||||
);
|
||||
|
|
@ -703,6 +731,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
const activeCompletion = getActiveCompletion();
|
||||
const shouldShowSuggestions = activeCompletion.showSuggestions;
|
||||
|
||||
// Notify parent about suggestions visibility changes
|
||||
useEffect(() => {
|
||||
if (onSuggestionsVisibilityChange) {
|
||||
onSuggestionsVisibilityChange(shouldShowSuggestions);
|
||||
}
|
||||
}, [shouldShowSuggestions, onSuggestionsVisibilityChange]);
|
||||
|
||||
const showAutoAcceptStyling =
|
||||
!shellModeActive && approvalMode === ApprovalMode.AUTO_EDIT;
|
||||
const showYoloStyling =
|
||||
|
|
@ -735,7 +770,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderColor={borderColor}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text
|
||||
color={statusColor ?? theme.text.accent}
|
||||
|
|
@ -866,7 +900,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
</Box>
|
||||
</Box>
|
||||
{shouldShowSuggestions && (
|
||||
<Box paddingRight={2}>
|
||||
<Box marginLeft={2} marginRight={2}>
|
||||
<SuggestionsDisplay
|
||||
suggestions={activeCompletion.suggestions}
|
||||
activeIndex={activeCompletion.activeSuggestionIndex}
|
||||
|
|
|
|||
117
packages/cli/src/ui/components/KeyboardShortcuts.tsx
Normal file
117
packages/cli/src/ui/components/KeyboardShortcuts.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface Shortcut {
|
||||
key: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// Platform-specific key mappings
|
||||
const getNewlineKey = () =>
|
||||
process.platform === 'win32' ? 'ctrl+enter' : 'ctrl+j';
|
||||
const getPasteKey = () => (process.platform === 'darwin' ? 'cmd+v' : 'ctrl+v');
|
||||
const getExternalEditorKey = () =>
|
||||
process.platform === 'darwin' ? 'ctrl+x' : 'ctrl+x';
|
||||
|
||||
// Generate shortcuts with translations (called at render time)
|
||||
const getShortcuts = (): Shortcut[] => [
|
||||
{ key: '!', description: t('for shell mode') },
|
||||
{ key: '/', description: t('for commands') },
|
||||
{ key: '@', description: t('for file paths') },
|
||||
{ key: 'esc esc', description: t('to clear input') },
|
||||
{ key: 'shift+tab', description: t('to cycle approvals') },
|
||||
{ key: 'ctrl+c', description: t('to quit') },
|
||||
{ key: getNewlineKey(), description: t('for newline') + ' ⏎' },
|
||||
{ key: 'ctrl+l', description: t('to clear screen') },
|
||||
{ key: 'ctrl+r', description: t('to search history') },
|
||||
{ key: getPasteKey(), description: t('to paste images') },
|
||||
{ key: getExternalEditorKey(), description: t('for external editor') },
|
||||
];
|
||||
|
||||
const ShortcutItem: React.FC<{ shortcut: Shortcut }> = ({ shortcut }) => (
|
||||
<Text color={theme.text.secondary}>
|
||||
<Text color={theme.text.accent}>{shortcut.key}</Text> {shortcut.description}
|
||||
</Text>
|
||||
);
|
||||
|
||||
// Layout constants
|
||||
const COLUMN_GAP = 4;
|
||||
const MARGIN_LEFT = 2;
|
||||
const MARGIN_RIGHT = 2;
|
||||
|
||||
// Column distribution for different layouts (3+4+4 for 3 cols, 6+5 for 2 cols)
|
||||
const COLUMN_SPLITS: Record<number, number[]> = {
|
||||
3: [3, 4, 4],
|
||||
2: [6, 5],
|
||||
1: [11],
|
||||
};
|
||||
|
||||
export const KeyboardShortcuts: React.FC = () => {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const shortcuts = getShortcuts();
|
||||
|
||||
// Helper to calculate width needed for a column layout
|
||||
const getShortcutWidth = (shortcut: Shortcut) =>
|
||||
shortcut.key.length + 1 + shortcut.description.length;
|
||||
|
||||
const calculateLayoutWidth = (splits: number[]): number => {
|
||||
let startIndex = 0;
|
||||
let totalWidth = 0;
|
||||
splits.forEach((count, colIndex) => {
|
||||
const columnItems = shortcuts.slice(startIndex, startIndex + count);
|
||||
const columnWidth = Math.max(...columnItems.map(getShortcutWidth));
|
||||
totalWidth += columnWidth;
|
||||
if (colIndex < splits.length - 1) {
|
||||
totalWidth += COLUMN_GAP;
|
||||
}
|
||||
startIndex += count;
|
||||
});
|
||||
return totalWidth;
|
||||
};
|
||||
|
||||
// Calculate number of columns based on terminal width and actual content
|
||||
const availableWidth = terminalWidth - MARGIN_LEFT - MARGIN_RIGHT;
|
||||
const width3Col = calculateLayoutWidth(COLUMN_SPLITS[3]);
|
||||
const width2Col = calculateLayoutWidth(COLUMN_SPLITS[2]);
|
||||
|
||||
const numColumns =
|
||||
availableWidth >= width3Col ? 3 : availableWidth >= width2Col ? 2 : 1;
|
||||
|
||||
// Split shortcuts into columns using predefined distribution
|
||||
const splits = COLUMN_SPLITS[numColumns];
|
||||
const columns: Shortcut[][] = [];
|
||||
let startIndex = 0;
|
||||
for (const count of splits) {
|
||||
columns.push(shortcuts.slice(startIndex, startIndex + count));
|
||||
startIndex += count;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="row"
|
||||
marginLeft={MARGIN_LEFT}
|
||||
marginRight={MARGIN_RIGHT}
|
||||
>
|
||||
{columns.map((column, colIndex) => (
|
||||
<Box
|
||||
key={colIndex}
|
||||
flexDirection="column"
|
||||
marginRight={colIndex < numColumns - 1 ? COLUMN_GAP : 0}
|
||||
>
|
||||
{column.map((shortcut) => (
|
||||
<ShortcutItem key={shortcut.key} shortcut={shortcut} />
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -23,6 +23,7 @@ export const MainContent = () => {
|
|||
const uiState = useUIState();
|
||||
const {
|
||||
pendingHistoryItems,
|
||||
terminalWidth,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
availableTerminalHeight,
|
||||
|
|
@ -36,7 +37,8 @@ export const MainContent = () => {
|
|||
<AppHeader key="app-header" version={version} />,
|
||||
...uiState.history.map((h) => (
|
||||
<HistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
terminalWidth={terminalWidth}
|
||||
mainAreaWidth={mainAreaWidth}
|
||||
availableTerminalHeight={staticAreaMaxItemHeight}
|
||||
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
|
||||
key={h.id}
|
||||
|
|
@ -57,7 +59,8 @@ export const MainContent = () => {
|
|||
availableTerminalHeight={
|
||||
uiState.constrainHeight ? availableTerminalHeight : undefined
|
||||
}
|
||||
terminalWidth={mainAreaWidth}
|
||||
terminalWidth={terminalWidth}
|
||||
mainAreaWidth={mainAreaWidth}
|
||||
item={{ ...item, id: 0 }}
|
||||
isPending={true}
|
||||
isFocused={!uiState.isEditorDialogOpen}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,13 @@ const StatRow: React.FC<StatRowProps> = ({
|
|||
</Box>
|
||||
);
|
||||
|
||||
export const ModelStatsDisplay: React.FC = () => {
|
||||
interface ModelStatsDisplayProps {
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
|
||||
width,
|
||||
}) => {
|
||||
const { stats } = useSessionStats();
|
||||
const { models } = stats.metrics;
|
||||
const activeModels = Object.entries(models).filter(
|
||||
|
|
@ -64,6 +70,7 @@ export const ModelStatsDisplay: React.FC = () => {
|
|||
borderColor={theme.border.default}
|
||||
paddingY={1}
|
||||
paddingX={2}
|
||||
width={width}
|
||||
>
|
||||
<Text color={theme.text.primary}>
|
||||
{t('No API calls have been made in this session.')}
|
||||
|
|
@ -93,6 +100,7 @@ export const ModelStatsDisplay: React.FC = () => {
|
|||
flexDirection="column"
|
||||
paddingY={1}
|
||||
paddingX={2}
|
||||
width={width}
|
||||
>
|
||||
<Text bold color={theme.text.accent}>
|
||||
{t('Model Stats For Nerds')}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export const PlanSummaryDisplay: React.FC<PlanSummaryDisplayProps> = ({
|
|||
text={plan}
|
||||
isPending={false}
|
||||
availableTerminalHeight={availableHeight}
|
||||
terminalWidth={childWidth}
|
||||
contentWidth={childWidth}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export const QuittingDisplay = () => {
|
|||
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
|
||||
|
||||
const availableTerminalHeight = terminalHeight;
|
||||
const { mainAreaWidth } = uiState;
|
||||
|
||||
if (!uiState.quittingMessages) {
|
||||
return null;
|
||||
|
|
@ -28,6 +29,7 @@ export const QuittingDisplay = () => {
|
|||
uiState.constrainHeight ? availableTerminalHeight : undefined
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
mainAreaWidth={mainAreaWidth}
|
||||
item={item}
|
||||
isPending={false}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -127,8 +127,8 @@ export function SessionPicker(props: SessionPickerProps) {
|
|||
|
||||
const { columns: width, rows: height } = useTerminalSize();
|
||||
|
||||
// Calculate box width (width + 6 for border padding)
|
||||
const boxWidth = width + 6;
|
||||
// Calculate box width (marginX={2})
|
||||
const boxWidth = width - 4;
|
||||
// Calculate visible items (same heuristic as before)
|
||||
// Reserved space: header (1), footer (1), separators (2), borders (2)
|
||||
const reservedLines = 6;
|
||||
|
|
@ -179,7 +179,7 @@ export function SessionPicker(props: SessionPickerProps) {
|
|||
|
||||
{/* Separator */}
|
||||
<Box>
|
||||
<Text color={theme.border.default}>{'─'.repeat(width - 2)}</Text>
|
||||
<Text color={theme.border.default}>{'─'.repeat(boxWidth - 2)}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Session list */}
|
||||
|
|
@ -212,7 +212,7 @@ export function SessionPicker(props: SessionPickerProps) {
|
|||
isLast={visibleIndex === picker.visibleSessions.length - 1}
|
||||
showScrollUp={picker.showScrollUp}
|
||||
showScrollDown={picker.showScrollDown}
|
||||
maxPromptWidth={width}
|
||||
maxPromptWidth={boxWidth - 6}
|
||||
prefixChars={PREFIX_CHARS}
|
||||
boldSelectedPrefix={false}
|
||||
/>
|
||||
|
|
@ -223,7 +223,7 @@ export function SessionPicker(props: SessionPickerProps) {
|
|||
|
||||
{/* Separator */}
|
||||
<Box>
|
||||
<Text color={theme.border.default}>{'─'.repeat(width - 2)}</Text>
|
||||
<Text color={theme.border.default}>{'─'.repeat(boxWidth - 2)}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
|
|
|
|||
|
|
@ -14,10 +14,12 @@ import { t } from '../../i18n/index.js';
|
|||
|
||||
interface SessionSummaryDisplayProps {
|
||||
duration: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
|
||||
duration,
|
||||
width,
|
||||
}) => {
|
||||
const config = useConfig();
|
||||
const { stats } = useSessionStats();
|
||||
|
|
@ -32,6 +34,7 @@ export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
|
|||
<StatsDisplay
|
||||
title={t('Agent powering down. Goodbye!')}
|
||||
duration={duration}
|
||||
width={width}
|
||||
/>
|
||||
{hasMessages && canResume && (
|
||||
<Box marginTop={1}>
|
||||
|
|
|
|||
|
|
@ -28,12 +28,13 @@ import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
|||
import { VimModeProvider } from '../contexts/VimModeContext.js';
|
||||
import { KeypressProvider } from '../contexts/KeypressContext.js';
|
||||
import { act } from 'react';
|
||||
import { saveModifiedSettings, TEST_ONLY } from '../../utils/settingsUtils.js';
|
||||
import {
|
||||
getSettingsSchema,
|
||||
type SettingDefinition,
|
||||
type SettingsSchemaType,
|
||||
} from '../../config/settingsSchema.js';
|
||||
getDialogSettingKeys,
|
||||
getSettingDefinition,
|
||||
saveModifiedSettings,
|
||||
TEST_ONLY,
|
||||
} from '../../utils/settingsUtils.js';
|
||||
import { OUTPUT_LANGUAGE_AUTO } from '../../utils/languageUtils.js';
|
||||
|
||||
// Mock the VimModeContext
|
||||
const mockToggleVimEnabled = vi.fn();
|
||||
|
|
@ -129,6 +130,14 @@ vi.mock('../../utils/settingsUtils.js', async () => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock('../../utils/languageUtils.js', async () => {
|
||||
const actual = await vi.importActual('../../utils/languageUtils.js');
|
||||
return {
|
||||
...actual,
|
||||
updateOutputLanguageFile: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Helper function to simulate key presses (commented out for now)
|
||||
// const simulateKeyPress = async (keyData: Partial<Key> & { name: string }) => {
|
||||
// if (currentKeypressHandler) {
|
||||
|
|
@ -210,8 +219,9 @@ describe('SettingsDialog', () => {
|
|||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Settings');
|
||||
expect(output).toContain('Apply To');
|
||||
expect(output).toContain('Use Enter to select, Tab to change focus');
|
||||
// Scope selector is now in a separate view (Tab to switch)
|
||||
expect(output).not.toContain('Apply To');
|
||||
expect(output).toContain('(Use Enter to select, Tab to configure scope)');
|
||||
});
|
||||
|
||||
it('should accept availableTerminalHeight prop without errors', () => {
|
||||
|
|
@ -231,7 +241,7 @@ describe('SettingsDialog', () => {
|
|||
const output = lastFrame();
|
||||
// Should still render properly with the height prop
|
||||
expect(output).toContain('Settings');
|
||||
expect(output).toContain('Use Enter to select');
|
||||
expect(output).toContain('Enter to select');
|
||||
});
|
||||
|
||||
it('should show settings list with default values', () => {
|
||||
|
|
@ -281,7 +291,12 @@ describe('SettingsDialog', () => {
|
|||
stdin.write(TerminalKeys.DOWN_ARROW as string); // Down arrow
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('● Disable Auto Update');
|
||||
const secondKey = getDialogSettingKeys()[1];
|
||||
expect(secondKey).toBeDefined();
|
||||
const secondLabel = secondKey
|
||||
? (getSettingDefinition(secondKey)?.label ?? secondKey)
|
||||
: '';
|
||||
expect(lastFrame()).toContain(`● ${secondLabel}`);
|
||||
|
||||
// The active index should have changed (tested indirectly through behavior)
|
||||
unmount();
|
||||
|
|
@ -342,7 +357,14 @@ describe('SettingsDialog', () => {
|
|||
|
||||
await wait();
|
||||
|
||||
expect(lastFrame()).toContain('● Vision Model Preview');
|
||||
const lastKey = getDialogSettingKeys().at(-1);
|
||||
expect(lastKey).toBeDefined();
|
||||
|
||||
const lastLabel = lastKey
|
||||
? (getSettingDefinition(lastKey)?.label ?? lastKey)
|
||||
: '';
|
||||
|
||||
expect(lastFrame()).toContain(`● ${lastLabel}`);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
|
@ -362,17 +384,24 @@ describe('SettingsDialog', () => {
|
|||
|
||||
const { stdin, unmount, lastFrame } = render(component);
|
||||
|
||||
// Wait for initial render and verify we're on Vim Mode (first setting)
|
||||
// Wait for initial render and verify we're on Tool Approval Mode (first setting)
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('● Vim Mode');
|
||||
expect(lastFrame()).toContain('● Tool Approval Mode');
|
||||
});
|
||||
|
||||
// Navigate to Disable Auto Update setting and verify we're there
|
||||
act(() => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string);
|
||||
});
|
||||
const dialogKeys = getDialogSettingKeys();
|
||||
const targetIndex = dialogKeys.indexOf('general.vimMode');
|
||||
expect(targetIndex).toBeGreaterThan(0);
|
||||
|
||||
// Navigate to Vim Mode setting and verify we're there
|
||||
for (let i = 0; i < targetIndex; i++) {
|
||||
act(() => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string);
|
||||
});
|
||||
await wait();
|
||||
}
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('● Disable Auto Update');
|
||||
expect(lastFrame()).toContain('● Vim Mode');
|
||||
});
|
||||
|
||||
// Toggle the setting
|
||||
|
|
@ -392,10 +421,10 @@ describe('SettingsDialog', () => {
|
|||
});
|
||||
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
|
||||
new Set<string>(['general.disableAutoUpdate']),
|
||||
new Set<string>(['general.vimMode']),
|
||||
{
|
||||
general: {
|
||||
disableAutoUpdate: true,
|
||||
vimMode: true,
|
||||
},
|
||||
},
|
||||
expect.any(LoadedSettings),
|
||||
|
|
@ -406,51 +435,10 @@ describe('SettingsDialog', () => {
|
|||
});
|
||||
|
||||
describe('enum values', () => {
|
||||
enum StringEnum {
|
||||
FOO = 'foo',
|
||||
BAR = 'bar',
|
||||
BAZ = 'baz',
|
||||
}
|
||||
|
||||
const SETTING: SettingDefinition = {
|
||||
type: 'enum',
|
||||
label: 'Theme',
|
||||
options: [
|
||||
{
|
||||
label: 'Foo',
|
||||
value: StringEnum.FOO,
|
||||
},
|
||||
{
|
||||
label: 'Bar',
|
||||
value: StringEnum.BAR,
|
||||
},
|
||||
{
|
||||
label: 'Baz',
|
||||
value: StringEnum.BAZ,
|
||||
},
|
||||
],
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: StringEnum.BAR,
|
||||
description: 'The color theme for the UI.',
|
||||
showInDialog: true,
|
||||
};
|
||||
|
||||
const FAKE_SCHEMA: SettingsSchemaType = {
|
||||
ui: {
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
theme: {
|
||||
...SETTING,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as SettingsSchemaType;
|
||||
|
||||
it('toggles enum values with the enter key', async () => {
|
||||
vi.mocked(saveModifiedSettings).mockClear();
|
||||
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(FAKE_SCHEMA);
|
||||
// Use real schema - first setting "Tool Approval Mode" is an enum
|
||||
const settings = createMockSettings();
|
||||
const onSelect = vi.fn();
|
||||
const component = (
|
||||
|
|
@ -459,24 +447,30 @@ describe('SettingsDialog', () => {
|
|||
</KeypressProvider>
|
||||
);
|
||||
|
||||
const { stdin, unmount } = render(component);
|
||||
const { stdin, unmount, lastFrame } = render(component);
|
||||
|
||||
// Press Enter to toggle current setting
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string);
|
||||
await wait();
|
||||
stdin.write(TerminalKeys.ENTER as string);
|
||||
// Verify we're on Tool Approval Mode (first setting, an enum)
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('● Tool Approval Mode');
|
||||
});
|
||||
|
||||
// Press Enter to cycle the enum value
|
||||
act(() => {
|
||||
stdin.write(TerminalKeys.ENTER as string);
|
||||
});
|
||||
await wait();
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Tool Approval Mode cycles through enum values
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
|
||||
new Set<string>(['ui.theme']),
|
||||
{
|
||||
ui: {
|
||||
theme: StringEnum.BAZ,
|
||||
},
|
||||
},
|
||||
new Set<string>(['tools.approvalMode']),
|
||||
expect.objectContaining({
|
||||
tools: expect.objectContaining({
|
||||
approvalMode: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
expect.any(LoadedSettings),
|
||||
SettingScope.User,
|
||||
);
|
||||
|
|
@ -486,10 +480,10 @@ describe('SettingsDialog', () => {
|
|||
|
||||
it('loops back when reaching the end of an enum', async () => {
|
||||
vi.mocked(saveModifiedSettings).mockClear();
|
||||
vi.mocked(getSettingsSchema).mockReturnValue(FAKE_SCHEMA);
|
||||
// Use Tool Approval Mode set to YOLO (last value) to test looping back to first
|
||||
const settings = createMockSettings({
|
||||
ui: {
|
||||
theme: StringEnum.BAZ,
|
||||
tools: {
|
||||
approvalMode: 'yolo', // Last enum value
|
||||
},
|
||||
});
|
||||
const onSelect = vi.fn();
|
||||
|
|
@ -499,24 +493,30 @@ describe('SettingsDialog', () => {
|
|||
</KeypressProvider>
|
||||
);
|
||||
|
||||
const { stdin, unmount } = render(component);
|
||||
const { stdin, unmount, lastFrame } = render(component);
|
||||
|
||||
// Press Enter to toggle current setting
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string);
|
||||
await wait();
|
||||
stdin.write(TerminalKeys.ENTER as string);
|
||||
// Verify we're on Tool Approval Mode (first setting)
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('● Tool Approval Mode');
|
||||
});
|
||||
|
||||
// Press Enter to cycle - should loop back to first value (Plan)
|
||||
act(() => {
|
||||
stdin.write(TerminalKeys.ENTER as string);
|
||||
});
|
||||
await wait();
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should loop back to first enum value (Plan)
|
||||
expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith(
|
||||
new Set<string>(['ui.theme']),
|
||||
{
|
||||
ui: {
|
||||
theme: StringEnum.FOO,
|
||||
},
|
||||
},
|
||||
new Set<string>(['tools.approvalMode']),
|
||||
expect.objectContaining({
|
||||
tools: expect.objectContaining({
|
||||
approvalMode: 'plan', // First enum value after YOLO
|
||||
}),
|
||||
}),
|
||||
expect.any(LoadedSettings),
|
||||
SettingScope.User,
|
||||
);
|
||||
|
|
@ -596,15 +596,15 @@ describe('SettingsDialog', () => {
|
|||
|
||||
// Wait for initial render
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Vim Mode');
|
||||
expect(lastFrame()).toContain('Tool Approval Mode');
|
||||
});
|
||||
|
||||
// The UI should show the settings section is active and scope section is inactive
|
||||
expect(lastFrame()).toContain('● Vim Mode'); // Settings section active
|
||||
expect(lastFrame()).toContain(' Apply To'); // Scope section inactive
|
||||
// The UI should show settings mode is active (scope is in separate view)
|
||||
expect(lastFrame()).toContain('● Tool Approval Mode'); // Settings section active
|
||||
expect(lastFrame()).not.toContain('Apply To'); // Scope is in a separate view
|
||||
|
||||
// This test validates the initial state - scope selection behavior
|
||||
// is complex due to keypress handling, so we focus on state validation
|
||||
// This test validates the initial state - scope selection is now
|
||||
// accessed via Tab key, not shown alongside settings
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
|
@ -668,12 +668,12 @@ describe('SettingsDialog', () => {
|
|||
|
||||
// Wait for initial render
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Hide Window Title');
|
||||
expect(lastFrame()).toContain('Tool Approval Mode');
|
||||
});
|
||||
|
||||
// Verify the dialog is rendered properly
|
||||
// Verify the dialog is rendered properly (scope is in separate view)
|
||||
expect(lastFrame()).toContain('Settings');
|
||||
expect(lastFrame()).toContain('Apply To');
|
||||
expect(lastFrame()).not.toContain('Apply To'); // Scope is in a separate view
|
||||
|
||||
// This test validates rendering - escape key behavior depends on complex
|
||||
// keypress handling that's difficult to test reliably in this environment
|
||||
|
|
@ -874,17 +874,40 @@ describe('SettingsDialog', () => {
|
|||
unmount();
|
||||
});
|
||||
|
||||
it('should clear restart prompt when switching scopes', async () => {
|
||||
it('should keep restart prompt when switching scopes', async () => {
|
||||
const settings = createMockSettings();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
const { unmount } = render(
|
||||
const { stdin, lastFrame, unmount } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={onSelect} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
// Restart prompt should be cleared when switching scopes
|
||||
// Trigger a restart-required setting change: navigate to "Language: UI" (2nd item) and toggle it.
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string);
|
||||
await wait();
|
||||
stdin.write(TerminalKeys.ENTER as string);
|
||||
await wait();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain(
|
||||
'To see changes, Qwen Code must be restarted',
|
||||
);
|
||||
});
|
||||
|
||||
// Switch scopes; restart prompt should remain visible.
|
||||
stdin.write(TerminalKeys.TAB as string);
|
||||
await wait();
|
||||
stdin.write('2');
|
||||
await wait();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain(
|
||||
'To see changes, Qwen Code must be restarted',
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
|
@ -929,6 +952,66 @@ describe('SettingsDialog', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Output Language', () => {
|
||||
it('treats empty output language as auto', async () => {
|
||||
const settings = createMockSettings({
|
||||
general: { outputLanguage: 'en' },
|
||||
});
|
||||
|
||||
const { stdin, unmount, lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<SettingsDialog settings={settings} onSelect={() => {}} />
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Settings');
|
||||
});
|
||||
|
||||
// Navigate to the output language setting, start editing, then commit empty.
|
||||
// Avoid hard-coding the item index because schema-driven ordering can differ by platform.
|
||||
const outputLanguageIndex = getDialogSettingKeys().indexOf(
|
||||
'general.outputLanguage',
|
||||
);
|
||||
expect(outputLanguageIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const press = async (key: string) => {
|
||||
act(() => {
|
||||
stdin.write(key);
|
||||
});
|
||||
await wait();
|
||||
};
|
||||
|
||||
for (let i = 0; i < outputLanguageIndex; i++) {
|
||||
await press(TerminalKeys.DOWN_ARROW as string);
|
||||
}
|
||||
await press(TerminalKeys.ENTER as string);
|
||||
await press(TerminalKeys.ENTER as string);
|
||||
|
||||
// Empty input should set 'auto' in settings (rule file is updated on restart)
|
||||
await waitFor(() => {
|
||||
const outputLanguageCall = vi
|
||||
.mocked(saveModifiedSettings)
|
||||
.mock.calls.find((call) =>
|
||||
(call[0] as Set<string>).has('general.outputLanguage'),
|
||||
);
|
||||
expect(outputLanguageCall).toBeTruthy();
|
||||
});
|
||||
|
||||
const outputLanguageCall = vi
|
||||
.mocked(saveModifiedSettings)
|
||||
.mock.calls.find((call) =>
|
||||
(call[0] as Set<string>).has('general.outputLanguage'),
|
||||
);
|
||||
// Should save 'auto' to settings
|
||||
expect(outputLanguageCall?.[1]).toMatchObject({
|
||||
general: { outputLanguage: OUTPUT_LANGUAGE_AUTO },
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard Shortcuts Edge Cases', () => {
|
||||
it('should handle rapid key presses gracefully', async () => {
|
||||
const settings = createMockSettings();
|
||||
|
|
@ -1018,15 +1101,15 @@ describe('SettingsDialog', () => {
|
|||
|
||||
// Wait for initial render
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Vim Mode');
|
||||
expect(lastFrame()).toContain('Tool Approval Mode');
|
||||
});
|
||||
|
||||
// Verify initial state: settings section active, scope section inactive
|
||||
expect(lastFrame()).toContain('● Vim Mode'); // Settings section active
|
||||
expect(lastFrame()).toContain(' Apply To'); // Scope section inactive
|
||||
// Verify initial state: settings mode active (scope is in separate view)
|
||||
expect(lastFrame()).toContain('● Tool Approval Mode'); // Settings mode active
|
||||
expect(lastFrame()).not.toContain('Apply To'); // Scope is in a separate view
|
||||
|
||||
// This test validates the rendered UI structure for tab navigation
|
||||
// Actual tab behavior testing is complex due to keypress handling
|
||||
// Tab now switches between settings view and scope view
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
|
@ -1080,20 +1163,19 @@ describe('SettingsDialog', () => {
|
|||
|
||||
// Wait for initial render
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Vim Mode');
|
||||
expect(lastFrame()).toContain('Tool Approval Mode');
|
||||
});
|
||||
|
||||
// Verify the complete UI is rendered with all necessary sections
|
||||
// Verify the complete UI is rendered (scope is in separate view)
|
||||
expect(lastFrame()).toContain('Settings'); // Title
|
||||
expect(lastFrame()).toContain('● Vim Mode'); // Active setting
|
||||
expect(lastFrame()).toContain('Apply To'); // Scope section
|
||||
expect(lastFrame()).toContain('User Settings'); // Scope options (no numbers when settings focused)
|
||||
expect(lastFrame()).toContain('● Tool Approval Mode'); // Active setting
|
||||
expect(lastFrame()).not.toContain('Apply To'); // Scope is in a separate view (Tab to access)
|
||||
expect(lastFrame()).toContain(
|
||||
'(Use Enter to select, Tab to change focus)',
|
||||
'(Use Enter to select, Tab to configure scope)',
|
||||
); // Help text
|
||||
|
||||
// This test validates the complete UI structure is available for user workflow
|
||||
// Individual interactions are tested in focused unit tests
|
||||
// Scope selection is now accessed via Tab key (view switching like ThemeDialog)
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
|
@ -1275,7 +1357,6 @@ describe('SettingsDialog', () => {
|
|||
ui: {
|
||||
hideWindowTitle: true,
|
||||
hideTips: true,
|
||||
showMemoryUsage: true,
|
||||
showLineNumbers: true,
|
||||
showCitations: true,
|
||||
accessibility: {
|
||||
|
|
@ -1324,7 +1405,6 @@ describe('SettingsDialog', () => {
|
|||
disableAutoUpdate: true,
|
||||
},
|
||||
ui: {
|
||||
showMemoryUsage: true,
|
||||
hideWindowTitle: false,
|
||||
},
|
||||
tools: {
|
||||
|
|
@ -1373,9 +1453,7 @@ describe('SettingsDialog', () => {
|
|||
vimMode: true,
|
||||
disableAutoUpdate: false,
|
||||
},
|
||||
ui: {
|
||||
showMemoryUsage: true,
|
||||
},
|
||||
ui: {},
|
||||
},
|
||||
);
|
||||
const onSelect = vi.fn();
|
||||
|
|
@ -1436,7 +1514,6 @@ describe('SettingsDialog', () => {
|
|||
disableLoadingPhrases: true,
|
||||
screenReader: true,
|
||||
},
|
||||
showMemoryUsage: true,
|
||||
showLineNumbers: true,
|
||||
},
|
||||
general: {
|
||||
|
|
@ -1517,7 +1594,6 @@ describe('SettingsDialog', () => {
|
|||
ui: {
|
||||
hideWindowTitle: false,
|
||||
hideTips: false,
|
||||
showMemoryUsage: false,
|
||||
showLineNumbers: false,
|
||||
showCitations: false,
|
||||
accessibility: {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import type React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import type { LoadedSettings, Settings } from '../../config/settings.js';
|
||||
|
|
@ -16,7 +17,6 @@ import {
|
|||
getDialogSettingKeys,
|
||||
setPendingSettingValue,
|
||||
getDisplayValue,
|
||||
hasRestartRequiredSettings,
|
||||
saveModifiedSettings,
|
||||
getSettingDefinition,
|
||||
isDefaultValue,
|
||||
|
|
@ -27,6 +27,7 @@ import {
|
|||
getNestedValue,
|
||||
getEffectiveValue,
|
||||
} from '../../utils/settingsUtils.js';
|
||||
import { updateOutputLanguageFile } from '../../utils/languageUtils.js';
|
||||
import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import { type Config } from '@qwen-code/qwen-code-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
|
|
@ -57,10 +58,8 @@ export function SettingsDialog({
|
|||
// Get vim mode context to sync vim mode changes
|
||||
const { vimEnabled, toggleVimEnabled } = useVimMode();
|
||||
|
||||
// Focus state: 'settings' or 'scope'
|
||||
const [focusSection, setFocusSection] = useState<'settings' | 'scope'>(
|
||||
'settings',
|
||||
);
|
||||
// Mode state: 'settings' or 'scope' (view switching like ThemeDialog)
|
||||
const [mode, setMode] = useState<'settings' | 'scope'>('settings');
|
||||
// Scope selector state (User by default)
|
||||
const [selectedScope, setSelectedScope] = useState<SettingScope>(
|
||||
SettingScope.User,
|
||||
|
|
@ -69,7 +68,6 @@ export function SettingsDialog({
|
|||
const [activeSettingIndex, setActiveSettingIndex] = useState(0);
|
||||
// Scroll offset for settings
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
const [showRestartPrompt, setShowRestartPrompt] = useState(false);
|
||||
|
||||
// Local pending settings state for the selected scope
|
||||
const [pendingSettings, setPendingSettings] = useState<Settings>(() =>
|
||||
|
|
@ -89,33 +87,33 @@ export function SettingsDialog({
|
|||
>(new Map());
|
||||
|
||||
// Track restart-required settings across scope changes
|
||||
const [_restartRequiredSettings, setRestartRequiredSettings] = useState<
|
||||
const [restartRequiredSettings, setRestartRequiredSettings] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
|
||||
const showRestartPrompt = restartRequiredSettings.size > 0;
|
||||
|
||||
useEffect(() => {
|
||||
// Base settings for selected scope
|
||||
let updated = structuredClone(settings.forScope(selectedScope).settings);
|
||||
// Overlay globally pending (unsaved) changes so user sees their modifications in any scope
|
||||
const newModified = new Set<string>();
|
||||
const newRestartRequired = new Set<string>();
|
||||
for (const [key, value] of globalPendingChanges.entries()) {
|
||||
const def = getSettingDefinition(key);
|
||||
if (def?.type === 'boolean' && typeof value === 'boolean') {
|
||||
updated = setPendingSettingValue(key, value, updated);
|
||||
} else if (
|
||||
(def?.type === 'number' && typeof value === 'number') ||
|
||||
(def?.type === 'string' && typeof value === 'string')
|
||||
(def?.type === 'string' && typeof value === 'string') ||
|
||||
(def?.type === 'enum' &&
|
||||
(typeof value === 'string' || typeof value === 'number'))
|
||||
) {
|
||||
updated = setPendingSettingValueAny(key, value, updated);
|
||||
}
|
||||
newModified.add(key);
|
||||
if (requiresRestart(key)) newRestartRequired.add(key);
|
||||
}
|
||||
setPendingSettings(updated);
|
||||
setModifiedSettings(newModified);
|
||||
setRestartRequiredSettings(newRestartRequired);
|
||||
setShowRestartPrompt(newRestartRequired.size > 0);
|
||||
}, [selectedScope, settings, globalPendingChanges]);
|
||||
|
||||
const generateSettingsItems = () => {
|
||||
|
|
@ -156,10 +154,6 @@ export function SettingsDialog({
|
|||
);
|
||||
}
|
||||
|
||||
setPendingSettings((prev) =>
|
||||
setPendingSettingValue(key, newValue as boolean, prev),
|
||||
);
|
||||
|
||||
if (!requiresRestart(key)) {
|
||||
const immediateSettings = new Set([key]);
|
||||
const immediateSettingsObject = setPendingSettingValueAny(
|
||||
|
|
@ -229,31 +223,22 @@ export function SettingsDialog({
|
|||
structuredClone(settings.forScope(selectedScope).settings),
|
||||
);
|
||||
} else {
|
||||
// For restart-required settings, track as modified
|
||||
setModifiedSettings((prev) => {
|
||||
const updated = new Set(prev).add(key);
|
||||
const needsRestart = hasRestartRequiredSettings(updated);
|
||||
console.log(
|
||||
`[DEBUG SettingsDialog] Modified settings:`,
|
||||
Array.from(updated),
|
||||
'Needs restart:',
|
||||
needsRestart,
|
||||
);
|
||||
if (needsRestart) {
|
||||
setShowRestartPrompt(true);
|
||||
setRestartRequiredSettings((prevRestart) =>
|
||||
new Set(prevRestart).add(key),
|
||||
);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
// For restart-required settings, save immediately but show restart prompt
|
||||
const immediateSettings = new Set([key]);
|
||||
const immediateSettingsObject = setPendingSettingValueAny(
|
||||
key,
|
||||
newValue,
|
||||
{} as Settings,
|
||||
);
|
||||
saveModifiedSettings(
|
||||
immediateSettings,
|
||||
immediateSettingsObject,
|
||||
settings,
|
||||
selectedScope,
|
||||
);
|
||||
|
||||
// Add/update pending change globally so it persists across scopes
|
||||
setGlobalPendingChanges((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(key, newValue as PendingValue);
|
||||
return next;
|
||||
});
|
||||
// Mark as needing restart and show prompt
|
||||
setRestartRequiredSettings((prev) => new Set(prev).add(key));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -296,7 +281,7 @@ export function SettingsDialog({
|
|||
return;
|
||||
}
|
||||
|
||||
let parsed: string | number;
|
||||
let parsed: string | number | undefined;
|
||||
if (type === 'number') {
|
||||
const numParsed = Number(editBuffer.trim());
|
||||
if (Number.isNaN(numParsed)) {
|
||||
|
|
@ -309,19 +294,32 @@ export function SettingsDialog({
|
|||
parsed = numParsed;
|
||||
} else {
|
||||
// For strings, use the buffer as is.
|
||||
parsed = editBuffer;
|
||||
// Special handling for outputLanguage: empty input means 'auto'
|
||||
if (key === 'general.outputLanguage') {
|
||||
const trimmed = editBuffer.trim();
|
||||
parsed = trimmed === '' ? 'auto' : trimmed;
|
||||
} else {
|
||||
parsed = editBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
// Update pending
|
||||
setPendingSettings((prev) => setPendingSettingValueAny(key, parsed, prev));
|
||||
setPendingSettings((prev) =>
|
||||
parsed === undefined
|
||||
? setPendingSettingValueAny(
|
||||
key,
|
||||
undefined as unknown as SettingsValue,
|
||||
prev,
|
||||
)
|
||||
: setPendingSettingValueAny(key, parsed, prev),
|
||||
);
|
||||
|
||||
if (!requiresRestart(key)) {
|
||||
const immediateSettings = new Set([key]);
|
||||
const immediateSettingsObject = setPendingSettingValueAny(
|
||||
key,
|
||||
parsed,
|
||||
{} as Settings,
|
||||
);
|
||||
const immediateSettingsObject =
|
||||
parsed === undefined
|
||||
? ({} as Settings)
|
||||
: setPendingSettingValueAny(key, parsed, {} as Settings);
|
||||
saveModifiedSettings(
|
||||
immediateSettings,
|
||||
immediateSettingsObject,
|
||||
|
|
@ -349,25 +347,26 @@ export function SettingsDialog({
|
|||
return next;
|
||||
});
|
||||
} else {
|
||||
// Mark as modified and needing restart
|
||||
setModifiedSettings((prev) => {
|
||||
const updated = new Set(prev).add(key);
|
||||
const needsRestart = hasRestartRequiredSettings(updated);
|
||||
if (needsRestart) {
|
||||
setShowRestartPrompt(true);
|
||||
setRestartRequiredSettings((prevRestart) =>
|
||||
new Set(prevRestart).add(key),
|
||||
);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
// For restart-required settings, save immediately but show restart prompt
|
||||
const immediateSettings = new Set([key]);
|
||||
const immediateSettingsObject =
|
||||
parsed === undefined
|
||||
? ({} as Settings)
|
||||
: setPendingSettingValueAny(key, parsed, {} as Settings);
|
||||
saveModifiedSettings(
|
||||
immediateSettings,
|
||||
immediateSettingsObject,
|
||||
settings,
|
||||
selectedScope,
|
||||
);
|
||||
|
||||
// Record pending change globally for persistence across scopes
|
||||
setGlobalPendingChanges((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(key, parsed as PendingValue);
|
||||
return next;
|
||||
});
|
||||
// Update output language rule file immediately (no restart needed for LLM effect)
|
||||
if (key === 'general.outputLanguage' && typeof parsed === 'string') {
|
||||
updateOutputLanguageFile(parsed);
|
||||
}
|
||||
|
||||
// Mark as needing restart and show prompt
|
||||
setRestartRequiredSettings((prev) => new Set(prev).add(key));
|
||||
}
|
||||
|
||||
setEditingKey(null);
|
||||
|
|
@ -381,15 +380,13 @@ export function SettingsDialog({
|
|||
|
||||
const handleScopeSelect = (scope: SettingScope) => {
|
||||
handleScopeHighlight(scope);
|
||||
setFocusSection('settings');
|
||||
setMode('settings');
|
||||
};
|
||||
|
||||
// Height constraint calculations similar to ThemeDialog
|
||||
const DIALOG_PADDING = 2;
|
||||
const SETTINGS_TITLE_HEIGHT = 2; // "Settings" title + spacing
|
||||
const SCROLL_ARROWS_HEIGHT = 2; // Up and down arrows
|
||||
const SPACING_HEIGHT = 1; // Space between settings list and scope
|
||||
const SCOPE_SELECTION_HEIGHT = 4; // Apply To section height
|
||||
const BOTTOM_HELP_TEXT_HEIGHT = 1; // Help text
|
||||
const RESTART_PROMPT_HEIGHT = showRestartPrompt ? 1 : 0;
|
||||
|
||||
|
|
@ -397,71 +394,28 @@ export function SettingsDialog({
|
|||
availableTerminalHeight ?? Number.MAX_SAFE_INTEGER;
|
||||
currentAvailableTerminalHeight -= 2; // Top and bottom borders
|
||||
|
||||
// Start with basic fixed height (without scope selection)
|
||||
let totalFixedHeight =
|
||||
// Calculate fixed height (scope selection is now in a separate view, not included here)
|
||||
const totalFixedHeight =
|
||||
DIALOG_PADDING +
|
||||
SETTINGS_TITLE_HEIGHT +
|
||||
SCROLL_ARROWS_HEIGHT +
|
||||
SPACING_HEIGHT +
|
||||
BOTTOM_HELP_TEXT_HEIGHT +
|
||||
RESTART_PROMPT_HEIGHT;
|
||||
|
||||
// Calculate how much space we have for settings
|
||||
let availableHeightForSettings = Math.max(
|
||||
const availableHeightForSettings = Math.max(
|
||||
1,
|
||||
currentAvailableTerminalHeight - totalFixedHeight,
|
||||
);
|
||||
|
||||
// Each setting item takes 2 lines (the setting row + spacing)
|
||||
let maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 2));
|
||||
|
||||
// Decide whether to show scope selection based on remaining space
|
||||
let showScopeSelection = true;
|
||||
|
||||
// If we have limited height, prioritize showing more settings over scope selection
|
||||
if (availableTerminalHeight && availableTerminalHeight < 25) {
|
||||
// For very limited height, hide scope selection to show more settings
|
||||
const totalWithScope = totalFixedHeight + SCOPE_SELECTION_HEIGHT;
|
||||
const availableWithScope = Math.max(
|
||||
1,
|
||||
currentAvailableTerminalHeight - totalWithScope,
|
||||
);
|
||||
const maxItemsWithScope = Math.max(1, Math.floor(availableWithScope / 2));
|
||||
|
||||
// If hiding scope selection allows us to show significantly more settings, do it
|
||||
if (maxVisibleItems > maxItemsWithScope + 1) {
|
||||
showScopeSelection = false;
|
||||
} else {
|
||||
// Otherwise include scope selection and recalculate
|
||||
totalFixedHeight += SCOPE_SELECTION_HEIGHT;
|
||||
availableHeightForSettings = Math.max(
|
||||
1,
|
||||
currentAvailableTerminalHeight - totalFixedHeight,
|
||||
);
|
||||
maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 2));
|
||||
}
|
||||
} else {
|
||||
// For normal height, include scope selection
|
||||
totalFixedHeight += SCOPE_SELECTION_HEIGHT;
|
||||
availableHeightForSettings = Math.max(
|
||||
1,
|
||||
currentAvailableTerminalHeight - totalFixedHeight,
|
||||
);
|
||||
maxVisibleItems = Math.max(1, Math.floor(availableHeightForSettings / 2));
|
||||
}
|
||||
// Each setting item takes 1 line
|
||||
const maxVisibleItems = Math.max(1, availableHeightForSettings);
|
||||
|
||||
// Use the calculated maxVisibleItems or fall back to the original maxItemsToShow
|
||||
const effectiveMaxItemsToShow = availableTerminalHeight
|
||||
? Math.min(maxVisibleItems, items.length)
|
||||
: maxItemsToShow;
|
||||
|
||||
// Ensure focus stays on settings when scope selection is hidden
|
||||
React.useEffect(() => {
|
||||
if (!showScopeSelection && focusSection === 'scope') {
|
||||
setFocusSection('settings');
|
||||
}
|
||||
}, [showScopeSelection, focusSection]);
|
||||
|
||||
// Scroll logic for settings
|
||||
const visibleItems = items.slice(
|
||||
scrollOffset,
|
||||
|
|
@ -474,10 +428,10 @@ export function SettingsDialog({
|
|||
useKeypress(
|
||||
(key) => {
|
||||
const { name, ctrl } = key;
|
||||
if (name === 'tab' && showScopeSelection) {
|
||||
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
|
||||
if (name === 'tab') {
|
||||
setMode((prev) => (prev === 'settings' ? 'scope' : 'settings'));
|
||||
}
|
||||
if (focusSection === 'settings') {
|
||||
if (mode === 'settings') {
|
||||
// If editing, capture input and control keys
|
||||
if (editingKey) {
|
||||
const definition = getSettingDefinition(editingKey);
|
||||
|
|
@ -599,6 +553,18 @@ export function SettingsDialog({
|
|||
}
|
||||
} else if (name === 'return' || name === 'space') {
|
||||
const currentItem = items[activeSettingIndex];
|
||||
if (currentItem?.value === 'ui.theme') {
|
||||
if (name === 'return') {
|
||||
onSelect('ui.theme', selectedScope);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (currentItem?.value === 'general.preferredEditor') {
|
||||
if (name === 'return') {
|
||||
onSelect('general.preferredEditor', selectedScope);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
currentItem?.type === 'number' ||
|
||||
currentItem?.type === 'string'
|
||||
|
|
@ -727,6 +693,9 @@ export function SettingsDialog({
|
|||
return next;
|
||||
});
|
||||
}
|
||||
setRestartRequiredSettings((prev) =>
|
||||
new Set(prev).add(currentSetting.value),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -756,7 +725,6 @@ export function SettingsDialog({
|
|||
});
|
||||
}
|
||||
|
||||
setShowRestartPrompt(false);
|
||||
setRestartRequiredSettings(new Set()); // Clear restart-required settings
|
||||
if (onRestartRequest) onRestartRequest();
|
||||
}
|
||||
|
|
@ -775,97 +743,95 @@ export function SettingsDialog({
|
|||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="row"
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<Text bold={focusSection === 'settings'} wrap="truncate">
|
||||
{focusSection === 'settings' ? '> ' : ' '}
|
||||
{t('Settings')}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
{showScrollUp && <Text color={theme.text.secondary}>▲</Text>}
|
||||
{visibleItems.map((item, idx) => {
|
||||
const isActive =
|
||||
focusSection === 'settings' &&
|
||||
activeSettingIndex === idx + scrollOffset;
|
||||
{mode === 'settings' ? (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<Text bold={mode === 'settings'} wrap="truncate">
|
||||
{mode === 'settings' ? '> ' : ' '}
|
||||
{t('Settings')}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
{showScrollUp && <Text color={theme.text.secondary}>▲</Text>}
|
||||
{visibleItems.map((item, idx) => {
|
||||
const isActive =
|
||||
mode === 'settings' && activeSettingIndex === idx + scrollOffset;
|
||||
|
||||
const scopeSettings = settings.forScope(selectedScope).settings;
|
||||
const mergedSettings = settings.merged;
|
||||
const scopeSettings = settings.forScope(selectedScope).settings;
|
||||
const mergedSettings = settings.merged;
|
||||
|
||||
let displayValue: string;
|
||||
if (editingKey === item.value) {
|
||||
// Show edit buffer with advanced cursor highlighting
|
||||
if (cursorVisible && editCursorPos < cpLen(editBuffer)) {
|
||||
// Cursor is in the middle or at start of text
|
||||
const beforeCursor = cpSlice(editBuffer, 0, editCursorPos);
|
||||
const atCursor = cpSlice(
|
||||
editBuffer,
|
||||
editCursorPos,
|
||||
editCursorPos + 1,
|
||||
let displayValue: string;
|
||||
if (editingKey === item.value) {
|
||||
// Show edit buffer with advanced cursor highlighting
|
||||
if (cursorVisible && editCursorPos < cpLen(editBuffer)) {
|
||||
// Cursor is in the middle or at start of text
|
||||
const beforeCursor = cpSlice(editBuffer, 0, editCursorPos);
|
||||
const atCursor = cpSlice(
|
||||
editBuffer,
|
||||
editCursorPos,
|
||||
editCursorPos + 1,
|
||||
);
|
||||
const afterCursor = cpSlice(editBuffer, editCursorPos + 1);
|
||||
displayValue =
|
||||
beforeCursor + chalk.inverse(atCursor) + afterCursor;
|
||||
} else if (cursorVisible && editCursorPos >= cpLen(editBuffer)) {
|
||||
// Cursor is at the end - show inverted space
|
||||
displayValue = editBuffer + chalk.inverse(' ');
|
||||
} else {
|
||||
// Cursor not visible
|
||||
displayValue = editBuffer;
|
||||
}
|
||||
} else if (item.type === 'number' || item.type === 'string') {
|
||||
// For numbers/strings, get the actual current value from pending settings
|
||||
const path = item.value.split('.');
|
||||
const currentValue = getNestedValue(pendingSettings, path);
|
||||
|
||||
const defaultValue = getDefaultValue(item.value);
|
||||
|
||||
if (currentValue !== undefined && currentValue !== null) {
|
||||
displayValue = String(currentValue);
|
||||
} else {
|
||||
displayValue =
|
||||
defaultValue !== undefined && defaultValue !== null
|
||||
? String(defaultValue)
|
||||
: '';
|
||||
}
|
||||
|
||||
// Add * if value differs from default OR if currently being modified
|
||||
const isModified = modifiedSettings.has(item.value);
|
||||
const effectiveCurrentValue =
|
||||
currentValue !== undefined && currentValue !== null
|
||||
? currentValue
|
||||
: defaultValue;
|
||||
const isDifferentFromDefault =
|
||||
effectiveCurrentValue !== defaultValue;
|
||||
|
||||
if (isDifferentFromDefault || isModified) {
|
||||
displayValue += '*';
|
||||
}
|
||||
} else {
|
||||
// For booleans and other types, use existing logic
|
||||
displayValue = getDisplayValue(
|
||||
item.value,
|
||||
scopeSettings,
|
||||
mergedSettings,
|
||||
modifiedSettings,
|
||||
pendingSettings,
|
||||
);
|
||||
const afterCursor = cpSlice(editBuffer, editCursorPos + 1);
|
||||
displayValue =
|
||||
beforeCursor + chalk.inverse(atCursor) + afterCursor;
|
||||
} else if (cursorVisible && editCursorPos >= cpLen(editBuffer)) {
|
||||
// Cursor is at the end - show inverted space
|
||||
displayValue = editBuffer + chalk.inverse(' ');
|
||||
} else {
|
||||
// Cursor not visible
|
||||
displayValue = editBuffer;
|
||||
}
|
||||
} else if (item.type === 'number' || item.type === 'string') {
|
||||
// For numbers/strings, get the actual current value from pending settings
|
||||
const path = item.value.split('.');
|
||||
const currentValue = getNestedValue(pendingSettings, path);
|
||||
const shouldBeGreyedOut = isDefaultValue(item.value, scopeSettings);
|
||||
|
||||
const defaultValue = getDefaultValue(item.value);
|
||||
|
||||
if (currentValue !== undefined && currentValue !== null) {
|
||||
displayValue = String(currentValue);
|
||||
} else {
|
||||
displayValue =
|
||||
defaultValue !== undefined && defaultValue !== null
|
||||
? String(defaultValue)
|
||||
: '';
|
||||
}
|
||||
|
||||
// Add * if value differs from default OR if currently being modified
|
||||
const isModified = modifiedSettings.has(item.value);
|
||||
const effectiveCurrentValue =
|
||||
currentValue !== undefined && currentValue !== null
|
||||
? currentValue
|
||||
: defaultValue;
|
||||
const isDifferentFromDefault =
|
||||
effectiveCurrentValue !== defaultValue;
|
||||
|
||||
if (isDifferentFromDefault || isModified) {
|
||||
displayValue += '*';
|
||||
}
|
||||
} else {
|
||||
// For booleans and other types, use existing logic
|
||||
displayValue = getDisplayValue(
|
||||
// Generate scope message for this setting
|
||||
const scopeMessage = getScopeMessageForSetting(
|
||||
item.value,
|
||||
scopeSettings,
|
||||
mergedSettings,
|
||||
modifiedSettings,
|
||||
pendingSettings,
|
||||
selectedScope,
|
||||
settings,
|
||||
);
|
||||
}
|
||||
const shouldBeGreyedOut = isDefaultValue(item.value, scopeSettings);
|
||||
|
||||
// Generate scope message for this setting
|
||||
const scopeMessage = getScopeMessageForSetting(
|
||||
item.value,
|
||||
selectedScope,
|
||||
settings,
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment key={item.value}>
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
return (
|
||||
<Box key={item.value} flexDirection="row" alignItems="center">
|
||||
<Box minWidth={2} flexShrink={0}>
|
||||
<Text
|
||||
color={
|
||||
|
|
@ -875,9 +841,10 @@ export function SettingsDialog({
|
|||
{isActive ? '●' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box minWidth={50}>
|
||||
<Box flexGrow={1} flexShrink={1}>
|
||||
<Text
|
||||
color={isActive ? theme.status.success : theme.text.primary}
|
||||
wrap="truncate"
|
||||
>
|
||||
{item.label}
|
||||
{scopeMessage && (
|
||||
|
|
@ -885,53 +852,47 @@ export function SettingsDialog({
|
|||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box minWidth={3} />
|
||||
<Text
|
||||
color={
|
||||
isActive
|
||||
? theme.status.success
|
||||
: shouldBeGreyedOut
|
||||
? theme.text.secondary
|
||||
: theme.text.primary
|
||||
}
|
||||
>
|
||||
{displayValue}
|
||||
</Text>
|
||||
<Box marginLeft={1} flexShrink={0}>
|
||||
<Text
|
||||
color={
|
||||
isActive
|
||||
? theme.status.success
|
||||
: shouldBeGreyedOut
|
||||
? theme.text.secondary
|
||||
: theme.text.primary
|
||||
}
|
||||
wrap="truncate"
|
||||
>
|
||||
{displayValue}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{showScrollDown && <Text color={theme.text.secondary}>▼</Text>}
|
||||
|
||||
<Box height={1} />
|
||||
|
||||
{/* Scope Selection - conditionally visible based on height constraints */}
|
||||
{showScopeSelection && (
|
||||
<Box marginTop={1}>
|
||||
<ScopeSelector
|
||||
onSelect={handleScopeSelect}
|
||||
onHighlight={handleScopeHighlight}
|
||||
isFocused={focusSection === 'scope'}
|
||||
initialScope={selectedScope}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box height={1} />
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('(Use Enter to select{{tabText}})', {
|
||||
tabText: showScopeSelection ? t(', Tab to change focus') : '',
|
||||
);
|
||||
})}
|
||||
{showScrollDown && <Text color={theme.text.secondary}>▼</Text>}
|
||||
</Box>
|
||||
) : (
|
||||
<ScopeSelector
|
||||
onSelect={handleScopeSelect}
|
||||
onHighlight={handleScopeHighlight}
|
||||
isFocused={mode === 'scope'}
|
||||
initialScope={selectedScope}
|
||||
/>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
{mode === 'settings'
|
||||
? t('(Use Enter to select, Tab to configure scope)')
|
||||
: t('(Use Enter to apply scope, Tab to go back)')}
|
||||
</Text>
|
||||
{showRestartPrompt && (
|
||||
<Text color={theme.status.warning}>
|
||||
{t(
|
||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.',
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{showRestartPrompt && (
|
||||
<Text color={theme.status.warning}>
|
||||
{t(
|
||||
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.',
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,11 +160,13 @@ const ModelUsageTable: React.FC<{
|
|||
interface StatsDisplayProps {
|
||||
duration: string;
|
||||
title?: string;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
duration,
|
||||
title,
|
||||
width,
|
||||
}) => {
|
||||
const { stats } = useSessionStats();
|
||||
const { metrics } = stats;
|
||||
|
|
@ -213,6 +215,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
|||
flexDirection="column"
|
||||
paddingY={1}
|
||||
paddingX={2}
|
||||
width={width}
|
||||
>
|
||||
{renderTitle()}
|
||||
<Box height={1} />
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export function SuggestionsDisplay({
|
|||
}: SuggestionsDisplayProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box paddingX={1} width={width}>
|
||||
<Box width={width}>
|
||||
<Text color="gray">Loading suggestions...</Text>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -70,7 +70,7 @@ export function SuggestionsDisplay({
|
|||
mode === 'slash' ? Math.min(maxLabelLength, Math.floor(width * 0.5)) : 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} width={width}>
|
||||
<Box flexDirection="column" width={width}>
|
||||
{scrollOffset > 0 && <Text color={theme.text.primary}>▲</Text>}
|
||||
|
||||
{visibleSuggestions.map((suggestion, index) => {
|
||||
|
|
|
|||
|
|
@ -258,7 +258,7 @@ def fibonacci(n):
|
|||
+ print(f"Hello, {name}!")
|
||||
`}
|
||||
availableTerminalHeight={diffHeight}
|
||||
terminalWidth={colorizeCodeWidth}
|
||||
contentWidth={colorizeCodeWidth}
|
||||
theme={previewTheme}
|
||||
/>
|
||||
</Box>
|
||||
|
|
@ -278,7 +278,7 @@ def fibonacci(n):
|
|||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
{mode === 'theme'
|
||||
? t('(Use Enter to select, Tab to configure scope)')
|
||||
: t('(Use Enter to apply scope, Tab to select theme)')}
|
||||
: t('(Use Enter to apply scope, Tab to go back)')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -4,42 +4,33 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { type Config } from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
interface TipsProps {
|
||||
config: Config;
|
||||
}
|
||||
const startupTips = [
|
||||
'Use /compress when the conversation gets long to summarize history and free up context.',
|
||||
'Start a fresh idea with /clear or /new; the previous session stays available in history.',
|
||||
'Use /bug to submit issues to the maintainers when something goes off.',
|
||||
'Switch auth type quickly with /auth.',
|
||||
'You can run any shell commands from Qwen Code using ! (e.g. !ls).',
|
||||
'Type / to open the command popup; Tab autocompletes slash commands and saved prompts.',
|
||||
'You can resume a previous conversation by running qwen --continue or qwen --resume.',
|
||||
'You can switch permission mode quickly with Shift+Tab or /approval-mode.',
|
||||
] as const;
|
||||
|
||||
export const Tips: React.FC = () => {
|
||||
const selectedTip = useMemo(() => {
|
||||
const randomIndex = Math.floor(Math.random() * startupTips.length);
|
||||
return startupTips[randomIndex];
|
||||
}, []);
|
||||
|
||||
export const Tips: React.FC<TipsProps> = ({ config }) => {
|
||||
const geminiMdFileCount = config.getGeminiMdFileCount();
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.primary}>{t('Tips for getting started:')}</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
{t('1. Ask questions, edit files, or run commands.')}
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
{t('2. Be specific for the best results.')}
|
||||
</Text>
|
||||
{geminiMdFileCount === 0 && (
|
||||
<Text color={theme.text.primary}>
|
||||
3. Create{' '}
|
||||
<Text bold color={theme.text.accent}>
|
||||
QWEN.md
|
||||
</Text>{' '}
|
||||
{t('files to customize your interactions with Qwen Code.')}
|
||||
</Text>
|
||||
)}
|
||||
<Text color={theme.text.primary}>
|
||||
{geminiMdFileCount === 0 ? '4.' : '3.'}{' '}
|
||||
<Text bold color={theme.text.accent}>
|
||||
/help
|
||||
</Text>{' '}
|
||||
{t('for more information.')}
|
||||
<Box marginLeft={2} marginRight={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Tips: ')}
|
||||
{t(selectedTip)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -53,7 +53,13 @@ const StatRow: React.FC<{
|
|||
);
|
||||
};
|
||||
|
||||
export const ToolStatsDisplay: React.FC = () => {
|
||||
interface ToolStatsDisplayProps {
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const ToolStatsDisplay: React.FC<ToolStatsDisplayProps> = ({
|
||||
width,
|
||||
}) => {
|
||||
const { stats } = useSessionStats();
|
||||
const { tools } = stats.metrics;
|
||||
const activeTools = Object.entries(tools.byName).filter(
|
||||
|
|
@ -67,6 +73,7 @@ export const ToolStatsDisplay: React.FC = () => {
|
|||
borderColor={theme.border.default}
|
||||
paddingY={1}
|
||||
paddingX={2}
|
||||
width={width}
|
||||
>
|
||||
<Text color={theme.text.primary}>
|
||||
{t('No tool calls have been made in this session.')}
|
||||
|
|
@ -101,7 +108,7 @@ export const ToolStatsDisplay: React.FC = () => {
|
|||
flexDirection="column"
|
||||
paddingY={1}
|
||||
paddingX={2}
|
||||
width={70}
|
||||
width={width}
|
||||
>
|
||||
<Text bold color={theme.text.accent}>
|
||||
{t('Tool Stats For Nerds')}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `"...s/to/make/it/long (main*) no sandbox gemini-pro (100%)"`;
|
||||
exports[`<Footer /> > footer rendering (golden snapshots) > renders complete footer on narrow terminal > complete-footer-narrow 1`] = `" ? for shortcuts 0.1% used"`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `"...directories/to/make/it/long (main*) no sandbox (see /docs) gemini-pro (100% context left)"`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `" no sandbox (see /docs)"`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `""`;
|
||||
|
||||
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `"...directories/to/make/it/long (main*) no sandbox (see /docs)"`;
|
||||
exports[`<Footer /> > footer rendering (golden snapshots) > renders complete footer on wide terminal > complete-footer-wide 1`] = `" ? for shortcuts 0.1% context used"`;
|
||||
|
|
|
|||
|
|
@ -1,137 +1,137 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<HistoryItemDisplay /> > should render a full gemini item when using availableTerminalHeightGemini 1`] = `
|
||||
"✦ Example code block:
|
||||
1 Line 1
|
||||
2 Line 2
|
||||
3 Line 3
|
||||
4 Line 4
|
||||
5 Line 5
|
||||
6 Line 6
|
||||
7 Line 7
|
||||
8 Line 8
|
||||
9 Line 9
|
||||
10 Line 10
|
||||
11 Line 11
|
||||
12 Line 12
|
||||
13 Line 13
|
||||
14 Line 14
|
||||
15 Line 15
|
||||
16 Line 16
|
||||
17 Line 17
|
||||
18 Line 18
|
||||
19 Line 19
|
||||
20 Line 20
|
||||
21 Line 21
|
||||
22 Line 22
|
||||
23 Line 23
|
||||
24 Line 24
|
||||
25 Line 25
|
||||
26 Line 26
|
||||
27 Line 27
|
||||
28 Line 28
|
||||
29 Line 29
|
||||
30 Line 30
|
||||
31 Line 31
|
||||
32 Line 32
|
||||
33 Line 33
|
||||
34 Line 34
|
||||
35 Line 35
|
||||
36 Line 36
|
||||
37 Line 37
|
||||
38 Line 38
|
||||
39 Line 39
|
||||
40 Line 40
|
||||
41 Line 41
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
" ✦ Example code block:
|
||||
1 Line 1
|
||||
2 Line 2
|
||||
3 Line 3
|
||||
4 Line 4
|
||||
5 Line 5
|
||||
6 Line 6
|
||||
7 Line 7
|
||||
8 Line 8
|
||||
9 Line 9
|
||||
10 Line 10
|
||||
11 Line 11
|
||||
12 Line 12
|
||||
13 Line 13
|
||||
14 Line 14
|
||||
15 Line 15
|
||||
16 Line 16
|
||||
17 Line 17
|
||||
18 Line 18
|
||||
19 Line 19
|
||||
20 Line 20
|
||||
21 Line 21
|
||||
22 Line 22
|
||||
23 Line 23
|
||||
24 Line 24
|
||||
25 Line 25
|
||||
26 Line 26
|
||||
27 Line 27
|
||||
28 Line 28
|
||||
29 Line 29
|
||||
30 Line 30
|
||||
31 Line 31
|
||||
32 Line 32
|
||||
33 Line 33
|
||||
34 Line 34
|
||||
35 Line 35
|
||||
36 Line 36
|
||||
37 Line 37
|
||||
38 Line 38
|
||||
39 Line 39
|
||||
40 Line 40
|
||||
41 Line 41
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > should render a full gemini_content item when using availableTerminalHeightGemini 1`] = `
|
||||
" Example code block:
|
||||
1 Line 1
|
||||
2 Line 2
|
||||
3 Line 3
|
||||
4 Line 4
|
||||
5 Line 5
|
||||
6 Line 6
|
||||
7 Line 7
|
||||
8 Line 8
|
||||
9 Line 9
|
||||
10 Line 10
|
||||
11 Line 11
|
||||
12 Line 12
|
||||
13 Line 13
|
||||
14 Line 14
|
||||
15 Line 15
|
||||
16 Line 16
|
||||
17 Line 17
|
||||
18 Line 18
|
||||
19 Line 19
|
||||
20 Line 20
|
||||
21 Line 21
|
||||
22 Line 22
|
||||
23 Line 23
|
||||
24 Line 24
|
||||
25 Line 25
|
||||
26 Line 26
|
||||
27 Line 27
|
||||
28 Line 28
|
||||
29 Line 29
|
||||
30 Line 30
|
||||
31 Line 31
|
||||
32 Line 32
|
||||
33 Line 33
|
||||
34 Line 34
|
||||
35 Line 35
|
||||
36 Line 36
|
||||
37 Line 37
|
||||
38 Line 38
|
||||
39 Line 39
|
||||
40 Line 40
|
||||
41 Line 41
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
" Example code block:
|
||||
1 Line 1
|
||||
2 Line 2
|
||||
3 Line 3
|
||||
4 Line 4
|
||||
5 Line 5
|
||||
6 Line 6
|
||||
7 Line 7
|
||||
8 Line 8
|
||||
9 Line 9
|
||||
10 Line 10
|
||||
11 Line 11
|
||||
12 Line 12
|
||||
13 Line 13
|
||||
14 Line 14
|
||||
15 Line 15
|
||||
16 Line 16
|
||||
17 Line 17
|
||||
18 Line 18
|
||||
19 Line 19
|
||||
20 Line 20
|
||||
21 Line 21
|
||||
22 Line 22
|
||||
23 Line 23
|
||||
24 Line 24
|
||||
25 Line 25
|
||||
26 Line 26
|
||||
27 Line 27
|
||||
28 Line 28
|
||||
29 Line 29
|
||||
30 Line 30
|
||||
31 Line 31
|
||||
32 Line 32
|
||||
33 Line 33
|
||||
34 Line 34
|
||||
35 Line 35
|
||||
36 Line 36
|
||||
37 Line 37
|
||||
38 Line 38
|
||||
39 Line 39
|
||||
40 Line 40
|
||||
41 Line 41
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > should render a truncated gemini item 1`] = `
|
||||
"✦ Example code block:
|
||||
... first 41 lines hidden ...
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
" ✦ Example code block:
|
||||
... first 41 lines hidden ...
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
`;
|
||||
|
||||
exports[`<HistoryItemDisplay /> > should render a truncated gemini_content item 1`] = `
|
||||
" Example code block:
|
||||
... first 41 lines hidden ...
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
" Example code block:
|
||||
... first 41 lines hidden ...
|
||||
42 Line 42
|
||||
43 Line 43
|
||||
44 Line 44
|
||||
45 Line 45
|
||||
46 Line 46
|
||||
47 Line 47
|
||||
48 Line 48
|
||||
49 Line 49
|
||||
50 Line 50"
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -20,38 +20,38 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c
|
|||
|
||||
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-collapsed-match 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
(r:) commit
|
||||
(r:) commit
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
git commit -m "feat: add search" in src/app"
|
||||
git commit -m "feat: add search" in src/app"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-expanded-match 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
(r:) commit
|
||||
(r:) commit
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
git commit -m "feat: add search" in src/app"
|
||||
git commit -m "feat: add search" in src/app"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
> Type your message or @path/to/file
|
||||
> Type your message or @path/to/file
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
! Type your message or @path/to/file
|
||||
! Type your message or @path/to/file
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
* Type your message or @path/to/file
|
||||
* Type your message or @path/to/file
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
> Type your message or @path/to/file
|
||||
> Type your message or @path/to/file
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -6,30 +6,17 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
|
|||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode false │
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ ● Tool Approval Mode Default │
|
||||
│ Language: UI Auto (detect from system) │
|
||||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode false │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ (Use Enter to select, Tab to configure scope) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
|
@ -40,30 +27,17 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
|
|||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode false │
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ ● Tool Approval Mode Default │
|
||||
│ Language: UI Auto (detect from system) │
|
||||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode false │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ (Use Enter to select, Tab to configure scope) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
|
@ -74,30 +48,17 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
|
|||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode true* │
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ ● Tool Approval Mode Default │
|
||||
│ Language: UI Auto (detect from system) │
|
||||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode true* │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ (Use Enter to select, Tab to configure scope) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
|
@ -108,30 +69,17 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
|
|||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode false* │
|
||||
│ │
|
||||
│ Disable Auto Update false* │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false* │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false* │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ ● Tool Approval Mode Default │
|
||||
│ Language: UI Auto (detect from system) │
|
||||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode false* │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false* │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ (Use Enter to select, Tab to configure scope) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
|
@ -142,30 +90,17 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
|
|||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode (Modified in System) false │
|
||||
│ │
|
||||
│ Disable Auto Update (Modified in System) false │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ ● Tool Approval Mode Default │
|
||||
│ Language: UI Auto (detect from system) │
|
||||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode (Modified in System) false │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ (Use Enter to select, Tab to configure scope) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
|
@ -176,30 +111,17 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
|
|||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode (Modified in Workspace) false │
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
│ Debug Keystroke Logging (Modified in Workspace) false │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ ● Tool Approval Mode Default │
|
||||
│ Language: UI Auto (detect from system) │
|
||||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode (Modified in Workspace) false │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ (Use Enter to select, Tab to configure scope) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
|
@ -210,30 +132,17 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
|
|||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode false │
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ ● Tool Approval Mode Default │
|
||||
│ Language: UI Auto (detect from system) │
|
||||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode false │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ (Use Enter to select, Tab to configure scope) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
|
@ -244,30 +153,17 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
|
|||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode false* │
|
||||
│ │
|
||||
│ Disable Auto Update true* │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false* │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ ● Tool Approval Mode Default │
|
||||
│ Language: UI Auto (detect from system) │
|
||||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode false* │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ (Use Enter to select, Tab to configure scope) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
|
@ -278,30 +174,17 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
|
|||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode false │
|
||||
│ │
|
||||
│ Disable Auto Update false │
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ ● Tool Approval Mode Default │
|
||||
│ Language: UI Auto (detect from system) │
|
||||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode false │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ (Use Enter to select, Tab to configure scope) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
|
@ -312,30 +195,17 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
|
|||
│ > Settings │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Vim Mode true* │
|
||||
│ │
|
||||
│ Disable Auto Update true* │
|
||||
│ │
|
||||
│ Debug Keystroke Logging true* │
|
||||
│ │
|
||||
│ Language Auto (detect from system) │
|
||||
│ │
|
||||
│ Terminal Bell true │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title true* │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ ● Tool Approval Mode Default │
|
||||
│ Language: UI Auto (detect from system) │
|
||||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode true* │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE true* │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
│ ● User Settings │
|
||||
│ Workspace Settings │
|
||||
│ │
|
||||
│ (Use Enter to select, Tab to change focus) │
|
||||
│ (Use Enter to select, Tab to configure scope) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ exports[`ThemeDialog Snapshots > should render correctly in scope selector mode
|
|||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ > Apply To │
|
||||
│ │
|
||||
│ ● 1. User Settings │
|
||||
│ 2. Workspace Settings │
|
||||
│ │
|
||||
│ (Use Enter to apply scope, Tab to select theme) │
|
||||
│ (Use Enter to apply scope, Tab to go back) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -1,85 +1,85 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<ToolStatsDisplay /> > should display stats for a single tool correctly 1`] = `
|
||||
"╭────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Tool Stats For Nerds │
|
||||
│ │
|
||||
│ Tool Name Calls Success Rate Avg Duration │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ test-tool 1 100.0% 100ms │
|
||||
│ │
|
||||
│ User Decision Summary │
|
||||
│ Total Reviewed Suggestions: 1 │
|
||||
│ » Accepted: 1 │
|
||||
│ » Rejected: 0 │
|
||||
│ » Modified: 0 │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ Overall Agreement Rate: 100.0% │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────╯"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Tool Stats For Nerds │
|
||||
│ │
|
||||
│ Tool Name Calls Success Rate Avg Duration │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ test-tool 1 100.0% 100ms │
|
||||
│ │
|
||||
│ User Decision Summary │
|
||||
│ Total Reviewed Suggestions: 1 │
|
||||
│ » Accepted: 1 │
|
||||
│ » Rejected: 0 │
|
||||
│ » Modified: 0 │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ Overall Agreement Rate: 100.0% │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolStatsDisplay /> > should display stats for multiple tools correctly 1`] = `
|
||||
"╭────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Tool Stats For Nerds │
|
||||
│ │
|
||||
│ Tool Name Calls Success Rate Avg Duration │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ tool-a 2 50.0% 100ms │
|
||||
│ tool-b 1 100.0% 100ms │
|
||||
│ │
|
||||
│ User Decision Summary │
|
||||
│ Total Reviewed Suggestions: 3 │
|
||||
│ » Accepted: 1 │
|
||||
│ » Rejected: 1 │
|
||||
│ » Modified: 1 │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ Overall Agreement Rate: 33.3% │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────╯"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Tool Stats For Nerds │
|
||||
│ │
|
||||
│ Tool Name Calls Success Rate Avg Duration │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ tool-a 2 50.0% 100ms │
|
||||
│ tool-b 1 100.0% 100ms │
|
||||
│ │
|
||||
│ User Decision Summary │
|
||||
│ Total Reviewed Suggestions: 3 │
|
||||
│ » Accepted: 1 │
|
||||
│ » Rejected: 1 │
|
||||
│ » Modified: 1 │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ Overall Agreement Rate: 33.3% │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolStatsDisplay /> > should handle large values without wrapping or overlapping 1`] = `
|
||||
"╭────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Tool Stats For Nerds │
|
||||
│ │
|
||||
│ Tool Name Calls Success Rate Avg Duration │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ long-named-tool-for-testi99999999 88.9% 1ms │
|
||||
│ ng-wrapping-and-such 9 │
|
||||
│ │
|
||||
│ User Decision Summary │
|
||||
│ Total Reviewed Suggestions: 222234566 │
|
||||
│ » Accepted: 123456789 │
|
||||
│ » Rejected: 98765432 │
|
||||
│ » Modified: 12345 │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ Overall Agreement Rate: 55.6% │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────╯"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Tool Stats For Nerds │
|
||||
│ │
|
||||
│ Tool Name Calls Success Rate Avg Duration │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ long-named-tool-for-testi99999999 88.9% 1ms │
|
||||
│ ng-wrapping-and-such 9 │
|
||||
│ │
|
||||
│ User Decision Summary │
|
||||
│ Total Reviewed Suggestions: 222234566 │
|
||||
│ » Accepted: 123456789 │
|
||||
│ » Rejected: 98765432 │
|
||||
│ » Modified: 12345 │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ Overall Agreement Rate: 55.6% │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolStatsDisplay /> > should handle zero decisions gracefully 1`] = `
|
||||
"╭────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Tool Stats For Nerds │
|
||||
│ │
|
||||
│ Tool Name Calls Success Rate Avg Duration │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ test-tool 1 100.0% 100ms │
|
||||
│ │
|
||||
│ User Decision Summary │
|
||||
│ Total Reviewed Suggestions: 0 │
|
||||
│ » Accepted: 0 │
|
||||
│ » Rejected: 0 │
|
||||
│ » Modified: 0 │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ Overall Agreement Rate: -- │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────╯"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Tool Stats For Nerds │
|
||||
│ │
|
||||
│ Tool Name Calls Success Rate Avg Duration │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ test-tool 1 100.0% 100ms │
|
||||
│ │
|
||||
│ User Decision Summary │
|
||||
│ Total Reviewed Suggestions: 0 │
|
||||
│ » Accepted: 0 │
|
||||
│ » Rejected: 0 │
|
||||
│ » Modified: 0 │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ Overall Agreement Rate: -- │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolStatsDisplay /> > should render "no tool calls" message when there are no active tools 1`] = `
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ index 0000000..e69de29
|
|||
<DiffRenderer
|
||||
diffContent={newFileDiffContent}
|
||||
filename="test.py"
|
||||
terminalWidth={80}
|
||||
contentWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
|
@ -63,7 +63,7 @@ index 0000000..e69de29
|
|||
<DiffRenderer
|
||||
diffContent={newFileDiffContent}
|
||||
filename="test.unknown"
|
||||
terminalWidth={80}
|
||||
contentWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
|
@ -88,7 +88,7 @@ index 0000000..e69de29
|
|||
`;
|
||||
render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer diffContent={newFileDiffContent} terminalWidth={80} />
|
||||
<DiffRenderer diffContent={newFileDiffContent} contentWidth={80} />
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(mockColorizeCode).toHaveBeenCalledWith(
|
||||
|
|
@ -115,7 +115,7 @@ index 0000001..0000002 100644
|
|||
<DiffRenderer
|
||||
diffContent={existingFileDiffContent}
|
||||
filename="test.txt"
|
||||
terminalWidth={80}
|
||||
contentWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
|
@ -145,7 +145,7 @@ index 1234567..1234567 100644
|
|||
<DiffRenderer
|
||||
diffContent={noChangeDiff}
|
||||
filename="file.txt"
|
||||
terminalWidth={80}
|
||||
contentWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
|
@ -156,7 +156,7 @@ index 1234567..1234567 100644
|
|||
it('should handle empty diff content', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer diffContent="" terminalWidth={80} />
|
||||
<DiffRenderer diffContent="" contentWidth={80} />
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(lastFrame()).toContain('No diff content');
|
||||
|
|
@ -182,7 +182,7 @@ index 123..456 100644
|
|||
<DiffRenderer
|
||||
diffContent={diffWithGap}
|
||||
filename="file.txt"
|
||||
terminalWidth={80}
|
||||
contentWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
|
@ -219,7 +219,7 @@ index abc..def 100644
|
|||
<DiffRenderer
|
||||
diffContent={diffWithSmallGap}
|
||||
filename="file.txt"
|
||||
terminalWidth={80}
|
||||
contentWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
|
@ -291,7 +291,7 @@ index 123..789 100644
|
|||
<DiffRenderer
|
||||
diffContent={diffWithMultipleHunks}
|
||||
filename="multi.js"
|
||||
terminalWidth={terminalWidth}
|
||||
contentWidth={terminalWidth}
|
||||
availableTerminalHeight={height}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
|
|
@ -323,7 +323,7 @@ fileDiff Index: file.txt
|
|||
<DiffRenderer
|
||||
diffContent={newFileDiff}
|
||||
filename="TEST"
|
||||
terminalWidth={80}
|
||||
contentWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
|
@ -353,7 +353,7 @@ fileDiff Index: Dockerfile
|
|||
<DiffRenderer
|
||||
diffContent={newFileDiff}
|
||||
filename="Dockerfile"
|
||||
terminalWidth={80}
|
||||
contentWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ interface DiffRendererProps {
|
|||
filename?: string;
|
||||
tabWidth?: number;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
contentWidth: number;
|
||||
theme?: Theme;
|
||||
}
|
||||
|
||||
|
|
@ -95,7 +95,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
|||
filename,
|
||||
tabWidth = DEFAULT_TAB_WIDTH,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
contentWidth,
|
||||
theme,
|
||||
}) => {
|
||||
const screenReaderEnabled = useIsScreenReaderEnabled();
|
||||
|
|
@ -155,7 +155,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
|||
addedContent,
|
||||
language,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
contentWidth,
|
||||
theme,
|
||||
);
|
||||
} else {
|
||||
|
|
@ -164,7 +164,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
|||
filename,
|
||||
tabWidth,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
contentWidth,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -176,7 +176,7 @@ const renderDiffContent = (
|
|||
filename: string | undefined,
|
||||
tabWidth = DEFAULT_TAB_WIDTH,
|
||||
availableTerminalHeight: number | undefined,
|
||||
terminalWidth: number,
|
||||
contentWidth: number,
|
||||
) => {
|
||||
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing
|
||||
const normalizedLines = parsedLines.map((line) => ({
|
||||
|
|
@ -238,7 +238,7 @@ const renderDiffContent = (
|
|||
return (
|
||||
<MaxSizedBox
|
||||
maxHeight={availableTerminalHeight}
|
||||
maxWidth={terminalWidth}
|
||||
maxWidth={contentWidth}
|
||||
key={key}
|
||||
>
|
||||
{displayableLines.reduce<React.ReactNode[]>((acc, line, index) => {
|
||||
|
|
@ -260,7 +260,7 @@ const renderDiffContent = (
|
|||
acc.push(
|
||||
<Box key={`gap-${index}`}>
|
||||
<Text wrap="truncate" color={semanticTheme.text.secondary}>
|
||||
{'═'.repeat(terminalWidth)}
|
||||
{'═'.repeat(contentWidth)}
|
||||
</Text>
|
||||
</Box>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const ErrorMessage: React.FC<ErrorMessageProps> = ({ text }) => {
|
|||
const prefixWidth = prefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={theme.status.error}>{prefix}</Text>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -14,14 +14,14 @@ interface GeminiMessageProps {
|
|||
text: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
contentWidth: number;
|
||||
}
|
||||
|
||||
export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
text,
|
||||
isPending,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
contentWidth,
|
||||
}) => {
|
||||
const prefix = '✦ ';
|
||||
const prefixWidth = prefix.length;
|
||||
|
|
@ -38,7 +38,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
|||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
contentWidth={contentWidth - prefixWidth}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ interface GeminiMessageContentProps {
|
|||
text: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
contentWidth: number;
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
@ -25,7 +25,7 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
|||
text,
|
||||
isPending,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
contentWidth,
|
||||
}) => {
|
||||
const originalPrefix = '✦ ';
|
||||
const prefixWidth = originalPrefix.length;
|
||||
|
|
@ -36,7 +36,7 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
|||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
contentWidth={contentWidth - prefixWidth}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ interface GeminiThoughtMessageProps {
|
|||
text: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
contentWidth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -24,13 +24,13 @@ export const GeminiThoughtMessage: React.FC<GeminiThoughtMessageProps> = ({
|
|||
text,
|
||||
isPending,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
contentWidth,
|
||||
}) => {
|
||||
const prefix = '✦ ';
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={theme.text.secondary}>{prefix}</Text>
|
||||
</Box>
|
||||
|
|
@ -39,7 +39,7 @@ export const GeminiThoughtMessage: React.FC<GeminiThoughtMessageProps> = ({
|
|||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
contentWidth={contentWidth - prefixWidth}
|
||||
textColor={theme.text.secondary}
|
||||
/>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ interface GeminiThoughtMessageContentProps {
|
|||
text: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
contentWidth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -22,17 +22,17 @@ interface GeminiThoughtMessageContentProps {
|
|||
*/
|
||||
export const GeminiThoughtMessageContent: React.FC<
|
||||
GeminiThoughtMessageContentProps
|
||||
> = ({ text, isPending, availableTerminalHeight, terminalWidth }) => {
|
||||
> = ({ text, isPending, availableTerminalHeight, contentWidth }) => {
|
||||
const originalPrefix = '✦ ';
|
||||
const prefixWidth = originalPrefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={prefixWidth} marginBottom={1}>
|
||||
<Box flexDirection="column" paddingLeft={prefixWidth}>
|
||||
<MarkdownDisplay
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
contentWidth={contentWidth - prefixWidth}
|
||||
textColor={theme.text.secondary}
|
||||
/>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({ text }) => {
|
|||
const prefixWidth = prefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={theme.status.warning}>{prefix}</Text>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ describe('ToolConfirmationMessage', () => {
|
|||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
contentWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ describe('ToolConfirmationMessage', () => {
|
|||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
contentWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
|
@ -81,7 +81,7 @@ describe('ToolConfirmationMessage', () => {
|
|||
confirmationDetails={confirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
contentWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
|
@ -162,7 +162,7 @@ describe('ToolConfirmationMessage', () => {
|
|||
confirmationDetails={details}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
contentWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
|
@ -180,7 +180,7 @@ describe('ToolConfirmationMessage', () => {
|
|||
confirmationDetails={details}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
contentWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
|
@ -212,7 +212,7 @@ describe('ToolConfirmationMessage', () => {
|
|||
confirmationDetails={editConfirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
contentWidth={80}
|
||||
/>,
|
||||
{
|
||||
settings: {
|
||||
|
|
@ -235,7 +235,7 @@ describe('ToolConfirmationMessage', () => {
|
|||
confirmationDetails={editConfirmationDetails}
|
||||
config={mockConfig}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
contentWidth={80}
|
||||
/>,
|
||||
{
|
||||
settings: {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export interface ToolConfirmationMessageProps {
|
|||
config: Config;
|
||||
isFocused?: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
contentWidth: number;
|
||||
compactMode?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -42,11 +42,10 @@ export const ToolConfirmationMessage: React.FC<
|
|||
config,
|
||||
isFocused = true,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
contentWidth,
|
||||
compactMode = false,
|
||||
}) => {
|
||||
const { onConfirm } = confirmationDetails;
|
||||
const childWidth = terminalWidth - 2; // 2 for padding
|
||||
|
||||
const settings = useSettings();
|
||||
const preferredEditor = settings.merged.general?.preferredEditor as
|
||||
|
|
@ -226,7 +225,7 @@ export const ToolConfirmationMessage: React.FC<
|
|||
diffContent={confirmationDetails.fileDiff}
|
||||
filename={confirmationDetails.fileName}
|
||||
availableTerminalHeight={availableBodyContentHeight()}
|
||||
terminalWidth={childWidth}
|
||||
contentWidth={contentWidth}
|
||||
/>
|
||||
);
|
||||
} else if (confirmationDetails.type === 'exec') {
|
||||
|
|
@ -263,7 +262,7 @@ export const ToolConfirmationMessage: React.FC<
|
|||
<Box paddingX={1} marginLeft={1}>
|
||||
<MaxSizedBox
|
||||
maxHeight={bodyContentHeight}
|
||||
maxWidth={Math.max(childWidth - 4, 1)}
|
||||
maxWidth={Math.max(contentWidth, 1)}
|
||||
>
|
||||
<Box>
|
||||
<Text color={theme.text.link}>{executionProps.command}</Text>
|
||||
|
|
@ -298,7 +297,7 @@ export const ToolConfirmationMessage: React.FC<
|
|||
text={planProps.plan}
|
||||
isPending={false}
|
||||
availableTerminalHeight={availableBodyContentHeight()}
|
||||
terminalWidth={childWidth}
|
||||
contentWidth={contentWidth}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -397,7 +396,7 @@ export const ToolConfirmationMessage: React.FC<
|
|||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1} width={childWidth}>
|
||||
<Box flexDirection="column" padding={1} width={contentWidth}>
|
||||
{/* Body Content (Diff Renderer or Command Info) */}
|
||||
{/* No separate context display here anymore for edits */}
|
||||
<Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}>
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ describe('<ToolGroupMessage />', () => {
|
|||
|
||||
const baseProps = {
|
||||
groupId: 1,
|
||||
terminalWidth: 80,
|
||||
contentWidth: 80,
|
||||
isFocused: true,
|
||||
};
|
||||
|
||||
|
|
@ -244,7 +244,7 @@ describe('<ToolGroupMessage />', () => {
|
|||
<ToolGroupMessage
|
||||
{...baseProps}
|
||||
toolCalls={toolCalls}
|
||||
terminalWidth={40}
|
||||
contentWidth={40}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ interface ToolGroupMessageProps {
|
|||
groupId: number;
|
||||
toolCalls: IndividualToolCallDisplay[];
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
contentWidth: number;
|
||||
isFocused?: boolean;
|
||||
activeShellPtyId?: number | null;
|
||||
embeddedShellFocused?: boolean;
|
||||
|
|
@ -30,7 +30,7 @@ interface ToolGroupMessageProps {
|
|||
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
toolCalls,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
contentWidth,
|
||||
isFocused = true,
|
||||
activeShellPtyId,
|
||||
embeddedShellFocused,
|
||||
|
|
@ -58,9 +58,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
|||
: theme.border.default;
|
||||
|
||||
const staticHeight = /* border */ 2 + /* marginBottom */ 1;
|
||||
// This is a bit of a magic number, but it accounts for the border and
|
||||
// marginLeft.
|
||||
const innerWidth = terminalWidth - 4;
|
||||
// account for border (2 chars) and padding (2 chars)
|
||||
const innerWidth = contentWidth - 4;
|
||||
|
||||
// only prompt for tool approval on the first 'confirming' tool in the list
|
||||
// note, after the CTA, this automatically moves over to the next 'confirming' tool
|
||||
|
|
@ -96,8 +95,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
|||
Ink to render the border of the box incorrectly and span multiple lines and even
|
||||
cause tearing.
|
||||
*/
|
||||
width="100%"
|
||||
marginLeft={1}
|
||||
width={contentWidth}
|
||||
borderDimColor={
|
||||
hasPending && (!isShellCommand || !isEmbeddedShellFocused)
|
||||
}
|
||||
|
|
@ -112,7 +110,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
|||
<ToolMessage
|
||||
{...tool}
|
||||
availableTerminalHeight={availableTerminalHeightPerToolMessage}
|
||||
terminalWidth={innerWidth}
|
||||
contentWidth={innerWidth}
|
||||
emphasis={
|
||||
isConfirming
|
||||
? 'high'
|
||||
|
|
@ -135,7 +133,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
|||
availableTerminalHeight={
|
||||
availableTerminalHeightPerToolMessage
|
||||
}
|
||||
terminalWidth={innerWidth}
|
||||
contentWidth={innerWidth}
|
||||
/>
|
||||
)}
|
||||
{tool.outputFile && (
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ describe('<ToolMessage />', () => {
|
|||
description: 'A tool for testing',
|
||||
resultDisplay: 'Test result',
|
||||
status: ToolCallStatus.Success,
|
||||
terminalWidth: 80,
|
||||
contentWidth: 80,
|
||||
confirmationDetails: undefined,
|
||||
emphasis: 'medium',
|
||||
config: mockConfig,
|
||||
|
|
@ -241,7 +241,7 @@ describe('<ToolMessage />', () => {
|
|||
description: 'Delegate task to subagent',
|
||||
resultDisplay: subagentResultDisplay,
|
||||
status: ToolCallStatus.Executing,
|
||||
terminalWidth: 80,
|
||||
contentWidth: 80,
|
||||
callId: 'test-call-id-2',
|
||||
confirmationDetails: undefined,
|
||||
config: mockConfig,
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ const StringResultRenderer: React.FC<{
|
|||
text={displayData}
|
||||
isPending={false}
|
||||
availableTerminalHeight={availableHeight}
|
||||
terminalWidth={childWidth}
|
||||
contentWidth={childWidth}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
|
@ -215,13 +215,13 @@ const DiffResultRenderer: React.FC<{
|
|||
diffContent={data.fileDiff}
|
||||
filename={data.fileName}
|
||||
availableTerminalHeight={availableHeight}
|
||||
terminalWidth={childWidth}
|
||||
contentWidth={childWidth}
|
||||
/>
|
||||
);
|
||||
|
||||
export interface ToolMessageProps extends IndividualToolCallDisplay {
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
contentWidth: number;
|
||||
emphasis?: TextEmphasis;
|
||||
renderOutputAsMarkdown?: boolean;
|
||||
activeShellPtyId?: number | null;
|
||||
|
|
@ -235,7 +235,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
|||
resultDisplay,
|
||||
status,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
contentWidth,
|
||||
emphasis = 'medium',
|
||||
renderOutputAsMarkdown = true,
|
||||
activeShellPtyId,
|
||||
|
|
@ -291,6 +291,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
|||
MIN_LINES_SHOWN + 1, // enforce minimum lines shown
|
||||
)
|
||||
: undefined;
|
||||
const innerWidth = contentWidth - STATUS_INDICATOR_WIDTH;
|
||||
|
||||
// Long tool call response in MarkdownDisplay doesn't respect availableTerminalHeight properly,
|
||||
// we're forcing it to not render as markdown when the response is too long, it will fallback
|
||||
|
|
@ -299,8 +300,6 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
|||
renderOutputAsMarkdown = false;
|
||||
}
|
||||
|
||||
const childWidth = terminalWidth - 3; // account for padding.
|
||||
|
||||
// Use the custom hook to determine the display type
|
||||
const displayRenderer = useResultDisplayRenderer(resultDisplay);
|
||||
|
||||
|
|
@ -333,14 +332,14 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
|||
<PlanResultRenderer
|
||||
data={displayRenderer.data}
|
||||
availableHeight={availableHeight}
|
||||
childWidth={childWidth}
|
||||
childWidth={innerWidth}
|
||||
/>
|
||||
)}
|
||||
{displayRenderer.type === 'task' && config && (
|
||||
<SubagentExecutionRenderer
|
||||
data={displayRenderer.data}
|
||||
availableHeight={availableHeight}
|
||||
childWidth={childWidth}
|
||||
childWidth={innerWidth}
|
||||
config={config}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -348,7 +347,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
|||
<DiffResultRenderer
|
||||
data={displayRenderer.data}
|
||||
availableHeight={availableHeight}
|
||||
childWidth={childWidth}
|
||||
childWidth={innerWidth}
|
||||
/>
|
||||
)}
|
||||
{displayRenderer.type === 'ansi' && (
|
||||
|
|
@ -362,7 +361,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
|||
data={displayRenderer.data}
|
||||
renderAsMarkdown={renderOutputAsMarkdown}
|
||||
availableHeight={availableHeight}
|
||||
childWidth={childWidth}
|
||||
childWidth={innerWidth}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export const WarningMessage: React.FC<WarningMessageProps> = ({ text }) => {
|
|||
const prefixWidth = 3;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={Colors.AccentYellow}>{prefix}</Text>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -1,105 +1,108 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<ToolGroupMessage /> > Border Color Logic > uses gray border when all tools are successful and no shell commands 1`] = `
|
||||
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium) │
|
||||
│ │
|
||||
│MockTool[tool-2]: ✓ another-tool - A tool for testing (medium) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium) │
|
||||
│ │
|
||||
│MockTool[tool-2]: ✓ another-tool - A tool for testing (medium) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border for shell commands even when successful 1`] = `
|
||||
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-123]: ✓ run_shell_command - A tool for testing (medium) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-123]: ✓ run_shell_command - A tool for testing (medium) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border when tools are pending 1`] = `
|
||||
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-123]: o test-tool - A tool for testing (medium) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-123]: o test-tool - A tool for testing (medium) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Confirmation Handling > shows confirmation dialog for first confirming tool only 1`] = `
|
||||
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-1]: ? first-confirm - A tool for testing (high) │
|
||||
│MockConfirmation: Confirm first tool │
|
||||
│ │
|
||||
│MockTool[tool-2]: ? second-confirm - A tool for testing (low) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-1]: ? first-confirm - A tool for testing (high) │
|
||||
│MockConfirmation: Confirm first tool │
|
||||
│ │
|
||||
│MockTool[tool-2]: ? second-confirm - A tool for testing (low) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls array 1`] = `
|
||||
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including shell command 1`] = `
|
||||
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-1]: ✓ read_file - Read a file (medium) │
|
||||
│ │
|
||||
│MockTool[tool-2]: ⊷ run_shell_command - Run command (medium) │
|
||||
│ │
|
||||
│MockTool[tool-3]: o write_file - Write to file (medium) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-1]: ✓ read_file - Read a file (medium) │
|
||||
│ │
|
||||
│MockTool[tool-2]: ⊷ run_shell_command - Run command (medium) │
|
||||
│ │
|
||||
│MockTool[tool-3]: o write_file - Write to file (medium) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders multiple tool calls with different statuses 1`] = `
|
||||
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-1]: ✓ successful-tool - This tool succeeded (medium) │
|
||||
│ │
|
||||
│MockTool[tool-2]: o pending-tool - This tool is pending (medium) │
|
||||
│ │
|
||||
│MockTool[tool-3]: x error-tool - This tool failed (medium) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-1]: ✓ successful-tool - This tool succeeded (medium) │
|
||||
│ │
|
||||
│MockTool[tool-2]: o pending-tool - This tool is pending (medium) │
|
||||
│ │
|
||||
│MockTool[tool-3]: x error-tool - This tool failed (medium) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders shell command with yellow border 1`] = `
|
||||
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[shell-1]: ✓ run_shell_command - Execute shell command (medium) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[shell-1]: ✓ run_shell_command - Execute shell command (medium) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders single successful tool call 1`] = `
|
||||
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call awaiting confirmation 1`] = `
|
||||
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-confirm]: ? confirmation-tool - This tool needs confirmation (high) │
|
||||
│MockConfirmation: Are you sure you want to proceed? │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-confirm]: ? confirmation-tool - This tool needs confirmation │
|
||||
│(high) │
|
||||
│MockConfirmation: Are you sure you want to proceed? │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders when not focused 1`] = `
|
||||
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-123]: ✓ test-tool - A tool for testing (medium) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal height 1`] = `
|
||||
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-1]: ✓ tool-with-result - Tool with output (medium) │
|
||||
│ │
|
||||
│MockTool[tool-2]: ✓ another-tool - Another tool (medium) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-1]: ✓ tool-with-result - Tool with output (medium) │
|
||||
│ │
|
||||
│MockTool[tool-2]: ✓ another-tool - Another tool (medium) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with narrow terminal width 1`] = `
|
||||
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-123]: ✓ very-long-tool-name-that-might-wrap - This is a very long description that │
|
||||
│might cause wrapping issues (medium) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭──────────────────────────────────────╮
|
||||
│MockTool[tool-123]: ✓ │
|
||||
│very-long-tool-name-that-might-wrap - │
|
||||
│This is a very long description that │
|
||||
│might cause wrapping issues (medium) │
|
||||
╰──────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Height Calculation > calculates available height correctly with multiple tools with results 1`] = `
|
||||
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-1]: ✓ test-tool - A tool for testing (medium) │
|
||||
│ │
|
||||
│MockTool[tool-2]: ✓ test-tool - A tool for testing (medium) │
|
||||
│ │
|
||||
│MockTool[tool-3]: ✓ test-tool - A tool for testing (medium) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│MockTool[tool-1]: ✓ test-tool - A tool for testing (medium) │
|
||||
│ │
|
||||
│MockTool[tool-2]: ✓ test-tool - A tool for testing (medium) │
|
||||
│ │
|
||||
│MockTool[tool-3]: ✓ test-tool - A tool for testing (medium) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export function ScopeSelector({
|
|||
{isFocused ? '> ' : ' '}
|
||||
{t('Apply To')}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<RadioButtonSelect
|
||||
items={scopeItems}
|
||||
initialIndex={safeInitialIndex}
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
|
|||
confirmationDetails={data.pendingConfirmation}
|
||||
isFocused={true}
|
||||
availableTerminalHeight={availableHeight}
|
||||
terminalWidth={childWidth}
|
||||
contentWidth={childWidth - 4}
|
||||
compactMode={true}
|
||||
config={config}
|
||||
/>
|
||||
|
|
@ -242,7 +242,7 @@ export const AgentExecutionDisplay: React.FC<AgentExecutionDisplayProps> = ({
|
|||
config={config}
|
||||
isFocused={true}
|
||||
availableTerminalHeight={availableHeight}
|
||||
terminalWidth={childWidth}
|
||||
contentWidth={childWidth - 4}
|
||||
compactMode={true}
|
||||
/>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -33,11 +33,7 @@ const mockTools: ToolDefinition[] = [
|
|||
describe('<ToolsList />', () => {
|
||||
it('renders correctly with descriptions', () => {
|
||||
const { lastFrame } = render(
|
||||
<ToolsList
|
||||
tools={mockTools}
|
||||
showDescriptions={true}
|
||||
terminalWidth={40}
|
||||
/>,
|
||||
<ToolsList tools={mockTools} showDescriptions={true} contentWidth={40} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
|
@ -47,7 +43,7 @@ describe('<ToolsList />', () => {
|
|||
<ToolsList
|
||||
tools={mockTools}
|
||||
showDescriptions={false}
|
||||
terminalWidth={40}
|
||||
contentWidth={40}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
|
|
@ -55,7 +51,7 @@ describe('<ToolsList />', () => {
|
|||
|
||||
it('renders correctly with no tools', () => {
|
||||
const { lastFrame } = render(
|
||||
<ToolsList tools={[]} showDescriptions={true} terminalWidth={40} />,
|
||||
<ToolsList tools={[]} showDescriptions={true} contentWidth={40} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,15 +14,15 @@ import { t } from '../../../i18n/index.js';
|
|||
interface ToolsListProps {
|
||||
tools: readonly ToolDefinition[];
|
||||
showDescriptions: boolean;
|
||||
terminalWidth: number;
|
||||
contentWidth: number;
|
||||
}
|
||||
|
||||
export const ToolsList: React.FC<ToolsListProps> = ({
|
||||
tools,
|
||||
showDescriptions,
|
||||
terminalWidth,
|
||||
contentWidth,
|
||||
}) => (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={theme.text.primary}>
|
||||
{t('Available Qwen Code CLI tools:')}
|
||||
</Text>
|
||||
|
|
@ -38,7 +38,7 @@ export const ToolsList: React.FC<ToolsListProps> = ({
|
|||
</Text>
|
||||
{showDescriptions && tool.description && (
|
||||
<MarkdownDisplay
|
||||
terminalWidth={terminalWidth}
|
||||
contentWidth={contentWidth}
|
||||
text={tool.description}
|
||||
isPending={false}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -11,15 +11,13 @@ exports[`<ToolsList /> > renders correctly with descriptions 1`] = `
|
|||
2. note use this tool wisely and be sure to consider how this tool interacts with word wrap.
|
||||
3. important this tool is awesome.
|
||||
- Test Tool Three (test-tool-three)
|
||||
This is the third test tool.
|
||||
"
|
||||
This is the third test tool."
|
||||
`;
|
||||
|
||||
exports[`<ToolsList /> > renders correctly with no tools 1`] = `
|
||||
"Available Qwen Code CLI tools:
|
||||
|
||||
No tools available
|
||||
"
|
||||
No tools available"
|
||||
`;
|
||||
|
||||
exports[`<ToolsList /> > renders correctly without descriptions 1`] = `
|
||||
|
|
@ -27,6 +25,5 @@ exports[`<ToolsList /> > renders correctly without descriptions 1`] = `
|
|||
|
||||
- Test Tool One
|
||||
- Test Tool Two
|
||||
- Test Tool Three
|
||||
"
|
||||
- Test Tool Three"
|
||||
`;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue