mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
pre-release commit
This commit is contained in:
595
packages/cli/src/ui/App.test.tsx
Normal file
595
packages/cli/src/ui/App.test.tsx
Normal file
@@ -0,0 +1,595 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { AppWrapper as App } from './App.js';
|
||||
import {
|
||||
Config as ServerConfig,
|
||||
MCPServerConfig,
|
||||
ApprovalMode,
|
||||
ToolRegistry,
|
||||
AccessibilitySettings,
|
||||
SandboxConfig,
|
||||
GeminiClient,
|
||||
} from '@qwen/qwen-code-core';
|
||||
import { LoadedSettings, SettingsFile, Settings } from '../config/settings.js';
|
||||
import process from 'node:process';
|
||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||
import { StreamingState } from './types.js';
|
||||
import { Tips } from './components/Tips.js';
|
||||
|
||||
// Define a more complete mock server config based on actual Config
|
||||
interface MockServerConfig {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
sandbox?: SandboxConfig;
|
||||
targetDir: string;
|
||||
debugMode: boolean;
|
||||
question?: string;
|
||||
fullContext: boolean;
|
||||
coreTools?: string[];
|
||||
toolDiscoveryCommand?: string;
|
||||
toolCallCommand?: string;
|
||||
mcpServerCommand?: string;
|
||||
mcpServers?: Record<string, MCPServerConfig>; // Use imported MCPServerConfig
|
||||
userAgent: string;
|
||||
userMemory: string;
|
||||
geminiMdFileCount: number;
|
||||
approvalMode: ApprovalMode;
|
||||
vertexai?: boolean;
|
||||
showMemoryUsage?: boolean;
|
||||
accessibility?: AccessibilitySettings;
|
||||
embeddingModel: string;
|
||||
|
||||
getApiKey: Mock<() => string>;
|
||||
getModel: Mock<() => string>;
|
||||
getSandbox: Mock<() => SandboxConfig | undefined>;
|
||||
getTargetDir: Mock<() => string>;
|
||||
getToolRegistry: Mock<() => ToolRegistry>; // Use imported ToolRegistry type
|
||||
getDebugMode: Mock<() => boolean>;
|
||||
getQuestion: Mock<() => string | undefined>;
|
||||
getFullContext: Mock<() => boolean>;
|
||||
getCoreTools: Mock<() => string[] | undefined>;
|
||||
getToolDiscoveryCommand: Mock<() => string | undefined>;
|
||||
getToolCallCommand: Mock<() => string | undefined>;
|
||||
getMcpServerCommand: Mock<() => string | undefined>;
|
||||
getMcpServers: Mock<() => Record<string, MCPServerConfig> | undefined>;
|
||||
getUserAgent: Mock<() => string>;
|
||||
getUserMemory: Mock<() => string>;
|
||||
setUserMemory: Mock<(newUserMemory: string) => void>;
|
||||
getGeminiMdFileCount: Mock<() => number>;
|
||||
setGeminiMdFileCount: Mock<(count: number) => void>;
|
||||
getApprovalMode: Mock<() => ApprovalMode>;
|
||||
setApprovalMode: Mock<(skip: ApprovalMode) => void>;
|
||||
getVertexAI: Mock<() => boolean | undefined>;
|
||||
getShowMemoryUsage: Mock<() => boolean>;
|
||||
getAccessibility: Mock<() => AccessibilitySettings>;
|
||||
getProjectRoot: Mock<() => string | undefined>;
|
||||
getAllGeminiMdFilenames: Mock<() => string[]>;
|
||||
getGeminiClient: Mock<() => GeminiClient | undefined>;
|
||||
getUserTier: Mock<() => Promise<string | undefined>>;
|
||||
}
|
||||
|
||||
// Mock @qwen/qwen-code-core and its Config class
|
||||
vi.mock('@qwen/qwen-code-core', async (importOriginal) => {
|
||||
const actualCore =
|
||||
await importOriginal<typeof import('@qwen/qwen-code-core')>();
|
||||
const ConfigClassMock = vi
|
||||
.fn()
|
||||
.mockImplementation((optionsPassedToConstructor) => {
|
||||
const opts = { ...optionsPassedToConstructor }; // Clone
|
||||
// Basic mock structure, will be extended by the instance in tests
|
||||
return {
|
||||
apiKey: opts.apiKey || 'test-key',
|
||||
model: opts.model || 'test-model-in-mock-factory',
|
||||
sandbox: opts.sandbox,
|
||||
targetDir: opts.targetDir || '/test/dir',
|
||||
debugMode: opts.debugMode || false,
|
||||
question: opts.question,
|
||||
fullContext: opts.fullContext ?? false,
|
||||
coreTools: opts.coreTools,
|
||||
toolDiscoveryCommand: opts.toolDiscoveryCommand,
|
||||
toolCallCommand: opts.toolCallCommand,
|
||||
mcpServerCommand: opts.mcpServerCommand,
|
||||
mcpServers: opts.mcpServers,
|
||||
userAgent: opts.userAgent || 'test-agent',
|
||||
userMemory: opts.userMemory || '',
|
||||
geminiMdFileCount: opts.geminiMdFileCount || 0,
|
||||
approvalMode: opts.approvalMode ?? ApprovalMode.DEFAULT,
|
||||
vertexai: opts.vertexai,
|
||||
showMemoryUsage: opts.showMemoryUsage ?? false,
|
||||
accessibility: opts.accessibility ?? {},
|
||||
embeddingModel: opts.embeddingModel || 'test-embedding-model',
|
||||
|
||||
getApiKey: vi.fn(() => opts.apiKey || 'test-key'),
|
||||
getModel: vi.fn(() => opts.model || 'test-model-in-mock-factory'),
|
||||
getSandbox: vi.fn(() => opts.sandbox),
|
||||
getTargetDir: vi.fn(() => opts.targetDir || '/test/dir'),
|
||||
getToolRegistry: vi.fn(() => ({}) as ToolRegistry), // Simple mock
|
||||
getDebugMode: vi.fn(() => opts.debugMode || false),
|
||||
getQuestion: vi.fn(() => opts.question),
|
||||
getFullContext: vi.fn(() => opts.fullContext ?? false),
|
||||
getCoreTools: vi.fn(() => opts.coreTools),
|
||||
getToolDiscoveryCommand: vi.fn(() => opts.toolDiscoveryCommand),
|
||||
getToolCallCommand: vi.fn(() => opts.toolCallCommand),
|
||||
getMcpServerCommand: vi.fn(() => opts.mcpServerCommand),
|
||||
getMcpServers: vi.fn(() => opts.mcpServers),
|
||||
getUserAgent: vi.fn(() => opts.userAgent || 'test-agent'),
|
||||
getUserMemory: vi.fn(() => opts.userMemory || ''),
|
||||
setUserMemory: vi.fn(),
|
||||
getGeminiMdFileCount: vi.fn(() => opts.geminiMdFileCount || 0),
|
||||
setGeminiMdFileCount: vi.fn(),
|
||||
getApprovalMode: vi.fn(() => opts.approvalMode ?? ApprovalMode.DEFAULT),
|
||||
setApprovalMode: vi.fn(),
|
||||
getVertexAI: vi.fn(() => opts.vertexai),
|
||||
getShowMemoryUsage: vi.fn(() => opts.showMemoryUsage ?? false),
|
||||
getAccessibility: vi.fn(() => opts.accessibility ?? {}),
|
||||
getProjectRoot: vi.fn(() => opts.targetDir),
|
||||
getGeminiClient: vi.fn(() => ({})),
|
||||
getCheckpointingEnabled: vi.fn(() => opts.checkpointing ?? true),
|
||||
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
|
||||
setFlashFallbackHandler: vi.fn(),
|
||||
getSessionId: vi.fn(() => 'test-session-id'),
|
||||
getUserTier: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
return {
|
||||
...actualCore,
|
||||
Config: ConfigClassMock,
|
||||
MCPServerConfig: actualCore.MCPServerConfig,
|
||||
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock heavy dependencies or those with side effects
|
||||
vi.mock('./hooks/useGeminiStream', () => ({
|
||||
useGeminiStream: vi.fn(() => ({
|
||||
streamingState: 'Idle',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('./hooks/useAuthCommand', () => ({
|
||||
useAuthCommand: vi.fn(() => ({
|
||||
isAuthDialogOpen: false,
|
||||
openAuthDialog: vi.fn(),
|
||||
handleAuthSelect: vi.fn(),
|
||||
handleAuthHighlight: vi.fn(),
|
||||
isAuthenticating: false,
|
||||
cancelAuthentication: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('./hooks/useLogger', () => ({
|
||||
useLogger: vi.fn(() => ({
|
||||
getPreviousUserMessages: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../config/config.js', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
// @ts-expect-error - this is fine
|
||||
...actual,
|
||||
loadHierarchicalGeminiMemory: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ memoryContent: '', fileCount: 0 }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./components/Tips.js', () => ({
|
||||
Tips: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock('./components/Header.js', () => ({
|
||||
Header: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
describe('App UI', () => {
|
||||
let mockConfig: MockServerConfig;
|
||||
let mockSettings: LoadedSettings;
|
||||
let mockVersion: string;
|
||||
let currentUnmount: (() => void) | undefined;
|
||||
|
||||
const createMockSettings = (
|
||||
settings: {
|
||||
system?: Partial<Settings>;
|
||||
user?: Partial<Settings>;
|
||||
workspace?: Partial<Settings>;
|
||||
} = {},
|
||||
): LoadedSettings => {
|
||||
const systemSettingsFile: SettingsFile = {
|
||||
path: '/system/settings.json',
|
||||
settings: settings.system || {},
|
||||
};
|
||||
const userSettingsFile: SettingsFile = {
|
||||
path: '/user/settings.json',
|
||||
settings: settings.user || {},
|
||||
};
|
||||
const workspaceSettingsFile: SettingsFile = {
|
||||
path: '/workspace/.qwen/settings.json',
|
||||
settings: settings.workspace || {},
|
||||
};
|
||||
return new LoadedSettings(
|
||||
systemSettingsFile,
|
||||
userSettingsFile,
|
||||
workspaceSettingsFile,
|
||||
[],
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
const ServerConfigMocked = vi.mocked(ServerConfig, true);
|
||||
mockConfig = new ServerConfigMocked({
|
||||
embeddingModel: 'test-embedding-model',
|
||||
sandbox: undefined,
|
||||
targetDir: '/test/dir',
|
||||
debugMode: false,
|
||||
userMemory: '',
|
||||
geminiMdFileCount: 0,
|
||||
showMemoryUsage: false,
|
||||
sessionId: 'test-session-id',
|
||||
cwd: '/tmp',
|
||||
model: 'model',
|
||||
}) as unknown as MockServerConfig;
|
||||
mockVersion = '0.0.0-test';
|
||||
|
||||
// Ensure the getShowMemoryUsage mock function is specifically set up if not covered by constructor mock
|
||||
if (!mockConfig.getShowMemoryUsage) {
|
||||
mockConfig.getShowMemoryUsage = vi.fn(() => false);
|
||||
}
|
||||
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' } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (currentUnmount) {
|
||||
currentUnmount();
|
||||
currentUnmount = undefined;
|
||||
}
|
||||
vi.clearAllMocks(); // Clear mocks after each test
|
||||
});
|
||||
|
||||
it('should display default "GEMINI.md" in footer when contextFileName is not set and count is 1', async () => {
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
|
||||
// For this test, ensure showMemoryUsage is false or debugMode is false if it relies on that
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve(); // Wait for any async updates
|
||||
expect(lastFrame()).toContain('Using 1 GEMINI.md file');
|
||||
});
|
||||
|
||||
it('should display default "GEMINI.md" with plural when contextFileName is not set and count is > 1', async () => {
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain('Using 2 GEMINI.md files');
|
||||
});
|
||||
|
||||
it('should display custom contextFileName in footer when set and count is 1', async () => {
|
||||
mockSettings = createMockSettings({
|
||||
workspace: { contextFileName: 'AGENTS.md', theme: 'Default' },
|
||||
});
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain('Using 1 AGENTS.md file');
|
||||
});
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain('Using 2 context files');
|
||||
});
|
||||
|
||||
it('should display custom contextFileName with plural when set and count is > 1', async () => {
|
||||
mockSettings = createMockSettings({
|
||||
workspace: { contextFileName: 'MY_NOTES.TXT', theme: 'Default' },
|
||||
});
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(3);
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain('Using 3 MY_NOTES.TXT files');
|
||||
});
|
||||
|
||||
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' },
|
||||
});
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(0);
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).not.toContain('ANY_FILE.MD');
|
||||
});
|
||||
|
||||
it('should display GEMINI.md and MCP server count when both are present', async () => {
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
|
||||
mockConfig.getMcpServers.mockReturnValue({
|
||||
server1: {} as MCPServerConfig,
|
||||
});
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain('server');
|
||||
});
|
||||
|
||||
it('should display only MCP server count when GEMINI.md count is 0', async () => {
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(0);
|
||||
mockConfig.getMcpServers.mockReturnValue({
|
||||
server1: {} as MCPServerConfig,
|
||||
server2: {} as MCPServerConfig,
|
||||
});
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain('Using 2 MCP servers');
|
||||
});
|
||||
|
||||
it('should display Tips component by default', async () => {
|
||||
const { unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(vi.mocked(Tips)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not display Tips component when hideTips is true', async () => {
|
||||
mockSettings = createMockSettings({
|
||||
workspace: {
|
||||
hideTips: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(vi.mocked(Tips)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should display Header component by default', async () => {
|
||||
const { Header } = await import('./components/Header.js');
|
||||
const { unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(vi.mocked(Header)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not display Header component when hideBanner is true', async () => {
|
||||
const { Header } = await import('./components/Header.js');
|
||||
mockSettings = createMockSettings({
|
||||
user: { hideBanner: true },
|
||||
});
|
||||
|
||||
const { unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(vi.mocked(Header)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
const { unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(vi.mocked(Tips)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('when no theme is set', () => {
|
||||
let originalNoColor: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalNoColor = process.env.NO_COLOR;
|
||||
// Ensure no theme is set for these tests
|
||||
mockSettings = createMockSettings({});
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NO_COLOR = originalNoColor;
|
||||
});
|
||||
|
||||
it('should display theme dialog if NO_COLOR is not set', async () => {
|
||||
delete process.env.NO_COLOR;
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
|
||||
expect(lastFrame()).toContain('Select Theme');
|
||||
});
|
||||
|
||||
it('should display a message if NO_COLOR is set', async () => {
|
||||
process.env.NO_COLOR = 'true';
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
|
||||
expect(lastFrame()).toContain(
|
||||
'Theme configuration unavailable due to NO_COLOR env variable.',
|
||||
);
|
||||
expect(lastFrame()).not.toContain('Select Theme');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with initial prompt from --prompt-interactive', () => {
|
||||
it('should submit the initial prompt automatically', async () => {
|
||||
const mockSubmitQuery = vi.fn();
|
||||
|
||||
mockConfig.getQuestion = vi.fn(() => 'hello from prompt-interactive');
|
||||
|
||||
vi.mocked(useGeminiStream).mockReturnValue({
|
||||
streamingState: StreamingState.Idle,
|
||||
submitQuery: mockSubmitQuery,
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
});
|
||||
|
||||
mockConfig.getGeminiClient.mockReturnValue({
|
||||
isInitialized: vi.fn(() => true),
|
||||
} as unknown as GeminiClient);
|
||||
|
||||
const { unmount, rerender } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
|
||||
// Force a re-render to trigger useEffect
|
||||
rerender(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(mockSubmitQuery).toHaveBeenCalledWith(
|
||||
'hello from prompt-interactive',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
989
packages/cli/src/ui/App.tsx
Normal file
989
packages/cli/src/ui/App.tsx
Normal file
@@ -0,0 +1,989 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
DOMElement,
|
||||
measureElement,
|
||||
Static,
|
||||
Text,
|
||||
useStdin,
|
||||
useStdout,
|
||||
useInput,
|
||||
type Key as InkKeyType,
|
||||
} from 'ink';
|
||||
import { StreamingState, type HistoryItem, MessageType } from './types.js';
|
||||
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
||||
import { useGeminiStream } from './hooks/useGeminiStream.js';
|
||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
||||
import { useAuthCommand } from './hooks/useAuthCommand.js';
|
||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
|
||||
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
||||
import { Header } from './components/Header.js';
|
||||
import { LoadingIndicator } from './components/LoadingIndicator.js';
|
||||
import { AutoAcceptIndicator } from './components/AutoAcceptIndicator.js';
|
||||
import { ShellModeIndicator } from './components/ShellModeIndicator.js';
|
||||
import { InputPrompt } from './components/InputPrompt.js';
|
||||
import { Footer } from './components/Footer.js';
|
||||
import { ThemeDialog } from './components/ThemeDialog.js';
|
||||
import { AuthDialog } from './components/AuthDialog.js';
|
||||
import { AuthInProgress } from './components/AuthInProgress.js';
|
||||
import { EditorSettingsDialog } from './components/EditorSettingsDialog.js';
|
||||
import { Colors } from './colors.js';
|
||||
import { Help } from './components/Help.js';
|
||||
import { loadHierarchicalGeminiMemory } from '../config/config.js';
|
||||
import { LoadedSettings } from '../config/settings.js';
|
||||
import { Tips } from './components/Tips.js';
|
||||
import { ConsolePatcher } from './utils/ConsolePatcher.js';
|
||||
import { registerCleanup } from '../utils/cleanup.js';
|
||||
import { DetailedMessagesDisplay } from './components/DetailedMessagesDisplay.js';
|
||||
import { HistoryItemDisplay } from './components/HistoryItemDisplay.js';
|
||||
import { ContextSummaryDisplay } from './components/ContextSummaryDisplay.js';
|
||||
import { useHistory } from './hooks/useHistoryManager.js';
|
||||
import process from 'node:process';
|
||||
import {
|
||||
getErrorMessage,
|
||||
type Config,
|
||||
getAllGeminiMdFilenames,
|
||||
ApprovalMode,
|
||||
isEditorAvailable,
|
||||
EditorType,
|
||||
FlashFallbackEvent,
|
||||
logFlashFallback,
|
||||
} from '@qwen/qwen-code-core';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
import { useLogger } from './hooks/useLogger.js';
|
||||
import { StreamingContext } from './contexts/StreamingContext.js';
|
||||
import {
|
||||
SessionStatsProvider,
|
||||
useSessionStats,
|
||||
} from './contexts/SessionContext.js';
|
||||
import { useGitBranchName } from './hooks/useGitBranchName.js';
|
||||
import { useBracketedPaste } from './hooks/useBracketedPaste.js';
|
||||
import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||
import * as fs from 'fs';
|
||||
import { UpdateNotification } from './components/UpdateNotification.js';
|
||||
import {
|
||||
isProQuotaExceededError,
|
||||
isGenericQuotaExceededError,
|
||||
UserTierId,
|
||||
} from '@qwen/qwen-code-core';
|
||||
import { checkForUpdates } from './utils/updateCheck.js';
|
||||
import ansiEscapes from 'ansi-escapes';
|
||||
import { OverflowProvider } from './contexts/OverflowContext.js';
|
||||
import { ShowMoreLines } from './components/ShowMoreLines.js';
|
||||
import { PrivacyNotice } from './privacy/PrivacyNotice.js';
|
||||
|
||||
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
|
||||
|
||||
interface AppProps {
|
||||
config: Config;
|
||||
settings: LoadedSettings;
|
||||
startupWarnings?: string[];
|
||||
version: string;
|
||||
}
|
||||
|
||||
export const AppWrapper = (props: AppProps) => (
|
||||
<SessionStatsProvider>
|
||||
<App {...props} />
|
||||
</SessionStatsProvider>
|
||||
);
|
||||
|
||||
const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
useBracketedPaste();
|
||||
const [updateMessage, setUpdateMessage] = useState<string | null>(null);
|
||||
const { stdout } = useStdout();
|
||||
const nightly = version.includes('nightly');
|
||||
|
||||
useEffect(() => {
|
||||
checkForUpdates().then(setUpdateMessage);
|
||||
}, []);
|
||||
|
||||
const { history, addItem, clearItems, loadHistory } = useHistory();
|
||||
const {
|
||||
consoleMessages,
|
||||
handleNewMessage,
|
||||
clearConsoleMessages: clearConsoleMessagesState,
|
||||
} = useConsoleMessages();
|
||||
|
||||
useEffect(() => {
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
onNewMessage: handleNewMessage,
|
||||
debugMode: config.getDebugMode(),
|
||||
});
|
||||
consolePatcher.patch();
|
||||
registerCleanup(consolePatcher.cleanup);
|
||||
}, [handleNewMessage, config]);
|
||||
|
||||
const { stats: sessionStats } = useSessionStats();
|
||||
const [staticNeedsRefresh, setStaticNeedsRefresh] = useState(false);
|
||||
const [staticKey, setStaticKey] = useState(0);
|
||||
const refreshStatic = useCallback(() => {
|
||||
stdout.write(ansiEscapes.clearTerminal);
|
||||
setStaticKey((prev) => prev + 1);
|
||||
}, [setStaticKey, stdout]);
|
||||
|
||||
const [geminiMdFileCount, setGeminiMdFileCount] = useState<number>(0);
|
||||
const [debugMessage, setDebugMessage] = useState<string>('');
|
||||
const [showHelp, setShowHelp] = useState<boolean>(false);
|
||||
const [themeError, setThemeError] = useState<string | null>(null);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const [editorError, setEditorError] = useState<string | null>(null);
|
||||
const [footerHeight, setFooterHeight] = useState<number>(0);
|
||||
const [corgiMode, setCorgiMode] = useState(false);
|
||||
const [currentModel, setCurrentModel] = useState(config.getModel());
|
||||
const [shellModeActive, setShellModeActive] = useState(false);
|
||||
const [showErrorDetails, setShowErrorDetails] = useState<boolean>(false);
|
||||
const [showToolDescriptions, setShowToolDescriptions] =
|
||||
useState<boolean>(false);
|
||||
const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false);
|
||||
const [quittingMessages, setQuittingMessages] = useState<
|
||||
HistoryItem[] | null
|
||||
>(null);
|
||||
const ctrlCTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false);
|
||||
const ctrlDTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
|
||||
const [showPrivacyNotice, setShowPrivacyNotice] = useState<boolean>(false);
|
||||
const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
|
||||
useState<boolean>(false);
|
||||
const [userTier, setUserTier] = useState<UserTierId | undefined>(undefined);
|
||||
|
||||
const openPrivacyNotice = useCallback(() => {
|
||||
setShowPrivacyNotice(true);
|
||||
}, []);
|
||||
const initialPromptSubmitted = useRef(false);
|
||||
|
||||
const errorCount = useMemo(
|
||||
() => consoleMessages.filter((msg) => msg.type === 'error').length,
|
||||
[consoleMessages],
|
||||
);
|
||||
|
||||
const {
|
||||
isThemeDialogOpen,
|
||||
openThemeDialog,
|
||||
handleThemeSelect,
|
||||
handleThemeHighlight,
|
||||
} = useThemeCommand(settings, setThemeError, addItem);
|
||||
|
||||
const {
|
||||
isAuthDialogOpen,
|
||||
openAuthDialog,
|
||||
handleAuthSelect,
|
||||
isAuthenticating,
|
||||
cancelAuthentication,
|
||||
} = useAuthCommand(settings, setAuthError, config);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.merged.selectedAuthType) {
|
||||
const error = validateAuthMethod(settings.merged.selectedAuthType);
|
||||
if (error) {
|
||||
setAuthError(error);
|
||||
openAuthDialog();
|
||||
}
|
||||
}
|
||||
}, [settings.merged.selectedAuthType, openAuthDialog, setAuthError]);
|
||||
|
||||
// Sync user tier from config when authentication changes
|
||||
useEffect(() => {
|
||||
const syncUserTier = async () => {
|
||||
try {
|
||||
const configUserTier = await config.getUserTier();
|
||||
if (configUserTier !== userTier) {
|
||||
setUserTier(configUserTier);
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail - this is not critical functionality
|
||||
// Only log in debug mode to avoid cluttering the console
|
||||
if (config.getDebugMode()) {
|
||||
console.debug('Failed to sync user tier:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Only sync when not currently authenticating
|
||||
if (!isAuthenticating) {
|
||||
syncUserTier();
|
||||
}
|
||||
}, [config, userTier, isAuthenticating]);
|
||||
|
||||
const {
|
||||
isEditorDialogOpen,
|
||||
openEditorDialog,
|
||||
handleEditorSelect,
|
||||
exitEditorDialog,
|
||||
} = useEditorSettings(settings, setEditorError, addItem);
|
||||
|
||||
const toggleCorgiMode = useCallback(() => {
|
||||
setCorgiMode((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const performMemoryRefresh = useCallback(async () => {
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Refreshing hierarchical memory (QWEN.md or other context files)...',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
try {
|
||||
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
|
||||
process.cwd(),
|
||||
config.getDebugMode(),
|
||||
config.getFileService(),
|
||||
config.getExtensionContextFilePaths(),
|
||||
);
|
||||
config.setUserMemory(memoryContent);
|
||||
config.setGeminiMdFileCount(fileCount);
|
||||
setGeminiMdFileCount(fileCount);
|
||||
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Memory refreshed successfully. ${memoryContent.length > 0 ? `Loaded ${memoryContent.length} characters from ${fileCount} file(s).` : 'No memory content found.'}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
if (config.getDebugMode()) {
|
||||
console.log(
|
||||
`[DEBUG] Refreshed memory content in config: ${memoryContent.substring(0, 200)}...`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: `Error refreshing memory: ${errorMessage}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
console.error('Error refreshing memory:', error);
|
||||
}
|
||||
}, [config, addItem]);
|
||||
|
||||
// Watch for model changes (e.g., from Flash fallback)
|
||||
useEffect(() => {
|
||||
const checkModelChange = () => {
|
||||
const configModel = config.getModel();
|
||||
if (configModel !== currentModel) {
|
||||
setCurrentModel(configModel);
|
||||
}
|
||||
};
|
||||
|
||||
// Check immediately and then periodically
|
||||
checkModelChange();
|
||||
const interval = setInterval(checkModelChange, 1000); // Check every second
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [config, currentModel]);
|
||||
|
||||
// Set up Flash fallback handler
|
||||
useEffect(() => {
|
||||
const flashFallbackHandler = async (
|
||||
currentModel: string,
|
||||
fallbackModel: string,
|
||||
error?: unknown,
|
||||
): Promise<boolean> => {
|
||||
let message: string;
|
||||
|
||||
// Use actual user tier if available, otherwise default to FREE tier behavior (safe default)
|
||||
const isPaidTier =
|
||||
userTier === UserTierId.LEGACY || userTier === UserTierId.STANDARD;
|
||||
|
||||
// Check if this is a Pro quota exceeded error
|
||||
if (error && isProQuotaExceededError(error)) {
|
||||
if (isPaidTier) {
|
||||
message = `⚡ You have reached your daily ${currentModel} quota limit.
|
||||
⚡ Automatically switching from ${currentModel} to ${fallbackModel} for the remainder of this session.
|
||||
⚡ To continue accessing the ${currentModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
|
||||
} else {
|
||||
message = `⚡ You have reached your daily ${currentModel} quota limit.
|
||||
⚡ Automatically switching from ${currentModel} to ${fallbackModel} for the remainder of this session.
|
||||
⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist
|
||||
⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key
|
||||
⚡ You can switch authentication methods by typing /auth`;
|
||||
}
|
||||
} else if (error && isGenericQuotaExceededError(error)) {
|
||||
if (isPaidTier) {
|
||||
message = `⚡ You have reached your daily quota limit.
|
||||
⚡ Automatically switching from ${currentModel} to ${fallbackModel} for the remainder of this session.
|
||||
⚡ To continue accessing the ${currentModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
|
||||
} else {
|
||||
message = `⚡ You have reached your daily quota limit.
|
||||
⚡ Automatically switching from ${currentModel} to ${fallbackModel} for the remainder of this session.
|
||||
⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist
|
||||
⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key
|
||||
⚡ You can switch authentication methods by typing /auth`;
|
||||
}
|
||||
} else {
|
||||
if (isPaidTier) {
|
||||
// Default fallback message for other cases (like consecutive 429s)
|
||||
message = `⚡ Automatically switching from ${currentModel} to ${fallbackModel} for faster responses for the remainder of this session.
|
||||
⚡ Possible reasons for this are that you have received multiple consecutive capacity errors or you have reached your daily ${currentModel} quota limit
|
||||
⚡ To continue accessing the ${currentModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
|
||||
} else {
|
||||
// Default fallback message for other cases (like consecutive 429s)
|
||||
message = `⚡ Automatically switching from ${currentModel} to ${fallbackModel} for faster responses for the remainder of this session.
|
||||
⚡ Possible reasons for this are that you have received multiple consecutive capacity errors or you have reached your daily ${currentModel} quota limit
|
||||
⚡ To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist
|
||||
⚡ Or you can utilize a Gemini API Key. See: https://goo.gle/gemini-cli-docs-auth#gemini-api-key
|
||||
⚡ You can switch authentication methods by typing /auth`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add message to UI history
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: message,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
// Set the flag to prevent tool continuation
|
||||
setModelSwitchedFromQuotaError(true);
|
||||
// Set global quota error flag to prevent Flash model calls
|
||||
config.setQuotaErrorOccurred(true);
|
||||
// Switch model for future use but return false to stop current retry
|
||||
config.setModel(fallbackModel);
|
||||
logFlashFallback(
|
||||
config,
|
||||
new FlashFallbackEvent(config.getContentGeneratorConfig().authType!),
|
||||
);
|
||||
return false; // Don't continue with current prompt
|
||||
};
|
||||
|
||||
config.setFlashFallbackHandler(flashFallbackHandler);
|
||||
}, [config, addItem, userTier]);
|
||||
|
||||
const {
|
||||
handleSlashCommand,
|
||||
slashCommands,
|
||||
pendingHistoryItems: pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
} = useSlashCommandProcessor(
|
||||
config,
|
||||
settings,
|
||||
history,
|
||||
addItem,
|
||||
clearItems,
|
||||
loadHistory,
|
||||
refreshStatic,
|
||||
setShowHelp,
|
||||
setDebugMessage,
|
||||
openThemeDialog,
|
||||
openAuthDialog,
|
||||
openEditorDialog,
|
||||
toggleCorgiMode,
|
||||
showToolDescriptions,
|
||||
setQuittingMessages,
|
||||
openPrivacyNotice,
|
||||
);
|
||||
const pendingHistoryItems = [...pendingSlashCommandHistoryItems];
|
||||
|
||||
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
|
||||
const isInitialMount = useRef(true);
|
||||
const { stdin, setRawMode } = useStdin();
|
||||
const isValidPath = useCallback((filePath: string): boolean => {
|
||||
try {
|
||||
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const widthFraction = 0.9;
|
||||
const inputWidth = Math.max(
|
||||
20,
|
||||
Math.floor(terminalWidth * widthFraction) - 3,
|
||||
);
|
||||
const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8));
|
||||
|
||||
const buffer = useTextBuffer({
|
||||
initialText: '',
|
||||
viewport: { height: 10, width: inputWidth },
|
||||
stdin,
|
||||
setRawMode,
|
||||
isValidPath,
|
||||
shellModeActive,
|
||||
});
|
||||
|
||||
const handleExit = useCallback(
|
||||
(
|
||||
pressedOnce: boolean,
|
||||
setPressedOnce: (value: boolean) => void,
|
||||
timerRef: React.MutableRefObject<NodeJS.Timeout | null>,
|
||||
) => {
|
||||
if (pressedOnce) {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
const quitCommand = slashCommands.find(
|
||||
(cmd) => cmd.name === 'quit' || cmd.altName === 'exit',
|
||||
);
|
||||
if (quitCommand && quitCommand.action) {
|
||||
quitCommand.action(commandContext, '');
|
||||
} else {
|
||||
// This is unlikely to be needed but added for an additional fallback.
|
||||
process.exit(0);
|
||||
}
|
||||
} else {
|
||||
setPressedOnce(true);
|
||||
timerRef.current = setTimeout(() => {
|
||||
setPressedOnce(false);
|
||||
timerRef.current = null;
|
||||
}, CTRL_EXIT_PROMPT_DURATION_MS);
|
||||
}
|
||||
},
|
||||
// Add commandContext to the dependency array here!
|
||||
[slashCommands, commandContext],
|
||||
);
|
||||
|
||||
useInput((input: string, key: InkKeyType) => {
|
||||
let enteringConstrainHeightMode = false;
|
||||
if (!constrainHeight) {
|
||||
// Automatically re-enter constrain height mode if the user types
|
||||
// anything. When constrainHeight==false, the user will experience
|
||||
// significant flickering so it is best to disable it immediately when
|
||||
// the user starts interacting with the app.
|
||||
enteringConstrainHeightMode = true;
|
||||
setConstrainHeight(true);
|
||||
}
|
||||
|
||||
if (key.ctrl && input === 'o') {
|
||||
setShowErrorDetails((prev) => !prev);
|
||||
} else if (key.ctrl && input === 't') {
|
||||
const newValue = !showToolDescriptions;
|
||||
setShowToolDescriptions(newValue);
|
||||
|
||||
const mcpServers = config.getMcpServers();
|
||||
if (Object.keys(mcpServers || {}).length > 0) {
|
||||
handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc');
|
||||
}
|
||||
} else if (key.ctrl && (input === 'c' || input === 'C')) {
|
||||
handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef);
|
||||
} else if (key.ctrl && (input === 'd' || input === 'D')) {
|
||||
if (buffer.text.length > 0) {
|
||||
// Do nothing if there is text in the input.
|
||||
return;
|
||||
}
|
||||
handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef);
|
||||
} else if (key.ctrl && input === 's' && !enteringConstrainHeightMode) {
|
||||
setConstrainHeight(false);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setGeminiMdFileCount(config.getGeminiMdFileCount());
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const getPreferredEditor = useCallback(() => {
|
||||
const editorType = settings.merged.preferredEditor;
|
||||
const isValidEditor = isEditorAvailable(editorType);
|
||||
if (!isValidEditor) {
|
||||
openEditorDialog();
|
||||
return;
|
||||
}
|
||||
return editorType as EditorType;
|
||||
}, [settings, openEditorDialog]);
|
||||
|
||||
const onAuthError = useCallback(() => {
|
||||
setAuthError('reauth required');
|
||||
openAuthDialog();
|
||||
}, [openAuthDialog, setAuthError]);
|
||||
|
||||
const {
|
||||
streamingState,
|
||||
submitQuery,
|
||||
initError,
|
||||
pendingHistoryItems: pendingGeminiHistoryItems,
|
||||
thought,
|
||||
} = useGeminiStream(
|
||||
config.getGeminiClient(),
|
||||
history,
|
||||
addItem,
|
||||
setShowHelp,
|
||||
config,
|
||||
setDebugMessage,
|
||||
handleSlashCommand,
|
||||
shellModeActive,
|
||||
getPreferredEditor,
|
||||
onAuthError,
|
||||
performMemoryRefresh,
|
||||
modelSwitchedFromQuotaError,
|
||||
setModelSwitchedFromQuotaError,
|
||||
);
|
||||
pendingHistoryItems.push(...pendingGeminiHistoryItems);
|
||||
const { elapsedTime, currentLoadingPhrase } =
|
||||
useLoadingIndicator(streamingState);
|
||||
const showAutoAcceptIndicator = useAutoAcceptIndicator({ config });
|
||||
|
||||
const handleFinalSubmit = useCallback(
|
||||
(submittedValue: string) => {
|
||||
const trimmedValue = submittedValue.trim();
|
||||
if (trimmedValue.length > 0) {
|
||||
submitQuery(trimmedValue);
|
||||
}
|
||||
},
|
||||
[submitQuery],
|
||||
);
|
||||
|
||||
const logger = useLogger();
|
||||
const [userMessages, setUserMessages] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserMessages = async () => {
|
||||
const pastMessagesRaw = (await logger?.getPreviousUserMessages()) || []; // Newest first
|
||||
|
||||
const currentSessionUserMessages = history
|
||||
.filter(
|
||||
(item): item is HistoryItem & { type: 'user'; text: string } =>
|
||||
item.type === 'user' &&
|
||||
typeof item.text === 'string' &&
|
||||
item.text.trim() !== '',
|
||||
)
|
||||
.map((item) => item.text)
|
||||
.reverse(); // Newest first, to match pastMessagesRaw sorting
|
||||
|
||||
// Combine, with current session messages being more recent
|
||||
const combinedMessages = [
|
||||
...currentSessionUserMessages,
|
||||
...pastMessagesRaw,
|
||||
];
|
||||
|
||||
// Deduplicate consecutive identical messages from the combined list (still newest first)
|
||||
const deduplicatedMessages: string[] = [];
|
||||
if (combinedMessages.length > 0) {
|
||||
deduplicatedMessages.push(combinedMessages[0]); // Add the newest one unconditionally
|
||||
for (let i = 1; i < combinedMessages.length; i++) {
|
||||
if (combinedMessages[i] !== combinedMessages[i - 1]) {
|
||||
deduplicatedMessages.push(combinedMessages[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reverse to oldest first for useInputHistory
|
||||
setUserMessages(deduplicatedMessages.reverse());
|
||||
};
|
||||
fetchUserMessages();
|
||||
}, [history, logger]);
|
||||
|
||||
const isInputActive = streamingState === StreamingState.Idle && !initError;
|
||||
|
||||
const handleClearScreen = useCallback(() => {
|
||||
clearItems();
|
||||
clearConsoleMessagesState();
|
||||
console.clear();
|
||||
refreshStatic();
|
||||
}, [clearItems, clearConsoleMessagesState, refreshStatic]);
|
||||
|
||||
const mainControlsRef = useRef<DOMElement>(null);
|
||||
const pendingHistoryItemRef = useRef<DOMElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (mainControlsRef.current) {
|
||||
const fullFooterMeasurement = measureElement(mainControlsRef.current);
|
||||
setFooterHeight(fullFooterMeasurement.height);
|
||||
}
|
||||
}, [terminalHeight, consoleMessages, showErrorDetails]);
|
||||
|
||||
const staticExtraHeight = /* margins and padding */ 3;
|
||||
const availableTerminalHeight = useMemo(
|
||||
() => terminalHeight - footerHeight - staticExtraHeight,
|
||||
[terminalHeight, footerHeight],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// skip refreshing Static during first mount
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// debounce so it doesn't fire up too often during resize
|
||||
const handler = setTimeout(() => {
|
||||
setStaticNeedsRefresh(false);
|
||||
refreshStatic();
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [terminalWidth, terminalHeight, refreshStatic]);
|
||||
|
||||
useEffect(() => {
|
||||
if (streamingState === StreamingState.Idle && staticNeedsRefresh) {
|
||||
setStaticNeedsRefresh(false);
|
||||
refreshStatic();
|
||||
}
|
||||
}, [streamingState, refreshStatic, staticNeedsRefresh]);
|
||||
|
||||
const filteredConsoleMessages = useMemo(() => {
|
||||
if (config.getDebugMode()) {
|
||||
return consoleMessages;
|
||||
}
|
||||
return consoleMessages.filter((msg) => msg.type !== 'debug');
|
||||
}, [consoleMessages, config]);
|
||||
|
||||
const branchName = useGitBranchName(config.getTargetDir());
|
||||
|
||||
const contextFileNames = useMemo(() => {
|
||||
const fromSettings = settings.merged.contextFileName;
|
||||
if (fromSettings) {
|
||||
return Array.isArray(fromSettings) ? fromSettings : [fromSettings];
|
||||
}
|
||||
return getAllGeminiMdFilenames();
|
||||
}, [settings.merged.contextFileName]);
|
||||
|
||||
const initialPrompt = useMemo(() => config.getQuestion(), [config]);
|
||||
const geminiClient = config.getGeminiClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
initialPrompt &&
|
||||
!initialPromptSubmitted.current &&
|
||||
!isAuthenticating &&
|
||||
!isAuthDialogOpen &&
|
||||
!isThemeDialogOpen &&
|
||||
!isEditorDialogOpen &&
|
||||
!showPrivacyNotice &&
|
||||
geminiClient?.isInitialized?.()
|
||||
) {
|
||||
submitQuery(initialPrompt);
|
||||
initialPromptSubmitted.current = true;
|
||||
}
|
||||
}, [
|
||||
initialPrompt,
|
||||
submitQuery,
|
||||
isAuthenticating,
|
||||
isAuthDialogOpen,
|
||||
isThemeDialogOpen,
|
||||
isEditorDialogOpen,
|
||||
showPrivacyNotice,
|
||||
geminiClient,
|
||||
]);
|
||||
|
||||
if (quittingMessages) {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{quittingMessages.map((item) => (
|
||||
<HistoryItemDisplay
|
||||
key={item.id}
|
||||
availableTerminalHeight={
|
||||
constrainHeight ? availableTerminalHeight : undefined
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
item={item}
|
||||
isPending={false}
|
||||
config={config}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const mainAreaWidth = Math.floor(terminalWidth * 0.9);
|
||||
const debugConsoleMaxHeight = Math.floor(Math.max(terminalHeight * 0.2, 5));
|
||||
// Arbitrary threshold to ensure that items in the static area are large
|
||||
// enough but not too large to make the terminal hard to use.
|
||||
const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100);
|
||||
return (
|
||||
<StreamingContext.Provider value={streamingState}>
|
||||
<Box flexDirection="column" marginBottom={1} width="90%">
|
||||
{/* Move UpdateNotification outside Static so it can re-render when updateMessage changes */}
|
||||
{updateMessage && <UpdateNotification message={updateMessage} />}
|
||||
|
||||
{/*
|
||||
* The Static component is an Ink intrinsic in which there can only be 1 per application.
|
||||
* Because of this restriction we're hacking it slightly by having a 'header' item here to
|
||||
* ensure that it's statically rendered.
|
||||
*
|
||||
* Background on the Static Item: Anything in the Static component is written a single time
|
||||
* to the console. Think of it like doing a console.log and then never using ANSI codes to
|
||||
* clear that content ever again. Effectively it has a moving frame that every time new static
|
||||
* content is set it'll flush content to the terminal and move the area which it's "clearing"
|
||||
* down a notch. Without Static the area which gets erased and redrawn continuously grows.
|
||||
*/}
|
||||
<Static
|
||||
key={staticKey}
|
||||
items={[
|
||||
<Box flexDirection="column" key="header">
|
||||
{!settings.merged.hideBanner && (
|
||||
<Header
|
||||
terminalWidth={terminalWidth}
|
||||
version={version}
|
||||
nightly={nightly}
|
||||
/>
|
||||
)}
|
||||
{!settings.merged.hideTips && <Tips config={config} />}
|
||||
</Box>,
|
||||
...history.map((h) => (
|
||||
<HistoryItemDisplay
|
||||
terminalWidth={mainAreaWidth}
|
||||
availableTerminalHeight={staticAreaMaxItemHeight}
|
||||
key={h.id}
|
||||
item={h}
|
||||
isPending={false}
|
||||
config={config}
|
||||
/>
|
||||
)),
|
||||
]}
|
||||
>
|
||||
{(item) => item}
|
||||
</Static>
|
||||
<OverflowProvider>
|
||||
<Box ref={pendingHistoryItemRef} flexDirection="column">
|
||||
{pendingHistoryItems.map((item, i) => (
|
||||
<HistoryItemDisplay
|
||||
key={i}
|
||||
availableTerminalHeight={
|
||||
constrainHeight ? availableTerminalHeight : undefined
|
||||
}
|
||||
terminalWidth={mainAreaWidth}
|
||||
// TODO(taehykim): It seems like references to ids aren't necessary in
|
||||
// HistoryItemDisplay. Refactor later. Use a fake id for now.
|
||||
item={{ ...item, id: 0 }}
|
||||
isPending={true}
|
||||
config={config}
|
||||
isFocused={!isEditorDialogOpen}
|
||||
/>
|
||||
))}
|
||||
<ShowMoreLines constrainHeight={constrainHeight} />
|
||||
</Box>
|
||||
</OverflowProvider>
|
||||
|
||||
{showHelp && <Help commands={slashCommands} />}
|
||||
|
||||
<Box flexDirection="column" ref={mainControlsRef}>
|
||||
{startupWarnings.length > 0 && (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentYellow}
|
||||
paddingX={1}
|
||||
marginY={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
{startupWarnings.map((warning, index) => (
|
||||
<Text key={index} color={Colors.AccentYellow}>
|
||||
{warning}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isThemeDialogOpen ? (
|
||||
<Box flexDirection="column">
|
||||
{themeError && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={Colors.AccentRed}>{themeError}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<ThemeDialog
|
||||
onSelect={handleThemeSelect}
|
||||
onHighlight={handleThemeHighlight}
|
||||
settings={settings}
|
||||
availableTerminalHeight={
|
||||
constrainHeight
|
||||
? terminalHeight - staticExtraHeight
|
||||
: undefined
|
||||
}
|
||||
terminalWidth={mainAreaWidth}
|
||||
/>
|
||||
</Box>
|
||||
) : isAuthenticating ? (
|
||||
<>
|
||||
<AuthInProgress
|
||||
onTimeout={() => {
|
||||
setAuthError('Authentication timed out. Please try again.');
|
||||
cancelAuthentication();
|
||||
openAuthDialog();
|
||||
}}
|
||||
/>
|
||||
{showErrorDetails && (
|
||||
<OverflowProvider>
|
||||
<Box flexDirection="column">
|
||||
<DetailedMessagesDisplay
|
||||
messages={filteredConsoleMessages}
|
||||
maxHeight={
|
||||
constrainHeight ? debugConsoleMaxHeight : undefined
|
||||
}
|
||||
width={inputWidth}
|
||||
/>
|
||||
<ShowMoreLines constrainHeight={constrainHeight} />
|
||||
</Box>
|
||||
</OverflowProvider>
|
||||
)}
|
||||
</>
|
||||
) : isAuthDialogOpen ? (
|
||||
<Box flexDirection="column">
|
||||
<AuthDialog
|
||||
onSelect={handleAuthSelect}
|
||||
settings={settings}
|
||||
initialErrorMessage={authError}
|
||||
/>
|
||||
</Box>
|
||||
) : isEditorDialogOpen ? (
|
||||
<Box flexDirection="column">
|
||||
{editorError && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={Colors.AccentRed}>{editorError}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<EditorSettingsDialog
|
||||
onSelect={handleEditorSelect}
|
||||
settings={settings}
|
||||
onExit={exitEditorDialog}
|
||||
/>
|
||||
</Box>
|
||||
) : showPrivacyNotice ? (
|
||||
<PrivacyNotice
|
||||
onExit={() => setShowPrivacyNotice(false)}
|
||||
config={config}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<LoadingIndicator
|
||||
thought={
|
||||
streamingState === StreamingState.WaitingForConfirmation ||
|
||||
config.getAccessibility()?.disableLoadingPhrases
|
||||
? undefined
|
||||
: thought
|
||||
}
|
||||
currentLoadingPhrase={
|
||||
config.getAccessibility()?.disableLoadingPhrases
|
||||
? undefined
|
||||
: currentLoadingPhrase
|
||||
}
|
||||
elapsedTime={elapsedTime}
|
||||
/>
|
||||
<Box
|
||||
marginTop={1}
|
||||
display="flex"
|
||||
justifyContent="space-between"
|
||||
width="100%"
|
||||
>
|
||||
<Box>
|
||||
{process.env.GEMINI_SYSTEM_MD && (
|
||||
<Text color={Colors.AccentRed}>|⌐■_■| </Text>
|
||||
)}
|
||||
{ctrlCPressedOnce ? (
|
||||
<Text color={Colors.AccentYellow}>
|
||||
Press Ctrl+C again to exit.
|
||||
</Text>
|
||||
) : ctrlDPressedOnce ? (
|
||||
<Text color={Colors.AccentYellow}>
|
||||
Press Ctrl+D again to exit.
|
||||
</Text>
|
||||
) : (
|
||||
<ContextSummaryDisplay
|
||||
geminiMdFileCount={geminiMdFileCount}
|
||||
contextFileNames={contextFileNames}
|
||||
mcpServers={config.getMcpServers()}
|
||||
showToolDescriptions={showToolDescriptions}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
{showAutoAcceptIndicator !== ApprovalMode.DEFAULT &&
|
||||
!shellModeActive && (
|
||||
<AutoAcceptIndicator
|
||||
approvalMode={showAutoAcceptIndicator}
|
||||
/>
|
||||
)}
|
||||
{shellModeActive && <ShellModeIndicator />}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{showErrorDetails && (
|
||||
<OverflowProvider>
|
||||
<Box flexDirection="column">
|
||||
<DetailedMessagesDisplay
|
||||
messages={filteredConsoleMessages}
|
||||
maxHeight={
|
||||
constrainHeight ? debugConsoleMaxHeight : undefined
|
||||
}
|
||||
width={inputWidth}
|
||||
/>
|
||||
<ShowMoreLines constrainHeight={constrainHeight} />
|
||||
</Box>
|
||||
</OverflowProvider>
|
||||
)}
|
||||
|
||||
{isInputActive && (
|
||||
<InputPrompt
|
||||
buffer={buffer}
|
||||
inputWidth={inputWidth}
|
||||
suggestionsWidth={suggestionsWidth}
|
||||
onSubmit={handleFinalSubmit}
|
||||
userMessages={userMessages}
|
||||
onClearScreen={handleClearScreen}
|
||||
config={config}
|
||||
slashCommands={slashCommands}
|
||||
commandContext={commandContext}
|
||||
shellModeActive={shellModeActive}
|
||||
setShellModeActive={setShellModeActive}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{initError && streamingState !== StreamingState.Responding && (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentRed}
|
||||
paddingX={1}
|
||||
marginBottom={1}
|
||||
>
|
||||
{history.find(
|
||||
(item) =>
|
||||
item.type === 'error' && item.text?.includes(initError),
|
||||
)?.text ? (
|
||||
<Text color={Colors.AccentRed}>
|
||||
{
|
||||
history.find(
|
||||
(item) =>
|
||||
item.type === 'error' && item.text?.includes(initError),
|
||||
)?.text
|
||||
}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text color={Colors.AccentRed}>
|
||||
Initialization Error: {initError}
|
||||
</Text>
|
||||
<Text color={Colors.AccentRed}>
|
||||
{' '}
|
||||
Please check API key and configuration.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<Footer
|
||||
model={currentModel}
|
||||
targetDir={config.getTargetDir()}
|
||||
debugMode={config.getDebugMode()}
|
||||
branchName={branchName}
|
||||
debugMessage={debugMessage}
|
||||
corgiMode={corgiMode}
|
||||
errorCount={errorCount}
|
||||
showErrorDetails={showErrorDetails}
|
||||
showMemoryUsage={
|
||||
config.getDebugMode() || config.getShowMemoryUsage()
|
||||
}
|
||||
promptTokenCount={sessionStats.lastPromptTokenCount}
|
||||
nightly={nightly}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</StreamingContext.Provider>
|
||||
);
|
||||
};
|
||||
50
packages/cli/src/ui/colors.ts
Normal file
50
packages/cli/src/ui/colors.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { themeManager } from './themes/theme-manager.js';
|
||||
import { ColorsTheme } from './themes/theme.js';
|
||||
|
||||
export const Colors: ColorsTheme = {
|
||||
get type() {
|
||||
return themeManager.getActiveTheme().colors.type;
|
||||
},
|
||||
get Foreground() {
|
||||
return themeManager.getActiveTheme().colors.Foreground;
|
||||
},
|
||||
get Background() {
|
||||
return themeManager.getActiveTheme().colors.Background;
|
||||
},
|
||||
get LightBlue() {
|
||||
return themeManager.getActiveTheme().colors.LightBlue;
|
||||
},
|
||||
get AccentBlue() {
|
||||
return themeManager.getActiveTheme().colors.AccentBlue;
|
||||
},
|
||||
get AccentPurple() {
|
||||
return themeManager.getActiveTheme().colors.AccentPurple;
|
||||
},
|
||||
get AccentCyan() {
|
||||
return themeManager.getActiveTheme().colors.AccentCyan;
|
||||
},
|
||||
get AccentGreen() {
|
||||
return themeManager.getActiveTheme().colors.AccentGreen;
|
||||
},
|
||||
get AccentYellow() {
|
||||
return themeManager.getActiveTheme().colors.AccentYellow;
|
||||
},
|
||||
get AccentRed() {
|
||||
return themeManager.getActiveTheme().colors.AccentRed;
|
||||
},
|
||||
get Comment() {
|
||||
return themeManager.getActiveTheme().colors.Comment;
|
||||
},
|
||||
get Gray() {
|
||||
return themeManager.getActiveTheme().colors.Gray;
|
||||
},
|
||||
get GradientColors() {
|
||||
return themeManager.getActiveTheme().colors.GradientColors;
|
||||
},
|
||||
};
|
||||
117
packages/cli/src/ui/commands/aboutCommand.test.ts
Normal file
117
packages/cli/src/ui/commands/aboutCommand.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { aboutCommand } from './aboutCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import * as versionUtils from '../../utils/version.js';
|
||||
import { MessageType } from '../types.js';
|
||||
|
||||
vi.mock('../../utils/version.js', () => ({
|
||||
getCliVersion: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('aboutCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
const originalPlatform = process.platform;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getModel: vi.fn(),
|
||||
},
|
||||
settings: {
|
||||
merged: {
|
||||
selectedAuthType: 'test-auth',
|
||||
},
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
addItem: vi.fn(),
|
||||
},
|
||||
} as unknown as CommandContext);
|
||||
|
||||
vi.mocked(versionUtils.getCliVersion).mockResolvedValue('test-version');
|
||||
vi.spyOn(mockContext.services.config!, 'getModel').mockReturnValue(
|
||||
'test-model',
|
||||
);
|
||||
process.env.GOOGLE_CLOUD_PROJECT = 'test-gcp-project';
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'test-os',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
process.env = originalEnv;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(aboutCommand.name).toBe('about');
|
||||
expect(aboutCommand.description).toBe('show version info');
|
||||
});
|
||||
|
||||
it('should call addItem with all version info', async () => {
|
||||
if (!aboutCommand.action) {
|
||||
throw new Error('The about command must have an action.');
|
||||
}
|
||||
|
||||
await aboutCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ABOUT,
|
||||
cliVersion: 'test-version',
|
||||
osVersion: 'test-os',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
gcpProject: 'test-gcp-project',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show the correct sandbox environment variable', async () => {
|
||||
process.env.SANDBOX = 'gemini-sandbox';
|
||||
if (!aboutCommand.action) {
|
||||
throw new Error('The about command must have an action.');
|
||||
}
|
||||
|
||||
await aboutCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sandboxEnv: 'gemini-sandbox',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show sandbox-exec profile when applicable', async () => {
|
||||
process.env.SANDBOX = 'sandbox-exec';
|
||||
process.env.SEATBELT_PROFILE = 'test-profile';
|
||||
if (!aboutCommand.action) {
|
||||
throw new Error('The about command must have an action.');
|
||||
}
|
||||
|
||||
await aboutCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sandboxEnv: 'sandbox-exec (test-profile)',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
43
packages/cli/src/ui/commands/aboutCommand.ts
Normal file
43
packages/cli/src/ui/commands/aboutCommand.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { getCliVersion } from '../../utils/version.js';
|
||||
import { SlashCommand } from './types.js';
|
||||
import process from 'node:process';
|
||||
import { MessageType, type HistoryItemAbout } from '../types.js';
|
||||
|
||||
export const aboutCommand: SlashCommand = {
|
||||
name: 'about',
|
||||
description: 'show version info',
|
||||
action: async (context) => {
|
||||
const osVersion = process.platform;
|
||||
let sandboxEnv = 'no sandbox';
|
||||
if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') {
|
||||
sandboxEnv = process.env.SANDBOX;
|
||||
} else if (process.env.SANDBOX === 'sandbox-exec') {
|
||||
sandboxEnv = `sandbox-exec (${
|
||||
process.env.SEATBELT_PROFILE || 'unknown'
|
||||
})`;
|
||||
}
|
||||
const modelVersion = context.services.config?.getModel() || 'Unknown';
|
||||
const cliVersion = await getCliVersion();
|
||||
const selectedAuthType =
|
||||
context.services.settings.merged.selectedAuthType || '';
|
||||
const gcpProject = process.env.GOOGLE_CLOUD_PROJECT || '';
|
||||
|
||||
const aboutItem: Omit<HistoryItemAbout, 'id'> = {
|
||||
type: MessageType.ABOUT,
|
||||
cliVersion,
|
||||
osVersion,
|
||||
sandboxEnv,
|
||||
modelVersion,
|
||||
selectedAuthType,
|
||||
gcpProject,
|
||||
};
|
||||
|
||||
context.ui.addItem(aboutItem, Date.now());
|
||||
},
|
||||
};
|
||||
36
packages/cli/src/ui/commands/authCommand.test.ts
Normal file
36
packages/cli/src/ui/commands/authCommand.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { authCommand } from './authCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
describe('authCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext();
|
||||
});
|
||||
|
||||
it('should return a dialog action to open the auth dialog', () => {
|
||||
if (!authCommand.action) {
|
||||
throw new Error('The auth command must have an action.');
|
||||
}
|
||||
|
||||
const result = authCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'auth',
|
||||
});
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(authCommand.name).toBe('auth');
|
||||
expect(authCommand.description).toBe('change the auth method');
|
||||
});
|
||||
});
|
||||
16
packages/cli/src/ui/commands/authCommand.ts
Normal file
16
packages/cli/src/ui/commands/authCommand.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||
|
||||
export const authCommand: SlashCommand = {
|
||||
name: 'auth',
|
||||
description: 'change the auth method',
|
||||
action: (_context, _args): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'auth',
|
||||
}),
|
||||
};
|
||||
78
packages/cli/src/ui/commands/clearCommand.test.ts
Normal file
78
packages/cli/src/ui/commands/clearCommand.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
|
||||
import { clearCommand } from './clearCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { GeminiClient } from '@google/gemini-cli-core';
|
||||
|
||||
describe('clearCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
let mockResetChat: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockResetChat = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getGeminiClient: () =>
|
||||
({
|
||||
resetChat: mockResetChat,
|
||||
}) as unknown as GeminiClient,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should set debug message, reset chat, and clear UI when config is available', async () => {
|
||||
if (!clearCommand.action) {
|
||||
throw new Error('clearCommand must have an action.');
|
||||
}
|
||||
|
||||
await clearCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.setDebugMessage).toHaveBeenCalledWith(
|
||||
'Clearing terminal and resetting chat.',
|
||||
);
|
||||
expect(mockContext.ui.setDebugMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockResetChat).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Check the order of operations.
|
||||
const setDebugMessageOrder = (mockContext.ui.setDebugMessage as Mock).mock
|
||||
.invocationCallOrder[0];
|
||||
const resetChatOrder = mockResetChat.mock.invocationCallOrder[0];
|
||||
const clearOrder = (mockContext.ui.clear as Mock).mock
|
||||
.invocationCallOrder[0];
|
||||
|
||||
expect(setDebugMessageOrder).toBeLessThan(resetChatOrder);
|
||||
expect(resetChatOrder).toBeLessThan(clearOrder);
|
||||
});
|
||||
|
||||
it('should not attempt to reset chat if config service is not available', async () => {
|
||||
if (!clearCommand.action) {
|
||||
throw new Error('clearCommand must have an action.');
|
||||
}
|
||||
|
||||
const nullConfigContext = createMockCommandContext({
|
||||
services: {
|
||||
config: null,
|
||||
},
|
||||
});
|
||||
|
||||
await clearCommand.action(nullConfigContext, '');
|
||||
|
||||
expect(nullConfigContext.ui.setDebugMessage).toHaveBeenCalledWith(
|
||||
'Clearing terminal and resetting chat.',
|
||||
);
|
||||
expect(mockResetChat).not.toHaveBeenCalled();
|
||||
expect(nullConfigContext.ui.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
17
packages/cli/src/ui/commands/clearCommand.ts
Normal file
17
packages/cli/src/ui/commands/clearCommand.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { SlashCommand } from './types.js';
|
||||
|
||||
export const clearCommand: SlashCommand = {
|
||||
name: 'clear',
|
||||
description: 'clear the screen and conversation history',
|
||||
action: async (context, _args) => {
|
||||
context.ui.setDebugMessage('Clearing terminal and resetting chat.');
|
||||
await context.services.config?.getGeminiClient()?.resetChat();
|
||||
context.ui.clear();
|
||||
},
|
||||
};
|
||||
40
packages/cli/src/ui/commands/helpCommand.test.ts
Normal file
40
packages/cli/src/ui/commands/helpCommand.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { helpCommand } from './helpCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
|
||||
describe('helpCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = {} as unknown as CommandContext;
|
||||
});
|
||||
|
||||
it("should return a dialog action and log a debug message for '/help'", () => {
|
||||
const consoleDebugSpy = vi
|
||||
.spyOn(console, 'debug')
|
||||
.mockImplementation(() => {});
|
||||
if (!helpCommand.action) {
|
||||
throw new Error('Help command has no action');
|
||||
}
|
||||
const result = helpCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'help',
|
||||
});
|
||||
expect(consoleDebugSpy).toHaveBeenCalledWith('Opening help UI ...');
|
||||
});
|
||||
|
||||
it("should also be triggered by its alternative name '?'", () => {
|
||||
// This test is more conceptual. The routing of altName to the command
|
||||
// is handled by the slash command processor, but we can assert the
|
||||
// altName is correctly defined on the command object itself.
|
||||
expect(helpCommand.altName).toBe('?');
|
||||
});
|
||||
});
|
||||
20
packages/cli/src/ui/commands/helpCommand.ts
Normal file
20
packages/cli/src/ui/commands/helpCommand.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||
|
||||
export const helpCommand: SlashCommand = {
|
||||
name: 'help',
|
||||
altName: '?',
|
||||
description: 'for help on qwen code',
|
||||
action: (_context, _args): OpenDialogActionReturn => {
|
||||
console.debug('Opening help UI ...');
|
||||
return {
|
||||
type: 'dialog',
|
||||
dialog: 'help',
|
||||
};
|
||||
},
|
||||
};
|
||||
248
packages/cli/src/ui/commands/memoryCommand.test.ts
Normal file
248
packages/cli/src/ui/commands/memoryCommand.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
|
||||
import { memoryCommand } from './memoryCommand.js';
|
||||
import { type CommandContext, SlashCommand } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { getErrorMessage } from '@qwen/qwen-code-core';
|
||||
|
||||
vi.mock('@qwen/qwen-code-core', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@qwen/qwen-code-core')>();
|
||||
return {
|
||||
...original,
|
||||
getErrorMessage: vi.fn((error: unknown) => {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('memoryCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
const getSubCommand = (name: 'show' | 'add' | 'refresh'): SlashCommand => {
|
||||
const subCommand = memoryCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === name,
|
||||
);
|
||||
if (!subCommand) {
|
||||
throw new Error(`/memory ${name} command not found.`);
|
||||
}
|
||||
return subCommand;
|
||||
};
|
||||
|
||||
describe('/memory show', () => {
|
||||
let showCommand: SlashCommand;
|
||||
let mockGetUserMemory: Mock;
|
||||
let mockGetGeminiMdFileCount: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
showCommand = getSubCommand('show');
|
||||
|
||||
mockGetUserMemory = vi.fn();
|
||||
mockGetGeminiMdFileCount = vi.fn();
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getUserMemory: mockGetUserMemory,
|
||||
getGeminiMdFileCount: mockGetGeminiMdFileCount,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should display a message if memory is empty', async () => {
|
||||
if (!showCommand.action) throw new Error('Command has no action');
|
||||
|
||||
mockGetUserMemory.mockReturnValue('');
|
||||
mockGetGeminiMdFileCount.mockReturnValue(0);
|
||||
|
||||
await showCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Memory is currently empty.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display the memory content and file count if it exists', async () => {
|
||||
if (!showCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const memoryContent = 'This is a test memory.';
|
||||
|
||||
mockGetUserMemory.mockReturnValue(memoryContent);
|
||||
mockGetGeminiMdFileCount.mockReturnValue(1);
|
||||
|
||||
await showCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Current memory content from 1 file(s):\n\n---\n${memoryContent}\n---`,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/memory add', () => {
|
||||
let addCommand: SlashCommand;
|
||||
|
||||
beforeEach(() => {
|
||||
addCommand = getSubCommand('add');
|
||||
mockContext = createMockCommandContext();
|
||||
});
|
||||
|
||||
it('should return an error message if no arguments are provided', () => {
|
||||
if (!addCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const result = addCommand.action(mockContext, ' ');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Usage: /memory add <text to remember>',
|
||||
});
|
||||
|
||||
expect(mockContext.ui.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return a tool action and add an info message when arguments are provided', () => {
|
||||
if (!addCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const fact = 'remember this';
|
||||
const result = addCommand.action(mockContext, ` ${fact} `);
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Attempting to save to memory: "${fact}"`,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'tool',
|
||||
toolName: 'save_memory',
|
||||
toolArgs: { fact },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/memory refresh', () => {
|
||||
let refreshCommand: SlashCommand;
|
||||
let mockRefreshMemory: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
refreshCommand = getSubCommand('refresh');
|
||||
mockRefreshMemory = vi.fn();
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
refreshMemory: mockRefreshMemory,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should display success message when memory is refreshed with content', async () => {
|
||||
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const refreshResult = {
|
||||
memoryContent: 'new memory content',
|
||||
fileCount: 2,
|
||||
};
|
||||
mockRefreshMemory.mockResolvedValue(refreshResult);
|
||||
|
||||
await refreshCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Refreshing memory from source files...',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
expect(mockRefreshMemory).toHaveBeenCalledOnce();
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Memory refreshed successfully. Loaded 18 characters from 2 file(s).',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display success message when memory is refreshed with no content', async () => {
|
||||
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const refreshResult = { memoryContent: '', fileCount: 0 };
|
||||
mockRefreshMemory.mockResolvedValue(refreshResult);
|
||||
|
||||
await refreshCommand.action(mockContext, '');
|
||||
|
||||
expect(mockRefreshMemory).toHaveBeenCalledOnce();
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Memory refreshed successfully. No memory content found.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display an error message if refreshing fails', async () => {
|
||||
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const error = new Error('Failed to read memory files.');
|
||||
mockRefreshMemory.mockRejectedValue(error);
|
||||
|
||||
await refreshCommand.action(mockContext, '');
|
||||
|
||||
expect(mockRefreshMemory).toHaveBeenCalledOnce();
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: `Error refreshing memory: ${error.message}`,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
expect(getErrorMessage).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
it('should not throw if config service is unavailable', async () => {
|
||||
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const nullConfigContext = createMockCommandContext({
|
||||
services: { config: null },
|
||||
});
|
||||
|
||||
await expect(
|
||||
refreshCommand.action(nullConfigContext, ''),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(nullConfigContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Refreshing memory from source files...',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
expect(mockRefreshMemory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
106
packages/cli/src/ui/commands/memoryCommand.ts
Normal file
106
packages/cli/src/ui/commands/memoryCommand.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { getErrorMessage } from '@qwen/qwen-code-core';
|
||||
import { MessageType } from '../types.js';
|
||||
import { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||
|
||||
export const memoryCommand: SlashCommand = {
|
||||
name: 'memory',
|
||||
description: 'Commands for interacting with memory.',
|
||||
subCommands: [
|
||||
{
|
||||
name: 'show',
|
||||
description: 'Show the current memory contents.',
|
||||
action: async (context) => {
|
||||
const memoryContent = context.services.config?.getUserMemory() || '';
|
||||
const fileCount = context.services.config?.getGeminiMdFileCount() || 0;
|
||||
|
||||
const messageContent =
|
||||
memoryContent.length > 0
|
||||
? `Current memory content from ${fileCount} file(s):\n\n---\n${memoryContent}\n---`
|
||||
: 'Memory is currently empty.';
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: messageContent,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'add',
|
||||
description: 'Add content to the memory.',
|
||||
action: (context, args): SlashCommandActionReturn | void => {
|
||||
if (!args || args.trim() === '') {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Usage: /memory add <text to remember>',
|
||||
};
|
||||
}
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Attempting to save to memory: "${args.trim()}"`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
return {
|
||||
type: 'tool',
|
||||
toolName: 'save_memory',
|
||||
toolArgs: { fact: args.trim() },
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'refresh',
|
||||
description: 'Refresh the memory from the source.',
|
||||
action: async (context) => {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Refreshing memory from source files...',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await context.services.config?.refreshMemory();
|
||||
|
||||
if (result) {
|
||||
const { memoryContent, fileCount } = result;
|
||||
const successMessage =
|
||||
memoryContent.length > 0
|
||||
? `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`
|
||||
: 'Memory refreshed successfully. No memory content found.';
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: successMessage,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: `Error refreshing memory: ${errorMessage}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
38
packages/cli/src/ui/commands/privacyCommand.test.ts
Normal file
38
packages/cli/src/ui/commands/privacyCommand.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { privacyCommand } from './privacyCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
describe('privacyCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext();
|
||||
});
|
||||
|
||||
it('should return a dialog action to open the privacy dialog', () => {
|
||||
// Ensure the command has an action to test.
|
||||
if (!privacyCommand.action) {
|
||||
throw new Error('The privacy command must have an action.');
|
||||
}
|
||||
|
||||
const result = privacyCommand.action(mockContext, '');
|
||||
|
||||
// Assert that the action returns the correct object to trigger the privacy dialog.
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'privacy',
|
||||
});
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(privacyCommand.name).toBe('privacy');
|
||||
expect(privacyCommand.description).toBe('display the privacy notice');
|
||||
});
|
||||
});
|
||||
16
packages/cli/src/ui/commands/privacyCommand.ts
Normal file
16
packages/cli/src/ui/commands/privacyCommand.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||
|
||||
export const privacyCommand: SlashCommand = {
|
||||
name: 'privacy',
|
||||
description: 'display the privacy notice',
|
||||
action: (): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'privacy',
|
||||
}),
|
||||
};
|
||||
38
packages/cli/src/ui/commands/themeCommand.test.ts
Normal file
38
packages/cli/src/ui/commands/themeCommand.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { themeCommand } from './themeCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
describe('themeCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext();
|
||||
});
|
||||
|
||||
it('should return a dialog action to open the theme dialog', () => {
|
||||
// Ensure the command has an action to test.
|
||||
if (!themeCommand.action) {
|
||||
throw new Error('The theme command must have an action.');
|
||||
}
|
||||
|
||||
const result = themeCommand.action(mockContext, '');
|
||||
|
||||
// Assert that the action returns the correct object to trigger the theme dialog.
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'theme',
|
||||
});
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(themeCommand.name).toBe('theme');
|
||||
expect(themeCommand.description).toBe('change the theme');
|
||||
});
|
||||
});
|
||||
16
packages/cli/src/ui/commands/themeCommand.ts
Normal file
16
packages/cli/src/ui/commands/themeCommand.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||
|
||||
export const themeCommand: SlashCommand = {
|
||||
name: 'theme',
|
||||
description: 'change the theme',
|
||||
action: (_context, _args): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'theme',
|
||||
}),
|
||||
};
|
||||
98
packages/cli/src/ui/commands/types.ts
Normal file
98
packages/cli/src/ui/commands/types.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Config, GitService, Logger } from '@qwen/qwen-code-core';
|
||||
import { LoadedSettings } from '../../config/settings.js';
|
||||
import { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||
import { SessionStatsState } from '../contexts/SessionContext.js';
|
||||
|
||||
// Grouped dependencies for clarity and easier mocking
|
||||
export interface CommandContext {
|
||||
// Core services and configuration
|
||||
services: {
|
||||
// TODO(abhipatel12): Ensure that config is never null.
|
||||
config: Config | null;
|
||||
settings: LoadedSettings;
|
||||
git: GitService | undefined;
|
||||
logger: Logger;
|
||||
};
|
||||
// UI state and history management
|
||||
ui: {
|
||||
// TODO - As more commands are add some additions may be needed or reworked using this new context.
|
||||
// Ex.
|
||||
// history: HistoryItem[];
|
||||
// pendingHistoryItems: HistoryItemWithoutId[];
|
||||
|
||||
/** Adds a new item to the history display. */
|
||||
addItem: UseHistoryManagerReturn['addItem'];
|
||||
/** Clears all history items and the console screen. */
|
||||
clear: () => void;
|
||||
/**
|
||||
* Sets the transient debug message displayed in the application footer in debug mode.
|
||||
*/
|
||||
setDebugMessage: (message: string) => void;
|
||||
};
|
||||
// Session-specific data
|
||||
session: {
|
||||
stats: SessionStatsState;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The return type for a command action that results in scheduling a tool call.
|
||||
*/
|
||||
export interface ToolActionReturn {
|
||||
type: 'tool';
|
||||
toolName: string;
|
||||
toolArgs: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The return type for a command action that results in a simple message
|
||||
* being displayed to the user.
|
||||
*/
|
||||
export interface MessageActionReturn {
|
||||
type: 'message';
|
||||
messageType: 'info' | 'error';
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The return type for a command action that needs to open a dialog.
|
||||
*/
|
||||
export interface OpenDialogActionReturn {
|
||||
type: 'dialog';
|
||||
// TODO: Add 'theme' | 'auth' | 'editor' | 'privacy' as migration happens.
|
||||
dialog: 'help' | 'auth' | 'theme' | 'privacy';
|
||||
}
|
||||
|
||||
export type SlashCommandActionReturn =
|
||||
| ToolActionReturn
|
||||
| MessageActionReturn
|
||||
| OpenDialogActionReturn;
|
||||
// The standardized contract for any command in the system.
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
altName?: string;
|
||||
description?: string;
|
||||
|
||||
// The action to run. Optional for parent commands that only group sub-commands.
|
||||
action?: (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
) =>
|
||||
| void
|
||||
| SlashCommandActionReturn
|
||||
| Promise<void | SlashCommandActionReturn>;
|
||||
|
||||
// Provides argument completion (e.g., completing a tag for `/chat resume <tag>`).
|
||||
completion?: (
|
||||
context: CommandContext,
|
||||
partialArg: string,
|
||||
) => Promise<string[]>;
|
||||
|
||||
subCommands?: SlashCommand[];
|
||||
}
|
||||
119
packages/cli/src/ui/components/AboutBox.tsx
Normal file
119
packages/cli/src/ui/components/AboutBox.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||
|
||||
interface AboutBoxProps {
|
||||
cliVersion: string;
|
||||
osVersion: string;
|
||||
sandboxEnv: string;
|
||||
modelVersion: string;
|
||||
selectedAuthType: string;
|
||||
gcpProject: string;
|
||||
}
|
||||
|
||||
export const AboutBox: React.FC<AboutBoxProps> = ({
|
||||
cliVersion,
|
||||
osVersion,
|
||||
sandboxEnv,
|
||||
modelVersion,
|
||||
selectedAuthType,
|
||||
gcpProject,
|
||||
}) => (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.Gray}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
marginY={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
About Gemini CLI
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={Colors.LightBlue}>
|
||||
CLI Version
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>{cliVersion}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) && (
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={Colors.LightBlue}>
|
||||
Git Commit
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>{GIT_COMMIT_INFO}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={Colors.LightBlue}>
|
||||
Model
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>{modelVersion}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={Colors.LightBlue}>
|
||||
Sandbox
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>{sandboxEnv}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={Colors.LightBlue}>
|
||||
OS
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>{osVersion}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={Colors.LightBlue}>
|
||||
Auth Method
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>
|
||||
{selectedAuthType.startsWith('oauth') ? 'OAuth' : selectedAuthType}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{gcpProject && (
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={Colors.LightBlue}>
|
||||
GCP Project
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>{gcpProject}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
22
packages/cli/src/ui/components/AsciiArt.ts
Normal file
22
packages/cli/src/ui/components/AsciiArt.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const shortAsciiLogo = `
|
||||
██████╗ ██╗ ██╗███████╗███╗ ██╗
|
||||
██╔═══██╗██║ ██║██╔════╝████╗ ██║
|
||||
██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║
|
||||
██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║
|
||||
╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║
|
||||
╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
|
||||
`;
|
||||
export const longAsciiLogo = `
|
||||
██╗ ██████╗ ██╗ ██╗███████╗███╗ ██╗
|
||||
╚██╗ ██╔═══██╗██║ ██║██╔════╝████╗ ██║
|
||||
╚██╗ ██║ ██║██║ █╗ ██║█████╗ ██╔██╗ ██║
|
||||
██╔╝ ██║▄▄ ██║██║███╗██║██╔══╝ ██║╚██╗██║
|
||||
██╔╝ ╚██████╔╝╚███╔███╔╝███████╗██║ ╚████║
|
||||
╚═╝ ╚══▀▀═╝ ╚══╝╚══╝ ╚══════╝╚═╝ ╚═══╝
|
||||
`;
|
||||
325
packages/cli/src/ui/components/AuthDialog.test.tsx
Normal file
325
packages/cli/src/ui/components/AuthDialog.test.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { AuthDialog } from './AuthDialog.js';
|
||||
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import { AuthType } from '@qwen/qwen-code-core';
|
||||
|
||||
describe('AuthDialog', () => {
|
||||
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
process.env.GEMINI_API_KEY = '';
|
||||
process.env.GEMINI_DEFAULT_AUTH_TYPE = '';
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should show an error if the initial auth type is invalid', () => {
|
||||
process.env.GEMINI_API_KEY = '';
|
||||
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: AuthType.USE_GEMINI,
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<AuthDialog
|
||||
onSelect={() => {}}
|
||||
settings={settings}
|
||||
initialErrorMessage="GEMINI_API_KEY environment variable not found"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain(
|
||||
'GEMINI_API_KEY environment variable not found',
|
||||
);
|
||||
});
|
||||
|
||||
describe('GEMINI_API_KEY environment variable', () => {
|
||||
it('should detect GEMINI_API_KEY environment variable', () => {
|
||||
process.env.GEMINI_API_KEY = 'foobar';
|
||||
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: undefined,
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
|
||||
// Since the auth dialog only shows OpenAI option now,
|
||||
// it won't show GEMINI_API_KEY messages
|
||||
expect(lastFrame()).toContain('OpenAI');
|
||||
});
|
||||
|
||||
it('should not show the GEMINI_API_KEY message if GEMINI_DEFAULT_AUTH_TYPE is set to something else', () => {
|
||||
process.env.GEMINI_API_KEY = 'foobar';
|
||||
process.env.GEMINI_DEFAULT_AUTH_TYPE = AuthType.LOGIN_WITH_GOOGLE;
|
||||
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: undefined,
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain(
|
||||
'Existing API key detected (GEMINI_API_KEY)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show the GEMINI_API_KEY message if GEMINI_DEFAULT_AUTH_TYPE is set to use api key', () => {
|
||||
process.env.GEMINI_API_KEY = 'foobar';
|
||||
process.env.GEMINI_DEFAULT_AUTH_TYPE = AuthType.USE_GEMINI;
|
||||
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: undefined,
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
|
||||
// Since the auth dialog only shows OpenAI option now,
|
||||
// it won't show GEMINI_API_KEY messages
|
||||
expect(lastFrame()).toContain('OpenAI');
|
||||
});
|
||||
});
|
||||
|
||||
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.LOGIN_WITH_GOOGLE;
|
||||
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: undefined,
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
|
||||
// Since only OpenAI is available, it should be selected by default
|
||||
expect(lastFrame()).toContain('○ OpenAI');
|
||||
});
|
||||
|
||||
it('should fall back to default if GEMINI_DEFAULT_AUTH_TYPE is not set', () => {
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: undefined,
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
|
||||
// Default is OpenAI (the only option)
|
||||
expect(lastFrame()).toContain('○ OpenAI');
|
||||
});
|
||||
|
||||
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';
|
||||
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: undefined,
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
|
||||
// Since the auth dialog doesn't show GEMINI_DEFAULT_AUTH_TYPE errors anymore,
|
||||
// it will just show the default OpenAI option
|
||||
expect(lastFrame()).toContain('○ OpenAI');
|
||||
});
|
||||
});
|
||||
|
||||
// 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: {},
|
||||
// path: '',
|
||||
// },
|
||||
// {
|
||||
// settings: {
|
||||
// selectedAuthType: undefined,
|
||||
// },
|
||||
// path: '',
|
||||
// },
|
||||
// {
|
||||
// settings: {},
|
||||
// path: '',
|
||||
// },
|
||||
// [],
|
||||
// );
|
||||
|
||||
// const { lastFrame, stdin, unmount } = render(
|
||||
// <AuthDialog onSelect={onSelect} settings={settings} />,
|
||||
// );
|
||||
// await wait();
|
||||
|
||||
// // Simulate pressing escape key
|
||||
// stdin.write('\u001b'); // ESC key
|
||||
// await wait(100); // Increased wait time for CI environment
|
||||
|
||||
// // 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: {},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { lastFrame, stdin, unmount } = render(
|
||||
<AuthDialog
|
||||
onSelect={onSelect}
|
||||
settings={settings}
|
||||
initialErrorMessage="Initial error"
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
expect(lastFrame()).toContain('Initial error');
|
||||
|
||||
// Simulate pressing escape key
|
||||
stdin.write('\u001b'); // ESC key
|
||||
await wait();
|
||||
|
||||
// Should not call onSelect
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should allow exiting when auth method is already selected', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: AuthType.USE_GEMINI,
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { stdin, unmount } = render(
|
||||
<AuthDialog onSelect={onSelect} settings={settings} />,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Simulate pressing escape key
|
||||
stdin.write('\u001b'); // ESC key
|
||||
await wait();
|
||||
|
||||
// Should call onSelect with undefined to exit
|
||||
expect(onSelect).toHaveBeenCalledWith(undefined, SettingScope.User);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
171
packages/cli/src/ui/components/AuthDialog.tsx
Normal file
171
packages/cli/src/ui/components/AuthDialog.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import { AuthType } from '@qwen/qwen-code-core';
|
||||
import {
|
||||
validateAuthMethod,
|
||||
setOpenAIApiKey,
|
||||
setOpenAIBaseUrl,
|
||||
setOpenAIModel,
|
||||
} from '../../config/auth.js';
|
||||
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
|
||||
|
||||
interface AuthDialogProps {
|
||||
onSelect: (authMethod: AuthType | undefined, scope: SettingScope) => void;
|
||||
settings: LoadedSettings;
|
||||
initialErrorMessage?: string | null;
|
||||
}
|
||||
|
||||
function parseDefaultAuthType(
|
||||
defaultAuthType: string | undefined,
|
||||
): AuthType | null {
|
||||
if (
|
||||
defaultAuthType &&
|
||||
Object.values(AuthType).includes(defaultAuthType as AuthType)
|
||||
) {
|
||||
return defaultAuthType as AuthType;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function AuthDialog({
|
||||
onSelect,
|
||||
settings,
|
||||
initialErrorMessage,
|
||||
}: AuthDialogProps): React.JSX.Element {
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(
|
||||
initialErrorMessage || null,
|
||||
);
|
||||
const [showOpenAIKeyPrompt, setShowOpenAIKeyPrompt] = useState(false);
|
||||
const items = [{ label: 'OpenAI', value: AuthType.USE_OPENAI }];
|
||||
|
||||
const initialAuthIndex = items.findIndex((item) => {
|
||||
if (settings.merged.selectedAuthType) {
|
||||
return item.value === settings.merged.selectedAuthType;
|
||||
}
|
||||
|
||||
const defaultAuthType = parseDefaultAuthType(
|
||||
process.env.GEMINI_DEFAULT_AUTH_TYPE,
|
||||
);
|
||||
if (defaultAuthType) {
|
||||
return item.value === defaultAuthType;
|
||||
}
|
||||
|
||||
if (process.env.GEMINI_API_KEY) {
|
||||
return item.value === AuthType.USE_GEMINI;
|
||||
}
|
||||
|
||||
return item.value === AuthType.LOGIN_WITH_GOOGLE;
|
||||
});
|
||||
|
||||
const handleAuthSelect = (authMethod: AuthType) => {
|
||||
const error = validateAuthMethod(authMethod);
|
||||
if (error) {
|
||||
if (authMethod === AuthType.USE_OPENAI && !process.env.OPENAI_API_KEY) {
|
||||
setShowOpenAIKeyPrompt(true);
|
||||
setErrorMessage(null);
|
||||
} else {
|
||||
setErrorMessage(error);
|
||||
}
|
||||
} else {
|
||||
setErrorMessage(null);
|
||||
onSelect(authMethod, SettingScope.User);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenAIKeySubmit = (
|
||||
apiKey: string,
|
||||
baseUrl: string,
|
||||
model: string,
|
||||
) => {
|
||||
setOpenAIApiKey(apiKey);
|
||||
setOpenAIBaseUrl(baseUrl);
|
||||
setOpenAIModel(model);
|
||||
setShowOpenAIKeyPrompt(false);
|
||||
onSelect(AuthType.USE_OPENAI, SettingScope.User);
|
||||
};
|
||||
|
||||
const handleOpenAIKeyCancel = () => {
|
||||
setShowOpenAIKeyPrompt(false);
|
||||
setErrorMessage('OpenAI API key is required to use OpenAI authentication.');
|
||||
};
|
||||
|
||||
useInput((_input, key) => {
|
||||
// 当显示 OpenAIKeyPrompt 时,不处理输入事件
|
||||
if (showOpenAIKeyPrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
// Prevent exit if there is an error message.
|
||||
// This means they user is not authenticated yet.
|
||||
if (errorMessage) {
|
||||
return;
|
||||
}
|
||||
if (settings.merged.selectedAuthType === undefined) {
|
||||
// Prevent exiting if no auth method is set
|
||||
setErrorMessage(
|
||||
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
onSelect(undefined, SettingScope.User);
|
||||
}
|
||||
});
|
||||
|
||||
if (showOpenAIKeyPrompt) {
|
||||
return (
|
||||
<OpenAIKeyPrompt
|
||||
onSubmit={handleOpenAIKeySubmit}
|
||||
onCancel={handleOpenAIKeyCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.Gray}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold>Get started</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>How would you like to authenticate for this project?</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect
|
||||
items={items}
|
||||
initialIndex={initialAuthIndex}
|
||||
onSelect={handleAuthSelect}
|
||||
isFocused={true}
|
||||
/>
|
||||
</Box>
|
||||
{errorMessage && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentRed}>{errorMessage}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentPurple}>(Use Enter to Set Auth)</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text>Terms of Services and Privacy Notice for Qwen Code</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.AccentBlue}>
|
||||
{'https://github.com/QwenLM/Qwen3-Coder/blob/main/README.md'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
57
packages/cli/src/ui/components/AuthInProgress.tsx
Normal file
57
packages/cli/src/ui/components/AuthInProgress.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import Spinner from 'ink-spinner';
|
||||
import { Colors } from '../colors.js';
|
||||
|
||||
interface AuthInProgressProps {
|
||||
onTimeout: () => void;
|
||||
}
|
||||
|
||||
export function AuthInProgress({
|
||||
onTimeout,
|
||||
}: AuthInProgressProps): React.JSX.Element {
|
||||
const [timedOut, setTimedOut] = useState(false);
|
||||
|
||||
useInput((_, key) => {
|
||||
if (key.escape) {
|
||||
onTimeout();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setTimedOut(true);
|
||||
onTimeout();
|
||||
}, 180000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [onTimeout]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.Gray}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
{timedOut ? (
|
||||
<Text color={Colors.AccentRed}>
|
||||
Authentication timed out. Please try again.
|
||||
</Text>
|
||||
) : (
|
||||
<Box>
|
||||
<Text>
|
||||
<Spinner type="dots" /> Waiting for auth... (Press ESC to cancel)
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
47
packages/cli/src/ui/components/AutoAcceptIndicator.tsx
Normal file
47
packages/cli/src/ui/components/AutoAcceptIndicator.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { ApprovalMode } from '@qwen/qwen-code-core';
|
||||
|
||||
interface AutoAcceptIndicatorProps {
|
||||
approvalMode: ApprovalMode;
|
||||
}
|
||||
|
||||
export const AutoAcceptIndicator: React.FC<AutoAcceptIndicatorProps> = ({
|
||||
approvalMode,
|
||||
}) => {
|
||||
let textColor = '';
|
||||
let textContent = '';
|
||||
let subText = '';
|
||||
|
||||
switch (approvalMode) {
|
||||
case ApprovalMode.AUTO_EDIT:
|
||||
textColor = Colors.AccentGreen;
|
||||
textContent = 'accepting edits';
|
||||
subText = ' (shift + tab to toggle)';
|
||||
break;
|
||||
case ApprovalMode.YOLO:
|
||||
textColor = Colors.AccentRed;
|
||||
textContent = 'YOLO mode';
|
||||
subText = ' (ctrl + y to toggle)';
|
||||
break;
|
||||
case ApprovalMode.DEFAULT:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={textColor}>
|
||||
{textContent}
|
||||
{subText && <Text color={Colors.Gray}>{subText}</Text>}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
35
packages/cli/src/ui/components/ConsoleSummaryDisplay.tsx
Normal file
35
packages/cli/src/ui/components/ConsoleSummaryDisplay.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
|
||||
interface ConsoleSummaryDisplayProps {
|
||||
errorCount: number;
|
||||
// logCount is not currently in the plan to be displayed in summary
|
||||
}
|
||||
|
||||
export const ConsoleSummaryDisplay: React.FC<ConsoleSummaryDisplayProps> = ({
|
||||
errorCount,
|
||||
}) => {
|
||||
if (errorCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errorIcon = '\u2716'; // Heavy multiplication x (✖)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{errorCount > 0 && (
|
||||
<Text color={Colors.AccentRed}>
|
||||
{errorIcon} {errorCount} error{errorCount > 1 ? 's' : ''}{' '}
|
||||
<Text color={Colors.Gray}>(ctrl+o for details)</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
67
packages/cli/src/ui/components/ContextSummaryDisplay.tsx
Normal file
67
packages/cli/src/ui/components/ContextSummaryDisplay.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { type MCPServerConfig } from '@qwen/qwen-code-core';
|
||||
|
||||
interface ContextSummaryDisplayProps {
|
||||
geminiMdFileCount: number;
|
||||
contextFileNames: string[];
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
showToolDescriptions?: boolean;
|
||||
}
|
||||
|
||||
export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
geminiMdFileCount,
|
||||
contextFileNames,
|
||||
mcpServers,
|
||||
showToolDescriptions,
|
||||
}) => {
|
||||
const mcpServerCount = Object.keys(mcpServers || {}).length;
|
||||
|
||||
if (geminiMdFileCount === 0 && mcpServerCount === 0) {
|
||||
return <Text> </Text>; // Render an empty space to reserve height
|
||||
}
|
||||
|
||||
const geminiMdText = (() => {
|
||||
if (geminiMdFileCount === 0) {
|
||||
return '';
|
||||
}
|
||||
const allNamesTheSame = new Set(contextFileNames).size < 2;
|
||||
const name = allNamesTheSame ? contextFileNames[0] : 'context';
|
||||
return `${geminiMdFileCount} ${name} file${
|
||||
geminiMdFileCount > 1 ? 's' : ''
|
||||
}`;
|
||||
})();
|
||||
|
||||
const mcpText =
|
||||
mcpServerCount > 0
|
||||
? `${mcpServerCount} MCP server${mcpServerCount > 1 ? 's' : ''}`
|
||||
: '';
|
||||
|
||||
let summaryText = 'Using ';
|
||||
if (geminiMdText) {
|
||||
summaryText += geminiMdText;
|
||||
}
|
||||
if (geminiMdText && mcpText) {
|
||||
summaryText += ' and ';
|
||||
}
|
||||
if (mcpText) {
|
||||
summaryText += mcpText;
|
||||
// Add ctrl+t hint when MCP servers are available
|
||||
if (mcpServers && Object.keys(mcpServers).length > 0) {
|
||||
if (showToolDescriptions) {
|
||||
summaryText += ' (ctrl+t to toggle)';
|
||||
} else {
|
||||
summaryText += ' (ctrl+t to view)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return <Text color={Colors.Gray}>{summaryText}</Text>;
|
||||
};
|
||||
82
packages/cli/src/ui/components/DetailedMessagesDisplay.tsx
Normal file
82
packages/cli/src/ui/components/DetailedMessagesDisplay.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { ConsoleMessageItem } from '../types.js';
|
||||
import { MaxSizedBox } from './shared/MaxSizedBox.js';
|
||||
|
||||
interface DetailedMessagesDisplayProps {
|
||||
messages: ConsoleMessageItem[];
|
||||
maxHeight: number | undefined;
|
||||
width: number;
|
||||
// debugMode is not needed here if App.tsx filters debug messages before passing them.
|
||||
// If DetailedMessagesDisplay should handle filtering, add debugMode prop.
|
||||
}
|
||||
|
||||
export const DetailedMessagesDisplay: React.FC<
|
||||
DetailedMessagesDisplayProps
|
||||
> = ({ messages, maxHeight, width }) => {
|
||||
if (messages.length === 0) {
|
||||
return null; // Don't render anything if there are no messages
|
||||
}
|
||||
|
||||
const borderAndPadding = 4;
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
marginTop={1}
|
||||
borderStyle="round"
|
||||
borderColor={Colors.Gray}
|
||||
paddingX={1}
|
||||
width={width}
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={Colors.Foreground}>
|
||||
Debug Console <Text color={Colors.Gray}>(ctrl+o to close)</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<MaxSizedBox maxHeight={maxHeight} maxWidth={width - borderAndPadding}>
|
||||
{messages.map((msg, index) => {
|
||||
let textColor = Colors.Foreground;
|
||||
let icon = '\u2139'; // Information source (ℹ)
|
||||
|
||||
switch (msg.type) {
|
||||
case 'warn':
|
||||
textColor = Colors.AccentYellow;
|
||||
icon = '\u26A0'; // Warning sign (⚠)
|
||||
break;
|
||||
case 'error':
|
||||
textColor = Colors.AccentRed;
|
||||
icon = '\u2716'; // Heavy multiplication x (✖)
|
||||
break;
|
||||
case 'debug':
|
||||
textColor = Colors.Gray; // Or Colors.Gray
|
||||
icon = '\u1F50D'; // Left-pointing magnifying glass (????)
|
||||
break;
|
||||
case 'log':
|
||||
default:
|
||||
// Default textColor and icon are already set
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={index} flexDirection="row">
|
||||
<Text color={textColor}>{icon} </Text>
|
||||
<Text color={textColor} wrap="wrap">
|
||||
{msg.content}
|
||||
{msg.count && msg.count > 1 && (
|
||||
<Text color={Colors.Gray}> (x{msg.count})</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</MaxSizedBox>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
168
packages/cli/src/ui/components/EditorSettingsDialog.tsx
Normal file
168
packages/cli/src/ui/components/EditorSettingsDialog.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import {
|
||||
EDITOR_DISPLAY_NAMES,
|
||||
editorSettingsManager,
|
||||
type EditorDisplay,
|
||||
} from '../editors/editorSettingsManager.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import { EditorType, isEditorAvailable } from '@qwen/qwen-code-core';
|
||||
|
||||
interface EditorDialogProps {
|
||||
onSelect: (editorType: EditorType | undefined, scope: SettingScope) => void;
|
||||
settings: LoadedSettings;
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
export function EditorSettingsDialog({
|
||||
onSelect,
|
||||
settings,
|
||||
onExit,
|
||||
}: EditorDialogProps): React.JSX.Element {
|
||||
const [selectedScope, setSelectedScope] = useState<SettingScope>(
|
||||
SettingScope.User,
|
||||
);
|
||||
const [focusedSection, setFocusedSection] = useState<'editor' | 'scope'>(
|
||||
'editor',
|
||||
);
|
||||
useInput((_, key) => {
|
||||
if (key.tab) {
|
||||
setFocusedSection((prev) => (prev === 'editor' ? 'scope' : 'editor'));
|
||||
}
|
||||
if (key.escape) {
|
||||
onExit();
|
||||
}
|
||||
});
|
||||
|
||||
const editorItems: EditorDisplay[] =
|
||||
editorSettingsManager.getAvailableEditorDisplays();
|
||||
|
||||
const currentPreference =
|
||||
settings.forScope(selectedScope).settings.preferredEditor;
|
||||
let editorIndex = currentPreference
|
||||
? editorItems.findIndex(
|
||||
(item: EditorDisplay) => item.type === currentPreference,
|
||||
)
|
||||
: 0;
|
||||
if (editorIndex === -1) {
|
||||
console.error(`Editor is not supported: ${currentPreference}`);
|
||||
editorIndex = 0;
|
||||
}
|
||||
|
||||
const scopeItems = [
|
||||
{ label: 'User Settings', value: SettingScope.User },
|
||||
{ label: 'Workspace Settings', value: SettingScope.Workspace },
|
||||
];
|
||||
|
||||
const handleEditorSelect = (editorType: EditorType | 'not_set') => {
|
||||
if (editorType === 'not_set') {
|
||||
onSelect(undefined, selectedScope);
|
||||
return;
|
||||
}
|
||||
onSelect(editorType, selectedScope);
|
||||
};
|
||||
|
||||
const handleScopeSelect = (scope: SettingScope) => {
|
||||
setSelectedScope(scope);
|
||||
setFocusedSection('editor');
|
||||
};
|
||||
|
||||
let otherScopeModifiedMessage = '';
|
||||
const otherScope =
|
||||
selectedScope === SettingScope.User
|
||||
? SettingScope.Workspace
|
||||
: SettingScope.User;
|
||||
if (settings.forScope(otherScope).settings.preferredEditor !== undefined) {
|
||||
otherScopeModifiedMessage =
|
||||
settings.forScope(selectedScope).settings.preferredEditor !== undefined
|
||||
? `(Also modified in ${otherScope})`
|
||||
: `(Modified in ${otherScope})`;
|
||||
}
|
||||
|
||||
let mergedEditorName = 'None';
|
||||
if (
|
||||
settings.merged.preferredEditor &&
|
||||
isEditorAvailable(settings.merged.preferredEditor)
|
||||
) {
|
||||
mergedEditorName =
|
||||
EDITOR_DISPLAY_NAMES[settings.merged.preferredEditor as EditorType];
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.Gray}
|
||||
flexDirection="row"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box flexDirection="column" width="45%" paddingRight={2}>
|
||||
<Text bold={focusedSection === 'editor'}>
|
||||
{focusedSection === 'editor' ? '> ' : ' '}Select Editor{' '}
|
||||
<Text color={Colors.Gray}>{otherScopeModifiedMessage}</Text>
|
||||
</Text>
|
||||
<RadioButtonSelect
|
||||
items={editorItems.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.type,
|
||||
disabled: item.disabled,
|
||||
}))}
|
||||
initialIndex={editorIndex}
|
||||
onSelect={handleEditorSelect}
|
||||
isFocused={focusedSection === 'editor'}
|
||||
key={selectedScope}
|
||||
/>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text bold={focusedSection === 'scope'}>
|
||||
{focusedSection === 'scope' ? '> ' : ' '}Apply To
|
||||
</Text>
|
||||
<RadioButtonSelect
|
||||
items={scopeItems}
|
||||
initialIndex={0}
|
||||
onSelect={handleScopeSelect}
|
||||
isFocused={focusedSection === 'scope'}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
(Use Enter to select, Tab to change focus)
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" width="55%" paddingLeft={2}>
|
||||
<Text bold>Editor Preference</Text>
|
||||
<Box flexDirection="column" gap={1} marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
These editors are currently supported. Please note that some editors
|
||||
cannot be used in sandbox mode.
|
||||
</Text>
|
||||
<Text color={Colors.Gray}>
|
||||
Your preferred editor is:{' '}
|
||||
<Text
|
||||
color={
|
||||
mergedEditorName === 'None'
|
||||
? Colors.AccentRed
|
||||
: Colors.AccentCyan
|
||||
}
|
||||
bold
|
||||
>
|
||||
{mergedEditorName}
|
||||
</Text>
|
||||
.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
121
packages/cli/src/ui/components/Footer.tsx
Normal file
121
packages/cli/src/ui/components/Footer.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { shortenPath, tildeifyPath, tokenLimit } from '@qwen/qwen-code-core';
|
||||
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
|
||||
import process from 'node:process';
|
||||
import Gradient from 'ink-gradient';
|
||||
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
|
||||
|
||||
interface FooterProps {
|
||||
model: string;
|
||||
targetDir: string;
|
||||
branchName?: string;
|
||||
debugMode: boolean;
|
||||
debugMessage: string;
|
||||
corgiMode: boolean;
|
||||
errorCount: number;
|
||||
showErrorDetails: boolean;
|
||||
showMemoryUsage?: boolean;
|
||||
promptTokenCount: number;
|
||||
nightly: boolean;
|
||||
}
|
||||
|
||||
export const Footer: React.FC<FooterProps> = ({
|
||||
model,
|
||||
targetDir,
|
||||
branchName,
|
||||
debugMode,
|
||||
debugMessage,
|
||||
corgiMode,
|
||||
errorCount,
|
||||
showErrorDetails,
|
||||
showMemoryUsage,
|
||||
promptTokenCount,
|
||||
nightly,
|
||||
}) => {
|
||||
const limit = tokenLimit(model);
|
||||
const percentage = promptTokenCount / limit;
|
||||
|
||||
return (
|
||||
<Box marginTop={1} justifyContent="space-between" width="100%">
|
||||
<Box>
|
||||
{nightly ? (
|
||||
<Gradient colors={Colors.GradientColors}>
|
||||
<Text>
|
||||
{shortenPath(tildeifyPath(targetDir), 70)}
|
||||
{branchName && <Text> ({branchName}*)</Text>}
|
||||
</Text>
|
||||
</Gradient>
|
||||
) : (
|
||||
<Text color={Colors.LightBlue}>
|
||||
{shortenPath(tildeifyPath(targetDir), 70)}
|
||||
{branchName && <Text color={Colors.Gray}> ({branchName}*)</Text>}
|
||||
</Text>
|
||||
)}
|
||||
{debugMode && (
|
||||
<Text color={Colors.AccentRed}>
|
||||
{' ' + (debugMessage || '--debug')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Middle Section: Centered Sandbox Info */}
|
||||
<Box
|
||||
flexGrow={1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
display="flex"
|
||||
>
|
||||
{process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec' ? (
|
||||
<Text color="green">
|
||||
{process.env.SANDBOX.replace(/^gemini-(?:cli-)?/, '')}
|
||||
</Text>
|
||||
) : process.env.SANDBOX === 'sandbox-exec' ? (
|
||||
<Text color={Colors.AccentYellow}>
|
||||
MacOS Seatbelt{' '}
|
||||
<Text color={Colors.Gray}>({process.env.SEATBELT_PROFILE})</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={Colors.AccentRed}>
|
||||
no sandbox <Text color={Colors.Gray}>(see /docs)</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Right Section: Gemini Label and Console Summary */}
|
||||
<Box alignItems="center">
|
||||
<Text color={Colors.AccentBlue}>
|
||||
{' '}
|
||||
{model}{' '}
|
||||
<Text color={Colors.Gray}>
|
||||
({((1 - percentage) * 100).toFixed(0)}% context left)
|
||||
</Text>
|
||||
</Text>
|
||||
{corgiMode && (
|
||||
<Text>
|
||||
<Text color={Colors.Gray}>| </Text>
|
||||
<Text color={Colors.AccentRed}>▼</Text>
|
||||
<Text color={Colors.Foreground}>(´</Text>
|
||||
<Text color={Colors.AccentRed}>ᴥ</Text>
|
||||
<Text color={Colors.Foreground}>`)</Text>
|
||||
<Text color={Colors.AccentRed}>▼ </Text>
|
||||
</Text>
|
||||
)}
|
||||
{!showErrorDetails && errorCount > 0 && (
|
||||
<Box>
|
||||
<Text color={Colors.Gray}>| </Text>
|
||||
<ConsoleSummaryDisplay errorCount={errorCount} />
|
||||
</Box>
|
||||
)}
|
||||
{showMemoryUsage && <MemoryUsageDisplay />}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
34
packages/cli/src/ui/components/GeminiRespondingSpinner.tsx
Normal file
34
packages/cli/src/ui/components/GeminiRespondingSpinner.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import Spinner from 'ink-spinner';
|
||||
import type { SpinnerName } from 'cli-spinners';
|
||||
import { useStreamingContext } from '../contexts/StreamingContext.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
|
||||
interface GeminiRespondingSpinnerProps {
|
||||
/**
|
||||
* Optional string to display when not in Responding state.
|
||||
* If not provided and not Responding, renders null.
|
||||
*/
|
||||
nonRespondingDisplay?: string;
|
||||
spinnerType?: SpinnerName;
|
||||
}
|
||||
|
||||
export const GeminiRespondingSpinner: React.FC<
|
||||
GeminiRespondingSpinnerProps
|
||||
> = ({ nonRespondingDisplay, spinnerType = 'dots' }) => {
|
||||
const streamingState = useStreamingContext();
|
||||
|
||||
if (streamingState === StreamingState.Responding) {
|
||||
return <Spinner type={spinnerType} />;
|
||||
} else if (nonRespondingDisplay) {
|
||||
return <Text>{nonRespondingDisplay}</Text>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
63
packages/cli/src/ui/components/Header.tsx
Normal file
63
packages/cli/src/ui/components/Header.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import Gradient from 'ink-gradient';
|
||||
import { Colors } from '../colors.js';
|
||||
import { shortAsciiLogo, longAsciiLogo } from './AsciiArt.js';
|
||||
import { getAsciiArtWidth } from '../utils/textUtils.js';
|
||||
|
||||
interface HeaderProps {
|
||||
customAsciiArt?: string; // For user-defined ASCII art
|
||||
terminalWidth: number; // For responsive logo
|
||||
version: string;
|
||||
nightly: boolean;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
customAsciiArt,
|
||||
terminalWidth,
|
||||
version,
|
||||
nightly,
|
||||
}) => {
|
||||
let displayTitle;
|
||||
const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo);
|
||||
|
||||
if (customAsciiArt) {
|
||||
displayTitle = customAsciiArt;
|
||||
} else {
|
||||
displayTitle =
|
||||
terminalWidth >= widthOfLongLogo ? longAsciiLogo : shortAsciiLogo;
|
||||
}
|
||||
|
||||
const artWidth = getAsciiArtWidth(displayTitle);
|
||||
|
||||
return (
|
||||
<Box
|
||||
marginBottom={1}
|
||||
alignItems="flex-start"
|
||||
width={artWidth}
|
||||
flexShrink={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{Colors.GradientColors ? (
|
||||
<Gradient colors={Colors.GradientColors}>
|
||||
<Text>{displayTitle}</Text>
|
||||
</Gradient>
|
||||
) : (
|
||||
<Text>{displayTitle}</Text>
|
||||
)}
|
||||
{nightly && (
|
||||
<Box width="100%" flexDirection="row" justifyContent="flex-end">
|
||||
<Gradient colors={Colors.GradientColors}>
|
||||
<Text>v{version}</Text>
|
||||
</Gradient>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
155
packages/cli/src/ui/components/Help.tsx
Normal file
155
packages/cli/src/ui/components/Help.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { SlashCommand } from '../commands/types.js';
|
||||
|
||||
interface Help {
|
||||
commands: SlashCommand[];
|
||||
}
|
||||
|
||||
export const Help: React.FC<Help> = ({ commands }) => (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
marginBottom={1}
|
||||
borderColor={Colors.Gray}
|
||||
borderStyle="round"
|
||||
padding={1}
|
||||
>
|
||||
{/* Basics */}
|
||||
<Text bold color={Colors.Foreground}>
|
||||
Basics:
|
||||
</Text>
|
||||
<Text color={Colors.Foreground}>
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
Add context
|
||||
</Text>
|
||||
: Use{' '}
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
@
|
||||
</Text>{' '}
|
||||
to specify files for context (e.g.,{' '}
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
@src/myFile.ts
|
||||
</Text>
|
||||
) to target specific files or folders.
|
||||
</Text>
|
||||
<Text color={Colors.Foreground}>
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
Shell mode
|
||||
</Text>
|
||||
: Execute shell commands via{' '}
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
!
|
||||
</Text>{' '}
|
||||
(e.g.,{' '}
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
!npm run start
|
||||
</Text>
|
||||
) or use natural language (e.g.{' '}
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
start server
|
||||
</Text>
|
||||
).
|
||||
</Text>
|
||||
|
||||
<Box height={1} />
|
||||
|
||||
{/* Commands */}
|
||||
<Text bold color={Colors.Foreground}>
|
||||
Commands:
|
||||
</Text>
|
||||
{commands
|
||||
.filter((command) => command.description)
|
||||
.map((command: SlashCommand) => (
|
||||
<Box key={command.name} flexDirection="column">
|
||||
<Text color={Colors.Foreground}>
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
{' '}
|
||||
/{command.name}
|
||||
</Text>
|
||||
{command.description && ' - ' + command.description}
|
||||
</Text>
|
||||
{command.subCommands &&
|
||||
command.subCommands.map((subCommand) => (
|
||||
<Text key={subCommand.name} color={Colors.Foreground}>
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
{' '}
|
||||
{subCommand.name}
|
||||
</Text>
|
||||
{subCommand.description && ' - ' + subCommand.description}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
<Text color={Colors.Foreground}>
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
{' '}
|
||||
!{' '}
|
||||
</Text>
|
||||
- shell command
|
||||
</Text>
|
||||
|
||||
<Box height={1} />
|
||||
|
||||
{/* Shortcuts */}
|
||||
<Text bold color={Colors.Foreground}>
|
||||
Keyboard Shortcuts:
|
||||
</Text>
|
||||
<Text color={Colors.Foreground}>
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
Enter
|
||||
</Text>{' '}
|
||||
- Send message
|
||||
</Text>
|
||||
<Text color={Colors.Foreground}>
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
{process.platform === 'win32' ? 'Ctrl+Enter' : 'Ctrl+J'}
|
||||
</Text>{' '}
|
||||
{process.platform === 'linux'
|
||||
? '- New line (Alt+Enter works for certain linux distros)'
|
||||
: '- New line'}
|
||||
</Text>
|
||||
<Text color={Colors.Foreground}>
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
Up/Down
|
||||
</Text>{' '}
|
||||
- Cycle through your prompt history
|
||||
</Text>
|
||||
<Text color={Colors.Foreground}>
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
Alt+Left/Right
|
||||
</Text>{' '}
|
||||
- Jump through words in the input
|
||||
</Text>
|
||||
<Text color={Colors.Foreground}>
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
Shift+Tab
|
||||
</Text>{' '}
|
||||
- Toggle auto-accepting edits
|
||||
</Text>
|
||||
<Text color={Colors.Foreground}>
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
Ctrl+Y
|
||||
</Text>{' '}
|
||||
- Toggle YOLO mode
|
||||
</Text>
|
||||
<Text color={Colors.Foreground}>
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
Esc
|
||||
</Text>{' '}
|
||||
- Cancel operation
|
||||
</Text>
|
||||
<Text color={Colors.Foreground}>
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
Ctrl+C
|
||||
</Text>{' '}
|
||||
- Quit application
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
112
packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
Normal file
112
packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @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 { HistoryItemDisplay } from './HistoryItemDisplay.js';
|
||||
import { HistoryItem, MessageType } from '../types.js';
|
||||
import { SessionStatsProvider } from '../contexts/SessionContext.js';
|
||||
|
||||
// Mock child components
|
||||
vi.mock('./messages/ToolGroupMessage.js', () => ({
|
||||
ToolGroupMessage: () => <div />,
|
||||
}));
|
||||
|
||||
describe('<HistoryItemDisplay />', () => {
|
||||
const baseItem = {
|
||||
id: 1,
|
||||
timestamp: 12345,
|
||||
isPending: false,
|
||||
terminalWidth: 80,
|
||||
};
|
||||
|
||||
it('renders UserMessage for "user" type', () => {
|
||||
const item: HistoryItem = {
|
||||
...baseItem,
|
||||
type: MessageType.USER,
|
||||
text: 'Hello',
|
||||
};
|
||||
const { lastFrame } = render(
|
||||
<HistoryItemDisplay {...baseItem} item={item} />,
|
||||
);
|
||||
expect(lastFrame()).toContain('Hello');
|
||||
});
|
||||
|
||||
it('renders StatsDisplay for "stats" type', () => {
|
||||
const item: HistoryItem = {
|
||||
...baseItem,
|
||||
type: MessageType.STATS,
|
||||
duration: '1s',
|
||||
};
|
||||
const { lastFrame } = render(
|
||||
<SessionStatsProvider>
|
||||
<HistoryItemDisplay {...baseItem} item={item} />
|
||||
</SessionStatsProvider>,
|
||||
);
|
||||
expect(lastFrame()).toContain('Stats');
|
||||
});
|
||||
|
||||
it('renders AboutBox for "about" type', () => {
|
||||
const item: HistoryItem = {
|
||||
...baseItem,
|
||||
type: MessageType.ABOUT,
|
||||
cliVersion: '1.0.0',
|
||||
osVersion: 'test-os',
|
||||
sandboxEnv: 'test-env',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
gcpProject: 'test-project',
|
||||
};
|
||||
const { lastFrame } = render(
|
||||
<HistoryItemDisplay {...baseItem} item={item} />,
|
||||
);
|
||||
expect(lastFrame()).toContain('About Gemini CLI');
|
||||
});
|
||||
|
||||
it('renders ModelStatsDisplay for "model_stats" type', () => {
|
||||
const item: HistoryItem = {
|
||||
...baseItem,
|
||||
type: 'model_stats',
|
||||
};
|
||||
const { lastFrame } = render(
|
||||
<SessionStatsProvider>
|
||||
<HistoryItemDisplay {...baseItem} item={item} />
|
||||
</SessionStatsProvider>,
|
||||
);
|
||||
expect(lastFrame()).toContain(
|
||||
'No API calls have been made in this session.',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders ToolStatsDisplay for "tool_stats" type', () => {
|
||||
const item: HistoryItem = {
|
||||
...baseItem,
|
||||
type: 'tool_stats',
|
||||
};
|
||||
const { lastFrame } = render(
|
||||
<SessionStatsProvider>
|
||||
<HistoryItemDisplay {...baseItem} item={item} />
|
||||
</SessionStatsProvider>,
|
||||
);
|
||||
expect(lastFrame()).toContain(
|
||||
'No tool calls have been made in this session.',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders SessionSummaryDisplay for "quit" type', () => {
|
||||
const item: HistoryItem = {
|
||||
...baseItem,
|
||||
type: 'quit',
|
||||
duration: '1s',
|
||||
};
|
||||
const { lastFrame } = render(
|
||||
<SessionStatsProvider>
|
||||
<HistoryItemDisplay {...baseItem} item={item} />
|
||||
</SessionStatsProvider>,
|
||||
);
|
||||
expect(lastFrame()).toContain('Agent powering down. Goodbye!');
|
||||
});
|
||||
});
|
||||
92
packages/cli/src/ui/components/HistoryItemDisplay.tsx
Normal file
92
packages/cli/src/ui/components/HistoryItemDisplay.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { HistoryItem } from '../types.js';
|
||||
import { UserMessage } from './messages/UserMessage.js';
|
||||
import { UserShellMessage } from './messages/UserShellMessage.js';
|
||||
import { GeminiMessage } from './messages/GeminiMessage.js';
|
||||
import { InfoMessage } from './messages/InfoMessage.js';
|
||||
import { ErrorMessage } from './messages/ErrorMessage.js';
|
||||
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
|
||||
import { GeminiMessageContent } from './messages/GeminiMessageContent.js';
|
||||
import { CompressionMessage } from './messages/CompressionMessage.js';
|
||||
import { Box } from 'ink';
|
||||
import { AboutBox } from './AboutBox.js';
|
||||
import { StatsDisplay } from './StatsDisplay.js';
|
||||
import { ModelStatsDisplay } from './ModelStatsDisplay.js';
|
||||
import { ToolStatsDisplay } from './ToolStatsDisplay.js';
|
||||
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
|
||||
import { Config } from '@qwen/qwen-code-core';
|
||||
|
||||
interface HistoryItemDisplayProps {
|
||||
item: HistoryItem;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
isPending: boolean;
|
||||
config?: Config;
|
||||
isFocused?: boolean;
|
||||
}
|
||||
|
||||
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
item,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
isPending,
|
||||
config,
|
||||
isFocused = true,
|
||||
}) => (
|
||||
<Box flexDirection="column" key={item.id}>
|
||||
{/* Render standard message types */}
|
||||
{item.type === 'user' && <UserMessage text={item.text} />}
|
||||
{item.type === 'user_shell' && <UserShellMessage text={item.text} />}
|
||||
{item.type === 'gemini' && (
|
||||
<GeminiMessage
|
||||
text={item.text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
)}
|
||||
{item.type === 'gemini_content' && (
|
||||
<GeminiMessageContent
|
||||
text={item.text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
)}
|
||||
{item.type === 'info' && <InfoMessage text={item.text} />}
|
||||
{item.type === 'error' && <ErrorMessage text={item.text} />}
|
||||
{item.type === 'about' && (
|
||||
<AboutBox
|
||||
cliVersion={item.cliVersion}
|
||||
osVersion={item.osVersion}
|
||||
sandboxEnv={item.sandboxEnv}
|
||||
modelVersion={item.modelVersion}
|
||||
selectedAuthType={item.selectedAuthType}
|
||||
gcpProject={item.gcpProject}
|
||||
/>
|
||||
)}
|
||||
{item.type === 'stats' && <StatsDisplay duration={item.duration} />}
|
||||
{item.type === 'model_stats' && <ModelStatsDisplay />}
|
||||
{item.type === 'tool_stats' && <ToolStatsDisplay />}
|
||||
{item.type === 'quit' && <SessionSummaryDisplay duration={item.duration} />}
|
||||
{item.type === 'tool_group' && (
|
||||
<ToolGroupMessage
|
||||
toolCalls={item.tools}
|
||||
groupId={item.id}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
config={config}
|
||||
isFocused={isFocused}
|
||||
/>
|
||||
)}
|
||||
{item.type === 'compression' && (
|
||||
<CompressionMessage compression={item.compression} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
546
packages/cli/src/ui/components/InputPrompt.test.tsx
Normal file
546
packages/cli/src/ui/components/InputPrompt.test.tsx
Normal file
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { InputPrompt, InputPromptProps } from './InputPrompt.js';
|
||||
import type { TextBuffer } from './shared/text-buffer.js';
|
||||
import { Config } from '@qwen/qwen-code-core';
|
||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import { vi } from 'vitest';
|
||||
import { useShellHistory } from '../hooks/useShellHistory.js';
|
||||
import { useCompletion } from '../hooks/useCompletion.js';
|
||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||
import * as clipboardUtils from '../utils/clipboardUtils.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
vi.mock('../hooks/useShellHistory.js');
|
||||
vi.mock('../hooks/useCompletion.js');
|
||||
vi.mock('../hooks/useInputHistory.js');
|
||||
vi.mock('../utils/clipboardUtils.js');
|
||||
|
||||
type MockedUseShellHistory = ReturnType<typeof useShellHistory>;
|
||||
type MockedUseCompletion = ReturnType<typeof useCompletion>;
|
||||
type MockedUseInputHistory = ReturnType<typeof useInputHistory>;
|
||||
|
||||
const mockSlashCommands: SlashCommand[] = [
|
||||
{ name: 'clear', description: 'Clear screen', action: vi.fn() },
|
||||
{
|
||||
name: 'memory',
|
||||
description: 'Manage memory',
|
||||
subCommands: [
|
||||
{ name: 'show', description: 'Show memory', action: vi.fn() },
|
||||
{ name: 'add', description: 'Add to memory', action: vi.fn() },
|
||||
{ name: 'refresh', description: 'Refresh memory', action: vi.fn() },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'chat',
|
||||
description: 'Manage chats',
|
||||
subCommands: [
|
||||
{
|
||||
name: 'resume',
|
||||
description: 'Resume a chat',
|
||||
action: vi.fn(),
|
||||
completion: async () => ['fix-foo', 'fix-bar'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe('InputPrompt', () => {
|
||||
let props: InputPromptProps;
|
||||
let mockShellHistory: MockedUseShellHistory;
|
||||
let mockCompletion: MockedUseCompletion;
|
||||
let mockInputHistory: MockedUseInputHistory;
|
||||
let mockBuffer: TextBuffer;
|
||||
let mockCommandContext: CommandContext;
|
||||
|
||||
const mockedUseShellHistory = vi.mocked(useShellHistory);
|
||||
const mockedUseCompletion = vi.mocked(useCompletion);
|
||||
const mockedUseInputHistory = vi.mocked(useInputHistory);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
mockCommandContext = createMockCommandContext();
|
||||
|
||||
mockBuffer = {
|
||||
text: '',
|
||||
cursor: [0, 0],
|
||||
lines: [''],
|
||||
setText: vi.fn((newText: string) => {
|
||||
mockBuffer.text = newText;
|
||||
mockBuffer.lines = [newText];
|
||||
mockBuffer.cursor = [0, newText.length];
|
||||
mockBuffer.viewportVisualLines = [newText];
|
||||
mockBuffer.allVisualLines = [newText];
|
||||
}),
|
||||
replaceRangeByOffset: vi.fn(),
|
||||
viewportVisualLines: [''],
|
||||
allVisualLines: [''],
|
||||
visualCursor: [0, 0],
|
||||
visualScrollRow: 0,
|
||||
handleInput: vi.fn(),
|
||||
move: vi.fn(),
|
||||
moveToOffset: vi.fn(),
|
||||
killLineRight: vi.fn(),
|
||||
killLineLeft: vi.fn(),
|
||||
openInExternalEditor: vi.fn(),
|
||||
newline: vi.fn(),
|
||||
backspace: vi.fn(),
|
||||
} as unknown as TextBuffer;
|
||||
|
||||
mockShellHistory = {
|
||||
addCommandToHistory: vi.fn(),
|
||||
getPreviousCommand: vi.fn().mockReturnValue(null),
|
||||
getNextCommand: vi.fn().mockReturnValue(null),
|
||||
resetHistoryPosition: vi.fn(),
|
||||
};
|
||||
mockedUseShellHistory.mockReturnValue(mockShellHistory);
|
||||
|
||||
mockCompletion = {
|
||||
suggestions: [],
|
||||
activeSuggestionIndex: -1,
|
||||
isLoadingSuggestions: false,
|
||||
showSuggestions: false,
|
||||
visibleStartIndex: 0,
|
||||
navigateUp: vi.fn(),
|
||||
navigateDown: vi.fn(),
|
||||
resetCompletionState: vi.fn(),
|
||||
setActiveSuggestionIndex: vi.fn(),
|
||||
setShowSuggestions: vi.fn(),
|
||||
};
|
||||
mockedUseCompletion.mockReturnValue(mockCompletion);
|
||||
|
||||
mockInputHistory = {
|
||||
navigateUp: vi.fn(),
|
||||
navigateDown: vi.fn(),
|
||||
handleSubmit: vi.fn(),
|
||||
};
|
||||
mockedUseInputHistory.mockReturnValue(mockInputHistory);
|
||||
|
||||
props = {
|
||||
buffer: mockBuffer,
|
||||
onSubmit: vi.fn(),
|
||||
userMessages: [],
|
||||
onClearScreen: vi.fn(),
|
||||
config: {
|
||||
getProjectRoot: () => '/test/project',
|
||||
getTargetDir: () => '/test/project/src',
|
||||
} as unknown as Config,
|
||||
slashCommands: [],
|
||||
commandContext: mockCommandContext,
|
||||
shellModeActive: false,
|
||||
setShellModeActive: vi.fn(),
|
||||
inputWidth: 80,
|
||||
suggestionsWidth: 80,
|
||||
focus: true,
|
||||
};
|
||||
|
||||
props.slashCommands = mockSlashCommands;
|
||||
});
|
||||
|
||||
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
it('should call shellHistory.getPreviousCommand on up arrow in shell mode', async () => {
|
||||
props.shellModeActive = true;
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait(100); // Increased wait time for CI environment
|
||||
|
||||
stdin.write('\u001B[A');
|
||||
await wait(100); // Increased wait time to ensure input is processed
|
||||
|
||||
expect(mockShellHistory.getPreviousCommand).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call shellHistory.getNextCommand on down arrow in shell mode', async () => {
|
||||
props.shellModeActive = true;
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait(100); // Increased wait time for CI environment
|
||||
|
||||
stdin.write('\u001B[B');
|
||||
await wait(100); // Increased wait time to ensure input is processed
|
||||
|
||||
expect(mockShellHistory.getNextCommand).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should set the buffer text when a shell history command is retrieved', async () => {
|
||||
props.shellModeActive = true;
|
||||
vi.mocked(mockShellHistory.getPreviousCommand).mockReturnValue(
|
||||
'previous command',
|
||||
);
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait(100); // Increased wait time for CI environment
|
||||
|
||||
stdin.write('\u001B[A');
|
||||
await wait(100); // Increased wait time to ensure input is processed
|
||||
|
||||
expect(mockShellHistory.getPreviousCommand).toHaveBeenCalled();
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('previous command');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call shellHistory.addCommandToHistory on submit in shell mode', async () => {
|
||||
props.shellModeActive = true;
|
||||
props.buffer.setText('ls -l');
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(mockShellHistory.addCommandToHistory).toHaveBeenCalledWith('ls -l');
|
||||
expect(props.onSubmit).toHaveBeenCalledWith('ls -l');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT call shell history methods when not in shell mode', async () => {
|
||||
props.buffer.setText('some text');
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\u001B[A'); // Up arrow
|
||||
await wait();
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
await wait();
|
||||
stdin.write('\r'); // Enter
|
||||
await wait();
|
||||
|
||||
expect(mockShellHistory.getPreviousCommand).not.toHaveBeenCalled();
|
||||
expect(mockShellHistory.getNextCommand).not.toHaveBeenCalled();
|
||||
expect(mockShellHistory.addCommandToHistory).not.toHaveBeenCalled();
|
||||
|
||||
expect(mockInputHistory.navigateUp).toHaveBeenCalled();
|
||||
expect(mockInputHistory.navigateDown).toHaveBeenCalled();
|
||||
expect(props.onSubmit).toHaveBeenCalledWith('some text');
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('clipboard image paste', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
|
||||
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null);
|
||||
vi.mocked(clipboardUtils.cleanupOldClipboardImages).mockResolvedValue(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle Ctrl+V when clipboard has an image', async () => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
|
||||
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
|
||||
'/test/.gemini-clipboard/clipboard-123.png',
|
||||
);
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
// Send Ctrl+V
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
await wait();
|
||||
|
||||
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
|
||||
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalledWith(
|
||||
props.config.getTargetDir(),
|
||||
);
|
||||
expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalledWith(
|
||||
props.config.getTargetDir(),
|
||||
);
|
||||
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not insert anything when clipboard has no image', async () => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
await wait();
|
||||
|
||||
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
|
||||
expect(clipboardUtils.saveClipboardImage).not.toHaveBeenCalled();
|
||||
expect(mockBuffer.setText).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle image save failure gracefully', async () => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
|
||||
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null);
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
await wait();
|
||||
|
||||
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
|
||||
expect(mockBuffer.setText).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should insert image path at cursor position with proper spacing', async () => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
|
||||
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
|
||||
'/test/.gemini-clipboard/clipboard-456.png',
|
||||
);
|
||||
|
||||
// Set initial text and cursor position
|
||||
mockBuffer.text = 'Hello world';
|
||||
mockBuffer.cursor = [0, 5]; // Cursor after "Hello"
|
||||
mockBuffer.lines = ['Hello world'];
|
||||
mockBuffer.replaceRangeByOffset = vi.fn();
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
await wait();
|
||||
|
||||
// Should insert at cursor position with spaces
|
||||
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
|
||||
|
||||
// Get the actual call to see what path was used
|
||||
const actualCall = vi.mocked(mockBuffer.replaceRangeByOffset).mock
|
||||
.calls[0];
|
||||
expect(actualCall[0]).toBe(5); // start offset
|
||||
expect(actualCall[1]).toBe(5); // end offset
|
||||
expect(actualCall[2]).toMatch(
|
||||
/@.*\.gemini-clipboard\/clipboard-456\.png/,
|
||||
); // flexible path match
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle errors during clipboard operations', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockRejectedValue(
|
||||
new Error('Clipboard error'),
|
||||
);
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
await wait();
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error handling clipboard image:',
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(mockBuffer.setText).not.toHaveBeenCalled();
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('should complete a partial parent command and add a space', async () => {
|
||||
// SCENARIO: /mem -> Tab
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'memory', value: 'memory', description: '...' }],
|
||||
activeSuggestionIndex: 0,
|
||||
});
|
||||
props.buffer.setText('/mem');
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\t'); // Press Tab
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory ');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should append a sub-command when the parent command is already complete with a space', async () => {
|
||||
// SCENARIO: /memory -> Tab (to accept 'add')
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [
|
||||
{ label: 'show', value: 'show' },
|
||||
{ label: 'add', value: 'add' },
|
||||
],
|
||||
activeSuggestionIndex: 1, // 'add' is highlighted
|
||||
});
|
||||
props.buffer.setText('/memory ');
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\t'); // Press Tab
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory add ');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle the "backspace" edge case correctly', async () => {
|
||||
// SCENARIO: /memory -> Backspace -> /memory -> Tab (to accept 'show')
|
||||
// This is the critical bug we fixed.
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [
|
||||
{ label: 'show', value: 'show' },
|
||||
{ label: 'add', value: 'add' },
|
||||
],
|
||||
activeSuggestionIndex: 0, // 'show' is highlighted
|
||||
});
|
||||
// The user has backspaced, so the query is now just '/memory'
|
||||
props.buffer.setText('/memory');
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\t'); // Press Tab
|
||||
await wait();
|
||||
|
||||
// It should NOT become '/show '. It should correctly become '/memory show '.
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory show ');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should complete a partial argument for a command', async () => {
|
||||
// SCENARIO: /chat resume fi- -> Tab
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'fix-foo', value: 'fix-foo' }],
|
||||
activeSuggestionIndex: 0,
|
||||
});
|
||||
props.buffer.setText('/chat resume fi-');
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\t'); // Press Tab
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/chat resume fix-foo ');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should autocomplete on Enter when suggestions are active, without submitting', async () => {
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'memory', value: 'memory' }],
|
||||
activeSuggestionIndex: 0,
|
||||
});
|
||||
props.buffer.setText('/mem');
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
// The app should autocomplete the text, NOT submit.
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory ');
|
||||
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should complete a command based on its altName', async () => {
|
||||
// Add a command with an altName to our mock for this test
|
||||
props.slashCommands.push({
|
||||
name: 'help',
|
||||
altName: '?',
|
||||
description: '...',
|
||||
});
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'help', value: 'help' }],
|
||||
activeSuggestionIndex: 0,
|
||||
});
|
||||
props.buffer.setText('/?');
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\t'); // Press Tab
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/help ');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
|
||||
props.buffer.setText(' '); // Set buffer to whitespace
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\r'); // Press Enter
|
||||
await wait();
|
||||
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should submit directly on Enter when a complete leaf command is typed', async () => {
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: false,
|
||||
});
|
||||
props.buffer.setText('/clear');
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(props.onSubmit).toHaveBeenCalledWith('/clear');
|
||||
expect(props.buffer.setText).not.toHaveBeenCalledWith('/clear ');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should autocomplete an @-path on Enter without submitting', async () => {
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'index.ts', value: 'index.ts' }],
|
||||
activeSuggestionIndex: 0,
|
||||
});
|
||||
props.buffer.setText('@src/components/');
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.replaceRangeByOffset).toHaveBeenCalled();
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should add a newline on enter when the line ends with a backslash', async () => {
|
||||
props.buffer.setText('first line\\');
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
expect(props.buffer.backspace).toHaveBeenCalled();
|
||||
expect(props.buffer.newline).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
486
packages/cli/src/ui/components/InputPrompt.tsx
Normal file
486
packages/cli/src/ui/components/InputPrompt.tsx
Normal file
@@ -0,0 +1,486 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
|
||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||
import { TextBuffer } from './shared/text-buffer.js';
|
||||
import { cpSlice, cpLen } from '../utils/textUtils.js';
|
||||
import chalk from 'chalk';
|
||||
import stringWidth from 'string-width';
|
||||
import { useShellHistory } from '../hooks/useShellHistory.js';
|
||||
import { useCompletion } from '../hooks/useCompletion.js';
|
||||
import { useKeypress, Key } from '../hooks/useKeypress.js';
|
||||
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
|
||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import { Config } from '@qwen/qwen-code-core';
|
||||
import {
|
||||
clipboardHasImage,
|
||||
saveClipboardImage,
|
||||
cleanupOldClipboardImages,
|
||||
} from '../utils/clipboardUtils.js';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface InputPromptProps {
|
||||
buffer: TextBuffer;
|
||||
onSubmit: (value: string) => void;
|
||||
userMessages: readonly string[];
|
||||
onClearScreen: () => void;
|
||||
config: Config;
|
||||
slashCommands: SlashCommand[];
|
||||
commandContext: CommandContext;
|
||||
placeholder?: string;
|
||||
focus?: boolean;
|
||||
inputWidth: number;
|
||||
suggestionsWidth: number;
|
||||
shellModeActive: boolean;
|
||||
setShellModeActive: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
buffer,
|
||||
onSubmit,
|
||||
userMessages,
|
||||
onClearScreen,
|
||||
config,
|
||||
slashCommands,
|
||||
commandContext,
|
||||
placeholder = ' Type your message or @path/to/file',
|
||||
focus = true,
|
||||
inputWidth,
|
||||
suggestionsWidth,
|
||||
shellModeActive,
|
||||
setShellModeActive,
|
||||
}) => {
|
||||
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
||||
const completion = useCompletion(
|
||||
buffer.text,
|
||||
config.getTargetDir(),
|
||||
isAtCommand(buffer.text) || isSlashCommand(buffer.text),
|
||||
slashCommands,
|
||||
commandContext,
|
||||
config,
|
||||
);
|
||||
|
||||
const resetCompletionState = completion.resetCompletionState;
|
||||
const shellHistory = useShellHistory(config.getProjectRoot());
|
||||
|
||||
const handleSubmitAndClear = useCallback(
|
||||
(submittedValue: string) => {
|
||||
if (shellModeActive) {
|
||||
shellHistory.addCommandToHistory(submittedValue);
|
||||
}
|
||||
// Clear the buffer *before* calling onSubmit to prevent potential re-submission
|
||||
// if onSubmit triggers a re-render while the buffer still holds the old value.
|
||||
buffer.setText('');
|
||||
onSubmit(submittedValue);
|
||||
resetCompletionState();
|
||||
},
|
||||
[onSubmit, buffer, resetCompletionState, shellModeActive, shellHistory],
|
||||
);
|
||||
|
||||
const customSetTextAndResetCompletionSignal = useCallback(
|
||||
(newText: string) => {
|
||||
buffer.setText(newText);
|
||||
setJustNavigatedHistory(true);
|
||||
},
|
||||
[buffer, setJustNavigatedHistory],
|
||||
);
|
||||
|
||||
const inputHistory = useInputHistory({
|
||||
userMessages,
|
||||
onSubmit: handleSubmitAndClear,
|
||||
isActive: !completion.showSuggestions && !shellModeActive,
|
||||
currentQuery: buffer.text,
|
||||
onChange: customSetTextAndResetCompletionSignal,
|
||||
});
|
||||
|
||||
// Effect to reset completion if history navigation just occurred and set the text
|
||||
useEffect(() => {
|
||||
if (justNavigatedHistory) {
|
||||
resetCompletionState();
|
||||
setJustNavigatedHistory(false);
|
||||
}
|
||||
}, [
|
||||
justNavigatedHistory,
|
||||
buffer.text,
|
||||
resetCompletionState,
|
||||
setJustNavigatedHistory,
|
||||
]);
|
||||
|
||||
const completionSuggestions = completion.suggestions;
|
||||
const handleAutocomplete = useCallback(
|
||||
(indexToUse: number) => {
|
||||
if (indexToUse < 0 || indexToUse >= completionSuggestions.length) {
|
||||
return;
|
||||
}
|
||||
const query = buffer.text;
|
||||
const suggestion = completionSuggestions[indexToUse].value;
|
||||
|
||||
if (query.trimStart().startsWith('/')) {
|
||||
const hasTrailingSpace = query.endsWith(' ');
|
||||
const parts = query
|
||||
.trimStart()
|
||||
.substring(1)
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
|
||||
let isParentPath = false;
|
||||
// If there's no trailing space, we need to check if the current query
|
||||
// is already a complete path to a parent command.
|
||||
if (!hasTrailingSpace) {
|
||||
let currentLevel: SlashCommand[] | undefined = slashCommands;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const found: SlashCommand | undefined = currentLevel?.find(
|
||||
(cmd) => cmd.name === part || cmd.altName === part,
|
||||
);
|
||||
|
||||
if (found) {
|
||||
if (i === parts.length - 1 && found.subCommands) {
|
||||
isParentPath = true;
|
||||
}
|
||||
currentLevel = found.subCommands;
|
||||
} else {
|
||||
// Path is invalid, so it can't be a parent path.
|
||||
currentLevel = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the base path of the command.
|
||||
// - If there's a trailing space, the whole command is the base.
|
||||
// - If it's a known parent path, the whole command is the base.
|
||||
// - Otherwise, the base is everything EXCEPT the last partial part.
|
||||
const basePath =
|
||||
hasTrailingSpace || isParentPath ? parts : parts.slice(0, -1);
|
||||
const newValue = `/${[...basePath, suggestion].join(' ')} `;
|
||||
|
||||
buffer.setText(newValue);
|
||||
} else {
|
||||
const atIndex = query.lastIndexOf('@');
|
||||
if (atIndex === -1) return;
|
||||
const pathPart = query.substring(atIndex + 1);
|
||||
const lastSlashIndexInPath = pathPart.lastIndexOf('/');
|
||||
let autoCompleteStartIndex = atIndex + 1;
|
||||
if (lastSlashIndexInPath !== -1) {
|
||||
autoCompleteStartIndex += lastSlashIndexInPath + 1;
|
||||
}
|
||||
buffer.replaceRangeByOffset(
|
||||
autoCompleteStartIndex,
|
||||
buffer.text.length,
|
||||
suggestion,
|
||||
);
|
||||
}
|
||||
resetCompletionState();
|
||||
},
|
||||
[resetCompletionState, buffer, completionSuggestions, slashCommands],
|
||||
);
|
||||
|
||||
// Handle clipboard image pasting with Ctrl+V
|
||||
const handleClipboardImage = useCallback(async () => {
|
||||
try {
|
||||
if (await clipboardHasImage()) {
|
||||
const imagePath = await saveClipboardImage(config.getTargetDir());
|
||||
if (imagePath) {
|
||||
// Clean up old images
|
||||
cleanupOldClipboardImages(config.getTargetDir()).catch(() => {
|
||||
// Ignore cleanup errors
|
||||
});
|
||||
|
||||
// Get relative path from current directory
|
||||
const relativePath = path.relative(config.getTargetDir(), imagePath);
|
||||
|
||||
// Insert @path reference at cursor position
|
||||
const insertText = `@${relativePath}`;
|
||||
const currentText = buffer.text;
|
||||
const [row, col] = buffer.cursor;
|
||||
|
||||
// Calculate offset from row/col
|
||||
let offset = 0;
|
||||
for (let i = 0; i < row; i++) {
|
||||
offset += buffer.lines[i].length + 1; // +1 for newline
|
||||
}
|
||||
offset += col;
|
||||
|
||||
// Add spaces around the path if needed
|
||||
let textToInsert = insertText;
|
||||
const charBefore = offset > 0 ? currentText[offset - 1] : '';
|
||||
const charAfter =
|
||||
offset < currentText.length ? currentText[offset] : '';
|
||||
|
||||
if (charBefore && charBefore !== ' ' && charBefore !== '\n') {
|
||||
textToInsert = ' ' + textToInsert;
|
||||
}
|
||||
if (!charAfter || (charAfter !== ' ' && charAfter !== '\n')) {
|
||||
textToInsert = textToInsert + ' ';
|
||||
}
|
||||
|
||||
// Insert at cursor position
|
||||
buffer.replaceRangeByOffset(offset, offset, textToInsert);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling clipboard image:', error);
|
||||
}
|
||||
}, [buffer, config]);
|
||||
|
||||
const handleInput = useCallback(
|
||||
(key: Key) => {
|
||||
if (!focus) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
key.sequence === '!' &&
|
||||
buffer.text === '' &&
|
||||
!completion.showSuggestions
|
||||
) {
|
||||
setShellModeActive(!shellModeActive);
|
||||
buffer.setText(''); // Clear the '!' from input
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === 'escape') {
|
||||
if (shellModeActive) {
|
||||
setShellModeActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (completion.showSuggestions) {
|
||||
completion.resetCompletionState();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (key.ctrl && key.name === 'l') {
|
||||
onClearScreen();
|
||||
return;
|
||||
}
|
||||
|
||||
if (completion.showSuggestions) {
|
||||
if (key.name === 'up') {
|
||||
completion.navigateUp();
|
||||
return;
|
||||
}
|
||||
if (key.name === 'down') {
|
||||
completion.navigateDown();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === 'tab' || (key.name === 'return' && !key.ctrl)) {
|
||||
if (completion.suggestions.length > 0) {
|
||||
const targetIndex =
|
||||
completion.activeSuggestionIndex === -1
|
||||
? 0 // Default to the first if none is active
|
||||
: completion.activeSuggestionIndex;
|
||||
if (targetIndex < completion.suggestions.length) {
|
||||
handleAutocomplete(targetIndex);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!shellModeActive) {
|
||||
if (key.ctrl && key.name === 'p') {
|
||||
inputHistory.navigateUp();
|
||||
return;
|
||||
}
|
||||
if (key.ctrl && key.name === 'n') {
|
||||
inputHistory.navigateDown();
|
||||
return;
|
||||
}
|
||||
// Handle arrow-up/down for history on single-line or at edges
|
||||
if (
|
||||
key.name === 'up' &&
|
||||
(buffer.allVisualLines.length === 1 ||
|
||||
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
|
||||
) {
|
||||
inputHistory.navigateUp();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
key.name === 'down' &&
|
||||
(buffer.allVisualLines.length === 1 ||
|
||||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
|
||||
) {
|
||||
inputHistory.navigateDown();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Shell History Navigation
|
||||
if (key.name === 'up') {
|
||||
const prevCommand = shellHistory.getPreviousCommand();
|
||||
if (prevCommand !== null) buffer.setText(prevCommand);
|
||||
return;
|
||||
}
|
||||
if (key.name === 'down') {
|
||||
const nextCommand = shellHistory.getNextCommand();
|
||||
if (nextCommand !== null) buffer.setText(nextCommand);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (key.name === 'return' && !key.ctrl && !key.meta && !key.paste) {
|
||||
if (buffer.text.trim()) {
|
||||
const [row, col] = buffer.cursor;
|
||||
const line = buffer.lines[row];
|
||||
const charBefore = col > 0 ? cpSlice(line, col - 1, col) : '';
|
||||
if (charBefore === '\\') {
|
||||
buffer.backspace();
|
||||
buffer.newline();
|
||||
} else {
|
||||
handleSubmitAndClear(buffer.text);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Newline insertion
|
||||
if (key.name === 'return' && (key.ctrl || key.meta || key.paste)) {
|
||||
buffer.newline();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+A (Home) / Ctrl+E (End)
|
||||
if (key.ctrl && key.name === 'a') {
|
||||
buffer.move('home');
|
||||
return;
|
||||
}
|
||||
if (key.ctrl && key.name === 'e') {
|
||||
buffer.move('end');
|
||||
return;
|
||||
}
|
||||
|
||||
// Kill line commands
|
||||
if (key.ctrl && key.name === 'k') {
|
||||
buffer.killLineRight();
|
||||
return;
|
||||
}
|
||||
if (key.ctrl && key.name === 'u') {
|
||||
buffer.killLineLeft();
|
||||
return;
|
||||
}
|
||||
|
||||
// External editor
|
||||
const isCtrlX = key.ctrl && (key.name === 'x' || key.sequence === '\x18');
|
||||
if (isCtrlX) {
|
||||
buffer.openInExternalEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+V for clipboard image paste
|
||||
if (key.ctrl && key.name === 'v') {
|
||||
handleClipboardImage();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to the text buffer's default input handling for all other keys
|
||||
buffer.handleInput(key);
|
||||
},
|
||||
[
|
||||
focus,
|
||||
buffer,
|
||||
completion,
|
||||
shellModeActive,
|
||||
setShellModeActive,
|
||||
onClearScreen,
|
||||
inputHistory,
|
||||
handleAutocomplete,
|
||||
handleSubmitAndClear,
|
||||
shellHistory,
|
||||
handleClipboardImage,
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleInput, { isActive: focus });
|
||||
|
||||
const linesToRender = buffer.viewportVisualLines;
|
||||
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
|
||||
buffer.visualCursor;
|
||||
const scrollVisualRow = buffer.visualScrollRow;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={shellModeActive ? Colors.AccentYellow : Colors.AccentBlue}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text
|
||||
color={shellModeActive ? Colors.AccentYellow : Colors.AccentPurple}
|
||||
>
|
||||
{shellModeActive ? '! ' : '> '}
|
||||
</Text>
|
||||
<Box flexGrow={1} flexDirection="column">
|
||||
{buffer.text.length === 0 && placeholder ? (
|
||||
focus ? (
|
||||
<Text>
|
||||
{chalk.inverse(placeholder.slice(0, 1))}
|
||||
<Text color={Colors.Gray}>{placeholder.slice(1)}</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={Colors.Gray}>{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);
|
||||
}
|
||||
|
||||
if (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) &&
|
||||
cpLen(display) === inputWidth
|
||||
) {
|
||||
display = display + chalk.inverse(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Text key={`line-${visualIdxInRenderedSet}`}>{display}</Text>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{completion.showSuggestions && (
|
||||
<Box>
|
||||
<SuggestionsDisplay
|
||||
suggestions={completion.suggestions}
|
||||
activeIndex={completion.activeSuggestionIndex}
|
||||
isLoading={completion.isLoadingSuggestions}
|
||||
width={suggestionsWidth}
|
||||
scrollOffset={completion.visibleStartIndex}
|
||||
userInput={buffer.text}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
226
packages/cli/src/ui/components/LoadingIndicator.test.tsx
Normal file
226
packages/cli/src/ui/components/LoadingIndicator.test.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { Text } from 'ink';
|
||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||
import { StreamingContext } from '../contexts/StreamingContext.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock GeminiRespondingSpinner
|
||||
vi.mock('./GeminiRespondingSpinner.js', () => ({
|
||||
GeminiRespondingSpinner: ({
|
||||
nonRespondingDisplay,
|
||||
}: {
|
||||
nonRespondingDisplay?: string;
|
||||
}) => {
|
||||
const streamingState = React.useContext(StreamingContext)!;
|
||||
if (streamingState === StreamingState.Responding) {
|
||||
return <Text>MockRespondingSpinner</Text>;
|
||||
} else if (nonRespondingDisplay) {
|
||||
return <Text>{nonRespondingDisplay}</Text>;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
const renderWithContext = (
|
||||
ui: React.ReactElement,
|
||||
streamingStateValue: StreamingState,
|
||||
) => {
|
||||
const contextValue: StreamingState = streamingStateValue;
|
||||
return render(
|
||||
<StreamingContext.Provider value={contextValue}>
|
||||
{ui}
|
||||
</StreamingContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('<LoadingIndicator />', () => {
|
||||
const defaultProps = {
|
||||
currentLoadingPhrase: 'Loading...',
|
||||
elapsedTime: 5,
|
||||
};
|
||||
|
||||
it('should not render when streamingState is Idle', () => {
|
||||
const { lastFrame } = renderWithContext(
|
||||
<LoadingIndicator {...defaultProps} />,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
|
||||
it('should render spinner, phrase, and time when streamingState is Responding', () => {
|
||||
const { lastFrame } = renderWithContext(
|
||||
<LoadingIndicator {...defaultProps} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('MockRespondingSpinner');
|
||||
expect(output).toContain('Loading...');
|
||||
expect(output).toContain('(esc to cancel, 5s)');
|
||||
});
|
||||
|
||||
it('should render spinner (static), phrase but no time/cancel when streamingState is WaitingForConfirmation', () => {
|
||||
const props = {
|
||||
currentLoadingPhrase: 'Confirm action',
|
||||
elapsedTime: 10,
|
||||
};
|
||||
const { lastFrame } = renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.WaitingForConfirmation,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('⠏'); // Static char for WaitingForConfirmation
|
||||
expect(output).toContain('Confirm action');
|
||||
expect(output).not.toContain('(esc to cancel)');
|
||||
expect(output).not.toContain(', 10s');
|
||||
});
|
||||
|
||||
it('should display the currentLoadingPhrase correctly', () => {
|
||||
const props = {
|
||||
currentLoadingPhrase: 'Processing data...',
|
||||
elapsedTime: 3,
|
||||
};
|
||||
const { lastFrame } = renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
expect(lastFrame()).toContain('Processing data...');
|
||||
});
|
||||
|
||||
it('should display the elapsedTime correctly when Responding', () => {
|
||||
const props = {
|
||||
currentLoadingPhrase: 'Working...',
|
||||
elapsedTime: 60,
|
||||
};
|
||||
const { lastFrame } = renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
expect(lastFrame()).toContain('(esc to cancel, 1m)');
|
||||
});
|
||||
|
||||
it('should display the elapsedTime correctly in human-readable format', () => {
|
||||
const props = {
|
||||
currentLoadingPhrase: 'Working...',
|
||||
elapsedTime: 125,
|
||||
};
|
||||
const { lastFrame } = renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
expect(lastFrame()).toContain('(esc to cancel, 2m 5s)');
|
||||
});
|
||||
|
||||
it('should render rightContent when provided', () => {
|
||||
const rightContent = <Text>Extra Info</Text>;
|
||||
const { lastFrame } = renderWithContext(
|
||||
<LoadingIndicator {...defaultProps} rightContent={rightContent} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
expect(lastFrame()).toContain('Extra Info');
|
||||
});
|
||||
|
||||
it('should transition correctly between states using rerender', () => {
|
||||
const { lastFrame, rerender } = renderWithContext(
|
||||
<LoadingIndicator {...defaultProps} />,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
expect(lastFrame()).toBe(''); // Initial: Idle
|
||||
|
||||
// Transition to Responding
|
||||
rerender(
|
||||
<StreamingContext.Provider value={StreamingState.Responding}>
|
||||
<LoadingIndicator
|
||||
currentLoadingPhrase="Now Responding"
|
||||
elapsedTime={2}
|
||||
/>
|
||||
</StreamingContext.Provider>,
|
||||
);
|
||||
let output = lastFrame();
|
||||
expect(output).toContain('MockRespondingSpinner');
|
||||
expect(output).toContain('Now Responding');
|
||||
expect(output).toContain('(esc to cancel, 2s)');
|
||||
|
||||
// Transition to WaitingForConfirmation
|
||||
rerender(
|
||||
<StreamingContext.Provider value={StreamingState.WaitingForConfirmation}>
|
||||
<LoadingIndicator
|
||||
currentLoadingPhrase="Please Confirm"
|
||||
elapsedTime={15}
|
||||
/>
|
||||
</StreamingContext.Provider>,
|
||||
);
|
||||
output = lastFrame();
|
||||
expect(output).toContain('⠏');
|
||||
expect(output).toContain('Please Confirm');
|
||||
expect(output).not.toContain('(esc to cancel)');
|
||||
expect(output).not.toContain(', 15s');
|
||||
|
||||
// Transition back to Idle
|
||||
rerender(
|
||||
<StreamingContext.Provider value={StreamingState.Idle}>
|
||||
<LoadingIndicator {...defaultProps} />
|
||||
</StreamingContext.Provider>,
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
|
||||
it('should display fallback phrase if thought is empty', () => {
|
||||
const props = {
|
||||
thought: null,
|
||||
currentLoadingPhrase: 'Loading...',
|
||||
elapsedTime: 5,
|
||||
};
|
||||
const { lastFrame } = renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Loading...');
|
||||
});
|
||||
|
||||
it('should display the subject of a thought', () => {
|
||||
const props = {
|
||||
thought: {
|
||||
subject: 'Thinking about something...',
|
||||
description: 'and other stuff.',
|
||||
},
|
||||
elapsedTime: 5,
|
||||
};
|
||||
const { lastFrame } = renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toBeDefined();
|
||||
if (output) {
|
||||
expect(output).toContain('Thinking about something...');
|
||||
expect(output).not.toContain('and other stuff.');
|
||||
}
|
||||
});
|
||||
|
||||
it('should prioritize thought.subject over currentLoadingPhrase', () => {
|
||||
const props = {
|
||||
thought: {
|
||||
subject: 'This should be displayed',
|
||||
description: 'A description',
|
||||
},
|
||||
currentLoadingPhrase: 'This should not be displayed',
|
||||
elapsedTime: 5,
|
||||
};
|
||||
const { lastFrame } = renderWithContext(
|
||||
<LoadingIndicator {...props} />,
|
||||
StreamingState.Responding,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('This should be displayed');
|
||||
expect(output).not.toContain('This should not be displayed');
|
||||
});
|
||||
});
|
||||
61
packages/cli/src/ui/components/LoadingIndicator.tsx
Normal file
61
packages/cli/src/ui/components/LoadingIndicator.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { ThoughtSummary } from '@qwen/qwen-code-core';
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { useStreamingContext } from '../contexts/StreamingContext.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
|
||||
interface LoadingIndicatorProps {
|
||||
currentLoadingPhrase?: string;
|
||||
elapsedTime: number;
|
||||
rightContent?: React.ReactNode;
|
||||
thought?: ThoughtSummary | null;
|
||||
}
|
||||
|
||||
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
currentLoadingPhrase,
|
||||
elapsedTime,
|
||||
rightContent,
|
||||
thought,
|
||||
}) => {
|
||||
const streamingState = useStreamingContext();
|
||||
|
||||
if (streamingState === StreamingState.Idle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const primaryText = thought?.subject || currentLoadingPhrase;
|
||||
|
||||
return (
|
||||
<Box marginTop={1} paddingLeft={0} flexDirection="column">
|
||||
{/* Main loading line */}
|
||||
<Box>
|
||||
<Box marginRight={1}>
|
||||
<GeminiRespondingSpinner
|
||||
nonRespondingDisplay={
|
||||
streamingState === StreamingState.WaitingForConfirmation
|
||||
? '⠏'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
{primaryText && <Text color={Colors.AccentPurple}>{primaryText}</Text>}
|
||||
<Text color={Colors.Gray}>
|
||||
{streamingState === StreamingState.WaitingForConfirmation
|
||||
? ''
|
||||
: ` (esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`}
|
||||
</Text>
|
||||
<Box flexGrow={1}>{/* Spacer */}</Box>
|
||||
{rightContent && <Box>{rightContent}</Box>}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
36
packages/cli/src/ui/components/MemoryUsageDisplay.tsx
Normal file
36
packages/cli/src/ui/components/MemoryUsageDisplay.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import process from 'node:process';
|
||||
import { formatMemoryUsage } from '../utils/formatters.js';
|
||||
|
||||
export const MemoryUsageDisplay: React.FC = () => {
|
||||
const [memoryUsage, setMemoryUsage] = useState<string>('');
|
||||
const [memoryUsageColor, setMemoryUsageColor] = useState<string>(Colors.Gray);
|
||||
|
||||
useEffect(() => {
|
||||
const updateMemory = () => {
|
||||
const usage = process.memoryUsage().rss;
|
||||
setMemoryUsage(formatMemoryUsage(usage));
|
||||
setMemoryUsageColor(
|
||||
usage >= 2 * 1024 * 1024 * 1024 ? Colors.AccentRed : Colors.Gray,
|
||||
);
|
||||
};
|
||||
const intervalId = setInterval(updateMemory, 2000);
|
||||
updateMemory(); // Initial update
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={Colors.Gray}>| </Text>
|
||||
<Text color={memoryUsageColor}>{memoryUsage}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
239
packages/cli/src/ui/components/ModelStatsDisplay.test.tsx
Normal file
239
packages/cli/src/ui/components/ModelStatsDisplay.test.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* @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 { ModelStatsDisplay } from './ModelStatsDisplay.js';
|
||||
import * as SessionContext from '../contexts/SessionContext.js';
|
||||
import { SessionMetrics } from '../contexts/SessionContext.js';
|
||||
|
||||
// Mock the context to provide controlled data for testing
|
||||
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof SessionContext>();
|
||||
return {
|
||||
...actual,
|
||||
useSessionStats: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
|
||||
|
||||
const renderWithMockedStats = (metrics: SessionMetrics) => {
|
||||
useSessionStatsMock.mockReturnValue({
|
||||
stats: {
|
||||
sessionStartTime: new Date(),
|
||||
metrics,
|
||||
lastPromptTokenCount: 0,
|
||||
promptCount: 5,
|
||||
},
|
||||
|
||||
getPromptCount: () => 5,
|
||||
startNewPrompt: vi.fn(),
|
||||
});
|
||||
|
||||
return render(<ModelStatsDisplay />);
|
||||
};
|
||||
|
||||
describe('<ModelStatsDisplay />', () => {
|
||||
it('should render "no API calls" message when there are no active models', () => {
|
||||
const { lastFrame } = renderWithMockedStats({
|
||||
models: {},
|
||||
tools: {
|
||||
totalCalls: 0,
|
||||
totalSuccess: 0,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 0,
|
||||
totalDecisions: { accept: 0, reject: 0, modify: 0 },
|
||||
byName: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain(
|
||||
'No API calls have been made in this session.',
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should not display conditional rows if no model has data for them', () => {
|
||||
const { lastFrame } = renderWithMockedStats({
|
||||
models: {
|
||||
'gemini-2.5-pro': {
|
||||
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
|
||||
tokens: {
|
||||
prompt: 10,
|
||||
candidates: 20,
|
||||
total: 30,
|
||||
cached: 0,
|
||||
thoughts: 0,
|
||||
tool: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
totalCalls: 0,
|
||||
totalSuccess: 0,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 0,
|
||||
totalDecisions: { accept: 0, reject: 0, modify: 0 },
|
||||
byName: {},
|
||||
},
|
||||
});
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('Cached');
|
||||
expect(output).not.toContain('Thoughts');
|
||||
expect(output).not.toContain('Tool');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should display conditional rows if at least one model has data', () => {
|
||||
const { lastFrame } = renderWithMockedStats({
|
||||
models: {
|
||||
'gemini-2.5-pro': {
|
||||
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
|
||||
tokens: {
|
||||
prompt: 10,
|
||||
candidates: 20,
|
||||
total: 30,
|
||||
cached: 5,
|
||||
thoughts: 2,
|
||||
tool: 0,
|
||||
},
|
||||
},
|
||||
'gemini-2.5-flash': {
|
||||
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 50 },
|
||||
tokens: {
|
||||
prompt: 5,
|
||||
candidates: 10,
|
||||
total: 15,
|
||||
cached: 0,
|
||||
thoughts: 0,
|
||||
tool: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
totalCalls: 0,
|
||||
totalSuccess: 0,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 0,
|
||||
totalDecisions: { accept: 0, reject: 0, modify: 0 },
|
||||
byName: {},
|
||||
},
|
||||
});
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Cached');
|
||||
expect(output).toContain('Thoughts');
|
||||
expect(output).toContain('Tool');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should display stats for multiple models correctly', () => {
|
||||
const { lastFrame } = renderWithMockedStats({
|
||||
models: {
|
||||
'gemini-2.5-pro': {
|
||||
api: { totalRequests: 10, totalErrors: 1, totalLatencyMs: 1000 },
|
||||
tokens: {
|
||||
prompt: 100,
|
||||
candidates: 200,
|
||||
total: 300,
|
||||
cached: 50,
|
||||
thoughts: 10,
|
||||
tool: 5,
|
||||
},
|
||||
},
|
||||
'gemini-2.5-flash': {
|
||||
api: { totalRequests: 20, totalErrors: 2, totalLatencyMs: 500 },
|
||||
tokens: {
|
||||
prompt: 200,
|
||||
candidates: 400,
|
||||
total: 600,
|
||||
cached: 100,
|
||||
thoughts: 20,
|
||||
tool: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
totalCalls: 0,
|
||||
totalSuccess: 0,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 0,
|
||||
totalDecisions: { accept: 0, reject: 0, modify: 0 },
|
||||
byName: {},
|
||||
},
|
||||
});
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('gemini-2.5-pro');
|
||||
expect(output).toContain('gemini-2.5-flash');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should handle large values without wrapping or overlapping', () => {
|
||||
const { lastFrame } = renderWithMockedStats({
|
||||
models: {
|
||||
'gemini-2.5-pro': {
|
||||
api: {
|
||||
totalRequests: 999999999,
|
||||
totalErrors: 123456789,
|
||||
totalLatencyMs: 9876,
|
||||
},
|
||||
tokens: {
|
||||
prompt: 987654321,
|
||||
candidates: 123456789,
|
||||
total: 999999999,
|
||||
cached: 123456789,
|
||||
thoughts: 111111111,
|
||||
tool: 222222222,
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
totalCalls: 0,
|
||||
totalSuccess: 0,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 0,
|
||||
totalDecisions: { accept: 0, reject: 0, modify: 0 },
|
||||
byName: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should display a single model correctly', () => {
|
||||
const { lastFrame } = renderWithMockedStats({
|
||||
models: {
|
||||
'gemini-2.5-pro': {
|
||||
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
|
||||
tokens: {
|
||||
prompt: 10,
|
||||
candidates: 20,
|
||||
total: 30,
|
||||
cached: 5,
|
||||
thoughts: 2,
|
||||
tool: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
totalCalls: 0,
|
||||
totalSuccess: 0,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 0,
|
||||
totalDecisions: { accept: 0, reject: 0, modify: 0 },
|
||||
byName: {},
|
||||
},
|
||||
});
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('gemini-2.5-pro');
|
||||
expect(output).not.toContain('gemini-2.5-flash');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
197
packages/cli/src/ui/components/ModelStatsDisplay.tsx
Normal file
197
packages/cli/src/ui/components/ModelStatsDisplay.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
import {
|
||||
calculateAverageLatency,
|
||||
calculateCacheHitRate,
|
||||
calculateErrorRate,
|
||||
} from '../utils/computeStats.js';
|
||||
import { useSessionStats, ModelMetrics } from '../contexts/SessionContext.js';
|
||||
|
||||
const METRIC_COL_WIDTH = 28;
|
||||
const MODEL_COL_WIDTH = 22;
|
||||
|
||||
interface StatRowProps {
|
||||
title: string;
|
||||
values: Array<string | React.ReactElement>;
|
||||
isSubtle?: boolean;
|
||||
isSection?: boolean;
|
||||
}
|
||||
|
||||
const StatRow: React.FC<StatRowProps> = ({
|
||||
title,
|
||||
values,
|
||||
isSubtle = false,
|
||||
isSection = false,
|
||||
}) => (
|
||||
<Box>
|
||||
<Box width={METRIC_COL_WIDTH}>
|
||||
<Text bold={isSection} color={isSection ? undefined : Colors.LightBlue}>
|
||||
{isSubtle ? ` ↳ ${title}` : title}
|
||||
</Text>
|
||||
</Box>
|
||||
{values.map((value, index) => (
|
||||
<Box width={MODEL_COL_WIDTH} key={index}>
|
||||
<Text>{value}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
export const ModelStatsDisplay: React.FC = () => {
|
||||
const { stats } = useSessionStats();
|
||||
const { models } = stats.metrics;
|
||||
const activeModels = Object.entries(models).filter(
|
||||
([, metrics]) => metrics.api.totalRequests > 0,
|
||||
);
|
||||
|
||||
if (activeModels.length === 0) {
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.Gray}
|
||||
paddingY={1}
|
||||
paddingX={2}
|
||||
>
|
||||
<Text>No API calls have been made in this session.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const modelNames = activeModels.map(([name]) => name);
|
||||
|
||||
const getModelValues = (
|
||||
getter: (metrics: ModelMetrics) => string | React.ReactElement,
|
||||
) => activeModels.map(([, metrics]) => getter(metrics));
|
||||
|
||||
const hasThoughts = activeModels.some(
|
||||
([, metrics]) => metrics.tokens.thoughts > 0,
|
||||
);
|
||||
const hasTool = activeModels.some(([, metrics]) => metrics.tokens.tool > 0);
|
||||
const hasCached = activeModels.some(
|
||||
([, metrics]) => metrics.tokens.cached > 0,
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.Gray}
|
||||
flexDirection="column"
|
||||
paddingY={1}
|
||||
paddingX={2}
|
||||
>
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
Model Stats For Nerds
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
|
||||
{/* Header */}
|
||||
<Box>
|
||||
<Box width={METRIC_COL_WIDTH}>
|
||||
<Text bold>Metric</Text>
|
||||
</Box>
|
||||
{modelNames.map((name) => (
|
||||
<Box width={MODEL_COL_WIDTH} key={name}>
|
||||
<Text bold>{name}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Divider */}
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderBottom={true}
|
||||
borderTop={false}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
/>
|
||||
|
||||
{/* API Section */}
|
||||
<StatRow title="API" values={[]} isSection />
|
||||
<StatRow
|
||||
title="Requests"
|
||||
values={getModelValues((m) => m.api.totalRequests.toLocaleString())}
|
||||
/>
|
||||
<StatRow
|
||||
title="Errors"
|
||||
values={getModelValues((m) => {
|
||||
const errorRate = calculateErrorRate(m);
|
||||
return (
|
||||
<Text
|
||||
color={
|
||||
m.api.totalErrors > 0 ? Colors.AccentRed : Colors.Foreground
|
||||
}
|
||||
>
|
||||
{m.api.totalErrors.toLocaleString()} ({errorRate.toFixed(1)}%)
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
/>
|
||||
<StatRow
|
||||
title="Avg Latency"
|
||||
values={getModelValues((m) => {
|
||||
const avgLatency = calculateAverageLatency(m);
|
||||
return formatDuration(avgLatency);
|
||||
})}
|
||||
/>
|
||||
|
||||
<Box height={1} />
|
||||
|
||||
{/* Tokens Section */}
|
||||
<StatRow title="Tokens" values={[]} isSection />
|
||||
<StatRow
|
||||
title="Total"
|
||||
values={getModelValues((m) => (
|
||||
<Text color={Colors.AccentYellow}>
|
||||
{m.tokens.total.toLocaleString()}
|
||||
</Text>
|
||||
))}
|
||||
/>
|
||||
<StatRow
|
||||
title="Prompt"
|
||||
isSubtle
|
||||
values={getModelValues((m) => m.tokens.prompt.toLocaleString())}
|
||||
/>
|
||||
{hasCached && (
|
||||
<StatRow
|
||||
title="Cached"
|
||||
isSubtle
|
||||
values={getModelValues((m) => {
|
||||
const cacheHitRate = calculateCacheHitRate(m);
|
||||
return (
|
||||
<Text color={Colors.AccentGreen}>
|
||||
{m.tokens.cached.toLocaleString()} ({cacheHitRate.toFixed(1)}%)
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{hasThoughts && (
|
||||
<StatRow
|
||||
title="Thoughts"
|
||||
isSubtle
|
||||
values={getModelValues((m) => m.tokens.thoughts.toLocaleString())}
|
||||
/>
|
||||
)}
|
||||
{hasTool && (
|
||||
<StatRow
|
||||
title="Tool"
|
||||
isSubtle
|
||||
values={getModelValues((m) => m.tokens.tool.toLocaleString())}
|
||||
/>
|
||||
)}
|
||||
<StatRow
|
||||
title="Output"
|
||||
isSubtle
|
||||
values={getModelValues((m) => m.tokens.candidates.toLocaleString())}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
64
packages/cli/src/ui/components/OpenAIKeyPrompt.test.tsx
Normal file
64
packages/cli/src/ui/components/OpenAIKeyPrompt.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @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 { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
|
||||
|
||||
describe('OpenAIKeyPrompt', () => {
|
||||
it('should render the prompt correctly', () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<OpenAIKeyPrompt onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('OpenAI Configuration Required');
|
||||
expect(lastFrame()).toContain('https://platform.openai.com/api-keys');
|
||||
expect(lastFrame()).toContain(
|
||||
'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show the component with proper styling', () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<OpenAIKeyPrompt onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('OpenAI Configuration Required');
|
||||
expect(output).toContain('API Key:');
|
||||
expect(output).toContain('Base URL:');
|
||||
expect(output).toContain('Model:');
|
||||
expect(output).toContain(
|
||||
'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle paste with control characters', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { stdin } = render(
|
||||
<OpenAIKeyPrompt onSubmit={onSubmit} onCancel={onCancel} />,
|
||||
);
|
||||
|
||||
// Simulate paste with control characters
|
||||
const pasteWithControlChars = '\x1b[200~sk-test123\x1b[201~';
|
||||
stdin.write(pasteWithControlChars);
|
||||
|
||||
// Wait a bit for processing
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// The component should have filtered out the control characters
|
||||
// and only kept 'sk-test123'
|
||||
expect(onSubmit).not.toHaveBeenCalled(); // Should not submit yet
|
||||
});
|
||||
});
|
||||
197
packages/cli/src/ui/components/OpenAIKeyPrompt.tsx
Normal file
197
packages/cli/src/ui/components/OpenAIKeyPrompt.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
|
||||
interface OpenAIKeyPromptProps {
|
||||
onSubmit: (apiKey: string, baseUrl: string, model: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function OpenAIKeyPrompt({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: OpenAIKeyPromptProps): React.JSX.Element {
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [baseUrl, setBaseUrl] = useState('');
|
||||
const [model, setModel] = useState('');
|
||||
const [currentField, setCurrentField] = useState<
|
||||
'apiKey' | 'baseUrl' | 'model'
|
||||
>('apiKey');
|
||||
|
||||
useInput((input, key) => {
|
||||
// 过滤粘贴相关的控制序列
|
||||
let cleanInput = (input || '')
|
||||
// 过滤 ESC 开头的控制序列(如 \u001b[200~、\u001b[201~ 等)
|
||||
.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, '') // eslint-disable-line no-control-regex
|
||||
// 过滤粘贴开始标记 [200~
|
||||
.replace(/\[200~/g, '')
|
||||
// 过滤粘贴结束标记 [201~
|
||||
.replace(/\[201~/g, '')
|
||||
// 过滤单独的 [ 和 ~ 字符(可能是粘贴标记的残留)
|
||||
.replace(/^\[|~$/g, '');
|
||||
|
||||
// 再过滤所有不可见字符(ASCII < 32,除了回车换行)
|
||||
cleanInput = cleanInput
|
||||
.split('')
|
||||
.filter((ch) => ch.charCodeAt(0) >= 32)
|
||||
.join('');
|
||||
|
||||
if (cleanInput.length > 0) {
|
||||
if (currentField === 'apiKey') {
|
||||
setApiKey((prev) => prev + cleanInput);
|
||||
} else if (currentField === 'baseUrl') {
|
||||
setBaseUrl((prev) => prev + cleanInput);
|
||||
} else if (currentField === 'model') {
|
||||
setModel((prev) => prev + cleanInput);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否是 Enter 键(通过检查输入是否包含换行符)
|
||||
if (input.includes('\n') || input.includes('\r')) {
|
||||
if (currentField === 'apiKey') {
|
||||
// 允许空 API key 跳转到下一个字段,让用户稍后可以返回修改
|
||||
setCurrentField('baseUrl');
|
||||
return;
|
||||
} else if (currentField === 'baseUrl') {
|
||||
setCurrentField('model');
|
||||
return;
|
||||
} else if (currentField === 'model') {
|
||||
// 只有在提交时才检查 API key 是否为空
|
||||
if (apiKey.trim()) {
|
||||
onSubmit(apiKey.trim(), baseUrl.trim(), model.trim());
|
||||
} else {
|
||||
// 如果 API key 为空,回到 API key 字段
|
||||
setCurrentField('apiKey');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Tab key for field navigation
|
||||
if (key.tab) {
|
||||
if (currentField === 'apiKey') {
|
||||
setCurrentField('baseUrl');
|
||||
} else if (currentField === 'baseUrl') {
|
||||
setCurrentField('model');
|
||||
} else if (currentField === 'model') {
|
||||
setCurrentField('apiKey');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle arrow keys for field navigation
|
||||
if (key.upArrow) {
|
||||
if (currentField === 'baseUrl') {
|
||||
setCurrentField('apiKey');
|
||||
} else if (currentField === 'model') {
|
||||
setCurrentField('baseUrl');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.downArrow) {
|
||||
if (currentField === 'apiKey') {
|
||||
setCurrentField('baseUrl');
|
||||
} else if (currentField === 'baseUrl') {
|
||||
setCurrentField('model');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle backspace - check both key.backspace and delete key
|
||||
if (key.backspace || key.delete) {
|
||||
if (currentField === 'apiKey') {
|
||||
setApiKey((prev) => prev.slice(0, -1));
|
||||
} else if (currentField === 'baseUrl') {
|
||||
setBaseUrl((prev) => prev.slice(0, -1));
|
||||
} else if (currentField === 'model') {
|
||||
setModel((prev) => prev.slice(0, -1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentBlue}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={Colors.AccentBlue}>
|
||||
OpenAI Configuration Required
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
Please enter your OpenAI configuration. You can get an API key from{' '}
|
||||
<Text color={Colors.AccentBlue}>
|
||||
https://platform.openai.com/api-keys
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="row">
|
||||
<Box width={12}>
|
||||
<Text
|
||||
color={currentField === 'apiKey' ? Colors.AccentBlue : Colors.Gray}
|
||||
>
|
||||
API Key:
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text>
|
||||
{currentField === 'apiKey' ? '> ' : ' '}
|
||||
{apiKey || ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="row">
|
||||
<Box width={12}>
|
||||
<Text
|
||||
color={currentField === 'baseUrl' ? Colors.AccentBlue : Colors.Gray}
|
||||
>
|
||||
Base URL:
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text>
|
||||
{currentField === 'baseUrl' ? '> ' : ' '}
|
||||
{baseUrl}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="row">
|
||||
<Box width={12}>
|
||||
<Text
|
||||
color={currentField === 'model' ? Colors.AccentBlue : Colors.Gray}
|
||||
>
|
||||
Model:
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text>
|
||||
{currentField === 'model' ? '> ' : ' '}
|
||||
{model}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @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 { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
|
||||
import * as SessionContext from '../contexts/SessionContext.js';
|
||||
import { SessionMetrics } from '../contexts/SessionContext.js';
|
||||
|
||||
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof SessionContext>();
|
||||
return {
|
||||
...actual,
|
||||
useSessionStats: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
|
||||
|
||||
const renderWithMockedStats = (metrics: SessionMetrics) => {
|
||||
useSessionStatsMock.mockReturnValue({
|
||||
stats: {
|
||||
sessionStartTime: new Date(),
|
||||
metrics,
|
||||
lastPromptTokenCount: 0,
|
||||
promptCount: 5,
|
||||
},
|
||||
|
||||
getPromptCount: () => 5,
|
||||
startNewPrompt: vi.fn(),
|
||||
});
|
||||
|
||||
return render(<SessionSummaryDisplay duration="1h 23m 45s" />);
|
||||
};
|
||||
|
||||
describe('<SessionSummaryDisplay />', () => {
|
||||
it('renders the summary display with a title', () => {
|
||||
const metrics: SessionMetrics = {
|
||||
models: {
|
||||
'gemini-2.5-pro': {
|
||||
api: { totalRequests: 10, totalErrors: 1, totalLatencyMs: 50234 },
|
||||
tokens: {
|
||||
prompt: 1000,
|
||||
candidates: 2000,
|
||||
total: 3500,
|
||||
cached: 500,
|
||||
thoughts: 300,
|
||||
tool: 200,
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
totalCalls: 0,
|
||||
totalSuccess: 0,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 0,
|
||||
totalDecisions: { accept: 0, reject: 0, modify: 0 },
|
||||
byName: {},
|
||||
},
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithMockedStats(metrics);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Agent powering down. Goodbye!');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
18
packages/cli/src/ui/components/SessionSummaryDisplay.tsx
Normal file
18
packages/cli/src/ui/components/SessionSummaryDisplay.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { StatsDisplay } from './StatsDisplay.js';
|
||||
|
||||
interface SessionSummaryDisplayProps {
|
||||
duration: string;
|
||||
}
|
||||
|
||||
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
|
||||
duration,
|
||||
}) => (
|
||||
<StatsDisplay title="Agent powering down. Goodbye!" duration={duration} />
|
||||
);
|
||||
18
packages/cli/src/ui/components/ShellModeIndicator.tsx
Normal file
18
packages/cli/src/ui/components/ShellModeIndicator.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
|
||||
export const ShellModeIndicator: React.FC = () => (
|
||||
<Box>
|
||||
<Text color={Colors.AccentYellow}>
|
||||
shell mode enabled
|
||||
<Text color={Colors.Gray}> (esc to disable)</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
40
packages/cli/src/ui/components/ShowMoreLines.tsx
Normal file
40
packages/cli/src/ui/components/ShowMoreLines.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { useOverflowState } from '../contexts/OverflowContext.js';
|
||||
import { useStreamingContext } from '../contexts/StreamingContext.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { Colors } from '../colors.js';
|
||||
|
||||
interface ShowMoreLinesProps {
|
||||
constrainHeight: boolean;
|
||||
}
|
||||
|
||||
export const ShowMoreLines = ({ constrainHeight }: ShowMoreLinesProps) => {
|
||||
const overflowState = useOverflowState();
|
||||
const streamingState = useStreamingContext();
|
||||
|
||||
if (
|
||||
overflowState === undefined ||
|
||||
overflowState.overflowingIds.size === 0 ||
|
||||
!constrainHeight ||
|
||||
!(
|
||||
streamingState === StreamingState.Idle ||
|
||||
streamingState === StreamingState.WaitingForConfirmation
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={Colors.Gray} wrap="truncate">
|
||||
Press ctrl-s to show more lines
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
311
packages/cli/src/ui/components/StatsDisplay.test.tsx
Normal file
311
packages/cli/src/ui/components/StatsDisplay.test.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* @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 { StatsDisplay } from './StatsDisplay.js';
|
||||
import * as SessionContext from '../contexts/SessionContext.js';
|
||||
import { SessionMetrics } from '../contexts/SessionContext.js';
|
||||
|
||||
// Mock the context to provide controlled data for testing
|
||||
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof SessionContext>();
|
||||
return {
|
||||
...actual,
|
||||
useSessionStats: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
|
||||
|
||||
const renderWithMockedStats = (metrics: SessionMetrics) => {
|
||||
useSessionStatsMock.mockReturnValue({
|
||||
stats: {
|
||||
sessionStartTime: new Date(),
|
||||
metrics,
|
||||
lastPromptTokenCount: 0,
|
||||
promptCount: 5,
|
||||
},
|
||||
|
||||
getPromptCount: () => 5,
|
||||
startNewPrompt: vi.fn(),
|
||||
});
|
||||
|
||||
return render(<StatsDisplay duration="1s" />);
|
||||
};
|
||||
|
||||
describe('<StatsDisplay />', () => {
|
||||
it('renders only the Performance section in its zero state', () => {
|
||||
const zeroMetrics: SessionMetrics = {
|
||||
models: {},
|
||||
tools: {
|
||||
totalCalls: 0,
|
||||
totalSuccess: 0,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 0,
|
||||
totalDecisions: { accept: 0, reject: 0, modify: 0 },
|
||||
byName: {},
|
||||
},
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithMockedStats(zeroMetrics);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Performance');
|
||||
expect(output).not.toContain('Interaction Summary');
|
||||
expect(output).not.toContain('Efficiency & Optimizations');
|
||||
expect(output).not.toContain('Model'); // The table header
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders a table with two models correctly', () => {
|
||||
const metrics: SessionMetrics = {
|
||||
models: {
|
||||
'gemini-2.5-pro': {
|
||||
api: { totalRequests: 3, totalErrors: 0, totalLatencyMs: 15000 },
|
||||
tokens: {
|
||||
prompt: 1000,
|
||||
candidates: 2000,
|
||||
total: 43234,
|
||||
cached: 500,
|
||||
thoughts: 100,
|
||||
tool: 50,
|
||||
},
|
||||
},
|
||||
'gemini-2.5-flash': {
|
||||
api: { totalRequests: 5, totalErrors: 1, totalLatencyMs: 4500 },
|
||||
tokens: {
|
||||
prompt: 25000,
|
||||
candidates: 15000,
|
||||
total: 150000000,
|
||||
cached: 10000,
|
||||
thoughts: 2000,
|
||||
tool: 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
totalCalls: 0,
|
||||
totalSuccess: 0,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 0,
|
||||
totalDecisions: { accept: 0, reject: 0, modify: 0 },
|
||||
byName: {},
|
||||
},
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithMockedStats(metrics);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('gemini-2.5-pro');
|
||||
expect(output).toContain('gemini-2.5-flash');
|
||||
expect(output).toContain('1,000');
|
||||
expect(output).toContain('25,000');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders all sections when all data is present', () => {
|
||||
const metrics: SessionMetrics = {
|
||||
models: {
|
||||
'gemini-2.5-pro': {
|
||||
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
|
||||
tokens: {
|
||||
prompt: 100,
|
||||
candidates: 100,
|
||||
total: 250,
|
||||
cached: 50,
|
||||
thoughts: 0,
|
||||
tool: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
totalCalls: 2,
|
||||
totalSuccess: 1,
|
||||
totalFail: 1,
|
||||
totalDurationMs: 123,
|
||||
totalDecisions: { accept: 1, reject: 0, modify: 0 },
|
||||
byName: {
|
||||
'test-tool': {
|
||||
count: 2,
|
||||
success: 1,
|
||||
fail: 1,
|
||||
durationMs: 123,
|
||||
decisions: { accept: 1, reject: 0, modify: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithMockedStats(metrics);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Performance');
|
||||
expect(output).toContain('Interaction Summary');
|
||||
expect(output).toContain('User Agreement');
|
||||
expect(output).toContain('Savings Highlight');
|
||||
expect(output).toContain('gemini-2.5-pro');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('Conditional Rendering Tests', () => {
|
||||
it('hides User Agreement when no decisions are made', () => {
|
||||
const metrics: SessionMetrics = {
|
||||
models: {},
|
||||
tools: {
|
||||
totalCalls: 2,
|
||||
totalSuccess: 1,
|
||||
totalFail: 1,
|
||||
totalDurationMs: 123,
|
||||
totalDecisions: { accept: 0, reject: 0, modify: 0 }, // No decisions
|
||||
byName: {
|
||||
'test-tool': {
|
||||
count: 2,
|
||||
success: 1,
|
||||
fail: 1,
|
||||
durationMs: 123,
|
||||
decisions: { accept: 0, reject: 0, modify: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithMockedStats(metrics);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Interaction Summary');
|
||||
expect(output).toContain('Success Rate');
|
||||
expect(output).not.toContain('User Agreement');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('hides Efficiency section when cache is not used', () => {
|
||||
const metrics: SessionMetrics = {
|
||||
models: {
|
||||
'gemini-2.5-pro': {
|
||||
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
|
||||
tokens: {
|
||||
prompt: 100,
|
||||
candidates: 100,
|
||||
total: 200,
|
||||
cached: 0,
|
||||
thoughts: 0,
|
||||
tool: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
totalCalls: 0,
|
||||
totalSuccess: 0,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 0,
|
||||
totalDecisions: { accept: 0, reject: 0, modify: 0 },
|
||||
byName: {},
|
||||
},
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithMockedStats(metrics);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).not.toContain('Efficiency & Optimizations');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Conditional Color Tests', () => {
|
||||
it('renders success rate in green for high values', () => {
|
||||
const metrics: SessionMetrics = {
|
||||
models: {},
|
||||
tools: {
|
||||
totalCalls: 10,
|
||||
totalSuccess: 10,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 0,
|
||||
totalDecisions: { accept: 0, reject: 0, modify: 0 },
|
||||
byName: {},
|
||||
},
|
||||
};
|
||||
const { lastFrame } = renderWithMockedStats(metrics);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders success rate in yellow for medium values', () => {
|
||||
const metrics: SessionMetrics = {
|
||||
models: {},
|
||||
tools: {
|
||||
totalCalls: 10,
|
||||
totalSuccess: 9,
|
||||
totalFail: 1,
|
||||
totalDurationMs: 0,
|
||||
totalDecisions: { accept: 0, reject: 0, modify: 0 },
|
||||
byName: {},
|
||||
},
|
||||
};
|
||||
const { lastFrame } = renderWithMockedStats(metrics);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders success rate in red for low values', () => {
|
||||
const metrics: SessionMetrics = {
|
||||
models: {},
|
||||
tools: {
|
||||
totalCalls: 10,
|
||||
totalSuccess: 5,
|
||||
totalFail: 5,
|
||||
totalDurationMs: 0,
|
||||
totalDecisions: { accept: 0, reject: 0, modify: 0 },
|
||||
byName: {},
|
||||
},
|
||||
};
|
||||
const { lastFrame } = renderWithMockedStats(metrics);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Title Rendering', () => {
|
||||
const zeroMetrics: SessionMetrics = {
|
||||
models: {},
|
||||
tools: {
|
||||
totalCalls: 0,
|
||||
totalSuccess: 0,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 0,
|
||||
totalDecisions: { accept: 0, reject: 0, modify: 0 },
|
||||
byName: {},
|
||||
},
|
||||
};
|
||||
|
||||
it('renders the default title when no title prop is provided', () => {
|
||||
const { lastFrame } = renderWithMockedStats(zeroMetrics);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Session Stats');
|
||||
expect(output).not.toContain('Agent powering down');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders the custom title when a title prop is provided', () => {
|
||||
useSessionStatsMock.mockReturnValue({
|
||||
stats: {
|
||||
sessionStartTime: new Date(),
|
||||
metrics: zeroMetrics,
|
||||
lastPromptTokenCount: 0,
|
||||
promptCount: 5,
|
||||
},
|
||||
|
||||
getPromptCount: () => 5,
|
||||
startNewPrompt: vi.fn(),
|
||||
});
|
||||
|
||||
const { lastFrame } = render(
|
||||
<StatsDisplay duration="1s" title="Agent powering down. Goodbye!" />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Agent powering down. Goodbye!');
|
||||
expect(output).not.toContain('Session Stats');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
259
packages/cli/src/ui/components/StatsDisplay.tsx
Normal file
259
packages/cli/src/ui/components/StatsDisplay.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import Gradient from 'ink-gradient';
|
||||
import { Colors } from '../colors.js';
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
import { useSessionStats, ModelMetrics } from '../contexts/SessionContext.js';
|
||||
import {
|
||||
getStatusColor,
|
||||
TOOL_SUCCESS_RATE_HIGH,
|
||||
TOOL_SUCCESS_RATE_MEDIUM,
|
||||
USER_AGREEMENT_RATE_HIGH,
|
||||
USER_AGREEMENT_RATE_MEDIUM,
|
||||
} from '../utils/displayUtils.js';
|
||||
import { computeSessionStats } from '../utils/computeStats.js';
|
||||
|
||||
// A more flexible and powerful StatRow component
|
||||
interface StatRowProps {
|
||||
title: string;
|
||||
children: React.ReactNode; // Use children to allow for complex, colored values
|
||||
}
|
||||
|
||||
const StatRow: React.FC<StatRowProps> = ({ title, children }) => (
|
||||
<Box>
|
||||
{/* Fixed width for the label creates a clean "gutter" for alignment */}
|
||||
<Box width={28}>
|
||||
<Text color={Colors.LightBlue}>{title}</Text>
|
||||
</Box>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
// A SubStatRow for indented, secondary information
|
||||
interface SubStatRowProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const SubStatRow: React.FC<SubStatRowProps> = ({ title, children }) => (
|
||||
<Box paddingLeft={2}>
|
||||
{/* Adjust width for the "» " prefix */}
|
||||
<Box width={26}>
|
||||
<Text>» {title}</Text>
|
||||
</Box>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
// A Section component to group related stats
|
||||
interface SectionProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Section: React.FC<SectionProps> = ({ title, children }) => (
|
||||
<Box flexDirection="column" width="100%" marginBottom={1}>
|
||||
<Text bold>{title}</Text>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const ModelUsageTable: React.FC<{
|
||||
models: Record<string, ModelMetrics>;
|
||||
totalCachedTokens: number;
|
||||
cacheEfficiency: number;
|
||||
}> = ({ models, totalCachedTokens, cacheEfficiency }) => {
|
||||
const nameWidth = 25;
|
||||
const requestsWidth = 8;
|
||||
const inputTokensWidth = 15;
|
||||
const outputTokensWidth = 15;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{/* Header */}
|
||||
<Box>
|
||||
<Box width={nameWidth}>
|
||||
<Text bold>Model Usage</Text>
|
||||
</Box>
|
||||
<Box width={requestsWidth} justifyContent="flex-end">
|
||||
<Text bold>Reqs</Text>
|
||||
</Box>
|
||||
<Box width={inputTokensWidth} justifyContent="flex-end">
|
||||
<Text bold>Input Tokens</Text>
|
||||
</Box>
|
||||
<Box width={outputTokensWidth} justifyContent="flex-end">
|
||||
<Text bold>Output Tokens</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{/* Divider */}
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderBottom={true}
|
||||
borderTop={false}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
width={nameWidth + requestsWidth + inputTokensWidth + outputTokensWidth}
|
||||
></Box>
|
||||
|
||||
{/* Rows */}
|
||||
{Object.entries(models).map(([name, modelMetrics]) => (
|
||||
<Box key={name}>
|
||||
<Box width={nameWidth}>
|
||||
<Text>{name.replace('-001', '')}</Text>
|
||||
</Box>
|
||||
<Box width={requestsWidth} justifyContent="flex-end">
|
||||
<Text>{modelMetrics.api.totalRequests}</Text>
|
||||
</Box>
|
||||
<Box width={inputTokensWidth} justifyContent="flex-end">
|
||||
<Text color={Colors.AccentYellow}>
|
||||
{modelMetrics.tokens.prompt.toLocaleString()}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={outputTokensWidth} justifyContent="flex-end">
|
||||
<Text color={Colors.AccentYellow}>
|
||||
{modelMetrics.tokens.candidates.toLocaleString()}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
{cacheEfficiency > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text>
|
||||
<Text color={Colors.AccentGreen}>Savings Highlight:</Text>{' '}
|
||||
{totalCachedTokens.toLocaleString()} ({cacheEfficiency.toFixed(1)}
|
||||
%) of input tokens were served from the cache, reducing costs.
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<Text color={Colors.Gray}>
|
||||
» Tip: For a full token breakdown, run `/stats model`.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface StatsDisplayProps {
|
||||
duration: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
duration,
|
||||
title,
|
||||
}) => {
|
||||
const { stats } = useSessionStats();
|
||||
const { metrics } = stats;
|
||||
const { models, tools } = metrics;
|
||||
const computed = computeSessionStats(metrics);
|
||||
|
||||
const successThresholds = {
|
||||
green: TOOL_SUCCESS_RATE_HIGH,
|
||||
yellow: TOOL_SUCCESS_RATE_MEDIUM,
|
||||
};
|
||||
const agreementThresholds = {
|
||||
green: USER_AGREEMENT_RATE_HIGH,
|
||||
yellow: USER_AGREEMENT_RATE_MEDIUM,
|
||||
};
|
||||
const successColor = getStatusColor(computed.successRate, successThresholds);
|
||||
const agreementColor = getStatusColor(
|
||||
computed.agreementRate,
|
||||
agreementThresholds,
|
||||
);
|
||||
|
||||
const renderTitle = () => {
|
||||
if (title) {
|
||||
return Colors.GradientColors && Colors.GradientColors.length > 0 ? (
|
||||
<Gradient colors={Colors.GradientColors}>
|
||||
<Text bold>{title}</Text>
|
||||
</Gradient>
|
||||
) : (
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
{title}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
Session Stats
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.Gray}
|
||||
flexDirection="column"
|
||||
paddingY={1}
|
||||
paddingX={2}
|
||||
>
|
||||
{renderTitle()}
|
||||
<Box height={1} />
|
||||
|
||||
{tools.totalCalls > 0 && (
|
||||
<Section title="Interaction Summary">
|
||||
<StatRow title="Tool Calls:">
|
||||
<Text>
|
||||
{tools.totalCalls} ({' '}
|
||||
<Text color={Colors.AccentGreen}>✔ {tools.totalSuccess}</Text>{' '}
|
||||
<Text color={Colors.AccentRed}>✖ {tools.totalFail}</Text> )
|
||||
</Text>
|
||||
</StatRow>
|
||||
<StatRow title="Success Rate:">
|
||||
<Text color={successColor}>{computed.successRate.toFixed(1)}%</Text>
|
||||
</StatRow>
|
||||
{computed.totalDecisions > 0 && (
|
||||
<StatRow title="User Agreement:">
|
||||
<Text color={agreementColor}>
|
||||
{computed.agreementRate.toFixed(1)}%{' '}
|
||||
<Text color={Colors.Gray}>
|
||||
({computed.totalDecisions} reviewed)
|
||||
</Text>
|
||||
</Text>
|
||||
</StatRow>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="Performance">
|
||||
<StatRow title="Wall Time:">
|
||||
<Text>{duration}</Text>
|
||||
</StatRow>
|
||||
<StatRow title="Agent Active:">
|
||||
<Text>{formatDuration(computed.agentActiveTime)}</Text>
|
||||
</StatRow>
|
||||
<SubStatRow title="API Time:">
|
||||
<Text>
|
||||
{formatDuration(computed.totalApiTime)}{' '}
|
||||
<Text color={Colors.Gray}>
|
||||
({computed.apiTimePercent.toFixed(1)}%)
|
||||
</Text>
|
||||
</Text>
|
||||
</SubStatRow>
|
||||
<SubStatRow title="Tool Time:">
|
||||
<Text>
|
||||
{formatDuration(computed.totalToolTime)}{' '}
|
||||
<Text color={Colors.Gray}>
|
||||
({computed.toolTimePercent.toFixed(1)}%)
|
||||
</Text>
|
||||
</Text>
|
||||
</SubStatRow>
|
||||
</Section>
|
||||
|
||||
{Object.keys(models).length > 0 && (
|
||||
<ModelUsageTable
|
||||
models={models}
|
||||
totalCachedTokens={computed.totalCachedTokens}
|
||||
cacheEfficiency={computed.cacheEfficiency}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
93
packages/cli/src/ui/components/SuggestionsDisplay.tsx
Normal file
93
packages/cli/src/ui/components/SuggestionsDisplay.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
export interface Suggestion {
|
||||
label: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
}
|
||||
interface SuggestionsDisplayProps {
|
||||
suggestions: Suggestion[];
|
||||
activeIndex: number;
|
||||
isLoading: boolean;
|
||||
width: number;
|
||||
scrollOffset: number;
|
||||
userInput: string;
|
||||
}
|
||||
|
||||
export const MAX_SUGGESTIONS_TO_SHOW = 8;
|
||||
|
||||
export function SuggestionsDisplay({
|
||||
suggestions,
|
||||
activeIndex,
|
||||
isLoading,
|
||||
width,
|
||||
scrollOffset,
|
||||
userInput,
|
||||
}: SuggestionsDisplayProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box paddingX={1} width={width}>
|
||||
<Text color="gray">Loading suggestions...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
return null; // Don't render anything if there are no suggestions
|
||||
}
|
||||
|
||||
// Calculate the visible slice based on scrollOffset
|
||||
const startIndex = scrollOffset;
|
||||
const endIndex = Math.min(
|
||||
scrollOffset + MAX_SUGGESTIONS_TO_SHOW,
|
||||
suggestions.length,
|
||||
);
|
||||
const visibleSuggestions = suggestions.slice(startIndex, endIndex);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} width={width}>
|
||||
{scrollOffset > 0 && <Text color={Colors.Foreground}>▲</Text>}
|
||||
|
||||
{visibleSuggestions.map((suggestion, index) => {
|
||||
const originalIndex = startIndex + index;
|
||||
const isActive = originalIndex === activeIndex;
|
||||
const textColor = isActive ? Colors.AccentPurple : Colors.Gray;
|
||||
|
||||
return (
|
||||
<Box key={`${suggestion}-${originalIndex}`} width={width}>
|
||||
<Box flexDirection="row">
|
||||
{userInput.startsWith('/') ? (
|
||||
// only use box model for (/) command mode
|
||||
<Box width={20} flexShrink={0}>
|
||||
<Text color={textColor}>{suggestion.label}</Text>
|
||||
</Box>
|
||||
) : (
|
||||
// use regular text for other modes (@ context)
|
||||
<Text color={textColor}>{suggestion.label}</Text>
|
||||
)}
|
||||
{suggestion.description ? (
|
||||
<Box flexGrow={1}>
|
||||
<Text color={textColor} wrap="wrap">
|
||||
{suggestion.description}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{endIndex < suggestions.length && <Text color="gray">▼</Text>}
|
||||
{suggestions.length > MAX_SUGGESTIONS_TO_SHOW && (
|
||||
<Text color="gray">
|
||||
({activeIndex + 1}/{suggestions.length})
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
272
packages/cli/src/ui/components/ThemeDialog.tsx
Normal file
272
packages/cli/src/ui/components/ThemeDialog.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Box, Text, useInput } 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';
|
||||
|
||||
interface ThemeDialogProps {
|
||||
/** Callback function when a theme is selected */
|
||||
onSelect: (themeName: string | undefined, scope: SettingScope) => void;
|
||||
|
||||
/** Callback function when a theme is highlighted */
|
||||
onHighlight: (themeName: string | undefined) => void;
|
||||
/** The settings object */
|
||||
settings: LoadedSettings;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
export function ThemeDialog({
|
||||
onSelect,
|
||||
onHighlight,
|
||||
settings,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
}: ThemeDialogProps): React.JSX.Element {
|
||||
const [selectedScope, setSelectedScope] = useState<SettingScope>(
|
||||
SettingScope.User,
|
||||
);
|
||||
|
||||
// Generate theme items
|
||||
const themeItems = themeManager.getAvailableThemes().map((theme) => {
|
||||
const typeString = theme.type.charAt(0).toUpperCase() + theme.type.slice(1);
|
||||
return {
|
||||
label: theme.name,
|
||||
value: theme.name,
|
||||
themeNameDisplay: theme.name,
|
||||
themeTypeDisplay: typeString,
|
||||
};
|
||||
});
|
||||
const [selectInputKey, setSelectInputKey] = useState(Date.now());
|
||||
|
||||
// Determine which radio button should be initially selected in the theme list
|
||||
// This should reflect the theme *saved* for the selected scope, or the default
|
||||
const initialThemeIndex = themeItems.findIndex(
|
||||
(item) => item.value === (settings.merged.theme || DEFAULT_THEME.name),
|
||||
);
|
||||
|
||||
const scopeItems = [
|
||||
{ label: 'User Settings', value: SettingScope.User },
|
||||
{ label: 'Workspace Settings', value: SettingScope.Workspace },
|
||||
{ label: 'System Settings', value: SettingScope.System },
|
||||
];
|
||||
|
||||
const handleThemeSelect = useCallback(
|
||||
(themeName: string) => {
|
||||
onSelect(themeName, selectedScope);
|
||||
},
|
||||
[onSelect, selectedScope],
|
||||
);
|
||||
|
||||
const handleScopeHighlight = useCallback((scope: SettingScope) => {
|
||||
setSelectedScope(scope);
|
||||
setSelectInputKey(Date.now());
|
||||
}, []);
|
||||
|
||||
const handleScopeSelect = useCallback(
|
||||
(scope: SettingScope) => {
|
||||
handleScopeHighlight(scope);
|
||||
setFocusedSection('theme'); // Reset focus to theme section
|
||||
},
|
||||
[handleScopeHighlight],
|
||||
);
|
||||
|
||||
const [focusedSection, setFocusedSection] = useState<'theme' | 'scope'>(
|
||||
'theme',
|
||||
);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.tab) {
|
||||
setFocusedSection((prev) => (prev === 'theme' ? 'scope' : 'theme'));
|
||||
}
|
||||
if (key.escape) {
|
||||
onSelect(undefined, selectedScope);
|
||||
}
|
||||
});
|
||||
|
||||
const otherScopes = Object.values(SettingScope).filter(
|
||||
(scope) => scope !== selectedScope,
|
||||
);
|
||||
|
||||
const modifiedInOtherScopes = otherScopes.filter(
|
||||
(scope) => settings.forScope(scope).settings.theme !== undefined,
|
||||
);
|
||||
|
||||
let otherScopeModifiedMessage = '';
|
||||
if (modifiedInOtherScopes.length > 0) {
|
||||
const modifiedScopesStr = modifiedInOtherScopes.join(', ');
|
||||
otherScopeModifiedMessage =
|
||||
settings.forScope(selectedScope).settings.theme !== undefined
|
||||
? `(Also modified in ${modifiedScopesStr})`
|
||||
: `(Modified in ${modifiedScopesStr})`;
|
||||
}
|
||||
|
||||
// Constants for calculating preview pane layout.
|
||||
// These values are based on the JSX structure below.
|
||||
const PREVIEW_PANE_WIDTH_PERCENTAGE = 0.55;
|
||||
// A safety margin to prevent text from touching the border.
|
||||
// This is a complete hack unrelated to the 0.9 used in App.tsx
|
||||
const PREVIEW_PANE_WIDTH_SAFETY_MARGIN = 0.9;
|
||||
// Combined horizontal padding from the dialog and preview pane.
|
||||
const TOTAL_HORIZONTAL_PADDING = 4;
|
||||
const colorizeCodeWidth = Math.max(
|
||||
Math.floor(
|
||||
(terminalWidth - TOTAL_HORIZONTAL_PADDING) *
|
||||
PREVIEW_PANE_WIDTH_PERCENTAGE *
|
||||
PREVIEW_PANE_WIDTH_SAFETY_MARGIN,
|
||||
),
|
||||
1,
|
||||
);
|
||||
|
||||
const DIALOG_PADDING = 2;
|
||||
const selectThemeHeight = themeItems.length + 1;
|
||||
const SCOPE_SELECTION_HEIGHT = 4; // Height for the scope selection section + margin.
|
||||
const SPACE_BETWEEN_THEME_SELECTION_AND_APPLY_TO = 1;
|
||||
const TAB_TO_SELECT_HEIGHT = 2;
|
||||
availableTerminalHeight = availableTerminalHeight ?? Number.MAX_SAFE_INTEGER;
|
||||
availableTerminalHeight -= 2; // Top and bottom borders.
|
||||
availableTerminalHeight -= TAB_TO_SELECT_HEIGHT;
|
||||
|
||||
let totalLeftHandSideHeight =
|
||||
DIALOG_PADDING +
|
||||
selectThemeHeight +
|
||||
SCOPE_SELECTION_HEIGHT +
|
||||
SPACE_BETWEEN_THEME_SELECTION_AND_APPLY_TO;
|
||||
|
||||
let showScopeSelection = true;
|
||||
let includePadding = true;
|
||||
|
||||
// Remove content from the LHS that can be omitted if it exceeds the available height.
|
||||
if (totalLeftHandSideHeight > availableTerminalHeight) {
|
||||
includePadding = false;
|
||||
totalLeftHandSideHeight -= DIALOG_PADDING;
|
||||
}
|
||||
|
||||
if (totalLeftHandSideHeight > availableTerminalHeight) {
|
||||
// First, try hiding the scope selection
|
||||
totalLeftHandSideHeight -= SCOPE_SELECTION_HEIGHT;
|
||||
showScopeSelection = false;
|
||||
}
|
||||
|
||||
// Don't focus the scope selection if it is hidden due to height constraints.
|
||||
const currenFocusedSection = !showScopeSelection ? 'theme' : focusedSection;
|
||||
|
||||
// Vertical space taken by elements other than the two code blocks in the preview pane.
|
||||
// Includes "Preview" title, borders, and margin between blocks.
|
||||
const PREVIEW_PANE_FIXED_VERTICAL_SPACE = 8;
|
||||
|
||||
// The right column doesn't need to ever be shorter than the left column.
|
||||
availableTerminalHeight = Math.max(
|
||||
availableTerminalHeight,
|
||||
totalLeftHandSideHeight,
|
||||
);
|
||||
const availableTerminalHeightCodeBlock =
|
||||
availableTerminalHeight -
|
||||
PREVIEW_PANE_FIXED_VERTICAL_SPACE -
|
||||
(includePadding ? 2 : 0) * 2;
|
||||
// Give slightly more space to the code block as it is 3 lines longer.
|
||||
const diffHeight = Math.floor(availableTerminalHeightCodeBlock / 2) - 1;
|
||||
const codeBlockHeight = Math.ceil(availableTerminalHeightCodeBlock / 2) + 1;
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.Gray}
|
||||
flexDirection="column"
|
||||
paddingTop={includePadding ? 1 : 0}
|
||||
paddingBottom={includePadding ? 1 : 0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box flexDirection="row">
|
||||
{/* Left Column: Selection */}
|
||||
<Box flexDirection="column" width="45%" paddingRight={2}>
|
||||
<Text bold={currenFocusedSection === 'theme'} wrap="truncate">
|
||||
{currenFocusedSection === 'theme' ? '> ' : ' '}Select Theme{' '}
|
||||
<Text color={Colors.Gray}>{otherScopeModifiedMessage}</Text>
|
||||
</Text>
|
||||
<RadioButtonSelect
|
||||
key={selectInputKey}
|
||||
items={themeItems}
|
||||
initialIndex={initialThemeIndex}
|
||||
onSelect={handleThemeSelect}
|
||||
onHighlight={onHighlight}
|
||||
isFocused={currenFocusedSection === 'theme'}
|
||||
maxItemsToShow={8}
|
||||
showScrollArrows={true}
|
||||
/>
|
||||
|
||||
{/* Scope Selection */}
|
||||
{showScopeSelection && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text bold={currenFocusedSection === 'scope'} wrap="truncate">
|
||||
{currenFocusedSection === 'scope' ? '> ' : ' '}Apply To
|
||||
</Text>
|
||||
<RadioButtonSelect
|
||||
items={scopeItems}
|
||||
initialIndex={0} // Default to User Settings
|
||||
onSelect={handleScopeSelect}
|
||||
onHighlight={handleScopeHighlight}
|
||||
isFocused={currenFocusedSection === 'scope'}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Right Column: Preview */}
|
||||
<Box flexDirection="column" width="55%" paddingLeft={2}>
|
||||
<Text bold>Preview</Text>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={Colors.Gray}
|
||||
paddingTop={includePadding ? 1 : 0}
|
||||
paddingBottom={includePadding ? 1 : 0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
{colorizeCode(
|
||||
`# function
|
||||
-def fibonacci(n):
|
||||
- a, b = 0, 1
|
||||
- for _ in range(n):
|
||||
- a, b = b, a + b
|
||||
- return a`,
|
||||
'python',
|
||||
codeBlockHeight,
|
||||
colorizeCodeWidth,
|
||||
)}
|
||||
<Box marginTop={1} />
|
||||
<DiffRenderer
|
||||
diffContent={`--- a/old_file.txt
|
||||
-+++ b/new_file.txt
|
||||
-@@ -1,4 +1,5 @@
|
||||
- This is a context line.
|
||||
--This line was deleted.
|
||||
-+This line was added.
|
||||
-`}
|
||||
availableTerminalHeight={diffHeight}
|
||||
terminalWidth={colorizeCodeWidth}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray} wrap="truncate">
|
||||
(Use Enter to select
|
||||
{showScopeSelection ? ', Tab to change focus' : ''})
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
45
packages/cli/src/ui/components/Tips.tsx
Normal file
45
packages/cli/src/ui/components/Tips.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { type Config } from '@qwen/qwen-code-core';
|
||||
|
||||
interface TipsProps {
|
||||
config: Config;
|
||||
}
|
||||
|
||||
export const Tips: React.FC<TipsProps> = ({ config }) => {
|
||||
const geminiMdFileCount = config.getGeminiMdFileCount();
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={Colors.Foreground}>Tips for getting started:</Text>
|
||||
<Text color={Colors.Foreground}>
|
||||
1. Ask questions, edit files, or run commands.
|
||||
</Text>
|
||||
<Text color={Colors.Foreground}>
|
||||
2. Be specific for the best results.
|
||||
</Text>
|
||||
{geminiMdFileCount === 0 && (
|
||||
<Text color={Colors.Foreground}>
|
||||
3. Create{' '}
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
QWEN.md
|
||||
</Text>{' '}
|
||||
files to customize your interactions with Qwen Code.
|
||||
</Text>
|
||||
)}
|
||||
<Text color={Colors.Foreground}>
|
||||
{geminiMdFileCount === 0 ? '4.' : '3.'}{' '}
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
/help
|
||||
</Text>{' '}
|
||||
for more information.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
180
packages/cli/src/ui/components/ToolStatsDisplay.test.tsx
Normal file
180
packages/cli/src/ui/components/ToolStatsDisplay.test.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @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 { ToolStatsDisplay } from './ToolStatsDisplay.js';
|
||||
import * as SessionContext from '../contexts/SessionContext.js';
|
||||
import { SessionMetrics } from '../contexts/SessionContext.js';
|
||||
|
||||
// Mock the context to provide controlled data for testing
|
||||
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof SessionContext>();
|
||||
return {
|
||||
...actual,
|
||||
useSessionStats: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
|
||||
|
||||
const renderWithMockedStats = (metrics: SessionMetrics) => {
|
||||
useSessionStatsMock.mockReturnValue({
|
||||
stats: {
|
||||
sessionStartTime: new Date(),
|
||||
metrics,
|
||||
lastPromptTokenCount: 0,
|
||||
promptCount: 5,
|
||||
},
|
||||
|
||||
getPromptCount: () => 5,
|
||||
startNewPrompt: vi.fn(),
|
||||
});
|
||||
|
||||
return render(<ToolStatsDisplay />);
|
||||
};
|
||||
|
||||
describe('<ToolStatsDisplay />', () => {
|
||||
it('should render "no tool calls" message when there are no active tools', () => {
|
||||
const { lastFrame } = renderWithMockedStats({
|
||||
models: {},
|
||||
tools: {
|
||||
totalCalls: 0,
|
||||
totalSuccess: 0,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 0,
|
||||
totalDecisions: { accept: 0, reject: 0, modify: 0 },
|
||||
byName: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain(
|
||||
'No tool calls have been made in this session.',
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should display stats for a single tool correctly', () => {
|
||||
const { lastFrame } = renderWithMockedStats({
|
||||
models: {},
|
||||
tools: {
|
||||
totalCalls: 1,
|
||||
totalSuccess: 1,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 100,
|
||||
totalDecisions: { accept: 1, reject: 0, modify: 0 },
|
||||
byName: {
|
||||
'test-tool': {
|
||||
count: 1,
|
||||
success: 1,
|
||||
fail: 0,
|
||||
durationMs: 100,
|
||||
decisions: { accept: 1, reject: 0, modify: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('test-tool');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should display stats for multiple tools correctly', () => {
|
||||
const { lastFrame } = renderWithMockedStats({
|
||||
models: {},
|
||||
tools: {
|
||||
totalCalls: 3,
|
||||
totalSuccess: 2,
|
||||
totalFail: 1,
|
||||
totalDurationMs: 300,
|
||||
totalDecisions: { accept: 1, reject: 1, modify: 1 },
|
||||
byName: {
|
||||
'tool-a': {
|
||||
count: 2,
|
||||
success: 1,
|
||||
fail: 1,
|
||||
durationMs: 200,
|
||||
decisions: { accept: 1, reject: 1, modify: 0 },
|
||||
},
|
||||
'tool-b': {
|
||||
count: 1,
|
||||
success: 1,
|
||||
fail: 0,
|
||||
durationMs: 100,
|
||||
decisions: { accept: 0, reject: 0, modify: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('tool-a');
|
||||
expect(output).toContain('tool-b');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should handle large values without wrapping or overlapping', () => {
|
||||
const { lastFrame } = renderWithMockedStats({
|
||||
models: {},
|
||||
tools: {
|
||||
totalCalls: 999999999,
|
||||
totalSuccess: 888888888,
|
||||
totalFail: 111111111,
|
||||
totalDurationMs: 987654321,
|
||||
totalDecisions: {
|
||||
accept: 123456789,
|
||||
reject: 98765432,
|
||||
modify: 12345,
|
||||
},
|
||||
byName: {
|
||||
'long-named-tool-for-testing-wrapping-and-such': {
|
||||
count: 999999999,
|
||||
success: 888888888,
|
||||
fail: 111111111,
|
||||
durationMs: 987654321,
|
||||
decisions: {
|
||||
accept: 123456789,
|
||||
reject: 98765432,
|
||||
modify: 12345,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should handle zero decisions gracefully', () => {
|
||||
const { lastFrame } = renderWithMockedStats({
|
||||
models: {},
|
||||
tools: {
|
||||
totalCalls: 1,
|
||||
totalSuccess: 1,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 100,
|
||||
totalDecisions: { accept: 0, reject: 0, modify: 0 },
|
||||
byName: {
|
||||
'test-tool': {
|
||||
count: 1,
|
||||
success: 1,
|
||||
fail: 0,
|
||||
durationMs: 100,
|
||||
decisions: { accept: 0, reject: 0, modify: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Total Reviewed Suggestions:');
|
||||
expect(output).toContain('0');
|
||||
expect(output).toContain('Overall Agreement Rate:');
|
||||
expect(output).toContain('--');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
208
packages/cli/src/ui/components/ToolStatsDisplay.tsx
Normal file
208
packages/cli/src/ui/components/ToolStatsDisplay.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
import {
|
||||
getStatusColor,
|
||||
TOOL_SUCCESS_RATE_HIGH,
|
||||
TOOL_SUCCESS_RATE_MEDIUM,
|
||||
USER_AGREEMENT_RATE_HIGH,
|
||||
USER_AGREEMENT_RATE_MEDIUM,
|
||||
} from '../utils/displayUtils.js';
|
||||
import { useSessionStats } from '../contexts/SessionContext.js';
|
||||
import { ToolCallStats } from '@qwen/qwen-code-core';
|
||||
|
||||
const TOOL_NAME_COL_WIDTH = 25;
|
||||
const CALLS_COL_WIDTH = 8;
|
||||
const SUCCESS_RATE_COL_WIDTH = 15;
|
||||
const AVG_DURATION_COL_WIDTH = 15;
|
||||
|
||||
const StatRow: React.FC<{
|
||||
name: string;
|
||||
stats: ToolCallStats;
|
||||
}> = ({ name, stats }) => {
|
||||
const successRate = stats.count > 0 ? (stats.success / stats.count) * 100 : 0;
|
||||
const avgDuration = stats.count > 0 ? stats.durationMs / stats.count : 0;
|
||||
const successColor = getStatusColor(successRate, {
|
||||
green: TOOL_SUCCESS_RATE_HIGH,
|
||||
yellow: TOOL_SUCCESS_RATE_MEDIUM,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box width={TOOL_NAME_COL_WIDTH}>
|
||||
<Text color={Colors.LightBlue}>{name}</Text>
|
||||
</Box>
|
||||
<Box width={CALLS_COL_WIDTH} justifyContent="flex-end">
|
||||
<Text>{stats.count}</Text>
|
||||
</Box>
|
||||
<Box width={SUCCESS_RATE_COL_WIDTH} justifyContent="flex-end">
|
||||
<Text color={successColor}>{successRate.toFixed(1)}%</Text>
|
||||
</Box>
|
||||
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
|
||||
<Text>{formatDuration(avgDuration)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToolStatsDisplay: React.FC = () => {
|
||||
const { stats } = useSessionStats();
|
||||
const { tools } = stats.metrics;
|
||||
const activeTools = Object.entries(tools.byName).filter(
|
||||
([, metrics]) => metrics.count > 0,
|
||||
);
|
||||
|
||||
if (activeTools.length === 0) {
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.Gray}
|
||||
paddingY={1}
|
||||
paddingX={2}
|
||||
>
|
||||
<Text>No tool calls have been made in this session.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const totalDecisions = Object.values(tools.byName).reduce(
|
||||
(acc, tool) => {
|
||||
acc.accept += tool.decisions.accept;
|
||||
acc.reject += tool.decisions.reject;
|
||||
acc.modify += tool.decisions.modify;
|
||||
return acc;
|
||||
},
|
||||
{ accept: 0, reject: 0, modify: 0 },
|
||||
);
|
||||
|
||||
const totalReviewed =
|
||||
totalDecisions.accept + totalDecisions.reject + totalDecisions.modify;
|
||||
const agreementRate =
|
||||
totalReviewed > 0 ? (totalDecisions.accept / totalReviewed) * 100 : 0;
|
||||
const agreementColor = getStatusColor(agreementRate, {
|
||||
green: USER_AGREEMENT_RATE_HIGH,
|
||||
yellow: USER_AGREEMENT_RATE_MEDIUM,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.Gray}
|
||||
flexDirection="column"
|
||||
paddingY={1}
|
||||
paddingX={2}
|
||||
width={70}
|
||||
>
|
||||
<Text bold color={Colors.AccentPurple}>
|
||||
Tool Stats For Nerds
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
|
||||
{/* Header */}
|
||||
<Box>
|
||||
<Box width={TOOL_NAME_COL_WIDTH}>
|
||||
<Text bold>Tool Name</Text>
|
||||
</Box>
|
||||
<Box width={CALLS_COL_WIDTH} justifyContent="flex-end">
|
||||
<Text bold>Calls</Text>
|
||||
</Box>
|
||||
<Box width={SUCCESS_RATE_COL_WIDTH} justifyContent="flex-end">
|
||||
<Text bold>Success Rate</Text>
|
||||
</Box>
|
||||
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
|
||||
<Text bold>Avg Duration</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Divider */}
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderBottom={true}
|
||||
borderTop={false}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
width="100%"
|
||||
/>
|
||||
|
||||
{/* Tool Rows */}
|
||||
{activeTools.map(([name, stats]) => (
|
||||
<StatRow key={name} name={name} stats={stats as ToolCallStats} />
|
||||
))}
|
||||
|
||||
<Box height={1} />
|
||||
|
||||
{/* User Decision Summary */}
|
||||
<Text bold>User Decision Summary</Text>
|
||||
<Box>
|
||||
<Box
|
||||
width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
|
||||
>
|
||||
<Text color={Colors.LightBlue}>Total Reviewed Suggestions:</Text>
|
||||
</Box>
|
||||
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
|
||||
<Text>{totalReviewed}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Box
|
||||
width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
|
||||
>
|
||||
<Text> » Accepted:</Text>
|
||||
</Box>
|
||||
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
|
||||
<Text color={Colors.AccentGreen}>{totalDecisions.accept}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Box
|
||||
width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
|
||||
>
|
||||
<Text> » Rejected:</Text>
|
||||
</Box>
|
||||
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
|
||||
<Text color={Colors.AccentRed}>{totalDecisions.reject}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Box
|
||||
width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
|
||||
>
|
||||
<Text> » Modified:</Text>
|
||||
</Box>
|
||||
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
|
||||
<Text color={Colors.AccentYellow}>{totalDecisions.modify}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Divider */}
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderBottom={true}
|
||||
borderTop={false}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
width="100%"
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Box
|
||||
width={TOOL_NAME_COL_WIDTH + CALLS_COL_WIDTH + SUCCESS_RATE_COL_WIDTH}
|
||||
>
|
||||
<Text> Overall Agreement Rate:</Text>
|
||||
</Box>
|
||||
<Box width={AVG_DURATION_COL_WIDTH} justifyContent="flex-end">
|
||||
<Text bold color={totalReviewed > 0 ? agreementColor : undefined}>
|
||||
{totalReviewed > 0 ? `${agreementRate.toFixed(1)}%` : '--'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
23
packages/cli/src/ui/components/UpdateNotification.tsx
Normal file
23
packages/cli/src/ui/components/UpdateNotification.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
|
||||
interface UpdateNotificationProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const UpdateNotification = ({ message }: UpdateNotificationProps) => (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentYellow}
|
||||
paddingX={1}
|
||||
marginY={1}
|
||||
>
|
||||
<Text color={Colors.AccentYellow}>{message}</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -0,0 +1,121 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<ModelStatsDisplay /> > should display a single model correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Model Stats For Nerds │
|
||||
│ │
|
||||
│ Metric gemini-2.5-pro │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ API │
|
||||
│ Requests 1 │
|
||||
│ Errors 0 (0.0%) │
|
||||
│ Avg Latency 100ms │
|
||||
│ │
|
||||
│ Tokens │
|
||||
│ Total 30 │
|
||||
│ ↳ Prompt 10 │
|
||||
│ ↳ Cached 5 (50.0%) │
|
||||
│ ↳ Thoughts 2 │
|
||||
│ ↳ Tool 1 │
|
||||
│ ↳ Output 20 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ModelStatsDisplay /> > should display conditional rows if at least one model has data 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Model Stats For Nerds │
|
||||
│ │
|
||||
│ Metric gemini-2.5-pro gemini-2.5-flash │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ API │
|
||||
│ Requests 1 1 │
|
||||
│ Errors 0 (0.0%) 0 (0.0%) │
|
||||
│ Avg Latency 100ms 50ms │
|
||||
│ │
|
||||
│ Tokens │
|
||||
│ Total 30 15 │
|
||||
│ ↳ Prompt 10 5 │
|
||||
│ ↳ Cached 5 (50.0%) 0 (0.0%) │
|
||||
│ ↳ Thoughts 2 0 │
|
||||
│ ↳ Tool 0 3 │
|
||||
│ ↳ Output 20 10 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ModelStatsDisplay /> > should display stats for multiple models correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Model Stats For Nerds │
|
||||
│ │
|
||||
│ Metric gemini-2.5-pro gemini-2.5-flash │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ API │
|
||||
│ Requests 10 20 │
|
||||
│ Errors 1 (10.0%) 2 (10.0%) │
|
||||
│ Avg Latency 100ms 25ms │
|
||||
│ │
|
||||
│ Tokens │
|
||||
│ Total 300 600 │
|
||||
│ ↳ Prompt 100 200 │
|
||||
│ ↳ Cached 50 (50.0%) 100 (50.0%) │
|
||||
│ ↳ Thoughts 10 20 │
|
||||
│ ↳ Tool 5 10 │
|
||||
│ ↳ Output 200 400 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ModelStatsDisplay /> > should handle large values without wrapping or overlapping 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Model Stats For Nerds │
|
||||
│ │
|
||||
│ Metric gemini-2.5-pro │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ API │
|
||||
│ Requests 999,999,999 │
|
||||
│ Errors 123,456,789 (12.3%) │
|
||||
│ Avg Latency 0ms │
|
||||
│ │
|
||||
│ Tokens │
|
||||
│ Total 999,999,999 │
|
||||
│ ↳ Prompt 987,654,321 │
|
||||
│ ↳ Cached 123,456,789 (12.5%) │
|
||||
│ ↳ Thoughts 111,111,111 │
|
||||
│ ↳ Tool 222,222,222 │
|
||||
│ ↳ Output 123,456,789 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ModelStatsDisplay /> > should not display conditional rows if no model has data for them 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Model Stats For Nerds │
|
||||
│ │
|
||||
│ Metric gemini-2.5-pro │
|
||||
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ API │
|
||||
│ Requests 1 │
|
||||
│ Errors 0 (0.0%) │
|
||||
│ Avg Latency 100ms │
|
||||
│ │
|
||||
│ Tokens │
|
||||
│ Total 30 │
|
||||
│ ↳ Prompt 10 │
|
||||
│ ↳ Output 20 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ModelStatsDisplay /> > should render "no API calls" message when there are no active models 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ No API calls have been made in this session. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -0,0 +1,24 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Agent powering down. Goodbye! │
|
||||
│ │
|
||||
│ Performance │
|
||||
│ Wall Time: 1h 23m 45s │
|
||||
│ Agent Active: 50.2s │
|
||||
│ » API Time: 50.2s (100.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
│ Model Usage Reqs Input Tokens Output Tokens │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 10 1,000 2,000 │
|
||||
│ │
|
||||
│ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │
|
||||
│ │
|
||||
│ » Tip: For a full token breakdown, run \`/stats model\`. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -0,0 +1,193 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in green for high values 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Session Stats │
|
||||
│ │
|
||||
│ Interaction Summary │
|
||||
│ Tool Calls: 10 ( ✔ 10 ✖ 0 ) │
|
||||
│ Success Rate: 100.0% │
|
||||
│ │
|
||||
│ Performance │
|
||||
│ Wall Time: 1s │
|
||||
│ Agent Active: 0s │
|
||||
│ » API Time: 0s (0.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in red for low values 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Session Stats │
|
||||
│ │
|
||||
│ Interaction Summary │
|
||||
│ Tool Calls: 10 ( ✔ 5 ✖ 5 ) │
|
||||
│ Success Rate: 50.0% │
|
||||
│ │
|
||||
│ Performance │
|
||||
│ Wall Time: 1s │
|
||||
│ Agent Active: 0s │
|
||||
│ » API Time: 0s (0.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in yellow for medium values 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Session Stats │
|
||||
│ │
|
||||
│ Interaction Summary │
|
||||
│ Tool Calls: 10 ( ✔ 9 ✖ 1 ) │
|
||||
│ Success Rate: 90.0% │
|
||||
│ │
|
||||
│ Performance │
|
||||
│ Wall Time: 1s │
|
||||
│ Agent Active: 0s │
|
||||
│ » API Time: 0s (0.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<StatsDisplay /> > Conditional Rendering Tests > hides Efficiency section when cache is not used 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Session Stats │
|
||||
│ │
|
||||
│ Performance │
|
||||
│ Wall Time: 1s │
|
||||
│ Agent Active: 100ms │
|
||||
│ » API Time: 100ms (100.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
│ Model Usage Reqs Input Tokens Output Tokens │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 1 100 100 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<StatsDisplay /> > Conditional Rendering Tests > hides User Agreement when no decisions are made 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Session Stats │
|
||||
│ │
|
||||
│ Interaction Summary │
|
||||
│ Tool Calls: 2 ( ✔ 1 ✖ 1 ) │
|
||||
│ Success Rate: 50.0% │
|
||||
│ │
|
||||
│ Performance │
|
||||
│ Wall Time: 1s │
|
||||
│ Agent Active: 123ms │
|
||||
│ » API Time: 0s (0.0%) │
|
||||
│ » Tool Time: 123ms (100.0%) │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<StatsDisplay /> > Title Rendering > renders the custom title when a title prop is provided 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Agent powering down. Goodbye! │
|
||||
│ │
|
||||
│ Performance │
|
||||
│ Wall Time: 1s │
|
||||
│ Agent Active: 0s │
|
||||
│ » API Time: 0s (0.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<StatsDisplay /> > Title Rendering > renders the default title when no title prop is provided 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Session Stats │
|
||||
│ │
|
||||
│ Performance │
|
||||
│ Wall Time: 1s │
|
||||
│ Agent Active: 0s │
|
||||
│ » API Time: 0s (0.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Session Stats │
|
||||
│ │
|
||||
│ Performance │
|
||||
│ Wall Time: 1s │
|
||||
│ Agent Active: 19.5s │
|
||||
│ » API Time: 19.5s (100.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
│ Model Usage Reqs Input Tokens Output Tokens │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 3 1,000 2,000 │
|
||||
│ gemini-2.5-flash 5 25,000 15,000 │
|
||||
│ │
|
||||
│ Savings Highlight: 10,500 (40.4%) of input tokens were served from the cache, reducing costs. │
|
||||
│ │
|
||||
│ » Tip: For a full token breakdown, run \`/stats model\`. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<StatsDisplay /> > renders all sections when all data is present 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Session Stats │
|
||||
│ │
|
||||
│ Interaction Summary │
|
||||
│ Tool Calls: 2 ( ✔ 1 ✖ 1 ) │
|
||||
│ Success Rate: 50.0% │
|
||||
│ User Agreement: 100.0% (1 reviewed) │
|
||||
│ │
|
||||
│ Performance │
|
||||
│ Wall Time: 1s │
|
||||
│ Agent Active: 223ms │
|
||||
│ » API Time: 100ms (44.8%) │
|
||||
│ » Tool Time: 123ms (55.2%) │
|
||||
│ │
|
||||
│ │
|
||||
│ Model Usage Reqs Input Tokens Output Tokens │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ gemini-2.5-pro 1 100 100 │
|
||||
│ │
|
||||
│ Savings Highlight: 50 (50.0%) of input tokens were served from the cache, reducing costs. │
|
||||
│ │
|
||||
│ » Tip: For a full token breakdown, run \`/stats model\`. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<StatsDisplay /> > renders only the Performance section in its zero state 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Session Stats │
|
||||
│ │
|
||||
│ Performance │
|
||||
│ Wall Time: 1s │
|
||||
│ Agent Active: 0s │
|
||||
│ » API Time: 0s (0.0%) │
|
||||
│ » Tool Time: 0s (0.0%) │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -0,0 +1,91 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<ToolStatsDisplay /> > should display stats for a single tool correctly 1`] = `
|
||||
"╭────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Tool Stats For Nerds │
|
||||
│ │
|
||||
│ Tool Name Calls Success Rate Avg Duration │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ test-tool 1 100.0% 100ms │
|
||||
│ │
|
||||
│ User Decision Summary │
|
||||
│ Total Reviewed Suggestions: 1 │
|
||||
│ » Accepted: 1 │
|
||||
│ » Rejected: 0 │
|
||||
│ » Modified: 0 │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ Overall Agreement Rate: 100.0% │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolStatsDisplay /> > should display stats for multiple tools correctly 1`] = `
|
||||
"╭────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Tool Stats For Nerds │
|
||||
│ │
|
||||
│ Tool Name Calls Success Rate Avg Duration │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ tool-a 2 50.0% 100ms │
|
||||
│ tool-b 1 100.0% 100ms │
|
||||
│ │
|
||||
│ User Decision Summary │
|
||||
│ Total Reviewed Suggestions: 3 │
|
||||
│ » Accepted: 1 │
|
||||
│ » Rejected: 1 │
|
||||
│ » Modified: 1 │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ Overall Agreement Rate: 33.3% │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolStatsDisplay /> > should handle large values without wrapping or overlapping 1`] = `
|
||||
"╭────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Tool Stats For Nerds │
|
||||
│ │
|
||||
│ Tool Name Calls Success Rate Avg Duration │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ long-named-tool-for-testi99999999 88.9% 1ms │
|
||||
│ ng-wrapping-and-such 9 │
|
||||
│ │
|
||||
│ User Decision Summary │
|
||||
│ Total Reviewed Suggestions: 222234566 │
|
||||
│ » Accepted: 123456789 │
|
||||
│ » Rejected: 98765432 │
|
||||
│ » Modified: 12345 │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ Overall Agreement Rate: 55.6% │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolStatsDisplay /> > should handle zero decisions gracefully 1`] = `
|
||||
"╭────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Tool Stats For Nerds │
|
||||
│ │
|
||||
│ Tool Name Calls Success Rate Avg Duration │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ test-tool 1 100.0% 100ms │
|
||||
│ │
|
||||
│ User Decision Summary │
|
||||
│ Total Reviewed Suggestions: 0 │
|
||||
│ » Accepted: 0 │
|
||||
│ » Rejected: 0 │
|
||||
│ » Modified: 0 │
|
||||
│ ──────────────────────────────────────────────────────────────── │
|
||||
│ Overall Agreement Rate: -- │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<ToolStatsDisplay /> > should render "no tool calls" message when there are no active tools 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ No tool calls have been made in this session. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { CompressionProps } from '../../types.js';
|
||||
import Spinner from 'ink-spinner';
|
||||
import { Colors } from '../../colors.js';
|
||||
|
||||
export interface CompressionDisplayProps {
|
||||
compression: CompressionProps;
|
||||
}
|
||||
|
||||
/*
|
||||
* Compression messages appear when the /compress command is run, and show a loading spinner
|
||||
* while compression is in progress, followed up by some compression stats.
|
||||
*/
|
||||
export const CompressionMessage: React.FC<CompressionDisplayProps> = ({
|
||||
compression,
|
||||
}) => {
|
||||
const text = compression.isPending
|
||||
? 'Compressing chat history'
|
||||
: `Chat history compressed from ${compression.originalTokenCount ?? 'unknown'}` +
|
||||
` to ${compression.newTokenCount ?? 'unknown'} tokens.`;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box marginRight={1}>
|
||||
{compression.isPending ? (
|
||||
<Spinner type="dots" />
|
||||
) : (
|
||||
<Text color={Colors.AccentPurple}>✦</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text
|
||||
color={
|
||||
compression.isPending ? Colors.AccentPurple : Colors.AccentGreen
|
||||
}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
362
packages/cli/src/ui/components/messages/DiffRenderer.test.tsx
Normal file
362
packages/cli/src/ui/components/messages/DiffRenderer.test.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { OverflowProvider } from '../../contexts/OverflowContext.js';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { DiffRenderer } from './DiffRenderer.js';
|
||||
import * as CodeColorizer from '../../utils/CodeColorizer.js';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
describe('<OverflowProvider><DiffRenderer /></OverflowProvider>', () => {
|
||||
const mockColorizeCode = vi.spyOn(CodeColorizer, 'colorizeCode');
|
||||
|
||||
beforeEach(() => {
|
||||
mockColorizeCode.mockClear();
|
||||
});
|
||||
|
||||
const sanitizeOutput = (output: string | undefined, terminalWidth: number) =>
|
||||
output?.replace(/GAP_INDICATOR/g, '═'.repeat(terminalWidth));
|
||||
|
||||
it('should call colorizeCode with correct language for new file with known extension', () => {
|
||||
const newFileDiffContent = `
|
||||
diff --git a/test.py b/test.py
|
||||
new file mode 100644
|
||||
index 0000000..e69de29
|
||||
--- /dev/null
|
||||
+++ b/test.py
|
||||
@@ -0,0 +1 @@
|
||||
+print("hello world")
|
||||
`;
|
||||
render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiffContent}
|
||||
filename="test.py"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(mockColorizeCode).toHaveBeenCalledWith(
|
||||
'print("hello world")',
|
||||
'python',
|
||||
undefined,
|
||||
80,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call colorizeCode with null language for new file with unknown extension', () => {
|
||||
const newFileDiffContent = `
|
||||
diff --git a/test.unknown b/test.unknown
|
||||
new file mode 100644
|
||||
index 0000000..e69de29
|
||||
--- /dev/null
|
||||
+++ b/test.unknown
|
||||
@@ -0,0 +1 @@
|
||||
+some content
|
||||
`;
|
||||
render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiffContent}
|
||||
filename="test.unknown"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(mockColorizeCode).toHaveBeenCalledWith(
|
||||
'some content',
|
||||
null,
|
||||
undefined,
|
||||
80,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call colorizeCode with null language for new file if no filename is provided', () => {
|
||||
const newFileDiffContent = `
|
||||
diff --git a/test.txt b/test.txt
|
||||
new file mode 100644
|
||||
index 0000000..e69de29
|
||||
--- /dev/null
|
||||
+++ b/test.txt
|
||||
@@ -0,0 +1 @@
|
||||
+some text content
|
||||
`;
|
||||
render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer diffContent={newFileDiffContent} terminalWidth={80} />
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(mockColorizeCode).toHaveBeenCalledWith(
|
||||
'some text content',
|
||||
null,
|
||||
undefined,
|
||||
80,
|
||||
);
|
||||
});
|
||||
|
||||
it('should render diff content for existing file (not calling colorizeCode directly for the whole block)', () => {
|
||||
const existingFileDiffContent = `
|
||||
diff --git a/test.txt b/test.txt
|
||||
index 0000001..0000002 100644
|
||||
--- a/test.txt
|
||||
+++ b/test.txt
|
||||
@@ -1 +1 @@
|
||||
-old line
|
||||
+new line
|
||||
`;
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={existingFileDiffContent}
|
||||
filename="test.txt"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
// colorizeCode is used internally by the line-by-line rendering, not for the whole block
|
||||
expect(mockColorizeCode).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('old line'),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockColorizeCode).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('new line'),
|
||||
expect.anything(),
|
||||
);
|
||||
const output = lastFrame();
|
||||
const lines = output!.split('\n');
|
||||
expect(lines[0]).toBe('1 - old line');
|
||||
expect(lines[1]).toBe('1 + new line');
|
||||
});
|
||||
|
||||
it('should handle diff with only header and no changes', () => {
|
||||
const noChangeDiff = `diff --git a/file.txt b/file.txt
|
||||
index 1234567..1234567 100644
|
||||
--- a/file.txt
|
||||
+++ b/file.txt
|
||||
`;
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={noChangeDiff}
|
||||
filename="file.txt"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(lastFrame()).toContain('No changes detected');
|
||||
expect(mockColorizeCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle empty diff content', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer diffContent="" terminalWidth={80} />
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(lastFrame()).toContain('No diff content');
|
||||
expect(mockColorizeCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should render a gap indicator for skipped lines', () => {
|
||||
const diffWithGap = `
|
||||
diff --git a/file.txt b/file.txt
|
||||
index 123..456 100644
|
||||
--- a/file.txt
|
||||
+++ b/file.txt
|
||||
@@ -1,2 +1,2 @@
|
||||
context line 1
|
||||
-deleted line
|
||||
+added line
|
||||
@@ -10,2 +10,2 @@
|
||||
context line 10
|
||||
context line 11
|
||||
`;
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={diffWithGap}
|
||||
filename="file.txt"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('═'); // Check for the border character used in the gap
|
||||
|
||||
// Verify that lines before and after the gap are rendered
|
||||
expect(output).toContain('context line 1');
|
||||
expect(output).toContain('added line');
|
||||
expect(output).toContain('context line 10');
|
||||
});
|
||||
|
||||
it('should not render a gap indicator for small gaps (<= MAX_CONTEXT_LINES_WITHOUT_GAP)', () => {
|
||||
const diffWithSmallGap = `
|
||||
diff --git a/file.txt b/file.txt
|
||||
index abc..def 100644
|
||||
--- a/file.txt
|
||||
+++ b/file.txt
|
||||
@@ -1,5 +1,5 @@
|
||||
context line 1
|
||||
context line 2
|
||||
context line 3
|
||||
context line 4
|
||||
context line 5
|
||||
@@ -11,5 +11,5 @@
|
||||
context line 11
|
||||
context line 12
|
||||
context line 13
|
||||
context line 14
|
||||
context line 15
|
||||
`;
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={diffWithSmallGap}
|
||||
filename="file.txt"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('═'); // Ensure no separator is rendered
|
||||
|
||||
// Verify that lines before and after the gap are rendered
|
||||
expect(output).toContain('context line 5');
|
||||
expect(output).toContain('context line 11');
|
||||
});
|
||||
|
||||
describe('should correctly render a diff with multiple hunks and a gap indicator', () => {
|
||||
const diffWithMultipleHunks = `
|
||||
diff --git a/multi.js b/multi.js
|
||||
index 123..789 100644
|
||||
--- a/multi.js
|
||||
+++ b/multi.js
|
||||
@@ -1,3 +1,3 @@
|
||||
console.log('first hunk');
|
||||
-const oldVar = 1;
|
||||
+const newVar = 1;
|
||||
console.log('end of first hunk');
|
||||
@@ -20,3 +20,3 @@
|
||||
console.log('second hunk');
|
||||
-const anotherOld = 'test';
|
||||
+const anotherNew = 'test';
|
||||
console.log('end of second hunk');
|
||||
`;
|
||||
|
||||
it.each([
|
||||
{
|
||||
terminalWidth: 80,
|
||||
height: undefined,
|
||||
expected: `1 console.log('first hunk');
|
||||
2 - const oldVar = 1;
|
||||
2 + const newVar = 1;
|
||||
3 console.log('end of first hunk');
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
20 console.log('second hunk');
|
||||
21 - const anotherOld = 'test';
|
||||
21 + const anotherNew = 'test';
|
||||
22 console.log('end of second hunk');`,
|
||||
},
|
||||
{
|
||||
terminalWidth: 80,
|
||||
height: 6,
|
||||
expected: `... first 4 lines hidden ...
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
20 console.log('second hunk');
|
||||
21 - const anotherOld = 'test';
|
||||
21 + const anotherNew = 'test';
|
||||
22 console.log('end of second hunk');`,
|
||||
},
|
||||
{
|
||||
terminalWidth: 30,
|
||||
height: 6,
|
||||
expected: `... first 10 lines hidden ...
|
||||
'test';
|
||||
21 + const anotherNew =
|
||||
'test';
|
||||
22 console.log('end of
|
||||
second hunk');`,
|
||||
},
|
||||
])(
|
||||
'with terminalWidth $terminalWidth and height $height',
|
||||
({ terminalWidth, height, expected }) => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={diffWithMultipleHunks}
|
||||
filename="multi.js"
|
||||
terminalWidth={terminalWidth}
|
||||
availableTerminalHeight={height}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(sanitizeOutput(output, terminalWidth)).toEqual(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should correctly render a diff with a SVN diff format', () => {
|
||||
const newFileDiff = `
|
||||
fileDiff Index: file.txt
|
||||
===================================================================
|
||||
--- a/file.txt Current
|
||||
+++ b/file.txt Proposed
|
||||
--- a/multi.js
|
||||
+++ b/multi.js
|
||||
@@ -1,1 +1,1 @@
|
||||
-const oldVar = 1;
|
||||
+const newVar = 1;
|
||||
@@ -20,1 +20,1 @@
|
||||
-const anotherOld = 'test';
|
||||
+const anotherNew = 'test';
|
||||
\\ No newline at end of file
|
||||
`;
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiff}
|
||||
filename="TEST"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toEqual(`1 - const oldVar = 1;
|
||||
1 + const newVar = 1;
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
20 - const anotherOld = 'test';
|
||||
20 + const anotherNew = 'test';`);
|
||||
});
|
||||
|
||||
it('should correctly render a new file with no file extension correctly', () => {
|
||||
const newFileDiff = `
|
||||
fileDiff Index: Dockerfile
|
||||
===================================================================
|
||||
--- Dockerfile Current
|
||||
+++ Dockerfile Proposed
|
||||
@@ -0,0 +1,3 @@
|
||||
+FROM node:14
|
||||
+RUN npm install
|
||||
+RUN npm run build
|
||||
\\ No newline at end of file
|
||||
`;
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiff}
|
||||
filename="Dockerfile"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toEqual(`1 FROM node:14
|
||||
2 RUN npm install
|
||||
3 RUN npm run build`);
|
||||
});
|
||||
});
|
||||
312
packages/cli/src/ui/components/messages/DiffRenderer.tsx
Normal file
312
packages/cli/src/ui/components/messages/DiffRenderer.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
import crypto from 'crypto';
|
||||
import { colorizeCode } from '../../utils/CodeColorizer.js';
|
||||
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||
|
||||
interface DiffLine {
|
||||
type: 'add' | 'del' | 'context' | 'hunk' | 'other';
|
||||
oldLine?: number;
|
||||
newLine?: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
function parseDiffWithLineNumbers(diffContent: string): DiffLine[] {
|
||||
const lines = diffContent.split('\n');
|
||||
const result: DiffLine[] = [];
|
||||
let currentOldLine = 0;
|
||||
let currentNewLine = 0;
|
||||
let inHunk = false;
|
||||
const hunkHeaderRegex = /^@@ -(\d+),?\d* \+(\d+),?\d* @@/;
|
||||
|
||||
for (const line of lines) {
|
||||
const hunkMatch = line.match(hunkHeaderRegex);
|
||||
if (hunkMatch) {
|
||||
currentOldLine = parseInt(hunkMatch[1], 10);
|
||||
currentNewLine = parseInt(hunkMatch[2], 10);
|
||||
inHunk = true;
|
||||
result.push({ type: 'hunk', content: line });
|
||||
// We need to adjust the starting point because the first line number applies to the *first* actual line change/context,
|
||||
// but we increment *before* pushing that line. So decrement here.
|
||||
currentOldLine--;
|
||||
currentNewLine--;
|
||||
continue;
|
||||
}
|
||||
if (!inHunk) {
|
||||
// Skip standard Git header lines more robustly
|
||||
if (
|
||||
line.startsWith('--- ') ||
|
||||
line.startsWith('+++ ') ||
|
||||
line.startsWith('diff --git') ||
|
||||
line.startsWith('index ') ||
|
||||
line.startsWith('similarity index') ||
|
||||
line.startsWith('rename from') ||
|
||||
line.startsWith('rename to') ||
|
||||
line.startsWith('new file mode') ||
|
||||
line.startsWith('deleted file mode')
|
||||
)
|
||||
continue;
|
||||
// If it's not a hunk or header, skip (or handle as 'other' if needed)
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('+')) {
|
||||
currentNewLine++; // Increment before pushing
|
||||
result.push({
|
||||
type: 'add',
|
||||
newLine: currentNewLine,
|
||||
content: line.substring(1),
|
||||
});
|
||||
} else if (line.startsWith('-')) {
|
||||
currentOldLine++; // Increment before pushing
|
||||
result.push({
|
||||
type: 'del',
|
||||
oldLine: currentOldLine,
|
||||
content: line.substring(1),
|
||||
});
|
||||
} else if (line.startsWith(' ')) {
|
||||
currentOldLine++; // Increment before pushing
|
||||
currentNewLine++;
|
||||
result.push({
|
||||
type: 'context',
|
||||
oldLine: currentOldLine,
|
||||
newLine: currentNewLine,
|
||||
content: line.substring(1),
|
||||
});
|
||||
} else if (line.startsWith('\\')) {
|
||||
// Handle "\ No newline at end of file"
|
||||
result.push({ type: 'other', content: line });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
interface DiffRendererProps {
|
||||
diffContent: string;
|
||||
filename?: string;
|
||||
tabWidth?: number;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization
|
||||
|
||||
export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||
diffContent,
|
||||
filename,
|
||||
tabWidth = DEFAULT_TAB_WIDTH,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
}) => {
|
||||
if (!diffContent || typeof diffContent !== 'string') {
|
||||
return <Text color={Colors.AccentYellow}>No diff content.</Text>;
|
||||
}
|
||||
|
||||
const parsedLines = parseDiffWithLineNumbers(diffContent);
|
||||
|
||||
if (parsedLines.length === 0) {
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={Colors.Gray} padding={1}>
|
||||
<Text dimColor>No changes detected.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the diff represents a new file (only additions and header lines)
|
||||
const isNewFile = parsedLines.every(
|
||||
(line) =>
|
||||
line.type === 'add' ||
|
||||
line.type === 'hunk' ||
|
||||
line.type === 'other' ||
|
||||
line.content.startsWith('diff --git') ||
|
||||
line.content.startsWith('new file mode'),
|
||||
);
|
||||
|
||||
let renderedOutput;
|
||||
|
||||
if (isNewFile) {
|
||||
// Extract only the added lines' content
|
||||
const addedContent = parsedLines
|
||||
.filter((line) => line.type === 'add')
|
||||
.map((line) => line.content)
|
||||
.join('\n');
|
||||
// Attempt to infer language from filename, default to plain text if no filename
|
||||
const fileExtension = filename?.split('.').pop() || null;
|
||||
const language = fileExtension
|
||||
? getLanguageFromExtension(fileExtension)
|
||||
: null;
|
||||
renderedOutput = colorizeCode(
|
||||
addedContent,
|
||||
language,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
);
|
||||
} else {
|
||||
renderedOutput = renderDiffContent(
|
||||
parsedLines,
|
||||
filename,
|
||||
tabWidth,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
);
|
||||
}
|
||||
|
||||
return renderedOutput;
|
||||
};
|
||||
|
||||
const renderDiffContent = (
|
||||
parsedLines: DiffLine[],
|
||||
filename: string | undefined,
|
||||
tabWidth = DEFAULT_TAB_WIDTH,
|
||||
availableTerminalHeight: number | undefined,
|
||||
terminalWidth: number,
|
||||
) => {
|
||||
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing
|
||||
const normalizedLines = parsedLines.map((line) => ({
|
||||
...line,
|
||||
content: line.content.replace(/\t/g, ' '.repeat(tabWidth)),
|
||||
}));
|
||||
|
||||
// Filter out non-displayable lines (hunks, potentially 'other') using the normalized list
|
||||
const displayableLines = normalizedLines.filter(
|
||||
(l) => l.type !== 'hunk' && l.type !== 'other',
|
||||
);
|
||||
|
||||
if (displayableLines.length === 0) {
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={Colors.Gray} padding={1}>
|
||||
<Text dimColor>No changes detected.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate the minimum indentation across all displayable lines
|
||||
let baseIndentation = Infinity; // Start high to find the minimum
|
||||
for (const line of displayableLines) {
|
||||
// Only consider lines with actual content for indentation calculation
|
||||
if (line.content.trim() === '') continue;
|
||||
|
||||
const firstCharIndex = line.content.search(/\S/); // Find index of first non-whitespace char
|
||||
const currentIndent = firstCharIndex === -1 ? 0 : firstCharIndex; // Indent is 0 if no non-whitespace found
|
||||
baseIndentation = Math.min(baseIndentation, currentIndent);
|
||||
}
|
||||
// If baseIndentation remained Infinity (e.g., no displayable lines with content), default to 0
|
||||
if (!isFinite(baseIndentation)) {
|
||||
baseIndentation = 0;
|
||||
}
|
||||
|
||||
const key = filename
|
||||
? `diff-box-${filename}`
|
||||
: `diff-box-${crypto.createHash('sha1').update(JSON.stringify(parsedLines)).digest('hex')}`;
|
||||
|
||||
let lastLineNumber: number | null = null;
|
||||
const MAX_CONTEXT_LINES_WITHOUT_GAP = 5;
|
||||
|
||||
return (
|
||||
<MaxSizedBox
|
||||
maxHeight={availableTerminalHeight}
|
||||
maxWidth={terminalWidth}
|
||||
key={key}
|
||||
>
|
||||
{displayableLines.reduce<React.ReactNode[]>((acc, line, index) => {
|
||||
// Determine the relevant line number for gap calculation based on type
|
||||
let relevantLineNumberForGapCalc: number | null = null;
|
||||
if (line.type === 'add' || line.type === 'context') {
|
||||
relevantLineNumberForGapCalc = line.newLine ?? null;
|
||||
} else if (line.type === 'del') {
|
||||
// For deletions, the gap is typically in relation to the original file's line numbering
|
||||
relevantLineNumberForGapCalc = line.oldLine ?? null;
|
||||
}
|
||||
|
||||
if (
|
||||
lastLineNumber !== null &&
|
||||
relevantLineNumberForGapCalc !== null &&
|
||||
relevantLineNumberForGapCalc >
|
||||
lastLineNumber + MAX_CONTEXT_LINES_WITHOUT_GAP + 1
|
||||
) {
|
||||
acc.push(
|
||||
<Box key={`gap-${index}`}>
|
||||
<Text wrap="truncate">{'═'.repeat(terminalWidth)}</Text>
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
|
||||
const lineKey = `diff-line-${index}`;
|
||||
let gutterNumStr = '';
|
||||
let color: string | undefined = undefined;
|
||||
let prefixSymbol = ' ';
|
||||
let dim = false;
|
||||
|
||||
switch (line.type) {
|
||||
case 'add':
|
||||
gutterNumStr = (line.newLine ?? '').toString();
|
||||
color = 'green';
|
||||
prefixSymbol = '+';
|
||||
lastLineNumber = line.newLine ?? null;
|
||||
break;
|
||||
case 'del':
|
||||
gutterNumStr = (line.oldLine ?? '').toString();
|
||||
color = 'red';
|
||||
prefixSymbol = '-';
|
||||
// For deletions, update lastLineNumber based on oldLine if it's advancing.
|
||||
// This helps manage gaps correctly if there are multiple consecutive deletions
|
||||
// or if a deletion is followed by a context line far away in the original file.
|
||||
if (line.oldLine !== undefined) {
|
||||
lastLineNumber = line.oldLine;
|
||||
}
|
||||
break;
|
||||
case 'context':
|
||||
gutterNumStr = (line.newLine ?? '').toString();
|
||||
dim = true;
|
||||
prefixSymbol = ' ';
|
||||
lastLineNumber = line.newLine ?? null;
|
||||
break;
|
||||
default:
|
||||
return acc;
|
||||
}
|
||||
|
||||
const displayContent = line.content.substring(baseIndentation);
|
||||
|
||||
acc.push(
|
||||
<Box key={lineKey} flexDirection="row">
|
||||
<Text color={Colors.Gray}>{gutterNumStr.padEnd(4)} </Text>
|
||||
<Text color={color} dimColor={dim}>
|
||||
{prefixSymbol}{' '}
|
||||
</Text>
|
||||
<Text color={color} dimColor={dim} wrap="wrap">
|
||||
{displayContent}
|
||||
</Text>
|
||||
</Box>,
|
||||
);
|
||||
return acc;
|
||||
}, [])}
|
||||
</MaxSizedBox>
|
||||
);
|
||||
};
|
||||
|
||||
const getLanguageFromExtension = (extension: string): string | null => {
|
||||
const languageMap: { [key: string]: string } = {
|
||||
js: 'javascript',
|
||||
ts: 'typescript',
|
||||
py: 'python',
|
||||
json: 'json',
|
||||
css: 'css',
|
||||
html: 'html',
|
||||
sh: 'bash',
|
||||
md: 'markdown',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
txt: 'plaintext',
|
||||
java: 'java',
|
||||
c: 'c',
|
||||
cpp: 'cpp',
|
||||
rb: 'ruby',
|
||||
};
|
||||
return languageMap[extension] || null; // Return null if extension not found
|
||||
};
|
||||
31
packages/cli/src/ui/components/messages/ErrorMessage.tsx
Normal file
31
packages/cli/src/ui/components/messages/ErrorMessage.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
|
||||
interface ErrorMessageProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const ErrorMessage: React.FC<ErrorMessageProps> = ({ text }) => {
|
||||
const prefix = '✕ ';
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={Colors.AccentRed}>{prefix}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text wrap="wrap" color={Colors.AccentRed}>
|
||||
{text}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
43
packages/cli/src/ui/components/messages/GeminiMessage.tsx
Normal file
43
packages/cli/src/ui/components/messages/GeminiMessage.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { Colors } from '../../colors.js';
|
||||
|
||||
interface GeminiMessageProps {
|
||||
text: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
text,
|
||||
isPending,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
}) => {
|
||||
const prefix = '✦ ';
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={Colors.AccentPurple}>{prefix}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} flexDirection="column">
|
||||
<MarkdownDisplay
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
|
||||
interface GeminiMessageContentProps {
|
||||
text: string;
|
||||
isPending: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
/*
|
||||
* Gemini message content is a semi-hacked component. The intention is to represent a partial
|
||||
* of GeminiMessage and is only used when a response gets too long. In that instance messages
|
||||
* are split into multiple GeminiMessageContent's to enable the root <Static> component in
|
||||
* App.tsx to be as performant as humanly possible.
|
||||
*/
|
||||
export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
||||
text,
|
||||
isPending,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
}) => {
|
||||
const originalPrefix = '✦ ';
|
||||
const prefixWidth = originalPrefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={prefixWidth}>
|
||||
<MarkdownDisplay
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
31
packages/cli/src/ui/components/messages/InfoMessage.tsx
Normal file
31
packages/cli/src/ui/components/messages/InfoMessage.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
|
||||
interface InfoMessageProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const InfoMessage: React.FC<InfoMessageProps> = ({ text }) => {
|
||||
const prefix = 'ℹ ';
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginTop={1}>
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={Colors.AccentYellow}>{prefix}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text wrap="wrap" color={Colors.AccentYellow}>
|
||||
{text}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @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 { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
|
||||
import { ToolCallConfirmationDetails } from '@qwen/qwen-code-core';
|
||||
|
||||
describe('ToolConfirmationMessage', () => {
|
||||
it('should not display urls if prompt and url are the same', () => {
|
||||
const confirmationDetails: ToolCallConfirmationDetails = {
|
||||
type: 'info',
|
||||
title: 'Confirm Web Fetch',
|
||||
prompt: 'https://example.com',
|
||||
urls: ['https://example.com'],
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
|
||||
const { lastFrame } = render(
|
||||
<ToolConfirmationMessage
|
||||
confirmationDetails={confirmationDetails}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('URLs to fetch:');
|
||||
});
|
||||
|
||||
it('should display urls if prompt and url are different', () => {
|
||||
const confirmationDetails: ToolCallConfirmationDetails = {
|
||||
type: 'info',
|
||||
title: 'Confirm Web Fetch',
|
||||
prompt:
|
||||
'fetch https://github.com/google/gemini-react/blob/main/README.md',
|
||||
urls: [
|
||||
'https://raw.githubusercontent.com/google/gemini-react/main/README.md',
|
||||
],
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
|
||||
const { lastFrame } = render(
|
||||
<ToolConfirmationMessage
|
||||
confirmationDetails={confirmationDetails}
|
||||
availableTerminalHeight={30}
|
||||
terminalWidth={80}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('URLs to fetch:');
|
||||
expect(lastFrame()).toContain(
|
||||
'- https://raw.githubusercontent.com/google/gemini-react/main/README.md',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { DiffRenderer } from './DiffRenderer.js';
|
||||
import { Colors } from '../../colors.js';
|
||||
import {
|
||||
ToolCallConfirmationDetails,
|
||||
ToolConfirmationOutcome,
|
||||
ToolExecuteConfirmationDetails,
|
||||
ToolMcpConfirmationDetails,
|
||||
Config,
|
||||
} from '@qwen/qwen-code-core';
|
||||
import {
|
||||
RadioButtonSelect,
|
||||
RadioSelectItem,
|
||||
} from '../shared/RadioButtonSelect.js';
|
||||
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||
|
||||
export interface ToolConfirmationMessageProps {
|
||||
confirmationDetails: ToolCallConfirmationDetails;
|
||||
config?: Config;
|
||||
isFocused?: boolean;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
}
|
||||
|
||||
export const ToolConfirmationMessage: React.FC<
|
||||
ToolConfirmationMessageProps
|
||||
> = ({
|
||||
confirmationDetails,
|
||||
isFocused = true,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
}) => {
|
||||
const { onConfirm } = confirmationDetails;
|
||||
const childWidth = terminalWidth - 2; // 2 for padding
|
||||
|
||||
useInput((_, key) => {
|
||||
if (!isFocused) return;
|
||||
if (key.escape) {
|
||||
onConfirm(ToolConfirmationOutcome.Cancel);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSelect = (item: ToolConfirmationOutcome) => onConfirm(item);
|
||||
|
||||
let bodyContent: React.ReactNode | null = null; // Removed contextDisplay here
|
||||
let question: string;
|
||||
|
||||
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = new Array<
|
||||
RadioSelectItem<ToolConfirmationOutcome>
|
||||
>();
|
||||
|
||||
// Body content is now the DiffRenderer, passing filename to it
|
||||
// The bordered box is removed from here and handled within DiffRenderer
|
||||
|
||||
function availableBodyContentHeight() {
|
||||
if (options.length === 0) {
|
||||
// This should not happen in practice as options are always added before this is called.
|
||||
throw new Error('Options not provided for confirmation message');
|
||||
}
|
||||
|
||||
if (availableTerminalHeight === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Calculate the vertical space (in lines) consumed by UI elements
|
||||
// surrounding the main body content.
|
||||
const PADDING_OUTER_Y = 2; // Main container has `padding={1}` (top & bottom).
|
||||
const MARGIN_BODY_BOTTOM = 1; // margin on the body container.
|
||||
const HEIGHT_QUESTION = 1; // The question text is one line.
|
||||
const MARGIN_QUESTION_BOTTOM = 1; // Margin on the question container.
|
||||
const HEIGHT_OPTIONS = options.length; // Each option in the radio select takes one line.
|
||||
|
||||
const surroundingElementsHeight =
|
||||
PADDING_OUTER_Y +
|
||||
MARGIN_BODY_BOTTOM +
|
||||
HEIGHT_QUESTION +
|
||||
MARGIN_QUESTION_BOTTOM +
|
||||
HEIGHT_OPTIONS;
|
||||
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
|
||||
}
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
if (confirmationDetails.isModifying) {
|
||||
return (
|
||||
<Box
|
||||
minWidth="90%"
|
||||
borderStyle="round"
|
||||
borderColor={Colors.Gray}
|
||||
justifyContent="space-around"
|
||||
padding={1}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Text>Modify in progress: </Text>
|
||||
<Text color={Colors.AccentGreen}>
|
||||
Save and close external editor to continue
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
question = `Apply this change?`;
|
||||
options.push(
|
||||
{
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
},
|
||||
{
|
||||
label: 'Yes, allow always',
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
},
|
||||
{
|
||||
label: 'Modify with external editor',
|
||||
value: ToolConfirmationOutcome.ModifyWithEditor,
|
||||
},
|
||||
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
|
||||
);
|
||||
bodyContent = (
|
||||
<DiffRenderer
|
||||
diffContent={confirmationDetails.fileDiff}
|
||||
filename={confirmationDetails.fileName}
|
||||
availableTerminalHeight={availableBodyContentHeight()}
|
||||
terminalWidth={childWidth}
|
||||
/>
|
||||
);
|
||||
} else if (confirmationDetails.type === 'exec') {
|
||||
const executionProps =
|
||||
confirmationDetails as ToolExecuteConfirmationDetails;
|
||||
|
||||
question = `Allow execution?`;
|
||||
options.push(
|
||||
{
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
},
|
||||
{
|
||||
label: `Yes, allow always "${executionProps.rootCommand} ..."`,
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
},
|
||||
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
|
||||
);
|
||||
|
||||
let bodyContentHeight = availableBodyContentHeight();
|
||||
if (bodyContentHeight !== undefined) {
|
||||
bodyContentHeight -= 2; // Account for padding;
|
||||
}
|
||||
bodyContent = (
|
||||
<Box flexDirection="column">
|
||||
<Box paddingX={1} marginLeft={1}>
|
||||
<MaxSizedBox
|
||||
maxHeight={bodyContentHeight}
|
||||
maxWidth={Math.max(childWidth - 4, 1)}
|
||||
>
|
||||
<Box>
|
||||
<Text color={Colors.AccentCyan}>{executionProps.command}</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
} else if (confirmationDetails.type === 'info') {
|
||||
const infoProps = confirmationDetails;
|
||||
const displayUrls =
|
||||
infoProps.urls &&
|
||||
!(infoProps.urls.length === 1 && infoProps.urls[0] === infoProps.prompt);
|
||||
|
||||
question = `Do you want to proceed?`;
|
||||
options.push(
|
||||
{
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
},
|
||||
{
|
||||
label: 'Yes, allow always',
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
},
|
||||
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
|
||||
);
|
||||
|
||||
bodyContent = (
|
||||
<Box flexDirection="column" paddingX={1} marginLeft={1}>
|
||||
<Text color={Colors.AccentCyan}>{infoProps.prompt}</Text>
|
||||
{displayUrls && infoProps.urls && infoProps.urls.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text>URLs to fetch:</Text>
|
||||
{infoProps.urls.map((url) => (
|
||||
<Text key={url}> - {url}</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
} else {
|
||||
// mcp tool confirmation
|
||||
const mcpProps = confirmationDetails as ToolMcpConfirmationDetails;
|
||||
|
||||
bodyContent = (
|
||||
<Box flexDirection="column" paddingX={1} marginLeft={1}>
|
||||
<Text color={Colors.AccentCyan}>MCP Server: {mcpProps.serverName}</Text>
|
||||
<Text color={Colors.AccentCyan}>Tool: {mcpProps.toolName}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`;
|
||||
options.push(
|
||||
{
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
},
|
||||
{
|
||||
label: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysTool, // Cast until types are updated
|
||||
},
|
||||
{
|
||||
label: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
},
|
||||
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1} width={childWidth}>
|
||||
{/* Body Content (Diff Renderer or Command Info) */}
|
||||
{/* No separate context display here anymore for edits */}
|
||||
<Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}>
|
||||
{bodyContent}
|
||||
</Box>
|
||||
|
||||
{/* Confirmation Question */}
|
||||
<Box marginBottom={1} flexShrink={0}>
|
||||
<Text wrap="truncate">{question}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Select Input for Options */}
|
||||
<Box flexShrink={0}>
|
||||
<RadioButtonSelect
|
||||
items={options}
|
||||
onSelect={handleSelect}
|
||||
isFocused={isFocused}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
123
packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
Normal file
123
packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { IndividualToolCallDisplay, ToolCallStatus } from '../../types.js';
|
||||
import { ToolMessage } from './ToolMessage.js';
|
||||
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
|
||||
import { Colors } from '../../colors.js';
|
||||
import { Config } from '@qwen/qwen-code-core';
|
||||
|
||||
interface ToolGroupMessageProps {
|
||||
groupId: number;
|
||||
toolCalls: IndividualToolCallDisplay[];
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
config?: Config;
|
||||
isFocused?: boolean;
|
||||
}
|
||||
|
||||
// Main component renders the border and maps the tools using ToolMessage
|
||||
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
toolCalls,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
config,
|
||||
isFocused = true,
|
||||
}) => {
|
||||
const hasPending = !toolCalls.every(
|
||||
(t) => t.status === ToolCallStatus.Success,
|
||||
);
|
||||
const borderColor = hasPending ? Colors.AccentYellow : Colors.Gray;
|
||||
|
||||
const staticHeight = /* border */ 2 + /* marginBottom */ 1;
|
||||
// This is a bit of a magic number, but it accounts for the border and
|
||||
// marginLeft.
|
||||
const innerWidth = terminalWidth - 4;
|
||||
|
||||
// only prompt for tool approval on the first 'confirming' tool in the list
|
||||
// note, after the CTA, this automatically moves over to the next 'confirming' tool
|
||||
const toolAwaitingApproval = useMemo(
|
||||
() => toolCalls.find((tc) => tc.status === ToolCallStatus.Confirming),
|
||||
[toolCalls],
|
||||
);
|
||||
|
||||
let countToolCallsWithResults = 0;
|
||||
for (const tool of toolCalls) {
|
||||
if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') {
|
||||
countToolCallsWithResults++;
|
||||
}
|
||||
}
|
||||
const countOneLineToolCalls = toolCalls.length - countToolCallsWithResults;
|
||||
const availableTerminalHeightPerToolMessage = availableTerminalHeight
|
||||
? Math.max(
|
||||
Math.floor(
|
||||
(availableTerminalHeight - staticHeight - countOneLineToolCalls) /
|
||||
Math.max(1, countToolCallsWithResults),
|
||||
),
|
||||
1,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
/*
|
||||
This width constraint is highly important and protects us from an Ink rendering bug.
|
||||
Since the ToolGroup can typically change rendering states frequently, it can cause
|
||||
Ink to render the border of the box incorrectly and span multiple lines and even
|
||||
cause tearing.
|
||||
*/
|
||||
width="100%"
|
||||
marginLeft={1}
|
||||
borderDimColor={hasPending}
|
||||
borderColor={borderColor}
|
||||
>
|
||||
{toolCalls.map((tool) => {
|
||||
const isConfirming = toolAwaitingApproval?.callId === tool.callId;
|
||||
return (
|
||||
<Box key={tool.callId} flexDirection="column" minHeight={1}>
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
<ToolMessage
|
||||
callId={tool.callId}
|
||||
name={tool.name}
|
||||
description={tool.description}
|
||||
resultDisplay={tool.resultDisplay}
|
||||
status={tool.status}
|
||||
confirmationDetails={tool.confirmationDetails}
|
||||
availableTerminalHeight={availableTerminalHeightPerToolMessage}
|
||||
terminalWidth={innerWidth}
|
||||
emphasis={
|
||||
isConfirming
|
||||
? 'high'
|
||||
: toolAwaitingApproval
|
||||
? 'low'
|
||||
: 'medium'
|
||||
}
|
||||
renderOutputAsMarkdown={tool.renderOutputAsMarkdown}
|
||||
/>
|
||||
</Box>
|
||||
{tool.status === ToolCallStatus.Confirming &&
|
||||
isConfirming &&
|
||||
tool.confirmationDetails && (
|
||||
<ToolConfirmationMessage
|
||||
confirmationDetails={tool.confirmationDetails}
|
||||
config={config}
|
||||
isFocused={isFocused}
|
||||
availableTerminalHeight={
|
||||
availableTerminalHeightPerToolMessage
|
||||
}
|
||||
terminalWidth={innerWidth}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
181
packages/cli/src/ui/components/messages/ToolMessage.test.tsx
Normal file
181
packages/cli/src/ui/components/messages/ToolMessage.test.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { ToolMessage, ToolMessageProps } from './ToolMessage.js';
|
||||
import { StreamingState, ToolCallStatus } from '../../types.js';
|
||||
import { Text } from 'ink';
|
||||
import { StreamingContext } from '../../contexts/StreamingContext.js';
|
||||
|
||||
// Mock child components or utilities if they are complex or have side effects
|
||||
vi.mock('../GeminiRespondingSpinner.js', () => ({
|
||||
GeminiRespondingSpinner: ({
|
||||
nonRespondingDisplay,
|
||||
}: {
|
||||
nonRespondingDisplay?: string;
|
||||
}) => {
|
||||
const streamingState = React.useContext(StreamingContext)!;
|
||||
if (streamingState === StreamingState.Responding) {
|
||||
return <Text>MockRespondingSpinner</Text>;
|
||||
}
|
||||
return nonRespondingDisplay ? <Text>{nonRespondingDisplay}</Text> : null;
|
||||
},
|
||||
}));
|
||||
vi.mock('./DiffRenderer.js', () => ({
|
||||
DiffRenderer: function MockDiffRenderer({
|
||||
diffContent,
|
||||
}: {
|
||||
diffContent: string;
|
||||
}) {
|
||||
return <Text>MockDiff:{diffContent}</Text>;
|
||||
},
|
||||
}));
|
||||
vi.mock('../../utils/MarkdownDisplay.js', () => ({
|
||||
MarkdownDisplay: function MockMarkdownDisplay({ text }: { text: string }) {
|
||||
return <Text>MockMarkdown:{text}</Text>;
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper to render with context
|
||||
const renderWithContext = (
|
||||
ui: React.ReactElement,
|
||||
streamingState: StreamingState,
|
||||
) => {
|
||||
const contextValue: StreamingState = streamingState;
|
||||
return render(
|
||||
<StreamingContext.Provider value={contextValue}>
|
||||
{ui}
|
||||
</StreamingContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('<ToolMessage />', () => {
|
||||
const baseProps: ToolMessageProps = {
|
||||
callId: 'tool-123',
|
||||
name: 'test-tool',
|
||||
description: 'A tool for testing',
|
||||
resultDisplay: 'Test result',
|
||||
status: ToolCallStatus.Success,
|
||||
terminalWidth: 80,
|
||||
confirmationDetails: undefined,
|
||||
emphasis: 'medium',
|
||||
};
|
||||
|
||||
it('renders basic tool information', () => {
|
||||
const { lastFrame } = renderWithContext(
|
||||
<ToolMessage {...baseProps} />,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
const output = lastFrame();
|
||||
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', () => {
|
||||
const { lastFrame } = renderWithContext(
|
||||
<ToolMessage {...baseProps} status={ToolCallStatus.Success} />,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
expect(lastFrame()).toContain('✔');
|
||||
});
|
||||
|
||||
it('shows o for Pending status', () => {
|
||||
const { lastFrame } = renderWithContext(
|
||||
<ToolMessage {...baseProps} status={ToolCallStatus.Pending} />,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
expect(lastFrame()).toContain('o');
|
||||
});
|
||||
|
||||
it('shows ? for Confirming status', () => {
|
||||
const { lastFrame } = renderWithContext(
|
||||
<ToolMessage {...baseProps} status={ToolCallStatus.Confirming} />,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
expect(lastFrame()).toContain('?');
|
||||
});
|
||||
|
||||
it('shows - for Canceled status', () => {
|
||||
const { lastFrame } = renderWithContext(
|
||||
<ToolMessage {...baseProps} status={ToolCallStatus.Canceled} />,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
expect(lastFrame()).toContain('-');
|
||||
});
|
||||
|
||||
it('shows x for Error status', () => {
|
||||
const { lastFrame } = renderWithContext(
|
||||
<ToolMessage {...baseProps} status={ToolCallStatus.Error} />,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
expect(lastFrame()).toContain('x');
|
||||
});
|
||||
|
||||
it('shows paused spinner for Executing status when streamingState is Idle', () => {
|
||||
const { lastFrame } = renderWithContext(
|
||||
<ToolMessage {...baseProps} status={ToolCallStatus.Executing} />,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
expect(lastFrame()).toContain('⊷');
|
||||
expect(lastFrame()).not.toContain('MockRespondingSpinner');
|
||||
expect(lastFrame()).not.toContain('✔');
|
||||
});
|
||||
|
||||
it('shows paused spinner for Executing status when streamingState is WaitingForConfirmation', () => {
|
||||
const { lastFrame } = renderWithContext(
|
||||
<ToolMessage {...baseProps} status={ToolCallStatus.Executing} />,
|
||||
StreamingState.WaitingForConfirmation,
|
||||
);
|
||||
expect(lastFrame()).toContain('⊷');
|
||||
expect(lastFrame()).not.toContain('MockRespondingSpinner');
|
||||
expect(lastFrame()).not.toContain('✔');
|
||||
});
|
||||
|
||||
it('shows MockRespondingSpinner for Executing status when streamingState is Responding', () => {
|
||||
const { lastFrame } = renderWithContext(
|
||||
<ToolMessage {...baseProps} status={ToolCallStatus.Executing} />,
|
||||
StreamingState.Responding, // Simulate app still responding
|
||||
);
|
||||
expect(lastFrame()).toContain('MockRespondingSpinner');
|
||||
expect(lastFrame()).not.toContain('✔');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders DiffRenderer for diff results', () => {
|
||||
const diffResult = {
|
||||
fileDiff: '--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new',
|
||||
fileName: 'file.txt',
|
||||
};
|
||||
const { lastFrame } = renderWithContext(
|
||||
<ToolMessage {...baseProps} resultDisplay={diffResult} />,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
// Check that the output contains the MockDiff content as part of the whole message
|
||||
expect(lastFrame()).toMatch(/MockDiff:--- a\/file\.txt/);
|
||||
});
|
||||
|
||||
it('renders emphasis correctly', () => {
|
||||
const { lastFrame: highEmphasisFrame } = renderWithContext(
|
||||
<ToolMessage {...baseProps} emphasis="high" />,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
// Check for trailing indicator or specific color if applicable (Colors are not easily testable here)
|
||||
expect(highEmphasisFrame()).toContain('←'); // Trailing indicator for high emphasis
|
||||
|
||||
const { lastFrame: lowEmphasisFrame } = renderWithContext(
|
||||
<ToolMessage {...baseProps} emphasis="low" />,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
// For low emphasis, the name and description might be dimmed (check for dimColor if possible)
|
||||
// This is harder to assert directly in text output without color checks.
|
||||
// We can at least ensure it doesn't have the high emphasis indicator.
|
||||
expect(lowEmphasisFrame()).not.toContain('←');
|
||||
});
|
||||
});
|
||||
194
packages/cli/src/ui/components/messages/ToolMessage.tsx
Normal file
194
packages/cli/src/ui/components/messages/ToolMessage.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { IndividualToolCallDisplay, 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';
|
||||
|
||||
const STATIC_HEIGHT = 1;
|
||||
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
|
||||
const STATUS_INDICATOR_WIDTH = 3;
|
||||
const MIN_LINES_SHOWN = 2; // show at least this many lines
|
||||
|
||||
// Large threshold to ensure we don't cause performance issues for very large
|
||||
// outputs that will get truncated further MaxSizedBox anyway.
|
||||
const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 1000000;
|
||||
export type TextEmphasis = 'high' | 'medium' | 'low';
|
||||
|
||||
export interface ToolMessageProps extends IndividualToolCallDisplay {
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
emphasis?: TextEmphasis;
|
||||
renderOutputAsMarkdown?: boolean;
|
||||
}
|
||||
|
||||
export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
name,
|
||||
description,
|
||||
resultDisplay,
|
||||
status,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
emphasis = 'medium',
|
||||
renderOutputAsMarkdown = true,
|
||||
}) => {
|
||||
const availableHeight = availableTerminalHeight
|
||||
? Math.max(
|
||||
availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT,
|
||||
MIN_LINES_SHOWN + 1, // enforce minimum lines shown
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Long tool call response in MarkdownDisplay doesn't respect availableTerminalHeight properly,
|
||||
// we're forcing it to not render as markdown when the response is too long, it will fallback
|
||||
// to render as plain text, which is contained within the terminal using MaxSizedBox
|
||||
if (availableHeight) {
|
||||
renderOutputAsMarkdown = false;
|
||||
}
|
||||
|
||||
const childWidth = terminalWidth - 3; // account for padding.
|
||||
if (typeof resultDisplay === 'string') {
|
||||
if (resultDisplay.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) {
|
||||
// Truncate the result display to fit within the available width.
|
||||
resultDisplay =
|
||||
'...' + resultDisplay.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Box paddingX={1} paddingY={0} flexDirection="column">
|
||||
<Box minHeight={1}>
|
||||
<ToolStatusIndicator status={status} />
|
||||
<ToolInfo
|
||||
name={name}
|
||||
status={status}
|
||||
description={description}
|
||||
emphasis={emphasis}
|
||||
/>
|
||||
{emphasis === 'high' && <TrailingIndicator />}
|
||||
</Box>
|
||||
{resultDisplay && (
|
||||
<Box paddingLeft={STATUS_INDICATOR_WIDTH} width="100%" marginTop={1}>
|
||||
<Box flexDirection="column">
|
||||
{typeof resultDisplay === 'string' && renderOutputAsMarkdown && (
|
||||
<Box flexDirection="column">
|
||||
<MarkdownDisplay
|
||||
text={resultDisplay}
|
||||
isPending={false}
|
||||
availableTerminalHeight={availableHeight}
|
||||
terminalWidth={childWidth}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{typeof resultDisplay === 'string' && !renderOutputAsMarkdown && (
|
||||
<MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>
|
||||
<Box>
|
||||
<Text wrap="wrap">{resultDisplay}</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
)}
|
||||
{typeof resultDisplay !== 'string' && (
|
||||
<DiffRenderer
|
||||
diffContent={resultDisplay.fileDiff}
|
||||
filename={resultDisplay.fileName}
|
||||
availableTerminalHeight={availableHeight}
|
||||
terminalWidth={childWidth}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
type ToolStatusIndicatorProps = {
|
||||
status: ToolCallStatus;
|
||||
};
|
||||
|
||||
const ToolStatusIndicator: React.FC<ToolStatusIndicatorProps> = ({
|
||||
status,
|
||||
}) => (
|
||||
<Box minWidth={STATUS_INDICATOR_WIDTH}>
|
||||
{status === ToolCallStatus.Pending && (
|
||||
<Text color={Colors.AccentGreen}>o</Text>
|
||||
)}
|
||||
{status === ToolCallStatus.Executing && (
|
||||
<GeminiRespondingSpinner
|
||||
spinnerType="toggle"
|
||||
nonRespondingDisplay={'⊷'}
|
||||
/>
|
||||
)}
|
||||
{status === ToolCallStatus.Success && (
|
||||
<Text color={Colors.AccentGreen}>✔</Text>
|
||||
)}
|
||||
{status === ToolCallStatus.Confirming && (
|
||||
<Text color={Colors.AccentYellow}>?</Text>
|
||||
)}
|
||||
{status === ToolCallStatus.Canceled && (
|
||||
<Text color={Colors.AccentYellow} bold>
|
||||
-
|
||||
</Text>
|
||||
)}
|
||||
{status === ToolCallStatus.Error && (
|
||||
<Text color={Colors.AccentRed} bold>
|
||||
x
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
type ToolInfo = {
|
||||
name: string;
|
||||
description: string;
|
||||
status: ToolCallStatus;
|
||||
emphasis: TextEmphasis;
|
||||
};
|
||||
const ToolInfo: React.FC<ToolInfo> = ({
|
||||
name,
|
||||
description,
|
||||
status,
|
||||
emphasis,
|
||||
}) => {
|
||||
const nameColor = React.useMemo<string>(() => {
|
||||
switch (emphasis) {
|
||||
case 'high':
|
||||
return Colors.Foreground;
|
||||
case 'medium':
|
||||
return Colors.Foreground;
|
||||
case 'low':
|
||||
return Colors.Gray;
|
||||
default: {
|
||||
const exhaustiveCheck: never = emphasis;
|
||||
return exhaustiveCheck;
|
||||
}
|
||||
}
|
||||
}, [emphasis]);
|
||||
return (
|
||||
<Box>
|
||||
<Text
|
||||
wrap="truncate-end"
|
||||
strikethrough={status === ToolCallStatus.Canceled}
|
||||
>
|
||||
<Text color={nameColor} bold>
|
||||
{name}
|
||||
</Text>{' '}
|
||||
<Text color={Colors.Gray}>{description}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const TrailingIndicator: React.FC = () => (
|
||||
<Text color={Colors.Foreground} wrap="truncate">
|
||||
{' '}
|
||||
←
|
||||
</Text>
|
||||
);
|
||||
39
packages/cli/src/ui/components/messages/UserMessage.tsx
Normal file
39
packages/cli/src/ui/components/messages/UserMessage.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
|
||||
interface UserMessageProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const UserMessage: React.FC<UserMessageProps> = ({ text }) => {
|
||||
const prefix = '> ';
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.Gray}
|
||||
flexDirection="row"
|
||||
paddingX={2}
|
||||
paddingY={0}
|
||||
marginY={1}
|
||||
alignSelf="flex-start"
|
||||
>
|
||||
<Box width={prefixWidth}>
|
||||
<Text color={Colors.Gray}>{prefix}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text wrap="wrap" color={Colors.Gray}>
|
||||
{text}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
25
packages/cli/src/ui/components/messages/UserShellMessage.tsx
Normal file
25
packages/cli/src/ui/components/messages/UserShellMessage.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
|
||||
interface UserShellMessageProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const UserShellMessage: React.FC<UserShellMessageProps> = ({ text }) => {
|
||||
// Remove leading '!' if present, as App.tsx adds it for the processor.
|
||||
const commandToDisplay = text.startsWith('!') ? text.substring(1) : text;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={Colors.AccentCyan}>$ </Text>
|
||||
<Text>{commandToDisplay}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
342
packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx
Normal file
342
packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { OverflowProvider } from '../../contexts/OverflowContext.js';
|
||||
import { MaxSizedBox, setMaxSizedBoxDebugging } from './MaxSizedBox.js';
|
||||
import { Box, Text } from 'ink';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('<MaxSizedBox />', () => {
|
||||
// Make sure MaxSizedBox logs errors on invalid configurations.
|
||||
// This is useful for debugging issues with the component.
|
||||
// It should be set to false in production for performance and to avoid
|
||||
// cluttering the console if there are ignorable issues.
|
||||
setMaxSizedBoxDebugging(true);
|
||||
|
||||
it('renders children without truncation when they fit', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={10}>
|
||||
<Box>
|
||||
<Text>Hello, World!</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(lastFrame()).equals('Hello, World!');
|
||||
});
|
||||
|
||||
it('hides lines when content exceeds maxHeight', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={2}>
|
||||
<Box>
|
||||
<Text>Line 1</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Line 2</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Line 3</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(lastFrame()).equals(`... first 2 lines hidden ...
|
||||
Line 3`);
|
||||
});
|
||||
|
||||
it('hides lines at the end when content exceeds maxHeight and overflowDirection is bottom', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
|
||||
<Box>
|
||||
<Text>Line 1</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Line 2</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Line 3</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(lastFrame()).equals(`Line 1
|
||||
... last 2 lines hidden ...`);
|
||||
});
|
||||
|
||||
it('wraps text that exceeds maxWidth', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={10} maxHeight={5}>
|
||||
<Box>
|
||||
<Text wrap="wrap">This is a long line of text</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).equals(`This is a
|
||||
long line
|
||||
of text`);
|
||||
});
|
||||
|
||||
it('handles mixed wrapping and non-wrapping segments', () => {
|
||||
const multilineText = `This part will wrap around.
|
||||
And has a line break.
|
||||
Leading spaces preserved.`;
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={20} maxHeight={20}>
|
||||
<Box>
|
||||
<Text>Example</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>No Wrap: </Text>
|
||||
<Text wrap="wrap">{multilineText}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Longer No Wrap: </Text>
|
||||
<Text wrap="wrap">This part will wrap around.</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).equals(
|
||||
`Example
|
||||
No Wrap: This part
|
||||
will wrap
|
||||
around.
|
||||
And has a
|
||||
line break.
|
||||
Leading
|
||||
spaces
|
||||
preserved.
|
||||
Longer No Wrap: This
|
||||
part
|
||||
will
|
||||
wrap
|
||||
arou
|
||||
nd.`,
|
||||
);
|
||||
});
|
||||
|
||||
it('handles words longer than maxWidth by splitting them', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={5} maxHeight={5}>
|
||||
<Box>
|
||||
<Text wrap="wrap">Supercalifragilisticexpialidocious</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).equals(`... …
|
||||
istic
|
||||
expia
|
||||
lidoc
|
||||
ious`);
|
||||
});
|
||||
|
||||
it('does not truncate when maxHeight is undefined', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={undefined}>
|
||||
<Box>
|
||||
<Text>Line 1</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Line 2</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(lastFrame()).equals(`Line 1
|
||||
Line 2`);
|
||||
});
|
||||
|
||||
it('shows plural "lines" when more than one line is hidden', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={2}>
|
||||
<Box>
|
||||
<Text>Line 1</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Line 2</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Line 3</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(lastFrame()).equals(`... first 2 lines hidden ...
|
||||
Line 3`);
|
||||
});
|
||||
|
||||
it('shows plural "lines" when more than one line is hidden and overflowDirection is bottom', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
|
||||
<Box>
|
||||
<Text>Line 1</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Line 2</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Line 3</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(lastFrame()).equals(`Line 1
|
||||
... last 2 lines hidden ...`);
|
||||
});
|
||||
|
||||
it('renders an empty box for empty children', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={10}></MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
// Expect an empty string or a box with nothing in it.
|
||||
// Ink renders an empty box as an empty string.
|
||||
expect(lastFrame()).equals('');
|
||||
});
|
||||
|
||||
it('wraps text with multi-byte unicode characters correctly', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={5} maxHeight={5}>
|
||||
<Box>
|
||||
<Text wrap="wrap">你好世界</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
// "你好" has a visual width of 4. "世界" has a visual width of 4.
|
||||
// With maxWidth=5, it should wrap after the second character.
|
||||
expect(lastFrame()).equals(`你好
|
||||
世界`);
|
||||
});
|
||||
|
||||
it('wraps text with multi-byte emoji characters correctly', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={5} maxHeight={5}>
|
||||
<Box>
|
||||
<Text wrap="wrap">🐶🐶🐶🐶🐶</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
// Each "🐶" has a visual width of 2.
|
||||
// With maxWidth=5, it should wrap every 2 emojis.
|
||||
expect(lastFrame()).equals(`🐶🐶
|
||||
🐶🐶
|
||||
🐶`);
|
||||
});
|
||||
|
||||
it('accounts for additionalHiddenLinesCount', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={2} additionalHiddenLinesCount={5}>
|
||||
<Box>
|
||||
<Text>Line 1</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Line 2</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Line 3</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
// 1 line is hidden by overflow, 5 are additionally hidden.
|
||||
expect(lastFrame()).equals(`... first 7 lines hidden ...
|
||||
Line 3`);
|
||||
});
|
||||
|
||||
it('handles React.Fragment as a child', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={10}>
|
||||
<>
|
||||
<Box>
|
||||
<Text>Line 1 from Fragment</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Line 2 from Fragment</Text>
|
||||
</Box>
|
||||
</>
|
||||
<Box>
|
||||
<Text>Line 3 direct child</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(lastFrame()).equals(`Line 1 from Fragment
|
||||
Line 2 from Fragment
|
||||
Line 3 direct child`);
|
||||
});
|
||||
|
||||
it('clips a long single text child from the top', () => {
|
||||
const THIRTY_LINES = Array.from(
|
||||
{ length: 30 },
|
||||
(_, i) => `Line ${i + 1}`,
|
||||
).join('\n');
|
||||
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={10}>
|
||||
<Box>
|
||||
<Text>{THIRTY_LINES}</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
const expected = [
|
||||
'... first 21 lines hidden ...',
|
||||
...Array.from({ length: 9 }, (_, i) => `Line ${22 + i}`),
|
||||
].join('\n');
|
||||
|
||||
expect(lastFrame()).equals(expected);
|
||||
});
|
||||
|
||||
it('clips a long single text child from the bottom', () => {
|
||||
const THIRTY_LINES = Array.from(
|
||||
{ length: 30 },
|
||||
(_, i) => `Line ${i + 1}`,
|
||||
).join('\n');
|
||||
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={80} maxHeight={10} overflowDirection="bottom">
|
||||
<Box>
|
||||
<Text>{THIRTY_LINES}</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
const expected = [
|
||||
...Array.from({ length: 9 }, (_, i) => `Line ${i + 1}`),
|
||||
'... last 21 lines hidden ...',
|
||||
].join('\n');
|
||||
|
||||
expect(lastFrame()).equals(expected);
|
||||
});
|
||||
});
|
||||
547
packages/cli/src/ui/components/shared/MaxSizedBox.tsx
Normal file
547
packages/cli/src/ui/components/shared/MaxSizedBox.tsx
Normal file
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { Fragment, useEffect, useId } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import stringWidth from 'string-width';
|
||||
import { Colors } from '../../colors.js';
|
||||
import { toCodePoints } from '../../utils/textUtils.js';
|
||||
import { useOverflowActions } from '../../contexts/OverflowContext.js';
|
||||
|
||||
let enableDebugLog = false;
|
||||
|
||||
/**
|
||||
* Minimum height for the MaxSizedBox component.
|
||||
* This ensures there is room for at least one line of content as well as the
|
||||
* message that content was truncated.
|
||||
*/
|
||||
export const MINIMUM_MAX_HEIGHT = 2;
|
||||
|
||||
export function setMaxSizedBoxDebugging(value: boolean) {
|
||||
enableDebugLog = value;
|
||||
}
|
||||
|
||||
function debugReportError(message: string, element: React.ReactNode) {
|
||||
if (!enableDebugLog) return;
|
||||
|
||||
if (!React.isValidElement(element)) {
|
||||
console.error(
|
||||
message,
|
||||
`Invalid element: '${String(element)}' typeof=${typeof element}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let sourceMessage = '<Unknown file>';
|
||||
try {
|
||||
const elementWithSource = element as {
|
||||
_source?: { fileName?: string; lineNumber?: number };
|
||||
};
|
||||
const fileName = elementWithSource._source?.fileName;
|
||||
const lineNumber = elementWithSource._source?.lineNumber;
|
||||
sourceMessage = fileName ? `${fileName}:${lineNumber}` : '<Unknown file>';
|
||||
} catch (error) {
|
||||
console.error('Error while trying to get file name:', error);
|
||||
}
|
||||
|
||||
console.error(message, `${String(element.type)}. Source: ${sourceMessage}`);
|
||||
}
|
||||
interface MaxSizedBoxProps {
|
||||
children?: React.ReactNode;
|
||||
maxWidth?: number;
|
||||
maxHeight: number | undefined;
|
||||
overflowDirection?: 'top' | 'bottom';
|
||||
additionalHiddenLinesCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A React component that constrains the size of its children and provides
|
||||
* content-aware truncation when the content exceeds the specified `maxHeight`.
|
||||
*
|
||||
* `MaxSizedBox` requires a specific structure for its children to correctly
|
||||
* measure and render the content:
|
||||
*
|
||||
* 1. **Direct children must be `<Box>` elements.** Each `<Box>` represents a
|
||||
* single row of content.
|
||||
* 2. **Row `<Box>` elements must contain only `<Text>` elements.** These
|
||||
* `<Text>` elements can be nested and there are no restrictions to Text
|
||||
* element styling other than that non-wrapping text elements must be
|
||||
* before wrapping text elements.
|
||||
*
|
||||
* **Constraints:**
|
||||
* - **Box Properties:** Custom properties on the child `<Box>` elements are
|
||||
* ignored. In debug mode, runtime checks will report errors for any
|
||||
* unsupported properties.
|
||||
* - **Text Wrapping:** Within a single row, `<Text>` elements with no wrapping
|
||||
* (e.g., headers, labels) must appear before any `<Text>` elements that wrap.
|
||||
* - **Element Types:** Runtime checks will warn if unsupported element types
|
||||
* are used as children.
|
||||
*
|
||||
* @example
|
||||
* <MaxSizedBox maxWidth={80} maxHeight={10}>
|
||||
* <Box>
|
||||
* <Text>This is the first line.</Text>
|
||||
* </Box>
|
||||
* <Box>
|
||||
* <Text color="cyan" wrap="truncate">Non-wrapping Header: </Text>
|
||||
* <Text>This is the rest of the line which will wrap if it's too long.</Text>
|
||||
* </Box>
|
||||
* <Box>
|
||||
* <Text>
|
||||
* Line 3 with <Text color="yellow">nested styled text</Text> inside of it.
|
||||
* </Text>
|
||||
* </Box>
|
||||
* </MaxSizedBox>
|
||||
*/
|
||||
export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
|
||||
children,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
overflowDirection = 'top',
|
||||
additionalHiddenLinesCount = 0,
|
||||
}) => {
|
||||
const id = useId();
|
||||
const { addOverflowingId, removeOverflowingId } = useOverflowActions() || {};
|
||||
|
||||
const laidOutStyledText: StyledText[][] = [];
|
||||
const targetMaxHeight = Math.max(
|
||||
Math.round(maxHeight ?? Number.MAX_SAFE_INTEGER),
|
||||
MINIMUM_MAX_HEIGHT,
|
||||
);
|
||||
|
||||
if (maxWidth === undefined) {
|
||||
throw new Error('maxWidth must be defined when maxHeight is set.');
|
||||
}
|
||||
function visitRows(element: React.ReactNode) {
|
||||
if (!React.isValidElement<{ children?: React.ReactNode }>(element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.type === Fragment) {
|
||||
React.Children.forEach(element.props.children, visitRows);
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.type === Box) {
|
||||
layoutInkElementAsStyledText(element, maxWidth!, laidOutStyledText);
|
||||
return;
|
||||
}
|
||||
|
||||
debugReportError('MaxSizedBox children must be <Box> elements', element);
|
||||
}
|
||||
|
||||
React.Children.forEach(children, visitRows);
|
||||
|
||||
const contentWillOverflow =
|
||||
(targetMaxHeight !== undefined &&
|
||||
laidOutStyledText.length > targetMaxHeight) ||
|
||||
additionalHiddenLinesCount > 0;
|
||||
const visibleContentHeight =
|
||||
contentWillOverflow && targetMaxHeight !== undefined
|
||||
? targetMaxHeight - 1
|
||||
: targetMaxHeight;
|
||||
|
||||
const hiddenLinesCount =
|
||||
visibleContentHeight !== undefined
|
||||
? Math.max(0, laidOutStyledText.length - visibleContentHeight)
|
||||
: 0;
|
||||
const totalHiddenLines = hiddenLinesCount + additionalHiddenLinesCount;
|
||||
|
||||
useEffect(() => {
|
||||
if (totalHiddenLines > 0) {
|
||||
addOverflowingId?.(id);
|
||||
} else {
|
||||
removeOverflowingId?.(id);
|
||||
}
|
||||
|
||||
return () => {
|
||||
removeOverflowingId?.(id);
|
||||
};
|
||||
}, [id, totalHiddenLines, addOverflowingId, removeOverflowingId]);
|
||||
|
||||
const visibleStyledText =
|
||||
hiddenLinesCount > 0
|
||||
? overflowDirection === 'top'
|
||||
? laidOutStyledText.slice(hiddenLinesCount, laidOutStyledText.length)
|
||||
: laidOutStyledText.slice(0, visibleContentHeight)
|
||||
: laidOutStyledText;
|
||||
|
||||
const visibleLines = visibleStyledText.map((line, index) => (
|
||||
<Box key={index}>
|
||||
{line.length > 0 ? (
|
||||
line.map((segment, segIndex) => (
|
||||
<Text key={segIndex} {...segment.props}>
|
||||
{segment.text}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text> </Text>
|
||||
)}
|
||||
</Box>
|
||||
));
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={maxWidth} flexShrink={0}>
|
||||
{totalHiddenLines > 0 && overflowDirection === 'top' && (
|
||||
<Text color={Colors.Gray} wrap="truncate">
|
||||
... first {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
|
||||
hidden ...
|
||||
</Text>
|
||||
)}
|
||||
{visibleLines}
|
||||
{totalHiddenLines > 0 && overflowDirection === 'bottom' && (
|
||||
<Text color={Colors.Gray} wrap="truncate">
|
||||
... last {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
|
||||
hidden ...
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// Define a type for styled text segments
|
||||
interface StyledText {
|
||||
text: string;
|
||||
props: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single row of content within the MaxSizedBox.
|
||||
*
|
||||
* A row can contain segments that are not wrapped, followed by segments that
|
||||
* are. This is a minimal implementation that only supports the functionality
|
||||
* needed today.
|
||||
*/
|
||||
interface Row {
|
||||
noWrapSegments: StyledText[];
|
||||
segments: StyledText[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens the child elements of MaxSizedBox into an array of `Row` objects.
|
||||
*
|
||||
* This function expects a specific child structure to function correctly:
|
||||
* 1. The top-level child of `MaxSizedBox` should be a single `<Box>`. This
|
||||
* outer box is primarily for structure and is not directly rendered.
|
||||
* 2. Inside the outer `<Box>`, there should be one or more children. Each of
|
||||
* these children must be a `<Box>` that represents a row.
|
||||
* 3. Inside each "row" `<Box>`, the children must be `<Text>` components.
|
||||
*
|
||||
* The structure should look like this:
|
||||
* <MaxSizedBox>
|
||||
* <Box> // Row 1
|
||||
* <Text>...</Text>
|
||||
* <Text>...</Text>
|
||||
* </Box>
|
||||
* <Box> // Row 2
|
||||
* <Text>...</Text>
|
||||
* </Box>
|
||||
* </MaxSizedBox>
|
||||
*
|
||||
* It is an error for a <Text> child without wrapping to appear after a
|
||||
* <Text> child with wrapping within the same row Box.
|
||||
*
|
||||
* @param element The React node to flatten.
|
||||
* @returns An array of `Row` objects.
|
||||
*/
|
||||
function visitBoxRow(element: React.ReactNode): Row {
|
||||
if (
|
||||
!React.isValidElement<{ children?: React.ReactNode }>(element) ||
|
||||
element.type !== Box
|
||||
) {
|
||||
debugReportError(
|
||||
`All children of MaxSizedBox must be <Box> elements`,
|
||||
element,
|
||||
);
|
||||
return {
|
||||
noWrapSegments: [{ text: '<ERROR>', props: {} }],
|
||||
segments: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (enableDebugLog) {
|
||||
const boxProps = element.props as {
|
||||
children?: React.ReactNode | undefined;
|
||||
readonly flexDirection?:
|
||||
| 'row'
|
||||
| 'column'
|
||||
| 'row-reverse'
|
||||
| 'column-reverse'
|
||||
| undefined;
|
||||
};
|
||||
// Ensure the Box has no props other than the default ones and key.
|
||||
let maxExpectedProps = 4;
|
||||
if (boxProps.children !== undefined) {
|
||||
// Allow the key prop, which is automatically added by React.
|
||||
maxExpectedProps += 1;
|
||||
}
|
||||
if (
|
||||
boxProps.flexDirection !== undefined &&
|
||||
boxProps.flexDirection !== 'row'
|
||||
) {
|
||||
debugReportError(
|
||||
'MaxSizedBox children must have flexDirection="row".',
|
||||
element,
|
||||
);
|
||||
}
|
||||
if (Object.keys(boxProps).length > maxExpectedProps) {
|
||||
debugReportError(
|
||||
`Boxes inside MaxSizedBox must not have additional props. ${Object.keys(
|
||||
boxProps,
|
||||
).join(', ')}`,
|
||||
element,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const row: Row = {
|
||||
noWrapSegments: [],
|
||||
segments: [],
|
||||
};
|
||||
|
||||
let hasSeenWrapped = false;
|
||||
|
||||
function visitRowChild(
|
||||
element: React.ReactNode,
|
||||
parentProps: Record<string, unknown> | undefined,
|
||||
) {
|
||||
if (element === null) {
|
||||
return;
|
||||
}
|
||||
if (typeof element === 'string' || typeof element === 'number') {
|
||||
const text = String(element);
|
||||
// Ignore empty strings as they don't need to be rendered.
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const segment: StyledText = { text, props: parentProps ?? {} };
|
||||
|
||||
// Check the 'wrap' property from the merged props to decide the segment type.
|
||||
if (parentProps === undefined || parentProps.wrap === 'wrap') {
|
||||
hasSeenWrapped = true;
|
||||
row.segments.push(segment);
|
||||
} else {
|
||||
if (!hasSeenWrapped) {
|
||||
row.noWrapSegments.push(segment);
|
||||
} else {
|
||||
// put in the wrapped segment as the row is already stuck in wrapped mode.
|
||||
row.segments.push(segment);
|
||||
debugReportError(
|
||||
'Text elements without wrapping cannot appear after elements with wrapping in the same row.',
|
||||
element,
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!React.isValidElement<{ children?: React.ReactNode }>(element)) {
|
||||
debugReportError('Invalid element.', element);
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.type === Fragment) {
|
||||
React.Children.forEach(element.props.children, (child) =>
|
||||
visitRowChild(child, parentProps),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.type !== Text) {
|
||||
debugReportError(
|
||||
'Children of a row Box must be <Text> elements.',
|
||||
element,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge props from parent <Text> elements. Child props take precedence.
|
||||
const { children, ...currentProps } = element.props;
|
||||
const mergedProps =
|
||||
parentProps === undefined
|
||||
? currentProps
|
||||
: { ...parentProps, ...currentProps };
|
||||
React.Children.forEach(children, (child) =>
|
||||
visitRowChild(child, mergedProps),
|
||||
);
|
||||
}
|
||||
|
||||
React.Children.forEach(element.props.children, (child) =>
|
||||
visitRowChild(child, undefined),
|
||||
);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function layoutInkElementAsStyledText(
|
||||
element: React.ReactElement,
|
||||
maxWidth: number,
|
||||
output: StyledText[][],
|
||||
) {
|
||||
const row = visitBoxRow(element);
|
||||
if (row.segments.length === 0 && row.noWrapSegments.length === 0) {
|
||||
// Return a single empty line if there are no segments to display
|
||||
output.push([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines: StyledText[][] = [];
|
||||
const nonWrappingContent: StyledText[] = [];
|
||||
let noWrappingWidth = 0;
|
||||
|
||||
// First, lay out the non-wrapping segments
|
||||
row.noWrapSegments.forEach((segment) => {
|
||||
nonWrappingContent.push(segment);
|
||||
noWrappingWidth += stringWidth(segment.text);
|
||||
});
|
||||
|
||||
if (row.segments.length === 0) {
|
||||
// This is a bit of a special case when there are no segments that allow
|
||||
// wrapping. It would be ideal to unify.
|
||||
const lines: StyledText[][] = [];
|
||||
let currentLine: StyledText[] = [];
|
||||
nonWrappingContent.forEach((segment) => {
|
||||
const textLines = segment.text.split('\n');
|
||||
textLines.forEach((text, index) => {
|
||||
if (index > 0) {
|
||||
lines.push(currentLine);
|
||||
currentLine = [];
|
||||
}
|
||||
if (text) {
|
||||
currentLine.push({ text, props: segment.props });
|
||||
}
|
||||
});
|
||||
});
|
||||
if (
|
||||
currentLine.length > 0 ||
|
||||
(nonWrappingContent.length > 0 &&
|
||||
nonWrappingContent[nonWrappingContent.length - 1].text.endsWith('\n'))
|
||||
) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
for (const line of lines) {
|
||||
output.push(line);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const availableWidth = maxWidth - noWrappingWidth;
|
||||
|
||||
if (availableWidth < 1) {
|
||||
// No room to render the wrapping segments. TODO(jacob314): consider an alternative fallback strategy.
|
||||
output.push(nonWrappingContent);
|
||||
return;
|
||||
}
|
||||
|
||||
// Now, lay out the wrapping segments
|
||||
let wrappingPart: StyledText[] = [];
|
||||
let wrappingPartWidth = 0;
|
||||
|
||||
function addWrappingPartToLines() {
|
||||
if (lines.length === 0) {
|
||||
lines.push([...nonWrappingContent, ...wrappingPart]);
|
||||
} else {
|
||||
if (noWrappingWidth > 0) {
|
||||
lines.push([
|
||||
...[{ text: ' '.repeat(noWrappingWidth), props: {} }],
|
||||
...wrappingPart,
|
||||
]);
|
||||
} else {
|
||||
lines.push(wrappingPart);
|
||||
}
|
||||
}
|
||||
wrappingPart = [];
|
||||
wrappingPartWidth = 0;
|
||||
}
|
||||
|
||||
function addToWrappingPart(text: string, props: Record<string, unknown>) {
|
||||
if (
|
||||
wrappingPart.length > 0 &&
|
||||
wrappingPart[wrappingPart.length - 1].props === props
|
||||
) {
|
||||
wrappingPart[wrappingPart.length - 1].text += text;
|
||||
} else {
|
||||
wrappingPart.push({ text, props });
|
||||
}
|
||||
}
|
||||
|
||||
row.segments.forEach((segment) => {
|
||||
const linesFromSegment = segment.text.split('\n');
|
||||
|
||||
linesFromSegment.forEach((lineText, lineIndex) => {
|
||||
if (lineIndex > 0) {
|
||||
addWrappingPartToLines();
|
||||
}
|
||||
|
||||
const words = lineText.split(/(\s+)/); // Split by whitespace
|
||||
|
||||
words.forEach((word) => {
|
||||
if (!word) return;
|
||||
const wordWidth = stringWidth(word);
|
||||
|
||||
if (
|
||||
wrappingPartWidth + wordWidth > availableWidth &&
|
||||
wrappingPartWidth > 0
|
||||
) {
|
||||
addWrappingPartToLines();
|
||||
if (/^\s+$/.test(word)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (wordWidth > availableWidth) {
|
||||
// Word is too long, needs to be split across lines
|
||||
const wordAsCodePoints = toCodePoints(word);
|
||||
let remainingWordAsCodePoints = wordAsCodePoints;
|
||||
while (remainingWordAsCodePoints.length > 0) {
|
||||
let splitIndex = 0;
|
||||
let currentSplitWidth = 0;
|
||||
for (const char of remainingWordAsCodePoints) {
|
||||
const charWidth = stringWidth(char);
|
||||
if (
|
||||
wrappingPartWidth + currentSplitWidth + charWidth >
|
||||
availableWidth
|
||||
) {
|
||||
break;
|
||||
}
|
||||
currentSplitWidth += charWidth;
|
||||
splitIndex++;
|
||||
}
|
||||
|
||||
if (splitIndex > 0) {
|
||||
const part = remainingWordAsCodePoints
|
||||
.slice(0, splitIndex)
|
||||
.join('');
|
||||
addToWrappingPart(part, segment.props);
|
||||
wrappingPartWidth += stringWidth(part);
|
||||
remainingWordAsCodePoints =
|
||||
remainingWordAsCodePoints.slice(splitIndex);
|
||||
}
|
||||
|
||||
if (remainingWordAsCodePoints.length > 0) {
|
||||
addWrappingPartToLines();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
addToWrappingPart(word, segment.props);
|
||||
wrappingPartWidth += wordWidth;
|
||||
}
|
||||
});
|
||||
});
|
||||
// Split omits a trailing newline, so we need to handle it here
|
||||
if (segment.text.endsWith('\n')) {
|
||||
addWrappingPartToLines();
|
||||
}
|
||||
});
|
||||
|
||||
if (wrappingPart.length > 0) {
|
||||
addWrappingPartToLines();
|
||||
}
|
||||
for (const line of lines) {
|
||||
output.push(line);
|
||||
}
|
||||
}
|
||||
157
packages/cli/src/ui/components/shared/RadioButtonSelect.tsx
Normal file
157
packages/cli/src/ui/components/shared/RadioButtonSelect.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Text, Box, useInput } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
|
||||
/**
|
||||
* Represents a single option for the RadioButtonSelect.
|
||||
* Requires a label for display and a value to be returned on selection.
|
||||
*/
|
||||
export interface RadioSelectItem<T> {
|
||||
label: string;
|
||||
value: T;
|
||||
disabled?: boolean;
|
||||
themeNameDisplay?: string;
|
||||
themeTypeDisplay?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the RadioButtonSelect component.
|
||||
* @template T The type of the value associated with each radio item.
|
||||
*/
|
||||
export interface RadioButtonSelectProps<T> {
|
||||
/** An array of items to display as radio options. */
|
||||
items: Array<RadioSelectItem<T>>;
|
||||
/** The initial index selected */
|
||||
initialIndex?: number;
|
||||
/** Function called when an item is selected. Receives the `value` of the selected item. */
|
||||
onSelect: (value: T) => void;
|
||||
/** Function called when an item is highlighted. Receives the `value` of the selected item. */
|
||||
onHighlight?: (value: T) => void;
|
||||
/** Whether this select input is currently focused and should respond to input. */
|
||||
isFocused?: boolean;
|
||||
/** Whether to show the scroll arrows. */
|
||||
showScrollArrows?: boolean;
|
||||
/** The maximum number of items to show at once. */
|
||||
maxItemsToShow?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom component that displays a list of items with radio buttons,
|
||||
* supporting scrolling and keyboard navigation.
|
||||
*
|
||||
* @template T The type of the value associated with each radio item.
|
||||
*/
|
||||
export function RadioButtonSelect<T>({
|
||||
items,
|
||||
initialIndex = 0,
|
||||
onSelect,
|
||||
onHighlight,
|
||||
isFocused,
|
||||
showScrollArrows = false,
|
||||
maxItemsToShow = 10,
|
||||
}: RadioButtonSelectProps<T>): React.JSX.Element {
|
||||
const [activeIndex, setActiveIndex] = useState(initialIndex);
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const newScrollOffset = Math.max(
|
||||
0,
|
||||
Math.min(activeIndex - maxItemsToShow + 1, items.length - maxItemsToShow),
|
||||
);
|
||||
if (activeIndex < scrollOffset) {
|
||||
setScrollOffset(activeIndex);
|
||||
} else if (activeIndex >= scrollOffset + maxItemsToShow) {
|
||||
setScrollOffset(newScrollOffset);
|
||||
}
|
||||
}, [activeIndex, items.length, scrollOffset, maxItemsToShow]);
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (input === 'k' || key.upArrow) {
|
||||
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
|
||||
setActiveIndex(newIndex);
|
||||
onHighlight?.(items[newIndex]!.value);
|
||||
}
|
||||
if (input === 'j' || key.downArrow) {
|
||||
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
|
||||
setActiveIndex(newIndex);
|
||||
onHighlight?.(items[newIndex]!.value);
|
||||
}
|
||||
if (key.return) {
|
||||
onSelect(items[activeIndex]!.value);
|
||||
}
|
||||
|
||||
// Enable selection directly from number keys.
|
||||
if (/^[1-9]$/.test(input)) {
|
||||
const targetIndex = Number.parseInt(input, 10) - 1;
|
||||
if (targetIndex >= 0 && targetIndex < visibleItems.length) {
|
||||
const selectedItem = visibleItems[targetIndex];
|
||||
if (selectedItem) {
|
||||
onSelect?.(selectedItem.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: isFocused && items.length > 0 },
|
||||
);
|
||||
|
||||
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{showScrollArrows && (
|
||||
<Text color={scrollOffset > 0 ? Colors.Foreground : Colors.Gray}>
|
||||
▲
|
||||
</Text>
|
||||
)}
|
||||
{visibleItems.map((item, index) => {
|
||||
const itemIndex = scrollOffset + index;
|
||||
const isSelected = activeIndex === itemIndex;
|
||||
|
||||
let textColor = Colors.Foreground;
|
||||
if (isSelected) {
|
||||
textColor = Colors.AccentGreen;
|
||||
} else if (item.disabled) {
|
||||
textColor = Colors.Gray;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={item.label}>
|
||||
<Box minWidth={2} flexShrink={0}>
|
||||
<Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}>
|
||||
{isSelected ? '●' : '○'}
|
||||
</Text>
|
||||
</Box>
|
||||
{item.themeNameDisplay && item.themeTypeDisplay ? (
|
||||
<Text color={textColor} wrap="truncate">
|
||||
{item.themeNameDisplay}{' '}
|
||||
<Text color={Colors.Gray}>{item.themeTypeDisplay}</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={textColor} wrap="truncate">
|
||||
{item.label}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{showScrollArrows && (
|
||||
<Text
|
||||
color={
|
||||
scrollOffset + maxItemsToShow < items.length
|
||||
? Colors.Foreground
|
||||
: Colors.Gray
|
||||
}
|
||||
>
|
||||
▼
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
1340
packages/cli/src/ui/components/shared/text-buffer.test.ts
Normal file
1340
packages/cli/src/ui/components/shared/text-buffer.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1389
packages/cli/src/ui/components/shared/text-buffer.ts
Normal file
1389
packages/cli/src/ui/components/shared/text-buffer.ts
Normal file
File diff suppressed because it is too large
Load Diff
15
packages/cli/src/ui/constants.ts
Normal file
15
packages/cli/src/ui/constants.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
const EstimatedArtWidth = 59;
|
||||
const BoxBorderWidth = 1;
|
||||
export const BOX_PADDING_X = 1;
|
||||
|
||||
// Calculate width based on art, padding, and border
|
||||
export const UI_WIDTH =
|
||||
EstimatedArtWidth + BOX_PADDING_X * 2 + BoxBorderWidth * 2; // ~63
|
||||
|
||||
export const STREAM_DEBOUNCE_MS = 100;
|
||||
87
packages/cli/src/ui/contexts/OverflowContext.tsx
Normal file
87
packages/cli/src/ui/contexts/OverflowContext.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
|
||||
interface OverflowState {
|
||||
overflowingIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
interface OverflowActions {
|
||||
addOverflowingId: (id: string) => void;
|
||||
removeOverflowingId: (id: string) => void;
|
||||
}
|
||||
|
||||
const OverflowStateContext = createContext<OverflowState | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const OverflowActionsContext = createContext<OverflowActions | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const useOverflowState = (): OverflowState | undefined =>
|
||||
useContext(OverflowStateContext);
|
||||
|
||||
export const useOverflowActions = (): OverflowActions | undefined =>
|
||||
useContext(OverflowActionsContext);
|
||||
|
||||
export const OverflowProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [overflowingIds, setOverflowingIds] = useState(new Set<string>());
|
||||
|
||||
const addOverflowingId = useCallback((id: string) => {
|
||||
setOverflowingIds((prevIds) => {
|
||||
if (prevIds.has(id)) {
|
||||
return prevIds;
|
||||
}
|
||||
const newIds = new Set(prevIds);
|
||||
newIds.add(id);
|
||||
return newIds;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeOverflowingId = useCallback((id: string) => {
|
||||
setOverflowingIds((prevIds) => {
|
||||
if (!prevIds.has(id)) {
|
||||
return prevIds;
|
||||
}
|
||||
const newIds = new Set(prevIds);
|
||||
newIds.delete(id);
|
||||
return newIds;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const stateValue = useMemo(
|
||||
() => ({
|
||||
overflowingIds,
|
||||
}),
|
||||
[overflowingIds],
|
||||
);
|
||||
|
||||
const actionsValue = useMemo(
|
||||
() => ({
|
||||
addOverflowingId,
|
||||
removeOverflowingId,
|
||||
}),
|
||||
[addOverflowingId, removeOverflowingId],
|
||||
);
|
||||
|
||||
return (
|
||||
<OverflowStateContext.Provider value={stateValue}>
|
||||
<OverflowActionsContext.Provider value={actionsValue}>
|
||||
{children}
|
||||
</OverflowActionsContext.Provider>
|
||||
</OverflowStateContext.Provider>
|
||||
);
|
||||
};
|
||||
132
packages/cli/src/ui/contexts/SessionContext.test.tsx
Normal file
132
packages/cli/src/ui/contexts/SessionContext.test.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type MutableRefObject } from 'react';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import {
|
||||
SessionStatsProvider,
|
||||
useSessionStats,
|
||||
SessionMetrics,
|
||||
} from './SessionContext.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { uiTelemetryService } from '@qwen/qwen-code-core';
|
||||
|
||||
/**
|
||||
* A test harness component that uses the hook and exposes the context value
|
||||
* via a mutable ref. This allows us to interact with the context's functions
|
||||
* and assert against its state directly in our tests.
|
||||
*/
|
||||
const TestHarness = ({
|
||||
contextRef,
|
||||
}: {
|
||||
contextRef: MutableRefObject<ReturnType<typeof useSessionStats> | undefined>;
|
||||
}) => {
|
||||
contextRef.current = useSessionStats();
|
||||
return null;
|
||||
};
|
||||
|
||||
describe('SessionStatsContext', () => {
|
||||
it('should provide the correct initial state', () => {
|
||||
const contextRef: MutableRefObject<
|
||||
ReturnType<typeof useSessionStats> | undefined
|
||||
> = { current: undefined };
|
||||
|
||||
render(
|
||||
<SessionStatsProvider>
|
||||
<TestHarness contextRef={contextRef} />
|
||||
</SessionStatsProvider>,
|
||||
);
|
||||
|
||||
const stats = contextRef.current?.stats;
|
||||
|
||||
expect(stats?.sessionStartTime).toBeInstanceOf(Date);
|
||||
expect(stats?.metrics).toBeDefined();
|
||||
expect(stats?.metrics.models).toEqual({});
|
||||
});
|
||||
|
||||
it('should update metrics when the uiTelemetryService emits an update', () => {
|
||||
const contextRef: MutableRefObject<
|
||||
ReturnType<typeof useSessionStats> | undefined
|
||||
> = { current: undefined };
|
||||
|
||||
render(
|
||||
<SessionStatsProvider>
|
||||
<TestHarness contextRef={contextRef} />
|
||||
</SessionStatsProvider>,
|
||||
);
|
||||
|
||||
const newMetrics: SessionMetrics = {
|
||||
models: {
|
||||
'gemini-pro': {
|
||||
api: {
|
||||
totalRequests: 1,
|
||||
totalErrors: 0,
|
||||
totalLatencyMs: 123,
|
||||
},
|
||||
tokens: {
|
||||
prompt: 100,
|
||||
candidates: 200,
|
||||
total: 300,
|
||||
cached: 50,
|
||||
thoughts: 20,
|
||||
tool: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
totalCalls: 1,
|
||||
totalSuccess: 1,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 456,
|
||||
totalDecisions: {
|
||||
accept: 1,
|
||||
reject: 0,
|
||||
modify: 0,
|
||||
},
|
||||
byName: {
|
||||
'test-tool': {
|
||||
count: 1,
|
||||
success: 1,
|
||||
fail: 0,
|
||||
durationMs: 456,
|
||||
decisions: {
|
||||
accept: 1,
|
||||
reject: 0,
|
||||
modify: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
act(() => {
|
||||
uiTelemetryService.emit('update', {
|
||||
metrics: newMetrics,
|
||||
lastPromptTokenCount: 100,
|
||||
});
|
||||
});
|
||||
|
||||
const stats = contextRef.current?.stats;
|
||||
expect(stats?.metrics).toEqual(newMetrics);
|
||||
expect(stats?.lastPromptTokenCount).toBe(100);
|
||||
});
|
||||
|
||||
it('should throw an error when useSessionStats is used outside of a provider', () => {
|
||||
// Suppress console.error for this test since we expect an error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
// Expect renderHook itself to throw when the hook is used outside a provider
|
||||
expect(() => {
|
||||
renderHook(() => useSessionStats());
|
||||
}).toThrow('useSessionStats must be used within a SessionStatsProvider');
|
||||
} finally {
|
||||
consoleSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
138
packages/cli/src/ui/contexts/SessionContext.tsx
Normal file
138
packages/cli/src/ui/contexts/SessionContext.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useState,
|
||||
useMemo,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
uiTelemetryService,
|
||||
SessionMetrics,
|
||||
ModelMetrics,
|
||||
} from '@qwen/qwen-code-core';
|
||||
|
||||
// --- Interface Definitions ---
|
||||
|
||||
export type { SessionMetrics, ModelMetrics };
|
||||
|
||||
export interface SessionStatsState {
|
||||
sessionStartTime: Date;
|
||||
metrics: SessionMetrics;
|
||||
lastPromptTokenCount: number;
|
||||
promptCount: number;
|
||||
}
|
||||
|
||||
export interface ComputedSessionStats {
|
||||
totalApiTime: number;
|
||||
totalToolTime: number;
|
||||
agentActiveTime: number;
|
||||
apiTimePercent: number;
|
||||
toolTimePercent: number;
|
||||
cacheEfficiency: number;
|
||||
totalDecisions: number;
|
||||
successRate: number;
|
||||
agreementRate: number;
|
||||
totalCachedTokens: number;
|
||||
totalPromptTokens: number;
|
||||
}
|
||||
|
||||
// Defines the final "value" of our context, including the state
|
||||
// and the functions to update it.
|
||||
interface SessionStatsContextValue {
|
||||
stats: SessionStatsState;
|
||||
startNewPrompt: () => void;
|
||||
getPromptCount: () => number;
|
||||
}
|
||||
|
||||
// --- Context Definition ---
|
||||
|
||||
const SessionStatsContext = createContext<SessionStatsContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
// --- Provider Component ---
|
||||
|
||||
export const SessionStatsProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [stats, setStats] = useState<SessionStatsState>({
|
||||
sessionStartTime: new Date(),
|
||||
metrics: uiTelemetryService.getMetrics(),
|
||||
lastPromptTokenCount: 0,
|
||||
promptCount: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleUpdate = ({
|
||||
metrics,
|
||||
lastPromptTokenCount,
|
||||
}: {
|
||||
metrics: SessionMetrics;
|
||||
lastPromptTokenCount: number;
|
||||
}) => {
|
||||
setStats((prevState) => ({
|
||||
...prevState,
|
||||
metrics,
|
||||
lastPromptTokenCount,
|
||||
}));
|
||||
};
|
||||
|
||||
uiTelemetryService.on('update', handleUpdate);
|
||||
// Set initial state
|
||||
handleUpdate({
|
||||
metrics: uiTelemetryService.getMetrics(),
|
||||
lastPromptTokenCount: uiTelemetryService.getLastPromptTokenCount(),
|
||||
});
|
||||
|
||||
return () => {
|
||||
uiTelemetryService.off('update', handleUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startNewPrompt = useCallback(() => {
|
||||
setStats((prevState) => ({
|
||||
...prevState,
|
||||
promptCount: prevState.promptCount + 1,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const getPromptCount = useCallback(
|
||||
() => stats.promptCount,
|
||||
[stats.promptCount],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
stats,
|
||||
startNewPrompt,
|
||||
getPromptCount,
|
||||
}),
|
||||
[stats, startNewPrompt, getPromptCount],
|
||||
);
|
||||
|
||||
return (
|
||||
<SessionStatsContext.Provider value={value}>
|
||||
{children}
|
||||
</SessionStatsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Consumer Hook ---
|
||||
|
||||
export const useSessionStats = () => {
|
||||
const context = useContext(SessionStatsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
'useSessionStats must be used within a SessionStatsProvider',
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
22
packages/cli/src/ui/contexts/StreamingContext.tsx
Normal file
22
packages/cli/src/ui/contexts/StreamingContext.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { createContext } from 'react';
|
||||
import { StreamingState } from '../types.js';
|
||||
|
||||
export const StreamingContext = createContext<StreamingState | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const useStreamingContext = (): StreamingState => {
|
||||
const context = React.useContext(StreamingContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
'useStreamingContext must be used within a StreamingContextProvider',
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
71
packages/cli/src/ui/editors/editorSettingsManager.ts
Normal file
71
packages/cli/src/ui/editors/editorSettingsManager.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
allowEditorTypeInSandbox,
|
||||
checkHasEditorType,
|
||||
type EditorType,
|
||||
} from '@qwen/qwen-code-core';
|
||||
|
||||
export interface EditorDisplay {
|
||||
name: string;
|
||||
type: EditorType | 'not_set';
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export const EDITOR_DISPLAY_NAMES: Record<EditorType, string> = {
|
||||
zed: 'Zed',
|
||||
vscode: 'VS Code',
|
||||
vscodium: 'VSCodium',
|
||||
windsurf: 'Windsurf',
|
||||
cursor: 'Cursor',
|
||||
vim: 'Vim',
|
||||
neovim: 'Neovim',
|
||||
};
|
||||
|
||||
class EditorSettingsManager {
|
||||
private readonly availableEditors: EditorDisplay[];
|
||||
|
||||
constructor() {
|
||||
const editorTypes: EditorType[] = [
|
||||
'zed',
|
||||
'vscode',
|
||||
'vscodium',
|
||||
'windsurf',
|
||||
'cursor',
|
||||
'vim',
|
||||
'neovim',
|
||||
];
|
||||
this.availableEditors = [
|
||||
{
|
||||
name: 'None',
|
||||
type: 'not_set',
|
||||
disabled: false,
|
||||
},
|
||||
...editorTypes.map((type) => {
|
||||
const hasEditor = checkHasEditorType(type);
|
||||
const isAllowedInSandbox = allowEditorTypeInSandbox(type);
|
||||
|
||||
let labelSuffix = !isAllowedInSandbox
|
||||
? ' (Not available in sandbox)'
|
||||
: '';
|
||||
labelSuffix = !hasEditor ? ' (Not installed)' : labelSuffix;
|
||||
|
||||
return {
|
||||
name: EDITOR_DISPLAY_NAMES[type] + labelSuffix,
|
||||
type,
|
||||
disabled: !hasEditor || !isAllowedInSandbox,
|
||||
};
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
getAvailableEditorDisplays(): EditorDisplay[] {
|
||||
return this.availableEditors;
|
||||
}
|
||||
}
|
||||
|
||||
export const editorSettingsManager = new EditorSettingsManager();
|
||||
762
packages/cli/src/ui/hooks/atCommandProcessor.test.ts
Normal file
762
packages/cli/src/ui/hooks/atCommandProcessor.test.ts
Normal file
@@ -0,0 +1,762 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
||||
import type { Mocked } from 'vitest';
|
||||
import { handleAtCommand } from './atCommandProcessor.js';
|
||||
import { Config, FileDiscoveryService } from '@qwen/qwen-code-core';
|
||||
import { ToolCallStatus } from '../types.js';
|
||||
import { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import * as fsPromises from 'fs/promises';
|
||||
import type { Stats } from 'fs';
|
||||
|
||||
const mockGetToolRegistry = vi.fn();
|
||||
const mockGetTargetDir = vi.fn();
|
||||
const mockConfig = {
|
||||
getToolRegistry: mockGetToolRegistry,
|
||||
getTargetDir: mockGetTargetDir,
|
||||
isSandboxed: vi.fn(() => false),
|
||||
getFileService: vi.fn(),
|
||||
getFileFilteringRespectGitIgnore: vi.fn(() => true),
|
||||
getEnableRecursiveFileSearch: vi.fn(() => true),
|
||||
} as unknown as Config;
|
||||
|
||||
const mockReadManyFilesExecute = vi.fn();
|
||||
const mockReadManyFilesTool = {
|
||||
name: 'read_many_files',
|
||||
displayName: 'Read Many Files',
|
||||
description: 'Reads multiple files.',
|
||||
execute: mockReadManyFilesExecute,
|
||||
getDescription: vi.fn((params) => `Read files: ${params.paths.join(', ')}`),
|
||||
};
|
||||
|
||||
const mockGlobExecute = vi.fn();
|
||||
const mockGlobTool = {
|
||||
name: 'glob',
|
||||
displayName: 'Glob Tool',
|
||||
execute: mockGlobExecute,
|
||||
getDescription: vi.fn(() => 'Glob tool description'),
|
||||
};
|
||||
|
||||
const mockAddItem: Mock<UseHistoryManagerReturn['addItem']> = vi.fn();
|
||||
const mockOnDebugMessage: Mock<(message: string) => void> = vi.fn();
|
||||
|
||||
vi.mock('fs/promises', async () => {
|
||||
const actual = await vi.importActual('fs/promises');
|
||||
return {
|
||||
...actual,
|
||||
stat: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@qwen/qwen-code-core', async () => {
|
||||
const actual = await vi.importActual('@qwen/qwen-code-core');
|
||||
return {
|
||||
...actual,
|
||||
FileDiscoveryService: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('handleAtCommand', () => {
|
||||
let abortController: AbortController;
|
||||
let mockFileDiscoveryService: Mocked<FileDiscoveryService>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
abortController = new AbortController();
|
||||
mockGetTargetDir.mockReturnValue('/test/dir');
|
||||
mockGetToolRegistry.mockReturnValue({
|
||||
getTool: vi.fn((toolName: string) => {
|
||||
if (toolName === 'read_many_files') return mockReadManyFilesTool;
|
||||
if (toolName === 'glob') return mockGlobTool;
|
||||
return undefined;
|
||||
}),
|
||||
});
|
||||
vi.mocked(fsPromises.stat).mockResolvedValue({
|
||||
isDirectory: () => false,
|
||||
} as Stats);
|
||||
mockReadManyFilesExecute.mockResolvedValue({
|
||||
llmContent: '',
|
||||
returnDisplay: '',
|
||||
});
|
||||
mockGlobExecute.mockResolvedValue({
|
||||
llmContent: 'No files found',
|
||||
returnDisplay: '',
|
||||
});
|
||||
|
||||
// Mock FileDiscoveryService
|
||||
mockFileDiscoveryService = {
|
||||
initialize: vi.fn(),
|
||||
shouldIgnoreFile: vi.fn(() => false),
|
||||
filterFiles: vi.fn((files) => files),
|
||||
getIgnoreInfo: vi.fn(() => ({ gitIgnored: [] })),
|
||||
isGitRepository: vi.fn(() => true),
|
||||
};
|
||||
vi.mocked(FileDiscoveryService).mockImplementation(
|
||||
() => mockFileDiscoveryService,
|
||||
);
|
||||
|
||||
// Mock getFileService to return the mocked FileDiscoveryService
|
||||
mockConfig.getFileService = vi
|
||||
.fn()
|
||||
.mockReturnValue(mockFileDiscoveryService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
it('should pass through query if no @ command is present', async () => {
|
||||
const query = 'regular user query';
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 123,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
{ type: 'user', text: query },
|
||||
123,
|
||||
);
|
||||
expect(result.processedQuery).toEqual([{ text: query }]);
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
expect(mockReadManyFilesExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass through original query if only a lone @ symbol is present', async () => {
|
||||
const queryWithSpaces = ' @ ';
|
||||
const result = await handleAtCommand({
|
||||
query: queryWithSpaces,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 124,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
{ type: 'user', text: queryWithSpaces },
|
||||
124,
|
||||
);
|
||||
expect(result.processedQuery).toEqual([{ text: queryWithSpaces }]);
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
'Lone @ detected, will be treated as text in the modified query.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should process a valid text file path', async () => {
|
||||
const filePath = 'path/to/file.txt';
|
||||
const query = `@${filePath}`;
|
||||
const fileContent = 'This is the file content.';
|
||||
mockReadManyFilesExecute.mockResolvedValue({
|
||||
llmContent: [`--- ${filePath} ---\n\n${fileContent}\n\n`],
|
||||
returnDisplay: 'Read 1 file.',
|
||||
});
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 125,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
{ type: 'user', text: query },
|
||||
125,
|
||||
);
|
||||
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
|
||||
{ paths: [filePath], respect_git_ignore: true },
|
||||
abortController.signal,
|
||||
);
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'tool_group',
|
||||
tools: [expect.objectContaining({ status: ToolCallStatus.Success })],
|
||||
}),
|
||||
125,
|
||||
);
|
||||
expect(result.processedQuery).toEqual([
|
||||
{ text: `@${filePath}` },
|
||||
{ text: '\n--- Content from referenced files ---' },
|
||||
{ text: `\nContent from @${filePath}:\n` },
|
||||
{ text: fileContent },
|
||||
{ text: '\n--- End of content ---' },
|
||||
]);
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
});
|
||||
|
||||
it('should process a valid directory path and convert to glob', async () => {
|
||||
const dirPath = 'path/to/dir';
|
||||
const query = `@${dirPath}`;
|
||||
const resolvedGlob = `${dirPath}/**`;
|
||||
const fileContent = 'Directory content.';
|
||||
vi.mocked(fsPromises.stat).mockResolvedValue({
|
||||
isDirectory: () => true,
|
||||
} as Stats);
|
||||
mockReadManyFilesExecute.mockResolvedValue({
|
||||
llmContent: [`--- ${resolvedGlob} ---\n\n${fileContent}\n\n`],
|
||||
returnDisplay: 'Read directory contents.',
|
||||
});
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 126,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
{ type: 'user', text: query },
|
||||
126,
|
||||
);
|
||||
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
|
||||
{ paths: [resolvedGlob], respect_git_ignore: true },
|
||||
abortController.signal,
|
||||
);
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
`Path ${dirPath} resolved to directory, using glob: ${resolvedGlob}`,
|
||||
);
|
||||
expect(result.processedQuery).toEqual([
|
||||
{ text: `@${resolvedGlob}` },
|
||||
{ text: '\n--- Content from referenced files ---' },
|
||||
{ text: `\nContent from @${resolvedGlob}:\n` },
|
||||
{ text: fileContent },
|
||||
{ text: '\n--- End of content ---' },
|
||||
]);
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
});
|
||||
|
||||
it('should process a valid image file path (as text content for now)', async () => {
|
||||
const imagePath = 'path/to/image.png';
|
||||
const query = `@${imagePath}`;
|
||||
// For @-commands, read_many_files is expected to return text or structured text.
|
||||
// If it were to return actual image Part, the test and handling would be different.
|
||||
// Current implementation of read_many_files for images returns base64 in text.
|
||||
const imageFileTextContent = '[base64 image data for path/to/image.png]';
|
||||
const imagePart = {
|
||||
mimeType: 'image/png',
|
||||
inlineData: imageFileTextContent,
|
||||
};
|
||||
mockReadManyFilesExecute.mockResolvedValue({
|
||||
llmContent: [imagePart],
|
||||
returnDisplay: 'Read 1 image.',
|
||||
});
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 127,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
expect(result.processedQuery).toEqual([
|
||||
{ text: `@${imagePath}` },
|
||||
{ text: '\n--- Content from referenced files ---' },
|
||||
imagePart,
|
||||
{ text: '\n--- End of content ---' },
|
||||
]);
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle query with text before and after @command', async () => {
|
||||
const textBefore = 'Explain this: ';
|
||||
const filePath = 'doc.md';
|
||||
const textAfter = ' in detail.';
|
||||
const query = `${textBefore}@${filePath}${textAfter}`;
|
||||
const fileContent = 'Markdown content.';
|
||||
mockReadManyFilesExecute.mockResolvedValue({
|
||||
llmContent: [`--- ${filePath} ---\n\n${fileContent}\n\n`],
|
||||
returnDisplay: 'Read 1 doc.',
|
||||
});
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 128,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
{ type: 'user', text: query },
|
||||
128,
|
||||
);
|
||||
expect(result.processedQuery).toEqual([
|
||||
{ text: `${textBefore}@${filePath}${textAfter}` },
|
||||
{ text: '\n--- Content from referenced files ---' },
|
||||
{ text: `\nContent from @${filePath}:\n` },
|
||||
{ text: fileContent },
|
||||
{ text: '\n--- End of content ---' },
|
||||
]);
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly unescape paths with escaped spaces', async () => {
|
||||
const rawPath = 'path/to/my\\ file.txt';
|
||||
const unescapedPath = 'path/to/my file.txt';
|
||||
const query = `@${rawPath}`;
|
||||
const fileContent = 'Content of file with space.';
|
||||
mockReadManyFilesExecute.mockResolvedValue({
|
||||
llmContent: [`--- ${unescapedPath} ---\n\n${fileContent}\n\n`],
|
||||
returnDisplay: 'Read 1 file.',
|
||||
});
|
||||
|
||||
await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 129,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
|
||||
{ paths: [unescapedPath], respect_git_ignore: true },
|
||||
abortController.signal,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple @file references', async () => {
|
||||
const file1 = 'file1.txt';
|
||||
const content1 = 'Content file1';
|
||||
const file2 = 'file2.md';
|
||||
const content2 = 'Content file2';
|
||||
const query = `@${file1} @${file2}`;
|
||||
|
||||
mockReadManyFilesExecute.mockResolvedValue({
|
||||
llmContent: [
|
||||
`--- ${file1} ---\n\n${content1}\n\n`,
|
||||
`--- ${file2} ---\n\n${content2}\n\n`,
|
||||
],
|
||||
returnDisplay: 'Read 2 files.',
|
||||
});
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 130,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
|
||||
{ paths: [file1, file2], respect_git_ignore: true },
|
||||
abortController.signal,
|
||||
);
|
||||
expect(result.processedQuery).toEqual([
|
||||
{ text: `@${file1} @${file2}` },
|
||||
{ text: '\n--- Content from referenced files ---' },
|
||||
{ text: `\nContent from @${file1}:\n` },
|
||||
{ text: content1 },
|
||||
{ text: `\nContent from @${file2}:\n` },
|
||||
{ text: content2 },
|
||||
{ text: '\n--- End of content ---' },
|
||||
]);
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle multiple @file references with interleaved text', async () => {
|
||||
const text1 = 'Check ';
|
||||
const file1 = 'f1.txt';
|
||||
const content1 = 'C1';
|
||||
const text2 = ' and ';
|
||||
const file2 = 'f2.md';
|
||||
const content2 = 'C2';
|
||||
const text3 = ' please.';
|
||||
const query = `${text1}@${file1}${text2}@${file2}${text3}`;
|
||||
|
||||
mockReadManyFilesExecute.mockResolvedValue({
|
||||
llmContent: [
|
||||
`--- ${file1} ---\n\n${content1}\n\n`,
|
||||
`--- ${file2} ---\n\n${content2}\n\n`,
|
||||
],
|
||||
returnDisplay: 'Read 2 files.',
|
||||
});
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 131,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
|
||||
{ paths: [file1, file2], respect_git_ignore: true },
|
||||
abortController.signal,
|
||||
);
|
||||
expect(result.processedQuery).toEqual([
|
||||
{ text: `${text1}@${file1}${text2}@${file2}${text3}` },
|
||||
{ text: '\n--- Content from referenced files ---' },
|
||||
{ text: `\nContent from @${file1}:\n` },
|
||||
{ text: content1 },
|
||||
{ text: `\nContent from @${file2}:\n` },
|
||||
{ text: content2 },
|
||||
{ text: '\n--- End of content ---' },
|
||||
]);
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle a mix of valid, invalid, and lone @ references', async () => {
|
||||
const file1 = 'valid1.txt';
|
||||
const content1 = 'Valid content 1';
|
||||
const invalidFile = 'nonexistent.txt';
|
||||
const query = `Look at @${file1} then @${invalidFile} and also just @ symbol, then @valid2.glob`;
|
||||
const file2Glob = 'valid2.glob';
|
||||
const resolvedFile2 = 'resolved/valid2.actual';
|
||||
const content2 = 'Globbed content';
|
||||
|
||||
// Mock fs.stat for file1 (valid)
|
||||
vi.mocked(fsPromises.stat).mockImplementation(async (p) => {
|
||||
if (p.toString().endsWith(file1))
|
||||
return { isDirectory: () => false } as Stats;
|
||||
if (p.toString().endsWith(invalidFile))
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
// For valid2.glob, stat will fail, triggering glob
|
||||
if (p.toString().endsWith(file2Glob))
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
return { isDirectory: () => false } as Stats; // Default
|
||||
});
|
||||
|
||||
// Mock glob to find resolvedFile2 for valid2.glob
|
||||
mockGlobExecute.mockImplementation(async (params) => {
|
||||
if (params.pattern.includes('valid2.glob')) {
|
||||
return {
|
||||
llmContent: `Found files:\n${mockGetTargetDir()}/${resolvedFile2}`,
|
||||
returnDisplay: 'Found 1 file',
|
||||
};
|
||||
}
|
||||
return { llmContent: 'No files found', returnDisplay: '' };
|
||||
});
|
||||
|
||||
mockReadManyFilesExecute.mockResolvedValue({
|
||||
llmContent: [
|
||||
`--- ${file1} ---\n\n${content1}\n\n`,
|
||||
`--- ${resolvedFile2} ---\n\n${content2}\n\n`,
|
||||
],
|
||||
returnDisplay: 'Read 2 files.',
|
||||
});
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 132,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
|
||||
{ paths: [file1, resolvedFile2], respect_git_ignore: true },
|
||||
abortController.signal,
|
||||
);
|
||||
expect(result.processedQuery).toEqual([
|
||||
// Original query has @nonexistent.txt and @, but resolved has @resolved/valid2.actual
|
||||
{
|
||||
text: `Look at @${file1} then @${invalidFile} and also just @ symbol, then @${resolvedFile2}`,
|
||||
},
|
||||
{ text: '\n--- Content from referenced files ---' },
|
||||
{ text: `\nContent from @${file1}:\n` },
|
||||
{ text: content1 },
|
||||
{ text: `\nContent from @${resolvedFile2}:\n` },
|
||||
{ text: content2 },
|
||||
{ text: '\n--- End of content ---' },
|
||||
]);
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
`Path ${invalidFile} not found directly, attempting glob search.`,
|
||||
);
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
`Glob search for '**/*${invalidFile}*' found no files or an error. Path ${invalidFile} will be skipped.`,
|
||||
);
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
'Lone @ detected, will be treated as text in the modified query.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return original query if all @paths are invalid or lone @', async () => {
|
||||
const query = 'Check @nonexistent.txt and @ also';
|
||||
vi.mocked(fsPromises.stat).mockRejectedValue(
|
||||
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
|
||||
);
|
||||
mockGlobExecute.mockResolvedValue({
|
||||
llmContent: 'No files found',
|
||||
returnDisplay: '',
|
||||
});
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 133,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
expect(mockReadManyFilesExecute).not.toHaveBeenCalled();
|
||||
// The modified query string will be "Check @nonexistent.txt and @ also" because no paths were resolved for reading.
|
||||
expect(result.processedQuery).toEqual([
|
||||
{ text: 'Check @nonexistent.txt and @ also' },
|
||||
]);
|
||||
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
});
|
||||
|
||||
it('should process a file path case-insensitively', async () => {
|
||||
// const actualFilePath = 'path/to/MyFile.txt'; // Unused, path in llmContent should match queryPath
|
||||
const queryPath = 'path/to/myfile.txt'; // Different case
|
||||
const query = `@${queryPath}`;
|
||||
const fileContent = 'This is the case-insensitive file content.';
|
||||
|
||||
// Mock fs.stat to "find" MyFile.txt when looking for myfile.txt
|
||||
// This simulates a case-insensitive file system or resolution
|
||||
vi.mocked(fsPromises.stat).mockImplementation(async (p) => {
|
||||
if (p.toString().toLowerCase().endsWith('myfile.txt')) {
|
||||
return {
|
||||
isDirectory: () => false,
|
||||
// You might need to add other Stats properties if your code uses them
|
||||
} as Stats;
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
mockReadManyFilesExecute.mockResolvedValue({
|
||||
llmContent: [`--- ${queryPath} ---\n\n${fileContent}\n\n`],
|
||||
returnDisplay: 'Read 1 file.',
|
||||
});
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 134, // New messageId
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
{ type: 'user', text: query },
|
||||
134,
|
||||
);
|
||||
// The atCommandProcessor resolves the path before calling read_many_files.
|
||||
// We expect it to be called with the path that fs.stat "found".
|
||||
// In a real case-insensitive FS, stat(myfile.txt) might return info for MyFile.txt.
|
||||
// The key is that *a* valid path that points to the content is used.
|
||||
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
|
||||
// Depending on how path resolution and fs.stat mock interact,
|
||||
// this could be queryPath or actualFilePath.
|
||||
// For this test, we'll assume the processor uses the path that stat "succeeded" with.
|
||||
// If the underlying fs/stat is truly case-insensitive, it might resolve to actualFilePath.
|
||||
// If the mock is simpler, it might use queryPath if stat(queryPath) succeeds.
|
||||
// The most important part is that *some* version of the path that leads to the content is used.
|
||||
// Let's assume it uses the path from the query if stat confirms it exists (even if different case on disk)
|
||||
{ paths: [queryPath], respect_git_ignore: true },
|
||||
abortController.signal,
|
||||
);
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'tool_group',
|
||||
tools: [expect.objectContaining({ status: ToolCallStatus.Success })],
|
||||
}),
|
||||
134,
|
||||
);
|
||||
expect(result.processedQuery).toEqual([
|
||||
{ text: `@${queryPath}` }, // Query uses the input path
|
||||
{ text: '\n--- Content from referenced files ---' },
|
||||
{ text: `\nContent from @${queryPath}:\n` }, // Content display also uses input path
|
||||
{ text: fileContent },
|
||||
{ text: '\n--- End of content ---' },
|
||||
]);
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
});
|
||||
|
||||
describe('git-aware filtering', () => {
|
||||
it('should skip git-ignored files in @ commands', async () => {
|
||||
const gitIgnoredFile = 'node_modules/package.json';
|
||||
const query = `@${gitIgnoredFile}`;
|
||||
|
||||
// Mock the file discovery service to report this file as git-ignored
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
|
||||
(path: string, options?: { respectGitIgnore?: boolean }) =>
|
||||
path === gitIgnoredFile && options?.respectGitIgnore !== false,
|
||||
);
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 200,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
|
||||
gitIgnoredFile,
|
||||
{ respectGitIgnore: true },
|
||||
);
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
`Path ${gitIgnoredFile} is git-ignored and will be skipped.`,
|
||||
);
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
'Ignored 1 git-ignored files: node_modules/package.json',
|
||||
);
|
||||
expect(mockReadManyFilesExecute).not.toHaveBeenCalled();
|
||||
expect(result.processedQuery).toEqual([{ text: query }]);
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
});
|
||||
|
||||
it('should process non-git-ignored files normally', async () => {
|
||||
const validFile = 'src/index.ts';
|
||||
const query = `@${validFile}`;
|
||||
const fileContent = 'console.log("Hello world");';
|
||||
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockReturnValue(false);
|
||||
mockReadManyFilesExecute.mockResolvedValue({
|
||||
llmContent: [`--- ${validFile} ---\n\n${fileContent}\n\n`],
|
||||
returnDisplay: 'Read 1 file.',
|
||||
});
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 201,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
|
||||
validFile,
|
||||
{ respectGitIgnore: true },
|
||||
);
|
||||
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
|
||||
{ paths: [validFile], respect_git_ignore: true },
|
||||
abortController.signal,
|
||||
);
|
||||
expect(result.processedQuery).toEqual([
|
||||
{ text: `@${validFile}` },
|
||||
{ text: '\n--- Content from referenced files ---' },
|
||||
{ text: `\nContent from @${validFile}:\n` },
|
||||
{ text: fileContent },
|
||||
{ text: '\n--- End of content ---' },
|
||||
]);
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle mixed git-ignored and valid files', async () => {
|
||||
const validFile = 'README.md';
|
||||
const gitIgnoredFile = '.env';
|
||||
const query = `@${validFile} @${gitIgnoredFile}`;
|
||||
const fileContent = '# Project README';
|
||||
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
|
||||
(path: string, options?: { respectGitIgnore?: boolean }) =>
|
||||
path === gitIgnoredFile && options?.respectGitIgnore !== false,
|
||||
);
|
||||
mockReadManyFilesExecute.mockResolvedValue({
|
||||
llmContent: [`--- ${validFile} ---\n\n${fileContent}\n\n`],
|
||||
returnDisplay: 'Read 1 file.',
|
||||
});
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 202,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
|
||||
validFile,
|
||||
{ respectGitIgnore: true },
|
||||
);
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
|
||||
gitIgnoredFile,
|
||||
{ respectGitIgnore: true },
|
||||
);
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
`Path ${gitIgnoredFile} is git-ignored and will be skipped.`,
|
||||
);
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
'Ignored 1 git-ignored files: .env',
|
||||
);
|
||||
expect(mockReadManyFilesExecute).toHaveBeenCalledWith(
|
||||
{ paths: [validFile], respect_git_ignore: true },
|
||||
abortController.signal,
|
||||
);
|
||||
expect(result.processedQuery).toEqual([
|
||||
{ text: `@${validFile} @${gitIgnoredFile}` },
|
||||
{ text: '\n--- Content from referenced files ---' },
|
||||
{ text: `\nContent from @${validFile}:\n` },
|
||||
{ text: fileContent },
|
||||
{ text: '\n--- End of content ---' },
|
||||
]);
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
});
|
||||
|
||||
it('should always ignore .git directory files', async () => {
|
||||
const gitFile = '.git/config';
|
||||
const query = `@${gitFile}`;
|
||||
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockReturnValue(true);
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 203,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(mockFileDiscoveryService.shouldIgnoreFile).toHaveBeenCalledWith(
|
||||
gitFile,
|
||||
{ respectGitIgnore: true },
|
||||
);
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
`Path ${gitFile} is git-ignored and will be skipped.`,
|
||||
);
|
||||
expect(mockReadManyFilesExecute).not.toHaveBeenCalled();
|
||||
expect(result.processedQuery).toEqual([{ text: query }]);
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when recursive file search is disabled', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(mockConfig.getEnableRecursiveFileSearch).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('should not use glob search for a nonexistent file', async () => {
|
||||
const invalidFile = 'nonexistent.txt';
|
||||
const query = `@${invalidFile}`;
|
||||
|
||||
vi.mocked(fsPromises.stat).mockRejectedValue(
|
||||
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
|
||||
);
|
||||
|
||||
const result = await handleAtCommand({
|
||||
query,
|
||||
config: mockConfig,
|
||||
addItem: mockAddItem,
|
||||
onDebugMessage: mockOnDebugMessage,
|
||||
messageId: 300,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(mockGlobExecute).not.toHaveBeenCalled();
|
||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||
`Glob tool not found. Path ${invalidFile} will be skipped.`,
|
||||
);
|
||||
expect(result.processedQuery).toEqual([{ text: query }]);
|
||||
expect(result.shouldProceed).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
423
packages/cli/src/ui/hooks/atCommandProcessor.ts
Normal file
423
packages/cli/src/ui/hooks/atCommandProcessor.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { PartListUnion, PartUnion } from '@google/genai';
|
||||
import {
|
||||
Config,
|
||||
getErrorMessage,
|
||||
isNodeError,
|
||||
unescapePath,
|
||||
} from '@qwen/qwen-code-core';
|
||||
import {
|
||||
HistoryItem,
|
||||
IndividualToolCallDisplay,
|
||||
ToolCallStatus,
|
||||
} from '../types.js';
|
||||
import { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
|
||||
interface HandleAtCommandParams {
|
||||
query: string;
|
||||
config: Config;
|
||||
addItem: UseHistoryManagerReturn['addItem'];
|
||||
onDebugMessage: (message: string) => void;
|
||||
messageId: number;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
interface HandleAtCommandResult {
|
||||
processedQuery: PartListUnion | null;
|
||||
shouldProceed: boolean;
|
||||
}
|
||||
|
||||
interface AtCommandPart {
|
||||
type: 'text' | 'atPath';
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a query string to find all '@<path>' commands and text segments.
|
||||
* Handles \ escaped spaces within paths.
|
||||
*/
|
||||
function parseAllAtCommands(query: string): AtCommandPart[] {
|
||||
const parts: AtCommandPart[] = [];
|
||||
let currentIndex = 0;
|
||||
|
||||
while (currentIndex < query.length) {
|
||||
let atIndex = -1;
|
||||
let nextSearchIndex = currentIndex;
|
||||
// Find next unescaped '@'
|
||||
while (nextSearchIndex < query.length) {
|
||||
if (
|
||||
query[nextSearchIndex] === '@' &&
|
||||
(nextSearchIndex === 0 || query[nextSearchIndex - 1] !== '\\')
|
||||
) {
|
||||
atIndex = nextSearchIndex;
|
||||
break;
|
||||
}
|
||||
nextSearchIndex++;
|
||||
}
|
||||
|
||||
if (atIndex === -1) {
|
||||
// No more @
|
||||
if (currentIndex < query.length) {
|
||||
parts.push({ type: 'text', content: query.substring(currentIndex) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Add text before @
|
||||
if (atIndex > currentIndex) {
|
||||
parts.push({
|
||||
type: 'text',
|
||||
content: query.substring(currentIndex, atIndex),
|
||||
});
|
||||
}
|
||||
|
||||
// Parse @path
|
||||
let pathEndIndex = atIndex + 1;
|
||||
let inEscape = false;
|
||||
while (pathEndIndex < query.length) {
|
||||
const char = query[pathEndIndex];
|
||||
if (inEscape) {
|
||||
inEscape = false;
|
||||
} else if (char === '\\') {
|
||||
inEscape = true;
|
||||
} else if (/\s/.test(char)) {
|
||||
// Path ends at first whitespace not escaped
|
||||
break;
|
||||
}
|
||||
pathEndIndex++;
|
||||
}
|
||||
const rawAtPath = query.substring(atIndex, pathEndIndex);
|
||||
// unescapePath expects the @ symbol to be present, and will handle it.
|
||||
const atPath = unescapePath(rawAtPath);
|
||||
parts.push({ type: 'atPath', content: atPath });
|
||||
currentIndex = pathEndIndex;
|
||||
}
|
||||
// Filter out empty text parts that might result from consecutive @paths or leading/trailing spaces
|
||||
return parts.filter(
|
||||
(part) => !(part.type === 'text' && part.content.trim() === ''),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes user input potentially containing one or more '@<path>' commands.
|
||||
* If found, it attempts to read the specified files/directories using the
|
||||
* 'read_many_files' tool. The user query is modified to include resolved paths,
|
||||
* and the content of the files is appended in a structured block.
|
||||
*
|
||||
* @returns An object indicating whether the main hook should proceed with an
|
||||
* LLM call and the processed query parts (including file content).
|
||||
*/
|
||||
export async function handleAtCommand({
|
||||
query,
|
||||
config,
|
||||
addItem,
|
||||
onDebugMessage,
|
||||
messageId: userMessageTimestamp,
|
||||
signal,
|
||||
}: HandleAtCommandParams): Promise<HandleAtCommandResult> {
|
||||
const commandParts = parseAllAtCommands(query);
|
||||
const atPathCommandParts = commandParts.filter(
|
||||
(part) => part.type === 'atPath',
|
||||
);
|
||||
|
||||
if (atPathCommandParts.length === 0) {
|
||||
addItem({ type: 'user', text: query }, userMessageTimestamp);
|
||||
return { processedQuery: [{ text: query }], shouldProceed: true };
|
||||
}
|
||||
|
||||
addItem({ type: 'user', text: query }, userMessageTimestamp);
|
||||
|
||||
// Get centralized file discovery service
|
||||
const fileDiscovery = config.getFileService();
|
||||
const respectGitIgnore = config.getFileFilteringRespectGitIgnore();
|
||||
|
||||
const pathSpecsToRead: string[] = [];
|
||||
const atPathToResolvedSpecMap = new Map<string, string>();
|
||||
const contentLabelsForDisplay: string[] = [];
|
||||
const ignoredPaths: string[] = [];
|
||||
|
||||
const toolRegistry = await config.getToolRegistry();
|
||||
const readManyFilesTool = toolRegistry.getTool('read_many_files');
|
||||
const globTool = toolRegistry.getTool('glob');
|
||||
|
||||
if (!readManyFilesTool) {
|
||||
addItem(
|
||||
{ type: 'error', text: 'Error: read_many_files tool not found.' },
|
||||
userMessageTimestamp,
|
||||
);
|
||||
return { processedQuery: null, shouldProceed: false };
|
||||
}
|
||||
|
||||
for (const atPathPart of atPathCommandParts) {
|
||||
const originalAtPath = atPathPart.content; // e.g., "@file.txt" or "@"
|
||||
|
||||
if (originalAtPath === '@') {
|
||||
onDebugMessage(
|
||||
'Lone @ detected, will be treated as text in the modified query.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pathName = originalAtPath.substring(1);
|
||||
if (!pathName) {
|
||||
// This case should ideally not be hit if parseAllAtCommands ensures content after @
|
||||
// but as a safeguard:
|
||||
addItem(
|
||||
{
|
||||
type: 'error',
|
||||
text: `Error: Invalid @ command '${originalAtPath}'. No path specified.`,
|
||||
},
|
||||
userMessageTimestamp,
|
||||
);
|
||||
// Decide if this is a fatal error for the whole command or just skip this @ part
|
||||
// For now, let's be strict and fail the command if one @path is malformed.
|
||||
return { processedQuery: null, shouldProceed: false };
|
||||
}
|
||||
|
||||
// Check if path should be ignored based on filtering options
|
||||
if (fileDiscovery.shouldIgnoreFile(pathName, { respectGitIgnore })) {
|
||||
const reason = respectGitIgnore ? 'git-ignored' : 'custom-ignored';
|
||||
onDebugMessage(`Path ${pathName} is ${reason} and will be skipped.`);
|
||||
ignoredPaths.push(pathName);
|
||||
continue;
|
||||
}
|
||||
|
||||
let currentPathSpec = pathName;
|
||||
let resolvedSuccessfully = false;
|
||||
|
||||
try {
|
||||
const absolutePath = path.resolve(config.getTargetDir(), pathName);
|
||||
const stats = await fs.stat(absolutePath);
|
||||
if (stats.isDirectory()) {
|
||||
currentPathSpec = pathName.endsWith('/')
|
||||
? `${pathName}**`
|
||||
: `${pathName}/**`;
|
||||
onDebugMessage(
|
||||
`Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`,
|
||||
);
|
||||
} else {
|
||||
onDebugMessage(`Path ${pathName} resolved to file: ${currentPathSpec}`);
|
||||
}
|
||||
resolvedSuccessfully = true;
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
if (config.getEnableRecursiveFileSearch() && globTool) {
|
||||
onDebugMessage(
|
||||
`Path ${pathName} not found directly, attempting glob search.`,
|
||||
);
|
||||
try {
|
||||
const globResult = await globTool.execute(
|
||||
{ pattern: `**/*${pathName}*`, path: config.getTargetDir() },
|
||||
signal,
|
||||
);
|
||||
if (
|
||||
globResult.llmContent &&
|
||||
typeof globResult.llmContent === 'string' &&
|
||||
!globResult.llmContent.startsWith('No files found') &&
|
||||
!globResult.llmContent.startsWith('Error:')
|
||||
) {
|
||||
const lines = globResult.llmContent.split('\n');
|
||||
if (lines.length > 1 && lines[1]) {
|
||||
const firstMatchAbsolute = lines[1].trim();
|
||||
currentPathSpec = path.relative(
|
||||
config.getTargetDir(),
|
||||
firstMatchAbsolute,
|
||||
);
|
||||
onDebugMessage(
|
||||
`Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`,
|
||||
);
|
||||
resolvedSuccessfully = true;
|
||||
} else {
|
||||
onDebugMessage(
|
||||
`Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
onDebugMessage(
|
||||
`Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`,
|
||||
);
|
||||
}
|
||||
} catch (globError) {
|
||||
console.error(
|
||||
`Error during glob search for ${pathName}: ${getErrorMessage(globError)}`,
|
||||
);
|
||||
onDebugMessage(
|
||||
`Error during glob search for ${pathName}. Path ${pathName} will be skipped.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
onDebugMessage(
|
||||
`Glob tool not found. Path ${pathName} will be skipped.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
`Error stating path ${pathName}: ${getErrorMessage(error)}`,
|
||||
);
|
||||
onDebugMessage(
|
||||
`Error stating path ${pathName}. Path ${pathName} will be skipped.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedSuccessfully) {
|
||||
pathSpecsToRead.push(currentPathSpec);
|
||||
atPathToResolvedSpecMap.set(originalAtPath, currentPathSpec);
|
||||
contentLabelsForDisplay.push(pathName);
|
||||
}
|
||||
}
|
||||
|
||||
// Construct the initial part of the query for the LLM
|
||||
let initialQueryText = '';
|
||||
for (let i = 0; i < commandParts.length; i++) {
|
||||
const part = commandParts[i];
|
||||
if (part.type === 'text') {
|
||||
initialQueryText += part.content;
|
||||
} else {
|
||||
// type === 'atPath'
|
||||
const resolvedSpec = atPathToResolvedSpecMap.get(part.content);
|
||||
if (
|
||||
i > 0 &&
|
||||
initialQueryText.length > 0 &&
|
||||
!initialQueryText.endsWith(' ') &&
|
||||
resolvedSpec
|
||||
) {
|
||||
// Add space if previous part was text and didn't end with space, or if previous was @path
|
||||
const prevPart = commandParts[i - 1];
|
||||
if (
|
||||
prevPart.type === 'text' ||
|
||||
(prevPart.type === 'atPath' &&
|
||||
atPathToResolvedSpecMap.has(prevPart.content))
|
||||
) {
|
||||
initialQueryText += ' ';
|
||||
}
|
||||
}
|
||||
if (resolvedSpec) {
|
||||
initialQueryText += `@${resolvedSpec}`;
|
||||
} else {
|
||||
// If not resolved for reading (e.g. lone @ or invalid path that was skipped),
|
||||
// add the original @-string back, ensuring spacing if it's not the first element.
|
||||
if (
|
||||
i > 0 &&
|
||||
initialQueryText.length > 0 &&
|
||||
!initialQueryText.endsWith(' ') &&
|
||||
!part.content.startsWith(' ')
|
||||
) {
|
||||
initialQueryText += ' ';
|
||||
}
|
||||
initialQueryText += part.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
initialQueryText = initialQueryText.trim();
|
||||
|
||||
// Inform user about ignored paths
|
||||
if (ignoredPaths.length > 0) {
|
||||
const ignoreType = respectGitIgnore ? 'git-ignored' : 'custom-ignored';
|
||||
onDebugMessage(
|
||||
`Ignored ${ignoredPaths.length} ${ignoreType} files: ${ignoredPaths.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText
|
||||
if (pathSpecsToRead.length === 0) {
|
||||
onDebugMessage('No valid file paths found in @ commands to read.');
|
||||
if (initialQueryText === '@' && query.trim() === '@') {
|
||||
// If the only thing was a lone @, pass original query (which might have spaces)
|
||||
return { processedQuery: [{ text: query }], shouldProceed: true };
|
||||
} else if (!initialQueryText && query) {
|
||||
// If all @-commands were invalid and no surrounding text, pass original query
|
||||
return { processedQuery: [{ text: query }], shouldProceed: true };
|
||||
}
|
||||
// Otherwise, proceed with the (potentially modified) query text that doesn't involve file reading
|
||||
return {
|
||||
processedQuery: [{ text: initialQueryText || query }],
|
||||
shouldProceed: true,
|
||||
};
|
||||
}
|
||||
|
||||
const processedQueryParts: PartUnion[] = [{ text: initialQueryText }];
|
||||
|
||||
const toolArgs = {
|
||||
paths: pathSpecsToRead,
|
||||
respect_git_ignore: respectGitIgnore, // Use configuration setting
|
||||
};
|
||||
let toolCallDisplay: IndividualToolCallDisplay;
|
||||
|
||||
try {
|
||||
const result = await readManyFilesTool.execute(toolArgs, signal);
|
||||
toolCallDisplay = {
|
||||
callId: `client-read-${userMessageTimestamp}`,
|
||||
name: readManyFilesTool.displayName,
|
||||
description: readManyFilesTool.getDescription(toolArgs),
|
||||
status: ToolCallStatus.Success,
|
||||
resultDisplay:
|
||||
result.returnDisplay ||
|
||||
`Successfully read: ${contentLabelsForDisplay.join(', ')}`,
|
||||
confirmationDetails: undefined,
|
||||
};
|
||||
|
||||
if (Array.isArray(result.llmContent)) {
|
||||
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
|
||||
processedQueryParts.push({
|
||||
text: '\n--- Content from referenced files ---',
|
||||
});
|
||||
for (const part of result.llmContent) {
|
||||
if (typeof part === 'string') {
|
||||
const match = fileContentRegex.exec(part);
|
||||
if (match) {
|
||||
const filePathSpecInContent = match[1]; // This is a resolved pathSpec
|
||||
const fileActualContent = match[2].trim();
|
||||
processedQueryParts.push({
|
||||
text: `\nContent from @${filePathSpecInContent}:\n`,
|
||||
});
|
||||
processedQueryParts.push({ text: fileActualContent });
|
||||
} else {
|
||||
processedQueryParts.push({ text: part });
|
||||
}
|
||||
} else {
|
||||
// part is a Part object.
|
||||
processedQueryParts.push(part);
|
||||
}
|
||||
}
|
||||
processedQueryParts.push({ text: '\n--- End of content ---' });
|
||||
} else {
|
||||
onDebugMessage(
|
||||
'read_many_files tool returned no content or empty content.',
|
||||
);
|
||||
}
|
||||
|
||||
addItem(
|
||||
{ type: 'tool_group', tools: [toolCallDisplay] } as Omit<
|
||||
HistoryItem,
|
||||
'id'
|
||||
>,
|
||||
userMessageTimestamp,
|
||||
);
|
||||
return { processedQuery: processedQueryParts, shouldProceed: true };
|
||||
} catch (error: unknown) {
|
||||
toolCallDisplay = {
|
||||
callId: `client-read-${userMessageTimestamp}`,
|
||||
name: readManyFilesTool.displayName,
|
||||
description: readManyFilesTool.getDescription(toolArgs),
|
||||
status: ToolCallStatus.Error,
|
||||
resultDisplay: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
|
||||
confirmationDetails: undefined,
|
||||
};
|
||||
addItem(
|
||||
{ type: 'tool_group', tools: [toolCallDisplay] } as Omit<
|
||||
HistoryItem,
|
||||
'id'
|
||||
>,
|
||||
userMessageTimestamp,
|
||||
);
|
||||
return { processedQuery: null, shouldProceed: false };
|
||||
}
|
||||
}
|
||||
179
packages/cli/src/ui/hooks/shellCommandProcessor.test.ts
Normal file
179
packages/cli/src/ui/hooks/shellCommandProcessor.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { vi } from 'vitest';
|
||||
import { useShellCommandProcessor } from './shellCommandProcessor';
|
||||
import { Config, GeminiClient } from '@qwen/qwen-code-core';
|
||||
import * as fs from 'fs';
|
||||
import EventEmitter from 'events';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('child_process');
|
||||
vi.mock('fs');
|
||||
vi.mock('os', () => ({
|
||||
default: {
|
||||
platform: () => 'linux',
|
||||
tmpdir: () => '/tmp',
|
||||
},
|
||||
platform: () => 'linux',
|
||||
tmpdir: () => '/tmp',
|
||||
}));
|
||||
vi.mock('@qwen/qwen-code-core');
|
||||
vi.mock('../utils/textUtils.js', () => ({
|
||||
isBinary: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('useShellCommandProcessor', () => {
|
||||
let spawnEmitter: EventEmitter;
|
||||
let addItemToHistoryMock: vi.Mock;
|
||||
let setPendingHistoryItemMock: vi.Mock;
|
||||
let onExecMock: vi.Mock;
|
||||
let onDebugMessageMock: vi.Mock;
|
||||
let configMock: Config;
|
||||
let geminiClientMock: GeminiClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
const { spawn } = await import('child_process');
|
||||
spawnEmitter = new EventEmitter();
|
||||
spawnEmitter.stdout = new EventEmitter();
|
||||
spawnEmitter.stderr = new EventEmitter();
|
||||
(spawn as vi.Mock).mockReturnValue(spawnEmitter);
|
||||
|
||||
vi.spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||
vi.spyOn(fs, 'readFileSync').mockReturnValue('');
|
||||
vi.spyOn(fs, 'unlinkSync').mockReturnValue(undefined);
|
||||
|
||||
addItemToHistoryMock = vi.fn();
|
||||
setPendingHistoryItemMock = vi.fn();
|
||||
onExecMock = vi.fn();
|
||||
onDebugMessageMock = vi.fn();
|
||||
|
||||
configMock = {
|
||||
getTargetDir: () => '/test/dir',
|
||||
} as unknown as Config;
|
||||
|
||||
geminiClientMock = {
|
||||
addHistory: vi.fn(),
|
||||
} as unknown as GeminiClient;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const renderProcessorHook = () =>
|
||||
renderHook(() =>
|
||||
useShellCommandProcessor(
|
||||
addItemToHistoryMock,
|
||||
setPendingHistoryItemMock,
|
||||
onExecMock,
|
||||
onDebugMessageMock,
|
||||
configMock,
|
||||
geminiClientMock,
|
||||
),
|
||||
);
|
||||
|
||||
it('should execute a command and update history on success', async () => {
|
||||
const { result } = renderProcessorHook();
|
||||
const abortController = new AbortController();
|
||||
|
||||
act(() => {
|
||||
result.current.handleShellCommand('ls -l', abortController.signal);
|
||||
});
|
||||
|
||||
expect(onExecMock).toHaveBeenCalledTimes(1);
|
||||
const execPromise = onExecMock.mock.calls[0][0];
|
||||
|
||||
// Simulate stdout
|
||||
act(() => {
|
||||
spawnEmitter.stdout.emit('data', Buffer.from('file1.txt\nfile2.txt'));
|
||||
});
|
||||
|
||||
// Simulate process exit
|
||||
act(() => {
|
||||
spawnEmitter.emit('exit', 0, null);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await execPromise;
|
||||
});
|
||||
|
||||
expect(addItemToHistoryMock).toHaveBeenCalledTimes(2);
|
||||
expect(addItemToHistoryMock.mock.calls[1][0]).toEqual({
|
||||
type: 'info',
|
||||
text: 'file1.txt\nfile2.txt',
|
||||
});
|
||||
expect(geminiClientMock.addHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle binary output', async () => {
|
||||
const { result } = renderProcessorHook();
|
||||
const abortController = new AbortController();
|
||||
const { isBinary } = await import('../utils/textUtils.js');
|
||||
(isBinary as vi.Mock).mockReturnValue(true);
|
||||
|
||||
act(() => {
|
||||
result.current.handleShellCommand(
|
||||
'cat myimage.png',
|
||||
abortController.signal,
|
||||
);
|
||||
});
|
||||
|
||||
expect(onExecMock).toHaveBeenCalledTimes(1);
|
||||
const execPromise = onExecMock.mock.calls[0][0];
|
||||
|
||||
act(() => {
|
||||
spawnEmitter.stdout.emit('data', Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
spawnEmitter.emit('exit', 0, null);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await execPromise;
|
||||
});
|
||||
|
||||
expect(addItemToHistoryMock).toHaveBeenCalledTimes(2);
|
||||
expect(addItemToHistoryMock.mock.calls[1][0]).toEqual({
|
||||
type: 'info',
|
||||
text: '[Command produced binary output, which is not shown.]',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle command failure', async () => {
|
||||
const { result } = renderProcessorHook();
|
||||
const abortController = new AbortController();
|
||||
|
||||
act(() => {
|
||||
result.current.handleShellCommand(
|
||||
'a-bad-command',
|
||||
abortController.signal,
|
||||
);
|
||||
});
|
||||
|
||||
const execPromise = onExecMock.mock.calls[0][0];
|
||||
|
||||
act(() => {
|
||||
spawnEmitter.stderr.emit('data', Buffer.from('command not found'));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
spawnEmitter.emit('exit', 127, null);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await execPromise;
|
||||
});
|
||||
|
||||
expect(addItemToHistoryMock).toHaveBeenCalledTimes(2);
|
||||
expect(addItemToHistoryMock.mock.calls[1][0]).toEqual({
|
||||
type: 'error',
|
||||
text: 'Command exited with code 127.\ncommand not found',
|
||||
});
|
||||
});
|
||||
});
|
||||
348
packages/cli/src/ui/hooks/shellCommandProcessor.ts
Normal file
348
packages/cli/src/ui/hooks/shellCommandProcessor.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { StringDecoder } from 'string_decoder';
|
||||
import type { HistoryItemWithoutId } from '../types.js';
|
||||
import { useCallback } from 'react';
|
||||
import { Config, GeminiClient } from '@qwen/qwen-code-core';
|
||||
import { type PartListUnion } from '@google/genai';
|
||||
import { formatMemoryUsage } from '../utils/formatters.js';
|
||||
import { isBinary } from '../utils/textUtils.js';
|
||||
import { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import crypto from 'crypto';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
|
||||
const OUTPUT_UPDATE_INTERVAL_MS = 1000;
|
||||
const MAX_OUTPUT_LENGTH = 10000;
|
||||
|
||||
/**
|
||||
* A structured result from a shell command execution.
|
||||
*/
|
||||
interface ShellExecutionResult {
|
||||
rawOutput: Buffer;
|
||||
output: string;
|
||||
exitCode: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
error: Error | null;
|
||||
aborted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a shell command using `spawn`, capturing all output and lifecycle events.
|
||||
* This is the single, unified implementation for shell execution.
|
||||
*
|
||||
* @param commandToExecute The exact command string to run.
|
||||
* @param cwd The working directory to execute the command in.
|
||||
* @param abortSignal An AbortSignal to terminate the process.
|
||||
* @param onOutputChunk A callback for streaming real-time output.
|
||||
* @param onDebugMessage A callback for logging debug information.
|
||||
* @returns A promise that resolves with the complete execution result.
|
||||
*/
|
||||
function executeShellCommand(
|
||||
commandToExecute: string,
|
||||
cwd: string,
|
||||
abortSignal: AbortSignal,
|
||||
onOutputChunk: (chunk: string) => void,
|
||||
onDebugMessage: (message: string) => void,
|
||||
): Promise<ShellExecutionResult> {
|
||||
return new Promise((resolve) => {
|
||||
const isWindows = os.platform() === 'win32';
|
||||
const shell = isWindows ? 'cmd.exe' : 'bash';
|
||||
const shellArgs = isWindows
|
||||
? ['/c', commandToExecute]
|
||||
: ['-c', commandToExecute];
|
||||
|
||||
const child = spawn(shell, shellArgs, {
|
||||
cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: !isWindows, // Use process groups on non-Windows for robust killing
|
||||
});
|
||||
|
||||
// Use decoders to handle multi-byte characters safely (for streaming output).
|
||||
const stdoutDecoder = new StringDecoder('utf8');
|
||||
const stderrDecoder = new StringDecoder('utf8');
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const outputChunks: Buffer[] = [];
|
||||
let error: Error | null = null;
|
||||
let exited = false;
|
||||
|
||||
let streamToUi = true;
|
||||
const MAX_SNIFF_SIZE = 4096;
|
||||
let sniffedBytes = 0;
|
||||
|
||||
const handleOutput = (data: Buffer, stream: 'stdout' | 'stderr') => {
|
||||
outputChunks.push(data);
|
||||
|
||||
if (streamToUi && sniffedBytes < MAX_SNIFF_SIZE) {
|
||||
// Use a limited-size buffer for the check to avoid performance issues.
|
||||
const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20));
|
||||
sniffedBytes = sniffBuffer.length;
|
||||
|
||||
if (isBinary(sniffBuffer)) {
|
||||
streamToUi = false;
|
||||
// Overwrite any garbled text that may have streamed with a clear message.
|
||||
onOutputChunk('[Binary output detected. Halting stream...]');
|
||||
}
|
||||
}
|
||||
|
||||
const decodedChunk =
|
||||
stream === 'stdout'
|
||||
? stdoutDecoder.write(data)
|
||||
: stderrDecoder.write(data);
|
||||
if (stream === 'stdout') {
|
||||
stdout += stripAnsi(decodedChunk);
|
||||
} else {
|
||||
stderr += stripAnsi(decodedChunk);
|
||||
}
|
||||
|
||||
if (!exited && streamToUi) {
|
||||
// Send only the new chunk to avoid re-rendering the whole output.
|
||||
const combinedOutput = stdout + (stderr ? `\n${stderr}` : '');
|
||||
onOutputChunk(combinedOutput);
|
||||
} else if (!exited && !streamToUi) {
|
||||
// Send progress updates for the binary stream
|
||||
const totalBytes = outputChunks.reduce(
|
||||
(sum, chunk) => sum + chunk.length,
|
||||
0,
|
||||
);
|
||||
onOutputChunk(
|
||||
`[Receiving binary output... ${formatMemoryUsage(totalBytes)} received]`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout.on('data', (data) => handleOutput(data, 'stdout'));
|
||||
child.stderr.on('data', (data) => handleOutput(data, 'stderr'));
|
||||
child.on('error', (err) => {
|
||||
error = err;
|
||||
});
|
||||
|
||||
const abortHandler = async () => {
|
||||
if (child.pid && !exited) {
|
||||
onDebugMessage(`Aborting shell command (PID: ${child.pid})`);
|
||||
if (isWindows) {
|
||||
spawn('taskkill', ['/pid', child.pid.toString(), '/f', '/t']);
|
||||
} else {
|
||||
try {
|
||||
// Kill the entire process group (negative PID).
|
||||
// SIGTERM first, then SIGKILL if it doesn't die.
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
await new Promise((res) => setTimeout(res, 200));
|
||||
if (!exited) {
|
||||
process.kill(-child.pid, 'SIGKILL');
|
||||
}
|
||||
} catch (_e) {
|
||||
// Fallback to killing just the main process if group kill fails.
|
||||
if (!exited) child.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
abortSignal.addEventListener('abort', abortHandler, { once: true });
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
exited = true;
|
||||
abortSignal.removeEventListener('abort', abortHandler);
|
||||
|
||||
// Handle any final bytes lingering in the decoders
|
||||
stdout += stdoutDecoder.end();
|
||||
stderr += stderrDecoder.end();
|
||||
|
||||
const finalBuffer = Buffer.concat(outputChunks);
|
||||
|
||||
resolve({
|
||||
rawOutput: finalBuffer,
|
||||
output: stdout + (stderr ? `\n${stderr}` : ''),
|
||||
exitCode: code,
|
||||
signal,
|
||||
error,
|
||||
aborted: abortSignal.aborted,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function addShellCommandToGeminiHistory(
|
||||
geminiClient: GeminiClient,
|
||||
rawQuery: string,
|
||||
resultText: string,
|
||||
) {
|
||||
const modelContent =
|
||||
resultText.length > MAX_OUTPUT_LENGTH
|
||||
? resultText.substring(0, MAX_OUTPUT_LENGTH) + '\n... (truncated)'
|
||||
: resultText;
|
||||
|
||||
geminiClient.addHistory({
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: `I ran the following shell command:
|
||||
\`\`\`sh
|
||||
${rawQuery}
|
||||
\`\`\`
|
||||
|
||||
This produced the following result:
|
||||
\`\`\`
|
||||
${modelContent}
|
||||
\`\`\``,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to process shell commands.
|
||||
* Orchestrates command execution and updates history and agent context.
|
||||
*/
|
||||
export const useShellCommandProcessor = (
|
||||
addItemToHistory: UseHistoryManagerReturn['addItem'],
|
||||
setPendingHistoryItem: React.Dispatch<
|
||||
React.SetStateAction<HistoryItemWithoutId | null>
|
||||
>,
|
||||
onExec: (command: Promise<void>) => void,
|
||||
onDebugMessage: (message: string) => void,
|
||||
config: Config,
|
||||
geminiClient: GeminiClient,
|
||||
) => {
|
||||
const handleShellCommand = useCallback(
|
||||
(rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => {
|
||||
if (typeof rawQuery !== 'string' || rawQuery.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userMessageTimestamp = Date.now();
|
||||
addItemToHistory(
|
||||
{ type: 'user_shell', text: rawQuery },
|
||||
userMessageTimestamp,
|
||||
);
|
||||
|
||||
const isWindows = os.platform() === 'win32';
|
||||
const targetDir = config.getTargetDir();
|
||||
let commandToExecute = rawQuery;
|
||||
let pwdFilePath: string | undefined;
|
||||
|
||||
// On non-windows, wrap the command to capture the final working directory.
|
||||
if (!isWindows) {
|
||||
let command = rawQuery.trim();
|
||||
const pwdFileName = `shell_pwd_${crypto.randomBytes(6).toString('hex')}.tmp`;
|
||||
pwdFilePath = path.join(os.tmpdir(), pwdFileName);
|
||||
// Ensure command ends with a separator before adding our own.
|
||||
if (!command.endsWith(';') && !command.endsWith('&')) {
|
||||
command += ';';
|
||||
}
|
||||
commandToExecute = `{ ${command} }; __code=$?; pwd > "${pwdFilePath}"; exit $__code`;
|
||||
}
|
||||
|
||||
const execPromise = new Promise<void>((resolve) => {
|
||||
let lastUpdateTime = 0;
|
||||
|
||||
onDebugMessage(`Executing in ${targetDir}: ${commandToExecute}`);
|
||||
executeShellCommand(
|
||||
commandToExecute,
|
||||
targetDir,
|
||||
abortSignal,
|
||||
(streamedOutput) => {
|
||||
// Throttle pending UI updates to avoid excessive re-renders.
|
||||
if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) {
|
||||
setPendingHistoryItem({ type: 'info', text: streamedOutput });
|
||||
lastUpdateTime = Date.now();
|
||||
}
|
||||
},
|
||||
onDebugMessage,
|
||||
)
|
||||
.then((result) => {
|
||||
// TODO(abhipatel12) - Consider updating pending item and using timeout to ensure
|
||||
// there is no jump where intermediate output is skipped.
|
||||
setPendingHistoryItem(null);
|
||||
|
||||
let historyItemType: HistoryItemWithoutId['type'] = 'info';
|
||||
let mainContent: string;
|
||||
|
||||
// The context sent to the model utilizes a text tokenizer which means raw binary data is
|
||||
// cannot be parsed and understood and thus would only pollute the context window and waste
|
||||
// tokens.
|
||||
if (isBinary(result.rawOutput)) {
|
||||
mainContent =
|
||||
'[Command produced binary output, which is not shown.]';
|
||||
} else {
|
||||
mainContent =
|
||||
result.output.trim() || '(Command produced no output)';
|
||||
}
|
||||
|
||||
let finalOutput = mainContent;
|
||||
|
||||
if (result.error) {
|
||||
historyItemType = 'error';
|
||||
finalOutput = `${result.error.message}\n${finalOutput}`;
|
||||
} else if (result.aborted) {
|
||||
finalOutput = `Command was cancelled.\n${finalOutput}`;
|
||||
} else if (result.signal) {
|
||||
historyItemType = 'error';
|
||||
finalOutput = `Command terminated by signal: ${result.signal}.\n${finalOutput}`;
|
||||
} else if (result.exitCode !== 0) {
|
||||
historyItemType = 'error';
|
||||
finalOutput = `Command exited with code ${result.exitCode}.\n${finalOutput}`;
|
||||
}
|
||||
|
||||
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
|
||||
const finalPwd = fs.readFileSync(pwdFilePath, 'utf8').trim();
|
||||
if (finalPwd && finalPwd !== targetDir) {
|
||||
const warning = `WARNING: shell mode is stateless; the directory change to '${finalPwd}' will not persist.`;
|
||||
finalOutput = `${warning}\n\n${finalOutput}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the complete, contextual result to the local UI history.
|
||||
addItemToHistory(
|
||||
{ type: historyItemType, text: finalOutput },
|
||||
userMessageTimestamp,
|
||||
);
|
||||
|
||||
// Add the same complete, contextual result to the LLM's history.
|
||||
addShellCommandToGeminiHistory(geminiClient, rawQuery, finalOutput);
|
||||
})
|
||||
.catch((err) => {
|
||||
setPendingHistoryItem(null);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : String(err);
|
||||
addItemToHistory(
|
||||
{
|
||||
type: 'error',
|
||||
text: `An unexpected error occurred: ${errorMessage}`,
|
||||
},
|
||||
userMessageTimestamp,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
|
||||
fs.unlinkSync(pwdFilePath);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
onExec(execPromise);
|
||||
return true; // Command was initiated
|
||||
},
|
||||
[
|
||||
config,
|
||||
onDebugMessage,
|
||||
addItemToHistory,
|
||||
setPendingHistoryItem,
|
||||
onExec,
|
||||
geminiClient,
|
||||
],
|
||||
);
|
||||
|
||||
return { handleShellCommand };
|
||||
};
|
||||
1301
packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
Normal file
1301
packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1218
packages/cli/src/ui/hooks/slashCommandProcessor.ts
Normal file
1218
packages/cli/src/ui/hooks/slashCommandProcessor.ts
Normal file
File diff suppressed because it is too large
Load Diff
88
packages/cli/src/ui/hooks/useAuthCommand.ts
Normal file
88
packages/cli/src/ui/hooks/useAuthCommand.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import {
|
||||
AuthType,
|
||||
Config,
|
||||
clearCachedCredentialFile,
|
||||
getErrorMessage,
|
||||
} from '@qwen/qwen-code-core';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
|
||||
export const useAuthCommand = (
|
||||
settings: LoadedSettings,
|
||||
setAuthError: (error: string | null) => void,
|
||||
config: Config,
|
||||
) => {
|
||||
const [isAuthDialogOpen, setIsAuthDialogOpen] = useState(
|
||||
settings.merged.selectedAuthType === undefined,
|
||||
);
|
||||
|
||||
const openAuthDialog = useCallback(() => {
|
||||
setIsAuthDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const authFlow = async () => {
|
||||
const authType = settings.merged.selectedAuthType;
|
||||
if (isAuthDialogOpen || !authType) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsAuthenticating(true);
|
||||
await config.refreshAuth(authType);
|
||||
console.log(`Authenticated via "${authType}".`);
|
||||
} catch (e) {
|
||||
setAuthError(`Failed to login. Message: ${getErrorMessage(e)}`);
|
||||
openAuthDialog();
|
||||
} finally {
|
||||
setIsAuthenticating(false);
|
||||
}
|
||||
};
|
||||
|
||||
void authFlow();
|
||||
}, [isAuthDialogOpen, settings, config, setAuthError, openAuthDialog]);
|
||||
|
||||
const handleAuthSelect = useCallback(
|
||||
async (authType: AuthType | undefined, scope: SettingScope) => {
|
||||
if (authType) {
|
||||
await clearCachedCredentialFile();
|
||||
settings.setValue(scope, 'selectedAuthType', authType);
|
||||
if (authType === AuthType.LOGIN_WITH_GOOGLE && config.getNoBrowser()) {
|
||||
runExitCleanup();
|
||||
console.log(
|
||||
`
|
||||
----------------------------------------------------------------
|
||||
Logging in with Google... Please restart Gemini CLI to continue.
|
||||
----------------------------------------------------------------
|
||||
`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
setIsAuthDialogOpen(false);
|
||||
setAuthError(null);
|
||||
},
|
||||
[settings, setAuthError, config],
|
||||
);
|
||||
|
||||
const cancelAuthentication = useCallback(() => {
|
||||
setIsAuthenticating(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isAuthDialogOpen,
|
||||
openAuthDialog,
|
||||
handleAuthSelect,
|
||||
isAuthenticating,
|
||||
cancelAuthentication,
|
||||
};
|
||||
};
|
||||
276
packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts
Normal file
276
packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
type MockedFunction,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useAutoAcceptIndicator } from './useAutoAcceptIndicator.js';
|
||||
|
||||
import {
|
||||
Config,
|
||||
Config as ActualConfigType,
|
||||
ApprovalMode,
|
||||
} from '@qwen/qwen-code-core';
|
||||
import { useInput, type Key as InkKey } from 'ink';
|
||||
|
||||
vi.mock('ink');
|
||||
|
||||
vi.mock('@qwen/qwen-code-core', async () => {
|
||||
const actualServerModule = (await vi.importActual(
|
||||
'@qwen/qwen-code-core',
|
||||
)) as Record<string, unknown>;
|
||||
return {
|
||||
...actualServerModule,
|
||||
Config: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
interface MockConfigInstanceShape {
|
||||
getApprovalMode: Mock<() => ApprovalMode>;
|
||||
setApprovalMode: Mock<(value: ApprovalMode) => void>;
|
||||
getCoreTools: Mock<() => string[]>;
|
||||
getToolDiscoveryCommand: Mock<() => string | undefined>;
|
||||
getTargetDir: Mock<() => string>;
|
||||
getApiKey: Mock<() => string>;
|
||||
getModel: Mock<() => string>;
|
||||
getSandbox: Mock<() => boolean | string>;
|
||||
getDebugMode: Mock<() => boolean>;
|
||||
getQuestion: Mock<() => string | undefined>;
|
||||
getFullContext: Mock<() => boolean>;
|
||||
getUserAgent: Mock<() => string>;
|
||||
getUserMemory: Mock<() => string>;
|
||||
getGeminiMdFileCount: Mock<() => number>;
|
||||
getToolRegistry: Mock<() => { discoverTools: Mock<() => void> }>;
|
||||
}
|
||||
|
||||
type UseInputKey = InkKey;
|
||||
type UseInputHandler = (input: string, key: UseInputKey) => void;
|
||||
|
||||
describe('useAutoAcceptIndicator', () => {
|
||||
let mockConfigInstance: MockConfigInstanceShape;
|
||||
let capturedUseInputHandler: UseInputHandler;
|
||||
let mockedInkUseInput: MockedFunction<typeof useInput>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
(
|
||||
Config as unknown as MockedFunction<() => MockConfigInstanceShape>
|
||||
).mockImplementation(() => {
|
||||
const instanceGetApprovalModeMock = vi.fn();
|
||||
const instanceSetApprovalModeMock = vi.fn();
|
||||
|
||||
const instance: MockConfigInstanceShape = {
|
||||
getApprovalMode: instanceGetApprovalModeMock as Mock<
|
||||
() => ApprovalMode
|
||||
>,
|
||||
setApprovalMode: instanceSetApprovalModeMock as Mock<
|
||||
(value: ApprovalMode) => void
|
||||
>,
|
||||
getCoreTools: vi.fn().mockReturnValue([]) as Mock<() => string[]>,
|
||||
getToolDiscoveryCommand: vi.fn().mockReturnValue(undefined) as Mock<
|
||||
() => string | undefined
|
||||
>,
|
||||
getTargetDir: vi.fn().mockReturnValue('.') as Mock<() => string>,
|
||||
getApiKey: vi.fn().mockReturnValue('test-api-key') as Mock<
|
||||
() => string
|
||||
>,
|
||||
getModel: vi.fn().mockReturnValue('test-model') as Mock<() => string>,
|
||||
getSandbox: vi.fn().mockReturnValue(false) as Mock<
|
||||
() => boolean | string
|
||||
>,
|
||||
getDebugMode: vi.fn().mockReturnValue(false) as Mock<() => boolean>,
|
||||
getQuestion: vi.fn().mockReturnValue(undefined) as Mock<
|
||||
() => string | undefined
|
||||
>,
|
||||
getFullContext: vi.fn().mockReturnValue(false) as Mock<() => boolean>,
|
||||
getUserAgent: vi.fn().mockReturnValue('test-user-agent') as Mock<
|
||||
() => string
|
||||
>,
|
||||
getUserMemory: vi.fn().mockReturnValue('') as Mock<() => string>,
|
||||
getGeminiMdFileCount: vi.fn().mockReturnValue(0) as Mock<() => number>,
|
||||
getToolRegistry: vi
|
||||
.fn()
|
||||
.mockReturnValue({ discoverTools: vi.fn() }) as Mock<
|
||||
() => { discoverTools: Mock<() => void> }
|
||||
>,
|
||||
};
|
||||
instanceSetApprovalModeMock.mockImplementation((value: ApprovalMode) => {
|
||||
instanceGetApprovalModeMock.mockReturnValue(value);
|
||||
});
|
||||
return instance;
|
||||
});
|
||||
|
||||
mockedInkUseInput = useInput as MockedFunction<typeof useInput>;
|
||||
mockedInkUseInput.mockImplementation((handler: UseInputHandler) => {
|
||||
capturedUseInputHandler = handler;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockConfigInstance = new (Config as any)() as MockConfigInstanceShape;
|
||||
});
|
||||
|
||||
it('should initialize with ApprovalMode.AUTO_EDIT if config.getApprovalMode returns ApprovalMode.AUTO_EDIT', () => {
|
||||
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.AUTO_EDIT);
|
||||
const { result } = renderHook(() =>
|
||||
useAutoAcceptIndicator({
|
||||
config: mockConfigInstance as unknown as ActualConfigType,
|
||||
}),
|
||||
);
|
||||
expect(result.current).toBe(ApprovalMode.AUTO_EDIT);
|
||||
expect(mockConfigInstance.getApprovalMode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should initialize with ApprovalMode.DEFAULT if config.getApprovalMode returns ApprovalMode.DEFAULT', () => {
|
||||
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
|
||||
const { result } = renderHook(() =>
|
||||
useAutoAcceptIndicator({
|
||||
config: mockConfigInstance as unknown as ActualConfigType,
|
||||
}),
|
||||
);
|
||||
expect(result.current).toBe(ApprovalMode.DEFAULT);
|
||||
expect(mockConfigInstance.getApprovalMode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should initialize with ApprovalMode.YOLO if config.getApprovalMode returns ApprovalMode.YOLO', () => {
|
||||
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.YOLO);
|
||||
const { result } = renderHook(() =>
|
||||
useAutoAcceptIndicator({
|
||||
config: mockConfigInstance as unknown as ActualConfigType,
|
||||
}),
|
||||
);
|
||||
expect(result.current).toBe(ApprovalMode.YOLO);
|
||||
expect(mockConfigInstance.getApprovalMode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should toggle the indicator and update config when Shift+Tab or Ctrl+Y is pressed', () => {
|
||||
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
|
||||
const { result } = renderHook(() =>
|
||||
useAutoAcceptIndicator({
|
||||
config: mockConfigInstance as unknown as ActualConfigType,
|
||||
}),
|
||||
);
|
||||
expect(result.current).toBe(ApprovalMode.DEFAULT);
|
||||
|
||||
act(() => {
|
||||
capturedUseInputHandler('', { tab: true, shift: true } as InkKey);
|
||||
});
|
||||
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
|
||||
ApprovalMode.AUTO_EDIT,
|
||||
);
|
||||
expect(result.current).toBe(ApprovalMode.AUTO_EDIT);
|
||||
|
||||
act(() => {
|
||||
capturedUseInputHandler('y', { ctrl: true } as InkKey);
|
||||
});
|
||||
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
|
||||
ApprovalMode.YOLO,
|
||||
);
|
||||
expect(result.current).toBe(ApprovalMode.YOLO);
|
||||
|
||||
act(() => {
|
||||
capturedUseInputHandler('y', { ctrl: true } as InkKey);
|
||||
});
|
||||
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
|
||||
ApprovalMode.DEFAULT,
|
||||
);
|
||||
expect(result.current).toBe(ApprovalMode.DEFAULT);
|
||||
|
||||
act(() => {
|
||||
capturedUseInputHandler('y', { ctrl: true } as InkKey);
|
||||
});
|
||||
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
|
||||
ApprovalMode.YOLO,
|
||||
);
|
||||
expect(result.current).toBe(ApprovalMode.YOLO);
|
||||
|
||||
act(() => {
|
||||
capturedUseInputHandler('', { tab: true, shift: true } as InkKey);
|
||||
});
|
||||
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
|
||||
ApprovalMode.AUTO_EDIT,
|
||||
);
|
||||
expect(result.current).toBe(ApprovalMode.AUTO_EDIT);
|
||||
|
||||
act(() => {
|
||||
capturedUseInputHandler('', { tab: true, shift: true } as InkKey);
|
||||
});
|
||||
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
|
||||
ApprovalMode.DEFAULT,
|
||||
);
|
||||
expect(result.current).toBe(ApprovalMode.DEFAULT);
|
||||
});
|
||||
|
||||
it('should not toggle if only one key or other keys combinations are pressed', () => {
|
||||
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
|
||||
renderHook(() =>
|
||||
useAutoAcceptIndicator({
|
||||
config: mockConfigInstance as unknown as ActualConfigType,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
capturedUseInputHandler('', { tab: true, shift: false } as InkKey);
|
||||
});
|
||||
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
capturedUseInputHandler('', { tab: false, shift: true } as InkKey);
|
||||
});
|
||||
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
capturedUseInputHandler('a', { tab: false, shift: false } as InkKey);
|
||||
});
|
||||
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
capturedUseInputHandler('y', { tab: true } as InkKey);
|
||||
});
|
||||
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
capturedUseInputHandler('a', { ctrl: true } as InkKey);
|
||||
});
|
||||
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
capturedUseInputHandler('y', { shift: true } as InkKey);
|
||||
});
|
||||
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
capturedUseInputHandler('a', { ctrl: true, shift: true } as InkKey);
|
||||
});
|
||||
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update indicator when config value changes externally (useEffect dependency)', () => {
|
||||
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT);
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { config: ActualConfigType }) => useAutoAcceptIndicator(props),
|
||||
{
|
||||
initialProps: {
|
||||
config: mockConfigInstance as unknown as ActualConfigType,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result.current).toBe(ApprovalMode.DEFAULT);
|
||||
|
||||
mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.AUTO_EDIT);
|
||||
|
||||
rerender({ config: mockConfigInstance as unknown as ActualConfigType });
|
||||
expect(result.current).toBe(ApprovalMode.AUTO_EDIT);
|
||||
expect(mockConfigInstance.getApprovalMode).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
49
packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts
Normal file
49
packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useInput } from 'ink';
|
||||
import { ApprovalMode, type Config } from '@qwen/qwen-code-core';
|
||||
|
||||
export interface UseAutoAcceptIndicatorArgs {
|
||||
config: Config;
|
||||
}
|
||||
|
||||
export function useAutoAcceptIndicator({
|
||||
config,
|
||||
}: UseAutoAcceptIndicatorArgs): ApprovalMode {
|
||||
const currentConfigValue = config.getApprovalMode();
|
||||
const [showAutoAcceptIndicator, setShowAutoAcceptIndicator] =
|
||||
useState(currentConfigValue);
|
||||
|
||||
useEffect(() => {
|
||||
setShowAutoAcceptIndicator(currentConfigValue);
|
||||
}, [currentConfigValue]);
|
||||
|
||||
useInput((input, key) => {
|
||||
let nextApprovalMode: ApprovalMode | undefined;
|
||||
|
||||
if (key.ctrl && input === 'y') {
|
||||
nextApprovalMode =
|
||||
config.getApprovalMode() === ApprovalMode.YOLO
|
||||
? ApprovalMode.DEFAULT
|
||||
: ApprovalMode.YOLO;
|
||||
} else if (key.tab && key.shift) {
|
||||
nextApprovalMode =
|
||||
config.getApprovalMode() === ApprovalMode.AUTO_EDIT
|
||||
? ApprovalMode.DEFAULT
|
||||
: ApprovalMode.AUTO_EDIT;
|
||||
}
|
||||
|
||||
if (nextApprovalMode) {
|
||||
config.setApprovalMode(nextApprovalMode);
|
||||
// Update local state immediately for responsiveness
|
||||
setShowAutoAcceptIndicator(nextApprovalMode);
|
||||
}
|
||||
});
|
||||
|
||||
return showAutoAcceptIndicator;
|
||||
}
|
||||
37
packages/cli/src/ui/hooks/useBracketedPaste.ts
Normal file
37
packages/cli/src/ui/hooks/useBracketedPaste.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const ENABLE_BRACKETED_PASTE = '\x1b[?2004h';
|
||||
const DISABLE_BRACKETED_PASTE = '\x1b[?2004l';
|
||||
|
||||
/**
|
||||
* Enables and disables bracketed paste mode in the terminal.
|
||||
*
|
||||
* This hook ensures that bracketed paste mode is enabled when the component
|
||||
* mounts and disabled when it unmounts or when the process exits.
|
||||
*/
|
||||
export const useBracketedPaste = () => {
|
||||
const cleanup = () => {
|
||||
process.stdout.write(DISABLE_BRACKETED_PASTE);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
process.stdout.write(ENABLE_BRACKETED_PASTE);
|
||||
|
||||
process.on('exit', cleanup);
|
||||
process.on('SIGINT', cleanup);
|
||||
process.on('SIGTERM', cleanup);
|
||||
|
||||
return () => {
|
||||
cleanup();
|
||||
process.removeListener('exit', cleanup);
|
||||
process.removeListener('SIGINT', cleanup);
|
||||
process.removeListener('SIGTERM', cleanup);
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
755
packages/cli/src/ui/hooks/useCompletion.integration.test.ts
Normal file
755
packages/cli/src/ui/hooks/useCompletion.integration.test.ts
Normal file
@@ -0,0 +1,755 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import type { Mocked } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useCompletion } from './useCompletion.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import { glob } from 'glob';
|
||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import { Config, FileDiscoveryService } from '@qwen/qwen-code-core';
|
||||
|
||||
interface MockConfig {
|
||||
getFileFilteringRespectGitIgnore: () => boolean;
|
||||
getEnableRecursiveFileSearch: () => boolean;
|
||||
getFileService: () => FileDiscoveryService | null;
|
||||
}
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('fs/promises');
|
||||
vi.mock('@qwen/qwen-code-core', async () => {
|
||||
const actual = await vi.importActual('@qwen/qwen-code-core');
|
||||
return {
|
||||
...actual,
|
||||
FileDiscoveryService: vi.fn(),
|
||||
isNodeError: vi.fn((error) => error.code === 'ENOENT'),
|
||||
escapePath: vi.fn((path) => path),
|
||||
unescapePath: vi.fn((path) => path),
|
||||
getErrorMessage: vi.fn((error) => error.message),
|
||||
};
|
||||
});
|
||||
vi.mock('glob');
|
||||
|
||||
describe('useCompletion git-aware filtering integration', () => {
|
||||
let mockFileDiscoveryService: Mocked<FileDiscoveryService>;
|
||||
let mockConfig: MockConfig;
|
||||
|
||||
const testCwd = '/test/project';
|
||||
const slashCommands = [
|
||||
{ name: 'help', description: 'Show help', action: vi.fn() },
|
||||
{ name: 'clear', description: 'Clear screen', action: vi.fn() },
|
||||
];
|
||||
|
||||
// A minimal mock is sufficient for these tests.
|
||||
const mockCommandContext = {} as CommandContext;
|
||||
|
||||
const mockSlashCommands: SlashCommand[] = [
|
||||
{
|
||||
name: 'help',
|
||||
altName: '?',
|
||||
description: 'Show help',
|
||||
action: vi.fn(),
|
||||
},
|
||||
{
|
||||
name: 'clear',
|
||||
description: 'Clear the screen',
|
||||
action: vi.fn(),
|
||||
},
|
||||
{
|
||||
name: 'memory',
|
||||
description: 'Manage memory',
|
||||
// This command is a parent, no action.
|
||||
subCommands: [
|
||||
{
|
||||
name: 'show',
|
||||
description: 'Show memory',
|
||||
action: vi.fn(),
|
||||
},
|
||||
{
|
||||
name: 'add',
|
||||
description: 'Add to memory',
|
||||
action: vi.fn(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'chat',
|
||||
description: 'Manage chat history',
|
||||
subCommands: [
|
||||
{
|
||||
name: 'save',
|
||||
description: 'Save chat',
|
||||
action: vi.fn(),
|
||||
},
|
||||
{
|
||||
name: 'resume',
|
||||
description: 'Resume a saved chat',
|
||||
action: vi.fn(),
|
||||
// This command provides its own argument completions
|
||||
completion: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
'my-chat-tag-1',
|
||||
'my-chat-tag-2',
|
||||
'my-channel',
|
||||
]),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockFileDiscoveryService = {
|
||||
shouldGitIgnoreFile: vi.fn(),
|
||||
shouldGeminiIgnoreFile: vi.fn(),
|
||||
shouldIgnoreFile: vi.fn(),
|
||||
filterFiles: vi.fn(),
|
||||
getGeminiIgnorePatterns: vi.fn(),
|
||||
projectRoot: '',
|
||||
gitIgnoreFilter: null,
|
||||
geminiIgnoreFilter: null,
|
||||
} as unknown as Mocked<FileDiscoveryService>;
|
||||
|
||||
mockConfig = {
|
||||
getFileFilteringRespectGitIgnore: vi.fn(() => true),
|
||||
getFileService: vi.fn().mockReturnValue(mockFileDiscoveryService),
|
||||
getEnableRecursiveFileSearch: vi.fn(() => true),
|
||||
};
|
||||
|
||||
vi.mocked(FileDiscoveryService).mockImplementation(
|
||||
() => mockFileDiscoveryService,
|
||||
);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should filter git-ignored entries from @ completions', async () => {
|
||||
const globResults = [`${testCwd}/data`, `${testCwd}/dist`];
|
||||
vi.mocked(glob).mockResolvedValue(globResults);
|
||||
|
||||
// Mock git ignore service to ignore certain files
|
||||
mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation(
|
||||
(path: string) => path.includes('dist'),
|
||||
);
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
|
||||
(path: string, options) => {
|
||||
if (options?.respectGitIgnore !== false) {
|
||||
return mockFileDiscoveryService.shouldGitIgnoreFile(path);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'@d',
|
||||
testCwd,
|
||||
true,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig as Config,
|
||||
),
|
||||
);
|
||||
|
||||
// Wait for async operations to complete
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150)); // Account for debounce
|
||||
});
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(1);
|
||||
expect(result.current.suggestions).toEqual(
|
||||
expect.arrayContaining([{ label: 'data', value: 'data' }]),
|
||||
);
|
||||
expect(result.current.showSuggestions).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter git-ignored directories from @ completions', async () => {
|
||||
// Mock fs.readdir to return both regular and git-ignored directories
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'src', isDirectory: () => true },
|
||||
{ name: 'node_modules', isDirectory: () => true },
|
||||
{ name: 'dist', isDirectory: () => true },
|
||||
{ name: 'README.md', isDirectory: () => false },
|
||||
{ name: '.env', isDirectory: () => false },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||
|
||||
// Mock git ignore service to ignore certain files
|
||||
mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation(
|
||||
(path: string) =>
|
||||
path.includes('node_modules') ||
|
||||
path.includes('dist') ||
|
||||
path.includes('.env'),
|
||||
);
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
|
||||
(path: string, options) => {
|
||||
if (options?.respectGitIgnore !== false) {
|
||||
return mockFileDiscoveryService.shouldGitIgnoreFile(path);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'@',
|
||||
testCwd,
|
||||
true,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig as Config,
|
||||
),
|
||||
);
|
||||
|
||||
// Wait for async operations to complete
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150)); // Account for debounce
|
||||
});
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(2);
|
||||
expect(result.current.suggestions).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ label: 'src/', value: 'src/' },
|
||||
{ label: 'README.md', value: 'README.md' },
|
||||
]),
|
||||
);
|
||||
expect(result.current.showSuggestions).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle recursive search with git-aware filtering', async () => {
|
||||
// Mock the recursive file search scenario
|
||||
vi.mocked(fs.readdir).mockImplementation(
|
||||
async (dirPath: string | Buffer | URL) => {
|
||||
if (dirPath === testCwd) {
|
||||
return [
|
||||
{ name: 'src', isDirectory: () => true },
|
||||
{ name: 'node_modules', isDirectory: () => true },
|
||||
{ name: 'temp', isDirectory: () => true },
|
||||
] as Array<{ name: string; isDirectory: () => boolean }>;
|
||||
}
|
||||
if (dirPath.endsWith('/src')) {
|
||||
return [
|
||||
{ name: 'index.ts', isDirectory: () => false },
|
||||
{ name: 'components', isDirectory: () => true },
|
||||
] as Array<{ name: string; isDirectory: () => boolean }>;
|
||||
}
|
||||
if (dirPath.endsWith('/temp')) {
|
||||
return [{ name: 'temp.log', isDirectory: () => false }] as Array<{
|
||||
name: string;
|
||||
isDirectory: () => boolean;
|
||||
}>;
|
||||
}
|
||||
return [] as Array<{ name: string; isDirectory: () => boolean }>;
|
||||
},
|
||||
);
|
||||
|
||||
// Mock git ignore service
|
||||
mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation(
|
||||
(path: string) => path.includes('node_modules') || path.includes('temp'),
|
||||
);
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
|
||||
(path: string, options) => {
|
||||
if (options?.respectGitIgnore !== false) {
|
||||
return mockFileDiscoveryService.shouldGitIgnoreFile(path);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'@t',
|
||||
testCwd,
|
||||
true,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig as Config,
|
||||
),
|
||||
);
|
||||
|
||||
// Wait for async operations to complete
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
// Should not include anything from node_modules or dist
|
||||
const suggestionLabels = result.current.suggestions.map((s) => s.label);
|
||||
expect(suggestionLabels).not.toContain('temp/');
|
||||
expect(suggestionLabels.some((l) => l.includes('node_modules'))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not perform recursive search when disabled in config', async () => {
|
||||
const globResults = [`${testCwd}/data`, `${testCwd}/dist`];
|
||||
vi.mocked(glob).mockResolvedValue(globResults);
|
||||
|
||||
// Disable recursive search in the mock config
|
||||
const mockConfigNoRecursive = {
|
||||
...mockConfig,
|
||||
getEnableRecursiveFileSearch: vi.fn(() => false),
|
||||
} as unknown as Config;
|
||||
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'data', isDirectory: () => true },
|
||||
{ name: 'dist', isDirectory: () => true },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||
|
||||
renderHook(() =>
|
||||
useCompletion(
|
||||
'@d',
|
||||
testCwd,
|
||||
true,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
mockConfigNoRecursive,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
// `glob` should not be called because recursive search is disabled
|
||||
expect(glob).not.toHaveBeenCalled();
|
||||
// `fs.readdir` should be called for the top-level directory instead
|
||||
expect(fs.readdir).toHaveBeenCalledWith(testCwd, { withFileTypes: true });
|
||||
});
|
||||
|
||||
it('should work without config (fallback behavior)', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'src', isDirectory: () => true },
|
||||
{ name: 'node_modules', isDirectory: () => true },
|
||||
{ name: 'README.md', isDirectory: () => false },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'@',
|
||||
testCwd,
|
||||
true,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
// Without config, should include all files
|
||||
expect(result.current.suggestions).toHaveLength(3);
|
||||
expect(result.current.suggestions).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ label: 'src/', value: 'src/' },
|
||||
{ label: 'node_modules/', value: 'node_modules/' },
|
||||
{ label: 'README.md', value: 'README.md' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle git discovery service initialization failure gracefully', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'src', isDirectory: () => true },
|
||||
{ name: 'README.md', isDirectory: () => false },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'@',
|
||||
testCwd,
|
||||
true,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig as Config,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
// Since we use centralized service, initialization errors are handled at config level
|
||||
// This test should verify graceful fallback behavior
|
||||
expect(result.current.suggestions.length).toBeGreaterThanOrEqual(0);
|
||||
// Should still show completions even if git discovery fails
|
||||
expect(result.current.suggestions.length).toBeGreaterThan(0);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle directory-specific completions with git filtering', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'component.tsx', isDirectory: () => false },
|
||||
{ name: 'temp.log', isDirectory: () => false },
|
||||
{ name: 'index.ts', isDirectory: () => false },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||
|
||||
mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation(
|
||||
(path: string) => path.includes('.log'),
|
||||
);
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
|
||||
(path: string, options) => {
|
||||
if (options?.respectGitIgnore !== false) {
|
||||
return mockFileDiscoveryService.shouldGitIgnoreFile(path);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'@src/comp',
|
||||
testCwd,
|
||||
true,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig as Config,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
// Should filter out .log files but include matching .tsx files
|
||||
expect(result.current.suggestions).toEqual([
|
||||
{ label: 'component.tsx', value: 'component.tsx' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use glob for top-level @ completions when available', async () => {
|
||||
const globResults = [`${testCwd}/src/index.ts`, `${testCwd}/README.md`];
|
||||
vi.mocked(glob).mockResolvedValue(globResults);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'@s',
|
||||
testCwd,
|
||||
true,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig as Config,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
expect(glob).toHaveBeenCalledWith('**/s*', {
|
||||
cwd: testCwd,
|
||||
dot: false,
|
||||
nocase: true,
|
||||
});
|
||||
expect(fs.readdir).not.toHaveBeenCalled(); // Ensure glob is used instead of readdir
|
||||
expect(result.current.suggestions).toEqual([
|
||||
{ label: 'README.md', value: 'README.md' },
|
||||
{ label: 'src/index.ts', value: 'src/index.ts' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should include dotfiles in glob search when input starts with a dot', async () => {
|
||||
const globResults = [
|
||||
`${testCwd}/.env`,
|
||||
`${testCwd}/.gitignore`,
|
||||
`${testCwd}/src/index.ts`,
|
||||
];
|
||||
vi.mocked(glob).mockResolvedValue(globResults);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'@.',
|
||||
testCwd,
|
||||
true,
|
||||
slashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig as Config,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
expect(glob).toHaveBeenCalledWith('**/.*', {
|
||||
cwd: testCwd,
|
||||
dot: true,
|
||||
nocase: true,
|
||||
});
|
||||
expect(fs.readdir).not.toHaveBeenCalled();
|
||||
expect(result.current.suggestions).toEqual([
|
||||
{ label: '.env', value: '.env' },
|
||||
{ label: '.gitignore', value: '.gitignore' },
|
||||
{ label: 'src/index.ts', value: 'src/index.ts' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should suggest top-level command names based on partial input', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/mem',
|
||||
'/test/cwd',
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toEqual([
|
||||
{ label: 'memory', value: 'memory', description: 'Manage memory' },
|
||||
]);
|
||||
expect(result.current.showSuggestions).toBe(true);
|
||||
});
|
||||
|
||||
it('should suggest commands based on altName', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/?',
|
||||
'/test/cwd',
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toEqual([
|
||||
{ label: 'help', value: 'help', description: 'Show help' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should suggest sub-command names for a parent command', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/memory a',
|
||||
'/test/cwd',
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toEqual([
|
||||
{ label: 'add', value: 'add', description: 'Add to memory' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should suggest all sub-commands when the query ends with the parent command and a space', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/memory ',
|
||||
'/test/cwd',
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(2);
|
||||
expect(result.current.suggestions).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ label: 'show', value: 'show', description: 'Show memory' },
|
||||
{ label: 'add', value: 'add', description: 'Add to memory' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call the command.completion function for argument suggestions', async () => {
|
||||
const availableTags = ['my-chat-tag-1', 'my-chat-tag-2', 'another-channel'];
|
||||
const mockCompletionFn = vi
|
||||
.fn()
|
||||
.mockImplementation(async (context: CommandContext, partialArg: string) =>
|
||||
availableTags.filter((tag) => tag.startsWith(partialArg)),
|
||||
);
|
||||
|
||||
const mockCommandsWithFiltering = JSON.parse(
|
||||
JSON.stringify(mockSlashCommands),
|
||||
) as SlashCommand[];
|
||||
|
||||
const chatCmd = mockCommandsWithFiltering.find(
|
||||
(cmd) => cmd.name === 'chat',
|
||||
);
|
||||
if (!chatCmd || !chatCmd.subCommands) {
|
||||
throw new Error(
|
||||
"Test setup error: Could not find the 'chat' command with subCommands in the mock data.",
|
||||
);
|
||||
}
|
||||
|
||||
const resumeCmd = chatCmd.subCommands.find((sc) => sc.name === 'resume');
|
||||
if (!resumeCmd) {
|
||||
throw new Error(
|
||||
"Test setup error: Could not find the 'resume' sub-command in the mock data.",
|
||||
);
|
||||
}
|
||||
|
||||
resumeCmd.completion = mockCompletionFn;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/chat resume my-ch',
|
||||
'/test/cwd',
|
||||
true,
|
||||
mockCommandsWithFiltering,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
expect(mockCompletionFn).toHaveBeenCalledWith(mockCommandContext, 'my-ch');
|
||||
|
||||
expect(result.current.suggestions).toEqual([
|
||||
{ label: 'my-chat-tag-1', value: 'my-chat-tag-1' },
|
||||
{ label: 'my-chat-tag-2', value: 'my-chat-tag-2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/clear ',
|
||||
'/test/cwd',
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(0);
|
||||
expect(result.current.showSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
it('should not provide suggestions for an unknown command', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/unknown-command',
|
||||
'/test/cwd',
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(0);
|
||||
expect(result.current.showSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
it('should suggest sub-commands for a fully typed parent command without a trailing space', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/memory', // Note: no trailing space
|
||||
'/test/cwd',
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
// Assert that suggestions for sub-commands are shown immediately
|
||||
expect(result.current.suggestions).toHaveLength(2);
|
||||
expect(result.current.suggestions).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ label: 'show', value: 'show', description: 'Show memory' },
|
||||
{ label: 'add', value: 'add', description: 'Add to memory' },
|
||||
]),
|
||||
);
|
||||
expect(result.current.showSuggestions).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/clear', // No trailing space
|
||||
'/test/cwd',
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(0);
|
||||
expect(result.current.showSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
it('should call command.completion with an empty string when args start with a space', async () => {
|
||||
const mockCompletionFn = vi
|
||||
.fn()
|
||||
.mockResolvedValue(['my-chat-tag-1', 'my-chat-tag-2', 'my-channel']);
|
||||
|
||||
const isolatedMockCommands = JSON.parse(
|
||||
JSON.stringify(mockSlashCommands),
|
||||
) as SlashCommand[];
|
||||
|
||||
const resumeCommand = isolatedMockCommands
|
||||
.find((cmd) => cmd.name === 'chat')
|
||||
?.subCommands?.find((cmd) => cmd.name === 'resume');
|
||||
|
||||
if (!resumeCommand) {
|
||||
throw new Error(
|
||||
'Test setup failed: could not find resume command in mock',
|
||||
);
|
||||
}
|
||||
resumeCommand.completion = mockCompletionFn;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/chat resume ', // Trailing space, no partial argument
|
||||
'/test/cwd',
|
||||
true,
|
||||
isolatedMockCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
expect(mockCompletionFn).toHaveBeenCalledWith(mockCommandContext, '');
|
||||
expect(result.current.suggestions).toHaveLength(3);
|
||||
expect(result.current.showSuggestions).toBe(true);
|
||||
});
|
||||
|
||||
it('should suggest all top-level commands for the root slash', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/',
|
||||
'/test/cwd',
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions.length).toBe(mockSlashCommands.length);
|
||||
expect(result.current.suggestions.map((s) => s.label)).toEqual(
|
||||
expect.arrayContaining(['help', 'clear', 'memory', 'chat']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should provide no suggestions for an invalid sub-command', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/memory dothisnow',
|
||||
'/test/cwd',
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(0);
|
||||
expect(result.current.showSuggestions).toBe(false);
|
||||
});
|
||||
});
|
||||
944
packages/cli/src/ui/hooks/useCompletion.test.ts
Normal file
944
packages/cli/src/ui/hooks/useCompletion.test.ts
Normal file
@@ -0,0 +1,944 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import type { Mocked } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useCompletion } from './useCompletion.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import { glob } from 'glob';
|
||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import { Config, FileDiscoveryService } from '@google/gemini-cli-core';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('fs/promises');
|
||||
vi.mock('glob');
|
||||
vi.mock('@google/gemini-cli-core', async () => {
|
||||
const actual = await vi.importActual('@google/gemini-cli-core');
|
||||
return {
|
||||
...actual,
|
||||
FileDiscoveryService: vi.fn(),
|
||||
isNodeError: vi.fn((error) => error.code === 'ENOENT'),
|
||||
escapePath: vi.fn((path) => path),
|
||||
unescapePath: vi.fn((path) => path),
|
||||
getErrorMessage: vi.fn((error) => error.message),
|
||||
};
|
||||
});
|
||||
vi.mock('glob');
|
||||
|
||||
describe('useCompletion', () => {
|
||||
let mockFileDiscoveryService: Mocked<FileDiscoveryService>;
|
||||
let mockConfig: Mocked<Config>;
|
||||
let mockCommandContext: CommandContext;
|
||||
let mockSlashCommands: SlashCommand[];
|
||||
|
||||
const testCwd = '/test/project';
|
||||
|
||||
beforeEach(() => {
|
||||
mockFileDiscoveryService = {
|
||||
shouldGitIgnoreFile: vi.fn(),
|
||||
shouldGeminiIgnoreFile: vi.fn(),
|
||||
shouldIgnoreFile: vi.fn(),
|
||||
filterFiles: vi.fn(),
|
||||
getGeminiIgnorePatterns: vi.fn(),
|
||||
projectRoot: '',
|
||||
gitIgnoreFilter: null,
|
||||
geminiIgnoreFilter: null,
|
||||
} as unknown as Mocked<FileDiscoveryService>;
|
||||
|
||||
mockConfig = {
|
||||
getFileFilteringRespectGitIgnore: vi.fn(() => true),
|
||||
getFileService: vi.fn().mockReturnValue(mockFileDiscoveryService),
|
||||
getEnableRecursiveFileSearch: vi.fn(() => true),
|
||||
} as unknown as Mocked<Config>;
|
||||
|
||||
mockCommandContext = {} as CommandContext;
|
||||
|
||||
mockSlashCommands = [
|
||||
{
|
||||
name: 'help',
|
||||
altName: '?',
|
||||
description: 'Show help',
|
||||
action: vi.fn(),
|
||||
},
|
||||
{
|
||||
name: 'clear',
|
||||
description: 'Clear the screen',
|
||||
action: vi.fn(),
|
||||
},
|
||||
{
|
||||
name: 'memory',
|
||||
description: 'Manage memory',
|
||||
subCommands: [
|
||||
{
|
||||
name: 'show',
|
||||
description: 'Show memory',
|
||||
action: vi.fn(),
|
||||
},
|
||||
{
|
||||
name: 'add',
|
||||
description: 'Add to memory',
|
||||
action: vi.fn(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'chat',
|
||||
description: 'Manage chat history',
|
||||
subCommands: [
|
||||
{
|
||||
name: 'save',
|
||||
description: 'Save chat',
|
||||
action: vi.fn(),
|
||||
},
|
||||
{
|
||||
name: 'resume',
|
||||
description: 'Resume a saved chat',
|
||||
action: vi.fn(),
|
||||
completion: vi.fn().mockResolvedValue(['chat1', 'chat2']),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Hook initialization and state', () => {
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'',
|
||||
testCwd,
|
||||
false,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toEqual([]);
|
||||
expect(result.current.activeSuggestionIndex).toBe(-1);
|
||||
expect(result.current.visibleStartIndex).toBe(0);
|
||||
expect(result.current.showSuggestions).toBe(false);
|
||||
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
it('should reset state when isActive becomes false', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ isActive }) =>
|
||||
useCompletion(
|
||||
'/help',
|
||||
testCwd,
|
||||
isActive,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
{ initialProps: { isActive: true } },
|
||||
);
|
||||
|
||||
rerender({ isActive: false });
|
||||
|
||||
expect(result.current.suggestions).toEqual([]);
|
||||
expect(result.current.activeSuggestionIndex).toBe(-1);
|
||||
expect(result.current.visibleStartIndex).toBe(0);
|
||||
expect(result.current.showSuggestions).toBe(false);
|
||||
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
it('should provide required functions', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
expect(typeof result.current.setActiveSuggestionIndex).toBe('function');
|
||||
expect(typeof result.current.setShowSuggestions).toBe('function');
|
||||
expect(typeof result.current.resetCompletionState).toBe('function');
|
||||
expect(typeof result.current.navigateUp).toBe('function');
|
||||
expect(typeof result.current.navigateDown).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetCompletionState', () => {
|
||||
it('should reset all state to default values', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/help',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setActiveSuggestionIndex(5);
|
||||
result.current.setShowSuggestions(true);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.resetCompletionState();
|
||||
});
|
||||
|
||||
expect(result.current.suggestions).toEqual([]);
|
||||
expect(result.current.activeSuggestionIndex).toBe(-1);
|
||||
expect(result.current.visibleStartIndex).toBe(0);
|
||||
expect(result.current.showSuggestions).toBe(false);
|
||||
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation functions', () => {
|
||||
it('should handle navigateUp with no suggestions', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateUp();
|
||||
});
|
||||
|
||||
expect(result.current.activeSuggestionIndex).toBe(-1);
|
||||
});
|
||||
|
||||
it('should handle navigateDown with no suggestions', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateDown();
|
||||
});
|
||||
|
||||
expect(result.current.activeSuggestionIndex).toBe(-1);
|
||||
});
|
||||
|
||||
it('should navigate up through suggestions with wrap-around', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/h',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions.length).toBe(1);
|
||||
expect(result.current.activeSuggestionIndex).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateUp();
|
||||
});
|
||||
|
||||
expect(result.current.activeSuggestionIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('should navigate down through suggestions with wrap-around', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/h',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions.length).toBe(1);
|
||||
expect(result.current.activeSuggestionIndex).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateDown();
|
||||
});
|
||||
|
||||
expect(result.current.activeSuggestionIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle navigation with multiple suggestions', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions.length).toBe(4);
|
||||
expect(result.current.activeSuggestionIndex).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateDown();
|
||||
});
|
||||
expect(result.current.activeSuggestionIndex).toBe(1);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateDown();
|
||||
});
|
||||
expect(result.current.activeSuggestionIndex).toBe(2);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateUp();
|
||||
});
|
||||
expect(result.current.activeSuggestionIndex).toBe(1);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateUp();
|
||||
});
|
||||
expect(result.current.activeSuggestionIndex).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateUp();
|
||||
});
|
||||
expect(result.current.activeSuggestionIndex).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle navigation with large suggestion lists and scrolling', () => {
|
||||
const largeMockCommands = Array.from({ length: 15 }, (_, i) => ({
|
||||
name: `command${i}`,
|
||||
description: `Command ${i}`,
|
||||
action: vi.fn(),
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/command',
|
||||
testCwd,
|
||||
true,
|
||||
largeMockCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions.length).toBe(15);
|
||||
expect(result.current.activeSuggestionIndex).toBe(0);
|
||||
expect(result.current.visibleStartIndex).toBe(0);
|
||||
|
||||
act(() => {
|
||||
result.current.navigateUp();
|
||||
});
|
||||
|
||||
expect(result.current.activeSuggestionIndex).toBe(14);
|
||||
expect(result.current.visibleStartIndex).toBe(Math.max(0, 15 - 8));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Slash command completion', () => {
|
||||
it('should show all commands for root slash', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(4);
|
||||
expect(result.current.suggestions.map((s) => s.label)).toEqual(
|
||||
expect.arrayContaining(['help', 'clear', 'memory', 'chat']),
|
||||
);
|
||||
expect(result.current.showSuggestions).toBe(true);
|
||||
expect(result.current.activeSuggestionIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('should filter commands by prefix', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/h',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(1);
|
||||
expect(result.current.suggestions[0].label).toBe('help');
|
||||
expect(result.current.suggestions[0].description).toBe('Show help');
|
||||
});
|
||||
|
||||
it('should suggest commands by altName', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/?',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(1);
|
||||
expect(result.current.suggestions[0].label).toBe('help');
|
||||
});
|
||||
|
||||
it('should not show suggestions for exact leaf command match', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/clear',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(0);
|
||||
expect(result.current.showSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
it('should show sub-commands for parent commands', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/memory',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(2);
|
||||
expect(result.current.suggestions.map((s) => s.label)).toEqual(
|
||||
expect.arrayContaining(['show', 'add']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show all sub-commands after parent command with space', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/memory ',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(2);
|
||||
expect(result.current.suggestions.map((s) => s.label)).toEqual(
|
||||
expect.arrayContaining(['show', 'add']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter sub-commands by prefix', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/memory a',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(1);
|
||||
expect(result.current.suggestions[0].label).toBe('add');
|
||||
});
|
||||
|
||||
it('should handle unknown command gracefully', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/unknown',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(0);
|
||||
expect(result.current.showSuggestions).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Command argument completion', () => {
|
||||
it('should call completion function for command arguments', async () => {
|
||||
const completionFn = vi.fn().mockResolvedValue(['arg1', 'arg2']);
|
||||
const commandsWithCompletion = [...mockSlashCommands];
|
||||
const chatCommand = commandsWithCompletion.find(
|
||||
(cmd) => cmd.name === 'chat',
|
||||
);
|
||||
const resumeCommand = chatCommand?.subCommands?.find(
|
||||
(cmd) => cmd.name === 'resume',
|
||||
);
|
||||
if (resumeCommand) {
|
||||
resumeCommand.completion = completionFn;
|
||||
}
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/chat resume ',
|
||||
testCwd,
|
||||
true,
|
||||
commandsWithCompletion,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
expect(completionFn).toHaveBeenCalledWith(mockCommandContext, '');
|
||||
expect(result.current.suggestions).toHaveLength(2);
|
||||
expect(result.current.suggestions.map((s) => s.label)).toEqual([
|
||||
'arg1',
|
||||
'arg2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should call completion function with partial argument', async () => {
|
||||
const completionFn = vi.fn().mockResolvedValue(['arg1', 'arg2']);
|
||||
const commandsWithCompletion = [...mockSlashCommands];
|
||||
const chatCommand = commandsWithCompletion.find(
|
||||
(cmd) => cmd.name === 'chat',
|
||||
);
|
||||
const resumeCommand = chatCommand?.subCommands?.find(
|
||||
(cmd) => cmd.name === 'resume',
|
||||
);
|
||||
if (resumeCommand) {
|
||||
resumeCommand.completion = completionFn;
|
||||
}
|
||||
|
||||
renderHook(() =>
|
||||
useCompletion(
|
||||
'/chat resume ar',
|
||||
testCwd,
|
||||
true,
|
||||
commandsWithCompletion,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
expect(completionFn).toHaveBeenCalledWith(mockCommandContext, 'ar');
|
||||
});
|
||||
|
||||
it('should handle completion function that returns null', async () => {
|
||||
const completionFn = vi.fn().mockResolvedValue(null);
|
||||
const commandsWithCompletion = [...mockSlashCommands];
|
||||
const chatCommand = commandsWithCompletion.find(
|
||||
(cmd) => cmd.name === 'chat',
|
||||
);
|
||||
const resumeCommand = chatCommand?.subCommands?.find(
|
||||
(cmd) => cmd.name === 'resume',
|
||||
);
|
||||
if (resumeCommand) {
|
||||
resumeCommand.completion = completionFn;
|
||||
}
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'/chat resume ',
|
||||
testCwd,
|
||||
true,
|
||||
commandsWithCompletion,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(0);
|
||||
expect(result.current.showSuggestions).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('File path completion (@-syntax)', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'file1.txt', isDirectory: () => false },
|
||||
{ name: 'file2.js', isDirectory: () => false },
|
||||
{ name: 'folder1', isDirectory: () => true },
|
||||
{ name: '.hidden', isDirectory: () => false },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||
});
|
||||
|
||||
it('should show file completions for @ prefix', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'@',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(3);
|
||||
expect(result.current.suggestions.map((s) => s.label)).toEqual(
|
||||
expect.arrayContaining(['file1.txt', 'file2.js', 'folder1/']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter files by prefix', async () => {
|
||||
// Mock for recursive search since enableRecursiveFileSearch is true
|
||||
vi.mocked(glob).mockResolvedValue([
|
||||
`${testCwd}/file1.txt`,
|
||||
`${testCwd}/file2.js`,
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'@file',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(2);
|
||||
expect(result.current.suggestions.map((s) => s.label)).toEqual(
|
||||
expect.arrayContaining(['file1.txt', 'file2.js']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include hidden files when prefix starts with dot', async () => {
|
||||
// Mock for recursive search since enableRecursiveFileSearch is true
|
||||
vi.mocked(glob).mockResolvedValue([`${testCwd}/.hidden`]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'@.',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(1);
|
||||
expect(result.current.suggestions[0].label).toBe('.hidden');
|
||||
});
|
||||
|
||||
it('should handle ENOENT error gracefully', async () => {
|
||||
const enoentError = new Error('No such file or directory');
|
||||
(enoentError as Error & { code: string }).code = 'ENOENT';
|
||||
vi.mocked(fs.readdir).mockRejectedValue(enoentError);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'@nonexistent',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(0);
|
||||
expect(result.current.showSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle other errors by resetting state', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
vi.mocked(fs.readdir).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'@',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
expect(result.current.suggestions).toHaveLength(0);
|
||||
expect(result.current.showSuggestions).toBe(false);
|
||||
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Debouncing', () => {
|
||||
it('should debounce file completion requests', async () => {
|
||||
// Mock for recursive search since enableRecursiveFileSearch is true
|
||||
vi.mocked(glob).mockResolvedValue([`${testCwd}/file1.txt`]);
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ query }) =>
|
||||
useCompletion(
|
||||
query,
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
{ initialProps: { query: '@f' } },
|
||||
);
|
||||
|
||||
rerender({ query: '@fi' });
|
||||
rerender({ query: '@fil' });
|
||||
rerender({ query: '@file' });
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
expect(glob).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Query handling edge cases', () => {
|
||||
it('should handle empty query', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(0);
|
||||
expect(result.current.showSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle query without slash or @', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'regular text',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(0);
|
||||
expect(result.current.showSuggestions).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle query with whitespace', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
' /hel',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(1);
|
||||
expect(result.current.suggestions[0].label).toBe('help');
|
||||
});
|
||||
|
||||
it('should handle @ at the end of query', async () => {
|
||||
// Mock for recursive search since enableRecursiveFileSearch is true
|
||||
vi.mocked(glob).mockResolvedValue([`${testCwd}/file1.txt`]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'some text @',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
// Wait for completion
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
// Should process the @ query and get suggestions
|
||||
expect(result.current.isLoadingSuggestions).toBe(false);
|
||||
expect(result.current.suggestions.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('File sorting behavior', () => {
|
||||
it('should prioritize source files over test files with same base name', async () => {
|
||||
// Mock glob to return files with same base name but different extensions
|
||||
vi.mocked(glob).mockResolvedValue([
|
||||
`${testCwd}/component.test.ts`,
|
||||
`${testCwd}/component.ts`,
|
||||
`${testCwd}/utils.spec.js`,
|
||||
`${testCwd}/utils.js`,
|
||||
`${testCwd}/api.test.tsx`,
|
||||
`${testCwd}/api.tsx`,
|
||||
]);
|
||||
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'@comp',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(6);
|
||||
|
||||
// Extract labels for easier testing
|
||||
const labels = result.current.suggestions.map((s) => s.label);
|
||||
|
||||
// Verify the exact sorted order: source files should come before their test counterparts
|
||||
expect(labels).toEqual([
|
||||
'api.tsx',
|
||||
'api.test.tsx',
|
||||
'component.ts',
|
||||
'component.test.ts',
|
||||
'utils.js',
|
||||
'utils.spec.js',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Config and FileDiscoveryService integration', () => {
|
||||
it('should work without config', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'file1.txt', isDirectory: () => false },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'@',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(1);
|
||||
expect(result.current.suggestions[0].label).toBe('file1.txt');
|
||||
});
|
||||
|
||||
it('should respect file filtering when config is provided', async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'file1.txt', isDirectory: () => false },
|
||||
{ name: 'ignored.log', isDirectory: () => false },
|
||||
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
|
||||
|
||||
mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
|
||||
(path: string) => path.includes('.log'),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCompletion(
|
||||
'@',
|
||||
testCwd,
|
||||
true,
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
mockConfig,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
expect(result.current.suggestions).toHaveLength(1);
|
||||
expect(result.current.suggestions[0].label).toBe('file1.txt');
|
||||
});
|
||||
});
|
||||
});
|
||||
543
packages/cli/src/ui/hooks/useCompletion.ts
Normal file
543
packages/cli/src/ui/hooks/useCompletion.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { glob } from 'glob';
|
||||
import {
|
||||
isNodeError,
|
||||
escapePath,
|
||||
unescapePath,
|
||||
getErrorMessage,
|
||||
Config,
|
||||
FileDiscoveryService,
|
||||
} from '@qwen/qwen-code-core';
|
||||
import {
|
||||
MAX_SUGGESTIONS_TO_SHOW,
|
||||
Suggestion,
|
||||
} from '../components/SuggestionsDisplay.js';
|
||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
|
||||
export interface UseCompletionReturn {
|
||||
suggestions: Suggestion[];
|
||||
activeSuggestionIndex: number;
|
||||
visibleStartIndex: number;
|
||||
showSuggestions: boolean;
|
||||
isLoadingSuggestions: boolean;
|
||||
setActiveSuggestionIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||
setShowSuggestions: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
resetCompletionState: () => void;
|
||||
navigateUp: () => void;
|
||||
navigateDown: () => void;
|
||||
}
|
||||
|
||||
export function useCompletion(
|
||||
query: string,
|
||||
cwd: string,
|
||||
isActive: boolean,
|
||||
slashCommands: SlashCommand[],
|
||||
commandContext: CommandContext,
|
||||
config?: Config,
|
||||
): UseCompletionReturn {
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||
const [activeSuggestionIndex, setActiveSuggestionIndex] =
|
||||
useState<number>(-1);
|
||||
const [visibleStartIndex, setVisibleStartIndex] = useState<number>(0);
|
||||
const [showSuggestions, setShowSuggestions] = useState<boolean>(false);
|
||||
const [isLoadingSuggestions, setIsLoadingSuggestions] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const resetCompletionState = useCallback(() => {
|
||||
setSuggestions([]);
|
||||
setActiveSuggestionIndex(-1);
|
||||
setVisibleStartIndex(0);
|
||||
setShowSuggestions(false);
|
||||
setIsLoadingSuggestions(false);
|
||||
}, []);
|
||||
|
||||
const navigateUp = useCallback(() => {
|
||||
if (suggestions.length === 0) return;
|
||||
|
||||
setActiveSuggestionIndex((prevActiveIndex) => {
|
||||
// Calculate new active index, handling wrap-around
|
||||
const newActiveIndex =
|
||||
prevActiveIndex <= 0 ? suggestions.length - 1 : prevActiveIndex - 1;
|
||||
|
||||
// Adjust scroll position based on the new active index
|
||||
setVisibleStartIndex((prevVisibleStart) => {
|
||||
// Case 1: Wrapped around to the last item
|
||||
if (
|
||||
newActiveIndex === suggestions.length - 1 &&
|
||||
suggestions.length > MAX_SUGGESTIONS_TO_SHOW
|
||||
) {
|
||||
return Math.max(0, suggestions.length - MAX_SUGGESTIONS_TO_SHOW);
|
||||
}
|
||||
// Case 2: Scrolled above the current visible window
|
||||
if (newActiveIndex < prevVisibleStart) {
|
||||
return newActiveIndex;
|
||||
}
|
||||
// Otherwise, keep the current scroll position
|
||||
return prevVisibleStart;
|
||||
});
|
||||
|
||||
return newActiveIndex;
|
||||
});
|
||||
}, [suggestions.length]);
|
||||
|
||||
const navigateDown = useCallback(() => {
|
||||
if (suggestions.length === 0) return;
|
||||
|
||||
setActiveSuggestionIndex((prevActiveIndex) => {
|
||||
// Calculate new active index, handling wrap-around
|
||||
const newActiveIndex =
|
||||
prevActiveIndex >= suggestions.length - 1 ? 0 : prevActiveIndex + 1;
|
||||
|
||||
// Adjust scroll position based on the new active index
|
||||
setVisibleStartIndex((prevVisibleStart) => {
|
||||
// Case 1: Wrapped around to the first item
|
||||
if (
|
||||
newActiveIndex === 0 &&
|
||||
suggestions.length > MAX_SUGGESTIONS_TO_SHOW
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
// Case 2: Scrolled below the current visible window
|
||||
const visibleEndIndex = prevVisibleStart + MAX_SUGGESTIONS_TO_SHOW;
|
||||
if (newActiveIndex >= visibleEndIndex) {
|
||||
return newActiveIndex - MAX_SUGGESTIONS_TO_SHOW + 1;
|
||||
}
|
||||
// Otherwise, keep the current scroll position
|
||||
return prevVisibleStart;
|
||||
});
|
||||
|
||||
return newActiveIndex;
|
||||
});
|
||||
}, [suggestions.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
resetCompletionState();
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedQuery = query.trimStart();
|
||||
|
||||
if (trimmedQuery.startsWith('/')) {
|
||||
const fullPath = trimmedQuery.substring(1);
|
||||
const hasTrailingSpace = trimmedQuery.endsWith(' ');
|
||||
|
||||
// Get all non-empty parts of the command.
|
||||
const rawParts = fullPath.split(/\s+/).filter((p) => p);
|
||||
|
||||
let commandPathParts = rawParts;
|
||||
let partial = '';
|
||||
|
||||
// If there's no trailing space, the last part is potentially a partial segment.
|
||||
// We tentatively separate it.
|
||||
if (!hasTrailingSpace && rawParts.length > 0) {
|
||||
partial = rawParts[rawParts.length - 1];
|
||||
commandPathParts = rawParts.slice(0, -1);
|
||||
}
|
||||
|
||||
// Traverse the Command Tree using the tentative completed path
|
||||
let currentLevel: SlashCommand[] | undefined = slashCommands;
|
||||
let leafCommand: SlashCommand | null = null;
|
||||
|
||||
for (const part of commandPathParts) {
|
||||
if (!currentLevel) {
|
||||
leafCommand = null;
|
||||
currentLevel = [];
|
||||
break;
|
||||
}
|
||||
const found: SlashCommand | undefined = currentLevel.find(
|
||||
(cmd) => cmd.name === part || cmd.altName === part,
|
||||
);
|
||||
if (found) {
|
||||
leafCommand = found;
|
||||
currentLevel = found.subCommands;
|
||||
} else {
|
||||
leafCommand = null;
|
||||
currentLevel = [];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle the Ambiguous Case
|
||||
if (!hasTrailingSpace && currentLevel) {
|
||||
const exactMatchAsParent = currentLevel.find(
|
||||
(cmd) =>
|
||||
(cmd.name === partial || cmd.altName === partial) &&
|
||||
cmd.subCommands,
|
||||
);
|
||||
|
||||
if (exactMatchAsParent) {
|
||||
// It's a perfect match for a parent command. Override our initial guess.
|
||||
// Treat it as a completed command path.
|
||||
leafCommand = exactMatchAsParent;
|
||||
currentLevel = exactMatchAsParent.subCommands;
|
||||
partial = ''; // We now want to suggest ALL of its sub-commands.
|
||||
}
|
||||
}
|
||||
|
||||
const depth = commandPathParts.length;
|
||||
|
||||
// Provide Suggestions based on the now-corrected context
|
||||
|
||||
// Argument Completion
|
||||
if (
|
||||
leafCommand?.completion &&
|
||||
(hasTrailingSpace ||
|
||||
(rawParts.length > depth && depth > 0 && partial !== ''))
|
||||
) {
|
||||
const fetchAndSetSuggestions = async () => {
|
||||
setIsLoadingSuggestions(true);
|
||||
const argString = rawParts.slice(depth).join(' ');
|
||||
const results =
|
||||
(await leafCommand!.completion!(commandContext, argString)) || [];
|
||||
const finalSuggestions = results.map((s) => ({ label: s, value: s }));
|
||||
setSuggestions(finalSuggestions);
|
||||
setShowSuggestions(finalSuggestions.length > 0);
|
||||
setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1);
|
||||
setIsLoadingSuggestions(false);
|
||||
};
|
||||
fetchAndSetSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
// Command/Sub-command Completion
|
||||
const commandsToSearch = currentLevel || [];
|
||||
if (commandsToSearch.length > 0) {
|
||||
let potentialSuggestions = commandsToSearch.filter(
|
||||
(cmd) =>
|
||||
cmd.description &&
|
||||
(cmd.name.startsWith(partial) || cmd.altName?.startsWith(partial)),
|
||||
);
|
||||
|
||||
// If a user's input is an exact match and it is a leaf command,
|
||||
// enter should submit immediately.
|
||||
if (potentialSuggestions.length > 0 && !hasTrailingSpace) {
|
||||
const perfectMatch = potentialSuggestions.find(
|
||||
(s) => s.name === partial,
|
||||
);
|
||||
if (perfectMatch && !perfectMatch.subCommands) {
|
||||
potentialSuggestions = [];
|
||||
}
|
||||
}
|
||||
|
||||
const finalSuggestions = potentialSuggestions.map((cmd) => ({
|
||||
label: cmd.name,
|
||||
value: cmd.name,
|
||||
description: cmd.description,
|
||||
}));
|
||||
|
||||
setSuggestions(finalSuggestions);
|
||||
setShowSuggestions(finalSuggestions.length > 0);
|
||||
setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1);
|
||||
setIsLoadingSuggestions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we fall through, no suggestions are available.
|
||||
resetCompletionState();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle At Command Completion
|
||||
const atIndex = query.lastIndexOf('@');
|
||||
if (atIndex === -1) {
|
||||
resetCompletionState();
|
||||
return;
|
||||
}
|
||||
|
||||
const partialPath = query.substring(atIndex + 1);
|
||||
const lastSlashIndex = partialPath.lastIndexOf('/');
|
||||
const baseDirRelative =
|
||||
lastSlashIndex === -1
|
||||
? '.'
|
||||
: partialPath.substring(0, lastSlashIndex + 1);
|
||||
const prefix = unescapePath(
|
||||
lastSlashIndex === -1
|
||||
? partialPath
|
||||
: partialPath.substring(lastSlashIndex + 1),
|
||||
);
|
||||
|
||||
const baseDirAbsolute = path.resolve(cwd, baseDirRelative);
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const findFilesRecursively = async (
|
||||
startDir: string,
|
||||
searchPrefix: string,
|
||||
fileDiscovery: FileDiscoveryService | null,
|
||||
filterOptions: {
|
||||
respectGitIgnore?: boolean;
|
||||
respectGeminiIgnore?: boolean;
|
||||
},
|
||||
currentRelativePath = '',
|
||||
depth = 0,
|
||||
maxDepth = 10, // Limit recursion depth
|
||||
maxResults = 50, // Limit number of results
|
||||
): Promise<Suggestion[]> => {
|
||||
if (depth > maxDepth) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lowerSearchPrefix = searchPrefix.toLowerCase();
|
||||
let foundSuggestions: Suggestion[] = [];
|
||||
try {
|
||||
const entries = await fs.readdir(startDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (foundSuggestions.length >= maxResults) break;
|
||||
|
||||
const entryPathRelative = path.join(currentRelativePath, entry.name);
|
||||
const entryPathFromRoot = path.relative(
|
||||
cwd,
|
||||
path.join(startDir, entry.name),
|
||||
);
|
||||
|
||||
// Conditionally ignore dotfiles
|
||||
if (!searchPrefix.startsWith('.') && entry.name.startsWith('.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this entry should be ignored by filtering options
|
||||
if (
|
||||
fileDiscovery &&
|
||||
fileDiscovery.shouldIgnoreFile(entryPathFromRoot, filterOptions)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.name.toLowerCase().startsWith(lowerSearchPrefix)) {
|
||||
foundSuggestions.push({
|
||||
label: entryPathRelative + (entry.isDirectory() ? '/' : ''),
|
||||
value: escapePath(
|
||||
entryPathRelative + (entry.isDirectory() ? '/' : ''),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (
|
||||
entry.isDirectory() &&
|
||||
entry.name !== 'node_modules' &&
|
||||
!entry.name.startsWith('.')
|
||||
) {
|
||||
if (foundSuggestions.length < maxResults) {
|
||||
foundSuggestions = foundSuggestions.concat(
|
||||
await findFilesRecursively(
|
||||
path.join(startDir, entry.name),
|
||||
searchPrefix, // Pass original searchPrefix for recursive calls
|
||||
fileDiscovery,
|
||||
filterOptions,
|
||||
entryPathRelative,
|
||||
depth + 1,
|
||||
maxDepth,
|
||||
maxResults - foundSuggestions.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// Ignore errors like permission denied or ENOENT during recursive search
|
||||
}
|
||||
return foundSuggestions.slice(0, maxResults);
|
||||
};
|
||||
|
||||
const findFilesWithGlob = async (
|
||||
searchPrefix: string,
|
||||
fileDiscoveryService: FileDiscoveryService,
|
||||
filterOptions: {
|
||||
respectGitIgnore?: boolean;
|
||||
respectGeminiIgnore?: boolean;
|
||||
},
|
||||
maxResults = 50,
|
||||
): Promise<Suggestion[]> => {
|
||||
const globPattern = `**/${searchPrefix}*`;
|
||||
const files = await glob(globPattern, {
|
||||
cwd,
|
||||
dot: searchPrefix.startsWith('.'),
|
||||
nocase: true,
|
||||
});
|
||||
|
||||
const suggestions: Suggestion[] = files
|
||||
.map((file: string) => {
|
||||
const relativePath = path.relative(cwd, file);
|
||||
return {
|
||||
label: relativePath,
|
||||
value: escapePath(relativePath),
|
||||
};
|
||||
})
|
||||
.filter((s) => {
|
||||
if (fileDiscoveryService) {
|
||||
return !fileDiscoveryService.shouldIgnoreFile(
|
||||
s.label,
|
||||
filterOptions,
|
||||
); // relative path
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.slice(0, maxResults);
|
||||
|
||||
return suggestions;
|
||||
};
|
||||
|
||||
const fetchSuggestions = async () => {
|
||||
setIsLoadingSuggestions(true);
|
||||
let fetchedSuggestions: Suggestion[] = [];
|
||||
|
||||
const fileDiscoveryService = config ? config.getFileService() : null;
|
||||
const enableRecursiveSearch =
|
||||
config?.getEnableRecursiveFileSearch() ?? true;
|
||||
const filterOptions = {
|
||||
respectGitIgnore: config?.getFileFilteringRespectGitIgnore() ?? true,
|
||||
respectGeminiIgnore: true,
|
||||
};
|
||||
|
||||
try {
|
||||
// If there's no slash, or it's the root, do a recursive search from cwd
|
||||
if (
|
||||
partialPath.indexOf('/') === -1 &&
|
||||
prefix &&
|
||||
enableRecursiveSearch
|
||||
) {
|
||||
if (fileDiscoveryService) {
|
||||
fetchedSuggestions = await findFilesWithGlob(
|
||||
prefix,
|
||||
fileDiscoveryService,
|
||||
filterOptions,
|
||||
);
|
||||
} else {
|
||||
fetchedSuggestions = await findFilesRecursively(
|
||||
cwd,
|
||||
prefix,
|
||||
fileDiscoveryService,
|
||||
filterOptions,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Original behavior: list files in the specific directory
|
||||
const lowerPrefix = prefix.toLowerCase();
|
||||
const entries = await fs.readdir(baseDirAbsolute, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
|
||||
// Filter entries using git-aware filtering
|
||||
const filteredEntries = [];
|
||||
for (const entry of entries) {
|
||||
// Conditionally ignore dotfiles
|
||||
if (!prefix.startsWith('.') && entry.name.startsWith('.')) {
|
||||
continue;
|
||||
}
|
||||
if (!entry.name.toLowerCase().startsWith(lowerPrefix)) continue;
|
||||
|
||||
const relativePath = path.relative(
|
||||
cwd,
|
||||
path.join(baseDirAbsolute, entry.name),
|
||||
);
|
||||
if (
|
||||
fileDiscoveryService &&
|
||||
fileDiscoveryService.shouldIgnoreFile(relativePath, filterOptions)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
filteredEntries.push(entry);
|
||||
}
|
||||
|
||||
fetchedSuggestions = filteredEntries.map((entry) => {
|
||||
const label = entry.isDirectory() ? entry.name + '/' : entry.name;
|
||||
return {
|
||||
label,
|
||||
value: escapePath(label), // Value for completion should be just the name part
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by depth, then directories first, then alphabetically
|
||||
fetchedSuggestions.sort((a, b) => {
|
||||
const depthA = (a.label.match(/\//g) || []).length;
|
||||
const depthB = (b.label.match(/\//g) || []).length;
|
||||
|
||||
if (depthA !== depthB) {
|
||||
return depthA - depthB;
|
||||
}
|
||||
|
||||
const aIsDir = a.label.endsWith('/');
|
||||
const bIsDir = b.label.endsWith('/');
|
||||
if (aIsDir && !bIsDir) return -1;
|
||||
if (!aIsDir && bIsDir) return 1;
|
||||
|
||||
// exclude extension when comparing
|
||||
const filenameA = a.label.substring(
|
||||
0,
|
||||
a.label.length - path.extname(a.label).length,
|
||||
);
|
||||
const filenameB = b.label.substring(
|
||||
0,
|
||||
b.label.length - path.extname(b.label).length,
|
||||
);
|
||||
|
||||
return (
|
||||
filenameA.localeCompare(filenameB) || a.label.localeCompare(b.label)
|
||||
);
|
||||
});
|
||||
|
||||
if (isMounted) {
|
||||
setSuggestions(fetchedSuggestions);
|
||||
setShowSuggestions(fetchedSuggestions.length > 0);
|
||||
setActiveSuggestionIndex(fetchedSuggestions.length > 0 ? 0 : -1);
|
||||
setVisibleStartIndex(0);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
if (isMounted) {
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
`Error fetching completion suggestions for ${partialPath}: ${getErrorMessage(error)}`,
|
||||
);
|
||||
if (isMounted) {
|
||||
resetCompletionState();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isMounted) {
|
||||
setIsLoadingSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const debounceTimeout = setTimeout(fetchSuggestions, 100);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
clearTimeout(debounceTimeout);
|
||||
};
|
||||
}, [
|
||||
query,
|
||||
cwd,
|
||||
isActive,
|
||||
resetCompletionState,
|
||||
slashCommands,
|
||||
commandContext,
|
||||
config,
|
||||
]);
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
activeSuggestionIndex,
|
||||
visibleStartIndex,
|
||||
showSuggestions,
|
||||
isLoadingSuggestions,
|
||||
setActiveSuggestionIndex,
|
||||
setShowSuggestions,
|
||||
resetCompletionState,
|
||||
navigateUp,
|
||||
navigateDown,
|
||||
};
|
||||
}
|
||||
212
packages/cli/src/ui/hooks/useConsoleMessages.test.ts
Normal file
212
packages/cli/src/ui/hooks/useConsoleMessages.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useConsoleMessages } from './useConsoleMessages.js';
|
||||
import { ConsoleMessageItem } from '../types.js';
|
||||
|
||||
// Mock setTimeout and clearTimeout
|
||||
vi.useFakeTimers();
|
||||
|
||||
describe('useConsoleMessages', () => {
|
||||
it('should initialize with an empty array of console messages', () => {
|
||||
const { result } = renderHook(() => useConsoleMessages());
|
||||
expect(result.current.consoleMessages).toEqual([]);
|
||||
});
|
||||
|
||||
it('should add a new message', () => {
|
||||
const { result } = renderHook(() => useConsoleMessages());
|
||||
const message: ConsoleMessageItem = {
|
||||
type: 'log',
|
||||
content: 'Test message',
|
||||
count: 1,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.handleNewMessage(message);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.runAllTimers(); // Process the queue
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toEqual([{ ...message, count: 1 }]);
|
||||
});
|
||||
|
||||
it('should consolidate identical consecutive messages', () => {
|
||||
const { result } = renderHook(() => useConsoleMessages());
|
||||
const message: ConsoleMessageItem = {
|
||||
type: 'log',
|
||||
content: 'Test message',
|
||||
count: 1,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.handleNewMessage(message);
|
||||
result.current.handleNewMessage(message);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toEqual([{ ...message, count: 2 }]);
|
||||
});
|
||||
|
||||
it('should not consolidate different messages', () => {
|
||||
const { result } = renderHook(() => useConsoleMessages());
|
||||
const message1: ConsoleMessageItem = {
|
||||
type: 'log',
|
||||
content: 'Test message 1',
|
||||
count: 1,
|
||||
};
|
||||
const message2: ConsoleMessageItem = {
|
||||
type: 'error',
|
||||
content: 'Test message 2',
|
||||
count: 1,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.handleNewMessage(message1);
|
||||
result.current.handleNewMessage(message2);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toEqual([
|
||||
{ ...message1, count: 1 },
|
||||
{ ...message2, count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not consolidate messages if type is different', () => {
|
||||
const { result } = renderHook(() => useConsoleMessages());
|
||||
const message1: ConsoleMessageItem = {
|
||||
type: 'log',
|
||||
content: 'Test message',
|
||||
count: 1,
|
||||
};
|
||||
const message2: ConsoleMessageItem = {
|
||||
type: 'error',
|
||||
content: 'Test message',
|
||||
count: 1,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.handleNewMessage(message1);
|
||||
result.current.handleNewMessage(message2);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toEqual([
|
||||
{ ...message1, count: 1 },
|
||||
{ ...message2, count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should clear console messages', () => {
|
||||
const { result } = renderHook(() => useConsoleMessages());
|
||||
const message: ConsoleMessageItem = {
|
||||
type: 'log',
|
||||
content: 'Test message',
|
||||
count: 1,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.handleNewMessage(message);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
result.current.clearConsoleMessages();
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toEqual([]);
|
||||
});
|
||||
|
||||
it('should clear pending timeout on clearConsoleMessages', () => {
|
||||
const { result } = renderHook(() => useConsoleMessages());
|
||||
const message: ConsoleMessageItem = {
|
||||
type: 'log',
|
||||
content: 'Test message',
|
||||
count: 1,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.handleNewMessage(message); // This schedules a timeout
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.clearConsoleMessages();
|
||||
});
|
||||
|
||||
// Ensure the queue is empty and no more messages are processed
|
||||
act(() => {
|
||||
vi.runAllTimers(); // If timeout wasn't cleared, this would process the queue
|
||||
});
|
||||
|
||||
expect(result.current.consoleMessages).toEqual([]);
|
||||
});
|
||||
|
||||
it('should clear message queue on clearConsoleMessages', () => {
|
||||
const { result } = renderHook(() => useConsoleMessages());
|
||||
const message: ConsoleMessageItem = {
|
||||
type: 'log',
|
||||
content: 'Test message',
|
||||
count: 1,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
// Add a message but don't process the queue yet
|
||||
result.current.handleNewMessage(message);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.clearConsoleMessages();
|
||||
});
|
||||
|
||||
// Process any pending timeouts (should be none related to message queue)
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
// The consoleMessages should be empty because the queue was cleared before processing
|
||||
expect(result.current.consoleMessages).toEqual([]);
|
||||
});
|
||||
|
||||
it('should cleanup timeout on unmount', () => {
|
||||
const { result, unmount } = renderHook(() => useConsoleMessages());
|
||||
const message: ConsoleMessageItem = {
|
||||
type: 'log',
|
||||
content: 'Test message',
|
||||
count: 1,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.handleNewMessage(message);
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
// This is a bit indirect. We check that clearTimeout was called.
|
||||
// If clearTimeout was not called, and we run timers, an error might occur
|
||||
// or the state might change, which it shouldn't after unmount.
|
||||
// Vitest's vi.clearAllTimers() or specific checks for clearTimeout calls
|
||||
// would be more direct if available and easy to set up here.
|
||||
// For now, we rely on the useEffect cleanup pattern.
|
||||
expect(vi.getTimerCount()).toBe(0); // Check if all timers are cleared
|
||||
});
|
||||
});
|
||||
89
packages/cli/src/ui/hooks/useConsoleMessages.ts
Normal file
89
packages/cli/src/ui/hooks/useConsoleMessages.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ConsoleMessageItem } from '../types.js';
|
||||
|
||||
export interface UseConsoleMessagesReturn {
|
||||
consoleMessages: ConsoleMessageItem[];
|
||||
handleNewMessage: (message: ConsoleMessageItem) => void;
|
||||
clearConsoleMessages: () => void;
|
||||
}
|
||||
|
||||
export function useConsoleMessages(): UseConsoleMessagesReturn {
|
||||
const [consoleMessages, setConsoleMessages] = useState<ConsoleMessageItem[]>(
|
||||
[],
|
||||
);
|
||||
const messageQueueRef = useRef<ConsoleMessageItem[]>([]);
|
||||
const messageQueueTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const processMessageQueue = useCallback(() => {
|
||||
if (messageQueueRef.current.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newMessagesToAdd = messageQueueRef.current;
|
||||
messageQueueRef.current = [];
|
||||
|
||||
setConsoleMessages((prevMessages) => {
|
||||
const newMessages = [...prevMessages];
|
||||
newMessagesToAdd.forEach((queuedMessage) => {
|
||||
if (
|
||||
newMessages.length > 0 &&
|
||||
newMessages[newMessages.length - 1].type === queuedMessage.type &&
|
||||
newMessages[newMessages.length - 1].content === queuedMessage.content
|
||||
) {
|
||||
newMessages[newMessages.length - 1].count =
|
||||
(newMessages[newMessages.length - 1].count || 1) + 1;
|
||||
} else {
|
||||
newMessages.push({ ...queuedMessage, count: 1 });
|
||||
}
|
||||
});
|
||||
return newMessages;
|
||||
});
|
||||
|
||||
messageQueueTimeoutRef.current = null; // Allow next scheduling
|
||||
}, []);
|
||||
|
||||
const scheduleQueueProcessing = useCallback(() => {
|
||||
if (messageQueueTimeoutRef.current === null) {
|
||||
messageQueueTimeoutRef.current = setTimeout(
|
||||
processMessageQueue,
|
||||
0,
|
||||
) as unknown as number;
|
||||
}
|
||||
}, [processMessageQueue]);
|
||||
|
||||
const handleNewMessage = useCallback(
|
||||
(message: ConsoleMessageItem) => {
|
||||
messageQueueRef.current.push(message);
|
||||
scheduleQueueProcessing();
|
||||
},
|
||||
[scheduleQueueProcessing],
|
||||
);
|
||||
|
||||
const clearConsoleMessages = useCallback(() => {
|
||||
setConsoleMessages([]);
|
||||
if (messageQueueTimeoutRef.current !== null) {
|
||||
clearTimeout(messageQueueTimeoutRef.current);
|
||||
messageQueueTimeoutRef.current = null;
|
||||
}
|
||||
messageQueueRef.current = [];
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
// Cleanup on unmount
|
||||
() => {
|
||||
if (messageQueueTimeoutRef.current !== null) {
|
||||
clearTimeout(messageQueueTimeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { consoleMessages, handleNewMessage, clearConsoleMessages };
|
||||
}
|
||||
283
packages/cli/src/ui/hooks/useEditorSettings.test.ts
Normal file
283
packages/cli/src/ui/hooks/useEditorSettings.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type MockedFunction,
|
||||
} from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useEditorSettings } from './useEditorSettings.js';
|
||||
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
||||
import { MessageType, type HistoryItem } from '../types.js';
|
||||
import {
|
||||
type EditorType,
|
||||
checkHasEditorType,
|
||||
allowEditorTypeInSandbox,
|
||||
} from '@qwen/qwen-code-core';
|
||||
|
||||
vi.mock('@qwen/qwen-code-core', async () => {
|
||||
const actual = await vi.importActual('@qwen/qwen-code-core');
|
||||
return {
|
||||
...actual,
|
||||
checkHasEditorType: vi.fn(() => true),
|
||||
allowEditorTypeInSandbox: vi.fn(() => true),
|
||||
};
|
||||
});
|
||||
|
||||
const mockCheckHasEditorType = vi.mocked(checkHasEditorType);
|
||||
const mockAllowEditorTypeInSandbox = vi.mocked(allowEditorTypeInSandbox);
|
||||
|
||||
describe('useEditorSettings', () => {
|
||||
let mockLoadedSettings: LoadedSettings;
|
||||
let mockSetEditorError: MockedFunction<(error: string | null) => void>;
|
||||
let mockAddItem: MockedFunction<
|
||||
(item: Omit<HistoryItem, 'id'>, timestamp: number) => void
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
mockLoadedSettings = {
|
||||
setValue: vi.fn(),
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
mockSetEditorError = vi.fn();
|
||||
mockAddItem = vi.fn();
|
||||
|
||||
// Reset mock implementations to default
|
||||
mockCheckHasEditorType.mockReturnValue(true);
|
||||
mockAllowEditorTypeInSandbox.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize with dialog closed', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem),
|
||||
);
|
||||
|
||||
expect(result.current.isEditorDialogOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should open editor dialog when openEditorDialog is called', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.openEditorDialog();
|
||||
});
|
||||
|
||||
expect(result.current.isEditorDialogOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('should close editor dialog when exitEditorDialog is called', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem),
|
||||
);
|
||||
act(() => {
|
||||
result.current.openEditorDialog();
|
||||
result.current.exitEditorDialog();
|
||||
});
|
||||
expect(result.current.isEditorDialogOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle editor selection successfully', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem),
|
||||
);
|
||||
|
||||
const editorType: EditorType = 'vscode';
|
||||
const scope = SettingScope.User;
|
||||
|
||||
act(() => {
|
||||
result.current.openEditorDialog();
|
||||
result.current.handleEditorSelect(editorType, scope);
|
||||
});
|
||||
|
||||
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
|
||||
scope,
|
||||
'preferredEditor',
|
||||
editorType,
|
||||
);
|
||||
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Editor preference set to "vscode" in User settings.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
expect(mockSetEditorError).toHaveBeenCalledWith(null);
|
||||
expect(result.current.isEditorDialogOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle clearing editor preference (undefined editor)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem),
|
||||
);
|
||||
|
||||
const scope = SettingScope.Workspace;
|
||||
|
||||
act(() => {
|
||||
result.current.openEditorDialog();
|
||||
result.current.handleEditorSelect(undefined, scope);
|
||||
});
|
||||
|
||||
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
|
||||
scope,
|
||||
'preferredEditor',
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Editor preference cleared in Workspace settings.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
expect(mockSetEditorError).toHaveBeenCalledWith(null);
|
||||
expect(result.current.isEditorDialogOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle different editor types', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem),
|
||||
);
|
||||
|
||||
const editorTypes: EditorType[] = ['cursor', 'windsurf', 'vim'];
|
||||
const scope = SettingScope.User;
|
||||
|
||||
editorTypes.forEach((editorType) => {
|
||||
act(() => {
|
||||
result.current.handleEditorSelect(editorType, scope);
|
||||
});
|
||||
|
||||
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
|
||||
scope,
|
||||
'preferredEditor',
|
||||
editorType,
|
||||
);
|
||||
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Editor preference set to "${editorType}" in User settings.`,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle different setting scopes', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem),
|
||||
);
|
||||
|
||||
const editorType: EditorType = 'vscode';
|
||||
const scopes = [SettingScope.User, SettingScope.Workspace];
|
||||
|
||||
scopes.forEach((scope) => {
|
||||
act(() => {
|
||||
result.current.handleEditorSelect(editorType, scope);
|
||||
});
|
||||
|
||||
expect(mockLoadedSettings.setValue).toHaveBeenCalledWith(
|
||||
scope,
|
||||
'preferredEditor',
|
||||
editorType,
|
||||
);
|
||||
|
||||
expect(mockAddItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Editor preference set to "vscode" in ${scope} settings.`,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not set preference for unavailable editors', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem),
|
||||
);
|
||||
|
||||
mockCheckHasEditorType.mockReturnValue(false);
|
||||
|
||||
const editorType: EditorType = 'vscode';
|
||||
const scope = SettingScope.User;
|
||||
|
||||
act(() => {
|
||||
result.current.openEditorDialog();
|
||||
result.current.handleEditorSelect(editorType, scope);
|
||||
});
|
||||
|
||||
expect(mockLoadedSettings.setValue).not.toHaveBeenCalled();
|
||||
expect(mockAddItem).not.toHaveBeenCalled();
|
||||
expect(result.current.isEditorDialogOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('should not set preference for editors not allowed in sandbox', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem),
|
||||
);
|
||||
|
||||
mockAllowEditorTypeInSandbox.mockReturnValue(false);
|
||||
|
||||
const editorType: EditorType = 'vscode';
|
||||
const scope = SettingScope.User;
|
||||
|
||||
act(() => {
|
||||
result.current.openEditorDialog();
|
||||
result.current.handleEditorSelect(editorType, scope);
|
||||
});
|
||||
|
||||
expect(mockLoadedSettings.setValue).not.toHaveBeenCalled();
|
||||
expect(mockAddItem).not.toHaveBeenCalled();
|
||||
expect(result.current.isEditorDialogOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle errors during editor selection', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useEditorSettings(mockLoadedSettings, mockSetEditorError, mockAddItem),
|
||||
);
|
||||
|
||||
const errorMessage = 'Failed to save settings';
|
||||
(
|
||||
mockLoadedSettings.setValue as MockedFunction<
|
||||
typeof mockLoadedSettings.setValue
|
||||
>
|
||||
).mockImplementation(() => {
|
||||
throw new Error(errorMessage);
|
||||
});
|
||||
|
||||
const editorType: EditorType = 'vscode';
|
||||
const scope = SettingScope.User;
|
||||
|
||||
act(() => {
|
||||
result.current.openEditorDialog();
|
||||
result.current.handleEditorSelect(editorType, scope);
|
||||
});
|
||||
|
||||
expect(mockSetEditorError).toHaveBeenCalledWith(
|
||||
`Failed to set editor preference: Error: ${errorMessage}`,
|
||||
);
|
||||
expect(mockAddItem).not.toHaveBeenCalled();
|
||||
expect(result.current.isEditorDialogOpen).toBe(true);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user