mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 20:20:57 +00:00
Merge branch 'main' into feat/hook_sessionstart_sessionend
This commit is contained in:
commit
b236e4152f
195 changed files with 7605 additions and 3975 deletions
|
|
@ -13,22 +13,23 @@ const RESOURCE_NOT_FOUND_CODE = -32002;
|
|||
const INTERNAL_ERROR_CODE = -32603;
|
||||
|
||||
const createFallback = (): FileSystemService => ({
|
||||
readTextFile: vi.fn(),
|
||||
readTextFileWithInfo: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ content: '', encoding: 'utf-8', bom: false }),
|
||||
writeTextFile: vi.fn(),
|
||||
detectFileBOM: vi.fn().mockResolvedValue(false),
|
||||
readTextFile: vi.fn().mockResolvedValue({
|
||||
content: '',
|
||||
_meta: { bom: false, encoding: 'utf-8' },
|
||||
}),
|
||||
writeTextFile: vi.fn().mockResolvedValue({ _meta: undefined }),
|
||||
findFiles: vi.fn().mockReturnValue([]),
|
||||
});
|
||||
|
||||
describe('AcpFileSystemService', () => {
|
||||
describe('detectFileBOM', () => {
|
||||
it('detects BOM through ACP client when content starts with U+FEFF', async () => {
|
||||
describe('readTextFile', () => {
|
||||
it('reads through ACP and returns response', async () => {
|
||||
const mockResponse = {
|
||||
content: 'hello',
|
||||
_meta: { bom: false, encoding: 'utf-8' },
|
||||
};
|
||||
const client = {
|
||||
readTextFile: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ content: '\ufeff// BOM file' }),
|
||||
readTextFile: vi.fn().mockResolvedValue(mockResponse),
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
|
|
@ -38,78 +39,15 @@ describe('AcpFileSystemService', () => {
|
|||
createFallback(),
|
||||
);
|
||||
|
||||
const result = await svc.detectFileBOM('/test/file.txt');
|
||||
expect(result).toBe(true);
|
||||
const result = await svc.readTextFile({ path: '/some/file.txt' });
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(client.readTextFile).toHaveBeenCalledWith({
|
||||
path: '/test/file.txt',
|
||||
path: '/some/file.txt',
|
||||
sessionId: 'session-1',
|
||||
limit: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('detects no BOM through ACP client when content does not start with U+FEFF', async () => {
|
||||
const client = {
|
||||
readTextFile: vi.fn().mockResolvedValue({ content: '// No BOM file' }),
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
'session-2',
|
||||
{ readTextFile: true, writeTextFile: true },
|
||||
createFallback(),
|
||||
);
|
||||
|
||||
const result = await svc.detectFileBOM('/test/file.txt');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to local filesystem when ACP client fails', async () => {
|
||||
const client = {
|
||||
readTextFile: vi.fn().mockRejectedValue(new Error('Network error')),
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
const fallback = createFallback();
|
||||
(fallback.detectFileBOM as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
true,
|
||||
);
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
'session-3',
|
||||
{ readTextFile: true, writeTextFile: true },
|
||||
fallback,
|
||||
);
|
||||
|
||||
const result = await svc.detectFileBOM('/test/file.txt');
|
||||
expect(result).toBe(true);
|
||||
expect(fallback.detectFileBOM).toHaveBeenCalledWith('/test/file.txt');
|
||||
});
|
||||
|
||||
it('falls back to local filesystem when readTextFile capability is disabled', async () => {
|
||||
const client = {
|
||||
readTextFile: vi.fn(),
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
const fallback = createFallback();
|
||||
(fallback.detectFileBOM as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
false,
|
||||
);
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
'session-4',
|
||||
{ readTextFile: false, writeTextFile: true },
|
||||
fallback,
|
||||
);
|
||||
|
||||
const result = await svc.detectFileBOM('/test/file.txt');
|
||||
expect(result).toBe(false);
|
||||
expect(fallback.detectFileBOM).toHaveBeenCalledWith('/test/file.txt');
|
||||
expect(client.readTextFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('readTextFile ENOENT handling', () => {
|
||||
it('converts RESOURCE_NOT_FOUND error to ENOENT', async () => {
|
||||
const resourceNotFoundError = {
|
||||
code: RESOURCE_NOT_FOUND_CODE,
|
||||
|
|
@ -126,7 +64,9 @@ describe('AcpFileSystemService', () => {
|
|||
createFallback(),
|
||||
);
|
||||
|
||||
await expect(svc.readTextFile('/some/file.txt')).rejects.toMatchObject({
|
||||
await expect(
|
||||
svc.readTextFile({ path: '/some/file.txt' }),
|
||||
).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
errno: -2,
|
||||
path: '/some/file.txt',
|
||||
|
|
@ -149,7 +89,9 @@ describe('AcpFileSystemService', () => {
|
|||
createFallback(),
|
||||
);
|
||||
|
||||
await expect(svc.readTextFile('/some/file.txt')).rejects.toMatchObject({
|
||||
await expect(
|
||||
svc.readTextFile({ path: '/some/file.txt' }),
|
||||
).rejects.toMatchObject({
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: 'Internal error',
|
||||
});
|
||||
|
|
@ -161,8 +103,12 @@ describe('AcpFileSystemService', () => {
|
|||
} as unknown as AgentSideConnection;
|
||||
|
||||
const fallback = createFallback();
|
||||
const fallbackResponse = {
|
||||
content: 'fallback content',
|
||||
_meta: { bom: false, encoding: 'utf-8' },
|
||||
};
|
||||
(fallback.readTextFile as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
'fallback content',
|
||||
fallbackResponse,
|
||||
);
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
|
|
@ -172,10 +118,12 @@ describe('AcpFileSystemService', () => {
|
|||
fallback,
|
||||
);
|
||||
|
||||
const result = await svc.readTextFile('/some/file.txt');
|
||||
const result = await svc.readTextFile({ path: '/some/file.txt' });
|
||||
|
||||
expect(result).toBe('fallback content');
|
||||
expect(fallback.readTextFile).toHaveBeenCalledWith('/some/file.txt');
|
||||
expect(result).toEqual(fallbackResponse);
|
||||
expect(fallback.readTextFile).toHaveBeenCalledWith({
|
||||
path: '/some/file.txt',
|
||||
});
|
||||
expect(client.readTextFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,15 +7,38 @@
|
|||
import type {
|
||||
AgentSideConnection,
|
||||
FileSystemCapability,
|
||||
ReadTextFileRequest,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import { RequestError } from '@agentclientprotocol/sdk';
|
||||
import type {
|
||||
FileReadResult,
|
||||
FileSystemService,
|
||||
ReadTextFileResponse,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
const RESOURCE_NOT_FOUND_CODE = -32002;
|
||||
|
||||
function getErrorCode(error: unknown): unknown {
|
||||
if (error instanceof RequestError) {
|
||||
return error.code;
|
||||
}
|
||||
|
||||
if (typeof error === 'object' && error !== null && 'code' in error) {
|
||||
return (error as { code?: unknown }).code;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function createEnoentError(filePath: string): NodeJS.ErrnoException {
|
||||
const err = new Error(`File not found: ${filePath}`) as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
err.errno = -2;
|
||||
err.path = filePath;
|
||||
return err;
|
||||
}
|
||||
|
||||
export class AcpFileSystemService implements FileSystemService {
|
||||
constructor(
|
||||
private readonly connection: AgentSideConnection,
|
||||
|
|
@ -24,82 +47,50 @@ export class AcpFileSystemService implements FileSystemService {
|
|||
private readonly fallback: FileSystemService,
|
||||
) {}
|
||||
|
||||
async readTextFile(filePath: string): Promise<string> {
|
||||
async readTextFile(
|
||||
params: Omit<ReadTextFileRequest, 'sessionId'>,
|
||||
): Promise<ReadTextFileResponse> {
|
||||
if (!this.capabilities.readTextFile) {
|
||||
return this.fallback.readTextFile(filePath);
|
||||
return this.fallback.readTextFile(params);
|
||||
}
|
||||
|
||||
let response: { content: string };
|
||||
let response: ReadTextFileResponse;
|
||||
try {
|
||||
response = await this.connection.readTextFile({
|
||||
path: filePath,
|
||||
...params,
|
||||
sessionId: this.sessionId,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorCode =
|
||||
error instanceof RequestError
|
||||
? error.code
|
||||
: typeof error === 'object' && error !== null && 'code' in error
|
||||
? (error as { code?: unknown }).code
|
||||
: undefined;
|
||||
const errorCode = getErrorCode(error);
|
||||
|
||||
if (errorCode === RESOURCE_NOT_FOUND_CODE) {
|
||||
const err = new Error(
|
||||
`File not found: ${filePath}`,
|
||||
) as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
err.errno = -2;
|
||||
err.path = filePath;
|
||||
throw err;
|
||||
throw createEnoentError(params.path);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.content;
|
||||
}
|
||||
|
||||
async readTextFileWithInfo(filePath: string): Promise<FileReadResult> {
|
||||
// ACP protocol does not expose encoding metadata; delegate to the local
|
||||
// fallback which performs a single-pass read with encoding detection.
|
||||
return this.fallback.readTextFileWithInfo(filePath);
|
||||
return response;
|
||||
}
|
||||
|
||||
async writeTextFile(
|
||||
filePath: string,
|
||||
content: string,
|
||||
options?: { bom?: boolean; encoding?: string },
|
||||
): Promise<void> {
|
||||
params: Omit<WriteTextFileRequest, 'sessionId'>,
|
||||
): Promise<WriteTextFileResponse> {
|
||||
if (!this.capabilities.writeTextFile) {
|
||||
return this.fallback.writeTextFile(filePath, content, options);
|
||||
return this.fallback.writeTextFile(params);
|
||||
}
|
||||
|
||||
const finalContent = options?.bom ? '\uFEFF' + content : content;
|
||||
const finalContent = params._meta?.['bom']
|
||||
? '\uFEFF' + params.content
|
||||
: params.content;
|
||||
|
||||
await this.connection.writeTextFile({
|
||||
path: filePath,
|
||||
...params,
|
||||
content: finalContent,
|
||||
sessionId: this.sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
async detectFileBOM(filePath: string): Promise<boolean> {
|
||||
if (this.capabilities.readTextFile) {
|
||||
try {
|
||||
const response = await this.connection.readTextFile({
|
||||
path: filePath,
|
||||
sessionId: this.sessionId,
|
||||
limit: 1,
|
||||
});
|
||||
return (
|
||||
response.content.length > 0 &&
|
||||
response.content.codePointAt(0) === 0xfeff
|
||||
);
|
||||
} catch {
|
||||
// Fall through to fallback if ACP read fails
|
||||
}
|
||||
}
|
||||
return this.fallback.detectFileBOM(filePath);
|
||||
return { _meta: params._meta };
|
||||
}
|
||||
|
||||
findFiles(fileName: string, searchPaths: readonly string[]): string[] {
|
||||
|
|
|
|||
|
|
@ -90,6 +90,14 @@ const debugLogger = createDebugLogger('SESSION');
|
|||
*/
|
||||
export class Session implements SessionContext {
|
||||
private pendingPrompt: AbortController | null = null;
|
||||
/**
|
||||
* Tracks the completion of the current prompt so that the next prompt
|
||||
* can await it. This prevents a new prompt from reading chat history
|
||||
* before the previous prompt's tool results have been added —
|
||||
* a race condition that causes malformed history on Windows where
|
||||
* process termination is slow.
|
||||
*/
|
||||
private pendingPromptCompletion: Promise<void> | null = null;
|
||||
private turn: number = 0;
|
||||
|
||||
// Modular components
|
||||
|
|
@ -143,10 +151,43 @@ export class Session implements SessionContext {
|
|||
}
|
||||
|
||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||
// Install this prompt's AbortController before awaiting the previous
|
||||
// prompt, so that a session/cancel during the wait targets us.
|
||||
this.pendingPrompt?.abort();
|
||||
const pendingSend = new AbortController();
|
||||
this.pendingPrompt = pendingSend;
|
||||
|
||||
// Wait for the previous prompt to finish so chat history is consistent.
|
||||
if (this.pendingPromptCompletion) {
|
||||
try {
|
||||
await this.pendingPromptCompletion;
|
||||
} catch {
|
||||
// Expected: previous prompt was cancelled or errored
|
||||
}
|
||||
}
|
||||
|
||||
// Cancelled while waiting for the previous prompt to finish.
|
||||
if (pendingSend.signal.aborted) {
|
||||
return { stopReason: 'cancelled' };
|
||||
}
|
||||
|
||||
// Track this prompt's completion for the next prompt to await
|
||||
let resolveCompletion!: () => void;
|
||||
this.pendingPromptCompletion = new Promise<void>((resolve) => {
|
||||
resolveCompletion = resolve;
|
||||
});
|
||||
|
||||
try {
|
||||
return await this.#executePrompt(params, pendingSend);
|
||||
} finally {
|
||||
resolveCompletion();
|
||||
}
|
||||
}
|
||||
|
||||
async #executePrompt(
|
||||
params: PromptRequest,
|
||||
pendingSend: AbortController,
|
||||
): Promise<PromptResponse> {
|
||||
// Increment turn counter for each user prompt
|
||||
this.turn += 1;
|
||||
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ export const addCommand: CommandModule = {
|
|||
describe: 'Set environment variables (e.g. -e KEY=value)',
|
||||
type: 'array',
|
||||
string: true,
|
||||
nargs: 1,
|
||||
})
|
||||
.option('header', {
|
||||
alias: 'H',
|
||||
|
|
@ -181,6 +182,7 @@ export const addCommand: CommandModule = {
|
|||
'Set HTTP headers for SSE and HTTP transports (e.g. -H "X-Api-Key: abc123" -H "Authorization: Bearer abc123")',
|
||||
type: 'array',
|
||||
string: true,
|
||||
nargs: 1,
|
||||
})
|
||||
.option('timeout', {
|
||||
describe: 'Set connection timeout in milliseconds',
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
Config,
|
||||
DEFAULT_QWEN_EMBEDDING_MODEL,
|
||||
FileDiscoveryService,
|
||||
FileEncoding,
|
||||
getAllGeminiMdFilenames,
|
||||
loadServerHierarchicalMemory,
|
||||
setGeminiMdFilename as setServerGeminiMdFilename,
|
||||
|
|
@ -1013,7 +1012,6 @@ export async function loadCliConfig(
|
|||
warnings: resolvedCliConfig.warnings,
|
||||
cliVersion: await getCliVersion(),
|
||||
webSearch: buildWebSearchConfig(argv, settings, selectedAuthType),
|
||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||
ideMode,
|
||||
chatCompression: settings.model?.chatCompression,
|
||||
folderTrust,
|
||||
|
|
@ -1027,7 +1025,6 @@ export async function loadCliConfig(
|
|||
skipStartupContext: settings.model?.skipStartupContext ?? false,
|
||||
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
|
||||
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
|
||||
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
|
||||
eventEmitter: appEvents,
|
||||
gitCoAuthor: settings.general?.gitCoAuthor,
|
||||
output: {
|
||||
|
|
@ -1043,8 +1040,7 @@ export async function loadCliConfig(
|
|||
// always be true and the settings file can never disable recording.
|
||||
chatRecording:
|
||||
argv.chatRecording ?? settings.general?.chatRecording ?? true,
|
||||
defaultFileEncoding:
|
||||
settings.general?.defaultFileEncoding ?? FileEncoding.UTF8,
|
||||
defaultFileEncoding: settings.general?.defaultFileEncoding,
|
||||
lsp: {
|
||||
enabled: lspEnabled,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ export const V1_TO_V2_MIGRATION_MAP: Record<string, string> = {
|
|||
shellPager: 'tools.shell.pager',
|
||||
shellShowColor: 'tools.shell.showColor',
|
||||
skipNextSpeakerCheck: 'model.skipNextSpeakerCheck',
|
||||
summarizeToolOutput: 'model.summarizeToolOutput',
|
||||
telemetry: 'telemetry',
|
||||
theme: 'ui.theme',
|
||||
toolDiscoveryCommand: 'tools.discoveryCommand',
|
||||
|
|
@ -157,7 +156,6 @@ export const V1_INDICATOR_KEYS = [
|
|||
'shellPager',
|
||||
'shellShowColor',
|
||||
'skipNextSpeakerCheck',
|
||||
'summarizeToolOutput',
|
||||
'toolDiscoveryCommand',
|
||||
'toolCallCommand',
|
||||
'usageStatisticsEnabled',
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@ import {
|
|||
QWEN_DIR,
|
||||
getErrorMessage,
|
||||
Storage,
|
||||
setDebugLogSession,
|
||||
sanitizeCwd,
|
||||
createDebugLogger,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
|
|
@ -105,10 +103,6 @@ export interface CheckpointingSettings {
|
|||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SummarizeToolOutputSettings {
|
||||
tokenBudget?: number;
|
||||
}
|
||||
|
||||
export interface AccessibilitySettings {
|
||||
enableLoadingPhrases?: boolean;
|
||||
screenReader?: boolean;
|
||||
|
|
@ -476,16 +470,6 @@ export function loadEnvironment(settings: Settings): void {
|
|||
export function loadSettings(
|
||||
workspaceDir: string = process.cwd(),
|
||||
): LoadedSettings {
|
||||
// Set up a temporary debug log session for the startup phase.
|
||||
// This allows migration errors to be logged to file instead of being
|
||||
// exposed to users via stderr. The Config class will override this
|
||||
// with the actual session once initialized.
|
||||
const resolvedWorkspaceDir = path.resolve(workspaceDir);
|
||||
const sanitizedProjectId = sanitizeCwd(resolvedWorkspaceDir);
|
||||
setDebugLogSession({
|
||||
getSessionId: () => `startup-${sanitizedProjectId}`,
|
||||
});
|
||||
|
||||
let systemSettings: Settings = {};
|
||||
let systemDefaultSettings: Settings = {};
|
||||
let userSettings: Settings = {};
|
||||
|
|
@ -496,7 +480,7 @@ export function loadSettings(
|
|||
const migratedInMemorScopes = new Set<SettingScope>();
|
||||
|
||||
// Resolve paths to their canonical representation to handle symlinks
|
||||
// Note: resolvedWorkspaceDir is already defined at the top of the function
|
||||
const resolvedWorkspaceDir = path.resolve(workspaceDir);
|
||||
const resolvedHomeDir = path.resolve(homedir());
|
||||
|
||||
let realWorkspaceDir = resolvedWorkspaceDir;
|
||||
|
|
|
|||
|
|
@ -76,12 +76,98 @@ export interface SettingDefinition {
|
|||
mergeStrategy?: MergeStrategy;
|
||||
/** Enum type options */
|
||||
options?: readonly SettingEnumOption[];
|
||||
/** Schema for array items when type is 'array' */
|
||||
items?: SettingItemDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema definition for array item types.
|
||||
* Supports simple types (string, number, boolean) and complex object types.
|
||||
*/
|
||||
export interface SettingItemDefinition {
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||
properties?: Record<
|
||||
string,
|
||||
SettingItemDefinition & {
|
||||
required?: boolean;
|
||||
enum?: string[];
|
||||
additionalProperties?: SettingItemDefinition;
|
||||
}
|
||||
>;
|
||||
items?: SettingItemDefinition;
|
||||
required?: boolean;
|
||||
enum?: string[];
|
||||
description?: string;
|
||||
additionalProperties?: boolean | SettingItemDefinition;
|
||||
}
|
||||
|
||||
export interface SettingsSchema {
|
||||
[key: string]: SettingDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common items schema for hook definitions.
|
||||
* Used by both UserPromptSubmit and Stop hooks.
|
||||
*/
|
||||
const HOOK_DEFINITION_ITEMS: SettingItemDefinition = {
|
||||
type: 'object',
|
||||
description:
|
||||
'A hook definition with an optional matcher and a list of hook configurations.',
|
||||
properties: {
|
||||
matcher: {
|
||||
type: 'string',
|
||||
description:
|
||||
'An optional matcher pattern to filter when this hook definition applies.',
|
||||
},
|
||||
sequential: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether the hooks should be executed sequentially instead of in parallel.',
|
||||
},
|
||||
hooks: {
|
||||
type: 'array',
|
||||
description: 'The list of hook configurations to execute.',
|
||||
required: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
description:
|
||||
'A hook configuration entry that defines a command to execute.',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
description: 'The type of hook.',
|
||||
enum: ['command'],
|
||||
required: true,
|
||||
},
|
||||
command: {
|
||||
type: 'string',
|
||||
description: 'The command to execute when the hook is triggered.',
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'An optional name for the hook.',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'An optional description of what the hook does.',
|
||||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
description: 'Timeout in milliseconds for the hook execution.',
|
||||
},
|
||||
env: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Environment variables to set when executing the hook command.',
|
||||
additionalProperties: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type MemoryImportFormat = 'tree' | 'flat';
|
||||
export type DnsResolutionOrder = 'ipv4first' | 'verbatim';
|
||||
|
||||
|
|
@ -546,17 +632,6 @@ const SETTINGS_SCHEMA = {
|
|||
'Maximum number of user/model/tool turns to keep in a session. -1 means unlimited.',
|
||||
showInDialog: false,
|
||||
},
|
||||
summarizeToolOutput: {
|
||||
type: 'object',
|
||||
label: 'Summarize Tool Output',
|
||||
category: 'Model',
|
||||
requiresRestart: false,
|
||||
default: undefined as
|
||||
| Record<string, { tokenBudget?: number }>
|
||||
| undefined,
|
||||
description: 'Settings for summarizing tool output.',
|
||||
showInDialog: false,
|
||||
},
|
||||
chatCompression: {
|
||||
type: 'object',
|
||||
label: 'Chat Compression',
|
||||
|
|
@ -941,15 +1016,6 @@ const SETTINGS_SCHEMA = {
|
|||
'Use the bundled ripgrep binary. When set to false, the system-level "rg" command will be used instead. This setting is only effective when useRipgrep is true.',
|
||||
showInDialog: false,
|
||||
},
|
||||
enableToolOutputTruncation: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Tool Output Truncation',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enable truncation of large tool outputs.',
|
||||
showInDialog: false,
|
||||
},
|
||||
truncateToolOutputThreshold: {
|
||||
type: 'number',
|
||||
label: 'Tool Output Truncation Threshold',
|
||||
|
|
@ -1233,6 +1299,7 @@ const SETTINGS_SCHEMA = {
|
|||
'Hooks that execute before agent processing. Can modify prompts or inject context.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
items: HOOK_DEFINITION_ITEMS,
|
||||
},
|
||||
Stop: {
|
||||
type: 'array',
|
||||
|
|
@ -1244,6 +1311,7 @@ const SETTINGS_SCHEMA = {
|
|||
'Hooks that execute after agent processing. Can post-process responses or log interactions.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
items: HOOK_DEFINITION_ITEMS,
|
||||
},
|
||||
Notification: {
|
||||
type: 'array',
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ export default {
|
|||
'Analysiert das Projekt und erstellt eine maßgeschneiderte QWEN.md-Datei.',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'Verfügbare Qwen Code Werkzeuge auflisten. Verwendung: /tools [desc]',
|
||||
'List available skills.': 'Verfügbare Skills auflisten.',
|
||||
'Available Qwen Code CLI tools:': 'Verfügbare Qwen Code CLI-Werkzeuge:',
|
||||
'No tools available': 'Keine Werkzeuge verfügbar',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -376,6 +377,7 @@ export default {
|
|||
'Diese Editoren werden derzeit unterstützt. Bitte beachten Sie, dass einige Editoren nicht im Sandbox-Modus verwendet werden können.',
|
||||
'Your preferred editor is:': 'Ihr bevorzugter Editor ist:',
|
||||
'Manage extensions': 'Erweiterungen verwalten',
|
||||
'Manage installed extensions': 'Installierte Erweiterungen verwalten',
|
||||
'List active extensions': 'Aktive Erweiterungen auflisten',
|
||||
'Update extensions. Usage: update <extension-names>|--all':
|
||||
'Erweiterungen aktualisieren. Verwendung: update <Erweiterungsnamen>|--all',
|
||||
|
|
@ -585,6 +587,38 @@ export default {
|
|||
'Fehler beim Konfigurieren von {{terminalName}}.',
|
||||
'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).':
|
||||
'Ihr Terminal ist bereits für optimale Erfahrung mit mehrzeiliger Eingabe konfiguriert (Umschalt+Enter und Strg+Enter).',
|
||||
// ============================================================================
|
||||
// Commands - Hooks
|
||||
// ============================================================================
|
||||
'Manage Qwen Code hooks': 'Qwen Code-Hooks verwalten',
|
||||
'List all configured hooks': 'Alle konfigurierten Hooks auflisten',
|
||||
'Enable a disabled hook': 'Einen deaktivierten Hook aktivieren',
|
||||
'Disable an active hook': 'Einen aktiven Hook deaktivieren',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Session Export
|
||||
// ============================================================================
|
||||
'Export current session message history to a file':
|
||||
'Den Nachrichtenverlauf der aktuellen Sitzung in eine Datei exportieren',
|
||||
'Export session to HTML format': 'Sitzung in das HTML-Format exportieren',
|
||||
'Export session to JSON format': 'Sitzung in das JSON-Format exportieren',
|
||||
'Export session to JSONL format (one message per line)':
|
||||
'Sitzung in das JSONL-Format exportieren (eine Nachricht pro Zeile)',
|
||||
'Export session to markdown format':
|
||||
'Sitzung in das Markdown-Format exportieren',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Insights
|
||||
// ============================================================================
|
||||
'generate personalized programming insights from your chat history':
|
||||
'Personalisierte Programmier-Einblicke aus Ihrem Chatverlauf generieren',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Session History
|
||||
// ============================================================================
|
||||
'Resume a previous session': 'Eine vorherige Sitzung fortsetzen',
|
||||
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested':
|
||||
'Einen Tool-Aufruf wiederherstellen. Dadurch werden Konversations- und Dateiverlauf auf den Zustand zurückgesetzt, in dem der Tool-Aufruf vorgeschlagen wurde',
|
||||
'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.':
|
||||
'Terminal-Typ konnte nicht erkannt werden. Unterstützte Terminals: VS Code, Cursor, Windsurf und Trae.',
|
||||
'Terminal "{{terminal}}" is not supported yet.':
|
||||
|
|
@ -745,6 +779,15 @@ export default {
|
|||
"Authentifizierung mit MCP-Server '{{name}}' fehlgeschlagen: {{error}}",
|
||||
"Re-discovering tools from '{{name}}'...":
|
||||
"Werkzeuge von '{{name}}' werden neu erkannt...",
|
||||
"Discovered {{count}} tool(s) from '{{name}}'.":
|
||||
"{{count}} Werkzeug(e) von '{{name}}' entdeckt.",
|
||||
'Authentication complete. Returning to server details...':
|
||||
'Authentifizierung abgeschlossen. Zurück zu den Serverdetails...',
|
||||
'Authentication successful.': 'Authentifizierung erfolgreich.',
|
||||
'If the browser does not open, copy and paste this URL into your browser:':
|
||||
'Falls der Browser sich nicht öffnet, kopieren Sie diese URL und fügen Sie sie in Ihren Browser ein:',
|
||||
'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.':
|
||||
'⚠️ Stellen Sie sicher, dass Sie die VOLLSTÄNDIGE URL kopieren – sie kann über mehrere Zeilen gehen.',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Chat
|
||||
|
|
@ -916,6 +959,8 @@ export default {
|
|||
Disable: 'Deaktivieren',
|
||||
Enable: 'Aktivieren',
|
||||
Authenticate: 'Authentifizieren',
|
||||
'Re-authenticate': 'Erneut authentifizieren',
|
||||
'Clear Authentication': 'Authentifizierung löschen',
|
||||
disabled: 'deaktiviert',
|
||||
'Server:': 'Server:',
|
||||
Reconnect: 'Neu verbinden',
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ export default {
|
|||
'Analyzes the project and creates a tailored QWEN.md file.',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'List available Qwen Code tools. Usage: /tools [desc]',
|
||||
'List available skills.': 'List available skills.',
|
||||
'Available Qwen Code CLI tools:': 'Available Qwen Code CLI tools:',
|
||||
'No tools available': 'No tools available',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -459,6 +460,7 @@ export default {
|
|||
'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.',
|
||||
'Your preferred editor is:': 'Your preferred editor is:',
|
||||
'Manage extensions': 'Manage extensions',
|
||||
'Manage installed extensions': 'Manage installed extensions',
|
||||
'List active extensions': 'List active extensions',
|
||||
'Update extensions. Usage: update <extension-names>|--all':
|
||||
'Update extensions. Usage: update <extension-names>|--all',
|
||||
|
|
@ -659,6 +661,37 @@ export default {
|
|||
'Failed to configure {{terminalName}}.',
|
||||
'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).':
|
||||
'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).',
|
||||
// ============================================================================
|
||||
// Commands - Hooks
|
||||
// ============================================================================
|
||||
'Manage Qwen Code hooks': 'Manage Qwen Code hooks',
|
||||
'List all configured hooks': 'List all configured hooks',
|
||||
'Enable a disabled hook': 'Enable a disabled hook',
|
||||
'Disable an active hook': 'Disable an active hook',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Session Export
|
||||
// ============================================================================
|
||||
'Export current session message history to a file':
|
||||
'Export current session message history to a file',
|
||||
'Export session to HTML format': 'Export session to HTML format',
|
||||
'Export session to JSON format': 'Export session to JSON format',
|
||||
'Export session to JSONL format (one message per line)':
|
||||
'Export session to JSONL format (one message per line)',
|
||||
'Export session to markdown format': 'Export session to markdown format',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Insights
|
||||
// ============================================================================
|
||||
'generate personalized programming insights from your chat history':
|
||||
'generate personalized programming insights from your chat history',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Session History
|
||||
// ============================================================================
|
||||
'Resume a previous session': 'Resume a previous session',
|
||||
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested':
|
||||
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested',
|
||||
'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.':
|
||||
'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.',
|
||||
'Terminal "{{terminal}}" is not supported yet.':
|
||||
|
|
@ -811,6 +844,15 @@ export default {
|
|||
"Failed to authenticate with MCP server '{{name}}': {{error}}",
|
||||
"Re-discovering tools from '{{name}}'...":
|
||||
"Re-discovering tools from '{{name}}'...",
|
||||
"Discovered {{count}} tool(s) from '{{name}}'.":
|
||||
"Discovered {{count}} tool(s) from '{{name}}'.",
|
||||
'Authentication complete. Returning to server details...':
|
||||
'Authentication complete. Returning to server details...',
|
||||
'Authentication successful.': 'Authentication successful.',
|
||||
'If the browser does not open, copy and paste this URL into your browser:':
|
||||
'If the browser does not open, copy and paste this URL into your browser:',
|
||||
'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.':
|
||||
'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.',
|
||||
|
||||
// ============================================================================
|
||||
// MCP Management Dialog
|
||||
|
|
@ -843,6 +885,8 @@ export default {
|
|||
Enable: 'Enable',
|
||||
Disable: 'Disable',
|
||||
Authenticate: 'Authenticate',
|
||||
'Re-authenticate': 'Re-authenticate',
|
||||
'Clear Authentication': 'Clear Authentication',
|
||||
'Server:': 'Server:',
|
||||
'Command:': 'Command:',
|
||||
'Working Directory:': 'Working Directory:',
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ export default {
|
|||
'プロジェクトを分析し、カスタマイズされた QWEN.md ファイルを作成',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'利用可能な Qwen Code ツールを一覧表示。使い方: /tools [desc]',
|
||||
'List available skills.': '利用可能なスキルを一覧表示する。',
|
||||
'Available Qwen Code CLI tools:': '利用可能な Qwen Code CLI ツール:',
|
||||
'No tools available': '利用可能なツールはありません',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -328,6 +329,7 @@ export default {
|
|||
'ワークスペース内のすべてのディレクトリを表示',
|
||||
'set external editor preference': '外部エディタの設定',
|
||||
'Manage extensions': '拡張機能を管理',
|
||||
'Manage installed extensions': 'インストール済みの拡張機能を管理する',
|
||||
'List active extensions': '有効な拡張機能を一覧表示',
|
||||
'Update extensions. Usage: update <extension-names>|--all':
|
||||
'拡張機能を更新。使い方: update <拡張機能名>|--all',
|
||||
|
|
@ -371,6 +373,38 @@ export default {
|
|||
'{{terminalName}} の設定に失敗しました',
|
||||
'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).':
|
||||
'ターミナルは複数行入力(Shift+Enter と Ctrl+Enter)に最適化されています',
|
||||
// ============================================================================
|
||||
// Commands - Hooks
|
||||
// ============================================================================
|
||||
'Manage Qwen Code hooks': 'Qwen Code のフックを管理する',
|
||||
'List all configured hooks': '設定済みのフックをすべて表示する',
|
||||
'Enable a disabled hook': '無効なフックを有効にする',
|
||||
'Disable an active hook': '有効なフックを無効にする',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Session Export
|
||||
// ============================================================================
|
||||
'Export current session message history to a file':
|
||||
'現在のセッションのメッセージ履歴をファイルにエクスポートする',
|
||||
'Export session to HTML format': 'セッションを HTML 形式でエクスポートする',
|
||||
'Export session to JSON format': 'セッションを JSON 形式でエクスポートする',
|
||||
'Export session to JSONL format (one message per line)':
|
||||
'セッションを JSONL 形式でエクスポートする(1 行に 1 メッセージ)',
|
||||
'Export session to markdown format':
|
||||
'セッションを Markdown 形式でエクスポートする',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Insights
|
||||
// ============================================================================
|
||||
'generate personalized programming insights from your chat history':
|
||||
'チャット履歴からパーソナライズされたプログラミングインサイトを生成する',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Session History
|
||||
// ============================================================================
|
||||
'Resume a previous session': '前のセッションを再開する',
|
||||
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested':
|
||||
'ツール呼び出しを復元します。これにより、会話とファイルの履歴はそのツール呼び出しが提案された時点の状態に戻ります',
|
||||
'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.':
|
||||
'ターミナルの種類を検出できませんでした。サポートされているターミナル: VS Code、Cursor、Windsurf、Trae',
|
||||
'Terminal "{{terminal}}" is not supported yet.':
|
||||
|
|
@ -507,6 +541,15 @@ export default {
|
|||
"MCPサーバー '{{name}}' での認証に失敗: {{error}}",
|
||||
"Re-discovering tools from '{{name}}'...":
|
||||
"'{{name}}' からツールを再検出中...",
|
||||
"Discovered {{count}} tool(s) from '{{name}}'.":
|
||||
"'{{name}}' から {{count}} 個のツールを検出しました。",
|
||||
'Authentication complete. Returning to server details...':
|
||||
'認証完了。サーバー詳細に戻ります...',
|
||||
'Authentication successful.': '認証成功。',
|
||||
'If the browser does not open, copy and paste this URL into your browser:':
|
||||
'ブラウザが開かない場合は、このURLをコピーしてブラウザに貼り付けてください:',
|
||||
'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.':
|
||||
'⚠️ URL全体をコピーしてください——複数行にまたがる場合があります。',
|
||||
'Configured MCP servers:': '設定済みMCPサーバー:',
|
||||
Ready: '準備完了',
|
||||
Disconnected: '切断',
|
||||
|
|
@ -655,6 +698,8 @@ export default {
|
|||
Disable: '無効化',
|
||||
Enable: '有効化',
|
||||
Authenticate: '認証',
|
||||
'Re-authenticate': '再認証',
|
||||
'Clear Authentication': '認証をクリア',
|
||||
disabled: '無効',
|
||||
'Server:': 'サーバー:',
|
||||
Reconnect: '再接続',
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ export default {
|
|||
'Analisa o projeto e cria um arquivo QWEN.md personalizado.',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'Listar ferramentas Qwen Code disponíveis. Uso: /tools [desc]',
|
||||
'List available skills.': 'Listar habilidades disponíveis.',
|
||||
'Available Qwen Code CLI tools:': 'Ferramentas CLI do Qwen Code disponíveis:',
|
||||
'No tools available': 'Nenhuma ferramenta disponível',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -401,6 +402,7 @@ export default {
|
|||
'Estes editores são suportados atualmente. Note que alguns editores não podem ser usados no modo sandbox.',
|
||||
'Your preferred editor is:': 'Seu editor preferido é:',
|
||||
'Manage extensions': 'Gerenciar extensões',
|
||||
'Manage installed extensions': 'Gerenciar extensões instaladas',
|
||||
'List active extensions': 'Listar extensões ativas',
|
||||
'Update extensions. Usage: update <extension-names>|--all':
|
||||
'Atualizar extensões. Uso: update <nomes-das-extensoes>|--all',
|
||||
|
|
@ -590,6 +592,38 @@ export default {
|
|||
'Falha ao configurar {{terminalName}}.',
|
||||
'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).':
|
||||
'Seu terminal já está configurado para uma experiência ideal com entrada multilinhas (Shift+Enter e Ctrl+Enter).',
|
||||
// ============================================================================
|
||||
// Commands - Hooks
|
||||
// ============================================================================
|
||||
'Manage Qwen Code hooks': 'Gerenciar hooks do Qwen Code',
|
||||
'List all configured hooks': 'Listar todos os hooks configurados',
|
||||
'Enable a disabled hook': 'Ativar um hook desativado',
|
||||
'Disable an active hook': 'Desativar um hook ativo',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Session Export
|
||||
// ============================================================================
|
||||
'Export current session message history to a file':
|
||||
'Exportar o histórico de mensagens da sessão atual para um arquivo',
|
||||
'Export session to HTML format': 'Exportar a sessão para o formato HTML',
|
||||
'Export session to JSON format': 'Exportar a sessão para o formato JSON',
|
||||
'Export session to JSONL format (one message per line)':
|
||||
'Exportar a sessão para o formato JSONL (uma mensagem por linha)',
|
||||
'Export session to markdown format':
|
||||
'Exportar a sessão para o formato Markdown',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Insights
|
||||
// ============================================================================
|
||||
'generate personalized programming insights from your chat history':
|
||||
'Gerar insights personalizados de programação a partir do seu histórico de chat',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Session History
|
||||
// ============================================================================
|
||||
'Resume a previous session': 'Retomar uma sessão anterior',
|
||||
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested':
|
||||
'Restaurar uma chamada de ferramenta. Isso redefinirá o histórico da conversa e dos arquivos para o estado em que a chamada da ferramenta foi sugerida',
|
||||
'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.':
|
||||
'Não foi possível detectar o tipo de terminal. Terminais suportados: VS Code, Cursor, Windsurf e Trae.',
|
||||
'Terminal "{{terminal}}" is not supported yet.':
|
||||
|
|
@ -751,6 +785,15 @@ export default {
|
|||
"Falha ao autenticar com o servidor MCP '{{name}}': {{error}}",
|
||||
"Re-discovering tools from '{{name}}'...":
|
||||
"Redescobrindo ferramentas de '{{name}}'...",
|
||||
"Discovered {{count}} tool(s) from '{{name}}'.":
|
||||
"{{count}} ferramenta(s) descoberta(s) de '{{name}}'.",
|
||||
'Authentication complete. Returning to server details...':
|
||||
'Autenticação concluída. Retornando aos detalhes do servidor...',
|
||||
'Authentication successful.': 'Autenticação bem-sucedida.',
|
||||
'If the browser does not open, copy and paste this URL into your browser:':
|
||||
'Se o navegador não abrir, copie e cole esta URL no seu navegador:',
|
||||
'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.':
|
||||
'⚠️ Certifique-se de copiar a URL COMPLETA – ela pode ocupar várias linhas.',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Chat
|
||||
|
|
@ -922,6 +965,8 @@ export default {
|
|||
Disable: 'Desativar',
|
||||
Enable: 'Ativar',
|
||||
Authenticate: 'Autenticar',
|
||||
'Re-authenticate': 'Reautenticar',
|
||||
'Clear Authentication': 'Limpar autenticação',
|
||||
disabled: 'desativado',
|
||||
'Server:': 'Servidor:',
|
||||
Reconnect: 'Reconectar',
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ export default {
|
|||
'Анализ проекта и создание адаптированного файла QWEN.md',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'Просмотр доступных инструментов Qwen Code. Использование: /tools [desc]',
|
||||
'List available skills.': 'Показать доступные навыки.',
|
||||
'Available Qwen Code CLI tools:': 'Доступные инструменты Qwen Code CLI:',
|
||||
'No tools available': 'Нет доступных инструментов',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -398,6 +399,7 @@ export default {
|
|||
'В настоящее время поддерживаются следующие редакторы. Обратите внимание, что некоторые редакторы нельзя использовать в режиме песочницы.',
|
||||
'Your preferred editor is:': 'Ваш предпочитаемый редактор:',
|
||||
'Manage extensions': 'Управление расширениями',
|
||||
'Manage installed extensions': 'Управлять установленными расширениями',
|
||||
'List active extensions': 'Показать активные расширения',
|
||||
'Update extensions. Usage: update <extension-names>|--all':
|
||||
'Обновить расширения. Использование: update <extension-names>|--all',
|
||||
|
|
@ -596,6 +598,38 @@ export default {
|
|||
'Не удалось настроить {{terminalName}}.',
|
||||
'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).':
|
||||
'Ваш терминал уже настроен для оптимальной работы с многострочным вводом (Shift+Enter и Ctrl+Enter).',
|
||||
// ============================================================================
|
||||
// Commands - Hooks
|
||||
// ============================================================================
|
||||
'Manage Qwen Code hooks': 'Управлять хуками Qwen Code',
|
||||
'List all configured hooks': 'Показать все настроенные хуки',
|
||||
'Enable a disabled hook': 'Включить отключенный хук',
|
||||
'Disable an active hook': 'Отключить активный хук',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Session Export
|
||||
// ============================================================================
|
||||
'Export current session message history to a file':
|
||||
'Экспортировать историю сообщений текущей сессии в файл',
|
||||
'Export session to HTML format': 'Экспортировать сессию в формат HTML',
|
||||
'Export session to JSON format': 'Экспортировать сессию в формат JSON',
|
||||
'Export session to JSONL format (one message per line)':
|
||||
'Экспортировать сессию в формат JSONL (одно сообщение на строку)',
|
||||
'Export session to markdown format':
|
||||
'Экспортировать сессию в формат Markdown',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Insights
|
||||
// ============================================================================
|
||||
'generate personalized programming insights from your chat history':
|
||||
'Создать персонализированные инсайты по программированию на основе истории чата',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Session History
|
||||
// ============================================================================
|
||||
'Resume a previous session': 'Продолжить предыдущую сессию',
|
||||
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested':
|
||||
'Восстановить вызов инструмента. Это вернет историю разговора и файлов к состоянию на момент, когда был предложен этот вызов инструмента',
|
||||
'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.':
|
||||
'Не удалось определить тип терминала. Поддерживаемые терминалы: VS Code, Cursor, Windsurf и Trae.',
|
||||
'Terminal "{{terminal}}" is not supported yet.':
|
||||
|
|
@ -754,6 +788,15 @@ export default {
|
|||
"Не удалось авторизоваться на MCP-сервере '{{name}}': {{error}}",
|
||||
"Re-discovering tools from '{{name}}'...":
|
||||
"Повторное обнаружение инструментов от '{{name}}'...",
|
||||
"Discovered {{count}} tool(s) from '{{name}}'.":
|
||||
"Обнаружено {{count}} инструмент(ов) от '{{name}}'.",
|
||||
'Authentication complete. Returning to server details...':
|
||||
'Аутентификация завершена. Возврат к деталям сервера...',
|
||||
'Authentication successful.': 'Аутентификация успешна.',
|
||||
'If the browser does not open, copy and paste this URL into your browser:':
|
||||
'Если браузер не открылся, скопируйте этот URL и вставьте его в браузер:',
|
||||
'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.':
|
||||
'⚠️ Убедитесь, что скопировали ПОЛНЫЙ URL — он может занимать несколько строк.',
|
||||
|
||||
// ============================================================================
|
||||
// Команды - Чат
|
||||
|
|
@ -900,6 +943,8 @@ export default {
|
|||
Disable: 'Отключить',
|
||||
Enable: 'Включить',
|
||||
Authenticate: 'Аутентификация',
|
||||
'Re-authenticate': 'Повторная аутентификация',
|
||||
'Clear Authentication': 'Очистить аутентификацию',
|
||||
disabled: 'отключен',
|
||||
'Server:': 'Сервер:',
|
||||
Reconnect: 'Переподключить',
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ export default {
|
|||
'分析项目并创建定制的 QWEN.md 文件',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'列出可用的 Qwen Code 工具。用法:/tools [desc]',
|
||||
'List available skills.': '列出可用技能。',
|
||||
'Available Qwen Code CLI tools:': '可用的 Qwen Code CLI 工具:',
|
||||
'No tools available': '没有可用工具',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -437,6 +438,7 @@ export default {
|
|||
'当前支持以下编辑器。请注意,某些编辑器无法在沙箱模式下使用。',
|
||||
'Your preferred editor is:': '您的首选编辑器是:',
|
||||
'Manage extensions': '管理扩展',
|
||||
'Manage installed extensions': '管理已安装的扩展',
|
||||
'List active extensions': '列出活动扩展',
|
||||
'Update extensions. Usage: update <extension-names>|--all':
|
||||
'更新扩展。用法:update <extension-names>|--all',
|
||||
|
|
@ -623,6 +625,37 @@ export default {
|
|||
'Failed to configure {{terminalName}}.': '配置 {{terminalName}} 失败。',
|
||||
'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).':
|
||||
'您的终端已配置为支持多行输入(Shift+Enter 和 Ctrl+Enter)的最佳体验。',
|
||||
// ============================================================================
|
||||
// Commands - Hooks
|
||||
// ============================================================================
|
||||
'Manage Qwen Code hooks': '管理 Qwen Code Hook',
|
||||
'List all configured hooks': '列出所有已配置的 Hook',
|
||||
'Enable a disabled hook': '启用已禁用的 Hook',
|
||||
'Disable an active hook': '禁用已启用的 Hook',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Session Export
|
||||
// ============================================================================
|
||||
'Export current session message history to a file':
|
||||
'将当前会话的消息记录导出到文件',
|
||||
'Export session to HTML format': '将会话导出为 HTML 文件',
|
||||
'Export session to JSON format': '将会话导出为 JSON 文件',
|
||||
'Export session to JSONL format (one message per line)':
|
||||
'将会话导出为 JSONL 文件(每行一条消息)',
|
||||
'Export session to markdown format': '将会话导出为 Markdown 文件',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Insights
|
||||
// ============================================================================
|
||||
'generate personalized programming insights from your chat history':
|
||||
'根据你的聊天记录生成个性化编程洞察',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Session History
|
||||
// ============================================================================
|
||||
'Resume a previous session': '恢复先前会话',
|
||||
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested':
|
||||
'恢复某次工具调用。这将把对话与文件历史重置到提出该工具调用建议时的状态',
|
||||
'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.':
|
||||
'无法检测终端类型。支持的终端:VS Code、Cursor、Windsurf 和 Trae。',
|
||||
'Terminal "{{terminal}}" is not supported yet.':
|
||||
|
|
@ -763,6 +796,15 @@ export default {
|
|||
"认证 MCP 服务器 '{{name}}' 失败:{{error}}",
|
||||
"Re-discovering tools from '{{name}}'...":
|
||||
"正在重新发现 '{{name}}' 的工具...",
|
||||
"Discovered {{count}} tool(s) from '{{name}}'.":
|
||||
"从 '{{name}}' 发现了 {{count}} 个工具。",
|
||||
'Authentication complete. Returning to server details...':
|
||||
'认证完成,正在返回服务器详情...',
|
||||
'Authentication successful.': '认证成功。',
|
||||
'If the browser does not open, copy and paste this URL into your browser:':
|
||||
'如果浏览器未自动打开,请复制以下 URL 并粘贴到浏览器中:',
|
||||
'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.':
|
||||
'⚠️ 请确保复制完整的 URL —— 它可能跨越多行。',
|
||||
|
||||
// ============================================================================
|
||||
// MCP Management Dialog
|
||||
|
|
@ -793,6 +835,8 @@ export default {
|
|||
Enable: '启用',
|
||||
Disable: '禁用',
|
||||
Authenticate: '认证',
|
||||
'Re-authenticate': '重新认证',
|
||||
'Clear Authentication': '清空认证',
|
||||
disabled: '已禁用',
|
||||
'Server:': '服务器:',
|
||||
'(disabled)': '(已禁用)',
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
uiTelemetryService,
|
||||
FatalInputError,
|
||||
ApprovalMode,
|
||||
SendMessageType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||
|
|
@ -250,7 +251,7 @@ describe('runNonInteractive', () => {
|
|||
[{ text: 'Test input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Hello World');
|
||||
expect(mockShutdownTelemetry).toHaveBeenCalled();
|
||||
|
|
@ -300,21 +301,21 @@ describe('runNonInteractive', () => {
|
|||
outputUpdateHandler: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
// Verify first call has isContinuation: false
|
||||
// Verify first call has type: UserQuery
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
[{ text: 'Use a tool' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-2',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
// Verify second call (after tool execution) has isContinuation: true
|
||||
// Verify second call (after tool execution) has type: ToolResult
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
[{ text: 'Tool response' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-2',
|
||||
{ isContinuation: true },
|
||||
{ type: SendMessageType.ToolResult },
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Final answer');
|
||||
});
|
||||
|
|
@ -383,7 +384,7 @@ describe('runNonInteractive', () => {
|
|||
],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-3',
|
||||
{ isContinuation: true },
|
||||
{ type: SendMessageType.ToolResult },
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Sorry, let me try again.');
|
||||
});
|
||||
|
|
@ -507,7 +508,7 @@ describe('runNonInteractive', () => {
|
|||
processedParts,
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-7',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
|
||||
// 6. Assert the final output is correct
|
||||
|
|
@ -539,7 +540,7 @@ describe('runNonInteractive', () => {
|
|||
[{ text: 'Test input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
|
||||
// JSON adapter emits array of messages, last one is result with stats
|
||||
|
|
@ -694,7 +695,7 @@ describe('runNonInteractive', () => {
|
|||
[{ text: 'Empty response test' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-empty',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
|
||||
// JSON adapter emits array of messages, last one is result with stats
|
||||
|
|
@ -881,7 +882,7 @@ describe('runNonInteractive', () => {
|
|||
[{ text: 'Prompt from command' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Response from command');
|
||||
|
|
@ -941,7 +942,7 @@ describe('runNonInteractive', () => {
|
|||
[{ text: '/unknowncommand' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-unknown',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown');
|
||||
|
|
@ -1299,7 +1300,7 @@ describe('runNonInteractive', () => {
|
|||
[{ text: 'Message from stream-json input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-envelope',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1775,7 +1776,7 @@ describe('runNonInteractive', () => {
|
|||
[{ text: 'Simple string content' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-string-content',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
|
||||
// UserMessage with array of text blocks
|
||||
|
|
@ -1808,7 +1809,7 @@ describe('runNonInteractive', () => {
|
|||
[{ text: 'First part' }, { text: 'Second part' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-blocks-content',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
uiTelemetryService,
|
||||
parseAndFormatApiError,
|
||||
createDebugLogger,
|
||||
SendMessageType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { Content, Part, PartListUnion } from '@google/genai';
|
||||
import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js';
|
||||
|
|
@ -265,7 +266,11 @@ export async function runNonInteractive(
|
|||
currentMessages[0]?.parts || [],
|
||||
abortController.signal,
|
||||
prompt_id,
|
||||
{ isContinuation: !isFirstTurn },
|
||||
{
|
||||
type: isFirstTurn
|
||||
? SendMessageType.UserQuery
|
||||
: SendMessageType.ToolResult,
|
||||
},
|
||||
);
|
||||
isFirstTurn = false;
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from '@qwen-code/qwen-code-core';
|
||||
import { CommandService } from './services/CommandService.js';
|
||||
import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js';
|
||||
import { BundledSkillLoader } from './services/BundledSkillLoader.js';
|
||||
import { FileCommandLoader } from './services/FileCommandLoader.js';
|
||||
import {
|
||||
CommandKind,
|
||||
|
|
@ -197,7 +198,7 @@ function filterCommandsForNonInteractive(
|
|||
allowedBuiltinCommandNames: Set<string>,
|
||||
): SlashCommand[] {
|
||||
return commands.filter((cmd) => {
|
||||
if (cmd.kind === CommandKind.FILE) {
|
||||
if (cmd.kind === CommandKind.FILE || cmd.kind === CommandKind.SKILL) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -252,6 +253,7 @@ export const handleSlashCommand = async (
|
|||
// Load all commands to check if the command exists but is not allowed
|
||||
const allLoaders = [
|
||||
new BuiltinCommandLoader(config),
|
||||
new BundledSkillLoader(config),
|
||||
new FileCommandLoader(config),
|
||||
];
|
||||
|
||||
|
|
@ -366,8 +368,12 @@ export const getAvailableCommands = async (
|
|||
// Only load BuiltinCommandLoader if there are allowed built-in commands
|
||||
const loaders =
|
||||
allowedBuiltinSet.size > 0
|
||||
? [new BuiltinCommandLoader(config), new FileCommandLoader(config)]
|
||||
: [new FileCommandLoader(config)];
|
||||
? [
|
||||
new BuiltinCommandLoader(config),
|
||||
new BundledSkillLoader(config),
|
||||
new FileCommandLoader(config),
|
||||
]
|
||||
: [new BundledSkillLoader(config), new FileCommandLoader(config)];
|
||||
|
||||
const commandService = await CommandService.create(loaders, abortSignal);
|
||||
const commands = commandService.getCommands();
|
||||
|
|
|
|||
128
packages/cli/src/services/BundledSkillLoader.test.ts
Normal file
128
packages/cli/src/services/BundledSkillLoader.test.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { BundledSkillLoader } from './BundledSkillLoader.js';
|
||||
import { CommandKind } from '../ui/commands/types.js';
|
||||
import type { Config, SkillConfig } from '@qwen-code/qwen-code-core';
|
||||
|
||||
function makeSkill(overrides: Partial<SkillConfig> = {}): SkillConfig {
|
||||
return {
|
||||
name: 'review',
|
||||
description: 'Review code changes',
|
||||
level: 'bundled',
|
||||
filePath: '/bundled/review/SKILL.md',
|
||||
body: 'You are an expert code reviewer.',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('BundledSkillLoader', () => {
|
||||
let mockConfig: Config;
|
||||
let mockSkillManager: {
|
||||
listSkills: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockSkillManager = {
|
||||
listSkills: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
mockConfig = {
|
||||
getSkillManager: vi.fn().mockReturnValue(mockSkillManager),
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
it('should return empty array when config is null', async () => {
|
||||
const loader = new BundledSkillLoader(null);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
expect(commands).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when SkillManager is not available', async () => {
|
||||
const config = {
|
||||
getSkillManager: vi.fn().mockReturnValue(null),
|
||||
} as unknown as Config;
|
||||
const loader = new BundledSkillLoader(config);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
expect(commands).toEqual([]);
|
||||
});
|
||||
|
||||
it('should load bundled skills as slash commands', async () => {
|
||||
const skill = makeSkill();
|
||||
mockSkillManager.listSkills.mockResolvedValue([skill]);
|
||||
|
||||
const loader = new BundledSkillLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(1);
|
||||
expect(commands[0].name).toBe('review');
|
||||
expect(commands[0].description).toBe('Review code changes');
|
||||
expect(commands[0].kind).toBe(CommandKind.SKILL);
|
||||
expect(mockSkillManager.listSkills).toHaveBeenCalledWith({
|
||||
level: 'bundled',
|
||||
});
|
||||
});
|
||||
|
||||
it('should submit skill body as prompt without args', async () => {
|
||||
const skill = makeSkill();
|
||||
mockSkillManager.listSkills.mockResolvedValue([skill]);
|
||||
|
||||
const loader = new BundledSkillLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const result = await commands[0].action!(
|
||||
{ invocation: { raw: '/review', args: '' } } as never,
|
||||
'',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'submit_prompt',
|
||||
content: [{ text: 'You are an expert code reviewer.' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should append raw invocation when args are provided', async () => {
|
||||
const skill = makeSkill();
|
||||
mockSkillManager.listSkills.mockResolvedValue([skill]);
|
||||
|
||||
const loader = new BundledSkillLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
const result = await commands[0].action!(
|
||||
{ invocation: { raw: '/review 123', args: '123' } } as never,
|
||||
'123',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'submit_prompt',
|
||||
content: [{ text: 'You are an expert code reviewer.\n\n/review 123' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array when listSkills throws', async () => {
|
||||
mockSkillManager.listSkills.mockRejectedValue(new Error('load failed'));
|
||||
|
||||
const loader = new BundledSkillLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toEqual([]);
|
||||
});
|
||||
|
||||
it('should load multiple bundled skills', async () => {
|
||||
const skills = [
|
||||
makeSkill({ name: 'review', description: 'Review code' }),
|
||||
makeSkill({ name: 'deploy', description: 'Deploy app' }),
|
||||
];
|
||||
mockSkillManager.listSkills.mockResolvedValue(skills);
|
||||
|
||||
const loader = new BundledSkillLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(signal);
|
||||
|
||||
expect(commands).toHaveLength(2);
|
||||
expect(commands.map((c) => c.name)).toEqual(['review', 'deploy']);
|
||||
});
|
||||
});
|
||||
64
packages/cli/src/services/BundledSkillLoader.ts
Normal file
64
packages/cli/src/services/BundledSkillLoader.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
createDebugLogger,
|
||||
appendToLastTextPart,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { ICommandLoader } from './types.js';
|
||||
import type {
|
||||
SlashCommand,
|
||||
SlashCommandActionReturn,
|
||||
} from '../ui/commands/types.js';
|
||||
import { CommandKind } from '../ui/commands/types.js';
|
||||
|
||||
const debugLogger = createDebugLogger('BUNDLED_SKILL_LOADER');
|
||||
|
||||
/**
|
||||
* Loads bundled skills as slash commands, making them directly invocable
|
||||
* via /<skill-name> (e.g., /review).
|
||||
*/
|
||||
export class BundledSkillLoader implements ICommandLoader {
|
||||
constructor(private readonly config: Config | null) {}
|
||||
|
||||
async loadCommands(_signal: AbortSignal): Promise<SlashCommand[]> {
|
||||
const skillManager = this.config?.getSkillManager();
|
||||
if (!skillManager) {
|
||||
debugLogger.debug('SkillManager not available, skipping bundled skills');
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const skills = await skillManager.listSkills({ level: 'bundled' });
|
||||
debugLogger.debug(
|
||||
`Loaded ${skills.length} bundled skill(s) as slash commands`,
|
||||
);
|
||||
|
||||
return skills.map((skill) => ({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
kind: CommandKind.SKILL,
|
||||
action: async (context, _args): Promise<SlashCommandActionReturn> => {
|
||||
const content = context.invocation?.args
|
||||
? appendToLastTextPart(
|
||||
[{ text: skill.body }],
|
||||
context.invocation.raw,
|
||||
)
|
||||
: [{ text: skill.body }];
|
||||
|
||||
return {
|
||||
type: 'submit_prompt',
|
||||
content,
|
||||
};
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
debugLogger.error('Failed to load bundled skills:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
|
|||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
|
@ -1137,6 +1138,102 @@ describe('DataProcessor', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('generateQualitativeInsights', () => {
|
||||
const mockMetrics = {
|
||||
totalSessions: 5,
|
||||
totalMessages: 50,
|
||||
totalHours: 2,
|
||||
heatmap: { '2025-01-15': 3 },
|
||||
topTools: [['read_file', 10]] as Array<[string, number]>,
|
||||
activeDays: 1,
|
||||
activeHours: { '10': 5 },
|
||||
totalLinesAdded: 100,
|
||||
totalLinesRemoved: 50,
|
||||
totalFiles: 10,
|
||||
streak: { currentStreak: 1, longestStreak: 1, dates: [] },
|
||||
} as unknown as Omit<InsightData, 'facets' | 'qualitative'>;
|
||||
|
||||
const mockFacets: SessionFacets[] = [
|
||||
{
|
||||
session_id: 'test-1',
|
||||
underlying_goal: 'Fix bug',
|
||||
goal_categories: { debugging: 1 },
|
||||
outcome: 'fully_achieved',
|
||||
user_satisfaction_counts: { satisfied: 1 },
|
||||
Qwen_helpfulness: 'very_helpful',
|
||||
session_type: 'single_task',
|
||||
friction_counts: {},
|
||||
friction_detail: '',
|
||||
primary_success: 'correct_code_edits',
|
||||
brief_summary: 'Fixed a bug',
|
||||
},
|
||||
];
|
||||
|
||||
it('should return partial qualitative data when some LLM calls fail', async () => {
|
||||
let callIndex = 0;
|
||||
mockGenerateJson.mockImplementation(() => {
|
||||
callIndex++;
|
||||
if (callIndex % 2 === 0) {
|
||||
return Promise.reject(new Error('LLM timeout'));
|
||||
}
|
||||
return Promise.resolve({ intro: 'test', areas: [], opportunities: [] });
|
||||
});
|
||||
|
||||
const result = await (
|
||||
dataProcessor as unknown as {
|
||||
generateQualitativeInsights(
|
||||
metrics: Omit<InsightData, 'facets' | 'qualitative'>,
|
||||
facets: SessionFacets[],
|
||||
): Promise<
|
||||
| import('../types/QualitativeInsightTypes.js').QualitativeInsights
|
||||
| undefined
|
||||
>;
|
||||
}
|
||||
).generateQualitativeInsights(mockMetrics, mockFacets);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.impressiveWorkflows).toBeDefined();
|
||||
expect(result!.projectAreas).toBeUndefined();
|
||||
expect(result!.futureOpportunities).toBeDefined();
|
||||
expect(result!.frictionPoints).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when facets are empty', async () => {
|
||||
const result = await (
|
||||
dataProcessor as unknown as {
|
||||
generateQualitativeInsights(
|
||||
metrics: Omit<InsightData, 'facets' | 'qualitative'>,
|
||||
facets: SessionFacets[],
|
||||
): Promise<
|
||||
| import('../types/QualitativeInsightTypes.js').QualitativeInsights
|
||||
| undefined
|
||||
>;
|
||||
}
|
||||
).generateQualitativeInsights(mockMetrics, []);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return full qualitative data when all LLM calls succeed', async () => {
|
||||
mockGenerateJson.mockResolvedValue({ intro: 'test', areas: [] });
|
||||
|
||||
const result = await (
|
||||
dataProcessor as unknown as {
|
||||
generateQualitativeInsights(
|
||||
metrics: Omit<InsightData, 'facets' | 'qualitative'>,
|
||||
facets: SessionFacets[],
|
||||
): Promise<
|
||||
| import('../types/QualitativeInsightTypes.js').QualitativeInsights
|
||||
| undefined
|
||||
>;
|
||||
}
|
||||
).generateQualitativeInsights(mockMetrics, mockFacets);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(mockGenerateJson).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateFacets', () => {
|
||||
it('should skip non-conversational sessions', async () => {
|
||||
const userOnlyRecords: ChatRecord[] = [
|
||||
|
|
|
|||
|
|
@ -388,7 +388,7 @@ export class DataProcessor {
|
|||
const generate = async <T>(
|
||||
promptTemplate: string,
|
||||
schema: Record<string, unknown>,
|
||||
): Promise<T> => {
|
||||
): Promise<T | undefined> => {
|
||||
const prompt = `${promptTemplate}\n\n${commonData}`;
|
||||
try {
|
||||
const result = await this.config.getBaseLlmClient().generateJson({
|
||||
|
|
@ -400,7 +400,7 @@ export class DataProcessor {
|
|||
return result as T;
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate insight:', error);
|
||||
throw error;
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -71,12 +71,12 @@ export interface InsightAtAGlance {
|
|||
}
|
||||
|
||||
export interface QualitativeInsights {
|
||||
impressiveWorkflows: InsightImpressiveWorkflows;
|
||||
projectAreas: InsightProjectAreas;
|
||||
futureOpportunities: InsightFutureOpportunities;
|
||||
frictionPoints: InsightFrictionPoints;
|
||||
memorableMoment: InsightMemorableMoment;
|
||||
improvements: InsightImprovements;
|
||||
interactionStyle: InsightInteractionStyle;
|
||||
atAGlance: InsightAtAGlance;
|
||||
impressiveWorkflows?: InsightImpressiveWorkflows;
|
||||
projectAreas?: InsightProjectAreas;
|
||||
futureOpportunities?: InsightFutureOpportunities;
|
||||
frictionPoints?: InsightFrictionPoints;
|
||||
memorableMoment?: InsightMemorableMoment;
|
||||
improvements?: InsightImprovements;
|
||||
interactionStyle?: InsightInteractionStyle;
|
||||
atAGlance?: InsightAtAGlance;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -345,7 +345,7 @@ export function AuthDialog(): React.JSX.Element {
|
|||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderStyle="single"
|
||||
borderColor={theme?.border?.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export function AuthInProgress({
|
|||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
|
|
|
|||
|
|
@ -19,14 +19,14 @@ import {
|
|||
} from '../utils/export/index.js';
|
||||
|
||||
const mockSessionServiceMocks = vi.hoisted(() => ({
|
||||
loadLastSession: vi.fn(),
|
||||
loadSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', () => {
|
||||
class SessionService {
|
||||
constructor(_cwd: string) {}
|
||||
async loadLastSession() {
|
||||
return mockSessionServiceMocks.loadLastSession();
|
||||
async loadSession(_sessionId: string) {
|
||||
return mockSessionServiceMocks.loadSession();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -68,13 +68,14 @@ describe('exportCommand', () => {
|
|||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockSessionServiceMocks.loadLastSession.mockResolvedValue(mockSessionData);
|
||||
mockSessionServiceMocks.loadSession.mockResolvedValue(mockSessionData);
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getWorkingDir: vi.fn().mockReturnValue('/test/dir'),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -132,7 +133,7 @@ describe('exportCommand', () => {
|
|||
content: expect.stringContaining('export-2025-01-01T00-00-00-000Z.md'),
|
||||
});
|
||||
|
||||
expect(mockSessionServiceMocks.loadLastSession).toHaveBeenCalled();
|
||||
expect(mockSessionServiceMocks.loadSession).toHaveBeenCalled();
|
||||
expect(collectSessionData).toHaveBeenCalledWith(
|
||||
mockSessionData.conversation,
|
||||
expect.anything(),
|
||||
|
|
@ -191,7 +192,7 @@ describe('exportCommand', () => {
|
|||
});
|
||||
|
||||
it('should return error when no session is found', async () => {
|
||||
mockSessionServiceMocks.loadLastSession.mockResolvedValue(undefined);
|
||||
mockSessionServiceMocks.loadSession.mockResolvedValue(undefined);
|
||||
|
||||
const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md');
|
||||
if (!mdCommand?.action) {
|
||||
|
|
@ -260,7 +261,7 @@ describe('exportCommand', () => {
|
|||
),
|
||||
});
|
||||
|
||||
expect(mockSessionServiceMocks.loadLastSession).toHaveBeenCalled();
|
||||
expect(mockSessionServiceMocks.loadSession).toHaveBeenCalled();
|
||||
expect(collectSessionData).toHaveBeenCalledWith(
|
||||
mockSessionData.conversation,
|
||||
expect.anything(),
|
||||
|
|
@ -323,7 +324,7 @@ describe('exportCommand', () => {
|
|||
});
|
||||
|
||||
it('should return error when no session is found', async () => {
|
||||
mockSessionServiceMocks.loadLastSession.mockResolvedValue(undefined);
|
||||
mockSessionServiceMocks.loadSession.mockResolvedValue(undefined);
|
||||
|
||||
const htmlCommand = exportCommand.subCommands?.find(
|
||||
(c) => c.name === 'html',
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
toJsonl,
|
||||
generateExportFilename,
|
||||
} from '../utils/export/index.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Action for the 'md' subcommand - exports session to markdown.
|
||||
|
|
@ -50,9 +51,10 @@ async function exportMarkdownAction(
|
|||
}
|
||||
|
||||
try {
|
||||
// Load the current session
|
||||
// Load the current session using the current session ID
|
||||
const sessionService = new SessionService(cwd);
|
||||
const sessionData = await sessionService.loadLastSession();
|
||||
const sessionId = config.getSessionId();
|
||||
const sessionData = await sessionService.loadSession(sessionId);
|
||||
|
||||
if (!sessionData) {
|
||||
return {
|
||||
|
|
@ -122,9 +124,10 @@ async function exportHtmlAction(
|
|||
}
|
||||
|
||||
try {
|
||||
// Load the current session
|
||||
// Load the current session using the current session ID
|
||||
const sessionService = new SessionService(cwd);
|
||||
const sessionData = await sessionService.loadLastSession();
|
||||
const sessionId = config.getSessionId();
|
||||
const sessionData = await sessionService.loadSession(sessionId);
|
||||
|
||||
if (!sessionData) {
|
||||
return {
|
||||
|
|
@ -194,9 +197,10 @@ async function exportJsonAction(
|
|||
}
|
||||
|
||||
try {
|
||||
// Load the current session
|
||||
// Load the current session using the current session ID
|
||||
const sessionService = new SessionService(cwd);
|
||||
const sessionData = await sessionService.loadLastSession();
|
||||
const sessionId = config.getSessionId();
|
||||
const sessionData = await sessionService.loadSession(sessionId);
|
||||
|
||||
if (!sessionData) {
|
||||
return {
|
||||
|
|
@ -266,9 +270,10 @@ async function exportJsonlAction(
|
|||
}
|
||||
|
||||
try {
|
||||
// Load the current session
|
||||
// Load the current session using the current session ID
|
||||
const sessionService = new SessionService(cwd);
|
||||
const sessionData = await sessionService.loadLastSession();
|
||||
const sessionId = config.getSessionId();
|
||||
const sessionData = await sessionService.loadSession(sessionId);
|
||||
|
||||
if (!sessionData) {
|
||||
return {
|
||||
|
|
@ -316,30 +321,40 @@ async function exportJsonlAction(
|
|||
*/
|
||||
export const exportCommand: SlashCommand = {
|
||||
name: 'export',
|
||||
description: 'Export current session message history to a file',
|
||||
get description() {
|
||||
return t('Export current session message history to a file');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'html',
|
||||
description: 'Export session to HTML format',
|
||||
get description() {
|
||||
return t('Export session to HTML format');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: exportHtmlAction,
|
||||
},
|
||||
{
|
||||
name: 'md',
|
||||
description: 'Export session to markdown format',
|
||||
get description() {
|
||||
return t('Export session to markdown format');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: exportMarkdownAction,
|
||||
},
|
||||
{
|
||||
name: 'json',
|
||||
description: 'Export session to JSON format',
|
||||
get description() {
|
||||
return t('Export session to JSON format');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: exportJsonAction,
|
||||
},
|
||||
{
|
||||
name: 'jsonl',
|
||||
description: 'Export session to JSONL format (one message per line)',
|
||||
get description() {
|
||||
return t('Export session to JSONL format (one message per line)');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: exportJsonlAction,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
CommandKind,
|
||||
} from './types.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
async function restoreAction(
|
||||
context: CommandContext,
|
||||
|
|
@ -144,8 +145,11 @@ export const restoreCommand = (config: Config | null): SlashCommand | 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',
|
||||
get description() {
|
||||
return t(
|
||||
'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,
|
||||
|
|
|
|||
|
|
@ -211,6 +211,7 @@ export enum CommandKind {
|
|||
BUILT_IN = 'built-in',
|
||||
FILE = 'file',
|
||||
MCP_PROMPT = 'mcp-prompt',
|
||||
SKILL = 'skill',
|
||||
}
|
||||
|
||||
export interface CommandCompletionItem {
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ describe('<Header />', () => {
|
|||
|
||||
it('renders with border around info panel', () => {
|
||||
const { lastFrame } = render(<Header {...defaultProps} />);
|
||||
expect(lastFrame()).toContain('╭');
|
||||
expect(lastFrame()).toContain('╯');
|
||||
expect(lastFrame()).toContain('┌');
|
||||
expect(lastFrame()).toContain('┐');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|||
{/* Right side: Info panel (flexible width, max 60 in two-column layout) */}
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={infoPanelPaddingX}
|
||||
flexGrow={showLogo ? 0 : 1}
|
||||
|
|
|
|||
|
|
@ -21,12 +21,13 @@ export const PlanSummaryDisplay: React.FC<PlanSummaryDisplayProps> = ({
|
|||
availableHeight,
|
||||
childWidth,
|
||||
}) => {
|
||||
const { message, plan } = data;
|
||||
const { message, plan, rejected } = data;
|
||||
const messageColor = rejected ? Colors.AccentYellow : Colors.AccentGreen;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text color={Colors.AccentGreen} wrap="wrap">
|
||||
<Text color={messageColor} wrap="wrap">
|
||||
{message}
|
||||
</Text>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -17,18 +17,6 @@ vi.mock('../hooks/useKeypress.js', () => ({
|
|||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock qrcode-terminal module
|
||||
vi.mock('qrcode-terminal', () => ({
|
||||
default: {
|
||||
generate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock ink-spinner
|
||||
vi.mock('ink-spinner', () => ({
|
||||
default: ({ type }: { type: string }) => `MockSpinner(${type})`,
|
||||
}));
|
||||
|
||||
// Mock ink-link
|
||||
vi.mock('ink-link', () => ({
|
||||
default: ({ children }: { children: React.ReactNode; url: string }) =>
|
||||
|
|
@ -95,19 +83,17 @@ describe('QwenOAuthProgress', () => {
|
|||
const { lastFrame } = renderComponent();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('MockSpinner(dots)');
|
||||
expect(output).toContain('Waiting for Qwen OAuth authentication...');
|
||||
expect(output).toContain('(Press ESC or CTRL+C to cancel)');
|
||||
expect(output).toContain('Esc to cancel');
|
||||
});
|
||||
|
||||
it('should render loading state with gray border', () => {
|
||||
it('should render loading state with single border', () => {
|
||||
const { lastFrame } = renderComponent();
|
||||
const output = lastFrame();
|
||||
|
||||
// Should not contain auth flow elements
|
||||
expect(output).not.toContain('Qwen OAuth Authentication');
|
||||
expect(output).not.toContain('Please visit this URL to authorize:');
|
||||
// Loading state still shows time remaining with default timeout
|
||||
// Should contain the auth title even in loading state
|
||||
expect(output).toContain('Qwen OAuth Authentication');
|
||||
// Loading state shows time remaining with default timeout
|
||||
expect(output).toContain('Time remaining:');
|
||||
});
|
||||
});
|
||||
|
|
@ -117,44 +103,20 @@ describe('QwenOAuthProgress', () => {
|
|||
const { lastFrame } = renderComponent({ deviceAuth: mockDeviceAuth });
|
||||
|
||||
const output = lastFrame();
|
||||
// Initially no QR code shown until it's generated, but the status area should be visible
|
||||
expect(output).toContain('MockSpinner(dots)');
|
||||
expect(output).toContain('Waiting for authorization');
|
||||
expect(output).toContain('Time remaining: 5:00');
|
||||
expect(output).toContain('(Press ESC or CTRL+C to cancel)');
|
||||
expect(output).toContain('Esc to cancel');
|
||||
});
|
||||
|
||||
it('should display correct URL in Static component when QR code is generated', async () => {
|
||||
const qrcode = await import('qrcode-terminal');
|
||||
const mockGenerate = vi.mocked(qrcode.default.generate);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let qrCallback: any = null;
|
||||
mockGenerate.mockImplementation((url, options, callback) => {
|
||||
qrCallback = callback;
|
||||
});
|
||||
|
||||
it('should display correct URL in auth URL display', () => {
|
||||
const customAuth = createMockDeviceAuth({
|
||||
verification_uri_complete: 'https://custom.com/auth?code=XYZ789',
|
||||
});
|
||||
|
||||
const { lastFrame, rerender } = renderComponent({
|
||||
const { lastFrame } = renderComponent({
|
||||
deviceAuth: customAuth,
|
||||
});
|
||||
|
||||
// Manually trigger the QR code callback
|
||||
if (qrCallback && typeof qrCallback === 'function') {
|
||||
qrCallback('Mock QR Code Data');
|
||||
}
|
||||
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={customAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('https://custom.com/auth?code=XYZ789');
|
||||
});
|
||||
|
||||
|
|
@ -282,10 +244,11 @@ describe('QwenOAuthProgress', () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
// Initial state should have no dots
|
||||
expect(lastFrame()).toContain('Waiting for authorization');
|
||||
// Initial state should show '...' (default value)
|
||||
const initialOutput = lastFrame();
|
||||
expect(initialOutput).toContain('Waiting for authorization');
|
||||
|
||||
// Advance by 500ms to add first dot
|
||||
// Advance by 500ms to cycle animation
|
||||
vi.advanceTimersByTime(500);
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
|
|
@ -294,9 +257,10 @@ describe('QwenOAuthProgress', () => {
|
|||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toContain('Waiting for authorization.');
|
||||
const after500ms = lastFrame();
|
||||
expect(after500ms).toContain('Waiting for authorization');
|
||||
|
||||
// Advance by another 500ms to add second dot
|
||||
// Advance by another 500ms to continue animation
|
||||
vi.advanceTimersByTime(500);
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
|
|
@ -305,9 +269,10 @@ describe('QwenOAuthProgress', () => {
|
|||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toContain('Waiting for authorization..');
|
||||
const after1000ms = lastFrame();
|
||||
expect(after1000ms).toContain('Waiting for authorization');
|
||||
|
||||
// Advance by another 500ms to add third dot
|
||||
// Advance by another 500ms to complete cycle
|
||||
vi.advanceTimersByTime(500);
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
|
|
@ -316,110 +281,8 @@ describe('QwenOAuthProgress', () => {
|
|||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toContain('Waiting for authorization...');
|
||||
|
||||
// Advance by another 500ms to reset dots
|
||||
vi.advanceTimersByTime(500);
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toContain('Waiting for authorization');
|
||||
});
|
||||
});
|
||||
|
||||
describe('QR Code functionality', () => {
|
||||
it('should generate QR code when deviceAuth is provided', async () => {
|
||||
const qrcode = await import('qrcode-terminal');
|
||||
const mockGenerate = vi.mocked(qrcode.default.generate);
|
||||
|
||||
mockGenerate.mockImplementation((url, options, callback) => {
|
||||
callback!('Mock QR Code Data');
|
||||
});
|
||||
|
||||
render(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockGenerate).toHaveBeenCalledWith(
|
||||
mockDeviceAuth.verification_uri_complete,
|
||||
{ small: true },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display QR code in Static component when available', async () => {
|
||||
const qrcode = await import('qrcode-terminal');
|
||||
const mockGenerate = vi.mocked(qrcode.default.generate);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let qrCallback: any = null;
|
||||
mockGenerate.mockImplementation((url, options, callback) => {
|
||||
qrCallback = callback;
|
||||
});
|
||||
|
||||
const { lastFrame, rerender } = render(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Manually trigger the QR code callback
|
||||
if (qrCallback && typeof qrCallback === 'function') {
|
||||
qrCallback('Mock QR Code Data');
|
||||
}
|
||||
|
||||
rerender(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Or scan the QR code below:');
|
||||
expect(output).toContain('Mock QR Code Data');
|
||||
});
|
||||
|
||||
it('should handle QR code generation errors gracefully', async () => {
|
||||
const qrcode = await import('qrcode-terminal');
|
||||
const mockGenerate = vi.mocked(qrcode.default.generate);
|
||||
mockGenerate.mockImplementation(() => {
|
||||
throw new Error('QR Code generation failed');
|
||||
});
|
||||
|
||||
const { lastFrame } = render(
|
||||
<QwenOAuthProgress
|
||||
onTimeout={mockOnTimeout}
|
||||
onCancel={mockOnCancel}
|
||||
deviceAuth={mockDeviceAuth}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should not crash and should not show QR code section since QR generation failed
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('Or scan the QR code below:');
|
||||
});
|
||||
|
||||
it('should not generate QR code when deviceAuth is null', async () => {
|
||||
const qrcode = await import('qrcode-terminal');
|
||||
const mockGenerate = vi.mocked(qrcode.default.generate);
|
||||
|
||||
render(
|
||||
<QwenOAuthProgress onTimeout={mockOnTimeout} onCancel={mockOnCancel} />,
|
||||
);
|
||||
|
||||
expect(mockGenerate).not.toHaveBeenCalled();
|
||||
const after1500ms = lastFrame();
|
||||
expect(after1500ms).toContain('Waiting for authorization');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,14 +5,11 @@
|
|||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import Spinner from 'ink-spinner';
|
||||
import Link from 'ink-link';
|
||||
import qrcode from 'qrcode-terminal';
|
||||
import { Colors } from '../colors.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core';
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
|
|
@ -30,98 +27,10 @@ interface QwenOAuthProgressProps {
|
|||
authMessage?: string | null;
|
||||
}
|
||||
|
||||
const debugLogger = createDebugLogger('QWEN_OAUTH_PROGRESS');
|
||||
|
||||
/**
|
||||
* Static QR Code Display Component
|
||||
* Renders the QR code and URL once and doesn't re-render unless the URL changes
|
||||
*/
|
||||
function QrCodeDisplay({
|
||||
verificationUrl,
|
||||
qrCodeData,
|
||||
}: {
|
||||
verificationUrl: string;
|
||||
qrCodeData: string | null;
|
||||
}): React.JSX.Element | null {
|
||||
if (!qrCodeData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentBlue}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={Colors.AccentBlue}>
|
||||
{t('Qwen OAuth Authentication')}
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('Please visit this URL to authorize:')}</Text>
|
||||
</Box>
|
||||
|
||||
<Link url={verificationUrl} fallback={false}>
|
||||
<Text color={Colors.AccentGreen} bold>
|
||||
{verificationUrl}
|
||||
</Text>
|
||||
</Link>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('Or scan the QR code below:')}</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>{qrCodeData}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic Status Display Component
|
||||
* Shows the loading spinner, timer, and status messages
|
||||
*/
|
||||
function StatusDisplay({
|
||||
timeRemaining,
|
||||
dots,
|
||||
}: {
|
||||
timeRemaining: number;
|
||||
dots: string;
|
||||
}): React.JSX.Element {
|
||||
const formatTime = (seconds: number): string => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentBlue}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
<Spinner type="dots" /> {t('Waiting for authorization')}
|
||||
{dots}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} justifyContent="space-between">
|
||||
<Text color={Colors.Gray}>
|
||||
{t('Time remaining:')} {formatTime(timeRemaining)}
|
||||
</Text>
|
||||
<Text color={Colors.AccentPurple}>
|
||||
{t('(Press ESC or CTRL+C to cancel)')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
function formatTime(seconds: number): string {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function QwenOAuthProgress({
|
||||
|
|
@ -133,13 +42,11 @@ export function QwenOAuthProgress({
|
|||
}: QwenOAuthProgressProps): React.JSX.Element {
|
||||
const defaultTimeout = deviceAuth?.expires_in || 300; // Default 5 minutes
|
||||
const [timeRemaining, setTimeRemaining] = useState<number>(defaultTimeout);
|
||||
const [dots, setDots] = useState<string>('');
|
||||
const [qrCodeData, setQrCodeData] = useState<string | null>(null);
|
||||
const [dots, setDots] = useState<string>('...');
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (authStatus === 'timeout' || authStatus === 'error') {
|
||||
// Any key press in timeout or error state should trigger cancel to return to auth dialog
|
||||
onCancel();
|
||||
} else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
||||
onCancel();
|
||||
|
|
@ -148,30 +55,6 @@ export function QwenOAuthProgress({
|
|||
{ isActive: true },
|
||||
);
|
||||
|
||||
// Generate QR code once when device auth is available
|
||||
useEffect(() => {
|
||||
if (!deviceAuth?.verification_uri_complete) {
|
||||
return;
|
||||
}
|
||||
|
||||
const generateQR = () => {
|
||||
try {
|
||||
qrcode.generate(
|
||||
deviceAuth.verification_uri_complete,
|
||||
{ small: true },
|
||||
(qrcode: string) => {
|
||||
setQrCodeData(qrcode);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
debugLogger.error('Failed to generate QR code:', error);
|
||||
setQrCodeData(null);
|
||||
}
|
||||
};
|
||||
|
||||
generateQR();
|
||||
}, [deviceAuth?.verification_uri_complete]);
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
|
|
@ -187,41 +70,29 @@ export function QwenOAuthProgress({
|
|||
return () => clearInterval(timer);
|
||||
}, [onTimeout]);
|
||||
|
||||
// Animated dots
|
||||
// Animated dots — cycle through fixed-width patterns to avoid layout shift
|
||||
useEffect(() => {
|
||||
const dotFrames = ['. ', '.. ', '...'];
|
||||
let frameIndex = 0;
|
||||
const dotsTimer = setInterval(() => {
|
||||
setDots((prev) => {
|
||||
if (prev.length >= 3) return '';
|
||||
return prev + '.';
|
||||
});
|
||||
frameIndex = (frameIndex + 1) % dotFrames.length;
|
||||
setDots(dotFrames[frameIndex]!);
|
||||
}, 500);
|
||||
|
||||
return () => clearInterval(dotsTimer);
|
||||
}, []);
|
||||
|
||||
// Memoize the QR code display to prevent unnecessary re-renders
|
||||
const qrCodeDisplay = useMemo(() => {
|
||||
if (!deviceAuth?.verification_uri_complete) return null;
|
||||
|
||||
return (
|
||||
<QrCodeDisplay
|
||||
verificationUrl={deviceAuth.verification_uri_complete}
|
||||
qrCodeData={qrCodeData}
|
||||
/>
|
||||
);
|
||||
}, [deviceAuth?.verification_uri_complete, qrCodeData]);
|
||||
|
||||
// Handle timeout state
|
||||
if (authStatus === 'timeout') {
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentRed}
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={Colors.AccentRed}>
|
||||
<Text bold color={theme.status.error}>
|
||||
{t('Qwen OAuth Authentication Timeout')}
|
||||
</Text>
|
||||
|
||||
|
|
@ -238,7 +109,7 @@ export function QwenOAuthProgress({
|
|||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Press any key to return to authentication type selection.')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
|
@ -249,26 +120,26 @@ export function QwenOAuthProgress({
|
|||
if (authStatus === 'error') {
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.AccentRed}
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold color={Colors.AccentRed}>
|
||||
Qwen OAuth Authentication Error
|
||||
<Text bold color={theme.status.error}>
|
||||
{t('Qwen OAuth Authentication Error')}
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
{authMessage ||
|
||||
'An error occurred during authentication. Please try again.'}
|
||||
t('An error occurred during authentication. Please try again.')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
Press any key to return to authentication type selection.
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Press any key to return to authentication type selection.')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -279,38 +150,61 @@ export function QwenOAuthProgress({
|
|||
if (!deviceAuth) {
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={Colors.Gray}
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box>
|
||||
<Text bold>{t('Qwen OAuth Authentication')}</Text>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text>{t('Waiting for Qwen OAuth authentication...')}</Text>
|
||||
<Text>
|
||||
<Spinner type="dots" />
|
||||
{t('Waiting for Qwen OAuth authentication...')}
|
||||
{t('Time remaining:')} {formatTime(timeRemaining)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} justifyContent="space-between">
|
||||
<Text color={Colors.Gray}>
|
||||
{t('Time remaining:')} {Math.floor(timeRemaining / 60)}:
|
||||
{(timeRemaining % 60).toString().padStart(2, '0')}
|
||||
</Text>
|
||||
<Text color={Colors.AccentPurple}>
|
||||
{t('(Press ESC or CTRL+C to cancel)')}
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>{t('Esc to cancel')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
{/* Static QR Code Display */}
|
||||
{qrCodeDisplay}
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text bold>{t('Qwen OAuth Authentication')}</Text>
|
||||
|
||||
{/* Dynamic Status Display */}
|
||||
<StatusDisplay timeRemaining={timeRemaining} dots={dots} />
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('Please visit this URL to authorize:')}</Text>
|
||||
</Box>
|
||||
|
||||
<Link url={deviceAuth.verification_uri_complete || ''} fallback={false}>
|
||||
<Text color={theme.text.link} bold>
|
||||
{deviceAuth.verification_uri_complete}
|
||||
</Text>
|
||||
</Link>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text>
|
||||
{t('Waiting for authorization')}
|
||||
{dots}
|
||||
</Text>
|
||||
<Text>
|
||||
{t('Time remaining:')} {formatTime(timeRemaining)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>{t('Esc to cancel')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type React from 'react';
|
|||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { ShellExecutionService } from '@qwen-code/qwen-code-core';
|
||||
import { keyToAnsi, type Key } from '../hooks/keyToAnsi.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
|
||||
export interface ShellInputPromptProps {
|
||||
activeShellPtyId: number | null;
|
||||
|
|
@ -33,6 +34,11 @@ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
|
|||
if (!focus || !activeShellPtyId) {
|
||||
return;
|
||||
}
|
||||
// Don't forward Ctrl+F to the PTY — it's used to toggle shell focus.
|
||||
// Without this, the raw ^F control character gets written to the shell.
|
||||
if (keyMatchers[Command.TOGGLE_SHELL_INPUT_FOCUS](key)) {
|
||||
return;
|
||||
}
|
||||
if (key.ctrl && key.shift && key.name === 'up') {
|
||||
ShellExecutionService.scrollPty(activeShellPtyId, -1);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { useConfig } from '../../contexts/ConfigContext.js';
|
|||
import {
|
||||
getMCPServerStatus,
|
||||
DiscoveredMCPTool,
|
||||
MCPOAuthTokenStorage,
|
||||
type MCPServerConfig,
|
||||
type AnyDeclarativeTool,
|
||||
type DiscoveredMCPPrompt,
|
||||
|
|
@ -109,6 +110,16 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
(t) => !t.name || !t.description,
|
||||
).length;
|
||||
|
||||
// Check if OAuth tokens exist for this server
|
||||
let hasOAuthTokens = false;
|
||||
try {
|
||||
const tokenStorage = new MCPOAuthTokenStorage();
|
||||
const credentials = await tokenStorage.getCredentials(name);
|
||||
hasOAuthTokens = credentials !== null;
|
||||
} catch {
|
||||
// Ignore errors when checking token existence
|
||||
}
|
||||
|
||||
serverInfos.push({
|
||||
name,
|
||||
status,
|
||||
|
|
@ -118,6 +129,7 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
invalidToolCount,
|
||||
promptCount: serverPrompts.length,
|
||||
isDisabled,
|
||||
hasOAuthTokens,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -249,6 +261,36 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
}
|
||||
}, [fetchServerData]);
|
||||
|
||||
// Clear OAuth authentication tokens and disconnect the server
|
||||
const handleClearAuth = useCallback(async () => {
|
||||
if (!config || !selectedServer) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const tokenStorage = new MCPOAuthTokenStorage();
|
||||
await tokenStorage.deleteCredentials(selectedServer.name);
|
||||
debugLogger.info(
|
||||
`Cleared OAuth tokens for server '${selectedServer.name}'`,
|
||||
);
|
||||
|
||||
// Disconnect the server so it no longer appears as connected
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
if (toolRegistry) {
|
||||
await toolRegistry.disconnectServer(selectedServer.name);
|
||||
}
|
||||
|
||||
// Reload to update hasOAuthTokens flag and server status
|
||||
await reloadServers();
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Error clearing OAuth tokens for server '${selectedServer.name}':`,
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [config, selectedServer, reloadServers]);
|
||||
|
||||
// Reconnect server
|
||||
const handleReconnect = useCallback(async () => {
|
||||
if (!config || !selectedServer) return;
|
||||
|
|
@ -537,6 +579,7 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
onReconnect={handleReconnect}
|
||||
onDisable={handleDisable}
|
||||
onAuthenticate={handleAuthenticate}
|
||||
onClearAuth={handleClearAuth}
|
||||
onBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
|
|
@ -569,10 +612,10 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
return (
|
||||
<AuthenticateStep
|
||||
server={selectedServer}
|
||||
onSuccess={() => {
|
||||
onBack={() => {
|
||||
handleNavigateBack();
|
||||
void reloadServers();
|
||||
}}
|
||||
onBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -594,6 +637,7 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
handleReconnect,
|
||||
handleDisable,
|
||||
handleAuthenticate,
|
||||
handleClearAuth,
|
||||
handleNavigateBack,
|
||||
handleSelectTool,
|
||||
handleSelectDisableScope,
|
||||
|
|
|
|||
|
|
@ -16,13 +16,15 @@ import {
|
|||
MCPOAuthTokenStorage,
|
||||
getErrorMessage,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { OAuthDisplayPayload } from '@qwen-code/qwen-code-core';
|
||||
import { appEvents, AppEvent } from '../../../../utils/events.js';
|
||||
|
||||
type AuthState = 'idle' | 'authenticating' | 'success' | 'error';
|
||||
|
||||
const AUTO_BACK_DELAY_MS = 2000;
|
||||
|
||||
export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
|
||||
server,
|
||||
onSuccess,
|
||||
onBack,
|
||||
}) => {
|
||||
const config = useConfig();
|
||||
|
|
@ -39,9 +41,12 @@ export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
|
|||
setMessages([]);
|
||||
setErrorMessage(null);
|
||||
|
||||
// Listen for OAuth display messages (same as mcpCommand.ts)
|
||||
const displayListener = (message: string) => {
|
||||
setMessages((prev) => [...prev, message]);
|
||||
// Listen for OAuth display messages - supports both plain strings and
|
||||
// structured i18n messages ({ key, params }) emitted by the core layer.
|
||||
const displayListener = (message: OAuthDisplayPayload) => {
|
||||
const text =
|
||||
typeof message === 'string' ? message : t(message.key, message.params);
|
||||
setMessages((prev) => [...prev, text]);
|
||||
};
|
||||
appEvents.on(AppEvent.OauthDisplayMessage, displayListener);
|
||||
|
||||
|
|
@ -83,6 +88,16 @@ export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
|
|||
}),
|
||||
]);
|
||||
await toolRegistry.discoverToolsForServer(server.name);
|
||||
|
||||
// Show discovered tool count
|
||||
const discoveredTools = toolRegistry.getToolsByServer(server.name);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
t("Discovered {{count}} tool(s) from '{{name}}'.", {
|
||||
count: String(discoveredTools.length),
|
||||
name: server.name,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
// Update the client with the new tools
|
||||
|
|
@ -91,8 +106,12 @@ export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
|
|||
await geminiClient.setTools();
|
||||
}
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
t('Authentication complete. Returning to server details...'),
|
||||
]);
|
||||
|
||||
setAuthState('success');
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
setErrorMessage(getErrorMessage(error));
|
||||
setAuthState('error');
|
||||
|
|
@ -100,13 +119,22 @@ export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
|
|||
isRunning.current = false;
|
||||
appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener);
|
||||
}
|
||||
}, [server, config, onSuccess]);
|
||||
}, [server, config]);
|
||||
|
||||
useEffect(() => {
|
||||
runAuthentication();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Auto-navigate back after authentication succeeds
|
||||
useEffect(() => {
|
||||
if (authState !== 'success') return;
|
||||
const timer = setTimeout(() => {
|
||||
onBack();
|
||||
}, AUTO_BACK_DELAY_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [authState, onBack]);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
|
|
@ -158,6 +186,11 @@ export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
|
|||
{t('Authenticating... Please complete the login in your browser.')}
|
||||
</Text>
|
||||
)}
|
||||
{authState === 'success' && (
|
||||
<Text color={theme.status.success}>
|
||||
{t('Authentication successful.')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ type ServerAction =
|
|||
| 'view-tools'
|
||||
| 'reconnect'
|
||||
| 'toggle-disable'
|
||||
| 'authenticate';
|
||||
| 'authenticate'
|
||||
| 'clear-auth';
|
||||
|
||||
export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
|
||||
server,
|
||||
|
|
@ -32,6 +33,7 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
|
|||
onReconnect,
|
||||
onDisable,
|
||||
onAuthenticate,
|
||||
onClearAuth,
|
||||
onBack,
|
||||
}) => {
|
||||
const statusColor = server
|
||||
|
|
@ -77,15 +79,24 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
|
|||
value: 'toggle-disable',
|
||||
});
|
||||
|
||||
// 待补充准确的认证判断方案,暂时全部开放
|
||||
// 已认证的服务器显示"重新认证",未认证的显示"认证"
|
||||
if (!server.isDisabled) {
|
||||
result.push({
|
||||
key: 'authenticate',
|
||||
label: t('Authenticate'),
|
||||
label: server.hasOAuthTokens ? t('Re-authenticate') : t('Authenticate'),
|
||||
value: 'authenticate',
|
||||
});
|
||||
}
|
||||
|
||||
// 只在存储有 OAuth 认证信息时显示“清空认证”选项
|
||||
if (!server.isDisabled && server.hasOAuthTokens) {
|
||||
result.push({
|
||||
key: 'clear-auth',
|
||||
label: t('Clear Authentication'),
|
||||
value: 'clear-auth',
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [server]);
|
||||
|
||||
|
|
@ -222,6 +233,9 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
|
|||
case 'authenticate':
|
||||
onAuthenticate?.();
|
||||
break;
|
||||
case 'clear-auth':
|
||||
onClearAuth?.();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ export interface MCPServerDisplayInfo {
|
|||
errorMessage?: string;
|
||||
/** 是否被禁用(在排除列表中) */
|
||||
isDisabled: boolean;
|
||||
/** 是否存储有 OAuth 认证信息 */
|
||||
hasOAuthTokens?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -132,6 +134,8 @@ export interface ServerDetailStepProps {
|
|||
onDisable?: () => void;
|
||||
/** OAuth 认证回调 */
|
||||
onAuthenticate?: () => void;
|
||||
/** 清空认证信息回调 */
|
||||
onClearAuth?: () => void;
|
||||
/** 返回回调 */
|
||||
onBack: () => void;
|
||||
}
|
||||
|
|
@ -178,8 +182,6 @@ export interface ToolDetailStepProps {
|
|||
export interface AuthenticateStepProps {
|
||||
/** 服务器信息 */
|
||||
server: MCPServerDisplayInfo | null;
|
||||
/** 认证成功回调 */
|
||||
onSuccess?: () => void;
|
||||
/** 返回回调 */
|
||||
onBack: () => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -174,33 +174,6 @@ describe('<AskUserQuestionDialog />', () => {
|
|||
unmount();
|
||||
});
|
||||
|
||||
it('navigates down with arrow key and selects', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails();
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate down to "Blue"
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
await wait();
|
||||
|
||||
// Press Enter
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
{ answers: { 0: 'Blue' } },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('navigates with number keys', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails();
|
||||
|
|
@ -271,72 +244,9 @@ describe('<AskUserQuestionDialog />', () => {
|
|||
expect(lastFrame()).toContain('[✓]');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('submits multi-select with Space to toggle then Enter to confirm', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [createSingleQuestion({ multiSelect: true })],
|
||||
});
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Space to toggle first option
|
||||
stdin.write(' ');
|
||||
await wait();
|
||||
|
||||
// Enter to confirm and submit
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
{ answers: { 0: 'Red' } },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple questions', () => {
|
||||
it('navigates between tabs with left/right arrows', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [
|
||||
createSingleQuestion({ header: 'Q1' }),
|
||||
createSingleQuestion({
|
||||
header: 'Q2',
|
||||
question: 'Second question?',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate right to Q2
|
||||
stdin.write('\u001B[C'); // Right arrow
|
||||
await wait();
|
||||
|
||||
expect(lastFrame()).toContain('Second question?');
|
||||
|
||||
// Navigate left back to Q1
|
||||
stdin.write('\u001B[D'); // Left arrow
|
||||
await wait();
|
||||
|
||||
expect(lastFrame()).toContain('What is your favorite color?');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows Submit tab for multiple questions', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
|
|
@ -367,41 +277,6 @@ describe('<AskUserQuestionDialog />', () => {
|
|||
unmount();
|
||||
});
|
||||
|
||||
it('cancels from Submit tab', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [
|
||||
createSingleQuestion({ header: 'Q1' }),
|
||||
createSingleQuestion({ header: 'Q2' }),
|
||||
],
|
||||
});
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate to submit tab
|
||||
stdin.write('\u001B[C'); // Right
|
||||
await wait();
|
||||
stdin.write('\u001B[C'); // Right
|
||||
await wait();
|
||||
|
||||
// Navigate down to Cancel option
|
||||
stdin.write('\u001B[B'); // Down
|
||||
await wait();
|
||||
|
||||
// Press Enter
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows unanswered questions as (not answered) in Submit tab', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Box } from 'ink';
|
||||
import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
import { ToolCallStatus } from '../../types.js';
|
||||
import { ToolMessage } from './ToolMessage.js';
|
||||
|
|
@ -136,13 +136,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
|||
contentWidth={innerWidth}
|
||||
/>
|
||||
)}
|
||||
{tool.outputFile && (
|
||||
<Box marginX={1}>
|
||||
<Text color={theme.text.primary}>
|
||||
Output too long and was saved to: {tool.outputFile}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -300,4 +300,55 @@ describe('<ToolMessage />', () => {
|
|||
);
|
||||
expect(lastFrame()).toContain('MockAnsiOutput:hello');
|
||||
});
|
||||
|
||||
it('renders rejected plan content with plan text still visible', () => {
|
||||
const planResultDisplay = {
|
||||
type: 'plan_summary' as const,
|
||||
message: 'Plan was rejected. Remaining in plan mode.',
|
||||
plan: '# My Plan\n- Step 1: Do something\n- Step 2: Do another thing',
|
||||
rejected: true,
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithContext(
|
||||
<ToolMessage
|
||||
{...baseProps}
|
||||
name="ExitPlanMode"
|
||||
description="Plan:"
|
||||
status={ToolCallStatus.Canceled}
|
||||
resultDisplay={planResultDisplay}
|
||||
/>,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Plan was rejected. Remaining in plan mode.');
|
||||
expect(output).toContain('MockMarkdown:# My Plan');
|
||||
expect(output).toContain('- Step 1: Do something');
|
||||
expect(output).toContain('- Step 2: Do another thing');
|
||||
});
|
||||
|
||||
it('renders approved plan content with approval message', () => {
|
||||
const planResultDisplay = {
|
||||
type: 'plan_summary' as const,
|
||||
message: 'User approved the plan.',
|
||||
plan: '# My Plan\n- Step 1\n- Step 2',
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithContext(
|
||||
<ToolMessage
|
||||
{...baseProps}
|
||||
name="ExitPlanMode"
|
||||
description="Plan:"
|
||||
status={ToolCallStatus.Success}
|
||||
resultDisplay={planResultDisplay}
|
||||
/>,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('User approved the plan.');
|
||||
expect(output).toContain('MockMarkdown:# My Plan');
|
||||
expect(output).toContain('- Step 1');
|
||||
expect(output).toContain('- Step 2');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1840,7 +1840,7 @@ export function useTextBuffer({
|
|||
process.env['VISUAL'] ??
|
||||
process.env['EDITOR'] ??
|
||||
(process.platform === 'win32' ? 'notepad' : 'vi');
|
||||
const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'gemini-edit-'));
|
||||
const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'qwen-edit-'));
|
||||
const filePath = pathMod.join(tmpDir, 'buffer.txt');
|
||||
fs.writeFileSync(filePath, text, 'utf8');
|
||||
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export function CreationSummary({
|
|||
}
|
||||
|
||||
// Check length warnings
|
||||
if (state.generatedDescription.length > 300) {
|
||||
if (state.generatedDescription.length > 1000) {
|
||||
allWarnings.push(
|
||||
t('Description is over {{length}} characters', {
|
||||
length: state.generatedDescription.length.toString(),
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import type { LoadedSettings } from '../../config/settings.js';
|
|||
import { type CommandContext, type SlashCommand } from '../commands/types.js';
|
||||
import { CommandService } from '../../services/CommandService.js';
|
||||
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
|
||||
import { BundledSkillLoader } from '../../services/BundledSkillLoader.js';
|
||||
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
|
||||
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
|
||||
import { parseSlashCommand } from '../../utils/commands.js';
|
||||
|
|
@ -311,6 +312,7 @@ export const useSlashCommandProcessor = (
|
|||
const loaders = [
|
||||
new McpPromptLoader(config),
|
||||
new BuiltinCommandLoader(config),
|
||||
new BundledSkillLoader(config),
|
||||
new FileCommandLoader(config),
|
||||
];
|
||||
const commandService = await CommandService.create(
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
ApprovalMode,
|
||||
AuthType,
|
||||
GeminiEventType as ServerGeminiEventType,
|
||||
SendMessageType,
|
||||
ToolErrorType,
|
||||
ToolConfirmationOutcome,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -482,7 +483,7 @@ describe('useGeminiStream', () => {
|
|||
expectedMergedResponse,
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-2',
|
||||
{ isContinuation: true },
|
||||
{ type: SendMessageType.ToolResult },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -806,7 +807,7 @@ describe('useGeminiStream', () => {
|
|||
toolCallResponseParts,
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-4',
|
||||
{ isContinuation: true },
|
||||
{ type: SendMessageType.ToolResult },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1122,7 +1123,7 @@ describe('useGeminiStream', () => {
|
|||
'This is the actual prompt from the command file.',
|
||||
expect.any(AbortSignal),
|
||||
expect.any(String),
|
||||
undefined,
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
|
||||
expect(mockScheduleToolCalls).not.toHaveBeenCalled();
|
||||
|
|
@ -1149,7 +1150,7 @@ describe('useGeminiStream', () => {
|
|||
'',
|
||||
expect.any(AbortSignal),
|
||||
expect.any(String),
|
||||
undefined,
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1168,7 +1169,7 @@ describe('useGeminiStream', () => {
|
|||
'// This is a line comment',
|
||||
expect.any(AbortSignal),
|
||||
expect.any(String),
|
||||
undefined,
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1187,7 +1188,7 @@ describe('useGeminiStream', () => {
|
|||
'/* This is a block comment */',
|
||||
expect.any(AbortSignal),
|
||||
expect.any(String),
|
||||
undefined,
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -2091,7 +2092,7 @@ describe('useGeminiStream', () => {
|
|||
processedQueryParts, // Argument 1: The parts array directly
|
||||
expect.any(AbortSignal), // Argument 2: An AbortSignal
|
||||
expect.any(String), // Argument 3: The prompt_id string
|
||||
undefined, // Argument 4: Options (undefined for normal prompts)
|
||||
{ type: SendMessageType.UserQuery }, // Argument 4: The options
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -2244,6 +2245,7 @@ describe('useGeminiStream', () => {
|
|||
it('should show a retry countdown and update pending history over time', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let continueToRetryAttempt: (() => void) | undefined;
|
||||
let resolveStream: (() => void) | undefined;
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
|
|
@ -2256,6 +2258,9 @@ describe('useGeminiStream', () => {
|
|||
delayMs: 3000,
|
||||
},
|
||||
};
|
||||
await new Promise<void>((resolve) => {
|
||||
continueToRetryAttempt = resolve;
|
||||
});
|
||||
yield {
|
||||
type: ServerGeminiEventType.Retry,
|
||||
};
|
||||
|
|
@ -2330,6 +2335,12 @@ describe('useGeminiStream', () => {
|
|||
'2s',
|
||||
);
|
||||
|
||||
continueToRetryAttempt?.();
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
resolveStream?.();
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -2347,6 +2358,103 @@ describe('useGeminiStream', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('should clear retry errors after auto-retry succeeds once the countdown has elapsed', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let continueAfterCountdown: (() => void) | undefined;
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
type: ServerGeminiEventType.Retry,
|
||||
retryInfo: {
|
||||
message: '[API Error: Rate limit exceeded]',
|
||||
attempt: 1,
|
||||
maxRetries: 3,
|
||||
delayMs: 1000,
|
||||
},
|
||||
};
|
||||
await new Promise<void>((resolve) => {
|
||||
continueAfterCountdown = resolve;
|
||||
});
|
||||
yield {
|
||||
type: ServerGeminiEventType.Retry,
|
||||
};
|
||||
yield {
|
||||
type: ServerGeminiEventType.Text,
|
||||
value: 'Success after retry',
|
||||
};
|
||||
yield {
|
||||
type: ServerGeminiEventType.Finished,
|
||||
value: { reason: 'STOP', usageMetadata: undefined },
|
||||
};
|
||||
})(),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGeminiStream(
|
||||
new MockedGeminiClientClass(mockConfig),
|
||||
[],
|
||||
mockAddItem,
|
||||
mockConfig,
|
||||
mockLoadedSettings,
|
||||
mockOnDebugMessage,
|
||||
mockHandleSlashCommand,
|
||||
false,
|
||||
() => 'vscode' as EditorType,
|
||||
() => {},
|
||||
() => Promise.resolve(),
|
||||
false,
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
80,
|
||||
24,
|
||||
),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
void result.current.submitQuery('Trigger retry after countdown');
|
||||
});
|
||||
|
||||
let errorItem = result.current.pendingHistoryItems.find(
|
||||
(item) => item.type === MessageType.ERROR,
|
||||
) as { hint?: string } | undefined;
|
||||
for (let attempts = 0; attempts < 5 && !errorItem; attempts++) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
errorItem = result.current.pendingHistoryItems.find(
|
||||
(item) => item.type === MessageType.ERROR,
|
||||
) as { hint?: string } | undefined;
|
||||
}
|
||||
expect(errorItem?.hint).toContain('1s');
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
});
|
||||
|
||||
const staleErrorBeforeRetryCompletes =
|
||||
result.current.pendingHistoryItems.find(
|
||||
(item) => item.type === MessageType.ERROR,
|
||||
) as { hint?: string } | undefined;
|
||||
expect(staleErrorBeforeRetryCompletes?.hint).toContain('0s');
|
||||
|
||||
await act(async () => {
|
||||
continueAfterCountdown?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const remainingError = result.current.pendingHistoryItems.find(
|
||||
(item) => item.type === MessageType.ERROR,
|
||||
);
|
||||
expect(remainingError).toBeUndefined();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('should memoize pendingHistoryItems', () => {
|
||||
mockUseReactToolScheduler.mockReturnValue([
|
||||
[],
|
||||
|
|
@ -2669,7 +2777,7 @@ describe('useGeminiStream', () => {
|
|||
'First query',
|
||||
expect.any(AbortSignal),
|
||||
expect.any(String),
|
||||
undefined,
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
|
||||
// Verify only the first query was added to history
|
||||
|
|
@ -2721,14 +2829,14 @@ describe('useGeminiStream', () => {
|
|||
'First query',
|
||||
expect.any(AbortSignal),
|
||||
expect.any(String),
|
||||
undefined,
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
expect(mockSendMessageStream).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'Second query',
|
||||
expect.any(AbortSignal),
|
||||
expect.any(String),
|
||||
undefined,
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -2751,7 +2859,7 @@ describe('useGeminiStream', () => {
|
|||
'Second query',
|
||||
expect.any(AbortSignal),
|
||||
expect.any(String),
|
||||
undefined,
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,14 +19,17 @@ import type {
|
|||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
GeminiEventType as ServerGeminiEventType,
|
||||
SendMessageType,
|
||||
createDebugLogger,
|
||||
getErrorMessage,
|
||||
isNodeError,
|
||||
MessageSenderType,
|
||||
logUserPrompt,
|
||||
logUserRetry,
|
||||
GitService,
|
||||
UnauthorizedError,
|
||||
UserPromptEvent,
|
||||
UserRetryEvent,
|
||||
logConversationFinishedEvent,
|
||||
ConversationFinishedEvent,
|
||||
ApprovalMode,
|
||||
|
|
@ -1034,7 +1037,8 @@ export const useGeminiStream = (
|
|||
// Show retry info if available (rate-limit / throttling errors)
|
||||
if (event.retryInfo) {
|
||||
startRetryCountdown(event.retryInfo);
|
||||
} else if (!pendingRetryCountdownItemRef.current) {
|
||||
} else {
|
||||
// The retry attempt is starting now, so any prior retry UI is stale.
|
||||
clearRetryCountdown();
|
||||
}
|
||||
break;
|
||||
|
|
@ -1075,26 +1079,28 @@ export const useGeminiStream = (
|
|||
setThought,
|
||||
pendingHistoryItemRef,
|
||||
setPendingHistoryItem,
|
||||
pendingRetryCountdownItemRef,
|
||||
],
|
||||
);
|
||||
|
||||
const submitQuery = useCallback(
|
||||
async (
|
||||
query: PartListUnion,
|
||||
options?: { isContinuation: boolean; skipPreparation?: boolean },
|
||||
submitType: SendMessageType = SendMessageType.UserQuery,
|
||||
prompt_id?: string,
|
||||
) => {
|
||||
// Prevent concurrent executions of submitQuery, but allow continuations
|
||||
// which are part of the same logical flow (tool responses)
|
||||
if (isSubmittingQueryRef.current && !options?.isContinuation) {
|
||||
if (
|
||||
isSubmittingQueryRef.current &&
|
||||
submitType !== SendMessageType.ToolResult
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(streamingState === StreamingState.Responding ||
|
||||
streamingState === StreamingState.WaitingForConfirmation) &&
|
||||
!options?.isContinuation
|
||||
submitType !== SendMessageType.ToolResult
|
||||
)
|
||||
return;
|
||||
|
||||
|
|
@ -1104,7 +1110,7 @@ export const useGeminiStream = (
|
|||
const userMessageTimestamp = Date.now();
|
||||
|
||||
// Reset quota error flag when starting a new query (not a continuation)
|
||||
if (!options?.isContinuation) {
|
||||
if (submitType !== SendMessageType.ToolResult) {
|
||||
setModelSwitchedFromQuotaError(false);
|
||||
// Commit any pending retry error to history (without hint) since the
|
||||
// user is starting a new conversation turn.
|
||||
|
|
@ -1127,14 +1133,15 @@ export const useGeminiStream = (
|
|||
}
|
||||
|
||||
return promptIdContext.run(prompt_id, async () => {
|
||||
const { queryToSend, shouldProceed } = options?.skipPreparation
|
||||
? { queryToSend: query, shouldProceed: true }
|
||||
: await prepareQueryForGemini(
|
||||
query,
|
||||
userMessageTimestamp,
|
||||
abortSignal,
|
||||
prompt_id!,
|
||||
);
|
||||
const { queryToSend, shouldProceed } =
|
||||
submitType === SendMessageType.Retry
|
||||
? { queryToSend: query, shouldProceed: true }
|
||||
: await prepareQueryForGemini(
|
||||
query,
|
||||
userMessageTimestamp,
|
||||
abortSignal,
|
||||
prompt_id!,
|
||||
);
|
||||
|
||||
if (!shouldProceed || queryToSend === null) {
|
||||
isSubmittingQueryRef.current = false;
|
||||
|
|
@ -1142,7 +1149,7 @@ export const useGeminiStream = (
|
|||
}
|
||||
|
||||
// Check image format support for non-continuations
|
||||
if (!options?.isContinuation) {
|
||||
if (submitType === SendMessageType.UserQuery) {
|
||||
const formatCheck = checkImageFormatsSupport(queryToSend);
|
||||
if (formatCheck.hasUnsupportedFormats) {
|
||||
addItem(
|
||||
|
|
@ -1159,7 +1166,7 @@ export const useGeminiStream = (
|
|||
lastPromptRef.current = finalQueryToSend;
|
||||
lastPromptErroredRef.current = false;
|
||||
|
||||
if (!options?.isContinuation) {
|
||||
if (submitType === SendMessageType.UserQuery) {
|
||||
// trigger new prompt event for session stats in CLI
|
||||
startNewPrompt();
|
||||
|
||||
|
|
@ -1180,6 +1187,10 @@ export const useGeminiStream = (
|
|||
setThought(null);
|
||||
}
|
||||
|
||||
if (submitType === SendMessageType.Retry) {
|
||||
logUserRetry(config, new UserRetryEvent(prompt_id));
|
||||
}
|
||||
|
||||
setIsResponding(true);
|
||||
setInitError(null);
|
||||
|
||||
|
|
@ -1188,7 +1199,7 @@ export const useGeminiStream = (
|
|||
finalQueryToSend,
|
||||
abortSignal,
|
||||
prompt_id!,
|
||||
options,
|
||||
{ type: submitType },
|
||||
);
|
||||
|
||||
const processingStatus = await processGeminiStreamEvents(
|
||||
|
|
@ -1276,7 +1287,7 @@ export const useGeminiStream = (
|
|||
*
|
||||
* When conditions are met:
|
||||
* - Clears any pending auto-retry countdown to avoid duplicate retries
|
||||
* - Re-submits the last query with skipPreparation: true for faster retry
|
||||
* - Re-submits the last query with isRetry: true, reusing the same prompt_id
|
||||
*
|
||||
* This function is exposed via UIActionsContext and triggered by InputPrompt
|
||||
* when the user presses Ctrl+Y (bound to Command.RETRY_LAST in keyBindings.ts).
|
||||
|
|
@ -1301,24 +1312,10 @@ export const useGeminiStream = (
|
|||
return;
|
||||
}
|
||||
|
||||
// Commit the error to history (without hint) before clearing
|
||||
const errorItem = pendingRetryErrorItemRef.current;
|
||||
if (errorItem) {
|
||||
addItem({ type: errorItem.type, text: errorItem.text }, Date.now());
|
||||
}
|
||||
clearRetryCountdown();
|
||||
|
||||
await submitQuery(lastPrompt, {
|
||||
isContinuation: false,
|
||||
skipPreparation: true,
|
||||
});
|
||||
}, [
|
||||
streamingState,
|
||||
addItem,
|
||||
clearRetryCountdown,
|
||||
submitQuery,
|
||||
pendingRetryErrorItemRef,
|
||||
]);
|
||||
await submitQuery(lastPrompt, SendMessageType.Retry);
|
||||
}, [streamingState, addItem, clearRetryCountdown, submitQuery]);
|
||||
|
||||
const handleApprovalModeChange = useCallback(
|
||||
async (newApprovalMode: ApprovalMode) => {
|
||||
|
|
@ -1463,13 +1460,7 @@ export const useGeminiStream = (
|
|||
return;
|
||||
}
|
||||
|
||||
submitQuery(
|
||||
responsesToSend,
|
||||
{
|
||||
isContinuation: true,
|
||||
},
|
||||
prompt_ids[0],
|
||||
);
|
||||
submitQuery(responsesToSend, SendMessageType.ToolResult, prompt_ids[0]);
|
||||
},
|
||||
[
|
||||
isResponding,
|
||||
|
|
|
|||
|
|
@ -252,7 +252,6 @@ export function mapToDisplay(
|
|||
status: mapCoreStatusToDisplayStatus(trackedCall.status),
|
||||
resultDisplay: trackedCall.response.resultDisplay,
|
||||
confirmationDetails: undefined,
|
||||
outputFile: trackedCall.response.outputFile,
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -68,7 +68,6 @@ export interface IndividualToolCallDisplay {
|
|||
confirmationDetails: ToolCallConfirmationDetails | undefined;
|
||||
renderOutputAsMarkdown?: boolean;
|
||||
ptyId?: number;
|
||||
outputFile?: string;
|
||||
}
|
||||
|
||||
export interface CompressionProps {
|
||||
|
|
|
|||
|
|
@ -22,4 +22,6 @@
|
|||
(literal "/dev/stdout")
|
||||
(literal "/dev/stderr")
|
||||
(literal "/dev/null")
|
||||
)
|
||||
(literal "/dev/ptmx")
|
||||
(regex #"^/dev/ttys[0-9]*$")
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue