Merge tag 'v0.3.0' into chore/sync-gemini-cli-v0.3.0

This commit is contained in:
mingholy.lmh
2025-09-10 21:01:40 +08:00
583 changed files with 30160 additions and 10770 deletions

View File

@@ -4,31 +4,41 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
import { renderWithProviders } from '../test-utils/render.js';
import { AppWrapper as App } from './App.js';
import {
Config as ServerConfig,
MCPServerConfig,
ApprovalMode,
ToolRegistry,
import type {
AccessibilitySettings,
SandboxConfig,
AuthType,
GeminiClient,
ideContext,
type AuthType,
MCPServerConfig,
SandboxConfig,
ToolRegistry,
} from '@qwen-code/qwen-code-core';
import { LoadedSettings, SettingsFile, Settings } from '../config/settings.js';
import {
ApprovalMode,
Config as ServerConfig,
ideContext,
} from '@qwen-code/qwen-code-core';
import { waitFor } from '@testing-library/react';
import { EventEmitter } from 'node:events';
import process from 'node:process';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
import { StreamingState, ConsoleMessageItem } from './types.js';
import { Tips } from './components/Tips.js';
import { checkForUpdates, UpdateObject } from './utils/updateCheck.js';
import { EventEmitter } from 'events';
import { updateEventEmitter } from '../utils/updateEventEmitter.js';
import type { Mock } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as auth from '../config/auth.js';
import {
LoadedSettings,
type Settings,
type SettingsFile,
} from '../config/settings.js';
import { renderWithProviders } from '../test-utils/render.js';
import { updateEventEmitter } from '../utils/updateEventEmitter.js';
import { AppWrapper as App } from './App.js';
import { Tips } from './components/Tips.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import * as useTerminalSize from './hooks/useTerminalSize.js';
import type { ConsoleMessageItem } from './types.js';
import { StreamingState, ToolCallStatus } from './types.js';
import type { UpdateObject } from './utils/updateCheck.js';
import { checkForUpdates } from './utils/updateCheck.js';
// Define a more complete mock server config based on actual Config
interface MockServerConfig {
@@ -52,6 +62,7 @@ interface MockServerConfig {
showMemoryUsage?: boolean;
accessibility?: AccessibilitySettings;
embeddingModel: string;
checkpointing?: boolean;
getApiKey: Mock<() => string>;
getModel: Mock<() => string>;
@@ -66,6 +77,7 @@ interface MockServerConfig {
getToolCallCommand: Mock<() => string | undefined>;
getMcpServerCommand: Mock<() => string | undefined>;
getMcpServers: Mock<() => Record<string, MCPServerConfig> | undefined>;
getPromptRegistry: Mock<() => Record<string, unknown>>;
getExtensions: Mock<
() => Array<{ name: string; version: string; isActive: boolean }>
>;
@@ -83,10 +95,34 @@ interface MockServerConfig {
getShowMemoryUsage: Mock<() => boolean>;
getAccessibility: Mock<() => AccessibilitySettings>;
getProjectRoot: Mock<() => string | undefined>;
getAllGeminiMdFilenames: Mock<() => string[]>;
getEnablePromptCompletion: Mock<() => boolean>;
getGeminiClient: Mock<() => GeminiClient | undefined>;
getCheckpointingEnabled: Mock<() => boolean>;
getAllGeminiMdFilenames: Mock<() => string[]>;
setFlashFallbackHandler: Mock<(handler: (fallback: boolean) => void) => void>;
getSessionId: Mock<() => string>;
getUserTier: Mock<() => Promise<string | undefined>>;
getIdeClient: Mock<() => { getCurrentIde: Mock<() => string | undefined> }>;
getIdeMode: Mock<() => boolean>;
getWorkspaceContext: Mock<
() => {
getDirectories: Mock<() => string[]>;
}
>;
getIdeClient: Mock<
() => {
getCurrentIde: Mock<() => string | undefined>;
getDetectedIdeDisplayName: Mock<() => string>;
addStatusChangeListener: Mock<
(listener: (status: string) => void) => void
>;
removeStatusChangeListener: Mock<
(listener: (status: string) => void) => void
>;
getConnectionStatus: Mock<() => string>;
}
>;
isTrustedFolder: Mock<() => boolean>;
getScreenReader: Mock<() => boolean>;
}
// Mock @qwen-code/qwen-code-core and its Config class
@@ -147,6 +183,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
getShowMemoryUsage: vi.fn(() => opts.showMemoryUsage ?? false),
getAccessibility: vi.fn(() => opts.accessibility ?? {}),
getProjectRoot: vi.fn(() => opts.targetDir),
getEnablePromptCompletion: vi.fn(() => false),
getGeminiClient: vi.fn(() => ({
getUserTier: vi.fn(),
})),
@@ -167,6 +204,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
getConnectionStatus: vi.fn(() => 'connected'),
})),
isTrustedFolder: vi.fn(() => true),
getScreenReader: vi.fn(() => false),
};
});
@@ -193,6 +231,7 @@ vi.mock('./hooks/useGeminiStream', () => ({
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
})),
}));
@@ -209,8 +248,10 @@ vi.mock('./hooks/useAuthCommand', () => ({
vi.mock('./hooks/useFolderTrust', () => ({
useFolderTrust: vi.fn(() => ({
isTrusted: undefined,
isFolderTrustDialogOpen: false,
handleFolderTrustSelect: vi.fn(),
isRestarting: false,
})),
}));
@@ -283,6 +324,10 @@ describe('App UI', () => {
path: '/system/settings.json',
settings: settings.system || {},
};
const systemDefaultsFile: SettingsFile = {
path: '/system/system-defaults.json',
settings: {},
};
const userSettingsFile: SettingsFile = {
path: '/user/settings.json',
settings: settings.user || {},
@@ -293,9 +338,12 @@ describe('App UI', () => {
};
return new LoadedSettings(
systemSettingsFile,
systemDefaultsFile,
userSettingsFile,
workspaceSettingsFile,
[],
true,
new Set(),
);
};
@@ -327,7 +375,9 @@ describe('App UI', () => {
mockConfig.getShowMemoryUsage.mockReturnValue(false); // Default for most tests
// Ensure a theme is set so the theme dialog does not appear.
mockSettings = createMockSettings({ workspace: { theme: 'Default' } });
mockSettings = createMockSettings({
workspace: { ui: { theme: 'Default' } },
});
// Ensure getWorkspaceContext is available if not added by the constructor
if (!mockConfig.getWorkspaceContext) {
@@ -352,9 +402,19 @@ describe('App UI', () => {
beforeEach(async () => {
const { spawn } = await import('node:child_process');
spawnEmitter = new EventEmitter();
spawnEmitter.stdout = new EventEmitter();
spawnEmitter.stderr = new EventEmitter();
(spawn as vi.Mock).mockReturnValue(spawnEmitter);
(
spawnEmitter as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
}
).stdout = new EventEmitter();
(
spawnEmitter as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
}
).stderr = new EventEmitter();
(spawn as Mock).mockReturnValue(spawnEmitter);
});
afterEach(() => {
@@ -368,6 +428,7 @@ describe('App UI', () => {
name: '@qwen-code/qwen-code',
latest: '1.1.0',
current: '1.0.0',
type: 'major' as const,
},
message: 'Qwen Code update available!',
};
@@ -383,9 +444,10 @@ describe('App UI', () => {
);
currentUnmount = unmount;
await new Promise((resolve) => setTimeout(resolve, 10));
expect(spawn).not.toHaveBeenCalled();
// Wait for any potential async operations to complete
await waitFor(() => {
expect(spawn).not.toHaveBeenCalled();
});
});
it('should show a success message when update succeeds', async () => {
@@ -395,6 +457,7 @@ describe('App UI', () => {
name: '@qwen-code/qwen-code',
latest: '1.1.0',
current: '1.0.0',
type: 'major' as const,
},
message: 'Update available',
};
@@ -411,11 +474,12 @@ describe('App UI', () => {
updateEventEmitter.emit('update-success', info);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(lastFrame()).toContain(
'Update successful! The new version will be used on your next run.',
);
// Wait for the success message to appear
await waitFor(() => {
expect(lastFrame()).toContain(
'Update successful! The new version will be used on your next run.',
);
});
});
it('should show an error message when update fails', async () => {
@@ -425,6 +489,7 @@ describe('App UI', () => {
name: '@qwen-code/qwen-code',
latest: '1.1.0',
current: '1.0.0',
type: 'major' as const,
},
message: 'Update available',
};
@@ -441,11 +506,12 @@ describe('App UI', () => {
updateEventEmitter.emit('update-failed', info);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(lastFrame()).toContain(
'Automatic update failed. Please try updating manually',
);
// Wait for the error message to appear
await waitFor(() => {
expect(lastFrame()).toContain(
'Automatic update failed. Please try updating manually',
);
});
});
it('should show an error message when spawn fails', async () => {
@@ -455,6 +521,7 @@ describe('App UI', () => {
name: '@qwen-code/qwen-code',
latest: '1.1.0',
current: '1.0.0',
type: 'major' as const,
},
message: 'Update available',
};
@@ -473,11 +540,12 @@ describe('App UI', () => {
// which is what should be emitted when a spawn error occurs elsewhere.
updateEventEmitter.emit('update-failed', info);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(lastFrame()).toContain(
'Automatic update failed. Please try updating manually',
);
// Wait for the error message to appear
await waitFor(() => {
expect(lastFrame()).toContain(
'Automatic update failed. Please try updating manually',
);
});
});
it('should not auto-update if GEMINI_CLI_DISABLE_AUTOUPDATER is true', async () => {
@@ -488,6 +556,7 @@ describe('App UI', () => {
name: '@qwen-code/qwen-code',
latest: '1.1.0',
current: '1.0.0',
type: 'major' as const,
},
message: 'Update available',
};
@@ -503,9 +572,10 @@ describe('App UI', () => {
);
currentUnmount = unmount;
await new Promise((resolve) => setTimeout(resolve, 10));
expect(spawn).not.toHaveBeenCalled();
// Wait for any potential async operations to complete
await waitFor(() => {
expect(spawn).not.toHaveBeenCalled();
});
});
});
@@ -659,7 +729,10 @@ describe('App UI', () => {
it('should display custom contextFileName in footer when set and count is 1', async () => {
mockSettings = createMockSettings({
workspace: { contextFileName: 'AGENTS.md', theme: 'Default' },
workspace: {
context: { fileName: 'AGENTS.md' },
ui: { theme: 'Default' },
},
});
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['AGENTS.md']);
@@ -681,8 +754,8 @@ describe('App UI', () => {
it('should display a generic message when multiple context files with different names are provided', async () => {
mockSettings = createMockSettings({
workspace: {
contextFileName: ['AGENTS.md', 'CONTEXT.md'],
theme: 'Default',
context: { fileName: ['AGENTS.md', 'CONTEXT.md'] },
ui: { theme: 'Default' },
},
});
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
@@ -707,7 +780,10 @@ describe('App UI', () => {
it('should display custom contextFileName with plural when set and count is > 1', async () => {
mockSettings = createMockSettings({
workspace: { contextFileName: 'MY_NOTES.TXT', theme: 'Default' },
workspace: {
context: { fileName: 'MY_NOTES.TXT' },
ui: { theme: 'Default' },
},
});
mockConfig.getGeminiMdFileCount.mockReturnValue(3);
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
@@ -732,7 +808,10 @@ describe('App UI', () => {
it('should not display context file message if count is 0, even if contextFileName is set', async () => {
mockSettings = createMockSettings({
workspace: { contextFileName: 'ANY_FILE.MD', theme: 'Default' },
workspace: {
context: { fileName: 'ANY_FILE.MD' },
ui: { theme: 'Default' },
},
});
mockConfig.getGeminiMdFileCount.mockReturnValue(0);
mockConfig.getAllGeminiMdFilenames.mockReturnValue([]);
@@ -810,7 +889,7 @@ describe('App UI', () => {
it('should not display Tips component when hideTips is true', async () => {
mockSettings = createMockSettings({
workspace: {
hideTips: true,
ui: { hideTips: true },
},
});
@@ -843,7 +922,7 @@ describe('App UI', () => {
it('should not display Header component when hideBanner is true', async () => {
const { Header } = await import('./components/Header.js');
mockSettings = createMockSettings({
user: { hideBanner: true },
user: { ui: { hideBanner: true } },
});
const { unmount } = renderWithProviders(
@@ -874,7 +953,7 @@ describe('App UI', () => {
it('should not display Footer component when hideFooter is true', async () => {
mockSettings = createMockSettings({
user: { hideFooter: true },
user: { ui: { hideFooter: true } },
});
const { lastFrame, unmount } = renderWithProviders(
@@ -892,9 +971,9 @@ describe('App UI', () => {
it('should show footer if system says show, but workspace and user settings say hide', async () => {
mockSettings = createMockSettings({
system: { hideFooter: false },
user: { hideFooter: true },
workspace: { hideFooter: true },
system: { ui: { hideFooter: false } },
user: { ui: { hideFooter: true } },
workspace: { ui: { hideFooter: true } },
});
const { lastFrame, unmount } = renderWithProviders(
@@ -912,9 +991,9 @@ describe('App UI', () => {
it('should show tips if system says show, but workspace and user settings say hide', async () => {
mockSettings = createMockSettings({
system: { hideTips: false },
user: { hideTips: true },
workspace: { hideTips: true },
system: { ui: { hideTips: false } },
user: { ui: { hideTips: true } },
workspace: { ui: { hideTips: true } },
});
const { unmount } = renderWithProviders(
@@ -995,6 +1074,7 @@ describe('App UI', () => {
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
});
const { lastFrame, unmount } = renderWithProviders(
@@ -1020,6 +1100,7 @@ describe('App UI', () => {
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
});
mockConfig.getGeminiClient.mockReturnValue({
@@ -1089,9 +1170,13 @@ describe('App UI', () => {
const validateAuthMethodSpy = vi.spyOn(auth, 'validateAuthMethod');
mockSettings = createMockSettings({
workspace: {
selectedAuthType: 'USE_GEMINI' as AuthType,
useExternalAuth: false,
theme: 'Default',
security: {
auth: {
selectedType: 'USE_GEMINI' as AuthType,
useExternal: false,
},
},
ui: { theme: 'Default' },
},
});
@@ -1111,9 +1196,13 @@ describe('App UI', () => {
const validateAuthMethodSpy = vi.spyOn(auth, 'validateAuthMethod');
mockSettings = createMockSettings({
workspace: {
selectedAuthType: 'USE_GEMINI' as AuthType,
useExternalAuth: true,
theme: 'Default',
security: {
auth: {
selectedType: 'USE_GEMINI' as AuthType,
useExternal: true,
},
},
ui: { theme: 'Default' },
},
});
@@ -1181,8 +1270,10 @@ describe('App UI', () => {
it('should display the folder trust dialog when isFolderTrustDialogOpen is true', async () => {
const { useFolderTrust } = await import('./hooks/useFolderTrust.js');
vi.mocked(useFolderTrust).mockReturnValue({
isTrusted: undefined,
isFolderTrustDialogOpen: true,
handleFolderTrustSelect: vi.fn(),
isRestarting: false,
});
const { lastFrame, unmount } = renderWithProviders(
@@ -1200,8 +1291,10 @@ describe('App UI', () => {
it('should display the folder trust dialog when the feature is enabled but the folder is not trusted', async () => {
const { useFolderTrust } = await import('./hooks/useFolderTrust.js');
vi.mocked(useFolderTrust).mockReturnValue({
isTrusted: false,
isFolderTrustDialogOpen: true,
handleFolderTrustSelect: vi.fn(),
isRestarting: false,
});
mockConfig.isTrustedFolder.mockReturnValue(false);
@@ -1220,8 +1313,10 @@ describe('App UI', () => {
it('should not display the folder trust dialog when the feature is disabled', async () => {
const { useFolderTrust } = await import('./hooks/useFolderTrust.js');
vi.mocked(useFolderTrust).mockReturnValue({
isTrusted: false,
isFolderTrustDialogOpen: false,
handleFolderTrustSelect: vi.fn(),
isRestarting: false,
});
mockConfig.isTrustedFolder.mockReturnValue(false);
@@ -1239,7 +1334,7 @@ describe('App UI', () => {
});
describe('Message Queuing', () => {
let mockSubmitQuery: typeof vi.fn;
let mockSubmitQuery: Mock;
beforeEach(() => {
mockSubmitQuery = vi.fn();
@@ -1257,6 +1352,7 @@ describe('App UI', () => {
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
});
const { unmount } = renderWithProviders(
@@ -1282,6 +1378,7 @@ describe('App UI', () => {
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
});
const { unmount, rerender } = renderWithProviders(
@@ -1300,6 +1397,7 @@ describe('App UI', () => {
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
});
// Rerender to trigger the useEffect with new state
@@ -1328,7 +1426,8 @@ describe('App UI', () => {
submitQuery: mockSubmitQuery,
initError: null,
pendingHistoryItems: [],
thought: 'Processing...',
thought: { subject: 'Processing', description: 'Processing...' },
cancelOngoingRequest: vi.fn(),
});
const { unmount, lastFrame } = renderWithProviders(
@@ -1356,6 +1455,7 @@ describe('App UI', () => {
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
});
const { unmount, lastFrame } = renderWithProviders(
@@ -1385,6 +1485,7 @@ describe('App UI', () => {
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
});
const { unmount } = renderWithProviders(
@@ -1413,6 +1514,7 @@ describe('App UI', () => {
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
});
const { unmount, lastFrame } = renderWithProviders(
@@ -1440,7 +1542,8 @@ describe('App UI', () => {
submitQuery: mockSubmitQuery,
initError: null,
pendingHistoryItems: [],
thought: 'Processing...',
thought: { subject: 'Processing', description: 'Processing...' },
cancelOngoingRequest: vi.fn(),
});
const { lastFrame, unmount } = renderWithProviders(
@@ -1471,7 +1574,8 @@ describe('App UI', () => {
submitQuery: mockSubmitQuery,
initError: null,
pendingHistoryItems: [],
thought: 'Processing...',
thought: { subject: 'Processing', description: 'Processing...' },
cancelOngoingRequest: vi.fn(),
});
const { lastFrame, unmount } = renderWithProviders(
@@ -1493,4 +1597,142 @@ describe('App UI', () => {
expect(output).toContain('esc to cancel');
});
});
describe('debug keystroke logging', () => {
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
consoleLogSpy.mockRestore();
});
it('should pass debugKeystrokeLogging setting to KeypressProvider', () => {
const mockSettingsWithDebug = createMockSettings({
workspace: {
ui: { theme: 'Default' },
general: { debugKeystrokeLogging: true },
},
});
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettingsWithDebug}
version={mockVersion}
/>,
);
currentUnmount = unmount;
const output = lastFrame();
expect(output).toBeDefined();
expect(mockSettingsWithDebug.merged.general?.debugKeystrokeLogging).toBe(
true,
);
});
it('should use default false value when debugKeystrokeLogging is not set', () => {
const { lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
const output = lastFrame();
expect(output).toBeDefined();
expect(
mockSettings.merged.general?.debugKeystrokeLogging,
).toBeUndefined();
});
});
describe('Ctrl+C behavior', () => {
it('should call cancel but only clear the prompt when a tool is executing', async () => {
const mockCancel = vi.fn();
let onCancelSubmitCallback = () => {};
// Simulate a tool in the "Executing" state.
vi.mocked(useGeminiStream).mockImplementation(
(
_client,
_history,
_addItem,
_config,
_onDebugMessage,
_handleSlashCommand,
_shellModeActive,
_getPreferredEditor,
_onAuthError,
_performMemoryRefresh,
_modelSwitchedFromQuotaError,
_setModelSwitchedFromQuotaError,
_onEditorClose,
onCancelSubmit, // Capture the cancel callback from App.tsx
) => {
onCancelSubmitCallback = onCancelSubmit;
return {
streamingState: StreamingState.Responding,
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [
{
type: 'tool_group',
tools: [
{
name: 'test_tool',
status: ToolCallStatus.Executing,
callId: 'test-call-id',
description: 'Test tool description',
resultDisplay: 'Test result',
confirmationDetails: undefined,
},
],
},
],
thought: null,
cancelOngoingRequest: () => {
mockCancel();
onCancelSubmitCallback(); // <--- This is the key change
},
};
},
);
const { stdin, lastFrame, unmount } = renderWithProviders(
<App
config={mockConfig as unknown as ServerConfig}
settings={mockSettings}
version={mockVersion}
/>,
);
currentUnmount = unmount;
// Simulate user typing something into the prompt while a tool is running.
stdin.write('some text');
await new Promise((resolve) => setTimeout(resolve, 100));
// Verify the text is in the prompt.
expect(lastFrame()).toContain('some text');
// Simulate Ctrl+C.
stdin.write('\x03');
await new Promise((resolve) => setTimeout(resolve, 100));
// The main cancellation handler SHOULD be called.
expect(mockCancel).toHaveBeenCalled();
// The prompt should now be empty as a result of the cancellation handler's logic.
// We can't directly test the buffer's state, but we can see the rendered output.
await waitFor(() => {
expect(lastFrame()).not.toContain('some text');
});
});
});
});

View File

@@ -7,14 +7,20 @@
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import {
Box,
DOMElement,
type DOMElement,
measureElement,
Static,
Text,
useStdin,
useStdout,
} from 'ink';
import { StreamingState, type HistoryItem, MessageType } from './types.js';
import {
StreamingState,
type HistoryItem,
MessageType,
ToolCallStatus,
type HistoryItemWithoutId,
} from './types.js';
import { useTerminalSize } from './hooks/useTerminalSize.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
@@ -43,7 +49,8 @@ import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js
import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js';
import { Colors } from './colors.js';
import { loadHierarchicalGeminiMemory } from '../config/config.js';
import { LoadedSettings, SettingScope } from '../config/settings.js';
import type { LoadedSettings } from '../config/settings.js';
import { SettingScope } from '../config/settings.js';
import { Tips } from './components/Tips.js';
import { ConsolePatcher } from './utils/ConsolePatcher.js';
import { registerCleanup } from '../utils/cleanup.js';
@@ -52,23 +59,22 @@ import { HistoryItemDisplay } from './components/HistoryItemDisplay.js';
import { ContextSummaryDisplay } from './components/ContextSummaryDisplay.js';
import { useHistory } from './hooks/useHistoryManager.js';
import process from 'node:process';
import type { EditorType, Config, IdeContext } from '@qwen-code/qwen-code-core';
import {
getErrorMessage,
type Config,
getAllGeminiMdFilenames,
ApprovalMode,
getAllGeminiMdFilenames,
isEditorAvailable,
EditorType,
FlashFallbackEvent,
logFlashFallback,
getErrorMessage,
AuthType,
type IdeContext,
logFlashFallback,
FlashFallbackEvent,
ideContext,
isProQuotaExceededError,
isGenericQuotaExceededError,
UserTierId,
} from '@qwen-code/qwen-code-core';
import {
IdeIntegrationNudge,
IdeIntegrationNudgeResult,
} from './IdeIntegrationNudge.js';
import type { IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
import { IdeIntegrationNudge } from './IdeIntegrationNudge.js';
import { validateAuthMethod } from '../config/auth.js';
import { useLogger } from './hooks/useLogger.js';
import { StreamingContext } from './contexts/StreamingContext.js';
@@ -82,18 +88,14 @@ import { useBracketedPaste } from './hooks/useBracketedPaste.js';
import { useTextBuffer } from './components/shared/text-buffer.js';
import { useVimMode, VimModeProvider } from './contexts/VimModeContext.js';
import { useVim } from './hooks/vim.js';
import { useKeypress, Key } from './hooks/useKeypress.js';
import type { Key } from './hooks/useKeypress.js';
import { useKeypress } from './hooks/useKeypress.js';
import { KeypressProvider } from './contexts/KeypressContext.js';
import { useKittyKeyboardProtocol } from './hooks/useKittyKeyboardProtocol.js';
import { keyMatchers, Command } from './keyMatchers.js';
import * as fs from 'fs';
import * as fs from 'node:fs';
import { UpdateNotification } from './components/UpdateNotification.js';
import {
isProQuotaExceededError,
isGenericQuotaExceededError,
UserTierId,
} from '@qwen-code/qwen-code-core';
import { UpdateObject } from './utils/updateCheck.js';
import type { UpdateObject } from './utils/updateCheck.js';
import ansiEscapes from 'ansi-escapes';
import { OverflowProvider } from './contexts/OverflowContext.js';
import { ShowMoreLines } from './components/ShowMoreLines.js';
@@ -103,6 +105,8 @@ import { SettingsDialog } from './components/SettingsDialog.js';
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
import { appEvents, AppEvent } from '../utils/events.js';
import { isNarrowWidth } from './utils/isNarrowWidth.js';
import { useWorkspaceMigration } from './hooks/useWorkspaceMigration.js';
import { WorkspaceMigrationDialog } from './components/WorkspaceMigrationDialog.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
// Maximum number of queued messages to display in UI to prevent performance issues
@@ -115,12 +119,26 @@ interface AppProps {
version: string;
}
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
return pendingHistoryItems.some((item) => {
if (item && item.type === 'tool_group') {
return item.tools.some(
(tool) => ToolCallStatus.Executing === tool.status,
);
}
return false;
});
}
export const AppWrapper = (props: AppProps) => {
const kittyProtocolStatus = useKittyKeyboardProtocol();
return (
<KeypressProvider
kittyProtocolEnabled={kittyProtocolStatus.enabled}
config={props.config}
debugKeystrokeLogging={
props.settings.merged.general?.debugKeystrokeLogging
}
>
<SessionStatsProvider>
<VimModeProvider settings={props.settings}>
@@ -147,7 +165,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const shouldShowIdePrompt =
currentIDE &&
!config.getIdeMode() &&
!settings.merged.hasSeenIdeIntegrationNudge &&
!settings.merged.ide?.hasSeenNudge &&
!idePromptAnswered;
useEffect(() => {
@@ -211,6 +229,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
>();
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const {
showWorkspaceMigrationDialog,
workspaceExtensions,
onWorkspaceMigrationDialogOpen,
onWorkspaceMigrationDialogClose,
} = useWorkspaceMigration(settings);
useEffect(() => {
const unsubscribe = ideContext.subscribeToIdeContext(setIdeContextState);
@@ -269,10 +293,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
useSettingsCommand();
const { isFolderTrustDialogOpen, handleFolderTrustSelect } = useFolderTrust(
settings,
setIsTrustedFolder,
);
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
useFolderTrust(settings, setIsTrustedFolder);
const {
isAuthDialogOpen,
@@ -292,16 +314,21 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
} = useQwenAuth(settings, isAuthenticating);
useEffect(() => {
if (settings.merged.selectedAuthType && !settings.merged.useExternalAuth) {
const error = validateAuthMethod(settings.merged.selectedAuthType);
if (
settings.merged.security?.auth?.selectedType &&
!settings.merged.security?.auth?.useExternal
) {
const error = validateAuthMethod(
settings.merged.security.auth.selectedType,
);
if (error) {
setAuthError(error);
openAuthDialog();
}
}
}, [
settings.merged.selectedAuthType,
settings.merged.useExternalAuth,
settings.merged.security?.auth?.selectedType,
settings.merged.security?.auth?.useExternal,
openAuthDialog,
setAuthError,
]);
@@ -357,14 +384,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
try {
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
process.cwd(),
settings.merged.loadMemoryFromIncludeDirectories
settings.merged.context?.loadMemoryFromIncludeDirectories
? config.getWorkspaceContext().getDirectories()
: [],
config.getDebugMode(),
config.getFileService(),
settings.merged,
config.getExtensionContextFilePaths(),
settings.merged.memoryImportFormat || 'tree', // Use setting or default to 'tree'
settings.merged.context?.importFormat || 'tree', // Use setting or default to 'tree'
config.getFileFilteringOptions(),
);
@@ -522,7 +549,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
}, []);
const getPreferredEditor = useCallback(() => {
const editorType = settings.merged.preferredEditor;
const editorType = settings.merged.general?.preferredEditor;
const isValidEditor = isEditorAvailable(editorType);
if (!isValidEditor) {
openEditorDialog();
@@ -608,6 +635,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
() => cancelHandlerRef.current(),
);
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
);
// Message queue for handling input during streaming
const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } =
useMessageQueue({
@@ -617,6 +649,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
// Update the cancel handler with message queue support
cancelHandlerRef.current = useCallback(() => {
if (isToolExecuting(pendingHistoryItems)) {
buffer.setText(''); // Just clear the prompt
return;
}
const lastUserMessage = userMessages.at(-1);
let textToSet = lastUserMessage || '';
@@ -630,7 +667,13 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
if (textToSet) {
buffer.setText(textToSet);
}
}, [buffer, userMessages, getQueuedMessagesText, clearQueue]);
}, [
buffer,
userMessages,
getQueuedMessagesText,
clearQueue,
pendingHistoryItems,
]);
// Input handling - queue messages for processing
const handleFinalSubmit = useCallback(
@@ -666,12 +709,10 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
);
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
const pendingHistoryItems = [...pendingSlashCommandHistoryItems];
pendingHistoryItems.push(...pendingGeminiHistoryItems);
const { elapsedTime, currentLoadingPhrase } =
useLoadingIndicator(streamingState);
const showAutoAcceptIndicator = useAutoAcceptIndicator({ config });
const showAutoAcceptIndicator = useAutoAcceptIndicator({ config, addItem });
const handleExit = useCallback(
(
@@ -698,6 +739,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const handleGlobalKeypress = useCallback(
(key: Key) => {
// Debug log keystrokes if enabled
if (settings.merged.general?.debugKeystrokeLogging) {
console.log('[DEBUG] Keystroke:', JSON.stringify(key));
}
let enteringConstrainHeightMode = false;
if (!constrainHeight) {
enteringConstrainHeightMode = true;
@@ -761,6 +807,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
handleSlashCommand,
isAuthenticating,
cancelOngoingRequest,
settings.merged.general?.debugKeystrokeLogging,
],
);
@@ -774,7 +821,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
}
}, [config, config.getGeminiMdFileCount]);
const logger = useLogger();
const logger = useLogger(config.storage);
useEffect(() => {
const fetchUserMessages = async () => {
@@ -876,12 +923,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const branchName = useGitBranchName(config.getTargetDir());
const contextFileNames = useMemo(() => {
const fromSettings = settings.merged.contextFileName;
const fromSettings = settings.merged.context?.fileName;
if (fromSettings) {
return Array.isArray(fromSettings) ? fromSettings : [fromSettings];
}
return getAllGeminiMdFilenames();
}, [settings.merged.contextFileName]);
}, [settings.merged.context?.fileName]);
const initialPrompt = useMemo(() => config.getQuestion(), [config]);
const geminiClient = config.getGeminiClient();
@@ -957,10 +1004,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
key={staticKey}
items={[
<Box flexDirection="column" key="header">
{!settings.merged.hideBanner && (
<Header version={version} nightly={nightly} />
{!(
settings.merged.ui?.hideBanner || config.getScreenReader()
) && <Header version={version} nightly={nightly} />}
{!(settings.merged.ui?.hideTips || config.getScreenReader()) && (
<Tips config={config} />
)}
{!settings.merged.hideTips && <Tips config={config} />}
</Box>,
...history.map((h) => (
<HistoryItemDisplay
@@ -1016,14 +1065,22 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
))}
</Box>
)}
{shouldShowIdePrompt && currentIDE ? (
{showWorkspaceMigrationDialog ? (
<WorkspaceMigrationDialog
workspaceExtensions={workspaceExtensions}
onOpen={onWorkspaceMigrationDialogOpen}
onClose={onWorkspaceMigrationDialogClose}
/>
) : shouldShowIdePrompt && currentIDE ? (
<IdeIntegrationNudge
ide={currentIDE}
onComplete={handleIdePromptComplete}
/>
) : isFolderTrustDialogOpen ? (
<FolderTrustDialog onSelect={handleFolderTrustSelect} />
<FolderTrustDialog
onSelect={handleFolderTrustSelect}
isRestarting={isRestarting}
/>
) : shellConfirmationRequest ? (
<ShellConfirmationDialog request={shellConfirmationRequest} />
) : confirmationRequest ? (
@@ -1146,12 +1203,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
<LoadingIndicator
thought={
streamingState === StreamingState.WaitingForConfirmation ||
config.getAccessibility()?.disableLoadingPhrases
config.getAccessibility()?.disableLoadingPhrases ||
config.getScreenReader()
? undefined
: thought
}
currentLoadingPhrase={
config.getAccessibility()?.disableLoadingPhrases
config.getAccessibility()?.disableLoadingPhrases ||
config.getScreenReader()
? undefined
: currentLoadingPhrase
}
@@ -1182,8 +1241,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
<Box paddingLeft={2}>
<Text dimColor>
... (+
{messageQueue.length -
MAX_DISPLAYED_QUEUED_MESSAGES}{' '}
{messageQueue.length - MAX_DISPLAYED_QUEUED_MESSAGES}
more)
</Text>
</Box>
@@ -1303,7 +1361,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
)}
</Box>
)}
{!settings.merged.hideFooter && (
{!settings.merged.ui?.hideFooter && (
<Footer
model={currentModel}
targetDir={config.getTargetDir()}
@@ -1315,7 +1373,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
showErrorDetails={showErrorDetails}
showMemoryUsage={
config.getDebugMode() ||
settings.merged.showMemoryUsage ||
settings.merged.ui?.showMemoryUsage ||
false
}
promptTokenCount={sessionStats.lastPromptTokenCount}

View File

@@ -4,12 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { DetectedIde, getIdeInfo } from '@qwen-code/qwen-code-core';
import { type DetectedIde, getIdeInfo } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink';
import {
RadioButtonSelect,
RadioSelectItem,
} from './components/shared/RadioButtonSelect.js';
import type { RadioSelectItem } from './components/shared/RadioButtonSelect.js';
import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js';
import { useKeypress } from './hooks/useKeypress.js';
export type IdeIntegrationNudgeResult = {
@@ -88,7 +86,7 @@ export function IdeIntegrationNudge({
<Box marginBottom={1} flexDirection="column">
<Text>
<Text color="yellow">{'> '}</Text>
{`Do you want to connect ${ideName ?? 'your'} editor to Qwen Code?`}
{`Do you want to connect ${ideName ?? 'your editor'} to Qwen Code?`}
</Text>
<Text dimColor>{installText}</Text>
</Box>

View File

@@ -5,7 +5,7 @@
*/
import { themeManager } from './themes/theme-manager.js';
import { ColorsTheme } from './themes/theme.js';
import type { ColorsTheme } from './themes/theme.js';
export const Colors: ColorsTheme = {
get type() {

View File

@@ -11,7 +11,7 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js
import * as versionUtils from '../../utils/version.js';
import { MessageType } from '../types.js';
import { IdeClient } from '../../../../core/src/ide/ide-client.js';
import type { IdeClient } from '../../../../core/src/ide/ide-client.js';
vi.mock('../../utils/version.js', () => ({
getCliVersion: vi.fn(),
@@ -32,7 +32,11 @@ describe('aboutCommand', () => {
},
settings: {
merged: {
selectedAuthType: 'test-auth',
security: {
auth: {
selectedType: 'test-auth',
},
},
},
},
},

View File

@@ -5,7 +5,8 @@
*/
import { getCliVersion } from '../../utils/version.js';
import { CommandKind, SlashCommand } from './types.js';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import process from 'node:process';
import { MessageType, type HistoryItemAbout } from '../types.js';
@@ -26,7 +27,7 @@ export const aboutCommand: SlashCommand = {
const modelVersion = context.services.config?.getModel() || 'Unknown';
const cliVersion = await getCliVersion();
const selectedAuthType =
context.services.settings.merged.selectedAuthType || '';
context.services.settings.merged.security?.auth?.selectedType || '';
const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || '';
const ideClient =
(context.services.config?.getIdeMode() &&

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
export const authCommand: SlashCommand = {
name: 'auth',

View File

@@ -4,29 +4,22 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
vi,
describe,
it,
expect,
beforeEach,
afterEach,
Mocked,
} from 'vitest';
import type { Mocked } from 'vitest';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
type CommandContext,
import type {
MessageActionReturn,
SlashCommand,
CommandContext,
} from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { Content } from '@google/genai';
import { GeminiClient } from '@qwen-code/qwen-code-core';
import type { Content } from '@google/genai';
import type { GeminiClient } from '@qwen-code/qwen-code-core';
import * as fsPromises from 'fs/promises';
import * as fsPromises from 'node:fs/promises';
import { chatCommand } from './chatCommand.js';
import { Stats } from 'fs';
import { HistoryItemWithoutId } from '../types.js';
import type { Stats } from 'node:fs';
import type { HistoryItemWithoutId } from '../types.js';
vi.mock('fs/promises', () => ({
stat: vi.fn(),
@@ -67,11 +60,14 @@ describe('chatCommand', () => {
mockContext = createMockCommandContext({
services: {
config: {
getProjectTempDir: () => '/tmp/gemini',
getProjectRoot: () => '/project/root',
getGeminiClient: () =>
({
getChat: mockGetChat,
}) as unknown as GeminiClient,
storage: {
getProjectTempDir: () => '/project/root/.gemini/tmp/mockhash',
},
},
logger: {
saveCheckpoint: mockSaveCheckpoint,

View File

@@ -4,20 +4,21 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fsPromises from 'fs/promises';
import * as fsPromises from 'node:fs/promises';
import React from 'react';
import { Text } from 'ink';
import { Colors } from '../colors.js';
import {
import type {
CommandContext,
SlashCommand,
MessageActionReturn,
CommandKind,
SlashCommandActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import { decodeTagName } from '@qwen-code/qwen-code-core';
import path from 'path';
import { HistoryItemWithoutId, MessageType } from '../types.js';
import path from 'node:path';
import type { HistoryItemWithoutId } from '../types.js';
import { MessageType } from '../types.js';
interface ChatDetail {
name: string;
@@ -28,7 +29,8 @@ const getSavedChatTags = async (
context: CommandContext,
mtSortDesc: boolean,
): Promise<ChatDetail[]> => {
const geminiDir = context.services.config?.getProjectTempDir();
const cfg = context.services.config;
const geminiDir = cfg?.storage?.getProjectTempDir();
if (!geminiDir) {
return [];
}

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
import type { Mock } from 'vitest';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { clearCommand } from './clearCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
@@ -20,7 +21,8 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
};
});
import { GeminiClient, uiTelemetryService } from '@qwen-code/qwen-code-core';
import type { GeminiClient } from '@qwen-code/qwen-code-core';
import { uiTelemetryService } from '@qwen-code/qwen-code-core';
describe('clearCommand', () => {
let mockContext: CommandContext;

View File

@@ -5,7 +5,8 @@
*/
import { uiTelemetryService } from '@qwen-code/qwen-code-core';
import { CommandKind, SlashCommand } from './types.js';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
export const clearCommand: SlashCommand = {
name: 'clear',

View File

@@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { GeminiClient } from '@qwen-code/qwen-code-core';
import {
CompressionStatus,
type ChatCompressionInfo,
type GeminiClient,
} from '@qwen-code/qwen-code-core';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { compressCommand } from './compressCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
@@ -35,6 +39,7 @@ describe('compressCommand', () => {
isPending: true,
originalTokenCount: null,
newTokenCount: null,
compressionStatus: null,
},
};
await compressCommand.action!(context, '');
@@ -50,25 +55,24 @@ describe('compressCommand', () => {
});
it('should set pending item, call tryCompressChat, and add result on success', async () => {
const compressedResult = {
const compressedResult: ChatCompressionInfo = {
originalTokenCount: 200,
compressionStatus: CompressionStatus.COMPRESSED,
newTokenCount: 100,
};
mockTryCompressChat.mockResolvedValue(compressedResult);
await compressCommand.action!(context, '');
expect(context.ui.setPendingItem).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
type: MessageType.COMPRESSION,
compression: {
isPending: true,
originalTokenCount: null,
newTokenCount: null,
},
}),
);
expect(context.ui.setPendingItem).toHaveBeenNthCalledWith(1, {
type: MessageType.COMPRESSION,
compression: {
isPending: true,
compressionStatus: null,
originalTokenCount: null,
newTokenCount: null,
},
});
expect(mockTryCompressChat).toHaveBeenCalledWith(
expect.stringMatching(/^compress-\d+$/),
@@ -76,14 +80,15 @@ describe('compressCommand', () => {
);
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
{
type: MessageType.COMPRESSION,
compression: {
isPending: false,
compressionStatus: CompressionStatus.COMPRESSED,
originalTokenCount: 200,
newTokenCount: 100,
},
}),
},
expect.any(Number),
);

View File

@@ -4,8 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { HistoryItemCompression, MessageType } from '../types.js';
import { CommandKind, SlashCommand } from './types.js';
import type { HistoryItemCompression } from '../types.js';
import { MessageType } from '../types.js';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
export const compressCommand: SlashCommand = {
name: 'compress',
@@ -31,6 +33,7 @@ export const compressCommand: SlashCommand = {
isPending: true,
originalTokenCount: null,
newTokenCount: null,
compressionStatus: null,
},
};
@@ -48,6 +51,7 @@ export const compressCommand: SlashCommand = {
isPending: false,
originalTokenCount: compressed.originalTokenCount,
newTokenCount: compressed.newTokenCount,
compressionStatus: compressed.compressionStatus,
},
} as HistoryItemCompression,
Date.now(),

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
import type { Mock } from 'vitest';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { copyCommand } from './copyCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
@@ -227,7 +228,7 @@ describe('copyCommand', () => {
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Failed to copy to the clipboard.',
content: `Failed to copy to the clipboard. ${clipboardError.message}`,
});
});
@@ -242,14 +243,15 @@ describe('copyCommand', () => {
];
mockGetHistory.mockReturnValue(historyWithAiMessage);
mockCopyToClipboard.mockRejectedValue('String error');
const rejectedValue = 'String error';
mockCopyToClipboard.mockRejectedValue(rejectedValue);
const result = await copyCommand.action(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Failed to copy to the clipboard.',
content: `Failed to copy to the clipboard. ${rejectedValue}`,
});
});

View File

@@ -5,11 +5,8 @@
*/
import { copyToClipboard } from '../utils/commandUtils.js';
import {
CommandKind,
SlashCommand,
SlashCommandActionReturn,
} from './types.js';
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
export const copyCommand: SlashCommand = {
name: 'copy',
@@ -53,7 +50,7 @@ export const copyCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Failed to copy to the clipboard.',
content: `Failed to copy to the clipboard. ${message}`,
};
}
} else {

View File

@@ -6,11 +6,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { directoryCommand, expandHomeDir } from './directoryCommand.js';
import { Config, WorkspaceContext } from '@qwen-code/qwen-code-core';
import { CommandContext } from './types.js';
import type { Config, WorkspaceContext } from '@qwen-code/qwen-code-core';
import type { CommandContext } from './types.js';
import { MessageType } from '../types.js';
import * as os from 'os';
import * as path from 'path';
import * as os from 'node:os';
import * as path from 'node:path';
describe('directoryCommand', () => {
let mockContext: CommandContext;

View File

@@ -4,10 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { SlashCommand, CommandContext, CommandKind } from './types.js';
import type { SlashCommand, CommandContext } from './types.js';
import { CommandKind } from './types.js';
import { MessageType } from '../types.js';
import * as os from 'os';
import * as path from 'path';
import * as os from 'node:os';
import * as path from 'node:path';
import { loadServerHierarchicalMemory } from '@qwen-code/qwen-code-core';
export function expandHomeDir(p: string): string {
@@ -91,34 +92,36 @@ export const directoryCommand: SlashCommand = {
}
}
if (added.length > 0) {
try {
if (config.shouldLoadMemoryFromIncludeDirectories()) {
const { memoryContent, fileCount } =
await loadServerHierarchicalMemory(
config.getWorkingDir(),
[...config.getWorkspaceContext().getDirectories()],
config.getDebugMode(),
config.getFileService(),
config.getExtensionContextFilePaths(),
context.services.settings.merged.memoryImportFormat || 'tree', // Use setting or default to 'tree'
config.getFileFilteringOptions(),
context.services.settings.merged.memoryDiscoveryMaxDirs,
);
config.setUserMemory(memoryContent);
config.setGeminiMdFileCount(fileCount);
context.ui.setGeminiMdFileCount(fileCount);
}
addItem(
{
type: MessageType.INFO,
text: `Successfully added memory files from the following directories if there are:\n- ${added.join('\n- ')}`,
},
Date.now(),
);
} catch (error) {
errors.push(`Error refreshing memory: ${(error as Error).message}`);
try {
if (config.shouldLoadMemoryFromIncludeDirectories()) {
const { memoryContent, fileCount } =
await loadServerHierarchicalMemory(
config.getWorkingDir(),
[
...config.getWorkspaceContext().getDirectories(),
...pathsToAdd,
],
config.getDebugMode(),
config.getFileService(),
config.getExtensionContextFilePaths(),
context.services.settings.merged.context?.importFormat ||
'tree', // Use setting or default to 'tree'
config.getFileFilteringOptions(),
context.services.settings.merged.context?.discoveryMaxDirs,
);
config.setUserMemory(memoryContent);
config.setGeminiMdFileCount(fileCount);
context.ui.setGeminiMdFileCount(fileCount);
}
addItem(
{
type: MessageType.INFO,
text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`,
},
Date.now(),
);
} catch (error) {
errors.push(`Error refreshing memory: ${(error as Error).message}`);
}
if (added.length > 0) {

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, SlashCommand } from './types.js';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { MessageType, type HistoryItemHelp } from '../types.js';
export const helpCommand: SlashCommand = {

View File

@@ -4,15 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
MockInstance,
vi,
describe,
it,
expect,
beforeEach,
afterEach,
} from 'vitest';
import type { MockInstance } from 'vitest';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { ideCommand } from './ideCommand.js';
import { type CommandContext } from './types.js';
import { type Config, DetectedIde } from '@qwen-code/qwen-code-core';
@@ -20,7 +13,14 @@ import * as core from '@qwen-code/qwen-code-core';
vi.mock('child_process');
vi.mock('glob');
vi.mock('@qwen-code/qwen-code-core');
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original = await importOriginal<typeof core>();
return {
...original,
getOauthClient: vi.fn(original.getOauthClient),
getIdeInstaller: vi.fn(original.getIdeInstaller),
};
});
describe('ideCommand', () => {
let mockContext: CommandContext;

View File

@@ -4,24 +4,20 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config, IdeClient, File } from '@qwen-code/qwen-code-core';
import {
Config,
DetectedIde,
QWEN_CODE_COMPANION_EXTENSION_NAME,
IDEConnectionStatus,
getIdeInfo,
getIdeInstaller,
IdeClient,
type File,
IDEConnectionStatus,
ideContext,
} from '@qwen-code/qwen-code-core';
import path from 'node:path';
import {
import type {
CommandContext,
SlashCommand,
SlashCommandActionReturn,
CommandKind,
} from './types.js';
import { CommandKind } from './types.js';
import { SettingScope } from '../../config/settings.js';
function getIdeStatusMessage(ideClient: IdeClient): {
@@ -130,11 +126,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
({
type: 'message',
messageType: 'error',
content: `IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: ${Object.values(
DetectedIde,
)
.map((ide) => getIdeInfo(ide).displayName)
.join(', ')}`,
content: `IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.`,
}) as const,
};
}
@@ -195,7 +187,11 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
Date.now(),
);
if (result.success) {
context.services.settings.setValue(SettingScope.User, 'ideMode', true);
context.services.settings.setValue(
SettingScope.User,
'ide.enabled',
true,
);
// Poll for up to 5 seconds for the extension to activate.
for (let i = 0; i < 10; i++) {
await config.setIdeModeAndSyncConnection(true);
@@ -235,7 +231,11 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
description: 'enable IDE integration',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
context.services.settings.setValue(SettingScope.User, 'ideMode', true);
context.services.settings.setValue(
SettingScope.User,
'ide.enabled',
true,
);
await config.setIdeModeAndSyncConnection(true);
const { messageType, content } = getIdeStatusMessage(ideClient);
context.ui.addItem(
@@ -253,7 +253,11 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
description: 'disable IDE integration',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext) => {
context.services.settings.setValue(SettingScope.User, 'ideMode', false);
context.services.settings.setValue(
SettingScope.User,
'ide.enabled',
false,
);
await config.setIdeModeAndSyncConnection(false);
const { messageType, content } = getIdeStatusMessage(ideClient);
context.ui.addItem(

View File

@@ -5,8 +5,8 @@
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { initCommand } from './initCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { type CommandContext } from './types.js';

View File

@@ -4,15 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'fs';
import * as path from 'path';
import {
import * as fs from 'node:fs';
import * as path from 'node:path';
import type {
CommandContext,
SlashCommand,
SlashCommandActionReturn,
CommandKind,
} from './types.js';
import { getCurrentGeminiMdFilename } from '@qwen-code/qwen-code-core';
import { CommandKind } from './types.js';
export const initCommand: SlashCommand = {
name: 'init',

View File

@@ -15,8 +15,9 @@ import {
DiscoveredMCPTool,
} from '@qwen-code/qwen-code-core';
import { MessageActionReturn } from './types.js';
import { Type, CallableTool } from '@google/genai';
import type { MessageActionReturn } from './types.js';
import type { CallableTool } from '@google/genai';
import { Type } from '@google/genai';
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =

View File

@@ -4,15 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
import type {
SlashCommand,
SlashCommandActionReturn,
CommandContext,
CommandKind,
MessageActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import type { DiscoveredMCPPrompt } from '@qwen-code/qwen-code-core';
import {
DiscoveredMCPPrompt,
DiscoveredMCPTool,
getMCPDiscoveryState,
getMCPServerStatus,

View File

@@ -4,12 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
import type { Mock } from 'vitest';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { memoryCommand } from './memoryCommand.js';
import { type CommandContext, SlashCommand } from './types.js';
import type { SlashCommand, type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
import { LoadedSettings } from '../../config/settings.js';
import type { LoadedSettings } from '../../config/settings.js';
import {
getErrorMessage,
loadServerHierarchicalMemory,

View File

@@ -13,11 +13,8 @@ import path from 'node:path';
import os from 'os';
import fs from 'fs/promises';
import { MessageType } from '../types.js';
import {
CommandKind,
SlashCommand,
SlashCommandActionReturn,
} from './types.js';
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
export const memoryCommand: SlashCommand = {
name: 'memory',
@@ -269,9 +266,10 @@ export const memoryCommand: SlashCommand = {
config.getDebugMode(),
config.getFileService(),
config.getExtensionContextFilePaths(),
context.services.settings.merged.memoryImportFormat || 'tree', // Use setting or default to 'tree'
context.services.settings.merged.context?.importFormat ||
'tree', // Use setting or default to 'tree'
config.getFileFilteringOptions(),
context.services.settings.merged.memoryDiscoveryMaxDirs,
context.services.settings.merged.context?.discoveryMaxDirs,
);
config.setUserMemory(memoryContent);
config.setGeminiMdFileCount(fileCount);

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
export const privacyCommand: SlashCommand = {
name: 'privacy',

View File

@@ -5,13 +5,13 @@
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { restoreCommand } from './restoreCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { Config, GitService } from '@qwen-code/qwen-code-core';
import type { Config, GitService } from '@qwen-code/qwen-code-core';
describe('restoreCommand', () => {
let mockContext: CommandContext;
@@ -39,7 +39,10 @@ describe('restoreCommand', () => {
mockConfig = {
getCheckpointingEnabled: vi.fn().mockReturnValue(true),
getProjectTempDir: vi.fn().mockReturnValue(geminiTempDir),
storage: {
getProjectTempCheckpointsDir: vi.fn().mockReturnValue(checkpointsDir),
getProjectTempDir: vi.fn().mockReturnValue(geminiTempDir),
},
getGeminiClient: vi.fn().mockReturnValue({
setHistory: mockSetHistory,
}),
@@ -77,7 +80,9 @@ describe('restoreCommand', () => {
describe('action', () => {
it('should return an error if temp dir is not found', async () => {
vi.mocked(mockConfig.getProjectTempDir).mockReturnValue('');
vi.mocked(
mockConfig.storage.getProjectTempCheckpointsDir,
).mockReturnValue('');
expect(
await restoreCommand(mockConfig)?.action?.(mockContext, ''),
@@ -219,7 +224,7 @@ describe('restoreCommand', () => {
describe('completion', () => {
it('should return an empty array if temp dir is not found', async () => {
vi.mocked(mockConfig.getProjectTempDir).mockReturnValue('');
vi.mocked(mockConfig.storage.getProjectTempDir).mockReturnValue('');
const command = restoreCommand(mockConfig);
expect(await command?.completion?.(mockContext, '')).toEqual([]);

View File

@@ -4,15 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'fs/promises';
import path from 'path';
import * as fs from 'node:fs/promises';
import path from 'node:path';
import {
type CommandContext,
type SlashCommand,
type SlashCommandActionReturn,
CommandKind,
} from './types.js';
import { Config } from '@qwen-code/qwen-code-core';
import type { Config } from '@qwen-code/qwen-code-core';
async function restoreAction(
context: CommandContext,
@@ -22,9 +22,7 @@ async function restoreAction(
const { config, git: gitService } = services;
const { addItem, loadHistory } = ui;
const checkpointDir = config?.getProjectTempDir()
? path.join(config.getProjectTempDir(), 'checkpoints')
: undefined;
const checkpointDir = config?.storage.getProjectTempCheckpointsDir();
if (!checkpointDir) {
return {
@@ -125,9 +123,7 @@ async function completion(
): Promise<string[]> {
const { services } = context;
const { config } = services;
const checkpointDir = config?.getProjectTempDir()
? path.join(config.getProjectTempDir(), 'checkpoints')
: undefined;
const checkpointDir = config?.storage.getProjectTempCheckpointsDir();
if (!checkpointDir) {
return [];
}

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
export const settingsCommand: SlashCommand = {
name: 'settings',

View File

@@ -15,7 +15,7 @@ import {
updateGitignore,
GITHUB_WORKFLOW_PATHS,
} from './setupGithubCommand.js';
import { CommandContext, ToolActionReturn } from './types.js';
import type { CommandContext, ToolActionReturn } from './types.js';
import * as commandUtils from '../utils/commandUtils.js';
vi.mock('child_process');

View File

@@ -9,7 +9,7 @@ import * as fs from 'node:fs';
import { Writable } from 'node:stream';
import { ProxyAgent } from 'undici';
import { CommandContext } from '../../ui/commands/types.js';
import type { CommandContext } from '../../ui/commands/types.js';
import {
getGitRepoRoot,
getLatestGitHubRelease,
@@ -17,11 +17,8 @@ import {
getGitHubRepoInfo,
} from '../../utils/gitUtils.js';
import {
CommandKind,
SlashCommand,
SlashCommandActionReturn,
} from './types.js';
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
export const GITHUB_WORKFLOW_PATHS = [

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { MessageType, HistoryItemStats } from '../types.js';
import type { HistoryItemStats } from '../types.js';
import { MessageType } from '../types.js';
import { formatDuration } from '../utils/formatters.js';
import {
type CommandContext,

View File

@@ -7,7 +7,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { terminalSetupCommand } from './terminalSetupCommand.js';
import * as terminalSetupModule from '../utils/terminalSetup.js';
import { CommandContext } from './types.js';
import type { CommandContext } from './types.js';
vi.mock('../utils/terminalSetup.js');

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { MessageActionReturn, SlashCommand, CommandKind } from './types.js';
import type { MessageActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { terminalSetup } from '../utils/terminalSetup.js';
/**

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
export const themeCommand: SlashCommand = {
name: 'theme',

View File

@@ -4,11 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect } from 'vitest';
import { toolsCommand } from './toolsCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
import { Tool } from '@qwen-code/qwen-code-core';
import type { Tool } from '@qwen-code/qwen-code-core';
// Mock tools for testing
const mockTools = [

View File

@@ -13,7 +13,7 @@ import { MessageType } from '../types.js';
export const toolsCommand: SlashCommand = {
name: 'tools',
description: 'list available Qwen Codetools',
description: 'list available Qwen Code tools. Usage: /tools [desc]',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args?: string): Promise<void> => {
const subCommand = args?.trim();

View File

@@ -5,13 +5,12 @@
*/
import { type ReactNode } from 'react';
import { Content } from '@google/genai';
import { HistoryItemWithoutId } from '../types.js';
import { Config, GitService, Logger } from '@qwen-code/qwen-code-core';
import { LoadedSettings } from '../../config/settings.js';
import { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import type { HistoryItem } from '../types.js';
import { SessionStatsState } from '../contexts/SessionContext.js';
import type { Content, PartListUnion } from '@google/genai';
import type { HistoryItemWithoutId, HistoryItem } from '../types.js';
import type { Config, GitService, Logger } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import type { SessionStatsState } from '../contexts/SessionContext.js';
// Grouped dependencies for clarity and easier mocking
export interface CommandContext {
@@ -123,7 +122,7 @@ export interface LoadHistoryActionReturn {
*/
export interface SubmitPromptActionReturn {
type: 'submit_prompt';
content: string;
content: PartListUnion;
}
/**

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, SlashCommand } from './types.js';
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
export const vimCommand: SlashCommand = {
name: 'vim',

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';

View File

@@ -18,7 +18,7 @@ describe('AuthDialog', () => {
beforeEach(() => {
originalEnv = { ...process.env };
process.env['GEMINI_API_KEY'] = '';
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = '';
process.env['QWEN_DEFAULT_AUTH_TYPE'] = '';
vi.clearAllMocks();
});
@@ -31,20 +31,30 @@ describe('AuthDialog', () => {
const settings: LoadedSettings = new LoadedSettings(
{
settings: { customThemes: {}, mcpServers: {} },
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: {},
path: '',
},
{
settings: {
selectedAuthType: AuthType.USE_GEMINI,
security: {
auth: {
selectedType: AuthType.USE_GEMINI,
},
},
},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
const { lastFrame } = renderWithProviders(
@@ -67,21 +77,27 @@ describe('AuthDialog', () => {
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
customThemes: {},
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: {},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
const { lastFrame } = renderWithProviders(
@@ -93,28 +109,34 @@ describe('AuthDialog', () => {
expect(lastFrame()).toContain('OpenAI');
});
it('should not show the GEMINI_API_KEY message if GEMINI_DEFAULT_AUTH_TYPE is set to something else', () => {
it('should not show the GEMINI_API_KEY message if QWEN_DEFAULT_AUTH_TYPE is set to something else', () => {
process.env['GEMINI_API_KEY'] = 'foobar';
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.LOGIN_WITH_GOOGLE;
process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.LOGIN_WITH_GOOGLE;
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
customThemes: {},
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: {},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
const { lastFrame } = renderWithProviders(
@@ -126,28 +148,34 @@ describe('AuthDialog', () => {
);
});
it('should show the GEMINI_API_KEY message if GEMINI_DEFAULT_AUTH_TYPE is set to use api key', () => {
it('should show the GEMINI_API_KEY message if QWEN_DEFAULT_AUTH_TYPE is set to use api key', () => {
process.env['GEMINI_API_KEY'] = 'foobar';
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.USE_GEMINI;
process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.USE_GEMINI;
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
customThemes: {},
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: {},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
const { lastFrame } = renderWithProviders(
@@ -160,28 +188,34 @@ describe('AuthDialog', () => {
});
});
describe('GEMINI_DEFAULT_AUTH_TYPE environment variable', () => {
it('should select the auth type specified by GEMINI_DEFAULT_AUTH_TYPE', () => {
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.USE_OPENAI;
describe('QWEN_DEFAULT_AUTH_TYPE environment variable', () => {
it('should select the auth type specified by QWEN_DEFAULT_AUTH_TYPE', () => {
process.env['QWEN_DEFAULT_AUTH_TYPE'] = AuthType.USE_OPENAI;
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
customThemes: {},
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: {},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
const { lastFrame } = renderWithProviders(
@@ -192,25 +226,31 @@ describe('AuthDialog', () => {
expect(lastFrame()).toContain('● 2. OpenAI');
});
it('should fall back to default if GEMINI_DEFAULT_AUTH_TYPE is not set', () => {
it('should fall back to default if QWEN_DEFAULT_AUTH_TYPE is not set', () => {
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
customThemes: {},
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: {},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
const { lastFrame } = renderWithProviders(
@@ -221,59 +261,116 @@ describe('AuthDialog', () => {
expect(lastFrame()).toContain('● 1. Qwen OAuth');
});
it('should show an error and fall back to default if GEMINI_DEFAULT_AUTH_TYPE is invalid', () => {
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = 'invalid-auth-type';
it('should show an error and fall back to default if QWEN_DEFAULT_AUTH_TYPE is invalid', () => {
process.env['QWEN_DEFAULT_AUTH_TYPE'] = 'invalid-auth-type';
const settings: LoadedSettings = new LoadedSettings(
{
settings: {
selectedAuthType: undefined,
customThemes: {},
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: {},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
const { lastFrame } = renderWithProviders(
<AuthDialog onSelect={() => {}} settings={settings} />,
);
// Since the auth dialog doesn't show GEMINI_DEFAULT_AUTH_TYPE errors anymore,
// Since the auth dialog doesn't show QWEN_DEFAULT_AUTH_TYPE errors anymore,
// it will just show the default Qwen OAuth option
expect(lastFrame()).toContain('● 1. Qwen OAuth');
});
});
it('should prevent exiting when no auth method is selected and show error message', async () => {
const onSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: {},
path: '',
},
{
settings: {
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
const { lastFrame, stdin, unmount } = renderWithProviders(
<AuthDialog onSelect={onSelect} settings={settings} />,
);
await wait();
// Simulate pressing escape key
stdin.write('\u001b'); // ESC key
await wait();
// Should show error message instead of calling onSelect
expect(lastFrame()).toContain(
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
);
expect(onSelect).not.toHaveBeenCalled();
unmount();
});
it('should not exit if there is already an error message', async () => {
const onSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
{
settings: { customThemes: {}, mcpServers: {} },
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: {},
path: '',
},
{
settings: {
selectedAuthType: undefined,
customThemes: {},
security: { auth: { selectedType: undefined } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
const { lastFrame, stdin, unmount } = renderWithProviders(
@@ -300,22 +397,28 @@ describe('AuthDialog', () => {
const onSelect = vi.fn();
const settings: LoadedSettings = new LoadedSettings(
{
settings: { customThemes: {}, mcpServers: {} },
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
{
settings: {},
path: '',
},
{
settings: {
selectedAuthType: AuthType.USE_GEMINI,
customThemes: {},
security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
ui: { customThemes: {} },
mcpServers: {},
},
path: '',
},
{
settings: { customThemes: {}, mcpServers: {} },
settings: { ui: { customThemes: {} }, mcpServers: {} },
path: '',
},
[],
true,
new Set(),
);
const { stdin, unmount } = renderWithProviders(

View File

@@ -4,16 +4,17 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState } from 'react';
import { AuthType } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink';
import React, { useState } from 'react';
import {
setOpenAIApiKey,
setOpenAIBaseUrl,
setOpenAIModel,
validateAuthMethod,
} from '../../config/auth.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { type LoadedSettings, SettingScope } from '../../config/settings.js';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
@@ -54,12 +55,12 @@ export function AuthDialog({
const initialAuthIndex = Math.max(
0,
items.findIndex((item) => {
if (settings.merged.selectedAuthType) {
return item.value === settings.merged.selectedAuthType;
if (settings.merged.security?.auth?.selectedType) {
return item.value === settings.merged.security?.auth?.selectedType;
}
const defaultAuthType = parseDefaultAuthType(
process.env['GEMINI_DEFAULT_AUTH_TYPE'],
process.env['QWEN_DEFAULT_AUTH_TYPE'],
);
if (defaultAuthType) {
return item.value === defaultAuthType;
@@ -120,7 +121,7 @@ export function AuthDialog({
if (errorMessage) {
return;
}
if (settings.merged.selectedAuthType === undefined) {
if (settings.merged.security?.auth?.selectedType === undefined) {
// Prevent exiting if no auth method is set
setErrorMessage(
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect } from 'react';
import type React from 'react';
import { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import { Colors } from '../colors.js';

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import {

View File

@@ -4,10 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { ConsoleMessageItem } from '../types.js';
import type { ConsoleMessageItem } from '../types.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js';
interface DetailedMessagesDisplayProps {
@@ -56,7 +56,7 @@ export const DetailedMessagesDisplay: React.FC<
break;
case 'debug':
textColor = Colors.Gray; // Or Colors.Gray
icon = '\u1F50D'; // Left-pointing magnifying glass (????)
icon = '\u{1F50D}'; // Left-pointing magnifying glass (🔍)
break;
case 'log':
default:

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState } from 'react';
import type React from 'react';
import { useState } from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import {
@@ -13,8 +14,10 @@ import {
type EditorDisplay,
} from '../editors/editorSettingsManager.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { EditorType, isEditorAvailable } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import type { EditorType } from '@qwen-code/qwen-code-core';
import { isEditorAvailable } from '@qwen-code/qwen-code-core';
import { useKeypress } from '../hooks/useKeypress.js';
interface EditorDialogProps {
@@ -50,7 +53,7 @@ export function EditorSettingsDialog({
editorSettingsManager.getAvailableEditorDisplays();
const currentPreference =
settings.forScope(selectedScope).settings.preferredEditor;
settings.forScope(selectedScope).settings.general?.preferredEditor;
let editorIndex = currentPreference
? editorItems.findIndex(
(item: EditorDisplay) => item.type === currentPreference,
@@ -84,20 +87,26 @@ export function EditorSettingsDialog({
selectedScope === SettingScope.User
? SettingScope.Workspace
: SettingScope.User;
if (settings.forScope(otherScope).settings.preferredEditor !== undefined) {
if (
settings.forScope(otherScope).settings.general?.preferredEditor !==
undefined
) {
otherScopeModifiedMessage =
settings.forScope(selectedScope).settings.preferredEditor !== undefined
settings.forScope(selectedScope).settings.general?.preferredEditor !==
undefined
? `(Also modified in ${otherScope})`
: `(Modified in ${otherScope})`;
}
let mergedEditorName = 'None';
if (
settings.merged.preferredEditor &&
isEditorAvailable(settings.merged.preferredEditor)
settings.merged.general?.preferredEditor &&
isEditorAvailable(settings.merged.general?.preferredEditor)
) {
mergedEditorName =
EDITOR_DISPLAY_NAMES[settings.merged.preferredEditor as EditorType];
EDITOR_DISPLAY_NAMES[
settings.merged.general?.preferredEditor as EditorType
];
}
return (

View File

@@ -8,8 +8,21 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '@testing-library/react';
import { vi } from 'vitest';
import { FolderTrustDialog, FolderTrustChoice } from './FolderTrustDialog.js';
import * as process from 'node:process';
vi.mock('process', async () => {
const actual = await vi.importActual('process');
return {
...actual,
exit: vi.fn(),
};
});
describe('FolderTrustDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render the dialog with title and description', () => {
const { lastFrame } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />,
@@ -21,16 +34,63 @@ describe('FolderTrustDialog', () => {
);
});
it('should call onSelect with DO_NOT_TRUST when escape is pressed', async () => {
it('should call onSelect with DO_NOT_TRUST when escape is pressed and not restarting', async () => {
const onSelect = vi.fn();
const { stdin } = renderWithProviders(
<FolderTrustDialog onSelect={onSelect} />,
<FolderTrustDialog onSelect={onSelect} isRestarting={false} />,
);
stdin.write('\x1b');
stdin.write('\x1b'); // escape key
await waitFor(() => {
expect(onSelect).toHaveBeenCalledWith(FolderTrustChoice.DO_NOT_TRUST);
});
});
it('should not call onSelect when escape is pressed and is restarting', async () => {
const onSelect = vi.fn();
const { stdin } = renderWithProviders(
<FolderTrustDialog onSelect={onSelect} isRestarting={true} />,
);
stdin.write('\x1b'); // escape key
await waitFor(() => {
expect(onSelect).not.toHaveBeenCalled();
});
});
it('should display restart message when isRestarting is true', () => {
const { lastFrame } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
);
expect(lastFrame()).toContain(
'To see changes, Gemini CLI must be restarted',
);
});
it('should call process.exit when "r" is pressed and isRestarting is true', async () => {
const { stdin } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
);
stdin.write('r');
await waitFor(() => {
expect(process.exit).toHaveBeenCalledWith(0);
});
});
it('should not call process.exit when "r" is pressed and isRestarting is false', async () => {
const { stdin } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={false} />,
);
stdin.write('r');
await waitFor(() => {
expect(process.exit).not.toHaveBeenCalled();
});
});
});

View File

@@ -5,13 +5,12 @@
*/
import { Box, Text } from 'ink';
import React from 'react';
import type React from 'react';
import { Colors } from '../colors.js';
import {
RadioButtonSelect,
RadioSelectItem,
} from './shared/RadioButtonSelect.js';
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import * as process from 'node:process';
export enum FolderTrustChoice {
TRUST_FOLDER = 'trust_folder',
@@ -21,10 +20,12 @@ export enum FolderTrustChoice {
interface FolderTrustDialogProps {
onSelect: (choice: FolderTrustChoice) => void;
isRestarting?: boolean;
}
export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
onSelect,
isRestarting,
}) => {
useKeypress(
(key) => {
@@ -32,7 +33,16 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
onSelect(FolderTrustChoice.DO_NOT_TRUST);
}
},
{ isActive: true },
{ isActive: !isRestarting },
);
useKeypress(
(key) => {
if (key.name === 'r') {
process.exit(0);
}
},
{ isActive: !!isRestarting },
);
const options: Array<RadioSelectItem<FolderTrustChoice>> = [
@@ -51,24 +61,38 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
];
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={Colors.AccentYellow}
padding={1}
width="100%"
marginLeft={1}
>
<Box flexDirection="column" marginBottom={1}>
<Text bold>Do you trust this folder?</Text>
<Text>
Trusting a folder allows Gemini to execute commands it suggests. This
is a security feature to prevent accidental execution in untrusted
directories.
</Text>
</Box>
<Box flexDirection="column">
<Box
flexDirection="column"
borderStyle="round"
borderColor={Colors.AccentYellow}
padding={1}
width="100%"
marginLeft={1}
>
<Box flexDirection="column" marginBottom={1}>
<Text bold>Do you trust this folder?</Text>
<Text>
Trusting a folder allows Gemini to execute commands it suggests.
This is a security feature to prevent accidental execution in
untrusted directories.
</Text>
</Box>
<RadioButtonSelect items={options} onSelect={onSelect} isFocused />
<RadioButtonSelect
items={options}
onSelect={onSelect}
isFocused={!isRestarting}
/>
</Box>
{isRestarting && (
<Box marginLeft={1} marginTop={1}>
<Text color={Colors.AccentYellow}>
To see changes, Gemini CLI must be restarted. Press r to exit and
apply changes now.
</Text>
</Box>
)}
</Box>
);
};

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core';

View File

@@ -4,12 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Text } from 'ink';
import type React from 'react';
import { Text, useIsScreenReaderEnabled } from 'ink';
import Spinner from 'ink-spinner';
import type { SpinnerName } from 'cli-spinners';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import {
SCREEN_READER_LOADING,
SCREEN_READER_RESPONDING,
} from '../textConstants.js';
interface GeminiRespondingSpinnerProps {
/**
@@ -24,11 +28,19 @@ export const GeminiRespondingSpinner: React.FC<
GeminiRespondingSpinnerProps
> = ({ nonRespondingDisplay, spinnerType = 'dots' }) => {
const streamingState = useStreamingContext();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
if (streamingState === StreamingState.Responding) {
return <Spinner type={spinnerType} />;
return isScreenReaderEnabled ? (
<Text>{SCREEN_READER_RESPONDING}</Text>
) : (
<Spinner type={spinnerType} />
);
} else if (nonRespondingDisplay) {
return <Text>{nonRespondingDisplay}</Text>;
return isScreenReaderEnabled ? (
<Text>{SCREEN_READER_LOADING}</Text>
) : (
<Text>{nonRespondingDisplay}</Text>
);
}
return null;
};

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import Gradient from 'ink-gradient';
import { Colors } from '../colors.js';

View File

@@ -4,10 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { SlashCommand } from '../commands/types.js';
import type { SlashCommand } from '../commands/types.js';
interface Help {
commands: readonly SlashCommand[];
@@ -149,7 +149,7 @@ export const Help: React.FC<Help> = ({ commands }) => (
<Text bold color={Colors.AccentPurple}>
Esc
</Text>{' '}
- Cancel operation
- Cancel operation / Clear input (double press)
</Text>
<Text color={Colors.Foreground}>
<Text bold color={Colors.AccentPurple}>

View File

@@ -7,8 +7,10 @@
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { HistoryItem, MessageType } from '../types.js';
import type { HistoryItem } from '../types.js';
import { MessageType } from '../types.js';
import { SessionStatsProvider } from '../contexts/SessionContext.js';
import type { Config } from '@qwen-code/qwen-code-core';
// Mock child components
vi.mock('./messages/ToolGroupMessage.js', () => ({
@@ -16,11 +18,13 @@ vi.mock('./messages/ToolGroupMessage.js', () => ({
}));
describe('<HistoryItemDisplay />', () => {
const mockConfig = {} as unknown as Config;
const baseItem = {
id: 1,
timestamp: 12345,
isPending: false,
terminalWidth: 80,
config: mockConfig,
};
it('renders UserMessage for "user" type', () => {

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import type { HistoryItem } from '../types.js';
import { UserMessage } from './messages/UserMessage.js';
import { UserShellMessage } from './messages/UserShellMessage.js';
@@ -20,16 +20,16 @@ import { StatsDisplay } from './StatsDisplay.js';
import { ModelStatsDisplay } from './ModelStatsDisplay.js';
import { ToolStatsDisplay } from './ToolStatsDisplay.js';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import { Config } from '@qwen-code/qwen-code-core';
import type { Config } from '@qwen-code/qwen-code-core';
import { Help } from './Help.js';
import { SlashCommand } from '../commands/types.js';
import type { SlashCommand } from '../commands/types.js';
interface HistoryItemDisplayProps {
item: HistoryItem;
availableTerminalHeight?: number;
terminalWidth: number;
isPending: boolean;
config?: Config;
config: Config;
isFocused?: boolean;
commands?: readonly SlashCommand[];
}

View File

@@ -5,29 +5,21 @@
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '@testing-library/react';
import { InputPrompt, InputPromptProps } from './InputPrompt.js';
import { waitFor, act } from '@testing-library/react';
import type { InputPromptProps } from './InputPrompt.js';
import { InputPrompt } from './InputPrompt.js';
import type { TextBuffer } from './shared/text-buffer.js';
import { Config } from '@qwen-code/qwen-code-core';
import * as path from 'path';
import {
CommandContext,
SlashCommand,
CommandKind,
} from '../commands/types.js';
import type { Config } from '@qwen-code/qwen-code-core';
import * as path from 'node:path';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import { CommandKind } from '../commands/types.js';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
useShellHistory,
UseShellHistoryReturn,
} from '../hooks/useShellHistory.js';
import {
useCommandCompletion,
UseCommandCompletionReturn,
} from '../hooks/useCommandCompletion.js';
import {
useInputHistory,
UseInputHistoryReturn,
} from '../hooks/useInputHistory.js';
import type { UseShellHistoryReturn } from '../hooks/useShellHistory.js';
import { useShellHistory } from '../hooks/useShellHistory.js';
import type { UseCommandCompletionReturn } from '../hooks/useCommandCompletion.js';
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
import type { UseInputHistoryReturn } from '../hooks/useInputHistory.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import * as clipboardUtils from '../utils/clipboardUtils.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
@@ -119,9 +111,9 @@ describe('InputPrompt', () => {
visualScrollRow: 0,
handleInput: vi.fn(),
move: vi.fn(),
moveToOffset: (offset: number) => {
moveToOffset: vi.fn((offset: number) => {
mockBuffer.cursor = [0, offset];
},
}),
killLineRight: vi.fn(),
killLineLeft: vi.fn(),
openInExternalEditor: vi.fn(),
@@ -160,6 +152,11 @@ describe('InputPrompt', () => {
setActiveSuggestionIndex: vi.fn(),
setShowSuggestions: vi.fn(),
handleAutocomplete: vi.fn(),
promptCompletion: {
text: '',
accept: vi.fn(),
clear: vi.fn(),
},
};
mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);
@@ -1415,13 +1412,27 @@ describe('InputPrompt', () => {
const { stdin, stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
stdin.write('\x12');
await wait();
stdin.write('\t');
await waitFor(() => {
expect(stdout.lastFrame()).not.toContain('(r:)');
// Enter reverse search mode with Ctrl+R
act(() => {
stdin.write('\x12');
});
await wait();
// Verify reverse search is active
expect(stdout.lastFrame()).toContain('(r:)');
// Press Tab to complete the highlighted entry
act(() => {
stdin.write('\t');
});
await waitFor(
() => {
expect(stdout.lastFrame()).not.toContain('(r:)');
},
{ timeout: 5000 },
); // Increase timeout
expect(props.buffer.setText).toHaveBeenCalledWith('echo hello');
unmount();
@@ -1431,10 +1442,17 @@ describe('InputPrompt', () => {
const { stdin, stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
stdin.write('\x12');
act(() => {
stdin.write('\x12');
});
await wait();
expect(stdout.lastFrame()).toContain('(r:)');
stdin.write('\r');
act(() => {
stdin.write('\r');
});
await waitFor(() => {
expect(stdout.lastFrame()).not.toContain('(r:)');
@@ -1464,4 +1482,42 @@ describe('InputPrompt', () => {
unmount();
});
});
describe('Ctrl+E keyboard shortcut', () => {
it('should move cursor to end of current line in multiline input', async () => {
props.buffer.text = 'line 1\nline 2\nline 3';
props.buffer.cursor = [1, 2];
props.buffer.lines = ['line 1', 'line 2', 'line 3'];
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\x05'); // Ctrl+E
await wait();
expect(props.buffer.move).toHaveBeenCalledWith('end');
expect(props.buffer.moveToOffset).not.toHaveBeenCalled();
unmount();
});
it('should move cursor to end of current line for single line input', async () => {
props.buffer.text = 'single line text';
props.buffer.cursor = [0, 5];
props.buffer.lines = ['single line text'];
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} />,
);
await wait();
stdin.write('\x05'); // Ctrl+E
await wait();
expect(props.buffer.move).toHaveBeenCalledWith('end');
expect(props.buffer.moveToOffset).not.toHaveBeenCalled();
unmount();
});
});
});

View File

@@ -4,28 +4,32 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useCallback, useEffect, useState, useRef } from 'react';
import type React from 'react';
import { useCallback, useEffect, useState, useRef } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import { TextBuffer, logicalPosToOffset } from './shared/text-buffer.js';
import { cpSlice, cpLen } from '../utils/textUtils.js';
import type { TextBuffer } from './shared/text-buffer.js';
import { logicalPosToOffset } from './shared/text-buffer.js';
import { cpSlice, cpLen, toCodePoints } from '../utils/textUtils.js';
import chalk from 'chalk';
import stringWidth from 'string-width';
import { useShellHistory } from '../hooks/useShellHistory.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
import { useKeypress, Key } from '../hooks/useKeypress.js';
import type { Key } from '../hooks/useKeypress.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { CommandContext, SlashCommand } from '../commands/types.js';
import { Config } from '@qwen-code/qwen-code-core';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { Config } from '@qwen-code/qwen-code-core';
import {
clipboardHasImage,
saveClipboardImage,
cleanupOldClipboardImages,
} from '../utils/clipboardUtils.js';
import * as path from 'path';
import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
export interface InputPromptProps {
buffer: TextBuffer;
@@ -81,7 +85,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const [cursorPosition, setCursorPosition] = useState<[number, number]>([
0, 0,
]);
const shellHistory = useShellHistory(config.getProjectRoot());
const shellHistory = useShellHistory(config.getProjectRoot(), config.storage);
const historyData = shellHistory.history;
const completion = useCommandCompletion(
@@ -403,6 +407,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
}
// Handle Tab key for ghost text acceptance
if (
key.name === 'tab' &&
!completion.showSuggestions &&
completion.promptCompletion.text
) {
completion.promptCompletion.accept();
return;
}
if (!shellModeActive) {
if (keyMatchers[Command.HISTORY_UP](key)) {
inputHistory.navigateUp();
@@ -471,7 +485,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
if (keyMatchers[Command.END](key)) {
buffer.move('end');
buffer.moveToOffset(cpLen(buffer.text));
return;
}
// Ctrl+C (Clear input)
@@ -507,6 +520,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// Fall back to the text buffer's default input handling for all other keys
buffer.handleInput(key);
// Clear ghost text when user types regular characters (not navigation/control keys)
if (
completion.promptCompletion.text &&
key.sequence &&
key.sequence.length === 1 &&
!key.ctrl &&
!key.meta
) {
completion.promptCompletion.clear();
}
},
[
focus,
@@ -540,6 +564,119 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.visualCursor;
const scrollVisualRow = buffer.visualScrollRow;
const getGhostTextLines = useCallback(() => {
if (
!completion.promptCompletion.text ||
!buffer.text ||
!completion.promptCompletion.text.startsWith(buffer.text)
) {
return { inlineGhost: '', additionalLines: [] };
}
const ghostSuffix = completion.promptCompletion.text.slice(
buffer.text.length,
);
if (!ghostSuffix) {
return { inlineGhost: '', additionalLines: [] };
}
const currentLogicalLine = buffer.lines[buffer.cursor[0]] || '';
const cursorCol = buffer.cursor[1];
const textBeforeCursor = cpSlice(currentLogicalLine, 0, cursorCol);
const usedWidth = stringWidth(textBeforeCursor);
const remainingWidth = Math.max(0, inputWidth - usedWidth);
const ghostTextLinesRaw = ghostSuffix.split('\n');
const firstLineRaw = ghostTextLinesRaw.shift() || '';
let inlineGhost = '';
let remainingFirstLine = '';
if (stringWidth(firstLineRaw) <= remainingWidth) {
inlineGhost = firstLineRaw;
} else {
const words = firstLineRaw.split(' ');
let currentLine = '';
let wordIdx = 0;
for (const word of words) {
const prospectiveLine = currentLine ? `${currentLine} ${word}` : word;
if (stringWidth(prospectiveLine) > remainingWidth) {
break;
}
currentLine = prospectiveLine;
wordIdx++;
}
inlineGhost = currentLine;
if (words.length > wordIdx) {
remainingFirstLine = words.slice(wordIdx).join(' ');
}
}
const linesToWrap = [];
if (remainingFirstLine) {
linesToWrap.push(remainingFirstLine);
}
linesToWrap.push(...ghostTextLinesRaw);
const remainingGhostText = linesToWrap.join('\n');
const additionalLines: string[] = [];
if (remainingGhostText) {
const textLines = remainingGhostText.split('\n');
for (const textLine of textLines) {
const words = textLine.split(' ');
let currentLine = '';
for (const word of words) {
const prospectiveLine = currentLine ? `${currentLine} ${word}` : word;
const prospectiveWidth = stringWidth(prospectiveLine);
if (prospectiveWidth > inputWidth) {
if (currentLine) {
additionalLines.push(currentLine);
}
let wordToProcess = word;
while (stringWidth(wordToProcess) > inputWidth) {
let part = '';
const wordCP = toCodePoints(wordToProcess);
let partWidth = 0;
let splitIndex = 0;
for (let i = 0; i < wordCP.length; i++) {
const char = wordCP[i];
const charWidth = stringWidth(char);
if (partWidth + charWidth > inputWidth) {
break;
}
part += char;
partWidth += charWidth;
splitIndex = i + 1;
}
additionalLines.push(part);
wordToProcess = cpSlice(wordToProcess, splitIndex);
}
currentLine = wordToProcess;
} else {
currentLine = prospectiveLine;
}
}
if (currentLine) {
additionalLines.push(currentLine);
}
}
}
return { inlineGhost, additionalLines };
}, [
completion.promptCompletion.text,
buffer.text,
buffer.lines,
buffer.cursor,
inputWidth,
]);
const { inlineGhost, additionalLines } = getGhostTextLines();
return (
<>
<Box
@@ -554,7 +691,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
>
{shellModeActive ? (
reverseSearchActive ? (
<Text color={theme.text.link}>(r:) </Text>
<Text
color={theme.text.link}
aria-label={SCREEN_READER_USER_PREFIX}
>
(r:){' '}
</Text>
) : (
'! '
)
@@ -573,42 +715,91 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
<Text color={theme.text.secondary}>{placeholder}</Text>
)
) : (
linesToRender.map((lineText, visualIdxInRenderedSet) => {
const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow;
let display = cpSlice(lineText, 0, inputWidth);
const currentVisualWidth = stringWidth(display);
if (currentVisualWidth < inputWidth) {
display = display + ' '.repeat(inputWidth - currentVisualWidth);
}
linesToRender
.map((lineText, visualIdxInRenderedSet) => {
const cursorVisualRow =
cursorVisualRowAbsolute - scrollVisualRow;
let display = cpSlice(lineText, 0, inputWidth);
if (focus && visualIdxInRenderedSet === cursorVisualRow) {
const relativeVisualColForHighlight = cursorVisualColAbsolute;
const isOnCursorLine =
focus && visualIdxInRenderedSet === cursorVisualRow;
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
if (relativeVisualColForHighlight >= 0) {
if (relativeVisualColForHighlight < cpLen(display)) {
const charToHighlight =
cpSlice(
display,
relativeVisualColForHighlight,
relativeVisualColForHighlight + 1,
) || ' ';
const highlighted = chalk.inverse(charToHighlight);
display =
cpSlice(display, 0, relativeVisualColForHighlight) +
highlighted +
cpSlice(display, relativeVisualColForHighlight + 1);
} else if (
relativeVisualColForHighlight === cpLen(display) &&
cpLen(display) === inputWidth
) {
display = display + chalk.inverse(' ');
const ghostWidth = stringWidth(currentLineGhost);
if (focus && visualIdxInRenderedSet === cursorVisualRow) {
const relativeVisualColForHighlight = cursorVisualColAbsolute;
if (relativeVisualColForHighlight >= 0) {
if (relativeVisualColForHighlight < cpLen(display)) {
const charToHighlight =
cpSlice(
display,
relativeVisualColForHighlight,
relativeVisualColForHighlight + 1,
) || ' ';
const highlighted = chalk.inverse(charToHighlight);
display =
cpSlice(display, 0, relativeVisualColForHighlight) +
highlighted +
cpSlice(display, relativeVisualColForHighlight + 1);
} else if (
relativeVisualColForHighlight === cpLen(display)
) {
if (!currentLineGhost) {
display = display + chalk.inverse(' ');
}
}
}
}
}
return (
<Text key={`line-${visualIdxInRenderedSet}`}>{display}</Text>
);
})
const showCursorBeforeGhost =
focus &&
visualIdxInRenderedSet === cursorVisualRow &&
cursorVisualColAbsolute ===
// eslint-disable-next-line no-control-regex
cpLen(display.replace(/\x1b\[[0-9;]*m/g, '')) &&
currentLineGhost;
const actualDisplayWidth = stringWidth(display);
const cursorWidth = showCursorBeforeGhost ? 1 : 0;
const totalContentWidth =
actualDisplayWidth + cursorWidth + ghostWidth;
const trailingPadding = Math.max(
0,
inputWidth - totalContentWidth,
);
return (
<Text key={`line-${visualIdxInRenderedSet}`}>
{display}
{showCursorBeforeGhost && chalk.inverse(' ')}
{currentLineGhost && (
<Text color={theme.text.secondary}>
{currentLineGhost}
</Text>
)}
{trailingPadding > 0 && ' '.repeat(trailingPadding)}
</Text>
);
})
.concat(
additionalLines.map((ghostLine, index) => {
const padding = Math.max(
0,
inputWidth - stringWidth(ghostLine),
);
return (
<Text
key={`ghost-line-${index}`}
color={theme.text.secondary}
>
{ghostLine}
{' '.repeat(padding)}
</Text>
);
}),
)
)}
</Box>
</Box>

View File

@@ -4,8 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { ThoughtSummary } from '@qwen-code/qwen-code-core';
import React from 'react';
import type { ThoughtSummary } from '@qwen-code/qwen-code-core';
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useEffect, useState } from 'react';
import type React from 'react';
import { useEffect, useState } from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import process from 'node:process';

View File

@@ -8,7 +8,7 @@ import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { ModelStatsDisplay } from './ModelStatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import { SessionMetrics } from '../contexts/SessionContext.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
// Mock the context to provide controlled data for testing
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { formatDuration } from '../utils/formatters.js';
@@ -13,7 +13,8 @@ import {
calculateCacheHitRate,
calculateErrorRate,
} from '../utils/computeStats.js';
import { useSessionStats, ModelMetrics } from '../contexts/SessionContext.js';
import type { ModelMetrics } from '../contexts/SessionContext.js';
import { useSessionStats } from '../contexts/SessionContext.js';
const METRIC_COL_WIDTH = 28;
const MODEL_COL_WIDTH = 22;

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState } from 'react';
import type React from 'react';
import { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { Colors } from '../colors.js';

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Text } from 'ink';
import { Colors } from '../colors.js';

View File

@@ -8,7 +8,7 @@
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
import { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
// Mock qrcode-terminal module
vi.mock('qrcode-terminal', () => ({

View File

@@ -4,13 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect, useMemo } from 'react';
import type React from 'react';
import { useState, useEffect, useMemo } from 'react';
import { Box, Text, useInput } from 'ink';
import Spinner from 'ink-spinner';
import Link from 'ink-link';
import qrcode from 'qrcode-terminal';
import { Colors } from '../colors.js';
import { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
import type { DeviceAuthorizationInfo } from '../hooks/useQwenAuth.js';
interface QwenOAuthProgressProps {
onTimeout: () => void;

View File

@@ -8,7 +8,7 @@ import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import { SessionMetrics } from '../contexts/SessionContext.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
const actual = await importOriginal<typeof SessionContext>();

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { StatsDisplay } from './StatsDisplay.js';
interface SessionSummaryDisplayProps {

View File

@@ -27,11 +27,70 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SettingsDialog } from './SettingsDialog.js';
import { LoadedSettings } from '../../config/settings.js';
import { VimModeProvider } from '../contexts/VimModeContext.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
// Mock the VimModeContext
const mockToggleVimEnabled = vi.fn();
const mockSetVimMode = vi.fn();
const createMockSettings = (
userSettings = {},
systemSettings = {},
workspaceSettings = {},
) =>
new LoadedSettings(
{
settings: { ui: { customThemes: {} }, mcpServers: {}, ...systemSettings },
path: '/system/settings.json',
},
{
settings: {},
path: '/system/system-defaults.json',
},
{
settings: {
ui: { customThemes: {} },
mcpServers: {},
...userSettings,
},
path: '/user/settings.json',
},
{
settings: {
ui: { customThemes: {} },
mcpServers: {},
...workspaceSettings,
},
path: '/workspace/settings.json',
},
[],
true,
new Set(),
);
vi.mock('../contexts/SettingsContext.js', async () => {
const actual = await vi.importActual('../contexts/SettingsContext.js');
let settings = createMockSettings({ 'a.string.setting': 'initial' });
return {
...actual,
useSettings: () => ({
settings,
setSetting: (key: string, value: string) => {
settings = createMockSettings({ [key]: value });
},
getSettingDefinition: (key: string) => {
if (key === 'a.string.setting') {
return {
type: 'string',
description: 'A string setting',
};
}
return undefined;
},
}),
};
});
vi.mock('../contexts/VimModeContext.js', async () => {
const actual = await vi.importActual('../contexts/VimModeContext.js');
return {
@@ -53,28 +112,6 @@ vi.mock('../../utils/settingsUtils.js', async () => {
};
});
// Mock the useKeypress hook to avoid context issues
interface Key {
name: string;
ctrl: boolean;
meta: boolean;
shift: boolean;
paste: boolean;
sequence: string;
}
// Variables for keypress simulation (not currently used)
// let currentKeypressHandler: ((key: Key) => void) | null = null;
// let isKeypressActive = false;
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(
(_handler: (key: Key) => void, _options: { isActive: boolean }) => {
// Mock implementation - simplified for test stability
},
),
}));
// Helper function to simulate key presses (commented out for now)
// const simulateKeyPress = async (keyData: Partial<Key> & { name: string }) => {
// if (currentKeypressHandler) {
@@ -124,22 +161,36 @@ describe('SettingsDialog', () => {
) =>
new LoadedSettings(
{
settings: { customThemes: {}, mcpServers: {}, ...systemSettings },
settings: {
ui: { customThemes: {} },
mcpServers: {},
...systemSettings,
},
path: '/system/settings.json',
},
{
settings: {},
path: '/system/system-defaults.json',
},
{
settings: {
customThemes: {},
ui: { customThemes: {} },
mcpServers: {},
...userSettings,
},
path: '/user/settings.json',
},
{
settings: { customThemes: {}, mcpServers: {}, ...workspaceSettings },
settings: {
ui: { customThemes: {} },
mcpServers: {},
...workspaceSettings,
},
path: '/workspace/settings.json',
},
[],
true,
new Set(),
);
describe('Initial Rendering', () => {
@@ -148,7 +199,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
const output = lastFrame();
@@ -162,7 +215,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
const output = lastFrame();
@@ -175,7 +230,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
const output = lastFrame();
@@ -190,7 +247,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Press down arrow
@@ -206,7 +265,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// First go down, then up
@@ -223,7 +284,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Navigate with vim keys
@@ -240,7 +303,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Try to go up from first item
@@ -258,7 +323,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Press Enter to toggle current setting
@@ -273,7 +340,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Press Space to toggle current setting
@@ -288,7 +357,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Navigate to vim mode setting and toggle it
@@ -307,7 +378,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Switch to scope focus
@@ -326,16 +399,18 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Wait for initial render
await waitFor(() => {
expect(lastFrame()).toContain('Hide Window Title');
expect(lastFrame()).toContain('Vim Mode');
});
// The UI should show the settings section is active and scope section is inactive
expect(lastFrame()).toContain('● Hide Window Title'); // Settings section active
expect(lastFrame()).toContain('● Vim Mode'); // Settings section active
expect(lastFrame()).toContain(' Apply To'); // Scope section inactive
// This test validates the initial state - scope selection behavior
@@ -351,11 +426,13 @@ describe('SettingsDialog', () => {
const onRestartRequest = vi.fn();
const { unmount } = render(
<SettingsDialog
settings={settings}
onSelect={() => {}}
onRestartRequest={onRestartRequest}
/>,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog
settings={settings}
onSelect={() => {}}
onRestartRequest={onRestartRequest}
/>
</KeypressProvider>,
);
// This test would need to trigger a restart-required setting change
@@ -370,11 +447,13 @@ describe('SettingsDialog', () => {
const onRestartRequest = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog
settings={settings}
onSelect={() => {}}
onRestartRequest={onRestartRequest}
/>,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog
settings={settings}
onSelect={() => {}}
onRestartRequest={onRestartRequest}
/>
</KeypressProvider>,
);
// Press 'r' key (this would only work if restart prompt is showing)
@@ -392,7 +471,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Wait for initial render
@@ -417,7 +498,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Switch to scope selector
@@ -441,7 +524,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Should show user scope values initially
@@ -458,7 +543,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Try to toggle a setting (this might trigger vim mode toggle)
@@ -476,7 +563,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Toggle a setting
@@ -498,7 +587,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Navigate down many times to test scrolling
@@ -518,7 +609,9 @@ describe('SettingsDialog', () => {
const { stdin, unmount } = render(
<VimModeProvider settings={settings}>
<SettingsDialog settings={settings} onSelect={onSelect} />
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>
</VimModeProvider>,
);
@@ -541,7 +634,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
const output = lastFrame();
@@ -554,7 +649,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Toggle a non-restart-required setting (like hideTips)
@@ -570,7 +667,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// This test would need to navigate to a specific restart-required setting
@@ -590,7 +689,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Restart prompt should be cleared when switching scopes
@@ -608,7 +709,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
const output = lastFrame();
@@ -625,7 +728,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
const output = lastFrame();
@@ -640,7 +745,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Rapid navigation
@@ -659,7 +766,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Press Ctrl+C to reset current setting to default
@@ -675,7 +784,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Press Ctrl+L to reset current setting to default
@@ -691,7 +802,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Try to navigate when potentially at bounds
@@ -708,16 +821,18 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Wait for initial render
await waitFor(() => {
expect(lastFrame()).toContain('Hide Window Title');
expect(lastFrame()).toContain('Vim Mode');
});
// Verify initial state: settings section active, scope section inactive
expect(lastFrame()).toContain('● Hide Window Title'); // Settings section active
expect(lastFrame()).toContain('● Vim Mode'); // Settings section active
expect(lastFrame()).toContain(' Apply To'); // Scope section inactive
// This test validates the rendered UI structure for tab navigation
@@ -738,7 +853,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Should still render without crashing
@@ -751,7 +868,9 @@ describe('SettingsDialog', () => {
// Should not crash even if some settings are missing definitions
const { lastFrame } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
expect(lastFrame()).toContain('Settings');
@@ -764,17 +883,19 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { lastFrame, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Wait for initial render
await waitFor(() => {
expect(lastFrame()).toContain('Hide Window Title');
expect(lastFrame()).toContain('Vim Mode');
});
// Verify the complete UI is rendered with all necessary sections
expect(lastFrame()).toContain('Settings'); // Title
expect(lastFrame()).toContain('● Hide Window Title'); // Active setting
expect(lastFrame()).toContain('● Vim Mode'); // Active setting
expect(lastFrame()).toContain('Apply To'); // Scope section
expect(lastFrame()).toContain('1. User Settings'); // Scope options
expect(lastFrame()).toContain(
@@ -792,7 +913,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Toggle first setting (should require restart)
@@ -821,7 +944,9 @@ describe('SettingsDialog', () => {
const onSelect = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog settings={settings} onSelect={onSelect} />,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Multiple scope changes
@@ -845,11 +970,13 @@ describe('SettingsDialog', () => {
const onRestartRequest = vi.fn();
const { stdin, unmount } = render(
<SettingsDialog
settings={settings}
onSelect={() => {}}
onRestartRequest={onRestartRequest}
/>,
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog
settings={settings}
onSelect={() => {}}
onRestartRequest={onRestartRequest}
/>
</KeypressProvider>,
);
// This would test the restart workflow if we could trigger it
@@ -862,4 +989,58 @@ describe('SettingsDialog', () => {
unmount();
});
});
describe('String Settings Editing', () => {
it('should allow editing and committing a string setting', async () => {
let settings = createMockSettings({ 'a.string.setting': 'initial' });
const onSelect = vi.fn();
const { stdin, unmount, rerender } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
// Wait for the dialog to render
await wait();
// Navigate to the last setting
for (let i = 0; i < 20; i++) {
stdin.write('j'); // Down
await wait(10);
}
// Press Enter to start editing
stdin.write('\r');
await wait();
// Type a new value
stdin.write('new value');
await wait();
// Press Enter to commit
stdin.write('\r');
await wait();
settings = createMockSettings(
{ 'a.string.setting': 'new value' },
{},
{},
);
rerender(
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>,
);
await wait();
// Press Escape to exit
stdin.write('\u001B');
await wait();
expect(onSelect).toHaveBeenCalledWith(undefined, 'User');
unmount();
});
});
});

View File

@@ -7,11 +7,8 @@
import React, { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import {
LoadedSettings,
SettingScope,
Settings,
} from '../../config/settings.js';
import type { LoadedSettings, Settings } from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import {
getScopeItems,
getScopeMessageForSetting,
@@ -35,7 +32,7 @@ import {
import { useVimMode } from '../contexts/VimModeContext.js';
import { useKeypress } from '../hooks/useKeypress.js';
import chalk from 'chalk';
import { cpSlice, cpLen } from '../utils/textUtils.js';
import { cpSlice, cpLen, stripUnsafeCharacters } from '../utils/textUtils.js';
interface SettingsDialogProps {
settings: LoadedSettings;
@@ -78,8 +75,8 @@ export function SettingsDialog({
new Set(),
);
// Preserve pending changes across scope switches (boolean and number values only)
type PendingValue = boolean | number;
// Preserve pending changes across scope switches
type PendingValue = boolean | number | string;
const [globalPendingChanges, setGlobalPendingChanges] = useState<
Map<string, PendingValue>
>(new Map());
@@ -99,7 +96,10 @@ export function SettingsDialog({
const def = getSettingDefinition(key);
if (def?.type === 'boolean' && typeof value === 'boolean') {
updated = setPendingSettingValue(key, value, updated);
} else if (def?.type === 'number' && typeof value === 'number') {
} else if (
(def?.type === 'number' && typeof value === 'number') ||
(def?.type === 'string' && typeof value === 'string')
) {
updated = setPendingSettingValueAny(key, value, updated);
}
newModified.add(key);
@@ -123,7 +123,7 @@ export function SettingsDialog({
type: definition?.type,
toggle: () => {
if (definition?.type !== 'boolean') {
// For non-boolean (e.g., number) items, toggle will be handled via edit mode.
// For non-boolean items, toggle will be handled via edit mode.
return;
}
const currentValue = getSettingValue(key, pendingSettings, {});
@@ -153,7 +153,7 @@ export function SettingsDialog({
);
// Special handling for vim mode to sync with VimModeContext
if (key === 'vimMode' && newValue !== vimEnabled) {
if (key === 'general.vimMode' && newValue !== vimEnabled) {
// Call toggleVimEnabled to sync the VimModeContext local state
toggleVimEnabled().catch((error) => {
console.error('Failed to toggle vim mode:', error);
@@ -220,7 +220,7 @@ export function SettingsDialog({
const items = generateSettingsItems();
// Number edit state
// Generic edit state
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editBuffer, setEditBuffer] = useState<string>('');
const [editCursorPos, setEditCursorPos] = useState<number>(0); // Cursor position within edit buffer
@@ -235,28 +235,39 @@ export function SettingsDialog({
return () => clearInterval(id);
}, [editingKey]);
const startEditingNumber = (key: string, initial?: string) => {
const startEditing = (key: string, initial?: string) => {
setEditingKey(key);
const initialValue = initial ?? '';
setEditBuffer(initialValue);
setEditCursorPos(cpLen(initialValue)); // Position cursor at end of initial value
};
const commitNumberEdit = (key: string) => {
if (editBuffer.trim() === '') {
// Nothing entered; cancel edit
const commitEdit = (key: string) => {
const definition = getSettingDefinition(key);
const type = definition?.type;
if (editBuffer.trim() === '' && type === 'number') {
// Nothing entered for a number; cancel edit
setEditingKey(null);
setEditBuffer('');
setEditCursorPos(0);
return;
}
const parsed = Number(editBuffer.trim());
if (Number.isNaN(parsed)) {
// Invalid number; cancel edit
setEditingKey(null);
setEditBuffer('');
setEditCursorPos(0);
return;
let parsed: string | number;
if (type === 'number') {
const numParsed = Number(editBuffer.trim());
if (Number.isNaN(numParsed)) {
// Invalid number; cancel edit
setEditingKey(null);
setEditBuffer('');
setEditCursorPos(0);
return;
}
parsed = numParsed;
} else {
// For strings, use the buffer as is.
parsed = editBuffer;
}
// Update pending
@@ -347,10 +358,16 @@ export function SettingsDialog({
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
}
if (focusSection === 'settings') {
// If editing a number, capture numeric input and control keys
// If editing, capture input and control keys
if (editingKey) {
const definition = getSettingDefinition(editingKey);
const type = definition?.type;
if (key.paste && key.sequence) {
const pasted = key.sequence.replace(/[^0-9\-+.]/g, '');
let pasted = key.sequence;
if (type === 'number') {
pasted = key.sequence.replace(/[^0-9\-+.]/g, '');
}
if (pasted) {
setEditBuffer((b) => {
const before = cpSlice(b, 0, editCursorPos);
@@ -380,16 +397,27 @@ export function SettingsDialog({
return;
}
if (name === 'escape') {
commitNumberEdit(editingKey);
commitEdit(editingKey);
return;
}
if (name === 'return') {
commitNumberEdit(editingKey);
commitEdit(editingKey);
return;
}
// Allow digits, minus, plus, and dot
const ch = key.sequence;
if (/[0-9\-+.]/.test(ch)) {
let ch = key.sequence;
let isValidChar = false;
if (type === 'number') {
// Allow digits, minus, plus, and dot.
isValidChar = /[0-9\-+.]/.test(ch);
} else {
ch = stripUnsafeCharacters(ch);
// For strings, allow any single character that isn't a control
// sequence.
isValidChar = ch.length === 1;
}
if (isValidChar) {
setEditBuffer((currentBuffer) => {
const beforeCursor = cpSlice(currentBuffer, 0, editCursorPos);
const afterCursor = cpSlice(currentBuffer, editCursorPos);
@@ -422,7 +450,7 @@ export function SettingsDialog({
if (name === 'up' || name === 'k') {
// If editing, commit first
if (editingKey) {
commitNumberEdit(editingKey);
commitEdit(editingKey);
}
const newIndex =
activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1;
@@ -436,7 +464,7 @@ export function SettingsDialog({
} else if (name === 'down' || name === 'j') {
// If editing, commit first
if (editingKey) {
commitNumberEdit(editingKey);
commitEdit(editingKey);
}
const newIndex =
activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0;
@@ -449,15 +477,18 @@ export function SettingsDialog({
}
} else if (name === 'return' || name === 'space') {
const currentItem = items[activeSettingIndex];
if (currentItem?.type === 'number') {
startEditingNumber(currentItem.value);
if (
currentItem?.type === 'number' ||
currentItem?.type === 'string'
) {
startEditing(currentItem.value);
} else {
currentItem?.toggle();
}
} else if (/^[0-9]$/.test(key.sequence || '') && !editingKey) {
const currentItem = items[activeSettingIndex];
if (currentItem?.type === 'number') {
startEditingNumber(currentItem.value, key.sequence);
startEditing(currentItem.value, key.sequence);
}
} else if (ctrl && (name === 'c' || name === 'l')) {
// Ctrl+C or Ctrl+L: Clear current setting and reset to default
@@ -475,8 +506,11 @@ export function SettingsDialog({
prev,
),
);
} else if (defType === 'number') {
if (typeof defaultValue === 'number') {
} else if (defType === 'number' || defType === 'string') {
if (
typeof defaultValue === 'number' ||
typeof defaultValue === 'string'
) {
setPendingSettings((prev) =>
setPendingSettingValueAny(
currentSetting.value,
@@ -509,7 +543,8 @@ export function SettingsDialog({
? typeof defaultValue === 'boolean'
? defaultValue
: false
: typeof defaultValue === 'number'
: typeof defaultValue === 'number' ||
typeof defaultValue === 'string'
? defaultValue
: undefined;
const immediateSettingsObject =
@@ -541,7 +576,9 @@ export function SettingsDialog({
(currentSetting.type === 'boolean' &&
typeof defaultValue === 'boolean') ||
(currentSetting.type === 'number' &&
typeof defaultValue === 'number')
typeof defaultValue === 'number') ||
(currentSetting.type === 'string' &&
typeof defaultValue === 'string')
) {
setGlobalPendingChanges((prev) => {
const next = new Map(prev);
@@ -584,7 +621,7 @@ export function SettingsDialog({
}
if (name === 'escape') {
if (editingKey) {
commitNumberEdit(editingKey);
commitEdit(editingKey);
} else {
onSelect(undefined, selectedScope);
}
@@ -637,8 +674,8 @@ export function SettingsDialog({
// Cursor not visible
displayValue = editBuffer;
}
} else if (item.type === 'number') {
// For numbers, get the actual current value from pending settings
} else if (item.type === 'number' || item.type === 'string') {
// For numbers/strings, get the actual current value from pending settings
const path = item.value.split('.');
const currentValue = getNestedValue(pendingSettings, path);

View File

@@ -6,14 +6,12 @@
import { ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink';
import React from 'react';
import type React from 'react';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { RenderInline } from '../utils/InlineMarkdownRenderer.js';
import {
RadioButtonSelect,
RadioSelectItem,
} from './shared/RadioButtonSelect.js';
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
export interface ShellConfirmationRequest {
commands: string[];

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';

View File

@@ -8,7 +8,7 @@ import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { StatsDisplay } from './StatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import { SessionMetrics } from '../contexts/SessionContext.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
// Mock the context to provide controlled data for testing
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {

View File

@@ -4,12 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import Gradient from 'ink-gradient';
import { theme } from '../semantic-colors.js';
import { formatDuration } from '../utils/formatters.js';
import { useSessionStats, ModelMetrics } from '../contexts/SessionContext.js';
import type { ModelMetrics } from '../contexts/SessionContext.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import {
getStatusColor,
TOOL_SUCCESS_RATE_HIGH,
@@ -31,7 +32,8 @@ const StatRow: React.FC<StatRowProps> = ({ title, children }) => (
<Box width={28}>
<Text color={theme.text.link}>{title}</Text>
</Box>
{children}
{/* FIX: Wrap children in a Box that can grow to fill remaining space */}
<Box flexGrow={1}>{children}</Box>
</Box>
);
@@ -47,7 +49,8 @@ const SubStatRow: React.FC<SubStatRowProps> = ({ title, children }) => (
<Box width={26}>
<Text>» {title}</Text>
</Box>
{children}
{/* FIX: Apply the same flexGrow fix here */}
<Box flexGrow={1}>{children}</Box>
</Box>
);
@@ -204,8 +207,8 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
<StatRow title="Tool Calls:">
<Text>
{tools.totalCalls} ({' '}
<Text color={theme.status.success}> {tools.totalSuccess}</Text>{' '}
<Text color={theme.status.error}> {tools.totalFail}</Text> )
<Text color={theme.status.success}> {tools.totalSuccess}</Text>{' '}
<Text color={theme.status.error}>x {tools.totalFail}</Text> )
</Text>
</StatRow>
<StatRow title="Success Rate:">

View File

@@ -7,6 +7,7 @@
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { PrepareLabel } from './PrepareLabel.js';
import { isSlashCommand } from '../utils/commandUtils.js';
export interface Suggestion {
label: string;
value: string;
@@ -52,6 +53,21 @@ export function SuggestionsDisplay({
);
const visibleSuggestions = suggestions.slice(startIndex, endIndex);
const isSlashCommandMode = isSlashCommand(userInput);
let commandNameWidth = 0;
if (isSlashCommandMode) {
const maxLabelLength = visibleSuggestions.length
? Math.max(...visibleSuggestions.map((s) => s.label.length))
: 0;
const maxAllowedWidth = Math.floor(width * 0.35);
commandNameWidth = Math.max(
15,
Math.min(maxLabelLength + 2, maxAllowedWidth),
);
}
return (
<Box flexDirection="column" paddingX={1} width={width}>
{scrollOffset > 0 && <Text color={Colors.Foreground}></Text>}
@@ -72,21 +88,31 @@ export function SuggestionsDisplay({
return (
<Box key={`${suggestion.value}-${originalIndex}`} width={width}>
<Box flexDirection="row">
{userInput.startsWith('/') ? (
// only use box model for (/) command mode
<Box width={20} flexShrink={0}>
{labelElement}
</Box>
{isSlashCommandMode ? (
<>
<Box width={commandNameWidth} flexShrink={0}>
{labelElement}
</Box>
{suggestion.description ? (
<Box flexGrow={1} marginLeft={1}>
<Text color={textColor} wrap="wrap">
{suggestion.description}
</Text>
</Box>
) : null}
</>
) : (
labelElement
<>
{labelElement}
{suggestion.description ? (
<Box flexGrow={1} marginLeft={1}>
<Text color={textColor} wrap="wrap">
{suggestion.description}
</Text>
</Box>
) : null}
</>
)}
{suggestion.description ? (
<Box flexGrow={1}>
<Text color={textColor} wrap="truncate">
{suggestion.description}
</Text>
</Box>
) : null}
</Box>
</Box>
);

View File

@@ -4,14 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useCallback, useState } from 'react';
import type React from 'react';
import { useCallback, useState } from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { DiffRenderer } from './messages/DiffRenderer.js';
import { colorizeCode } from '../utils/CodeColorizer.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import type { LoadedSettings } from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import {
getScopeItems,
getScopeMessageForSetting,
@@ -44,13 +46,13 @@ export function ThemeDialog({
// Track the currently highlighted theme name
const [highlightedThemeName, setHighlightedThemeName] = useState<
string | undefined
>(settings.merged.theme || DEFAULT_THEME.name);
>(settings.merged.ui?.theme || DEFAULT_THEME.name);
// Generate theme items filtered by selected scope
const customThemes =
selectedScope === SettingScope.User
? settings.user.settings.customThemes || {}
: settings.merged.customThemes || {};
? settings.user.settings.ui?.customThemes || {}
: settings.merged.ui?.customThemes || {};
const builtInThemes = themeManager
.getAvailableThemes()
.filter((theme) => theme.type !== 'custom');
@@ -74,7 +76,7 @@ export function ThemeDialog({
const [selectInputKey, setSelectInputKey] = useState(Date.now());
// Find the index of the selected theme, but only if it exists in the list
const selectedThemeName = settings.merged.theme || DEFAULT_THEME.name;
const selectedThemeName = settings.merged.ui?.theme || DEFAULT_THEME.name;
const initialThemeIndex = themeItems.findIndex(
(item) => item.value === selectedThemeName,
);
@@ -126,7 +128,7 @@ export function ThemeDialog({
// Generate scope message for theme setting
const otherScopeModifiedMessage = getScopeMessageForSetting(
'theme',
'ui.theme',
selectedScope,
settings,
);

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { type Config } from '@qwen-code/qwen-code-core';

View File

@@ -6,7 +6,8 @@
import { render } from 'ink-testing-library';
import { describe, it, expect } from 'vitest';
import { TodoItem, TodoDisplay } from './TodoDisplay.js';
import type { TodoItem } from './TodoDisplay.js';
import { TodoDisplay } from './TodoDisplay.js';
describe('TodoDisplay', () => {
const mockTodos: TodoItem[] = [

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';

View File

@@ -8,7 +8,7 @@ import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { ToolStatsDisplay } from './ToolStatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import { SessionMetrics } from '../contexts/SessionContext.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
// Mock the context to provide controlled data for testing
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { formatDuration } from '../utils/formatters.js';
@@ -16,7 +16,7 @@ import {
USER_AGREEMENT_RATE_MEDIUM,
} from '../utils/displayUtils.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import { ToolCallStats } from '@qwen-code/qwen-code-core';
import type { ToolCallStats } from '@qwen-code/qwen-code-core';
const TOOL_NAME_COL_WIDTH = 25;
const CALLS_COL_WIDTH = 8;

View File

@@ -0,0 +1,108 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text, useInput } from 'ink';
import {
type Extension,
performWorkspaceExtensionMigration,
} from '../../config/extension.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { Colors } from '../colors.js';
import { useState } from 'react';
export function WorkspaceMigrationDialog(props: {
workspaceExtensions: Extension[];
onOpen: () => void;
onClose: () => void;
}) {
const { workspaceExtensions, onOpen, onClose } = props;
const [migrationComplete, setMigrationComplete] = useState(false);
const [failedExtensions, setFailedExtensions] = useState<string[]>([]);
onOpen();
const onMigrate = async () => {
const failed =
await performWorkspaceExtensionMigration(workspaceExtensions);
setFailedExtensions(failed);
setMigrationComplete(true);
};
useInput((input) => {
if (migrationComplete && input === 'q') {
process.exit(0);
}
});
if (migrationComplete) {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={Colors.Gray}
padding={1}
>
{failedExtensions.length > 0 ? (
<>
<Text>
The following extensions failed to migrate. Please try installing
them manually. To see other changes, Gemini CLI must be restarted.
Press {"'q'"} to quit.
</Text>
<Box flexDirection="column" marginTop={1} marginLeft={2}>
{failedExtensions.map((failed) => (
<Text key={failed}>- {failed}</Text>
))}
</Box>
</>
) : (
<Text>
Migration complete. To see changes, Gemini CLI must be restarted.
Press {"'q'"} to quit.
</Text>
)}
</Box>
);
}
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={Colors.Gray}
padding={1}
>
<Text bold>Workspace-level extensions are deprecated{'\n'}</Text>
<Text>Would you like to install them at the user level?</Text>
<Text>
The extension definition will remain in your workspace directory.
</Text>
<Text>
If you opt to skip, you can install them manually using the extensions
install command.
</Text>
<Box flexDirection="column" marginTop={1} marginLeft={2}>
{workspaceExtensions.map((extension) => (
<Text key={extension.config.name}>- {extension.config.name}</Text>
))}
</Box>
<Box marginTop={1}>
<RadioButtonSelect
items={[
{ label: 'Install all', value: 'migrate' },
{ label: 'Skip', value: 'skip' },
]}
onSelect={(value: string) => {
if (value === 'migrate') {
onMigrate();
} else {
onClose();
}
}}
/>
</Box>
</Box>
);
}

View File

@@ -7,7 +7,7 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
│ │
│ Interaction Summary │
│ Session ID: │
│ Tool Calls: 0 ( 0 0 ) │
│ Tool Calls: 0 ( 0 x 0 ) │
│ Success Rate: 0.0% │
│ Code Changes: +42 -15 │
│ │

View File

@@ -7,7 +7,7 @@ exports[`<StatsDisplay /> > Code Changes Display > displays Code Changes when li
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 1 ( 1 0 ) │
│ Tool Calls: 1 ( 1 x 0 ) │
│ Success Rate: 100.0% │
│ Code Changes: +42 -18 │
│ │
@@ -28,7 +28,7 @@ exports[`<StatsDisplay /> > Code Changes Display > hides Code Changes when no li
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 1 ( 1 0 ) │
│ Tool Calls: 1 ( 1 x 0 ) │
│ Success Rate: 100.0% │
│ │
│ Performance │
@@ -48,7 +48,7 @@ exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in gr
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 10 ( 10 0 ) │
│ Tool Calls: 10 ( 10 x 0 ) │
│ Success Rate: 100.0% │
│ │
│ Performance │
@@ -68,7 +68,7 @@ exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in re
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 10 ( 5 5 ) │
│ Tool Calls: 10 ( 5 x 5 ) │
│ Success Rate: 50.0% │
│ │
│ Performance │
@@ -88,7 +88,7 @@ exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in ye
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 10 ( 9 1 ) │
│ Tool Calls: 10 ( 9 x 1 ) │
│ Success Rate: 90.0% │
│ │
│ Performance │
@@ -108,7 +108,7 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides Efficiency secti
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 0 ( 0 0 ) │
│ Tool Calls: 0 ( 0 x 0 ) │
│ Success Rate: 0.0% │
│ │
│ Performance │
@@ -132,7 +132,7 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides User Agreement w
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 2 ( 1 1 ) │
│ Tool Calls: 2 ( 1 x 1 ) │
│ Success Rate: 50.0% │
│ │
│ Performance │
@@ -152,7 +152,7 @@ exports[`<StatsDisplay /> > Title Rendering > renders the custom title when a ti
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 0 ( 0 0 ) │
│ Tool Calls: 0 ( 0 x 0 ) │
│ Success Rate: 0.0% │
│ │
│ Performance │
@@ -172,7 +172,7 @@ exports[`<StatsDisplay /> > Title Rendering > renders the default title when no
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 0 ( 0 0 ) │
│ Tool Calls: 0 ( 0 x 0 ) │
│ Success Rate: 0.0% │
│ │
│ Performance │
@@ -192,7 +192,7 @@ exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 0 ( 0 0 ) │
│ Tool Calls: 0 ( 0 x 0 ) │
│ Success Rate: 0.0% │
│ │
│ Performance │
@@ -221,7 +221,7 @@ exports[`<StatsDisplay /> > renders all sections when all data is present 1`] =
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 2 ( 1 1 ) │
│ Tool Calls: 2 ( 1 x 1 ) │
│ Success Rate: 50.0% │
│ User Agreement: 100.0% (1 reviewed) │
│ │
@@ -250,7 +250,7 @@ exports[`<StatsDisplay /> > renders only the Performance section in its zero sta
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 0 ( 0 0 ) │
│ Tool Calls: 0 ( 0 x 0 ) │
│ Success Rate: 0.0% │
│ │
│ Performance │

View File

@@ -4,11 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { CompressionProps } from '../../types.js';
import type { CompressionProps } from '../../types.js';
import Spinner from 'ink-spinner';
import { Colors } from '../../colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
export interface CompressionDisplayProps {
compression: CompressionProps;
@@ -40,6 +41,7 @@ export const CompressionMessage: React.FC<CompressionDisplayProps> = ({
color={
compression.isPending ? Colors.AccentPurple : Colors.AccentGreen
}
aria-label={SCREEN_READER_MODEL_PREFIX}
>
{text}
</Text>

View File

@@ -4,10 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import type React from 'react';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { Colors } from '../../colors.js';
import crypto from 'crypto';
import crypto from 'node:crypto';
import { colorizeCode, colorizeLine } from '../../utils/CodeColorizer.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { theme } from '../../semantic-colors.js';
@@ -107,6 +107,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
terminalWidth,
theme,
}) => {
const screenReaderEnabled = useIsScreenReaderEnabled();
if (!diffContent || typeof diffContent !== 'string') {
return <Text color={Colors.AccentYellow}>No diff content.</Text>;
}
@@ -120,6 +121,17 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
</Box>
);
}
if (screenReaderEnabled) {
return (
<Box flexDirection="column">
{parsedLines.map((line, index) => (
<Text key={index}>
{line.type}: {line.content}
</Text>
))}
</Box>
);
}
// Check if the diff represents a new file (only additions and header lines)
const isNewFile = parsedLines.every(

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Text, Box } from 'ink';
import { Colors } from '../../colors.js';

View File

@@ -4,10 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Text, Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { Colors } from '../../colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
interface GeminiMessageProps {
text: string;
@@ -28,7 +29,12 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={Colors.AccentPurple}>{prefix}</Text>
<Text
color={Colors.AccentPurple}
aria-label={SCREEN_READER_MODEL_PREFIX}
>
{prefix}
</Text>
</Box>
<Box flexGrow={1} flexDirection="column">
<MarkdownDisplay

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Text, Box } from 'ink';
import { Colors } from '../../colors.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';

View File

@@ -6,10 +6,18 @@
import { describe, it, expect, vi } from 'vitest';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import { ToolCallConfirmationDetails } from '@qwen-code/qwen-code-core';
import type {
ToolCallConfirmationDetails,
Config,
} from '@qwen-code/qwen-code-core';
import { renderWithProviders } from '../../../test-utils/render.js';
describe('ToolConfirmationMessage', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
} as unknown as Config;
it('should not display urls if prompt and url are the same', () => {
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'info',
@@ -22,6 +30,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
@@ -45,6 +54,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
@@ -55,4 +65,119 @@ describe('ToolConfirmationMessage', () => {
'- https://raw.githubusercontent.com/google/gemini-react/main/README.md',
);
});
describe('with folder trust', () => {
const editConfirmationDetails: ToolCallConfirmationDetails = {
type: 'edit',
title: 'Confirm Edit',
fileName: 'test.txt',
filePath: '/test.txt',
fileDiff: '...diff...',
originalContent: 'a',
newContent: 'b',
onConfirm: vi.fn(),
};
const execConfirmationDetails: ToolCallConfirmationDetails = {
type: 'exec',
title: 'Confirm Execution',
command: 'echo "hello"',
rootCommand: 'echo',
onConfirm: vi.fn(),
};
const infoConfirmationDetails: ToolCallConfirmationDetails = {
type: 'info',
title: 'Confirm Web Fetch',
prompt: 'https://example.com',
urls: ['https://example.com'],
onConfirm: vi.fn(),
};
const mcpConfirmationDetails: ToolCallConfirmationDetails = {
type: 'mcp',
title: 'Confirm MCP Tool',
serverName: 'test-server',
toolName: 'test-tool',
toolDisplayName: 'Test Tool',
onConfirm: vi.fn(),
};
describe.each([
{
description: 'for edit confirmations',
details: editConfirmationDetails,
alwaysAllowText: 'Yes, allow always',
},
{
description: 'for exec confirmations',
details: execConfirmationDetails,
alwaysAllowText: 'Yes, allow always',
},
{
description: 'for info confirmations',
details: infoConfirmationDetails,
alwaysAllowText: 'Yes, allow always',
},
{
description: 'for mcp confirmations',
details: mcpConfirmationDetails,
alwaysAllowText: 'always allow',
},
])('$description', ({ details, alwaysAllowText }) => {
it('should show "allow always" when folder is trusted', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
} as unknown as Config;
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={details}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain(alwaysAllowText);
});
it('should show "allow always" when folder trust is undefined', () => {
const mockConfig = {
isTrustedFolder: () => undefined,
getIdeMode: () => false,
} as unknown as Config;
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={details}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
expect(lastFrame()).toContain(alwaysAllowText);
});
it('should NOT show "allow always" when folder is untrusted', () => {
const mockConfig = {
isTrustedFolder: () => false,
getIdeMode: () => false,
} as unknown as Config;
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={details}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);
expect(lastFrame()).not.toContain(alwaysAllowText);
});
});
});
});

View File

@@ -4,28 +4,26 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { Colors } from '../../colors.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
import {
import type {
ToolCallConfirmationDetails,
ToolConfirmationOutcome,
ToolExecuteConfirmationDetails,
ToolMcpConfirmationDetails,
Config,
} from '@qwen-code/qwen-code-core';
import {
RadioButtonSelect,
RadioSelectItem,
} from '../shared/RadioButtonSelect.js';
import { ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
import type { RadioSelectItem } from '../shared/RadioButtonSelect.js';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { useKeypress } from '../../hooks/useKeypress.js';
export interface ToolConfirmationMessageProps {
confirmationDetails: ToolCallConfirmationDetails;
config?: Config;
config: Config;
isFocused?: boolean;
availableTerminalHeight?: number;
terminalWidth: number;
@@ -45,8 +43,8 @@ export const ToolConfirmationMessage: React.FC<
const handleConfirm = async (outcome: ToolConfirmationOutcome) => {
if (confirmationDetails.type === 'edit') {
const ideClient = config?.getIdeClient();
if (config?.getIdeMode()) {
const ideClient = config.getIdeClient();
if (config.getIdeMode()) {
const cliOutcome =
outcome === ToolConfirmationOutcome.Cancel ? 'rejected' : 'accepted';
await ideClient?.resolveDiffFromCli(
@@ -58,6 +56,8 @@ export const ToolConfirmationMessage: React.FC<
onConfirm(outcome);
};
const isTrustedFolder = config.isTrustedFolder() !== false;
useKeypress(
(key) => {
if (!isFocused) return;
@@ -127,17 +127,17 @@ export const ToolConfirmationMessage: React.FC<
}
question = `Apply this change?`;
options.push(
{
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
},
{
options.push({
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
});
if (isTrustedFolder) {
options.push({
label: 'Yes, allow always',
value: ToolConfirmationOutcome.ProceedAlways,
},
);
if (config?.getIdeMode()) {
});
}
if (config.getIdeMode()) {
options.push({
label: 'No (esc)',
value: ToolConfirmationOutcome.Cancel,
@@ -166,20 +166,20 @@ export const ToolConfirmationMessage: React.FC<
confirmationDetails as ToolExecuteConfirmationDetails;
question = `Allow execution of: '${executionProps.rootCommand}'?`;
options.push(
{
label: `Yes, allow once`,
value: ToolConfirmationOutcome.ProceedOnce,
},
{
options.push({
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
});
if (isTrustedFolder) {
options.push({
label: `Yes, allow always ...`,
value: ToolConfirmationOutcome.ProceedAlways,
},
{
label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
},
);
});
}
options.push({
label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
});
let bodyContentHeight = availableBodyContentHeight();
if (bodyContentHeight !== undefined) {
@@ -206,20 +206,20 @@ export const ToolConfirmationMessage: React.FC<
!(infoProps.urls.length === 1 && infoProps.urls[0] === infoProps.prompt);
question = `Do you want to proceed?`;
options.push(
{
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
},
{
options.push({
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
});
if (isTrustedFolder) {
options.push({
label: 'Yes, allow always',
value: ToolConfirmationOutcome.ProceedAlways,
},
{
label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
},
);
});
}
options.push({
label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
});
bodyContent = (
<Box flexDirection="column" paddingX={1} marginLeft={1}>
@@ -251,24 +251,24 @@ export const ToolConfirmationMessage: React.FC<
);
question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`;
options.push(
{
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
},
{
options.push({
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
});
if (isTrustedFolder) {
options.push({
label: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
value: ToolConfirmationOutcome.ProceedAlwaysTool, // Cast until types are updated
},
{
});
options.push({
label: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
value: ToolConfirmationOutcome.ProceedAlwaysServer,
},
{
label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
},
);
});
}
options.push({
label: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
});
}
return (

View File

@@ -0,0 +1,350 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { Text } from 'ink';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import { type IndividualToolCallDisplay, ToolCallStatus } from '../../types.js';
import type {
Config,
ToolCallConfirmationDetails,
} from '@qwen-code/qwen-code-core';
import { TOOL_STATUS } from '../../constants.js';
// Mock child components to isolate ToolGroupMessage behavior
vi.mock('./ToolMessage.js', () => ({
ToolMessage: function MockToolMessage({
callId,
name,
description,
status,
emphasis,
}: {
callId: string;
name: string;
description: string;
status: ToolCallStatus;
emphasis: string;
}) {
// Use the same constants as the real component
const statusSymbolMap: Record<ToolCallStatus, string> = {
[ToolCallStatus.Success]: TOOL_STATUS.SUCCESS,
[ToolCallStatus.Pending]: TOOL_STATUS.PENDING,
[ToolCallStatus.Executing]: TOOL_STATUS.EXECUTING,
[ToolCallStatus.Confirming]: TOOL_STATUS.CONFIRMING,
[ToolCallStatus.Canceled]: TOOL_STATUS.CANCELED,
[ToolCallStatus.Error]: TOOL_STATUS.ERROR,
};
const statusSymbol = statusSymbolMap[status] || '?';
return (
<Text>
MockTool[{callId}]: {statusSymbol} {name} - {description} ({emphasis})
</Text>
);
},
}));
vi.mock('./ToolConfirmationMessage.js', () => ({
ToolConfirmationMessage: function MockToolConfirmationMessage({
confirmationDetails,
}: {
confirmationDetails: ToolCallConfirmationDetails;
}) {
const displayText =
confirmationDetails?.type === 'info'
? (confirmationDetails as { prompt: string }).prompt
: confirmationDetails?.title || 'confirm';
return <Text>MockConfirmation: {displayText}</Text>;
},
}));
describe('<ToolGroupMessage />', () => {
const mockConfig: Config = {} as Config;
const createToolCall = (
overrides: Partial<IndividualToolCallDisplay> = {},
): IndividualToolCallDisplay => ({
callId: 'tool-123',
name: 'test-tool',
description: 'A tool for testing',
resultDisplay: 'Test result',
status: ToolCallStatus.Success,
confirmationDetails: undefined,
renderOutputAsMarkdown: false,
...overrides,
});
const baseProps = {
groupId: 1,
terminalWidth: 80,
config: mockConfig,
isFocused: true,
};
describe('Golden Snapshots', () => {
it('renders single successful tool call', () => {
const toolCalls = [createToolCall()];
const { lastFrame } = render(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders multiple tool calls with different statuses', () => {
const toolCalls = [
createToolCall({
callId: 'tool-1',
name: 'successful-tool',
description: 'This tool succeeded',
status: ToolCallStatus.Success,
}),
createToolCall({
callId: 'tool-2',
name: 'pending-tool',
description: 'This tool is pending',
status: ToolCallStatus.Pending,
}),
createToolCall({
callId: 'tool-3',
name: 'error-tool',
description: 'This tool failed',
status: ToolCallStatus.Error,
}),
];
const { lastFrame } = render(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders tool call awaiting confirmation', () => {
const toolCalls = [
createToolCall({
callId: 'tool-confirm',
name: 'confirmation-tool',
description: 'This tool needs confirmation',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'info',
title: 'Confirm Tool Execution',
prompt: 'Are you sure you want to proceed?',
onConfirm: vi.fn(),
},
}),
];
const { lastFrame } = render(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders shell command with yellow border', () => {
const toolCalls = [
createToolCall({
callId: 'shell-1',
name: 'run_shell_command',
description: 'Execute shell command',
status: ToolCallStatus.Success,
}),
];
const { lastFrame } = render(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders mixed tool calls including shell command', () => {
const toolCalls = [
createToolCall({
callId: 'tool-1',
name: 'read_file',
description: 'Read a file',
status: ToolCallStatus.Success,
}),
createToolCall({
callId: 'tool-2',
name: 'run_shell_command',
description: 'Run command',
status: ToolCallStatus.Executing,
}),
createToolCall({
callId: 'tool-3',
name: 'write_file',
description: 'Write to file',
status: ToolCallStatus.Pending,
}),
];
const { lastFrame } = render(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders with limited terminal height', () => {
const toolCalls = [
createToolCall({
callId: 'tool-1',
name: 'tool-with-result',
description: 'Tool with output',
resultDisplay:
'This is a long result that might need height constraints',
}),
createToolCall({
callId: 'tool-2',
name: 'another-tool',
description: 'Another tool',
resultDisplay: 'More output here',
}),
];
const { lastFrame } = render(
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
availableTerminalHeight={10}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders when not focused', () => {
const toolCalls = [createToolCall()];
const { lastFrame } = render(
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
isFocused={false}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders with narrow terminal width', () => {
const toolCalls = [
createToolCall({
name: 'very-long-tool-name-that-might-wrap',
description:
'This is a very long description that might cause wrapping issues',
}),
];
const { lastFrame } = render(
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
terminalWidth={40}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders empty tool calls array', () => {
const { lastFrame } = render(
<ToolGroupMessage {...baseProps} toolCalls={[]} />,
);
expect(lastFrame()).toMatchSnapshot();
});
});
describe('Border Color Logic', () => {
it('uses yellow border when tools are pending', () => {
const toolCalls = [createToolCall({ status: ToolCallStatus.Pending })];
const { lastFrame } = render(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
// The snapshot will capture the visual appearance including border color
expect(lastFrame()).toMatchSnapshot();
});
it('uses yellow border for shell commands even when successful', () => {
const toolCalls = [
createToolCall({
name: 'run_shell_command',
status: ToolCallStatus.Success,
}),
];
const { lastFrame } = render(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('uses gray border when all tools are successful and no shell commands', () => {
const toolCalls = [
createToolCall({ status: ToolCallStatus.Success }),
createToolCall({
callId: 'tool-2',
name: 'another-tool',
status: ToolCallStatus.Success,
}),
];
const { lastFrame } = render(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
expect(lastFrame()).toMatchSnapshot();
});
});
describe('Height Calculation', () => {
it('calculates available height correctly with multiple tools with results', () => {
const toolCalls = [
createToolCall({
callId: 'tool-1',
resultDisplay: 'Result 1',
}),
createToolCall({
callId: 'tool-2',
resultDisplay: 'Result 2',
}),
createToolCall({
callId: 'tool-3',
resultDisplay: '', // No result
}),
];
const { lastFrame } = render(
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
availableTerminalHeight={20}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
});
describe('Confirmation Handling', () => {
it('shows confirmation dialog for first confirming tool only', () => {
const toolCalls = [
createToolCall({
callId: 'tool-1',
name: 'first-confirm',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'info',
title: 'Confirm First Tool',
prompt: 'Confirm first tool',
onConfirm: vi.fn(),
},
}),
createToolCall({
callId: 'tool-2',
name: 'second-confirm',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'info',
title: 'Confirm Second Tool',
prompt: 'Confirm second tool',
onConfirm: vi.fn(),
},
}),
];
const { lastFrame } = render(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
// Should only show confirmation for the first tool
expect(lastFrame()).toMatchSnapshot();
});
});
});

View File

@@ -4,13 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useMemo } from 'react';
import type React from 'react';
import { useMemo } from 'react';
import { Box } from 'ink';
import { IndividualToolCallDisplay, ToolCallStatus } from '../../types.js';
import type { IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
import { ToolMessage } from './ToolMessage.js';
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
import { Colors } from '../../colors.js';
import { Config } from '@qwen-code/qwen-code-core';
import type { Config } from '@qwen-code/qwen-code-core';
import { SHELL_COMMAND_NAME } from '../../constants.js';
interface ToolGroupMessageProps {
@@ -18,7 +20,7 @@ interface ToolGroupMessageProps {
toolCalls: IndividualToolCallDisplay[];
availableTerminalHeight?: number;
terminalWidth: number;
config?: Config;
config: Config;
isFocused?: boolean;
}
@@ -80,6 +82,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
marginLeft={1}
borderDimColor={hasPending}
borderColor={borderColor}
gap={1}
>
{toolCalls.map((tool) => {
const isConfirming = toolAwaitingApproval?.callId === tool.callId;

View File

@@ -6,7 +6,8 @@
import React from 'react';
import { render } from 'ink-testing-library';
import { ToolMessage, ToolMessageProps } from './ToolMessage.js';
import type { ToolMessageProps } from './ToolMessage.js';
import { ToolMessage } from './ToolMessage.js';
import { StreamingState, ToolCallStatus } from '../../types.js';
import { Text } from 'ink';
import { StreamingContext } from '../../contexts/StreamingContext.js';
@@ -71,19 +72,19 @@ describe('<ToolMessage />', () => {
StreamingState.Idle,
);
const output = lastFrame();
expect(output).toContain(''); // Success indicator
expect(output).toContain(''); // Success indicator
expect(output).toContain('test-tool');
expect(output).toContain('A tool for testing');
expect(output).toContain('MockMarkdown:Test result');
});
describe('ToolStatusIndicator rendering', () => {
it('shows for Success status', () => {
it('shows for Success status', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} status={ToolCallStatus.Success} />,
StreamingState.Idle,
);
expect(lastFrame()).toContain('');
expect(lastFrame()).toContain('');
});
it('shows o for Pending status', () => {
@@ -125,7 +126,7 @@ describe('<ToolMessage />', () => {
);
expect(lastFrame()).toContain('⊷');
expect(lastFrame()).not.toContain('MockRespondingSpinner');
expect(lastFrame()).not.toContain('');
expect(lastFrame()).not.toContain('');
});
it('shows paused spinner for Executing status when streamingState is WaitingForConfirmation', () => {
@@ -135,7 +136,7 @@ describe('<ToolMessage />', () => {
);
expect(lastFrame()).toContain('⊷');
expect(lastFrame()).not.toContain('MockRespondingSpinner');
expect(lastFrame()).not.toContain('');
expect(lastFrame()).not.toContain('');
});
it('shows MockRespondingSpinner for Executing status when streamingState is Responding', () => {
@@ -144,7 +145,7 @@ describe('<ToolMessage />', () => {
StreamingState.Responding, // Simulate app still responding
);
expect(lastFrame()).toContain('MockRespondingSpinner');
expect(lastFrame()).not.toContain('');
expect(lastFrame()).not.toContain('');
});
});

View File

@@ -6,14 +6,16 @@
import React from 'react';
import { Box, Text } from 'ink';
import { IndividualToolCallDisplay, ToolCallStatus } from '../../types.js';
import type { IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
import { DiffRenderer } from './DiffRenderer.js';
import { Colors } from '../../colors.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { TodoDisplay } from '../TodoDisplay.js';
import { TodoResultDisplay } from '@qwen-code/qwen-code-core';
import type { TodoResultDisplay } from '@qwen-code/qwen-code-core';
import { TOOL_STATUS } from '../../constants.js';
const STATIC_HEIGHT = 1;
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
@@ -220,28 +222,32 @@ const ToolStatusIndicator: React.FC<ToolStatusIndicatorProps> = ({
}) => (
<Box minWidth={STATUS_INDICATOR_WIDTH}>
{status === ToolCallStatus.Pending && (
<Text color={Colors.AccentGreen}>o</Text>
<Text color={Colors.AccentGreen}>{TOOL_STATUS.PENDING}</Text>
)}
{status === ToolCallStatus.Executing && (
<GeminiRespondingSpinner
spinnerType="toggle"
nonRespondingDisplay={'⊷'}
nonRespondingDisplay={TOOL_STATUS.EXECUTING}
/>
)}
{status === ToolCallStatus.Success && (
<Text color={Colors.AccentGreen}></Text>
<Text color={Colors.AccentGreen} aria-label={'Success:'}>
{TOOL_STATUS.SUCCESS}
</Text>
)}
{status === ToolCallStatus.Confirming && (
<Text color={Colors.AccentYellow}>?</Text>
<Text color={Colors.AccentYellow} aria-label={'Confirming:'}>
{TOOL_STATUS.CONFIRMING}
</Text>
)}
{status === ToolCallStatus.Canceled && (
<Text color={Colors.AccentYellow} bold>
-
<Text color={Colors.AccentYellow} aria-label={'Canceled:'} bold>
{TOOL_STATUS.CANCELED}
</Text>
)}
{status === ToolCallStatus.Error && (
<Text color={Colors.AccentRed} bold>
x
<Text color={Colors.AccentRed} aria-label={'Error:'} bold>
{TOOL_STATUS.ERROR}
</Text>
)}
</Box>

View File

@@ -4,9 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Text, Box } from 'ink';
import { Colors } from '../../colors.js';
import { SCREEN_READER_USER_PREFIX } from '../../textConstants.js';
import { isSlashCommand as checkIsSlashCommand } from '../../utils/commandUtils.js';
interface UserMessageProps {
text: string;
@@ -15,7 +17,7 @@ interface UserMessageProps {
export const UserMessage: React.FC<UserMessageProps> = ({ text }) => {
const prefix = '> ';
const prefixWidth = prefix.length;
const isSlashCommand = text.startsWith('/');
const isSlashCommand = checkIsSlashCommand(text);
const textColor = isSlashCommand ? Colors.AccentPurple : Colors.Gray;
const borderColor = isSlashCommand ? Colors.AccentPurple : Colors.Gray;
@@ -31,7 +33,9 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text }) => {
alignSelf="flex-start"
>
<Box width={prefixWidth}>
<Text color={textColor}>{prefix}</Text>
<Text color={textColor} aria-label={SCREEN_READER_USER_PREFIX}>
{prefix}
</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={textColor}>

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';

Some files were not shown because too many files have changed in this diff Show More