Merge branch 'main' into docs-byYijing

This commit is contained in:
pomelo-nwu
2025-12-15 19:42:20 +08:00
310 changed files with 46867 additions and 2127 deletions

View File

@@ -89,7 +89,6 @@ import { useSessionStats } from './contexts/SessionContext.js';
import { useGitBranchName } from './hooks/useGitBranchName.js';
import { useExtensionUpdates } from './hooks/useExtensionUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { useQuitConfirmation } from './hooks/useQuitConfirmation.js';
import { t } from '../i18n/index.js';
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
import { useDialogClose } from './hooks/useDialogClose.js';
@@ -137,7 +136,6 @@ export const AppContainer = (props: AppContainerProps) => {
const { settings, config, initializationResult } = props;
const historyManager = useHistory();
useMemoryMonitor(historyManager);
const [corgiMode, setCorgiMode] = useState(false);
const [debugMessage, setDebugMessage] = useState<string>('');
const [quittingMessages, setQuittingMessages] = useState<
HistoryItem[] | null
@@ -446,8 +444,6 @@ export const AppContainer = (props: AppContainerProps) => {
const { toggleVimEnabled } = useVimMode();
const { showQuitConfirmation } = useQuitConfirmation();
const {
isSubagentCreateDialogOpen,
openSubagentCreateDialog,
@@ -488,12 +484,10 @@ export const AppContainer = (props: AppContainerProps) => {
}, 100);
},
setDebugMessage,
toggleCorgiMode: () => setCorgiMode((prev) => !prev),
dispatchExtensionStateUpdate,
addConfirmUpdateExtensionRequest,
openSubagentCreateDialog,
openAgentsManagerDialog,
_showQuitConfirmation: showQuitConfirmation,
}),
[
openAuthDialog,
@@ -502,12 +496,10 @@ export const AppContainer = (props: AppContainerProps) => {
openSettingsDialog,
openModelDialog,
setDebugMessage,
setCorgiMode,
dispatchExtensionStateUpdate,
openPermissionsDialog,
openApprovalModeDialog,
addConfirmUpdateExtensionRequest,
showQuitConfirmation,
openSubagentCreateDialog,
openAgentsManagerDialog,
],
@@ -520,7 +512,6 @@ export const AppContainer = (props: AppContainerProps) => {
commandContext,
shellConfirmationRequest,
confirmationRequest,
quitConfirmationRequest,
} = useSlashCommandProcessor(
config,
settings,
@@ -951,6 +942,7 @@ export const AppContainer = (props: AppContainerProps) => {
isFocused,
streamingState,
elapsedTime,
settings,
});
// Dialog close functionality
@@ -969,7 +961,6 @@ export const AppContainer = (props: AppContainerProps) => {
isFolderTrustDialogOpen,
showWelcomeBackDialog,
handleWelcomeBackClose,
quitConfirmationRequest,
});
const handleExit = useCallback(
@@ -983,25 +974,18 @@ export const AppContainer = (props: AppContainerProps) => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
// Exit directly without showing confirmation dialog
// Exit directly
handleSlashCommand('/quit');
return;
}
// First press: Prioritize cleanup tasks
// Special case: If quit-confirm dialog is open, Ctrl+C means "quit immediately"
if (quitConfirmationRequest) {
handleSlashCommand('/quit');
return;
}
// 1. Close other dialogs (highest priority)
/**
* For AuthDialog it is required to complete the authentication process,
* otherwise user cannot proceed to the next step.
* So a quit on AuthDialog should go with normal two press quit
* and without quit-confirm dialog.
* So a quit on AuthDialog should go with normal two press quit.
*/
if (isAuthDialogOpen) {
setPressedOnce(true);
@@ -1022,14 +1006,17 @@ export const AppContainer = (props: AppContainerProps) => {
return; // Request cancelled, end processing
}
// 3. Clear input buffer (if has content)
// 4. Clear input buffer (if has content)
if (buffer.text.length > 0) {
buffer.setText('');
return; // Input cleared, end processing
}
// All cleanup tasks completed, show quit confirmation dialog
handleSlashCommand('/quit-confirm');
// All cleanup tasks completed, set flag for double-press to quit
setPressedOnce(true);
timerRef.current = setTimeout(() => {
setPressedOnce(false);
}, CTRL_EXIT_PROMPT_DURATION_MS);
},
[
isAuthDialogOpen,
@@ -1037,7 +1024,6 @@ export const AppContainer = (props: AppContainerProps) => {
closeAnyOpenDialog,
streamingState,
cancelOngoingRequest,
quitConfirmationRequest,
buffer,
],
);
@@ -1054,8 +1040,8 @@ export const AppContainer = (props: AppContainerProps) => {
return;
}
// On first press: set flag, start timer, and call handleExit for cleanup/quit-confirm
// On second press (within 500ms): handleExit sees flag and does fast quit
// On first press: set flag, start timer, and call handleExit for cleanup
// On second press (within timeout): handleExit sees flag and does fast quit
if (!ctrlCPressedOnce) {
setCtrlCPressedOnce(true);
ctrlCTimerRef.current = setTimeout(() => {
@@ -1196,7 +1182,6 @@ export const AppContainer = (props: AppContainerProps) => {
!!confirmationRequest ||
confirmUpdateExtensionRequests.length > 0 ||
!!loopDetectionConfirmationRequest ||
!!quitConfirmationRequest ||
isThemeDialogOpen ||
isSettingsDialogOpen ||
isModelDialogOpen ||
@@ -1231,7 +1216,6 @@ export const AppContainer = (props: AppContainerProps) => {
qwenAuthState,
editorError,
isEditorDialogOpen,
corgiMode,
debugMessage,
quittingMessages,
isSettingsDialogOpen,
@@ -1245,7 +1229,6 @@ export const AppContainer = (props: AppContainerProps) => {
confirmationRequest,
confirmUpdateExtensionRequests,
loopDetectionConfirmationRequest,
quitConfirmationRequest,
geminiMdFileCount,
streamingState,
initError,
@@ -1323,7 +1306,6 @@ export const AppContainer = (props: AppContainerProps) => {
qwenAuthState,
editorError,
isEditorDialogOpen,
corgiMode,
debugMessage,
quittingMessages,
isSettingsDialogOpen,
@@ -1337,7 +1319,6 @@ export const AppContainer = (props: AppContainerProps) => {
confirmationRequest,
confirmUpdateExtensionRequests,
loopDetectionConfirmationRequest,
quitConfirmationRequest,
geminiMdFileCount,
streamingState,
initError,

View File

@@ -1,34 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { corgiCommand } from './corgiCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('corgiCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
vi.spyOn(mockContext.ui, 'toggleCorgiMode');
});
it('should call the toggleCorgiMode function on the UI context', async () => {
if (!corgiCommand.action) {
throw new Error('The corgi command must have an action.');
}
await corgiCommand.action(mockContext, '');
expect(mockContext.ui.toggleCorgiMode).toHaveBeenCalledTimes(1);
});
it('should have the correct name and description', () => {
expect(corgiCommand.name).toBe('corgi');
expect(corgiCommand.description).toBe('Toggles corgi mode.');
});
});

View File

@@ -1,17 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, type SlashCommand } from './types.js';
export const corgiCommand: SlashCommand = {
name: 'corgi',
description: 'Toggles corgi mode.',
hidden: true,
kind: CommandKind.BUILT_IN,
action: (context, _args) => {
context.ui.toggleCorgiMode();
},
};

View File

@@ -0,0 +1,600 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import { type CommandContext, CommandKind } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
// Mock i18n module
vi.mock('../../i18n/index.js', () => ({
setLanguageAsync: vi.fn().mockResolvedValue(undefined),
getCurrentLanguage: vi.fn().mockReturnValue('en'),
t: vi.fn((key: string) => key),
}));
// Mock settings module to avoid Storage side effect
vi.mock('../../config/settings.js', () => ({
SettingScope: {
User: 'user',
Workspace: 'workspace',
Default: 'default',
},
}));
// Mock fs module
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
default: {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
},
};
});
// Mock Storage from core
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
Storage: {
getGlobalQwenDir: vi.fn().mockReturnValue('/mock/.qwen'),
getGlobalSettingsPath: vi
.fn()
.mockReturnValue('/mock/.qwen/settings.json'),
},
};
});
// Import modules after mocking
import * as i18n from '../../i18n/index.js';
import { languageCommand } from './languageCommand.js';
describe('languageCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
vi.clearAllMocks();
mockContext = createMockCommandContext({
services: {
config: {
getModel: vi.fn().mockReturnValue('test-model'),
},
settings: {
merged: {},
setValue: vi.fn(),
},
},
});
// Reset i18n mocks
vi.mocked(i18n.getCurrentLanguage).mockReturnValue('en');
vi.mocked(i18n.t).mockImplementation((key: string) => key);
// Reset fs mocks
vi.mocked(fs.existsSync).mockReturnValue(false);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('command metadata', () => {
it('should have the correct name', () => {
expect(languageCommand.name).toBe('language');
});
it('should have a description', () => {
expect(languageCommand.description).toBeDefined();
expect(typeof languageCommand.description).toBe('string');
});
it('should be a built-in command', () => {
expect(languageCommand.kind).toBe(CommandKind.BUILT_IN);
});
it('should have subcommands', () => {
expect(languageCommand.subCommands).toBeDefined();
expect(languageCommand.subCommands?.length).toBe(2);
});
it('should have ui and output subcommands', () => {
const subCommandNames = languageCommand.subCommands?.map((c) => c.name);
expect(subCommandNames).toContain('ui');
expect(subCommandNames).toContain('output');
});
});
describe('main command action - no arguments', () => {
it('should show current language settings when no arguments provided', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Current UI language:'),
});
});
it('should show available subcommands in help', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('/language ui'),
});
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('/language output'),
});
});
it('should show LLM output language when set', async () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
'# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY',
);
// Make t() function handle interpolation for this test
vi.mocked(i18n.t).mockImplementation(
(key: string, params?: Record<string, string>) => {
if (params && key.includes('{{lang}}')) {
return key.replace('{{lang}}', params['lang'] || '');
}
return key;
},
);
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Current UI language:'),
});
// Verify it correctly parses "Chinese" from the template format
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Chinese'),
});
});
});
describe('main command action - config not available', () => {
it('should return error when config is null', async () => {
mockContext.services.config = null;
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Configuration not available'),
});
});
});
describe('/language ui subcommand', () => {
it('should show help when no language argument provided', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Usage: /language ui'),
});
});
it('should set English with "en"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui en');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(mockContext.services.settings.setValue).toHaveBeenCalled();
expect(mockContext.ui.reloadCommands).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set English with "en-US"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui en-US');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set English with "english"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui english');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set Chinese with "zh"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui zh');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set Chinese with "zh-CN"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui zh-CN');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set Chinese with "chinese"', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui chinese');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should return error for invalid language', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'ui invalid');
expect(i18n.setLanguageAsync).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Invalid language'),
});
});
it('should persist setting to user scope', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
await languageCommand.action(mockContext, 'ui en');
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
expect.anything(), // SettingScope.User
'general.language',
'en',
);
});
});
describe('/language output subcommand', () => {
it('should show help when no language argument provided', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'output');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('Usage: /language output'),
});
});
it('should create LLM output language rule file', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(
mockContext,
'output Chinese',
);
expect(fs.mkdirSync).toHaveBeenCalled();
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('output-language.md'),
expect.stringContaining('Chinese'),
'utf-8',
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining(
'LLM output language rule file generated',
),
});
});
it('should include restart notice in success message', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(
mockContext,
'output Japanese',
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('restart'),
});
});
it('should handle file write errors gracefully', async () => {
vi.mocked(fs.writeFileSync).mockImplementation(() => {
throw new Error('Permission denied');
});
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'output German');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Failed to generate'),
});
});
});
describe('backward compatibility - direct language arguments', () => {
it('should set Chinese with direct "zh" argument', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'zh');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should set English with direct "en" argument', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'en');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should return error for unknown direct argument', async () => {
if (!languageCommand.action) {
throw new Error('The language command must have an action.');
}
const result = await languageCommand.action(mockContext, 'unknown');
expect(i18n.setLanguageAsync).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Invalid command'),
});
});
});
describe('ui subcommand object', () => {
const uiSubcommand = languageCommand.subCommands?.find(
(c) => c.name === 'ui',
);
it('should have correct metadata', () => {
expect(uiSubcommand).toBeDefined();
expect(uiSubcommand?.name).toBe('ui');
expect(uiSubcommand?.kind).toBe(CommandKind.BUILT_IN);
});
it('should have nested language subcommands', () => {
const nestedNames = uiSubcommand?.subCommands?.map((c) => c.name);
expect(nestedNames).toContain('zh-CN');
expect(nestedNames).toContain('en-US');
});
it('should have action that sets language', async () => {
if (!uiSubcommand?.action) {
throw new Error('UI subcommand must have an action.');
}
const result = await uiSubcommand.action(mockContext, 'en');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
});
describe('output subcommand object', () => {
const outputSubcommand = languageCommand.subCommands?.find(
(c) => c.name === 'output',
);
it('should have correct metadata', () => {
expect(outputSubcommand).toBeDefined();
expect(outputSubcommand?.name).toBe('output');
expect(outputSubcommand?.kind).toBe(CommandKind.BUILT_IN);
});
it('should have action that generates rule file', async () => {
if (!outputSubcommand?.action) {
throw new Error('Output subcommand must have an action.');
}
// Ensure mocks are properly set for this test
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
const result = await outputSubcommand.action(mockContext, 'French');
expect(fs.writeFileSync).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining(
'LLM output language rule file generated',
),
});
});
});
describe('nested ui language subcommands', () => {
const uiSubcommand = languageCommand.subCommands?.find(
(c) => c.name === 'ui',
);
const zhCNSubcommand = uiSubcommand?.subCommands?.find(
(c) => c.name === 'zh-CN',
);
const enUSSubcommand = uiSubcommand?.subCommands?.find(
(c) => c.name === 'en-US',
);
it('zh-CN should have aliases', () => {
expect(zhCNSubcommand?.altNames).toContain('zh');
expect(zhCNSubcommand?.altNames).toContain('chinese');
});
it('en-US should have aliases', () => {
expect(enUSSubcommand?.altNames).toContain('en');
expect(enUSSubcommand?.altNames).toContain('english');
});
it('zh-CN action should set Chinese', async () => {
if (!zhCNSubcommand?.action) {
throw new Error('zh-CN subcommand must have an action.');
}
const result = await zhCNSubcommand.action(mockContext, '');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('en-US action should set English', async () => {
if (!enUSSubcommand?.action) {
throw new Error('en-US subcommand must have an action.');
}
const result = await enUSSubcommand.action(mockContext, '');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en');
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.');
}
const result = await zhCNSubcommand.action(mockContext, 'extra args');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('do not accept additional arguments'),
});
});
});
});

View File

@@ -81,8 +81,9 @@ function getCurrentLlmOutputLanguage(): string | null {
if (fs.existsSync(filePath)) {
try {
const content = fs.readFileSync(filePath, 'utf-8');
// Extract language name from the first line (e.g., "# Chinese Response Rules" -> "Chinese")
const match = content.match(/^#\s+(.+?)\s+Response Rules/i);
// Extract language name from the first line
// Template format: "# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY"
const match = content.match(/^#.*?(\w+)\s+Output Language Rule/i);
if (match) {
return match[1];
}
@@ -127,16 +128,17 @@ async function setUiLanguage(
context.ui.reloadCommands();
// Map language codes to friendly display names
const langDisplayNames: Record<SupportedLanguage, string> = {
const langDisplayNames: Partial<Record<SupportedLanguage, string>> = {
zh: '中文zh-CN',
en: 'Englishen-US',
ru: 'Русский (ru-RU)',
};
return {
type: 'message',
messageType: 'info',
content: t('UI language changed to {{lang}}', {
lang: langDisplayNames[lang],
lang: langDisplayNames[lang] || lang,
}),
};
}
@@ -216,7 +218,7 @@ export const languageCommand: SlashCommand = {
: t('LLM output language not set'),
'',
t('Available subcommands:'),
` /language ui [zh-CN|en-US] - ${t('Set UI language')}`,
` /language ui [zh-CN|en-US|ru-RU] - ${t('Set UI language')}`,
` /language output <language> - ${t('Set LLM output language')}`,
].join('\n');
@@ -232,7 +234,7 @@ export const languageCommand: SlashCommand = {
const subcommand = parts[0].toLowerCase();
if (subcommand === 'ui') {
// Handle /language ui [zh-CN|en-US]
// Handle /language ui [zh-CN|en-US|ru-RU]
if (parts.length === 1) {
// Show UI language subcommand help
return {
@@ -241,11 +243,12 @@ export const languageCommand: SlashCommand = {
content: [
t('Set UI language'),
'',
t('Usage: /language ui [zh-CN|en-US]'),
t('Usage: /language ui [zh-CN|en-US|ru-RU]'),
'',
t('Available options:'),
t(' - zh-CN: Simplified Chinese'),
t(' - en-US: English'),
t(' - ru-RU: Russian'),
'',
t(
'To request additional UI language packs, please open an issue on GitHub.',
@@ -266,11 +269,18 @@ export const languageCommand: SlashCommand = {
langArg === 'zh-cn'
) {
targetLang = 'zh';
} else if (
langArg === 'ru' ||
langArg === 'ru-RU' ||
langArg === 'russian' ||
langArg === 'русский'
) {
targetLang = 'ru';
} else {
return {
type: 'message',
messageType: 'error',
content: t('Invalid language. Available: en-US, zh-CN'),
content: t('Invalid language. Available: en-US, zh-CN, ru-RU'),
};
}
@@ -307,13 +317,20 @@ export const languageCommand: SlashCommand = {
langArg === 'zh-cn'
) {
targetLang = 'zh';
} else if (
langArg === 'ru' ||
langArg === 'ru-RU' ||
langArg === 'russian' ||
langArg === 'русский'
) {
targetLang = 'ru';
} else {
return {
type: 'message',
messageType: 'error',
content: [
t('Invalid command. Available subcommands:'),
' - /language ui [zh-CN|en-US] - ' + t('Set UI language'),
' - /language ui [zh-CN|en-US|ru-RU] - ' + t('Set UI language'),
' - /language output <language> - ' + t('Set LLM output language'),
].join('\n'),
};
@@ -423,6 +440,29 @@ export const languageCommand: SlashCommand = {
return setUiLanguage(context, 'en');
},
},
{
name: 'ru-RU',
altNames: ['ru', 'russian', 'русский'],
get description() {
return t('Set UI language to Russian (ru-RU)');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: t(
'Language subcommands do not accept additional arguments.',
),
};
}
return setUiLanguage(context, 'ru');
},
},
],
},
{

View File

@@ -8,35 +8,6 @@ import { formatDuration } from '../utils/formatters.js';
import { CommandKind, type SlashCommand } from './types.js';
import { t } from '../../i18n/index.js';
export const quitConfirmCommand: SlashCommand = {
name: 'quit-confirm',
get description() {
return t('Show quit confirmation dialog');
},
kind: CommandKind.BUILT_IN,
action: (context) => {
const now = Date.now();
const { sessionStartTime } = context.session.stats;
const wallDuration = now - sessionStartTime.getTime();
return {
type: 'quit_confirmation',
messages: [
{
type: 'user',
text: `/quit-confirm`,
id: now - 1,
},
{
type: 'quit_confirmation',
duration: formatDuration(wallDuration),
id: now,
},
],
};
},
};
export const quitCommand: SlashCommand = {
name: 'quit',
altNames: ['exit'],

View File

@@ -84,7 +84,7 @@ describe('setupGithubCommand', async () => {
const expectedSubstrings = [
`set -eEuo pipefail`,
`fakeOpenCommand "https://github.com/google-github-actions/run-gemini-cli`,
`fakeOpenCommand "https://github.com/QwenLM/qwen-code-action`,
];
for (const substring of expectedSubstrings) {
@@ -112,7 +112,7 @@ describe('setupGithubCommand', async () => {
if (gitignoreExists) {
const gitignoreContent = await fs.readFile(gitignorePath, 'utf8');
expect(gitignoreContent).toContain('.gemini/');
expect(gitignoreContent).toContain('.qwen/');
expect(gitignoreContent).toContain('gha-creds-*.json');
}
});
@@ -135,7 +135,7 @@ describe('updateGitignore', () => {
const gitignorePath = path.join(scratchDir, '.gitignore');
const content = await fs.readFile(gitignorePath, 'utf8');
expect(content).toBe('.gemini/\ngha-creds-*.json\n');
expect(content).toBe('.qwen/\ngha-creds-*.json\n');
});
it('appends entries to existing .gitignore file', async () => {
@@ -148,13 +148,13 @@ describe('updateGitignore', () => {
const content = await fs.readFile(gitignorePath, 'utf8');
expect(content).toBe(
'# Existing content\nnode_modules/\n\n.gemini/\ngha-creds-*.json\n',
'# Existing content\nnode_modules/\n\n.qwen/\ngha-creds-*.json\n',
);
});
it('does not add duplicate entries', async () => {
const gitignorePath = path.join(scratchDir, '.gitignore');
const existingContent = '.gemini/\nsome-other-file\ngha-creds-*.json\n';
const existingContent = '.qwen/\nsome-other-file\ngha-creds-*.json\n';
await fs.writeFile(gitignorePath, existingContent);
await updateGitignore(scratchDir);
@@ -166,7 +166,7 @@ describe('updateGitignore', () => {
it('adds only missing entries when some already exist', async () => {
const gitignorePath = path.join(scratchDir, '.gitignore');
const existingContent = '.gemini/\nsome-other-file\n';
const existingContent = '.qwen/\nsome-other-file\n';
await fs.writeFile(gitignorePath, existingContent);
await updateGitignore(scratchDir);
@@ -174,17 +174,17 @@ describe('updateGitignore', () => {
const content = await fs.readFile(gitignorePath, 'utf8');
// Should add only the missing gha-creds-*.json entry
expect(content).toBe('.gemini/\nsome-other-file\n\ngha-creds-*.json\n');
expect(content).toBe('.qwen/\nsome-other-file\n\ngha-creds-*.json\n');
expect(content).toContain('gha-creds-*.json');
// Should not duplicate .gemini/ entry
expect((content.match(/\.gemini\//g) || []).length).toBe(1);
// Should not duplicate .qwen/ entry
expect((content.match(/\.qwen\//g) || []).length).toBe(1);
});
it('does not get confused by entries in comments or as substrings', async () => {
const gitignorePath = path.join(scratchDir, '.gitignore');
const existingContent = [
'# This is a comment mentioning .gemini/ folder',
'my-app.gemini/config',
'# This is a comment mentioning .qwen/ folder',
'my-app.qwen/config',
'# Another comment with gha-creds-*.json pattern',
'some-other-gha-creds-file.json',
'',
@@ -196,7 +196,7 @@ describe('updateGitignore', () => {
const content = await fs.readFile(gitignorePath, 'utf8');
// Should add both entries since they don't actually exist as gitignore rules
expect(content).toContain('.gemini/');
expect(content).toContain('.qwen/');
expect(content).toContain('gha-creds-*.json');
// Verify the entries were added (not just mentioned in comments)
@@ -204,9 +204,9 @@ describe('updateGitignore', () => {
.split('\n')
.map((line) => line.split('#')[0].trim())
.filter((line) => line);
expect(lines).toContain('.gemini/');
expect(lines).toContain('.qwen/');
expect(lines).toContain('gha-creds-*.json');
expect(lines).toContain('my-app.gemini/config');
expect(lines).toContain('my-app.qwen/config');
expect(lines).toContain('some-other-gha-creds-file.json');
});

View File

@@ -23,11 +23,11 @@ import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
import { t } from '../../i18n/index.js';
export const GITHUB_WORKFLOW_PATHS = [
'gemini-dispatch/gemini-dispatch.yml',
'gemini-assistant/gemini-invoke.yml',
'issue-triage/gemini-triage.yml',
'issue-triage/gemini-scheduled-triage.yml',
'pr-review/gemini-review.yml',
'qwen-dispatch/qwen-dispatch.yml',
'qwen-assistant/qwen-invoke.yml',
'issue-triage/qwen-triage.yml',
'issue-triage/qwen-scheduled-triage.yml',
'pr-review/qwen-review.yml',
];
// Generate OS-specific commands to open the GitHub pages needed for setup.
@@ -50,9 +50,9 @@ function getOpenUrlsCommands(readmeUrl: string): string[] {
return commands;
}
// Add Gemini CLI specific entries to .gitignore file
// Add Qwen Code specific entries to .gitignore file
export async function updateGitignore(gitRepoRoot: string): Promise<void> {
const gitignoreEntries = ['.gemini/', 'gha-creds-*.json'];
const gitignoreEntries = ['.qwen/', 'gha-creds-*.json'];
const gitignorePath = path.join(gitRepoRoot, '.gitignore');
try {
@@ -121,7 +121,7 @@ export const setupGithubCommand: SlashCommand = {
// Get the latest release tag from GitHub
const proxy = context?.services?.config?.getProxy();
const releaseTag = await getLatestGitHubRelease(proxy);
const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`;
const readmeUrl = `https://github.com/QwenLM/qwen-code-action/blob/${releaseTag}/README.md#quick-start`;
// Create the .github/workflows directory to download the files into
const githubWorkflowsDir = path.join(gitRepoRoot, '.github', 'workflows');
@@ -143,7 +143,7 @@ export const setupGithubCommand: SlashCommand = {
for (const workflow of GITHUB_WORKFLOW_PATHS) {
downloads.push(
(async () => {
const endpoint = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${releaseTag}/examples/workflows/${workflow}`;
const endpoint = `https://raw.githubusercontent.com/QwenLM/qwen-code-action/refs/tags/${releaseTag}/examples/workflows/${workflow}`;
const response = await fetch(endpoint, {
method: 'GET',
dispatcher: proxy ? new ProxyAgent(proxy) : undefined,
@@ -204,8 +204,9 @@ export const setupGithubCommand: SlashCommand = {
toolName: 'run_shell_command',
toolArgs: {
description:
'Setting up GitHub Actions to triage issues and review PRs with Gemini.',
'Setting up GitHub Actions to triage issues and review PRs with Qwen.',
command,
is_background: false,
},
};
},

View File

@@ -64,8 +64,6 @@ export interface CommandContext {
* @param history The array of history items to load.
*/
loadHistory: UseHistoryManagerReturn['loadHistory'];
/** Toggles a special display mode. */
toggleCorgiMode: () => void;
toggleVimEnabled: () => Promise<boolean>;
setGeminiMdFileCount: (count: number) => void;
reloadCommands: () => void;
@@ -100,12 +98,6 @@ export interface QuitActionReturn {
messages: HistoryItem[];
}
/** The return type for a command action that requests quit confirmation. */
export interface QuitConfirmationActionReturn {
type: 'quit_confirmation';
messages: HistoryItem[];
}
/**
* The return type for a command action that results in a simple message
* being displayed to the user.
@@ -182,7 +174,6 @@ export type SlashCommandActionReturn =
| ToolActionReturn
| MessageActionReturn
| QuitActionReturn
| QuitConfirmationActionReturn
| OpenDialogActionReturn
| LoadHistoryActionReturn
| SubmitPromptActionReturn

View File

@@ -120,7 +120,6 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
},
branchName: 'main',
debugMessage: '',
corgiMode: false,
errorCount: 0,
nightly: false,
isTrustedFolder: true,
@@ -183,6 +182,7 @@ describe('Composer', () => {
const { lastFrame } = renderComposer(uiState, settings);
// Smoke check that the Footer renders when enabled.
expect(lastFrame()).toContain('Footer');
});
@@ -200,7 +200,6 @@ describe('Composer', () => {
it('passes correct props to Footer including vim mode when enabled', async () => {
const uiState = createMockUIState({
branchName: 'feature-branch',
corgiMode: true,
errorCount: 2,
sessionStats: {
sessionId: 'test-session',

View File

@@ -36,10 +36,6 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js';
import { ModelSwitchDialog } from './ModelSwitchDialog.js';
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
import {
QuitConfirmationDialog,
QuitChoice,
} from './QuitConfirmationDialog.js';
interface DialogManagerProps {
addItem: UseHistoryManagerReturn['addItem'];
@@ -127,24 +123,6 @@ export const DialogManager = ({
/>
);
}
if (uiState.quitConfirmationRequest) {
return (
<QuitConfirmationDialog
onSelect={(choice: QuitChoice) => {
if (choice === QuitChoice.CANCEL) {
uiState.quitConfirmationRequest?.onConfirm(false, 'cancel');
} else if (choice === QuitChoice.QUIT) {
uiState.quitConfirmationRequest?.onConfirm(true, 'quit');
} else if (choice === QuitChoice.SUMMARY_AND_QUIT) {
uiState.quitConfirmationRequest?.onConfirm(
true,
'summary_and_quit',
);
}
}}
/>
);
}
if (uiState.confirmationRequest) {
return (
<ConsentPrompt

View File

@@ -33,7 +33,6 @@ export const Footer: React.FC = () => {
debugMode,
branchName,
debugMessage,
corgiMode,
errorCount,
showErrorDetails,
promptTokenCount,
@@ -45,7 +44,6 @@ export const Footer: React.FC = () => {
debugMode: config.getDebugMode(),
branchName: uiState.branchName,
debugMessage: uiState.debugMessage,
corgiMode: uiState.corgiMode,
errorCount: uiState.errorCount,
showErrorDetails: uiState.showErrorDetails,
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
@@ -153,16 +151,6 @@ export const Footer: React.FC = () => {
{showMemoryUsage && <MemoryUsageDisplay />}
</Box>
<Box alignItems="center" paddingLeft={2}>
{corgiMode && (
<Text>
<Text color={theme.ui.symbol}>| </Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>(´</Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>`)</Text>
<Text color={theme.status.error}>▼ </Text>
</Text>
)}
{!showErrorDetails && errorCount > 0 && (
<Box>
<Text color={theme.ui.symbol}>| </Text>

View File

@@ -15,6 +15,7 @@ import type {
} from '@qwen-code/qwen-code-core';
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
// Mock child components
vi.mock('./messages/ToolGroupMessage.js', () => ({
@@ -22,7 +23,9 @@ vi.mock('./messages/ToolGroupMessage.js', () => ({
}));
describe('<HistoryItemDisplay />', () => {
const mockConfig = {} as unknown as Config;
const mockConfig = {
getChatRecordingService: () => undefined,
} as unknown as Config;
const baseItem = {
id: 1,
timestamp: 12345,
@@ -133,9 +136,11 @@ describe('<HistoryItemDisplay />', () => {
duration: '1s',
};
const { lastFrame } = renderWithProviders(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
<ConfigContext.Provider value={mockConfig as never}>
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>
</ConfigContext.Provider>,
);
expect(lastFrame()).toContain('Agent powering down. Goodbye!');
});

View File

@@ -15,6 +15,8 @@ import { InfoMessage } from './messages/InfoMessage.js';
import { ErrorMessage } from './messages/ErrorMessage.js';
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { GeminiMessageContent } from './messages/GeminiMessageContent.js';
import { GeminiThoughtMessage } from './messages/GeminiThoughtMessage.js';
import { GeminiThoughtMessageContent } from './messages/GeminiThoughtMessageContent.js';
import { CompressionMessage } from './messages/CompressionMessage.js';
import { SummaryMessage } from './messages/SummaryMessage.js';
import { WarningMessage } from './messages/WarningMessage.js';
@@ -85,6 +87,26 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
terminalWidth={terminalWidth}
/>
)}
{itemForDisplay.type === 'gemini_thought' && (
<GeminiThoughtMessage
text={itemForDisplay.text}
isPending={isPending}
availableTerminalHeight={
availableTerminalHeightGemini ?? availableTerminalHeight
}
terminalWidth={terminalWidth}
/>
)}
{itemForDisplay.type === 'gemini_thought_content' && (
<GeminiThoughtMessageContent
text={itemForDisplay.text}
isPending={isPending}
availableTerminalHeight={
availableTerminalHeightGemini ?? availableTerminalHeight
}
terminalWidth={terminalWidth}
/>
)}
{itemForDisplay.type === 'info' && (
<InfoMessage text={itemForDisplay.text} />
)}
@@ -108,9 +130,6 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'quit' && (
<SessionSummaryDisplay duration={itemForDisplay.duration} />
)}
{itemForDisplay.type === 'quit_confirmation' && (
<SessionSummaryDisplay duration={itemForDisplay.duration} />
)}
{itemForDisplay.type === 'tool_group' && (
<ToolGroupMessage
toolCalls={itemForDisplay.tools}

View File

@@ -1307,7 +1307,7 @@ describe('InputPrompt', () => {
mockBuffer.text = text;
mockBuffer.lines = [text];
mockBuffer.viewportVisualLines = [text];
mockBuffer.visualCursor = [0, 8]; // cursor after '👍' (length is 6 + 2 for emoji)
mockBuffer.visualCursor = [0, 7]; // cursor after '👍' (emoji is 1 code point, so total is 7)
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,

View File

@@ -707,15 +707,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
statusText = t('Accepting edits');
}
const borderColor =
isShellFocused && !isEmbeddedShellFocused
? (statusColor ?? theme.border.focused)
: theme.border.default;
return (
<>
<Box
borderStyle="round"
borderColor={
isShellFocused && !isEmbeddedShellFocused
? (statusColor ?? theme.border.focused)
: theme.border.default
}
borderStyle="single"
borderTop={true}
borderBottom={true}
borderLeft={false}
borderRight={false}
borderColor={borderColor}
paddingX={1}
>
<Text
@@ -829,9 +834,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText)
) {
// Add zero-width space after cursor to prevent Ink from trimming trailing whitespace
renderedLine.push(
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
{showCursor ? chalk.inverse(' ') : ' '}
{showCursor ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
</Text>,
);
}

View File

@@ -1,73 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import type React from 'react';
import { Colors } from '../colors.js';
import {
RadioButtonSelect,
type RadioSelectItem,
} from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { t } from '../../i18n/index.js';
export enum QuitChoice {
CANCEL = 'cancel',
QUIT = 'quit',
SUMMARY_AND_QUIT = 'summary_and_quit',
}
interface QuitConfirmationDialogProps {
onSelect: (choice: QuitChoice) => void;
}
export const QuitConfirmationDialog: React.FC<QuitConfirmationDialogProps> = ({
onSelect,
}) => {
useKeypress(
(key) => {
if (key.name === 'escape') {
onSelect(QuitChoice.CANCEL);
}
},
{ isActive: true },
);
const options: Array<RadioSelectItem<QuitChoice>> = [
{
key: 'quit',
label: t('Quit immediately (/quit)'),
value: QuitChoice.QUIT,
},
{
key: 'summary-and-quit',
label: t('Generate summary and quit (/summary)'),
value: QuitChoice.SUMMARY_AND_QUIT,
},
{
key: 'cancel',
label: t('Cancel (stay in application)'),
value: QuitChoice.CANCEL,
},
];
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={Colors.AccentYellow}
padding={1}
width="100%"
marginLeft={1}
>
<Box flexDirection="column" marginBottom={1}>
<Text>{t('What would you like to do before exiting?')}</Text>
</Box>
<RadioButtonSelect items={options} onSelect={onSelect} isFocused />
</Box>
);
};

View File

@@ -9,6 +9,7 @@ import { describe, it, expect, vi } from 'vitest';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = await importOriginal<typeof SessionContext>();
@@ -20,20 +21,36 @@ vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = (metrics: SessionMetrics) => {
const renderWithMockedStats = (
metrics: SessionMetrics,
sessionId: string = 'test-session-id-12345',
promptCount: number = 5,
chatRecordingEnabled: boolean = true,
) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionId,
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
promptCount: 5,
promptCount,
},
getPromptCount: () => 5,
getPromptCount: () => promptCount,
startNewPrompt: vi.fn(),
});
return render(<SessionSummaryDisplay duration="1h 23m 45s" />);
const mockConfig = {
getChatRecordingService: vi.fn(() =>
chatRecordingEnabled ? ({} as never) : undefined,
),
};
return render(
<ConfigContext.Provider value={mockConfig as never}>
<SessionSummaryDisplay duration="1h 23m 45s" />
</ConfigContext.Provider>,
);
};
describe('<SessionSummaryDisplay />', () => {
@@ -70,6 +87,68 @@ describe('<SessionSummaryDisplay />', () => {
const output = lastFrame();
expect(output).toContain('Agent powering down. Goodbye!');
expect(output).toContain('To continue this session, run');
expect(output).toContain('qwen --resume test-session-id-12345');
expect(output).toMatchSnapshot();
});
it('does not show resume message when there are no messages', () => {
const metrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
// Pass promptCount = 0 to simulate no messages
const { lastFrame } = renderWithMockedStats(
metrics,
'test-session-id-12345',
0,
);
const output = lastFrame();
expect(output).toContain('Agent powering down. Goodbye!');
expect(output).not.toContain('To continue this session, run');
expect(output).not.toContain('qwen --resume');
});
it('does not show resume message when chat recording is disabled', () => {
const metrics: SessionMetrics = {
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: { accept: 0, reject: 0, modify: 0 },
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
const { lastFrame } = renderWithMockedStats(
metrics,
'test-session-id-12345',
5,
false,
);
const output = lastFrame();
expect(output).toContain('Agent powering down. Goodbye!');
expect(output).not.toContain('To continue this session, run');
expect(output).not.toContain('qwen --resume');
});
});

View File

@@ -5,7 +5,11 @@
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { StatsDisplay } from './StatsDisplay.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { theme } from '../semantic-colors.js';
import { t } from '../../i18n/index.js';
interface SessionSummaryDisplayProps {
@@ -14,9 +18,31 @@ interface SessionSummaryDisplayProps {
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
duration,
}) => (
<StatsDisplay
title={t('Agent powering down. Goodbye!')}
duration={duration}
/>
);
}) => {
const config = useConfig();
const { stats } = useSessionStats();
// Only show the resume message if there were messages in the session AND
// chat recording is enabled (otherwise there is nothing to resume).
const hasMessages = stats.promptCount > 0;
const canResume = !!config.getChatRecordingService();
return (
<>
<StatsDisplay
title={t('Agent powering down. Goodbye!')}
duration={duration}
/>
{hasMessages && canResume && (
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('To continue this session, run')}{' '}
<Text color={theme.text.accent}>
qwen --resume {stats.sessionId}
</Text>
</Text>
</Box>
)}
</>
);
};

View File

@@ -1461,7 +1461,7 @@ describe('SettingsDialog', () => {
context: {
fileFiltering: {
respectGitIgnore: false,
respectQwemIgnore: true,
respectQwenIgnore: true,
enableRecursiveFileSearch: false,
disableFuzzySearch: true,
},
@@ -1535,7 +1535,7 @@ describe('SettingsDialog', () => {
loadMemoryFromIncludeDirectories: false,
fileFiltering: {
respectGitIgnore: false,
respectQwemIgnore: false,
respectQwenIgnore: false,
enableRecursiveFileSearch: false,
disableFuzzySearch: false,
},

View File

@@ -19,39 +19,39 @@ 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"
`;
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"
`;
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,7 +6,7 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
│ Agent powering down. Goodbye! │
│ │
│ Interaction Summary │
│ Session ID:
│ Session ID: test-session-id-12345
│ Tool Calls: 0 ( ✓ 0 x 0 ) │
│ Success Rate: 0.0% │
│ Code Changes: +42 -15 │
@@ -26,5 +26,7 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
│ │
│ » Tip: For a full token breakdown, run \`/stats model\`. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
To continue this session, run qwen --resume test-session-id-12345"
`;

View File

@@ -14,14 +14,14 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -48,14 +48,14 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -82,14 +82,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -116,14 +116,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false* │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false* │
│ │
│ ▼ │
│ │
│ │
@@ -150,14 +150,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -184,14 +184,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -218,14 +218,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -252,14 +252,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false* │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -286,14 +286,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -320,14 +320,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title true* │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips true* │
│ │
│ ▼ │
│ │
│ │

View File

@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { theme } from '../../semantic-colors.js';
interface GeminiThoughtMessageProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
}
/**
* Displays model thinking/reasoning text with a softer, dimmed style
* to visually distinguish it from regular content output.
*/
export const GeminiThoughtMessage: React.FC<GeminiThoughtMessageProps> = ({
text,
isPending,
availableTerminalHeight,
terminalWidth,
}) => {
const prefix = '✦ ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row" marginBottom={1}>
<Box width={prefixWidth}>
<Text color={theme.text.secondary}>{prefix}</Text>
</Box>
<Box flexGrow={1} flexDirection="column">
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
textColor={theme.text.secondary}
/>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,40 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { theme } from '../../semantic-colors.js';
interface GeminiThoughtMessageContentProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
}
/**
* Continuation component for thought messages, similar to GeminiMessageContent.
* Used when a thought response gets too long and needs to be split for performance.
*/
export const GeminiThoughtMessageContent: React.FC<
GeminiThoughtMessageContentProps
> = ({ text, isPending, availableTerminalHeight, terminalWidth }) => {
const originalPrefix = '✦ ';
const prefixWidth = originalPrefix.length;
return (
<Box flexDirection="column" paddingLeft={prefixWidth} marginBottom={1}>
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
textColor={theme.text.secondary}
/>
</Box>
);
};

View File

@@ -69,7 +69,10 @@ export function EditOptionsStep({
if (selectedValue === 'editor') {
// Launch editor directly
try {
await launchEditor(selectedAgent?.filePath);
if (!selectedAgent.filePath) {
throw new Error('Agent has no file path');
}
await launchEditor(selectedAgent.filePath);
} catch (err) {
setError(
t('Failed to launch editor: {{error}}', {

View File

@@ -218,7 +218,7 @@ export const AgentSelectionStep = ({
const renderAgentItem = (
agent: {
name: string;
level: 'project' | 'user' | 'builtin';
level: 'project' | 'user' | 'builtin' | 'session';
isBuiltin?: boolean;
},
index: number,
@@ -267,7 +267,7 @@ export const AgentSelectionStep = ({
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.text.primary} bold>
{t('Project Level ({{path}})', {
path: projectAgents[0].filePath.replace(/\/[^/]+$/, ''),
path: projectAgents[0].filePath?.replace(/\/[^/]+$/, '') || '',
})}
</Text>
<Box marginTop={1} flexDirection="column">
@@ -289,7 +289,7 @@ export const AgentSelectionStep = ({
>
<Text color={theme.text.primary} bold>
{t('User Level ({{path}})', {
path: userAgents[0].filePath.replace(/\/[^/]+$/, ''),
path: userAgents[0].filePath?.replace(/\/[^/]+$/, '') || '',
})}
</Text>
<Box marginTop={1} flexDirection="column">

View File

@@ -12,7 +12,6 @@ import type {
ShellConfirmationRequest,
ConfirmationRequest,
LoopDetectionConfirmationRequest,
QuitConfirmationRequest,
HistoryItemWithoutId,
StreamingState,
} from '../types.js';
@@ -55,7 +54,6 @@ export interface UIState {
qwenAuthState: QwenAuthState;
editorError: string | null;
isEditorDialogOpen: boolean;
corgiMode: boolean;
debugMessage: string;
quittingMessages: HistoryItem[] | null;
isSettingsDialogOpen: boolean;
@@ -69,7 +67,6 @@ export interface UIState {
confirmationRequest: ConfirmationRequest | null;
confirmUpdateExtensionRequests: ConfirmationRequest[];
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
quitConfirmationRequest: QuitConfirmationRequest | null;
geminiMdFileCount: number;
streamingState: StreamingState;
initError: string | null;

View File

@@ -153,7 +153,6 @@ describe('useSlashCommandProcessor', () => {
openModelDialog: mockOpenModelDialog,
quit: mockSetQuittingMessages,
setDebugMessage: vi.fn(),
toggleCorgiMode: vi.fn(),
},
),
);
@@ -909,7 +908,6 @@ describe('useSlashCommandProcessor', () => {
vi.fn(), // openThemeDialog
mockOpenAuthDialog,
vi.fn(), // openEditorDialog
vi.fn(), // toggleCorgiMode
mockSetQuittingMessages,
vi.fn(), // openSettingsDialog
vi.fn(), // openModelSelectionDialog
@@ -918,7 +916,6 @@ describe('useSlashCommandProcessor', () => {
vi.fn(), // toggleVimEnabled
vi.fn(), // setIsProcessing
vi.fn(), // setGeminiMdFileCount
vi.fn(), // _showQuitConfirmation
),
);

View File

@@ -18,7 +18,6 @@ import {
IdeClient,
} from '@qwen-code/qwen-code-core';
import { useSessionStats } from '../contexts/SessionContext.js';
import { formatDuration } from '../utils/formatters.js';
import type {
Message,
HistoryItemWithoutId,
@@ -53,7 +52,6 @@ function serializeHistoryItemForRecording(
const SLASH_COMMANDS_SKIP_RECORDING = new Set([
'quit',
'quit-confirm',
'exit',
'clear',
'reset',
@@ -70,12 +68,10 @@ interface SlashCommandProcessorActions {
openApprovalModeDialog: () => void;
quit: (messages: HistoryItem[]) => void;
setDebugMessage: (message: string) => void;
toggleCorgiMode: () => void;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
openSubagentCreateDialog: () => void;
openAgentsManagerDialog: () => void;
_showQuitConfirmation: () => void;
}
/**
@@ -115,10 +111,6 @@ export const useSlashCommandProcessor = (
prompt: React.ReactNode;
onConfirm: (confirmed: boolean) => void;
}>(null);
const [quitConfirmationRequest, setQuitConfirmationRequest] =
useState<null | {
onConfirm: (shouldQuit: boolean, action?: string) => void;
}>(null);
const [sessionShellAllowlist, setSessionShellAllowlist] = useState(
new Set<string>(),
@@ -174,11 +166,6 @@ export const useSlashCommandProcessor = (
type: 'quit',
duration: message.duration,
};
} else if (message.type === MessageType.QUIT_CONFIRMATION) {
historyItemContent = {
type: 'quit_confirmation',
duration: message.duration,
};
} else if (message.type === MessageType.COMPRESSION) {
historyItemContent = {
type: 'compression',
@@ -218,7 +205,6 @@ export const useSlashCommandProcessor = (
setDebugMessage: actions.setDebugMessage,
pendingItem,
setPendingItem,
toggleCorgiMode: actions.toggleCorgiMode,
toggleVimEnabled,
setGeminiMdFileCount,
reloadCommands,
@@ -449,66 +435,6 @@ export const useSlashCommandProcessor = (
});
return { type: 'handled' };
}
case 'quit_confirmation':
// Show quit confirmation dialog instead of immediately quitting
setQuitConfirmationRequest({
onConfirm: (shouldQuit: boolean, action?: string) => {
setQuitConfirmationRequest(null);
if (!shouldQuit) {
// User cancelled the quit operation - do nothing
return;
}
if (shouldQuit) {
if (action === 'summary_and_quit') {
// Generate summary and then quit
handleSlashCommand('/summary')
.then(() => {
// Wait for user to see the summary result
setTimeout(() => {
handleSlashCommand('/quit');
}, 1200);
})
.catch((error) => {
// If summary fails, still quit but show error
addItemWithRecording(
{
type: 'error',
text: `Failed to generate summary before quit: ${
error instanceof Error
? error.message
: String(error)
}`,
},
Date.now(),
);
// Give user time to see the error message
setTimeout(() => {
handleSlashCommand('/quit');
}, 1000);
});
} else {
// Just quit immediately - trigger the actual quit action
const now = Date.now();
const { sessionStartTime } = sessionStats;
const wallDuration = now - sessionStartTime.getTime();
actions.quit([
{
type: 'user',
text: `/quit`,
id: now - 1,
},
{
type: 'quit',
duration: formatDuration(wallDuration),
id: now,
},
]);
}
}
},
});
return { type: 'handled' };
case 'quit':
actions.quit(result.messages);
@@ -692,7 +618,6 @@ export const useSlashCommandProcessor = (
setSessionShellAllowlist,
setIsProcessing,
setConfirmationRequest,
sessionStats,
],
);
@@ -703,6 +628,5 @@ export const useSlashCommandProcessor = (
commandContext,
shellConfirmationRequest,
confirmationRequest,
quitConfirmationRequest,
};
};

View File

@@ -15,6 +15,23 @@ import {
LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS,
useAttentionNotifications,
} from './useAttentionNotifications.js';
import type { LoadedSettings } from '../../config/settings.js';
const mockSettings: LoadedSettings = {
merged: {
general: {
terminalBell: true,
},
},
} as LoadedSettings;
const mockSettingsDisabled: LoadedSettings = {
merged: {
general: {
terminalBell: false,
},
},
} as LoadedSettings;
vi.mock('../../utils/attentionNotification.js', () => ({
notifyTerminalAttention: vi.fn(),
@@ -40,6 +57,7 @@ describe('useAttentionNotifications', () => {
isFocused: true,
streamingState: StreamingState.Idle,
elapsedTime: 0,
settings: mockSettings,
...props,
},
},
@@ -53,11 +71,13 @@ describe('useAttentionNotifications', () => {
isFocused: false,
streamingState: StreamingState.WaitingForConfirmation,
elapsedTime: 0,
settings: mockSettings,
},
});
expect(mockedNotify).toHaveBeenCalledWith(
AttentionNotificationReason.ToolApproval,
{ enabled: true },
);
});
@@ -72,6 +92,7 @@ describe('useAttentionNotifications', () => {
isFocused: false,
streamingState: StreamingState.WaitingForConfirmation,
elapsedTime: 0,
settings: mockSettings,
},
});
@@ -86,6 +107,7 @@ describe('useAttentionNotifications', () => {
isFocused: false,
streamingState: StreamingState.Responding,
elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 5,
settings: mockSettings,
},
});
@@ -94,11 +116,13 @@ describe('useAttentionNotifications', () => {
isFocused: false,
streamingState: StreamingState.Idle,
elapsedTime: 0,
settings: mockSettings,
},
});
expect(mockedNotify).toHaveBeenCalledWith(
AttentionNotificationReason.LongTaskComplete,
{ enabled: true },
);
});
@@ -110,6 +134,7 @@ describe('useAttentionNotifications', () => {
isFocused: true,
streamingState: StreamingState.Responding,
elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 2,
settings: mockSettings,
},
});
@@ -118,6 +143,7 @@ describe('useAttentionNotifications', () => {
isFocused: true,
streamingState: StreamingState.Idle,
elapsedTime: 0,
settings: mockSettings,
},
});
@@ -135,6 +161,7 @@ describe('useAttentionNotifications', () => {
isFocused: false,
streamingState: StreamingState.Responding,
elapsedTime: 5,
settings: mockSettings,
},
});
@@ -143,9 +170,30 @@ describe('useAttentionNotifications', () => {
isFocused: false,
streamingState: StreamingState.Idle,
elapsedTime: 0,
settings: mockSettings,
},
});
expect(mockedNotify).not.toHaveBeenCalled();
});
it('does not notify when terminalBell setting is disabled', () => {
const { rerender } = render({
settings: mockSettingsDisabled,
});
rerender({
hookProps: {
isFocused: false,
streamingState: StreamingState.WaitingForConfirmation,
elapsedTime: 0,
settings: mockSettingsDisabled,
},
});
expect(mockedNotify).toHaveBeenCalledWith(
AttentionNotificationReason.ToolApproval,
{ enabled: false },
);
});
});

View File

@@ -10,6 +10,7 @@ import {
notifyTerminalAttention,
AttentionNotificationReason,
} from '../../utils/attentionNotification.js';
import type { LoadedSettings } from '../../config/settings.js';
export const LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS = 20;
@@ -17,13 +18,16 @@ interface UseAttentionNotificationsOptions {
isFocused: boolean;
streamingState: StreamingState;
elapsedTime: number;
settings: LoadedSettings;
}
export const useAttentionNotifications = ({
isFocused,
streamingState,
elapsedTime,
settings,
}: UseAttentionNotificationsOptions) => {
const terminalBellEnabled = settings?.merged?.general?.terminalBell ?? true;
const awaitingNotificationSentRef = useRef(false);
const respondingElapsedRef = useRef(0);
@@ -33,14 +37,16 @@ export const useAttentionNotifications = ({
!isFocused &&
!awaitingNotificationSentRef.current
) {
notifyTerminalAttention(AttentionNotificationReason.ToolApproval);
notifyTerminalAttention(AttentionNotificationReason.ToolApproval, {
enabled: terminalBellEnabled,
});
awaitingNotificationSentRef.current = true;
}
if (streamingState !== StreamingState.WaitingForConfirmation || isFocused) {
awaitingNotificationSentRef.current = false;
}
}, [isFocused, streamingState]);
}, [isFocused, streamingState, terminalBellEnabled]);
useEffect(() => {
if (streamingState === StreamingState.Responding) {
@@ -53,11 +59,13 @@ export const useAttentionNotifications = ({
respondingElapsedRef.current >=
LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS;
if (wasLongTask && !isFocused) {
notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete);
notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete, {
enabled: terminalBellEnabled,
});
}
// Reset tracking for next task
respondingElapsedRef.current = 0;
return;
}
}, [streamingState, elapsedTime, isFocused]);
}, [streamingState, elapsedTime, isFocused, terminalBellEnabled]);
};

View File

@@ -44,11 +44,6 @@ export interface DialogCloseOptions {
// Welcome back dialog
showWelcomeBackDialog: boolean;
handleWelcomeBackClose: () => void;
// Quit confirmation dialog
quitConfirmationRequest: {
onConfirm: (shouldQuit: boolean, action?: string) => void;
} | null;
}
/**
@@ -96,9 +91,6 @@ export function useDialogClose(options: DialogCloseOptions) {
return true;
}
// Note: quitConfirmationRequest is NOT handled here anymore
// It's handled specially in handleExit - ctrl+c in quit-confirm should exit immediately
// No dialog was open
return false;
}, [options]);

View File

@@ -2261,6 +2261,57 @@ describe('useGeminiStream', () => {
});
});
it('should accumulate streamed thought descriptions', async () => {
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Thought,
value: { subject: '', description: 'thinking ' },
};
yield {
type: ServerGeminiEventType.Thought,
value: { subject: '', description: 'more' },
};
yield {
type: ServerGeminiEventType.Finished,
value: { reason: 'STOP', usageMetadata: undefined },
};
})(),
);
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
false, // visionModelPreviewEnabled
() => {},
80,
24,
),
);
await act(async () => {
await result.current.submitQuery('Streamed thought');
});
await waitFor(() => {
expect(result.current.thought?.description).toBe('thinking more');
});
});
it('should memoize pendingHistoryItems', () => {
mockUseReactToolScheduler.mockReturnValue([
[],

View File

@@ -497,6 +497,61 @@ export const useGeminiStream = (
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
);
const mergeThought = useCallback(
(incoming: ThoughtSummary) => {
setThought((prev) => {
if (!prev) {
return incoming;
}
const subject = incoming.subject || prev.subject;
const description = `${prev.description ?? ''}${incoming.description ?? ''}`;
return { subject, description };
});
},
[setThought],
);
const handleThoughtEvent = useCallback(
(
eventValue: ThoughtSummary,
currentThoughtBuffer: string,
userMessageTimestamp: number,
): string => {
if (turnCancelledRef.current) {
return '';
}
// Extract the description text from the thought summary
const thoughtText = eventValue.description ?? '';
if (!thoughtText) {
return currentThoughtBuffer;
}
const newThoughtBuffer = currentThoughtBuffer + thoughtText;
// If we're not already showing a thought, start a new one
if (pendingHistoryItemRef.current?.type !== 'gemini_thought') {
// If there's a pending non-thought item, finalize it first
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
}
setPendingHistoryItem({ type: 'gemini_thought', text: '' });
}
// Update the existing thought message with accumulated content
setPendingHistoryItem({
type: 'gemini_thought',
text: newThoughtBuffer,
});
// Also update the thought state for the loading indicator
mergeThought(eventValue);
return newThoughtBuffer;
},
[addItem, pendingHistoryItemRef, setPendingHistoryItem, mergeThought],
);
const handleUserCancelledEvent = useCallback(
(userMessageTimestamp: number) => {
if (turnCancelledRef.current) {
@@ -710,11 +765,16 @@ export const useGeminiStream = (
signal: AbortSignal,
): Promise<StreamProcessingStatus> => {
let geminiMessageBuffer = '';
let thoughtBuffer = '';
const toolCallRequests: ToolCallRequestInfo[] = [];
for await (const event of stream) {
switch (event.type) {
case ServerGeminiEventType.Thought:
setThought(event.value);
thoughtBuffer = handleThoughtEvent(
event.value,
thoughtBuffer,
userMessageTimestamp,
);
break;
case ServerGeminiEventType.Content:
geminiMessageBuffer = handleContentEvent(
@@ -776,6 +836,7 @@ export const useGeminiStream = (
},
[
handleContentEvent,
handleThoughtEvent,
handleUserCancelledEvent,
handleErrorEvent,
scheduleToolCalls,

View File

@@ -1,37 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback } from 'react';
import { QuitChoice } from '../components/QuitConfirmationDialog.js';
export const useQuitConfirmation = () => {
const [isQuitConfirmationOpen, setIsQuitConfirmationOpen] = useState(false);
const showQuitConfirmation = useCallback(() => {
setIsQuitConfirmationOpen(true);
}, []);
const handleQuitConfirmationSelect = useCallback((choice: QuitChoice) => {
setIsQuitConfirmationOpen(false);
if (choice === QuitChoice.CANCEL) {
return { shouldQuit: false, action: 'cancel' };
} else if (choice === QuitChoice.QUIT) {
return { shouldQuit: true, action: 'quit' };
} else if (choice === QuitChoice.SUMMARY_AND_QUIT) {
return { shouldQuit: true, action: 'summary_and_quit' };
}
// Default to cancel if unknown choice
return { shouldQuit: false, action: 'cancel' };
}, []);
return {
isQuitConfirmationOpen,
showQuitConfirmation,
handleQuitConfirmationSelect,
};
};

View File

@@ -20,7 +20,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
loadHistory: (_newHistory) => {},
pendingItem: null,
setPendingItem: (_item) => {},
toggleCorgiMode: () => {},
toggleVimEnabled: async () => false,
setGeminiMdFileCount: (_count) => {},
reloadCommands: () => {},

View File

@@ -103,6 +103,16 @@ export type HistoryItemGeminiContent = HistoryItemBase & {
text: string;
};
export type HistoryItemGeminiThought = HistoryItemBase & {
type: 'gemini_thought';
text: string;
};
export type HistoryItemGeminiThoughtContent = HistoryItemBase & {
type: 'gemini_thought_content';
text: string;
};
export type HistoryItemInfo = HistoryItemBase & {
type: 'info';
text: string;
@@ -161,11 +171,6 @@ export type HistoryItemQuit = HistoryItemBase & {
duration: string;
};
export type HistoryItemQuitConfirmation = HistoryItemBase & {
type: 'quit_confirmation';
duration: string;
};
export type HistoryItemToolGroup = HistoryItemBase & {
type: 'tool_group';
tools: IndividualToolCallDisplay[];
@@ -246,6 +251,8 @@ export type HistoryItemWithoutId =
| HistoryItemUserShell
| HistoryItemGemini
| HistoryItemGeminiContent
| HistoryItemGeminiThought
| HistoryItemGeminiThoughtContent
| HistoryItemInfo
| HistoryItemError
| HistoryItemWarning
@@ -256,7 +263,6 @@ export type HistoryItemWithoutId =
| HistoryItemModelStats
| HistoryItemToolStats
| HistoryItemQuit
| HistoryItemQuitConfirmation
| HistoryItemCompression
| HistoryItemSummary
| HistoryItemCompression
@@ -278,7 +284,6 @@ export enum MessageType {
MODEL_STATS = 'model_stats',
TOOL_STATS = 'tool_stats',
QUIT = 'quit',
QUIT_CONFIRMATION = 'quit_confirmation',
GEMINI = 'gemini',
COMPRESSION = 'compression',
SUMMARY = 'summary',
@@ -342,12 +347,6 @@ export type Message =
duration: string;
content?: string;
}
| {
type: MessageType.QUIT_CONFIRMATION;
timestamp: Date;
duration: string;
content?: string;
}
| {
type: MessageType.COMPRESSION;
compression: CompressionProps;
@@ -404,7 +403,3 @@ export interface ConfirmationRequest {
export interface LoopDetectionConfirmationRequest {
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
}
export interface QuitConfirmationRequest {
onConfirm: (shouldQuit: boolean, action?: string) => void;
}

View File

@@ -19,12 +19,16 @@ const UNDERLINE_TAG_END_LENGTH = 4; // For "</u>"
interface RenderInlineProps {
text: string;
textColor?: string;
}
const RenderInlineInternal: React.FC<RenderInlineProps> = ({ text }) => {
const RenderInlineInternal: React.FC<RenderInlineProps> = ({
text,
textColor = theme.text.primary,
}) => {
// Early return for plain text without markdown or URLs
if (!/[*_~`<[https?:]/.test(text)) {
return <Text color={theme.text.primary}>{text}</Text>;
return <Text color={textColor}>{text}</Text>;
}
const nodes: React.ReactNode[] = [];

View File

@@ -17,6 +17,7 @@ interface MarkdownDisplayProps {
isPending: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
textColor?: string;
}
// Constants for Markdown parsing and rendering
@@ -31,6 +32,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
isPending,
availableTerminalHeight,
terminalWidth,
textColor = theme.text.primary,
}) => {
if (!text) return <></>;
@@ -116,7 +118,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
addContentBlock(
<Box key={key}>
<Text wrap="wrap">
<RenderInline text={line} />
<RenderInline text={line} textColor={textColor} />
</Text>
</Box>,
);
@@ -155,7 +157,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
addContentBlock(
<Box key={key}>
<Text wrap="wrap">
<RenderInline text={line} />
<RenderInline text={line} textColor={textColor} />
</Text>
</Box>,
);
@@ -173,36 +175,36 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
switch (level) {
case 1:
headerNode = (
<Text bold color={theme.text.link}>
<RenderInline text={headerText} />
<Text bold color={textColor}>
<RenderInline text={headerText} textColor={textColor} />
</Text>
);
break;
case 2:
headerNode = (
<Text bold color={theme.text.link}>
<RenderInline text={headerText} />
<Text bold color={textColor}>
<RenderInline text={headerText} textColor={textColor} />
</Text>
);
break;
case 3:
headerNode = (
<Text bold color={theme.text.primary}>
<RenderInline text={headerText} />
<Text bold color={textColor}>
<RenderInline text={headerText} textColor={textColor} />
</Text>
);
break;
case 4:
headerNode = (
<Text italic color={theme.text.secondary}>
<RenderInline text={headerText} />
<Text italic color={textColor}>
<RenderInline text={headerText} textColor={textColor} />
</Text>
);
break;
default:
headerNode = (
<Text color={theme.text.primary}>
<RenderInline text={headerText} />
<Text color={textColor}>
<RenderInline text={headerText} textColor={textColor} />
</Text>
);
break;
@@ -219,6 +221,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
type="ul"
marker={marker}
leadingWhitespace={leadingWhitespace}
textColor={textColor}
/>,
);
} else if (olMatch) {
@@ -232,6 +235,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
type="ol"
marker={marker}
leadingWhitespace={leadingWhitespace}
textColor={textColor}
/>,
);
} else {
@@ -245,8 +249,8 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
} else {
addContentBlock(
<Box key={key}>
<Text wrap="wrap" color={theme.text.primary}>
<RenderInline text={line} />
<Text wrap="wrap" color={textColor}>
<RenderInline text={line} textColor={textColor} />
</Text>
</Box>,
);
@@ -367,6 +371,7 @@ interface RenderListItemProps {
type: 'ul' | 'ol';
marker: string;
leadingWhitespace?: string;
textColor?: string;
}
const RenderListItemInternal: React.FC<RenderListItemProps> = ({
@@ -374,6 +379,7 @@ const RenderListItemInternal: React.FC<RenderListItemProps> = ({
type,
marker,
leadingWhitespace = '',
textColor = theme.text.primary,
}) => {
const prefix = type === 'ol' ? `${marker}. ` : `${marker} `;
const prefixWidth = prefix.length;
@@ -385,11 +391,11 @@ const RenderListItemInternal: React.FC<RenderListItemProps> = ({
flexDirection="row"
>
<Box width={prefixWidth}>
<Text color={theme.text.primary}>{prefix}</Text>
<Text color={textColor}>{prefix}</Text>
</Box>
<Box flexGrow={LIST_ITEM_TEXT_FLEX_GROW}>
<Text wrap="wrap" color={theme.text.primary}>
<RenderInline text={itemText} />
<Text wrap="wrap" color={textColor}>
<RenderInline text={itemText} textColor={textColor} />
</Text>
</Box>
</Box>

View File

@@ -102,7 +102,7 @@ describe('resumeHistoryUtils', () => {
]);
});
it('marks tool results as error, skips thought text, and falls back when tool is missing', () => {
it('marks tool results as error, captures thought text, and falls back when tool is missing', () => {
const conversation = {
messages: [
{
@@ -142,6 +142,11 @@ describe('resumeHistoryUtils', () => {
const items = buildResumedHistoryItems(session, makeConfig({}));
expect(items).toEqual([
{
id: expect.any(Number),
type: 'gemini_thought',
text: 'should be skipped',
},
{ id: expect.any(Number), type: 'gemini', text: 'visible text' },
{
id: expect.any(Number),

View File

@@ -17,7 +17,7 @@ import type { HistoryItem, HistoryItemWithoutId } from '../types.js';
import { ToolCallStatus } from '../types.js';
/**
* Extracts text content from a Content object's parts.
* Extracts text content from a Content object's parts (excluding thought parts).
*/
function extractTextFromParts(parts: Part[] | undefined): string {
if (!parts) return '';
@@ -34,6 +34,22 @@ function extractTextFromParts(parts: Part[] | undefined): string {
return textParts.join('\n');
}
/**
* Extracts thought text content from a Content object's parts.
* Thought parts are identified by having `thought: true`.
*/
function extractThoughtTextFromParts(parts: Part[] | undefined): string {
if (!parts) return '';
const thoughtParts: string[] = [];
for (const part of parts) {
if ('text' in part && part.text && 'thought' in part && part.thought) {
thoughtParts.push(part.text);
}
}
return thoughtParts.join('\n');
}
/**
* Extracts function calls from a Content object's parts.
*/
@@ -187,12 +203,28 @@ function convertToHistoryItems(
case 'assistant': {
const parts = record.message?.parts as Part[] | undefined;
// Extract thought content
const thoughtText = extractThoughtTextFromParts(parts);
// Extract text content (non-function-call, non-thought)
const text = extractTextFromParts(parts);
// Extract function calls
const functionCalls = extractFunctionCalls(parts);
// If there's thought content, add it as a gemini_thought message
if (thoughtText) {
// Flush any pending tool group before thought
if (currentToolGroup.length > 0) {
items.push({
type: 'tool_group',
tools: [...currentToolGroup],
});
currentToolGroup = [];
}
items.push({ type: 'gemini_thought', text: thoughtText });
}
// If there's text content, add it as a gemini message
if (text) {
// Flush any pending tool group before text