Merge branch 'main' into feat/multimodal-input-support

This commit is contained in:
tanzhenxin 2026-01-21 19:49:31 +08:00
commit 2ec3ec2c38
159 changed files with 4391 additions and 3303 deletions

View file

@ -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>
))}

View 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');
});
});

View file

@ -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>
);
};

View file

@ -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>

View file

@ -5,29 +5,10 @@
*/
export const shortAsciiLogo = `
`;
export const longAsciiLogo = `
`;
export const tinyAsciiLogo = `
`;

View file

@ -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');
});
});

View file

@ -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>
);
};

View file

@ -44,7 +44,7 @@ describe('ConsentPrompt', () => {
{
isPending: true,
text: prompt,
terminalWidth,
contentWidth: terminalWidth,
},
undefined,
);

View file

@ -32,7 +32,7 @@ export const ConsentPrompt = (props: ConsentPromptProps) => {
<MarkdownDisplay
isPending={true}
text={prompt}
terminalWidth={terminalWidth}
contentWidth={terminalWidth}
/>
) : (
prompt

View file

@ -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>
);
};

View file

@ -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

View file

@ -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'

View file

@ -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');
});

View file

@ -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>
);
};

View file

@ -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('╯');
});
});

View file

@ -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}>
&gt;_ 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>
);
};

View file

@ -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}>

View file

@ -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', () => {

View file

@ -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}
/>

View file

@ -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 =
@ -714,10 +749,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
statusColor = theme.ui.symbol;
statusText = t('Shell mode');
} else if (showYoloStyling) {
statusColor = theme.status.error;
statusColor = theme.status.errorDim;
statusText = t('YOLO mode');
} else if (showAutoAcceptStyling) {
statusColor = theme.status.warning;
statusColor = theme.status.warningDim;
statusText = t('Accepting edits');
}
@ -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}

View 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>
);
};

View file

@ -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}

View file

@ -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')}

View file

@ -34,7 +34,7 @@ export const PlanSummaryDisplay: React.FC<PlanSummaryDisplayProps> = ({
text={plan}
isPending={false}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
contentWidth={childWidth}
/>
</Box>
);

View file

@ -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}
/>

View file

@ -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 */}

View file

@ -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}>

View file

@ -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: {

View file

@ -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>
);
}

View file

@ -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} />

View file

@ -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) => {

View file

@ -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>

View file

@ -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>
);

View file

@ -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')}

View file

@ -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"`;

View file

@ -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"
`;

View file

@ -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
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;

View 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) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View file

@ -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)
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View file

@ -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`] = `

View file

@ -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>,
);

View file

@ -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>,
);

View file

@ -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>

View file

@ -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>

View file

@ -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>
);

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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: {

View file

@ -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}>

View file

@ -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();

View file

@ -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 && (

View file

@ -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,

View file

@ -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>

View file

@ -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>

View file

@ -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) │
──────────────────────────────────────────────────────────────────────────────╯"
`;

View file

@ -45,6 +45,7 @@ export function ScopeSelector({
{isFocused ? '> ' : ' '}
{t('Apply To')}
</Text>
<Box height={1} />
<RadioButtonSelect
items={scopeItems}
initialIndex={safeInitialIndex}

View file

@ -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>

View file

@ -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();
});

View file

@ -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}
/>

View file

@ -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"
`;