Merge branch 'main' into fix/shift-tab-windows-powershell

This commit is contained in:
LaZzyMan 2026-02-02 19:51:32 +08:00
commit 86ba86e297
352 changed files with 38309 additions and 6463 deletions

View file

@ -434,7 +434,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Check for enforced auth type mismatch
useEffect(() => {
// Check for initialization error first
const currentAuthType = config.modelsConfig.getCurrentAuthType();
const currentAuthType = config.getModelsConfig().getCurrentAuthType();
if (
settings.merged.security?.auth?.enforcedType &&
@ -623,7 +623,7 @@ export const AppContainer = (props: AppContainerProps) => {
try {
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
process.cwd(),
settings.merged.context?.loadMemoryFromIncludeDirectories
settings.merged.context?.loadFromIncludeDirectories
? config.getWorkspaceContext().getDirectories()
: [],
config.getDebugMode(),
@ -1350,6 +1350,7 @@ export const AppContainer = (props: AppContainerProps) => {
isFeedbackDialogOpen,
openFeedbackDialog,
closeFeedbackDialog,
temporaryCloseFeedbackDialog,
submitFeedback,
} = useFeedbackDialog({
config,
@ -1597,6 +1598,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
temporaryCloseFeedbackDialog,
submitFeedback,
}),
[
@ -1637,6 +1639,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
temporaryCloseFeedbackDialog,
submitFeedback,
],
);

View file

@ -5,19 +5,21 @@ import { useUIActions } from './contexts/UIActionsContext.js';
import { useUIState } from './contexts/UIStateContext.js';
import { useKeypress } from './hooks/useKeypress.js';
const FEEDBACK_OPTIONS = {
export const FEEDBACK_OPTIONS = {
GOOD: 1,
BAD: 2,
NOT_SURE: 3,
FINE: 3,
DISMISS: 0,
} as const;
const FEEDBACK_OPTION_KEYS = {
[FEEDBACK_OPTIONS.GOOD]: '1',
[FEEDBACK_OPTIONS.BAD]: '2',
[FEEDBACK_OPTIONS.NOT_SURE]: 'any',
[FEEDBACK_OPTIONS.FINE]: '3',
[FEEDBACK_OPTIONS.DISMISS]: '0',
} as const;
export const FEEDBACK_DIALOG_KEYS = ['1', '2'] as const;
export const FEEDBACK_DIALOG_KEYS = ['1', '2', '3', '0'] as const;
export const FeedbackDialog: React.FC = () => {
const uiState = useUIState();
@ -25,15 +27,19 @@ export const FeedbackDialog: React.FC = () => {
useKeypress(
(key) => {
if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.GOOD]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.GOOD);
} else if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.BAD]) {
// Handle keys 0-3: permanent close with feedback/dismiss
if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.BAD]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.BAD);
} else if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.FINE]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.FINE);
} else if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.GOOD]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.GOOD);
} else if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.DISMISS]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.DISMISS);
} else {
uiActions.submitFeedback(FEEDBACK_OPTIONS.NOT_SURE);
// Handle other keys: temporary close
uiActions.temporaryCloseFeedbackDialog();
}
uiActions.closeFeedbackDialog();
},
{ isActive: uiState.isFeedbackDialogOpen },
);
@ -53,8 +59,16 @@ export const FeedbackDialog: React.FC = () => {
<Text color="cyan">{FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.BAD]}: </Text>
<Text>{t('Bad')}</Text>
<Text> </Text>
<Text color="cyan">{t('Any other key')}: </Text>
<Text>{t('Not Sure Yet')}</Text>
<Text color="cyan">
{FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.FINE]}:{' '}
</Text>
<Text>{t('Fine')}</Text>
<Text> </Text>
<Text color="cyan">
{FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.DISMISS]}:{' '}
</Text>
<Text>{t('Dismiss')}</Text>
<Text> </Text>
</Box>
</Box>
);

View file

