mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 09:17:53 +00:00
Merge branch 'main' into docs-byYijing
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
600
packages/cli/src/ui/commands/languageCommand.test.ts
Normal file
600
packages/cli/src/ui/commands/languageCommand.test.ts
Normal 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'),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: 'English(en-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');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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!');
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />,
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
@@ -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"
|
||||
`;
|
||||
|
||||
@@ -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* │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}}', {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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([
|
||||
[],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -20,7 +20,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
|
||||
loadHistory: (_newHistory) => {},
|
||||
pendingItem: null,
|
||||
setPendingItem: (_item) => {},
|
||||
toggleCorgiMode: () => {},
|
||||
toggleVimEnabled: async () => false,
|
||||
setGeminiMdFileCount: (_count) => {},
|
||||
reloadCommands: () => {},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user