mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 08:47:44 +00:00
Merge tag 'v0.1.15' into feature/yiheng/sync-gemini-cli-0.1.15
This commit is contained in:
@@ -29,11 +29,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@qwen-code/qwen-code-core": "file:../core",
|
||||
"@google/genai": "1.9.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@types/update-notifier": "^6.0.8",
|
||||
"command-exists": "^1.2.9",
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^17.1.0",
|
||||
"gaxios": "^7.1.1",
|
||||
"glob": "^10.4.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "^6.0.1",
|
||||
@@ -52,7 +53,8 @@
|
||||
"strip-ansi": "^7.1.0",
|
||||
"strip-json-comments": "^3.1.1",
|
||||
"update-notifier": "^7.3.1",
|
||||
"yargs": "^18.0.0"
|
||||
"yargs": "^17.7.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/runtime": "^7.27.6",
|
||||
|
||||
464
packages/cli/src/acp/acp.ts
Normal file
464
packages/cli/src/acp/acp.ts
Normal file
@@ -0,0 +1,464 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/* ACP defines a schema for a simple (experimental) JSON-RPC protocol that allows GUI applications to interact with agents. */
|
||||
|
||||
import { Icon } from '@qwen-code/qwen-code-core';
|
||||
import { WritableStream, ReadableStream } from 'node:stream/web';
|
||||
|
||||
export class ClientConnection implements Client {
|
||||
#connection: Connection<Agent>;
|
||||
|
||||
constructor(
|
||||
agent: (client: Client) => Agent,
|
||||
input: WritableStream<Uint8Array>,
|
||||
output: ReadableStream<Uint8Array>,
|
||||
) {
|
||||
this.#connection = new Connection(agent(this), input, output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams part of an assistant response to the client
|
||||
*/
|
||||
async streamAssistantMessageChunk(
|
||||
params: StreamAssistantMessageChunkParams,
|
||||
): Promise<void> {
|
||||
await this.#connection.sendRequest('streamAssistantMessageChunk', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request confirmation before running a tool
|
||||
*
|
||||
* When allowed, the client returns a [`ToolCallId`] which can be used
|
||||
* to update the tool call's `status` and `content` as it runs.
|
||||
*/
|
||||
requestToolCallConfirmation(
|
||||
params: RequestToolCallConfirmationParams,
|
||||
): Promise<RequestToolCallConfirmationResponse> {
|
||||
return this.#connection.sendRequest('requestToolCallConfirmation', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* pushToolCall allows the agent to start a tool call
|
||||
* when it does not need to request permission to do so.
|
||||
*
|
||||
* The returned id can be used to update the UI for the tool
|
||||
* call as needed.
|
||||
*/
|
||||
pushToolCall(params: PushToolCallParams): Promise<PushToolCallResponse> {
|
||||
return this.#connection.sendRequest('pushToolCall', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* updateToolCall allows the agent to update the content and status of the tool call.
|
||||
*
|
||||
* The new content replaces what is currently displayed in the UI.
|
||||
*
|
||||
* The [`ToolCallId`] is included in the response of
|
||||
* `pushToolCall` or `requestToolCallConfirmation` respectively.
|
||||
*/
|
||||
async updateToolCall(params: UpdateToolCallParams): Promise<void> {
|
||||
await this.#connection.sendRequest('updateToolCall', params);
|
||||
}
|
||||
}
|
||||
|
||||
type AnyMessage = AnyRequest | AnyResponse;
|
||||
|
||||
type AnyRequest = {
|
||||
id: number;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
};
|
||||
|
||||
type AnyResponse = { jsonrpc: '2.0'; id: number } & Result<unknown>;
|
||||
|
||||
type Result<T> =
|
||||
| {
|
||||
result: T;
|
||||
}
|
||||
| {
|
||||
error: ErrorResponse;
|
||||
};
|
||||
|
||||
type ErrorResponse = {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: { details?: string };
|
||||
};
|
||||
|
||||
type PendingResponse = {
|
||||
resolve: (response: unknown) => void;
|
||||
reject: (error: ErrorResponse) => void;
|
||||
};
|
||||
|
||||
class Connection<D> {
|
||||
#pendingResponses: Map<number, PendingResponse> = new Map();
|
||||
#nextRequestId: number = 0;
|
||||
#delegate: D;
|
||||
#peerInput: WritableStream<Uint8Array>;
|
||||
#writeQueue: Promise<void> = Promise.resolve();
|
||||
#textEncoder: TextEncoder;
|
||||
|
||||
constructor(
|
||||
delegate: D,
|
||||
peerInput: WritableStream<Uint8Array>,
|
||||
peerOutput: ReadableStream<Uint8Array>,
|
||||
) {
|
||||
this.#peerInput = peerInput;
|
||||
this.#textEncoder = new TextEncoder();
|
||||
|
||||
this.#delegate = delegate;
|
||||
this.#receive(peerOutput);
|
||||
}
|
||||
|
||||
async #receive(output: ReadableStream<Uint8Array>) {
|
||||
let content = '';
|
||||
const decoder = new TextDecoder();
|
||||
for await (const chunk of output) {
|
||||
content += decoder.decode(chunk, { stream: true });
|
||||
const lines = content.split('\n');
|
||||
content = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
if (trimmedLine) {
|
||||
const message = JSON.parse(trimmedLine);
|
||||
this.#processMessage(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #processMessage(message: AnyMessage) {
|
||||
if ('method' in message) {
|
||||
const response = await this.#tryCallDelegateMethod(
|
||||
message.method,
|
||||
message.params,
|
||||
);
|
||||
|
||||
await this.#sendMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
...response,
|
||||
});
|
||||
} else {
|
||||
this.#handleResponse(message);
|
||||
}
|
||||
}
|
||||
|
||||
async #tryCallDelegateMethod(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
): Promise<Result<unknown>> {
|
||||
const methodName = method as keyof D;
|
||||
if (typeof this.#delegate[methodName] !== 'function') {
|
||||
return RequestError.methodNotFound(method).toResult();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.#delegate[methodName](params);
|
||||
return { result: result ?? null };
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof RequestError) {
|
||||
return error.toResult();
|
||||
}
|
||||
|
||||
let details;
|
||||
|
||||
if (error instanceof Error) {
|
||||
details = error.message;
|
||||
} else if (
|
||||
typeof error === 'object' &&
|
||||
error != null &&
|
||||
'message' in error &&
|
||||
typeof error.message === 'string'
|
||||
) {
|
||||
details = error.message;
|
||||
}
|
||||
|
||||
return RequestError.internalError(details).toResult();
|
||||
}
|
||||
}
|
||||
|
||||
#handleResponse(response: AnyResponse) {
|
||||
const pendingResponse = this.#pendingResponses.get(response.id);
|
||||
if (pendingResponse) {
|
||||
if ('result' in response) {
|
||||
pendingResponse.resolve(response.result);
|
||||
} else if ('error' in response) {
|
||||
pendingResponse.reject(response.error);
|
||||
}
|
||||
this.#pendingResponses.delete(response.id);
|
||||
}
|
||||
}
|
||||
|
||||
async sendRequest<Req, Resp>(method: string, params?: Req): Promise<Resp> {
|
||||
const id = this.#nextRequestId++;
|
||||
const responsePromise = new Promise((resolve, reject) => {
|
||||
this.#pendingResponses.set(id, { resolve, reject });
|
||||
});
|
||||
await this.#sendMessage({ jsonrpc: '2.0', id, method, params });
|
||||
return responsePromise as Promise<Resp>;
|
||||
}
|
||||
|
||||
async #sendMessage(json: AnyMessage) {
|
||||
const content = JSON.stringify(json) + '\n';
|
||||
this.#writeQueue = this.#writeQueue
|
||||
.then(async () => {
|
||||
const writer = this.#peerInput.getWriter();
|
||||
try {
|
||||
await writer.write(this.#textEncoder.encode(content));
|
||||
} finally {
|
||||
writer.releaseLock();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
// Continue processing writes on error
|
||||
console.error('ACP write error:', error);
|
||||
});
|
||||
return this.#writeQueue;
|
||||
}
|
||||
}
|
||||
|
||||
export class RequestError extends Error {
|
||||
data?: { details?: string };
|
||||
|
||||
constructor(
|
||||
public code: number,
|
||||
message: string,
|
||||
details?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'RequestError';
|
||||
if (details) {
|
||||
this.data = { details };
|
||||
}
|
||||
}
|
||||
|
||||
static parseError(details?: string): RequestError {
|
||||
return new RequestError(-32700, 'Parse error', details);
|
||||
}
|
||||
|
||||
static invalidRequest(details?: string): RequestError {
|
||||
return new RequestError(-32600, 'Invalid request', details);
|
||||
}
|
||||
|
||||
static methodNotFound(details?: string): RequestError {
|
||||
return new RequestError(-32601, 'Method not found', details);
|
||||
}
|
||||
|
||||
static invalidParams(details?: string): RequestError {
|
||||
return new RequestError(-32602, 'Invalid params', details);
|
||||
}
|
||||
|
||||
static internalError(details?: string): RequestError {
|
||||
return new RequestError(-32603, 'Internal error', details);
|
||||
}
|
||||
|
||||
toResult<T>(): Result<T> {
|
||||
return {
|
||||
error: {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
data: this.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Protocol types
|
||||
|
||||
export const LATEST_PROTOCOL_VERSION = '0.0.9';
|
||||
|
||||
export type AssistantMessageChunk =
|
||||
| {
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
thought: string;
|
||||
};
|
||||
|
||||
export type ToolCallConfirmation =
|
||||
| {
|
||||
description?: string | null;
|
||||
type: 'edit';
|
||||
}
|
||||
| {
|
||||
description?: string | null;
|
||||
type: 'execute';
|
||||
command: string;
|
||||
rootCommand: string;
|
||||
}
|
||||
| {
|
||||
description?: string | null;
|
||||
type: 'mcp';
|
||||
serverName: string;
|
||||
toolDisplayName: string;
|
||||
toolName: string;
|
||||
}
|
||||
| {
|
||||
description?: string | null;
|
||||
type: 'fetch';
|
||||
urls: string[];
|
||||
}
|
||||
| {
|
||||
description: string;
|
||||
type: 'other';
|
||||
};
|
||||
|
||||
export type ToolCallContent =
|
||||
| {
|
||||
type: 'markdown';
|
||||
markdown: string;
|
||||
}
|
||||
| {
|
||||
type: 'diff';
|
||||
newText: string;
|
||||
oldText: string | null;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type ToolCallStatus = 'running' | 'finished' | 'error';
|
||||
|
||||
export type ToolCallId = number;
|
||||
|
||||
export type ToolCallConfirmationOutcome =
|
||||
| 'allow'
|
||||
| 'alwaysAllow'
|
||||
| 'alwaysAllowMcpServer'
|
||||
| 'alwaysAllowTool'
|
||||
| 'reject'
|
||||
| 'cancel';
|
||||
|
||||
/**
|
||||
* A part in a user message
|
||||
*/
|
||||
export type UserMessageChunk =
|
||||
| {
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
path: string;
|
||||
};
|
||||
|
||||
export interface StreamAssistantMessageChunkParams {
|
||||
chunk: AssistantMessageChunk;
|
||||
}
|
||||
|
||||
export interface RequestToolCallConfirmationParams {
|
||||
confirmation: ToolCallConfirmation;
|
||||
content?: ToolCallContent | null;
|
||||
icon: Icon;
|
||||
label: string;
|
||||
locations?: ToolCallLocation[];
|
||||
}
|
||||
|
||||
export interface ToolCallLocation {
|
||||
line?: number | null;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface PushToolCallParams {
|
||||
content?: ToolCallContent | null;
|
||||
icon: Icon;
|
||||
label: string;
|
||||
locations?: ToolCallLocation[];
|
||||
}
|
||||
|
||||
export interface UpdateToolCallParams {
|
||||
content: ToolCallContent | null;
|
||||
status: ToolCallStatus;
|
||||
toolCallId: ToolCallId;
|
||||
}
|
||||
|
||||
export interface RequestToolCallConfirmationResponse {
|
||||
id: ToolCallId;
|
||||
outcome: ToolCallConfirmationOutcome;
|
||||
}
|
||||
|
||||
export interface PushToolCallResponse {
|
||||
id: ToolCallId;
|
||||
}
|
||||
|
||||
export interface InitializeParams {
|
||||
/**
|
||||
* The version of the protocol that the client supports.
|
||||
* This should be the latest version supported by the client.
|
||||
*/
|
||||
protocolVersion: string;
|
||||
}
|
||||
|
||||
export interface SendUserMessageParams {
|
||||
chunks: UserMessageChunk[];
|
||||
}
|
||||
|
||||
export interface InitializeResponse {
|
||||
/**
|
||||
* Indicates whether the agent is authenticated and
|
||||
* ready to handle requests.
|
||||
*/
|
||||
isAuthenticated: boolean;
|
||||
/**
|
||||
* The version of the protocol that the agent supports.
|
||||
* If the agent supports the requested version, it should respond with the same version.
|
||||
* Otherwise, the agent should respond with the latest version it supports.
|
||||
*/
|
||||
protocolVersion: string;
|
||||
}
|
||||
|
||||
export interface Error {
|
||||
code: number;
|
||||
data?: unknown;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
streamAssistantMessageChunk(
|
||||
params: StreamAssistantMessageChunkParams,
|
||||
): Promise<void>;
|
||||
|
||||
requestToolCallConfirmation(
|
||||
params: RequestToolCallConfirmationParams,
|
||||
): Promise<RequestToolCallConfirmationResponse>;
|
||||
|
||||
pushToolCall(params: PushToolCallParams): Promise<PushToolCallResponse>;
|
||||
|
||||
updateToolCall(params: UpdateToolCallParams): Promise<void>;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
/**
|
||||
* Initializes the agent's state. It should be called before any other method,
|
||||
* and no other methods should be called until it has completed.
|
||||
*
|
||||
* If the agent is not authenticated, then the client should prompt the user to authenticate,
|
||||
* and then call the `authenticate` method.
|
||||
* Otherwise the client can send other messages to the agent.
|
||||
*/
|
||||
initialize(params: InitializeParams): Promise<InitializeResponse>;
|
||||
|
||||
/**
|
||||
* Begins the authentication process.
|
||||
*
|
||||
* This method should only be called if `initialize` indicates the user isn't already authenticated.
|
||||
* The Promise MUST not resolve until authentication is complete.
|
||||
*/
|
||||
authenticate(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Allows the user to send a message to the agent.
|
||||
* This method should complete after the agent is finished, during
|
||||
* which time the agent may update the client by calling
|
||||
* streamAssistantMessageChunk and other methods.
|
||||
*/
|
||||
sendUserMessage(params: SendUserMessageParams): Promise<void>;
|
||||
|
||||
/**
|
||||
* Cancels the current generation.
|
||||
*/
|
||||
cancelSendMessage(): Promise<void>;
|
||||
}
|
||||
674
packages/cli/src/acp/acpPeer.ts
Normal file
674
packages/cli/src/acp/acpPeer.ts
Normal file
@@ -0,0 +1,674 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { WritableStream, ReadableStream } from 'node:stream/web';
|
||||
|
||||
import {
|
||||
AuthType,
|
||||
Config,
|
||||
GeminiChat,
|
||||
ToolRegistry,
|
||||
logToolCall,
|
||||
ToolResult,
|
||||
convertToFunctionResponse,
|
||||
ToolCallConfirmationDetails,
|
||||
ToolConfirmationOutcome,
|
||||
clearCachedCredentialFile,
|
||||
isNodeError,
|
||||
getErrorMessage,
|
||||
isWithinRoot,
|
||||
getErrorStatus,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import * as acp from './acp.js';
|
||||
import { Agent } from './acp.js';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { Content, Part, FunctionCall, PartListUnion } from '@google/genai';
|
||||
import { LoadedSettings, SettingScope } from '../config/settings.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
export async function runAcpPeer(config: Config, settings: LoadedSettings) {
|
||||
const stdout = Writable.toWeb(process.stdout) as WritableStream;
|
||||
const stdin = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
|
||||
|
||||
// Stdout is used to send messages to the client, so console.log/console.info
|
||||
// messages to stderr so that they don't interfere with ACP.
|
||||
console.log = console.error;
|
||||
console.info = console.error;
|
||||
console.debug = console.error;
|
||||
|
||||
new acp.ClientConnection(
|
||||
(client: acp.Client) => new GeminiAgent(config, settings, client),
|
||||
stdout,
|
||||
stdin,
|
||||
);
|
||||
}
|
||||
|
||||
class GeminiAgent implements Agent {
|
||||
chat?: GeminiChat;
|
||||
pendingSend?: AbortController;
|
||||
|
||||
constructor(
|
||||
private config: Config,
|
||||
private settings: LoadedSettings,
|
||||
private client: acp.Client,
|
||||
) {}
|
||||
|
||||
async initialize(_: acp.InitializeParams): Promise<acp.InitializeResponse> {
|
||||
let isAuthenticated = false;
|
||||
if (this.settings.merged.selectedAuthType) {
|
||||
try {
|
||||
await this.config.refreshAuth(this.settings.merged.selectedAuthType);
|
||||
isAuthenticated = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh auth:', error);
|
||||
}
|
||||
}
|
||||
return { protocolVersion: acp.LATEST_PROTOCOL_VERSION, isAuthenticated };
|
||||
}
|
||||
|
||||
async authenticate(): Promise<void> {
|
||||
await clearCachedCredentialFile();
|
||||
await this.config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
|
||||
this.settings.setValue(
|
||||
SettingScope.User,
|
||||
'selectedAuthType',
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
}
|
||||
|
||||
async cancelSendMessage(): Promise<void> {
|
||||
if (!this.pendingSend) {
|
||||
throw new Error('Not currently generating');
|
||||
}
|
||||
|
||||
this.pendingSend.abort();
|
||||
delete this.pendingSend;
|
||||
}
|
||||
|
||||
async sendUserMessage(params: acp.SendUserMessageParams): Promise<void> {
|
||||
this.pendingSend?.abort();
|
||||
const pendingSend = new AbortController();
|
||||
this.pendingSend = pendingSend;
|
||||
|
||||
if (!this.chat) {
|
||||
const geminiClient = this.config.getGeminiClient();
|
||||
this.chat = await geminiClient.startChat();
|
||||
}
|
||||
|
||||
const promptId = Math.random().toString(16).slice(2);
|
||||
const chat = this.chat!;
|
||||
const toolRegistry: ToolRegistry = await this.config.getToolRegistry();
|
||||
const parts = await this.#resolveUserMessage(params, pendingSend.signal);
|
||||
|
||||
let nextMessage: Content | null = { role: 'user', parts };
|
||||
|
||||
while (nextMessage !== null) {
|
||||
if (pendingSend.signal.aborted) {
|
||||
chat.addHistory(nextMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const functionCalls: FunctionCall[] = [];
|
||||
|
||||
try {
|
||||
const responseStream = await chat.sendMessageStream(
|
||||
{
|
||||
message: nextMessage?.parts ?? [],
|
||||
config: {
|
||||
abortSignal: pendingSend.signal,
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: toolRegistry.getFunctionDeclarations(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
promptId,
|
||||
);
|
||||
nextMessage = null;
|
||||
|
||||
for await (const resp of responseStream) {
|
||||
if (pendingSend.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (resp.candidates && resp.candidates.length > 0) {
|
||||
const candidate = resp.candidates[0];
|
||||
for (const part of candidate.content?.parts ?? []) {
|
||||
if (!part.text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.client.streamAssistantMessageChunk({
|
||||
chunk: part.thought
|
||||
? { thought: part.text }
|
||||
: { text: part.text },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (resp.functionCalls) {
|
||||
functionCalls.push(...resp.functionCalls);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (getErrorStatus(error) === 429) {
|
||||
throw new acp.RequestError(
|
||||
429,
|
||||
'Rate limit exceeded. Try again later.',
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (functionCalls.length > 0) {
|
||||
const toolResponseParts: Part[] = [];
|
||||
|
||||
for (const fc of functionCalls) {
|
||||
const response = await this.#runTool(
|
||||
pendingSend.signal,
|
||||
promptId,
|
||||
fc,
|
||||
);
|
||||
|
||||
const parts = Array.isArray(response) ? response : [response];
|
||||
|
||||
for (const part of parts) {
|
||||
if (typeof part === 'string') {
|
||||
toolResponseParts.push({ text: part });
|
||||
} else if (part) {
|
||||
toolResponseParts.push(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nextMessage = { role: 'user', parts: toolResponseParts };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #runTool(
|
||||
abortSignal: AbortSignal,
|
||||
promptId: string,
|
||||
fc: FunctionCall,
|
||||
): Promise<PartListUnion> {
|
||||
const callId = fc.id ?? `${fc.name}-${Date.now()}`;
|
||||
const args = (fc.args ?? {}) as Record<string, unknown>;
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
const errorResponse = (error: Error) => {
|
||||
const durationMs = Date.now() - startTime;
|
||||
logToolCall(this.config, {
|
||||
'event.name': 'tool_call',
|
||||
'event.timestamp': new Date().toISOString(),
|
||||
prompt_id: promptId,
|
||||
function_name: fc.name ?? '',
|
||||
function_args: args,
|
||||
duration_ms: durationMs,
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
functionResponse: {
|
||||
id: callId,
|
||||
name: fc.name ?? '',
|
||||
response: { error: error.message },
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
if (!fc.name) {
|
||||
return errorResponse(new Error('Missing function name'));
|
||||
}
|
||||
|
||||
const toolRegistry: ToolRegistry = await this.config.getToolRegistry();
|
||||
const tool = toolRegistry.getTool(fc.name as string);
|
||||
|
||||
if (!tool) {
|
||||
return errorResponse(
|
||||
new Error(`Tool "${fc.name}" not found in registry.`),
|
||||
);
|
||||
}
|
||||
|
||||
let toolCallId;
|
||||
const confirmationDetails = await tool.shouldConfirmExecute(
|
||||
args,
|
||||
abortSignal,
|
||||
);
|
||||
if (confirmationDetails) {
|
||||
let content: acp.ToolCallContent | null = null;
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
content = {
|
||||
type: 'diff',
|
||||
path: confirmationDetails.fileName,
|
||||
oldText: confirmationDetails.originalContent,
|
||||
newText: confirmationDetails.newContent,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await this.client.requestToolCallConfirmation({
|
||||
label: tool.getDescription(args),
|
||||
icon: tool.icon,
|
||||
content,
|
||||
confirmation: toAcpToolCallConfirmation(confirmationDetails),
|
||||
locations: tool.toolLocations(args),
|
||||
});
|
||||
|
||||
await confirmationDetails.onConfirm(toToolCallOutcome(result.outcome));
|
||||
switch (result.outcome) {
|
||||
case 'reject':
|
||||
return errorResponse(
|
||||
new Error(`Tool "${fc.name}" not allowed to run by the user.`),
|
||||
);
|
||||
|
||||
case 'cancel':
|
||||
return errorResponse(
|
||||
new Error(`Tool "${fc.name}" was canceled by the user.`),
|
||||
);
|
||||
case 'allow':
|
||||
case 'alwaysAllow':
|
||||
case 'alwaysAllowMcpServer':
|
||||
case 'alwaysAllowTool':
|
||||
break;
|
||||
default: {
|
||||
const resultOutcome: never = result.outcome;
|
||||
throw new Error(`Unexpected: ${resultOutcome}`);
|
||||
}
|
||||
}
|
||||
|
||||
toolCallId = result.id;
|
||||
} else {
|
||||
const result = await this.client.pushToolCall({
|
||||
icon: tool.icon,
|
||||
label: tool.getDescription(args),
|
||||
locations: tool.toolLocations(args),
|
||||
});
|
||||
|
||||
toolCallId = result.id;
|
||||
}
|
||||
|
||||
try {
|
||||
const toolResult: ToolResult = await tool.execute(args, abortSignal);
|
||||
const toolCallContent = toToolCallContent(toolResult);
|
||||
|
||||
await this.client.updateToolCall({
|
||||
toolCallId,
|
||||
status: 'finished',
|
||||
content: toolCallContent,
|
||||
});
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
logToolCall(this.config, {
|
||||
'event.name': 'tool_call',
|
||||
'event.timestamp': new Date().toISOString(),
|
||||
function_name: fc.name,
|
||||
function_args: args,
|
||||
duration_ms: durationMs,
|
||||
success: true,
|
||||
prompt_id: promptId,
|
||||
});
|
||||
|
||||
return convertToFunctionResponse(fc.name, callId, toolResult.llmContent);
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e : new Error(String(e));
|
||||
await this.client.updateToolCall({
|
||||
toolCallId,
|
||||
status: 'error',
|
||||
content: { type: 'markdown', markdown: error.message },
|
||||
});
|
||||
|
||||
return errorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
async #resolveUserMessage(
|
||||
message: acp.SendUserMessageParams,
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<Part[]> {
|
||||
const atPathCommandParts = message.chunks.filter((part) => 'path' in part);
|
||||
|
||||
if (atPathCommandParts.length === 0) {
|
||||
return message.chunks.map((chunk) => {
|
||||
if ('text' in chunk) {
|
||||
return { text: chunk.text };
|
||||
} else {
|
||||
throw new Error('Unexpected chunk type');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get centralized file discovery service
|
||||
const fileDiscovery = this.config.getFileService();
|
||||
const respectGitIgnore = this.config.getFileFilteringRespectGitIgnore();
|
||||
|
||||
const pathSpecsToRead: string[] = [];
|
||||
const atPathToResolvedSpecMap = new Map<string, string>();
|
||||
const contentLabelsForDisplay: string[] = [];
|
||||
const ignoredPaths: string[] = [];
|
||||
|
||||
const toolRegistry = await this.config.getToolRegistry();
|
||||
const readManyFilesTool = toolRegistry.getTool('read_many_files');
|
||||
const globTool = toolRegistry.getTool('glob');
|
||||
|
||||
if (!readManyFilesTool) {
|
||||
throw new Error('Error: read_many_files tool not found.');
|
||||
}
|
||||
|
||||
for (const atPathPart of atPathCommandParts) {
|
||||
const pathName = atPathPart.path;
|
||||
|
||||
// Check if path should be ignored by git
|
||||
if (fileDiscovery.shouldGitIgnoreFile(pathName)) {
|
||||
ignoredPaths.push(pathName);
|
||||
const reason = respectGitIgnore
|
||||
? 'git-ignored and will be skipped'
|
||||
: 'ignored by custom patterns';
|
||||
console.warn(`Path ${pathName} is ${reason}.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let currentPathSpec = pathName;
|
||||
let resolvedSuccessfully = false;
|
||||
|
||||
try {
|
||||
const absolutePath = path.resolve(this.config.getTargetDir(), pathName);
|
||||
if (isWithinRoot(absolutePath, this.config.getTargetDir())) {
|
||||
const stats = await fs.stat(absolutePath);
|
||||
if (stats.isDirectory()) {
|
||||
currentPathSpec = pathName.endsWith('/')
|
||||
? `${pathName}**`
|
||||
: `${pathName}/**`;
|
||||
this.#debug(
|
||||
`Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`,
|
||||
);
|
||||
} else {
|
||||
this.#debug(
|
||||
`Path ${pathName} resolved to file: ${currentPathSpec}`,
|
||||
);
|
||||
}
|
||||
resolvedSuccessfully = true;
|
||||
} else {
|
||||
this.#debug(
|
||||
`Path ${pathName} is outside the project directory. Skipping.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
if (this.config.getEnableRecursiveFileSearch() && globTool) {
|
||||
this.#debug(
|
||||
`Path ${pathName} not found directly, attempting glob search.`,
|
||||
);
|
||||
try {
|
||||
const globResult = await globTool.execute(
|
||||
{
|
||||
pattern: `**/*${pathName}*`,
|
||||
path: this.config.getTargetDir(),
|
||||
},
|
||||
abortSignal,
|
||||
);
|
||||
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(
|
||||
this.config.getTargetDir(),
|
||||
firstMatchAbsolute,
|
||||
);
|
||||
this.#debug(
|
||||
`Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`,
|
||||
);
|
||||
resolvedSuccessfully = true;
|
||||
} else {
|
||||
this.#debug(
|
||||
`Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.#debug(
|
||||
`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)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.#debug(
|
||||
`Glob tool not found. Path ${pathName} will be skipped.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
`Error stating path ${pathName}. Path ${pathName} will be skipped.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedSuccessfully) {
|
||||
pathSpecsToRead.push(currentPathSpec);
|
||||
atPathToResolvedSpecMap.set(pathName, currentPathSpec);
|
||||
contentLabelsForDisplay.push(pathName);
|
||||
}
|
||||
}
|
||||
|
||||
// Construct the initial part of the query for the LLM
|
||||
let initialQueryText = '';
|
||||
for (let i = 0; i < message.chunks.length; i++) {
|
||||
const chunk = message.chunks[i];
|
||||
if ('text' in chunk) {
|
||||
initialQueryText += chunk.text;
|
||||
} else {
|
||||
// type === 'atPath'
|
||||
const resolvedSpec = atPathToResolvedSpecMap.get(chunk.path);
|
||||
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 = message.chunks[i - 1];
|
||||
if (
|
||||
'text' in prevPart ||
|
||||
('path' in prevPart && atPathToResolvedSpecMap.has(prevPart.path))
|
||||
) {
|
||||
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(' ') &&
|
||||
!chunk.path.startsWith(' ')
|
||||
) {
|
||||
initialQueryText += ' ';
|
||||
}
|
||||
initialQueryText += `@${chunk.path}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
initialQueryText = initialQueryText.trim();
|
||||
|
||||
// Inform user about ignored paths
|
||||
if (ignoredPaths.length > 0) {
|
||||
const ignoreType = respectGitIgnore ? 'git-ignored' : 'custom-ignored';
|
||||
this.#debug(
|
||||
`Ignored ${ignoredPaths.length} ${ignoreType} files: ${ignoredPaths.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText
|
||||
if (pathSpecsToRead.length === 0) {
|
||||
console.warn('No valid file paths found in @ commands to read.');
|
||||
return [{ text: initialQueryText }];
|
||||
}
|
||||
|
||||
const processedQueryParts: Part[] = [{ text: initialQueryText }];
|
||||
|
||||
const toolArgs = {
|
||||
paths: pathSpecsToRead,
|
||||
respectGitIgnore, // Use configuration setting
|
||||
};
|
||||
|
||||
const toolCall = await this.client.pushToolCall({
|
||||
icon: readManyFilesTool.icon,
|
||||
label: readManyFilesTool.getDescription(toolArgs),
|
||||
});
|
||||
try {
|
||||
const result = await readManyFilesTool.execute(toolArgs, abortSignal);
|
||||
const content = toToolCallContent(result) || {
|
||||
type: 'markdown',
|
||||
markdown: `Successfully read: ${contentLabelsForDisplay.join(', ')}`,
|
||||
};
|
||||
await this.client.updateToolCall({
|
||||
toolCallId: toolCall.id,
|
||||
status: 'finished',
|
||||
content,
|
||||
});
|
||||
|
||||
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 {
|
||||
console.warn(
|
||||
'read_many_files tool returned no content or empty content.',
|
||||
);
|
||||
}
|
||||
|
||||
return processedQueryParts;
|
||||
} catch (error: unknown) {
|
||||
await this.client.updateToolCall({
|
||||
toolCallId: toolCall.id,
|
||||
status: 'error',
|
||||
content: {
|
||||
type: 'markdown',
|
||||
markdown: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
#debug(msg: string) {
|
||||
if (this.config.getDebugMode()) {
|
||||
console.warn(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
|
||||
if (toolResult.returnDisplay) {
|
||||
if (typeof toolResult.returnDisplay === 'string') {
|
||||
return {
|
||||
type: 'markdown',
|
||||
markdown: toolResult.returnDisplay,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: 'diff',
|
||||
path: toolResult.returnDisplay.fileName,
|
||||
oldText: toolResult.returnDisplay.originalContent,
|
||||
newText: toolResult.returnDisplay.newContent,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toAcpToolCallConfirmation(
|
||||
confirmationDetails: ToolCallConfirmationDetails,
|
||||
): acp.ToolCallConfirmation {
|
||||
switch (confirmationDetails.type) {
|
||||
case 'edit':
|
||||
return { type: 'edit' };
|
||||
case 'exec':
|
||||
return {
|
||||
type: 'execute',
|
||||
rootCommand: confirmationDetails.rootCommand,
|
||||
command: confirmationDetails.command,
|
||||
};
|
||||
case 'mcp':
|
||||
return {
|
||||
type: 'mcp',
|
||||
serverName: confirmationDetails.serverName,
|
||||
toolName: confirmationDetails.toolName,
|
||||
toolDisplayName: confirmationDetails.toolDisplayName,
|
||||
};
|
||||
case 'info':
|
||||
return {
|
||||
type: 'fetch',
|
||||
urls: confirmationDetails.urls || [],
|
||||
description: confirmationDetails.urls?.length
|
||||
? null
|
||||
: confirmationDetails.prompt,
|
||||
};
|
||||
default: {
|
||||
const unreachable: never = confirmationDetails;
|
||||
throw new Error(`Unexpected: ${unreachable}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toToolCallOutcome(
|
||||
outcome: acp.ToolCallConfirmationOutcome,
|
||||
): ToolConfirmationOutcome {
|
||||
switch (outcome) {
|
||||
case 'allow':
|
||||
return ToolConfirmationOutcome.ProceedOnce;
|
||||
case 'alwaysAllow':
|
||||
return ToolConfirmationOutcome.ProceedAlways;
|
||||
case 'alwaysAllowMcpServer':
|
||||
return ToolConfirmationOutcome.ProceedAlwaysServer;
|
||||
case 'alwaysAllowTool':
|
||||
return ToolConfirmationOutcome.ProceedAlwaysTool;
|
||||
case 'reject':
|
||||
case 'cancel':
|
||||
return ToolConfirmationOutcome.Cancel;
|
||||
default: {
|
||||
const unreachable: never = outcome;
|
||||
throw new Error(`Unexpected: ${unreachable}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,10 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as os from 'os';
|
||||
import { loadCliConfig, parseArguments, CliArgs } from './config.js';
|
||||
import { loadCliConfig, parseArguments } from './config.js';
|
||||
import { Settings } from './settings.js';
|
||||
import { Extension } from './extension.js';
|
||||
import * as ServerConfig from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
TelemetryTarget,
|
||||
ConfigParameters,
|
||||
DEFAULT_TELEMETRY_TARGET,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const actualOs = await importOriginal<typeof os>();
|
||||
@@ -42,63 +37,19 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||
...actualServer,
|
||||
loadEnvironment: vi.fn(),
|
||||
loadServerHierarchicalMemory: vi.fn(
|
||||
(cwd, debug, fileService, extensionPaths) =>
|
||||
(cwd, debug, fileService, extensionPaths, _maxDirs) =>
|
||||
Promise.resolve({
|
||||
memoryContent: extensionPaths?.join(',') || '',
|
||||
fileCount: extensionPaths?.length || 0,
|
||||
}),
|
||||
),
|
||||
Config: class MockConfig extends actualServer.Config {
|
||||
private enableOpenAILogging: boolean;
|
||||
|
||||
constructor(params: ConfigParameters) {
|
||||
super(params);
|
||||
this.enableOpenAILogging = params.enableOpenAILogging ?? false;
|
||||
}
|
||||
|
||||
getEnableOpenAILogging(): boolean {
|
||||
return this.enableOpenAILogging;
|
||||
}
|
||||
|
||||
// Override other methods to ensure they work correctly
|
||||
getShowMemoryUsage(): boolean {
|
||||
return (
|
||||
(this as unknown as { showMemoryUsage?: boolean }).showMemoryUsage ??
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
getTelemetryEnabled(): boolean {
|
||||
return (
|
||||
(this as unknown as { telemetrySettings?: { enabled?: boolean } })
|
||||
.telemetrySettings?.enabled ?? false
|
||||
);
|
||||
}
|
||||
|
||||
getTelemetryLogPromptsEnabled(): boolean {
|
||||
return (
|
||||
(this as unknown as { telemetrySettings?: { logPrompts?: boolean } })
|
||||
.telemetrySettings?.logPrompts ?? false
|
||||
);
|
||||
}
|
||||
|
||||
getTelemetryOtlpEndpoint(): string {
|
||||
return (
|
||||
(this as unknown as { telemetrySettings?: { otlpEndpoint?: string } })
|
||||
.telemetrySettings?.otlpEndpoint ??
|
||||
'http://tracing-analysis-dc-hz.aliyuncs.com:8090'
|
||||
);
|
||||
}
|
||||
|
||||
getTelemetryTarget(): TelemetryTarget {
|
||||
return (
|
||||
(
|
||||
this as unknown as {
|
||||
telemetrySettings?: { target?: TelemetryTarget };
|
||||
}
|
||||
).telemetrySettings?.target ?? DEFAULT_TELEMETRY_TARGET
|
||||
);
|
||||
}
|
||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: {
|
||||
respectGitIgnore: false,
|
||||
respectGeminiIgnore: true,
|
||||
},
|
||||
DEFAULT_FILE_FILTERING_OPTIONS: {
|
||||
respectGitIgnore: true,
|
||||
respectGeminiIgnore: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -244,6 +195,85 @@ describe('loadCliConfig', () => {
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getShowMemoryUsage()).toBe(true);
|
||||
});
|
||||
|
||||
it(`should leave proxy to empty by default`, async () => {
|
||||
// Clear all proxy environment variables to ensure clean test
|
||||
delete process.env.https_proxy;
|
||||
delete process.env.http_proxy;
|
||||
delete process.env.HTTPS_PROXY;
|
||||
delete process.env.HTTP_PROXY;
|
||||
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getProxy()).toBeFalsy();
|
||||
});
|
||||
|
||||
const proxy_url = 'http://localhost:7890';
|
||||
const testCases = [
|
||||
{
|
||||
input: {
|
||||
env_name: 'https_proxy',
|
||||
proxy_url,
|
||||
},
|
||||
expected: proxy_url,
|
||||
},
|
||||
{
|
||||
input: {
|
||||
env_name: 'http_proxy',
|
||||
proxy_url,
|
||||
},
|
||||
expected: proxy_url,
|
||||
},
|
||||
{
|
||||
input: {
|
||||
env_name: 'HTTPS_PROXY',
|
||||
proxy_url,
|
||||
},
|
||||
expected: proxy_url,
|
||||
},
|
||||
{
|
||||
input: {
|
||||
env_name: 'HTTP_PROXY',
|
||||
proxy_url,
|
||||
},
|
||||
expected: proxy_url,
|
||||
},
|
||||
];
|
||||
testCases.forEach(({ input, expected }) => {
|
||||
it(`should set proxy to ${expected} according to environment variable [${input.env_name}]`, async () => {
|
||||
// Clear all proxy environment variables first
|
||||
delete process.env.https_proxy;
|
||||
delete process.env.http_proxy;
|
||||
delete process.env.HTTPS_PROXY;
|
||||
delete process.env.HTTP_PROXY;
|
||||
|
||||
process.env[input.env_name] = input.proxy_url;
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getProxy()).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should set proxy when --proxy flag is present', async () => {
|
||||
process.argv = ['node', 'script.js', '--proxy', 'http://localhost:7890'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getProxy()).toBe('http://localhost:7890');
|
||||
});
|
||||
|
||||
it('should prioritize CLI flag over environment variable for proxy (CLI http://localhost:7890, environment variable http://localhost:7891)', async () => {
|
||||
process.env['http_proxy'] = 'http://localhost:7891';
|
||||
process.argv = ['node', 'script.js', '--proxy', 'http://localhost:7890'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getProxy()).toBe('http://localhost:7890');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig telemetry', () => {
|
||||
@@ -350,9 +380,7 @@ describe('loadCliConfig telemetry', () => {
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = { telemetry: { enabled: true } };
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getTelemetryOtlpEndpoint()).toBe(
|
||||
'http://tracing-analysis-dc-hz.aliyuncs.com:8090',
|
||||
);
|
||||
expect(config.getTelemetryOtlpEndpoint()).toBe('http://localhost:4317');
|
||||
});
|
||||
|
||||
it('should use telemetry target from settings if CLI flag is not present', async () => {
|
||||
@@ -411,81 +439,12 @@ describe('loadCliConfig telemetry', () => {
|
||||
expect(config.getTelemetryLogPromptsEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should use default log prompts (false) if no value is provided via CLI or settings', async () => {
|
||||
it('should use default log prompts (true) if no value is provided via CLI or settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = { telemetry: { enabled: true } };
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getTelemetryLogPromptsEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should set enableOpenAILogging to true when --openai-logging flag is present', async () => {
|
||||
const settings: Settings = {};
|
||||
const argv = await parseArguments();
|
||||
argv.openaiLogging = true;
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(
|
||||
(
|
||||
config as unknown as { getEnableOpenAILogging(): boolean }
|
||||
).getEnableOpenAILogging(),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should set enableOpenAILogging to false when --openai-logging flag is not present', async () => {
|
||||
const settings: Settings = {};
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(
|
||||
(
|
||||
config as unknown as { getEnableOpenAILogging(): boolean }
|
||||
).getEnableOpenAILogging(),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should use enableOpenAILogging value from settings if CLI flag is not present (settings true)', async () => {
|
||||
const settings: Settings = { enableOpenAILogging: true };
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(
|
||||
(
|
||||
config as unknown as { getEnableOpenAILogging(): boolean }
|
||||
).getEnableOpenAILogging(),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should use enableOpenAILogging value from settings if CLI flag is not present (settings false)', async () => {
|
||||
const settings: Settings = { enableOpenAILogging: false };
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(
|
||||
(
|
||||
config as unknown as { getEnableOpenAILogging(): boolean }
|
||||
).getEnableOpenAILogging(),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should prioritize --openai-logging CLI flag (true) over settings (false)', async () => {
|
||||
const settings: Settings = { enableOpenAILogging: false };
|
||||
const argv = await parseArguments();
|
||||
argv.openaiLogging = true;
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(
|
||||
(
|
||||
config as unknown as { getEnableOpenAILogging(): boolean }
|
||||
).getEnableOpenAILogging(),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should prioritize --openai-logging CLI flag (false) over settings (true)', async () => {
|
||||
const settings: Settings = { enableOpenAILogging: true };
|
||||
const argv = await parseArguments();
|
||||
argv.openaiLogging = false;
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(
|
||||
(
|
||||
config as unknown as { getEnableOpenAILogging(): boolean }
|
||||
).getEnableOpenAILogging(),
|
||||
).toBe(false);
|
||||
expect(config.getTelemetryLogPromptsEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -540,6 +499,11 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
'/path/to/ext3/context1.md',
|
||||
'/path/to/ext3/context2.md',
|
||||
],
|
||||
{
|
||||
respectGitIgnore: false,
|
||||
respectGeminiIgnore: true,
|
||||
},
|
||||
undefined, // maxDirs
|
||||
);
|
||||
});
|
||||
|
||||
@@ -853,6 +817,66 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
|
||||
const config = await loadCliConfig(baseSettings, [], 'test-session', argv);
|
||||
expect(config.getMcpServers()).toEqual({});
|
||||
});
|
||||
|
||||
it('should read allowMCPServers from settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
...baseSettings,
|
||||
allowMCPServers: ['server1', 'server2'],
|
||||
};
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getMcpServers()).toEqual({
|
||||
server1: { url: 'http://localhost:8080' },
|
||||
server2: { url: 'http://localhost:8081' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should read excludeMCPServers from settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
...baseSettings,
|
||||
excludeMCPServers: ['server1', 'server2'],
|
||||
};
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getMcpServers()).toEqual({
|
||||
server3: { url: 'http://localhost:8082' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should override allowMCPServers with excludeMCPServers if overlapping ', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
...baseSettings,
|
||||
excludeMCPServers: ['server1'],
|
||||
allowMCPServers: ['server1', 'server2'],
|
||||
};
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getMcpServers()).toEqual({
|
||||
server2: { url: 'http://localhost:8081' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should prioritize mcp server flag if set ', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--allowed-mcp-server-names',
|
||||
'server1',
|
||||
];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
...baseSettings,
|
||||
excludeMCPServers: ['server1'],
|
||||
allowMCPServers: ['server2'],
|
||||
};
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getMcpServers()).toEqual({
|
||||
server1: { url: 'http://localhost:8080' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig extensions', () => {
|
||||
@@ -908,6 +932,7 @@ describe('loadCliConfig ideMode', () => {
|
||||
// Explicitly delete TERM_PROGRAM and SANDBOX before each test
|
||||
delete process.env.TERM_PROGRAM;
|
||||
delete process.env.SANDBOX;
|
||||
delete process.env.GEMINI_CLI_IDE_SERVER_PORT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -944,6 +969,7 @@ describe('loadCliConfig ideMode', () => {
|
||||
process.argv = ['node', 'script.js', '--ide-mode'];
|
||||
const argv = await parseArguments();
|
||||
process.env.TERM_PROGRAM = 'vscode';
|
||||
process.env.GEMINI_CLI_IDE_SERVER_PORT = '3000';
|
||||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getIdeMode()).toBe(true);
|
||||
@@ -953,6 +979,7 @@ describe('loadCliConfig ideMode', () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
process.env.TERM_PROGRAM = 'vscode';
|
||||
process.env.GEMINI_CLI_IDE_SERVER_PORT = '3000';
|
||||
const settings: Settings = { ideMode: true };
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getIdeMode()).toBe(true);
|
||||
@@ -962,6 +989,7 @@ describe('loadCliConfig ideMode', () => {
|
||||
process.argv = ['node', 'script.js', '--ide-mode'];
|
||||
const argv = await parseArguments();
|
||||
process.env.TERM_PROGRAM = 'vscode';
|
||||
process.env.GEMINI_CLI_IDE_SERVER_PORT = '3000';
|
||||
const settings: Settings = { ideMode: false };
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getIdeMode()).toBe(true);
|
||||
@@ -995,82 +1023,4 @@ describe('loadCliConfig ideMode', () => {
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getIdeMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('should add __ide_server when ideMode is true', async () => {
|
||||
process.argv = ['node', 'script.js', '--ide-mode'];
|
||||
const argv = await parseArguments();
|
||||
process.env.TERM_PROGRAM = 'vscode';
|
||||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getIdeMode()).toBe(true);
|
||||
const mcpServers = config.getMcpServers();
|
||||
expect(mcpServers?.['_ide_server']).toBeDefined();
|
||||
expect(mcpServers?.['_ide_server']?.httpUrl).toBe(
|
||||
'http://localhost:3000/mcp',
|
||||
);
|
||||
expect(mcpServers?.['_ide_server']?.description).toBe('IDE connection');
|
||||
expect(mcpServers?.['_ide_server']?.trust).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig systemPromptMappings', () => {
|
||||
it('should use default systemPromptMappings when not provided in settings', async () => {
|
||||
const mockSettings: Settings = {
|
||||
theme: 'dark',
|
||||
};
|
||||
const mockExtensions: Extension[] = [];
|
||||
const mockSessionId = 'test-session';
|
||||
const mockArgv: CliArgs = {
|
||||
model: 'test-model',
|
||||
} as CliArgs;
|
||||
|
||||
const config = await loadCliConfig(
|
||||
mockSettings,
|
||||
mockExtensions,
|
||||
mockSessionId,
|
||||
mockArgv,
|
||||
);
|
||||
|
||||
expect(config.getSystemPromptMappings()).toEqual([
|
||||
{
|
||||
baseUrls: [
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1/',
|
||||
'https://dashscope-intl.aliyuncs.com/compatible-mode/v1/',
|
||||
],
|
||||
modelNames: ['qwen3-coder-plus'],
|
||||
template:
|
||||
'SYSTEM_TEMPLATE:{"name":"qwen3_coder","params":{"is_git_repository":{RUNTIME_VARS_IS_GIT_REPO},"sandbox":"{RUNTIME_VARS_SANDBOX}"}}',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use custom systemPromptMappings when provided in settings', async () => {
|
||||
const customSystemPromptMappings = [
|
||||
{
|
||||
baseUrls: ['https://custom-api.com'],
|
||||
modelNames: ['custom-model'],
|
||||
template: 'Custom template',
|
||||
},
|
||||
];
|
||||
const mockSettings: Settings = {
|
||||
theme: 'dark',
|
||||
systemPromptMappings: customSystemPromptMappings,
|
||||
};
|
||||
const mockExtensions: Extension[] = [];
|
||||
const mockSessionId = 'test-session';
|
||||
const mockArgv: CliArgs = {
|
||||
model: 'test-model',
|
||||
} as CliArgs;
|
||||
|
||||
const config = await loadCliConfig(
|
||||
mockSettings,
|
||||
mockExtensions,
|
||||
mockSessionId,
|
||||
mockArgv,
|
||||
);
|
||||
|
||||
expect(config.getSystemPromptMappings()).toEqual(
|
||||
customSystemPromptMappings,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,13 +15,15 @@ import {
|
||||
ApprovalMode,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
FileDiscoveryService,
|
||||
TelemetryTarget,
|
||||
MCPServerConfig,
|
||||
FileFilteringOptions,
|
||||
IdeClient,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { Settings } from './settings.js';
|
||||
|
||||
import { Extension, filterActiveExtensions } from './extension.js';
|
||||
import { Extension, annotateActiveExtensions } from './extension.js';
|
||||
import { getCliVersion } from '../utils/version.js';
|
||||
import { loadSandboxConfig } from './sandboxConfig.js';
|
||||
|
||||
@@ -52,13 +54,16 @@ export interface CliArgs {
|
||||
telemetryTarget: string | undefined;
|
||||
telemetryOtlpEndpoint: string | undefined;
|
||||
telemetryLogPrompts: boolean | undefined;
|
||||
telemetryOutfile: string | undefined;
|
||||
allowedMcpServerNames: string[] | undefined;
|
||||
experimentalAcp: boolean | undefined;
|
||||
extensions: string[] | undefined;
|
||||
listExtensions: boolean | undefined;
|
||||
ideMode: boolean | undefined;
|
||||
openaiLogging: boolean | undefined;
|
||||
openaiApiKey: string | undefined;
|
||||
openaiBaseUrl: string | undefined;
|
||||
proxy: string | undefined;
|
||||
}
|
||||
|
||||
export async function parseArguments(): Promise<CliArgs> {
|
||||
@@ -157,12 +162,20 @@ export async function parseArguments(): Promise<CliArgs> {
|
||||
description:
|
||||
'Enable or disable logging of user prompts for telemetry. Overrides settings files.',
|
||||
})
|
||||
.option('telemetry-outfile', {
|
||||
type: 'string',
|
||||
description: 'Redirect all telemetry output to the specified file.',
|
||||
})
|
||||
.option('checkpointing', {
|
||||
alias: 'c',
|
||||
type: 'boolean',
|
||||
description: 'Enables checkpointing of file edits',
|
||||
default: false,
|
||||
})
|
||||
.option('experimental-acp', {
|
||||
type: 'boolean',
|
||||
description: 'Starts the agent in ACP mode',
|
||||
})
|
||||
.option('allowed-mcp-server-names', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
@@ -197,7 +210,11 @@ export async function parseArguments(): Promise<CliArgs> {
|
||||
type: 'string',
|
||||
description: 'OpenAI base URL (for custom endpoints)',
|
||||
})
|
||||
|
||||
.option('proxy', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Proxy for gemini client, like schema://user:password@host:port',
|
||||
})
|
||||
.version(await getCliVersion()) // This will enable the --version flag based on package.json
|
||||
.alias('v', 'version')
|
||||
.help()
|
||||
@@ -223,13 +240,16 @@ export async function loadHierarchicalGeminiMemory(
|
||||
currentWorkingDirectory: string,
|
||||
debugMode: boolean,
|
||||
fileService: FileDiscoveryService,
|
||||
settings: Settings,
|
||||
extensionContextFilePaths: string[] = [],
|
||||
fileFilteringOptions?: FileFilteringOptions,
|
||||
): Promise<{ memoryContent: string; fileCount: number }> {
|
||||
if (debugMode) {
|
||||
logger.debug(
|
||||
`CLI: Delegating hierarchical memory load to server for CWD: ${currentWorkingDirectory}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Directly call the server function.
|
||||
// The server function will use its own homedir() for the global path.
|
||||
return loadServerHierarchicalMemory(
|
||||
@@ -237,6 +257,8 @@ export async function loadHierarchicalGeminiMemory(
|
||||
debugMode,
|
||||
fileService,
|
||||
extensionContextFilePaths,
|
||||
fileFilteringOptions,
|
||||
settings.memoryDiscoveryMaxDirs,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -257,11 +279,19 @@ export async function loadCliConfig(
|
||||
process.env.TERM_PROGRAM === 'vscode' &&
|
||||
!process.env.SANDBOX;
|
||||
|
||||
const activeExtensions = filterActiveExtensions(
|
||||
let ideClient: IdeClient | undefined;
|
||||
if (ideMode) {
|
||||
ideClient = new IdeClient();
|
||||
}
|
||||
|
||||
const allExtensions = annotateActiveExtensions(
|
||||
extensions,
|
||||
argv.extensions || [],
|
||||
);
|
||||
|
||||
const activeExtensions = extensions.filter(
|
||||
(_, i) => allExtensions[i].isActive,
|
||||
);
|
||||
// Handle OpenAI API key from command line
|
||||
if (argv.openaiApiKey) {
|
||||
process.env.OPENAI_API_KEY = argv.openaiApiKey;
|
||||
@@ -288,46 +318,72 @@ export async function loadCliConfig(
|
||||
);
|
||||
|
||||
const fileService = new FileDiscoveryService(process.cwd());
|
||||
|
||||
const fileFiltering = {
|
||||
...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
...settings.fileFiltering,
|
||||
};
|
||||
|
||||
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
|
||||
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
|
||||
process.cwd(),
|
||||
debugMode,
|
||||
fileService,
|
||||
settings,
|
||||
extensionContextFilePaths,
|
||||
fileFiltering,
|
||||
);
|
||||
|
||||
let mcpServers = mergeMcpServers(settings, activeExtensions);
|
||||
const excludeTools = mergeExcludeTools(settings, activeExtensions);
|
||||
const blockedMcpServers: Array<{ name: string; extensionName: string }> = [];
|
||||
|
||||
if (!argv.allowedMcpServerNames) {
|
||||
if (settings.allowMCPServers) {
|
||||
const allowedNames = new Set(settings.allowMCPServers.filter(Boolean));
|
||||
if (allowedNames.size > 0) {
|
||||
mcpServers = Object.fromEntries(
|
||||
Object.entries(mcpServers).filter(([key]) => allowedNames.has(key)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.excludeMCPServers) {
|
||||
const excludedNames = new Set(settings.excludeMCPServers.filter(Boolean));
|
||||
if (excludedNames.size > 0) {
|
||||
mcpServers = Object.fromEntries(
|
||||
Object.entries(mcpServers).filter(([key]) => !excludedNames.has(key)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (argv.allowedMcpServerNames) {
|
||||
const allowedNames = new Set(argv.allowedMcpServerNames.filter(Boolean));
|
||||
if (allowedNames.size > 0) {
|
||||
mcpServers = Object.fromEntries(
|
||||
Object.entries(mcpServers).filter(([key]) => allowedNames.has(key)),
|
||||
Object.entries(mcpServers).filter(([key, server]) => {
|
||||
const isAllowed = allowedNames.has(key);
|
||||
if (!isAllowed) {
|
||||
blockedMcpServers.push({
|
||||
name: key,
|
||||
extensionName: server.extensionName || '',
|
||||
});
|
||||
}
|
||||
return isAllowed;
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
blockedMcpServers.push(
|
||||
...Object.entries(mcpServers).map(([key, server]) => ({
|
||||
name: key,
|
||||
extensionName: server.extensionName || '',
|
||||
})),
|
||||
);
|
||||
mcpServers = {};
|
||||
}
|
||||
}
|
||||
|
||||
if (ideMode) {
|
||||
mcpServers['_ide_server'] = new MCPServerConfig(
|
||||
undefined, // command
|
||||
undefined, // args
|
||||
undefined, // env
|
||||
undefined, // cwd
|
||||
undefined, // url
|
||||
'http://localhost:3000/mcp', // httpUrl
|
||||
undefined, // headers
|
||||
undefined, // tcp
|
||||
undefined, // timeout
|
||||
false, // trust
|
||||
'IDE connection', // description
|
||||
undefined, // includeTools
|
||||
undefined, // excludeTools
|
||||
);
|
||||
}
|
||||
|
||||
const sandboxConfig = await loadSandboxConfig(settings, argv);
|
||||
|
||||
return new Config({
|
||||
@@ -362,16 +418,19 @@ export async function loadCliConfig(
|
||||
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ??
|
||||
settings.telemetry?.otlpEndpoint,
|
||||
logPrompts: argv.telemetryLogPrompts ?? settings.telemetry?.logPrompts,
|
||||
outfile: argv.telemetryOutfile ?? settings.telemetry?.outfile,
|
||||
},
|
||||
usageStatisticsEnabled: settings.usageStatisticsEnabled ?? true,
|
||||
// Git-aware file filtering settings
|
||||
fileFiltering: {
|
||||
respectGitIgnore: settings.fileFiltering?.respectGitIgnore,
|
||||
respectGeminiIgnore: settings.fileFiltering?.respectGeminiIgnore,
|
||||
enableRecursiveFileSearch:
|
||||
settings.fileFiltering?.enableRecursiveFileSearch,
|
||||
},
|
||||
checkpointing: argv.checkpointing || settings.checkpointing?.enabled,
|
||||
proxy:
|
||||
argv.proxy ||
|
||||
process.env.HTTPS_PROXY ||
|
||||
process.env.https_proxy ||
|
||||
process.env.HTTP_PROXY ||
|
||||
@@ -382,15 +441,14 @@ export async function loadCliConfig(
|
||||
model: argv.model!,
|
||||
extensionContextFilePaths,
|
||||
maxSessionTurns: settings.maxSessionTurns ?? -1,
|
||||
sessionTokenLimit: settings.sessionTokenLimit ?? 32000,
|
||||
maxFolderItems: settings.maxFolderItems ?? 20,
|
||||
experimentalAcp: argv.experimentalAcp || false,
|
||||
listExtensions: argv.listExtensions || false,
|
||||
activeExtensions: activeExtensions.map((e) => ({
|
||||
name: e.config.name,
|
||||
version: e.config.version,
|
||||
})),
|
||||
extensions: allExtensions,
|
||||
blockedMcpServers,
|
||||
noBrowser: !!process.env.NO_BROWSER,
|
||||
summarizeToolOutput: settings.summarizeToolOutput,
|
||||
ideMode,
|
||||
ideClient,
|
||||
enableOpenAILogging:
|
||||
(typeof argv.openaiLogging === 'undefined'
|
||||
? settings.enableOpenAILogging
|
||||
@@ -421,7 +479,10 @@ function mergeMcpServers(settings: Settings, extensions: Extension[]) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
mcpServers[key] = server;
|
||||
mcpServers[key] = {
|
||||
...server,
|
||||
extensionName: extension.config.name,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import * as path from 'path';
|
||||
import {
|
||||
EXTENSIONS_CONFIG_FILENAME,
|
||||
EXTENSIONS_DIRECTORY_NAME,
|
||||
filterActiveExtensions,
|
||||
annotateActiveExtensions,
|
||||
loadExtensions,
|
||||
} from './extension.js';
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('loadExtensions', () => {
|
||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should load context file path when GEMINI.md is present', () => {
|
||||
it('should load context file path when QWEN.md is present', () => {
|
||||
const workspaceExtensionsDir = path.join(
|
||||
tempWorkspaceDir,
|
||||
EXTENSIONS_DIRECTORY_NAME,
|
||||
@@ -86,42 +86,52 @@ describe('loadExtensions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterActiveExtensions', () => {
|
||||
describe('annotateActiveExtensions', () => {
|
||||
const extensions = [
|
||||
{ config: { name: 'ext1', version: '1.0.0' }, contextFiles: [] },
|
||||
{ config: { name: 'ext2', version: '1.0.0' }, contextFiles: [] },
|
||||
{ config: { name: 'ext3', version: '1.0.0' }, contextFiles: [] },
|
||||
];
|
||||
|
||||
it('should return all extensions if no enabled extensions are provided', () => {
|
||||
const activeExtensions = filterActiveExtensions(extensions, []);
|
||||
it('should mark all extensions as active if no enabled extensions are provided', () => {
|
||||
const activeExtensions = annotateActiveExtensions(extensions, []);
|
||||
expect(activeExtensions).toHaveLength(3);
|
||||
expect(activeExtensions.every((e) => e.isActive)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return only the enabled extensions', () => {
|
||||
const activeExtensions = filterActiveExtensions(extensions, [
|
||||
it('should mark only the enabled extensions as active', () => {
|
||||
const activeExtensions = annotateActiveExtensions(extensions, [
|
||||
'ext1',
|
||||
'ext3',
|
||||
]);
|
||||
expect(activeExtensions).toHaveLength(2);
|
||||
expect(activeExtensions.some((e) => e.config.name === 'ext1')).toBe(true);
|
||||
expect(activeExtensions.some((e) => e.config.name === 'ext3')).toBe(true);
|
||||
expect(activeExtensions).toHaveLength(3);
|
||||
expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe(
|
||||
true,
|
||||
);
|
||||
expect(activeExtensions.find((e) => e.name === 'ext2')?.isActive).toBe(
|
||||
false,
|
||||
);
|
||||
expect(activeExtensions.find((e) => e.name === 'ext3')?.isActive).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return no extensions when "none" is provided', () => {
|
||||
const activeExtensions = filterActiveExtensions(extensions, ['none']);
|
||||
expect(activeExtensions).toHaveLength(0);
|
||||
it('should mark all extensions as inactive when "none" is provided', () => {
|
||||
const activeExtensions = annotateActiveExtensions(extensions, ['none']);
|
||||
expect(activeExtensions).toHaveLength(3);
|
||||
expect(activeExtensions.every((e) => !e.isActive)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle case-insensitivity', () => {
|
||||
const activeExtensions = filterActiveExtensions(extensions, ['EXT1']);
|
||||
expect(activeExtensions).toHaveLength(1);
|
||||
expect(activeExtensions[0].config.name).toBe('ext1');
|
||||
const activeExtensions = annotateActiveExtensions(extensions, ['EXT1']);
|
||||
expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should log an error for unknown extensions', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
filterActiveExtensions(extensions, ['ext4']);
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
annotateActiveExtensions(extensions, ['ext4']);
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { MCPServerConfig } from '@qwen-code/qwen-code-core';
|
||||
import { MCPServerConfig, GeminiCLIExtension } from '@qwen-code/qwen-code-core';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
@@ -34,9 +34,6 @@ export function loadExtensions(workspaceDir: string): Extension[] {
|
||||
const uniqueExtensions = new Map<string, Extension>();
|
||||
for (const extension of allExtensions) {
|
||||
if (!uniqueExtensions.has(extension.config.name)) {
|
||||
console.log(
|
||||
`Loading extension: ${extension.config.name} (version: ${extension.config.version})`,
|
||||
);
|
||||
uniqueExtensions.set(extension.config.name, extension);
|
||||
}
|
||||
}
|
||||
@@ -113,12 +110,18 @@ function getContextFileNames(config: ExtensionConfig): string[] {
|
||||
return config.contextFileName;
|
||||
}
|
||||
|
||||
export function filterActiveExtensions(
|
||||
export function annotateActiveExtensions(
|
||||
extensions: Extension[],
|
||||
enabledExtensionNames: string[],
|
||||
): Extension[] {
|
||||
): GeminiCLIExtension[] {
|
||||
const annotatedExtensions: GeminiCLIExtension[] = [];
|
||||
|
||||
if (enabledExtensionNames.length === 0) {
|
||||
return extensions;
|
||||
return extensions.map((extension) => ({
|
||||
name: extension.config.name,
|
||||
version: extension.config.version,
|
||||
isActive: true,
|
||||
}));
|
||||
}
|
||||
|
||||
const lowerCaseEnabledExtensions = new Set(
|
||||
@@ -129,31 +132,33 @@ export function filterActiveExtensions(
|
||||
lowerCaseEnabledExtensions.size === 1 &&
|
||||
lowerCaseEnabledExtensions.has('none')
|
||||
) {
|
||||
if (extensions.length > 0) {
|
||||
console.log('All extensions are disabled.');
|
||||
}
|
||||
return [];
|
||||
return extensions.map((extension) => ({
|
||||
name: extension.config.name,
|
||||
version: extension.config.version,
|
||||
isActive: false,
|
||||
}));
|
||||
}
|
||||
|
||||
const activeExtensions: Extension[] = [];
|
||||
const notFoundNames = new Set(lowerCaseEnabledExtensions);
|
||||
|
||||
for (const extension of extensions) {
|
||||
const lowerCaseName = extension.config.name.toLowerCase();
|
||||
if (lowerCaseEnabledExtensions.has(lowerCaseName)) {
|
||||
console.log(
|
||||
`Activated extension: ${extension.config.name} (version: ${extension.config.version})`,
|
||||
);
|
||||
activeExtensions.push(extension);
|
||||
const isActive = lowerCaseEnabledExtensions.has(lowerCaseName);
|
||||
|
||||
if (isActive) {
|
||||
notFoundNames.delete(lowerCaseName);
|
||||
} else {
|
||||
console.log(`Disabled extension: ${extension.config.name}`);
|
||||
}
|
||||
|
||||
annotatedExtensions.push({
|
||||
name: extension.config.name,
|
||||
version: extension.config.version,
|
||||
isActive,
|
||||
});
|
||||
}
|
||||
|
||||
for (const requestedName of notFoundNames) {
|
||||
console.log(`Extension not found: ${requestedName}`);
|
||||
console.error(`Extension not found: ${requestedName}`);
|
||||
}
|
||||
|
||||
return activeExtensions;
|
||||
return annotatedExtensions;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ import stripJsonComments from 'strip-json-comments'; // Will be mocked separatel
|
||||
import {
|
||||
loadSettings,
|
||||
USER_SETTINGS_PATH, // This IS the mocked path.
|
||||
SYSTEM_SETTINGS_PATH,
|
||||
getSystemSettingsPath,
|
||||
SETTINGS_DIRECTORY_NAME, // This is from the original module, but used by the mock.
|
||||
SettingScope,
|
||||
} from './settings.js';
|
||||
@@ -95,13 +95,16 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(settings.system.settings).toEqual({});
|
||||
expect(settings.user.settings).toEqual({});
|
||||
expect(settings.workspace.settings).toEqual({});
|
||||
expect(settings.merged).toEqual({});
|
||||
expect(settings.merged).toEqual({
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
});
|
||||
expect(settings.errors.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should load system settings if only system file exists', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === SYSTEM_SETTINGS_PATH,
|
||||
(p: fs.PathLike) => p === getSystemSettingsPath(),
|
||||
);
|
||||
const systemSettingsContent = {
|
||||
theme: 'system-default',
|
||||
@@ -109,7 +112,7 @@ describe('Settings Loading and Merging', () => {
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === SYSTEM_SETTINGS_PATH)
|
||||
if (p === getSystemSettingsPath())
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
@@ -118,13 +121,17 @@ describe('Settings Loading and Merging', () => {
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(
|
||||
SYSTEM_SETTINGS_PATH,
|
||||
getSystemSettingsPath(),
|
||||
'utf-8',
|
||||
);
|
||||
expect(settings.system.settings).toEqual(systemSettingsContent);
|
||||
expect(settings.user.settings).toEqual({});
|
||||
expect(settings.workspace.settings).toEqual({});
|
||||
expect(settings.merged).toEqual(systemSettingsContent);
|
||||
expect(settings.merged).toEqual({
|
||||
...systemSettingsContent,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should load user settings if only user file exists', () => {
|
||||
@@ -153,7 +160,11 @@ describe('Settings Loading and Merging', () => {
|
||||
);
|
||||
expect(settings.user.settings).toEqual(userSettingsContent);
|
||||
expect(settings.workspace.settings).toEqual({});
|
||||
expect(settings.merged).toEqual(userSettingsContent);
|
||||
expect(settings.merged).toEqual({
|
||||
...userSettingsContent,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should load workspace settings if only workspace file exists', () => {
|
||||
@@ -180,7 +191,11 @@ describe('Settings Loading and Merging', () => {
|
||||
);
|
||||
expect(settings.user.settings).toEqual({});
|
||||
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
|
||||
expect(settings.merged).toEqual(workspaceSettingsContent);
|
||||
expect(settings.merged).toEqual({
|
||||
...workspaceSettingsContent,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should merge user and workspace settings, with workspace taking precedence', () => {
|
||||
@@ -215,6 +230,8 @@ describe('Settings Loading and Merging', () => {
|
||||
sandbox: true,
|
||||
coreTools: ['tool1'],
|
||||
contextFileName: 'WORKSPACE_CONTEXT.md',
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -223,6 +240,7 @@ describe('Settings Loading and Merging', () => {
|
||||
const systemSettingsContent = {
|
||||
theme: 'system-theme',
|
||||
sandbox: false,
|
||||
allowMCPServers: ['server1', 'server2'],
|
||||
telemetry: { enabled: false },
|
||||
};
|
||||
const userSettingsContent = {
|
||||
@@ -234,11 +252,12 @@ describe('Settings Loading and Merging', () => {
|
||||
sandbox: false,
|
||||
coreTools: ['tool1'],
|
||||
contextFileName: 'WORKSPACE_CONTEXT.md',
|
||||
allowMCPServers: ['server1', 'server2', 'server3'],
|
||||
};
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === SYSTEM_SETTINGS_PATH)
|
||||
if (p === getSystemSettingsPath())
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
@@ -259,6 +278,9 @@ describe('Settings Loading and Merging', () => {
|
||||
telemetry: { enabled: false },
|
||||
coreTools: ['tool1'],
|
||||
contextFileName: 'WORKSPACE_CONTEXT.md',
|
||||
allowMCPServers: ['server1', 'server2'],
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -370,6 +392,134 @@ describe('Settings Loading and Merging', () => {
|
||||
(fs.readFileSync as Mock).mockReturnValue('{}');
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.telemetry).toBeUndefined();
|
||||
expect(settings.merged.customThemes).toEqual({});
|
||||
expect(settings.merged.mcpServers).toEqual({});
|
||||
});
|
||||
|
||||
it('should merge MCP servers correctly, with workspace taking precedence', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const userSettingsContent = {
|
||||
mcpServers: {
|
||||
'user-server': {
|
||||
command: 'user-command',
|
||||
args: ['--user-arg'],
|
||||
description: 'User MCP server',
|
||||
},
|
||||
'shared-server': {
|
||||
command: 'user-shared-command',
|
||||
description: 'User shared server config',
|
||||
},
|
||||
},
|
||||
};
|
||||
const workspaceSettingsContent = {
|
||||
mcpServers: {
|
||||
'workspace-server': {
|
||||
command: 'workspace-command',
|
||||
args: ['--workspace-arg'],
|
||||
description: 'Workspace MCP server',
|
||||
},
|
||||
'shared-server': {
|
||||
command: 'workspace-shared-command',
|
||||
description: 'Workspace shared server config',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(settings.user.settings).toEqual(userSettingsContent);
|
||||
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
|
||||
expect(settings.merged.mcpServers).toEqual({
|
||||
'user-server': {
|
||||
command: 'user-command',
|
||||
args: ['--user-arg'],
|
||||
description: 'User MCP server',
|
||||
},
|
||||
'workspace-server': {
|
||||
command: 'workspace-command',
|
||||
args: ['--workspace-arg'],
|
||||
description: 'Workspace MCP server',
|
||||
},
|
||||
'shared-server': {
|
||||
command: 'workspace-shared-command',
|
||||
description: 'Workspace shared server config',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle MCP servers when only in user settings', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const userSettingsContent = {
|
||||
mcpServers: {
|
||||
'user-only-server': {
|
||||
command: 'user-only-command',
|
||||
description: 'User only server',
|
||||
},
|
||||
},
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.mcpServers).toEqual({
|
||||
'user-only-server': {
|
||||
command: 'user-only-command',
|
||||
description: 'User only server',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle MCP servers when only in workspace settings', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
|
||||
);
|
||||
const workspaceSettingsContent = {
|
||||
mcpServers: {
|
||||
'workspace-only-server': {
|
||||
command: 'workspace-only-command',
|
||||
description: 'Workspace only server',
|
||||
},
|
||||
},
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
|
||||
return JSON.stringify(workspaceSettingsContent);
|
||||
return '';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.mcpServers).toEqual({
|
||||
'workspace-only-server': {
|
||||
command: 'workspace-only-command',
|
||||
description: 'Workspace only server',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should have mcpServers as empty object if not in any settings file', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist
|
||||
(fs.readFileSync as Mock).mockReturnValue('{}');
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.mcpServers).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle JSON parsing errors gracefully', () => {
|
||||
@@ -407,7 +557,10 @@ describe('Settings Loading and Merging', () => {
|
||||
// Check that settings are empty due to parsing errors
|
||||
expect(settings.user.settings).toEqual({});
|
||||
expect(settings.workspace.settings).toEqual({});
|
||||
expect(settings.merged).toEqual({});
|
||||
expect(settings.merged).toEqual({
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
});
|
||||
|
||||
// Check that error objects are populated in settings.errors
|
||||
expect(settings.errors).toBeDefined();
|
||||
@@ -448,10 +601,13 @@ describe('Settings Loading and Merging', () => {
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
// @ts-expect-error: dynamic property for test
|
||||
expect(settings.user.settings.apiKey).toBe('user_api_key_from_env');
|
||||
// @ts-expect-error: dynamic property for test
|
||||
expect(settings.user.settings.someUrl).toBe(
|
||||
'https://test.com/user_api_key_from_env',
|
||||
);
|
||||
// @ts-expect-error: dynamic property for test
|
||||
expect(settings.merged.apiKey).toBe('user_api_key_from_env');
|
||||
delete process.env.TEST_API_KEY;
|
||||
});
|
||||
@@ -480,6 +636,7 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(settings.workspace.settings.nested.value).toBe(
|
||||
'workspace_endpoint_from_env',
|
||||
);
|
||||
// @ts-expect-error: dynamic property for test
|
||||
expect(settings.merged.endpoint).toBe('workspace_endpoint_from_env/api');
|
||||
delete process.env.WORKSPACE_ENDPOINT;
|
||||
});
|
||||
@@ -509,13 +666,16 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// @ts-expect-error: dynamic property for test
|
||||
expect(settings.user.settings.configValue).toBe(
|
||||
'user_value_for_user_read',
|
||||
);
|
||||
// @ts-expect-error: dynamic property for test
|
||||
expect(settings.workspace.settings.configValue).toBe(
|
||||
'workspace_value_for_workspace_read',
|
||||
);
|
||||
// Merged should take workspace's resolved value
|
||||
// @ts-expect-error: dynamic property for test
|
||||
expect(settings.merged.configValue).toBe(
|
||||
'workspace_value_for_workspace_read',
|
||||
);
|
||||
@@ -583,7 +743,7 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === SYSTEM_SETTINGS_PATH) {
|
||||
if (p === getSystemSettingsPath()) {
|
||||
process.env.SHARED_VAR = 'system_value_for_system_read'; // Set for system settings read
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
}
|
||||
@@ -597,13 +757,16 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// @ts-expect-error: dynamic property for test
|
||||
expect(settings.system.settings.configValue).toBe(
|
||||
'system_value_for_system_read',
|
||||
);
|
||||
// @ts-expect-error: dynamic property for test
|
||||
expect(settings.workspace.settings.configValue).toBe(
|
||||
'workspace_value_for_workspace_read',
|
||||
);
|
||||
// Merged should take workspace's resolved value
|
||||
// Merged should take system's resolved value
|
||||
// @ts-expect-error: dynamic property for test
|
||||
expect(settings.merged.configValue).toBe('system_value_for_system_read');
|
||||
|
||||
// Restore original environment variable state
|
||||
@@ -750,6 +913,50 @@ describe('Settings Loading and Merging', () => {
|
||||
delete process.env.TEST_HOST;
|
||||
delete process.env.TEST_PORT;
|
||||
});
|
||||
|
||||
describe('when GEMINI_CLI_SYSTEM_SETTINGS_PATH is set', () => {
|
||||
const MOCK_ENV_SYSTEM_SETTINGS_PATH = '/mock/env/system/settings.json';
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH =
|
||||
MOCK_ENV_SYSTEM_SETTINGS_PATH;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH;
|
||||
});
|
||||
|
||||
it('should load system settings from the path specified in the environment variable', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === MOCK_ENV_SYSTEM_SETTINGS_PATH,
|
||||
);
|
||||
const systemSettingsContent = {
|
||||
theme: 'env-var-theme',
|
||||
sandbox: true,
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === MOCK_ENV_SYSTEM_SETTINGS_PATH)
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(
|
||||
MOCK_ENV_SYSTEM_SETTINGS_PATH,
|
||||
'utf-8',
|
||||
);
|
||||
expect(settings.system.path).toBe(MOCK_ENV_SYSTEM_SETTINGS_PATH);
|
||||
expect(settings.system.settings).toEqual(systemSettingsContent);
|
||||
expect(settings.merged).toEqual({
|
||||
...systemSettingsContent,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('LoadedSettings class', () => {
|
||||
|
||||
@@ -19,12 +19,16 @@ import {
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
import { DefaultLight } from '../ui/themes/default-light.js';
|
||||
import { DefaultDark } from '../ui/themes/default.js';
|
||||
import { CustomTheme } from '../ui/themes/theme.js';
|
||||
|
||||
export const SETTINGS_DIRECTORY_NAME = '.qwen';
|
||||
export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
|
||||
export const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json');
|
||||
|
||||
function getSystemSettingsPath(): string {
|
||||
export function getSystemSettingsPath(): string {
|
||||
if (process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH) {
|
||||
return process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH;
|
||||
}
|
||||
if (platform() === 'darwin') {
|
||||
return '/Library/Application Support/QwenCode/settings.json';
|
||||
} else if (platform() === 'win32') {
|
||||
@@ -34,8 +38,6 @@ function getSystemSettingsPath(): string {
|
||||
}
|
||||
}
|
||||
|
||||
export const SYSTEM_SETTINGS_PATH = getSystemSettingsPath();
|
||||
|
||||
export enum SettingScope {
|
||||
User = 'User',
|
||||
Workspace = 'Workspace',
|
||||
@@ -46,12 +48,17 @@ export interface CheckpointingSettings {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SummarizeToolOutputSettings {
|
||||
tokenBudget?: number;
|
||||
}
|
||||
|
||||
export interface AccessibilitySettings {
|
||||
disableLoadingPhrases?: boolean;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
theme?: string;
|
||||
customThemes?: Record<string, CustomTheme>;
|
||||
selectedAuthType?: AuthType;
|
||||
sandbox?: boolean | string;
|
||||
coreTools?: string[];
|
||||
@@ -60,6 +67,8 @@ export interface Settings {
|
||||
toolCallCommand?: string;
|
||||
mcpServerCommand?: string;
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
allowMCPServers?: string[];
|
||||
excludeMCPServers?: string[];
|
||||
showMemoryUsage?: boolean;
|
||||
contextFileName?: string | string[];
|
||||
accessibility?: AccessibilitySettings;
|
||||
@@ -74,43 +83,32 @@ export interface Settings {
|
||||
// Git-aware file filtering settings
|
||||
fileFiltering?: {
|
||||
respectGitIgnore?: boolean;
|
||||
respectGeminiIgnore?: boolean;
|
||||
enableRecursiveFileSearch?: boolean;
|
||||
};
|
||||
|
||||
// UI setting. Does not display the ANSI-controlled terminal title.
|
||||
hideWindowTitle?: boolean;
|
||||
|
||||
hideTips?: boolean;
|
||||
hideBanner?: boolean;
|
||||
|
||||
// Setting for setting maximum number of user/model/tool turns in a session.
|
||||
maxSessionTurns?: number;
|
||||
|
||||
// Setting for maximum token limit for conversation history before blocking requests
|
||||
sessionTokenLimit?: number;
|
||||
// A map of tool names to their summarization settings.
|
||||
summarizeToolOutput?: Record<string, SummarizeToolOutputSettings>;
|
||||
|
||||
// Setting for maximum number of files and folders to show in folder structure
|
||||
maxFolderItems?: number;
|
||||
|
||||
// Sampling parameters for content generation
|
||||
sampling_params?: {
|
||||
top_p?: number;
|
||||
top_k?: number;
|
||||
repetition_penalty?: number;
|
||||
presence_penalty?: number;
|
||||
frequency_penalty?: number;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
};
|
||||
|
||||
// System prompt mappings for different base URLs and model names
|
||||
systemPromptMappings?: Array<{
|
||||
baseUrls?: string[];
|
||||
modelNames?: string[];
|
||||
template?: string;
|
||||
}>;
|
||||
vimMode?: boolean;
|
||||
|
||||
// Add other settings here.
|
||||
ideMode?: boolean;
|
||||
memoryDiscoveryMaxDirs?: number;
|
||||
sampling_params?: Record<string, unknown>;
|
||||
systemPromptMappings?: Array<{
|
||||
baseUrls: string[];
|
||||
modelNames: string[];
|
||||
template: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SettingsError {
|
||||
@@ -148,10 +146,24 @@ export class LoadedSettings {
|
||||
}
|
||||
|
||||
private computeMergedSettings(): Settings {
|
||||
const system = this.system.settings;
|
||||
const user = this.user.settings;
|
||||
const workspace = this.workspace.settings;
|
||||
|
||||
return {
|
||||
...this.user.settings,
|
||||
...this.workspace.settings,
|
||||
...this.system.settings,
|
||||
...user,
|
||||
...workspace,
|
||||
...system,
|
||||
customThemes: {
|
||||
...(user.customThemes || {}),
|
||||
...(workspace.customThemes || {}),
|
||||
...(system.customThemes || {}),
|
||||
},
|
||||
mcpServers: {
|
||||
...(user.mcpServers || {}),
|
||||
...(workspace.mcpServers || {}),
|
||||
...(system.mcpServers || {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -168,13 +180,12 @@ export class LoadedSettings {
|
||||
}
|
||||
}
|
||||
|
||||
setValue(
|
||||
setValue<K extends keyof Settings>(
|
||||
scope: SettingScope,
|
||||
key: keyof Settings,
|
||||
value: string | Record<string, MCPServerConfig> | undefined,
|
||||
key: K,
|
||||
value: Settings[K],
|
||||
): void {
|
||||
const settingsFile = this.forScope(scope);
|
||||
// @ts-expect-error - value can be string | Record<string, MCPServerConfig>
|
||||
settingsFile.settings[key] = value;
|
||||
this._merged = this.computeMergedSettings();
|
||||
saveSettings(settingsFile);
|
||||
@@ -296,11 +307,11 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
|
||||
let userSettings: Settings = {};
|
||||
let workspaceSettings: Settings = {};
|
||||
const settingsErrors: SettingsError[] = [];
|
||||
|
||||
const systemSettingsPath = getSystemSettingsPath();
|
||||
// Load system settings
|
||||
try {
|
||||
if (fs.existsSync(SYSTEM_SETTINGS_PATH)) {
|
||||
const systemContent = fs.readFileSync(SYSTEM_SETTINGS_PATH, 'utf-8');
|
||||
if (fs.existsSync(systemSettingsPath)) {
|
||||
const systemContent = fs.readFileSync(systemSettingsPath, 'utf-8');
|
||||
const parsedSystemSettings = JSON.parse(
|
||||
stripJsonComments(systemContent),
|
||||
) as Settings;
|
||||
@@ -309,7 +320,7 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
|
||||
} catch (error: unknown) {
|
||||
settingsErrors.push({
|
||||
message: getErrorMessage(error),
|
||||
path: SYSTEM_SETTINGS_PATH,
|
||||
path: systemSettingsPath,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -367,7 +378,7 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
|
||||
|
||||
return new LoadedSettings(
|
||||
{
|
||||
path: SYSTEM_SETTINGS_PATH,
|
||||
path: systemSettingsPath,
|
||||
settings: systemSettings,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { main } from './gemini.js';
|
||||
import { main, setupUnhandledRejectionHandler } from './gemini.js';
|
||||
import {
|
||||
LoadedSettings,
|
||||
SettingsFile,
|
||||
loadSettings,
|
||||
} from './config/settings.js';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
|
||||
// Custom error to identify mock process.exit calls
|
||||
class MockProcessExitError extends Error {
|
||||
@@ -55,6 +56,16 @@ vi.mock('update-notifier', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('./utils/events.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./utils/events.js')>();
|
||||
return {
|
||||
...actual,
|
||||
appEvents: {
|
||||
emit: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./utils/sandbox.js', () => ({
|
||||
sandbox_command: vi.fn(() => ''), // Default to no sandbox command
|
||||
start_sandbox: vi.fn(() => Promise.resolve()), // Mock as an async function that resolves
|
||||
@@ -65,6 +76,8 @@ describe('gemini.tsx main function', () => {
|
||||
let loadSettingsMock: ReturnType<typeof vi.mocked<typeof loadSettings>>;
|
||||
let originalEnvGeminiSandbox: string | undefined;
|
||||
let originalEnvSandbox: string | undefined;
|
||||
let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =
|
||||
[];
|
||||
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
@@ -82,6 +95,8 @@ describe('gemini.tsx main function', () => {
|
||||
delete process.env.SANDBOX;
|
||||
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
initialUnhandledRejectionListeners =
|
||||
process.listeners('unhandledRejection');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -96,6 +111,15 @@ describe('gemini.tsx main function', () => {
|
||||
} else {
|
||||
delete process.env.SANDBOX;
|
||||
}
|
||||
|
||||
const currentListeners = process.listeners('unhandledRejection');
|
||||
const addedListener = currentListeners.find(
|
||||
(listener) => !initialUnhandledRejectionListeners.includes(listener),
|
||||
);
|
||||
|
||||
if (addedListener) {
|
||||
process.removeListener('unhandledRejection', addedListener);
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -109,7 +133,7 @@ describe('gemini.tsx main function', () => {
|
||||
settings: {},
|
||||
};
|
||||
const workspaceSettingsFile: SettingsFile = {
|
||||
path: '/workspace/.qwen/settings.json',
|
||||
path: '/workspace/.gemini/settings.json',
|
||||
settings: {},
|
||||
};
|
||||
const systemSettingsFile: SettingsFile = {
|
||||
@@ -145,7 +169,45 @@ describe('gemini.tsx main function', () => {
|
||||
'Please fix /test/settings.json and try again.',
|
||||
);
|
||||
|
||||
// Verify process.exit was called (indirectly, via the thrown error)
|
||||
// Verify process.exit was called.
|
||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should log unhandled promise rejections and open debug console on first error', async () => {
|
||||
const appEventsMock = vi.mocked(appEvents);
|
||||
const rejectionError = new Error('Test unhandled rejection');
|
||||
|
||||
setupUnhandledRejectionHandler();
|
||||
// Simulate an unhandled rejection.
|
||||
// We are not using Promise.reject here as vitest will catch it.
|
||||
// Instead we will dispatch the event manually.
|
||||
process.emit('unhandledRejection', rejectionError, Promise.resolve());
|
||||
|
||||
// We need to wait for the rejection handler to be called.
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(appEventsMock.emit).toHaveBeenCalledWith(AppEvent.OpenDebugConsole);
|
||||
expect(appEventsMock.emit).toHaveBeenCalledWith(
|
||||
AppEvent.LogError,
|
||||
expect.stringContaining('Unhandled Promise Rejection'),
|
||||
);
|
||||
expect(appEventsMock.emit).toHaveBeenCalledWith(
|
||||
AppEvent.LogError,
|
||||
expect.stringContaining('Please file a bug report using the /bug tool.'),
|
||||
);
|
||||
|
||||
// Simulate a second rejection
|
||||
const secondRejectionError = new Error('Second test unhandled rejection');
|
||||
process.emit('unhandledRejection', secondRejectionError, Promise.resolve());
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
// Ensure emit was only called once for OpenDebugConsole
|
||||
const openDebugConsoleCalls = appEventsMock.emit.mock.calls.filter(
|
||||
(call) => call[0] === AppEvent.OpenDebugConsole,
|
||||
);
|
||||
expect(openDebugConsoleCalls.length).toBe(1);
|
||||
|
||||
// Avoid the process.exit error from being thrown.
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,6 @@ import { start_sandbox } from './utils/sandbox.js';
|
||||
import {
|
||||
LoadedSettings,
|
||||
loadSettings,
|
||||
USER_SETTINGS_PATH,
|
||||
SettingScope,
|
||||
} from './config/settings.js';
|
||||
import { themeManager } from './ui/themes/theme-manager.js';
|
||||
@@ -40,6 +39,8 @@ import {
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { validateAuthMethod } from './config/auth.js';
|
||||
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
|
||||
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
|
||||
function getNodeMemoryArgs(config: Config): string[] {
|
||||
const totalMemoryMB = os.totalmem() / (1024 * 1024);
|
||||
@@ -84,8 +85,32 @@ async function relaunchWithAdditionalArgs(additionalArgs: string[]) {
|
||||
await new Promise((resolve) => child.on('close', resolve));
|
||||
process.exit(0);
|
||||
}
|
||||
import { runAcpPeer } from './acp/acpPeer.js';
|
||||
|
||||
export function setupUnhandledRejectionHandler() {
|
||||
let unhandledRejectionOccurred = false;
|
||||
process.on('unhandledRejection', (reason, _promise) => {
|
||||
const errorMessage = `=========================================
|
||||
This is an unexpected error. Please file a bug report using the /bug tool.
|
||||
CRITICAL: Unhandled Promise Rejection!
|
||||
=========================================
|
||||
Reason: ${reason}${
|
||||
reason instanceof Error && reason.stack
|
||||
? `
|
||||
Stack trace:
|
||||
${reason.stack}`
|
||||
: ''
|
||||
}`;
|
||||
appEvents.emit(AppEvent.LogError, errorMessage);
|
||||
if (!unhandledRejectionOccurred) {
|
||||
unhandledRejectionOccurred = true;
|
||||
appEvents.emit(AppEvent.OpenDebugConsole);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
setupUnhandledRejectionHandler();
|
||||
const workspaceRoot = process.cwd();
|
||||
const settings = loadSettings(workspaceRoot);
|
||||
|
||||
@@ -141,6 +166,9 @@ export async function main() {
|
||||
|
||||
await config.initialize();
|
||||
|
||||
// Load custom themes from settings
|
||||
themeManager.loadCustomThemes(settings.merged.customThemes);
|
||||
|
||||
if (settings.merged.theme) {
|
||||
if (!themeManager.setActiveTheme(settings.merged.theme)) {
|
||||
// If the theme is not found during initial load, log a warning and continue.
|
||||
@@ -183,12 +211,16 @@ export async function main() {
|
||||
|
||||
if (
|
||||
settings.merged.selectedAuthType === AuthType.LOGIN_WITH_GOOGLE &&
|
||||
config.getNoBrowser()
|
||||
config.isBrowserLaunchSuppressed()
|
||||
) {
|
||||
// Do oauth before app renders to make copying the link possible.
|
||||
await getOauthClient(settings.merged.selectedAuthType, config);
|
||||
}
|
||||
|
||||
if (config.getExperimentalAcp()) {
|
||||
return runAcpPeer(config, settings);
|
||||
}
|
||||
|
||||
let input = config.getQuestion();
|
||||
const startupWarnings = [
|
||||
...(await getStartupWarnings()),
|
||||
@@ -264,21 +296,6 @@ function setWindowTitle(title: string, settings: LoadedSettings) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Global Unhandled Rejection Handler ---
|
||||
process.on('unhandledRejection', (reason, _promise) => {
|
||||
// Log other unexpected unhandled rejections as critical errors
|
||||
console.error('=========================================');
|
||||
console.error('CRITICAL: Unhandled Promise Rejection!');
|
||||
console.error('=========================================');
|
||||
console.error('Reason:', reason);
|
||||
console.error('Stack trace may follow:');
|
||||
if (!(reason instanceof Error)) {
|
||||
console.error(reason);
|
||||
}
|
||||
// Exit for genuinely unhandled errors
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
async function loadNonInteractiveConfig(
|
||||
config: Config,
|
||||
extensions: Extension[],
|
||||
@@ -312,51 +329,8 @@ async function loadNonInteractiveConfig(
|
||||
await finalConfig.initialize();
|
||||
}
|
||||
|
||||
return await validateNonInterActiveAuth(
|
||||
return await validateNonInteractiveAuth(
|
||||
settings.merged.selectedAuthType,
|
||||
finalConfig,
|
||||
);
|
||||
}
|
||||
|
||||
async function validateNonInterActiveAuth(
|
||||
selectedAuthType: AuthType | undefined,
|
||||
nonInteractiveConfig: Config,
|
||||
) {
|
||||
// making a special case for the cli. many headless environments might not have a settings.json set
|
||||
// so if GEMINI_API_KEY or OPENAI_API_KEY is set, we'll use that. However since the oauth things are interactive anyway, we'll
|
||||
// still expect that exists
|
||||
if (
|
||||
!selectedAuthType &&
|
||||
!process.env.GEMINI_API_KEY &&
|
||||
!process.env.OPENAI_API_KEY
|
||||
) {
|
||||
console.error(
|
||||
`Please set an Auth method in your ${USER_SETTINGS_PATH} OR specify GEMINI_API_KEY or OPENAI_API_KEY env variable before running`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Determine auth type based on available environment variables
|
||||
if (!selectedAuthType) {
|
||||
if (process.env.OPENAI_API_KEY) {
|
||||
selectedAuthType = AuthType.USE_OPENAI;
|
||||
} else if (process.env.GEMINI_API_KEY) {
|
||||
selectedAuthType = AuthType.USE_GEMINI;
|
||||
}
|
||||
}
|
||||
|
||||
// This should never happen due to the check above, but TypeScript needs assurance
|
||||
if (!selectedAuthType) {
|
||||
console.error('No valid authentication method found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const err = validateAuthMethod(selectedAuthType);
|
||||
if (err != null) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await nonInteractiveConfig.refreshAuth(selectedAuthType);
|
||||
return nonInteractiveConfig;
|
||||
}
|
||||
|
||||
@@ -229,14 +229,14 @@ describe('runNonInteractive', () => {
|
||||
it('should not exit if a tool is not found, and should send error back to model', async () => {
|
||||
const functionCall: FunctionCall = {
|
||||
id: 'fcNotFound',
|
||||
name: 'nonExistentTool',
|
||||
name: 'nonexistentTool',
|
||||
args: {},
|
||||
};
|
||||
const errorResponsePart: Part = {
|
||||
functionResponse: {
|
||||
name: 'nonExistentTool',
|
||||
name: 'nonexistentTool',
|
||||
id: 'fcNotFound',
|
||||
response: { error: 'Tool "nonExistentTool" not found in registry.' },
|
||||
response: { error: 'Tool "nonexistentTool" not found in registry.' },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -246,8 +246,8 @@ describe('runNonInteractive', () => {
|
||||
vi.mocked(mockCoreExecuteToolCall).mockResolvedValue({
|
||||
callId: 'fcNotFound',
|
||||
responseParts: [errorResponsePart],
|
||||
resultDisplay: 'Tool "nonExistentTool" not found in registry.',
|
||||
error: new Error('Tool "nonExistentTool" not found in registry.'),
|
||||
resultDisplay: 'Tool "nonexistentTool" not found in registry.',
|
||||
error: new Error('Tool "nonexistentTool" not found in registry.'),
|
||||
});
|
||||
|
||||
const stream1 = (async function* () {
|
||||
@@ -278,7 +278,7 @@ describe('runNonInteractive', () => {
|
||||
);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error executing tool nonExistentTool: Tool "nonExistentTool" not found in registry.',
|
||||
'Error executing tool nonexistentTool: Tool "nonexistentTool" not found in registry.',
|
||||
);
|
||||
|
||||
expect(mockProcessExit).not.toHaveBeenCalled();
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
ToolRegistry,
|
||||
shutdownTelemetry,
|
||||
isTelemetrySdkInitialized,
|
||||
ToolResultDisplay,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
Content,
|
||||
@@ -44,83 +43,6 @@ function getResponseText(response: GenerateContentResponse): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper function to format tool call arguments for display
|
||||
function formatToolArgs(args: Record<string, unknown>): string {
|
||||
if (!args || Object.keys(args).length === 0) {
|
||||
return '(no arguments)';
|
||||
}
|
||||
|
||||
const formattedArgs = Object.entries(args)
|
||||
.map(([key, value]) => {
|
||||
if (typeof value === 'string') {
|
||||
return `${key}: "${value}"`;
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
return `${key}: ${JSON.stringify(value)}`;
|
||||
} else {
|
||||
return `${key}: ${value}`;
|
||||
}
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
return `(${formattedArgs})`;
|
||||
}
|
||||
// Helper function to display tool call information
|
||||
function displayToolCallInfo(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
status: 'start' | 'success' | 'error',
|
||||
resultDisplay?: ToolResultDisplay,
|
||||
errorMessage?: string,
|
||||
): void {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const argsStr = formatToolArgs(args);
|
||||
|
||||
switch (status) {
|
||||
case 'start':
|
||||
process.stdout.write(
|
||||
`\n[${timestamp}] 🔧 Executing tool: ${toolName} ${argsStr}\n`,
|
||||
);
|
||||
break;
|
||||
case 'success':
|
||||
if (resultDisplay) {
|
||||
if (typeof resultDisplay === 'string' && resultDisplay.trim()) {
|
||||
process.stdout.write(
|
||||
`[${timestamp}] ✅ Tool ${toolName} completed successfully\n`,
|
||||
);
|
||||
process.stdout.write(`📋 Result:\n${resultDisplay}\n`);
|
||||
} else if (
|
||||
typeof resultDisplay === 'object' &&
|
||||
'fileDiff' in resultDisplay
|
||||
) {
|
||||
process.stdout.write(
|
||||
`[${timestamp}] ✅ Tool ${toolName} completed successfully\n`,
|
||||
);
|
||||
process.stdout.write(`📋 File: ${resultDisplay.fileName}\n`);
|
||||
process.stdout.write(`📋 Diff:\n${resultDisplay.fileDiff}\n`);
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`[${timestamp}] ✅ Tool ${toolName} completed successfully (no output)\n`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`[${timestamp}] ✅ Tool ${toolName} completed successfully (no output)\n`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'error':
|
||||
process.stdout.write(
|
||||
`[${timestamp}] ❌ Tool ${toolName} failed: ${errorMessage}\n`,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
process.stdout.write(
|
||||
`[${timestamp}] ⚠️ Tool ${toolName} reported unknown status: ${status}\n`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runNonInteractive(
|
||||
config: Config,
|
||||
input: string,
|
||||
@@ -196,9 +118,6 @@ export async function runNonInteractive(
|
||||
prompt_id,
|
||||
};
|
||||
|
||||
//Display tool call start information
|
||||
displayToolCallInfo(fc.name as string, fc.args ?? {}, 'start');
|
||||
|
||||
const toolResponse = await executeToolCall(
|
||||
config,
|
||||
requestInfo,
|
||||
@@ -207,20 +126,6 @@ export async function runNonInteractive(
|
||||
);
|
||||
|
||||
if (toolResponse.error) {
|
||||
// Display tool call error information
|
||||
const errorMessage =
|
||||
typeof toolResponse.resultDisplay === 'string'
|
||||
? toolResponse.resultDisplay
|
||||
: toolResponse.error?.message;
|
||||
|
||||
displayToolCallInfo(
|
||||
fc.name as string,
|
||||
fc.args ?? {},
|
||||
'error',
|
||||
undefined,
|
||||
errorMessage,
|
||||
);
|
||||
|
||||
const isToolNotFound = toolResponse.error.message.includes(
|
||||
'not found in registry',
|
||||
);
|
||||
@@ -230,14 +135,6 @@ export async function runNonInteractive(
|
||||
if (!isToolNotFound) {
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
// Display tool call success information
|
||||
displayToolCallInfo(
|
||||
fc.name as string,
|
||||
fc.args ?? {},
|
||||
'success',
|
||||
toolResponse.resultDisplay,
|
||||
);
|
||||
}
|
||||
|
||||
if (toolResponse.responseParts) {
|
||||
|
||||
17
packages/cli/src/patches/is-in-ci.ts
Normal file
17
packages/cli/src/patches/is-in-ci.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// This is a replacement for the `is-in-ci` package that always returns false.
|
||||
// We are doing this to avoid the issue where `ink` does not render the UI
|
||||
// when it detects that it is running in a CI environment.
|
||||
// This is safe because `ink` (and thus `is-in-ci`) is only used in the
|
||||
// interactive code path of the CLI.
|
||||
// See issue #1563 for more details.
|
||||
|
||||
const isInCi = false;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default isInCi;
|
||||
127
packages/cli/src/services/BuiltinCommandLoader.test.ts
Normal file
127
packages/cli/src/services/BuiltinCommandLoader.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
vi.mock('../ui/commands/aboutCommand.js', async () => {
|
||||
const { CommandKind } = await import('../ui/commands/types.js');
|
||||
return {
|
||||
aboutCommand: {
|
||||
name: 'about',
|
||||
description: 'About the CLI',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../ui/commands/ideCommand.js', () => ({ ideCommand: vi.fn() }));
|
||||
vi.mock('../ui/commands/restoreCommand.js', () => ({
|
||||
restoreCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { BuiltinCommandLoader } from './BuiltinCommandLoader.js';
|
||||
import { Config } from '@qwen-code/qwen-code-core';
|
||||
import { CommandKind } from '../ui/commands/types.js';
|
||||
|
||||
import { ideCommand } from '../ui/commands/ideCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
|
||||
vi.mock('../ui/commands/authCommand.js', () => ({ authCommand: {} }));
|
||||
vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} }));
|
||||
vi.mock('../ui/commands/chatCommand.js', () => ({ chatCommand: {} }));
|
||||
vi.mock('../ui/commands/clearCommand.js', () => ({ clearCommand: {} }));
|
||||
vi.mock('../ui/commands/compressCommand.js', () => ({ compressCommand: {} }));
|
||||
vi.mock('../ui/commands/corgiCommand.js', () => ({ corgiCommand: {} }));
|
||||
vi.mock('../ui/commands/docsCommand.js', () => ({ docsCommand: {} }));
|
||||
vi.mock('../ui/commands/editorCommand.js', () => ({ editorCommand: {} }));
|
||||
vi.mock('../ui/commands/extensionsCommand.js', () => ({
|
||||
extensionsCommand: {},
|
||||
}));
|
||||
vi.mock('../ui/commands/helpCommand.js', () => ({ helpCommand: {} }));
|
||||
vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: {} }));
|
||||
vi.mock('../ui/commands/privacyCommand.js', () => ({ privacyCommand: {} }));
|
||||
vi.mock('../ui/commands/quitCommand.js', () => ({ quitCommand: {} }));
|
||||
vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} }));
|
||||
vi.mock('../ui/commands/themeCommand.js', () => ({ themeCommand: {} }));
|
||||
vi.mock('../ui/commands/toolsCommand.js', () => ({ toolsCommand: {} }));
|
||||
vi.mock('../ui/commands/mcpCommand.js', () => ({
|
||||
mcpCommand: {
|
||||
name: 'mcp',
|
||||
description: 'MCP command',
|
||||
kind: 'BUILT_IN',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('BuiltinCommandLoader', () => {
|
||||
let mockConfig: Config;
|
||||
|
||||
const ideCommandMock = ideCommand as Mock;
|
||||
const restoreCommandMock = restoreCommand as Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConfig = { some: 'config' } as unknown as Config;
|
||||
|
||||
ideCommandMock.mockReturnValue({
|
||||
name: 'ide',
|
||||
description: 'IDE command',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
});
|
||||
restoreCommandMock.mockReturnValue({
|
||||
name: 'restore',
|
||||
description: 'Restore command',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly pass the config object to command factory functions', async () => {
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
await loader.loadCommands(new AbortController().signal);
|
||||
|
||||
expect(ideCommandMock).toHaveBeenCalledTimes(1);
|
||||
expect(ideCommandMock).toHaveBeenCalledWith(mockConfig);
|
||||
expect(restoreCommandMock).toHaveBeenCalledTimes(1);
|
||||
expect(restoreCommandMock).toHaveBeenCalledWith(mockConfig);
|
||||
});
|
||||
|
||||
it('should filter out null command definitions returned by factories', async () => {
|
||||
// Override the mock's behavior for this specific test.
|
||||
ideCommandMock.mockReturnValue(null);
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
|
||||
// The 'ide' command should be filtered out.
|
||||
const ideCmd = commands.find((c) => c.name === 'ide');
|
||||
expect(ideCmd).toBeUndefined();
|
||||
|
||||
// Other commands should still be present.
|
||||
const aboutCmd = commands.find((c) => c.name === 'about');
|
||||
expect(aboutCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle a null config gracefully when calling factories', async () => {
|
||||
const loader = new BuiltinCommandLoader(null);
|
||||
await loader.loadCommands(new AbortController().signal);
|
||||
expect(ideCommandMock).toHaveBeenCalledTimes(1);
|
||||
expect(ideCommandMock).toHaveBeenCalledWith(null);
|
||||
expect(restoreCommandMock).toHaveBeenCalledTimes(1);
|
||||
expect(restoreCommandMock).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('should return a list of all loaded commands', async () => {
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
|
||||
const aboutCmd = commands.find((c) => c.name === 'about');
|
||||
expect(aboutCmd).toBeDefined();
|
||||
expect(aboutCmd?.kind).toBe(CommandKind.BUILT_IN);
|
||||
|
||||
const ideCmd = commands.find((c) => c.name === 'ide');
|
||||
expect(ideCmd).toBeDefined();
|
||||
|
||||
const mcpCmd = commands.find((c) => c.name === 'mcp');
|
||||
expect(mcpCmd).toBeDefined();
|
||||
});
|
||||
});
|
||||
75
packages/cli/src/services/BuiltinCommandLoader.ts
Normal file
75
packages/cli/src/services/BuiltinCommandLoader.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { ICommandLoader } from './types.js';
|
||||
import { SlashCommand } from '../ui/commands/types.js';
|
||||
import { Config } from '@qwen-code/qwen-code-core';
|
||||
import { aboutCommand } from '../ui/commands/aboutCommand.js';
|
||||
import { authCommand } from '../ui/commands/authCommand.js';
|
||||
import { bugCommand } from '../ui/commands/bugCommand.js';
|
||||
import { chatCommand } from '../ui/commands/chatCommand.js';
|
||||
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||
import { compressCommand } from '../ui/commands/compressCommand.js';
|
||||
import { copyCommand } from '../ui/commands/copyCommand.js';
|
||||
import { corgiCommand } from '../ui/commands/corgiCommand.js';
|
||||
import { docsCommand } from '../ui/commands/docsCommand.js';
|
||||
import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
import { ideCommand } from '../ui/commands/ideCommand.js';
|
||||
import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
||||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||
import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
import { statsCommand } from '../ui/commands/statsCommand.js';
|
||||
import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||
import { toolsCommand } from '../ui/commands/toolsCommand.js';
|
||||
import { vimCommand } from '../ui/commands/vimCommand.js';
|
||||
|
||||
/**
|
||||
* Loads the core, hard-coded slash commands that are an integral part
|
||||
* of the Gemini CLI application.
|
||||
*/
|
||||
export class BuiltinCommandLoader implements ICommandLoader {
|
||||
constructor(private config: Config | null) {}
|
||||
|
||||
/**
|
||||
* Gathers all raw built-in command definitions, injects dependencies where
|
||||
* needed (e.g., config) and filters out any that are not available.
|
||||
*
|
||||
* @param _signal An AbortSignal (unused for this synchronous loader).
|
||||
* @returns A promise that resolves to an array of `SlashCommand` objects.
|
||||
*/
|
||||
async loadCommands(_signal: AbortSignal): Promise<SlashCommand[]> {
|
||||
const allDefinitions: Array<SlashCommand | null> = [
|
||||
aboutCommand,
|
||||
authCommand,
|
||||
bugCommand,
|
||||
chatCommand,
|
||||
clearCommand,
|
||||
compressCommand,
|
||||
copyCommand,
|
||||
corgiCommand,
|
||||
docsCommand,
|
||||
editorCommand,
|
||||
extensionsCommand,
|
||||
helpCommand,
|
||||
ideCommand(this.config),
|
||||
memoryCommand,
|
||||
privacyCommand,
|
||||
mcpCommand,
|
||||
quitCommand,
|
||||
restoreCommand(this.config),
|
||||
statsCommand,
|
||||
themeCommand,
|
||||
toolsCommand,
|
||||
vimCommand,
|
||||
];
|
||||
|
||||
return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null);
|
||||
}
|
||||
}
|
||||
@@ -4,135 +4,177 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { CommandService } from './CommandService.js';
|
||||
import { type SlashCommand } from '../ui/commands/types.js';
|
||||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||
import { authCommand } from '../ui/commands/authCommand.js';
|
||||
import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||
import { aboutCommand } from '../ui/commands/aboutCommand.js';
|
||||
import { type ICommandLoader } from './types.js';
|
||||
import { CommandKind, type SlashCommand } from '../ui/commands/types.js';
|
||||
|
||||
// Mock the command modules to isolate the service from the command implementations.
|
||||
vi.mock('../ui/commands/memoryCommand.js', () => ({
|
||||
memoryCommand: { name: 'memory', description: 'Mock Memory' },
|
||||
}));
|
||||
vi.mock('../ui/commands/helpCommand.js', () => ({
|
||||
helpCommand: { name: 'help', description: 'Mock Help' },
|
||||
}));
|
||||
vi.mock('../ui/commands/clearCommand.js', () => ({
|
||||
clearCommand: { name: 'clear', description: 'Mock Clear' },
|
||||
}));
|
||||
vi.mock('../ui/commands/authCommand.js', () => ({
|
||||
authCommand: { name: 'auth', description: 'Mock Auth' },
|
||||
}));
|
||||
vi.mock('../ui/commands/themeCommand.js', () => ({
|
||||
themeCommand: { name: 'theme', description: 'Mock Theme' },
|
||||
}));
|
||||
vi.mock('../ui/commands/privacyCommand.js', () => ({
|
||||
privacyCommand: { name: 'privacy', description: 'Mock Privacy' },
|
||||
}));
|
||||
vi.mock('../ui/commands/aboutCommand.js', () => ({
|
||||
aboutCommand: { name: 'about', description: 'Mock About' },
|
||||
}));
|
||||
const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({
|
||||
name,
|
||||
description: `Description for ${name}`,
|
||||
kind,
|
||||
action: vi.fn(),
|
||||
});
|
||||
|
||||
const mockCommandA = createMockCommand('command-a', CommandKind.BUILT_IN);
|
||||
const mockCommandB = createMockCommand('command-b', CommandKind.BUILT_IN);
|
||||
const mockCommandC = createMockCommand('command-c', CommandKind.FILE);
|
||||
const mockCommandB_Override = createMockCommand('command-b', CommandKind.FILE);
|
||||
|
||||
class MockCommandLoader implements ICommandLoader {
|
||||
private commandsToLoad: SlashCommand[];
|
||||
|
||||
constructor(commandsToLoad: SlashCommand[]) {
|
||||
this.commandsToLoad = commandsToLoad;
|
||||
}
|
||||
|
||||
loadCommands = vi.fn(
|
||||
async (): Promise<SlashCommand[]> => Promise.resolve(this.commandsToLoad),
|
||||
);
|
||||
}
|
||||
|
||||
describe('CommandService', () => {
|
||||
describe('when using default production loader', () => {
|
||||
let commandService: CommandService;
|
||||
|
||||
beforeEach(() => {
|
||||
commandService = new CommandService();
|
||||
});
|
||||
|
||||
it('should initialize with an empty command tree', () => {
|
||||
const tree = commandService.getCommands();
|
||||
expect(tree).toBeInstanceOf(Array);
|
||||
expect(tree.length).toBe(0);
|
||||
});
|
||||
|
||||
describe('loadCommands', () => {
|
||||
it('should load the built-in commands into the command tree', async () => {
|
||||
// Pre-condition check
|
||||
expect(commandService.getCommands().length).toBe(0);
|
||||
|
||||
// Action
|
||||
await commandService.loadCommands();
|
||||
const tree = commandService.getCommands();
|
||||
|
||||
// Post-condition assertions
|
||||
expect(tree.length).toBe(7);
|
||||
|
||||
const commandNames = tree.map((cmd) => cmd.name);
|
||||
expect(commandNames).toContain('auth');
|
||||
expect(commandNames).toContain('memory');
|
||||
expect(commandNames).toContain('help');
|
||||
expect(commandNames).toContain('clear');
|
||||
expect(commandNames).toContain('theme');
|
||||
expect(commandNames).toContain('privacy');
|
||||
expect(commandNames).toContain('about');
|
||||
});
|
||||
|
||||
it('should overwrite any existing commands when called again', async () => {
|
||||
// Load once
|
||||
await commandService.loadCommands();
|
||||
expect(commandService.getCommands().length).toBe(7);
|
||||
|
||||
// Load again
|
||||
await commandService.loadCommands();
|
||||
const tree = commandService.getCommands();
|
||||
|
||||
// Should not append, but overwrite
|
||||
expect(tree.length).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCommandTree', () => {
|
||||
it('should return the current command tree', async () => {
|
||||
const initialTree = commandService.getCommands();
|
||||
expect(initialTree).toEqual([]);
|
||||
|
||||
await commandService.loadCommands();
|
||||
|
||||
const loadedTree = commandService.getCommands();
|
||||
expect(loadedTree.length).toBe(7);
|
||||
expect(loadedTree).toEqual([
|
||||
aboutCommand,
|
||||
authCommand,
|
||||
clearCommand,
|
||||
helpCommand,
|
||||
memoryCommand,
|
||||
privacyCommand,
|
||||
themeCommand,
|
||||
]);
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe('when initialized with an injected loader function', () => {
|
||||
it('should use the provided loader instead of the built-in one', async () => {
|
||||
// Arrange: Create a set of mock commands.
|
||||
const mockCommands: SlashCommand[] = [
|
||||
{ name: 'injected-test-1', description: 'injected 1' },
|
||||
{ name: 'injected-test-2', description: 'injected 2' },
|
||||
];
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Arrange: Create a mock loader FUNCTION that resolves with our mock commands.
|
||||
const mockLoader = vi.fn().mockResolvedValue(mockCommands);
|
||||
it('should load commands from a single loader', async () => {
|
||||
const mockLoader = new MockCommandLoader([mockCommandA, mockCommandB]);
|
||||
const service = await CommandService.create(
|
||||
[mockLoader],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
// Act: Instantiate the service WITH the injected loader function.
|
||||
const commandService = new CommandService(mockLoader);
|
||||
await commandService.loadCommands();
|
||||
const tree = commandService.getCommands();
|
||||
const commands = service.getCommands();
|
||||
|
||||
// Assert: The tree should contain ONLY our injected commands.
|
||||
expect(mockLoader).toHaveBeenCalled(); // Verify our mock loader was actually called.
|
||||
expect(tree.length).toBe(2);
|
||||
expect(tree).toEqual(mockCommands);
|
||||
expect(mockLoader.loadCommands).toHaveBeenCalledTimes(1);
|
||||
expect(commands).toHaveLength(2);
|
||||
expect(commands).toEqual(
|
||||
expect.arrayContaining([mockCommandA, mockCommandB]),
|
||||
);
|
||||
});
|
||||
|
||||
const commandNames = tree.map((cmd) => cmd.name);
|
||||
expect(commandNames).not.toContain('memory'); // Verify it didn't load production commands.
|
||||
});
|
||||
it('should aggregate commands from multiple loaders', async () => {
|
||||
const loader1 = new MockCommandLoader([mockCommandA]);
|
||||
const loader2 = new MockCommandLoader([mockCommandC]);
|
||||
const service = await CommandService.create(
|
||||
[loader1, loader2],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const commands = service.getCommands();
|
||||
|
||||
expect(loader1.loadCommands).toHaveBeenCalledTimes(1);
|
||||
expect(loader2.loadCommands).toHaveBeenCalledTimes(1);
|
||||
expect(commands).toHaveLength(2);
|
||||
expect(commands).toEqual(
|
||||
expect.arrayContaining([mockCommandA, mockCommandC]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should override commands from earlier loaders with those from later loaders', async () => {
|
||||
const loader1 = new MockCommandLoader([mockCommandA, mockCommandB]);
|
||||
const loader2 = new MockCommandLoader([
|
||||
mockCommandB_Override,
|
||||
mockCommandC,
|
||||
]);
|
||||
const service = await CommandService.create(
|
||||
[loader1, loader2],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const commands = service.getCommands();
|
||||
|
||||
expect(commands).toHaveLength(3); // Should be A, C, and the overridden B.
|
||||
|
||||
// The final list should contain the override from the *last* loader.
|
||||
const commandB = commands.find((cmd) => cmd.name === 'command-b');
|
||||
expect(commandB).toBeDefined();
|
||||
expect(commandB?.kind).toBe(CommandKind.FILE); // Verify it's the overridden version.
|
||||
expect(commandB).toEqual(mockCommandB_Override);
|
||||
|
||||
// Ensure the other commands are still present.
|
||||
expect(commands).toEqual(
|
||||
expect.arrayContaining([
|
||||
mockCommandA,
|
||||
mockCommandC,
|
||||
mockCommandB_Override,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle loaders that return an empty array of commands gracefully', async () => {
|
||||
const loader1 = new MockCommandLoader([mockCommandA]);
|
||||
const emptyLoader = new MockCommandLoader([]);
|
||||
const loader3 = new MockCommandLoader([mockCommandB]);
|
||||
const service = await CommandService.create(
|
||||
[loader1, emptyLoader, loader3],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const commands = service.getCommands();
|
||||
|
||||
expect(emptyLoader.loadCommands).toHaveBeenCalledTimes(1);
|
||||
expect(commands).toHaveLength(2);
|
||||
expect(commands).toEqual(
|
||||
expect.arrayContaining([mockCommandA, mockCommandB]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should load commands from successful loaders even if one fails', async () => {
|
||||
const successfulLoader = new MockCommandLoader([mockCommandA]);
|
||||
const failingLoader = new MockCommandLoader([]);
|
||||
const error = new Error('Loader failed');
|
||||
vi.spyOn(failingLoader, 'loadCommands').mockRejectedValue(error);
|
||||
|
||||
const service = await CommandService.create(
|
||||
[successfulLoader, failingLoader],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const commands = service.getCommands();
|
||||
expect(commands).toHaveLength(1);
|
||||
expect(commands).toEqual([mockCommandA]);
|
||||
expect(console.debug).toHaveBeenCalledWith(
|
||||
'A command loader failed:',
|
||||
error,
|
||||
);
|
||||
});
|
||||
|
||||
it('getCommands should return a readonly array that cannot be mutated', async () => {
|
||||
const service = await CommandService.create(
|
||||
[new MockCommandLoader([mockCommandA])],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
const commands = service.getCommands();
|
||||
|
||||
// Expect it to throw a TypeError at runtime because the array is frozen.
|
||||
expect(() => {
|
||||
// @ts-expect-error - Testing immutability is intentional here.
|
||||
commands.push(mockCommandB);
|
||||
}).toThrow();
|
||||
|
||||
// Verify the original array was not mutated.
|
||||
expect(service.getCommands()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should pass the abort signal to all loaders', async () => {
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
|
||||
const loader1 = new MockCommandLoader([mockCommandA]);
|
||||
const loader2 = new MockCommandLoader([mockCommandB]);
|
||||
|
||||
await CommandService.create([loader1, loader2], signal);
|
||||
|
||||
expect(loader1.loadCommands).toHaveBeenCalledTimes(1);
|
||||
expect(loader1.loadCommands).toHaveBeenCalledWith(signal);
|
||||
expect(loader2.loadCommands).toHaveBeenCalledTimes(1);
|
||||
expect(loader2.loadCommands).toHaveBeenCalledWith(signal);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,40 +5,79 @@
|
||||
*/
|
||||
|
||||
import { SlashCommand } from '../ui/commands/types.js';
|
||||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||
import { authCommand } from '../ui/commands/authCommand.js';
|
||||
import { themeCommand } from '../ui/commands/themeCommand.js';
|
||||
import { privacyCommand } from '../ui/commands/privacyCommand.js';
|
||||
import { aboutCommand } from '../ui/commands/aboutCommand.js';
|
||||
|
||||
const loadBuiltInCommands = async (): Promise<SlashCommand[]> => [
|
||||
aboutCommand,
|
||||
authCommand,
|
||||
clearCommand,
|
||||
helpCommand,
|
||||
memoryCommand,
|
||||
privacyCommand,
|
||||
themeCommand,
|
||||
];
|
||||
import { ICommandLoader } from './types.js';
|
||||
|
||||
/**
|
||||
* Orchestrates the discovery and loading of all slash commands for the CLI.
|
||||
*
|
||||
* This service operates on a provider-based loader pattern. It is initialized
|
||||
* with an array of `ICommandLoader` instances, each responsible for fetching
|
||||
* commands from a specific source (e.g., built-in code, local files).
|
||||
*
|
||||
* The CommandService is responsible for invoking these loaders, aggregating their
|
||||
* results, and resolving any name conflicts. This architecture allows the command
|
||||
* system to be extended with new sources without modifying the service itself.
|
||||
*/
|
||||
export class CommandService {
|
||||
private commands: SlashCommand[] = [];
|
||||
/**
|
||||
* Private constructor to enforce the use of the async factory.
|
||||
* @param commands A readonly array of the fully loaded and de-duplicated commands.
|
||||
*/
|
||||
private constructor(private readonly commands: readonly SlashCommand[]) {}
|
||||
|
||||
constructor(
|
||||
private commandLoader: () => Promise<SlashCommand[]> = loadBuiltInCommands,
|
||||
) {
|
||||
// The constructor can be used for dependency injection in the future.
|
||||
/**
|
||||
* Asynchronously creates and initializes a new CommandService instance.
|
||||
*
|
||||
* This factory method orchestrates the entire command loading process. It
|
||||
* runs all provided loaders in parallel, aggregates their results, handles
|
||||
* name conflicts by letting the last-loaded command win, and then returns a
|
||||
* fully constructed `CommandService` instance.
|
||||
*
|
||||
* @param loaders An array of objects that conform to the `ICommandLoader`
|
||||
* interface. The order of loaders is significant: if multiple loaders
|
||||
* provide a command with the same name, the command from the loader that
|
||||
* appears later in the array will take precedence.
|
||||
* @param signal An AbortSignal to cancel the loading process.
|
||||
* @returns A promise that resolves to a new, fully initialized `CommandService` instance.
|
||||
*/
|
||||
static async create(
|
||||
loaders: ICommandLoader[],
|
||||
signal: AbortSignal,
|
||||
): Promise<CommandService> {
|
||||
const results = await Promise.allSettled(
|
||||
loaders.map((loader) => loader.loadCommands(signal)),
|
||||
);
|
||||
|
||||
const allCommands: SlashCommand[] = [];
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
allCommands.push(...result.value);
|
||||
} else {
|
||||
console.debug('A command loader failed:', result.reason);
|
||||
}
|
||||
}
|
||||
|
||||
// De-duplicate commands using a Map. The last one found with a given name wins.
|
||||
// This creates a natural override system based on the order of the loaders
|
||||
// passed to the constructor.
|
||||
const commandMap = new Map<string, SlashCommand>();
|
||||
for (const cmd of allCommands) {
|
||||
commandMap.set(cmd.name, cmd);
|
||||
}
|
||||
|
||||
const finalCommands = Object.freeze(Array.from(commandMap.values()));
|
||||
return new CommandService(finalCommands);
|
||||
}
|
||||
|
||||
async loadCommands(): Promise<void> {
|
||||
// For now, we only load the built-in commands.
|
||||
// File-based and remote commands will be added later.
|
||||
this.commands = await this.commandLoader();
|
||||
}
|
||||
|
||||
getCommands(): SlashCommand[] {
|
||||
/**
|
||||
* Retrieves the currently loaded and de-duplicated list of slash commands.
|
||||
*
|
||||
* This method is a safe accessor for the service's state. It returns a
|
||||
* readonly array, preventing consumers from modifying the service's internal state.
|
||||
*
|
||||
* @returns A readonly, unified array of available `SlashCommand` objects.
|
||||
*/
|
||||
getCommands(): readonly SlashCommand[] {
|
||||
return this.commands;
|
||||
}
|
||||
}
|
||||
|
||||
606
packages/cli/src/services/FileCommandLoader.test.ts
Normal file
606
packages/cli/src/services/FileCommandLoader.test.ts
Normal file
@@ -0,0 +1,606 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { FileCommandLoader } from './FileCommandLoader.js';
|
||||
import {
|
||||
Config,
|
||||
getProjectCommandsDir,
|
||||
getUserCommandsDir,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import mock from 'mock-fs';
|
||||
import { assert, vi } from 'vitest';
|
||||
import { createMockCommandContext } from '../test-utils/mockCommandContext.js';
|
||||
import {
|
||||
SHELL_INJECTION_TRIGGER,
|
||||
SHORTHAND_ARGS_PLACEHOLDER,
|
||||
} from './prompt-processors/types.js';
|
||||
import {
|
||||
ConfirmationRequiredError,
|
||||
ShellProcessor,
|
||||
} from './prompt-processors/shellProcessor.js';
|
||||
import { ShorthandArgumentProcessor } from './prompt-processors/argumentProcessor.js';
|
||||
|
||||
const mockShellProcess = vi.hoisted(() => vi.fn());
|
||||
vi.mock('./prompt-processors/shellProcessor.js', () => ({
|
||||
ShellProcessor: vi.fn().mockImplementation(() => ({
|
||||
process: mockShellProcess,
|
||||
})),
|
||||
ConfirmationRequiredError: class extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public commandsToConfirm: string[],
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ConfirmationRequiredError';
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./prompt-processors/argumentProcessor.js', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<
|
||||
typeof import('./prompt-processors/argumentProcessor.js')
|
||||
>();
|
||||
return {
|
||||
ShorthandArgumentProcessor: vi
|
||||
.fn()
|
||||
.mockImplementation(() => new original.ShorthandArgumentProcessor()),
|
||||
DefaultArgumentProcessor: vi
|
||||
.fn()
|
||||
.mockImplementation(() => new original.DefaultArgumentProcessor()),
|
||||
};
|
||||
});
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...original,
|
||||
isCommandAllowed: vi.fn(),
|
||||
ShellExecutionService: {
|
||||
execute: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('FileCommandLoader', () => {
|
||||
const signal: AbortSignal = new AbortController().signal;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockShellProcess.mockImplementation((prompt) => Promise.resolve(prompt));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('loads a single command from a file', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'test.toml': 'prompt = "This is a test prompt"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
const command = commands[0];
|
||||
expect(command).toBeDefined();
|
||||
expect(command.name).toBe('test');
|
||||
|
||||
const result = await command.action?.(
|
||||
createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/test',
|
||||
name: 'test',
|
||||
args: '',
|
||||
},
|
||||
}),
|
||||
'',
|
||||
);
|
||||
if (result?.type === 'submit_prompt') {
|
||||
expect(result.content).toBe('This is a test prompt');
|
||||
} else {
|
||||
assert.fail('Incorrect action type');
|
||||
}
|
||||
});
|
||||
|
||||
// Symlink creation on Windows requires special permissions that are not
|
||||
// available in the standard CI environment. Therefore, we skip these tests
|
||||
// on Windows to prevent CI failures. The core functionality is still
|
||||
// validated on Linux and macOS.
|
||||
const itif = (condition: boolean) => (condition ? it : it.skip);
|
||||
|
||||
itif(process.platform !== 'win32')(
|
||||
'loads commands from a symlinked directory',
|
||||
async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
const realCommandsDir = '/real/commands';
|
||||
mock({
|
||||
[realCommandsDir]: {
|
||||
'test.toml': 'prompt = "This is a test prompt"',
|
||||
},
|
||||
// Symlink the user commands directory to the real one
|
||||
[userCommandsDir]: mock.symlink({
|
||||
path: realCommandsDir,
|
||||
}),
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
const command = commands[0];
|
||||
expect(command).toBeDefined();
|
||||
expect(command.name).toBe('test');
|
||||
},
|
||||
);
|
||||
|
||||
itif(process.platform !== 'win32')(
|
||||
'loads commands from a symlinked subdirectory',
|
||||
async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
const realNamespacedDir = '/real/namespaced-commands';
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
namespaced: mock.symlink({
|
||||
path: realNamespacedDir,
|
||||
}),
|
||||
},
|
||||
[realNamespacedDir]: {
|
||||
'my-test.toml': 'prompt = "This is a test prompt"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
const command = commands[0];
|
||||
expect(command).toBeDefined();
|
||||
expect(command.name).toBe('namespaced:my-test');
|
||||
},
|
||||
);
|
||||
|
||||
it('loads multiple commands', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'test1.toml': 'prompt = "Prompt 1"',
|
||||
'test2.toml': 'prompt = "Prompt 2"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('creates deeply nested namespaces correctly', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
gcp: {
|
||||
pipelines: {
|
||||
'run.toml': 'prompt = "run pipeline"',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const loader = new FileCommandLoader({
|
||||
getProjectRoot: () => '/path/to/project',
|
||||
} as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
expect(commands).toHaveLength(1);
|
||||
expect(commands[0]!.name).toBe('gcp:pipelines:run');
|
||||
});
|
||||
|
||||
it('creates namespaces from nested directories', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
git: {
|
||||
'commit.toml': 'prompt = "git commit prompt"',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
const command = commands[0];
|
||||
expect(command).toBeDefined();
|
||||
expect(command.name).toBe('git:commit');
|
||||
});
|
||||
|
||||
it('overrides user commands with project commands', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
const projectCommandsDir = getProjectCommandsDir(process.cwd());
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'test.toml': 'prompt = "User prompt"',
|
||||
},
|
||||
[projectCommandsDir]: {
|
||||
'test.toml': 'prompt = "Project prompt"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader({
|
||||
getProjectRoot: () => process.cwd(),
|
||||
} as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
const command = commands[0];
|
||||
expect(command).toBeDefined();
|
||||
|
||||
const result = await command.action?.(
|
||||
createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/test',
|
||||
name: 'test',
|
||||
args: '',
|
||||
},
|
||||
}),
|
||||
'',
|
||||
);
|
||||
if (result?.type === 'submit_prompt') {
|
||||
expect(result.content).toBe('Project prompt');
|
||||
} else {
|
||||
assert.fail('Incorrect action type');
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores files with TOML syntax errors', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'invalid.toml': 'this is not valid toml',
|
||||
'good.toml': 'prompt = "This one is fine"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
expect(commands[0].name).toBe('good');
|
||||
});
|
||||
|
||||
it('ignores files that are semantically invalid (missing prompt)', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'no_prompt.toml': 'description = "This file is missing a prompt"',
|
||||
'good.toml': 'prompt = "This one is fine"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
expect(commands[0].name).toBe('good');
|
||||
});
|
||||
|
||||
it('handles filename edge cases correctly', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'test.v1.toml': 'prompt = "Test prompt"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const command = commands[0];
|
||||
expect(command).toBeDefined();
|
||||
expect(command.name).toBe('test.v1');
|
||||
});
|
||||
|
||||
it('handles file system errors gracefully', async () => {
|
||||
mock({}); // Mock an empty file system
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
expect(commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('uses a default description if not provided', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'test.toml': 'prompt = "Test prompt"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const command = commands[0];
|
||||
expect(command).toBeDefined();
|
||||
expect(command.description).toBe('Custom command from test.toml');
|
||||
});
|
||||
|
||||
it('uses the provided description', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'test.toml': 'prompt = "Test prompt"\ndescription = "My test command"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const command = commands[0];
|
||||
expect(command).toBeDefined();
|
||||
expect(command.description).toBe('My test command');
|
||||
});
|
||||
|
||||
it('should sanitize colons in filenames to prevent namespace conflicts', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'legacy:command.toml': 'prompt = "This is a legacy command"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
const command = commands[0];
|
||||
expect(command).toBeDefined();
|
||||
|
||||
// Verify that the ':' in the filename was replaced with an '_'
|
||||
expect(command.name).toBe('legacy_command');
|
||||
});
|
||||
|
||||
describe('Shorthand Argument Processor Integration', () => {
|
||||
it('correctly processes a command with {{args}}', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'shorthand.toml':
|
||||
'prompt = "The user wants to: {{args}}"\ndescription = "Shorthand test"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const command = commands.find((c) => c.name === 'shorthand');
|
||||
expect(command).toBeDefined();
|
||||
|
||||
const result = await command!.action?.(
|
||||
createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/shorthand do something cool',
|
||||
name: 'shorthand',
|
||||
args: 'do something cool',
|
||||
},
|
||||
}),
|
||||
'do something cool',
|
||||
);
|
||||
expect(result?.type).toBe('submit_prompt');
|
||||
if (result?.type === 'submit_prompt') {
|
||||
expect(result.content).toBe('The user wants to: do something cool');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Default Argument Processor Integration', () => {
|
||||
it('correctly processes a command without {{args}}', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'model_led.toml':
|
||||
'prompt = "This is the instruction."\ndescription = "Default processor test"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const command = commands.find((c) => c.name === 'model_led');
|
||||
expect(command).toBeDefined();
|
||||
|
||||
const result = await command!.action?.(
|
||||
createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/model_led 1.2.0 added "a feature"',
|
||||
name: 'model_led',
|
||||
args: '1.2.0 added "a feature"',
|
||||
},
|
||||
}),
|
||||
'1.2.0 added "a feature"',
|
||||
);
|
||||
expect(result?.type).toBe('submit_prompt');
|
||||
if (result?.type === 'submit_prompt') {
|
||||
const expectedContent =
|
||||
'This is the instruction.\n\n/model_led 1.2.0 added "a feature"';
|
||||
expect(result.content).toBe(expectedContent);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shell Processor Integration', () => {
|
||||
it('instantiates ShellProcessor if the trigger is present', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'shell.toml': `prompt = "Run this: ${SHELL_INJECTION_TRIGGER}echo hello}"`,
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
await loader.loadCommands(signal);
|
||||
|
||||
expect(ShellProcessor).toHaveBeenCalledWith('shell');
|
||||
});
|
||||
|
||||
it('does not instantiate ShellProcessor if trigger is missing', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'regular.toml': `prompt = "Just a regular prompt"`,
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
await loader.loadCommands(signal);
|
||||
|
||||
expect(ShellProcessor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a "submit_prompt" action if shell processing succeeds', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'shell.toml': `prompt = "Run !{echo 'hello'}"`,
|
||||
},
|
||||
});
|
||||
mockShellProcess.mockResolvedValue('Run hello');
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const command = commands.find((c) => c.name === 'shell');
|
||||
expect(command).toBeDefined();
|
||||
|
||||
const result = await command!.action!(
|
||||
createMockCommandContext({
|
||||
invocation: { raw: '/shell', name: 'shell', args: '' },
|
||||
}),
|
||||
'',
|
||||
);
|
||||
|
||||
expect(result?.type).toBe('submit_prompt');
|
||||
if (result?.type === 'submit_prompt') {
|
||||
expect(result.content).toBe('Run hello');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns a "confirm_shell_commands" action if shell processing requires it', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
const rawInvocation = '/shell rm -rf /';
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'shell.toml': `prompt = "Run !{rm -rf /}"`,
|
||||
},
|
||||
});
|
||||
|
||||
// Mock the processor to throw the specific error
|
||||
const error = new ConfirmationRequiredError('Confirmation needed', [
|
||||
'rm -rf /',
|
||||
]);
|
||||
mockShellProcess.mockRejectedValue(error);
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const command = commands.find((c) => c.name === 'shell');
|
||||
expect(command).toBeDefined();
|
||||
|
||||
const result = await command!.action!(
|
||||
createMockCommandContext({
|
||||
invocation: { raw: rawInvocation, name: 'shell', args: 'rm -rf /' },
|
||||
}),
|
||||
'rm -rf /',
|
||||
);
|
||||
|
||||
expect(result?.type).toBe('confirm_shell_commands');
|
||||
if (result?.type === 'confirm_shell_commands') {
|
||||
expect(result.commandsToConfirm).toEqual(['rm -rf /']);
|
||||
expect(result.originalInvocation.raw).toBe(rawInvocation);
|
||||
}
|
||||
});
|
||||
|
||||
it('re-throws other errors from the processor', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'shell.toml': `prompt = "Run !{something}"`,
|
||||
},
|
||||
});
|
||||
|
||||
const genericError = new Error('Something else went wrong');
|
||||
mockShellProcess.mockRejectedValue(genericError);
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const command = commands.find((c) => c.name === 'shell');
|
||||
expect(command).toBeDefined();
|
||||
|
||||
await expect(
|
||||
command!.action!(
|
||||
createMockCommandContext({
|
||||
invocation: { raw: '/shell', name: 'shell', args: '' },
|
||||
}),
|
||||
'',
|
||||
),
|
||||
).rejects.toThrow('Something else went wrong');
|
||||
});
|
||||
|
||||
it('assembles the processor pipeline in the correct order (Shell -> Argument)', async () => {
|
||||
const userCommandsDir = getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'pipeline.toml': `
|
||||
prompt = "Shell says: ${SHELL_INJECTION_TRIGGER}echo foo} and user says: ${SHORTHAND_ARGS_PLACEHOLDER}"
|
||||
`,
|
||||
},
|
||||
});
|
||||
|
||||
// Mock the process methods to track call order
|
||||
const argProcessMock = vi
|
||||
.fn()
|
||||
.mockImplementation((p) => `${p}-arg-processed`);
|
||||
|
||||
// Redefine the mock for this specific test
|
||||
mockShellProcess.mockImplementation((p) =>
|
||||
Promise.resolve(`${p}-shell-processed`),
|
||||
);
|
||||
|
||||
vi.mocked(ShorthandArgumentProcessor).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
process: argProcessMock,
|
||||
}) as unknown as ShorthandArgumentProcessor,
|
||||
);
|
||||
|
||||
const loader = new FileCommandLoader(null as unknown as Config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const command = commands.find((c) => c.name === 'pipeline');
|
||||
expect(command).toBeDefined();
|
||||
|
||||
await command!.action!(
|
||||
createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/pipeline bar',
|
||||
name: 'pipeline',
|
||||
args: 'bar',
|
||||
},
|
||||
}),
|
||||
'bar',
|
||||
);
|
||||
|
||||
// Verify that the shell processor was called before the argument processor
|
||||
expect(mockShellProcess.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
argProcessMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
|
||||
// Also verify the flow of the prompt through the processors
|
||||
expect(mockShellProcess).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(argProcessMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('-shell-processed'), // It receives the output of the shell processor
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
240
packages/cli/src/services/FileCommandLoader.ts
Normal file
240
packages/cli/src/services/FileCommandLoader.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import toml from '@iarna/toml';
|
||||
import { glob } from 'glob';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
Config,
|
||||
getProjectCommandsDir,
|
||||
getUserCommandsDir,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { ICommandLoader } from './types.js';
|
||||
import {
|
||||
CommandContext,
|
||||
CommandKind,
|
||||
SlashCommand,
|
||||
SlashCommandActionReturn,
|
||||
} from '../ui/commands/types.js';
|
||||
import {
|
||||
DefaultArgumentProcessor,
|
||||
ShorthandArgumentProcessor,
|
||||
} from './prompt-processors/argumentProcessor.js';
|
||||
import {
|
||||
IPromptProcessor,
|
||||
SHORTHAND_ARGS_PLACEHOLDER,
|
||||
SHELL_INJECTION_TRIGGER,
|
||||
} from './prompt-processors/types.js';
|
||||
import {
|
||||
ConfirmationRequiredError,
|
||||
ShellProcessor,
|
||||
} from './prompt-processors/shellProcessor.js';
|
||||
|
||||
/**
|
||||
* Defines the Zod schema for a command definition file. This serves as the
|
||||
* single source of truth for both validation and type inference.
|
||||
*/
|
||||
const TomlCommandDefSchema = z.object({
|
||||
prompt: z.string({
|
||||
required_error: "The 'prompt' field is required.",
|
||||
invalid_type_error: "The 'prompt' field must be a string.",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Discovers and loads custom slash commands from .toml files in both the
|
||||
* user's global config directory and the current project's directory.
|
||||
*
|
||||
* This loader is responsible for:
|
||||
* - Recursively scanning command directories.
|
||||
* - Parsing and validating TOML files.
|
||||
* - Adapting valid definitions into executable SlashCommand objects.
|
||||
* - Handling file system errors and malformed files gracefully.
|
||||
*/
|
||||
export class FileCommandLoader implements ICommandLoader {
|
||||
private readonly projectRoot: string;
|
||||
|
||||
constructor(private readonly config: Config | null) {
|
||||
this.projectRoot = config?.getProjectRoot() || process.cwd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all commands, applying the precedence rule where project-level
|
||||
* commands override user-level commands with the same name.
|
||||
* @param signal An AbortSignal to cancel the loading process.
|
||||
* @returns A promise that resolves to an array of loaded SlashCommands.
|
||||
*/
|
||||
async loadCommands(signal: AbortSignal): Promise<SlashCommand[]> {
|
||||
const commandMap = new Map<string, SlashCommand>();
|
||||
const globOptions = {
|
||||
nodir: true,
|
||||
dot: true,
|
||||
signal,
|
||||
follow: true,
|
||||
};
|
||||
|
||||
try {
|
||||
// User Commands
|
||||
const userDir = getUserCommandsDir();
|
||||
const userFiles = await glob('**/*.toml', {
|
||||
...globOptions,
|
||||
cwd: userDir,
|
||||
});
|
||||
const userCommandPromises = userFiles.map((file) =>
|
||||
this.parseAndAdaptFile(path.join(userDir, file), userDir),
|
||||
);
|
||||
const userCommands = (await Promise.all(userCommandPromises)).filter(
|
||||
(cmd): cmd is SlashCommand => cmd !== null,
|
||||
);
|
||||
for (const cmd of userCommands) {
|
||||
commandMap.set(cmd.name, cmd);
|
||||
}
|
||||
|
||||
// Project Commands (these intentionally override user commands)
|
||||
const projectDir = getProjectCommandsDir(this.projectRoot);
|
||||
const projectFiles = await glob('**/*.toml', {
|
||||
...globOptions,
|
||||
cwd: projectDir,
|
||||
});
|
||||
const projectCommandPromises = projectFiles.map((file) =>
|
||||
this.parseAndAdaptFile(path.join(projectDir, file), projectDir),
|
||||
);
|
||||
const projectCommands = (
|
||||
await Promise.all(projectCommandPromises)
|
||||
).filter((cmd): cmd is SlashCommand => cmd !== null);
|
||||
for (const cmd of projectCommands) {
|
||||
commandMap.set(cmd.name, cmd);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[FileCommandLoader] Error during file search:`, error);
|
||||
}
|
||||
|
||||
return Array.from(commandMap.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a single .toml file and transforms it into a SlashCommand object.
|
||||
* @param filePath The absolute path to the .toml file.
|
||||
* @param baseDir The root command directory for name calculation.
|
||||
* @returns A promise resolving to a SlashCommand, or null if the file is invalid.
|
||||
*/
|
||||
private async parseAndAdaptFile(
|
||||
filePath: string,
|
||||
baseDir: string,
|
||||
): Promise<SlashCommand | null> {
|
||||
let fileContent: string;
|
||||
try {
|
||||
fileContent = await fs.readFile(filePath, 'utf-8');
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`[FileCommandLoader] Failed to read file ${filePath}:`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = toml.parse(fileContent);
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`[FileCommandLoader] Failed to parse TOML file ${filePath}:`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const validationResult = TomlCommandDefSchema.safeParse(parsed);
|
||||
|
||||
if (!validationResult.success) {
|
||||
console.error(
|
||||
`[FileCommandLoader] Skipping invalid command file: ${filePath}. Validation errors:`,
|
||||
validationResult.error.flatten(),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const validDef = validationResult.data;
|
||||
|
||||
const relativePathWithExt = path.relative(baseDir, filePath);
|
||||
const relativePath = relativePathWithExt.substring(
|
||||
0,
|
||||
relativePathWithExt.length - 5, // length of '.toml'
|
||||
);
|
||||
const commandName = relativePath
|
||||
.split(path.sep)
|
||||
// Sanitize each path segment to prevent ambiguity. Since ':' is our
|
||||
// namespace separator, we replace any literal colons in filenames
|
||||
// with underscores to avoid naming conflicts.
|
||||
.map((segment) => segment.replaceAll(':', '_'))
|
||||
.join(':');
|
||||
|
||||
const processors: IPromptProcessor[] = [];
|
||||
|
||||
// Add the Shell Processor if needed.
|
||||
if (validDef.prompt.includes(SHELL_INJECTION_TRIGGER)) {
|
||||
processors.push(new ShellProcessor(commandName));
|
||||
}
|
||||
|
||||
// The presence of '{{args}}' is the switch that determines the behavior.
|
||||
if (validDef.prompt.includes(SHORTHAND_ARGS_PLACEHOLDER)) {
|
||||
processors.push(new ShorthandArgumentProcessor());
|
||||
} else {
|
||||
processors.push(new DefaultArgumentProcessor());
|
||||
}
|
||||
|
||||
return {
|
||||
name: commandName,
|
||||
description:
|
||||
validDef.description ||
|
||||
`Custom command from ${path.basename(filePath)}`,
|
||||
kind: CommandKind.FILE,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
_args: string,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
if (!context.invocation) {
|
||||
console.error(
|
||||
`[FileCommandLoader] Critical error: Command '${commandName}' was executed without invocation context.`,
|
||||
);
|
||||
return {
|
||||
type: 'submit_prompt',
|
||||
content: validDef.prompt, // Fallback to unprocessed prompt
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
let processedPrompt = validDef.prompt;
|
||||
for (const processor of processors) {
|
||||
processedPrompt = await processor.process(processedPrompt, context);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'submit_prompt',
|
||||
content: processedPrompt,
|
||||
};
|
||||
} catch (e) {
|
||||
// Check if it's our specific error type
|
||||
if (e instanceof ConfirmationRequiredError) {
|
||||
// Halt and request confirmation from the UI layer.
|
||||
return {
|
||||
type: 'confirm_shell_commands',
|
||||
commandsToConfirm: e.commandsToConfirm,
|
||||
originalInvocation: {
|
||||
raw: context.invocation.raw,
|
||||
},
|
||||
};
|
||||
}
|
||||
// Re-throw other errors to be handled by the global error handler.
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
231
packages/cli/src/services/McpPromptLoader.ts
Normal file
231
packages/cli/src/services/McpPromptLoader.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
Config,
|
||||
getErrorMessage,
|
||||
getMCPServerPrompts,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
CommandContext,
|
||||
CommandKind,
|
||||
SlashCommand,
|
||||
SlashCommandActionReturn,
|
||||
} from '../ui/commands/types.js';
|
||||
import { ICommandLoader } from './types.js';
|
||||
import { PromptArgument } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
/**
|
||||
* Discovers and loads executable slash commands from prompts exposed by
|
||||
* Model-Context-Protocol (MCP) servers.
|
||||
*/
|
||||
export class McpPromptLoader implements ICommandLoader {
|
||||
constructor(private readonly config: Config | null) {}
|
||||
|
||||
/**
|
||||
* Loads all available prompts from all configured MCP servers and adapts
|
||||
* them into executable SlashCommand objects.
|
||||
*
|
||||
* @param _signal An AbortSignal (unused for this synchronous loader).
|
||||
* @returns A promise that resolves to an array of loaded SlashCommands.
|
||||
*/
|
||||
loadCommands(_signal: AbortSignal): Promise<SlashCommand[]> {
|
||||
const promptCommands: SlashCommand[] = [];
|
||||
if (!this.config) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
const mcpServers = this.config.getMcpServers() || {};
|
||||
for (const serverName in mcpServers) {
|
||||
const prompts = getMCPServerPrompts(this.config, serverName) || [];
|
||||
for (const prompt of prompts) {
|
||||
const commandName = `${prompt.name}`;
|
||||
const newPromptCommand: SlashCommand = {
|
||||
name: commandName,
|
||||
description: prompt.description || `Invoke prompt ${prompt.name}`,
|
||||
kind: CommandKind.MCP_PROMPT,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'help',
|
||||
description: 'Show help for this prompt',
|
||||
kind: CommandKind.MCP_PROMPT,
|
||||
action: async (): Promise<SlashCommandActionReturn> => {
|
||||
if (!prompt.arguments || prompt.arguments.length === 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Prompt "${prompt.name}" has no arguments.`,
|
||||
};
|
||||
}
|
||||
|
||||
let helpMessage = `Arguments for "${prompt.name}":\n\n`;
|
||||
if (prompt.arguments && prompt.arguments.length > 0) {
|
||||
helpMessage += `You can provide arguments by name (e.g., --argName="value") or by position.\n\n`;
|
||||
helpMessage += `e.g., ${prompt.name} ${prompt.arguments?.map((_) => `"foo"`)} is equivalent to ${prompt.name} ${prompt.arguments?.map((arg) => `--${arg.name}="foo"`)}\n\n`;
|
||||
}
|
||||
for (const arg of prompt.arguments) {
|
||||
helpMessage += ` --${arg.name}\n`;
|
||||
if (arg.description) {
|
||||
helpMessage += ` ${arg.description}\n`;
|
||||
}
|
||||
helpMessage += ` (required: ${
|
||||
arg.required ? 'yes' : 'no'
|
||||
})\n\n`;
|
||||
}
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: helpMessage,
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
if (!this.config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
};
|
||||
}
|
||||
|
||||
const promptInputs = this.parseArgs(args, prompt.arguments);
|
||||
if (promptInputs instanceof Error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: promptInputs.message,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const mcpServers = this.config.getMcpServers() || {};
|
||||
const mcpServerConfig = mcpServers[serverName];
|
||||
if (!mcpServerConfig) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `MCP server config not found for '${serverName}'.`,
|
||||
};
|
||||
}
|
||||
const result = await prompt.invoke(promptInputs);
|
||||
|
||||
if (result.error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Error invoking prompt: ${result.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!result.messages?.[0]?.content?.text) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content:
|
||||
'Received an empty or invalid prompt response from the server.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'submit_prompt',
|
||||
content: JSON.stringify(result.messages[0].content.text),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Error: ${getErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
completion: async (_: CommandContext, partialArg: string) => {
|
||||
if (!prompt || !prompt.arguments) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const suggestions: string[] = [];
|
||||
const usedArgNames = new Set(
|
||||
(partialArg.match(/--([^=]+)/g) || []).map((s) => s.substring(2)),
|
||||
);
|
||||
|
||||
for (const arg of prompt.arguments) {
|
||||
if (!usedArgNames.has(arg.name)) {
|
||||
suggestions.push(`--${arg.name}=""`);
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
},
|
||||
};
|
||||
promptCommands.push(newPromptCommand);
|
||||
}
|
||||
}
|
||||
return Promise.resolve(promptCommands);
|
||||
}
|
||||
|
||||
private parseArgs(
|
||||
userArgs: string,
|
||||
promptArgs: PromptArgument[] | undefined,
|
||||
): Record<string, unknown> | Error {
|
||||
const argValues: { [key: string]: string } = {};
|
||||
const promptInputs: Record<string, unknown> = {};
|
||||
|
||||
// arg parsing: --key="value" or --key=value
|
||||
const namedArgRegex = /--([^=]+)=(?:"((?:\\.|[^"\\])*)"|([^ ]*))/g;
|
||||
let match;
|
||||
const remainingArgs: string[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
while ((match = namedArgRegex.exec(userArgs)) !== null) {
|
||||
const key = match[1];
|
||||
const value = match[2] ?? match[3]; // Quoted or unquoted value
|
||||
argValues[key] = value;
|
||||
// Capture text between matches as potential positional args
|
||||
if (match.index > lastIndex) {
|
||||
remainingArgs.push(userArgs.substring(lastIndex, match.index).trim());
|
||||
}
|
||||
lastIndex = namedArgRegex.lastIndex;
|
||||
}
|
||||
|
||||
// Capture any remaining text after the last named arg
|
||||
if (lastIndex < userArgs.length) {
|
||||
remainingArgs.push(userArgs.substring(lastIndex).trim());
|
||||
}
|
||||
|
||||
const positionalArgs = remainingArgs.join(' ').split(/ +/);
|
||||
|
||||
if (!promptArgs) {
|
||||
return promptInputs;
|
||||
}
|
||||
for (const arg of promptArgs) {
|
||||
if (argValues[arg.name]) {
|
||||
promptInputs[arg.name] = argValues[arg.name];
|
||||
}
|
||||
}
|
||||
|
||||
const unfilledArgs = promptArgs.filter(
|
||||
(arg) => arg.required && !promptInputs[arg.name],
|
||||
);
|
||||
|
||||
const missingArgs: string[] = [];
|
||||
for (let i = 0; i < unfilledArgs.length; i++) {
|
||||
if (positionalArgs.length > i && positionalArgs[i]) {
|
||||
promptInputs[unfilledArgs[i].name] = positionalArgs[i];
|
||||
} else {
|
||||
missingArgs.push(unfilledArgs[i].name);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingArgs.length > 0) {
|
||||
const missingArgNames = missingArgs.map((name) => `--${name}`).join(', ');
|
||||
return new Error(`Missing required argument(s): ${missingArgNames}`);
|
||||
}
|
||||
return promptInputs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
DefaultArgumentProcessor,
|
||||
ShorthandArgumentProcessor,
|
||||
} from './argumentProcessor.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
describe('Argument Processors', () => {
|
||||
describe('ShorthandArgumentProcessor', () => {
|
||||
const processor = new ShorthandArgumentProcessor();
|
||||
|
||||
it('should replace a single {{args}} instance', async () => {
|
||||
const prompt = 'Refactor the following code: {{args}}';
|
||||
const context = createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/refactor make it faster',
|
||||
name: 'refactor',
|
||||
args: 'make it faster',
|
||||
},
|
||||
});
|
||||
const result = await processor.process(prompt, context);
|
||||
expect(result).toBe('Refactor the following code: make it faster');
|
||||
});
|
||||
|
||||
it('should replace multiple {{args}} instances', async () => {
|
||||
const prompt = 'User said: {{args}}. I repeat: {{args}}!';
|
||||
const context = createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/repeat hello world',
|
||||
name: 'repeat',
|
||||
args: 'hello world',
|
||||
},
|
||||
});
|
||||
const result = await processor.process(prompt, context);
|
||||
expect(result).toBe('User said: hello world. I repeat: hello world!');
|
||||
});
|
||||
|
||||
it('should handle an empty args string', async () => {
|
||||
const prompt = 'The user provided no input: {{args}}.';
|
||||
const context = createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/input',
|
||||
name: 'input',
|
||||
args: '',
|
||||
},
|
||||
});
|
||||
const result = await processor.process(prompt, context);
|
||||
expect(result).toBe('The user provided no input: .');
|
||||
});
|
||||
|
||||
it('should not change the prompt if {{args}} is not present', async () => {
|
||||
const prompt = 'This is a static prompt.';
|
||||
const context = createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/static some arguments',
|
||||
name: 'static',
|
||||
args: 'some arguments',
|
||||
},
|
||||
});
|
||||
const result = await processor.process(prompt, context);
|
||||
expect(result).toBe('This is a static prompt.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DefaultArgumentProcessor', () => {
|
||||
const processor = new DefaultArgumentProcessor();
|
||||
|
||||
it('should append the full command if args are provided', async () => {
|
||||
const prompt = 'Parse the command.';
|
||||
const context = createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/mycommand arg1 "arg two"',
|
||||
name: 'mycommand',
|
||||
args: 'arg1 "arg two"',
|
||||
},
|
||||
});
|
||||
const result = await processor.process(prompt, context);
|
||||
expect(result).toBe('Parse the command.\n\n/mycommand arg1 "arg two"');
|
||||
});
|
||||
|
||||
it('should NOT append the full command if no args are provided', async () => {
|
||||
const prompt = 'Parse the command.';
|
||||
const context = createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/mycommand',
|
||||
name: 'mycommand',
|
||||
args: '',
|
||||
},
|
||||
});
|
||||
const result = await processor.process(prompt, context);
|
||||
expect(result).toBe('Parse the command.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { IPromptProcessor, SHORTHAND_ARGS_PLACEHOLDER } from './types.js';
|
||||
import { CommandContext } from '../../ui/commands/types.js';
|
||||
|
||||
/**
|
||||
* Replaces all instances of `{{args}}` in a prompt with the user-provided
|
||||
* argument string.
|
||||
*/
|
||||
export class ShorthandArgumentProcessor implements IPromptProcessor {
|
||||
async process(prompt: string, context: CommandContext): Promise<string> {
|
||||
return prompt.replaceAll(
|
||||
SHORTHAND_ARGS_PLACEHOLDER,
|
||||
context.invocation!.args,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the user's full command invocation to the prompt if arguments are
|
||||
* provided, allowing the model to perform its own argument parsing.
|
||||
*/
|
||||
export class DefaultArgumentProcessor implements IPromptProcessor {
|
||||
async process(prompt: string, context: CommandContext): Promise<string> {
|
||||
if (context.invocation!.args) {
|
||||
return `${prompt}\n\n${context.invocation!.raw}`;
|
||||
}
|
||||
return prompt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ConfirmationRequiredError, ShellProcessor } from './shellProcessor.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { CommandContext } from '../../ui/commands/types.js';
|
||||
import { Config } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const mockCheckCommandPermissions = vi.hoisted(() => vi.fn());
|
||||
const mockShellExecute = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const original = await importOriginal<object>();
|
||||
return {
|
||||
...original,
|
||||
checkCommandPermissions: mockCheckCommandPermissions,
|
||||
ShellExecutionService: {
|
||||
execute: mockShellExecute,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('ShellProcessor', () => {
|
||||
let context: CommandContext;
|
||||
let mockConfig: Partial<Config>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockConfig = {
|
||||
getTargetDir: vi.fn().mockReturnValue('/test/dir'),
|
||||
};
|
||||
|
||||
context = createMockCommandContext({
|
||||
services: {
|
||||
config: mockConfig as Config,
|
||||
},
|
||||
session: {
|
||||
sessionShellAllowlist: new Set(),
|
||||
},
|
||||
});
|
||||
|
||||
mockShellExecute.mockReturnValue({
|
||||
result: Promise.resolve({
|
||||
output: 'default shell output',
|
||||
}),
|
||||
});
|
||||
mockCheckCommandPermissions.mockReturnValue({
|
||||
allAllowed: true,
|
||||
disallowedCommands: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not change the prompt if no shell injections are present', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt = 'This is a simple prompt with no injections.';
|
||||
const result = await processor.process(prompt, context);
|
||||
expect(result).toBe(prompt);
|
||||
expect(mockShellExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should process a single valid shell injection if allowed', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt = 'The current status is: !{git status}';
|
||||
mockCheckCommandPermissions.mockReturnValue({
|
||||
allAllowed: true,
|
||||
disallowedCommands: [],
|
||||
});
|
||||
mockShellExecute.mockReturnValue({
|
||||
result: Promise.resolve({ output: 'On branch main' }),
|
||||
});
|
||||
|
||||
const result = await processor.process(prompt, context);
|
||||
|
||||
expect(mockCheckCommandPermissions).toHaveBeenCalledWith(
|
||||
'git status',
|
||||
expect.any(Object),
|
||||
context.session.sessionShellAllowlist,
|
||||
);
|
||||
expect(mockShellExecute).toHaveBeenCalledWith(
|
||||
'git status',
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(result).toBe('The current status is: On branch main');
|
||||
});
|
||||
|
||||
it('should process multiple valid shell injections if all are allowed', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt = '!{git status} in !{pwd}';
|
||||
mockCheckCommandPermissions.mockReturnValue({
|
||||
allAllowed: true,
|
||||
disallowedCommands: [],
|
||||
});
|
||||
|
||||
mockShellExecute
|
||||
.mockReturnValueOnce({
|
||||
result: Promise.resolve({ output: 'On branch main' }),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
result: Promise.resolve({ output: '/usr/home' }),
|
||||
});
|
||||
|
||||
const result = await processor.process(prompt, context);
|
||||
|
||||
expect(mockCheckCommandPermissions).toHaveBeenCalledTimes(2);
|
||||
expect(mockShellExecute).toHaveBeenCalledTimes(2);
|
||||
expect(result).toBe('On branch main in /usr/home');
|
||||
});
|
||||
|
||||
it('should throw ConfirmationRequiredError if a command is not allowed', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt = 'Do something dangerous: !{rm -rf /}';
|
||||
mockCheckCommandPermissions.mockReturnValue({
|
||||
allAllowed: false,
|
||||
disallowedCommands: ['rm -rf /'],
|
||||
});
|
||||
|
||||
await expect(processor.process(prompt, context)).rejects.toThrow(
|
||||
ConfirmationRequiredError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ConfirmationRequiredError with the correct command', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt = 'Do something dangerous: !{rm -rf /}';
|
||||
mockCheckCommandPermissions.mockReturnValue({
|
||||
allAllowed: false,
|
||||
disallowedCommands: ['rm -rf /'],
|
||||
});
|
||||
|
||||
try {
|
||||
await processor.process(prompt, context);
|
||||
// Fail if it doesn't throw
|
||||
expect(true).toBe(false);
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ConfirmationRequiredError);
|
||||
if (e instanceof ConfirmationRequiredError) {
|
||||
expect(e.commandsToConfirm).toEqual(['rm -rf /']);
|
||||
}
|
||||
}
|
||||
|
||||
expect(mockShellExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw ConfirmationRequiredError with multiple commands if multiple are disallowed', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt = '!{cmd1} and !{cmd2}';
|
||||
mockCheckCommandPermissions.mockImplementation((cmd) => {
|
||||
if (cmd === 'cmd1') {
|
||||
return { allAllowed: false, disallowedCommands: ['cmd1'] };
|
||||
}
|
||||
if (cmd === 'cmd2') {
|
||||
return { allAllowed: false, disallowedCommands: ['cmd2'] };
|
||||
}
|
||||
return { allAllowed: true, disallowedCommands: [] };
|
||||
});
|
||||
|
||||
try {
|
||||
await processor.process(prompt, context);
|
||||
// Fail if it doesn't throw
|
||||
expect(true).toBe(false);
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ConfirmationRequiredError);
|
||||
if (e instanceof ConfirmationRequiredError) {
|
||||
expect(e.commandsToConfirm).toEqual(['cmd1', 'cmd2']);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should not execute any commands if at least one requires confirmation', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt = 'First: !{echo "hello"}, Second: !{rm -rf /}';
|
||||
|
||||
mockCheckCommandPermissions.mockImplementation((cmd) => {
|
||||
if (cmd.includes('rm')) {
|
||||
return { allAllowed: false, disallowedCommands: [cmd] };
|
||||
}
|
||||
return { allAllowed: true, disallowedCommands: [] };
|
||||
});
|
||||
|
||||
await expect(processor.process(prompt, context)).rejects.toThrow(
|
||||
ConfirmationRequiredError,
|
||||
);
|
||||
|
||||
// Ensure no commands were executed because the pipeline was halted.
|
||||
expect(mockShellExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should only request confirmation for disallowed commands in a mixed prompt', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt = 'Allowed: !{ls -l}, Disallowed: !{rm -rf /}';
|
||||
|
||||
mockCheckCommandPermissions.mockImplementation((cmd) => ({
|
||||
allAllowed: !cmd.includes('rm'),
|
||||
disallowedCommands: cmd.includes('rm') ? [cmd] : [],
|
||||
}));
|
||||
|
||||
try {
|
||||
await processor.process(prompt, context);
|
||||
expect.fail('Should have thrown ConfirmationRequiredError');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(ConfirmationRequiredError);
|
||||
if (e instanceof ConfirmationRequiredError) {
|
||||
expect(e.commandsToConfirm).toEqual(['rm -rf /']);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should execute all commands if they are on the session allowlist', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt = 'Run !{cmd1} and !{cmd2}';
|
||||
|
||||
// Add commands to the session allowlist
|
||||
context.session.sessionShellAllowlist = new Set(['cmd1', 'cmd2']);
|
||||
|
||||
// checkCommandPermissions should now pass for these
|
||||
mockCheckCommandPermissions.mockReturnValue({
|
||||
allAllowed: true,
|
||||
disallowedCommands: [],
|
||||
});
|
||||
|
||||
mockShellExecute
|
||||
.mockReturnValueOnce({ result: Promise.resolve({ output: 'output1' }) })
|
||||
.mockReturnValueOnce({ result: Promise.resolve({ output: 'output2' }) });
|
||||
|
||||
const result = await processor.process(prompt, context);
|
||||
|
||||
expect(mockCheckCommandPermissions).toHaveBeenCalledWith(
|
||||
'cmd1',
|
||||
expect.any(Object),
|
||||
context.session.sessionShellAllowlist,
|
||||
);
|
||||
expect(mockCheckCommandPermissions).toHaveBeenCalledWith(
|
||||
'cmd2',
|
||||
expect.any(Object),
|
||||
context.session.sessionShellAllowlist,
|
||||
);
|
||||
expect(mockShellExecute).toHaveBeenCalledTimes(2);
|
||||
expect(result).toBe('Run output1 and output2');
|
||||
});
|
||||
|
||||
it('should trim whitespace from the command inside the injection', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt = 'Files: !{ ls -l }';
|
||||
mockCheckCommandPermissions.mockReturnValue({
|
||||
allAllowed: true,
|
||||
disallowedCommands: [],
|
||||
});
|
||||
mockShellExecute.mockReturnValue({
|
||||
result: Promise.resolve({ output: 'total 0' }),
|
||||
});
|
||||
|
||||
await processor.process(prompt, context);
|
||||
|
||||
expect(mockCheckCommandPermissions).toHaveBeenCalledWith(
|
||||
'ls -l', // Verifies that the command was trimmed
|
||||
expect.any(Object),
|
||||
context.session.sessionShellAllowlist,
|
||||
);
|
||||
expect(mockShellExecute).toHaveBeenCalledWith(
|
||||
'ls -l',
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle an empty command inside the injection gracefully', async () => {
|
||||
const processor = new ShellProcessor('test-command');
|
||||
const prompt = 'This is weird: !{}';
|
||||
mockCheckCommandPermissions.mockReturnValue({
|
||||
allAllowed: true,
|
||||
disallowedCommands: [],
|
||||
});
|
||||
mockShellExecute.mockReturnValue({
|
||||
result: Promise.resolve({ output: 'empty output' }),
|
||||
});
|
||||
|
||||
const result = await processor.process(prompt, context);
|
||||
|
||||
expect(mockCheckCommandPermissions).toHaveBeenCalledWith(
|
||||
'',
|
||||
expect.any(Object),
|
||||
context.session.sessionShellAllowlist,
|
||||
);
|
||||
expect(mockShellExecute).toHaveBeenCalledWith(
|
||||
'',
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(result).toBe('This is weird: empty output');
|
||||
});
|
||||
});
|
||||
106
packages/cli/src/services/prompt-processors/shellProcessor.ts
Normal file
106
packages/cli/src/services/prompt-processors/shellProcessor.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
checkCommandPermissions,
|
||||
ShellExecutionService,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
import { CommandContext } from '../../ui/commands/types.js';
|
||||
import { IPromptProcessor } from './types.js';
|
||||
|
||||
export class ConfirmationRequiredError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public commandsToConfirm: string[],
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ConfirmationRequiredError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all instances of shell command injections (`!{...}`) in a prompt,
|
||||
* executes them, and replaces the injection site with the command's output.
|
||||
*
|
||||
* This processor ensures that only allowlisted commands are executed. If a
|
||||
* disallowed command is found, it halts execution and reports an error.
|
||||
*/
|
||||
export class ShellProcessor implements IPromptProcessor {
|
||||
/**
|
||||
* A regular expression to find all instances of `!{...}`. The inner
|
||||
* capture group extracts the command itself.
|
||||
*/
|
||||
private static readonly SHELL_INJECTION_REGEX = /!\{([^}]*)\}/g;
|
||||
|
||||
/**
|
||||
* @param commandName The name of the custom command being executed, used
|
||||
* for logging and error messages.
|
||||
*/
|
||||
constructor(private readonly commandName: string) {}
|
||||
|
||||
async process(prompt: string, context: CommandContext): Promise<string> {
|
||||
const { config, sessionShellAllowlist } = {
|
||||
...context.services,
|
||||
...context.session,
|
||||
};
|
||||
const commandsToExecute: Array<{ fullMatch: string; command: string }> = [];
|
||||
const commandsToConfirm = new Set<string>();
|
||||
|
||||
const matches = [...prompt.matchAll(ShellProcessor.SHELL_INJECTION_REGEX)];
|
||||
if (matches.length === 0) {
|
||||
return prompt; // No shell commands, nothing to do.
|
||||
}
|
||||
|
||||
// Discover all commands and check permissions.
|
||||
for (const match of matches) {
|
||||
const command = match[1].trim();
|
||||
const { allAllowed, disallowedCommands, blockReason, isHardDenial } =
|
||||
checkCommandPermissions(command, config!, sessionShellAllowlist);
|
||||
|
||||
if (!allAllowed) {
|
||||
// If it's a hard denial, this is a non-recoverable security error.
|
||||
if (isHardDenial) {
|
||||
throw new Error(
|
||||
`${this.commandName} cannot be run. ${blockReason || 'A shell command in this custom command is explicitly blocked in your config settings.'}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Add each soft denial disallowed command to the set for confirmation.
|
||||
disallowedCommands.forEach((uc) => commandsToConfirm.add(uc));
|
||||
}
|
||||
commandsToExecute.push({ fullMatch: match[0], command });
|
||||
}
|
||||
|
||||
// If any commands require confirmation, throw a special error to halt the
|
||||
// pipeline and trigger the UI flow.
|
||||
if (commandsToConfirm.size > 0) {
|
||||
throw new ConfirmationRequiredError(
|
||||
'Shell command confirmation required',
|
||||
Array.from(commandsToConfirm),
|
||||
);
|
||||
}
|
||||
|
||||
// Execute all commands (only runs if no confirmation was needed).
|
||||
let processedPrompt = prompt;
|
||||
for (const { fullMatch, command } of commandsToExecute) {
|
||||
const { result } = ShellExecutionService.execute(
|
||||
command,
|
||||
config!.getTargetDir(),
|
||||
() => {}, // No streaming needed.
|
||||
new AbortController().signal, // For now, we don't support cancellation from here.
|
||||
);
|
||||
|
||||
const executionResult = await result;
|
||||
processedPrompt = processedPrompt.replace(
|
||||
fullMatch,
|
||||
executionResult.output,
|
||||
);
|
||||
}
|
||||
|
||||
return processedPrompt;
|
||||
}
|
||||
}
|
||||
42
packages/cli/src/services/prompt-processors/types.ts
Normal file
42
packages/cli/src/services/prompt-processors/types.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandContext } from '../../ui/commands/types.js';
|
||||
|
||||
/**
|
||||
* Defines the interface for a prompt processor, a module that can transform
|
||||
* a prompt string before it is sent to the model. Processors are chained
|
||||
* together to create a processing pipeline.
|
||||
*/
|
||||
export interface IPromptProcessor {
|
||||
/**
|
||||
* Processes a prompt string, applying a specific transformation as part of a pipeline.
|
||||
*
|
||||
* Each processor in a command's pipeline receives the output of the previous
|
||||
* processor. This method provides the full command context, allowing for
|
||||
* complex transformations that may require access to invocation details,
|
||||
* application services, or UI state.
|
||||
*
|
||||
* @param prompt The current state of the prompt string. This may have been
|
||||
* modified by previous processors in the pipeline.
|
||||
* @param context The full command context, providing access to invocation
|
||||
* details (like `context.invocation.raw` and `context.invocation.args`),
|
||||
* application services, and UI handlers.
|
||||
* @returns A promise that resolves to the transformed prompt string, which
|
||||
* will be passed to the next processor or, if it's the last one, sent to the model.
|
||||
*/
|
||||
process(prompt: string, context: CommandContext): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The placeholder string for shorthand argument injection in custom commands.
|
||||
*/
|
||||
export const SHORTHAND_ARGS_PLACEHOLDER = '{{args}}';
|
||||
|
||||
/**
|
||||
* The trigger string for shell command injection in custom commands.
|
||||
*/
|
||||
export const SHELL_INJECTION_TRIGGER = '!{';
|
||||
24
packages/cli/src/services/types.ts
Normal file
24
packages/cli/src/services/types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { SlashCommand } from '../ui/commands/types.js';
|
||||
|
||||
/**
|
||||
* Defines the contract for any class that can load and provide slash commands.
|
||||
* This allows the CommandService to be extended with new command sources
|
||||
* (e.g., file-based, remote APIs) without modification.
|
||||
*
|
||||
* Loaders should receive any necessary dependencies (like Config) via their
|
||||
* constructor.
|
||||
*/
|
||||
export interface ICommandLoader {
|
||||
/**
|
||||
* Discovers and returns a list of slash commands from the loader's source.
|
||||
* @param signal An AbortSignal to allow cancellation.
|
||||
* @returns A promise that resolves to an array of SlashCommand objects.
|
||||
*/
|
||||
loadCommands(signal: AbortSignal): Promise<SlashCommand[]>;
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
import { vi } from 'vitest';
|
||||
import { CommandContext } from '../ui/commands/types.js';
|
||||
import { LoadedSettings } from '../config/settings.js';
|
||||
import { GitService } from '@google/gemini-cli-core';
|
||||
import { GitService } from '@qwen-code/qwen-code-core';
|
||||
import { SessionStatsState } from '../ui/contexts/SessionContext.js';
|
||||
|
||||
// A utility type to make all properties of an object, and its nested objects, partial.
|
||||
@@ -28,6 +28,11 @@ export const createMockCommandContext = (
|
||||
overrides: DeepPartial<CommandContext> = {},
|
||||
): CommandContext => {
|
||||
const defaultMocks: CommandContext = {
|
||||
invocation: {
|
||||
raw: '',
|
||||
name: '',
|
||||
args: '',
|
||||
},
|
||||
services: {
|
||||
config: null,
|
||||
settings: { merged: {} } as LoadedSettings,
|
||||
@@ -44,6 +49,10 @@ export const createMockCommandContext = (
|
||||
addItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
setDebugMessage: vi.fn(),
|
||||
pendingItem: null,
|
||||
setPendingItem: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
toggleCorgiMode: vi.fn(),
|
||||
},
|
||||
session: {
|
||||
stats: {
|
||||
@@ -60,9 +69,7 @@ export const createMockCommandContext = (
|
||||
byName: {},
|
||||
},
|
||||
},
|
||||
promptCount: 0,
|
||||
} as SessionStatsState,
|
||||
resetSession: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -76,15 +83,13 @@ export const createMockCommandContext = (
|
||||
const targetValue = output[key];
|
||||
|
||||
if (
|
||||
sourceValue &&
|
||||
typeof sourceValue === 'object' &&
|
||||
!Array.isArray(sourceValue) &&
|
||||
targetValue &&
|
||||
typeof targetValue === 'object' &&
|
||||
!Array.isArray(targetValue)
|
||||
// We only want to recursivlty merge plain objects
|
||||
Object.prototype.toString.call(sourceValue) === '[object Object]' &&
|
||||
Object.prototype.toString.call(targetValue) === '[object Object]'
|
||||
) {
|
||||
output[key] = merge(targetValue, sourceValue);
|
||||
} else {
|
||||
// If not, we do a direct assignment. This preserves Date objects and others.
|
||||
output[key] = sourceValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,13 @@ import {
|
||||
AccessibilitySettings,
|
||||
SandboxConfig,
|
||||
GeminiClient,
|
||||
ideContext,
|
||||
} from '@qwen-code/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 { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
||||
import { StreamingState, ConsoleMessageItem } from './types.js';
|
||||
import { Tips } from './components/Tips.js';
|
||||
|
||||
// Define a more complete mock server config based on actual Config
|
||||
@@ -58,6 +60,12 @@ interface MockServerConfig {
|
||||
getToolCallCommand: Mock<() => string | undefined>;
|
||||
getMcpServerCommand: Mock<() => string | undefined>;
|
||||
getMcpServers: Mock<() => Record<string, MCPServerConfig> | undefined>;
|
||||
getExtensions: Mock<
|
||||
() => Array<{ name: string; version: string; isActive: boolean }>
|
||||
>;
|
||||
getBlockedMcpServers: Mock<
|
||||
() => Array<{ name: string; extensionName: string }>
|
||||
>;
|
||||
getUserAgent: Mock<() => string>;
|
||||
getUserMemory: Mock<() => string>;
|
||||
setUserMemory: Mock<(newUserMemory: string) => void>;
|
||||
@@ -118,6 +126,9 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
getToolCallCommand: vi.fn(() => opts.toolCallCommand),
|
||||
getMcpServerCommand: vi.fn(() => opts.mcpServerCommand),
|
||||
getMcpServers: vi.fn(() => opts.mcpServers),
|
||||
getPromptRegistry: vi.fn(),
|
||||
getExtensions: vi.fn(() => []),
|
||||
getBlockedMcpServers: vi.fn(() => []),
|
||||
getUserAgent: vi.fn(() => opts.userAgent || 'test-agent'),
|
||||
getUserMemory: vi.fn(() => opts.userMemory || ''),
|
||||
setUserMemory: vi.fn(),
|
||||
@@ -129,19 +140,29 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
getShowMemoryUsage: vi.fn(() => opts.showMemoryUsage ?? false),
|
||||
getAccessibility: vi.fn(() => opts.accessibility ?? {}),
|
||||
getProjectRoot: vi.fn(() => opts.targetDir),
|
||||
getGeminiClient: vi.fn(() => ({})),
|
||||
getGeminiClient: vi.fn(() => ({
|
||||
getUserTier: 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),
|
||||
getIdeMode: vi.fn(() => false),
|
||||
};
|
||||
});
|
||||
|
||||
const ideContextMock = {
|
||||
getOpenFilesContext: vi.fn(),
|
||||
subscribeToOpenFiles: vi.fn(() => vi.fn()), // subscribe returns an unsubscribe function
|
||||
};
|
||||
|
||||
return {
|
||||
...actualCore,
|
||||
Config: ConfigClassMock,
|
||||
MCPServerConfig: actualCore.MCPServerConfig,
|
||||
getAllGeminiMdFilenames: vi.fn(() => ['GEMINI.md']),
|
||||
ideContext: ideContextMock,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -172,6 +193,14 @@ vi.mock('./hooks/useLogger', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('./hooks/useConsoleMessages.js', () => ({
|
||||
useConsoleMessages: vi.fn(() => ({
|
||||
consoleMessages: [],
|
||||
handleNewMessage: vi.fn(),
|
||||
clearConsoleMessages: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../config/config.js', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
@@ -213,7 +242,7 @@ describe('App UI', () => {
|
||||
settings: settings.user || {},
|
||||
};
|
||||
const workspaceSettingsFile: SettingsFile = {
|
||||
path: '/workspace/.qwen/settings.json',
|
||||
path: '/workspace/.gemini/settings.json',
|
||||
settings: settings.workspace || {},
|
||||
};
|
||||
return new LoadedSettings(
|
||||
@@ -248,6 +277,7 @@ describe('App UI', () => {
|
||||
|
||||
// Ensure a theme is set so the theme dialog does not appear.
|
||||
mockSettings = createMockSettings({ workspace: { theme: 'Default' } });
|
||||
vi.mocked(ideContext.getOpenFilesContext).mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -258,8 +288,68 @@ describe('App UI', () => {
|
||||
vi.clearAllMocks(); // Clear mocks after each test
|
||||
});
|
||||
|
||||
it('should display active file when available', async () => {
|
||||
vi.mocked(ideContext.getOpenFilesContext).mockReturnValue({
|
||||
activeFile: '/path/to/my-file.ts',
|
||||
recentOpenFiles: [{ filePath: '/path/to/my-file.ts', content: 'hello' }],
|
||||
selectedText: 'hello',
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain('1 recent file (ctrl+e to view)');
|
||||
});
|
||||
|
||||
it('should not display active file when not available', async () => {
|
||||
vi.mocked(ideContext.getOpenFilesContext).mockReturnValue({
|
||||
activeFile: '',
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).not.toContain('Open File');
|
||||
});
|
||||
|
||||
it('should display active file and other context', async () => {
|
||||
vi.mocked(ideContext.getOpenFilesContext).mockReturnValue({
|
||||
activeFile: '/path/to/my-file.ts',
|
||||
recentOpenFiles: [{ filePath: '/path/to/my-file.ts', content: 'hello' }],
|
||||
selectedText: 'hello',
|
||||
});
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.md']);
|
||||
|
||||
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 recent file (ctrl+e to view) | 1 GEMINI.md file',
|
||||
);
|
||||
});
|
||||
|
||||
it('should display default "GEMINI.md" in footer when contextFileName is not set and count is 1', async () => {
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['GEMINI.md']);
|
||||
// For this test, ensure showMemoryUsage is false or debugMode is false if it relies on that
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
@@ -273,11 +363,15 @@ describe('App UI', () => {
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve(); // Wait for any async updates
|
||||
expect(lastFrame()).toContain('Using 1 GEMINI.md file');
|
||||
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.getAllGeminiMdFilenames.mockReturnValue([
|
||||
'GEMINI.md',
|
||||
'GEMINI.md',
|
||||
]);
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
|
||||
@@ -290,7 +384,7 @@ describe('App UI', () => {
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain('Using 2 GEMINI.md files');
|
||||
expect(lastFrame()).toContain('Using: 2 GEMINI.md files');
|
||||
});
|
||||
|
||||
it('should display custom contextFileName in footer when set and count is 1', async () => {
|
||||
@@ -298,6 +392,7 @@ describe('App UI', () => {
|
||||
workspace: { contextFileName: 'AGENTS.md', theme: 'Default' },
|
||||
});
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(1);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue(['AGENTS.md']);
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
|
||||
@@ -310,7 +405,7 @@ describe('App UI', () => {
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain('Using 1 AGENTS.md file');
|
||||
expect(lastFrame()).toContain('Using: 1 AGENTS.md file');
|
||||
});
|
||||
|
||||
it('should display a generic message when multiple context files with different names are provided', async () => {
|
||||
@@ -321,6 +416,10 @@ describe('App UI', () => {
|
||||
},
|
||||
});
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
|
||||
'AGENTS.md',
|
||||
'CONTEXT.md',
|
||||
]);
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
|
||||
@@ -333,7 +432,7 @@ describe('App UI', () => {
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain('Using 2 context files');
|
||||
expect(lastFrame()).toContain('Using: 2 context files');
|
||||
});
|
||||
|
||||
it('should display custom contextFileName with plural when set and count is > 1', async () => {
|
||||
@@ -341,6 +440,11 @@ describe('App UI', () => {
|
||||
workspace: { contextFileName: 'MY_NOTES.TXT', theme: 'Default' },
|
||||
});
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(3);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
|
||||
'MY_NOTES.TXT',
|
||||
'MY_NOTES.TXT',
|
||||
'MY_NOTES.TXT',
|
||||
]);
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
|
||||
@@ -353,7 +457,7 @@ describe('App UI', () => {
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain('Using 3 MY_NOTES.TXT files');
|
||||
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 () => {
|
||||
@@ -361,6 +465,7 @@ describe('App UI', () => {
|
||||
workspace: { contextFileName: 'ANY_FILE.MD', theme: 'Default' },
|
||||
});
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(0);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue([]);
|
||||
mockConfig.getDebugMode.mockReturnValue(false);
|
||||
mockConfig.getShowMemoryUsage.mockReturnValue(false);
|
||||
|
||||
@@ -378,6 +483,10 @@ describe('App UI', () => {
|
||||
|
||||
it('should display GEMINI.md and MCP server count when both are present', async () => {
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(2);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue([
|
||||
'GEMINI.md',
|
||||
'GEMINI.md',
|
||||
]);
|
||||
mockConfig.getMcpServers.mockReturnValue({
|
||||
server1: {} as MCPServerConfig,
|
||||
});
|
||||
@@ -393,11 +502,12 @@ describe('App UI', () => {
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain('server');
|
||||
expect(lastFrame()).toContain('1 MCP server');
|
||||
});
|
||||
|
||||
it('should display only MCP server count when GEMINI.md count is 0', async () => {
|
||||
mockConfig.getGeminiMdFileCount.mockReturnValue(0);
|
||||
mockConfig.getAllGeminiMdFilenames.mockReturnValue([]);
|
||||
mockConfig.getMcpServers.mockReturnValue({
|
||||
server1: {} as MCPServerConfig,
|
||||
server2: {} as MCPServerConfig,
|
||||
@@ -414,7 +524,7 @@ describe('App UI', () => {
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
expect(lastFrame()).toContain('Using 2 MCP servers');
|
||||
expect(lastFrame()).toContain('Using: 2 MCP servers (ctrl+t to view)');
|
||||
});
|
||||
|
||||
it('should display Tips component by default', async () => {
|
||||
@@ -527,7 +637,7 @@ describe('App UI', () => {
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
|
||||
expect(lastFrame()).toContain('Select Theme');
|
||||
expect(lastFrame()).toContain("I'm Feeling Lucky (esc to cancel");
|
||||
});
|
||||
|
||||
it('should display a message if NO_COLOR is set', async () => {
|
||||
@@ -542,13 +652,43 @@ describe('App UI', () => {
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
|
||||
expect(lastFrame()).toContain(
|
||||
'Theme configuration unavailable due to NO_COLOR env variable.',
|
||||
);
|
||||
expect(lastFrame()).toContain("I'm Feeling Lucky (esc to cancel");
|
||||
expect(lastFrame()).not.toContain('Select Theme');
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the initial UI correctly', () => {
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render correctly with the prompt input box', () => {
|
||||
vi.mocked(useGeminiStream).mockReturnValue({
|
||||
streamingState: StreamingState.Idle,
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('with initial prompt from --prompt-interactive', () => {
|
||||
it('should submit the initial prompt automatically', async () => {
|
||||
const mockSubmitQuery = vi.fn();
|
||||
@@ -565,6 +705,7 @@ describe('App UI', () => {
|
||||
|
||||
mockConfig.getGeminiClient.mockReturnValue({
|
||||
isInitialized: vi.fn(() => true),
|
||||
getUserTier: vi.fn(),
|
||||
} as unknown as GeminiClient);
|
||||
|
||||
const { unmount, rerender } = render(
|
||||
@@ -592,4 +733,35 @@ describe('App UI', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errorCount', () => {
|
||||
it('should correctly sum the counts of error messages', async () => {
|
||||
const mockConsoleMessages: ConsoleMessageItem[] = [
|
||||
{ type: 'error', content: 'First error', count: 1 },
|
||||
{ type: 'log', content: 'some log', count: 1 },
|
||||
{ type: 'error', content: 'Second error', count: 3 },
|
||||
{ type: 'warn', content: 'a warning', count: 1 },
|
||||
{ type: 'error', content: 'Third error', count: 1 },
|
||||
];
|
||||
|
||||
vi.mocked(useConsoleMessages).mockReturnValue({
|
||||
consoleMessages: mockConsoleMessages,
|
||||
handleNewMessage: vi.fn(),
|
||||
clearConsoleMessages: vi.fn(),
|
||||
});
|
||||
|
||||
const { lastFrame, unmount } = render(
|
||||
<App
|
||||
config={mockConfig as unknown as ServerConfig}
|
||||
settings={mockSettings}
|
||||
version={mockVersion}
|
||||
/>,
|
||||
);
|
||||
currentUnmount = unmount;
|
||||
await Promise.resolve();
|
||||
|
||||
// Total error count should be 1 + 3 + 1 = 5
|
||||
expect(lastFrame()).toContain('5 errors');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ 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 { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js';
|
||||
import { Colors } from './colors.js';
|
||||
import { Help } from './components/Help.js';
|
||||
import { loadHierarchicalGeminiMemory } from '../config/config.js';
|
||||
@@ -46,6 +47,7 @@ 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 { IDEContextDetailDisplay } from './components/IDEContextDetailDisplay.js';
|
||||
import { useHistory } from './hooks/useHistoryManager.js';
|
||||
import process from 'node:process';
|
||||
import {
|
||||
@@ -57,6 +59,9 @@ import {
|
||||
EditorType,
|
||||
FlashFallbackEvent,
|
||||
logFlashFallback,
|
||||
AuthType,
|
||||
type OpenFiles,
|
||||
ideContext,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
import { useLogger } from './hooks/useLogger.js';
|
||||
@@ -66,8 +71,11 @@ import {
|
||||
useSessionStats,
|
||||
} from './contexts/SessionContext.js';
|
||||
import { useGitBranchName } from './hooks/useGitBranchName.js';
|
||||
import { useFocus } from './hooks/useFocus.js';
|
||||
import { useBracketedPaste } from './hooks/useBracketedPaste.js';
|
||||
import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||
import { useVimMode, VimModeProvider } from './contexts/VimModeContext.js';
|
||||
import { useVim } from './hooks/vim.js';
|
||||
import * as fs from 'fs';
|
||||
import { UpdateNotification } from './components/UpdateNotification.js';
|
||||
import {
|
||||
@@ -80,6 +88,7 @@ import ansiEscapes from 'ansi-escapes';
|
||||
import { OverflowProvider } from './contexts/OverflowContext.js';
|
||||
import { ShowMoreLines } from './components/ShowMoreLines.js';
|
||||
import { PrivacyNotice } from './privacy/PrivacyNotice.js';
|
||||
import { appEvents, AppEvent } from '../utils/events.js';
|
||||
|
||||
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
|
||||
|
||||
@@ -92,11 +101,14 @@ interface AppProps {
|
||||
|
||||
export const AppWrapper = (props: AppProps) => (
|
||||
<SessionStatsProvider>
|
||||
<App {...props} />
|
||||
<VimModeProvider settings={props.settings}>
|
||||
<App {...props} />
|
||||
</VimModeProvider>
|
||||
</SessionStatsProvider>
|
||||
);
|
||||
|
||||
const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
const isFocused = useFocus();
|
||||
useBracketedPaste();
|
||||
const [updateMessage, setUpdateMessage] = useState<string | null>(null);
|
||||
const { stdout } = useStdout();
|
||||
@@ -143,6 +155,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
const [showErrorDetails, setShowErrorDetails] = useState<boolean>(false);
|
||||
const [showToolDescriptions, setShowToolDescriptions] =
|
||||
useState<boolean>(false);
|
||||
const [showIDEContextDetail, setShowIDEContextDetail] =
|
||||
useState<boolean>(false);
|
||||
const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false);
|
||||
const [quittingMessages, setQuittingMessages] = useState<
|
||||
HistoryItem[] | null
|
||||
@@ -155,6 +169,37 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
|
||||
useState<boolean>(false);
|
||||
const [userTier, setUserTier] = useState<UserTierId | undefined>(undefined);
|
||||
const [openFiles, setOpenFiles] = useState<OpenFiles | undefined>();
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = ideContext.subscribeToOpenFiles(setOpenFiles);
|
||||
// Set the initial value
|
||||
setOpenFiles(ideContext.getOpenFilesContext());
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const openDebugConsole = () => {
|
||||
setShowErrorDetails(true);
|
||||
setConstrainHeight(false); // Make sure the user sees the full message.
|
||||
};
|
||||
appEvents.on(AppEvent.OpenDebugConsole, openDebugConsole);
|
||||
|
||||
const logErrorHandler = (errorMessage: unknown) => {
|
||||
handleNewMessage({
|
||||
type: 'error',
|
||||
content: String(errorMessage),
|
||||
count: 1,
|
||||
});
|
||||
};
|
||||
appEvents.on(AppEvent.LogError, logErrorHandler);
|
||||
|
||||
return () => {
|
||||
appEvents.off(AppEvent.OpenDebugConsole, openDebugConsole);
|
||||
appEvents.off(AppEvent.LogError, logErrorHandler);
|
||||
};
|
||||
}, [handleNewMessage]);
|
||||
|
||||
const openPrivacyNotice = useCallback(() => {
|
||||
setShowPrivacyNotice(true);
|
||||
@@ -162,7 +207,10 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
const initialPromptSubmitted = useRef(false);
|
||||
|
||||
const errorCount = useMemo(
|
||||
() => consoleMessages.filter((msg) => msg.type === 'error').length,
|
||||
() =>
|
||||
consoleMessages
|
||||
.filter((msg) => msg.type === 'error')
|
||||
.reduce((total, msg) => total + msg.count, 0),
|
||||
[consoleMessages],
|
||||
);
|
||||
|
||||
@@ -193,26 +241,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
|
||||
// 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();
|
||||
setUserTier(config.getGeminiClient()?.getUserTier());
|
||||
}
|
||||
}, [config, userTier, isAuthenticating]);
|
||||
}, [config, isAuthenticating]);
|
||||
|
||||
const {
|
||||
isEditorDialogOpen,
|
||||
@@ -238,8 +271,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
process.cwd(),
|
||||
config.getDebugMode(),
|
||||
config.getFileService(),
|
||||
settings.merged,
|
||||
config.getExtensionContextFilePaths(),
|
||||
config.getFileFilteringOptions(),
|
||||
);
|
||||
|
||||
config.setUserMemory(memoryContent);
|
||||
config.setGeminiMdFileCount(fileCount);
|
||||
setGeminiMdFileCount(fileCount);
|
||||
@@ -267,7 +303,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
);
|
||||
console.error('Error refreshing memory:', error);
|
||||
}
|
||||
}, [config, addItem]);
|
||||
}, [config, addItem, settings.merged]);
|
||||
|
||||
// Watch for model changes (e.g., from Flash fallback)
|
||||
useEffect(() => {
|
||||
@@ -294,64 +330,70 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
): 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;
|
||||
if (
|
||||
config.getContentGeneratorConfig().authType ===
|
||||
AuthType.LOGIN_WITH_GOOGLE
|
||||
) {
|
||||
// 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.
|
||||
// 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.
|
||||
} 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.
|
||||
}
|
||||
} 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.
|
||||
} 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.
|
||||
}
|
||||
} 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.
|
||||
} 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);
|
||||
}
|
||||
|
||||
// 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(
|
||||
@@ -364,15 +406,58 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
config.setFlashFallbackHandler(flashFallbackHandler);
|
||||
}, [config, addItem, userTier]);
|
||||
|
||||
// Terminal and UI setup
|
||||
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
|
||||
const { stdin, setRawMode } = useStdin();
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
const widthFraction = 0.9;
|
||||
const inputWidth = Math.max(
|
||||
20,
|
||||
Math.floor(terminalWidth * widthFraction) - 3,
|
||||
);
|
||||
const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8));
|
||||
|
||||
// Utility callbacks
|
||||
const isValidPath = useCallback((filePath: string): boolean => {
|
||||
try {
|
||||
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
|
||||
// Core hooks and processors
|
||||
const {
|
||||
vimEnabled: vimModeEnabled,
|
||||
vimMode,
|
||||
toggleVimEnabled,
|
||||
} = useVimMode();
|
||||
|
||||
const {
|
||||
handleSlashCommand,
|
||||
slashCommands,
|
||||
pendingHistoryItems: pendingSlashCommandHistoryItems,
|
||||
commandContext,
|
||||
shellConfirmationRequest,
|
||||
} = useSlashCommandProcessor(
|
||||
config,
|
||||
settings,
|
||||
history,
|
||||
addItem,
|
||||
clearItems,
|
||||
loadHistory,
|
||||
@@ -383,29 +468,44 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
openAuthDialog,
|
||||
openEditorDialog,
|
||||
toggleCorgiMode,
|
||||
showToolDescriptions,
|
||||
setQuittingMessages,
|
||||
openPrivacyNotice,
|
||||
toggleVimEnabled,
|
||||
setIsProcessing,
|
||||
);
|
||||
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 {
|
||||
streamingState,
|
||||
submitQuery,
|
||||
initError,
|
||||
pendingHistoryItems: pendingGeminiHistoryItems,
|
||||
thought,
|
||||
} = useGeminiStream(
|
||||
config.getGeminiClient(),
|
||||
history,
|
||||
addItem,
|
||||
setShowHelp,
|
||||
config,
|
||||
setDebugMessage,
|
||||
handleSlashCommand,
|
||||
shellModeActive,
|
||||
getPreferredEditor,
|
||||
onAuthError,
|
||||
performMemoryRefresh,
|
||||
modelSwitchedFromQuotaError,
|
||||
setModelSwitchedFromQuotaError,
|
||||
);
|
||||
|
||||
// Input handling
|
||||
const handleFinalSubmit = useCallback(
|
||||
(submittedValue: string) => {
|
||||
const trimmedValue = submittedValue.trim();
|
||||
if (trimmedValue.length > 0) {
|
||||
submitQuery(trimmedValue);
|
||||
}
|
||||
},
|
||||
[submitQuery],
|
||||
);
|
||||
const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8));
|
||||
|
||||
const buffer = useTextBuffer({
|
||||
initialText: '',
|
||||
@@ -416,6 +516,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
shellModeActive,
|
||||
});
|
||||
|
||||
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
|
||||
const pendingHistoryItems = [...pendingSlashCommandHistoryItems];
|
||||
pendingHistoryItems.push(...pendingGeminiHistoryItems);
|
||||
|
||||
const { elapsedTime, currentLoadingPhrase } =
|
||||
useLoadingIndicator(streamingState);
|
||||
const showAutoAcceptIndicator = useAutoAcceptIndicator({ config });
|
||||
|
||||
const handleExit = useCallback(
|
||||
(
|
||||
pressedOnce: boolean,
|
||||
@@ -426,15 +534,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
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);
|
||||
}
|
||||
// Directly invoke the central command handler.
|
||||
handleSlashCommand('/quit');
|
||||
} else {
|
||||
setPressedOnce(true);
|
||||
timerRef.current = setTimeout(() => {
|
||||
@@ -443,8 +544,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
}, CTRL_EXIT_PROMPT_DURATION_MS);
|
||||
}
|
||||
},
|
||||
// Add commandContext to the dependency array here!
|
||||
[slashCommands, commandContext],
|
||||
[handleSlashCommand],
|
||||
);
|
||||
|
||||
useInput((input: string, key: InkKeyType) => {
|
||||
@@ -468,6 +568,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
if (Object.keys(mcpServers || {}).length > 0) {
|
||||
handleSlashCommand(newValue ? '/mcp desc' : '/mcp nodesc');
|
||||
}
|
||||
} else if (key.ctrl && input === 'e' && ideContext) {
|
||||
setShowIDEContextDetail((prev) => !prev);
|
||||
} else if (key.ctrl && (input === 'c' || input === 'C')) {
|
||||
handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef);
|
||||
} else if (key.ctrl && (input === 'd' || input === 'D')) {
|
||||
@@ -487,57 +589,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
}
|
||||
}, [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[]>([]);
|
||||
|
||||
@@ -577,7 +628,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
fetchUserMessages();
|
||||
}, [history, logger]);
|
||||
|
||||
const isInputActive = streamingState === StreamingState.Idle && !initError;
|
||||
const isInputActive =
|
||||
streamingState === StreamingState.Idle && !initError && !isProcessing;
|
||||
|
||||
const handleClearScreen = useCallback(() => {
|
||||
clearItems();
|
||||
@@ -695,9 +747,13 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
// 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);
|
||||
const placeholder = vimModeEnabled
|
||||
? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode."
|
||||
: ' Type your message or @path/to/file';
|
||||
|
||||
return (
|
||||
<StreamingContext.Provider value={streamingState}>
|
||||
<Box flexDirection="column" marginBottom={1} width="90%">
|
||||
<Box flexDirection="column" width="90%">
|
||||
{/* Move UpdateNotification outside Static so it can re-render when updateMessage changes */}
|
||||
{updateMessage && <UpdateNotification message={updateMessage} />}
|
||||
|
||||
@@ -779,7 +835,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isThemeDialogOpen ? (
|
||||
{shellConfirmationRequest ? (
|
||||
<ShellConfirmationDialog request={shellConfirmationRequest} />
|
||||
) : isThemeDialogOpen ? (
|
||||
<Box flexDirection="column">
|
||||
{themeError && (
|
||||
<Box marginBottom={1}>
|
||||
@@ -864,6 +922,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
}
|
||||
elapsedTime={elapsedTime}
|
||||
/>
|
||||
|
||||
<Box
|
||||
marginTop={1}
|
||||
display="flex"
|
||||
@@ -884,9 +943,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
</Text>
|
||||
) : (
|
||||
<ContextSummaryDisplay
|
||||
openFiles={openFiles}
|
||||
geminiMdFileCount={geminiMdFileCount}
|
||||
contextFileNames={contextFileNames}
|
||||
mcpServers={config.getMcpServers()}
|
||||
blockedMcpServers={config.getBlockedMcpServers()}
|
||||
showToolDescriptions={showToolDescriptions}
|
||||
/>
|
||||
)}
|
||||
@@ -901,7 +962,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
{shellModeActive && <ShellModeIndicator />}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{showIDEContextDetail && (
|
||||
<IDEContextDetailDisplay openFiles={openFiles} />
|
||||
)}
|
||||
{showErrorDetails && (
|
||||
<OverflowProvider>
|
||||
<Box flexDirection="column">
|
||||
@@ -930,6 +993,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
commandContext={commandContext}
|
||||
shellModeActive={shellModeActive}
|
||||
setShellModeActive={setShellModeActive}
|
||||
focus={isFocused}
|
||||
vimHandleInput={vimHandleInput}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -981,6 +1047,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||
}
|
||||
promptTokenCount={sessionStats.lastPromptTokenCount}
|
||||
nightly={nightly}
|
||||
vimMode={vimModeEnabled ? vimMode : undefined}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
18
packages/cli/src/ui/__snapshots__/App.test.tsx.snap
Normal file
18
packages/cli/src/ui/__snapshots__/App.test.tsx.snap
Normal file
@@ -0,0 +1,18 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`App UI > should render correctly with the prompt input box 1`] = `
|
||||
"
|
||||
|
||||
╭────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ > Type your message or @path/to/file │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
/test/dir no sandbox (see /docs) model (100% context left)"
|
||||
`;
|
||||
|
||||
exports[`App UI > should render the initial UI correctly 1`] = `
|
||||
"
|
||||
I'm Feeling Lucky (esc to cancel, 0s)
|
||||
|
||||
|
||||
/test/dir no sandbox (see /docs) model (100% context left)"
|
||||
`;
|
||||
@@ -38,6 +38,12 @@ export const Colors: ColorsTheme = {
|
||||
get AccentRed() {
|
||||
return themeManager.getActiveTheme().colors.AccentRed;
|
||||
},
|
||||
get DiffAdded() {
|
||||
return themeManager.getActiveTheme().colors.DiffAdded;
|
||||
},
|
||||
get DiffRemoved() {
|
||||
return themeManager.getActiveTheme().colors.DiffRemoved;
|
||||
},
|
||||
get Comment() {
|
||||
return themeManager.getActiveTheme().colors.Comment;
|
||||
},
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
*/
|
||||
|
||||
import { getCliVersion } from '../../utils/version.js';
|
||||
import { SlashCommand } from './types.js';
|
||||
import { CommandKind, 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',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
const osVersion = process.platform;
|
||||
let sandboxEnv = 'no sandbox';
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||
import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||
|
||||
export const authCommand: SlashCommand = {
|
||||
name: 'auth',
|
||||
description: 'change the auth method',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (_context, _args): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'auth',
|
||||
|
||||
98
packages/cli/src/ui/commands/bugCommand.test.ts
Normal file
98
packages/cli/src/ui/commands/bugCommand.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import open from 'open';
|
||||
import { bugCommand } from './bugCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { getCliVersion } from '../../utils/version.js';
|
||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||
import { formatMemoryUsage } from '../utils/formatters.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('open');
|
||||
vi.mock('../../utils/version.js');
|
||||
vi.mock('../utils/formatters.js');
|
||||
vi.mock('node:process', () => ({
|
||||
default: {
|
||||
platform: 'test-platform',
|
||||
version: 'v20.0.0',
|
||||
// Keep other necessary process properties if needed by other parts of the code
|
||||
env: process.env,
|
||||
memoryUsage: () => ({ rss: 0 }),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('bugCommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getCliVersion).mockResolvedValue('0.1.0');
|
||||
vi.mocked(formatMemoryUsage).mockReturnValue('100 MB');
|
||||
vi.stubEnv('SANDBOX', 'qwen-test');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should generate the default GitHub issue URL', async () => {
|
||||
const mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getModel: () => 'qwen3-coder-plus',
|
||||
getBugCommand: () => undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!bugCommand.action) throw new Error('Action is not defined');
|
||||
await bugCommand.action(mockContext, 'A test bug');
|
||||
|
||||
const expectedInfo = `
|
||||
* **CLI Version:** 0.1.0
|
||||
* **Git Commit:** ${GIT_COMMIT_INFO}
|
||||
* **Operating System:** test-platform v20.0.0
|
||||
* **Sandbox Environment:** test
|
||||
* **Model Version:** qwen3-coder-plus
|
||||
* **Memory Usage:** 100 MB
|
||||
`;
|
||||
const expectedUrl =
|
||||
'https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&title=A%20test%20bug&info=' +
|
||||
encodeURIComponent(expectedInfo);
|
||||
|
||||
expect(open).toHaveBeenCalledWith(expectedUrl);
|
||||
});
|
||||
|
||||
it('should use a custom URL template from config if provided', async () => {
|
||||
const customTemplate =
|
||||
'https://internal.bug-tracker.com/new?desc={title}&details={info}';
|
||||
const mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getModel: () => 'qwen3-coder-plus',
|
||||
getBugCommand: () => ({ urlTemplate: customTemplate }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!bugCommand.action) throw new Error('Action is not defined');
|
||||
await bugCommand.action(mockContext, 'A custom bug');
|
||||
|
||||
const expectedInfo = `
|
||||
* **CLI Version:** 0.1.0
|
||||
* **Git Commit:** ${GIT_COMMIT_INFO}
|
||||
* **Operating System:** test-platform v20.0.0
|
||||
* **Sandbox Environment:** test
|
||||
* **Model Version:** qwen3-coder-plus
|
||||
* **Memory Usage:** 100 MB
|
||||
`;
|
||||
const expectedUrl = customTemplate
|
||||
.replace('{title}', encodeURIComponent('A custom bug'))
|
||||
.replace('{info}', encodeURIComponent(expectedInfo));
|
||||
|
||||
expect(open).toHaveBeenCalledWith(expectedUrl);
|
||||
});
|
||||
});
|
||||
83
packages/cli/src/ui/commands/bugCommand.ts
Normal file
83
packages/cli/src/ui/commands/bugCommand.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import open from 'open';
|
||||
import process from 'node:process';
|
||||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||
import { formatMemoryUsage } from '../utils/formatters.js';
|
||||
import { getCliVersion } from '../../utils/version.js';
|
||||
|
||||
export const bugCommand: SlashCommand = {
|
||||
name: 'bug',
|
||||
description: 'submit a bug report',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext, args?: string): Promise<void> => {
|
||||
const bugDescription = (args || '').trim();
|
||||
const { config } = context.services;
|
||||
|
||||
const osVersion = `${process.platform} ${process.version}`;
|
||||
let sandboxEnv = 'no sandbox';
|
||||
if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') {
|
||||
sandboxEnv = process.env.SANDBOX.replace(/^qwen-(?:code-)?/, '');
|
||||
} else if (process.env.SANDBOX === 'sandbox-exec') {
|
||||
sandboxEnv = `sandbox-exec (${
|
||||
process.env.SEATBELT_PROFILE || 'unknown'
|
||||
})`;
|
||||
}
|
||||
const modelVersion = config?.getModel() || 'Unknown';
|
||||
const cliVersion = await getCliVersion();
|
||||
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
|
||||
|
||||
const info = `
|
||||
* **CLI Version:** ${cliVersion}
|
||||
* **Git Commit:** ${GIT_COMMIT_INFO}
|
||||
* **Operating System:** ${osVersion}
|
||||
* **Sandbox Environment:** ${sandboxEnv}
|
||||
* **Model Version:** ${modelVersion}
|
||||
* **Memory Usage:** ${memoryUsage}
|
||||
`;
|
||||
|
||||
let bugReportUrl =
|
||||
'https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&title={title}&info={info}';
|
||||
|
||||
const bugCommandSettings = config?.getBugCommand();
|
||||
if (bugCommandSettings?.urlTemplate) {
|
||||
bugReportUrl = bugCommandSettings.urlTemplate;
|
||||
}
|
||||
|
||||
bugReportUrl = bugReportUrl
|
||||
.replace('{title}', encodeURIComponent(bugDescription))
|
||||
.replace('{info}', encodeURIComponent(info));
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `To submit your bug report, please open the following URL in your browser:\n${bugReportUrl}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
try {
|
||||
await open(bugReportUrl);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: `Could not open URL in browser: ${errorMessage}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
300
packages/cli/src/ui/commands/chatCommand.test.ts
Normal file
300
packages/cli/src/ui/commands/chatCommand.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
vi,
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
Mocked,
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
type CommandContext,
|
||||
MessageActionReturn,
|
||||
SlashCommand,
|
||||
} from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { Content } from '@google/genai';
|
||||
import { GeminiClient } from '@qwen-code/qwen-code-core';
|
||||
|
||||
import * as fsPromises from 'fs/promises';
|
||||
import { chatCommand } from './chatCommand.js';
|
||||
import { Stats } from 'fs';
|
||||
import { HistoryItemWithoutId } from '../types.js';
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
stat: vi.fn(),
|
||||
readdir: vi.fn().mockResolvedValue(['file1.txt', 'file2.txt'] as string[]),
|
||||
}));
|
||||
|
||||
describe('chatCommand', () => {
|
||||
const mockFs = fsPromises as Mocked<typeof fsPromises>;
|
||||
|
||||
let mockContext: CommandContext;
|
||||
let mockGetChat: ReturnType<typeof vi.fn>;
|
||||
let mockSaveCheckpoint: ReturnType<typeof vi.fn>;
|
||||
let mockLoadCheckpoint: ReturnType<typeof vi.fn>;
|
||||
let mockGetHistory: ReturnType<typeof vi.fn>;
|
||||
|
||||
const getSubCommand = (name: 'list' | 'save' | 'resume'): SlashCommand => {
|
||||
const subCommand = chatCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === name,
|
||||
);
|
||||
if (!subCommand) {
|
||||
throw new Error(`/memory ${name} command not found.`);
|
||||
}
|
||||
return subCommand;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetHistory = vi.fn().mockReturnValue([]);
|
||||
mockGetChat = vi.fn().mockResolvedValue({
|
||||
getHistory: mockGetHistory,
|
||||
});
|
||||
mockSaveCheckpoint = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadCheckpoint = vi.fn().mockResolvedValue([]);
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getProjectTempDir: () => '/tmp/gemini',
|
||||
getGeminiClient: () =>
|
||||
({
|
||||
getChat: mockGetChat,
|
||||
}) as unknown as GeminiClient,
|
||||
},
|
||||
logger: {
|
||||
saveCheckpoint: mockSaveCheckpoint,
|
||||
loadCheckpoint: mockLoadCheckpoint,
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should have the correct main command definition', () => {
|
||||
expect(chatCommand.name).toBe('chat');
|
||||
expect(chatCommand.description).toBe('Manage conversation history.');
|
||||
expect(chatCommand.subCommands).toHaveLength(3);
|
||||
});
|
||||
|
||||
describe('list subcommand', () => {
|
||||
let listCommand: SlashCommand;
|
||||
|
||||
beforeEach(() => {
|
||||
listCommand = getSubCommand('list');
|
||||
});
|
||||
|
||||
it('should inform when no checkpoints are found', async () => {
|
||||
mockFs.readdir.mockImplementation(
|
||||
(async (_: string): Promise<string[]> =>
|
||||
[] as string[]) as unknown as typeof fsPromises.readdir,
|
||||
);
|
||||
const result = await listCommand?.action?.(mockContext, '');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No saved conversation checkpoints found.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should list found checkpoints', async () => {
|
||||
const fakeFiles = ['checkpoint-test1.json', 'checkpoint-test2.json'];
|
||||
const date = new Date();
|
||||
|
||||
mockFs.readdir.mockImplementation(
|
||||
(async (_: string): Promise<string[]> =>
|
||||
fakeFiles as string[]) as unknown as typeof fsPromises.readdir,
|
||||
);
|
||||
mockFs.stat.mockImplementation((async (path: string): Promise<Stats> => {
|
||||
if (path.endsWith('test1.json')) {
|
||||
return { mtime: date } as Stats;
|
||||
}
|
||||
return { mtime: new Date(date.getTime() + 1000) } as Stats;
|
||||
}) as unknown as typeof fsPromises.stat);
|
||||
|
||||
const result = (await listCommand?.action?.(
|
||||
mockContext,
|
||||
'',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
const content = result?.content ?? '';
|
||||
expect(result?.type).toBe('message');
|
||||
expect(content).toContain('List of saved conversations:');
|
||||
const isoDate = date
|
||||
.toISOString()
|
||||
.match(/(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/);
|
||||
const formattedDate = isoDate ? `${isoDate[1]} ${isoDate[2]}` : '';
|
||||
expect(content).toContain(formattedDate);
|
||||
const index1 = content.indexOf('- \u001b[36mtest1\u001b[0m');
|
||||
const index2 = content.indexOf('- \u001b[36mtest2\u001b[0m');
|
||||
expect(index1).toBeGreaterThanOrEqual(0);
|
||||
expect(index2).toBeGreaterThan(index1);
|
||||
});
|
||||
|
||||
it('should handle invalid date formats gracefully', async () => {
|
||||
const fakeFiles = ['checkpoint-baddate.json'];
|
||||
const badDate = {
|
||||
toISOString: () => 'an-invalid-date-string',
|
||||
} as Date;
|
||||
|
||||
mockFs.readdir.mockResolvedValue(fakeFiles);
|
||||
mockFs.stat.mockResolvedValue({ mtime: badDate } as Stats);
|
||||
|
||||
const result = (await listCommand?.action?.(
|
||||
mockContext,
|
||||
'',
|
||||
)) as MessageActionReturn;
|
||||
|
||||
const content = result?.content ?? '';
|
||||
expect(content).toContain('(saved on Invalid Date)');
|
||||
});
|
||||
});
|
||||
describe('save subcommand', () => {
|
||||
let saveCommand: SlashCommand;
|
||||
const tag = 'my-tag';
|
||||
beforeEach(() => {
|
||||
saveCommand = getSubCommand('save');
|
||||
});
|
||||
|
||||
it('should return an error if tag is missing', async () => {
|
||||
const result = await saveCommand?.action?.(mockContext, ' ');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Missing tag. Usage: /chat save <tag>',
|
||||
});
|
||||
});
|
||||
|
||||
it('should inform if conversation history is empty', async () => {
|
||||
mockGetHistory.mockReturnValue([]);
|
||||
const result = await saveCommand?.action?.(mockContext, tag);
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No conversation found to save.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should save the conversation', async () => {
|
||||
const history: HistoryItemWithoutId[] = [
|
||||
{
|
||||
type: 'user',
|
||||
text: 'hello',
|
||||
},
|
||||
];
|
||||
mockGetHistory.mockReturnValue(history);
|
||||
const result = await saveCommand?.action?.(mockContext, tag);
|
||||
|
||||
expect(mockSaveCheckpoint).toHaveBeenCalledWith(history, tag);
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Conversation checkpoint saved with tag: ${tag}.`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resume subcommand', () => {
|
||||
const goodTag = 'good-tag';
|
||||
const badTag = 'bad-tag';
|
||||
|
||||
let resumeCommand: SlashCommand;
|
||||
beforeEach(() => {
|
||||
resumeCommand = getSubCommand('resume');
|
||||
});
|
||||
|
||||
it('should return an error if tag is missing', async () => {
|
||||
const result = await resumeCommand?.action?.(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Missing tag. Usage: /chat resume <tag>',
|
||||
});
|
||||
});
|
||||
|
||||
it('should inform if checkpoint is not found', async () => {
|
||||
mockLoadCheckpoint.mockResolvedValue([]);
|
||||
|
||||
const result = await resumeCommand?.action?.(mockContext, badTag);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `No saved checkpoint found with tag: ${badTag}.`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should resume a conversation', async () => {
|
||||
const conversation: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'hello gemini' }] },
|
||||
{ role: 'model', parts: [{ text: 'hello world' }] },
|
||||
];
|
||||
mockLoadCheckpoint.mockResolvedValue(conversation);
|
||||
|
||||
const result = await resumeCommand?.action?.(mockContext, goodTag);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'load_history',
|
||||
history: [
|
||||
{ type: 'user', text: 'hello gemini' },
|
||||
{ type: 'gemini', text: 'hello world' },
|
||||
] as HistoryItemWithoutId[],
|
||||
clientHistory: conversation,
|
||||
});
|
||||
});
|
||||
|
||||
describe('completion', () => {
|
||||
it('should provide completion suggestions', async () => {
|
||||
const fakeFiles = ['checkpoint-alpha.json', 'checkpoint-beta.json'];
|
||||
mockFs.readdir.mockImplementation(
|
||||
(async (_: string): Promise<string[]> =>
|
||||
fakeFiles as string[]) as unknown as typeof fsPromises.readdir,
|
||||
);
|
||||
|
||||
mockFs.stat.mockImplementation(
|
||||
(async (_: string): Promise<Stats> =>
|
||||
({
|
||||
mtime: new Date(),
|
||||
}) as Stats) as unknown as typeof fsPromises.stat,
|
||||
);
|
||||
|
||||
const result = await resumeCommand?.completion?.(mockContext, 'a');
|
||||
|
||||
expect(result).toEqual(['alpha']);
|
||||
});
|
||||
|
||||
it('should suggest filenames sorted by modified time (newest first)', async () => {
|
||||
const fakeFiles = ['checkpoint-test1.json', 'checkpoint-test2.json'];
|
||||
const date = new Date();
|
||||
mockFs.readdir.mockImplementation(
|
||||
(async (_: string): Promise<string[]> =>
|
||||
fakeFiles as string[]) as unknown as typeof fsPromises.readdir,
|
||||
);
|
||||
mockFs.stat.mockImplementation((async (
|
||||
path: string,
|
||||
): Promise<Stats> => {
|
||||
if (path.endsWith('test1.json')) {
|
||||
return { mtime: date } as Stats;
|
||||
}
|
||||
return { mtime: new Date(date.getTime() + 1000) } as Stats;
|
||||
}) as unknown as typeof fsPromises.stat);
|
||||
|
||||
const result = await resumeCommand?.completion?.(mockContext, '');
|
||||
// Sort items by last modified time (newest first)
|
||||
expect(result).toEqual(['test2', 'test1']);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
214
packages/cli/src/ui/commands/chatCommand.ts
Normal file
214
packages/cli/src/ui/commands/chatCommand.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fsPromises from 'fs/promises';
|
||||
import {
|
||||
CommandContext,
|
||||
SlashCommand,
|
||||
MessageActionReturn,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import path from 'path';
|
||||
import { HistoryItemWithoutId, MessageType } from '../types.js';
|
||||
|
||||
interface ChatDetail {
|
||||
name: string;
|
||||
mtime: Date;
|
||||
}
|
||||
|
||||
const getSavedChatTags = async (
|
||||
context: CommandContext,
|
||||
mtSortDesc: boolean,
|
||||
): Promise<ChatDetail[]> => {
|
||||
const geminiDir = context.services.config?.getProjectTempDir();
|
||||
if (!geminiDir) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const file_head = 'checkpoint-';
|
||||
const file_tail = '.json';
|
||||
const files = await fsPromises.readdir(geminiDir);
|
||||
const chatDetails: Array<{ name: string; mtime: Date }> = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.startsWith(file_head) && file.endsWith(file_tail)) {
|
||||
const filePath = path.join(geminiDir, file);
|
||||
const stats = await fsPromises.stat(filePath);
|
||||
chatDetails.push({
|
||||
name: file.slice(file_head.length, -file_tail.length),
|
||||
mtime: stats.mtime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
chatDetails.sort((a, b) =>
|
||||
mtSortDesc
|
||||
? b.mtime.getTime() - a.mtime.getTime()
|
||||
: a.mtime.getTime() - b.mtime.getTime(),
|
||||
);
|
||||
|
||||
return chatDetails;
|
||||
} catch (_err) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const listCommand: SlashCommand = {
|
||||
name: 'list',
|
||||
description: 'List saved conversation checkpoints',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context): Promise<MessageActionReturn> => {
|
||||
const chatDetails = await getSavedChatTags(context, false);
|
||||
if (chatDetails.length === 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No saved conversation checkpoints found.',
|
||||
};
|
||||
}
|
||||
|
||||
const maxNameLength = Math.max(
|
||||
...chatDetails.map((chat) => chat.name.length),
|
||||
);
|
||||
|
||||
let message = 'List of saved conversations:\n\n';
|
||||
for (const chat of chatDetails) {
|
||||
const paddedName = chat.name.padEnd(maxNameLength, ' ');
|
||||
const isoString = chat.mtime.toISOString();
|
||||
const match = isoString.match(/(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/);
|
||||
const formattedDate = match ? `${match[1]} ${match[2]}` : 'Invalid Date';
|
||||
message += ` - \u001b[36m${paddedName}\u001b[0m \u001b[90m(saved on ${formattedDate})\u001b[0m\n`;
|
||||
}
|
||||
message += `\n\u001b[90mNote: Newest last, oldest first\u001b[0m`;
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: message,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const saveCommand: SlashCommand = {
|
||||
name: 'save',
|
||||
description:
|
||||
'Save the current conversation as a checkpoint. Usage: /chat save <tag>',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context, args): Promise<MessageActionReturn> => {
|
||||
const tag = args.trim();
|
||||
if (!tag) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Missing tag. Usage: /chat save <tag>',
|
||||
};
|
||||
}
|
||||
|
||||
const { logger, config } = context.services;
|
||||
await logger.initialize();
|
||||
const chat = await config?.getGeminiClient()?.getChat();
|
||||
if (!chat) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'No chat client available to save conversation.',
|
||||
};
|
||||
}
|
||||
|
||||
const history = chat.getHistory();
|
||||
if (history.length > 0) {
|
||||
await logger.saveCheckpoint(history, tag);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Conversation checkpoint saved with tag: ${tag}.`,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No conversation found to save.',
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const resumeCommand: SlashCommand = {
|
||||
name: 'resume',
|
||||
altNames: ['load'],
|
||||
description:
|
||||
'Resume a conversation from a checkpoint. Usage: /chat resume <tag>',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context, args) => {
|
||||
const tag = args.trim();
|
||||
if (!tag) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Missing tag. Usage: /chat resume <tag>',
|
||||
};
|
||||
}
|
||||
|
||||
const { logger } = context.services;
|
||||
await logger.initialize();
|
||||
const conversation = await logger.loadCheckpoint(tag);
|
||||
|
||||
if (conversation.length === 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `No saved checkpoint found with tag: ${tag}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const rolemap: { [key: string]: MessageType } = {
|
||||
user: MessageType.USER,
|
||||
model: MessageType.GEMINI,
|
||||
};
|
||||
|
||||
const uiHistory: HistoryItemWithoutId[] = [];
|
||||
let hasSystemPrompt = false;
|
||||
let i = 0;
|
||||
|
||||
for (const item of conversation) {
|
||||
i += 1;
|
||||
const text =
|
||||
item.parts
|
||||
?.filter((m) => !!m.text)
|
||||
.map((m) => m.text)
|
||||
.join('') || '';
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
if (i === 1 && text.match(/context for our chat/)) {
|
||||
hasSystemPrompt = true;
|
||||
}
|
||||
if (i > 2 || !hasSystemPrompt) {
|
||||
uiHistory.push({
|
||||
type: (item.role && rolemap[item.role]) || MessageType.GEMINI,
|
||||
text,
|
||||
} as HistoryItemWithoutId);
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'load_history',
|
||||
history: uiHistory,
|
||||
clientHistory: conversation,
|
||||
};
|
||||
},
|
||||
completion: async (context, partialArg) => {
|
||||
const chatDetails = await getSavedChatTags(context, true);
|
||||
return chatDetails
|
||||
.map((chat) => chat.name)
|
||||
.filter((name) => name.startsWith(partialArg));
|
||||
},
|
||||
};
|
||||
|
||||
export const chatCommand: SlashCommand = {
|
||||
name: 'chat',
|
||||
description: 'Manage conversation history.',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [listCommand, saveCommand, resumeCommand],
|
||||
};
|
||||
@@ -8,7 +8,19 @@ 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';
|
||||
|
||||
// Mock the telemetry service
|
||||
vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||
const actual = await vi.importActual('@qwen-code/qwen-code-core');
|
||||
return {
|
||||
...actual,
|
||||
uiTelemetryService: {
|
||||
resetLastPromptTokenCount: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { GeminiClient, uiTelemetryService } from '@qwen-code/qwen-code-core';
|
||||
|
||||
describe('clearCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
@@ -16,6 +28,7 @@ describe('clearCommand', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mockResetChat = vi.fn().mockResolvedValue(undefined);
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
@@ -29,7 +42,7 @@ describe('clearCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should set debug message, reset chat, and clear UI when config is available', async () => {
|
||||
it('should set debug message, reset chat, reset telemetry, and clear UI when config is available', async () => {
|
||||
if (!clearCommand.action) {
|
||||
throw new Error('clearCommand must have an action.');
|
||||
}
|
||||
@@ -42,23 +55,24 @@ describe('clearCommand', () => {
|
||||
expect(mockContext.ui.setDebugMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockResetChat).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockContext.session.resetSession).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(uiTelemetryService.resetLastPromptTokenCount).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 resetSessionOrder = (mockContext.session.resetSession as Mock).mock
|
||||
.invocationCallOrder[0];
|
||||
const resetTelemetryOrder = (
|
||||
uiTelemetryService.resetLastPromptTokenCount as Mock
|
||||
).mock.invocationCallOrder[0];
|
||||
const clearOrder = (mockContext.ui.clear as Mock).mock
|
||||
.invocationCallOrder[0];
|
||||
|
||||
expect(setDebugMessageOrder).toBeLessThan(resetChatOrder);
|
||||
expect(resetChatOrder).toBeLessThan(resetSessionOrder);
|
||||
expect(resetSessionOrder).toBeLessThan(clearOrder);
|
||||
expect(resetChatOrder).toBeLessThan(resetTelemetryOrder);
|
||||
expect(resetTelemetryOrder).toBeLessThan(clearOrder);
|
||||
});
|
||||
|
||||
it('should not attempt to reset chat if config service is not available', async () => {
|
||||
@@ -75,10 +89,12 @@ describe('clearCommand', () => {
|
||||
await clearCommand.action(nullConfigContext, '');
|
||||
|
||||
expect(nullConfigContext.ui.setDebugMessage).toHaveBeenCalledWith(
|
||||
'Clearing terminal and resetting chat.',
|
||||
'Clearing terminal.',
|
||||
);
|
||||
expect(mockResetChat).not.toHaveBeenCalled();
|
||||
expect(nullConfigContext.session.resetSession).toHaveBeenCalledTimes(1);
|
||||
expect(uiTelemetryService.resetLastPromptTokenCount).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
expect(nullConfigContext.ui.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,15 +4,26 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { SlashCommand } from './types.js';
|
||||
import { uiTelemetryService } from '@qwen-code/qwen-code-core';
|
||||
import { CommandKind, SlashCommand } from './types.js';
|
||||
|
||||
export const clearCommand: SlashCommand = {
|
||||
name: 'clear',
|
||||
description: 'clear the screen and conversation history',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context, _args) => {
|
||||
context.ui.setDebugMessage('Clearing terminal and resetting chat.');
|
||||
await context.services.config?.getGeminiClient()?.resetChat();
|
||||
context.session.resetSession();
|
||||
const geminiClient = context.services.config?.getGeminiClient();
|
||||
|
||||
if (geminiClient) {
|
||||
context.ui.setDebugMessage('Clearing terminal and resetting chat.');
|
||||
// If resetChat fails, the exception will propagate and halt the command,
|
||||
// which is the correct behavior to signal a failure to the user.
|
||||
await geminiClient.resetChat();
|
||||
} else {
|
||||
context.ui.setDebugMessage('Clearing terminal.');
|
||||
}
|
||||
|
||||
uiTelemetryService.resetLastPromptTokenCount();
|
||||
context.ui.clear();
|
||||
},
|
||||
};
|
||||
|
||||
129
packages/cli/src/ui/commands/compressCommand.test.ts
Normal file
129
packages/cli/src/ui/commands/compressCommand.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { GeminiClient } from '@qwen-code/qwen-code-core';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { compressCommand } from './compressCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
|
||||
describe('compressCommand', () => {
|
||||
let context: ReturnType<typeof createMockCommandContext>;
|
||||
let mockTryCompressChat: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockTryCompressChat = vi.fn();
|
||||
context = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getGeminiClient: () =>
|
||||
({
|
||||
tryCompressChat: mockTryCompressChat,
|
||||
}) as unknown as GeminiClient,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should do nothing if a compression is already pending', async () => {
|
||||
context.ui.pendingItem = {
|
||||
type: MessageType.COMPRESSION,
|
||||
compression: {
|
||||
isPending: true,
|
||||
originalTokenCount: null,
|
||||
newTokenCount: null,
|
||||
},
|
||||
};
|
||||
await compressCommand.action!(context, '');
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Already compressing, wait for previous request to complete',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(context.ui.setPendingItem).not.toHaveBeenCalled();
|
||||
expect(mockTryCompressChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set pending item, call tryCompressChat, and add result on success', async () => {
|
||||
const compressedResult = {
|
||||
originalTokenCount: 200,
|
||||
newTokenCount: 100,
|
||||
};
|
||||
mockTryCompressChat.mockResolvedValue(compressedResult);
|
||||
|
||||
await compressCommand.action!(context, '');
|
||||
|
||||
expect(context.ui.setPendingItem).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
type: MessageType.COMPRESSION,
|
||||
compression: {
|
||||
isPending: true,
|
||||
originalTokenCount: null,
|
||||
newTokenCount: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockTryCompressChat).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^compress-\d+$/),
|
||||
true,
|
||||
);
|
||||
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.COMPRESSION,
|
||||
compression: {
|
||||
isPending: false,
|
||||
originalTokenCount: 200,
|
||||
newTokenCount: 100,
|
||||
},
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
expect(context.ui.setPendingItem).toHaveBeenNthCalledWith(2, null);
|
||||
});
|
||||
|
||||
it('should add an error message if tryCompressChat returns falsy', async () => {
|
||||
mockTryCompressChat.mockResolvedValue(null);
|
||||
|
||||
await compressCommand.action!(context, '');
|
||||
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Failed to compress chat history.',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('should add an error message if tryCompressChat throws', async () => {
|
||||
const error = new Error('Compression failed');
|
||||
mockTryCompressChat.mockRejectedValue(error);
|
||||
|
||||
await compressCommand.action!(context, '');
|
||||
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to compress chat history: ${error.message}`,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('should clear the pending item in a finally block', async () => {
|
||||
mockTryCompressChat.mockRejectedValue(new Error('some error'));
|
||||
await compressCommand.action!(context, '');
|
||||
expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
78
packages/cli/src/ui/commands/compressCommand.ts
Normal file
78
packages/cli/src/ui/commands/compressCommand.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { HistoryItemCompression, MessageType } from '../types.js';
|
||||
import { CommandKind, SlashCommand } from './types.js';
|
||||
|
||||
export const compressCommand: SlashCommand = {
|
||||
name: 'compress',
|
||||
altNames: ['summarize'],
|
||||
description: 'Compresses the context by replacing it with a summary.',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
const { ui } = context;
|
||||
if (ui.pendingItem) {
|
||||
ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Already compressing, wait for previous request to complete',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingMessage: HistoryItemCompression = {
|
||||
type: MessageType.COMPRESSION,
|
||||
compression: {
|
||||
isPending: true,
|
||||
originalTokenCount: null,
|
||||
newTokenCount: null,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
ui.setPendingItem(pendingMessage);
|
||||
const promptId = `compress-${Date.now()}`;
|
||||
const compressed = await context.services.config
|
||||
?.getGeminiClient()
|
||||
?.tryCompressChat(promptId, true);
|
||||
if (compressed) {
|
||||
ui.addItem(
|
||||
{
|
||||
type: MessageType.COMPRESSION,
|
||||
compression: {
|
||||
isPending: false,
|
||||
originalTokenCount: compressed.originalTokenCount,
|
||||
newTokenCount: compressed.newTokenCount,
|
||||
},
|
||||
} as HistoryItemCompression,
|
||||
Date.now(),
|
||||
);
|
||||
} else {
|
||||
ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Failed to compress chat history.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to compress chat history: ${
|
||||
e instanceof Error ? e.message : String(e)
|
||||
}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} finally {
|
||||
ui.setPendingItem(null);
|
||||
}
|
||||
},
|
||||
};
|
||||
296
packages/cli/src/ui/commands/copyCommand.test.ts
Normal file
296
packages/cli/src/ui/commands/copyCommand.test.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
|
||||
import { copyCommand } from './copyCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { copyToClipboard } from '../utils/commandUtils.js';
|
||||
|
||||
vi.mock('../utils/commandUtils.js', () => ({
|
||||
copyToClipboard: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('copyCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
let mockCopyToClipboard: Mock;
|
||||
let mockGetChat: Mock;
|
||||
let mockGetHistory: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockCopyToClipboard = vi.mocked(copyToClipboard);
|
||||
mockGetChat = vi.fn();
|
||||
mockGetHistory = vi.fn();
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getGeminiClient: () => ({
|
||||
getChat: mockGetChat,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockGetChat.mockReturnValue({
|
||||
getHistory: mockGetHistory,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return info message when no history is available', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
mockGetChat.mockReturnValue(undefined);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No output in history',
|
||||
});
|
||||
|
||||
expect(mockCopyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return info message when history is empty', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
mockGetHistory.mockReturnValue([]);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No output in history',
|
||||
});
|
||||
|
||||
expect(mockCopyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return info message when no AI messages are found in history', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const historyWithUserOnly = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'Hello' }],
|
||||
},
|
||||
];
|
||||
|
||||
mockGetHistory.mockReturnValue(historyWithUserOnly);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No output in history',
|
||||
});
|
||||
|
||||
expect(mockCopyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should copy last AI message to clipboard successfully', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const historyWithAiMessage = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'Hello' }],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'Hi there! How can I help you?' }],
|
||||
},
|
||||
];
|
||||
|
||||
mockGetHistory.mockReturnValue(historyWithAiMessage);
|
||||
mockCopyToClipboard.mockResolvedValue(undefined);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Last output copied to the clipboard',
|
||||
});
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith(
|
||||
'Hi there! How can I help you?',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple text parts in AI message', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const historyWithMultipleParts = [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'Part 1: ' }, { text: 'Part 2: ' }, { text: 'Part 3' }],
|
||||
},
|
||||
];
|
||||
|
||||
mockGetHistory.mockReturnValue(historyWithMultipleParts);
|
||||
mockCopyToClipboard.mockResolvedValue(undefined);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('Part 1: Part 2: Part 3');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Last output copied to the clipboard',
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out non-text parts', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const historyWithMixedParts = [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'Text part' },
|
||||
{ image: 'base64data' }, // Non-text part
|
||||
{ text: ' more text' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockGetHistory.mockReturnValue(historyWithMixedParts);
|
||||
mockCopyToClipboard.mockResolvedValue(undefined);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('Text part more text');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Last output copied to the clipboard',
|
||||
});
|
||||
});
|
||||
|
||||
it('should get the last AI message when multiple AI messages exist', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const historyWithMultipleAiMessages = [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'First AI response' }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'User message' }],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'Second AI response' }],
|
||||
},
|
||||
];
|
||||
|
||||
mockGetHistory.mockReturnValue(historyWithMultipleAiMessages);
|
||||
mockCopyToClipboard.mockResolvedValue(undefined);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('Second AI response');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Last output copied to the clipboard',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle clipboard copy error', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const historyWithAiMessage = [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'AI response' }],
|
||||
},
|
||||
];
|
||||
|
||||
mockGetHistory.mockReturnValue(historyWithAiMessage);
|
||||
const clipboardError = new Error('Clipboard access denied');
|
||||
mockCopyToClipboard.mockRejectedValue(clipboardError);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Failed to copy to the clipboard.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle non-Error clipboard errors', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const historyWithAiMessage = [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'AI response' }],
|
||||
},
|
||||
];
|
||||
|
||||
mockGetHistory.mockReturnValue(historyWithAiMessage);
|
||||
mockCopyToClipboard.mockRejectedValue('String error');
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Failed to copy to the clipboard.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return info message when no text parts found in AI message', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const historyWithEmptyParts = [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ image: 'base64data' }], // No text parts
|
||||
},
|
||||
];
|
||||
|
||||
mockGetHistory.mockReturnValue(historyWithEmptyParts);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Last AI output contains no text to copy.',
|
||||
});
|
||||
|
||||
expect(mockCopyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle unavailable config service', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const nullConfigContext = createMockCommandContext({
|
||||
services: { config: null },
|
||||
});
|
||||
|
||||
const result = await copyCommand.action(nullConfigContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No output in history',
|
||||
});
|
||||
|
||||
expect(mockCopyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
67
packages/cli/src/ui/commands/copyCommand.ts
Normal file
67
packages/cli/src/ui/commands/copyCommand.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { copyToClipboard } from '../utils/commandUtils.js';
|
||||
import {
|
||||
CommandKind,
|
||||
SlashCommand,
|
||||
SlashCommandActionReturn,
|
||||
} from './types.js';
|
||||
|
||||
export const copyCommand: SlashCommand = {
|
||||
name: 'copy',
|
||||
description: 'Copy the last result or code snippet to clipboard',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context, _args): Promise<SlashCommandActionReturn | void> => {
|
||||
const chat = await context.services.config?.getGeminiClient()?.getChat();
|
||||
const history = chat?.getHistory();
|
||||
|
||||
// Get the last message from the AI (model role)
|
||||
const lastAiMessage = history
|
||||
? history.filter((item) => item.role === 'model').pop()
|
||||
: undefined;
|
||||
|
||||
if (!lastAiMessage) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No output in history',
|
||||
};
|
||||
}
|
||||
// Extract text from the parts
|
||||
const lastAiOutput = lastAiMessage.parts
|
||||
?.filter((part) => part.text)
|
||||
.map((part) => part.text)
|
||||
.join('');
|
||||
|
||||
if (lastAiOutput) {
|
||||
try {
|
||||
await copyToClipboard(lastAiOutput);
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Last output copied to the clipboard',
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.debug(message);
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Failed to copy to the clipboard.',
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Last AI output contains no text to copy.',
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
34
packages/cli/src/ui/commands/corgiCommand.test.ts
Normal file
34
packages/cli/src/ui/commands/corgiCommand.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { corgiCommand } from './corgiCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
describe('corgiCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext();
|
||||
vi.spyOn(mockContext.ui, 'toggleCorgiMode');
|
||||
});
|
||||
|
||||
it('should call the toggleCorgiMode function on the UI context', async () => {
|
||||
if (!corgiCommand.action) {
|
||||
throw new Error('The corgi command must have an action.');
|
||||
}
|
||||
|
||||
await corgiCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.toggleCorgiMode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(corgiCommand.name).toBe('corgi');
|
||||
expect(corgiCommand.description).toBe('Toggles corgi mode.');
|
||||
});
|
||||
});
|
||||
16
packages/cli/src/ui/commands/corgiCommand.ts
Normal file
16
packages/cli/src/ui/commands/corgiCommand.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
|
||||
export const corgiCommand: SlashCommand = {
|
||||
name: 'corgi',
|
||||
description: 'Toggles corgi mode.',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (context, _args) => {
|
||||
context.ui.toggleCorgiMode();
|
||||
},
|
||||
};
|
||||
99
packages/cli/src/ui/commands/docsCommand.test.ts
Normal file
99
packages/cli/src/ui/commands/docsCommand.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import open from 'open';
|
||||
import { docsCommand } from './docsCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
|
||||
// Mock the 'open' library
|
||||
vi.mock('open', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('docsCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
beforeEach(() => {
|
||||
// Create a fresh mock context before each test
|
||||
mockContext = createMockCommandContext();
|
||||
// Reset the `open` mock
|
||||
vi.mocked(open).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore any stubbed environment variables
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("should add an info message and call 'open' in a non-sandbox environment", async () => {
|
||||
if (!docsCommand.action) {
|
||||
throw new Error('docsCommand must have an action.');
|
||||
}
|
||||
|
||||
const docsUrl = 'https://goo.gle/gemini-cli-docs';
|
||||
|
||||
await docsCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Opening documentation in your browser: ${docsUrl}`,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
expect(open).toHaveBeenCalledWith(docsUrl);
|
||||
});
|
||||
|
||||
it('should only add an info message in a sandbox environment', async () => {
|
||||
if (!docsCommand.action) {
|
||||
throw new Error('docsCommand must have an action.');
|
||||
}
|
||||
|
||||
// Simulate a sandbox environment
|
||||
process.env.SANDBOX = 'gemini-sandbox';
|
||||
const docsUrl = 'https://goo.gle/gemini-cli-docs';
|
||||
|
||||
await docsCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Please open the following URL in your browser to view the documentation:\n${docsUrl}`,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
// Ensure 'open' was not called in the sandbox
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not open browser for 'sandbox-exec'", async () => {
|
||||
if (!docsCommand.action) {
|
||||
throw new Error('docsCommand must have an action.');
|
||||
}
|
||||
|
||||
// Simulate the specific 'sandbox-exec' environment
|
||||
process.env.SANDBOX = 'sandbox-exec';
|
||||
const docsUrl = 'https://goo.gle/gemini-cli-docs';
|
||||
|
||||
await docsCommand.action(mockContext, '');
|
||||
|
||||
// The logic should fall through to the 'else' block
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Opening documentation in your browser: ${docsUrl}`,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
// 'open' should be called in this specific sandbox case
|
||||
expect(open).toHaveBeenCalledWith(docsUrl);
|
||||
});
|
||||
});
|
||||
42
packages/cli/src/ui/commands/docsCommand.ts
Normal file
42
packages/cli/src/ui/commands/docsCommand.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import open from 'open';
|
||||
import process from 'node:process';
|
||||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
|
||||
export const docsCommand: SlashCommand = {
|
||||
name: 'docs',
|
||||
description: 'open full Gemini CLI documentation in your browser',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext): Promise<void> => {
|
||||
const docsUrl = 'https://goo.gle/gemini-cli-docs';
|
||||
|
||||
if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Please open the following URL in your browser to view the documentation:\n${docsUrl}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} else {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Opening documentation in your browser: ${docsUrl}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
await open(docsUrl);
|
||||
}
|
||||
},
|
||||
};
|
||||
30
packages/cli/src/ui/commands/editorCommand.test.ts
Normal file
30
packages/cli/src/ui/commands/editorCommand.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { editorCommand } from './editorCommand.js';
|
||||
// 1. Import the mock context utility
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
describe('editorCommand', () => {
|
||||
it('should return a dialog action to open the editor dialog', () => {
|
||||
if (!editorCommand.action) {
|
||||
throw new Error('The editor command must have an action.');
|
||||
}
|
||||
const mockContext = createMockCommandContext();
|
||||
const result = editorCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'editor',
|
||||
});
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(editorCommand.name).toBe('editor');
|
||||
expect(editorCommand.description).toBe('set external editor preference');
|
||||
});
|
||||
});
|
||||
21
packages/cli/src/ui/commands/editorCommand.ts
Normal file
21
packages/cli/src/ui/commands/editorCommand.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
CommandKind,
|
||||
type OpenDialogActionReturn,
|
||||
type SlashCommand,
|
||||
} from './types.js';
|
||||
|
||||
export const editorCommand: SlashCommand = {
|
||||
name: 'editor',
|
||||
description: 'set external editor preference',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'editor',
|
||||
}),
|
||||
};
|
||||
67
packages/cli/src/ui/commands/extensionsCommand.test.ts
Normal file
67
packages/cli/src/ui/commands/extensionsCommand.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extensionsCommand } from './extensionsCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
|
||||
describe('extensionsCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
it('should display "No active extensions." when none are found', async () => {
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getExtensions: () => [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!extensionsCommand.action) throw new Error('Action not defined');
|
||||
await extensionsCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'No active extensions.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should list active extensions when they are found', async () => {
|
||||
const mockExtensions = [
|
||||
{ name: 'ext-one', version: '1.0.0', isActive: true },
|
||||
{ name: 'ext-two', version: '2.1.0', isActive: true },
|
||||
{ name: 'ext-three', version: '3.0.0', isActive: false },
|
||||
];
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getExtensions: () => mockExtensions,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!extensionsCommand.action) throw new Error('Action not defined');
|
||||
await extensionsCommand.action(mockContext, '');
|
||||
|
||||
const expectedMessage =
|
||||
'Active extensions:\n\n' +
|
||||
` - \u001b[36mext-one (v1.0.0)\u001b[0m\n` +
|
||||
` - \u001b[36mext-two (v2.1.0)\u001b[0m\n`;
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: expectedMessage,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
46
packages/cli/src/ui/commands/extensionsCommand.ts
Normal file
46
packages/cli/src/ui/commands/extensionsCommand.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
|
||||
export const extensionsCommand: SlashCommand = {
|
||||
name: 'extensions',
|
||||
description: 'list active extensions',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext): Promise<void> => {
|
||||
const activeExtensions = context.services.config
|
||||
?.getExtensions()
|
||||
.filter((ext) => ext.isActive);
|
||||
if (!activeExtensions || activeExtensions.length === 0) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'No active extensions.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const extensionLines = activeExtensions.map(
|
||||
(ext) => ` - \u001b[36m${ext.name} (v${ext.version})\u001b[0m`,
|
||||
);
|
||||
const message = `Active extensions:\n\n${extensionLines.join('\n')}\n`;
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: message,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -32,9 +32,9 @@ describe('helpCommand', () => {
|
||||
});
|
||||
|
||||
it("should also be triggered by its alternative name '?'", () => {
|
||||
// This test is more conceptual. The routing of altName to the command
|
||||
// This test is more conceptual. The routing of altNames 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('?');
|
||||
// altNames is correctly defined on the command object itself.
|
||||
expect(helpCommand.altNames).toContain('?');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||
import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||
|
||||
export const helpCommand: SlashCommand = {
|
||||
name: 'help',
|
||||
altName: '?',
|
||||
description: 'for help on qwen code',
|
||||
altNames: ['?'],
|
||||
description: 'for help on Qwen Code',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (_context, _args): OpenDialogActionReturn => {
|
||||
console.debug('Opening help UI ...');
|
||||
return {
|
||||
|
||||
270
packages/cli/src/ui/commands/ideCommand.test.ts
Normal file
270
packages/cli/src/ui/commands/ideCommand.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
MockInstance,
|
||||
vi,
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
} from 'vitest';
|
||||
import { ideCommand } from './ideCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { type Config } from '@qwen-code/qwen-code-core';
|
||||
import * as child_process from 'child_process';
|
||||
import { glob } from 'glob';
|
||||
|
||||
import { IDEConnectionStatus } from '@qwen-code/qwen-code-core/index.js';
|
||||
|
||||
vi.mock('child_process');
|
||||
vi.mock('glob');
|
||||
|
||||
function regexEscape(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
describe('ideCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
let mockConfig: Config;
|
||||
let execSyncSpy: MockInstance;
|
||||
let globSyncSpy: MockInstance;
|
||||
let platformSpy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = {
|
||||
ui: {
|
||||
addItem: vi.fn(),
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
|
||||
mockConfig = {
|
||||
getIdeMode: vi.fn(),
|
||||
getIdeClient: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
execSyncSpy = vi.spyOn(child_process, 'execSync');
|
||||
globSyncSpy = vi.spyOn(glob, 'sync');
|
||||
platformSpy = vi.spyOn(process, 'platform', 'get');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return null if ideMode is not enabled', () => {
|
||||
vi.mocked(mockConfig.getIdeMode).mockReturnValue(false);
|
||||
const command = ideCommand(mockConfig);
|
||||
expect(command).toBeNull();
|
||||
});
|
||||
|
||||
it('should return the ide command if ideMode is enabled', () => {
|
||||
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
|
||||
const command = ideCommand(mockConfig);
|
||||
expect(command).not.toBeNull();
|
||||
expect(command?.name).toBe('ide');
|
||||
expect(command?.subCommands).toHaveLength(2);
|
||||
expect(command?.subCommands?.[0].name).toBe('status');
|
||||
expect(command?.subCommands?.[1].name).toBe('install');
|
||||
});
|
||||
|
||||
describe('status subcommand', () => {
|
||||
const mockGetConnectionStatus = vi.fn();
|
||||
beforeEach(() => {
|
||||
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
|
||||
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
|
||||
getConnectionStatus: mockGetConnectionStatus,
|
||||
} as ReturnType<Config['getIdeClient']>);
|
||||
});
|
||||
|
||||
it('should show connected status', () => {
|
||||
mockGetConnectionStatus.mockReturnValue({
|
||||
status: IDEConnectionStatus.Connected,
|
||||
});
|
||||
const command = ideCommand(mockConfig);
|
||||
const result = command!.subCommands![0].action!(mockContext, '');
|
||||
expect(mockGetConnectionStatus).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: '🟢 Connected',
|
||||
});
|
||||
});
|
||||
|
||||
it('should show connecting status', () => {
|
||||
mockGetConnectionStatus.mockReturnValue({
|
||||
status: IDEConnectionStatus.Connecting,
|
||||
});
|
||||
const command = ideCommand(mockConfig);
|
||||
const result = command!.subCommands![0].action!(mockContext, '');
|
||||
expect(mockGetConnectionStatus).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `🟡 Connecting...`,
|
||||
});
|
||||
});
|
||||
it('should show disconnected status', () => {
|
||||
mockGetConnectionStatus.mockReturnValue({
|
||||
status: IDEConnectionStatus.Disconnected,
|
||||
});
|
||||
const command = ideCommand(mockConfig);
|
||||
const result = command!.subCommands![0].action!(mockContext, '');
|
||||
expect(mockGetConnectionStatus).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `🔴 Disconnected`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should show disconnected status with details', () => {
|
||||
const details = 'Something went wrong';
|
||||
mockGetConnectionStatus.mockReturnValue({
|
||||
status: IDEConnectionStatus.Disconnected,
|
||||
details,
|
||||
});
|
||||
const command = ideCommand(mockConfig);
|
||||
const result = command!.subCommands![0].action!(mockContext, '');
|
||||
expect(mockGetConnectionStatus).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `🔴 Disconnected: ${details}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('install subcommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
|
||||
platformSpy.mockReturnValue('linux');
|
||||
});
|
||||
|
||||
it('should show an error if VSCode is not installed', async () => {
|
||||
execSyncSpy.mockImplementation(() => {
|
||||
throw new Error('Command not found');
|
||||
});
|
||||
|
||||
const command = ideCommand(mockConfig);
|
||||
|
||||
await command!.subCommands![1].action!(mockContext, '');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
text: expect.stringMatching(/VS Code command-line tool .* not found/),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show an error if the VSIX file is not found', async () => {
|
||||
execSyncSpy.mockReturnValue(''); // VSCode is installed
|
||||
globSyncSpy.mockReturnValue([]); // No .vsix file found
|
||||
|
||||
const command = ideCommand(mockConfig);
|
||||
await command!.subCommands![1].action!(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
text: 'Could not find the required VS Code companion extension. Please file a bug via /bug.',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should install the extension if found in the bundle directory', async () => {
|
||||
const vsixPath = '/path/to/bundle/gemini.vsix';
|
||||
execSyncSpy.mockReturnValue(''); // VSCode is installed
|
||||
globSyncSpy.mockReturnValue([vsixPath]); // Found .vsix file
|
||||
|
||||
const command = ideCommand(mockConfig);
|
||||
await command!.subCommands![1].action!(mockContext, '');
|
||||
|
||||
expect(globSyncSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('.vsix'),
|
||||
);
|
||||
expect(execSyncSpy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
new RegExp(
|
||||
`code(.cmd)? --install-extension ${regexEscape(vsixPath)} --force`,
|
||||
),
|
||||
),
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'info',
|
||||
text: `Installing VS Code companion extension...`,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'info',
|
||||
text: 'VS Code companion extension installed successfully. Restart gemini-cli in a fresh terminal window.',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should install the extension if found in the dev directory', async () => {
|
||||
const vsixPath = '/path/to/dev/gemini.vsix';
|
||||
execSyncSpy.mockReturnValue(''); // VSCode is installed
|
||||
// First glob call for bundle returns nothing, second for dev returns path.
|
||||
globSyncSpy.mockReturnValueOnce([]).mockReturnValueOnce([vsixPath]);
|
||||
|
||||
const command = ideCommand(mockConfig);
|
||||
await command!.subCommands![1].action!(mockContext, '');
|
||||
|
||||
expect(globSyncSpy).toHaveBeenCalledTimes(2);
|
||||
expect(execSyncSpy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
new RegExp(
|
||||
`code(.cmd)? --install-extension ${regexEscape(vsixPath)} --force`,
|
||||
),
|
||||
),
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'info',
|
||||
text: 'VS Code companion extension installed successfully. Restart gemini-cli in a fresh terminal window.',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show an error if installation fails', async () => {
|
||||
const vsixPath = '/path/to/bundle/gemini.vsix';
|
||||
const errorMessage = 'Installation failed';
|
||||
execSyncSpy
|
||||
.mockReturnValueOnce('') // VSCode is installed check
|
||||
.mockImplementation(() => {
|
||||
// Installation command
|
||||
const error: Error & { stderr?: Buffer } = new Error(
|
||||
'Command failed',
|
||||
);
|
||||
error.stderr = Buffer.from(errorMessage);
|
||||
throw error;
|
||||
});
|
||||
globSyncSpy.mockReturnValue([vsixPath]);
|
||||
|
||||
const command = ideCommand(mockConfig);
|
||||
await command!.subCommands![1].action!(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
text: `Failed to install VS Code companion extension.`,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
157
packages/cli/src/ui/commands/ideCommand.ts
Normal file
157
packages/cli/src/ui/commands/ideCommand.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
import { Config, IDEConnectionStatus } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
CommandContext,
|
||||
SlashCommand,
|
||||
SlashCommandActionReturn,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import * as child_process from 'child_process';
|
||||
import * as process from 'process';
|
||||
import { glob } from 'glob';
|
||||
import * as path from 'path';
|
||||
|
||||
const VSCODE_COMMAND = process.platform === 'win32' ? 'code.cmd' : 'code';
|
||||
const VSCODE_COMPANION_EXTENSION_FOLDER = 'vscode-ide-companion';
|
||||
|
||||
function isVSCodeInstalled(): boolean {
|
||||
try {
|
||||
child_process.execSync(
|
||||
process.platform === 'win32'
|
||||
? `where.exe ${VSCODE_COMMAND}`
|
||||
: `command -v ${VSCODE_COMMAND}`,
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const ideCommand = (config: Config | null): SlashCommand | null => {
|
||||
if (!config?.getIdeMode()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'ide',
|
||||
description: 'manage IDE integration',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'status',
|
||||
description: 'check status of IDE integration',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (_context: CommandContext): SlashCommandActionReturn => {
|
||||
const connection = config.getIdeClient()?.getConnectionStatus();
|
||||
switch (connection?.status) {
|
||||
case IDEConnectionStatus.Connected:
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `🟢 Connected`,
|
||||
} as const;
|
||||
case IDEConnectionStatus.Connecting:
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `🟡 Connecting...`,
|
||||
} as const;
|
||||
default: {
|
||||
let content = `🔴 Disconnected`;
|
||||
if (connection?.details) {
|
||||
content += `: ${connection.details}`;
|
||||
}
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content,
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'install',
|
||||
description: 'install required VS Code companion extension',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
if (!isVSCodeInstalled()) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'error',
|
||||
text: `VS Code command-line tool "${VSCODE_COMMAND}" not found in your PATH.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const bundleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
// The VSIX file is copied to the bundle directory as part of the build.
|
||||
let vsixFiles = glob.sync(path.join(bundleDir, '*.vsix'));
|
||||
if (vsixFiles.length === 0) {
|
||||
// If the VSIX file is not in the bundle, it might be a dev
|
||||
// environment running with `npm start`. Look for it in the original
|
||||
// package location, relative to the bundle dir.
|
||||
const devPath = path.join(
|
||||
bundleDir,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
VSCODE_COMPANION_EXTENSION_FOLDER,
|
||||
'*.vsix',
|
||||
);
|
||||
vsixFiles = glob.sync(devPath);
|
||||
}
|
||||
if (vsixFiles.length === 0) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'error',
|
||||
text: 'Could not find the required VS Code companion extension. Please file a bug via /bug.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const vsixPath = vsixFiles[0];
|
||||
const command = `${VSCODE_COMMAND} --install-extension ${vsixPath} --force`;
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: `Installing VS Code companion extension...`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
try {
|
||||
child_process.execSync(command, { stdio: 'pipe' });
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: 'VS Code companion extension installed successfully. Restart gemini-cli in a fresh terminal window.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} catch (_error) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'error',
|
||||
text: `Failed to install VS Code companion extension.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
1067
packages/cli/src/ui/commands/mcpCommand.test.ts
Normal file
1067
packages/cli/src/ui/commands/mcpCommand.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
524
packages/cli/src/ui/commands/mcpCommand.ts
Normal file
524
packages/cli/src/ui/commands/mcpCommand.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
SlashCommand,
|
||||
SlashCommandActionReturn,
|
||||
CommandContext,
|
||||
CommandKind,
|
||||
MessageActionReturn,
|
||||
} from './types.js';
|
||||
import {
|
||||
DiscoveredMCPPrompt,
|
||||
DiscoveredMCPTool,
|
||||
getMCPDiscoveryState,
|
||||
getMCPServerStatus,
|
||||
MCPDiscoveryState,
|
||||
MCPServerStatus,
|
||||
mcpServerRequiresOAuth,
|
||||
getErrorMessage,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import open from 'open';
|
||||
|
||||
const COLOR_GREEN = '\u001b[32m';
|
||||
const COLOR_YELLOW = '\u001b[33m';
|
||||
const COLOR_RED = '\u001b[31m';
|
||||
const COLOR_CYAN = '\u001b[36m';
|
||||
const COLOR_GREY = '\u001b[90m';
|
||||
const RESET_COLOR = '\u001b[0m';
|
||||
|
||||
const getMcpStatus = async (
|
||||
context: CommandContext,
|
||||
showDescriptions: boolean,
|
||||
showSchema: boolean,
|
||||
showTips: boolean = false,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
};
|
||||
}
|
||||
|
||||
const toolRegistry = await config.getToolRegistry();
|
||||
if (!toolRegistry) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Could not retrieve tool registry.',
|
||||
};
|
||||
}
|
||||
|
||||
const mcpServers = config.getMcpServers() || {};
|
||||
const serverNames = Object.keys(mcpServers);
|
||||
const blockedMcpServers = config.getBlockedMcpServers() || [];
|
||||
|
||||
if (serverNames.length === 0 && blockedMcpServers.length === 0) {
|
||||
const docsUrl = 'https://goo.gle/gemini-cli-docs-mcp';
|
||||
if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `No MCP servers configured. Please open the following URL in your browser to view documentation:\n${docsUrl}`,
|
||||
};
|
||||
} else {
|
||||
// Open the URL in the browser
|
||||
await open(docsUrl);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `No MCP servers configured. Opening documentation in your browser: ${docsUrl}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any servers are still connecting
|
||||
const connectingServers = serverNames.filter(
|
||||
(name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING,
|
||||
);
|
||||
const discoveryState = getMCPDiscoveryState();
|
||||
|
||||
let message = '';
|
||||
|
||||
// Add overall discovery status message if needed
|
||||
if (
|
||||
discoveryState === MCPDiscoveryState.IN_PROGRESS ||
|
||||
connectingServers.length > 0
|
||||
) {
|
||||
message += `${COLOR_YELLOW}⏳ MCP servers are starting up (${connectingServers.length} initializing)...${RESET_COLOR}\n`;
|
||||
message += `${COLOR_CYAN}Note: First startup may take longer. Tool availability will update automatically.${RESET_COLOR}\n\n`;
|
||||
}
|
||||
|
||||
message += 'Configured MCP servers:\n\n';
|
||||
|
||||
const allTools = toolRegistry.getAllTools();
|
||||
for (const serverName of serverNames) {
|
||||
const serverTools = allTools.filter(
|
||||
(tool) =>
|
||||
tool instanceof DiscoveredMCPTool && tool.serverName === serverName,
|
||||
) as DiscoveredMCPTool[];
|
||||
const promptRegistry = await config.getPromptRegistry();
|
||||
const serverPrompts = promptRegistry.getPromptsByServer(serverName) || [];
|
||||
|
||||
const status = getMCPServerStatus(serverName);
|
||||
|
||||
// Add status indicator with descriptive text
|
||||
let statusIndicator = '';
|
||||
let statusText = '';
|
||||
switch (status) {
|
||||
case MCPServerStatus.CONNECTED:
|
||||
statusIndicator = '🟢';
|
||||
statusText = 'Ready';
|
||||
break;
|
||||
case MCPServerStatus.CONNECTING:
|
||||
statusIndicator = '🔄';
|
||||
statusText = 'Starting... (first startup may take longer)';
|
||||
break;
|
||||
case MCPServerStatus.DISCONNECTED:
|
||||
default:
|
||||
statusIndicator = '🔴';
|
||||
statusText = 'Disconnected';
|
||||
break;
|
||||
}
|
||||
|
||||
// Get server description if available
|
||||
const server = mcpServers[serverName];
|
||||
let serverDisplayName = serverName;
|
||||
if (server.extensionName) {
|
||||
serverDisplayName += ` (from ${server.extensionName})`;
|
||||
}
|
||||
|
||||
// Format server header with bold formatting and status
|
||||
message += `${statusIndicator} \u001b[1m${serverDisplayName}\u001b[0m - ${statusText}`;
|
||||
|
||||
let needsAuthHint = mcpServerRequiresOAuth.get(serverName) || false;
|
||||
// Add OAuth status if applicable
|
||||
if (server?.oauth?.enabled) {
|
||||
needsAuthHint = true;
|
||||
try {
|
||||
const { MCPOAuthTokenStorage } = await import(
|
||||
'@qwen-code/qwen-code-core'
|
||||
);
|
||||
const hasToken = await MCPOAuthTokenStorage.getToken(serverName);
|
||||
if (hasToken) {
|
||||
const isExpired = MCPOAuthTokenStorage.isTokenExpired(hasToken.token);
|
||||
if (isExpired) {
|
||||
message += ` ${COLOR_YELLOW}(OAuth token expired)${RESET_COLOR}`;
|
||||
} else {
|
||||
message += ` ${COLOR_GREEN}(OAuth authenticated)${RESET_COLOR}`;
|
||||
needsAuthHint = false;
|
||||
}
|
||||
} else {
|
||||
message += ` ${COLOR_RED}(OAuth not authenticated)${RESET_COLOR}`;
|
||||
}
|
||||
} catch (_err) {
|
||||
// If we can't check OAuth status, just continue
|
||||
}
|
||||
}
|
||||
|
||||
// Add tool count with conditional messaging
|
||||
if (status === MCPServerStatus.CONNECTED) {
|
||||
const parts = [];
|
||||
if (serverTools.length > 0) {
|
||||
parts.push(
|
||||
`${serverTools.length} ${serverTools.length === 1 ? 'tool' : 'tools'}`,
|
||||
);
|
||||
}
|
||||
if (serverPrompts.length > 0) {
|
||||
parts.push(
|
||||
`${serverPrompts.length} ${
|
||||
serverPrompts.length === 1 ? 'prompt' : 'prompts'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
message += ` (${parts.join(', ')})`;
|
||||
} else {
|
||||
message += ` (0 tools)`;
|
||||
}
|
||||
} else if (status === MCPServerStatus.CONNECTING) {
|
||||
message += ` (tools and prompts will appear when ready)`;
|
||||
} else {
|
||||
message += ` (${serverTools.length} tools cached)`;
|
||||
}
|
||||
|
||||
// Add server description with proper handling of multi-line descriptions
|
||||
if (showDescriptions && server?.description) {
|
||||
const descLines = server.description.trim().split('\n');
|
||||
if (descLines) {
|
||||
message += ':\n';
|
||||
for (const descLine of descLines) {
|
||||
message += ` ${COLOR_GREEN}${descLine}${RESET_COLOR}\n`;
|
||||
}
|
||||
} else {
|
||||
message += '\n';
|
||||
}
|
||||
} else {
|
||||
message += '\n';
|
||||
}
|
||||
|
||||
// Reset formatting after server entry
|
||||
message += RESET_COLOR;
|
||||
|
||||
if (serverTools.length > 0) {
|
||||
message += ` ${COLOR_CYAN}Tools:${RESET_COLOR}\n`;
|
||||
serverTools.forEach((tool) => {
|
||||
if (showDescriptions && tool.description) {
|
||||
// Format tool name in cyan using simple ANSI cyan color
|
||||
message += ` - ${COLOR_CYAN}${tool.name}${RESET_COLOR}`;
|
||||
|
||||
// Handle multi-line descriptions by properly indenting and preserving formatting
|
||||
const descLines = tool.description.trim().split('\n');
|
||||
if (descLines) {
|
||||
message += ':\n';
|
||||
for (const descLine of descLines) {
|
||||
message += ` ${COLOR_GREEN}${descLine}${RESET_COLOR}\n`;
|
||||
}
|
||||
} else {
|
||||
message += '\n';
|
||||
}
|
||||
// Reset is handled inline with each line now
|
||||
} else {
|
||||
// Use cyan color for the tool name even when not showing descriptions
|
||||
message += ` - ${COLOR_CYAN}${tool.name}${RESET_COLOR}\n`;
|
||||
}
|
||||
const parameters =
|
||||
tool.schema.parametersJsonSchema ?? tool.schema.parameters;
|
||||
if (showSchema && parameters) {
|
||||
// Prefix the parameters in cyan
|
||||
message += ` ${COLOR_CYAN}Parameters:${RESET_COLOR}\n`;
|
||||
|
||||
const paramsLines = JSON.stringify(parameters, null, 2)
|
||||
.trim()
|
||||
.split('\n');
|
||||
if (paramsLines) {
|
||||
for (const paramsLine of paramsLines) {
|
||||
message += ` ${COLOR_GREEN}${paramsLine}${RESET_COLOR}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if (serverPrompts.length > 0) {
|
||||
if (serverTools.length > 0) {
|
||||
message += '\n';
|
||||
}
|
||||
message += ` ${COLOR_CYAN}Prompts:${RESET_COLOR}\n`;
|
||||
serverPrompts.forEach((prompt: DiscoveredMCPPrompt) => {
|
||||
if (showDescriptions && prompt.description) {
|
||||
message += ` - ${COLOR_CYAN}${prompt.name}${RESET_COLOR}`;
|
||||
const descLines = prompt.description.trim().split('\n');
|
||||
if (descLines) {
|
||||
message += ':\n';
|
||||
for (const descLine of descLines) {
|
||||
message += ` ${COLOR_GREEN}${descLine}${RESET_COLOR}\n`;
|
||||
}
|
||||
} else {
|
||||
message += '\n';
|
||||
}
|
||||
} else {
|
||||
message += ` - ${COLOR_CYAN}${prompt.name}${RESET_COLOR}\n`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (serverTools.length === 0 && serverPrompts.length === 0) {
|
||||
message += ' No tools or prompts available\n';
|
||||
} else if (serverTools.length === 0) {
|
||||
message += ' No tools available';
|
||||
if (status === MCPServerStatus.DISCONNECTED && needsAuthHint) {
|
||||
message += ` ${COLOR_GREY}(type: "/mcp auth ${serverName}" to authenticate this server)${RESET_COLOR}`;
|
||||
}
|
||||
message += '\n';
|
||||
} else if (status === MCPServerStatus.DISCONNECTED && needsAuthHint) {
|
||||
// This case is for when serverTools.length > 0
|
||||
message += ` ${COLOR_GREY}(type: "/mcp auth ${serverName}" to authenticate this server)${RESET_COLOR}\n`;
|
||||
}
|
||||
message += '\n';
|
||||
}
|
||||
|
||||
for (const server of blockedMcpServers) {
|
||||
let serverDisplayName = server.name;
|
||||
if (server.extensionName) {
|
||||
serverDisplayName += ` (from ${server.extensionName})`;
|
||||
}
|
||||
message += `🔴 \u001b[1m${serverDisplayName}\u001b[0m - Blocked\n\n`;
|
||||
}
|
||||
|
||||
// Add helpful tips when no arguments are provided
|
||||
if (showTips) {
|
||||
message += '\n';
|
||||
message += `${COLOR_CYAN}💡 Tips:${RESET_COLOR}\n`;
|
||||
message += ` • Use ${COLOR_CYAN}/mcp desc${RESET_COLOR} to show server and tool descriptions\n`;
|
||||
message += ` • Use ${COLOR_CYAN}/mcp schema${RESET_COLOR} to show tool parameter schemas\n`;
|
||||
message += ` • Use ${COLOR_CYAN}/mcp nodesc${RESET_COLOR} to hide descriptions\n`;
|
||||
message += ` • Use ${COLOR_CYAN}/mcp auth <server-name>${RESET_COLOR} to authenticate with OAuth-enabled servers\n`;
|
||||
message += ` • Press ${COLOR_CYAN}Ctrl+T${RESET_COLOR} to toggle tool descriptions on/off\n`;
|
||||
message += '\n';
|
||||
}
|
||||
|
||||
// Make sure to reset any ANSI formatting at the end to prevent it from affecting the terminal
|
||||
message += RESET_COLOR;
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: message,
|
||||
};
|
||||
};
|
||||
|
||||
const authCommand: SlashCommand = {
|
||||
name: 'auth',
|
||||
description: 'Authenticate with an OAuth-enabled MCP server',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<MessageActionReturn> => {
|
||||
const serverName = args.trim();
|
||||
const { config } = context.services;
|
||||
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
};
|
||||
}
|
||||
|
||||
const mcpServers = config.getMcpServers() || {};
|
||||
|
||||
if (!serverName) {
|
||||
// List servers that support OAuth
|
||||
const oauthServers = Object.entries(mcpServers)
|
||||
.filter(([_, server]) => server.oauth?.enabled)
|
||||
.map(([name, _]) => name);
|
||||
|
||||
if (oauthServers.length === 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No MCP servers configured with OAuth authentication.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `MCP servers with OAuth authentication:\n${oauthServers.map((s) => ` - ${s}`).join('\n')}\n\nUse /mcp auth <server-name> to authenticate.`,
|
||||
};
|
||||
}
|
||||
|
||||
const server = mcpServers[serverName];
|
||||
if (!server) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `MCP server '${serverName}' not found.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Always attempt OAuth authentication, even if not explicitly configured
|
||||
// The authentication process will discover OAuth requirements automatically
|
||||
|
||||
try {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: `Starting OAuth authentication for MCP server '${serverName}'...`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
// Import dynamically to avoid circular dependencies
|
||||
const { MCPOAuthProvider } = await import('@qwen-code/qwen-code-core');
|
||||
|
||||
let oauthConfig = server.oauth;
|
||||
if (!oauthConfig) {
|
||||
oauthConfig = { enabled: false };
|
||||
}
|
||||
|
||||
// Pass the MCP server URL for OAuth discovery
|
||||
const mcpServerUrl = server.httpUrl || server.url;
|
||||
await MCPOAuthProvider.authenticate(
|
||||
serverName,
|
||||
oauthConfig,
|
||||
mcpServerUrl,
|
||||
);
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: `✅ Successfully authenticated with MCP server '${serverName}'!`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
// Trigger tool re-discovery to pick up authenticated server
|
||||
const toolRegistry = await config.getToolRegistry();
|
||||
if (toolRegistry) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: `Re-discovering tools from '${serverName}'...`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
await toolRegistry.discoverToolsForServer(serverName);
|
||||
}
|
||||
// Update the client with the new tools
|
||||
const geminiClient = config.getGeminiClient();
|
||||
if (geminiClient) {
|
||||
await geminiClient.setTools();
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Successfully authenticated and refreshed tools for '${serverName}'.`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to authenticate with MCP server '${serverName}': ${getErrorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
completion: async (context: CommandContext, partialArg: string) => {
|
||||
const { config } = context.services;
|
||||
if (!config) return [];
|
||||
|
||||
const mcpServers = config.getMcpServers() || {};
|
||||
return Object.keys(mcpServers).filter((name) =>
|
||||
name.startsWith(partialArg),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const listCommand: SlashCommand = {
|
||||
name: 'list',
|
||||
description: 'List configured MCP servers and tools',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext, args: string) => {
|
||||
const lowerCaseArgs = args.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
|
||||
const hasDesc =
|
||||
lowerCaseArgs.includes('desc') || lowerCaseArgs.includes('descriptions');
|
||||
const hasNodesc =
|
||||
lowerCaseArgs.includes('nodesc') ||
|
||||
lowerCaseArgs.includes('nodescriptions');
|
||||
const showSchema = lowerCaseArgs.includes('schema');
|
||||
|
||||
// Show descriptions if `desc` or `schema` is present,
|
||||
// but `nodesc` takes precedence and disables them.
|
||||
const showDescriptions = !hasNodesc && (hasDesc || showSchema);
|
||||
|
||||
// Show tips only when no arguments are provided
|
||||
const showTips = lowerCaseArgs.length === 0;
|
||||
|
||||
return getMcpStatus(context, showDescriptions, showSchema, showTips);
|
||||
},
|
||||
};
|
||||
|
||||
const refreshCommand: SlashCommand = {
|
||||
name: 'refresh',
|
||||
description: 'Refresh the list of MCP servers and tools',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
};
|
||||
}
|
||||
|
||||
const toolRegistry = await config.getToolRegistry();
|
||||
if (!toolRegistry) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Could not retrieve tool registry.',
|
||||
};
|
||||
}
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: 'Refreshing MCP servers and tools...',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
await toolRegistry.discoverMcpTools();
|
||||
|
||||
// Update the client with the new tools
|
||||
const geminiClient = config.getGeminiClient();
|
||||
if (geminiClient) {
|
||||
await geminiClient.setTools();
|
||||
}
|
||||
|
||||
return getMcpStatus(context, false, false, false);
|
||||
},
|
||||
};
|
||||
|
||||
export const mcpCommand: SlashCommand = {
|
||||
name: 'mcp',
|
||||
description:
|
||||
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [listCommand, authCommand, refreshCommand],
|
||||
// Default action when no subcommand is provided
|
||||
action: async (context: CommandContext, args: string) =>
|
||||
// If no subcommand, run the list command
|
||||
listCommand.action!(context, args),
|
||||
};
|
||||
@@ -9,7 +9,12 @@ 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-code/qwen-code-core';
|
||||
import { LoadedSettings } from '../../config/settings.js';
|
||||
import {
|
||||
getErrorMessage,
|
||||
loadServerHierarchicalMemory,
|
||||
type FileDiscoveryService,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const original =
|
||||
@@ -20,9 +25,12 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}),
|
||||
loadServerHierarchicalMemory: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockLoadServerHierarchicalMemory = loadServerHierarchicalMemory as Mock;
|
||||
|
||||
describe('memoryCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
@@ -139,19 +147,37 @@ describe('memoryCommand', () => {
|
||||
|
||||
describe('/memory refresh', () => {
|
||||
let refreshCommand: SlashCommand;
|
||||
let mockRefreshMemory: Mock;
|
||||
let mockSetUserMemory: Mock;
|
||||
let mockSetGeminiMdFileCount: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
refreshCommand = getSubCommand('refresh');
|
||||
mockRefreshMemory = vi.fn();
|
||||
mockSetUserMemory = vi.fn();
|
||||
mockSetGeminiMdFileCount = vi.fn();
|
||||
const mockConfig = {
|
||||
setUserMemory: mockSetUserMemory,
|
||||
setGeminiMdFileCount: mockSetGeminiMdFileCount,
|
||||
getWorkingDir: () => '/test/dir',
|
||||
getDebugMode: () => false,
|
||||
getFileService: () => ({}) as FileDiscoveryService,
|
||||
getExtensionContextFilePaths: () => [],
|
||||
getFileFilteringOptions: () => ({
|
||||
ignore: [],
|
||||
include: [],
|
||||
}),
|
||||
};
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
refreshMemory: mockRefreshMemory,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
config: Promise.resolve(mockConfig),
|
||||
settings: {
|
||||
merged: {
|
||||
memoryDiscoveryMaxDirs: 1000,
|
||||
},
|
||||
} as LoadedSettings,
|
||||
},
|
||||
});
|
||||
mockLoadServerHierarchicalMemory.mockClear();
|
||||
});
|
||||
|
||||
it('should display success message when memory is refreshed with content', async () => {
|
||||
@@ -161,7 +187,7 @@ describe('memoryCommand', () => {
|
||||
memoryContent: 'new memory content',
|
||||
fileCount: 2,
|
||||
};
|
||||
mockRefreshMemory.mockResolvedValue(refreshResult);
|
||||
mockLoadServerHierarchicalMemory.mockResolvedValue(refreshResult);
|
||||
|
||||
await refreshCommand.action(mockContext, '');
|
||||
|
||||
@@ -173,7 +199,13 @@ describe('memoryCommand', () => {
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
expect(mockRefreshMemory).toHaveBeenCalledOnce();
|
||||
expect(loadServerHierarchicalMemory).toHaveBeenCalledOnce();
|
||||
expect(mockSetUserMemory).toHaveBeenCalledWith(
|
||||
refreshResult.memoryContent,
|
||||
);
|
||||
expect(mockSetGeminiMdFileCount).toHaveBeenCalledWith(
|
||||
refreshResult.fileCount,
|
||||
);
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
@@ -188,11 +220,13 @@ describe('memoryCommand', () => {
|
||||
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const refreshResult = { memoryContent: '', fileCount: 0 };
|
||||
mockRefreshMemory.mockResolvedValue(refreshResult);
|
||||
mockLoadServerHierarchicalMemory.mockResolvedValue(refreshResult);
|
||||
|
||||
await refreshCommand.action(mockContext, '');
|
||||
|
||||
expect(mockRefreshMemory).toHaveBeenCalledOnce();
|
||||
expect(loadServerHierarchicalMemory).toHaveBeenCalledOnce();
|
||||
expect(mockSetUserMemory).toHaveBeenCalledWith('');
|
||||
expect(mockSetGeminiMdFileCount).toHaveBeenCalledWith(0);
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
@@ -207,11 +241,13 @@ describe('memoryCommand', () => {
|
||||
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const error = new Error('Failed to read memory files.');
|
||||
mockRefreshMemory.mockRejectedValue(error);
|
||||
mockLoadServerHierarchicalMemory.mockRejectedValue(error);
|
||||
|
||||
await refreshCommand.action(mockContext, '');
|
||||
|
||||
expect(mockRefreshMemory).toHaveBeenCalledOnce();
|
||||
expect(loadServerHierarchicalMemory).toHaveBeenCalledOnce();
|
||||
expect(mockSetUserMemory).not.toHaveBeenCalled();
|
||||
expect(mockSetGeminiMdFileCount).not.toHaveBeenCalled();
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
@@ -243,7 +279,7 @@ describe('memoryCommand', () => {
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
expect(mockRefreshMemory).not.toHaveBeenCalled();
|
||||
expect(loadServerHierarchicalMemory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,17 +4,26 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { getErrorMessage } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
getErrorMessage,
|
||||
loadServerHierarchicalMemory,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { MessageType } from '../types.js';
|
||||
import { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||
import {
|
||||
CommandKind,
|
||||
SlashCommand,
|
||||
SlashCommandActionReturn,
|
||||
} from './types.js';
|
||||
|
||||
export const memoryCommand: SlashCommand = {
|
||||
name: 'memory',
|
||||
description: 'Commands for interacting with memory.',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'show',
|
||||
description: 'Show the current memory contents.',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
const memoryContent = context.services.config?.getUserMemory() || '';
|
||||
const fileCount = context.services.config?.getGeminiMdFileCount() || 0;
|
||||
@@ -36,6 +45,7 @@ export const memoryCommand: SlashCommand = {
|
||||
{
|
||||
name: 'add',
|
||||
description: 'Add content to the memory.',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (context, args): SlashCommandActionReturn | void => {
|
||||
if (!args || args.trim() === '') {
|
||||
return {
|
||||
@@ -63,6 +73,7 @@ export const memoryCommand: SlashCommand = {
|
||||
{
|
||||
name: 'refresh',
|
||||
description: 'Refresh the memory from the source.',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
context.ui.addItem(
|
||||
{
|
||||
@@ -73,10 +84,20 @@ export const memoryCommand: SlashCommand = {
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await context.services.config?.refreshMemory();
|
||||
const config = await context.services.config;
|
||||
if (config) {
|
||||
const { memoryContent, fileCount } =
|
||||
await loadServerHierarchicalMemory(
|
||||
config.getWorkingDir(),
|
||||
config.getDebugMode(),
|
||||
config.getFileService(),
|
||||
config.getExtensionContextFilePaths(),
|
||||
config.getFileFilteringOptions(),
|
||||
context.services.settings.merged.memoryDiscoveryMaxDirs,
|
||||
);
|
||||
config.setUserMemory(memoryContent);
|
||||
config.setGeminiMdFileCount(fileCount);
|
||||
|
||||
if (result) {
|
||||
const { memoryContent, fileCount } = result;
|
||||
const successMessage =
|
||||
memoryContent.length > 0
|
||||
? `Memory refreshed successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||
import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||
|
||||
export const privacyCommand: SlashCommand = {
|
||||
name: 'privacy',
|
||||
description: 'display the privacy notice',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'privacy',
|
||||
|
||||
55
packages/cli/src/ui/commands/quitCommand.test.ts
Normal file
55
packages/cli/src/ui/commands/quitCommand.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { quitCommand } from './quitCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
|
||||
vi.mock('../utils/formatters.js');
|
||||
|
||||
describe('quitCommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-01-01T01:00:00Z'));
|
||||
vi.mocked(formatDuration).mockReturnValue('1h 0m 0s');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns a QuitActionReturn object with the correct messages', () => {
|
||||
const mockContext = createMockCommandContext({
|
||||
session: {
|
||||
stats: {
|
||||
sessionStartTime: new Date('2025-01-01T00:00:00Z'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!quitCommand.action) throw new Error('Action is not defined');
|
||||
const result = quitCommand.action(mockContext, 'quit');
|
||||
|
||||
expect(formatDuration).toHaveBeenCalledWith(3600000); // 1 hour in ms
|
||||
expect(result).toEqual({
|
||||
type: 'quit',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
text: '/quit',
|
||||
id: expect.any(Number),
|
||||
},
|
||||
{
|
||||
type: 'quit',
|
||||
duration: '1h 0m 0s',
|
||||
id: expect.any(Number),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
36
packages/cli/src/ui/commands/quitCommand.ts
Normal file
36
packages/cli/src/ui/commands/quitCommand.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
|
||||
export const quitCommand: SlashCommand = {
|
||||
name: 'quit',
|
||||
altNames: ['exit'],
|
||||
description: 'exit the cli',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (context) => {
|
||||
const now = Date.now();
|
||||
const { sessionStartTime } = context.session.stats;
|
||||
const wallDuration = now - sessionStartTime.getTime();
|
||||
|
||||
return {
|
||||
type: 'quit',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
text: `/quit`, // Keep it consistent, even if /exit was used
|
||||
id: now - 1,
|
||||
},
|
||||
{
|
||||
type: 'quit',
|
||||
duration: formatDuration(wallDuration),
|
||||
id: now,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
250
packages/cli/src/ui/commands/restoreCommand.test.ts
Normal file
250
packages/cli/src/ui/commands/restoreCommand.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { restoreCommand } from './restoreCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { Config, GitService } from '@qwen-code/qwen-code-core';
|
||||
|
||||
describe('restoreCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
let mockConfig: Config;
|
||||
let mockGitService: GitService;
|
||||
let mockSetHistory: ReturnType<typeof vi.fn>;
|
||||
let testRootDir: string;
|
||||
let geminiTempDir: string;
|
||||
let checkpointsDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testRootDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'restore-command-test-'),
|
||||
);
|
||||
geminiTempDir = path.join(testRootDir, '.gemini');
|
||||
checkpointsDir = path.join(geminiTempDir, 'checkpoints');
|
||||
// The command itself creates this, but for tests it's easier to have it ready.
|
||||
// Some tests might remove it to test error paths.
|
||||
await fs.mkdir(checkpointsDir, { recursive: true });
|
||||
|
||||
mockSetHistory = vi.fn().mockResolvedValue(undefined);
|
||||
mockGitService = {
|
||||
restoreProjectFromSnapshot: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as GitService;
|
||||
|
||||
mockConfig = {
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(true),
|
||||
getProjectTempDir: vi.fn().mockReturnValue(geminiTempDir),
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
setHistory: mockSetHistory,
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: mockConfig,
|
||||
git: mockGitService,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await fs.rm(testRootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should return null if checkpointing is not enabled', () => {
|
||||
vi.mocked(mockConfig.getCheckpointingEnabled).mockReturnValue(false);
|
||||
|
||||
expect(restoreCommand(mockConfig)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return the command if checkpointing is enabled', () => {
|
||||
expect(restoreCommand(mockConfig)).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'restore',
|
||||
description: expect.any(String),
|
||||
action: expect.any(Function),
|
||||
completion: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('action', () => {
|
||||
it('should return an error if temp dir is not found', async () => {
|
||||
vi.mocked(mockConfig.getProjectTempDir).mockReturnValue('');
|
||||
|
||||
expect(
|
||||
await restoreCommand(mockConfig)?.action?.(mockContext, ''),
|
||||
).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Could not determine the .gemini directory path.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should inform when no checkpoints are found if no args are passed', async () => {
|
||||
// Remove the directory to ensure the command creates it.
|
||||
await fs.rm(checkpointsDir, { recursive: true, force: true });
|
||||
const command = restoreCommand(mockConfig);
|
||||
|
||||
expect(await command?.action?.(mockContext, '')).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No restorable tool calls found.',
|
||||
});
|
||||
// Verify the directory was created by the command.
|
||||
await expect(fs.stat(checkpointsDir)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('should list available checkpoints if no args are passed', async () => {
|
||||
await fs.writeFile(path.join(checkpointsDir, 'test1.json'), '{}');
|
||||
await fs.writeFile(path.join(checkpointsDir, 'test2.json'), '{}');
|
||||
const command = restoreCommand(mockConfig);
|
||||
|
||||
expect(await command?.action?.(mockContext, '')).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Available tool calls to restore:\n\ntest1\ntest2',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an error if the specified file is not found', async () => {
|
||||
await fs.writeFile(path.join(checkpointsDir, 'test1.json'), '{}');
|
||||
const command = restoreCommand(mockConfig);
|
||||
|
||||
expect(await command?.action?.(mockContext, 'test2')).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'File not found: test2.json',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle file read errors gracefully', async () => {
|
||||
const checkpointName = 'test1';
|
||||
const checkpointPath = path.join(
|
||||
checkpointsDir,
|
||||
`${checkpointName}.json`,
|
||||
);
|
||||
// Create a directory instead of a file to cause a read error.
|
||||
await fs.mkdir(checkpointPath);
|
||||
const command = restoreCommand(mockConfig);
|
||||
|
||||
expect(await command?.action?.(mockContext, checkpointName)).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining(
|
||||
'Could not read restorable tool calls.',
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it('should restore a tool call and project state', async () => {
|
||||
const toolCallData = {
|
||||
history: [{ type: 'user', text: 'do a thing' }],
|
||||
clientHistory: [{ role: 'user', parts: [{ text: 'do a thing' }] }],
|
||||
commitHash: 'abcdef123',
|
||||
toolCall: { name: 'run_shell_command', args: 'ls' },
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(checkpointsDir, 'my-checkpoint.json'),
|
||||
JSON.stringify(toolCallData),
|
||||
);
|
||||
const command = restoreCommand(mockConfig);
|
||||
|
||||
expect(await command?.action?.(mockContext, 'my-checkpoint')).toEqual({
|
||||
type: 'tool',
|
||||
toolName: 'run_shell_command',
|
||||
toolArgs: 'ls',
|
||||
});
|
||||
expect(mockContext.ui.loadHistory).toHaveBeenCalledWith(
|
||||
toolCallData.history,
|
||||
);
|
||||
expect(mockSetHistory).toHaveBeenCalledWith(toolCallData.clientHistory);
|
||||
expect(mockGitService.restoreProjectFromSnapshot).toHaveBeenCalledWith(
|
||||
toolCallData.commitHash,
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'info',
|
||||
text: 'Restored project to the state before the tool call.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should restore even if only toolCall is present', async () => {
|
||||
const toolCallData = {
|
||||
toolCall: { name: 'run_shell_command', args: 'ls' },
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(checkpointsDir, 'my-checkpoint.json'),
|
||||
JSON.stringify(toolCallData),
|
||||
);
|
||||
|
||||
const command = restoreCommand(mockConfig);
|
||||
|
||||
expect(await command?.action?.(mockContext, 'my-checkpoint')).toEqual({
|
||||
type: 'tool',
|
||||
toolName: 'run_shell_command',
|
||||
toolArgs: 'ls',
|
||||
});
|
||||
|
||||
expect(mockContext.ui.loadHistory).not.toHaveBeenCalled();
|
||||
expect(mockSetHistory).not.toHaveBeenCalled();
|
||||
expect(mockGitService.restoreProjectFromSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an error for a checkpoint file missing the toolCall property', async () => {
|
||||
const checkpointName = 'missing-toolcall';
|
||||
await fs.writeFile(
|
||||
path.join(checkpointsDir, `${checkpointName}.json`),
|
||||
JSON.stringify({ history: [] }), // An object that is valid JSON but missing the 'toolCall' property
|
||||
);
|
||||
const command = restoreCommand(mockConfig);
|
||||
|
||||
expect(await command?.action?.(mockContext, checkpointName)).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
// A more specific error message would be ideal, but for now, we can assert the current behavior.
|
||||
content: expect.stringContaining('Could not read restorable tool calls.'),
|
||||
});
|
||||
});
|
||||
|
||||
describe('completion', () => {
|
||||
it('should return an empty array if temp dir is not found', async () => {
|
||||
vi.mocked(mockConfig.getProjectTempDir).mockReturnValue('');
|
||||
const command = restoreCommand(mockConfig);
|
||||
|
||||
expect(await command?.completion?.(mockContext, '')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return an empty array on readdir error', async () => {
|
||||
await fs.rm(checkpointsDir, { recursive: true, force: true });
|
||||
const command = restoreCommand(mockConfig);
|
||||
|
||||
expect(await command?.completion?.(mockContext, '')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return a list of checkpoint names', async () => {
|
||||
await fs.writeFile(path.join(checkpointsDir, 'test1.json'), '{}');
|
||||
await fs.writeFile(path.join(checkpointsDir, 'test2.json'), '{}');
|
||||
await fs.writeFile(
|
||||
path.join(checkpointsDir, 'not-a-checkpoint.txt'),
|
||||
'{}',
|
||||
);
|
||||
const command = restoreCommand(mockConfig);
|
||||
|
||||
expect(await command?.completion?.(mockContext, '')).toEqual([
|
||||
'test1',
|
||||
'test2',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
157
packages/cli/src/ui/commands/restoreCommand.ts
Normal file
157
packages/cli/src/ui/commands/restoreCommand.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
type SlashCommandActionReturn,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { Config } from '@qwen-code/qwen-code-core';
|
||||
|
||||
async function restoreAction(
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<void | SlashCommandActionReturn> {
|
||||
const { services, ui } = context;
|
||||
const { config, git: gitService } = services;
|
||||
const { addItem, loadHistory } = ui;
|
||||
|
||||
const checkpointDir = config?.getProjectTempDir()
|
||||
? path.join(config.getProjectTempDir(), 'checkpoints')
|
||||
: undefined;
|
||||
|
||||
if (!checkpointDir) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Could not determine the .gemini directory path.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure the directory exists before trying to read it.
|
||||
await fs.mkdir(checkpointDir, { recursive: true });
|
||||
const files = await fs.readdir(checkpointDir);
|
||||
const jsonFiles = files.filter((file) => file.endsWith('.json'));
|
||||
|
||||
if (!args) {
|
||||
if (jsonFiles.length === 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No restorable tool calls found.',
|
||||
};
|
||||
}
|
||||
const truncatedFiles = jsonFiles.map((file) => {
|
||||
const components = file.split('.');
|
||||
if (components.length <= 1) {
|
||||
return file;
|
||||
}
|
||||
components.pop();
|
||||
return components.join('.');
|
||||
});
|
||||
const fileList = truncatedFiles.join('\n');
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Available tool calls to restore:\n\n${fileList}`,
|
||||
};
|
||||
}
|
||||
|
||||
const selectedFile = args.endsWith('.json') ? args : `${args}.json`;
|
||||
|
||||
if (!jsonFiles.includes(selectedFile)) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `File not found: ${selectedFile}`,
|
||||
};
|
||||
}
|
||||
|
||||
const filePath = path.join(checkpointDir, selectedFile);
|
||||
const data = await fs.readFile(filePath, 'utf-8');
|
||||
const toolCallData = JSON.parse(data);
|
||||
|
||||
if (toolCallData.history) {
|
||||
if (!loadHistory) {
|
||||
// This should not happen
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'loadHistory function is not available.',
|
||||
};
|
||||
}
|
||||
loadHistory(toolCallData.history);
|
||||
}
|
||||
|
||||
if (toolCallData.clientHistory) {
|
||||
await config?.getGeminiClient()?.setHistory(toolCallData.clientHistory);
|
||||
}
|
||||
|
||||
if (toolCallData.commitHash) {
|
||||
await gitService?.restoreProjectFromSnapshot(toolCallData.commitHash);
|
||||
addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: 'Restored project to the state before the tool call.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'tool',
|
||||
toolName: toolCallData.toolCall.name,
|
||||
toolArgs: toolCallData.toolCall.args,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Could not read restorable tool calls. This is the error: ${error}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function completion(
|
||||
context: CommandContext,
|
||||
_partialArg: string,
|
||||
): Promise<string[]> {
|
||||
const { services } = context;
|
||||
const { config } = services;
|
||||
const checkpointDir = config?.getProjectTempDir()
|
||||
? path.join(config.getProjectTempDir(), 'checkpoints')
|
||||
: undefined;
|
||||
if (!checkpointDir) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const files = await fs.readdir(checkpointDir);
|
||||
return files
|
||||
.filter((file) => file.endsWith('.json'))
|
||||
.map((file) => file.replace('.json', ''));
|
||||
} catch (_err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const restoreCommand = (config: Config | null): SlashCommand | null => {
|
||||
if (!config?.getCheckpointingEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'restore',
|
||||
description:
|
||||
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: restoreAction,
|
||||
completion,
|
||||
};
|
||||
};
|
||||
78
packages/cli/src/ui/commands/statsCommand.test.ts
Normal file
78
packages/cli/src/ui/commands/statsCommand.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 } from 'vitest';
|
||||
import { statsCommand } from './statsCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
|
||||
describe('statsCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
const startTime = new Date('2025-07-14T10:00:00.000Z');
|
||||
const endTime = new Date('2025-07-14T10:00:30.000Z');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(endTime);
|
||||
|
||||
// 1. Create the mock context with all default values
|
||||
mockContext = createMockCommandContext();
|
||||
|
||||
// 2. Directly set the property on the created mock context
|
||||
mockContext.session.stats.sessionStartTime = startTime;
|
||||
});
|
||||
|
||||
it('should display general session stats when run with no subcommand', () => {
|
||||
if (!statsCommand.action) throw new Error('Command has no action');
|
||||
|
||||
statsCommand.action(mockContext, '');
|
||||
|
||||
const expectedDuration = formatDuration(
|
||||
endTime.getTime() - startTime.getTime(),
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.STATS,
|
||||
duration: expectedDuration,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display model stats when using the "model" subcommand', () => {
|
||||
const modelSubCommand = statsCommand.subCommands?.find(
|
||||
(sc) => sc.name === 'model',
|
||||
);
|
||||
if (!modelSubCommand?.action) throw new Error('Subcommand has no action');
|
||||
|
||||
modelSubCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.MODEL_STATS,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display tool stats when using the "tools" subcommand', () => {
|
||||
const toolsSubCommand = statsCommand.subCommands?.find(
|
||||
(sc) => sc.name === 'tools',
|
||||
);
|
||||
if (!toolsSubCommand?.action) throw new Error('Subcommand has no action');
|
||||
|
||||
toolsSubCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.TOOL_STATS,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
70
packages/cli/src/ui/commands/statsCommand.ts
Normal file
70
packages/cli/src/ui/commands/statsCommand.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { MessageType, HistoryItemStats } from '../types.js';
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
|
||||
export const statsCommand: SlashCommand = {
|
||||
name: 'stats',
|
||||
altNames: ['usage'],
|
||||
description: 'check session stats. Usage: /stats [model|tools]',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (context: CommandContext) => {
|
||||
const now = new Date();
|
||||
const { sessionStartTime } = context.session.stats;
|
||||
if (!sessionStartTime) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Session start time is unavailable, cannot calculate stats.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const wallDuration = now.getTime() - sessionStartTime.getTime();
|
||||
|
||||
const statsItem: HistoryItemStats = {
|
||||
type: MessageType.STATS,
|
||||
duration: formatDuration(wallDuration),
|
||||
};
|
||||
|
||||
context.ui.addItem(statsItem, Date.now());
|
||||
},
|
||||
subCommands: [
|
||||
{
|
||||
name: 'model',
|
||||
description: 'Show model-specific usage statistics.',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (context: CommandContext) => {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.MODEL_STATS,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tools',
|
||||
description: 'Show tool-specific usage statistics.',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (context: CommandContext) => {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.TOOL_STATS,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -4,11 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||
import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||
|
||||
export const themeCommand: SlashCommand = {
|
||||
name: 'theme',
|
||||
description: 'change the theme',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (_context, _args): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'theme',
|
||||
|
||||
108
packages/cli/src/ui/commands/toolsCommand.test.ts
Normal file
108
packages/cli/src/ui/commands/toolsCommand.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { toolsCommand } from './toolsCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { Tool } from '@qwen-code/qwen-code-core';
|
||||
|
||||
// Mock tools for testing
|
||||
const mockTools = [
|
||||
{
|
||||
name: 'file-reader',
|
||||
displayName: 'File Reader',
|
||||
description: 'Reads files from the local system.',
|
||||
schema: {},
|
||||
},
|
||||
{
|
||||
name: 'code-editor',
|
||||
displayName: 'Code Editor',
|
||||
description: 'Edits code files.',
|
||||
schema: {},
|
||||
},
|
||||
] as Tool[];
|
||||
|
||||
describe('toolsCommand', () => {
|
||||
it('should display an error if the tool registry is unavailable', async () => {
|
||||
const mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getToolRegistry: () => Promise.resolve(undefined),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!toolsCommand.action) throw new Error('Action not defined');
|
||||
await toolsCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Could not retrieve tool registry.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display "No tools available" when none are found', async () => {
|
||||
const mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getToolRegistry: () =>
|
||||
Promise.resolve({ getAllTools: () => [] as Tool[] }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!toolsCommand.action) throw new Error('Action not defined');
|
||||
await toolsCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('No tools available'),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should list tools without descriptions by default', async () => {
|
||||
const mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getToolRegistry: () =>
|
||||
Promise.resolve({ getAllTools: () => mockTools }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!toolsCommand.action) throw new Error('Action not defined');
|
||||
await toolsCommand.action(mockContext, '');
|
||||
|
||||
const message = (mockContext.ui.addItem as vi.Mock).mock.calls[0][0].text;
|
||||
expect(message).not.toContain('Reads files from the local system.');
|
||||
expect(message).toContain('File Reader');
|
||||
expect(message).toContain('Code Editor');
|
||||
});
|
||||
|
||||
it('should list tools with descriptions when "desc" arg is passed', async () => {
|
||||
const mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getToolRegistry: () =>
|
||||
Promise.resolve({ getAllTools: () => mockTools }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!toolsCommand.action) throw new Error('Action not defined');
|
||||
await toolsCommand.action(mockContext, 'desc');
|
||||
|
||||
const message = (mockContext.ui.addItem as vi.Mock).mock.calls[0][0].text;
|
||||
expect(message).toContain('Reads files from the local system.');
|
||||
expect(message).toContain('Edits code files.');
|
||||
});
|
||||
});
|
||||
71
packages/cli/src/ui/commands/toolsCommand.ts
Normal file
71
packages/cli/src/ui/commands/toolsCommand.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
|
||||
export const toolsCommand: SlashCommand = {
|
||||
name: 'tools',
|
||||
description: 'list available Gemini CLI tools',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext, args?: string): Promise<void> => {
|
||||
const subCommand = args?.trim();
|
||||
|
||||
// Default to NOT showing descriptions. The user must opt in with an argument.
|
||||
let useShowDescriptions = false;
|
||||
if (subCommand === 'desc' || subCommand === 'descriptions') {
|
||||
useShowDescriptions = true;
|
||||
}
|
||||
|
||||
const toolRegistry = await context.services.config?.getToolRegistry();
|
||||
if (!toolRegistry) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Could not retrieve tool registry.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const tools = toolRegistry.getAllTools();
|
||||
// Filter out MCP tools by checking for the absence of a serverName property
|
||||
const geminiTools = tools.filter((tool) => !('serverName' in tool));
|
||||
|
||||
let message = 'Available Gemini CLI tools:\n\n';
|
||||
|
||||
if (geminiTools.length > 0) {
|
||||
geminiTools.forEach((tool) => {
|
||||
if (useShowDescriptions && tool.description) {
|
||||
message += ` - \u001b[36m${tool.displayName} (${tool.name})\u001b[0m:\n`;
|
||||
|
||||
const greenColor = '\u001b[32m';
|
||||
const resetColor = '\u001b[0m';
|
||||
|
||||
// Handle multi-line descriptions
|
||||
const descLines = tool.description.trim().split('\n');
|
||||
for (const descLine of descLines) {
|
||||
message += ` ${greenColor}${descLine}${resetColor}\n`;
|
||||
}
|
||||
} else {
|
||||
message += ` - \u001b[36m${tool.displayName}\u001b[0m\n`;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
message += ' No tools available\n';
|
||||
}
|
||||
message += '\n';
|
||||
|
||||
message += '\u001b[0m';
|
||||
|
||||
context.ui.addItem({ type: MessageType.INFO, text: message }, Date.now());
|
||||
},
|
||||
};
|
||||
@@ -4,13 +4,25 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Content } from '@google/genai';
|
||||
import { HistoryItemWithoutId } from '../types.js';
|
||||
import { Config, GitService, Logger } from '@qwen-code/qwen-code-core';
|
||||
import { LoadedSettings } from '../../config/settings.js';
|
||||
import { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||
import type { HistoryItem } from '../types.js';
|
||||
import { SessionStatsState } from '../contexts/SessionContext.js';
|
||||
|
||||
// Grouped dependencies for clarity and easier mocking
|
||||
export interface CommandContext {
|
||||
// Invocation properties for when commands are called.
|
||||
invocation?: {
|
||||
/** The raw, untrimmed input string from the user. */
|
||||
raw: string;
|
||||
/** The primary name of the command that was matched. */
|
||||
name: string;
|
||||
/** The arguments string that follows the command name. */
|
||||
args: string;
|
||||
};
|
||||
// Core services and configuration
|
||||
services: {
|
||||
// TODO(abhipatel12): Ensure that config is never null.
|
||||
@@ -21,11 +33,6 @@ export interface CommandContext {
|
||||
};
|
||||
// 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. */
|
||||
@@ -34,11 +41,30 @@ export interface CommandContext {
|
||||
* Sets the transient debug message displayed in the application footer in debug mode.
|
||||
*/
|
||||
setDebugMessage: (message: string) => void;
|
||||
/** The currently pending history item, if any. */
|
||||
pendingItem: HistoryItemWithoutId | null;
|
||||
/**
|
||||
* Sets a pending item in the history, which is useful for indicating
|
||||
* that a long-running operation is in progress.
|
||||
*
|
||||
* @param item The history item to display as pending, or `null` to clear.
|
||||
*/
|
||||
setPendingItem: (item: HistoryItemWithoutId | null) => void;
|
||||
/**
|
||||
* Loads a new set of history items, replacing the current history.
|
||||
*
|
||||
* @param history The array of history items to load.
|
||||
*/
|
||||
loadHistory: UseHistoryManagerReturn['loadHistory'];
|
||||
/** Toggles a special display mode. */
|
||||
toggleCorgiMode: () => void;
|
||||
toggleVimEnabled: () => Promise<boolean>;
|
||||
};
|
||||
// Session-specific data
|
||||
session: {
|
||||
stats: SessionStatsState;
|
||||
resetSession: () => void;
|
||||
/** A transient list of shell commands the user has approved for this session. */
|
||||
sessionShellAllowlist: Set<string>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,6 +77,12 @@ export interface ToolActionReturn {
|
||||
toolArgs: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** The return type for a command action that results in the app quitting. */
|
||||
export interface QuitActionReturn {
|
||||
type: 'quit';
|
||||
messages: HistoryItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The return type for a command action that results in a simple message
|
||||
* being displayed to the user.
|
||||
@@ -66,24 +98,69 @@ export interface MessageActionReturn {
|
||||
*/
|
||||
export interface OpenDialogActionReturn {
|
||||
type: 'dialog';
|
||||
// TODO: Add 'theme' | 'auth' | 'editor' | 'privacy' as migration happens.
|
||||
dialog: 'help' | 'auth' | 'theme' | 'privacy';
|
||||
dialog: 'help' | 'auth' | 'theme' | 'editor' | 'privacy';
|
||||
}
|
||||
|
||||
/**
|
||||
* The return type for a command action that results in replacing
|
||||
* the entire conversation history.
|
||||
*/
|
||||
export interface LoadHistoryActionReturn {
|
||||
type: 'load_history';
|
||||
history: HistoryItemWithoutId[];
|
||||
clientHistory: Content[]; // The history for the generative client
|
||||
}
|
||||
|
||||
/**
|
||||
* The return type for a command action that should immediately submit
|
||||
* content as a prompt to the Gemini model.
|
||||
*/
|
||||
export interface SubmitPromptActionReturn {
|
||||
type: 'submit_prompt';
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The return type for a command action that needs to pause and request
|
||||
* confirmation for a set of shell commands before proceeding.
|
||||
*/
|
||||
export interface ConfirmShellCommandsActionReturn {
|
||||
type: 'confirm_shell_commands';
|
||||
/** The list of shell commands that require user confirmation. */
|
||||
commandsToConfirm: string[];
|
||||
/** The original invocation context to be re-run after confirmation. */
|
||||
originalInvocation: {
|
||||
raw: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type SlashCommandActionReturn =
|
||||
| ToolActionReturn
|
||||
| MessageActionReturn
|
||||
| OpenDialogActionReturn;
|
||||
| QuitActionReturn
|
||||
| OpenDialogActionReturn
|
||||
| LoadHistoryActionReturn
|
||||
| SubmitPromptActionReturn
|
||||
| ConfirmShellCommandsActionReturn;
|
||||
|
||||
export enum CommandKind {
|
||||
BUILT_IN = 'built-in',
|
||||
FILE = 'file',
|
||||
MCP_PROMPT = 'mcp-prompt',
|
||||
}
|
||||
|
||||
// The standardized contract for any command in the system.
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
altName?: string;
|
||||
description?: string;
|
||||
altNames?: string[];
|
||||
description: string;
|
||||
|
||||
kind: CommandKind;
|
||||
|
||||
// The action to run. Optional for parent commands that only group sub-commands.
|
||||
action?: (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
args: string, // TODO: Remove args. CommandContext now contains the complete invocation.
|
||||
) =>
|
||||
| void
|
||||
| SlashCommandActionReturn
|
||||
|
||||
25
packages/cli/src/ui/commands/vimCommand.ts
Normal file
25
packages/cli/src/ui/commands/vimCommand.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, SlashCommand } from './types.js';
|
||||
|
||||
export const vimCommand: SlashCommand = {
|
||||
name: 'vim',
|
||||
description: 'toggle vim mode on/off',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context, _args) => {
|
||||
const newVimState = await context.ui.toggleVimEnabled();
|
||||
|
||||
const message = newVimState
|
||||
? 'Entered Vim mode. Run /vim again to exit.'
|
||||
: 'Exited Vim mode.';
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: message,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -31,7 +31,7 @@ describe('AuthDialog', () => {
|
||||
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {},
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
@@ -41,7 +41,7 @@ describe('AuthDialog', () => {
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
@@ -68,11 +68,17 @@ describe('AuthDialog', () => {
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: undefined,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
@@ -95,11 +101,17 @@ describe('AuthDialog', () => {
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: undefined,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
@@ -122,11 +134,17 @@ describe('AuthDialog', () => {
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: undefined,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
@@ -144,17 +162,23 @@ describe('AuthDialog', () => {
|
||||
|
||||
describe('GEMINI_DEFAULT_AUTH_TYPE environment variable', () => {
|
||||
it('should select the auth type specified by GEMINI_DEFAULT_AUTH_TYPE', () => {
|
||||
process.env.GEMINI_DEFAULT_AUTH_TYPE = AuthType.LOGIN_WITH_GOOGLE;
|
||||
process.env.GEMINI_DEFAULT_AUTH_TYPE = AuthType.USE_OPENAI;
|
||||
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: undefined,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
@@ -164,8 +188,8 @@ describe('AuthDialog', () => {
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
|
||||
// Since only OpenAI is available, it should be selected by default
|
||||
expect(lastFrame()).toContain('● OpenAI');
|
||||
// This is a bit brittle, but it's the best way to check which item is selected.
|
||||
expect(lastFrame()).toContain('● 1. OpenAI');
|
||||
});
|
||||
|
||||
it('should fall back to default if GEMINI_DEFAULT_AUTH_TYPE is not set', () => {
|
||||
@@ -173,11 +197,17 @@ describe('AuthDialog', () => {
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: undefined,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
@@ -187,8 +217,8 @@ describe('AuthDialog', () => {
|
||||
<AuthDialog onSelect={() => {}} settings={settings} />,
|
||||
);
|
||||
|
||||
// Default is OpenAI (the only option)
|
||||
expect(lastFrame()).toContain('● OpenAI');
|
||||
// Default is OpenAI (only option available)
|
||||
expect(lastFrame()).toContain('● 1. OpenAI');
|
||||
});
|
||||
|
||||
it('should show an error and fall back to default if GEMINI_DEFAULT_AUTH_TYPE is invalid', () => {
|
||||
@@ -198,11 +228,17 @@ describe('AuthDialog', () => {
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: undefined,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
@@ -214,7 +250,7 @@ describe('AuthDialog', () => {
|
||||
|
||||
// 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');
|
||||
expect(lastFrame()).toContain('● 1. OpenAI');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -259,11 +295,19 @@ describe('AuthDialog', () => {
|
||||
const onSelect = vi.fn();
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {},
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
settings: {
|
||||
selectedAuthType: undefined,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
@@ -293,17 +337,19 @@ describe('AuthDialog', () => {
|
||||
const onSelect = vi.fn();
|
||||
const settings: LoadedSettings = new LoadedSettings(
|
||||
{
|
||||
settings: {},
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {
|
||||
selectedAuthType: AuthType.USE_GEMINI,
|
||||
customThemes: {},
|
||||
mcpServers: {},
|
||||
},
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
settings: { customThemes: {}, mcpServers: {} },
|
||||
path: '',
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -102,7 +102,6 @@ export function AuthDialog({
|
||||
};
|
||||
|
||||
useInput((_input, key) => {
|
||||
// 当显示 OpenAIKeyPrompt 时,不处理输入事件
|
||||
if (showOpenAIKeyPrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7,27 +7,48 @@
|
||||
import React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { type MCPServerConfig } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
type OpenFiles,
|
||||
type MCPServerConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
interface ContextSummaryDisplayProps {
|
||||
geminiMdFileCount: number;
|
||||
contextFileNames: string[];
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
blockedMcpServers?: Array<{ name: string; extensionName: string }>;
|
||||
showToolDescriptions?: boolean;
|
||||
openFiles?: OpenFiles;
|
||||
}
|
||||
|
||||
export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
geminiMdFileCount,
|
||||
contextFileNames,
|
||||
mcpServers,
|
||||
blockedMcpServers,
|
||||
showToolDescriptions,
|
||||
openFiles,
|
||||
}) => {
|
||||
const mcpServerCount = Object.keys(mcpServers || {}).length;
|
||||
const blockedMcpServerCount = blockedMcpServers?.length || 0;
|
||||
|
||||
if (geminiMdFileCount === 0 && mcpServerCount === 0) {
|
||||
if (
|
||||
geminiMdFileCount === 0 &&
|
||||
mcpServerCount === 0 &&
|
||||
blockedMcpServerCount === 0 &&
|
||||
(openFiles?.recentOpenFiles?.length ?? 0) === 0
|
||||
) {
|
||||
return <Text> </Text>; // Render an empty space to reserve height
|
||||
}
|
||||
|
||||
const recentFilesText = (() => {
|
||||
const count = openFiles?.recentOpenFiles?.length ?? 0;
|
||||
if (count === 0) {
|
||||
return '';
|
||||
}
|
||||
return `${count} recent file${count > 1 ? 's' : ''} (ctrl+e to view)`;
|
||||
})();
|
||||
|
||||
const geminiMdText = (() => {
|
||||
if (geminiMdFileCount === 0) {
|
||||
return '';
|
||||
@@ -39,27 +60,47 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
|
||||
}`;
|
||||
})();
|
||||
|
||||
const mcpText =
|
||||
mcpServerCount > 0
|
||||
? `${mcpServerCount} MCP server${mcpServerCount > 1 ? 's' : ''}`
|
||||
: '';
|
||||
const mcpText = (() => {
|
||||
if (mcpServerCount === 0 && blockedMcpServerCount === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let summaryText = 'Using ';
|
||||
if (geminiMdText) {
|
||||
summaryText += geminiMdText;
|
||||
const parts = [];
|
||||
if (mcpServerCount > 0) {
|
||||
parts.push(
|
||||
`${mcpServerCount} MCP server${mcpServerCount > 1 ? 's' : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (blockedMcpServerCount > 0) {
|
||||
let blockedText = `${blockedMcpServerCount} Blocked`;
|
||||
if (mcpServerCount === 0) {
|
||||
blockedText += ` MCP server${blockedMcpServerCount > 1 ? 's' : ''}`;
|
||||
}
|
||||
parts.push(blockedText);
|
||||
}
|
||||
return parts.join(', ');
|
||||
})();
|
||||
|
||||
let summaryText = 'Using: ';
|
||||
const summaryParts = [];
|
||||
if (recentFilesText) {
|
||||
summaryParts.push(recentFilesText);
|
||||
}
|
||||
if (geminiMdText && mcpText) {
|
||||
summaryText += ' and ';
|
||||
if (geminiMdText) {
|
||||
summaryParts.push(geminiMdText);
|
||||
}
|
||||
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)';
|
||||
}
|
||||
summaryParts.push(mcpText);
|
||||
}
|
||||
summaryText += summaryParts.join(' | ');
|
||||
|
||||
// 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)';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ interface FooterProps {
|
||||
showMemoryUsage?: boolean;
|
||||
promptTokenCount: number;
|
||||
nightly: boolean;
|
||||
vimMode?: string;
|
||||
}
|
||||
|
||||
export const Footer: React.FC<FooterProps> = ({
|
||||
@@ -43,13 +44,15 @@ export const Footer: React.FC<FooterProps> = ({
|
||||
showMemoryUsage,
|
||||
promptTokenCount,
|
||||
nightly,
|
||||
vimMode,
|
||||
}) => {
|
||||
const limit = tokenLimit(model);
|
||||
const percentage = promptTokenCount / limit;
|
||||
|
||||
return (
|
||||
<Box marginTop={1} justifyContent="space-between" width="100%">
|
||||
<Box justifyContent="space-between" width="100%">
|
||||
<Box>
|
||||
{vimMode && <Text color={Colors.Gray}>[{vimMode}] </Text>}
|
||||
{nightly ? (
|
||||
<Gradient colors={Colors.GradientColors}>
|
||||
<Text>
|
||||
@@ -83,7 +86,7 @@ export const Footer: React.FC<FooterProps> = ({
|
||||
</Text>
|
||||
) : process.env.SANDBOX === 'sandbox-exec' ? (
|
||||
<Text color={Colors.AccentYellow}>
|
||||
MacOS Seatbelt{' '}
|
||||
macOS Seatbelt{' '}
|
||||
<Text color={Colors.Gray}>({process.env.SEATBELT_PROFILE})</Text>
|
||||
</Text>
|
||||
) : (
|
||||
|
||||
@@ -38,7 +38,6 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
|
||||
return (
|
||||
<Box
|
||||
marginBottom={1}
|
||||
alignItems="flex-start"
|
||||
width={artWidth}
|
||||
flexShrink={0}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Colors } from '../colors.js';
|
||||
import { SlashCommand } from '../commands/types.js';
|
||||
|
||||
interface Help {
|
||||
commands: SlashCommand[];
|
||||
commands: readonly SlashCommand[];
|
||||
}
|
||||
|
||||
export const Help: React.FC<Help> = ({ commands }) => (
|
||||
|
||||
52
packages/cli/src/ui/components/IDEContextDetailDisplay.tsx
Normal file
52
packages/cli/src/ui/components/IDEContextDetailDisplay.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { type OpenFiles } from '@qwen-code/qwen-code-core';
|
||||
import { Colors } from '../colors.js';
|
||||
import path from 'node:path';
|
||||
|
||||
interface IDEContextDetailDisplayProps {
|
||||
openFiles: OpenFiles | undefined;
|
||||
}
|
||||
|
||||
export function IDEContextDetailDisplay({
|
||||
openFiles,
|
||||
}: IDEContextDetailDisplayProps) {
|
||||
if (
|
||||
!openFiles ||
|
||||
!openFiles.recentOpenFiles ||
|
||||
openFiles.recentOpenFiles.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const recentFiles = openFiles.recentOpenFiles || [];
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
marginTop={1}
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentCyan}
|
||||
paddingX={1}
|
||||
>
|
||||
<Text color={Colors.AccentCyan} bold>
|
||||
IDE Context (ctrl+e to toggle)
|
||||
</Text>
|
||||
{recentFiles.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text bold>Recent files:</Text>
|
||||
{recentFiles.map((file) => (
|
||||
<Text key={file.filePath}>
|
||||
- {path.basename(file.filePath)}
|
||||
{file.filePath === openFiles.activeFile ? ' (active)' : ''}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -8,11 +8,22 @@ import { render } from 'ink-testing-library';
|
||||
import { InputPrompt, InputPromptProps } from './InputPrompt.js';
|
||||
import type { TextBuffer } from './shared/text-buffer.js';
|
||||
import { Config } from '@qwen-code/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 path from 'path';
|
||||
import {
|
||||
CommandContext,
|
||||
SlashCommand,
|
||||
CommandKind,
|
||||
} from '../commands/types.js';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
useShellHistory,
|
||||
UseShellHistoryReturn,
|
||||
} from '../hooks/useShellHistory.js';
|
||||
import { useCompletion, UseCompletionReturn } from '../hooks/useCompletion.js';
|
||||
import {
|
||||
useInputHistory,
|
||||
UseInputHistoryReturn,
|
||||
} from '../hooks/useInputHistory.js';
|
||||
import * as clipboardUtils from '../utils/clipboardUtils.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
@@ -21,28 +32,47 @@ 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: 'clear',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'Clear screen',
|
||||
action: vi.fn(),
|
||||
},
|
||||
{
|
||||
name: 'memory',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
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: 'show',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'Show memory',
|
||||
action: vi.fn(),
|
||||
},
|
||||
{
|
||||
name: 'add',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'Add to memory',
|
||||
action: vi.fn(),
|
||||
},
|
||||
{
|
||||
name: 'refresh',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'Refresh memory',
|
||||
action: vi.fn(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'chat',
|
||||
description: 'Manage chats',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'resume',
|
||||
description: 'Resume a chat',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: vi.fn(),
|
||||
completion: async () => ['fix-foo', 'fix-bar'],
|
||||
},
|
||||
@@ -52,9 +82,9 @@ const mockSlashCommands: SlashCommand[] = [
|
||||
|
||||
describe('InputPrompt', () => {
|
||||
let props: InputPromptProps;
|
||||
let mockShellHistory: MockedUseShellHistory;
|
||||
let mockCompletion: MockedUseCompletion;
|
||||
let mockInputHistory: MockedUseInputHistory;
|
||||
let mockShellHistory: UseShellHistoryReturn;
|
||||
let mockCompletion: UseCompletionReturn;
|
||||
let mockInputHistory: UseInputHistoryReturn;
|
||||
let mockBuffer: TextBuffer;
|
||||
let mockCommandContext: CommandContext;
|
||||
|
||||
@@ -91,6 +121,15 @@ describe('InputPrompt', () => {
|
||||
openInExternalEditor: vi.fn(),
|
||||
newline: vi.fn(),
|
||||
backspace: vi.fn(),
|
||||
preferredCol: null,
|
||||
selectionAnchor: null,
|
||||
insert: vi.fn(),
|
||||
del: vi.fn(),
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
replaceRange: vi.fn(),
|
||||
deleteWordLeft: vi.fn(),
|
||||
deleteWordRight: vi.fn(),
|
||||
} as unknown as TextBuffer;
|
||||
|
||||
mockShellHistory = {
|
||||
@@ -107,11 +146,13 @@ describe('InputPrompt', () => {
|
||||
isLoadingSuggestions: false,
|
||||
showSuggestions: false,
|
||||
visibleStartIndex: 0,
|
||||
isPerfectMatch: false,
|
||||
navigateUp: vi.fn(),
|
||||
navigateDown: vi.fn(),
|
||||
resetCompletionState: vi.fn(),
|
||||
setActiveSuggestionIndex: vi.fn(),
|
||||
setShowSuggestions: vi.fn(),
|
||||
handleAutocomplete: vi.fn(),
|
||||
};
|
||||
mockedUseCompletion.mockReturnValue(mockCompletion);
|
||||
|
||||
@@ -128,10 +169,11 @@ describe('InputPrompt', () => {
|
||||
userMessages: [],
|
||||
onClearScreen: vi.fn(),
|
||||
config: {
|
||||
getProjectRoot: () => '/test/project',
|
||||
getTargetDir: () => '/test/project/src',
|
||||
getProjectRoot: () => path.join('test', 'project'),
|
||||
getTargetDir: () => path.join('test', 'project', 'src'),
|
||||
getVimMode: () => false,
|
||||
} as unknown as Config,
|
||||
slashCommands: [],
|
||||
slashCommands: mockSlashCommands,
|
||||
commandContext: mockCommandContext,
|
||||
shellModeActive: false,
|
||||
setShellModeActive: vi.fn(),
|
||||
@@ -139,8 +181,6 @@ describe('InputPrompt', () => {
|
||||
suggestionsWidth: 80,
|
||||
focus: true,
|
||||
};
|
||||
|
||||
props.slashCommands = mockSlashCommands;
|
||||
});
|
||||
|
||||
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -148,10 +188,10 @@ describe('InputPrompt', () => {
|
||||
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
|
||||
await wait();
|
||||
|
||||
stdin.write('\u001B[A');
|
||||
await wait(100); // Increased wait time to ensure input is processed
|
||||
await wait();
|
||||
|
||||
expect(mockShellHistory.getPreviousCommand).toHaveBeenCalled();
|
||||
unmount();
|
||||
@@ -160,10 +200,10 @@ describe('InputPrompt', () => {
|
||||
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
|
||||
await wait();
|
||||
|
||||
stdin.write('\u001B[B');
|
||||
await wait(100); // Increased wait time to ensure input is processed
|
||||
await wait();
|
||||
|
||||
expect(mockShellHistory.getNextCommand).toHaveBeenCalled();
|
||||
unmount();
|
||||
@@ -175,10 +215,10 @@ describe('InputPrompt', () => {
|
||||
'previous command',
|
||||
);
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait(100); // Increased wait time for CI environment
|
||||
await wait();
|
||||
|
||||
stdin.write('\u001B[A');
|
||||
await wait(100); // Increased wait time to ensure input is processed
|
||||
await wait();
|
||||
|
||||
expect(mockShellHistory.getPreviousCommand).toHaveBeenCalled();
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('previous command');
|
||||
@@ -221,6 +261,83 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call completion.navigateUp for both up arrow and Ctrl+P when suggestions are showing', async () => {
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [
|
||||
{ label: 'memory', value: 'memory' },
|
||||
{ label: 'memcache', value: 'memcache' },
|
||||
],
|
||||
});
|
||||
|
||||
props.buffer.setText('/mem');
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
// Test up arrow
|
||||
stdin.write('\u001B[A'); // Up arrow
|
||||
await wait();
|
||||
|
||||
stdin.write('\u0010'); // Ctrl+P
|
||||
await wait();
|
||||
expect(mockCompletion.navigateUp).toHaveBeenCalledTimes(2);
|
||||
expect(mockCompletion.navigateDown).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call completion.navigateDown for both down arrow and Ctrl+N when suggestions are showing', async () => {
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [
|
||||
{ label: 'memory', value: 'memory' },
|
||||
{ label: 'memcache', value: 'memcache' },
|
||||
],
|
||||
});
|
||||
props.buffer.setText('/mem');
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
// Test down arrow
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
await wait();
|
||||
|
||||
stdin.write('\u000E'); // Ctrl+N
|
||||
await wait();
|
||||
expect(mockCompletion.navigateDown).toHaveBeenCalledTimes(2);
|
||||
expect(mockCompletion.navigateUp).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT call completion navigation when suggestions are not showing', async () => {
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: false,
|
||||
});
|
||||
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('\u0010'); // Ctrl+P
|
||||
await wait();
|
||||
stdin.write('\u000E'); // Ctrl+N
|
||||
await wait();
|
||||
|
||||
expect(mockCompletion.navigateUp).not.toHaveBeenCalled();
|
||||
expect(mockCompletion.navigateDown).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('clipboard image paste', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
|
||||
@@ -285,10 +402,13 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
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',
|
||||
const imagePath = path.join(
|
||||
'test',
|
||||
'.gemini-clipboard',
|
||||
'clipboard-456.png',
|
||||
);
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
|
||||
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(imagePath);
|
||||
|
||||
// Set initial text and cursor position
|
||||
mockBuffer.text = 'Hello world';
|
||||
@@ -310,9 +430,9 @@ describe('InputPrompt', () => {
|
||||
.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
|
||||
expect(actualCall[2]).toBe(
|
||||
' @' + path.relative(path.join('test', 'project', 'src'), imagePath),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -341,7 +461,7 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should complete a partial parent command and add a space', async () => {
|
||||
it('should complete a partial parent command', async () => {
|
||||
// SCENARIO: /mem -> Tab
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
@@ -357,12 +477,12 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\t'); // Press Tab
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory ');
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should append a sub-command when the parent command is already complete with a space', async () => {
|
||||
// SCENARIO: /memory -> Tab (to accept 'add')
|
||||
it('should append a sub-command when the parent command is already complete', async () => {
|
||||
// SCENARIO: /memory -> Tab (to accept 'add')
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
@@ -380,13 +500,12 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\t'); // Press Tab
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory add ');
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(1);
|
||||
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.
|
||||
// SCENARIO: /memory -> Backspace -> /memory -> Tab (to accept 'show')
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
@@ -405,8 +524,8 @@ describe('InputPrompt', () => {
|
||||
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 ');
|
||||
// It should NOT become '/show'. It should correctly become '/memory show'.
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -426,7 +545,7 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\t'); // Press Tab
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/chat resume fix-foo ');
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -446,19 +565,21 @@ describe('InputPrompt', () => {
|
||||
await wait();
|
||||
|
||||
// The app should autocomplete the text, NOT submit.
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/memory ');
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
|
||||
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: '...',
|
||||
});
|
||||
it('should complete a command based on its altNames', async () => {
|
||||
props.slashCommands = [
|
||||
{
|
||||
name: 'help',
|
||||
altNames: ['?'],
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: '...',
|
||||
},
|
||||
];
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
@@ -471,10 +592,10 @@ describe('InputPrompt', () => {
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\t'); // Press Tab
|
||||
stdin.write('\t'); // Press Tab for autocomplete
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('/help ');
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -491,10 +612,29 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should submit directly on Enter when a complete leaf command is typed', async () => {
|
||||
it('should submit directly on Enter when isPerfectMatch is true', async () => {
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: false,
|
||||
isPerfectMatch: true,
|
||||
});
|
||||
props.buffer.setText('/clear');
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(props.onSubmit).toHaveBeenCalledWith('/clear');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should submit directly on Enter when a complete leaf command is typed', async () => {
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: false,
|
||||
isPerfectMatch: false, // Added explicit isPerfectMatch false
|
||||
});
|
||||
props.buffer.setText('/clear');
|
||||
|
||||
@@ -505,7 +645,6 @@ describe('InputPrompt', () => {
|
||||
await wait();
|
||||
|
||||
expect(props.onSubmit).toHaveBeenCalledWith('/clear');
|
||||
expect(props.buffer.setText).not.toHaveBeenCalledWith('/clear ');
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -524,13 +663,16 @@ describe('InputPrompt', () => {
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.replaceRangeByOffset).toHaveBeenCalled();
|
||||
expect(mockCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
|
||||
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\\');
|
||||
// This test simulates multi-line input, not submission
|
||||
mockBuffer.text = 'first line\\';
|
||||
mockBuffer.cursor = [0, 11];
|
||||
mockBuffer.lines = ['first line\\'];
|
||||
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
@@ -543,4 +685,471 @@ describe('InputPrompt', () => {
|
||||
expect(props.buffer.newline).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should clear the buffer on Ctrl+C if it has text', async () => {
|
||||
props.buffer.setText('some text to clear');
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x03'); // Ctrl+C character
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).toHaveBeenCalledWith('');
|
||||
expect(mockCompletion.resetCompletionState).toHaveBeenCalled();
|
||||
expect(props.onSubmit).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT clear the buffer on Ctrl+C if it is empty', async () => {
|
||||
props.buffer.text = '';
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x03'); // Ctrl+C character
|
||||
await wait();
|
||||
|
||||
expect(props.buffer.setText).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('cursor-based completion trigger', () => {
|
||||
it('should trigger completion when cursor is after @ without spaces', async () => {
|
||||
// Set up buffer state
|
||||
mockBuffer.text = '@src/components';
|
||||
mockBuffer.lines = ['@src/components'];
|
||||
mockBuffer.cursor = [0, 15];
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'Button.tsx', value: 'Button.tsx' }],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
// Verify useCompletion was called with correct signature
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should trigger completion when cursor is after / without spaces', async () => {
|
||||
mockBuffer.text = '/memory';
|
||||
mockBuffer.lines = ['/memory'];
|
||||
mockBuffer.cursor = [0, 7];
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'show', value: 'show' }],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT trigger completion when cursor is after space following @', async () => {
|
||||
mockBuffer.text = '@src/file.ts hello';
|
||||
mockBuffer.lines = ['@src/file.ts hello'];
|
||||
mockBuffer.cursor = [0, 18];
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: false,
|
||||
suggestions: [],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT trigger completion when cursor is after space following /', async () => {
|
||||
mockBuffer.text = '/memory add';
|
||||
mockBuffer.lines = ['/memory add'];
|
||||
mockBuffer.cursor = [0, 11];
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: false,
|
||||
suggestions: [],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT trigger completion when cursor is not after @ or /', async () => {
|
||||
mockBuffer.text = 'hello world';
|
||||
mockBuffer.lines = ['hello world'];
|
||||
mockBuffer.cursor = [0, 5];
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: false,
|
||||
suggestions: [],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle multiline text correctly', async () => {
|
||||
mockBuffer.text = 'first line\n/memory';
|
||||
mockBuffer.lines = ['first line', '/memory'];
|
||||
mockBuffer.cursor = [1, 7];
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: false,
|
||||
suggestions: [],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
// Verify useCompletion was called with the buffer
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle single line slash command correctly', async () => {
|
||||
mockBuffer.text = '/memory';
|
||||
mockBuffer.lines = ['/memory'];
|
||||
mockBuffer.cursor = [0, 7];
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'show', value: 'show' }],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle Unicode characters (emojis) correctly in paths', async () => {
|
||||
// Test with emoji in path after @
|
||||
mockBuffer.text = '@src/file👍.txt';
|
||||
mockBuffer.lines = ['@src/file👍.txt'];
|
||||
mockBuffer.cursor = [0, 14]; // After the emoji character
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'file👍.txt', value: 'file👍.txt' }],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle Unicode characters with spaces after them', async () => {
|
||||
// Test with emoji followed by space - should NOT trigger completion
|
||||
mockBuffer.text = '@src/file👍.txt hello';
|
||||
mockBuffer.lines = ['@src/file👍.txt hello'];
|
||||
mockBuffer.cursor = [0, 20]; // After the space
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: false,
|
||||
suggestions: [],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle escaped spaces in paths correctly', async () => {
|
||||
// Test with escaped space in path - should trigger completion
|
||||
mockBuffer.text = '@src/my\\ file.txt';
|
||||
mockBuffer.lines = ['@src/my\\ file.txt'];
|
||||
mockBuffer.cursor = [0, 16]; // After the escaped space and filename
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'my file.txt', value: 'my file.txt' }],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT trigger completion after unescaped space following escaped space', async () => {
|
||||
// Test: @path/my\ file.txt hello (unescaped space after escaped space)
|
||||
mockBuffer.text = '@path/my\\ file.txt hello';
|
||||
mockBuffer.lines = ['@path/my\\ file.txt hello'];
|
||||
mockBuffer.cursor = [0, 24]; // After "hello"
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: false,
|
||||
suggestions: [],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle multiple escaped spaces in paths', async () => {
|
||||
// Test with multiple escaped spaces
|
||||
mockBuffer.text = '@docs/my\\ long\\ file\\ name.md';
|
||||
mockBuffer.lines = ['@docs/my\\ long\\ file\\ name.md'];
|
||||
mockBuffer.cursor = [0, 29]; // At the end
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [
|
||||
{ label: 'my long file name.md', value: 'my long file name.md' },
|
||||
],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle escaped spaces in slash commands', async () => {
|
||||
// Test escaped spaces with slash commands (though less common)
|
||||
mockBuffer.text = '/memory\\ test';
|
||||
mockBuffer.lines = ['/memory\\ test'];
|
||||
mockBuffer.cursor = [0, 13]; // At the end
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [{ label: 'test-command', value: 'test-command' }],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle Unicode characters with escaped spaces', async () => {
|
||||
// Test combining Unicode and escaped spaces
|
||||
mockBuffer.text = '@' + path.join('files', 'emoji\\ 👍\\ test.txt');
|
||||
mockBuffer.lines = ['@' + path.join('files', 'emoji\\ 👍\\ test.txt')];
|
||||
mockBuffer.cursor = [0, 25]; // After the escaped space and emoji
|
||||
|
||||
mockedUseCompletion.mockReturnValue({
|
||||
...mockCompletion,
|
||||
showSuggestions: true,
|
||||
suggestions: [
|
||||
{ label: 'emoji 👍 test.txt', value: 'emoji 👍 test.txt' },
|
||||
],
|
||||
});
|
||||
|
||||
const { unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
expect(mockedUseCompletion).toHaveBeenCalledWith(
|
||||
mockBuffer,
|
||||
path.join('test', 'project', 'src'),
|
||||
mockSlashCommands,
|
||||
mockCommandContext,
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim mode', () => {
|
||||
it('should not call buffer.handleInput when vim mode is enabled and vim handles the input', async () => {
|
||||
props.vimModeEnabled = true;
|
||||
props.vimHandleInput = vi.fn().mockReturnValue(true); // Mock that vim handled it.
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('i');
|
||||
await wait();
|
||||
|
||||
expect(props.vimHandleInput).toHaveBeenCalled();
|
||||
expect(mockBuffer.handleInput).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call buffer.handleInput when vim mode is enabled but vim does not handle the input', async () => {
|
||||
props.vimModeEnabled = true;
|
||||
props.vimHandleInput = vi.fn().mockReturnValue(false); // Mock that vim did NOT handle it.
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('i');
|
||||
await wait();
|
||||
|
||||
expect(props.vimHandleInput).toHaveBeenCalled();
|
||||
expect(mockBuffer.handleInput).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should call handleInput when vim mode is disabled', async () => {
|
||||
// Mock vimHandleInput to return false (vim didn't handle the input)
|
||||
props.vimHandleInput = vi.fn().mockReturnValue(false);
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('i');
|
||||
await wait();
|
||||
|
||||
expect(props.vimHandleInput).toHaveBeenCalled();
|
||||
expect(mockBuffer.handleInput).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unfocused paste', () => {
|
||||
it('should handle bracketed paste when not focused', async () => {
|
||||
props.focus = false;
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('\x1B[200~pasted text\x1B[201~');
|
||||
await wait();
|
||||
|
||||
expect(mockBuffer.handleInput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
paste: true,
|
||||
sequence: 'pasted text',
|
||||
}),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should ignore regular keypresses when not focused', async () => {
|
||||
props.focus = false;
|
||||
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||
await wait();
|
||||
|
||||
stdin.write('a');
|
||||
await wait();
|
||||
|
||||
expect(mockBuffer.handleInput).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,6 @@ 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-code/qwen-code-core';
|
||||
import {
|
||||
@@ -32,7 +31,7 @@ export interface InputPromptProps {
|
||||
userMessages: readonly string[];
|
||||
onClearScreen: () => void;
|
||||
config: Config;
|
||||
slashCommands: SlashCommand[];
|
||||
slashCommands: readonly SlashCommand[];
|
||||
commandContext: CommandContext;
|
||||
placeholder?: string;
|
||||
focus?: boolean;
|
||||
@@ -40,6 +39,7 @@ export interface InputPromptProps {
|
||||
suggestionsWidth: number;
|
||||
shellModeActive: boolean;
|
||||
setShellModeActive: (value: boolean) => void;
|
||||
vimHandleInput?: (key: Key) => boolean;
|
||||
}
|
||||
|
||||
export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
@@ -56,12 +56,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
suggestionsWidth,
|
||||
shellModeActive,
|
||||
setShellModeActive,
|
||||
vimHandleInput,
|
||||
}) => {
|
||||
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
||||
|
||||
const completion = useCompletion(
|
||||
buffer.text,
|
||||
buffer,
|
||||
config.getTargetDir(),
|
||||
isAtCommand(buffer.text) || isSlashCommand(buffer.text),
|
||||
slashCommands,
|
||||
commandContext,
|
||||
config,
|
||||
@@ -95,7 +96,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const inputHistory = useInputHistory({
|
||||
userMessages,
|
||||
onSubmit: handleSubmitAndClear,
|
||||
isActive: !completion.showSuggestions && !shellModeActive,
|
||||
isActive:
|
||||
(!completion.showSuggestions || completion.suggestions.length === 1) &&
|
||||
!shellModeActive,
|
||||
currentQuery: buffer.text,
|
||||
onChange: customSetTextAndResetCompletionSignal,
|
||||
});
|
||||
@@ -113,76 +116,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
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 {
|
||||
@@ -233,7 +166,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
const handleInput = useCallback(
|
||||
(key: Key) => {
|
||||
if (!focus) {
|
||||
/// We want to handle paste even when not focused to support drag and drop.
|
||||
if (!focus && !key.paste) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (vimHandleInput && vimHandleInput(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -264,14 +202,22 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// If the command is a perfect match, pressing enter should execute it.
|
||||
if (completion.isPerfectMatch && key.name === 'return') {
|
||||
handleSubmitAndClear(buffer.text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (completion.showSuggestions) {
|
||||
if (key.name === 'up') {
|
||||
completion.navigateUp();
|
||||
return;
|
||||
}
|
||||
if (key.name === 'down') {
|
||||
completion.navigateDown();
|
||||
return;
|
||||
if (completion.suggestions.length > 1) {
|
||||
if (key.name === 'up' || (key.ctrl && key.name === 'p')) {
|
||||
completion.navigateUp();
|
||||
return;
|
||||
}
|
||||
if (key.name === 'down' || (key.ctrl && key.name === 'n')) {
|
||||
completion.navigateDown();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (key.name === 'tab' || (key.name === 'return' && !key.ctrl)) {
|
||||
@@ -281,66 +227,66 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
? 0 // Default to the first if none is active
|
||||
: completion.activeSuggestionIndex;
|
||||
if (targetIndex < completion.suggestions.length) {
|
||||
handleAutocomplete(targetIndex);
|
||||
completion.handleAutocomplete(targetIndex);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
// 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
|
||||
@@ -356,6 +302,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
if (key.ctrl && key.name === 'e') {
|
||||
buffer.move('end');
|
||||
buffer.moveToOffset(cpLen(buffer.text));
|
||||
return;
|
||||
}
|
||||
// Ctrl+C (Clear input)
|
||||
if (key.ctrl && key.name === 'c') {
|
||||
if (buffer.text.length > 0) {
|
||||
buffer.setText('');
|
||||
resetCompletionState();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -382,7 +338,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to the text buffer's default input handling for all other keys
|
||||
// Fall back to the text buffer's default input handling for all other keys
|
||||
buffer.handleInput(key);
|
||||
},
|
||||
[
|
||||
@@ -393,14 +349,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setShellModeActive,
|
||||
onClearScreen,
|
||||
inputHistory,
|
||||
handleAutocomplete,
|
||||
handleSubmitAndClear,
|
||||
shellHistory,
|
||||
handleClipboardImage,
|
||||
resetCompletionState,
|
||||
vimHandleInput,
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleInput, { isActive: focus });
|
||||
useKeypress(handleInput, { isActive: true });
|
||||
|
||||
const linesToRender = buffer.viewportVisualLines;
|
||||
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
|
||||
@@ -438,7 +395,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
display = display + ' '.repeat(inputWidth - currentVisualWidth);
|
||||
}
|
||||
|
||||
if (visualIdxInRenderedSet === cursorVisualRow) {
|
||||
if (focus && visualIdxInRenderedSet === cursorVisualRow) {
|
||||
const relativeVisualColForHighlight = cursorVisualColAbsolute;
|
||||
|
||||
if (relativeVisualColForHighlight >= 0) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @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 { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
|
||||
|
||||
describe('ShellConfirmationDialog', () => {
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
const request = {
|
||||
commands: ['ls -la', 'echo "hello"'],
|
||||
onConfirm,
|
||||
};
|
||||
|
||||
it('renders correctly', () => {
|
||||
const { lastFrame } = render(<ShellConfirmationDialog request={request} />);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('calls onConfirm with ProceedOnce when "Yes, allow once" is selected', () => {
|
||||
const { lastFrame } = render(<ShellConfirmationDialog request={request} />);
|
||||
const select = lastFrame()!.toString();
|
||||
// Simulate selecting the first option
|
||||
// This is a simplified way to test the selection
|
||||
expect(select).toContain('Yes, allow once');
|
||||
});
|
||||
|
||||
it('calls onConfirm with ProceedAlways when "Yes, allow always for this session" is selected', () => {
|
||||
const { lastFrame } = render(<ShellConfirmationDialog request={request} />);
|
||||
const select = lastFrame()!.toString();
|
||||
// Simulate selecting the second option
|
||||
expect(select).toContain('Yes, allow always for this session');
|
||||
});
|
||||
|
||||
it('calls onConfirm with Cancel when "No (esc)" is selected', () => {
|
||||
const { lastFrame } = render(<ShellConfirmationDialog request={request} />);
|
||||
const select = lastFrame()!.toString();
|
||||
// Simulate selecting the third option
|
||||
expect(select).toContain('No (esc)');
|
||||
});
|
||||
});
|
||||
98
packages/cli/src/ui/components/ShellConfirmationDialog.tsx
Normal file
98
packages/cli/src/ui/components/ShellConfirmationDialog.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import React from 'react';
|
||||
import { Colors } from '../colors.js';
|
||||
import {
|
||||
RadioButtonSelect,
|
||||
RadioSelectItem,
|
||||
} from './shared/RadioButtonSelect.js';
|
||||
|
||||
export interface ShellConfirmationRequest {
|
||||
commands: string[];
|
||||
onConfirm: (
|
||||
outcome: ToolConfirmationOutcome,
|
||||
approvedCommands?: string[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface ShellConfirmationDialogProps {
|
||||
request: ShellConfirmationRequest;
|
||||
}
|
||||
|
||||
export const ShellConfirmationDialog: React.FC<
|
||||
ShellConfirmationDialogProps
|
||||
> = ({ request }) => {
|
||||
const { commands, onConfirm } = request;
|
||||
|
||||
useInput((_, key) => {
|
||||
if (key.escape) {
|
||||
onConfirm(ToolConfirmationOutcome.Cancel);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSelect = (item: ToolConfirmationOutcome) => {
|
||||
if (item === ToolConfirmationOutcome.Cancel) {
|
||||
onConfirm(item);
|
||||
} else {
|
||||
// For both ProceedOnce and ProceedAlways, we approve all the
|
||||
// commands that were requested.
|
||||
onConfirm(item, commands);
|
||||
}
|
||||
};
|
||||
|
||||
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = [
|
||||
{
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
},
|
||||
{
|
||||
label: 'Yes, allow always for this session',
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
},
|
||||
{
|
||||
label: 'No (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentYellow}
|
||||
padding={1}
|
||||
width="100%"
|
||||
marginLeft={1}
|
||||
>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold>Shell Command Execution</Text>
|
||||
<Text>A custom command wants to run the following shell commands:</Text>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={Colors.Gray}
|
||||
paddingX={1}
|
||||
marginTop={1}
|
||||
>
|
||||
{commands.map((cmd) => (
|
||||
<Text key={cmd} color={Colors.AccentCyan}>
|
||||
{cmd}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginBottom={1}>
|
||||
<Text>Do you want to proceed?</Text>
|
||||
</Box>
|
||||
|
||||
<RadioButtonSelect items={options} onSelect={handleSelect} isFocused />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -36,23 +36,45 @@ export function ThemeDialog({
|
||||
SettingScope.User,
|
||||
);
|
||||
|
||||
// Track the currently highlighted theme name
|
||||
const [highlightedThemeName, setHighlightedThemeName] = useState<
|
||||
string | undefined
|
||||
>(settings.merged.theme || DEFAULT_THEME.name);
|
||||
|
||||
// Generate theme items filtered by selected scope
|
||||
const customThemes =
|
||||
selectedScope === SettingScope.User
|
||||
? settings.user.settings.customThemes || {}
|
||||
: settings.merged.customThemes || {};
|
||||
const builtInThemes = themeManager
|
||||
.getAvailableThemes()
|
||||
.filter((theme) => theme.type !== 'custom');
|
||||
const customThemeNames = Object.keys(customThemes);
|
||||
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
// Generate theme items
|
||||
const themeItems = themeManager.getAvailableThemes().map((theme) => {
|
||||
const typeString = theme.type.charAt(0).toUpperCase() + theme.type.slice(1);
|
||||
return {
|
||||
const themeItems = [
|
||||
...builtInThemes.map((theme) => ({
|
||||
label: theme.name,
|
||||
value: theme.name,
|
||||
themeNameDisplay: theme.name,
|
||||
themeTypeDisplay: typeString,
|
||||
};
|
||||
});
|
||||
themeTypeDisplay: capitalize(theme.type),
|
||||
})),
|
||||
...customThemeNames.map((name) => ({
|
||||
label: name,
|
||||
value: name,
|
||||
themeNameDisplay: name,
|
||||
themeTypeDisplay: 'Custom',
|
||||
})),
|
||||
];
|
||||
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
|
||||
// Find the index of the selected theme, but only if it exists in the list
|
||||
const selectedThemeName = settings.merged.theme || DEFAULT_THEME.name;
|
||||
const initialThemeIndex = themeItems.findIndex(
|
||||
(item) => item.value === (settings.merged.theme || DEFAULT_THEME.name),
|
||||
(item) => item.value === selectedThemeName,
|
||||
);
|
||||
// If not found, fall back to the first theme
|
||||
const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0;
|
||||
|
||||
const scopeItems = [
|
||||
{ label: 'User Settings', value: SettingScope.User },
|
||||
@@ -67,6 +89,11 @@ export function ThemeDialog({
|
||||
[onSelect, selectedScope],
|
||||
);
|
||||
|
||||
const handleThemeHighlight = (themeName: string) => {
|
||||
setHighlightedThemeName(themeName);
|
||||
onHighlight(themeName);
|
||||
};
|
||||
|
||||
const handleScopeHighlight = useCallback((scope: SettingScope) => {
|
||||
setSelectedScope(scope);
|
||||
setSelectInputKey(Date.now());
|
||||
@@ -158,7 +185,7 @@ export function ThemeDialog({
|
||||
}
|
||||
|
||||
// Don't focus the scope selection if it is hidden due to height constraints.
|
||||
const currenFocusedSection = !showScopeSelection ? 'theme' : focusedSection;
|
||||
const currentFocusedSection = !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.
|
||||
@@ -173,10 +200,16 @@ export function ThemeDialog({
|
||||
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;
|
||||
|
||||
// Subtract margin between code blocks from available height.
|
||||
const availableHeightForPanes = Math.max(
|
||||
0,
|
||||
availableTerminalHeightCodeBlock - 1,
|
||||
);
|
||||
|
||||
// The code block is slightly longer than the diff, so give it more space.
|
||||
const codeBlockHeight = Math.ceil(availableHeightForPanes * 0.6);
|
||||
const diffHeight = Math.floor(availableHeightForPanes * 0.4);
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
@@ -191,33 +224,35 @@ export function ThemeDialog({
|
||||
<Box flexDirection="row">
|
||||
{/* Left Column: Selection */}
|
||||
<Box flexDirection="column" width="45%" paddingRight={2}>
|
||||
<Text bold={currenFocusedSection === 'theme'} wrap="truncate">
|
||||
{currenFocusedSection === 'theme' ? '> ' : ' '}Select Theme{' '}
|
||||
<Text bold={currentFocusedSection === 'theme'} wrap="truncate">
|
||||
{currentFocusedSection === 'theme' ? '> ' : ' '}Select Theme{' '}
|
||||
<Text color={Colors.Gray}>{otherScopeModifiedMessage}</Text>
|
||||
</Text>
|
||||
<RadioButtonSelect
|
||||
key={selectInputKey}
|
||||
items={themeItems}
|
||||
initialIndex={initialThemeIndex}
|
||||
initialIndex={safeInitialThemeIndex}
|
||||
onSelect={handleThemeSelect}
|
||||
onHighlight={onHighlight}
|
||||
isFocused={currenFocusedSection === 'theme'}
|
||||
onHighlight={handleThemeHighlight}
|
||||
isFocused={currentFocusedSection === 'theme'}
|
||||
maxItemsToShow={8}
|
||||
showScrollArrows={true}
|
||||
showNumbers={currentFocusedSection === 'theme'}
|
||||
/>
|
||||
|
||||
{/* Scope Selection */}
|
||||
{showScopeSelection && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text bold={currenFocusedSection === 'scope'} wrap="truncate">
|
||||
{currenFocusedSection === 'scope' ? '> ' : ' '}Apply To
|
||||
<Text bold={currentFocusedSection === 'scope'} wrap="truncate">
|
||||
{currentFocusedSection === 'scope' ? '> ' : ' '}Apply To
|
||||
</Text>
|
||||
<RadioButtonSelect
|
||||
items={scopeItems}
|
||||
initialIndex={0} // Default to User Settings
|
||||
onSelect={handleScopeSelect}
|
||||
onHighlight={handleScopeHighlight}
|
||||
isFocused={currenFocusedSection === 'scope'}
|
||||
isFocused={currentFocusedSection === 'scope'}
|
||||
showNumbers={currentFocusedSection === 'scope'}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
@@ -226,39 +261,48 @@ export function ThemeDialog({
|
||||
{/* 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>
|
||||
{/* Get the Theme object for the highlighted theme, fall back to default if not found */}
|
||||
{(() => {
|
||||
const previewTheme =
|
||||
themeManager.getTheme(
|
||||
highlightedThemeName || DEFAULT_THEME.name,
|
||||
) || DEFAULT_THEME;
|
||||
return (
|
||||
<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/util.py
|
||||
+++ b/util.py
|
||||
@@ -1,2 +1,2 @@
|
||||
- print("Hello, " + name)
|
||||
+ print(f"Hello, {name}!")
|
||||
`}
|
||||
availableTerminalHeight={diffHeight}
|
||||
terminalWidth={colorizeCodeWidth}
|
||||
theme={previewTheme}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
|
||||
@@ -16,7 +16,7 @@ interface TipsProps {
|
||||
export const Tips: React.FC<TipsProps> = ({ config }) => {
|
||||
const geminiMdFileCount = config.getGeminiMdFileCount();
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box flexDirection="column">
|
||||
<Text color={Colors.Foreground}>Tips for getting started:</Text>
|
||||
<Text color={Colors.Foreground}>
|
||||
1. Ask questions, edit files, or run commands.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ShellConfirmationDialog > renders correctly 1`] = `
|
||||
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ Shell Command Execution │
|
||||
│ A custom command wants to run the following shell commands: │
|
||||
│ │
|
||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
|
||||
│ │ ls -la │ │
|
||||
│ │ echo "hello" │ │
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ Do you want to proceed? │
|
||||
│ │
|
||||
│ ● 1. Yes, allow once │
|
||||
│ 2. Yes, allow always for this session │
|
||||
│ 3. No (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -44,6 +44,7 @@ index 0000000..e69de29
|
||||
'python',
|
||||
undefined,
|
||||
80,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -71,6 +72,7 @@ index 0000000..e69de29
|
||||
null,
|
||||
undefined,
|
||||
80,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -94,6 +96,7 @@ index 0000000..e69de29
|
||||
null,
|
||||
undefined,
|
||||
80,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -127,8 +130,8 @@ index 0000001..0000002 100644
|
||||
);
|
||||
const output = lastFrame();
|
||||
const lines = output!.split('\n');
|
||||
expect(lines[0]).toBe('1 - old line');
|
||||
expect(lines[1]).toBe('1 + new line');
|
||||
expect(lines[0]).toBe('1 - old line');
|
||||
expect(lines[1]).toBe('1 + new line');
|
||||
});
|
||||
|
||||
it('should handle diff with only header and no changes', () => {
|
||||
@@ -250,35 +253,35 @@ index 123..789 100644
|
||||
{
|
||||
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');
|
||||
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');`,
|
||||
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');`,
|
||||
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');`,
|
||||
;
|
||||
21 + const anotherNew = 'test'
|
||||
;
|
||||
22 console.log('end of
|
||||
second hunk');`,
|
||||
},
|
||||
])(
|
||||
'with terminalWidth $terminalWidth and height $height',
|
||||
@@ -326,11 +329,11 @@ fileDiff Index: file.txt
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toEqual(`1 - const oldVar = 1;
|
||||
1 + const newVar = 1;
|
||||
expect(output).toEqual(` 1 - const oldVar = 1;
|
||||
1 + const newVar = 1;
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
20 - const anotherOld = 'test';
|
||||
20 + const anotherNew = 'test';`);
|
||||
20 - const anotherOld = 'test';
|
||||
20 + const anotherNew = 'test';`);
|
||||
});
|
||||
|
||||
it('should correctly render a new file with no file extension correctly', () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 { colorizeCode, colorizeLine } from '../../utils/CodeColorizer.js';
|
||||
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||
|
||||
interface DiffLine {
|
||||
@@ -93,6 +93,7 @@ interface DiffRendererProps {
|
||||
tabWidth?: number;
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
theme?: import('../../themes/theme.js').Theme;
|
||||
}
|
||||
|
||||
const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization
|
||||
@@ -103,6 +104,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||
tabWidth = DEFAULT_TAB_WIDTH,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
theme,
|
||||
}) => {
|
||||
if (!diffContent || typeof diffContent !== 'string') {
|
||||
return <Text color={Colors.AccentYellow}>No diff content.</Text>;
|
||||
@@ -146,6 +148,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||
language,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
theme,
|
||||
);
|
||||
} else {
|
||||
renderedOutput = renderDiffContent(
|
||||
@@ -186,6 +189,18 @@ const renderDiffContent = (
|
||||
);
|
||||
}
|
||||
|
||||
const maxLineNumber = Math.max(
|
||||
0,
|
||||
...displayableLines.map((l) => l.oldLine ?? 0),
|
||||
...displayableLines.map((l) => l.newLine ?? 0),
|
||||
);
|
||||
const gutterWidth = Math.max(1, maxLineNumber.toString().length);
|
||||
|
||||
const fileExtension = filename?.split('.').pop() || null;
|
||||
const language = fileExtension
|
||||
? getLanguageFromExtension(fileExtension)
|
||||
: null;
|
||||
|
||||
// Calculate the minimum indentation across all displayable lines
|
||||
let baseIndentation = Infinity; // Start high to find the minimum
|
||||
for (const line of displayableLines) {
|
||||
@@ -232,27 +247,25 @@ const renderDiffContent = (
|
||||
) {
|
||||
acc.push(
|
||||
<Box key={`gap-${index}`}>
|
||||
<Text wrap="truncate">{'═'.repeat(terminalWidth)}</Text>
|
||||
<Text wrap="truncate" color={Colors.Gray}>
|
||||
{'═'.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
|
||||
@@ -263,7 +276,6 @@ const renderDiffContent = (
|
||||
break;
|
||||
case 'context':
|
||||
gutterNumStr = (line.newLine ?? '').toString();
|
||||
dim = true;
|
||||
prefixSymbol = ' ';
|
||||
lastLineNumber = line.newLine ?? null;
|
||||
break;
|
||||
@@ -275,13 +287,26 @@ const renderDiffContent = (
|
||||
|
||||
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 color={Colors.Gray}>
|
||||
{gutterNumStr.padStart(gutterWidth)}{' '}
|
||||
</Text>
|
||||
{line.type === 'context' ? (
|
||||
<>
|
||||
<Text>{prefixSymbol} </Text>
|
||||
<Text wrap="wrap">
|
||||
{colorizeLine(displayContent, language)}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text
|
||||
backgroundColor={
|
||||
line.type === 'add' ? Colors.DiffAdded : Colors.DiffRemoved
|
||||
}
|
||||
wrap="wrap"
|
||||
>
|
||||
{prefixSymbol} {colorizeLine(displayContent, language)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>,
|
||||
);
|
||||
return acc;
|
||||
|
||||
@@ -132,19 +132,20 @@ export const ToolConfirmationMessage: React.FC<
|
||||
const executionProps =
|
||||
confirmationDetails as ToolExecuteConfirmationDetails;
|
||||
|
||||
question = `Allow execution?`;
|
||||
question = `Allow execution of: '${executionProps.rootCommand}'?`;
|
||||
options.push(
|
||||
{
|
||||
label: 'Yes, allow once',
|
||||
label: `Yes, allow once`,
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
},
|
||||
{
|
||||
label: `Yes, allow always "${executionProps.rootCommand} ..."`,
|
||||
label: `Yes, allow always ...`,
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
},
|
||||
{ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel },
|
||||
);
|
||||
|
||||
options.push({ label: 'No (esc)', value: ToolConfirmationOutcome.Cancel });
|
||||
|
||||
let bodyContentHeight = availableBodyContentHeight();
|
||||
if (bodyContentHeight !== undefined) {
|
||||
bodyContentHeight -= 2; // Account for padding;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ToolMessage } from './ToolMessage.js';
|
||||
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
|
||||
import { Colors } from '../../colors.js';
|
||||
import { Config } from '@qwen-code/qwen-code-core';
|
||||
import { SHELL_COMMAND_NAME } from '../../constants.js';
|
||||
|
||||
interface ToolGroupMessageProps {
|
||||
groupId: number;
|
||||
@@ -32,7 +33,9 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
const hasPending = !toolCalls.every(
|
||||
(t) => t.status === ToolCallStatus.Success,
|
||||
);
|
||||
const borderColor = hasPending ? Colors.AccentYellow : Colors.Gray;
|
||||
const isShellCommand = toolCalls.some((t) => t.name === SHELL_COMMAND_NAME);
|
||||
const borderColor =
|
||||
hasPending || isShellCommand ? 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
|
||||
|
||||
@@ -152,6 +152,8 @@ describe('<ToolMessage />', () => {
|
||||
const diffResult = {
|
||||
fileDiff: '--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new',
|
||||
fileName: 'file.txt',
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
};
|
||||
const { lastFrame } = renderWithContext(
|
||||
<ToolMessage {...baseProps} resultDisplay={diffResult} />,
|
||||
|
||||
@@ -248,6 +248,89 @@ Line 3`);
|
||||
🐶`);
|
||||
});
|
||||
|
||||
it('falls back to an ellipsis when width is extremely small', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={2} maxHeight={2}>
|
||||
<Box>
|
||||
<Text>No</Text>
|
||||
<Text wrap="wrap">wrap</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).equals('N…');
|
||||
});
|
||||
|
||||
it('truncates long non-wrapping text with ellipsis', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={3} maxHeight={2}>
|
||||
<Box>
|
||||
<Text>ABCDE</Text>
|
||||
<Text wrap="wrap">wrap</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).equals('AB…');
|
||||
});
|
||||
|
||||
it('truncates non-wrapping text containing line breaks', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={3} maxHeight={2}>
|
||||
<Box>
|
||||
<Text>{'A\nBCDE'}</Text>
|
||||
<Text wrap="wrap">wrap</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).equals(`A\n…`);
|
||||
});
|
||||
|
||||
it('truncates emoji characters correctly with ellipsis', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={3} maxHeight={2}>
|
||||
<Box>
|
||||
<Text>🐶🐶🐶</Text>
|
||||
<Text wrap="wrap">wrap</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).equals(`🐶…`);
|
||||
});
|
||||
|
||||
it('shows ellipsis for multiple rows with long non-wrapping text', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={3} maxHeight={3}>
|
||||
<Box>
|
||||
<Text>AAA</Text>
|
||||
<Text wrap="wrap">first</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>BBB</Text>
|
||||
<Text wrap="wrap">second</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>CCC</Text>
|
||||
<Text wrap="wrap">third</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).equals(`AA…\nBB…\nCC…`);
|
||||
});
|
||||
|
||||
it('accounts for additionalHiddenLinesCount', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
|
||||
@@ -432,8 +432,85 @@ function layoutInkElementAsStyledText(
|
||||
const availableWidth = maxWidth - noWrappingWidth;
|
||||
|
||||
if (availableWidth < 1) {
|
||||
// No room to render the wrapping segments. TODO(jacob314): consider an alternative fallback strategy.
|
||||
output.push(nonWrappingContent);
|
||||
// No room to render the wrapping segments. Truncate the non-wrapping
|
||||
// content and append an ellipsis so the line always fits within maxWidth.
|
||||
|
||||
// Handle line breaks in non-wrapping content when truncating
|
||||
const lines: StyledText[][] = [];
|
||||
let currentLine: StyledText[] = [];
|
||||
let currentLineWidth = 0;
|
||||
|
||||
for (const segment of nonWrappingContent) {
|
||||
const textLines = segment.text.split('\n');
|
||||
textLines.forEach((text, index) => {
|
||||
if (index > 0) {
|
||||
// New line encountered, finish current line and start new one
|
||||
lines.push(currentLine);
|
||||
currentLine = [];
|
||||
currentLineWidth = 0;
|
||||
}
|
||||
|
||||
if (text) {
|
||||
const textWidth = stringWidth(text);
|
||||
|
||||
// When there's no room for wrapping content, be very conservative
|
||||
// For lines after the first line break, show only ellipsis if the text would be truncated
|
||||
if (index > 0 && textWidth > 0) {
|
||||
// This is content after a line break - just show ellipsis to indicate truncation
|
||||
currentLine.push({ text: '…', props: {} });
|
||||
currentLineWidth = stringWidth('…');
|
||||
} else {
|
||||
// This is the first line or a continuation, try to fit what we can
|
||||
const maxContentWidth = Math.max(0, maxWidth - stringWidth('…'));
|
||||
|
||||
if (textWidth <= maxContentWidth && currentLineWidth === 0) {
|
||||
// Text fits completely on this line
|
||||
currentLine.push({ text, props: segment.props });
|
||||
currentLineWidth += textWidth;
|
||||
} else {
|
||||
// Text needs truncation
|
||||
const codePoints = toCodePoints(text);
|
||||
let truncatedWidth = currentLineWidth;
|
||||
let sliceEndIndex = 0;
|
||||
|
||||
for (const char of codePoints) {
|
||||
const charWidth = stringWidth(char);
|
||||
if (truncatedWidth + charWidth > maxContentWidth) {
|
||||
break;
|
||||
}
|
||||
truncatedWidth += charWidth;
|
||||
sliceEndIndex++;
|
||||
}
|
||||
|
||||
const slice = codePoints.slice(0, sliceEndIndex).join('');
|
||||
if (slice) {
|
||||
currentLine.push({ text: slice, props: segment.props });
|
||||
}
|
||||
currentLine.push({ text: '…', props: {} });
|
||||
currentLineWidth = truncatedWidth + stringWidth('…');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add the last line if it has content or if the last segment ended with \n
|
||||
if (
|
||||
currentLine.length > 0 ||
|
||||
(nonWrappingContent.length > 0 &&
|
||||
nonWrappingContent[nonWrappingContent.length - 1].text.endsWith('\n'))
|
||||
) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
// If we don't have any lines yet, add an ellipsis line
|
||||
if (lines.length === 0) {
|
||||
lines.push([{ text: '…', props: {} }]);
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
output.push(line);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
115
packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx
Normal file
115
packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import {
|
||||
RadioButtonSelect,
|
||||
type RadioSelectItem,
|
||||
} from './RadioButtonSelect.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
const ITEMS: Array<RadioSelectItem<string>> = [
|
||||
{ label: 'Option 1', value: 'one' },
|
||||
{ label: 'Option 2', value: 'two' },
|
||||
{ label: 'Option 3', value: 'three', disabled: true },
|
||||
];
|
||||
|
||||
describe('<RadioButtonSelect />', () => {
|
||||
it('renders a list of items and matches snapshot', () => {
|
||||
const { lastFrame } = render(
|
||||
<RadioButtonSelect items={ITEMS} onSelect={() => {}} isFocused={true} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders with the second item selected and matches snapshot', () => {
|
||||
const { lastFrame } = render(
|
||||
<RadioButtonSelect
|
||||
items={ITEMS}
|
||||
initialIndex={1}
|
||||
onSelect={() => {}}
|
||||
isFocused={true}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders with numbers hidden and matches snapshot', () => {
|
||||
const { lastFrame } = render(
|
||||
<RadioButtonSelect
|
||||
items={ITEMS}
|
||||
onSelect={() => {}}
|
||||
isFocused={true}
|
||||
showNumbers={false}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders with scroll arrows and matches snapshot', () => {
|
||||
const manyItems = Array.from({ length: 20 }, (_, i) => ({
|
||||
label: `Item ${i + 1}`,
|
||||
value: `item-${i + 1}`,
|
||||
}));
|
||||
const { lastFrame } = render(
|
||||
<RadioButtonSelect
|
||||
items={manyItems}
|
||||
onSelect={() => {}}
|
||||
isFocused={true}
|
||||
showScrollArrows={true}
|
||||
maxItemsToShow={5}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders with special theme display and matches snapshot', () => {
|
||||
const themeItems: Array<RadioSelectItem<string>> = [
|
||||
{
|
||||
label: 'Theme A (Light)',
|
||||
value: 'a-light',
|
||||
themeNameDisplay: 'Theme A',
|
||||
themeTypeDisplay: '(Light)',
|
||||
},
|
||||
{
|
||||
label: 'Theme B (Dark)',
|
||||
value: 'b-dark',
|
||||
themeNameDisplay: 'Theme B',
|
||||
themeTypeDisplay: '(Dark)',
|
||||
},
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
<RadioButtonSelect
|
||||
items={themeItems}
|
||||
onSelect={() => {}}
|
||||
isFocused={true}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders a list with >10 items and matches snapshot', () => {
|
||||
const manyItems = Array.from({ length: 12 }, (_, i) => ({
|
||||
label: `Item ${i + 1}`,
|
||||
value: `item-${i + 1}`,
|
||||
}));
|
||||
const { lastFrame } = render(
|
||||
<RadioButtonSelect
|
||||
items={manyItems}
|
||||
onSelect={() => {}}
|
||||
isFocused={true}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders nothing when no items are provided', () => {
|
||||
const { lastFrame } = render(
|
||||
<RadioButtonSelect items={[]} onSelect={() => {}} isFocused={true} />,
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Text, Box, useInput } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface RadioButtonSelectProps<T> {
|
||||
showScrollArrows?: boolean;
|
||||
/** The maximum number of items to show at once. */
|
||||
maxItemsToShow?: number;
|
||||
/** Whether to show numbers next to items. */
|
||||
showNumbers?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,23 +57,12 @@ export function RadioButtonSelect<T>({
|
||||
isFocused,
|
||||
showScrollArrows = false,
|
||||
maxItemsToShow = 10,
|
||||
showNumbers = true,
|
||||
}: RadioButtonSelectProps<T>): React.JSX.Element {
|
||||
// Ensure initialIndex is within bounds
|
||||
const safeInitialIndex =
|
||||
items.length > 0
|
||||
? Math.max(0, Math.min(initialIndex, items.length - 1))
|
||||
: 0;
|
||||
const [activeIndex, setActiveIndex] = useState(safeInitialIndex);
|
||||
const [activeIndex, setActiveIndex] = useState(initialIndex);
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
|
||||
// Ensure activeIndex is always within bounds when items change
|
||||
useEffect(() => {
|
||||
if (items.length === 0) {
|
||||
setActiveIndex(0);
|
||||
} else if (activeIndex >= items.length) {
|
||||
setActiveIndex(Math.max(0, items.length - 1));
|
||||
}
|
||||
}, [items.length, activeIndex]);
|
||||
const [numberInput, setNumberInput] = useState('');
|
||||
const numberInputTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const newScrollOffset = Math.max(
|
||||
@@ -85,55 +76,85 @@ export function RadioButtonSelect<T>({
|
||||
}
|
||||
}, [activeIndex, items.length, scrollOffset, maxItemsToShow]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (numberInputTimer.current) {
|
||||
clearTimeout(numberInputTimer.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (input === 'k' || key.upArrow) {
|
||||
if (items.length > 0) {
|
||||
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
|
||||
setActiveIndex(newIndex);
|
||||
if (items[newIndex]) {
|
||||
onHighlight?.(items[newIndex].value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (input === 'j' || key.downArrow) {
|
||||
if (items.length > 0) {
|
||||
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
|
||||
setActiveIndex(newIndex);
|
||||
if (items[newIndex]) {
|
||||
onHighlight?.(items[newIndex].value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (key.return) {
|
||||
// Add bounds check before accessing items[activeIndex]
|
||||
if (
|
||||
activeIndex >= 0 &&
|
||||
activeIndex < items.length &&
|
||||
items[activeIndex]
|
||||
) {
|
||||
onSelect(items[activeIndex].value);
|
||||
}
|
||||
const isNumeric = showNumbers && /^[0-9]$/.test(input);
|
||||
|
||||
// Any key press that is not a digit should clear the number input buffer.
|
||||
if (!isNumeric && numberInputTimer.current) {
|
||||
clearTimeout(numberInputTimer.current);
|
||||
setNumberInput('');
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (input === 'k' || key.upArrow) {
|
||||
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
|
||||
setActiveIndex(newIndex);
|
||||
onHighlight?.(items[newIndex]!.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (input === 'j' || key.downArrow) {
|
||||
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
|
||||
setActiveIndex(newIndex);
|
||||
onHighlight?.(items[newIndex]!.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
onSelect(items[activeIndex]!.value);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle numeric input for selection.
|
||||
if (isNumeric) {
|
||||
if (numberInputTimer.current) {
|
||||
clearTimeout(numberInputTimer.current);
|
||||
}
|
||||
|
||||
const newNumberInput = numberInput + input;
|
||||
setNumberInput(newNumberInput);
|
||||
|
||||
const targetIndex = Number.parseInt(newNumberInput, 10) - 1;
|
||||
|
||||
// A single '0' is not a valid selection since items are 1-indexed.
|
||||
if (newNumberInput === '0') {
|
||||
numberInputTimer.current = setTimeout(() => setNumberInput(''), 350);
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetIndex >= 0 && targetIndex < items.length) {
|
||||
const targetItem = items[targetIndex]!;
|
||||
setActiveIndex(targetIndex);
|
||||
onHighlight?.(targetItem.value);
|
||||
|
||||
// If the typed number can't be a prefix for another valid number,
|
||||
// select it immediately. Otherwise, wait for more input.
|
||||
const potentialNextNumber = Number.parseInt(newNumberInput + '0', 10);
|
||||
if (potentialNextNumber > items.length) {
|
||||
onSelect(targetItem.value);
|
||||
setNumberInput('');
|
||||
} else {
|
||||
numberInputTimer.current = setTimeout(() => {
|
||||
onSelect(targetItem.value);
|
||||
setNumberInput('');
|
||||
}, 350); // Debounce time for multi-digit input.
|
||||
}
|
||||
} else {
|
||||
// The typed number is out of bounds, clear the buffer
|
||||
setNumberInput('');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
isActive:
|
||||
isFocused &&
|
||||
items.length > 0 &&
|
||||
activeIndex >= 0 &&
|
||||
activeIndex < items.length,
|
||||
},
|
||||
{ isActive: isFocused && items.length > 0 },
|
||||
);
|
||||
|
||||
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
|
||||
@@ -150,19 +171,38 @@ export function RadioButtonSelect<T>({
|
||||
const isSelected = activeIndex === itemIndex;
|
||||
|
||||
let textColor = Colors.Foreground;
|
||||
let numberColor = Colors.Foreground;
|
||||
if (isSelected) {
|
||||
textColor = Colors.AccentGreen;
|
||||
numberColor = Colors.AccentGreen;
|
||||
} else if (item.disabled) {
|
||||
textColor = Colors.Gray;
|
||||
numberColor = Colors.Gray;
|
||||
}
|
||||
|
||||
if (!showNumbers) {
|
||||
numberColor = Colors.Gray;
|
||||
}
|
||||
|
||||
const numberColumnWidth = String(items.length).length;
|
||||
const itemNumberText = `${String(itemIndex + 1).padStart(
|
||||
numberColumnWidth,
|
||||
)}.`;
|
||||
|
||||
return (
|
||||
<Box key={item.label}>
|
||||
<Box key={item.label} alignItems="center">
|
||||
<Box minWidth={2} flexShrink={0}>
|
||||
<Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}>
|
||||
{isSelected ? '●' : '○'}
|
||||
{isSelected ? '●' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
marginRight={1}
|
||||
flexShrink={0}
|
||||
minWidth={itemNumberText.length}
|
||||
>
|
||||
<Text color={numberColor}>{itemNumberText}</Text>
|
||||
</Box>
|
||||
{item.themeNameDisplay && item.themeTypeDisplay ? (
|
||||
<Text color={textColor} wrap="truncate">
|
||||
{item.themeNameDisplay}{' '}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<RadioButtonSelect /> > renders a list of items and matches snapshot 1`] = `
|
||||
"● 1. Option 1
|
||||
2. Option 2
|
||||
3. Option 3"
|
||||
`;
|
||||
|
||||
exports[`<RadioButtonSelect /> > renders a list with >10 items and matches snapshot 1`] = `
|
||||
"● 1. Item 1
|
||||
2. Item 2
|
||||
3. Item 3
|
||||
4. Item 4
|
||||
5. Item 5
|
||||
6. Item 6
|
||||
7. Item 7
|
||||
8. Item 8
|
||||
9. Item 9
|
||||
10. Item 10"
|
||||
`;
|
||||
|
||||
exports[`<RadioButtonSelect /> > renders with numbers hidden and matches snapshot 1`] = `
|
||||
"● 1. Option 1
|
||||
2. Option 2
|
||||
3. Option 3"
|
||||
`;
|
||||
|
||||
exports[`<RadioButtonSelect /> > renders with scroll arrows and matches snapshot 1`] = `
|
||||
"▲
|
||||
● 1. Item 1
|
||||
2. Item 2
|
||||
3. Item 3
|
||||
4. Item 4
|
||||
5. Item 5
|
||||
▼"
|
||||
`;
|
||||
|
||||
exports[`<RadioButtonSelect /> > renders with special theme display and matches snapshot 1`] = `
|
||||
"● 1. Theme A (Light)
|
||||
2. Theme B (Dark)"
|
||||
`;
|
||||
|
||||
exports[`<RadioButtonSelect /> > renders with the second item selected and matches snapshot 1`] = `
|
||||
" 1. Option 1
|
||||
● 2. Option 2
|
||||
3. Option 3"
|
||||
`;
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Viewport,
|
||||
TextBuffer,
|
||||
offsetToLogicalPos,
|
||||
logicalPosToOffset,
|
||||
textBufferReducer,
|
||||
TextBufferState,
|
||||
TextBufferAction,
|
||||
@@ -407,8 +408,8 @@ describe('useTextBuffer', () => {
|
||||
useTextBuffer({ viewport, isValidPath: () => true }),
|
||||
);
|
||||
const filePath = '/path/to/a/valid/file.txt';
|
||||
act(() => result.current.insert(filePath));
|
||||
expect(getBufferState(result).text).toBe(`@${filePath}`);
|
||||
act(() => result.current.insert(filePath, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe(`@${filePath} `);
|
||||
});
|
||||
|
||||
it('should not prepend @ to an invalid file path on insert', () => {
|
||||
@@ -416,7 +417,7 @@ describe('useTextBuffer', () => {
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
const notAPath = 'this is just some long text';
|
||||
act(() => result.current.insert(notAPath));
|
||||
act(() => result.current.insert(notAPath, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe(notAPath);
|
||||
});
|
||||
|
||||
@@ -425,8 +426,8 @@ describe('useTextBuffer', () => {
|
||||
useTextBuffer({ viewport, isValidPath: () => true }),
|
||||
);
|
||||
const filePath = "'/path/to/a/valid/file.txt'";
|
||||
act(() => result.current.insert(filePath));
|
||||
expect(getBufferState(result).text).toBe(`@/path/to/a/valid/file.txt`);
|
||||
act(() => result.current.insert(filePath, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe(`@/path/to/a/valid/file.txt `);
|
||||
});
|
||||
|
||||
it('should not prepend @ to short text that is not a path', () => {
|
||||
@@ -434,7 +435,7 @@ describe('useTextBuffer', () => {
|
||||
useTextBuffer({ viewport, isValidPath: () => true }),
|
||||
);
|
||||
const shortText = 'ab';
|
||||
act(() => result.current.insert(shortText));
|
||||
act(() => result.current.insert(shortText, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe(shortText);
|
||||
});
|
||||
});
|
||||
@@ -449,7 +450,7 @@ describe('useTextBuffer', () => {
|
||||
}),
|
||||
);
|
||||
const filePath = '/path/to/a/valid/file.txt';
|
||||
act(() => result.current.insert(filePath));
|
||||
act(() => result.current.insert(filePath, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe(filePath); // No @ prefix
|
||||
});
|
||||
|
||||
@@ -462,7 +463,7 @@ describe('useTextBuffer', () => {
|
||||
}),
|
||||
);
|
||||
const quotedFilePath = "'/path/to/a/valid/file.txt'";
|
||||
act(() => result.current.insert(quotedFilePath));
|
||||
act(() => result.current.insert(quotedFilePath, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe(quotedFilePath); // No @ prefix, keeps quotes
|
||||
});
|
||||
|
||||
@@ -475,7 +476,7 @@ describe('useTextBuffer', () => {
|
||||
}),
|
||||
);
|
||||
const notAPath = 'this is just some text';
|
||||
act(() => result.current.insert(notAPath));
|
||||
act(() => result.current.insert(notAPath, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe(notAPath);
|
||||
});
|
||||
|
||||
@@ -488,7 +489,7 @@ describe('useTextBuffer', () => {
|
||||
}),
|
||||
);
|
||||
const shortText = 'ls';
|
||||
act(() => result.current.insert(shortText));
|
||||
act(() => result.current.insert(shortText, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe(shortText); // No @ prefix for short text
|
||||
});
|
||||
});
|
||||
@@ -849,6 +850,7 @@ describe('useTextBuffer', () => {
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: '\x7f',
|
||||
});
|
||||
result.current.handleInput({
|
||||
@@ -856,6 +858,7 @@ describe('useTextBuffer', () => {
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: '\x7f',
|
||||
});
|
||||
result.current.handleInput({
|
||||
@@ -863,6 +866,7 @@ describe('useTextBuffer', () => {
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: '\x7f',
|
||||
});
|
||||
});
|
||||
@@ -990,9 +994,9 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||
|
||||
// Simulate pasting the long text multiple times
|
||||
act(() => {
|
||||
result.current.insert(longText);
|
||||
result.current.insert(longText);
|
||||
result.current.insert(longText);
|
||||
result.current.insert(longText, { paste: true });
|
||||
result.current.insert(longText, { paste: true });
|
||||
result.current.insert(longText, { paste: true });
|
||||
});
|
||||
|
||||
const state = getBufferState(result);
|
||||
@@ -1338,3 +1342,216 @@ describe('offsetToLogicalPos', () => {
|
||||
expect(offsetToLogicalPos(text, 2)).toEqual([0, 2]); // After 🐱
|
||||
});
|
||||
});
|
||||
|
||||
describe('logicalPosToOffset', () => {
|
||||
it('should convert row/col position to offset correctly', () => {
|
||||
const lines = ['hello', 'world', '123'];
|
||||
|
||||
// Line 0: "hello" (5 chars)
|
||||
expect(logicalPosToOffset(lines, 0, 0)).toBe(0); // Start of 'hello'
|
||||
expect(logicalPosToOffset(lines, 0, 3)).toBe(3); // 'l' in 'hello'
|
||||
expect(logicalPosToOffset(lines, 0, 5)).toBe(5); // End of 'hello'
|
||||
|
||||
// Line 1: "world" (5 chars), offset starts at 6 (5 + 1 for newline)
|
||||
expect(logicalPosToOffset(lines, 1, 0)).toBe(6); // Start of 'world'
|
||||
expect(logicalPosToOffset(lines, 1, 2)).toBe(8); // 'r' in 'world'
|
||||
expect(logicalPosToOffset(lines, 1, 5)).toBe(11); // End of 'world'
|
||||
|
||||
// Line 2: "123" (3 chars), offset starts at 12 (5 + 1 + 5 + 1)
|
||||
expect(logicalPosToOffset(lines, 2, 0)).toBe(12); // Start of '123'
|
||||
expect(logicalPosToOffset(lines, 2, 1)).toBe(13); // '2' in '123'
|
||||
expect(logicalPosToOffset(lines, 2, 3)).toBe(15); // End of '123'
|
||||
});
|
||||
|
||||
it('should handle empty lines', () => {
|
||||
const lines = ['a', '', 'c'];
|
||||
|
||||
expect(logicalPosToOffset(lines, 0, 0)).toBe(0); // 'a'
|
||||
expect(logicalPosToOffset(lines, 0, 1)).toBe(1); // End of 'a'
|
||||
expect(logicalPosToOffset(lines, 1, 0)).toBe(2); // Empty line
|
||||
expect(logicalPosToOffset(lines, 2, 0)).toBe(3); // 'c'
|
||||
expect(logicalPosToOffset(lines, 2, 1)).toBe(4); // End of 'c'
|
||||
});
|
||||
|
||||
it('should handle single empty line', () => {
|
||||
const lines = [''];
|
||||
|
||||
expect(logicalPosToOffset(lines, 0, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('should be inverse of offsetToLogicalPos', () => {
|
||||
const lines = ['hello', 'world', '123'];
|
||||
const text = lines.join('\n');
|
||||
|
||||
// Test round-trip conversion
|
||||
for (let offset = 0; offset <= text.length; offset++) {
|
||||
const [row, col] = offsetToLogicalPos(text, offset);
|
||||
const convertedOffset = logicalPosToOffset(lines, row, col);
|
||||
expect(convertedOffset).toBe(offset);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle out-of-bounds positions', () => {
|
||||
const lines = ['hello'];
|
||||
|
||||
// Beyond end of line
|
||||
expect(logicalPosToOffset(lines, 0, 10)).toBe(5); // Clamps to end of line
|
||||
|
||||
// Beyond array bounds - should clamp to the last line
|
||||
expect(logicalPosToOffset(lines, 5, 0)).toBe(0); // Clamps to start of last line (row 0)
|
||||
expect(logicalPosToOffset(lines, 5, 10)).toBe(5); // Clamps to end of last line
|
||||
});
|
||||
});
|
||||
|
||||
describe('textBufferReducer vim operations', () => {
|
||||
describe('vim_delete_line', () => {
|
||||
it('should delete a single line including newline in multi-line text', () => {
|
||||
const initialState: TextBufferState = {
|
||||
lines: ['line1', 'line2', 'line3'],
|
||||
cursorRow: 1,
|
||||
cursorCol: 2,
|
||||
preferredCol: null,
|
||||
visualLines: [['line1'], ['line2'], ['line3']],
|
||||
visualScrollRow: 0,
|
||||
visualCursor: { row: 1, col: 2 },
|
||||
viewport: { width: 10, height: 5 },
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
const action: TextBufferAction = {
|
||||
type: 'vim_delete_line',
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = textBufferReducer(initialState, action);
|
||||
|
||||
// After deleting line2, we should have line1 and line3, with cursor on line3 (now at index 1)
|
||||
expect(result.lines).toEqual(['line1', 'line3']);
|
||||
expect(result.cursorRow).toBe(1);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete multiple lines when count > 1', () => {
|
||||
const initialState: TextBufferState = {
|
||||
lines: ['line1', 'line2', 'line3', 'line4'],
|
||||
cursorRow: 1,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
visualLines: [['line1'], ['line2'], ['line3'], ['line4']],
|
||||
visualScrollRow: 0,
|
||||
visualCursor: { row: 1, col: 0 },
|
||||
viewport: { width: 10, height: 5 },
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
const action: TextBufferAction = {
|
||||
type: 'vim_delete_line',
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = textBufferReducer(initialState, action);
|
||||
|
||||
// Should delete line2 and line3, leaving line1 and line4
|
||||
expect(result.lines).toEqual(['line1', 'line4']);
|
||||
expect(result.cursorRow).toBe(1);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should clear single line content when only one line exists', () => {
|
||||
const initialState: TextBufferState = {
|
||||
lines: ['only line'],
|
||||
cursorRow: 0,
|
||||
cursorCol: 5,
|
||||
preferredCol: null,
|
||||
visualLines: [['only line']],
|
||||
visualScrollRow: 0,
|
||||
visualCursor: { row: 0, col: 5 },
|
||||
viewport: { width: 10, height: 5 },
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
const action: TextBufferAction = {
|
||||
type: 'vim_delete_line',
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = textBufferReducer(initialState, action);
|
||||
|
||||
// Should clear the line content but keep the line
|
||||
expect(result.lines).toEqual(['']);
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle deleting the last line properly', () => {
|
||||
const initialState: TextBufferState = {
|
||||
lines: ['line1', 'line2'],
|
||||
cursorRow: 1,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
visualLines: [['line1'], ['line2']],
|
||||
visualScrollRow: 0,
|
||||
visualCursor: { row: 1, col: 0 },
|
||||
viewport: { width: 10, height: 5 },
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
const action: TextBufferAction = {
|
||||
type: 'vim_delete_line',
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = textBufferReducer(initialState, action);
|
||||
|
||||
// Should delete the last line completely, not leave empty line
|
||||
expect(result.lines).toEqual(['line1']);
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle deleting all lines and maintain valid state for subsequent paste', () => {
|
||||
const initialState: TextBufferState = {
|
||||
lines: ['line1', 'line2', 'line3', 'line4'],
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
visualLines: [['line1'], ['line2'], ['line3'], ['line4']],
|
||||
visualScrollRow: 0,
|
||||
visualCursor: { row: 0, col: 0 },
|
||||
viewport: { width: 10, height: 5 },
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
// Delete all 4 lines with 4dd
|
||||
const deleteAction: TextBufferAction = {
|
||||
type: 'vim_delete_line',
|
||||
payload: { count: 4 },
|
||||
};
|
||||
|
||||
const afterDelete = textBufferReducer(initialState, deleteAction);
|
||||
|
||||
// After deleting all lines, should have one empty line
|
||||
expect(afterDelete.lines).toEqual(['']);
|
||||
expect(afterDelete.cursorRow).toBe(0);
|
||||
expect(afterDelete.cursorCol).toBe(0);
|
||||
|
||||
// Now paste multiline content - this should work correctly
|
||||
const pasteAction: TextBufferAction = {
|
||||
type: 'insert',
|
||||
payload: 'new1\nnew2\nnew3\nnew4',
|
||||
};
|
||||
|
||||
const afterPaste = textBufferReducer(afterDelete, pasteAction);
|
||||
|
||||
// All lines including the first one should be present
|
||||
expect(afterPaste.lines).toEqual(['new1', 'new2', 'new3', 'new4']);
|
||||
expect(afterPaste.cursorRow).toBe(3);
|
||||
expect(afterPaste.cursorCol).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
796
packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts
Normal file
796
packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts
Normal file
@@ -0,0 +1,796 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { handleVimAction } from './vim-buffer-actions.js';
|
||||
import type { TextBufferState } from './text-buffer.js';
|
||||
|
||||
// Helper to create test state
|
||||
const createTestState = (
|
||||
lines: string[] = ['hello world'],
|
||||
cursorRow = 0,
|
||||
cursorCol = 0,
|
||||
): TextBufferState => ({
|
||||
lines,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
preferredCol: null,
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
clipboard: null,
|
||||
selectionAnchor: null,
|
||||
viewportWidth: 80,
|
||||
});
|
||||
|
||||
describe('vim-buffer-actions', () => {
|
||||
describe('Movement commands', () => {
|
||||
describe('vim_move_left', () => {
|
||||
it('should move cursor left by count', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = {
|
||||
type: 'vim_move_left' as const,
|
||||
payload: { count: 3 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(2);
|
||||
expect(result.preferredCol).toBeNull();
|
||||
});
|
||||
|
||||
it('should not move past beginning of line', () => {
|
||||
const state = createTestState(['hello'], 0, 2);
|
||||
const action = {
|
||||
type: 'vim_move_left' as const,
|
||||
payload: { count: 5 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should wrap to previous line when at beginning', () => {
|
||||
const state = createTestState(['line1', 'line2'], 1, 0);
|
||||
const action = {
|
||||
type: 'vim_move_left' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(4); // On last character '1' of 'line1'
|
||||
});
|
||||
|
||||
it('should handle multiple line wrapping', () => {
|
||||
const state = createTestState(['abc', 'def', 'ghi'], 2, 0);
|
||||
const action = {
|
||||
type: 'vim_move_left' as const,
|
||||
payload: { count: 5 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(1); // On 'b' after 5 left movements
|
||||
});
|
||||
|
||||
it('should correctly handle h/l movement between lines', () => {
|
||||
// Start at end of first line at 'd' (position 10)
|
||||
let state = createTestState(['hello world', 'foo bar'], 0, 10);
|
||||
|
||||
// Move right - should go to beginning of next line
|
||||
state = handleVimAction(state, {
|
||||
type: 'vim_move_right' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(state.cursorRow).toBe(1);
|
||||
expect(state.cursorCol).toBe(0); // Should be on 'f'
|
||||
|
||||
// Move left - should go back to end of previous line on 'd'
|
||||
state = handleVimAction(state, {
|
||||
type: 'vim_move_left' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(state.cursorRow).toBe(0);
|
||||
expect(state.cursorCol).toBe(10); // Should be on 'd', not past it
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_move_right', () => {
|
||||
it('should move cursor right by count', () => {
|
||||
const state = createTestState(['hello world'], 0, 2);
|
||||
const action = {
|
||||
type: 'vim_move_right' as const,
|
||||
payload: { count: 3 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(5);
|
||||
});
|
||||
|
||||
it('should not move past last character of line', () => {
|
||||
const state = createTestState(['hello'], 0, 3);
|
||||
const action = {
|
||||
type: 'vim_move_right' as const,
|
||||
payload: { count: 5 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(4); // Last character of 'hello'
|
||||
});
|
||||
|
||||
it('should wrap to next line when at end', () => {
|
||||
const state = createTestState(['line1', 'line2'], 0, 4); // At end of 'line1'
|
||||
const action = {
|
||||
type: 'vim_move_right' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(1);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_move_up', () => {
|
||||
it('should move cursor up by count', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 2, 3);
|
||||
const action = { type: 'vim_move_up' as const, payload: { count: 2 } };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it('should not move past first line', () => {
|
||||
const state = createTestState(['line1', 'line2'], 1, 3);
|
||||
const action = { type: 'vim_move_up' as const, payload: { count: 5 } };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
});
|
||||
|
||||
it('should adjust column for shorter lines', () => {
|
||||
const state = createTestState(['short', 'very long line'], 1, 10);
|
||||
const action = { type: 'vim_move_up' as const, payload: { count: 1 } };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(5); // End of 'short'
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_move_down', () => {
|
||||
it('should move cursor down by count', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 0, 2);
|
||||
const action = {
|
||||
type: 'vim_move_down' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(2);
|
||||
expect(result.cursorCol).toBe(2);
|
||||
});
|
||||
|
||||
it('should not move past last line', () => {
|
||||
const state = createTestState(['line1', 'line2'], 0, 2);
|
||||
const action = {
|
||||
type: 'vim_move_down' as const,
|
||||
payload: { count: 5 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_move_word_forward', () => {
|
||||
it('should move to start of next word', () => {
|
||||
const state = createTestState(['hello world test'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_move_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(6); // Start of 'world'
|
||||
});
|
||||
|
||||
it('should handle multiple words', () => {
|
||||
const state = createTestState(['hello world test'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_move_word_forward' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(12); // Start of 'test'
|
||||
});
|
||||
|
||||
it('should handle punctuation correctly', () => {
|
||||
const state = createTestState(['hello, world!'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_move_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(5); // Start of ','
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_move_word_backward', () => {
|
||||
it('should move to start of previous word', () => {
|
||||
const state = createTestState(['hello world test'], 0, 12);
|
||||
const action = {
|
||||
type: 'vim_move_word_backward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(6); // Start of 'world'
|
||||
});
|
||||
|
||||
it('should handle multiple words', () => {
|
||||
const state = createTestState(['hello world test'], 0, 12);
|
||||
const action = {
|
||||
type: 'vim_move_word_backward' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(0); // Start of 'hello'
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_move_word_end', () => {
|
||||
it('should move to end of current word', () => {
|
||||
const state = createTestState(['hello world'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_move_word_end' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(4); // End of 'hello'
|
||||
});
|
||||
|
||||
it('should move to end of next word if already at word end', () => {
|
||||
const state = createTestState(['hello world'], 0, 4);
|
||||
const action = {
|
||||
type: 'vim_move_word_end' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(10); // End of 'world'
|
||||
});
|
||||
});
|
||||
|
||||
describe('Position commands', () => {
|
||||
it('vim_move_to_line_start should move to column 0', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = { type: 'vim_move_to_line_start' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('vim_move_to_line_end should move to last character', () => {
|
||||
const state = createTestState(['hello world'], 0, 0);
|
||||
const action = { type: 'vim_move_to_line_end' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(10); // Last character of 'hello world'
|
||||
});
|
||||
|
||||
it('vim_move_to_first_nonwhitespace should skip leading whitespace', () => {
|
||||
const state = createTestState([' hello world'], 0, 0);
|
||||
const action = { type: 'vim_move_to_first_nonwhitespace' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(3); // Position of 'h'
|
||||
});
|
||||
|
||||
it('vim_move_to_first_line should move to row 0', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 2, 5);
|
||||
const action = { type: 'vim_move_to_first_line' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('vim_move_to_last_line should move to last row', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 0, 5);
|
||||
const action = { type: 'vim_move_to_last_line' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(2);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('vim_move_to_line should move to specific line', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 0, 5);
|
||||
const action = {
|
||||
type: 'vim_move_to_line' as const,
|
||||
payload: { lineNumber: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(1); // 0-indexed
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('vim_move_to_line should clamp to valid range', () => {
|
||||
const state = createTestState(['line1', 'line2'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_move_to_line' as const,
|
||||
payload: { lineNumber: 10 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(1); // Last line
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edit commands', () => {
|
||||
describe('vim_delete_char', () => {
|
||||
it('should delete single character', () => {
|
||||
const state = createTestState(['hello'], 0, 1);
|
||||
const action = {
|
||||
type: 'vim_delete_char' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hllo');
|
||||
expect(result.cursorCol).toBe(1);
|
||||
});
|
||||
|
||||
it('should delete multiple characters', () => {
|
||||
const state = createTestState(['hello'], 0, 1);
|
||||
const action = {
|
||||
type: 'vim_delete_char' as const,
|
||||
payload: { count: 3 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('ho');
|
||||
expect(result.cursorCol).toBe(1);
|
||||
});
|
||||
|
||||
it('should not delete past end of line', () => {
|
||||
const state = createTestState(['hello'], 0, 3);
|
||||
const action = {
|
||||
type: 'vim_delete_char' as const,
|
||||
payload: { count: 5 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hel');
|
||||
expect(result.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it('should do nothing at end of line', () => {
|
||||
const state = createTestState(['hello'], 0, 5);
|
||||
const action = {
|
||||
type: 'vim_delete_char' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hello');
|
||||
expect(result.cursorCol).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_delete_word_forward', () => {
|
||||
it('should delete from cursor to next word start', () => {
|
||||
const state = createTestState(['hello world test'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_delete_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('world test');
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete multiple words', () => {
|
||||
const state = createTestState(['hello world test'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_delete_word_forward' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('test');
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete to end if no more words', () => {
|
||||
const state = createTestState(['hello world'], 0, 6);
|
||||
const action = {
|
||||
type: 'vim_delete_word_forward' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hello ');
|
||||
expect(result.cursorCol).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_delete_word_backward', () => {
|
||||
it('should delete from cursor to previous word start', () => {
|
||||
const state = createTestState(['hello world test'], 0, 12);
|
||||
const action = {
|
||||
type: 'vim_delete_word_backward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hello test');
|
||||
expect(result.cursorCol).toBe(6);
|
||||
});
|
||||
|
||||
it('should delete multiple words backward', () => {
|
||||
const state = createTestState(['hello world test'], 0, 12);
|
||||
const action = {
|
||||
type: 'vim_delete_word_backward' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('test');
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_delete_line', () => {
|
||||
it('should delete current line', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 1, 2);
|
||||
const action = {
|
||||
type: 'vim_delete_line' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines).toEqual(['line1', 'line3']);
|
||||
expect(result.cursorRow).toBe(1);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete multiple lines', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 0, 2);
|
||||
const action = {
|
||||
type: 'vim_delete_line' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines).toEqual(['line3']);
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should leave empty line when deleting all lines', () => {
|
||||
const state = createTestState(['only line'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_delete_line' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines).toEqual(['']);
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_delete_to_end_of_line', () => {
|
||||
it('should delete from cursor to end of line', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = { type: 'vim_delete_to_end_of_line' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hello');
|
||||
expect(result.cursorCol).toBe(5);
|
||||
});
|
||||
|
||||
it('should do nothing at end of line', () => {
|
||||
const state = createTestState(['hello'], 0, 5);
|
||||
const action = { type: 'vim_delete_to_end_of_line' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hello');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Insert mode commands', () => {
|
||||
describe('vim_insert_at_cursor', () => {
|
||||
it('should not change cursor position', () => {
|
||||
const state = createTestState(['hello'], 0, 2);
|
||||
const action = { type: 'vim_insert_at_cursor' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_append_at_cursor', () => {
|
||||
it('should move cursor right by one', () => {
|
||||
const state = createTestState(['hello'], 0, 2);
|
||||
const action = { type: 'vim_append_at_cursor' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it('should not move past end of line', () => {
|
||||
const state = createTestState(['hello'], 0, 5);
|
||||
const action = { type: 'vim_append_at_cursor' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_append_at_line_end', () => {
|
||||
it('should move cursor to end of line', () => {
|
||||
const state = createTestState(['hello world'], 0, 3);
|
||||
const action = { type: 'vim_append_at_line_end' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(11);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_insert_at_line_start', () => {
|
||||
it('should move to first non-whitespace character', () => {
|
||||
const state = createTestState([' hello world'], 0, 5);
|
||||
const action = { type: 'vim_insert_at_line_start' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(2);
|
||||
});
|
||||
|
||||
it('should move to column 0 for line with only whitespace', () => {
|
||||
const state = createTestState([' '], 0, 1);
|
||||
const action = { type: 'vim_insert_at_line_start' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_open_line_below', () => {
|
||||
it('should insert newline at end of current line', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = { type: 'vim_open_line_below' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
// The implementation inserts newline at end of current line and cursor moves to column 0
|
||||
expect(result.lines[0]).toBe('hello world\n');
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0); // Cursor position after replaceRangeInternal
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_open_line_above', () => {
|
||||
it('should insert newline before current line', () => {
|
||||
const state = createTestState(['hello', 'world'], 1, 2);
|
||||
const action = { type: 'vim_open_line_above' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
// The implementation inserts newline at beginning of current line
|
||||
expect(result.lines).toEqual(['hello', '\nworld']);
|
||||
expect(result.cursorRow).toBe(1);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_escape_insert_mode', () => {
|
||||
it('should move cursor left', () => {
|
||||
const state = createTestState(['hello'], 0, 3);
|
||||
const action = { type: 'vim_escape_insert_mode' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(2);
|
||||
});
|
||||
|
||||
it('should not move past beginning of line', () => {
|
||||
const state = createTestState(['hello'], 0, 0);
|
||||
const action = { type: 'vim_escape_insert_mode' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Change commands', () => {
|
||||
describe('vim_change_word_forward', () => {
|
||||
it('should delete from cursor to next word start', () => {
|
||||
const state = createTestState(['hello world test'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_change_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('world test');
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_change_line', () => {
|
||||
it('should delete entire line content', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = {
|
||||
type: 'vim_change_line' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('');
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_change_movement', () => {
|
||||
it('should change characters to the left', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = {
|
||||
type: 'vim_change_movement' as const,
|
||||
payload: { movement: 'h', count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hel world');
|
||||
expect(result.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it('should change characters to the right', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = {
|
||||
type: 'vim_change_movement' as const,
|
||||
payload: { movement: 'l', count: 3 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hellorld'); // Deletes ' wo' (3 chars to the right)
|
||||
expect(result.cursorCol).toBe(5);
|
||||
});
|
||||
|
||||
it('should change multiple lines down', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 0, 2);
|
||||
const action = {
|
||||
type: 'vim_change_movement' as const,
|
||||
payload: { movement: 'j', count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
// The movement 'j' with count 2 changes 2 lines starting from cursor row
|
||||
// Since we're at cursor position 2, it changes lines starting from current row
|
||||
expect(result.lines).toEqual(['line1', 'line2', 'line3']); // No change because count > available lines
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty text', () => {
|
||||
const state = createTestState([''], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_move_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle single character line', () => {
|
||||
const state = createTestState(['a'], 0, 0);
|
||||
const action = { type: 'vim_move_to_line_end' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(0); // Should be last character position
|
||||
});
|
||||
|
||||
it('should handle empty lines in multi-line text', () => {
|
||||
const state = createTestState(['line1', '', 'line3'], 1, 0);
|
||||
const action = {
|
||||
type: 'vim_move_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
// Should move to next line with content
|
||||
expect(result.cursorRow).toBe(2);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should preserve undo stack in operations', () => {
|
||||
const state = createTestState(['hello'], 0, 0);
|
||||
state.undoStack = [{ lines: ['previous'], cursorRow: 0, cursorCol: 0 }];
|
||||
|
||||
const action = {
|
||||
type: 'vim_delete_char' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.undoStack).toHaveLength(2); // Original plus new snapshot
|
||||
});
|
||||
});
|
||||
});
|
||||
887
packages/cli/src/ui/components/shared/vim-buffer-actions.ts
Normal file
887
packages/cli/src/ui/components/shared/vim-buffer-actions.ts
Normal file
@@ -0,0 +1,887 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
TextBufferState,
|
||||
TextBufferAction,
|
||||
findNextWordStart,
|
||||
findPrevWordStart,
|
||||
findWordEnd,
|
||||
getOffsetFromPosition,
|
||||
getPositionFromOffsets,
|
||||
getLineRangeOffsets,
|
||||
replaceRangeInternal,
|
||||
pushUndo,
|
||||
} from './text-buffer.js';
|
||||
import { cpLen } from '../../utils/textUtils.js';
|
||||
|
||||
export type VimAction = Extract<
|
||||
TextBufferAction,
|
||||
| { type: 'vim_delete_word_forward' }
|
||||
| { type: 'vim_delete_word_backward' }
|
||||
| { type: 'vim_delete_word_end' }
|
||||
| { type: 'vim_change_word_forward' }
|
||||
| { type: 'vim_change_word_backward' }
|
||||
| { type: 'vim_change_word_end' }
|
||||
| { type: 'vim_delete_line' }
|
||||
| { type: 'vim_change_line' }
|
||||
| { type: 'vim_delete_to_end_of_line' }
|
||||
| { type: 'vim_change_to_end_of_line' }
|
||||
| { type: 'vim_change_movement' }
|
||||
| { type: 'vim_move_left' }
|
||||
| { type: 'vim_move_right' }
|
||||
| { type: 'vim_move_up' }
|
||||
| { type: 'vim_move_down' }
|
||||
| { type: 'vim_move_word_forward' }
|
||||
| { type: 'vim_move_word_backward' }
|
||||
| { type: 'vim_move_word_end' }
|
||||
| { type: 'vim_delete_char' }
|
||||
| { type: 'vim_insert_at_cursor' }
|
||||
| { type: 'vim_append_at_cursor' }
|
||||
| { type: 'vim_open_line_below' }
|
||||
| { type: 'vim_open_line_above' }
|
||||
| { type: 'vim_append_at_line_end' }
|
||||
| { type: 'vim_insert_at_line_start' }
|
||||
| { type: 'vim_move_to_line_start' }
|
||||
| { type: 'vim_move_to_line_end' }
|
||||
| { type: 'vim_move_to_first_nonwhitespace' }
|
||||
| { type: 'vim_move_to_first_line' }
|
||||
| { type: 'vim_move_to_last_line' }
|
||||
| { type: 'vim_move_to_line' }
|
||||
| { type: 'vim_escape_insert_mode' }
|
||||
>;
|
||||
|
||||
export function handleVimAction(
|
||||
state: TextBufferState,
|
||||
action: VimAction,
|
||||
): TextBufferState {
|
||||
const { lines, cursorRow, cursorCol } = state;
|
||||
// Cache text join to avoid repeated calculations for word operations
|
||||
let text: string | null = null;
|
||||
const getText = () => text ?? (text = lines.join('\n'));
|
||||
|
||||
switch (action.type) {
|
||||
case 'vim_delete_word_forward': {
|
||||
const { count } = action.payload;
|
||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
let endOffset = currentOffset;
|
||||
let searchOffset = currentOffset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const nextWordOffset = findNextWordStart(getText(), searchOffset);
|
||||
if (nextWordOffset > searchOffset) {
|
||||
searchOffset = nextWordOffset;
|
||||
endOffset = nextWordOffset;
|
||||
} else {
|
||||
// If no next word, delete to end of current word
|
||||
const wordEndOffset = findWordEnd(getText(), searchOffset);
|
||||
endOffset = Math.min(wordEndOffset + 1, getText().length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endOffset > currentOffset) {
|
||||
const nextState = pushUndo(state);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
currentOffset,
|
||||
endOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_delete_word_backward': {
|
||||
const { count } = action.payload;
|
||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
let startOffset = currentOffset;
|
||||
let searchOffset = currentOffset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const prevWordOffset = findPrevWordStart(getText(), searchOffset);
|
||||
if (prevWordOffset < searchOffset) {
|
||||
searchOffset = prevWordOffset;
|
||||
startOffset = prevWordOffset;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (startOffset < currentOffset) {
|
||||
const nextState = pushUndo(state);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
startOffset,
|
||||
currentOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
const newState = replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
// Cursor is already at the correct position after deletion
|
||||
return newState;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_delete_word_end': {
|
||||
const { count } = action.payload;
|
||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
let offset = currentOffset;
|
||||
let endOffset = currentOffset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const wordEndOffset = findWordEnd(getText(), offset);
|
||||
if (wordEndOffset >= offset) {
|
||||
endOffset = wordEndOffset + 1; // Include the character at word end
|
||||
// For next iteration, move to start of next word
|
||||
if (i < count - 1) {
|
||||
const nextWordStart = findNextWordStart(
|
||||
getText(),
|
||||
wordEndOffset + 1,
|
||||
);
|
||||
offset = nextWordStart;
|
||||
if (nextWordStart <= wordEndOffset) {
|
||||
break; // No more words
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
endOffset = Math.min(endOffset, getText().length);
|
||||
|
||||
if (endOffset > currentOffset) {
|
||||
const nextState = pushUndo(state);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
currentOffset,
|
||||
endOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_change_word_forward': {
|
||||
const { count } = action.payload;
|
||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
let searchOffset = currentOffset;
|
||||
let endOffset = currentOffset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const nextWordOffset = findNextWordStart(getText(), searchOffset);
|
||||
if (nextWordOffset > searchOffset) {
|
||||
searchOffset = nextWordOffset;
|
||||
endOffset = nextWordOffset;
|
||||
} else {
|
||||
// If no next word, change to end of current word
|
||||
const wordEndOffset = findWordEnd(getText(), searchOffset);
|
||||
endOffset = Math.min(wordEndOffset + 1, getText().length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endOffset > currentOffset) {
|
||||
const nextState = pushUndo(state);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
currentOffset,
|
||||
endOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_change_word_backward': {
|
||||
const { count } = action.payload;
|
||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
let startOffset = currentOffset;
|
||||
let searchOffset = currentOffset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const prevWordOffset = findPrevWordStart(getText(), searchOffset);
|
||||
if (prevWordOffset < searchOffset) {
|
||||
searchOffset = prevWordOffset;
|
||||
startOffset = prevWordOffset;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (startOffset < currentOffset) {
|
||||
const nextState = pushUndo(state);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
startOffset,
|
||||
currentOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_change_word_end': {
|
||||
const { count } = action.payload;
|
||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
let offset = currentOffset;
|
||||
let endOffset = currentOffset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const wordEndOffset = findWordEnd(getText(), offset);
|
||||
if (wordEndOffset >= offset) {
|
||||
endOffset = wordEndOffset + 1; // Include the character at word end
|
||||
// For next iteration, move to start of next word
|
||||
if (i < count - 1) {
|
||||
const nextWordStart = findNextWordStart(
|
||||
getText(),
|
||||
wordEndOffset + 1,
|
||||
);
|
||||
offset = nextWordStart;
|
||||
if (nextWordStart <= wordEndOffset) {
|
||||
break; // No more words
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
endOffset = Math.min(endOffset, getText().length);
|
||||
|
||||
if (endOffset !== currentOffset) {
|
||||
const nextState = pushUndo(state);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
Math.min(currentOffset, endOffset),
|
||||
Math.max(currentOffset, endOffset),
|
||||
nextState.lines,
|
||||
);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_delete_line': {
|
||||
const { count } = action.payload;
|
||||
if (lines.length === 0) return state;
|
||||
|
||||
const linesToDelete = Math.min(count, lines.length - cursorRow);
|
||||
const totalLines = lines.length;
|
||||
|
||||
if (totalLines === 1 || linesToDelete >= totalLines) {
|
||||
// If there's only one line, or we're deleting all remaining lines,
|
||||
// clear the content but keep one empty line (text editors should never be completely empty)
|
||||
const nextState = pushUndo(state);
|
||||
return {
|
||||
...nextState,
|
||||
lines: [''],
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
const nextState = pushUndo(state);
|
||||
const newLines = [...nextState.lines];
|
||||
newLines.splice(cursorRow, linesToDelete);
|
||||
|
||||
// Adjust cursor position
|
||||
const newCursorRow = Math.min(cursorRow, newLines.length - 1);
|
||||
const newCursorCol = 0; // Vim places cursor at beginning of line after dd
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
cursorRow: newCursorRow,
|
||||
cursorCol: newCursorCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_change_line': {
|
||||
const { count } = action.payload;
|
||||
if (lines.length === 0) return state;
|
||||
|
||||
const linesToChange = Math.min(count, lines.length - cursorRow);
|
||||
const nextState = pushUndo(state);
|
||||
|
||||
const { startOffset, endOffset } = getLineRangeOffsets(
|
||||
cursorRow,
|
||||
linesToChange,
|
||||
nextState.lines,
|
||||
);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
startOffset,
|
||||
endOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
case 'vim_delete_to_end_of_line': {
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
if (cursorCol < currentLine.length) {
|
||||
const nextState = pushUndo(state);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
currentLine.length,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_change_to_end_of_line': {
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
if (cursorCol < currentLine.length) {
|
||||
const nextState = pushUndo(state);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
currentLine.length,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_change_movement': {
|
||||
const { movement, count } = action.payload;
|
||||
const totalLines = lines.length;
|
||||
|
||||
switch (movement) {
|
||||
case 'h': {
|
||||
// Left
|
||||
// Change N characters to the left
|
||||
const startCol = Math.max(0, cursorCol - count);
|
||||
return replaceRangeInternal(
|
||||
pushUndo(state),
|
||||
cursorRow,
|
||||
startCol,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
case 'j': {
|
||||
// Down
|
||||
const linesToChange = Math.min(count, totalLines - cursorRow);
|
||||
if (linesToChange > 0) {
|
||||
if (totalLines === 1) {
|
||||
const currentLine = state.lines[0] || '';
|
||||
return replaceRangeInternal(
|
||||
pushUndo(state),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
cpLen(currentLine),
|
||||
'',
|
||||
);
|
||||
} else {
|
||||
const nextState = pushUndo(state);
|
||||
const { startOffset, endOffset } = getLineRangeOffsets(
|
||||
cursorRow,
|
||||
linesToChange,
|
||||
nextState.lines,
|
||||
);
|
||||
const { startRow, startCol, endRow, endCol } =
|
||||
getPositionFromOffsets(startOffset, endOffset, nextState.lines);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'k': {
|
||||
// Up
|
||||
const upLines = Math.min(count, cursorRow + 1);
|
||||
if (upLines > 0) {
|
||||
if (state.lines.length === 1) {
|
||||
const currentLine = state.lines[0] || '';
|
||||
return replaceRangeInternal(
|
||||
pushUndo(state),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
cpLen(currentLine),
|
||||
'',
|
||||
);
|
||||
} else {
|
||||
const startRow = Math.max(0, cursorRow - count + 1);
|
||||
const linesToChange = cursorRow - startRow + 1;
|
||||
const nextState = pushUndo(state);
|
||||
const { startOffset, endOffset } = getLineRangeOffsets(
|
||||
startRow,
|
||||
linesToChange,
|
||||
nextState.lines,
|
||||
);
|
||||
const {
|
||||
startRow: newStartRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
} = getPositionFromOffsets(
|
||||
startOffset,
|
||||
endOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
const resultState = replaceRangeInternal(
|
||||
nextState,
|
||||
newStartRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
return {
|
||||
...resultState,
|
||||
cursorRow: startRow,
|
||||
cursorCol: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'l': {
|
||||
// Right
|
||||
// Change N characters to the right
|
||||
return replaceRangeInternal(
|
||||
pushUndo(state),
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
Math.min(cpLen(lines[cursorRow] || ''), cursorCol + count),
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
case 'vim_move_left': {
|
||||
const { count } = action.payload;
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
let newRow = cursorRow;
|
||||
let newCol = cursorCol;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (newCol > 0) {
|
||||
newCol--;
|
||||
} else if (newRow > 0) {
|
||||
// Move to end of previous line
|
||||
newRow--;
|
||||
const prevLine = lines[newRow] || '';
|
||||
const prevLineLength = cpLen(prevLine);
|
||||
// Position on last character, or column 0 for empty lines
|
||||
newCol = prevLineLength === 0 ? 0 : prevLineLength - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorRow: newRow,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_right': {
|
||||
const { count } = action.payload;
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
let newRow = cursorRow;
|
||||
let newCol = cursorCol;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const currentLine = lines[newRow] || '';
|
||||
const lineLength = cpLen(currentLine);
|
||||
// Don't move past the last character of the line
|
||||
// For empty lines, stay at column 0; for non-empty lines, don't go past last character
|
||||
if (lineLength === 0) {
|
||||
// Empty line - try to move to next line
|
||||
if (newRow < lines.length - 1) {
|
||||
newRow++;
|
||||
newCol = 0;
|
||||
}
|
||||
} else if (newCol < lineLength - 1) {
|
||||
newCol++;
|
||||
} else if (newRow < lines.length - 1) {
|
||||
// At end of line - move to beginning of next line
|
||||
newRow++;
|
||||
newCol = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorRow: newRow,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_up': {
|
||||
const { count } = action.payload;
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
const newRow = Math.max(0, cursorRow - count);
|
||||
const newCol = Math.min(cursorCol, cpLen(lines[newRow] || ''));
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorRow: newRow,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_down': {
|
||||
const { count } = action.payload;
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
const newRow = Math.min(lines.length - 1, cursorRow + count);
|
||||
const newCol = Math.min(cursorCol, cpLen(lines[newRow] || ''));
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorRow: newRow,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_word_forward': {
|
||||
const { count } = action.payload;
|
||||
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const nextWordOffset = findNextWordStart(getText(), offset);
|
||||
if (nextWordOffset > offset) {
|
||||
offset = nextWordOffset;
|
||||
} else {
|
||||
// No more words to move to
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const { startRow, startCol } = getPositionFromOffsets(
|
||||
offset,
|
||||
offset,
|
||||
lines,
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
cursorRow: startRow,
|
||||
cursorCol: startCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_word_backward': {
|
||||
const { count } = action.payload;
|
||||
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
offset = findPrevWordStart(getText(), offset);
|
||||
}
|
||||
|
||||
const { startRow, startCol } = getPositionFromOffsets(
|
||||
offset,
|
||||
offset,
|
||||
lines,
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
cursorRow: startRow,
|
||||
cursorCol: startCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_word_end': {
|
||||
const { count } = action.payload;
|
||||
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
offset = findWordEnd(getText(), offset);
|
||||
}
|
||||
|
||||
const { startRow, startCol } = getPositionFromOffsets(
|
||||
offset,
|
||||
offset,
|
||||
lines,
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
cursorRow: startRow,
|
||||
cursorCol: startCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_delete_char': {
|
||||
const { count } = action.payload;
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
const lineLength = cpLen(currentLine);
|
||||
|
||||
if (cursorCol < lineLength) {
|
||||
const deleteCount = Math.min(count, lineLength - cursorCol);
|
||||
const nextState = pushUndo(state);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
cursorCol + deleteCount,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_insert_at_cursor': {
|
||||
// Just return state - mode change is handled elsewhere
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_append_at_cursor': {
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
const newCol = cursorCol < cpLen(currentLine) ? cursorCol + 1 : cursorCol;
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_open_line_below': {
|
||||
const { cursorRow, lines } = state;
|
||||
const nextState = pushUndo(state);
|
||||
|
||||
// Insert newline at end of current line
|
||||
const endOfLine = cpLen(lines[cursorRow] || '');
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
endOfLine,
|
||||
cursorRow,
|
||||
endOfLine,
|
||||
'\n',
|
||||
);
|
||||
}
|
||||
|
||||
case 'vim_open_line_above': {
|
||||
const { cursorRow } = state;
|
||||
const nextState = pushUndo(state);
|
||||
|
||||
// Insert newline at beginning of current line
|
||||
const resultState = replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
0,
|
||||
cursorRow,
|
||||
0,
|
||||
'\n',
|
||||
);
|
||||
|
||||
// Move cursor to the new line above
|
||||
return {
|
||||
...resultState,
|
||||
cursorRow,
|
||||
cursorCol: 0,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_append_at_line_end': {
|
||||
const { cursorRow, lines } = state;
|
||||
const lineLength = cpLen(lines[cursorRow] || '');
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: lineLength,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_insert_at_line_start': {
|
||||
const { cursorRow, lines } = state;
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
let col = 0;
|
||||
|
||||
// Find first non-whitespace character using proper Unicode handling
|
||||
const lineCodePoints = [...currentLine]; // Proper Unicode iteration
|
||||
while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) {
|
||||
col++;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: col,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_to_line_start': {
|
||||
return {
|
||||
...state,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_to_line_end': {
|
||||
const { cursorRow, lines } = state;
|
||||
const lineLength = cpLen(lines[cursorRow] || '');
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: lineLength > 0 ? lineLength - 1 : 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_to_first_nonwhitespace': {
|
||||
const { cursorRow, lines } = state;
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
let col = 0;
|
||||
|
||||
// Find first non-whitespace character using proper Unicode handling
|
||||
const lineCodePoints = [...currentLine]; // Proper Unicode iteration
|
||||
while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) {
|
||||
col++;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: col,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_to_first_line': {
|
||||
return {
|
||||
...state,
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_to_last_line': {
|
||||
const { lines } = state;
|
||||
const lastRow = lines.length - 1;
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorRow: lastRow,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_to_line': {
|
||||
const { lineNumber } = action.payload;
|
||||
const { lines } = state;
|
||||
const targetRow = Math.min(Math.max(0, lineNumber - 1), lines.length - 1);
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorRow: targetRow,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_escape_insert_mode': {
|
||||
// Move cursor left if not at beginning of line (vim behavior when exiting insert mode)
|
||||
const { cursorCol } = state;
|
||||
const newCol = cursorCol > 0 ? cursorCol - 1 : 0;
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
// This should never happen if TypeScript is working correctly
|
||||
const _exhaustiveCheck: never = action;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user