@ -21,6 +21,8 @@ vi.mock('../../i18n/index.js', () => ({
en: 'English',
ru: 'Russian',
de: 'German',
ja: 'Japanese',
pt: 'Portuguese',
};
return map[locale] || 'English';
}),
@ -72,6 +74,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
// Import modules after mocking
import * as i18n from '../../i18n/index.js';
import { SUPPORTED_LANGUAGES } from '../../i18n/languages.js';
import { languageCommand } from './languageCommand.js';
import { initializeLlmOutputLanguage } from '../../utils/languageUtils.js';
@ -565,10 +568,9 @@ describe('languageCommand', () => {
it('should have nested language subcommands', () => {
const nestedNames = uiSubcommand?.subCommands?.map((c) => c.name);
expect(nestedNames).toContain('zh-CN');
expect(nestedNames).toContain('en-US');
expect(nestedNames).toContain('ru-RU');
expect(nestedNames).toContain('de-DE');
for (const lang of SUPPORTED_LANGUAGES) {
expect(nestedNames).toContain(lang.id);
}
});
it('should have action that sets language', async () => {
@ -678,6 +680,24 @@ describe('languageCommand', () => {
});
});
const jaJPSubcommand = uiSubcommand?.subCommands?.find(
(c) => c.name === 'ja-JP',
);
it('ja-JP action should set Japanese', async () => {
if (!jaJPSubcommand?.action) {
throw new Error('ja-JP subcommand must have an action.');
}
const result = await jaJPSubcommand.action(mockContext, '');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('ja');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should reject extra arguments', async () => {
if (!zhCNSubcommand?.action) {
throw new Error('zh-CN subcommand must have an action.');
@ -798,5 +818,31 @@ describe('languageCommand', () => {
'utf-8',
);
});
it('should detect Japanese locale and create Japanese rule file', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('ja');
initializeLlmOutputLanguage();
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('output-language.md'),
expect.stringContaining('Japanese'),
'utf-8',
);
});
it('should detect Portuguese locale and create Portuguese rule file', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('pt');
initializeLlmOutputLanguage();
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('output-language.md'),
expect.stringContaining('Portuguese'),
'utf-8',
);
});
});
});

View file

