mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
Merge tag 'v0.3.0' into chore/sync-gemini-cli-v0.3.0
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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() &&
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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:">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
108
packages/cli/src/ui/components/WorkspaceMigrationDialog.tsx
Normal file
108
packages/cli/src/ui/components/WorkspaceMigrationDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 │
|
||||
│ │
|
||||
|
||||
@@ -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 │
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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('✓');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user