@ -18,7 +18,10 @@ import {
type SupportedLanguage,
t,
} from '../../i18n/index.js';
import { SUPPORTED_LANGUAGES } from '../../i18n/languages.js';
import {
SUPPORTED_LANGUAGES,
getSupportedLanguageIds,
} from '../../i18n/languages.js';
import {
OUTPUT_LANGUAGE_AUTO,
isAutoLanguage,
@ -62,11 +65,14 @@ function parseUiLanguageArg(input: string): SupportedLanguage | null {
}
/**
* Formats a UI language code for display (e.g., "zh" -> "Chinesezh-CN").
* Formats a UI language code for display (e.g., "zh" -> "中文 (Chinese) [zh-CN]").
*/
function formatUiLanguageDisplay(lang: SupportedLanguage): string {
const option = SUPPORTED_LANGUAGES.find((o) => o.code === lang);
return option ? `${option.fullName}${option.id}` : lang;
if (!option) return lang;
return option.nativeName && option.nativeName !== option.fullName
? `${option.nativeName} (${option.fullName}) [${option.id}]`
: `${option.fullName} [${option.id}]`;
}
/**
@ -219,7 +225,7 @@ export const languageCommand: SlashCommand = {
messageType: 'error',
content: [
t('Invalid command. Available subcommands:'),
` - /language ui [${SUPPORTED_LANGUAGES.map((o) => o.id).join('|')}] - ${t('Set UI language')}`,
` - /language ui [${getSupportedLanguageIds()}] - ${t('Set UI language')}`,
` - /language output <language> - ${t('Set LLM output language')}`,
].join('\n'),
};
@ -245,7 +251,7 @@ export const languageCommand: SlashCommand = {
t('Current LLM output language: {{lang}}', { lang: outputLangDisplay }),
'',
t('Available subcommands:'),
` /language ui [${SUPPORTED_LANGUAGES.map((o) => o.id).join('|')}] - ${t('Set UI language')}`,
` /language ui [${getSupportedLanguageIds()}] - ${t('Set UI language')}`,
` /language output <language> - ${t('Set LLM output language')}`,
].join('\n'),
};
@ -274,12 +280,12 @@ export const languageCommand: SlashCommand = {
t('Set UI language'),
'',
t('Usage: /language ui [{{options}}]', {
options: SUPPORTED_LANGUAGES.map((o) => o.id).join('|'),
options: getSupportedLanguageIds(),
}),
'',
t('Available options:'),
...SUPPORTED_LANGUAGES.map(
(o) => ` - ${o.id}: ${t(o.fullName)}`,
(o) => ` - ${o.id}: ${o.nativeName || o.fullName}`,
),
'',
t(
@ -295,7 +301,7 @@ export const languageCommand: SlashCommand = {
type: 'message',
messageType: 'error',
content: t('Invalid language. Available: {{options}}', {
options: SUPPORTED_LANGUAGES.map((o) => o.id).join(','),
options: getSupportedLanguageIds(','),
}),
};
}
@ -308,7 +314,9 @@ export const languageCommand: SlashCommand = {
(lang): SlashCommand => ({
name: lang.id,
get description() {
return t('Set UI language to {{name}}', { name: lang.fullName });
return t('Set UI language to {{name}}', {
name: lang.nativeName || lang.fullName,
});
},
kind: CommandKind.BUILT_IN,
action: async (context, args) => {

View file

@ -6,22 +6,21 @@
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { tokenLimit } from '@qwen-code/qwen-code-core';
export const ContextUsageDisplay = ({
promptTokenCount,
model,
terminalWidth,
contextWindowSize,
}: {
promptTokenCount: number;
model: string;
terminalWidth: number;
contextWindowSize: number;
}) => {
if (promptTokenCount === 0) {
return null;
}
const percentage = promptTokenCount / tokenLimit(model);
const percentage = promptTokenCount / contextWindowSize;
const percentageUsed = (percentage * 100).toFixed(1);
const label = terminalWidth < 100 ? '% used' : '% context used';

View file

@ -23,6 +23,7 @@ const defaultProps = {
const createMockConfig = (overrides = {}) => ({
getModel: vi.fn(() => defaultProps.model),
getDebugMode: vi.fn(() => false),
getContentGeneratorConfig: vi.fn(() => ({ contextWindowSize: 131072 })),
getMcpServers: vi.fn(() => ({})),
getBlockedMcpServers: vi.fn(() => []),
...overrides,

View file

@ -26,13 +26,11 @@ export const Footer: React.FC = () => {
const { vimEnabled, vimMode } = useVimMode();
const {
model,
errorCount,
showErrorDetails,
promptTokenCount,
showAutoAcceptIndicator,
} = {
model: config.getModel(),
errorCount: uiState.errorCount,
showErrorDetails: uiState.showErrorDetails,
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
@ -57,6 +55,9 @@ export const Footer: React.FC = () => {
// Check if debug mode is enabled
const debugMode = config.getDebugMode();
const contextWindowSize =
config.getContentGeneratorConfig()?.contextWindowSize;
// 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>
@ -88,15 +89,15 @@ export const Footer: React.FC = () => {
node: <Text color={theme.status.warning}>Debug Mode</Text>,
});
}
if (promptTokenCount > 0) {
if (promptTokenCount > 0 && contextWindowSize) {
rightItems.push({
key: 'context',
node: (
<Text color={theme.text.accent}>
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
model={model}
terminalWidth={terminalWidth}
contextWindowSize={contextWindowSize}
/>
</Text>
),

View file

@ -36,6 +36,11 @@ vi.mock('../utils/clipboardUtils.js');
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: vi.fn(() => ({ isFeedbackDialogOpen: false })),
}));
vi.mock('../contexts/UIActionsContext.js', () => ({
useUIActions: vi.fn(() => ({
temporaryCloseFeedbackDialog: vi.fn(),
})),
}));
const mockSlashCommands: SlashCommand[] = [
{
@ -376,7 +381,7 @@ describe('InputPrompt', () => {
it('should handle Ctrl+V when clipboard has an image', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
'/test/.gemini-clipboard/clipboard-123.png',
'/test/.qwen-clipboard/clipboard-123.png',
);
const { stdin, unmount } = renderWithProviders(
@ -436,7 +441,7 @@ describe('InputPrompt', () => {
it('should insert image path at cursor position with proper spacing', async () => {
const imagePath = path.join(
'test',
'.gemini-clipboard',
'.qwen-clipboard',
'clipboard-456.png',
);
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);

View file

@ -37,6 +37,7 @@ import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
export interface InputPromptProps {
buffer: TextBuffer;
@ -109,6 +110,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}) => {
const isShellFocused = useShellFocusState();
const uiState = useUIState();
const uiActions = useUIActions();
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@ -337,12 +339,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
// Intercept feedback dialog option keys (1, 2) when dialog is open
if (
uiState.isFeedbackDialogOpen &&
(FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)
) {
return;
// Handle feedback dialog keyboard interactions when dialog is open
if (uiState.isFeedbackDialogOpen) {
// If it's one of the feedback option keys (1-4), let FeedbackDialog handle it
if ((FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)) {
return;
} else {
// For any other key, close feedback dialog temporarily and continue with normal processing
uiActions.temporaryCloseFeedbackDialog();
// Continue processing the key for normal input handling
}
}
// Reset ESC count and hide prompt on any non-ESC key
@ -712,6 +718,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onToggleShortcuts,
showShortcuts,
uiState,
uiActions,
],
);

View file

@ -1368,7 +1368,7 @@ describe('SettingsDialog', () => {
enabled: true,
},
context: {
loadMemoryFromIncludeDirectories: true,
loadFromIncludeDirectories: true,
fileFiltering: {
respectGitIgnore: true,
respectQwenIgnore: true,
@ -1540,7 +1540,7 @@ describe('SettingsDialog', () => {
enableRecursiveFileSearch: false,
disableFuzzySearch: true,
},
loadMemoryFromIncludeDirectories: true,
loadFromIncludeDirectories: true,
},
});
const onSelect = vi.fn();
@ -1605,7 +1605,7 @@ describe('SettingsDialog', () => {
enabled: false,
},
context: {
loadMemoryFromIncludeDirectories: false,
loadFromIncludeDirectories: false,
fileFiltering: {
respectGitIgnore: false,
respectQwenIgnore: false,

View file

@ -71,6 +71,7 @@ export interface UIActions {
// Feedback dialog
openFeedbackDialog: () => void;
closeFeedbackDialog: () => void;
temporaryCloseFeedbackDialog: () => void;
submitFeedback: (rating: number) => void;
}

View file

@ -15,6 +15,7 @@ import {
USER_SETTINGS_PATH,
} from '../../config/settings.js';
import type { SessionStatsState } from '../contexts/SessionContext.js';
import { FEEDBACK_OPTIONS } from '../FeedbackDialog.js';
import stripJsonComments from 'strip-json-comments';
const FEEDBACK_SHOW_PROBABILITY = 0.25; // 25% probability of showing feedback dialog
@ -96,37 +97,48 @@ export const useFeedbackDialog = ({
}: UseFeedbackDialogProps) => {
// Feedback dialog state
const [isFeedbackDialogOpen, setIsFeedbackDialogOpen] = useState(false);
const [isFeedbackDismissedTemporarily, setIsFeedbackDismissedTemporarily] =
useState(false);
const openFeedbackDialog = useCallback(() => {
setIsFeedbackDialogOpen(true);
// Record the timestamp when feedback dialog is shown (fire and forget)
settings.setValue(
SettingScope.User,
'ui.feedbackLastShownTimestamp',
Date.now(),
);
}, [settings]);
}, []);
const closeFeedbackDialog = useCallback(
() => setIsFeedbackDialogOpen(false),
[],
);
const temporaryCloseFeedbackDialog = useCallback(() => {
setIsFeedbackDialogOpen(false);
setIsFeedbackDismissedTemporarily(true);
}, []);
const submitFeedback = useCallback(
(rating: number) => {
// Create and log the feedback event
const feedbackEvent = new UserFeedbackEvent(
sessionStats.sessionId,
rating as UserFeedbackRating,
config.getModel(),
config.getApprovalMode(),
// Only create and log feedback event for ratings 1-3 (GOOD, BAD, FINE)
// Rating 0 (DISMISS) should not trigger any telemetry
if (rating >= FEEDBACK_OPTIONS.GOOD && rating <= FEEDBACK_OPTIONS.FINE) {
const feedbackEvent = new UserFeedbackEvent(
sessionStats.sessionId,
rating as UserFeedbackRating,
config.getModel(),
config.getApprovalMode(),
);
logUserFeedback(config, feedbackEvent);
}
// Record the timestamp when feedback dialog is submitted
settings.setValue(
SettingScope.User,
'ui.feedbackLastShownTimestamp',
Date.now(),
);
logUserFeedback(config, feedbackEvent);
closeFeedbackDialog();
},
[config, sessionStats, closeFeedbackDialog],
[closeFeedbackDialog, sessionStats.sessionId, config, settings],
);
useEffect(() => {
@ -140,13 +152,15 @@ export const useFeedbackDialog = ({
// 5. Random chance (25% probability)
// 6. Meets minimum requirements (tool calls > 10 OR user messages > 5)
// 7. Fatigue mechanism allows showing (not shown recently across sessions)
// 8. Not temporarily dismissed
if (
config.getAuthType() !== AuthType.QWEN_OAUTH ||
!config.getUsageStatisticsEnabled() ||
settings.merged.ui?.enableUserFeedback === false ||
!lastMessageIsAIResponse(history) ||
Math.random() > FEEDBACK_SHOW_PROBABILITY ||
!meetsMinimumSessionRequirements(sessionStats)
!meetsMinimumSessionRequirements(sessionStats) ||
isFeedbackDismissedTemporarily
) {
return;
}
@ -164,15 +178,27 @@ export const useFeedbackDialog = ({
history,
sessionStats,
isFeedbackDialogOpen,
isFeedbackDismissedTemporarily,
openFeedbackDialog,
settings.merged.ui?.enableUserFeedback,
config,
]);
// Reset temporary dismissal when a new AI response starts streaming
useEffect(() => {
if (
streamingState === StreamingState.Responding &&
isFeedbackDismissedTemporarily
) {
setIsFeedbackDismissedTemporarily(false);
}
}, [streamingState, isFeedbackDismissedTemporarily]);
return {
isFeedbackDialogOpen,
openFeedbackDialog,
closeFeedbackDialog,
temporaryCloseFeedbackDialog,
submitFeedback,
};
};

View file

@ -52,7 +52,7 @@ export async function saveClipboardImage(
// Create a temporary directory for clipboard images within the target directory
// This avoids security restrictions on paths outside the target directory
const baseDir = targetDir || process.cwd();
const tempDir = path.join(baseDir, '.gemini-clipboard');
const tempDir = path.join(baseDir, '.qwen-clipboard');
await fs.mkdir(tempDir, { recursive: true });
// Generate a unique filename with timestamp
@ -130,7 +130,7 @@ export async function cleanupOldClipboardImages(
): Promise<void> {
try {
const baseDir = targetDir || process.cwd();
const tempDir = path.join(baseDir, '.gemini-clipboard');
const tempDir = path.join(baseDir, '.qwen-clipboard');
const files = await fs.readdir(tempDir);
const oneHourAgo = Date.now() - 60 * 60 * 1000;